Skip to content

Commit

Permalink
feat(graphql): add fields based on unique constraints
Browse files Browse the repository at this point in the history
Closes #67
  • Loading branch information
calebmer committed Jun 19, 2016
1 parent 091b111 commit 7be8164
Show file tree
Hide file tree
Showing 13 changed files with 221 additions and 93 deletions.
19 changes: 10 additions & 9 deletions examples/kitchen-sink/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ set search_path = kitchen_sink;

create table thing (
id serial not null primary key,
note text not null
note text not null,
lucky_number int unique
);

create table relation (
Expand Down Expand Up @@ -130,14 +131,14 @@ $$ language plpgsql
strict
set search_path from current;

insert into thing (note) values
('hello'),
('world'),
('foo'),
('bar'),
('baz'),
('bux'),
('qux');
insert into thing (note, lucky_number) values
('hello', 42),
('world', 420),
('foo', 12),
('bar', 7),
('baz', 98),
('bux', 66),
('qux', 0);

insert into relation (a_thing_id, b_thing_id) values
(1, 2),
Expand Down
48 changes: 48 additions & 0 deletions src/graphql/query/createAllNodeQueryField.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { memoize } from 'lodash'
import { GraphQLNonNull, GraphQLID } from 'graphql'
import { $$isViewer } from '../../symbols.js'
import { NodeType, fromID } from '../types.js'
import resolveTableSingle from '../resolveTableSingle.js'

const createAllNodeQueryField = schema => {
const getTable = memoize(tableName => schema.catalog.getTable(schema.name, tableName))
return {
type: NodeType,
description: 'Fetches an object given its globally unique `ID`.',

args: {
id: {
type: new GraphQLNonNull(GraphQLID),
description: 'The `ID` of the node.',
},
},

resolve: (source, args, context) => {
const { id } = args

// If the id is just `viewer`, we are trying to refetch the viewer node.
if (id === 'viewer')
return { [$$isViewer]: true }

const { tableName, values } = fromID(id)
const table = getTable(tableName)

if (!table)
throw new Error(`No table '${tableName}' in schema '${schema.name}'.`)

return getResolveNode(table)({ values }, {}, context)
},
}
}

export default createAllNodeQueryField

// This function will be called for every resolution, therefore it is (and
// must be) memoized.
//
// Because this is memoized, fetching primary keys is ok here.
const getResolveNode = memoize(table => resolveTableSingle(
table,
table.getPrimaryKeys(),
({ values }) => values,
))
60 changes: 27 additions & 33 deletions src/graphql/query/createNodeQueryField.js
Original file line number Diff line number Diff line change
@@ -1,48 +1,42 @@
import { memoize } from 'lodash'
import { GraphQLNonNull, GraphQLID } from 'graphql'
import { $$isViewer } from '../../symbols.js'
import { NodeType, fromID } from '../types.js'
import { fromID } from '../types.js'
import createTableType from '../createTableType.js'
import resolveTableSingle from '../resolveTableSingle.js'

const createNodeQueryField = schema => {
const getTable = memoize(tableName => schema.catalog.getTable(schema.name, tableName))
/**
* Creates an object field for selecting a single row of a table.
*
* @param {Table} table
* @returns {GraphQLFieldConfig}
*/
const createNodeQueryField = table => {
const primaryKeys = table.getPrimaryKeys()

// Can’t query a single node of a table if it does not have a primary key.
if (primaryKeys.length === 0)
return null

return {
type: NodeType,
description: 'Fetches an object given its globally unique `ID`.',
type: createTableType(table),
description: `Queries a single ${table.getMarkdownTypeName()} using its primary keys.`,

args: {
id: {
type: new GraphQLNonNull(GraphQLID),
description: 'The `ID` of the node.',
description: `The \`ID\` of the ${table.getMarkdownTypeName()} node.`,
},
},

resolve: (source, args, context) => {
const { id } = args

// If the id is just `viewer`, we are trying to refetch the viewer node.
if (id === 'viewer')
return { [$$isViewer]: true }

const { tableName, values } = fromID(id)
const table = getTable(tableName)

if (!table)
throw new Error(`No table '${tableName}' in schema '${schema.name}'.`)

return getResolveNode(table)({ values }, {}, context)
},
resolve: resolveTableSingle(
table,
primaryKeys,
(source, { id }) => {
const { tableName, values } = fromID(id)
if (tableName !== table.name) return null
return values
}
),
}
}

export default createNodeQueryField

// This function will be called for every resolution, therefore it is (and
// must be) memoized.
//
// Because this is memoized, fetching primary keys is ok here.
const getResolveNode = memoize(table => resolveTableSingle(
table,
table.getPrimaryKeys(),
({ values }) => values,
))
4 changes: 2 additions & 2 deletions src/graphql/query/createQueryFields.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { memoize, fromPairs, ary, assign } from 'lodash'
import createNodeQueryField from './createNodeQueryField.js'
import createAllNodeQueryField from './createAllNodeQueryField.js'
import createTableQueryFields from './createTableQueryFields.js'
import createProcedureQueryField from './createProcedureQueryField.js'

const createQueryFields = memoize(schema => ({
// Add the node query field.
node: createNodeQueryField(schema),
node: createAllNodeQueryField(schema),
// Add fields for procedures.
...fromPairs(
schema
Expand Down
56 changes: 21 additions & 35 deletions src/graphql/query/createSingleQueryField.js
Original file line number Diff line number Diff line change
@@ -1,42 +1,28 @@
import { GraphQLNonNull, GraphQLID } from 'graphql'
import { fromID } from '../types.js'
import { fromPairs } from 'lodash'
import { GraphQLNonNull, getNullableType } from 'graphql'
import createTableType from '../createTableType.js'
import getColumnType from '../getColumnType.js'
import resolveTableSingle from '../resolveTableSingle.js'

/**
* Creates an object field for selecting a single row of a table.
*
* @param {Table} table
* @returns {GraphQLFieldConfig}
*/
const createSingleQueryField = table => {
const primaryKeys = table.getPrimaryKeys()
const createSingleQueryField = (table, columns) => ({
type: createTableType(table),
description:
`Queries a single ${table.getMarkdownTypeName()} using a uniqueness ` +
`constraint with field${columns.length === 1 ? '' : 's'} ` +
`${columns.map(column => column.getMarkdownFieldName()).join(', ')}.`,

// Can’t query a single node of a table if it does not have a primary key.
if (primaryKeys.length === 0)
return null
args: fromPairs(
columns.map(column => [column.getFieldName(), {
type: new GraphQLNonNull(getNullableType(getColumnType(column))),
description: `The exact value of the ${column.getMarkdownFieldName()} field to match.`,
}])
),

return {
type: createTableType(table),
description: `Queries a single ${table.getMarkdownTypeName()} using its primary keys.`,

args: {
id: {
type: new GraphQLNonNull(GraphQLID),
description: `The \`ID\` of the ${table.getMarkdownTypeName()} node.`,
},
},

resolve: resolveTableSingle(
table,
primaryKeys,
(source, { id }) => {
const { tableName, values } = fromID(id)
if (tableName !== table.name) return null
return values
}
),
}
}
resolve: resolveTableSingle(
table,
columns,
(source, args) => columns.map(column => args[column.getFieldName()])
),
})

export default createSingleQueryField
21 changes: 14 additions & 7 deletions src/graphql/query/createTableQueryFields.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import createSingleQueryField from './createSingleQueryField.js'
import { upperFirst } from 'lodash'
import createNodeQueryField from './createNodeQueryField.js'
import createNodesQueryField from './createNodesQueryField.js'
import createSingleQueryField from './createSingleQueryField.js'

/**
* Creates the fields for a single table in the database. To see the type these
Expand All @@ -12,13 +14,18 @@ import createNodesQueryField from './createNodesQueryField.js'
const createTableQueryFields = table => {
const fields = {}

const singleField = createSingleQueryField(table)
const listField = createNodesQueryField(table)
// `createSingleQueryField` may return `null`, so we must check for that.
const nodeField = createNodeQueryField(table)
if (nodeField) fields[table.getFieldName()] = nodeField

for (const columns of table.getUniqueConstraints()) {
const fieldName =
`${table.getFieldName()}By${columns.map(column => upperFirst(column.getFieldName())).join('And')}`

fields[fieldName] = createSingleQueryField(table, columns)
}

// `createSingleQueryField` and others may return `null`, so we must check
// for that.
if (singleField) fields[table.getFieldName()] = singleField
if (listField) fields[`${table.getFieldName()}Nodes`] = listField
fields[`${table.getFieldName()}Nodes`] = createNodesQueryField(table)

return fields
}
Expand Down
2 changes: 1 addition & 1 deletion src/graphql/resolveTableSingle.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const resolveTableSingle = (table, columns, getColumnValues) => {
// run this query is with the `= any (…)` field. This feature is PostgreSQL
// specific and can’t be done with `sql`.
const query = {
name: `${table.schema.name}_${table.name}_single`,
name: `${table.schema.name}_${table.name}_single_${columns.map(column => column.name).join('_')}`,
text: `select * from "${table.schema.name}"."${table.name}" where ${primaryKeyMatch} = any ($1)`,
}

Expand Down
4 changes: 4 additions & 0 deletions src/postgres/catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ export class Schema {
* @member {ForeignKey[]} reverseForeignKeys
*/
export class Table {
_uniqueConstraints = []

constructor ({ schema, name, description, isInsertable, isUpdatable, isDeletable }) {
this.schema = schema
this.name = name
Expand Down Expand Up @@ -162,6 +164,8 @@ export class Table {
})
})

getUniqueConstraints = () => this._uniqueConstraints

getIdentifier = once(() => {
return `"${this.schema.name}"."${this.name}"`
})
Expand Down
23 changes: 23 additions & 0 deletions src/postgres/getCatalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const getCatalog = withClient(async client => {

await Promise.all([
addForeignKeys(client, catalog),
addUniqueConstraints(client, catalog),
addProcedures(client, catalog),
])

Expand Down Expand Up @@ -321,6 +322,28 @@ const addForeignKeys = (client, catalog) =>
})
.each(foreignKey => catalog.addForeignKey(foreignKey))

const addUniqueConstraints = (client, catalog) =>
client.queryAsync(`
select
n.nspname as "schemaName",
t.relname as "tableName",
c.conkey as "columnNums"
from
pg_catalog.pg_constraint as c
left join pg_catalog.pg_class as t on t.oid = c.conrelid
left join pg_catalog.pg_namespace as n on n.oid = t.relnamespace
where
n.nspname not in ('pg_catalog', 'information_schema') and
(c.contype = 'u' or c.contype = 'p');
`)
.then(({ rows }) => rows)
.each(({ schemaName, tableName, columnNums }) => {
const table = catalog.getTable(schemaName, tableName)
const allColumns = table.getColumns()
const columns = columnNums.map(colNum => allColumns.find(({ num }) => num === colNum))
table._uniqueConstraints.push(columns)
})

const addProcedures = (client, catalog) =>
client.queryAsync(`
select
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@ import expect from 'expect'
import { keys } from 'lodash'
import { GraphQLObjectType, GraphQLNonNull, GraphQLID } from 'graphql'
import { TestTable, TestColumn } from '../../helpers.js'
import createSingleQueryField from '#/graphql/query/createSingleQueryField.js'
import createNodeQueryField from '#/graphql/query/createNodeQueryField.js'

describe('createSingleQueryField', () => {
describe('createNodeQueryField', () => {
it('is an object type', async () => {
const person = createSingleQueryField(new TestTable({ name: 'person' }))
const person = createNodeQueryField(new TestTable({ name: 'person' }))
expect(person.type).toBeA(GraphQLObjectType)
expect(person.type.name).toEqual('Person')
})

it('has args for only the node `id`', () => {
const person = createSingleQueryField(
const person = createNodeQueryField(
new TestTable({
name: 'person',
columns: [
Expand All @@ -23,7 +23,7 @@ describe('createSingleQueryField', () => {
})
)

const compoundKey = createSingleQueryField(
const compoundKey = createNodeQueryField(
new TestTable({
name: 'compound_key',
columns: [
Expand All @@ -47,6 +47,6 @@ describe('createSingleQueryField', () => {
it('it will return null for tables without primary keys', () => {
const table = new TestTable()
table.schema.catalog.addColumn(new TestColumn({ table }))
expect(createSingleQueryField(table)).toNotExist()
expect(createNodeQueryField(table)).toNotExist()
})
})
17 changes: 17 additions & 0 deletions tests/integration/fixtures/unique-constraints.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
query UniqueConstraints {
thingByRowId(rowId: 1) { ...thing }
relationByAThingIdAndBThingId(aThingId: 1, bThingId: 2) {
id
aThingId
bThingId
thingByAThingId { ...thing }
thingByBThingId { ...thing }
}
thingByLuckyNumber(luckyNumber: 98) { ...thing }
}

fragment thing on Thing {
id
rowId
note
}
Loading

0 comments on commit 7be8164

Please sign in to comment.