graphile-build-pg
Version:
Build a GraphQL schema by reflection over a PostgreSQL schema. Easy to customize since it's built with plugins on graphile-build
654 lines (640 loc) • 24.6 kB
Flow
// @flow
import type { Plugin } from "graphile-build";
const base64 = str => Buffer.from(String(str)).toString("base64");
const hasNonNullKey = row => {
if (
Array.isArray(row.__identifiers) &&
row.__identifiers.every(i => i != null)
) {
return true;
}
for (const k in row) {
if (Object.prototype.hasOwnProperty.call(row, k)) {
if ((k[0] !== "_" || k[1] !== "_") && row[k] !== null) {
return true;
}
}
}
return false;
};
export default (function PgTablesPlugin(
builder,
{ pgForbidSetofFunctionsToReturnNull = false, subscriptions = false }
) {
const handleNullRow = pgForbidSetofFunctionsToReturnNull
? (row, _identifiers) => row
: (row, identifiers) => {
if ((identifiers && hasNonNullKey(identifiers)) || hasNonNullKey(row)) {
return row;
} else {
return null;
}
};
builder.hook(
"init",
(_, build) => {
const {
getNodeIdForTypeAndIdentifiers,
nodeIdFieldName,
newWithHooks,
getSafeAliasFromResolveInfo,
pgSql: sql,
pgIntrospectionResultsByKind: introspectionResultsByKind,
getTypeByName,
pgGetGqlTypeByTypeIdAndModifier,
pgGetGqlInputTypeByTypeIdAndModifier,
pgRegisterGqlTypeByTypeId,
pgRegisterGqlInputTypeByTypeId,
pg2GqlMapper,
gql2pg,
graphql: {
GraphQLObjectType,
GraphQLNonNull,
GraphQLID,
GraphQLList,
GraphQLInputObjectType,
},
inflection,
describePgEntity,
sqlCommentByAddingTags,
pgField,
} = build;
const nullableIf = (condition, Type) =>
condition ? Type : new GraphQLNonNull(Type);
const Cursor = getTypeByName("Cursor");
introspectionResultsByKind.class.forEach(table => {
if (table.tags.enum) {
return;
}
const tablePgType = table.type;
if (!tablePgType) {
throw new Error("Could not determine the type for this table");
}
const arrayTablePgType = tablePgType.arrayType;
const primaryKeyConstraint = table.primaryKeyConstraint;
const primaryKeys =
primaryKeyConstraint && primaryKeyConstraint.keyAttributes;
const attributes = table.attributes;
const tableTypeName = inflection.tableType(table);
const shouldHaveNodeId: boolean =
nodeIdFieldName &&
table.isSelectable &&
table.namespace &&
primaryKeys &&
primaryKeys.length
? true
: false;
let TableType;
let TablePatchType;
let TableBaseInputType;
pgRegisterGqlTypeByTypeId(
tablePgType.id,
cb => {
if (TableType) {
return TableType;
}
if (pg2GqlMapper[tablePgType.id]) {
// Already handled
throw new Error(
`Register was called but there's already a mapper in place for '${tablePgType.id}'!`
);
}
TableType = newWithHooks(
GraphQLObjectType,
{
description: table.description || tablePgType.description,
name: tableTypeName,
interfaces: () => {
if (shouldHaveNodeId) {
return [getTypeByName(inflection.builtin("Node"))];
} else {
return [];
}
},
fields: ({ addDataGeneratorForField, Self }) => {
const fields = {};
if (shouldHaveNodeId) {
// Enable nodeId interface
addDataGeneratorForField(nodeIdFieldName, () => {
return {
pgQuery: queryBuilder => {
queryBuilder.selectIdentifiers(table);
},
};
});
fields[nodeIdFieldName] = {
description: build.wrapDescription(
"A globally unique identifier. Can be used in various places throughout the system to identify this single value.",
"field"
),
type: new GraphQLNonNull(GraphQLID),
resolve(data) {
const identifiers = data.__identifiers;
if (!identifiers) {
return null;
}
/*
* For bigint we want NodeIDs to be the same as int up
* to the limits of int, and only to be strings after
* that point.
*/
const finalIdentifiers = identifiers.map(
(identifier, idx) => {
const key = primaryKeys[idx];
const type = key.type.domainBaseType || key.type;
if (type.id === "20" /* bigint */) {
/*
* When migrating from 'int' to 'bigint' we want
* to maintain nodeIDs in the safe range before
* moving to strings for larger numbers. Since we
* can represent ints up to MAX_SAFE_INTEGER
* (2^53 - 1) fine, we're using that as the
* boundary.
*/
const int = parseInt(identifier, 10);
if (
int >= -Number.MAX_SAFE_INTEGER &&
int <= Number.MAX_SAFE_INTEGER
) {
return int;
}
}
return identifier;
}
);
return getNodeIdForTypeAndIdentifiers(
Self,
...finalIdentifiers
);
},
};
}
return fields;
},
},
{
__origin: `Adding table type for ${describePgEntity(
table
)}. You can rename the table's GraphQL type via a 'Smart Comment':\n\n ${sqlCommentByAddingTags(
table,
{
name: "newNameHere",
}
)}`,
pgIntrospection: table,
isPgRowType: table.isSelectable,
isPgCompoundType: !table.isSelectable, // TODO:v5: remove - typo
isPgCompositeType: !table.isSelectable,
}
);
cb(TableType);
const pgCreateInputFields = {};
const pgPatchInputFields = {};
const pgBaseInputFields = {};
newWithHooks(
GraphQLInputObjectType,
{
description: build.wrapDescription(
`An input for mutations affecting \`${tableTypeName}\``,
"type"
),
name: inflection.inputType(TableType),
},
{
__origin: `Adding table input type for ${describePgEntity(
table
)}. You can rename the table's GraphQL type via a 'Smart Comment':\n\n ${sqlCommentByAddingTags(
table,
{
name: "newNameHere",
}
)}`,
pgIntrospection: table,
isInputType: true,
isPgRowType: table.isSelectable,
isPgCompoundType: !table.isSelectable,
pgAddSubfield(fieldName, attrName, pgType, spec, typeModifier) {
pgCreateInputFields[fieldName] = {
name: attrName,
type: pgType,
typeModifier,
};
return spec;
},
},
true // If no fields, skip type automatically
);
if (table.isSelectable) {
// XXX: these don't belong here; but we have to keep them here
// because third-party code depends on `getTypeByName` to find
// them; so we have to register them ahead of time. A better
// approach is to use the modifier to specify the type you need,
// 'patch' or 'base', so they can be registered just in time.
TablePatchType = newWithHooks(
GraphQLInputObjectType,
{
description: build.wrapDescription(
`Represents an update to a \`${tableTypeName}\`. Fields that are set will be updated.`,
"type"
),
name: inflection.patchType(TableType),
},
{
__origin: `Adding table patch type for ${describePgEntity(
table
)}. You can rename the table's GraphQL type via a 'Smart Comment':\n\n ${sqlCommentByAddingTags(
table,
{
name: "newNameHere",
}
)}`,
pgIntrospection: table,
isPgRowType: table.isSelectable,
isPgCompoundType: !table.isSelectable,
isPgPatch: true,
pgAddSubfield(
fieldName,
attrName,
pgType,
spec,
typeModifier
) {
pgPatchInputFields[fieldName] = {
name: attrName,
type: pgType,
typeModifier,
};
return spec;
},
},
true // Safe to skip this if no fields support updating
);
TableBaseInputType = newWithHooks(
GraphQLInputObjectType,
{
description: build.wrapDescription(
`An input representation of \`${tableTypeName}\` with nullable fields.`,
"type"
),
name: inflection.baseInputType(TableType),
},
{
__origin: `Adding table base input type for ${describePgEntity(
table
)}. You can rename the table's GraphQL type via a 'Smart Comment':\n\n ${sqlCommentByAddingTags(
table,
{
name: "newNameHere",
}
)}`,
pgIntrospection: table,
isPgRowType: table.isSelectable,
isPgCompoundType: !table.isSelectable,
isPgBaseInput: true,
pgAddSubfield(
fieldName,
attrName,
pgType,
spec,
typeModifier
) {
pgBaseInputFields[fieldName] = {
name: attrName,
type: pgType,
typeModifier,
};
return spec;
},
}
);
}
pg2GqlMapper[tablePgType.id] = {
map: _ => _,
unmap: (obj, modifier) => {
let fieldLookup;
if (modifier === "patch") {
fieldLookup = pgPatchInputFields;
} else if (modifier === "base") {
fieldLookup = pgBaseInputFields;
} else {
fieldLookup = pgCreateInputFields;
}
const attr2sql = attr => {
// TODO: this should use `fieldInput[*].name` to find the attribute
const fieldName = inflection.column(attr);
const inputField = fieldLookup[fieldName];
const v = obj[fieldName];
if (inputField && v != null) {
const { type, typeModifier } = inputField;
return sql.fragment`${gql2pg(v, type, typeModifier)}::${
type.isFake
? sql.identifier("unknown")
: sql.identifier(type.namespaceName, type.name)
}`;
} else {
return sql.null; // TODO: return default instead.
}
};
return sql.fragment`row(${sql.join(
attributes.map(attr2sql),
","
)})::${
tablePgType.isFake
? sql.identifier("unknown")
: sql.identifier(
tablePgType.namespaceName,
tablePgType.name
)
}`;
},
};
const EdgeType = newWithHooks(
GraphQLObjectType,
{
description: build.wrapDescription(
`A \`${tableTypeName}\` edge in the connection.`,
"type"
),
name: inflection.edge(TableType.name),
fields: ({ fieldWithHooks }) => {
return {
cursor: fieldWithHooks(
"cursor",
({ addDataGenerator }) => {
addDataGenerator(() => ({
usesCursor: [true],
pgQuery: queryBuilder => {
if (primaryKeys) {
queryBuilder.selectIdentifiers(table);
}
},
}));
return {
description: build.wrapDescription(
"A cursor for use in pagination.",
"field"
),
type: Cursor,
resolve(data) {
return (
data.__cursor &&
base64(JSON.stringify(data.__cursor))
);
},
};
},
{
isCursorField: true,
}
),
node: pgField(
build,
fieldWithHooks,
"node",
{
description: build.wrapDescription(
`The \`${tableTypeName}\` at the end of the edge.`,
"field"
),
type: nullableIf(
!pgForbidSetofFunctionsToReturnNull,
TableType
),
resolve(data, _args, resolveContext, resolveInfo) {
const safeAlias =
getSafeAliasFromResolveInfo(resolveInfo);
const record = handleNullRow(
data[safeAlias],
data.__identifiers
);
const liveRecord =
resolveInfo.rootValue &&
resolveInfo.rootValue.liveRecord;
if (
record &&
primaryKeys &&
liveRecord &&
data.__identifiers
) {
liveRecord("pg", table, data.__identifiers);
}
return record;
},
},
{},
false,
{
withQueryBuilder: queryBuilder => {
if (subscriptions) {
queryBuilder.selectIdentifiers(table);
}
},
}
),
};
},
},
{
__origin: `Adding table edge type for ${describePgEntity(
table
)}. You can rename the table's GraphQL type via a 'Smart Comment':\n\n ${sqlCommentByAddingTags(
table,
{
name: "newNameHere",
}
)}`,
isEdgeType: true,
isPgRowEdgeType: true,
nodeType: TableType,
pgIntrospection: table,
}
);
const PageInfo = getTypeByName(inflection.builtin("PageInfo"));
/*const ConnectionType = */
newWithHooks(
GraphQLObjectType,
{
description: build.wrapDescription(
`A connection to a list of \`${tableTypeName}\` values.`,
"type"
),
name: inflection.connection(TableType.name),
fields: ({ recurseDataGeneratorsForField, fieldWithHooks }) => {
recurseDataGeneratorsForField("pageInfo", true);
return {
nodes: pgField(
build,
fieldWithHooks,
"nodes",
{
description: build.wrapDescription(
`A list of \`${tableTypeName}\` objects.`,
"field"
),
type: new GraphQLNonNull(
new GraphQLList(
nullableIf(
!pgForbidSetofFunctionsToReturnNull,
TableType
)
)
),
resolve(data, _args, resolveContext, resolveInfo) {
const safeAlias =
getSafeAliasFromResolveInfo(resolveInfo);
const liveRecord =
resolveInfo.rootValue &&
resolveInfo.rootValue.liveRecord;
return data.data.map(entry => {
const record = handleNullRow(
entry[safeAlias],
entry[safeAlias].__identifiers
);
if (
record &&
liveRecord &&
primaryKeys &&
entry[safeAlias].__identifiers
) {
liveRecord(
"pg",
table,
entry[safeAlias].__identifiers
);
}
return record;
});
},
},
{},
false,
{
withQueryBuilder: queryBuilder => {
if (subscriptions) {
queryBuilder.selectIdentifiers(table);
}
},
}
),
edges: pgField(
build,
fieldWithHooks,
"edges",
{
description: build.wrapDescription(
`A list of edges which contains the \`${tableTypeName}\` and cursor to aid in pagination.`,
"field"
),
type: new GraphQLNonNull(
new GraphQLList(new GraphQLNonNull(EdgeType))
),
resolve(data, _args, _context, resolveInfo) {
const safeAlias =
getSafeAliasFromResolveInfo(resolveInfo);
return data.data.map(entry => ({
...entry,
...entry[safeAlias],
}));
},
},
{},
false,
{
hoistCursor: true,
}
),
pageInfo: PageInfo && {
description: build.wrapDescription(
"Information to aid in pagination.",
"field"
),
type: new GraphQLNonNull(PageInfo),
resolve(data) {
return data;
},
},
};
},
},
{
__origin: `Adding table connection type for ${describePgEntity(
table
)}. You can rename the table's GraphQL type via a 'Smart Comment':\n\n ${sqlCommentByAddingTags(
table,
{
name: "newNameHere",
}
)}`,
isConnectionType: true,
isPgRowConnectionType: true,
edgeType: EdgeType,
nodeType: TableType,
pgIntrospection: table,
}
);
},
true
);
pgRegisterGqlInputTypeByTypeId(
tablePgType.id,
(_set, modifier) => {
// This must come first, it triggers creation of all the types
const TableType = pgGetGqlTypeByTypeIdAndModifier(
tablePgType.id,
null
);
// This must come after the pgGetGqlTypeByTypeIdAndModifier call
if (modifier === "patch") {
// TODO: v5: move the definition from above down here
return TablePatchType;
}
if (modifier === "base") {
// TODO: v5: move the definition from above down here
return TableBaseInputType;
}
if (TableType) {
return getTypeByName(inflection.inputType(TableType));
}
return null;
},
true
);
if (arrayTablePgType) {
// Note: these do not return
//
// `new GraphQLList(new GraphQLNonNull(...))`
//
// because it's possible to return null entries from postgresql
// functions. We should probably add a flag to instead export
// the non-null version as that's more typical.
pgRegisterGqlTypeByTypeId(
arrayTablePgType.id,
() => {
const TableType = pgGetGqlTypeByTypeIdAndModifier(
tablePgType.id,
null
);
return new GraphQLList(TableType);
},
true
);
pgRegisterGqlInputTypeByTypeId(
arrayTablePgType.id,
(_set, modifier) => {
const RelevantTableInputType =
pgGetGqlInputTypeByTypeIdAndModifier(tablePgType.id, modifier);
if (RelevantTableInputType) {
return new GraphQLList(RelevantTableInputType);
}
},
true
);
}
});
return _;
},
["PgTables"],
[],
["PgTypes"]
);
}: Plugin);