Skip to content

feat(ddl): typed CREATE FUNCTION / CREATE TRIGGER for PG (Phase 1, #91) - #191

Merged
productdevbook merged 1 commit into
mainfrom
feat/typed-functions-triggers
May 20, 2026
Merged

productdevbook merged 1 commit into
mainfrom
feat/typed-functions-triggers

Conversation

@productdevbook

Copy link
Copy Markdown
Owner

Summary

Implementation of ADR 005 (PR #190) Phase 1 — typed PostgreSQL functions and triggers as code. References issue #91; does not close it (the issue's broader thesis spans Phase 1 + Phase 2 + RLS; the latter shipped earlier in #172 / #179, and Phase 2 procedural plpgsql is deferred per the ADR).

Four new DDL surfaces:

  • CREATE FUNCTION with a typed expression body. LANGUAGE sql emits AS \$\$ SELECT <expr> \$\$; LANGUAGE plpgsql wraps the same body in AS \$\$ BEGIN RETURN <expr>; END \$\$. Full optional-clause surface: OR REPLACE, IMMUTABLE / STABLE, STRICT, PARALLEL safe|restricted|unsafe, SECURITY DEFINER|INVOKER, arg DEFAULT and mode: IN|OUT|INOUT|VARIADIC.
  • DROP FUNCTION with .argTypes(...) for overload disambiguation, IF EXISTS, CASCADE.
  • CREATE TRIGGER standalone DDL — BEFORE / AFTER / INSTEAD OF, multi-event (AFTER INSERT OR UPDATE OR DELETE), UPDATE OF (cols), FOR EACH ROW / STATEMENT, optional WHEN predicate, EXECUTE FUNCTION fn(args), and CREATE CONSTRAINT TRIGGER ... DEFERRABLE INITIALLY DEFERRED.
  • DROP TRIGGER with ON table, IF EXISTS, CASCADE.

Call-site type inference is the payoff. createFunction(...).build() returns TypedFunction<Args, Ret> whose .call({...}) produces Expression<Ret> referencing the function by name. The args generic flows from .args({ price: arg("numeric") }) through the body callback into the call site:

const taxes = db.schema.createFunction("compute_taxes")
  .args({ price: arg("numeric"), tax: arg("numeric", { default: val(0.2) }) })
  .returns("numeric")
  .languageSql()
  .body(({ price, tax }) => mul(price, add(val(1), tax)))
  .build()

db.selectFrom("products").select({
  withTax: taxes.call({ price: typedCol("price"), tax: val(0.18) }),
})
// SELECT compute_taxes("price", 0.18) AS "withTax" FROM "products"

SQL → TS scalar mapping: numeric/integer/bigint/float/… → number, text/varchar/char/uuid/… → string, boolean/boolboolean, date/timestamp/timestamptz/… → Date, anything else → unknown (override via the explicit arg<MyBrand>("...") generic).

PG only. CREATE_FUNCTION / CREATE_TRIGGER feature flags refuse on MySQL / SQLite / MSSQL via UnsupportedDialectFeatureError. The dialect grammars diverge enough (procedural-only bodies, different return-type position, no LANGUAGE SQL form on MySQL, SQLite has no CREATE FUNCTION at all) that they need separate AST + printer work — scheduled for follow-ups.

Phase 2 (deferred)

Procedural plpgsql — IF / LOOP / RAISE, variable declarations, NEW / OLD / TG_OP magic variables. Phase 1's body: ExpressionNode shape widens to ExpressionNode | StatementBlockNode additively per the ADR; Phase 1 call sites stay valid. Until then, the documented escape hatch for trigger functions that need NEW.<col> references is raw SQL (the integration test demonstrates this pattern). See ADR 005 — Phase 2 sketch.

Files

  • src/ast/ddl-nodes.ts — four new node types (CreateFunctionNode, DropFunctionNode, CreateTriggerNode, DropTriggerNode) + FunctionArg interface, added to the DDLNode union.
  • src/builder/ddl/function.ts (new) — CreateFunctionBuilder<Args, Ret> with typed .args(...) / .returns(...) / .body(...) chain; DropFunctionBuilder; arg<S, T>() helper.
  • src/builder/ddl/trigger.ts (new) — CreateTriggerBuilder / DropTriggerBuilder.
  • src/dialect/features.tsCREATE_FUNCTION and CREATE_TRIGGER feature flags (PG-only).
  • src/printer/ddl.tsprintCreateFunction / printDropFunction / printCreateTrigger / printDropTrigger plus a param case in the DDL printExpr (param-binding into DDL contexts — needed for any future caller that injects a param node into a function body).
  • src/sumak.tsSchemaBuilder.createFunction / dropFunction / createTrigger / dropTrigger methods; node types added to DDL_NODE_TYPES.
  • src/index.ts — re-exports for the four builders, the arg helper, the four AST node types, plus FunctionArg, FunctionArgSpec, SqlToTs, TypedFunction.
  • test/ddl/function.test.ts (new) — 38 tests covering builder shape, PG emission, call-site inference (including cross-function call), DROP, non-PG rejection, PGlite roundtrip (SQL and plpgsql bodies).
  • test/ddl/trigger.test.ts (new) — 37 tests covering every timing/event combo, FOR EACH ROW vs STATEMENT, WHEN clauses, multi-event, constraint triggers (with mutual-exclusion checks), DROP, non-PG rejection, PGlite roundtrip (audit-log pattern using a raw plpgsql trigger function).

Test plan

  • pnpm fmt && pnpm lint && pnpm typecheck && pnpm vitest run — all green (3280 tests pass, 65 skipped).
  • New tests: 75 specs across function.test.ts + trigger.test.ts.
  • PGlite roundtrip: compute_taxes called from SELECT returns 118 for (100, 0.18); plpgsql inc_rt(41) returns 42; audit-log trigger lands the expected row in users_audit_rt.
  • MySQL / SQLite / MSSQL — every function and trigger surface throws UnsupportedDialectFeatureError.

Deviations from the ADR

None of substance. Two minor extensions:

  • The arg() helper takes a generic <S extends string, T = unknown> rather than a bare <T = unknown> so the SQL type literal survives into the inference path (arg("numeric") produces FunctionArgSpec<unknown, "numeric">, which SqlToTs<"numeric"> then resolves to number). Without this, every arg resolved to unknown at the body callback site. Backward-compatible with the ADR's spec.
  • Added param handling to the DDL printer's expression context. The ADR's expression-body shape can land params via nested val(x) calls that route through printRaw, but a hand-rolled AST with a param node in a function body would have hit the printer's default throw. Now it pushes the param and emits \$N (PG) / ? (other) — matches the DML printer contract.

🤖 Generated with Claude Code

Implements ADR 005 Phase 1 — typed PostgreSQL CREATE FUNCTION with an
expression body callback, CREATE TRIGGER referencing a function by
name, the matching DROP statements, and call-site type inference that
turns `taxes.call({ price, tax })` into Expression<number> usable
inside any other typed query.

The body callback receives `{ price: Expression<number>, tax:
Expression<number> }` inferred from the args' SQL types (numeric →
number, text → string, boolean → boolean, date/timestamp → Date,
others → unknown). The same inference flows into `.build().call(...)`
so schema + body + every invocation share one source of typed truth.

PG only — feature flags CREATE_FUNCTION and CREATE_TRIGGER refuse on
MySQL / SQLite / MSSQL with a pointer at the dialect divergence (their
grammars need separate AST + printer work, scheduled for later).
Procedural plpgsql (IF / LOOP / RAISE / NEW / OLD) is Phase 2,
deferred per the ADR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@productdevbook
productdevbook merged commit cf56048 into main May 20, 2026
1 check passed
@productdevbook
productdevbook deleted the feat/typed-functions-triggers branch May 20, 2026 02:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant