From 690aa0efdd3f4df7eab94f14951b31de36d39796 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 19 Oct 2026 01:48:30 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(graphile-settings):=20createFunctionFr?= =?UTF-8?q?omSource=20=E2=80=94=20author=20customer=20functions=20from=20S?= =?UTF-8?q?QL=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../function-from-source.integration.test.ts | 127 ++++++++ .../__tests__/function-from-source.test.ts | 132 ++++++++ graphile/graphile-settings/package.json | 1 + .../sql/function-from-source-seed.sql | 19 ++ .../src/plugins/function-from-source.ts | 304 ++++++++++++++++++ .../graphile-settings/src/plugins/index.ts | 10 + .../src/presets/constructive-preset.ts | 4 +- pnpm-lock.yaml | 3 + 8 files changed, 599 insertions(+), 1 deletion(-) create mode 100644 graphile/graphile-settings/__tests__/function-from-source.integration.test.ts create mode 100644 graphile/graphile-settings/__tests__/function-from-source.test.ts create mode 100644 graphile/graphile-settings/sql/function-from-source-seed.sql create mode 100644 graphile/graphile-settings/src/plugins/function-from-source.ts diff --git a/graphile/graphile-settings/__tests__/function-from-source.integration.test.ts b/graphile/graphile-settings/__tests__/function-from-source.integration.test.ts new file mode 100644 index 0000000000..d91ec967f6 --- /dev/null +++ b/graphile/graphile-settings/__tests__/function-from-source.integration.test.ts @@ -0,0 +1,127 @@ +import type { GraphQLResponse } from 'graphile-test'; +import { getConnections, seed } from 'graphile-test'; +import { join } from 'path'; + +import { ConstructivePreset } from '../src/presets/constructive-preset'; + +jest.setTimeout(120000); + +const workspaceRoot = join(__dirname, '../../..'); +const TENANT_A = '00000000-0000-4000-8000-00000000000a'; +const TENANT_B = '00000000-0000-4000-8000-00000000000b'; +const SCHEMA_A = '00000000-0000-4000-8000-0000000000aa'; + +const SOURCE = ` +CREATE FUNCTION visible_total(min_amount numeric) + RETURNS numeric + LANGUAGE plpgsql STABLE + AS $$ +BEGIN + RETURN coalesce((SELECT sum(o.amount) FROM app_public.orders o WHERE o.amount >= min_amount), 0); +END; +$$;`; + +const CREATE = ` + mutation ($input: CreateFunctionFromSourceInput!) { + createFunctionFromSource(input: $input) { + clientMutationId + function { id databaseId schemaId name kind volatility returns arguments apiExposed } + } + } +`; + +type QueryFn = ( + query: string, + variables?: Record, + commit?: boolean, + reqOptions?: Record +) => Promise>; + +const asTenant = (databaseId: string) => ({ + pgSettings: { role: 'authenticated', 'jwt.claims.database_id': databaseId } +}); + +describe('createFunctionFromSource', () => { + let pg: any; + let db: any; + let query: QueryFn; + let teardown: () => Promise; + + beforeAll(async () => { + const connections = await (getConnections as any)( + { + schemas: ['metaschema_public'], + preset: { extends: [ConstructivePreset] }, + useRoot: true, + authRole: 'authenticated' + }, + [seed.pgpm(workspaceRoot), seed.sqlfile([join(__dirname, '../sql/function-from-source-seed.sql')])] + ); + ({ pg, db, query, teardown } = connections); + }); + + afterAll(async () => { + await teardown(); + }); + + beforeEach(async () => { + await pg.beforeEach(); + await db.beforeEach(); + }); + + afterEach(async () => { + await db.afterEach(); + await pg.afterEach(); + }); + + const create = (databaseId: string, source: string, claimsFor = databaseId) => + query( + CREATE, + { input: { clientMutationId: 'm1', databaseId, schemaId: SCHEMA_A, source, apiExposed: true } }, + false, + asTenant(claimsFor) + ); + + it('inserts the parsed definition under the caller\'s claims', async () => { + const result = await create(TENANT_A, SOURCE); + expect(result.errors).toBeUndefined(); + const { clientMutationId, function: fn } = result.data.createFunctionFromSource; + expect(clientMutationId).toBe('m1'); + expect(fn).toMatchObject({ + databaseId: TENANT_A, + schemaId: SCHEMA_A, + name: 'visible_total', + kind: 'plpgsql', + volatility: 'STABLE', + returns: { type: { name: 'numeric' } }, + arguments: [{ name: 'min_amount', type: { name: 'numeric' } }], + apiExposed: true + }); + + const { + rows: [row] + } = await pg.query('SELECT body_ast, security_invoker FROM metaschema_public.function WHERE id = $1', [fn.id]); + expect(row.security_invoker).toBe(true); + expect(row.body_ast.PLpgSQL_function.datums.map((d: any) => d.PLpgSQL_var.refname)).toEqual([ + 'min_amount', + 'found' + ]); + }); + + it('is refused by RLS when the caller writes into another database', async () => { + const result = await create(TENANT_A, SOURCE, TENANT_B); + expect(result.data?.createFunctionFromSource ?? null).toBeNull(); + expect(result.errors?.[0].message).toMatch(/row-level security policy/); + }); + + it('returns a parse error with the parser position and inserts nothing', async () => { + const source = 'CREATE FUNCTION f() RETURNS int LANGUAGE sql AS $$ SELEC 1 $$'; + const result = await create(TENANT_A, source); + expect(result.errors?.[0]).toMatchObject({ + message: 'syntax error at or near "SELEC"', + extensions: { code: 'FUNCTION_SOURCE_PARSE_ERROR', position: source.indexOf('SELEC') } + }); + const { rows } = await pg.query('SELECT count(*)::int AS n FROM metaschema_public.function'); + expect(rows[0].n).toBe(0); + }); +}); diff --git a/graphile/graphile-settings/__tests__/function-from-source.test.ts b/graphile/graphile-settings/__tests__/function-from-source.test.ts new file mode 100644 index 0000000000..aa5e6a1941 --- /dev/null +++ b/graphile/graphile-settings/__tests__/function-from-source.test.ts @@ -0,0 +1,132 @@ +import { + FUNCTION_SOURCE_PARSE_ERROR, + FUNCTION_SOURCE_UNSUPPORTED, + parseFunctionSource +} from '../src/plugins/function-from-source'; + +const rejection = async (source: string) => { + const err = await parseFunctionSource(source).then( + () => { + throw new Error('expected the source to be rejected'); + }, + (e) => e + ); + return { message: err.message, extensions: err.extensions }; +}; + +describe('parseFunctionSource', () => { + it('maps a PL/pgSQL function onto the function columns, datums intact', async () => { + const definition = await parseFunctionSource(` + CREATE FUNCTION visible_total(min_amount numeric, VARIADIC tags text[]) + RETURNS SETOF numeric + LANGUAGE plpgsql STABLE STRICT + AS $$ + BEGIN + RETURN QUERY SELECT sum(o.amount) FROM app_public.orders o WHERE o.amount >= min_amount; + END; + $$; + `); + expect(definition).toMatchObject({ + name: 'visible_total', + kind: 'plpgsql', + arguments: [ + { name: 'min_amount', type: { name: 'numeric' } }, + { name: 'tags', type: { name: 'text', array_dimensions: 1 }, mode: 'variadic' } + ], + returns: { type: { name: 'numeric' }, setof: true }, + volatility: 'STABLE', + is_strict: true, + security_invoker: true + }); + const datums = definition.body_ast.PLpgSQL_function.datums.map( + (d: any) => d.PLpgSQL_var.refname + ); + expect(datums).toEqual(['min_amount', 'tags', 'found']); + }); + + it('maps a SQL function, its body parsed into statements', async () => { + const definition = await parseFunctionSource(` + CREATE FUNCTION order_ids() + RETURNS TABLE (id uuid, amount numeric(10, 2)) + LANGUAGE sql + AS $$ SELECT o.id, o.amount FROM app_public.orders o $$ + `); + expect(definition).toMatchObject({ + name: 'order_ids', + kind: 'sql', + arguments: [], + returns: { + table: [ + { name: 'id', type: { name: 'uuid' } }, + { name: 'amount', type: { name: 'numeric', args: [10, 2] } } + ] + }, + volatility: 'VOLATILE', + is_strict: false + }); + expect(definition.body_ast.stmts).toHaveLength(1); + expect(definition.body_ast.stmts[0].stmt.SelectStmt).toBeDefined(); + }); + + it('maps RETURNS trigger to the trigger kind', async () => { + const definition = await parseFunctionSource(` + CREATE FUNCTION touch() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + NEW.updated_at := now(); + RETURN NEW; + END; + $$ + `); + expect(definition.kind).toBe('trigger'); + expect(definition.returns).toEqual({ type: { name: 'trigger' } }); + }); + + it('passes SECURITY DEFINER through for the database to refuse', async () => { + const definition = await parseFunctionSource( + 'CREATE FUNCTION f() RETURNS int LANGUAGE sql SECURITY DEFINER AS $$ SELECT 1 $$' + ); + expect(definition.security_invoker).toBe(false); + }); + + it('reports a statement syntax error with the parser position', async () => { + const source = 'CREATE FUNCTION f( RETURNS int LANGUAGE sql AS $$ SELECT 1 $$'; + expect(await rejection(source)).toEqual({ + message: 'syntax error at or near "LANGUAGE"', + extensions: { code: FUNCTION_SOURCE_PARSE_ERROR, position: source.indexOf('LANGUAGE') } + }); + }); + + it('reports a SQL body syntax error at its position in the source', async () => { + const source = 'CREATE FUNCTION f() RETURNS int LANGUAGE sql AS $$ SELEC 1 $$'; + expect(await rejection(source)).toEqual({ + message: 'syntax error at or near "SELEC"', + extensions: { code: FUNCTION_SOURCE_PARSE_ERROR, position: source.indexOf('SELEC') } + }); + }); + + it('reports a PL/pgSQL body syntax error', async () => { + expect( + await rejection(`CREATE FUNCTION f() RETURNS int LANGUAGE plpgsql AS $$ + BEGIN + RETRN 1; + END + $$`) + ).toEqual({ + message: 'syntax error at or near "RETRN"', + extensions: { code: FUNCTION_SOURCE_PARSE_ERROR } + }); + }); + + it.each([ + ['two statements', 'CREATE FUNCTION f() RETURNS int LANGUAGE sql AS $$ SELECT 1 $$; SELECT 1'], + ['a non-function statement', 'SELECT 1'], + ['a qualified name', 'CREATE FUNCTION app_public.f() RETURNS int LANGUAGE sql AS $$ SELECT 1 $$'], + ['OR REPLACE', 'CREATE OR REPLACE FUNCTION f() RETURNS int LANGUAGE sql AS $$ SELECT 1 $$'], + ['an argument default', 'CREATE FUNCTION f(a int DEFAULT 1) RETURNS int LANGUAGE sql AS $$ SELECT a $$'], + ['an option the columns cannot carry', 'CREATE FUNCTION f() RETURNS int LANGUAGE sql COST 5 AS $$ SELECT 1 $$'], + ['another language', "CREATE FUNCTION f() RETURNS int LANGUAGE c AS 'lib', 'sym'"] + ])('refuses %s instead of dropping it', async (_label, source) => { + const { extensions } = await rejection(source); + expect(extensions.code).toBe(FUNCTION_SOURCE_UNSUPPORTED); + }); +}); diff --git a/graphile/graphile-settings/package.json b/graphile/graphile-settings/package.json index 60bf76b48c..6e0c539a1e 100644 --- a/graphile/graphile-settings/package.json +++ b/graphile/graphile-settings/package.json @@ -70,6 +70,7 @@ "pg": "^8.21.0", "pg-query-context": "workspace:^", "pg-sql2": "5.0.1", + "plpgsql-parser": "^18.5.14", "postgraphile": "5.1.4", "request-ip": "^3.3.0", "tamedevil": "0.1.1" diff --git a/graphile/graphile-settings/sql/function-from-source-seed.sql b/graphile/graphile-settings/sql/function-from-source-seed.sql new file mode 100644 index 0000000000..d3c5b921e2 --- /dev/null +++ b/graphile/graphile-settings/sql/function-from-source-seed.sql @@ -0,0 +1,19 @@ +-- Two tenants, each with one schema, and an RLS policy on metaschema_public.function +-- that only lets a caller write and read its own database's rows (jwt.claims.database_id). +INSERT INTO metaschema_public.database (id, name) VALUES + ('00000000-0000-4000-8000-00000000000a', 'tenant_a'), + ('00000000-0000-4000-8000-00000000000b', 'tenant_b'); + +INSERT INTO metaschema_public.schema (id, database_id, name, schema_name) VALUES + ('00000000-0000-4000-8000-0000000000aa', '00000000-0000-4000-8000-00000000000a', 'app_public', 'tenant_a_app_public'), + ('00000000-0000-4000-8000-0000000000bb', '00000000-0000-4000-8000-00000000000b', 'app_public', 'tenant_b_app_public'); + +GRANT USAGE ON SCHEMA metaschema_public TO authenticated; +GRANT SELECT, INSERT ON metaschema_public.function TO authenticated; + +ALTER TABLE metaschema_public.function ENABLE ROW LEVEL SECURITY; + +CREATE POLICY function_own_database ON metaschema_public.function + TO authenticated + USING (database_id = current_setting('jwt.claims.database_id')::uuid) + WITH CHECK (database_id = current_setting('jwt.claims.database_id')::uuid); diff --git a/graphile/graphile-settings/src/plugins/function-from-source.ts b/graphile/graphile-settings/src/plugins/function-from-source.ts new file mode 100644 index 0000000000..850509e2b6 --- /dev/null +++ b/graphile/graphile-settings/src/plugins/function-from-source.ts @@ -0,0 +1,304 @@ +import { pgInsertSingle, type PgResource } from '@dataplan/pg'; +import { access, lambda, object } from 'grafast'; +import type { GraphileConfig } from 'graphile-config'; +import { GraphQLError } from 'graphql'; +import { loadModule, parseSql, parseSync } from 'plpgsql-parser'; + +type AstNode = Record; + +interface FieldType { + name: string; + schema?: string; + args?: (number | string)[]; + array_dimensions?: number; +} + +export interface FunctionDefinition { + name: string; + kind: 'sql' | 'plpgsql' | 'trigger'; + arguments: { name?: string; type: FieldType; mode?: string }[]; + returns: { type: FieldType; setof?: boolean } | { table: { name: string; type: FieldType }[] }; + volatility: 'IMMUTABLE' | 'STABLE' | 'VOLATILE'; + is_strict: boolean; + security_invoker: boolean; + body_ast: AstNode; +} + +export const FUNCTION_SOURCE_PARSE_ERROR = 'FUNCTION_SOURCE_PARSE_ERROR'; +export const FUNCTION_SOURCE_UNSUPPORTED = 'FUNCTION_SOURCE_UNSUPPORTED'; + +/** `position` is the parser's 0-based offset into the submitted source. */ +const sourceError = (code: string, message: string, position?: number) => + new GraphQLError(message, { + extensions: position === undefined ? { code } : { code, position } + }); + +const unsupported = (message: string, position?: number) => + sourceError(FUNCTION_SOURCE_UNSUPPORTED, message, position); + +/** Rethrows anything that is not a parser syntax error. */ +const asParseError = (err: unknown, offset: number | undefined): GraphQLError => { + const details = (err as { sqlDetails?: { message: string; cursorPosition: number } }).sqlDetails; + if (!details) throw err; + return sourceError( + FUNCTION_SOURCE_PARSE_ERROR, + details.message, + offset === undefined ? undefined : offset + details.cursorPosition + ); +}; + +const sval = (node: AstNode) => node.String.sval as string; + +const MODES: Record = { + FUNC_PARAM_DEFAULT: undefined, + FUNC_PARAM_IN: 'in', + FUNC_PARAM_OUT: 'out', + FUNC_PARAM_INOUT: 'inout', + FUNC_PARAM_VARIADIC: 'variadic' +}; + +const fieldType = (typeName: AstNode): FieldType => { + if (typeName.pct_type) throw unsupported('%TYPE references are not supported', typeName.location); + if (typeName.setof) throw unsupported('SETOF is only supported as the return type', typeName.location); + const names = typeName.names.map(sval); + if (names.length > 2) throw unsupported('type names take at most a schema qualifier', typeName.location); + const name = names[names.length - 1]; + const type: FieldType = { name }; + if (names.length === 2 && names[0] !== 'pg_catalog') type.schema = names[0]; + if (typeName.typmods) { + if (name === 'interval') throw unsupported('interval field qualifiers are not supported', typeName.location); + type.args = typeName.typmods.map((mod: AstNode) => { + const value = mod.A_Const?.ival?.ival ?? mod.A_Const?.sval?.sval; + if (value === undefined) throw unsupported('type modifiers must be constants', typeName.location); + return value; + }); + } + if (typeName.arrayBounds) type.array_dimensions = typeName.arrayBounds.length; + return type; +}; + +/** + * Maps one `CREATE FUNCTION` statement onto the columns `metaschema_public.function` + * accepts. Anything those columns cannot carry is refused rather than dropped; whether + * the definition is *allowed* is left to the database (capability gate, RLS, validator). + */ +export const parseFunctionSource = async (source: string): Promise => { + await loadModule(); + + let parsed: ReturnType; + try { + parsed = parseSync(source); + } catch (err) { + throw asParseError(err, 0); + } + + const stmts: AstNode[] = parsed.sql.stmts ?? []; + if (stmts.length !== 1 || !stmts[0].stmt.CreateFunctionStmt) { + throw unsupported('source must be exactly one CREATE FUNCTION statement', stmts[1]?.stmt_location); + } + const stmt = stmts[0].stmt.CreateFunctionStmt as AstNode; + if (stmt.is_procedure) throw unsupported('procedures are not supported'); + if (stmt.replace) throw unsupported('CREATE OR REPLACE is not supported; this creates a new function'); + if (stmt.sql_body) throw unsupported('write the body as a string literal (AS $$ ... $$)'); + if (stmt.funcname.length !== 1) { + throw unsupported('the function name must be unqualified; schemaId chooses its schema'); + } + + let language: string | undefined; + let body: string | undefined; + let bodyLocation: number | undefined; + let volatility: FunctionDefinition['volatility'] = 'VOLATILE'; + let isStrict = false; + let securityInvoker = true; + for (const { DefElem: opt } of stmt.options ?? []) { + switch (opt.defname) { + case 'language': + language = sval(opt.arg).toLowerCase(); + break; + case 'as': + if (opt.arg.List.items.length !== 1) throw unsupported('AS takes a single body string', opt.location); + body = sval(opt.arg.List.items[0]); + bodyLocation = opt.location; + break; + case 'volatility': + volatility = sval(opt.arg).toUpperCase() as FunctionDefinition['volatility']; + break; + case 'strict': + isStrict = Boolean(opt.arg.Boolean.boolval); + break; + case 'security': + securityInvoker = !opt.arg.Boolean.boolval; + break; + default: + throw unsupported(`${opt.defname.toUpperCase()} is not supported`, opt.location); + } + } + if (body === undefined) throw unsupported('the function has no body (AS $$ ... $$)'); + + const args: FunctionDefinition['arguments'] = []; + const table: { name: string; type: FieldType }[] = []; + for (const { FunctionParameter: param } of stmt.parameters ?? []) { + if (param.defexpr) throw unsupported('argument defaults are not supported', param.location); + if (param.mode === 'FUNC_PARAM_TABLE') { + table.push({ name: param.name, type: fieldType(param.argType) }); + continue; + } + if (!(param.mode in MODES)) throw unsupported(`argument mode ${param.mode} is not supported`, param.location); + const mode = MODES[param.mode]; + args.push({ + ...(param.name ? { name: param.name } : {}), + type: fieldType(param.argType), + ...(mode ? { mode } : {}) + }); + } + + const returnType = stmt.returnType as AstNode | undefined; + if (!returnType) throw unsupported('the function must declare RETURNS'); + const returns: FunctionDefinition['returns'] = table.length + ? { table } + : { type: fieldType({ ...returnType, setof: false }), ...(returnType.setof ? { setof: true } : {}) }; + + const common = { + name: sval(stmt.funcname[0]), + arguments: args, + returns, + volatility, + is_strict: isStrict, + security_invoker: securityInvoker + }; + + if (language === 'sql') { + // Body offsets map onto the source only when the body appears verbatim (dollar-quoted). + const bodyStart = source.indexOf(body, bodyLocation); + let bodyAst: AstNode; + try { + bodyAst = parseSql(body); + } catch (err) { + throw asParseError(err, bodyStart < 0 ? undefined : bodyStart); + } + return { ...common, kind: 'sql', body_ast: bodyAst }; + } + + if (language === 'plpgsql') { + const [bodyError] = parsed.errors; + if (bodyError) throw sourceError(FUNCTION_SOURCE_PARSE_ERROR, bodyError.message); + const fn = parsed.functions[0]; + const [hydrationError] = fn.plpgsql.errors; + if (hydrationError) throw sourceError(FUNCTION_SOURCE_PARSE_ERROR, hydrationError.error); + const kind = 'type' in returns && returns.type.name === 'trigger' ? 'trigger' : 'plpgsql'; + return { ...common, kind, body_ast: fn.plpgsql.hydrated.plpgsql_funcs[0] }; + } + + throw unsupported(`LANGUAGE ${language ?? '(none)'} is not supported; use plpgsql or sql`); +}; + +const isFunctionTable = (resource: PgResource) => { + const pg = resource.codec.extensions?.pg; + return !resource.parameters && pg?.schemaName === 'metaschema_public' && pg?.name === 'function'; +}; + +const findFunctionResource = (build: GraphileBuild.Build) => + Object.values(build.pgResources).find( + (resource) => + isFunctionTable(resource) && build.behavior.pgResourceMatches(resource, 'resource:insert') === true + ); + +const INPUT_TYPE = 'CreateFunctionFromSourceInput'; + +/** + * `createFunctionFromSource`: author a customer function from SQL text. The server parses + * (the database never parses strings) and performs the same `pgInsertSingle` as + * `createFunction`, on the request's client under the caller's claims. + */ +export const FunctionFromSourcePlugin: GraphileConfig.Plugin = { + name: 'FunctionFromSourcePlugin', + version: '1.0.0', + after: ['PgMutationCreatePlugin', 'PgMutationPayloadEdgePlugin'], + schema: { + hooks: { + init(_, build) { + if (!findFunctionResource(build)) return _; + const { GraphQLBoolean, GraphQLNonNull, GraphQLString } = build.graphql; + build.registerInputObjectType( + INPUT_TYPE, + { isMutationInput: true }, + () => ({ + description: 'Author a function from its `CREATE FUNCTION` source text.', + fields: () => { + const uuid = build.getInputTypeByName('UUID'); + return { + clientMutationId: { + type: GraphQLString, + apply(qb: any, val: unknown) { + qb.setMeta('clientMutationId', val); + } + }, + databaseId: { type: new GraphQLNonNull(uuid) }, + schemaId: { type: new GraphQLNonNull(uuid) }, + source: { + type: new GraphQLNonNull(GraphQLString), + description: 'One `CREATE FUNCTION` statement (LANGUAGE plpgsql or sql).' + }, + apiExposed: { type: GraphQLBoolean, defaultValue: false } + }; + } + }), + 'FunctionFromSourcePlugin input' + ); + return _; + }, + GraphQLObjectType_fields(fields, build, context) { + if (!context.scope.isRootMutation) return fields; + const resource = findFunctionResource(build); + if (!resource) return fields; + const { GraphQLNonNull } = build.graphql; + return build.extend( + fields, + { + createFunctionFromSource: context.fieldWithHooks( + { fieldName: 'createFunctionFromSource' }, + { + description: 'Creates a `Function` from its `CREATE FUNCTION` source text.', + type: build.getOutputTypeByName(build.inflection.createPayloadType(resource)), + args: { + input: { + type: new GraphQLNonNull(build.getInputTypeByName(INPUT_TYPE)), + applyPlan(_: unknown, $insert: unknown) { + return $insert; + } + } + }, + plan(_$root: unknown, fieldArgs: any) { + const $input = fieldArgs.getRaw('input'); + const $definition = lambda(access($input, 'source'), (source) => + parseFunctionSource(source as string) + ); + const $insert = pgInsertSingle(resource, { + database_id: access($input, 'databaseId'), + schema_id: access($input, 'schemaId'), + api_exposed: access($input, 'apiExposed'), + name: access($definition, 'name'), + kind: access($definition, 'kind'), + arguments: access($definition, 'arguments'), + returns: access($definition, 'returns'), + volatility: access($definition, 'volatility'), + is_strict: access($definition, 'is_strict'), + security_invoker: access($definition, 'security_invoker'), + body_ast: access($definition, 'body_ast') + }); + fieldArgs.apply($insert); + return object({ result: $insert }); + } + } + ) + }, + 'Adding createFunctionFromSource mutation' + ); + } + } + } +}; + +export const FunctionFromSourcePreset: GraphileConfig.Preset = { + plugins: [FunctionFromSourcePlugin] +}; diff --git a/graphile/graphile-settings/src/plugins/index.ts b/graphile/graphile-settings/src/plugins/index.ts index 829bba18c6..d9b74accb3 100644 --- a/graphile/graphile-settings/src/plugins/index.ts +++ b/graphile/graphile-settings/src/plugins/index.ts @@ -47,6 +47,16 @@ export { PrimaryKeyOnlyPreset, } from './primary-key-only'; +// Author customer functions from CREATE FUNCTION source text +export type { FunctionDefinition } from './function-from-source'; +export { + FUNCTION_SOURCE_PARSE_ERROR, + FUNCTION_SOURCE_UNSUPPORTED, + FunctionFromSourcePlugin, + FunctionFromSourcePreset, + parseFunctionSource, +} from './function-from-source'; + // Meta schema plugin for introspection (tables, fields, indexes, constraints) export { getTablesMetaForSchema, diff --git a/graphile/graphile-settings/src/presets/constructive-preset.ts b/graphile/graphile-settings/src/presets/constructive-preset.ts index f99327d7af..d131e17f44 100644 --- a/graphile/graphile-settings/src/presets/constructive-preset.ts +++ b/graphile/graphile-settings/src/presets/constructive-preset.ts @@ -16,6 +16,7 @@ import { UploadPreset } from 'graphile-upload-plugin'; import { ConflictDetectorPreset, EnableAllFilterColumnsPreset, + FunctionFromSourcePreset, InflectorLoggerPreset, InflektPreset, ManyToManyOptInPreset, @@ -159,7 +160,8 @@ export function createConstructivePreset( NoUniqueLookupPreset, MetaSchemaPreset, PgTypeMappingsPreset, - RequiredInputPreset + RequiredInputPreset, + FunctionFromSourcePreset ]; if (opts.enableConnectionFilter) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 274fa3c5c3..7dafd68ca0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1522,6 +1522,9 @@ importers: pg-sql2: specifier: 5.0.1 version: 5.0.1 + plpgsql-parser: + specifier: ^18.5.14 + version: 18.5.14 postgraphile: specifier: 5.1.4 version: 5.1.4(6fac2ccd59325b02d1508e585e9ab2d6) From 0a33ca020229f338698fb0d43a9839e9847bc3fd Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 19 Oct 2026 04:14:36 +0000 Subject: [PATCH 2/4] refactor: move createFunctionFromSource into graphile-function-source --- .github/workflows/run-tests.yaml | 2 +- graphile/graphile-function-source/README.md | 31 ++++++++++ .../function-from-source.integration.test.ts | 7 ++- .../__tests__/function-from-source.test.ts | 2 +- .../graphile-function-source/jest.config.js | 18 ++++++ .../graphile-function-source/package.json | 57 +++++++++++++++++++ .../sql/function-from-source-seed.sql | 0 .../src}/function-from-source.ts | 3 + .../graphile-function-source/src/index.ts | 8 +++ .../tsconfig.esm.json | 8 +++ .../graphile-function-source/tsconfig.json | 8 +++ graphile/graphile-settings/package.json | 2 +- .../graphile-settings/src/plugins/index.ts | 10 ---- .../src/presets/constructive-preset.ts | 2 +- pnpm-lock.yaml | 44 +++++++++++++- 15 files changed, 182 insertions(+), 20 deletions(-) create mode 100644 graphile/graphile-function-source/README.md rename graphile/{graphile-settings => graphile-function-source}/__tests__/function-from-source.integration.test.ts (94%) rename graphile/{graphile-settings => graphile-function-source}/__tests__/function-from-source.test.ts (99%) create mode 100644 graphile/graphile-function-source/jest.config.js create mode 100644 graphile/graphile-function-source/package.json rename graphile/{graphile-settings => graphile-function-source}/sql/function-from-source-seed.sql (100%) rename graphile/{graphile-settings/src/plugins => graphile-function-source/src}/function-from-source.ts (99%) create mode 100644 graphile/graphile-function-source/src/index.ts create mode 100644 graphile/graphile-function-source/tsconfig.esm.json create mode 100644 graphile/graphile-function-source/tsconfig.json diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index e95140a849..93da3d912f 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -244,7 +244,7 @@ jobs: - batch: pg-postgres packages: 'postgres/pgsql-test postgres/drizzle-orm-test postgres/introspectron graphile/graphile-test graphile/graphile-scoped-introspection graphile/graphile-connection-filter graphile/graphile-postgis' - batch: pg-graphql - packages: 'graphile/graphile-search graphile/graphile-ltree graphile/graphile-bulk-mutations graphile/graphile-function-bindings graphile/graphile-history graphile/graphile-meta graphile/graphile-schema graphql/orm-test graphql/test graphql/playwright-test' + packages: 'graphile/graphile-search graphile/graphile-function-source graphile/graphile-ltree graphile/graphile-bulk-mutations graphile/graphile-function-bindings graphile/graphile-history graphile/graphile-meta graphile/graphile-schema graphql/orm-test graphql/test graphql/playwright-test' - batch: pg-graphile-extras packages: 'graphile/graphile-i18n graphile/graphile-pg-aggregates graphile/graphile-query graphile/graphile-realtime-test' diff --git a/graphile/graphile-function-source/README.md b/graphile/graphile-function-source/README.md new file mode 100644 index 0000000000..7b8dae0bfb --- /dev/null +++ b/graphile/graphile-function-source/README.md @@ -0,0 +1,31 @@ +# graphile-function-source + +

+ +

+ +

+ + + + + +

+ +Author PostgreSQL functions from `CREATE FUNCTION` source text in PostGraphile v5. + +Adds one mutation, `createFunctionFromSource(input: { databaseId, schemaId, source, apiExposed })`, to schemas that expose an insertable `metaschema_public.function` table. The server parses `source` with [`plpgsql-parser`](https://www.npmjs.com/package/plpgsql-parser), maps it onto the table's columns (`name`, `kind`, `arguments`, `returns`, `volatility`, `is_strict`, `security_invoker`, and the hydrated `body_ast`), and inserts the row on the request's own client — so RLS, triggers and validators decide exactly as they do for `createFunction`. + +Parse failures are GraphQL errors with `extensions.code = 'FUNCTION_SOURCE_PARSE_ERROR'` and, when the parser reports one, `extensions.position` (0-based offset into `source`). Constructs the table cannot represent are refused with `FUNCTION_SOURCE_UNSUPPORTED`. + +## Usage + +```ts +import { FunctionFromSourcePreset } from 'graphile-function-source'; + +const preset = { + extends: [FunctionFromSourcePreset] +}; +``` + +`parseFunctionSource(source)` is exported for parsing without GraphQL. diff --git a/graphile/graphile-settings/__tests__/function-from-source.integration.test.ts b/graphile/graphile-function-source/__tests__/function-from-source.integration.test.ts similarity index 94% rename from graphile/graphile-settings/__tests__/function-from-source.integration.test.ts rename to graphile/graphile-function-source/__tests__/function-from-source.integration.test.ts index d91ec967f6..8b6829dc07 100644 --- a/graphile/graphile-settings/__tests__/function-from-source.integration.test.ts +++ b/graphile/graphile-function-source/__tests__/function-from-source.integration.test.ts @@ -2,7 +2,7 @@ import type { GraphQLResponse } from 'graphile-test'; import { getConnections, seed } from 'graphile-test'; import { join } from 'path'; -import { ConstructivePreset } from '../src/presets/constructive-preset'; +import { FunctionFromSourcePreset } from '../src'; jest.setTimeout(120000); @@ -51,7 +51,7 @@ describe('createFunctionFromSource', () => { const connections = await (getConnections as any)( { schemas: ['metaschema_public'], - preset: { extends: [ConstructivePreset] }, + preset: { extends: [FunctionFromSourcePreset] }, useRoot: true, authRole: 'authenticated' }, @@ -100,7 +100,8 @@ describe('createFunctionFromSource', () => { const { rows: [row] - } = await pg.query('SELECT body_ast, security_invoker FROM metaschema_public.function WHERE id = $1', [fn.id]); + } = await pg.query('SELECT body_ast, security_invoker FROM metaschema_public.function WHERE database_id = $1 AND name = $2', + [TENANT_A, 'visible_total']); expect(row.security_invoker).toBe(true); expect(row.body_ast.PLpgSQL_function.datums.map((d: any) => d.PLpgSQL_var.refname)).toEqual([ 'min_amount', diff --git a/graphile/graphile-settings/__tests__/function-from-source.test.ts b/graphile/graphile-function-source/__tests__/function-from-source.test.ts similarity index 99% rename from graphile/graphile-settings/__tests__/function-from-source.test.ts rename to graphile/graphile-function-source/__tests__/function-from-source.test.ts index aa5e6a1941..78a592ffd4 100644 --- a/graphile/graphile-settings/__tests__/function-from-source.test.ts +++ b/graphile/graphile-function-source/__tests__/function-from-source.test.ts @@ -2,7 +2,7 @@ import { FUNCTION_SOURCE_PARSE_ERROR, FUNCTION_SOURCE_UNSUPPORTED, parseFunctionSource -} from '../src/plugins/function-from-source'; +} from '../src'; const rejection = async (source: string) => { const err = await parseFunctionSource(source).then( diff --git a/graphile/graphile-function-source/jest.config.js b/graphile/graphile-function-source/jest.config.js new file mode 100644 index 0000000000..eecd073352 --- /dev/null +++ b/graphile/graphile-function-source/jest.config.js @@ -0,0 +1,18 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + babelConfig: false, + tsconfig: 'tsconfig.json' + } + ] + }, + transformIgnorePatterns: [`/node_modules/*`], + testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + modulePathIgnorePatterns: ['dist/*'] +}; diff --git a/graphile/graphile-function-source/package.json b/graphile/graphile-function-source/package.json new file mode 100644 index 0000000000..e3b2910f59 --- /dev/null +++ b/graphile/graphile-function-source/package.json @@ -0,0 +1,57 @@ +{ + "name": "graphile-function-source", + "version": "0.1.0", + "description": "Author PostgreSQL functions from CREATE FUNCTION source text in PostGraphile v5", + "author": "Constructive ", + "homepage": "https://github.com/constructive-io/constructive", + "license": "MIT", + "main": "index.js", + "module": "esm/index.js", + "types": "index.d.ts", + "scripts": { + "clean": "makage clean", + "prepack": "npm run build", + "build": "makage build", + "build:dev": "makage build --dev", + "lint": "eslint . --fix", + "test": "jest" + }, + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "repository": { + "type": "git", + "url": "https://github.com/constructive-io/constructive" + }, + "keywords": [ + "postgraphile", + "graphile", + "constructive", + "plugin", + "postgres", + "graphql", + "plpgsql", + "functions" + ], + "bugs": { + "url": "https://github.com/constructive-io/constructive/issues" + }, + "dependencies": { + "plpgsql-parser": "^18.5.14" + }, + "peerDependencies": { + "@dataplan/pg": "^1.1.1", + "grafast": "^1.1.2", + "graphile-build": "^5.1.1", + "graphile-build-pg": "^5.1.3", + "graphile-config": "^1.1.0", + "graphql": "^16.9.0", + "postgraphile": "^5.1.3" + }, + "devDependencies": { + "@types/node": "^22.19.11", + "graphile-test": "workspace:^", + "makage": "^0.8.0" + } +} diff --git a/graphile/graphile-settings/sql/function-from-source-seed.sql b/graphile/graphile-function-source/sql/function-from-source-seed.sql similarity index 100% rename from graphile/graphile-settings/sql/function-from-source-seed.sql rename to graphile/graphile-function-source/sql/function-from-source-seed.sql diff --git a/graphile/graphile-settings/src/plugins/function-from-source.ts b/graphile/graphile-function-source/src/function-from-source.ts similarity index 99% rename from graphile/graphile-settings/src/plugins/function-from-source.ts rename to graphile/graphile-function-source/src/function-from-source.ts index 850509e2b6..a9ee35af7e 100644 --- a/graphile/graphile-settings/src/plugins/function-from-source.ts +++ b/graphile/graphile-function-source/src/function-from-source.ts @@ -1,3 +1,6 @@ +import 'graphile-build'; +import 'graphile-build-pg'; + import { pgInsertSingle, type PgResource } from '@dataplan/pg'; import { access, lambda, object } from 'grafast'; import type { GraphileConfig } from 'graphile-config'; diff --git a/graphile/graphile-function-source/src/index.ts b/graphile/graphile-function-source/src/index.ts new file mode 100644 index 0000000000..492cf2d77c --- /dev/null +++ b/graphile/graphile-function-source/src/index.ts @@ -0,0 +1,8 @@ +export type { FunctionDefinition } from './function-from-source'; +export { + FUNCTION_SOURCE_PARSE_ERROR, + FUNCTION_SOURCE_UNSUPPORTED, + FunctionFromSourcePlugin, + FunctionFromSourcePreset, + parseFunctionSource +} from './function-from-source'; diff --git a/graphile/graphile-function-source/tsconfig.esm.json b/graphile/graphile-function-source/tsconfig.esm.json new file mode 100644 index 0000000000..0843fea973 --- /dev/null +++ b/graphile/graphile-function-source/tsconfig.esm.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "moduleResolution": "bundler", + "outDir": "dist/esm", + "module": "ESNext" + } +} diff --git a/graphile/graphile-function-source/tsconfig.json b/graphile/graphile-function-source/tsconfig.json new file mode 100644 index 0000000000..9c8a7d7c10 --- /dev/null +++ b/graphile/graphile-function-source/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"] +} diff --git a/graphile/graphile-settings/package.json b/graphile/graphile-settings/package.json index 6e0c539a1e..ad73c1cc56 100644 --- a/graphile/graphile-settings/package.json +++ b/graphile/graphile-settings/package.json @@ -51,6 +51,7 @@ "graphile-bulk-mutations": "workspace:^", "graphile-config": "1.1.0", "graphile-connection-filter": "workspace:^", + "graphile-function-source": "workspace:^", "graphile-history": "workspace:^", "graphile-i18n": "workspace:^", "graphile-llm": "workspace:^", @@ -70,7 +71,6 @@ "pg": "^8.21.0", "pg-query-context": "workspace:^", "pg-sql2": "5.0.1", - "plpgsql-parser": "^18.5.14", "postgraphile": "5.1.4", "request-ip": "^3.3.0", "tamedevil": "0.1.1" diff --git a/graphile/graphile-settings/src/plugins/index.ts b/graphile/graphile-settings/src/plugins/index.ts index d9b74accb3..829bba18c6 100644 --- a/graphile/graphile-settings/src/plugins/index.ts +++ b/graphile/graphile-settings/src/plugins/index.ts @@ -47,16 +47,6 @@ export { PrimaryKeyOnlyPreset, } from './primary-key-only'; -// Author customer functions from CREATE FUNCTION source text -export type { FunctionDefinition } from './function-from-source'; -export { - FUNCTION_SOURCE_PARSE_ERROR, - FUNCTION_SOURCE_UNSUPPORTED, - FunctionFromSourcePlugin, - FunctionFromSourcePreset, - parseFunctionSource, -} from './function-from-source'; - // Meta schema plugin for introspection (tables, fields, indexes, constraints) export { getTablesMetaForSchema, diff --git a/graphile/graphile-settings/src/presets/constructive-preset.ts b/graphile/graphile-settings/src/presets/constructive-preset.ts index d131e17f44..c6629c199e 100644 --- a/graphile/graphile-settings/src/presets/constructive-preset.ts +++ b/graphile/graphile-settings/src/presets/constructive-preset.ts @@ -2,6 +2,7 @@ import { BucketProvisionerPreset } from 'graphile-bucket-provisioner-plugin'; import { BulkMutationPreset } from 'graphile-bulk-mutations'; import type { GraphileConfig } from 'graphile-config'; import { ConnectionFilterPreset } from 'graphile-connection-filter'; +import { FunctionFromSourcePreset } from 'graphile-function-source'; import { HistoryPreset } from 'graphile-history'; import { I18nPreset } from 'graphile-i18n'; import { GraphileLlmPreset } from 'graphile-llm'; @@ -16,7 +17,6 @@ import { UploadPreset } from 'graphile-upload-plugin'; import { ConflictDetectorPreset, EnableAllFilterColumnsPreset, - FunctionFromSourcePreset, InflectorLoggerPreset, InflektPreset, ManyToManyOptInPreset, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7dafd68ca0..09178c7320 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -713,6 +713,44 @@ importers: version: link:../../postgres/pgsql-test/dist publishDirectory: dist + graphile/graphile-function-source: + dependencies: + '@dataplan/pg': + specifier: 1.1.1 + version: 1.1.1(@dataplan/json@1.0.1(grafast@1.1.2(graphql@16.13.0)))(grafast@1.1.2(graphql@16.13.0))(graphile-config@1.1.0)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) + grafast: + specifier: 1.1.2 + version: 1.1.2(graphql@16.13.0) + graphile-build: + specifier: 5.1.1 + version: 5.1.1(grafast@1.1.2(graphql@16.13.0))(graphile-config@1.1.0)(graphql@16.13.0) + graphile-build-pg: + specifier: 5.1.3 + version: 5.1.3(@dataplan/pg@1.1.1(@dataplan/json@1.0.1(grafast@1.1.2(graphql@16.13.0)))(grafast@1.1.2(graphql@16.13.0))(graphile-config@1.1.0)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.1.2(graphql@16.13.0))(graphile-build@5.1.1(grafast@1.1.2(graphql@16.13.0))(graphile-config@1.1.0)(graphql@16.13.0))(graphile-config@1.1.0)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) + graphile-config: + specifier: 1.1.0 + version: 1.1.0 + graphql: + specifier: 16.13.0 + version: 16.13.0 + plpgsql-parser: + specifier: ^18.5.14 + version: 18.5.14 + postgraphile: + specifier: 5.1.4 + version: 5.1.4(abb1e7d37c806d345dec450464477780) + devDependencies: + '@types/node': + specifier: ^22.19.11 + version: 22.19.19 + graphile-test: + specifier: workspace:^ + version: link:../graphile-test/dist + makage: + specifier: ^0.8.0 + version: 0.8.0 + publishDirectory: dist + graphile/graphile-history: dependencies: '@dataplan/pg': @@ -1465,6 +1503,9 @@ importers: graphile-connection-filter: specifier: workspace:^ version: link:../graphile-connection-filter/dist + graphile-function-source: + specifier: workspace:^ + version: link:../graphile-function-source/dist graphile-history: specifier: workspace:^ version: link:../graphile-history/dist @@ -1522,9 +1563,6 @@ importers: pg-sql2: specifier: 5.0.1 version: 5.0.1 - plpgsql-parser: - specifier: ^18.5.14 - version: 18.5.14 postgraphile: specifier: 5.1.4 version: 5.1.4(6fac2ccd59325b02d1508e585e9ab2d6) From 0359e685f82b70547c4fbc4163bd129819238fe3 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 19 Oct 2026 04:47:20 +0000 Subject: [PATCH 3/4] =?UTF-8?q?feat(graphile-function-source):=20updateFun?= =?UTF-8?q?ctionFromSource=20=E2=80=94=20replace=20a=20function's=20defini?= =?UTF-8?q?tion=20from=20SQL=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- graphile/graphile-function-source/README.md | 9 +- .../function-from-source.integration.test.ts | 90 +++++++++ .../sql/function-from-source-seed.sql | 2 +- .../src/function-from-source.ts | 190 +++++++++++------- 4 files changed, 221 insertions(+), 70 deletions(-) diff --git a/graphile/graphile-function-source/README.md b/graphile/graphile-function-source/README.md index 7b8dae0bfb..377a77ceb9 100644 --- a/graphile/graphile-function-source/README.md +++ b/graphile/graphile-function-source/README.md @@ -14,7 +14,14 @@ Author PostgreSQL functions from `CREATE FUNCTION` source text in PostGraphile v5. -Adds one mutation, `createFunctionFromSource(input: { databaseId, schemaId, source, apiExposed })`, to schemas that expose an insertable `metaschema_public.function` table. The server parses `source` with [`plpgsql-parser`](https://www.npmjs.com/package/plpgsql-parser), maps it onto the table's columns (`name`, `kind`, `arguments`, `returns`, `volatility`, `is_strict`, `security_invoker`, and the hydrated `body_ast`), and inserts the row on the request's own client — so RLS, triggers and validators decide exactly as they do for `createFunction`. +Adds two mutations over the `metaschema_public.function` table: + +- `createFunctionFromSource(input: { databaseId, schemaId, source, apiExposed })`, when the table is insertable — the same insert as `createFunction`. +- `updateFunctionFromSource(input: { id, source })`, when the table is updatable — the same update as `updateFunction`. It rewrites only the definition columns; `databaseId`, `schemaId` and `apiExposed` are unchanged. + +Both take the same text: one plain `CREATE FUNCTION` statement. `CREATE OR REPLACE` is refused by both — replacing is what `updateFunctionFromSource` means. + +The server parses `source` with [`plpgsql-parser`](https://www.npmjs.com/package/plpgsql-parser), maps it onto the table's columns (`name`, `kind`, `arguments`, `returns`, `volatility`, `is_strict`, `security_invoker`, and the hydrated `body_ast`), and writes the row on the request's own client — so RLS, triggers and validators decide exactly as they do for the generated mutations. Deleting needs no source: use the generated `deleteFunction`. Parse failures are GraphQL errors with `extensions.code = 'FUNCTION_SOURCE_PARSE_ERROR'` and, when the parser reports one, `extensions.position` (0-based offset into `source`). Constructs the table cannot represent are refused with `FUNCTION_SOURCE_UNSUPPORTED`. diff --git a/graphile/graphile-function-source/__tests__/function-from-source.integration.test.ts b/graphile/graphile-function-source/__tests__/function-from-source.integration.test.ts index 8b6829dc07..ca44a0fe8b 100644 --- a/graphile/graphile-function-source/__tests__/function-from-source.integration.test.ts +++ b/graphile/graphile-function-source/__tests__/function-from-source.integration.test.ts @@ -37,6 +37,21 @@ type QueryFn = ( reqOptions?: Record ) => Promise>; +const UPDATE = ` + mutation ($input: UpdateFunctionFromSourceInput!) { + updateFunctionFromSource(input: $input) { + clientMutationId + function { databaseId schemaId name kind volatility returns arguments apiExposed } + } + } +`; + +const REPLACEMENT = ` +CREATE FUNCTION renamed_total(threshold integer) + RETURNS integer + LANGUAGE sql IMMUTABLE + AS $$ SELECT threshold * 2 $$;`; + const asTenant = (databaseId: string) => ({ pgSettings: { role: 'authenticated', 'jwt.claims.database_id': databaseId } }); @@ -125,4 +140,79 @@ describe('createFunctionFromSource', () => { const { rows } = await pg.query('SELECT count(*)::int AS n FROM metaschema_public.function'); expect(rows[0].n).toBe(0); }); + + describe('updateFunctionFromSource', () => { + // Read back as tenant A: the last mutation's claims are still set on this transaction. + const rowOf = async (name: string) => { + await pg.query(`SELECT set_config('jwt.claims.database_id', $1, true)`, [TENANT_A]); + const { rows } = await pg.query( + `SELECT id, database_id, schema_id, name, kind, volatility, arguments, returns, api_exposed, body_ast + FROM metaschema_public.function WHERE database_id = $1 AND name = $2`, + [TENANT_A, name] + ); + return rows[0]; + }; + + const update = async (id: string, source: string, claimsFor = TENANT_A) => + query(UPDATE, { input: { clientMutationId: 'u1', id, source } }, false, asTenant(claimsFor)); + + let id: string; + beforeEach(async () => { + expect((await create(TENANT_A, SOURCE)).errors).toBeUndefined(); + ({ id } = await rowOf('visible_total')); + }); + + it('replaces the definition under the caller\'s claims, keeping database, schema and exposure', async () => { + const result = await update(id, REPLACEMENT); + expect(result.errors).toBeUndefined(); + expect(result.data.updateFunctionFromSource).toMatchObject({ + clientMutationId: 'u1', + function: { + databaseId: TENANT_A, + schemaId: SCHEMA_A, + name: 'renamed_total', + kind: 'sql', + volatility: 'IMMUTABLE', + returns: { type: { name: 'int4' } }, + arguments: [{ name: 'threshold', type: { name: 'int4' } }], + apiExposed: true + } + }); + const row = await rowOf('renamed_total'); + expect(row.id).toBe(id); + expect(row.body_ast.stmts).toHaveLength(1); + expect(await rowOf('visible_total')).toBeUndefined(); + }); + + // RLS hides the row, so — exactly as with `updateFunction` — nothing matches and the + // payload carries no function. + it('is refused by RLS when the caller updates another database\'s function', async () => { + const before = await rowOf('visible_total'); + const result = await update(id, REPLACEMENT, TENANT_B); + expect(result.errors).toBeUndefined(); + expect(result.data.updateFunctionFromSource.function).toBeNull(); + expect(await rowOf('visible_total')).toEqual(before); + }); + + it('refuses OR REPLACE, the same text format as create, and changes nothing', async () => { + const before = await rowOf('visible_total'); + const result = await update(id, REPLACEMENT.replace('CREATE FUNCTION', 'CREATE OR REPLACE FUNCTION')); + expect(result.errors?.[0]).toMatchObject({ + message: expect.stringContaining('updateFunctionFromSource with its id'), + extensions: { code: 'FUNCTION_SOURCE_UNSUPPORTED' } + }); + expect(await rowOf('visible_total')).toEqual(before); + }); + + it('returns a parse error and changes nothing', async () => { + const before = await rowOf('visible_total'); + const source = 'CREATE FUNCTION visible_total() RETURNS int LANGUAGE sql AS $$ SELEC 1 $$'; + const result = await update(id, source); + expect(result.errors?.[0]).toMatchObject({ + message: 'syntax error at or near "SELEC"', + extensions: { code: 'FUNCTION_SOURCE_PARSE_ERROR', position: source.indexOf('SELEC') } + }); + expect(await rowOf('visible_total')).toEqual(before); + }); + }); }); diff --git a/graphile/graphile-function-source/sql/function-from-source-seed.sql b/graphile/graphile-function-source/sql/function-from-source-seed.sql index d3c5b921e2..000d6dec03 100644 --- a/graphile/graphile-function-source/sql/function-from-source-seed.sql +++ b/graphile/graphile-function-source/sql/function-from-source-seed.sql @@ -9,7 +9,7 @@ INSERT INTO metaschema_public.schema (id, database_id, name, schema_name) VALUES ('00000000-0000-4000-8000-0000000000bb', '00000000-0000-4000-8000-00000000000b', 'app_public', 'tenant_b_app_public'); GRANT USAGE ON SCHEMA metaschema_public TO authenticated; -GRANT SELECT, INSERT ON metaschema_public.function TO authenticated; +GRANT SELECT, INSERT, UPDATE ON metaschema_public.function TO authenticated; ALTER TABLE metaschema_public.function ENABLE ROW LEVEL SECURITY; diff --git a/graphile/graphile-function-source/src/function-from-source.ts b/graphile/graphile-function-source/src/function-from-source.ts index a9ee35af7e..2a426da7a4 100644 --- a/graphile/graphile-function-source/src/function-from-source.ts +++ b/graphile/graphile-function-source/src/function-from-source.ts @@ -1,7 +1,7 @@ import 'graphile-build'; import 'graphile-build-pg'; -import { pgInsertSingle, type PgResource } from '@dataplan/pg'; +import { pgInsertSingle, type PgResource,pgUpdateSingle } from '@dataplan/pg'; import { access, lambda, object } from 'grafast'; import type { GraphileConfig } from 'graphile-config'; import { GraphQLError } from 'graphql'; @@ -84,6 +84,7 @@ const fieldType = (typeName: AstNode): FieldType => { * Maps one `CREATE FUNCTION` statement onto the columns `metaschema_public.function` * accepts. Anything those columns cannot carry is refused rather than dropped; whether * the definition is *allowed* is left to the database (capability gate, RLS, validator). + * Create and update take the same text: replacing is chosen by the mutation, not the source. */ export const parseFunctionSource = async (source: string): Promise => { await loadModule(); @@ -101,7 +102,11 @@ export const parseFunctionSource = async (source: string): Promise) => { return !resource.parameters && pg?.schemaName === 'metaschema_public' && pg?.name === 'function'; }; -const findFunctionResource = (build: GraphileBuild.Build) => +/** `updateFunctionFromSource` addresses the row the way `updateFunction` does: by `id`. */ +const isIdPrimaryKey = (unique: { isPrimary?: boolean; attributes: readonly string[] }) => + Boolean(unique.isPrimary) && unique.attributes.length === 1 && unique.attributes[0] === 'id'; + +const findFunctionResource = (build: GraphileBuild.Build, behavior: 'resource:insert' | 'resource:update') => Object.values(build.pgResources).find( (resource) => - isFunctionTable(resource) && build.behavior.pgResourceMatches(resource, 'resource:insert') === true + isFunctionTable(resource) && + (behavior === 'resource:insert' || resource.uniques.some(isIdPrimaryKey)) && + build.behavior.pgResourceMatches(resource, behavior) === true ); -const INPUT_TYPE = 'CreateFunctionFromSourceInput'; +const CREATE_INPUT = 'CreateFunctionFromSourceInput'; +const UPDATE_INPUT = 'UpdateFunctionFromSourceInput'; + +/** The columns the source text decides; create and update write exactly these. */ +const definitionColumns = ($input: any) => { + const $definition = lambda(access($input, 'source'), (source) => parseFunctionSource(source as string)); + return { + name: access($definition, 'name'), + kind: access($definition, 'kind'), + arguments: access($definition, 'arguments'), + returns: access($definition, 'returns'), + volatility: access($definition, 'volatility'), + is_strict: access($definition, 'is_strict'), + security_invoker: access($definition, 'security_invoker'), + body_ast: access($definition, 'body_ast') + }; +}; /** - * `createFunctionFromSource`: author a customer function from SQL text. The server parses - * (the database never parses strings) and performs the same `pgInsertSingle` as - * `createFunction`, on the request's client under the caller's claims. + * `createFunctionFromSource` / `updateFunctionFromSource`: author or replace a customer + * function from SQL text. The server parses (the database never parses strings) and + * performs the same `pgInsertSingle` / `pgUpdateSingle` as `createFunction` / + * `updateFunction`, on the request's client under the caller's claims. */ export const FunctionFromSourcePlugin: GraphileConfig.Plugin = { name: 'FunctionFromSourcePlugin', version: '1.0.0', - after: ['PgMutationCreatePlugin', 'PgMutationPayloadEdgePlugin'], + after: ['PgMutationCreatePlugin', 'PgMutationUpdateDeletePlugin', 'PgMutationPayloadEdgePlugin'], schema: { hooks: { init(_, build) { - if (!findFunctionResource(build)) return _; const { GraphQLBoolean, GraphQLNonNull, GraphQLString } = build.graphql; - build.registerInputObjectType( - INPUT_TYPE, - { isMutationInput: true }, - () => ({ - description: 'Author a function from its `CREATE FUNCTION` source text.', - fields: () => { - const uuid = build.getInputTypeByName('UUID'); - return { + const register = (name: string, description: string, fields: (uuid: any) => Record) => + build.registerInputObjectType( + name, + { isMutationInput: true }, + () => ({ + description, + fields: () => ({ clientMutationId: { type: GraphQLString, apply(qb: any, val: unknown) { qb.setMeta('clientMutationId', val); } }, - databaseId: { type: new GraphQLNonNull(uuid) }, - schemaId: { type: new GraphQLNonNull(uuid) }, + ...fields(build.getInputTypeByName('UUID')), source: { type: new GraphQLNonNull(GraphQLString), description: 'One `CREATE FUNCTION` statement (LANGUAGE plpgsql or sql).' - }, - apiExposed: { type: GraphQLBoolean, defaultValue: false } - }; - } - }), - 'FunctionFromSourcePlugin input' - ); + } + }) + }), + 'FunctionFromSourcePlugin input' + ); + if (findFunctionResource(build, 'resource:insert')) { + register(CREATE_INPUT, 'Author a function from its `CREATE FUNCTION` source text.', (uuid) => ({ + databaseId: { type: new GraphQLNonNull(uuid) }, + schemaId: { type: new GraphQLNonNull(uuid) }, + apiExposed: { type: GraphQLBoolean, defaultValue: false } + })); + } + if (findFunctionResource(build, 'resource:update')) { + register( + UPDATE_INPUT, + 'Replace a function\'s definition from its `CREATE FUNCTION` source text.', + (uuid) => ({ id: { type: new GraphQLNonNull(uuid) } }) + ); + } return _; }, GraphQLObjectType_fields(fields, build, context) { if (!context.scope.isRootMutation) return fields; - const resource = findFunctionResource(build); - if (!resource) return fields; const { GraphQLNonNull } = build.graphql; - return build.extend( - fields, - { - createFunctionFromSource: context.fieldWithHooks( - { fieldName: 'createFunctionFromSource' }, - { - description: 'Creates a `Function` from its `CREATE FUNCTION` source text.', - type: build.getOutputTypeByName(build.inflection.createPayloadType(resource)), - args: { - input: { - type: new GraphQLNonNull(build.getInputTypeByName(INPUT_TYPE)), - applyPlan(_: unknown, $insert: unknown) { - return $insert; - } + const field = ( + fieldName: string, + description: string, + payloadType: string, + inputType: string, + plan: (fieldArgs: any) => any + ) => + context.fieldWithHooks( + { fieldName }, + { + description, + type: build.getOutputTypeByName(payloadType), + args: { + input: { + type: new GraphQLNonNull(build.getInputTypeByName(inputType)), + applyPlan(_: unknown, $mutation: unknown) { + return $mutation; } - }, - plan(_$root: unknown, fieldArgs: any) { - const $input = fieldArgs.getRaw('input'); - const $definition = lambda(access($input, 'source'), (source) => - parseFunctionSource(source as string) - ); - const $insert = pgInsertSingle(resource, { - database_id: access($input, 'databaseId'), - schema_id: access($input, 'schemaId'), - api_exposed: access($input, 'apiExposed'), - name: access($definition, 'name'), - kind: access($definition, 'kind'), - arguments: access($definition, 'arguments'), - returns: access($definition, 'returns'), - volatility: access($definition, 'volatility'), - is_strict: access($definition, 'is_strict'), - security_invoker: access($definition, 'security_invoker'), - body_ast: access($definition, 'body_ast') - }); - fieldArgs.apply($insert); - return object({ result: $insert }); } + }, + plan(_$root: unknown, fieldArgs: any) { + const $mutation = plan(fieldArgs); + fieldArgs.apply($mutation); + return object({ result: $mutation }); } - ) - }, - 'Adding createFunctionFromSource mutation' - ); + } + ); + + const added: Record = {}; + const insertable = findFunctionResource(build, 'resource:insert'); + if (insertable) { + added.createFunctionFromSource = field( + 'createFunctionFromSource', + 'Creates a `Function` from its `CREATE FUNCTION` source text.', + build.inflection.createPayloadType(insertable), + CREATE_INPUT, + (fieldArgs) => { + const $input = fieldArgs.getRaw('input'); + return pgInsertSingle(insertable, { + database_id: access($input, 'databaseId'), + schema_id: access($input, 'schemaId'), + api_exposed: access($input, 'apiExposed'), + ...definitionColumns($input) + }); + } + ); + } + const updatable = findFunctionResource(build, 'resource:update'); + if (updatable) { + added.updateFunctionFromSource = field( + 'updateFunctionFromSource', + 'Replaces a `Function`\'s definition from its source text; its database, schema and exposure are unchanged.', + build.inflection.updatePayloadType({ resource: updatable }), + UPDATE_INPUT, + (fieldArgs) => { + const $input = fieldArgs.getRaw('input'); + return pgUpdateSingle(updatable, { id: access($input, 'id') }, definitionColumns($input)); + } + ); + } + return build.extend(fields, added, 'Adding FunctionFromSource mutations'); } } } From 5de05523f6b923f5d5949cdf0adb99f76390ff58 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 19 Oct 2026 05:13:12 +0000 Subject: [PATCH 4/4] test(graphile-function-source): mechanics-only tests; authorization is the database's --- graphile/graphile-function-source/README.md | 2 +- .../function-from-source.integration.test.ts | 50 +++++-------------- .../sql/function-from-source-seed.sql | 19 ++----- .../src/function-from-source.ts | 4 +- 4 files changed, 18 insertions(+), 57 deletions(-) diff --git a/graphile/graphile-function-source/README.md b/graphile/graphile-function-source/README.md index 377a77ceb9..d3b19dd8ef 100644 --- a/graphile/graphile-function-source/README.md +++ b/graphile/graphile-function-source/README.md @@ -21,7 +21,7 @@ Adds two mutations over the `metaschema_public.function` table: Both take the same text: one plain `CREATE FUNCTION` statement. `CREATE OR REPLACE` is refused by both — replacing is what `updateFunctionFromSource` means. -The server parses `source` with [`plpgsql-parser`](https://www.npmjs.com/package/plpgsql-parser), maps it onto the table's columns (`name`, `kind`, `arguments`, `returns`, `volatility`, `is_strict`, `security_invoker`, and the hydrated `body_ast`), and writes the row on the request's own client — so RLS, triggers and validators decide exactly as they do for the generated mutations. Deleting needs no source: use the generated `deleteFunction`. +The server parses `source` with [`plpgsql-parser`](https://www.npmjs.com/package/plpgsql-parser), maps it onto the table's columns (`name`, `kind`, `arguments`, `returns`, `volatility`, `is_strict`, `security_invoker`, and the hydrated `body_ast`), and writes the same row `createFunction` / `updateFunction` would, on the request's own client. The database decides whether a definition is allowed; the plugin only parses and writes. Deleting needs no source: use the generated `deleteFunction`. Parse failures are GraphQL errors with `extensions.code = 'FUNCTION_SOURCE_PARSE_ERROR'` and, when the parser reports one, `extensions.position` (0-based offset into `source`). Constructs the table cannot represent are refused with `FUNCTION_SOURCE_UNSUPPORTED`. diff --git a/graphile/graphile-function-source/__tests__/function-from-source.integration.test.ts b/graphile/graphile-function-source/__tests__/function-from-source.integration.test.ts index ca44a0fe8b..1b3cfaf636 100644 --- a/graphile/graphile-function-source/__tests__/function-from-source.integration.test.ts +++ b/graphile/graphile-function-source/__tests__/function-from-source.integration.test.ts @@ -8,7 +8,6 @@ jest.setTimeout(120000); const workspaceRoot = join(__dirname, '../../..'); const TENANT_A = '00000000-0000-4000-8000-00000000000a'; -const TENANT_B = '00000000-0000-4000-8000-00000000000b'; const SCHEMA_A = '00000000-0000-4000-8000-0000000000aa'; const SOURCE = ` @@ -52,10 +51,6 @@ CREATE FUNCTION renamed_total(threshold integer) LANGUAGE sql IMMUTABLE AS $$ SELECT threshold * 2 $$;`; -const asTenant = (databaseId: string) => ({ - pgSettings: { role: 'authenticated', 'jwt.claims.database_id': databaseId } -}); - describe('createFunctionFromSource', () => { let pg: any; let db: any; @@ -68,7 +63,7 @@ describe('createFunctionFromSource', () => { schemas: ['metaschema_public'], preset: { extends: [FunctionFromSourcePreset] }, useRoot: true, - authRole: 'authenticated' + authRole: 'postgres' }, [seed.pgpm(workspaceRoot), seed.sqlfile([join(__dirname, '../sql/function-from-source-seed.sql')])] ); @@ -89,16 +84,13 @@ describe('createFunctionFromSource', () => { await pg.afterEach(); }); - const create = (databaseId: string, source: string, claimsFor = databaseId) => - query( - CREATE, - { input: { clientMutationId: 'm1', databaseId, schemaId: SCHEMA_A, source, apiExposed: true } }, - false, - asTenant(claimsFor) - ); + const create = (source: string) => + query(CREATE, { + input: { clientMutationId: 'm1', databaseId: TENANT_A, schemaId: SCHEMA_A, source, apiExposed: true } + }); - it('inserts the parsed definition under the caller\'s claims', async () => { - const result = await create(TENANT_A, SOURCE); + it('inserts the mapped columns, datums intact', async () => { + const result = await create(SOURCE); expect(result.errors).toBeUndefined(); const { clientMutationId, function: fn } = result.data.createFunctionFromSource; expect(clientMutationId).toBe('m1'); @@ -124,15 +116,9 @@ describe('createFunctionFromSource', () => { ]); }); - it('is refused by RLS when the caller writes into another database', async () => { - const result = await create(TENANT_A, SOURCE, TENANT_B); - expect(result.data?.createFunctionFromSource ?? null).toBeNull(); - expect(result.errors?.[0].message).toMatch(/row-level security policy/); - }); - it('returns a parse error with the parser position and inserts nothing', async () => { const source = 'CREATE FUNCTION f() RETURNS int LANGUAGE sql AS $$ SELEC 1 $$'; - const result = await create(TENANT_A, source); + const result = await create(source); expect(result.errors?.[0]).toMatchObject({ message: 'syntax error at or near "SELEC"', extensions: { code: 'FUNCTION_SOURCE_PARSE_ERROR', position: source.indexOf('SELEC') } @@ -142,9 +128,7 @@ describe('createFunctionFromSource', () => { }); describe('updateFunctionFromSource', () => { - // Read back as tenant A: the last mutation's claims are still set on this transaction. const rowOf = async (name: string) => { - await pg.query(`SELECT set_config('jwt.claims.database_id', $1, true)`, [TENANT_A]); const { rows } = await pg.query( `SELECT id, database_id, schema_id, name, kind, volatility, arguments, returns, api_exposed, body_ast FROM metaschema_public.function WHERE database_id = $1 AND name = $2`, @@ -153,16 +137,16 @@ describe('createFunctionFromSource', () => { return rows[0]; }; - const update = async (id: string, source: string, claimsFor = TENANT_A) => - query(UPDATE, { input: { clientMutationId: 'u1', id, source } }, false, asTenant(claimsFor)); + const update = (id: string, source: string) => + query(UPDATE, { input: { clientMutationId: 'u1', id, source } }); let id: string; beforeEach(async () => { - expect((await create(TENANT_A, SOURCE)).errors).toBeUndefined(); + expect((await create(SOURCE)).errors).toBeUndefined(); ({ id } = await rowOf('visible_total')); }); - it('replaces the definition under the caller\'s claims, keeping database, schema and exposure', async () => { + it('writes only the definition columns, keeping database, schema and exposure', async () => { const result = await update(id, REPLACEMENT); expect(result.errors).toBeUndefined(); expect(result.data.updateFunctionFromSource).toMatchObject({ @@ -184,16 +168,6 @@ describe('createFunctionFromSource', () => { expect(await rowOf('visible_total')).toBeUndefined(); }); - // RLS hides the row, so — exactly as with `updateFunction` — nothing matches and the - // payload carries no function. - it('is refused by RLS when the caller updates another database\'s function', async () => { - const before = await rowOf('visible_total'); - const result = await update(id, REPLACEMENT, TENANT_B); - expect(result.errors).toBeUndefined(); - expect(result.data.updateFunctionFromSource.function).toBeNull(); - expect(await rowOf('visible_total')).toEqual(before); - }); - it('refuses OR REPLACE, the same text format as create, and changes nothing', async () => { const before = await rowOf('visible_total'); const result = await update(id, REPLACEMENT.replace('CREATE FUNCTION', 'CREATE OR REPLACE FUNCTION')); diff --git a/graphile/graphile-function-source/sql/function-from-source-seed.sql b/graphile/graphile-function-source/sql/function-from-source-seed.sql index 000d6dec03..3cee9adc1a 100644 --- a/graphile/graphile-function-source/sql/function-from-source-seed.sql +++ b/graphile/graphile-function-source/sql/function-from-source-seed.sql @@ -1,19 +1,6 @@ --- Two tenants, each with one schema, and an RLS policy on metaschema_public.function --- that only lets a caller write and read its own database's rows (jwt.claims.database_id). +-- One database with one schema for the function rows to belong to. INSERT INTO metaschema_public.database (id, name) VALUES - ('00000000-0000-4000-8000-00000000000a', 'tenant_a'), - ('00000000-0000-4000-8000-00000000000b', 'tenant_b'); + ('00000000-0000-4000-8000-00000000000a', 'tenant_a'); INSERT INTO metaschema_public.schema (id, database_id, name, schema_name) VALUES - ('00000000-0000-4000-8000-0000000000aa', '00000000-0000-4000-8000-00000000000a', 'app_public', 'tenant_a_app_public'), - ('00000000-0000-4000-8000-0000000000bb', '00000000-0000-4000-8000-00000000000b', 'app_public', 'tenant_b_app_public'); - -GRANT USAGE ON SCHEMA metaschema_public TO authenticated; -GRANT SELECT, INSERT, UPDATE ON metaschema_public.function TO authenticated; - -ALTER TABLE metaschema_public.function ENABLE ROW LEVEL SECURITY; - -CREATE POLICY function_own_database ON metaschema_public.function - TO authenticated - USING (database_id = current_setting('jwt.claims.database_id')::uuid) - WITH CHECK (database_id = current_setting('jwt.claims.database_id')::uuid); + ('00000000-0000-4000-8000-0000000000aa', '00000000-0000-4000-8000-00000000000a', 'app_public', 'tenant_a_app_public'); diff --git a/graphile/graphile-function-source/src/function-from-source.ts b/graphile/graphile-function-source/src/function-from-source.ts index 2a426da7a4..4a5c552d8d 100644 --- a/graphile/graphile-function-source/src/function-from-source.ts +++ b/graphile/graphile-function-source/src/function-from-source.ts @@ -83,7 +83,7 @@ const fieldType = (typeName: AstNode): FieldType => { /** * Maps one `CREATE FUNCTION` statement onto the columns `metaschema_public.function` * accepts. Anything those columns cannot carry is refused rather than dropped; whether - * the definition is *allowed* is left to the database (capability gate, RLS, validator). + * the definition is *allowed* is left to the database. * Create and update take the same text: replacing is chosen by the mutation, not the source. */ export const parseFunctionSource = async (source: string): Promise => { @@ -239,7 +239,7 @@ const definitionColumns = ($input: any) => { * `createFunctionFromSource` / `updateFunctionFromSource`: author or replace a customer * function from SQL text. The server parses (the database never parses strings) and * performs the same `pgInsertSingle` / `pgUpdateSingle` as `createFunction` / - * `updateFunction`, on the request's client under the caller's claims. + * `updateFunction`, on the request's own client. */ export const FunctionFromSourcePlugin: GraphileConfig.Plugin = { name: 'FunctionFromSourcePlugin',