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..d3b19dd8ef
--- /dev/null
+++ b/graphile/graphile-function-source/README.md
@@ -0,0 +1,38 @@
+# graphile-function-source
+
+
+
+
+
+
+
+
+
+
+
+
+
+Author PostgreSQL functions from `CREATE FUNCTION` source text in PostGraphile v5.
+
+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 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`.
+
+## 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-function-source/__tests__/function-from-source.integration.test.ts b/graphile/graphile-function-source/__tests__/function-from-source.integration.test.ts
new file mode 100644
index 0000000000..1b3cfaf636
--- /dev/null
+++ b/graphile/graphile-function-source/__tests__/function-from-source.integration.test.ts
@@ -0,0 +1,192 @@
+import type { GraphQLResponse } from 'graphile-test';
+import { getConnections, seed } from 'graphile-test';
+import { join } from 'path';
+
+import { FunctionFromSourcePreset } from '../src';
+
+jest.setTimeout(120000);
+
+const workspaceRoot = join(__dirname, '../../..');
+const TENANT_A = '00000000-0000-4000-8000-00000000000a';
+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 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 $$;`;
+
+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: [FunctionFromSourcePreset] },
+ useRoot: true,
+ authRole: 'postgres'
+ },
+ [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 = (source: string) =>
+ query(CREATE, {
+ input: { clientMutationId: 'm1', databaseId: TENANT_A, schemaId: SCHEMA_A, source, apiExposed: true }
+ });
+
+ 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');
+ 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 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',
+ 'found'
+ ]);
+ });
+
+ 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(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);
+ });
+
+ describe('updateFunctionFromSource', () => {
+ const rowOf = async (name: string) => {
+ 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 = (id: string, source: string) =>
+ query(UPDATE, { input: { clientMutationId: 'u1', id, source } });
+
+ let id: string;
+ beforeEach(async () => {
+ expect((await create(SOURCE)).errors).toBeUndefined();
+ ({ id } = await rowOf('visible_total'));
+ });
+
+ 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({
+ 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();
+ });
+
+ 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/__tests__/function-from-source.test.ts b/graphile/graphile-function-source/__tests__/function-from-source.test.ts
new file mode 100644
index 0000000000..78a592ffd4
--- /dev/null
+++ b/graphile/graphile-function-source/__tests__/function-from-source.test.ts
@@ -0,0 +1,132 @@
+import {
+ FUNCTION_SOURCE_PARSE_ERROR,
+ FUNCTION_SOURCE_UNSUPPORTED,
+ parseFunctionSource
+} from '../src';
+
+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-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-function-source/sql/function-from-source-seed.sql b/graphile/graphile-function-source/sql/function-from-source-seed.sql
new file mode 100644
index 0000000000..3cee9adc1a
--- /dev/null
+++ b/graphile/graphile-function-source/sql/function-from-source-seed.sql
@@ -0,0 +1,6 @@
+-- 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');
+
+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');
diff --git a/graphile/graphile-function-source/src/function-from-source.ts b/graphile/graphile-function-source/src/function-from-source.ts
new file mode 100644
index 0000000000..4a5c552d8d
--- /dev/null
+++ b/graphile/graphile-function-source/src/function-from-source.ts
@@ -0,0 +1,361 @@
+import 'graphile-build';
+import 'graphile-build-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';
+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.
+ * 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();
+
+ 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; to replace a function, send CREATE FUNCTION to updateFunctionFromSource with its id'
+ );
+ }
+ 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';
+};
+
+/** `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) &&
+ (behavior === 'resource:insert' || resource.uniques.some(isIdPrimaryKey)) &&
+ build.behavior.pgResourceMatches(resource, behavior) === true
+ );
+
+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` / `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 own client.
+ */
+export const FunctionFromSourcePlugin: GraphileConfig.Plugin = {
+ name: 'FunctionFromSourcePlugin',
+ version: '1.0.0',
+ after: ['PgMutationCreatePlugin', 'PgMutationUpdateDeletePlugin', 'PgMutationPayloadEdgePlugin'],
+ schema: {
+ hooks: {
+ init(_, build) {
+ const { GraphQLBoolean, GraphQLNonNull, GraphQLString } = build.graphql;
+ 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);
+ }
+ },
+ ...fields(build.getInputTypeByName('UUID')),
+ source: {
+ type: new GraphQLNonNull(GraphQLString),
+ description: 'One `CREATE FUNCTION` statement (LANGUAGE plpgsql or sql).'
+ }
+ })
+ }),
+ '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 { GraphQLNonNull } = build.graphql;
+ 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 $mutation = plan(fieldArgs);
+ fieldArgs.apply($mutation);
+ return object({ result: $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');
+ }
+ }
+ }
+};
+
+export const FunctionFromSourcePreset: GraphileConfig.Preset = {
+ plugins: [FunctionFromSourcePlugin]
+};
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 60bf76b48c..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:^",
diff --git a/graphile/graphile-settings/src/presets/constructive-preset.ts b/graphile/graphile-settings/src/presets/constructive-preset.ts
index f99327d7af..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';
@@ -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..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