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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
38 changes: 38 additions & 0 deletions graphile/graphile-function-source/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# graphile-function-source

<p align="center" width="100%">
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
</p>

<p align="center" width="100%">
<a href="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml">
<img height="20" src="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml/badge.svg" />
</a>
<a href="https://github.com/constructive-io/constructive/blob/main/LICENSE"><img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/></a>
<a href="https://www.npmjs.com/package/graphile-function-source"><img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/constructive?filename=graphile%2Fgraphile-function-source%2Fpackage.json"/></a>
</p>

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.
Original file line number Diff line number Diff line change
@@ -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 = <T = unknown>(
query: string,
variables?: Record<string, unknown>,
commit?: boolean,
reqOptions?: Record<string, unknown>
) => Promise<GraphQLResponse<T>>;

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<void>;

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<any>(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<any>(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);
});
});
});
Original file line number Diff line number Diff line change
@@ -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);
});
});
18 changes: 18 additions & 0 deletions graphile/graphile-function-source/jest.config.js
Original file line number Diff line number Diff line change
@@ -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/*']
};
Loading
Loading