diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8e9093ad..3670b12c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -109,6 +109,26 @@ const User = new GraphQLObjectType({ The old `sortKey` synax (as an object looking like `{key, order}`) continues to work but is no longer recommended. The new syntax allows for reliable sortKey ordering as well as independent directions per sort key, with all the complicated SQL generation handled for you. +- Add support for resolving GraphQL scalars backed by `sqlTable`s. Usually, scalars point to table columns, but this allows them to use the same `extensions` property to declare that the scalar is a whole table. This is often paired with a `resolve` function that takes what `join-monster` returns and turns it into a valid value for the scalar. An example would be a tags field that outputs a list of strings, but where each tag is actually stored as it's own row in a different table in the database, or backing a `JSONScalar` by a table to get around GraphQL's type strictness. + +```javascript +export const Tag = new GraphQLScalarType({ + name: 'Tag', + extensions: { + joinMonster: { + sqlTable: 'tags', + uniqueKey: 'id', + alwaysFetch: ['id', 'tag', 'tag_order'] + } + }, + parseValue: String, + serialize: String, + parseLiteral(ast) { + // ... + } +}) +``` + ### v2.1.2 (May 25, 2020) #### Fixed diff --git a/docs/map-to-table.md b/docs/map-to-table.md index 66e077b7..eea26cf8 100644 --- a/docs/map-to-table.md +++ b/docs/map-to-table.md @@ -88,3 +88,31 @@ const User = new GraphQLObjectType({ }) }) ``` + +## Using scalars instead of objects + +Rarely, you may have a value in your GraphQL API that's best represented as a scalar value instead of an object with fields, like a special string or a JSON scalar. `GraphQLScalar`s can also be extended such that `join-monster` will retrieve them using SQL joins or batches. + +As an example, we could set up a `Post` object, powered by a `posts` table, that has a `tags` field which is powered by a whole other `tags` table. The `Tag` scalar might be a custom `GraphQLScalar` like so: + +```javascript +const Tag = new GraphQLScalarType({ + name: 'Tag', + extensions: { + joinMonster: { + sqlTable: 'tags', + uniqueKey: 'id', + alwaysFetch: ['id', 'tag_name'] + } + }, + parseValue: String, + serialize: String, + parseLiteral(ast) { + // ... + } +}) +``` + +which configures `join-monster` to fetch tags from the `tags` table, and to always fetch the `tag_name` column. + +The `Post` object can then join `Tag`s just like any other `join-monster` powered object, using either a connection or a plain `GraphQLList`. See the section on [joins](/start-joins) for more details. diff --git a/package-lock.json b/package-lock.json index 86bc2c94..74ef6fa3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "join-monster", - "version": "3.0.0", + "version": "3.0.0-alpha.2", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/src/index.d.ts b/src/index.d.ts index 176f0076..44b7ed0d 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -100,6 +100,12 @@ export interface InterfaceTypeExtension { alwaysFetch?: string | string[] } +export interface ScalarTypeExtension { + sqlTable?: string + uniqueKey?: string | string[] + alwaysFetch?: string | string[] +} + declare module 'graphql' { interface GraphQLObjectTypeExtensions { joinMonster?: ObjectTypeExtension @@ -117,6 +123,9 @@ declare module 'graphql' { interface GraphQLInterfaceTypeExtensions { joinMonster?: InterfaceTypeExtension } + interface GraphQLScalarTypeExtensions { + joinMonster?: ScalarTypeExtension + } } // JoinMonster lib interface diff --git a/src/query-ast-to-sql-ast/index.js b/src/query-ast-to-sql-ast/index.js index 8566aa82..0b4fff5b 100644 --- a/src/query-ast-to-sql-ast/index.js +++ b/src/query-ast-to-sql-ast/index.js @@ -31,7 +31,8 @@ class SQLASTNode { const TABLE_TYPES = [ 'GraphQLObjectType', 'GraphQLUnionType', - 'GraphQLInterfaceType' + 'GraphQLInterfaceType', + 'GraphQLScalarType' ] function mergeAll(fieldNodes) { diff --git a/test-api/data/db/demo-data.sl3 b/test-api/data/db/demo-data.sl3 index 8c151c22..87799c75 100644 Binary files a/test-api/data/db/demo-data.sl3 and b/test-api/data/db/demo-data.sl3 differ diff --git a/test-api/data/db/test1-data.sl3 b/test-api/data/db/test1-data.sl3 index 15372bef..97058088 100644 Binary files a/test-api/data/db/test1-data.sl3 and b/test-api/data/db/test1-data.sl3 differ diff --git a/test-api/data/schema/mysql.sql b/test-api/data/schema/mysql.sql index 59da1580..4e8f0ef3 100644 --- a/test-api/data/schema/mysql.sql +++ b/test-api/data/schema/mysql.sql @@ -53,3 +53,12 @@ CREATE TABLE sponsors ( created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) ); + +DROP TABLE IF EXISTS tags; +CREATE TABLE tags ( + id INT AUTO_INCREMENT PRIMARY KEY, + post_id INTEGER NOT NULL, + tag VARCHAR(255), + tag_order INTEGER DEFAULT 1, + created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) +); \ No newline at end of file diff --git a/test-api/data/schema/oracle.sql b/test-api/data/schema/oracle.sql index ae49fe75..db0b694c 100644 --- a/test-api/data/schema/oracle.sql +++ b/test-api/data/schema/oracle.sql @@ -86,3 +86,16 @@ CREATE TABLE "sponsors" ( "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) + +begin +execute immediate 'drop table "tags" purge'; +exception when others then null; +end; + +CREATE TABLE "tags" ( + "id" NUMBER , + "post_id" NUMBER DEFAULT , + "tag" VARCHAR(255), + "tag_order" NUMBER DEFAULT , + "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); diff --git a/test-api/data/schema/pg.sql b/test-api/data/schema/pg.sql index 60c7d607..b08c1cac 100644 --- a/test-api/data/schema/pg.sql +++ b/test-api/data/schema/pg.sql @@ -53,3 +53,11 @@ CREATE TABLE sponsors ( created_at TIMESTAMPTZ DEFAULT NOW() ); +DROP TABLE IF EXISTS tags; +CREATE TABLE tags ( + id SERIAL PRIMARY KEY, + post_id INTEGER NOT NULL, + tag VARCHAR(255), + tag_order INTEGER DEFAULT 1, + created_at TIMESTAMPTZ DEFAULT NOW() +); diff --git a/test-api/data/schema/sqlite3.sql b/test-api/data/schema/sqlite3.sql index 501b7604..9981f8a5 100644 --- a/test-api/data/schema/sqlite3.sql +++ b/test-api/data/schema/sqlite3.sql @@ -53,3 +53,11 @@ CREATE TABLE sponsors ( created_at DEFAULT CURRENT_TIMESTAMP ); +DROP TABLE IF EXISTS tags; +CREATE TABLE tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL, + tag VARCHAR(255), + tag_order INTEGER DEFAULT 1, + created_at DEFAULT CURRENT_TIMESTAMP +); diff --git a/test-api/data/setup/demo.js b/test-api/data/setup/demo.js index c5ea311e..3d98be33 100644 --- a/test-api/data/setup/demo.js +++ b/test-api/data/setup/demo.js @@ -11,6 +11,7 @@ const numPosts = 50 const numComments = 300 const numRelationships = 15 const numLikes = 300 +const numTags = 200 module.exports = async db => { const knex = await require('../schema/setup')(db, 'demo') @@ -117,5 +118,17 @@ module.exports = async db => { } ]) + console.log('creating tags...') + const tags = new Array(numTags) + for (let i of count(numTags)) { + tags[i] = { + tag: faker.random.word(), + post_id: faker.random.number({ min: 1, max: numPosts }), + tag_order: i, + created_at: faker.date.past() + } + } + await knex.batchInsert('tags', tags, 50) + await knex.destroy() } diff --git a/test-api/data/setup/test1.js b/test-api/data/setup/test1.js index f9308f83..2d0e2536 100644 --- a/test-api/data/setup/test1.js +++ b/test-api/data/setup/test1.js @@ -182,5 +182,28 @@ module.exports = async db => { } ]) + await knex.batchInsert('tags', [ + { + post_id: 1, + tag: 'foo', + tag_order: 1 + }, + { + post_id: 1, + tag: 'bar', + tag_order: 2 + }, + { + post_id: 1, + tag: 'baz', + tag_order: 3 + }, + { + post_id: 2, + tag: 'foo', + tag_order: 1 + } + ]) + await knex.destroy() } diff --git a/test-api/schema-paginated/Post.js b/test-api/schema-paginated/Post.js index 5be54981..7c5c6047 100644 --- a/test-api/schema-paginated/Post.js +++ b/test-api/schema-paginated/Post.js @@ -2,7 +2,8 @@ import { GraphQLObjectType, GraphQLString, GraphQLInt, - GraphQLBoolean + GraphQLBoolean, + GraphQLList } from 'graphql' import { @@ -15,6 +16,7 @@ import { import { User } from './User' import { CommentConnection } from './Comment' +import { Tag, TagConnection } from './Tag' import { Authored } from './Authored/Interface' import { nodeInterface } from './Node' import { q, bool } from '../shared' @@ -161,6 +163,80 @@ export const Post = new GraphQLObjectType({ sqlColumn: 'created_at' } } + }, + tags: { + type: new GraphQLList(Tag), + resolve: source => { + return source.tags.map(tag => tag.tag) + }, + extensions: { + joinMonster: { + orderBy: 'tag_order', + ...(STRATEGY === 'batch' + ? { + sqlBatch: { + thisKey: 'id', + parentKey: 'post_id' + } + } + : { + sqlJoin: (postTable, tagTable) => + `${postTable}.${q('id', DB)} = ${tagTable}.${q( + 'post_id', + DB + )}` + }) + } + } + }, + tagsConnection: { + type: TagConnection, + resolve: source => source.tags.map(tag => tag.tag), + extensions: { + joinMonster: { + orderBy: 'tag_order', + ...(STRATEGY === 'batch' + ? { + sqlBatch: { + thisKey: 'id', + parentKey: 'post_id' + } + } + : { + sqlJoin: (postTable, tagTable) => + `${postTable}.${q('id', DB)} = ${tagTable}.${q( + 'post_id', + DB + )}` + }) + } + } + }, + firstTag: { + type: Tag, + resolve: source => { + return source.tags[0].tag + }, + extensions: { + joinMonster: { + limit: 1, + orderBy: 'tag_order', + ...(STRATEGY === 'batch' + ? { + sqlBatch: { + thisKey: 'id', + parentKey: 'post_id' + } + } + : { + sqlJoin: (postTable, tagTable) => + `${postTable}.${q('id', DB)} = ${tagTable}.${q( + 'post_id', + DB + )}` + }) + } + } } }) }) diff --git a/test-api/schema-paginated/QueryRoot.js b/test-api/schema-paginated/QueryRoot.js index 40821c28..208847f6 100644 --- a/test-api/schema-paginated/QueryRoot.js +++ b/test-api/schema-paginated/QueryRoot.js @@ -13,6 +13,7 @@ import { import knex from './database' import { User, UserConnection } from './User' +import { Post } from './Post' import Sponsor from './Sponsor' import { nodeField } from './Node' import ContextPost from './ContextPost' @@ -138,6 +139,31 @@ export default new GraphQLObjectType({ ) } }, + post: { + type: Post, + args: { + id: { + description: 'The posts ID number', + type: GraphQLInt + } + }, + extensions: { + joinMonster: { + where: (postsTable, args) => { + // eslint-disable-line no-unused-vars + if (args.id) return `${postsTable}.${q('id', DB)} = ${args.id}` + } + } + }, + resolve: (parent, args, context, resolveInfo) => { + return joinMonster( + resolveInfo, + context, + sql => dbCall(sql, knex, context), + options + ) + } + }, sponsors: { type: new GraphQLList(Sponsor), resolve: (parent, args, context, resolveInfo) => { diff --git a/test-api/schema-paginated/Tag.js b/test-api/schema-paginated/Tag.js new file mode 100644 index 00000000..caa77d1f --- /dev/null +++ b/test-api/schema-paginated/Tag.js @@ -0,0 +1,36 @@ +import { GraphQLInt, GraphQLScalarType, Kind } from 'graphql' +import { q } from '../shared' +import { connectionDefinitions } from 'graphql-relay' +const { PAGINATE, DB } = process.env + +// This is to test Scalars being extended with join-monster properties and is not a great way to actually model tags +export const Tag = new GraphQLScalarType({ + name: 'Tag', + description: 'Custom scalar representing a tag', + extensions: { + joinMonster: { + sqlTable: `(SELECT * FROM ${q('tags', DB)})`, + uniqueKey: 'id', + alwaysFetch: ['id', 'tag', 'tag_order'] + } + }, + parseValue: String, + serialize: String, + parseLiteral(ast) { + if (ast.kind === Kind.STRING) { + return ast.value + } + return null + } +}) + +const connectionConfig = { nodeType: Tag } +if (PAGINATE === 'offset') { + connectionConfig.connectionFields = { + total: { type: GraphQLInt } + } +} +const { connectionType: TagConnection } = connectionDefinitions( + connectionConfig +) +export { TagConnection } diff --git a/test/pagination/scalars.js b/test/pagination/scalars.js new file mode 100644 index 00000000..d56658ad --- /dev/null +++ b/test/pagination/scalars.js @@ -0,0 +1,56 @@ +import test from 'ava' +import { graphql } from 'graphql' +import schemaRelay from '../../test-api/schema-paginated/index' +import { partial } from 'lodash' +import { errCheck } from '../_util' + +const run = partial(graphql, schemaRelay) + +test('it should get a scalar list and resolve it', async t => { + const { data, errors } = await run(` + query { + post(id: 1) { + id + tags + } + } + `) + + errCheck(t, errors) + t.deepEqual(['foo', 'bar', 'baz'], data.post.tags) +}) + +test('it should get a connection of scalars and resolve it', async t => { + const { data, errors } = await run(` + query { + post(id: 1) { + id + tagsConnection { + edges { + node + } + } + } + } + `) + + errCheck(t, errors) + t.deepEqual( + ['foo', 'bar', 'baz'], + data.post.tags.edges.map(edge => edge.node) + ) +}) + +test('it should get a scalar via a join or batch and resolve it', async t => { + const { data, errors } = await run(` + query { + post(id: 1) { + id + firstTag + } + } + `) + + errCheck(t, errors) + t.deepEqual('foo', data.post.firstTag) +})