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
1,380 lines (1,288 loc) • 42.2 kB
Flow
// @flow
import type { Plugin } from "graphile-build";
import type { Client } from "pg";
import withPgClient, {
getPgClientAndReleaserFromConfig,
} from "../withPgClient";
import { parseTags } from "../utils";
import { readFile as rawReadFile } from "fs";
import debugFactory from "debug";
import chalk from "chalk";
import throttle from "lodash/throttle";
import flatMap from "lodash/flatMap";
import { makeIntrospectionQuery } from "./introspectionQuery";
import * as pgSql from "pg-sql2";
import { version } from "../../package.json";
import queryFromResolveDataFactory from "../queryFromResolveDataFactory";
const debug = debugFactory("graphile-build-pg");
const WATCH_FIXTURES_PATH = `${__dirname}/../../res/watch-fixtures.sql`;
// Ref: https://github.com/graphile/postgraphile/tree/master/src/postgres/introspection/object
export type PgNamespace = {
kind: "namespace",
id: string,
name: string,
comment: ?string,
description: ?string,
tags: { [string]: string },
};
export type PgProc = {
kind: "procedure",
id: string,
name: string,
comment: ?string,
description: ?string,
namespaceId: string,
namespaceName: string,
isStrict: boolean,
returnsSet: boolean,
isStable: boolean,
returnTypeId: string,
argTypeIds: Array<string>,
argNames: Array<string>,
argModes: Array<"i" | "o" | "b" | "v" | "t">,
inputArgsCount: number,
argDefaultsNum: number,
namespace: PgNamespace,
tags: { [string]: string },
cost: number,
aclExecutable: boolean,
language: string,
};
export type PgClass = {
kind: "class",
id: string,
name: string,
comment: ?string,
description: ?string,
classKind: string,
namespaceId: string,
namespaceName: string,
typeId: string,
isSelectable: boolean,
isInsertable: boolean,
isUpdatable: boolean,
isDeletable: boolean,
isExtensionConfigurationTable: boolean,
namespace: PgNamespace,
type: PgType,
tags: { [string]: string },
attributes: Array<PgAttribute>,
constraints: Array<PgConstraint>,
foreignConstraints: Array<PgConstraint>,
primaryKeyConstraint: ?PgConstraint,
aclSelectable: boolean,
aclInsertable: boolean,
aclUpdatable: boolean,
aclDeletable: boolean,
canUseAsterisk: boolean,
// eslint-disable-next-line flowtype/no-weak-types
_internalEnumData?: any[], // This is Graphile internal, do not use this.
};
export type PgType = {
kind: "type",
id: string,
name: string,
comment: ?string,
description: ?string,
namespaceId: string,
namespaceName: string,
type: string,
category: string,
domainIsNotNull: boolean,
arrayItemTypeId: ?string,
arrayItemType: ?PgType,
arrayType: ?PgType,
typeLength: ?number,
isPgArray: boolean,
classId: ?string,
class: ?PgClass,
domainBaseTypeId: ?string,
domainBaseType: ?PgType,
domainTypeModifier: ?number,
domainHasDefault: boolean,
enumVariants: ?(string[]),
enumDescriptions: ?(string[]),
rangeSubTypeId: ?string,
tags: { [string]: string },
isFake: ?boolean,
};
export type PgAttribute = {
kind: "attribute",
classId: string,
num: number,
name: string,
comment: ?string,
description: ?string,
typeId: string,
typeModifier: number,
isNotNull: boolean,
hasDefault: boolean,
identity: "" | "a" | "d",
class: PgClass,
type: PgType,
namespace: PgNamespace,
tags: { [string]: string },
aclSelectable: boolean,
aclInsertable: boolean,
aclUpdatable: boolean,
isIndexed: ?boolean,
isUnique: ?boolean,
columnLevelSelectGrant: boolean,
};
export type PgConstraint = {
kind: "constraint",
id: string,
name: string,
type: string,
classId: string,
class: PgClass,
foreignClassId: ?string,
foreignClass: ?PgClass,
comment: ?string,
description: ?string,
keyAttributeNums: Array<number>,
keyAttributes: Array<PgAttribute>,
foreignKeyAttributeNums: Array<number>,
foreignKeyAttributes: Array<PgAttribute>,
namespace: PgNamespace,
isIndexed: ?boolean,
tags: { [string]: string },
};
export type PgExtension = {
kind: "extension",
id: string,
name: string,
namespaceId: string,
namespaceName: string,
relocatable: boolean,
version: string,
configurationClassIds?: Array<string>,
comment: ?string,
description: ?string,
tags: { [string]: string },
};
export type PgIndex = {
kind: "index",
id: string,
name: string,
namespaceName: string,
classId: string,
numberOfAttributes: number,
indexType: string,
isUnique: boolean,
isPrimary: boolean,
/*
Though these exist, we don't want to officially
support them yet.
isImmediate: boolean,
isReplicaIdentity: boolean,
isValid: boolean,
*/
isPartial: boolean,
attributeNums: Array<number>,
attributePropertiesAsc: ?Array<boolean>,
attributePropertiesNullsFirst: ?Array<boolean>,
description: ?string,
tags: { [string]: string },
};
export type PgEntity =
| PgNamespace
| PgProc
| PgClass
| PgType
| PgAttribute
| PgConstraint
| PgExtension
| PgIndex;
const fakeEnumIdentifier = (
namespaceName: string,
name: string,
identifierExtra = "",
list = false
) => {
const baseEnumIdentifier = `FAKE_ENUM_${namespaceName}_${name}${identifierExtra}`;
if (list) {
return `${baseEnumIdentifier}_list`;
} else {
return baseEnumIdentifier;
}
};
export type PgIntrospectionResultsByKind = {
__pgVersion: number,
attribute: PgAttribute[],
attributeByClassIdAndNum: {
[classId: string]: { [num: string]: PgAttribute },
},
class: PgClass[],
classById: { [classId: string]: PgClass },
constraint: PgConstraint[],
extension: PgExtension[],
extensionById: { [extId: string]: PgExtension },
index: PgIndex[],
namespace: PgNamespace[],
namespaceById: { [namespaceId: string]: PgNamespace },
procedure: PgProc[],
type: PgType[],
typeById: { [typeId: string]: PgType },
};
function readFile(filename, encoding) {
return new Promise((resolve, reject) => {
rawReadFile(filename, encoding, (err, res) => {
if (err) reject(err);
else resolve(res);
});
});
}
const removeQuotes = str => {
const trimmed = str.trim();
if (trimmed[0] === '"') {
if (trimmed[trimmed.length - 1] !== '"') {
throw new Error(
`We failed to parse a quoted identifier '${str}'. Please avoid putting quotes or commas in smart comment identifiers (or file a PR to fix the parser).`
);
}
return trimmed.slice(1, -1);
} else {
// PostgreSQL lower-cases unquoted columns, so we should too.
return trimmed.toLowerCase();
}
};
const parseSqlColumnArray = str => {
if (!str) {
throw new Error(`Cannot parse '${str}'`);
}
const parts = str.split(",");
return parts.map(removeQuotes);
};
const parseSqlColumnString = str => {
if (!str) {
throw new Error(`Cannot parse '${str}'`);
}
return removeQuotes(str);
};
function parseConstraintSpec(rawSpec) {
const [spec, ...tagComponents] = rawSpec.split(/\|/);
const parsed = parseTags(tagComponents.join("\n"));
return {
spec,
tags: parsed.tags,
description: parsed.text,
};
}
function smartCommentConstraints(introspectionResults) {
const attributesByNames = (tbl, cols, debugStr) => {
const attributes = introspectionResults.attribute
.filter(a => a.classId === tbl.id)
.sort((a, b) => a.num - b.num);
if (!cols) {
const pk = introspectionResults.constraint.find(
c => c.classId == tbl.id && c.type === "p"
);
if (pk) {
return pk.keyAttributeNums.map(n => attributes.find(a => a.num === n));
} else {
throw new Error(
`No columns specified for '${tbl.namespaceName}.${tbl.name}' (oid: ${tbl.id}) and no PK found (${debugStr}).`
);
}
}
return cols.map(colName => {
const attr = attributes.find(a => a.name === colName);
if (!attr) {
throw new Error(
`Could not find attribute '${colName}' in '${tbl.namespaceName}.${tbl.name}'`
);
}
return attr;
});
};
// First: primary and unique keys
introspectionResults.class.forEach(klass => {
const namespace = introspectionResults.namespace.find(
n => n.id === klass.namespaceId
);
if (!namespace) {
return;
}
function addKey(key: string, isPrimary = false) {
const tag = isPrimary ? "@primaryKey" : "@unique";
if (typeof key !== "string") {
if (isPrimary) {
throw new Error(
`${tag} configuration of '${klass.namespaceName}.${klass.name}' is invalid; please specify just once "${tag} col1,col2"`
);
}
throw new Error(
`${tag} configuration of '${klass.namespaceName}.${
klass.name
}' is invalid; expected ${
isPrimary ? "a string" : "a string or string array"
} but found ${typeof key}`
);
}
const { spec: keySpec, tags, description } = parseConstraintSpec(key);
const columns: string[] = parseSqlColumnArray(keySpec);
const attributes = attributesByNames(klass, columns, `${tag} ${key}`);
if (isPrimary) {
attributes.forEach(attr => {
attr.tags.notNull = true;
});
}
const keyAttributeNums = attributes.map(a => a.num);
// Now we need to fake a constraint for this:
const fakeConstraint = {
kind: "constraint",
isFake: true,
isIndexed: true, // otherwise it gets ignored by ignoreIndexes
id: Math.random(),
name: `FAKE_${klass.namespaceName}_${klass.name}_${tag}`,
type: isPrimary ? "p" : "u",
classId: klass.id,
foreignClassId: null,
comment: null,
description,
keyAttributeNums,
foreignKeyAttributeNums: null,
tags,
};
introspectionResults.constraint.push(fakeConstraint);
}
if (klass.tags.primaryKey) {
addKey(klass.tags.primaryKey, true);
}
if (klass.tags.unique) {
if (Array.isArray(klass.tags.unique)) {
klass.tags.unique.forEach(key => addKey(key));
} else {
addKey(klass.tags.unique);
}
}
});
// Now primary keys are in place, we can apply foreign keys
introspectionResults.class.forEach(klass => {
const namespace = introspectionResults.namespace.find(
n => n.id === klass.namespaceId
);
if (!namespace) {
return;
}
const getType = () =>
introspectionResults.type.find(t => t.id === klass.typeId);
const foreignKey = klass.tags.foreignKey || getType().tags.foreignKey;
if (foreignKey) {
const foreignKeys =
typeof foreignKey === "string" ? [foreignKey] : foreignKey;
if (!Array.isArray(foreignKeys)) {
throw new Error(
`Invalid foreign key smart comment specified on '${klass.namespaceName}.${klass.name}'`
);
}
foreignKeys.forEach((fkSpecRaw, index) => {
if (typeof fkSpecRaw !== "string") {
throw new Error(
`Invalid foreign key spec (${index}) on '${klass.namespaceName}.${klass.name}'`
);
}
const {
spec: fkSpec,
tags,
description,
} = parseConstraintSpec(fkSpecRaw);
const matches = fkSpec.match(
/^\(([^()]+)\) references ([^().]+)(?:\.([^().]+))?(?:\s*\(([^()]+)\))?$/i
);
if (!matches) {
throw new Error(
`Invalid foreignKey syntax for '${klass.namespaceName}.${klass.name}'; expected something like "(col1,col2) references schema.table (c1, c2)", you passed '${fkSpecRaw}'`
);
}
const [
,
rawColumns,
rawSchemaOrTable,
rawTableOnly,
rawForeignColumns,
] = matches;
const rawSchema = rawTableOnly
? rawSchemaOrTable
: `"${klass.namespaceName}"`;
const rawTable = rawTableOnly || rawSchemaOrTable;
const columns: string[] = parseSqlColumnArray(rawColumns);
const foreignSchema: string = parseSqlColumnString(rawSchema);
const foreignTable: string = parseSqlColumnString(rawTable);
const foreignColumns: string[] | null = rawForeignColumns
? parseSqlColumnArray(rawForeignColumns)
: null;
const foreignKlass = introspectionResults.class.find(
k => k.name === foreignTable && k.namespaceName === foreignSchema
);
if (!foreignKlass) {
throw new Error(
`@foreignKey smart comment referenced non-existant table/view '${foreignSchema}'.'${foreignTable}'. Note that this reference must use *database names* (i.e. it does not respect @name). (${fkSpecRaw})`
);
}
const foreignNamespace = introspectionResults.namespace.find(
n => n.id === foreignKlass.namespaceId
);
if (!foreignNamespace) {
return;
}
const keyAttributeNums = attributesByNames(
klass,
columns,
`@foreignKey ${fkSpecRaw}`
).map(a => a.num);
const foreignKeyAttributeNums = attributesByNames(
foreignKlass,
foreignColumns,
`@foreignKey ${fkSpecRaw}`
).map(a => a.num);
// Now we need to fake a constraint for this:
const fakeConstraint = {
kind: "constraint",
isFake: true,
isIndexed: true, // otherwise it gets ignored by ignoreIndexes
id: Math.random(),
name: `FAKE_${klass.namespaceName}_${klass.name}_foreignKey_${index}`,
type: "f", // foreign key
classId: klass.id,
foreignClassId: foreignKlass.id,
comment: null,
description,
keyAttributeNums,
foreignKeyAttributeNums,
tags,
};
introspectionResults.constraint.push(fakeConstraint);
});
}
});
}
function isEnumConstraint(
klass: PgClass,
con: PgConstraint,
isEnumTable: boolean
) {
if (con.classId === klass.id) {
const isPrimaryKey = con.type === "p";
const isUniqueConstraint = con.type === "u";
if (isPrimaryKey || isUniqueConstraint) {
const isExplicitEnumConstraint =
con.tags.enum === true || typeof con.tags.enum === "string";
const isPrimaryKeyOfEnumTableConstraint = con.type === "p" && isEnumTable;
if (isExplicitEnumConstraint || isPrimaryKeyOfEnumTableConstraint) {
const hasExactlyOneColumn = con.keyAttributeNums.length === 1;
if (!hasExactlyOneColumn) {
throw new Error(
`Enum table "${klass.namespaceName}"."${klass.name}" enum constraint '${con.name}' is composite; it should have exactly one column (found: ${con.keyAttributeNums.length})`
);
}
return true;
}
}
}
return false;
}
function enumTables(introspectionResults) {
introspectionResults.class.map(async klass => {
const isEnumTable =
klass.tags.enum === true || typeof klass.tags.enum === "string";
if (isEnumTable) {
// Prevent the table being recognised as a table
// eslint-disable-next-line require-atomic-updates
klass.tags.omit = true;
// eslint-disable-next-line require-atomic-updates
klass.isSelectable = false;
// eslint-disable-next-line require-atomic-updates
klass.isInsertable = false;
// eslint-disable-next-line require-atomic-updates
klass.isUpdatable = false;
// eslint-disable-next-line require-atomic-updates
klass.isDeletable = false;
}
// By this point, even views should have "fake" constraints we can use
// (e.g. `@primaryKey`)
const enumConstraints = introspectionResults.constraint.filter(con =>
isEnumConstraint(klass, con, isEnumTable)
);
// Get all the columns
const enumTableColumns = introspectionResults.attribute.filter(
attr => attr.classId === klass.id
);
// Get description column
const descriptionColumn = enumTableColumns.find(
attr => attr.name === "description" || attr.tags.enumDescription
);
const allData = klass._internalEnumData || [];
enumConstraints.forEach(constraint => {
const col = enumTableColumns.find(
col => col.num === constraint.keyAttributeNums[0]
);
if (!col) {
// Should never happen
throw new Error(
"Graphile Engine error - could not find column for enum constraint"
);
}
const data = allData.filter(row => row[col.name] != null);
if (data.length < 1) {
throw new Error(
`Enum table "${klass.namespaceName}"."${klass.name}" contains no visible entries for enum constraint '${constraint.name}'. Check that the table contains at least one row and that the rows are not hidden by row-level security policies.`
);
}
// Create fake enum type
const constraintIdent =
constraint.type === "p" ? "" : `_${constraint.name}`;
const enumTypeArray = {
kind: "type",
isFake: true,
id: fakeEnumIdentifier(
klass.namespaceName,
klass.name,
constraintIdent,
true
),
name: `_${klass.name}${constraintIdent}`,
description: null,
tags: {},
namespaceId: klass.namespaceId,
namespaceName: klass.namespaceName,
type: "b",
category: "A",
domainIsNotNull: null,
arrayItemTypeId: null,
typeLength: -1,
isPgArray: true,
classId: null,
domainBaseTypeId: null,
domainTypeModifier: null,
domainHasDefault: false,
enumVariants: null,
enumDescriptions: null,
rangeSubTypeId: null,
};
const enumType = {
kind: "type",
isFake: true,
id: fakeEnumIdentifier(
klass.namespaceName,
klass.name,
constraintIdent,
false
),
name: `${klass.name}${constraintIdent}`,
description: klass.description,
tags: { ...klass.tags, ...constraint.tags },
namespaceId: klass.namespaceId,
namespaceName: klass.namespaceName,
type: "e",
category: "E",
domainIsNotNull: null,
arrayItemTypeId: enumTypeArray.id,
typeLength: 4, // ???
isPgArray: false,
classId: null,
domainBaseTypeId: null,
domainTypeModifier: null,
domainHasDefault: false,
enumVariants: data.map(r => r[col.name]),
enumDescriptions: descriptionColumn
? data.map(r => r[descriptionColumn.name])
: null,
// TODO: enumDescriptions
rangeSubTypeId: null,
};
introspectionResults.type.push(enumType, enumTypeArray);
introspectionResults.typeById[enumType.id] = enumType;
introspectionResults.typeById[enumTypeArray.id] = enumTypeArray;
// Change type of all attributes that reference this table to
// reference this enum type
introspectionResults.constraint.forEach(c => {
if (
c.type === "f" &&
c.foreignClassId === klass.id &&
c.foreignKeyAttributeNums.length === 1 &&
c.foreignKeyAttributeNums[0] === col.num
) {
// Get the attribute
const fkattr = introspectionResults.attribute.find(
attr =>
attr.classId === c.classId && attr.num === c.keyAttributeNums[0]
);
if (fkattr) {
// Override the detected type to pretend to be our enum
fkattr.typeId = enumType.id;
}
}
});
});
});
}
/* The argument to this must not contain cyclic references! */
const deepClone = value => {
if (Array.isArray(value)) {
return value.map(val => deepClone(val));
} else if (typeof value === "object" && value) {
return Object.keys(value).reduce((memo, k) => {
memo[k] = deepClone(value[k]);
return memo;
}, {});
} else {
return value;
}
};
export default (async function PgIntrospectionPlugin(
builder,
{
pgConfig,
pgSchemas: schemas,
pgEnableTags,
persistentMemoizeWithKey = (key, fn) => fn(),
pgThrowOnMissingSchema = false,
pgIncludeExtensionResources = false,
pgLegacyFunctionsOnly = false,
pgIgnoreRBAC = true,
pgSkipInstallingWatchFixtures = false,
pgOwnerConnectionString,
pgUsePartitionedParent = false,
}
) {
/**
* Introspect database and get the table/view/constraints.
*/
async function introspect(): Promise<PgIntrospectionResultsByKind> {
// Perform introspection
if (!Array.isArray(schemas)) {
throw new Error("Argument 'schemas' (array) is required");
}
const cacheKey = `PgIntrospectionPlugin-introspectionResultsByKind-v${version}`;
const introspectionResultsByKind = deepClone(
await persistentMemoizeWithKey(cacheKey, () =>
withPgClient(pgConfig, async pgClient => {
const versionResult = await pgClient.query(
"show server_version_num;"
);
const serverVersionNum = parseInt(
versionResult.rows[0].server_version_num,
10
);
const introspectionQuery = makeIntrospectionQuery(serverVersionNum, {
pgLegacyFunctionsOnly,
pgIgnoreRBAC,
pgUsePartitionedParent,
});
const { rows } = await pgClient.query(introspectionQuery, [
schemas,
pgIncludeExtensionResources,
]);
const result = {
__pgVersion: serverVersionNum,
namespace: [],
class: [],
attribute: [],
type: [],
constraint: [],
procedure: [],
extension: [],
index: [],
};
for (const { object } of rows) {
result[object.kind].push(object);
}
// Parse tags from comments
[
"namespace",
"class",
"attribute",
"type",
"constraint",
"procedure",
"extension",
"index",
].forEach(kind => {
result[kind].forEach(object => {
// Keep a copy of the raw comment
object.comment = object.description;
if (pgEnableTags && object.description) {
const parsed = parseTags(object.description);
object.tags = parsed.tags;
object.description = parsed.text;
} else {
object.tags = {};
}
});
});
const extensionConfigurationClassIds = flatMap(
result.extension,
e => e.configurationClassIds
);
result.class.forEach(klass => {
klass.isExtensionConfigurationTable =
extensionConfigurationClassIds.indexOf(klass.id) >= 0;
});
// Assert the columns are text
const VARCHAR_ID = "1043";
const TEXT_ID = "25";
const CHAR_ID = "18";
const BPCHAR_ID = "1042";
const VALID_TYPE_IDS = [VARCHAR_ID, TEXT_ID, CHAR_ID, BPCHAR_ID];
await Promise.all(
result.class.map(async klass => {
if (!schemas.includes(klass.namespaceName)) {
// Only support enums in public tables/views
return;
}
const isEnumTable =
klass.tags.enum === true || typeof klass.tags.enum === "string";
// NOTE: this only matches on tables (not views, since they don't
// have constraints), which is why we repeat the isEnumTable check below.
const hasEnumConstraints = result.constraint.some(con =>
isEnumConstraint(klass, con, isEnumTable)
);
if (isEnumTable || hasEnumConstraints) {
// Get the list of columns enums are defined for
const enumTableColumns = result.attribute
.filter(
attr =>
attr.classId === klass.id &&
VALID_TYPE_IDS.includes(attr.typeId)
)
.sort((a, z) => a.num - z.num);
// Load data from the table/view.
const query = pgSql.compile(
pgSql.fragment`select ${pgSql.join(
enumTableColumns.map(col => pgSql.identifier(col.name)),
", "
)} from ${pgSql.identifier(klass.namespaceName, klass.name)};`
);
let allData;
try {
({ rows: allData } = await pgClient.query(query));
} catch (e) {
let role = "RELEVANT_POSTGRES_USER";
try {
const {
rows: [{ user }],
} = await pgClient.query("select user;");
role = user;
} catch (e) {
/*
* Ignore; this is likely a 25P02 (transaction aborted)
* error caused by the statement above failing.
*/
}
throw new Error(`Introspection could not read from enum table "${klass.namespaceName}"."${klass.name}", perhaps you need to grant access:
GRANT USAGE ON SCHEMA "${klass.namespaceName}" TO "${role}";
GRANT SELECT ON "${klass.namespaceName}"."${klass.name}" TO "${role}";
Original error: ${e.message}
`);
}
klass._internalEnumData = allData;
}
})
);
[
"namespace",
"class",
"attribute",
"type",
"constraint",
"procedure",
"extension",
"index",
].forEach(k => {
result[k].forEach(Object.freeze);
});
return Object.freeze(result);
})
)
);
const knownSchemas = introspectionResultsByKind.namespace.map(n => n.name);
const missingSchemas = schemas.filter(s => knownSchemas.indexOf(s) < 0);
if (missingSchemas.length) {
const errorMessage = `You requested to use schema '${schemas.join(
"', '"
)}'; however we couldn't find some of those! Missing schemas are: '${missingSchemas.join(
"', '"
)}'`;
if (pgThrowOnMissingSchema) {
throw new Error(errorMessage);
} else {
console.warn("⚠️ WARNING⚠️ " + errorMessage); // eslint-disable-line no-console
}
}
return introspectionResultsByKind;
}
function introspectionResultsFromRaw(
rawResults,
pgAugmentIntrospectionResults
) {
const introspectionResultsByKind = deepClone(rawResults);
const xByY = (arrayOfX, attrKey) =>
arrayOfX.reduce((memo, x) => {
memo[x[attrKey]] = x;
return memo;
}, {});
const xByYAndZ = (arrayOfX, attrKey, attrKey2) =>
arrayOfX.reduce((memo, x) => {
if (!memo[x[attrKey]]) memo[x[attrKey]] = {};
memo[x[attrKey]][x[attrKey2]] = x;
return memo;
}, {});
introspectionResultsByKind.namespaceById = xByY(
introspectionResultsByKind.namespace,
"id"
);
introspectionResultsByKind.classById = xByY(
introspectionResultsByKind.class,
"id"
);
introspectionResultsByKind.typeById = xByY(
introspectionResultsByKind.type,
"id"
);
introspectionResultsByKind.attributeByClassIdAndNum = xByYAndZ(
introspectionResultsByKind.attribute,
"classId",
"num"
);
introspectionResultsByKind.extensionById = xByY(
introspectionResultsByKind.extension,
"id"
);
const relate = (array, newAttr, lookupAttr, lookup, missingOk = false) => {
array.forEach(entry => {
const key = entry[lookupAttr];
if (Array.isArray(key)) {
entry[newAttr] = key
.map(innerKey => {
const result = lookup[innerKey];
if (innerKey && !result) {
if (missingOk) {
return;
}
throw new Error(
`Could not look up '${newAttr}' by '${lookupAttr}' ('${innerKey}') on '${JSON.stringify(
entry
)}'`
);
}
return result;
})
.filter(_ => _);
} else {
const result = lookup[key];
if (key && !result) {
if (missingOk) {
return;
}
throw new Error(
`Could not look up '${newAttr}' by '${lookupAttr}' (= '${key}') on '${JSON.stringify(
entry
)}'`
);
}
entry[newAttr] = result;
}
});
};
const augment = introspectionResults => {
[
pgAugmentIntrospectionResults,
smartCommentConstraints,
enumTables,
].forEach(fn => (fn ? fn(introspectionResults) : null));
};
augment(introspectionResultsByKind);
relate(
introspectionResultsByKind.class,
"namespace",
"namespaceId",
introspectionResultsByKind.namespaceById,
true // Because it could be a type defined in a different namespace - which is fine so long as we don't allow querying it directly
);
relate(
introspectionResultsByKind.class,
"type",
"typeId",
introspectionResultsByKind.typeById
);
relate(
introspectionResultsByKind.attribute,
"class",
"classId",
introspectionResultsByKind.classById
);
relate(
introspectionResultsByKind.attribute,
"type",
"typeId",
introspectionResultsByKind.typeById
);
relate(
introspectionResultsByKind.procedure,
"namespace",
"namespaceId",
introspectionResultsByKind.namespaceById
);
relate(
introspectionResultsByKind.type,
"class",
"classId",
introspectionResultsByKind.classById,
true
);
relate(
introspectionResultsByKind.type,
"domainBaseType",
"domainBaseTypeId",
introspectionResultsByKind.typeById,
true // Because not all types are domains
);
relate(
introspectionResultsByKind.type,
"arrayItemType",
"arrayItemTypeId",
introspectionResultsByKind.typeById,
true // Because not all types are arrays
);
relate(
introspectionResultsByKind.constraint,
"class",
"classId",
introspectionResultsByKind.classById
);
relate(
introspectionResultsByKind.constraint,
"foreignClass",
"foreignClassId",
introspectionResultsByKind.classById,
true // Because many constraints don't apply to foreign classes
);
relate(
introspectionResultsByKind.extension,
"namespace",
"namespaceId",
introspectionResultsByKind.namespaceById,
true // Because the extension could be a defined in a different namespace
);
relate(
introspectionResultsByKind.extension,
"configurationClasses",
"configurationClassIds",
introspectionResultsByKind.classById,
true // Because the configuration table could be a defined in a different namespace
);
relate(
introspectionResultsByKind.index,
"class",
"classId",
introspectionResultsByKind.classById
);
// Reverse arrayItemType -> arrayType
introspectionResultsByKind.type.forEach(type => {
if (type.arrayItemType) {
type.arrayItemType.arrayType = type;
}
});
// Table/type columns / constraints
introspectionResultsByKind.class.forEach(klass => {
klass.attributes = introspectionResultsByKind.attribute.filter(
attr => attr.classId === klass.id
);
klass.canUseAsterisk = !klass.attributes.some(
attr => attr.columnLevelSelectGrant
);
klass.constraints = introspectionResultsByKind.constraint.filter(
constraint => constraint.classId === klass.id
);
klass.foreignConstraints = introspectionResultsByKind.constraint.filter(
constraint => constraint.foreignClassId === klass.id
);
klass.primaryKeyConstraint = klass.constraints.find(
constraint => constraint.type === "p"
);
});
// Constraint attributes
introspectionResultsByKind.constraint.forEach(constraint => {
if (constraint.keyAttributeNums && constraint.class) {
constraint.keyAttributes = constraint.keyAttributeNums.map(nr =>
constraint.class.attributes.find(attr => attr.num === nr)
);
} else {
constraint.keyAttributes = [];
}
if (constraint.foreignKeyAttributeNums && constraint.foreignClass) {
constraint.foreignKeyAttributes =
constraint.foreignKeyAttributeNums.map(nr =>
constraint.foreignClass.attributes.find(attr => attr.num === nr)
);
} else {
constraint.foreignKeyAttributes = [];
}
});
// Detect which columns and constraints are indexed
introspectionResultsByKind.index.forEach(index => {
const columns = index.attributeNums.map(nr =>
index.class.attributes.find(attr => attr.num === nr)
);
// Indexed column (for orderBy / filter):
if (columns[0]) {
columns[0].isIndexed = true;
}
if (columns[0] && columns.length === 1 && index.isUnique) {
columns[0].isUnique = true;
}
// Indexed constraints (for reverse relations):
index.class.constraints
.filter(constraint => constraint.type === "f")
.forEach(constraint => {
if (
constraint.keyAttributeNums.every(
(nr, idx) => index.attributeNums[idx] === nr
)
) {
constraint.isIndexed = true;
}
});
});
return introspectionResultsByKind;
}
let rawIntrospectionResultsByKind = await introspect();
let listener;
class Listener {
_handleChange: () => void;
client: Client | null;
stopped: boolean;
_reallyReleaseClient: (() => Promise<void>) | null;
_haveDisplayedError: boolean;
constructor(triggerRebuild) {
this.stopped = false;
this._handleChange = throttle(
async () => {
debug(`Schema change detected: re-inspecting schema...`);
try {
rawIntrospectionResultsByKind = await introspect();
debug(`Schema change detected: re-inspecting schema complete`);
triggerRebuild();
} catch (e) {
// eslint-disable-next-line no-console
console.error(`Schema introspection failed: ${e.message}`);
}
},
750,
{
leading: true,
trailing: true,
}
);
this._listener = this._listener.bind(this);
this._handleClientError = this._handleClientError.bind(this);
this._start();
}
async _start(isReconnect = false) {
if (this.stopped) {
return;
}
// Connect to DB
try {
const { pgClient, releasePgClient } =
await getPgClientAndReleaserFromConfig(pgConfig);
this.client = pgClient;
// $FlowFixMe: hack property
this._reallyReleaseClient = releasePgClient;
pgClient.on("notification", this._listener);
pgClient.on("error", this._handleClientError);
if (this.stopped) {
// In case watch mode was cancelled in the interrim.
return this._releaseClient();
} else {
await pgClient.query("listen postgraphile_watch");
// Install the watch fixtures.
if (!pgSkipInstallingWatchFixtures) {
const watchSqlInner = await readFile(WATCH_FIXTURES_PATH, "utf8");
const sql = `begin; ${watchSqlInner};`;
await withPgClient(
pgOwnerConnectionString || pgConfig,
async pgClient => {
try {
await pgClient.query(sql);
} catch (error) {
if (!this._haveDisplayedError) {
this._haveDisplayedError = true;
/* eslint-disable no-console */
console.warn(
`${chalk.bold.yellow(
"Failed to setup watch fixtures in Postgres database"
)} ️️⚠️`
);
console.warn(
chalk.yellow(
"This is likely because the PostgreSQL user in the connection string does not have sufficient privileges; you can solve this by passing the 'owner' connection string via '--owner-connection' / 'ownerConnectionString'. If the fixtures already exist, the watch functionality may still work."
)
);
console.warn(
chalk.yellow(
"Enable DEBUG='graphile-build-pg' to see the error"
)
);
/* eslint-enable no-console */
}
debug(error);
} finally {
await pgClient.query("commit;");
}
}
);
}
// Trigger re-introspection on server reconnect
if (isReconnect) {
this._handleChange();
}
}
} catch (e) {
// If something goes wrong, disconnect and try again after a short delay
this._reconnect(e);
}
}
_handleClientError: (e: Error) => void;
_handleClientError(e) {
this._releaseClient(false);
this._reconnect(e);
}
async _reconnect(e) {
if (this.stopped) {
return;
}
// eslint-disable-next-line no-console
console.error(
"Error occurred for PG watching client; reconnecting in 2 seconds.",
e.message
);
await this._releaseClient();
setTimeout(() => {
if (!this.stopped) {
// Listen for further changes
this._start(true);
}
}, 2000);
}
// eslint-disable-next-line flowtype/no-weak-types
_listener: (notification: any) => void;
// eslint-disable-next-line flowtype/no-weak-types
async _listener(notification: any) {
if (notification.channel !== "postgraphile_watch") {
return;
}
try {
const payload = JSON.parse(notification.payload);
payload.payload = payload.payload || [];
if (payload.type === "ddl") {
const commands = payload.payload
.filter(
({ schema }) => schema == null || schemas.indexOf(schema) >= 0
)
.map(({ command }) => command);
if (commands.length) {
this._handleChange();
}
} else if (payload.type === "drop") {
const affectsOurSchemas = payload.payload.some(
schemaName => schemas.indexOf(schemaName) >= 0
);
if (affectsOurSchemas) {
this._handleChange();
}
} else if (payload.type === "manual") {
this._handleChange();
} else {
throw new Error(`Payload type '${payload.type}' not recognised`);
}
} catch (e) {
debug(`Error occurred parsing notification payload: ${e}`);
}
}
async stop() {
this.stopped = true;
this._handleChange.cancel();
await this._releaseClient();
}
/**
* Only pass `false` to this function if you know the client is going to be
* terminated; otherwise we risk leaving listeners running.
*/
async _releaseClient(clientIsStillViable = true) {
// $FlowFixMe
const pgClient = this.client;
const reallyReleaseClient = this._reallyReleaseClient;
this.client = null;
this._reallyReleaseClient = null;
if (pgClient) {
// Don't attempt to run a query after a client has errored.
if (clientIsStillViable) {
pgClient.query("unlisten postgraphile_watch").catch(e => {
debug(`Error occurred trying to unlisten watch: ${e}`);
});
}
pgClient.removeListener("notification", this._listener);
pgClient.removeListener("error", this._handleClientError);
}
if (reallyReleaseClient) {
await reallyReleaseClient();
}
}
}
builder.registerWatcher(
async triggerRebuild => {
// In case we started listening before, clean up
if (listener) {
await listener.stop();
}
// We're not worried about a race condition here.
// eslint-disable-next-line require-atomic-updates
listener = new Listener(triggerRebuild);
},
async () => {
const l = listener;
listener = null;
if (l) {
await l.stop();
}
}
);
builder.hook(
"build",
build => {
const introspectionResultsByKind = introspectionResultsFromRaw(
rawIntrospectionResultsByKind,
build.pgAugmentIntrospectionResults
);
if (introspectionResultsByKind.__pgVersion < 90500) {
// TODO:v5: remove this workaround
// This is a bit of a hack, but until we have plugin priorities it's the
// easiest way to conditionally support PG9.4.
build.pgQueryFromResolveData = queryFromResolveDataFactory({
supportsJSONB: false,
});
}
return build.extend(build, {
pgIntrospectionResultsByKind: introspectionResultsByKind,
getPgFakeEnumIdentifier: fakeEnumIdentifier,
});
},
["PgIntrospection"],
[],
["PgBasics"]
);
}: Plugin);
// TypeScript compatibility
export const PgEntityKind = {
NAMESPACE: "namespace",
PROCEDURE: "procedure",
CLASS: "class",
TYPE: "type",
ATTRIBUTE: "attribute",
CONSTRAINT: "constraint",
EXTENSION: "extension",
INDEX: "index",
};