Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion graphile/graphile-meta/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
20 changes: 16 additions & 4 deletions graphile/graphile-meta/__tests__/form-meta.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ function attr(pgType: string, opts: Record<string, unknown> = {}) {
return { codec: { name: pgType }, notNull: false, hasDefault: false, ...opts };
}

function generated(pgType: string, opts: Record<string, unknown> = {}) {
return attr(pgType, { hasDefault: true, extensions: { generatedValue: true }, ...opts });
}

function codec(name: string, attributes: Record<string, unknown>, tags: Record<string, unknown> = {}) {
return { name, attributes, extensions: { pg: { schemaName: 'app_public', name }, tags } };
}
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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]);
Expand Down
68 changes: 68 additions & 0 deletions graphile/graphile-meta/src/column-defaults.ts
Original file line number Diff line number Diff line change
@@ -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);
}
4 changes: 4 additions & 0 deletions graphile/graphile-meta/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
9 changes: 6 additions & 3 deletions graphile/graphile-meta/src/junction-meta-builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
76 changes: 70 additions & 6 deletions graphile/graphile-meta/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<object, Map<number, string>>();

type PgService = Parameters<typeof withPgClientFromPgService>[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<GraphQLSchema, TableMeta[]>();

function getRuntimeTablesMeta(
Expand Down Expand Up @@ -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 }),
};
},
},
},
Expand Down
2 changes: 2 additions & 0 deletions graphile/graphile-meta/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,8 @@ export interface PgCodec {
export interface PgAttributeExtensions extends Record<string, unknown> {
/** 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;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { _isConstantDefault } from 'graphile-meta';
import { getConnectionsObjectUnwrapped, seed } from 'graphile-test';
import { join } from 'path';

Expand Down Expand Up @@ -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 = `
Expand Down Expand Up @@ -171,6 +223,7 @@ function allowedValuesIn(value: unknown, found: unknown[] = []): unknown[] {
describe('_meta form metadata on a real schema', () => {
let teardown: () => Promise<void>;
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);
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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;
Expand Down
Loading