feat(ddl): typed CREATE FUNCTION / CREATE TRIGGER for PG (Phase 1, #91) - #191
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 FUNCTIONwith a typed expression body.LANGUAGE sqlemitsAS \$\$ SELECT <expr> \$\$;LANGUAGE plpgsqlwraps the same body inAS \$\$ BEGIN RETURN <expr>; END \$\$. Full optional-clause surface:OR REPLACE,IMMUTABLE/STABLE,STRICT,PARALLEL safe|restricted|unsafe,SECURITY DEFINER|INVOKER, argDEFAULTandmode: IN|OUT|INOUT|VARIADIC.DROP FUNCTIONwith.argTypes(...)for overload disambiguation,IF EXISTS,CASCADE.CREATE TRIGGERstandalone DDL —BEFORE/AFTER/INSTEAD OF, multi-event (AFTER INSERT OR UPDATE OR DELETE),UPDATE OF (cols),FOR EACH ROW/STATEMENT, optionalWHENpredicate,EXECUTE FUNCTION fn(args), andCREATE CONSTRAINT TRIGGER ... DEFERRABLE INITIALLY DEFERRED.DROP TRIGGERwithON table,IF EXISTS,CASCADE.Call-site type inference is the payoff.
createFunction(...).build()returnsTypedFunction<Args, Ret>whose.call({...})producesExpression<Ret>referencing the function by name. The args generic flows from.args({ price: arg("numeric") })through the body callback into the call site:SQL → TS scalar mapping:
numeric/integer/bigint/float/… →number,text/varchar/char/uuid/… →string,boolean/bool→boolean,date/timestamp/timestamptz/… →Date, anything else →unknown(override via the explicitarg<MyBrand>("...")generic).PG only.
CREATE_FUNCTION/CREATE_TRIGGERfeature flags refuse on MySQL / SQLite / MSSQL viaUnsupportedDialectFeatureError. The dialect grammars diverge enough (procedural-only bodies, different return-type position, noLANGUAGE SQLform on MySQL, SQLite has noCREATE FUNCTIONat 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_OPmagic variables. Phase 1'sbody: ExpressionNodeshape widens toExpressionNode | StatementBlockNodeadditively per the ADR; Phase 1 call sites stay valid. Until then, the documented escape hatch for trigger functions that needNEW.<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) +FunctionArginterface, added to theDDLNodeunion.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.ts—CREATE_FUNCTIONandCREATE_TRIGGERfeature flags (PG-only).src/printer/ddl.ts—printCreateFunction/printDropFunction/printCreateTrigger/printDropTriggerplus aparamcase in the DDLprintExpr(param-binding into DDL contexts — needed for any future caller that injects aparamnode into a function body).src/sumak.ts—SchemaBuilder.createFunction / dropFunction / createTrigger / dropTriggermethods; node types added toDDL_NODE_TYPES.src/index.ts— re-exports for the four builders, thearghelper, the four AST node types, plusFunctionArg,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).function.test.ts+trigger.test.ts.inc_rt(41)returns 42; audit-log trigger lands the expected row inusers_audit_rt.UnsupportedDialectFeatureError.Deviations from the ADR
None of substance. Two minor extensions:
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")producesFunctionArgSpec<unknown, "numeric">, whichSqlToTs<"numeric">then resolves tonumber). Without this, every arg resolved tounknownat the body callback site. Backward-compatible with the ADR's spec.paramhandling to the DDL printer's expression context. The ADR's expression-body shape can land params via nestedval(x)calls that route throughprintRaw, but a hand-rolled AST with aparamnode in a function body would have hit the printer'sdefaultthrow. Now it pushes the param and emits\$N(PG) /?(other) — matches the DML printer contract.🤖 Generated with Claude Code