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