├── .prettierrc ├── .eslintrc ├── __TEST__ ├── fixtures │ ├── testSnapshot.js │ ├── mockApp.js │ └── database │ │ ├── seeds │ │ ├── master.js │ │ ├── track.js │ │ ├── test_db.js │ │ ├── release.js │ │ ├── label.js │ │ └── artist.js │ │ └── migrations │ │ └── 20180217152657_test_tables.js ├── extra-artist.test.js ├── utils.test.js ├── master.test.js ├── artist.test.js ├── __snapshots__ │ ├── master.test.js.snap │ ├── connection.test.js.snap │ ├── artist.test.js.snap │ ├── track.test.js.snap │ ├── release.test.js.snap │ ├── label.test.js.snap │ ├── search.test.js.snap │ └── extra-artist.test.js.snap ├── connection.test.js ├── label.test.js ├── track.test.js ├── release.test.js └── search.test.js ├── src ├── database.js ├── schema.js ├── types │ ├── index.js │ ├── extra-artist.js │ ├── edge.js │ ├── image.js │ ├── format.js │ ├── connection.js │ ├── track.js │ ├── page-info.js │ ├── artist.js │ ├── helpers.js │ ├── master.js │ ├── label.js │ ├── release.js │ └── connections.js ├── utils.js ├── knexfile.js ├── queries │ ├── lookup.js │ └── search.js ├── index.js └── loaders.js ├── .circleci └── config.yml ├── LICENSE ├── .gitignore ├── schema_fixes.sql ├── package.json └── readme.md /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "bracketSpacing": false, 4 | "printWidth": 120 5 | } 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["standard", "prettier"], 3 | "plugins": ["jest"], 4 | "env": { 5 | "jest/globals": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /__TEST__/fixtures/testSnapshot.js: -------------------------------------------------------------------------------- 1 | import mockApp from './mockApp'; 2 | 3 | export default async query => { 4 | const app = await mockApp(); 5 | const {body} = await app.post('/').send({query}); 6 | expect(body).toMatchSnapshot(); 7 | }; 8 | -------------------------------------------------------------------------------- /src/database.js: -------------------------------------------------------------------------------- 1 | import knex from 'knex'; 2 | import config from './knexfile'; 3 | 4 | export default async function(environment) { 5 | const db = knex(config[environment]); 6 | 7 | if (environment === 'testing') { 8 | await db.migrate.latest(config[environment]); 9 | await db.seed.run(config[environment]); 10 | } 11 | 12 | return db; 13 | } 14 | -------------------------------------------------------------------------------- /src/schema.js: -------------------------------------------------------------------------------- 1 | import {GraphQLSchema, GraphQLObjectType} from 'graphql'; 2 | 3 | import LookupQuery from './queries/lookup'; 4 | import SearchQuery from './queries/search'; 5 | 6 | export default new GraphQLSchema({ 7 | query: new GraphQLObjectType({ 8 | name: 'Query', 9 | fields: () => ({ 10 | lookup: LookupQuery, 11 | search: SearchQuery 12 | }) 13 | }) 14 | }); 15 | -------------------------------------------------------------------------------- /src/types/index.js: -------------------------------------------------------------------------------- 1 | export {default as Track} from './track'; 2 | export {default as Label} from './label'; 3 | export {default as Format} from './format'; 4 | export {default as Image} from './image'; 5 | export {default as PageInfo} from './page-info'; 6 | export {default as Artist} from './artist'; 7 | export {default as Release} from './release'; 8 | export {default as ExtraArtist} from './extra-artist'; 9 | export {default as Master} from './master'; 10 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:8.9.0 6 | 7 | working_directory: ~/repo 8 | 9 | steps: 10 | - checkout 11 | 12 | - restore_cache: 13 | keys: 14 | - v1-dependencies-{{ checksum "package.json" }} 15 | - v1-dependencies- 16 | 17 | - run: npm install 18 | 19 | - save_cache: 20 | paths: 21 | - node_modules 22 | key: v1-dependencies-{{ checksum "package.json" }} 23 | 24 | - run: npm test 25 | - run: bash <(curl -s https://codecov.io/bash) -------------------------------------------------------------------------------- /src/types/extra-artist.js: -------------------------------------------------------------------------------- 1 | import {GraphQLString, GraphQLObjectType} from 'graphql'; 2 | 3 | import property from 'lodash/property'; 4 | import {createArtistFields} from './artist'; 5 | 6 | export default new GraphQLObjectType({ 7 | name: 'ExtraArtist', 8 | description: 9 | 'The Artist resource represents a person in the Discogs database who contributed to a Release in some capacity.', 10 | fields: () => ({ 11 | ...createArtistFields('ExtraArtist'), 12 | role: { 13 | description: 'Role the artist has in the parent entity', 14 | type: GraphQLString, 15 | resolve: property('role') 16 | } 17 | }) 18 | }); 19 | -------------------------------------------------------------------------------- /__TEST__/extra-artist.test.js: -------------------------------------------------------------------------------- 1 | import testSnapshot from './fixtures/testSnapshot'; 2 | 3 | test('Should return all fields on extra artist type', async () => { 4 | const query = ` 5 | query { 6 | lookup { 7 | release (id: 2) { 8 | id 9 | extraArtists { 10 | edges { 11 | node { 12 | id 13 | name 14 | realName 15 | aliases 16 | urls 17 | role 18 | } 19 | } 20 | } 21 | } 22 | } 23 | } 24 | `; 25 | 26 | await testSnapshot(query); 27 | }); 28 | -------------------------------------------------------------------------------- /__TEST__/utils.test.js: -------------------------------------------------------------------------------- 1 | import {sanitizeAndSplit} from '../src/utils'; 2 | 3 | test('Should split string into array', () => { 4 | expect(sanitizeAndSplit('{"One","Two","Three"}')).toEqual(['One', 'Two', 'Three']); 5 | expect(sanitizeAndSplit('{"One","Tw,o","Three"}')).toEqual(['One', 'Tw,o', 'Three']); 6 | expect(sanitizeAndSplit('{"One One","Two",Three}')).toEqual(['One One', 'Two', 'Three']); 7 | expect(sanitizeAndSplit('{"One\'s","Two",Three}')).toEqual(["One's", 'Two', 'Three']); 8 | expect(sanitizeAndSplit('{"One\'s","Two",Three}')).toEqual(["One's", 'Two', 'Three']); 9 | expect(sanitizeAndSplit(['One', 'Two', 'Three'])).toEqual(['One', 'Two', 'Three']); 10 | }); 11 | -------------------------------------------------------------------------------- /__TEST__/fixtures/mockApp.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import supertest from 'supertest'; 3 | import bodyParser from 'body-parser'; 4 | import {graphqlExpress} from 'apollo-server-express'; 5 | 6 | import RootSchema from '../../src/schema'; 7 | import database from '../../src/database'; 8 | import createLoaders from '../../src/loaders'; 9 | 10 | export default async () => { 11 | const app = express(); 12 | 13 | const db = await database('testing'); 14 | 15 | app.use( 16 | '/', 17 | bodyParser.json(), 18 | graphqlExpress({ 19 | schema: RootSchema, 20 | context: () => ({db, loaders: createLoaders(db)}) 21 | }) 22 | ); 23 | 24 | return supertest(app); 25 | }; 26 | -------------------------------------------------------------------------------- /src/types/edge.js: -------------------------------------------------------------------------------- 1 | import {GraphQLString, GraphQLObjectType} from 'graphql'; 2 | import {base64Encode, splitCamel} from '../utils'; 3 | 4 | export default function createEdgeType(type, name) { 5 | return new GraphQLObjectType({ 6 | description: `${splitCamel(name)} edge`, 7 | name: `${name}Edge`, 8 | fields: () => ({ 9 | node: { 10 | description: 'Entity at the end of the connection', 11 | type, 12 | resolve: source => source 13 | }, 14 | cursor: { 15 | description: 'Identifier used in pagination', 16 | type: GraphQLString, 17 | resolve: source => base64Encode(source.id || source.trackno) 18 | } 19 | }) 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /__TEST__/fixtures/database/seeds/master.js: -------------------------------------------------------------------------------- 1 | exports.seed = function(knex, Promise) { 2 | return Promise.join( 3 | knex('master').insert({ 4 | id: 713738, 5 | main_release: 2, 6 | title: `Knockin' Boots Vol 2 Of 2` 7 | }), 8 | knex('master').insert({ 9 | id: 18500, 10 | title: 'New Soil', 11 | main_release: 155102, 12 | year: 2001, 13 | genres: '{Electronic}', 14 | styles: '{Techno}' 15 | }), 16 | knex('master').insert({ 17 | id: 188325, 18 | title: 'Hyph Mngo / Wet Look', 19 | main_release: 1921107, 20 | year: 2009, 21 | genres: '{Electronic}', 22 | styles: `{"UK Garage",Dubstep}` 23 | }) 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/types/image.js: -------------------------------------------------------------------------------- 1 | import {GraphQLString, GraphQLInt, GraphQLObjectType} from 'graphql'; 2 | 3 | import property from 'lodash/property'; 4 | 5 | export default new GraphQLObjectType({ 6 | name: 'Image', 7 | description: 'Image entity, describes the position and dimensions of an image', 8 | fields: () => ({ 9 | type: { 10 | description: 'Whether the image is Primary or Secondary', 11 | type: GraphQLString, 12 | resolve: property('type') 13 | }, 14 | height: { 15 | description: 'Height of the image', 16 | type: GraphQLInt, 17 | resolve: property('height') 18 | }, 19 | width: { 20 | description: 'Width of the image', 21 | type: GraphQLInt, 22 | resolve: property('width') 23 | } 24 | }) 25 | }); 26 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import {flow, isArray, isString} from 'lodash'; 2 | 3 | const sanitize = input => (isString(input) ? input.replace(/[{}\\]/g, '') : input); 4 | const split = input => (isString(input) ? input.match(/"[^"]*"|[^,]+/g) : input); 5 | const unquote = input => (isArray(input) ? input.map(unquote) : isString(input) && input.replace(/^"(.*)"$/, '$1')); 6 | 7 | export const sanitizeAndSplit = input => input && flow(sanitize, split, unquote)(input); 8 | 9 | export const base64Encode = value => Buffer.from(value.toString()).toString('base64'); 10 | export const base64Decode = value => Buffer.from(value, 'base64').toString(); 11 | 12 | export const splitCamel = value => { 13 | return value 14 | .split(/(?=[A-Z])/) 15 | .map(s => `${s.charAt(0).toUpperCase()}${s.substr(1)}`) 16 | .join(' '); 17 | }; 18 | -------------------------------------------------------------------------------- /src/knexfile.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | module.exports = { 4 | testing: { 5 | client: 'sqlite3', 6 | connection: { 7 | filename: ':memory:' 8 | }, 9 | migrations: { 10 | directory: path.join(__dirname, '../__TEST__/fixtures/database/migrations') 11 | }, 12 | seeds: { 13 | directory: path.join(__dirname, '../__TEST__/fixtures/database/seeds') 14 | }, 15 | useNullAsDefault: true 16 | }, 17 | 18 | production: { 19 | client: 'postgresql', 20 | connection: { 21 | host: process.env.DB_HOST, 22 | user: process.env.DB_USER, 23 | password: process.env.DB_PASSWORD, 24 | database: process.env.DB_NAME 25 | }, 26 | pool: { 27 | min: 2, 28 | max: 10 29 | }, 30 | migrations: { 31 | tableName: 'knex_migrations' 32 | } 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /__TEST__/master.test.js: -------------------------------------------------------------------------------- 1 | import testSnapshot from './fixtures/testSnapshot'; 2 | 3 | test('Should have all basic fields on the master type', async () => { 4 | const query = ` 5 | query { 6 | lookup { 7 | master (id: 18500) { 8 | id 9 | title 10 | year 11 | genres 12 | styles 13 | } 14 | } 15 | } 16 | `; 17 | 18 | await testSnapshot(query); 19 | }); 20 | 21 | test('Should implement the master-release connection', async () => { 22 | const query = ` 23 | query { 24 | lookup { 25 | master (id: 18500) { 26 | releases (first: 1) { 27 | edges { 28 | node { 29 | id 30 | title 31 | } 32 | } 33 | } 34 | } 35 | } 36 | } 37 | `; 38 | 39 | await testSnapshot(query); 40 | }); 41 | -------------------------------------------------------------------------------- /__TEST__/artist.test.js: -------------------------------------------------------------------------------- 1 | import testSnapshot from './fixtures/testSnapshot'; 2 | 3 | test('Shoulds have all basic fields on the artist type', async () => { 4 | const query = ` 5 | query { 6 | lookup { 7 | artist (id: 1) { 8 | id 9 | name 10 | realName 11 | aliases 12 | urls 13 | } 14 | } 15 | } 16 | `; 17 | 18 | await testSnapshot(query); 19 | }); 20 | 21 | test('Should implement the artist-release connection', async () => { 22 | const query = ` 23 | query { 24 | lookup { 25 | artist (id: 1) { 26 | id 27 | releases (first: 1) { 28 | edges { 29 | node { 30 | id 31 | title 32 | } 33 | } 34 | } 35 | } 36 | } 37 | } 38 | `; 39 | 40 | await testSnapshot(query); 41 | }); 42 | -------------------------------------------------------------------------------- /src/types/format.js: -------------------------------------------------------------------------------- 1 | import {GraphQLString, GraphQLList, GraphQLInt, GraphQLObjectType} from 'graphql'; 2 | 3 | import property from 'lodash/property'; 4 | 5 | export default new GraphQLObjectType({ 6 | name: 'Format', 7 | fields: () => ({ 8 | position: { 9 | description: 'Position in the list of formats available', 10 | type: GraphQLInt, 11 | resolve: property('position') 12 | }, 13 | formatName: { 14 | description: 'Name of the format', 15 | type: GraphQLString, 16 | resolve: property('format_name') 17 | }, 18 | quantity: { 19 | description: 'Number of physical items that make up the release in this format', 20 | type: GraphQLInt, 21 | resolve: property('qty') 22 | }, 23 | descriptions: { 24 | description: 'Extra information about the format: record size, speed played at, etc', 25 | type: new GraphQLList(GraphQLString), 26 | resolve: property('descriptions') 27 | } 28 | }) 29 | }); 30 | -------------------------------------------------------------------------------- /src/queries/lookup.js: -------------------------------------------------------------------------------- 1 | import {GraphQLObjectType, GraphQLInt} from 'graphql'; 2 | import {Artist, Label, Release, Master} from '../types'; 3 | 4 | function createLookupQuery(type) { 5 | return { 6 | type, 7 | description: `Return entity of type: ${type.name}`, 8 | args: { 9 | id: { 10 | type: GraphQLInt 11 | } 12 | }, 13 | resolve: async (_, args, context) => { 14 | return context.db 15 | .select() 16 | .from(type.name.toLowerCase()) 17 | .where(args) 18 | .first(); 19 | } 20 | }; 21 | } 22 | 23 | export default { 24 | type: new GraphQLObjectType({ 25 | name: 'Lookup', 26 | description: 'Return a single discogs entity from its unique identifier', 27 | fields: () => ({ 28 | artist: createLookupQuery(Artist), 29 | label: createLookupQuery(Label), 30 | release: createLookupQuery(Release), 31 | master: createLookupQuery(Master) 32 | }) 33 | }), 34 | resolve: source => ({}) 35 | }; 36 | -------------------------------------------------------------------------------- /__TEST__/__snapshots__/master.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Should have all basic fields on the master type 1`] = ` 4 | Object { 5 | "data": Object { 6 | "lookup": Object { 7 | "master": Object { 8 | "genres": Array [ 9 | "Electronic", 10 | ], 11 | "id": 18500, 12 | "styles": Array [ 13 | "Techno", 14 | ], 15 | "title": "New Soil", 16 | "year": 2001, 17 | }, 18 | }, 19 | }, 20 | } 21 | `; 22 | 23 | exports[`Should implement the master-release connection 1`] = ` 24 | Object { 25 | "data": Object { 26 | "lookup": Object { 27 | "master": Object { 28 | "releases": Object { 29 | "edges": Array [ 30 | Object { 31 | "node": Object { 32 | "id": 5477, 33 | "title": "New Soil", 34 | }, 35 | }, 36 | ], 37 | }, 38 | }, 39 | }, 40 | }, 41 | } 42 | `; 43 | -------------------------------------------------------------------------------- /src/types/connection.js: -------------------------------------------------------------------------------- 1 | import {GraphQLInt, GraphQLList, GraphQLObjectType} from 'graphql'; 2 | 3 | import createEdgeType from './edge'; 4 | import {PageInfo} from './index'; 5 | import {splitCamel} from '../utils'; 6 | 7 | export default function createConnectionType(type, name) { 8 | const edgeType = createEdgeType(type, name); 9 | 10 | return new GraphQLObjectType({ 11 | description: `${splitCamel(name)} connection`, 12 | name: `${name}Connection`, 13 | fields: () => ({ 14 | edges: { 15 | description: 'List of edges to connecting nodes', 16 | type: new GraphQLList(edgeType), 17 | resolve: source => source.edges 18 | }, 19 | pageInfo: { 20 | description: 'Pagination information', 21 | type: PageInfo, 22 | resolve: source => source 23 | }, 24 | totalReturned: { 25 | description: 'Total number of edges returned', 26 | type: GraphQLInt, 27 | resolve: source => source.edges.length 28 | } 29 | }) 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /__TEST__/__snapshots__/connection.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Should apply correct pagination when cursor is provided 1`] = ` 4 | Object { 5 | "data": Object { 6 | "search": Object { 7 | "release": Object { 8 | "edges": Array [ 9 | Object { 10 | "cursor": "MjA1NjUzMg==", 11 | "node": Object { 12 | "id": 2056532, 13 | }, 14 | }, 15 | ], 16 | "pageInfo": Object { 17 | "endCursor": "MjA1NjUzMg==", 18 | "hasNextPage": false, 19 | }, 20 | "totalReturned": 1, 21 | }, 22 | }, 23 | }, 24 | } 25 | `; 26 | 27 | exports[`Should return correct pageInfo when source edge is null 1`] = ` 28 | Object { 29 | "data": Object { 30 | "search": Object { 31 | "artist": Object { 32 | "edges": Array [], 33 | "pageInfo": Object { 34 | "endCursor": null, 35 | "hasNextPage": null, 36 | }, 37 | "totalReturned": 0, 38 | }, 39 | }, 40 | }, 41 | } 42 | `; 43 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import bodyParser from 'body-parser'; 3 | import {graphqlExpress, graphiqlExpress} from 'apollo-server-express'; 4 | import {Engine} from 'apollo-engine'; 5 | import RootSchema from './schema'; 6 | import createLoaders from './loaders'; 7 | import database from './database'; 8 | 9 | (async function() { 10 | const app = express(); 11 | const db = await database('production'); 12 | 13 | app.use( 14 | '/graphql', 15 | bodyParser.json(), 16 | graphqlExpress({ 17 | schema: RootSchema, 18 | context: () => ({db, loaders: createLoaders(db)}), 19 | tracing: true, 20 | cacheControl: true 21 | }) 22 | ); 23 | 24 | app.get('/graphiql', graphiqlExpress({endpointURL: '/graphql'})); 25 | 26 | if (process.env.ENGINE_KEY) { 27 | const engine = new Engine({ 28 | graphqlPort: process.env.APP_PORT, 29 | engineConfig: { 30 | apiKey: process.env.ENGINE_KEY 31 | } 32 | }); 33 | 34 | engine.start(); 35 | app.use(engine.expressMiddleware()); 36 | } 37 | 38 | app.listen(process.env.PORT || 3000); 39 | })(); 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 hufferd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /__TEST__/connection.test.js: -------------------------------------------------------------------------------- 1 | import testSnapshot from './fixtures/testSnapshot'; 2 | 3 | test('Should return correct pageInfo when source edge is null', async () => { 4 | const query = ` 5 | query { 6 | search { 7 | artist (name: "DSIKFOIJSDOIFJOISAJDOIJ") { 8 | edges { 9 | node { 10 | id 11 | } 12 | cursor 13 | } 14 | pageInfo { 15 | hasNextPage 16 | endCursor 17 | } 18 | totalReturned 19 | } 20 | } 21 | } 22 | `; 23 | 24 | await testSnapshot(query); 25 | }); 26 | 27 | test('Should apply correct pagination when cursor is provided', async () => { 28 | const query = ` 29 | query { 30 | search { 31 | release (title: "hyph mngo", first: 1, after: "MTk2Njc3OQ==") { 32 | edges { 33 | node { 34 | id 35 | } 36 | cursor 37 | } 38 | pageInfo { 39 | hasNextPage 40 | endCursor 41 | } 42 | totalReturned 43 | } 44 | } 45 | } 46 | `; 47 | 48 | await testSnapshot(query); 49 | }); 50 | -------------------------------------------------------------------------------- /__TEST__/__snapshots__/artist.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Should implement the artist-release connection 1`] = ` 4 | Object { 5 | "data": Object { 6 | "lookup": Object { 7 | "artist": Object { 8 | "id": 1, 9 | "releases": Object { 10 | "edges": Array [ 11 | Object { 12 | "node": Object { 13 | "id": 1, 14 | "title": "Stockholm", 15 | }, 16 | }, 17 | ], 18 | }, 19 | }, 20 | }, 21 | }, 22 | } 23 | `; 24 | 25 | exports[`Shoulds have all basic fields on the artist type 1`] = ` 26 | Object { 27 | "data": Object { 28 | "lookup": Object { 29 | "artist": Object { 30 | "aliases": Array [ 31 | "Dick Track", 32 | "Faxid", 33 | "Groove Machine", 34 | "Janne Me' Amazonen", 35 | "Jesper Dahlbäck", 36 | "Lenk", 37 | "The Pinguin Man", 38 | ], 39 | "id": 1, 40 | "name": "The Persuader", 41 | "realName": "Jesper Dahlbäck", 42 | "urls": null, 43 | }, 44 | }, 45 | }, 46 | } 47 | `; 48 | -------------------------------------------------------------------------------- /src/loaders.js: -------------------------------------------------------------------------------- 1 | import DataLoader from 'dataloader'; 2 | import keyBy from 'lodash/keyBy'; 3 | import groupBy from 'lodash/groupBy'; 4 | 5 | function mapResultsToKey(keys, hashMap) { 6 | return keys.map(k => hashMap[k] || null); 7 | } 8 | 9 | async function loader(db, keys, table, column, keyFn) { 10 | return mapResultsToKey( 11 | keys, 12 | keyFn( 13 | await db 14 | .select() 15 | .from(table) 16 | .whereIn(column, keys), 17 | column 18 | ) 19 | ); 20 | } 21 | 22 | const loadSingular = (...args) => loader(...args, keyBy); 23 | const loadMany = (...args) => loader(...args, groupBy); 24 | 25 | export default function(db) { 26 | return { 27 | artist: new DataLoader(keys => loadSingular(db, keys, 'artist', 'id')), 28 | release: new DataLoader(keys => loadSingular(db, keys, 'release', 'id')), 29 | master: new DataLoader(keys => loadSingular(db, keys, 'master', 'id')), 30 | label: new DataLoader(keys => loadSingular(db, keys, 'label', 'name')), 31 | track: new DataLoader(keys => loadSingular(db, keys, 'track', 'track_id')), 32 | format: new DataLoader(keys => loadMany(db, keys, 'releases_formats', 'release_id')), 33 | image: new DataLoader(keys => loadMany(db, keys, 'releases_images', 'release_id')) 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/node 3 | 4 | lib 5 | 6 | ### Node ### 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (http://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Typescript v1 declaration files 46 | typings/ 47 | 48 | # Optional npm cache directory 49 | .npm 50 | 51 | # Optional eslint cache 52 | .eslintcache 53 | 54 | # Optional REPL history 55 | .node_repl_history 56 | 57 | # Output of 'npm pack' 58 | *.tgz 59 | 60 | # Yarn Integrity file 61 | .yarn-integrity 62 | 63 | # dotenv environment variables file 64 | .env 65 | 66 | 67 | 68 | # End of https://www.gitignore.io/api/node -------------------------------------------------------------------------------- /__TEST__/fixtures/database/seeds/track.js: -------------------------------------------------------------------------------- 1 | exports.seed = function(knex, Promise) { 2 | return Promise.join( 3 | knex('track').insert({ 4 | release_id: 1, 5 | position: 'A', 6 | title: 'Östermalm', 7 | duration: '4:45', 8 | trackno: 1 9 | }), 10 | knex('track').insert({ 11 | release_id: 1, 12 | position: 'B1', 13 | title: 'Vasastaden', 14 | duration: '6:11', 15 | trackno: 2 16 | }), 17 | knex('track').insert({ 18 | release_id: 3, 19 | position: '1', 20 | title: 'Untitled 8', 21 | trackno: 1 22 | }), 23 | knex('track').insert({ 24 | release_id: 3, 25 | position: 2, 26 | title: 'Anjua (Sneaky 3)', 27 | trackno: 2 28 | }), 29 | knex('track').insert({ 30 | release_id: 3, 31 | position: 3, 32 | title: 33 | 'When The Funk Hits The Fan (Mood II Swing When The Dub Hits The Fan)', 34 | trackno: 3 35 | }), 36 | knex('tracks_artists').insert({ 37 | track_id: 3, 38 | position: 1, 39 | artist_id: 5 40 | }), 41 | knex('tracks_artists').insert({ 42 | track_id: 3, 43 | position: 2, 44 | artist_id: 4 45 | }), 46 | knex('tracks_extraartists').insert({ 47 | track_id: 5, 48 | artist_id: 8, 49 | artist_name: 'Mood II Swing', 50 | role: 'Remix' 51 | }) 52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /src/types/track.js: -------------------------------------------------------------------------------- 1 | import {GraphQLString, GraphQLInt, GraphQLObjectType} from 'graphql'; 2 | 3 | import property from 'lodash/property'; 4 | import {TrackArtist, TrackExtraArtist} from './connections'; 5 | 6 | export default new GraphQLObjectType({ 7 | description: 'A Track is an individual sound recording, a Release is generally a composition of multiple tracks', 8 | name: 'Track', 9 | fields: () => ({ 10 | id: { 11 | description: 'Unique identifier for the track', 12 | type: GraphQLString, 13 | resolve: property('track_id') 14 | }, 15 | position: { 16 | description: 17 | 'Where the track is located on the release. For example: the side and number (A1) on a vinyl release', 18 | type: GraphQLString, 19 | resolve: property('position') 20 | }, 21 | title: { 22 | description: 'Title of the track', 23 | type: GraphQLString, 24 | resolve: property('title') 25 | }, 26 | duration: { 27 | description: 'Duration of the track', 28 | type: GraphQLString, 29 | resolve: property('duration') 30 | }, 31 | trackno: { 32 | description: 'Ordered numbering of the track. For example: A1 on a vinyl relesae translates to 01 and A2, 02', 33 | type: GraphQLInt, 34 | resolve: property('trackno') 35 | }, 36 | artists: TrackArtist('TrackArtist'), 37 | extraArtists: TrackExtraArtist('TrackExtraArtist') 38 | }) 39 | }); 40 | -------------------------------------------------------------------------------- /src/types/page-info.js: -------------------------------------------------------------------------------- 1 | import {GraphQLString, GraphQLBoolean, GraphQLObjectType} from 'graphql'; 2 | 3 | import isEmpty from 'lodash/isEmpty'; 4 | import last from 'lodash/last'; 5 | import {base64Encode} from '../utils'; 6 | 7 | const QUERY_LIMIT = 99999999999; 8 | 9 | const getProjectedField = table => { 10 | if (table === 'track') { 11 | return 'track_id'; 12 | } 13 | 14 | return 'id'; 15 | }; 16 | 17 | export default new GraphQLObjectType({ 18 | description: "Pagination information describing how to paginate through a connection's result set", 19 | name: 'PageInfo', 20 | fields: () => ({ 21 | hasNextPage: { 22 | description: 'Does the set of results have a next page', 23 | type: GraphQLBoolean, 24 | resolve: async function(source) { 25 | const projectedField = getProjectedField(source.query._single.table); 26 | const count = await source.query.select(projectedField).limit(QUERY_LIMIT); 27 | return source.args.first && count.length > source.args.first; 28 | } 29 | }, 30 | endCursor: { 31 | description: 'The cursor to access the last page of results', 32 | type: GraphQLString, 33 | resolve: async function(source) { 34 | if (isEmpty(source.edges)) { 35 | return null; 36 | } 37 | 38 | const lastItem = last(source.edges); 39 | 40 | return base64Encode(lastItem.id || lastItem.trackno); 41 | } 42 | } 43 | }) 44 | }); 45 | -------------------------------------------------------------------------------- /src/types/artist.js: -------------------------------------------------------------------------------- 1 | import {GraphQLInt, GraphQLString, GraphQLList, GraphQLObjectType} from 'graphql'; 2 | 3 | import property from 'lodash/property'; 4 | import {ArtistRelease} from './connections'; 5 | import {sanitizeAndSplit} from '../utils'; 6 | 7 | export const createArtistFields = artistType => ({ 8 | id: { 9 | description: 'Unique artist identifier', 10 | type: GraphQLInt, 11 | resolve: property('id') 12 | }, 13 | name: { 14 | description: 'Official name of the artist', 15 | type: GraphQLString, 16 | resolve: property('name') 17 | }, 18 | realName: { 19 | description: 'Real name of the artist', 20 | type: GraphQLString, 21 | resolve: property('realname') 22 | }, 23 | aliases: { 24 | description: 'Aliases the artist has or does use', 25 | type: new GraphQLList(GraphQLString), 26 | resolve: source => sanitizeAndSplit(source.aliases) 27 | }, 28 | urls: { 29 | description: 'URLs of relevant links associated with the artist', 30 | type: new GraphQLList(GraphQLString), 31 | resolve: source => sanitizeAndSplit(source.urls) 32 | }, 33 | releases: ArtistRelease(`${artistType}Release`) 34 | }); 35 | 36 | export default new GraphQLObjectType({ 37 | name: 'Artist', 38 | description: 39 | 'The Artist resource represents a person in the Discogs database who contributed to a Release in some capacity.', 40 | 41 | fields: () => ({ 42 | ...createArtistFields('artist') 43 | }) 44 | }); 45 | -------------------------------------------------------------------------------- /__TEST__/label.test.js: -------------------------------------------------------------------------------- 1 | import testSnapshot from './fixtures/testSnapshot'; 2 | 3 | test('Should have all basic fields on the label type', async () => { 4 | const query = ` 5 | query { 6 | lookup { 7 | label (id: 1) { 8 | id 9 | name 10 | contactInfo 11 | profile 12 | urls 13 | } 14 | } 15 | } 16 | `; 17 | 18 | await testSnapshot(query); 19 | }); 20 | 21 | test('Should implement the label-release connection', async () => { 22 | const query = ` 23 | query { 24 | lookup { 25 | label (id: 1) { 26 | releases (first: 1) { 27 | edges { 28 | node { 29 | id 30 | title 31 | } 32 | } 33 | } 34 | } 35 | } 36 | } 37 | `; 38 | 39 | await testSnapshot(query); 40 | }); 41 | 42 | test('Should get sub labels', async () => { 43 | const query = ` 44 | query { 45 | lookup { 46 | label (id: 1) { 47 | subLabels { 48 | id 49 | name 50 | } 51 | } 52 | } 53 | } 54 | `; 55 | 56 | await testSnapshot(query); 57 | }); 58 | 59 | test('Should get parent label', async () => { 60 | const query = ` 61 | query { 62 | lookup { 63 | label (id: 123456) { 64 | parentLabel { 65 | id 66 | } 67 | } 68 | } 69 | } 70 | `; 71 | 72 | await testSnapshot(query); 73 | }); 74 | -------------------------------------------------------------------------------- /src/types/helpers.js: -------------------------------------------------------------------------------- 1 | import {GraphQLInt, GraphQLString} from 'graphql'; 2 | 3 | import createConnectionType from './connection'; 4 | import {base64Decode} from '../utils'; 5 | 6 | export function composeResolver(opts) { 7 | return async function(source, ctx) { 8 | let results = await source.query; 9 | 10 | const edges = 11 | opts.loader && opts.identifier 12 | ? await ctx.loaders[opts.loader].loadMany(results.map(r => r[opts.identifier])) 13 | : results; 14 | 15 | return { 16 | ...source, 17 | edges 18 | }; 19 | }; 20 | } 21 | 22 | export function wrapConnection(field) { 23 | return { 24 | description: field.description, 25 | type: createConnectionType(field.type, field.name), 26 | args: { 27 | first: { 28 | type: GraphQLInt 29 | }, 30 | after: { 31 | type: GraphQLString 32 | }, 33 | ...field.args 34 | }, 35 | resolve: async function(source, args, context, resolveInfo) { 36 | const query = field.query(source, context, args); 37 | 38 | if (args.first) { 39 | query.limit(args.first); 40 | } 41 | 42 | if (args.after) { 43 | query.andWhere(`${getIdField(query)}`, '>', base64Decode(args.after)); 44 | } 45 | 46 | return composeResolver(field)( 47 | { 48 | ...source, 49 | args, 50 | query 51 | }, 52 | context 53 | ); 54 | } 55 | }; 56 | } 57 | 58 | function getIdField(query) { 59 | if (query._single.table === 'track') { 60 | return 'trackno'; 61 | } 62 | 63 | return 'id'; 64 | } 65 | -------------------------------------------------------------------------------- /src/types/master.js: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLString, 3 | GraphQLInt, 4 | GraphQLList, 5 | GraphQLObjectType 6 | } from 'graphql'; 7 | 8 | import property from 'lodash/property'; 9 | import {MasterRelease} from './connections'; 10 | import {sanitizeAndSplit} from '../utils'; 11 | 12 | export default new GraphQLObjectType({ 13 | description: 14 | 'The Master resource represents a set of similar Releases. Masters (also known as “master releases”) have a “main release” which is often the chronologically earliest.', 15 | name: 'Master', 16 | fields: () => ({ 17 | id: { 18 | description: 'Unique identifier for the master release', 19 | type: GraphQLInt, 20 | resolve: property('id') 21 | }, 22 | title: { 23 | description: 'Title of the master release', 24 | type: GraphQLString, 25 | resolve: property('title') 26 | }, 27 | year: { 28 | description: 'Year the master release was officially released', 29 | type: GraphQLInt, 30 | resolve: property('year') 31 | }, 32 | genres: { 33 | description: 34 | 'List of main genres the release is categorised as. Genres are top level classifications, e.g. Electronic, Rock', 35 | type: new GraphQLList(GraphQLString), 36 | resolve: source => sanitizeAndSplit(source.genres) 37 | }, 38 | styles: { 39 | description: 40 | 'List of styles the release is categorised as. Styles are genres with more specificty, e.g. Techno, House', 41 | type: new GraphQLList(GraphQLString), 42 | resolve: source => sanitizeAndSplit(source.styles) 43 | }, 44 | releases: MasterRelease('MasterRelease') 45 | }) 46 | }); 47 | -------------------------------------------------------------------------------- /src/queries/search.js: -------------------------------------------------------------------------------- 1 | import {GraphQLObjectType, GraphQLString} from 'graphql'; 2 | import {Artist, Label, Release, Master} from '../types'; 3 | import {wrapConnection} from '../types/helpers'; 4 | 5 | const SearchArgs = { 6 | Artist: ['name'], 7 | Release: ['title', 'genres', 'styles', 'country', 'released'], 8 | Label: ['name'], 9 | Master: ['title', 'genres', 'styles', 'year'] 10 | }; 11 | 12 | function createArgs(type) { 13 | return type.reduce((args, arg) => { 14 | args[arg] = {type: GraphQLString}; 15 | return args; 16 | }, {}); 17 | } 18 | 19 | function createSearchQuery(type, searchArgs) { 20 | return wrapConnection({ 21 | type, 22 | name: `${type}SearchResult`, 23 | description: `${type.name} search result`, 24 | args: searchArgs, 25 | 26 | query: (source, context, args) => { 27 | const query = context.db 28 | .select() 29 | .from(type.name.toLowerCase()) 30 | .orderBy('id', 'asc'); 31 | 32 | Object.keys(searchArgs) 33 | .filter(arg => args[arg]) 34 | .forEach(arg => { 35 | query.whereRaw(`LOWER(${arg.toLowerCase()}) LIKE ?`, [`%${args[arg].toLowerCase()}%`]); 36 | }); 37 | 38 | return query; 39 | } 40 | }); 41 | } 42 | 43 | export default { 44 | type: new GraphQLObjectType({ 45 | name: 'Search', 46 | description: 'Search an entity on its name or title', 47 | fields: () => ({ 48 | artist: createSearchQuery(Artist, createArgs(SearchArgs.Artist)), 49 | label: createSearchQuery(Label, createArgs(SearchArgs.Label)), 50 | release: createSearchQuery(Release, createArgs(SearchArgs.Release)), 51 | master: createSearchQuery(Master, createArgs(SearchArgs.Master)) 52 | }) 53 | }), 54 | resolve: source => ({}) 55 | }; 56 | -------------------------------------------------------------------------------- /schema_fixes.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE artists_images SET LOGGED; 2 | ALTER TABLE labels_images SET LOGGED; 3 | ALTER TABLE masters_images SET LOGGED; 4 | ALTER TABLE releases_images SET LOGGED; 5 | ALTER TABLE releases_labels SET LOGGED; 6 | ALTER TABLE format SET LOGGED; 7 | ALTER TABLE release SET LOGGED; 8 | ALTER TABLE country SET LOGGED; 9 | ALTER TABLE artist SET LOGGED; 10 | ALTER TABLE genre SET LOGGED; 11 | ALTER TABLE label SET LOGGED; 12 | ALTER TABLE releases_artists SET LOGGED; 13 | ALTER TABLE releases_extraartists SET LOGGED; 14 | ALTER TABLE role SET LOGGED; 15 | ALTER TABLE track SET LOGGED; 16 | ALTER TABLE tracks_artists SET LOGGED; 17 | ALTER TABLE tracks_extraartists SET LOGGED; 18 | ALTER TABLE releases_formats SET LOGGED; 19 | ALTER TABLE master SET LOGGED; 20 | ALTER TABLE masters_artists SET LOGGED; 21 | ALTER TABLE masters_artists_joins SET LOGGED; 22 | ALTER TABLE masters_extraartists SET LOGGED; 23 | ALTER TABLE masters_formats SET LOGGED; 24 | 25 | CREATE INDEX idx_release_master ON release (master_id); 26 | CREATE INDEX idx_release_title_lower ON release (lower(title) varchar_pattern_ops); 27 | CREATE INDEX idx_release_genres_lower ON release (lower(genres) varchar_pattern_ops); 28 | CREATE INDEX idx_release_styles_lower ON release (lower(styles) varchar_pattern_ops); 29 | CREATE INDEX idx_release_country_lower ON release (lower(country) varchar_pattern_ops); 30 | CREATE INDEX idx_release_released_lower ON release (lower(released) varchar_pattern_ops); 31 | 32 | CREATE INDEX idx_master_title_lower ON master (lower(title) varchar_pattern_ops); 33 | CREATE INDEX idx_master_genres_lower ON master (lower(genres) varchar_pattern_ops); 34 | CREATE INDEX idx_master_styles_lower ON master (lower(styles) varchar_pattern_ops); 35 | CREATE INDEX idx_master_year ON master (year); 36 | 37 | CREATE INDEX idx_artist_name_lower ON artist (lower(name) varchar_pattern_ops); 38 | 39 | CREATE INDEX idx_label_name_lower ON label (lower(name) varchar_pattern_ops); -------------------------------------------------------------------------------- /__TEST__/track.test.js: -------------------------------------------------------------------------------- 1 | import testSnapshot from './fixtures/testSnapshot'; 2 | 3 | test('Should implement tracks artist connection', async () => { 4 | const query = ` 5 | query { 6 | lookup { 7 | release(id: 3) { 8 | tracks (first: 1) { 9 | edges { 10 | node { 11 | trackno 12 | title 13 | artists { 14 | edges { 15 | node { 16 | id 17 | } 18 | } 19 | } 20 | } 21 | } 22 | } 23 | } 24 | } 25 | } 26 | `; 27 | 28 | await testSnapshot(query); 29 | }); 30 | 31 | test('Should implement the track extra artist connection', async () => { 32 | const query = ` 33 | query { 34 | lookup { 35 | release(id: 3) { 36 | tracks (first: 3) { 37 | edges { 38 | node { 39 | title 40 | trackno 41 | extraArtists { 42 | edges { 43 | node { 44 | id 45 | } 46 | } 47 | } 48 | } 49 | } 50 | } 51 | } 52 | } 53 | } 54 | `; 55 | 56 | await testSnapshot(query); 57 | }); 58 | 59 | test('Should paginate tracks by their track number', async () => { 60 | const query = ` 61 | query { 62 | lookup { 63 | release(id: 1) { 64 | tracks (first:1, after:"MQ==") { 65 | pageInfo { 66 | hasNextPage 67 | endCursor 68 | } 69 | edges { 70 | node { 71 | title 72 | trackno 73 | } 74 | cursor 75 | } 76 | } 77 | } 78 | } 79 | } 80 | `; 81 | 82 | await testSnapshot(query); 83 | }); 84 | -------------------------------------------------------------------------------- /src/types/label.js: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLString, 3 | GraphQLInt, 4 | GraphQLList, 5 | GraphQLObjectType 6 | } from 'graphql'; 7 | 8 | import property from 'lodash/property'; 9 | import {LabelRelease} from './connections'; 10 | import {sanitizeAndSplit} from '../utils'; 11 | 12 | const Label = new GraphQLObjectType({ 13 | description: 14 | 'The Label resource represents a label, company, recording studio, location, or other entity involved with Artists and Releases.', 15 | name: 'Label', 16 | fields: () => ({ 17 | id: { 18 | description: 'Unique identifier for the label', 19 | type: GraphQLInt, 20 | resolve: property('id') 21 | }, 22 | name: { 23 | description: 'Name of the label', 24 | type: GraphQLString, 25 | resolve: property('name') 26 | }, 27 | contactInfo: { 28 | description: 'Contact information such as phone number or fax', 29 | type: GraphQLString, 30 | resolve: property('contactinfo') 31 | }, 32 | profile: { 33 | description: 'Short blurb about the label', 34 | type: GraphQLString, 35 | resolve: property('profile') 36 | }, 37 | parentLabel: { 38 | description: 'Node of the parent label', 39 | type: Label, 40 | resolve: (source, args, context) => { 41 | return ( 42 | source.parent_label && context.loaders.label.load(source.parent_label) 43 | ); 44 | } 45 | }, 46 | subLabels: { 47 | description: 'List of nodes of sub labels', 48 | type: new GraphQLList(Label), 49 | resolve: (source, args, context) => { 50 | return ( 51 | source.sublabels && 52 | context.loaders.label.loadMany(sanitizeAndSplit(source.sublabels)) 53 | ); 54 | } 55 | }, 56 | urls: { 57 | description: 'List of URLs associated with the label', 58 | type: new GraphQLList(GraphQLString), 59 | resolve: source => sanitizeAndSplit(source.urls) 60 | }, 61 | releases: LabelRelease('LabelRelease') 62 | }) 63 | }); 64 | 65 | export default Label; 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discography", 3 | "version": "0.0.1", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "babel --out-dir lib src", 8 | "start": "node lib/index.js", 9 | "start:dev": "node --require 'dotenv/config' --require 'babel-register' src/index.js", 10 | "test": "jest --coverage --forceExit", 11 | "lint:staged": "lint-staged", 12 | "format": "prettier --write **/*.js", 13 | "lint": "eslint .", 14 | "lint:fix": "eslint --fix ." 15 | }, 16 | "author": "", 17 | "license": "ISC", 18 | "dependencies": { 19 | "apollo-engine": "^0.8.3", 20 | "apollo-server-express": "^1.3.2", 21 | "body-parser": "^1.18.2", 22 | "dataloader": "^1.3.0", 23 | "dotenv": "^4.0.0", 24 | "express": "4.16.2", 25 | "graphql": "^0.12.3", 26 | "graphql-tools": "^2.18.0", 27 | "knex": "^0.14.6", 28 | "lodash": "^4.17.10", 29 | "pg": "^7.2.0" 30 | }, 31 | "devDependencies": { 32 | "babel-cli": "^6.26.0", 33 | "babel-eslint": "^8.2.2", 34 | "babel-plugin-transform-es2015-modules-commonjs": "^6.26.2", 35 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 36 | "babel-plugin-transform-runtime": "^6.23.0", 37 | "babel-register": "^6.26.0", 38 | "eslint": "^4.19.1", 39 | "eslint-config-prettier": "^2.9.0", 40 | "eslint-config-standard": "^11.0.0", 41 | "eslint-plugin-import": "^2.9.0", 42 | "eslint-plugin-jest": "^21.12.2", 43 | "eslint-plugin-node": "^6.0.1", 44 | "eslint-plugin-promise": "^3.6.0", 45 | "eslint-plugin-standard": "^3.0.1", 46 | "jest": "^22.1.4", 47 | "lint-staged": "^6.1.0", 48 | "pre-commit": "^1.2.2", 49 | "prettier": "^1.10.2", 50 | "prettier-eslint": "^8.8.1", 51 | "sqlite3": "^3.1.13", 52 | "supertest": "^3.0.0" 53 | }, 54 | "babel": { 55 | "plugins": [ 56 | "transform-es2015-modules-commonjs", 57 | "transform-runtime", 58 | "transform-object-rest-spread" 59 | ] 60 | }, 61 | "jest": { 62 | "verbose": true 63 | }, 64 | "lint-staged": { 65 | "*.js": [ 66 | "prettier --write", 67 | "git add" 68 | ] 69 | }, 70 | "pre-commit": "lint:staged" 71 | } 72 | -------------------------------------------------------------------------------- /__TEST__/fixtures/database/seeds/test_db.js: -------------------------------------------------------------------------------- 1 | exports.seed = function(knex, Promise) { 2 | return Promise.join( 3 | knex('releases_artists').insert({ 4 | release_id: 1, 5 | position: 1, 6 | artist_id: 1, 7 | artist_name: 'The Persuader', 8 | join_relation: ',' 9 | }), 10 | knex('releases_extraartists').insert({ 11 | release_id: 2, 12 | artist_id: 26, 13 | artist_name: 'Alexi Delano', 14 | role: 'Producer' 15 | }), 16 | knex('releases_extraartists').insert({ 17 | release_id: 2, 18 | artist_id: 26, 19 | artist_name: 'Alexi Delano', 20 | role: 'Recorded By' 21 | }), 22 | knex('releases_extraartists').insert({ 23 | release_id: 2, 24 | artist_id: 26, 25 | artist_name: 'Alexi Delano', 26 | anv: 'A. Delano', 27 | role: 'Written-By' 28 | }), 29 | knex('releases_extraartists').insert({ 30 | release_id: 2, 31 | artist_id: 27, 32 | artist_name: 'Cari Lekebusch', 33 | role: 'Producer' 34 | }), 35 | knex('releases_extraartists').insert({ 36 | release_id: 2, 37 | artist_id: 27, 38 | artist_name: 'Cari Lekebusch', 39 | role: 'Recorded By' 40 | }), 41 | knex('releases_extraartists').insert({ 42 | release_id: 2, 43 | artist_id: 27, 44 | artist_name: 'Cari Lekebusch', 45 | anv: 'C. Lekebusch', 46 | role: 'Written-By' 47 | }), 48 | knex('releases_labels').insert({ 49 | label: 'Planet E', 50 | release_id: 1025, 51 | catno: 'pe65234' 52 | }), 53 | knex('releases_formats').insert({ 54 | release_id: 2, 55 | position: 1, 56 | format_name: 'Vinyl', 57 | qty: 1, 58 | descriptions: `{"12","33 ⅓ RPM"}` 59 | }), 60 | knex('releases_images').insert({ 61 | release_id: 2, 62 | type: 'primary', 63 | height: 394, 64 | width: 400 65 | }), 66 | knex('releases_images').insert({ 67 | release_id: 2, 68 | type: 'secondary', 69 | height: 600, 70 | width: 600 71 | }), 72 | knex('releases_labels').insert({ 73 | label: 'Svek', 74 | release_id: 1, 75 | catno: 'SK032' 76 | }) 77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /__TEST__/fixtures/database/seeds/release.js: -------------------------------------------------------------------------------- 1 | exports.seed = function(knex, Promise) { 2 | return Promise.join( 3 | knex('release').insert({ 4 | id: 1, 5 | status: 'Accepted', 6 | title: 'Stockholm', 7 | country: 'Sweden', 8 | released: '1993-03-00', 9 | genres: '{Electronic}', 10 | styles: '{"Deep House"}' 11 | }), 12 | knex('release').insert({ 13 | id: 2, 14 | status: 'Accepted', 15 | title: `Knockin' Boots Vol 2 Of 2`, 16 | country: 'Sweden', 17 | released: '1998-06-00', 18 | notes: 'All joints recorded in NYC (Dec.97).', 19 | genres: '{Electronic}', 20 | styles: `{"Broken Beat",Techno,"Tech House"}`, 21 | master_id: 713738 22 | }), 23 | knex('release').insert({ 24 | id: 3, 25 | master_id: 66256, 26 | title: 'Profound Sounds Vol. 1' 27 | }), 28 | knex('release').insert({ 29 | id: 1025, 30 | status: 'Accepted', 31 | title: 'Silentintroduction', 32 | country: 'US', 33 | released: '1997-11-00', 34 | genres: '{Electronic}', 35 | styles: `{"Deep House",House}`, 36 | master_id: 6119 37 | }), 38 | knex('release').insert({ 39 | id: 2056532, 40 | status: 'Accepted', 41 | title: `Hyph Mngo (Andreas Saag's House Perspective)`, 42 | country: 'Sweden', 43 | released: '2009-12-20', 44 | notes: '125bpm', 45 | genres: '{Electronic}', 46 | styles: '{House}' 47 | }), 48 | knex('release').insert({ 49 | id: 5477, 50 | title: 'New Soil', 51 | master_id: 18500 52 | }), 53 | knex('release').insert({ 54 | id: 2151, 55 | title: 'U Got Me', 56 | country: 'US', 57 | released: '1992-00-00', 58 | genres: '{Electronic}', 59 | styles: `{"Deep House"}` 60 | }), 61 | knex('release').insert({ 62 | id: 2152, 63 | title: 'Together', 64 | country: 'US', 65 | released: '1992-00-00', 66 | genres: '{Electronic}', 67 | styles: `{"Deep House"}` 68 | }), 69 | knex('release').insert({ 70 | id: 2153, 71 | title: "Some Lovin'", 72 | country: 'US', 73 | released: '1992-00-00', 74 | genres: '{Electronic}', 75 | styles: `{"Deep House"}` 76 | }) 77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Discography 2 | 3 | Graphql API wrapper for Discogs using a local PostgreSQL database. 4 | 5 | ## Installation 6 | 7 | ##### Prerequisites 8 | 9 | * [Download the data dumps from Discogs](https://data.discogs.com/?prefix=data/2018/ 'Download the data dumps from Discogs'). (The latest tested and working data dump is 20180201) 10 | * Import to PostgreSQL using [discogs-xml2db](https://github.com/philipmat/discogs-xml2db 'discogs-xml2db') 11 | 12 | ##### Procedure 13 | 14 | * Run some additional SQL schema fixes `psql -U discogs discogs -f schema_fixes.sql` 15 | * Create a .env file in the root of the project and configure with the correct environment variables (see below) 16 | * Build with `npm run build` 17 | * Run the server with `npm start` 18 | 19 | ##### Environment Variables 20 | 21 | * **APP_PORT**: Port to run the server on 22 | * **DB_HOST**: Hostname of PSQL server 23 | * **DB_USER**: Name of the database user 24 | * **DB_PASSWORD**: Password for the database 25 | * **DB_NAME**: Name of the database 26 | * **ENGINE_KEY**: Optional [Apollo Engine](https://engine.apollographql.com/ 'Apollo Engine') key 27 | 28 | ## Example Queries 29 | 30 | There are two main query types: Lookup and Search. 31 | 32 | Lookup will return a given entity from it's discogs id. The following example will return the name of the label, and its first 5 releases. The [Relay connection specification](https://facebook.github.io/relay/graphql/connections.htm 'Relay connection specification') is also supported. 33 | 34 | ```graphql 35 | query { 36 | lookup { 37 | label(id: 271) { 38 | name 39 | releases(first: 5) { 40 | edges { 41 | node { 42 | id 43 | title 44 | } 45 | } 46 | pageInfo { 47 | hasNextPage 48 | endCursor 49 | } 50 | } 51 | } 52 | } 53 | } 54 | ``` 55 | 56 | The search query will search an entity on combinations of fields that are specific to each. For example the following query searches for Deep House from 1992 released on US labels. 57 | 58 | ```graphql 59 | query { 60 | search { 61 | release(styles: "Deep House", released: "1992", country: "US", first: 50) { 62 | edges { 63 | node { 64 | id 65 | title 66 | styles 67 | released 68 | country 69 | } 70 | } 71 | } 72 | } 73 | } 74 | ``` 75 | -------------------------------------------------------------------------------- /__TEST__/fixtures/database/seeds/label.js: -------------------------------------------------------------------------------- 1 | exports.seed = function(knex, Promise) { 2 | return Promise.join( 3 | knex('label').insert({ 4 | id: 1, 5 | name: 'Planet E', 6 | contactinfo: `Planet E Communications\rP.O. Box 27218\rDetroit, Michigan, MI 48227\rUSA\r\rPhone: +1 313 874 8729\rFax: +1 313 874 8732\rEmail: info@Planet-e.net`, 7 | sublabels: `{"Antidote (4)","Community Projects","Guilty Pleasures","I Ner Zon Sounds","Planet E Communications","Planet E Communications, Inc.",TWPENTY}`, 8 | urls: `{http://planet-e.net,http://planetecommunications.bandcamp.com,http://www.facebook.com/planetedetroit,http://www.flickr.com/photos/planetedetroit,http://plus.google.com/100841702106447505236,http://www.instagram.com/carlcraignet,http://myspace.com/planetecom,http://myspace.com/planetedetroit,http://soundcloud.com/planetedetroit,http://twitter.com/planetedetroit,http://vimeo.com/user1265384,http://en.wikipedia.org/wiki/Planet_E_Communications,http://www.youtube.com/user/planetedetroit}`, 9 | profile: `[a=Carl Craig]'s classic techno label founded in 1991.\r\rOn at least 1 release, Planet E is listed as publisher.` 10 | }), 11 | knex('label').insert({ 12 | id: 86537, 13 | name: 'Antidote (4)' 14 | }), 15 | knex('label').insert({ 16 | id: 41841, 17 | name: 'Community Projects' 18 | }), 19 | knex('label').insert({ 20 | id: 153760, 21 | name: 'Guilty Pleasures' 22 | }), 23 | knex('label').insert({ 24 | id: 31405, 25 | name: 'I Ner Zon Sounds' 26 | }), 27 | knex('label').insert({ 28 | id: 277579, 29 | name: 'Planet E Communications' 30 | }), 31 | knex('label').insert({ 32 | id: 294738, 33 | name: 'Planet E Communications, Inc.' 34 | }), 35 | knex('label').insert({ 36 | id: 488315, 37 | name: 'TWPENTY' 38 | }), 39 | knex('label').insert({ 40 | id: 123456, 41 | name: 'Polydor, S.A.', 42 | parent_label: 'Polydor International' 43 | }), 44 | knex('label').insert({ 45 | id: 73924, 46 | name: 'Polydor International' 47 | }), 48 | knex('label').insert({ 49 | id: 5, 50 | name: 'Svek' 51 | }), 52 | knex('label').insert({ 53 | id: 22912, 54 | name: 'Hotflush Recordings' 55 | }), 56 | knex('label').insert({ 57 | id: 742086, 58 | name: 'Hotflush Recordings Ltd' 59 | }) 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /__TEST__/release.test.js: -------------------------------------------------------------------------------- 1 | import testSnapshot from './fixtures/testSnapshot'; 2 | 3 | test('Should have all basic fields on the release type', async () => { 4 | const query = ` 5 | query { 6 | lookup { 7 | release (id: 2) { 8 | id 9 | title 10 | country 11 | released 12 | genres 13 | styles 14 | formats { 15 | position 16 | formatName 17 | quantity 18 | } 19 | images { 20 | type 21 | height 22 | width 23 | } 24 | master { 25 | id 26 | } 27 | } 28 | } 29 | } 30 | `; 31 | 32 | await testSnapshot(query); 33 | }); 34 | 35 | test('Should implement the release artist connection', async () => { 36 | const query = ` 37 | query { 38 | lookup { 39 | release (id: 1) { 40 | id 41 | artists (first: 1) { 42 | edges { 43 | node { 44 | id 45 | } 46 | } 47 | } 48 | } 49 | } 50 | } 51 | `; 52 | 53 | await testSnapshot(query); 54 | }); 55 | 56 | test('Should implement the release label connection', async () => { 57 | const query = ` 58 | query { 59 | lookup { 60 | release (id: 1) { 61 | id 62 | labels (first: 1) { 63 | edges { 64 | node { 65 | id 66 | } 67 | } 68 | } 69 | } 70 | } 71 | } 72 | `; 73 | 74 | await testSnapshot(query); 75 | }); 76 | 77 | test('Should implement the release track connection', async () => { 78 | const query = ` 79 | query { 80 | lookup { 81 | release (id: 1) { 82 | id 83 | tracks (first: 1) { 84 | edges { 85 | node { 86 | trackno 87 | title 88 | } 89 | } 90 | } 91 | } 92 | } 93 | } 94 | `; 95 | 96 | await testSnapshot(query); 97 | }); 98 | 99 | test('Should implement the extra artist connection', async () => { 100 | const query = ` 101 | query { 102 | lookup { 103 | release (id: 2) { 104 | id 105 | extraArtists (first: 1) { 106 | edges { 107 | node { 108 | id 109 | } 110 | } 111 | } 112 | } 113 | } 114 | } 115 | `; 116 | 117 | await testSnapshot(query); 118 | }); 119 | -------------------------------------------------------------------------------- /__TEST__/search.test.js: -------------------------------------------------------------------------------- 1 | import testSnapshot from './fixtures/testSnapshot'; 2 | 3 | test('Should search for artist', async () => { 4 | const query = ` 5 | query { 6 | search { 7 | artist (name: "Joy Orbison", first: 1) { 8 | edges { 9 | node { 10 | id 11 | } 12 | cursor 13 | } 14 | pageInfo { 15 | hasNextPage 16 | endCursor 17 | } 18 | totalReturned 19 | } 20 | } 21 | } 22 | `; 23 | 24 | await testSnapshot(query); 25 | }); 26 | 27 | test('Should search for release', async () => { 28 | const query = ` 29 | query { 30 | search { 31 | release (title: "Hyph Mngo", first: 1, after: "MTk2Njc3OQ==") { 32 | edges { 33 | node { 34 | id 35 | } 36 | cursor 37 | } 38 | pageInfo { 39 | hasNextPage 40 | endCursor 41 | } 42 | totalReturned 43 | } 44 | } 45 | } 46 | `; 47 | 48 | await testSnapshot(query); 49 | }); 50 | 51 | test('Should search for label', async () => { 52 | const query = ` 53 | query { 54 | search { 55 | label (name: "Hotflush", first: 1) { 56 | edges { 57 | node { 58 | id 59 | } 60 | cursor 61 | } 62 | pageInfo { 63 | hasNextPage 64 | endCursor 65 | } 66 | totalReturned 67 | } 68 | } 69 | } 70 | `; 71 | 72 | await testSnapshot(query); 73 | }); 74 | 75 | test('Should search for master release', async () => { 76 | const query = ` 77 | query { 78 | search { 79 | master (title: "Hyph Mngo", first: 1) { 80 | edges { 81 | node { 82 | id 83 | } 84 | cursor 85 | } 86 | pageInfo { 87 | hasNextPage 88 | endCursor 89 | } 90 | totalReturned 91 | } 92 | } 93 | } 94 | `; 95 | 96 | await testSnapshot(query); 97 | }); 98 | 99 | test('Should apply more complex search query parameters', async () => { 100 | const query = ` 101 | query { 102 | search { 103 | release(styles: "Deep House", released: "1992", country: "US", first: 3) { 104 | edges { 105 | node { 106 | id 107 | title 108 | styles 109 | released 110 | country 111 | } 112 | } 113 | } 114 | } 115 | } 116 | `; 117 | 118 | await testSnapshot(query); 119 | }); 120 | -------------------------------------------------------------------------------- /src/types/release.js: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLString, 3 | GraphQLInt, 4 | GraphQLList, 5 | GraphQLObjectType 6 | } from 'graphql'; 7 | 8 | import {Format, Image, Master} from './index'; 9 | 10 | import {sanitizeAndSplit} from '../utils'; 11 | import property from 'lodash/property'; 12 | 13 | import { 14 | ReleaseArtist, 15 | ReleaseExtraArtist, 16 | ReleaseLabel, 17 | ReleaseTrack 18 | } from './connections'; 19 | 20 | export default new GraphQLObjectType({ 21 | description: 22 | 'The Release resource represents a particular physical or digital object released by one or more Artists.', 23 | name: 'Release', 24 | fields: () => ({ 25 | id: { 26 | description: 'Unique identifier of the release', 27 | type: GraphQLInt, 28 | resolve: property('id') 29 | }, 30 | title: { 31 | description: 'Title of the release', 32 | type: GraphQLString, 33 | resolve: property('title') 34 | }, 35 | country: { 36 | description: 37 | 'Country of origin. Usually this is the country the label is active in', 38 | type: GraphQLString, 39 | resolve: property('country') 40 | }, 41 | released: { 42 | description: 'Date of release. Has no specific structure', 43 | type: GraphQLString, 44 | resolve: property('released') 45 | }, 46 | genres: { 47 | description: 48 | 'List of main genres the release is categorised as. Genres are top level classifications, e.g. Electronic, Rock', 49 | type: new GraphQLList(GraphQLString), 50 | resolve: source => sanitizeAndSplit(source.genres) 51 | }, 52 | styles: { 53 | description: 54 | 'List of styles the release is categorised as. Styles are genres with more specificty, e.g. Techno, House', 55 | type: new GraphQLList(GraphQLString), 56 | resolve: source => sanitizeAndSplit(source.styles) 57 | }, 58 | formats: { 59 | description: 'Formats the release has been distributed on', 60 | type: new GraphQLList(Format), 61 | resolve: (source, args, context) => { 62 | return context.loaders.format.load(source.id); 63 | } 64 | }, 65 | images: { 66 | description: 'Images such as cover art, record centre labels', 67 | type: new GraphQLList(Image), 68 | resolve: (source, args, context) => { 69 | return context.loaders.image.load(source.id); 70 | } 71 | }, 72 | master: { 73 | description: 'Node corresponding to the master release', 74 | type: Master, 75 | resolve: (source, args, context) => { 76 | return ( 77 | source.master_id && context.loaders.master.load(source.master_id) 78 | ); 79 | } 80 | }, 81 | artists: ReleaseArtist('ReleaseArtist'), 82 | labels: ReleaseLabel('ReleaseLabel'), 83 | tracks: ReleaseTrack('ReleaseTrack'), 84 | extraArtists: ReleaseExtraArtist('ReleaseExtraArtist') 85 | }) 86 | }); 87 | -------------------------------------------------------------------------------- /__TEST__/fixtures/database/seeds/artist.js: -------------------------------------------------------------------------------- 1 | exports.seed = function(knex, Promise) { 2 | return Promise.join( 3 | knex('artist').insert({ 4 | id: 1, 5 | name: 'The Persuader', 6 | realname: 'Jesper Dahlbäck', 7 | namevariations: `{Persuader,"The Presuader"}`, 8 | aliases: `{"Dick Track",Faxid,"Groove Machine","Janne Me' Amazonen","Jesper Dahlbäck",Lenk,"The Pinguin Man"}` 9 | }), 10 | knex('artist').insert({ 11 | id: 5, 12 | name: 'Heiko Laux' 13 | }), 14 | knex('artist').insert({ 15 | id: 4, 16 | name: 'Johannes Heil' 17 | }), 18 | knex('artist').insert({ 19 | id: 8, 20 | name: 'Mood II Swing' 21 | }), 22 | knex('artist').insert({ 23 | id: 1564482, 24 | name: 'Joy Orbison', 25 | realname: `Peter O'Grady`, 26 | namevariations: `{"Joy O"}`, 27 | aliases: `{"Peter O'Grady"}`, 28 | profile: 'Nephew of [a2332].', 29 | urls: `{http://www.facebook.com/pages/Joy-Orbison/192924608912,http://myspace.com/joyorbison,http://www.songkick.com/artists/2552181-joy-orbison,http://www.liaisonartists.com/artist/joy-orbison,http://en.wikipedia.org/wiki/Joy_Orbison,http://www.whosampled.com/Joy-Orbison/,http://soundcloud.com/joy-orbison,http://equipboard.com/pros/joy-orbison}` 30 | }), 31 | knex('artist').insert({ 32 | id: 26, 33 | name: 'Alexi Delano', 34 | realname: 'Alexi Delano', 35 | urls: `{http://www.myspace.com/alexidelano,http://twitter.com/AlexiDelano}`, 36 | namevariations: `{"A Delano","A. D.","A. Delano",A.D,A.D.,A.Delano,AD,"Alex Delano","Alexei Delano","Alexi "Adny" Delano","Alexi Delano (ADNY)","Alexi Dolano","Alexi V. Delano",Delano,"Delano, Alexi","DJ Alexi","DJ Alexi Delano","Lords Of Svek"}`, 37 | aliases: `{A.D.1010,ADNY,"Bob Brewthbaker",G.O.L.,Leiva}` 38 | }), 39 | knex('artist').insert({ 40 | id: 27, 41 | name: 'Cari Lekebusch', 42 | realname: 'Kari Pekka Lekebusch', 43 | urls: `{http://www.lekebuschmusik.se,http://blog.carilekebusch.com,http://de.wikipedia.org/wiki/Cari_Lekebusch,http://twitter.com/carilekebusch,http://www.facebook.com/pages/Cari-Lekebusch/36671431790,http://www.youtube.com/carilekebusch,http://www.flickr.com/photos/carilekebusch,http://soundcloud.com/carilekebusch,http://www.beatport.com/artists/cari+lekebusch,http://www.residentadvisor.net/dj/carilekebusch,http://www.alivenotdead.com/CariLekebusch,http://www.myspace.com/carilekebusch,http://world.secondlife.com/resident/ce8435c8-f55c-4710-aab4-ac7156078221}`, 44 | namevariations: `{"C Lekebusch",C-Blast,"C. Lekebusch","C. Lekebush",C.Lekebusch,C.Lekebush,"Cari Le Kebusch","Cari Leke Busch","Cari Lekebusch den rykande Bönsyrsan","Cari Lekebush","Cari Lelebusch","Carl Lekebusch",Lekebusch,"Lekebusch Musik"}`, 45 | aliases: `{"Agent Orange",Braincell,C-Blast,Cerebus,"Crushed Insect","DJ Mystiska K",Fred,"Kari Pekka",Magenta,Mentap,"Mr. James Barth","Mystic Letter K","Phunkey Rhythm Doctor",Rotortype,Rubberneck,"Shape Changer","Sir Jeremy Augustus Hutley Of Granith Hall","Szerementa Programs","The Mantis (2)",Vector,Yakari}` 46 | }) 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /__TEST__/__snapshots__/track.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Should implement the track extra artist connection 1`] = ` 4 | Object { 5 | "data": Object { 6 | "lookup": Object { 7 | "release": Object { 8 | "tracks": Object { 9 | "edges": Array [ 10 | Object { 11 | "node": Object { 12 | "extraArtists": Object { 13 | "edges": Array [], 14 | }, 15 | "title": "Untitled 8", 16 | "trackno": 1, 17 | }, 18 | }, 19 | Object { 20 | "node": Object { 21 | "extraArtists": Object { 22 | "edges": Array [], 23 | }, 24 | "title": "Anjua (Sneaky 3)", 25 | "trackno": 2, 26 | }, 27 | }, 28 | Object { 29 | "node": Object { 30 | "extraArtists": Object { 31 | "edges": Array [ 32 | Object { 33 | "node": Object { 34 | "id": 8, 35 | }, 36 | }, 37 | ], 38 | }, 39 | "title": "When The Funk Hits The Fan (Mood II Swing When The Dub Hits The Fan)", 40 | "trackno": 3, 41 | }, 42 | }, 43 | ], 44 | }, 45 | }, 46 | }, 47 | }, 48 | } 49 | `; 50 | 51 | exports[`Should implement tracks artist connection 1`] = ` 52 | Object { 53 | "data": Object { 54 | "lookup": Object { 55 | "release": Object { 56 | "tracks": Object { 57 | "edges": Array [ 58 | Object { 59 | "node": Object { 60 | "artists": Object { 61 | "edges": Array [ 62 | Object { 63 | "node": Object { 64 | "id": 4, 65 | }, 66 | }, 67 | Object { 68 | "node": Object { 69 | "id": 5, 70 | }, 71 | }, 72 | ], 73 | }, 74 | "title": "Untitled 8", 75 | "trackno": 1, 76 | }, 77 | }, 78 | ], 79 | }, 80 | }, 81 | }, 82 | }, 83 | } 84 | `; 85 | 86 | exports[`Should paginate tracks by their track number 1`] = ` 87 | Object { 88 | "data": Object { 89 | "lookup": Object { 90 | "release": Object { 91 | "tracks": Object { 92 | "edges": Array [ 93 | Object { 94 | "cursor": "Mg==", 95 | "node": Object { 96 | "title": "Vasastaden", 97 | "trackno": 2, 98 | }, 99 | }, 100 | ], 101 | "pageInfo": Object { 102 | "endCursor": "Mg==", 103 | "hasNextPage": false, 104 | }, 105 | }, 106 | }, 107 | }, 108 | }, 109 | } 110 | `; 111 | -------------------------------------------------------------------------------- /__TEST__/__snapshots__/release.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Should have all basic fields on the release type 1`] = ` 4 | Object { 5 | "data": Object { 6 | "lookup": Object { 7 | "release": Object { 8 | "country": "Sweden", 9 | "formats": Array [ 10 | Object { 11 | "formatName": "Vinyl", 12 | "position": 1, 13 | "quantity": 1, 14 | }, 15 | ], 16 | "genres": Array [ 17 | "Electronic", 18 | ], 19 | "id": 2, 20 | "images": Array [ 21 | Object { 22 | "height": 394, 23 | "type": "primary", 24 | "width": 400, 25 | }, 26 | Object { 27 | "height": 600, 28 | "type": "secondary", 29 | "width": 600, 30 | }, 31 | ], 32 | "master": Object { 33 | "id": 713738, 34 | }, 35 | "released": "1998-06-00", 36 | "styles": Array [ 37 | "Broken Beat", 38 | "Techno", 39 | "Tech House", 40 | ], 41 | "title": "Knockin' Boots Vol 2 Of 2", 42 | }, 43 | }, 44 | }, 45 | } 46 | `; 47 | 48 | exports[`Should implement the extra artist connection 1`] = ` 49 | Object { 50 | "data": Object { 51 | "lookup": Object { 52 | "release": Object { 53 | "extraArtists": Object { 54 | "edges": Array [ 55 | Object { 56 | "node": Object { 57 | "id": 26, 58 | }, 59 | }, 60 | ], 61 | }, 62 | "id": 2, 63 | }, 64 | }, 65 | }, 66 | } 67 | `; 68 | 69 | exports[`Should implement the release artist connection 1`] = ` 70 | Object { 71 | "data": Object { 72 | "lookup": Object { 73 | "release": Object { 74 | "artists": Object { 75 | "edges": Array [ 76 | Object { 77 | "node": Object { 78 | "id": 1, 79 | }, 80 | }, 81 | ], 82 | }, 83 | "id": 1, 84 | }, 85 | }, 86 | }, 87 | } 88 | `; 89 | 90 | exports[`Should implement the release label connection 1`] = ` 91 | Object { 92 | "data": Object { 93 | "lookup": Object { 94 | "release": Object { 95 | "id": 1, 96 | "labels": Object { 97 | "edges": Array [ 98 | Object { 99 | "node": Object { 100 | "id": 5, 101 | }, 102 | }, 103 | ], 104 | }, 105 | }, 106 | }, 107 | }, 108 | } 109 | `; 110 | 111 | exports[`Should implement the release track connection 1`] = ` 112 | Object { 113 | "data": Object { 114 | "lookup": Object { 115 | "release": Object { 116 | "id": 1, 117 | "tracks": Object { 118 | "edges": Array [ 119 | Object { 120 | "node": Object { 121 | "title": "Östermalm", 122 | "trackno": 1, 123 | }, 124 | }, 125 | ], 126 | }, 127 | }, 128 | }, 129 | }, 130 | } 131 | `; 132 | -------------------------------------------------------------------------------- /__TEST__/__snapshots__/label.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Should get parent label 1`] = ` 4 | Object { 5 | "data": Object { 6 | "lookup": Object { 7 | "label": Object { 8 | "parentLabel": Object { 9 | "id": 73924, 10 | }, 11 | }, 12 | }, 13 | }, 14 | } 15 | `; 16 | 17 | exports[`Should get sub labels 1`] = ` 18 | Object { 19 | "data": Object { 20 | "lookup": Object { 21 | "label": Object { 22 | "subLabels": Array [ 23 | Object { 24 | "id": 86537, 25 | "name": "Antidote (4)", 26 | }, 27 | Object { 28 | "id": 41841, 29 | "name": "Community Projects", 30 | }, 31 | Object { 32 | "id": 153760, 33 | "name": "Guilty Pleasures", 34 | }, 35 | Object { 36 | "id": 31405, 37 | "name": "I Ner Zon Sounds", 38 | }, 39 | Object { 40 | "id": 277579, 41 | "name": "Planet E Communications", 42 | }, 43 | Object { 44 | "id": 294738, 45 | "name": "Planet E Communications, Inc.", 46 | }, 47 | Object { 48 | "id": 488315, 49 | "name": "TWPENTY", 50 | }, 51 | ], 52 | }, 53 | }, 54 | }, 55 | } 56 | `; 57 | 58 | exports[`Should have all basic fields on the label type 1`] = ` 59 | Object { 60 | "data": Object { 61 | "lookup": Object { 62 | "label": Object { 63 | "contactInfo": "Planet E Communications 64 | P.O. Box 27218 65 | Detroit, Michigan, MI 48227 66 | USA 67 | 68 | Phone: +1 313 874 8729 69 | Fax: +1 313 874 8732 70 | Email: info@Planet-e.net", 71 | "id": 1, 72 | "name": "Planet E", 73 | "profile": "[a=Carl Craig]'s classic techno label founded in 1991. 74 | 75 | On at least 1 release, Planet E is listed as publisher.", 76 | "urls": Array [ 77 | "http://planet-e.net", 78 | "http://planetecommunications.bandcamp.com", 79 | "http://www.facebook.com/planetedetroit", 80 | "http://www.flickr.com/photos/planetedetroit", 81 | "http://plus.google.com/100841702106447505236", 82 | "http://www.instagram.com/carlcraignet", 83 | "http://myspace.com/planetecom", 84 | "http://myspace.com/planetedetroit", 85 | "http://soundcloud.com/planetedetroit", 86 | "http://twitter.com/planetedetroit", 87 | "http://vimeo.com/user1265384", 88 | "http://en.wikipedia.org/wiki/Planet_E_Communications", 89 | "http://www.youtube.com/user/planetedetroit", 90 | ], 91 | }, 92 | }, 93 | }, 94 | } 95 | `; 96 | 97 | exports[`Should implement the label-release connection 1`] = ` 98 | Object { 99 | "data": Object { 100 | "lookup": Object { 101 | "label": Object { 102 | "releases": Object { 103 | "edges": Array [ 104 | Object { 105 | "node": Object { 106 | "id": 1025, 107 | "title": "Silentintroduction", 108 | }, 109 | }, 110 | ], 111 | }, 112 | }, 113 | }, 114 | }, 115 | } 116 | `; 117 | -------------------------------------------------------------------------------- /__TEST__/__snapshots__/search.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Should apply more complex search query parameters 1`] = ` 4 | Object { 5 | "data": Object { 6 | "search": Object { 7 | "release": Object { 8 | "edges": Array [ 9 | Object { 10 | "node": Object { 11 | "country": "US", 12 | "id": 2151, 13 | "released": "1992-00-00", 14 | "styles": Array [ 15 | "Deep House", 16 | ], 17 | "title": "U Got Me", 18 | }, 19 | }, 20 | Object { 21 | "node": Object { 22 | "country": "US", 23 | "id": 2152, 24 | "released": "1992-00-00", 25 | "styles": Array [ 26 | "Deep House", 27 | ], 28 | "title": "Together", 29 | }, 30 | }, 31 | Object { 32 | "node": Object { 33 | "country": "US", 34 | "id": 2153, 35 | "released": "1992-00-00", 36 | "styles": Array [ 37 | "Deep House", 38 | ], 39 | "title": "Some Lovin'", 40 | }, 41 | }, 42 | ], 43 | }, 44 | }, 45 | }, 46 | } 47 | `; 48 | 49 | exports[`Should search for artist 1`] = ` 50 | Object { 51 | "data": Object { 52 | "search": Object { 53 | "artist": Object { 54 | "edges": Array [ 55 | Object { 56 | "cursor": "MTU2NDQ4Mg==", 57 | "node": Object { 58 | "id": 1564482, 59 | }, 60 | }, 61 | ], 62 | "pageInfo": Object { 63 | "endCursor": "MTU2NDQ4Mg==", 64 | "hasNextPage": false, 65 | }, 66 | "totalReturned": 1, 67 | }, 68 | }, 69 | }, 70 | } 71 | `; 72 | 73 | exports[`Should search for label 1`] = ` 74 | Object { 75 | "data": Object { 76 | "search": Object { 77 | "label": Object { 78 | "edges": Array [ 79 | Object { 80 | "cursor": "MjI5MTI=", 81 | "node": Object { 82 | "id": 22912, 83 | }, 84 | }, 85 | ], 86 | "pageInfo": Object { 87 | "endCursor": "MjI5MTI=", 88 | "hasNextPage": true, 89 | }, 90 | "totalReturned": 1, 91 | }, 92 | }, 93 | }, 94 | } 95 | `; 96 | 97 | exports[`Should search for master release 1`] = ` 98 | Object { 99 | "data": Object { 100 | "search": Object { 101 | "master": Object { 102 | "edges": Array [ 103 | Object { 104 | "cursor": "MTg4MzI1", 105 | "node": Object { 106 | "id": 188325, 107 | }, 108 | }, 109 | ], 110 | "pageInfo": Object { 111 | "endCursor": "MTg4MzI1", 112 | "hasNextPage": false, 113 | }, 114 | "totalReturned": 1, 115 | }, 116 | }, 117 | }, 118 | } 119 | `; 120 | 121 | exports[`Should search for release 1`] = ` 122 | Object { 123 | "data": Object { 124 | "search": Object { 125 | "release": Object { 126 | "edges": Array [ 127 | Object { 128 | "cursor": "MjA1NjUzMg==", 129 | "node": Object { 130 | "id": 2056532, 131 | }, 132 | }, 133 | ], 134 | "pageInfo": Object { 135 | "endCursor": "MjA1NjUzMg==", 136 | "hasNextPage": false, 137 | }, 138 | "totalReturned": 1, 139 | }, 140 | }, 141 | }, 142 | } 143 | `; 144 | -------------------------------------------------------------------------------- /__TEST__/fixtures/database/migrations/20180217152657_test_tables.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex, Promise) { 2 | return Promise.all([ 3 | knex.schema.createTable('artist', table => { 4 | table.integer('id').notNullable(); 5 | table.string('name').notNullable(); 6 | table.string('realname'); 7 | table.string('urls'); 8 | table.string('namevariations'); 9 | table.string('aliases'); 10 | table.string('profile'); 11 | table.string('members'); 12 | table.string('groups'); 13 | table.string('data_quality'); 14 | }), 15 | 16 | knex.schema.createTable('label', table => { 17 | table.integer('id').notNullable(); 18 | table.string('name').notNullable(); 19 | table.string('contactinfo'); 20 | table.string('profile'); 21 | table.string('parent_label'); 22 | table.string('sublabels'); 23 | table.string('urls'); 24 | table.string('data_quality'); 25 | }), 26 | 27 | knex.schema.createTable('release', table => { 28 | table.integer('id').notNullable(); 29 | table.string('status'); 30 | table.string('title'); 31 | table.string('country'); 32 | table.string('released'); 33 | table.string('barcode'); 34 | table.string('notes'); 35 | table.string('genres'); 36 | table.string('styles'); 37 | table.string('data_quality'); 38 | table.integer('master_id'); 39 | }), 40 | 41 | knex.schema.createTable('master', table => { 42 | table.integer('id').notNullable(); 43 | table.string('title'); 44 | table.integer('main_release').notNullable(); 45 | table.integer('year'); 46 | table.string('notes'); 47 | table.string('genres'); 48 | table.string('styles'); 49 | table.string('role'); 50 | table.string('data_quality'); 51 | }), 52 | 53 | knex.schema.createTable('releases_artists', table => { 54 | table.integer('release_id'); 55 | table.integer('position'); 56 | table.integer('artist_id'); 57 | table.string('artist_name'); 58 | table.string('anv'); 59 | table.string('join_relation'); 60 | }), 61 | 62 | knex.schema.createTable('releases_extraartists', table => { 63 | table.integer('release_id'); 64 | table.integer('artist_id'); 65 | table.string('artist_name'); 66 | table.string('anv'); 67 | table.string('role'); 68 | }), 69 | 70 | knex.schema.createTable('releases_labels', table => { 71 | table.string('label'); 72 | table.integer('release_id'); 73 | table.string('catno'); 74 | }), 75 | 76 | knex.schema.createTable('releases_formats', table => { 77 | table.integer('release_id'); 78 | table.integer('position'); 79 | table.string('format_name'); 80 | table.string('descriptions'); 81 | table.decimal('qty', 100, 0); 82 | }), 83 | 84 | knex.schema.createTable('releases_images', table => { 85 | table.integer('release_id'); 86 | table.string('type'); 87 | table.integer('height'); 88 | table.integer('width'); 89 | table.string('image_uri'); 90 | }), 91 | 92 | knex.schema.createTable('track', table => { 93 | table.integer('release_id'); 94 | table.string('position'); 95 | table.increments('track_id'); 96 | table.string('title'); 97 | table.string('duration'); 98 | table.int('trackno'); 99 | }), 100 | 101 | knex.schema.createTable('tracks_artists', table => { 102 | table.string('track_id'); 103 | table.integer('position'); 104 | table.integer('artist_id'); 105 | table.string('artist_name'); 106 | table.string('anv'); 107 | table.string('join_relation'); 108 | }), 109 | 110 | knex.schema.createTable('tracks_extraartists', table => { 111 | table.string('track_id'); 112 | table.integer('artist_id'); 113 | table.string('artist_name'); 114 | table.string('anv'); 115 | table.string('role'); 116 | table.string('data_quality'); 117 | }) 118 | ]); 119 | }; 120 | 121 | exports.down = () => {}; 122 | -------------------------------------------------------------------------------- /src/types/connections.js: -------------------------------------------------------------------------------- 1 | import {wrapConnection} from './helpers'; 2 | import Release from './release'; 3 | import Artist from './artist'; 4 | import ExtraArtist from './extra-artist'; 5 | import Label from './label'; 6 | import Track from './track'; 7 | 8 | export const ArtistRelease = name => 9 | wrapConnection({ 10 | description: 'Releases from this artist', 11 | name: name, 12 | type: Release, 13 | loader: 'release', 14 | identifier: 'release_id', 15 | 16 | query: function(source, context, args) { 17 | return context 18 | .db('releases_artists') 19 | .select('release_id') 20 | .where('artist_id', source.id) 21 | .orderBy('release_id', 'asc'); 22 | } 23 | }); 24 | 25 | export const LabelRelease = name => 26 | wrapConnection({ 27 | description: 'Releases on this label', 28 | name: name, 29 | type: Release, 30 | loader: 'release', 31 | identifier: 'release_id', 32 | 33 | query: function(source, context, args) { 34 | return context 35 | .db('releases_labels') 36 | .select('release_id') 37 | .where('label', source.name) 38 | .orderBy('release_id', 'asc'); 39 | } 40 | }); 41 | 42 | export const MasterRelease = name => 43 | wrapConnection({ 44 | description: 'Releases linked to the master release', 45 | name: name, 46 | type: Release, 47 | loader: 'release', 48 | identifier: 'id', 49 | 50 | query: function(source, context, args) { 51 | return context 52 | .db('release') 53 | .select('id') 54 | .where('master_id', source.id) 55 | .orderBy('id', 'asc'); 56 | } 57 | }); 58 | 59 | export const ReleaseArtist = name => 60 | wrapConnection({ 61 | description: 'Main artist on this release', 62 | name: name, 63 | type: Artist, 64 | loader: 'artist', 65 | identifier: 'artist_id', 66 | 67 | query: function(source, context, args) { 68 | return context 69 | .db('releases_artists') 70 | .select('artist_id') 71 | .where('release_id', source.id) 72 | .orderBy('artist_id', 'asc'); 73 | } 74 | }); 75 | 76 | export const ReleaseExtraArtist = name => 77 | wrapConnection({ 78 | description: 'Extra artists with a role in this release', 79 | name: name, 80 | type: ExtraArtist, 81 | 82 | query: function(source, context, args) { 83 | return context 84 | .db('releases_extraartists') 85 | .select() 86 | .where('release_id', source.id) 87 | .leftJoin('artist', 'artist.id', 'releases_extraartists.artist_id') 88 | .orderBy('artist_id', 'asc'); 89 | } 90 | }); 91 | 92 | export const ReleaseLabel = name => 93 | wrapConnection({ 94 | description: 'Labels that have featured this release', 95 | name: name, 96 | type: Label, 97 | loader: 'label', 98 | identifier: 'label', 99 | 100 | query: function(source, context, args) { 101 | return context 102 | .db('releases_labels') 103 | .select('label') 104 | .where('release_id', source.id); 105 | } 106 | }); 107 | 108 | export const ReleaseTrack = name => 109 | wrapConnection({ 110 | description: 'Tracks on this release', 111 | name: name, 112 | type: Track, 113 | loader: 'track', 114 | identifier: 'track_id', 115 | 116 | query: function(source, context, args) { 117 | return context 118 | .db('track') 119 | .select() 120 | .where('release_id', source.id) 121 | .orderBy('trackno', 'asc'); 122 | } 123 | }); 124 | 125 | export const TrackArtist = name => 126 | wrapConnection({ 127 | description: 'Main artists who have been credited with this track', 128 | name: name, 129 | type: Artist, 130 | loader: 'artist', 131 | identifier: 'artist_id', 132 | 133 | query: async function(source, context, args) { 134 | return context 135 | .db('tracks_artists') 136 | .select('artist_id') 137 | .where('track_id', source.track_id) 138 | .orderBy('artist_id', 'asc'); 139 | } 140 | }); 141 | 142 | export const TrackExtraArtist = name => 143 | wrapConnection({ 144 | description: 'Extra artists who have contributed to this track', 145 | name: name, 146 | type: ExtraArtist, 147 | 148 | query: async function(source, context, args) { 149 | return context 150 | .db('tracks_extraartists') 151 | .select() 152 | .where('track_id', source.track_id) 153 | .leftJoin('artist', 'artist.id', 'tracks_extraartists.artist_id') 154 | .orderBy('artist_id', 'asc'); 155 | } 156 | }); 157 | -------------------------------------------------------------------------------- /__TEST__/__snapshots__/extra-artist.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Should return all fields on extra artist type 1`] = ` 4 | Object { 5 | "data": Object { 6 | "lookup": Object { 7 | "release": Object { 8 | "extraArtists": Object { 9 | "edges": Array [ 10 | Object { 11 | "node": Object { 12 | "aliases": Array [ 13 | "A.D.1010", 14 | "ADNY", 15 | "Bob Brewthbaker", 16 | "G.O.L.", 17 | "Leiva", 18 | ], 19 | "id": 26, 20 | "name": "Alexi Delano", 21 | "realName": "Alexi Delano", 22 | "role": "Producer", 23 | "urls": Array [ 24 | "http://www.myspace.com/alexidelano", 25 | "http://twitter.com/AlexiDelano", 26 | ], 27 | }, 28 | }, 29 | Object { 30 | "node": Object { 31 | "aliases": Array [ 32 | "A.D.1010", 33 | "ADNY", 34 | "Bob Brewthbaker", 35 | "G.O.L.", 36 | "Leiva", 37 | ], 38 | "id": 26, 39 | "name": "Alexi Delano", 40 | "realName": "Alexi Delano", 41 | "role": "Recorded By", 42 | "urls": Array [ 43 | "http://www.myspace.com/alexidelano", 44 | "http://twitter.com/AlexiDelano", 45 | ], 46 | }, 47 | }, 48 | Object { 49 | "node": Object { 50 | "aliases": Array [ 51 | "A.D.1010", 52 | "ADNY", 53 | "Bob Brewthbaker", 54 | "G.O.L.", 55 | "Leiva", 56 | ], 57 | "id": 26, 58 | "name": "Alexi Delano", 59 | "realName": "Alexi Delano", 60 | "role": "Written-By", 61 | "urls": Array [ 62 | "http://www.myspace.com/alexidelano", 63 | "http://twitter.com/AlexiDelano", 64 | ], 65 | }, 66 | }, 67 | Object { 68 | "node": Object { 69 | "aliases": Array [ 70 | "Agent Orange", 71 | "Braincell", 72 | "C-Blast", 73 | "Cerebus", 74 | "Crushed Insect", 75 | "DJ Mystiska K", 76 | "Fred", 77 | "Kari Pekka", 78 | "Magenta", 79 | "Mentap", 80 | "Mr. James Barth", 81 | "Mystic Letter K", 82 | "Phunkey Rhythm Doctor", 83 | "Rotortype", 84 | "Rubberneck", 85 | "Shape Changer", 86 | "Sir Jeremy Augustus Hutley Of Granith Hall", 87 | "Szerementa Programs", 88 | "The Mantis (2)", 89 | "Vector", 90 | "Yakari", 91 | ], 92 | "id": 27, 93 | "name": "Cari Lekebusch", 94 | "realName": "Kari Pekka Lekebusch", 95 | "role": "Producer", 96 | "urls": Array [ 97 | "http://www.lekebuschmusik.se", 98 | "http://blog.carilekebusch.com", 99 | "http://de.wikipedia.org/wiki/Cari_Lekebusch", 100 | "http://twitter.com/carilekebusch", 101 | "http://www.facebook.com/pages/Cari-Lekebusch/36671431790", 102 | "http://www.youtube.com/carilekebusch", 103 | "http://www.flickr.com/photos/carilekebusch", 104 | "http://soundcloud.com/carilekebusch", 105 | "http://www.beatport.com/artists/cari+lekebusch", 106 | "http://www.residentadvisor.net/dj/carilekebusch", 107 | "http://www.alivenotdead.com/CariLekebusch", 108 | "http://www.myspace.com/carilekebusch", 109 | "http://world.secondlife.com/resident/ce8435c8-f55c-4710-aab4-ac7156078221", 110 | ], 111 | }, 112 | }, 113 | Object { 114 | "node": Object { 115 | "aliases": Array [ 116 | "Agent Orange", 117 | "Braincell", 118 | "C-Blast", 119 | "Cerebus", 120 | "Crushed Insect", 121 | "DJ Mystiska K", 122 | "Fred", 123 | "Kari Pekka", 124 | "Magenta", 125 | "Mentap", 126 | "Mr. James Barth", 127 | "Mystic Letter K", 128 | "Phunkey Rhythm Doctor", 129 | "Rotortype", 130 | "Rubberneck", 131 | "Shape Changer", 132 | "Sir Jeremy Augustus Hutley Of Granith Hall", 133 | "Szerementa Programs", 134 | "The Mantis (2)", 135 | "Vector", 136 | "Yakari", 137 | ], 138 | "id": 27, 139 | "name": "Cari Lekebusch", 140 | "realName": "Kari Pekka Lekebusch", 141 | "role": "Recorded By", 142 | "urls": Array [ 143 | "http://www.lekebuschmusik.se", 144 | "http://blog.carilekebusch.com", 145 | "http://de.wikipedia.org/wiki/Cari_Lekebusch", 146 | "http://twitter.com/carilekebusch", 147 | "http://www.facebook.com/pages/Cari-Lekebusch/36671431790", 148 | "http://www.youtube.com/carilekebusch", 149 | "http://www.flickr.com/photos/carilekebusch", 150 | "http://soundcloud.com/carilekebusch", 151 | "http://www.beatport.com/artists/cari+lekebusch", 152 | "http://www.residentadvisor.net/dj/carilekebusch", 153 | "http://www.alivenotdead.com/CariLekebusch", 154 | "http://www.myspace.com/carilekebusch", 155 | "http://world.secondlife.com/resident/ce8435c8-f55c-4710-aab4-ac7156078221", 156 | ], 157 | }, 158 | }, 159 | Object { 160 | "node": Object { 161 | "aliases": Array [ 162 | "Agent Orange", 163 | "Braincell", 164 | "C-Blast", 165 | "Cerebus", 166 | "Crushed Insect", 167 | "DJ Mystiska K", 168 | "Fred", 169 | "Kari Pekka", 170 | "Magenta", 171 | "Mentap", 172 | "Mr. James Barth", 173 | "Mystic Letter K", 174 | "Phunkey Rhythm Doctor", 175 | "Rotortype", 176 | "Rubberneck", 177 | "Shape Changer", 178 | "Sir Jeremy Augustus Hutley Of Granith Hall", 179 | "Szerementa Programs", 180 | "The Mantis (2)", 181 | "Vector", 182 | "Yakari", 183 | ], 184 | "id": 27, 185 | "name": "Cari Lekebusch", 186 | "realName": "Kari Pekka Lekebusch", 187 | "role": "Written-By", 188 | "urls": Array [ 189 | "http://www.lekebuschmusik.se", 190 | "http://blog.carilekebusch.com", 191 | "http://de.wikipedia.org/wiki/Cari_Lekebusch", 192 | "http://twitter.com/carilekebusch", 193 | "http://www.facebook.com/pages/Cari-Lekebusch/36671431790", 194 | "http://www.youtube.com/carilekebusch", 195 | "http://www.flickr.com/photos/carilekebusch", 196 | "http://soundcloud.com/carilekebusch", 197 | "http://www.beatport.com/artists/cari+lekebusch", 198 | "http://www.residentadvisor.net/dj/carilekebusch", 199 | "http://www.alivenotdead.com/CariLekebusch", 200 | "http://www.myspace.com/carilekebusch", 201 | "http://world.secondlife.com/resident/ce8435c8-f55c-4710-aab4-ac7156078221", 202 | ], 203 | }, 204 | }, 205 | ], 206 | }, 207 | "id": 2, 208 | }, 209 | }, 210 | }, 211 | } 212 | `; 213 | --------------------------------------------------------------------------------