diff --git a/graphile/graphile-meta/README.md b/graphile/graphile-meta/README.md index f347dc127..df6dc7e58 100644 --- a/graphile/graphile-meta/README.md +++ b/graphile/graphile-meta/README.md @@ -129,7 +129,7 @@ These form fields only describe what the final schema exposes: a column hidden f These four are set on the fields under `MetaTable.fields` and are `null` on the fields nested in indexes, constraints and relations. - **Detected many-to-many relations.** A table is a detected junction when its exposed non-system columns are exactly the local columns of two foreign keys whose relations have the select behavior. A column is a system column when a client never picks its value in a form: 1. it's a surrogate key: in the primary key and in no foreign key (a primary key made of the two foreign keys is the classic junction); - 2. it has a default, which covers generated ids, `owner_id DEFAULT current_user_id()` and `created_at`/`updated_at DEFAULT now()`; + 2. the database generates its value: identity, serial and generated columns, and any default that isn't a constant, which covers `gen_random_uuid()` ids, `owner_id DEFAULT current_user_id()` and `created_at`/`updated_at DEFAULT now()`. A constant default (`quantity DEFAULT 1`, `status DEFAULT 'pending'`, including casts like `'{}'::jsonb`) is a starting value the client may change, so that column is data; 3. the create input can't set it (`@behavior -insert`); 4. it's the table's `@scope` key column. diff --git a/graphile/graphile-meta/__tests__/form-meta.test.ts b/graphile/graphile-meta/__tests__/form-meta.test.ts index 2d4a07b91..a3bcf33a9 100644 --- a/graphile/graphile-meta/__tests__/form-meta.test.ts +++ b/graphile/graphile-meta/__tests__/form-meta.test.ts @@ -28,6 +28,10 @@ function attr(pgType: string, opts: Record = {}) { return { codec: { name: pgType }, notNull: false, hasDefault: false, ...opts }; } +function generated(pgType: string, opts: Record = {}) { + return attr(pgType, { hasDefault: true, extensions: { generatedValue: true }, ...opts }); +} + function codec(name: string, attributes: Record, tags: Record = {}) { return { name, attributes, extensions: { pg: { schemaName: 'app_public', name }, tags } }; } @@ -151,12 +155,12 @@ describe('junction detection', () => { const volunteers = resource(codec('volunteers', { id: attr('uuid', { hasDefault: true }) }), [pk]); const signups = resource( codec('shift_signups', { - id: attr('uuid', { notNull: true, hasDefault: true }), + id: generated('uuid', { notNull: true }), shift_id: attr('uuid', { notNull: true }), volunteer_id: attr('uuid', { notNull: true }), - owner_id: attr('uuid', { notNull: true, hasDefault: true }), - created_at: attr('timestamptz', { notNull: true, hasDefault: true }), - updated_at: attr('timestamptz', { notNull: true, hasDefault: true }), + owner_id: generated('uuid', { notNull: true }), + created_at: generated('timestamptz', { notNull: true }), + updated_at: generated('timestamptz', { notNull: true }), ...extra }), [pk] @@ -205,6 +209,14 @@ describe('junction detection', () => { expect(byName(collectTablesMeta(build(withData) as any)).shift_signups.isJunction).toBe(false); }); + it('counts a column with a constant default as data', () => { + const withQuantity = foodBank({ quantity: attr('int4', { notNull: true, hasDefault: true }) }); + const tables = byName(collectTablesMeta(build(withQuantity) as any)); + + expect(tables.shift_signups.isJunction).toBe(false); + expect(tables.shifts.relations.manyToMany).toEqual([]); + }); + it('treats a primary key made of the two foreign keys as the junction itself', () => { const posts = resource(codec('posts', { id: attr('uuid', { hasDefault: true }) }), [pk]); const tags = resource(codec('tags', { id: attr('uuid', { hasDefault: true }) }), [pk]); diff --git a/graphile/graphile-meta/src/column-defaults.ts b/graphile/graphile-meta/src/column-defaults.ts new file mode 100644 index 000000000..36940e69e --- /dev/null +++ b/graphile/graphile-meta/src/column-defaults.ts @@ -0,0 +1,68 @@ +import { asPgNode, parsePgNodeTree, type PgNode, type PgNodeValue } from './pg-node-tree'; + +/** + * Whether a column default (`pg_attrdef.adbin`) is a constant: built only from + * literals and casts, such as `1`, `'pending'`, `false`, `'{}'::jsonb`, + * `ARRAY[]::text[]` or `0` on a `numeric(10,2)` column. A client picks such a + * value in a form, so the column is data. + * + * Anything else is generated: a function call (`now()`, `gen_random_uuid()`, + * `nextval(...)` behind serial), an SQL value function (`CURRENT_TIMESTAMP`, + * `current_user`), an operator, or a node this reader doesn't know. + */ + +// CoercionForm: COERCE_EXPLICIT_CAST and COERCE_IMPLICIT_CAST. A FUNCEXPR in +// either form is a cast or length coercion (`int8(int4)`, `numeric(numeric, +// int4)`), not a call the default was written with. +const CAST_FORMS = new Set(['1', '2']); + +function isConstantNode(node: PgNode, inArrayCoerce = false): boolean { + const fields = node.fields; + switch (node.node) { + case 'CONST': + return true; + case 'CASETESTEXPR': + return inArrayCoerce; + case 'RELABELTYPE': + case 'COERCETODOMAIN': + case 'COERCEVIAIO': + return isConstantValue(fields.arg, inArrayCoerce); + case 'ARRAYCOERCEEXPR': + return isConstantValue(fields.arg, inArrayCoerce) && isConstantValue(fields.elemexpr, true); + case 'ARRAYEXPR': + return fields.elements === null || isConstantValue(fields.elements, inArrayCoerce); + case 'FUNCEXPR': + return ( + CAST_FORMS.has(fields.funcformat as string) && + (fields.args === null || isConstantValue(fields.args, inArrayCoerce)) + ); + default: + return false; + } +} + +function isConstantValue(value: PgNodeValue | undefined, inArrayCoerce: boolean): boolean { + if (Array.isArray(value)) return value.every((item) => isConstantValue(item, inArrayCoerce)); + const node = asPgNode(value); + return !!node && isConstantNode(node, inArrayCoerce); +} + +export function isConstantDefault(adbin: string): boolean { + const expr = parsePgNodeTree(adbin); + return !!expr && isConstantNode(expr); +} + +export interface GeneratedColumnLike { + attidentity?: string | null; + attgenerated?: string | null; +} + +/** + * Whether the database generates the column's value: an identity column, a + * generated column, or a default that isn't a constant (see + * `isConstantDefault`). `adbin` is the column's `pg_attrdef.adbin`, if any. + */ +export function isGeneratedColumn(attribute: GeneratedColumnLike, adbin: string | null | undefined): boolean { + if (attribute.attidentity || attribute.attgenerated) return true; + return adbin != null && !isConstantDefault(adbin); +} diff --git a/graphile/graphile-meta/src/index.ts b/graphile/graphile-meta/src/index.ts index 4d5159189..e9c33479f 100644 --- a/graphile/graphile-meta/src/index.ts +++ b/graphile/graphile-meta/src/index.ts @@ -7,6 +7,7 @@ import type { GraphileConfig } from 'graphile-config'; +import { isConstantDefault } from './column-defaults'; import { buildScalarEncoding } from './encoding-meta-builders'; import { getTablesMetaForSchema, MetaSchemaPlugin } from './plugin'; import { buildFieldMeta, pgTypeToGqlType } from './type-mappings'; @@ -41,4 +42,7 @@ export { buildFieldMeta as _buildFieldMeta }; /** @internal Exported for testing only */ export { buildScalarEncoding as _buildScalarEncoding }; +/** @internal Exported for testing only */ +export { isConstantDefault as _isConstantDefault }; + export default MetaSchemaPlugin; diff --git a/graphile/graphile-meta/src/junction-meta-builders.ts b/graphile/graphile-meta/src/junction-meta-builders.ts index ab3fad47c..e29f2ba51 100644 --- a/graphile/graphile-meta/src/junction-meta-builders.ts +++ b/graphile/graphile-meta/src/junction-meta-builders.ts @@ -16,8 +16,11 @@ export interface JunctionIndex { * 1. it is a surrogate key: part of the primary key and of no foreign key (a * primary key made of the two foreign keys is the classic junction, and * those columns are its data); - * 2. it has a default: generated ids, `owner_id DEFAULT current_user_id()`, - * `created_at`/`updated_at DEFAULT now()`; + * 2. the database generates its value: identity, serial, generated columns + * and defaults that aren't constants (`gen_random_uuid()`, + * `owner_id DEFAULT current_user_id()`, `created_at DEFAULT now()`). A + * constant default such as `quantity DEFAULT 1` is only a starting value + * the client may change, so that column is data; * 3. the create input can't set it (`@behavior -insert`), such as a column a * trigger fills; * 4. it is the table's `@scope` key column (e.g. `entity_id`), which clients @@ -33,7 +36,7 @@ export function isSystemColumn( context: BuildContext, ): boolean { if (surrogateKey.has(attrName)) return true; - if (codec.attributes?.[attrName]?.hasDefault) return true; + if (codec.attributes?.[attrName]?.extensions?.generatedValue) return true; const matches = context.build.behavior?.pgCodecAttributeMatches; if (matches && !matches([codec, attrName], 'attribute:insert')) return true; return codec.extensions?.tags?.scopeKey === attrName; diff --git a/graphile/graphile-meta/src/plugin.ts b/graphile/graphile-meta/src/plugin.ts index 54fe7f0be..3ac6517f7 100644 --- a/graphile/graphile-meta/src/plugin.ts +++ b/graphile/graphile-meta/src/plugin.ts @@ -1,11 +1,12 @@ // Type augmentation: adds the 'schema' property to GraphileConfig.Plugin import 'graphile-build'; -import type {} from 'graphile-build-pg'; +import { withPgClientFromPgService } from 'graphile-build-pg'; import type { GraphileConfig } from 'graphile-config'; import type { GraphQLSchema } from 'graphql'; import { allowedValuesForAttribute } from './check-constraint-values'; +import { isGeneratedColumn } from './column-defaults'; import { extendQueryWithMetaField } from './graphql-meta-field'; import { collectTablesMeta } from './table-meta-builder'; import type { MetaBuild, TableMeta } from './types'; @@ -15,10 +16,28 @@ declare global { interface PgCodecAttributeExtensions { /** Values permitted by the column's simple CHECK constraints. */ allowedValues?: string[]; + /** + * The database generates the column's value: identity, a generated + * column, or a non-constant default such as `now()` or serial's + * `nextval(...)`. + */ + generatedValue?: boolean; } } } +// pg_attrdef.adbin of each table's defaulted columns, by attnum, read once per +// introspection. +const columnDefaultsByClass = new WeakMap>(); + +type PgService = Parameters[0]; + +const COLUMN_DEFAULTS_QUERY = ` + select adrelid::text as adrelid, adnum, adbin::text as adbin + from pg_catalog.pg_attrdef + where adrelid = any($1::oid[]) +`; + const runtimeTablesBySchema = new WeakMap(); function getRuntimeTablesMeta( @@ -50,17 +69,62 @@ export const MetaSchemaPlugin: GraphileConfig.Plugin = { description: 'Exposes _meta query for database schema introspection', gather: { hooks: { - // Record each column's CHECK-constraint values on the codec attribute, from - // the introspection the gather phase already holds (conbin), so _meta can - // report them without querying the database. + // Read the default expression of every defaulted column, which the + // introspection doesn't carry, so pgCodecs_attribute can tell a + // generated value from a constant default. + async pgIntrospection_introspection(info, event) { + const { introspection, serviceName } = event; + const pgServices: readonly PgService[] | undefined = info.resolvedPreset.pgServices; + const pgService = pgServices?.find((service) => service.name === serviceName); + const relids = [ + ...new Set( + introspection.attributes + .filter((attribute) => attribute.atthasdef) + .map((attribute) => attribute.attrelid) + ), + ]; + if (!pgService || relids.length === 0) return; + const { rows } = await withPgClientFromPgService( + pgService, + pgService.pgSettingsForIntrospection ?? null, + (client) => + client.query<{ adrelid: string; adnum: number; adbin: string }>({ + text: COLUMN_DEFAULTS_QUERY, + values: [relids], + }) + ); + const classes = new Map( + introspection.classes.map((pgClass) => [pgClass._id, pgClass]) + ); + for (const { adrelid, adnum, adbin } of rows) { + const pgClass = classes.get(adrelid); + if (!pgClass) continue; + let defaults = columnDefaultsByClass.get(pgClass); + if (!defaults) { + defaults = new Map(); + columnDefaultsByClass.set(pgClass, defaults); + } + defaults.set(adnum, adbin); + } + }, + // Record each column's CHECK-constraint values, and whether the database + // generates its value, on the codec attribute, where _meta reads them. pgCodecs_attribute(_info, event) { const { pgClass, pgAttribute, attribute } = event; const allowedValues = allowedValuesForAttribute( pgClass.getConstraints(), pgAttribute.attnum ); - if (!allowedValues) return; - attribute.extensions = { ...attribute.extensions, allowedValues }; + const generatedValue = isGeneratedColumn( + pgAttribute, + columnDefaultsByClass.get(pgClass)?.get(pgAttribute.attnum) + ); + if (!allowedValues && !generatedValue) return; + attribute.extensions = { + ...attribute.extensions, + ...(allowedValues && { allowedValues }), + ...(generatedValue && { generatedValue }), + }; }, }, }, diff --git a/graphile/graphile-meta/src/types.ts b/graphile/graphile-meta/src/types.ts index f12e6e7be..dfd7b1b00 100644 --- a/graphile/graphile-meta/src/types.ts +++ b/graphile/graphile-meta/src/types.ts @@ -325,6 +325,8 @@ export interface PgCodec { export interface PgAttributeExtensions extends Record { /** Set at gather time from the column's simple CHECK constraints. */ allowedValues?: string[] | null; + /** Set at gather time: identity, generated column or non-constant default. */ + generatedValue?: boolean; geometrySubtype?: string | null; geometrySrid?: number | null; vectorDimensions?: number | null; diff --git a/graphile/graphile-settings/__tests__/meta-form-fields.integration.test.ts b/graphile/graphile-settings/__tests__/meta-form-fields.integration.test.ts index cca5520b2..c779a083e 100644 --- a/graphile/graphile-settings/__tests__/meta-form-fields.integration.test.ts +++ b/graphile/graphile-settings/__tests__/meta-form-fields.integration.test.ts @@ -1,3 +1,4 @@ +import { _isConstantDefault } from 'graphile-meta'; import { getConnectionsObjectUnwrapped, seed } from 'graphile-test'; import { join } from 'path'; @@ -70,6 +71,57 @@ const formSchema = ` shift_id uuid NOT NULL REFERENCES form_meta.shifts (id), badge_id uuid NOT NULL REFERENCES form_meta.badges (id) ); + + -- A quantity with a constant default is a value a client sets, so this + -- table isn't a link table. + CREATE TABLE form_meta.orders ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid() + ); + CREATE TABLE form_meta.products ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL + ); + CREATE TABLE form_meta.order_items ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + order_id uuid NOT NULL REFERENCES form_meta.orders (id), + product_id uuid NOT NULL REFERENCES form_meta.products (id), + quantity integer NOT NULL DEFAULT 1 + ); + + -- Generated values around the two keys: serial, identity, a stored + -- generated column, CURRENT_TIMESTAMP and current_user. + CREATE TABLE form_meta.product_locations ( + product_id uuid NOT NULL REFERENCES form_meta.products (id), + location_id uuid NOT NULL REFERENCES form_meta.locations (id), + seq bigserial, + rank integer GENERATED BY DEFAULT AS IDENTITY, + one integer GENERATED ALWAYS AS (1) STORED, + added_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + added_by text NOT NULL DEFAULT current_user, + PRIMARY KEY (product_id, location_id) + ); + + CREATE TABLE form_meta.default_kinds ( + c_int integer DEFAULT 1, + c_bigint bigint DEFAULT 1, + c_text text DEFAULT 'pending', + c_varchar varchar(10) DEFAULT 'x', + c_bool boolean DEFAULT false, + c_numeric numeric(10, 2) DEFAULT 0, + c_jsonb jsonb DEFAULT '{}', + c_text_array text[] DEFAULT '{}', + c_empty_array text[] DEFAULT ARRAY[]::text[], + c_varchar_array varchar(5)[] DEFAULT ARRAY['a']::varchar(5)[], + c_interval interval DEFAULT '1 day', + c_null integer DEFAULT NULL, + g_now timestamptz DEFAULT now(), + g_current_timestamp timestamptz DEFAULT CURRENT_TIMESTAMP, + g_current_user text DEFAULT current_user, + g_uuid uuid DEFAULT gen_random_uuid(), + g_serial serial, + g_expression integer DEFAULT 1 + 1, + g_constant_call text DEFAULT lower('X') + ); `; const metaQuery = ` @@ -171,6 +223,7 @@ function allowedValuesIn(value: unknown, found: unknown[] = []): unknown[] { describe('_meta form metadata on a real schema', () => { let teardown: () => Promise; let meta: { _meta: { tables: Table[] } }; + let defaultKinds: { attname: string; adbin: string }[]; const table = (tableName: string) => meta._meta.tables.find((t) => t.tableName === tableName); const field = (tableName: string, name: string) => table(tableName)?.fields.find((f) => f.name === name); @@ -191,6 +244,14 @@ describe('_meta form metadata on a real schema', () => { ); teardown = connections.teardown; meta = await connections.query<{ _meta: { tables: Table[] } }>({ query: metaQuery }); + defaultKinds = ( + await connections.pg.query( + `SELECT attname, adbin::text AS adbin + FROM pg_attrdef JOIN pg_attribute ON attrelid = adrelid AND attnum = adnum + WHERE adrelid = 'form_meta.default_kinds'::regclass + ORDER BY adnum` + ) + ).rows; }); afterAll(async () => { @@ -274,6 +335,31 @@ describe('_meta form metadata on a real schema', () => { ).toEqual([]); }); + it('treats a column with a constant default as data, so its table is no junction', () => { + expect(table('order_items')?.isJunction).toBe(false); + expect( + table('orders')?.relations.manyToMany.filter((m) => m.junctionTable.name === table('order_items')?.name) + ).toEqual([]); + }); + + it('treats serial, identity, generated and function defaults as system columns', () => { + const links = table('products')?.relations.manyToMany.filter( + (m) => m.junctionTable.name === table('product_locations')?.name + ); + + expect(table('product_locations')?.isJunction).toBe(true); + expect(links).toHaveLength(1); + expect(links?.[0]).toMatchObject({ rightTable: { name: table('locations')?.name } }); + }); + + it('reads literals and casts as constant defaults, and calls, value functions and operators as generated', () => { + const constant = defaultKinds.filter((d) => _isConstantDefault(d.adbin)).map((d) => d.attname); + const generated = defaultKinds.filter((d) => !_isConstantDefault(d.adbin)).map((d) => d.attname); + + expect(constant).toEqual(defaultKinds.map((d) => d.attname).filter((name) => name.startsWith('c_'))); + expect(generated).toEqual(defaultKinds.map((d) => d.attname).filter((name) => name.startsWith('g_'))); + }); + it('serves no backend internals in any value', () => { const forbidden = /@\w|smart ?tag|plugin|registry|presigned|derived|inflect|introspect|trigger|behavio|provenance|check constraint/i;