From dc57ccd64a5ccf7bf010a8c09248870bf97e62ee Mon Sep 17 00:00:00 2001 From: Rhae-Shane Date: Sun, 20 Sep 2026 23:54:35 +0530 Subject: [PATCH 01/10] docs: shape postgres SQL functions project for nanoid defaults Capture the client vs DB default plane mismatch, issue draft, and sliced plan so maintainers can direction-fit before larger follow-ups. Co-authored-by: Cursor Signed-off-by: Rhae-Shane Co-authored-by: Cursor --- projects/postgres-sql-functions/README.md | 30 +++++++ .../postgres-sql-functions/github-issue.md | 61 ++++++++++++++ projects/postgres-sql-functions/plan.md | 42 ++++++++++ projects/postgres-sql-functions/spec.md | 83 +++++++++++++++++++ 4 files changed, 216 insertions(+) create mode 100644 projects/postgres-sql-functions/README.md create mode 100644 projects/postgres-sql-functions/github-issue.md create mode 100644 projects/postgres-sql-functions/plan.md create mode 100644 projects/postgres-sql-functions/spec.md diff --git a/projects/postgres-sql-functions/README.md b/projects/postgres-sql-functions/README.md new file mode 100644 index 000000000000..a2402a6de855 --- /dev/null +++ b/projects/postgres-sql-functions/README.md @@ -0,0 +1,30 @@ +# Postgres SQL functions + +Shaping and implementation for first-class Postgres `function` entities so migrate can `CREATE`/`DROP` user-defined functions (the `nanoid` / raw SQL default footgun). + +| Artifact | Path | +|---|---| +| Spec | [`spec.md`](spec.md) | +| Plan | [`plan.md`](plan.md) | +| GitHub issue draft | [`github-issue.md`](github-issue.md) | + +## File the issue + +This environment does not have the official GitHub CLI. To open the issue: + +1. Install [GitHub CLI](https://cli.github.com/) (not the npm `gh` package). +2. From the repo root: + +```bash +gh issue create --title "Custom Postgres functions as schema objects (DB-side nanoid / raw SQL defaults break migrate)" --body-file projects/postgres-sql-functions/github-issue.md +``` + +Or paste [`github-issue.md`](github-issue.md) into https://github.com/prisma/orm/issues/new. + +## What landed in-tree (MVP) + +- **Diagnostics:** raw `` sql`nanoid(...)` `` / `dbgenerated("nanoid(...)")` refused when the expression looks like a client generator (`PSL_RAW_DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR` / `CONTRACT.DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR`). +- **Entity:** `PostgresFunction` + PSL `function` block + TS `pgFunction(...)` on the entities list. +- **Migrations:** `createFunction` / `dropFunction` in the `dep` / `dropType` buckets so CREATE FUNCTION runs before CREATE TABLE. + +Prefer client `@default(nanoid(16))` / `field.generated(nanoid())` when the app always inserts through Prisma. For a DB-side helper, name it something other than a Prisma generator (e.g. `app_nanoid`) and declare it with `pgFunction` / `function`. diff --git a/projects/postgres-sql-functions/github-issue.md b/projects/postgres-sql-functions/github-issue.md new file mode 100644 index 000000000000..197c2f0bfecb --- /dev/null +++ b/projects/postgres-sql-functions/github-issue.md @@ -0,0 +1,61 @@ +# GitHub issue draft — file on prisma/orm + +> **Status:** Ready to file. This environment does not have the official GitHub CLI (`gh` resolves to an unrelated npm package). Paste this into a new issue at https://github.com/prisma/orm/issues/new or run `gh issue create` after installing https://cli.github.com/. + +## Title + +Custom Postgres functions as schema objects (DB-side `nanoid` / raw SQL defaults break migrate) + +## Body + +### Problem + +When a schema uses a **database** default that calls a user-defined Postgres function that is not built in, migrate emits only the column `DEFAULT` and fails at apply time: + +```prisma +model User { + id String @id @default(dbgenerated("nanoid(16)")) @db.VarChar(16) +} +``` + +or (Prisma 8 direction): + +```prisma +id String @id @default(sql`nanoid(16)`) @db.VarChar(16) +``` + +Postgres reports something like `function nanoid(integer) does not exist`. Users must hand-edit the migration to add `CREATE OR REPLACE FUNCTION nanoid(...)` before the table/column DDL. + +This is easy to confuse with Prisma’s **client-side** generator of the same name: + +| Authoring | Plane | Migration | +|---|---|---| +| `@default(nanoid(16))` / `field.generated(nanoid())` | Client generates before insert | No `DEFAULT` | +| `@default(dbgenerated("nanoid(16)"))` / `` @default(sql`nanoid(16)`) `` | DB must already have `nanoid(int)` | `DEFAULT (nanoid(16))` only | + +### Why Prisma does not invent the function today + +The migration planner renders authored default expressions verbatim after a syntax safety check. It does **not** synthesize `CREATE FUNCTION` from a default expression. That matches the design rule: authoring declares, the planner reconciles — never invent undeclared schema objects (see ADR 244 / check-constraint unification). + +Docs already name the long-term recipe: pack-contributed entity kinds for “custom functions, views” beside RLS ([Adapters & Targets](https://github.com/prisma/orm/blob/main/docs/architecture%20docs/subsystems/5.%20Adapters%20%26%20Targets.md)). + +### Proposed directions (need maintainer pick) + +1. **Diagnostics / docs** — Warn or error when a raw SQL default expression looks like a known client generator (`nanoid`, `cuid`, `ulid`, `uuid`, …); document client vs DB defaults and the hand `CREATE FUNCTION` workaround. +2. **First-class Postgres `function` entity** — PSL/TS block → contract IR → schema IR → `CREATE`/`DROP FUNCTION` in migrations (create in the `dep` bucket like native enums), with defaults still written as raw SQL that call the declared function. Do **not** invent a named Prisma default that means “DB nanoid” (conflicts with client `nanoid()`; see remove-dbgenerated D6). + +Shaping docs for (2) live under `projects/postgres-sql-functions/` on the contribution branch. + +### Immediate workaround + +Prefer client-side generation when the app always inserts through Prisma: + +```ts +id: field.generated(nanoid({ size: 16 })).id() +``` + +Only use a DB-side `nanoid` when Postgres must generate the value (raw SQL inserts, multi-writer without the client). Until (2) lands, that path still requires a hand-written `CREATE FUNCTION` in the migration. + +### Ask + +Which scope should the first PR target — (1), (2), or (1) then (2)? Any constraints on opaque function bodies, introspection, or extension-space ownership (ADR 212)? diff --git a/projects/postgres-sql-functions/plan.md b/projects/postgres-sql-functions/plan.md new file mode 100644 index 000000000000..e2bcd5843833 --- /dev/null +++ b/projects/postgres-sql-functions/plan.md @@ -0,0 +1,42 @@ +# Project plan — Postgres SQL functions + +Spec: [`spec.md`](spec.md). Issue draft: [`github-issue.md`](github-issue.md). + +## Slices + +| Slice | Deliverable | Depends on | +|---|---|---| +| A | Diagnostics + docs (client-generator lookalike in raw SQL defaults) | — | +| B | Contract IR + authoring (TS `pgFunction` + PSL `function` block) | A (docs context) | +| C | Schema IR + CREATE/DROP DDL + planner classification | B | +| D | Integration test: nanoid function + column default orders correctly | C | + +Slices B–D form the MVP entity. A can merge alone. + +## Slice A — Diagnostics + docs + +1. Extend [`default-sql-body.ts`](../../packages/2-sql/1-core/contract/src/default-sql-body.ts) with `clientGeneratorSqlDefaultBody(body)` returning the matched generator name or `undefined`. +2. Wire into PSL sql-tag / dbgenerated lowering and TypeScript `sql` / `defaultSql` paths with diagnostic codes documented in [`error-reference.md`](../../docs/reference/error-reference.md). +3. Short doc note in contract-psl README + ids README linking client vs DB. +4. Unit tests for match / non-match cases (`gen_random_uuid()` must not match). + +## Slice B — IR + authoring + +1. `PostgresFunction` IR class + arktype schema + `functionEntityKind`. +2. Register in `postgresAuthoringEntityTypes` / PSL block / `composeSqlEntityKinds`. +3. TS helper `pgFunction({ name, signature, returns, body, … })` under `@prisma/postgres` contract surface (mirror `nativeEnum`). +4. Authoring tests. + +## Slice C — Migrations + +1. `PostgresFunctionSchemaNode` + namespace children + contract→schema projection. +2. `CreateFunctionCall` / `DropFunctionCall` + DDL render + `classifyCall` (`dep` / `dropType`). +3. Planner unit tests. + +## Slice D — Happy path + +Integration test: contract with `nanoid` function entity + column `` .default(sql`nanoid(16)`) `` plans CREATE FUNCTION before CREATE TABLE. + +Note: Slice A’s diagnostic will refuse bare `nanoid(16)` as a raw default. The happy path for DB-side nanoid after A must either (a) use a **different** DB function name (e.g. `app_nanoid(16)`) in the default expression, or (b) allow an authoring escape (declared function entity with matching name suppresses the diagnostic). **Decision for this project:** (b) — if a `function` entity with `functionName === matchedName` exists in the same contract namespace in scope at build time, suppress the lookalike diagnostic. Until entity authoring lands, tests for DB-side defaults use a non-client name (`app_nanoid`) or temporarily test the diagnostic only. Slice D uses `app_nanoid` for the default expression to keep A and D independent, documenting that renaming avoids the client-generator collision. + +Amendment: prefer **(a) `app_nanoid`** for Slice D so Slice A stays a pure string check without a contract-wide entity scan. Authors who want DB `nanoid` name the function `nanoid` and use an escape hatch later; MVP docs recommend `app_nanoid` or client `nanoid()`. diff --git a/projects/postgres-sql-functions/spec.md b/projects/postgres-sql-functions/spec.md new file mode 100644 index 000000000000..d31502c929b8 --- /dev/null +++ b/projects/postgres-sql-functions/spec.md @@ -0,0 +1,83 @@ +# Project spec — Postgres SQL functions as schema objects + +**Linear:** not yet created. **Issue draft:** [`github-issue.md`](github-issue.md). **Branch:** contribution branch cut from `main`. **Shape:** project, four slices (diagnostics first, then entity MVP). + +## Purpose + +Raw SQL column defaults can call user-defined Postgres functions (`nanoid(16)`, app-specific helpers, etc.). Today migrate emits only `DEFAULT ()`. If the function is not already in the catalog, apply fails. Users hand-edit migrations with `CREATE FUNCTION`. + +This project makes **app-owned Postgres functions first-class contract entities** so migrate creates and drops them, and adds **diagnostics** so authors do not confuse client generators (`@default(nanoid(16))`) with DB calls (`` @default(sql`nanoid(16)`) ``). + +## Background + +- Storage defaults of kind `{ kind: 'function', expression }` are rendered as `DEFAULT ()` after `checkSqlDefaultBody` ([`default-sql-body.ts`](../../packages/2-sql/1-core/contract/src/default-sql-body.ts)). No catalog check. +- Client generators (`nanoid`, `uuid`, `cuid`, `ulid`) lower to `execution.mutations.defaults` and never emit a column default ([`@internal/ids`](../../packages/1-framework/2-authoring/ids/README.md)). +- Pack-contributed entity kinds (ADR 225) already support `role`, `rls`, `policy`, `native_enum`. Docs name custom functions as a future kind ([Adapters & Targets](../../docs/architecture%20docs/subsystems/5.%20Adapters%20%26%20Targets.md)). +- Opaque SQL bodies + wire naming: checks (ADR 244) and RLS predicates. Functions store an opaque create-body string the same way — the planner does not parse SQL ASTs. +- `remove-dbgenerated` D6: database functions stay raw SQL defaults; do not dress them as Prisma named defaults beside client `nanoid()`. + +## Goals + +1. Diagnose raw SQL defaults whose expression looks like a client generator call, with a fix hint pointing at `@default(nanoid(...))` / `field.generated(nanoid())` or at declaring a DB function. +2. Author a Postgres `function` entity (TS + PSL) with an opaque SQL body. +3. Migrate plans `CREATE FUNCTION` before tables/columns (`dep` bucket) and `DROP FUNCTION` with type drops (`dropType` bucket). +4. Column defaults keep calling the function via raw SQL (`` sql`nanoid(16)` `` / `dbgenerated`); no typed function-ref in defaults for MVP. + +## Non-goals + +- Shipping a built-in Prisma `nanoid` PL/pgSQL body as a magic default. +- Auto-inventing `CREATE FUNCTION` because a default mentions a name. +- Introspection / `contract infer` of live functions (deferred). +- `ALTER FUNCTION` / body replace as a structured op (deferred; `CREATE OR REPLACE` may appear inside the opaque body). +- Views, triggers, or procedures. +- SQLite or other targets. +- Extension-owned large function dumps (ADR 212 contract spaces) — app contract only for MVP. + +## Decisions + +### D1. Postgres-only entity kind `function` + +Discriminator / entries key `'function'`. Contract IR node kind `'postgres-function'`. Registered on the Postgres pack via `AuthoringContributions.entityTypes`, same recipe as `native_enum`. + +### D2. Opaque body, author owns correctness + +The entity stores: + +- `functionName` — Postgres function name (unqualified; lives in the entity’s namespace/schema) +- `signature` — argument list as opaque text for `CREATE FUNCTION name(signature)`, e.g. `size int DEFAULT 16` +- `returns` — return type text, e.g. `text` +- `body` — function body text (the part inside `AS $$ … $$` or `AS '…'`), opaque +- optional `language` — default `plpgsql` +- optional `volatility` — default `STABLE` for MVP documentation; rendered into DDL + +The planner renders a deterministic `CREATE OR REPLACE FUNCTION` / `DROP FUNCTION` from these fields. It does not validate that `body` is correct SQL; Postgres reports errors at apply. + +### D3. Planner never synthesizes functions from defaults + +A column default that calls `nanoid(16)` does not create a function entity. Authors must declare the function in `entities` / a PSL `function` block. Diagnostics may hint that path. + +### D4. Ordering + +`createFunction` classifies as `dep` (with `createNativeEnumType`). `dropFunction` classifies as `dropType`. Fine-grained `dependsOn` from columns to functions is deferred; bucket ordering is enough for the nanoid happy path when the function is created in the same plan as the table. + +### D5. Client-generator lookalike diagnostic + +When a storage default expression matches a known client generator call shape (`nanoid`, `nanoid()`, `uuid`, `uuid(4|7)`, `cuid`, `cuid(2)`, `ulid`, with optional schema qualification stripped), authoring emits a diagnostic that distinguishes client vs DB defaults. Severity: **error** at authoring time for the known bare names (authors almost always meant the client generator). Escapes: any expression that is not a simple call of those names (e.g. `public.nanoid(16)` after we strip only unprefixed forms — actually `public.nanoid` should still warn). Decision: match the **function identifier** after stripping a single optional schema qualifier; if the name is in the client-generator set, emit `PSL_RAW_DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR` / `CONTRACT.DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR`. + +### D6. No named Prisma default for DB nanoid + +Do not register `nanoid()` as a storage default function. Client `nanoid()` stays execution-only. + +## Acceptance criteria + +- AC1. Raw `` @default(sql`nanoid(16)`) `` / `.default(sql\`nanoid(16)\`)` / `dbgenerated("nanoid(16)")` fails authoring with the lookalike diagnostic and a fix hint. +- AC2. Declaring a `function` entity and a column default that calls it produces a migration whose first relevant ops include `CREATE FUNCTION` before `CREATE TABLE` / `ADD COLUMN` with that default. +- AC3. Dropping the function entity plans `DROP FUNCTION`. +- AC4. Docs state client vs DB defaults and link to this project / error reference. + +## Out of scope for later slices + +- Introspection and infer printing of functions +- Referential check that a default names a declared function +- Cross-schema function references +- `OR REPLACE` vs create-only policy beyond what D2 renders From 228fd15d1c497cd5b6050149c57cf70ad75e9cb5 Mon Sep 17 00:00:00 2001 From: Rhae-Shane Date: Sun, 20 Sep 2026 23:55:23 +0530 Subject: [PATCH 02/10] feat(sql): refuse raw defaults that look like client ID generators Bare nanoid/uuid/cuid/ulid calls in sql`...` or dbgenerated() almost always mean the Prisma client generator, not a Postgres function. Fail authoring with a fix hint instead of emitting DEFAULT that breaks migrate. Signed-off-by: Rhae-Shane Co-authored-by: Cursor --- docs/reference/error-reference.md | 4 ++ .../1-framework/2-authoring/ids/README.md | 6 +++ .../1-core/contract/src/default-sql-body.ts | 27 ++++++++++++ .../1-core/contract/src/exports/validators.ts | 7 ++- .../2-sql/2-authoring/contract-psl/README.md | 4 +- ...nterpreter.defaults.tagged-literal.test.ts | 8 +++- .../contract-ts/src/contract-errors.ts | 1 + .../contract-ts/src/sql-default-literal.ts | 25 ++++++++++- .../test/sql-default-literal.test.ts | 14 ++++-- .../src/core/sql-default-literal-tag.ts | 31 +++++++++++-- .../2-sql/9-family/src/exports/control.ts | 1 + .../test/sql-default-literal-tag.test.ts | 44 +++++++++++++++++-- .../src/core/control-mutation-defaults.ts | 21 +++++++++ .../src/core/control-mutation-defaults.ts | 21 +++++++++ 14 files changed, 199 insertions(+), 15 deletions(-) diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 7d0df3a2ff85..b145f80e07fc 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -583,6 +583,10 @@ A list column declares `@default(autoincrement())`: `Field "." is A `` @default(sql`...`) `` body fails the SQL family's body check: `Default SQL must not contain semicolons, SQL comment tokens, dollar-quoting, or subqueries.` (the rule the migration planners apply at DDL time, run at authoring time so it has a source span), or is exactly `now()` or `autoincrement()`: `` Write @default(now()) instead of sql`now()`; now() is a Prisma default function, not raw SQL. `` The message names the tag as written (`sql`, `pg.sql` or `sqlite.sql`). Reported at the literal. +### PSL_RAW_DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR + +A raw SQL default (`` @default(sql`...`) `` or `@default(dbgenerated("..."))`) is a bare call to a Prisma client-side ID generator (`nanoid`, `uuid`, `cuid`, or `ulid`, optionally schema-qualified). That expression would become `DEFAULT (nanoid(16))` in migrate without creating the function in Postgres, so apply fails. Write `@default(nanoid(16))` (or the matching named generator) so Prisma generates the value before insert, or declare a Postgres function entity and call a database function name that is not a Prisma generator (for example `app_nanoid(16)`). Reported at the literal or `dbgenerated` call. TypeScript `` sql`nanoid(16)` `` raises `CONTRACT.DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR` with the same message. + ## ORM ### ORM.AGGREGATE_OPERATION_RESERVED diff --git a/packages/1-framework/2-authoring/ids/README.md b/packages/1-framework/2-authoring/ids/README.md index 8e3cf9eaf1b5..fdca251f80b1 100644 --- a/packages/1-framework/2-authoring/ids/README.md +++ b/packages/1-framework/2-authoring/ids/README.md @@ -68,6 +68,12 @@ import { generateId } from '@internal/ids/runtime'; const value = generateId({ id: 'uuidv4' }); ``` +## Client vs database defaults + +`nanoid()`, `uuid()`, `cuid()`, and `ulid()` are **client-side** generators: Prisma fills the value before the insert. They never require a Postgres function and never emit `DEFAULT` in migrations. + +A raw SQL default such as `` sql`nanoid(16)` `` or `@default(dbgenerated("nanoid(16)"))` means “call a database function named `nanoid`.” Postgres has no built-in `nanoid`, so migrate fails unless you create that function yourself (or declare a Postgres function entity — see [`projects/postgres-sql-functions`](../../../projects/postgres-sql-functions/spec.md)). Authoring refuses those lookalike raw defaults with `PSL_RAW_DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR` / `CONTRACT.DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR`. + ## Related docs - [Data Contract](../../../docs/architecture%20docs/subsystems/1.%20Data%20Contract.md) diff --git a/packages/2-sql/1-core/contract/src/default-sql-body.ts b/packages/2-sql/1-core/contract/src/default-sql-body.ts index c695d275e0c9..24cc846eb96e 100644 --- a/packages/2-sql/1-core/contract/src/default-sql-body.ts +++ b/packages/2-sql/1-core/contract/src/default-sql-body.ts @@ -1,5 +1,8 @@ const UNSAFE_DEFAULT_BODY = /;|--|\/\*|\$\$|\bSELECT\b/i; +/** Client-side Prisma generators that authors often paste into raw SQL defaults by mistake. */ +const CLIENT_GENERATOR_CALL = /^\s*(?:[A-Za-z_][\w$]*\.)?(nanoid|uuid|cuid|ulid)\s*\([^;]*\)\s*$/i; + /** Returns undefined when the body may be rendered as `DEFAULT ()`, else the reason. */ export function checkSqlDefaultBody(body: string): string | undefined { return UNSAFE_DEFAULT_BODY.test(body) @@ -20,3 +23,27 @@ export function reservedSqlDefaultBody(body: string): 'now' | 'autoincrement' | return undefined; } } + +/** + * When a raw SQL default is a bare call to a Prisma client-side generator (`nanoid`, `uuid`, + * `cuid`, `ulid`), returns that name. Migrate would emit `DEFAULT (nanoid(16))` without creating + * the function; authors almost always meant `@default(nanoid(16))` instead. + */ +export function clientGeneratorSqlDefaultBody(body: string): string | undefined { + const match = CLIENT_GENERATOR_CALL.exec(body); + return match?.[1]?.toLowerCase(); +} + +/** Fix hint shared by PSL and TypeScript when {@link clientGeneratorSqlDefaultBody} matches. */ +export function clientGeneratorSqlDefaultMessage(options: { + readonly generator: string; + readonly body: string; + readonly namedForm: string; +}): string { + return ( + `Raw SQL default "${options.body.trim()}" looks like Prisma's client-side ${options.generator}() generator. ` + + `Write ${options.namedForm} so Prisma generates the value before insert, ` + + 'or declare a Postgres function entity and call a database function name that is not a Prisma generator ' + + '(for example app_nanoid(16)). Migrate does not create a function from this expression alone.' + ); +} diff --git a/packages/2-sql/1-core/contract/src/exports/validators.ts b/packages/2-sql/1-core/contract/src/exports/validators.ts index a63b1d3a1e29..b0d08d0575f1 100644 --- a/packages/2-sql/1-core/contract/src/exports/validators.ts +++ b/packages/2-sql/1-core/contract/src/exports/validators.ts @@ -1,4 +1,9 @@ -export { checkSqlDefaultBody, reservedSqlDefaultBody } from '../default-sql-body'; +export { + checkSqlDefaultBody, + clientGeneratorSqlDefaultBody, + clientGeneratorSqlDefaultMessage, + reservedSqlDefaultBody, +} from '../default-sql-body'; export type { SqlToOneRelationStorage } from '../relation-storage'; export { resolveSqlToOneRelationStorage } from '../relation-storage'; export * from '../validators'; diff --git a/packages/2-sql/2-authoring/contract-psl/README.md b/packages/2-sql/2-authoring/contract-psl/README.md index ece7f77dc462..a5a8652b3d96 100644 --- a/packages/2-sql/2-authoring/contract-psl/README.md +++ b/packages/2-sql/2-authoring/contract-psl/README.md @@ -60,8 +60,8 @@ Unsupported PSL constructs in v1 (strict errors): Supported `@default(...)` surface in v1 when composed contributors provide handlers: - Storage defaults: `autoincrement()`, `now()`, literals, `dbgenerated("...")` -- Raw SQL defaults as a tagged literal: a tag followed by a string literal, as in `` @default(sql`(now() + interval '7 days')`) `` or, for a body with backticks, `@default(sql"(now() + '00:03:00'::interval)")`. The string may use any quote style, and whitespace, newlines, or comments may sit between the tag and the string. A backtick string may span lines and resolves only `` \` `` and `\\`, so `${` is ordinary text and needs no escape; `"` and `'` strings resolve the usual escapes. A backtick string is only valid after a tag; anywhere else it is `PSL_BACKTICK_STRING_REQUIRES_TAG`. A body that is exactly `now()` or `autoincrement()` (`` sql`now()` ``) is refused: write the named form, `@default(now())`. Any other body, including `NOW()` and `gen_random_uuid()`, is used as written. The body is canonicalized (line endings, a blank first and last line, common indentation) and used verbatim as the default expression. `sql` is registered by every SQL target; `pg.sql` (Postgres) and `sqlite.sql` (SQLite) are target-prefixed spellings of the same tag. An unregistered tag is `PSL_UNKNOWN_DEFAULT_LITERAL_TAG`, reported when the default is lowered. -- Execution defaults: `uuid()`, `uuid(4)`, `uuid(7)`, `cuid(2)`, `ulid()`, `nanoid()`, `nanoid(<2-255>)` +- Raw SQL defaults as a tagged literal: a tag followed by a string literal, as in `` @default(sql`(now() + interval '7 days')`) `` or, for a body with backticks, `@default(sql"(now() + '00:03:00'::interval)")`. The string may use any quote style, and whitespace, newlines, or comments may sit between the tag and the string. A backtick string may span lines and resolves only `` \` `` and `\\`, so `${` is ordinary text and needs no escape; `"` and `'` strings resolve the usual escapes. A backtick string is only valid after a tag; anywhere else it is `PSL_BACKTICK_STRING_REQUIRES_TAG`. A body that is exactly `now()` or `autoincrement()` (`` sql`now()` ``) is refused: write the named form, `@default(now())`. A bare call to a Prisma client-side generator (`nanoid`, `uuid`, `cuid`, `ulid`) is refused as `PSL_RAW_DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR` — write `@default(nanoid(16))` for client generation, or call a differently named database function after declaring a Postgres function entity (see [`projects/postgres-sql-functions`](../../../../projects/postgres-sql-functions/spec.md)). Any other body, including `NOW()` and `gen_random_uuid()`, is used as written. The body is canonicalized (line endings, a blank first and last line, common indentation) and used verbatim as the default expression. `sql` is registered by every SQL target; `pg.sql` (Postgres) and `sqlite.sql` (SQLite) are target-prefixed spellings of the same tag. An unregistered tag is `PSL_UNKNOWN_DEFAULT_LITERAL_TAG`, reported when the default is lowered. +- Execution defaults: `uuid()`, `uuid(4)`, `uuid(7)`, `cuid(2)`, `ulid()`, `nanoid()`, `nanoid(<2-255>)` — these generate values in the Prisma client before insert and do **not** emit a database `DEFAULT`. Do not wrap them in `dbgenerated` / `` sql`...` ``. - Explicitly unsupported in v1: `cuid()` (diagnostic suggests `cuid(2)`) - `dbgenerated("...")` preserves the parsed PSL string-literal contents as-is (escaped sequences are not normalized in v1). diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.tagged-literal.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.tagged-literal.test.ts index edcba34d1e00..032bba8febd5 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.tagged-literal.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.tagged-literal.test.ts @@ -173,11 +173,17 @@ describe('interpretPslDocumentToSqlContract tagged literal defaults', () => { it.each([ ['NOW()', 'v DateTime @default(sql`NOW()`)'], ['gen_random_uuid()', 'v String @default(sql`gen_random_uuid()`)'], - ['uuid()', 'v String @default(sql`uuid()`)'], + ['app_nanoid(16)', 'v String @default(sql`app_nanoid(16)`)'], ])('lowers sql`%s` verbatim', (expression, fieldLine) => { expect(columnDefault(fieldLine, 'v')).toEqual({ kind: 'function', expression }); }); + it('rejects a client-generator lookalike in a raw SQL default', () => { + expect(diagnostics('v String @default(sql`nanoid(16)`)')).toEqual([ + expect.objectContaining({ code: 'PSL_RAW_DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR' }), + ]); + }); + it('still rejects a client-side generator on a list column', () => { expect(diagnostics('tags String[] @default(uuid())')).toEqual([ expect.objectContaining({ code: 'PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED' }), diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-errors.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-errors.ts index 556ef3df27e9..0e2dd6bdc436 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-errors.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-errors.ts @@ -21,6 +21,7 @@ type ContractSubcode = | 'IDENTITY_INVALID' | 'CONSTRAINT_INVALID' | 'DEFAULT_INVALID' + | 'DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR' | 'DEFAULT_SQL_INTERPOLATION' | 'ENUM_INVALID' | 'CHECK_NAME_RESERVED' diff --git a/packages/2-sql/2-authoring/contract-ts/src/sql-default-literal.ts b/packages/2-sql/2-authoring/contract-ts/src/sql-default-literal.ts index 75ec3e2e7d98..63e242b7c17f 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/sql-default-literal.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/sql-default-literal.ts @@ -4,7 +4,12 @@ import { describeTaggedLiteralFailure, resolveTemplateTagEscapes, } from '@internal/framework-components/control'; -import { checkSqlDefaultBody, reservedSqlDefaultBody } from '@internal/sql-contract/validators'; +import { + checkSqlDefaultBody, + clientGeneratorSqlDefaultBody, + clientGeneratorSqlDefaultMessage, + reservedSqlDefaultBody, +} from '@internal/sql-contract/validators'; import { contractError } from './contract-errors'; /** @@ -39,6 +44,24 @@ export function sql(strings: TemplateStringsArray, ...values: readonly never[]): { meta: { reason: 'reserved-function', expression: canonical.body } }, ); } + const clientGenerator = clientGeneratorSqlDefaultBody(canonical.body); + if (clientGenerator !== undefined) { + throw contractError( + 'CONTRACT.DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR', + clientGeneratorSqlDefaultMessage({ + generator: clientGenerator, + body: canonical.body, + namedForm: `.default(${canonical.body.trim()})`, + }), + { + meta: { + reason: 'client-generator', + expression: canonical.body, + generator: clientGenerator, + }, + }, + ); + } const rejected = checkSqlDefaultBody(canonical.body); if (rejected !== undefined) { throw contractError('CONTRACT.DEFAULT_INVALID', rejected, { diff --git a/packages/2-sql/2-authoring/contract-ts/test/sql-default-literal.test.ts b/packages/2-sql/2-authoring/contract-ts/test/sql-default-literal.test.ts index f83a3acdd8b1..873d2c1898aa 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/sql-default-literal.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/sql-default-literal.test.ts @@ -90,14 +90,22 @@ describe('sql template tag', () => { ); }); - it('passes NOW(), gen_random_uuid() and uuid() through verbatim', () => { - expect([sql`NOW()`, sql`gen_random_uuid()`, sql`uuid()`]).toEqual([ + it('passes NOW(), gen_random_uuid() and app_nanoid(16) through verbatim', () => { + expect([sql`NOW()`, sql`gen_random_uuid()`, sql`app_nanoid(16)`]).toEqual([ { kind: 'function', expression: 'NOW()' }, { kind: 'function', expression: 'gen_random_uuid()' }, - { kind: 'function', expression: 'uuid()' }, + { kind: 'function', expression: 'app_nanoid(16)' }, ]); }); + it('rejects a client-generator lookalike with CONTRACT.DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR', () => { + expect(() => sql`nanoid(16)`).toThrow( + expect.objectContaining({ + code: 'CONTRACT.DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR', + }), + ); + }); + it('rejects a body the SQL check refuses with CONTRACT.DEFAULT_INVALID', () => { expect(() => sql`x; drop table t`).toThrow( expect.objectContaining({ diff --git a/packages/2-sql/9-family/src/core/sql-default-literal-tag.ts b/packages/2-sql/9-family/src/core/sql-default-literal-tag.ts index 63d656720769..b7d484dfbb0a 100644 --- a/packages/2-sql/9-family/src/core/sql-default-literal-tag.ts +++ b/packages/2-sql/9-family/src/core/sql-default-literal-tag.ts @@ -3,23 +3,35 @@ import type { LoweredDefaultResult, } from '@internal/framework-components/control'; import type { ContributedPslDiagnosticCode } from '@internal/framework-components/psl-ast'; -import { checkSqlDefaultBody, reservedSqlDefaultBody } from '@internal/sql-contract/validators'; +import { + checkSqlDefaultBody, + clientGeneratorSqlDefaultBody, + clientGeneratorSqlDefaultMessage, + reservedSqlDefaultBody, +} from '@internal/sql-contract/validators'; /** A `` @default(sql`...`) `` body the SQL family refuses to lower. */ export const PSL_INVALID_DEFAULT_SQL: ContributedPslDiagnosticCode = 'PSL_INVALID_DEFAULT_SQL'; +/** A raw SQL default that looks like a Prisma client-side ID generator. */ +export const PSL_RAW_DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR: ContributedPslDiagnosticCode = + 'PSL_RAW_DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR'; + /** - * The `` sql`...` `` default literal every SQL target registers: the canonical body becomes the expression verbatim. A body that is exactly `now()` or `autoincrement()` is refused so the named form is written instead. + * The `` sql`...` `` default literal every SQL target registers: the canonical body becomes the expression verbatim. A body that is exactly `now()` or `autoincrement()` is refused so the named form is written instead. A bare call to a client generator (`nanoid`, `uuid`, `cuid`, `ulid`) is refused so authors do not confuse it with a database function. */ export function sqlDefaultLiteralTagEntry(usage: string): ControlDefaultLiteralTagEntry { return { usage, documentation: "Uses the SQL in the string, verbatim, as the column's default expression.", lower: ({ literal, context }): LoweredDefaultResult => { - const reject = (message: string): LoweredDefaultResult => ({ + const reject = ( + message: string, + code: ContributedPslDiagnosticCode = PSL_INVALID_DEFAULT_SQL, + ): LoweredDefaultResult => ({ ok: false, diagnostic: { - code: PSL_INVALID_DEFAULT_SQL, + code, message, sourceId: context.sourceId, span: literal.span, @@ -31,6 +43,17 @@ export function sqlDefaultLiteralTagEntry(usage: string): ControlDefaultLiteralT `Write @default(${reserved}()) instead of ${literal.tag}\`${reserved}()\`; ${reserved}() is a Prisma default function, not raw SQL.`, ); } + const clientGenerator = clientGeneratorSqlDefaultBody(literal.body); + if (clientGenerator !== undefined) { + return reject( + clientGeneratorSqlDefaultMessage({ + generator: clientGenerator, + body: literal.body, + namedForm: `@default(${literal.body.trim()})`, + }), + PSL_RAW_DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR, + ); + } const reason = checkSqlDefaultBody(literal.body); if (reason !== undefined) return reject(reason); return { diff --git a/packages/2-sql/9-family/src/exports/control.ts b/packages/2-sql/9-family/src/exports/control.ts index ee459ad97af8..3f1900c15800 100644 --- a/packages/2-sql/9-family/src/exports/control.ts +++ b/packages/2-sql/9-family/src/exports/control.ts @@ -93,6 +93,7 @@ export type { } from '../core/migrations/types'; export { PSL_INVALID_DEFAULT_SQL, + PSL_RAW_DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR, sqlDefaultLiteralTagEntry, } from '../core/sql-default-literal-tag'; export { diff --git a/packages/2-sql/9-family/test/sql-default-literal-tag.test.ts b/packages/2-sql/9-family/test/sql-default-literal-tag.test.ts index acf9abf39ce4..e464e273cff0 100644 --- a/packages/2-sql/9-family/test/sql-default-literal-tag.test.ts +++ b/packages/2-sql/9-family/test/sql-default-literal-tag.test.ts @@ -1,4 +1,7 @@ -import { checkSqlDefaultBody } from '@internal/sql-contract/validators'; +import { + checkSqlDefaultBody, + clientGeneratorSqlDefaultBody, +} from '@internal/sql-contract/validators'; import { describe, expect, it } from 'vitest'; import { createBuiltinLikeControlMutationDefaults } from '../../2-authoring/contract-psl/test/fixtures'; import { sqlDefaultLiteralTagEntry } from '../src/core/sql-default-literal-tag'; @@ -36,6 +39,26 @@ describe('checkSqlDefaultBody', () => { }); }); +describe('clientGeneratorSqlDefaultBody', () => { + it.each([ + ['nanoid()', 'nanoid'], + ['nanoid(16)', 'nanoid'], + [' uuid(7) ', 'uuid'], + ['cuid(2)', 'cuid'], + ['ulid()', 'ulid'], + ['public.nanoid(16)', 'nanoid'], + ])('matches %j', (body, name) => { + expect(clientGeneratorSqlDefaultBody(body)).toBe(name); + }); + + it.each([['gen_random_uuid()'], ['app_nanoid(16)'], ['NOW()'], ['random()'], ['']])( + 'ignores %j', + (body) => { + expect(clientGeneratorSqlDefaultBody(body)).toBeUndefined(); + }, + ); +}); + describe('sqlDefaultLiteralTagEntry', () => { const entry = sqlDefaultLiteralTagEntry('pg.sql`...`'); @@ -78,8 +101,8 @@ describe('sqlDefaultLiteralTagEntry', () => { }); }); - it.each([['NOW()'], ['gen_random_uuid()'], ['uuid()']])( - 'lowers %s verbatim: only the exact texts now() and autoincrement() are reserved', + it.each([['NOW()'], ['gen_random_uuid()'], ['random()'], ['app_nanoid(16)']])( + 'lowers %s verbatim: client generators are refused separately', (body) => { expect(entry.lower({ literal: { tag: 'sql', body, span }, context })).toEqual({ ok: true, @@ -88,6 +111,21 @@ describe('sqlDefaultLiteralTagEntry', () => { }, ); + it.each([ + ['uuid()'], + ['nanoid()'], + ['nanoid(16)'], + ['cuid(2)'], + ['ulid()'], + ['public.nanoid(16)'], + ])('refuses client-generator lookalike %s', (body) => { + const result = entry.lower({ literal: { tag: 'sql', body, span }, context }); + expect(result).toMatchObject({ + ok: false, + diagnostic: { code: 'PSL_RAW_DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR', span }, + }); + }); + it('accepts a body that uses now() inside a larger expression', () => { expect( entry.lower({ diff --git a/packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts b/packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts index 1ee52d7e25eb..155d2229c8aa 100644 --- a/packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts +++ b/packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts @@ -1,5 +1,6 @@ import type { ExecutionMutationDefaultValue } from '@internal/contract/types'; import { + PSL_RAW_DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR, sqlDefaultLiteralTagEntry, timestampNowControlDescriptor, } from '@internal/family-sql/control'; @@ -15,6 +16,10 @@ import type { import { builtinGeneratorRegistryMetadata } from '@internal/ids'; import type { FuncCallSig } from '@internal/psl-parser'; import { int, num, oneOf, optional, str } from '@internal/psl-parser'; +import { + clientGeneratorSqlDefaultBody, + clientGeneratorSqlDefaultMessage, +} from '@internal/sql-contract/validators'; import { PG_TIMESTAMPTZ_DATE_CODEC_ID } from '@internal/target-postgres/codec-ids'; import { instantNowControlDescriptor, @@ -113,6 +118,22 @@ function lowerDbgenerated(input: { message: 'Default function "dbgenerated" argument cannot be empty.', }); } + const clientGenerator = clientGeneratorSqlDefaultBody(expression); + if (clientGenerator !== undefined) { + return { + ok: false, + diagnostic: { + code: PSL_RAW_DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR, + message: clientGeneratorSqlDefaultMessage({ + generator: clientGenerator, + body: expression, + namedForm: `@default(${expression.trim()})`, + }), + sourceId: input.context.sourceId, + span: input.call.span, + }, + }; + } return { ok: true, value: { diff --git a/packages/3-targets/6-adapters/sqlite/src/core/control-mutation-defaults.ts b/packages/3-targets/6-adapters/sqlite/src/core/control-mutation-defaults.ts index dff3f0136187..5d2cbc9d63b0 100644 --- a/packages/3-targets/6-adapters/sqlite/src/core/control-mutation-defaults.ts +++ b/packages/3-targets/6-adapters/sqlite/src/core/control-mutation-defaults.ts @@ -1,5 +1,6 @@ import type { ExecutionMutationDefaultValue } from '@internal/contract/types'; import { + PSL_RAW_DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR, sqlDefaultLiteralTagEntry, timestampNowControlDescriptor, } from '@internal/family-sql/control'; @@ -15,6 +16,10 @@ import type { import { builtinGeneratorRegistryMetadata } from '@internal/ids'; import type { FuncCallSig } from '@internal/psl-parser'; import { int, num, oneOf, optional, str } from '@internal/psl-parser'; +import { + clientGeneratorSqlDefaultBody, + clientGeneratorSqlDefaultMessage, +} from '@internal/sql-contract/validators'; import { SQLITE_BIGINT_CODEC_ID, SQLITE_BLOB_CODEC_ID, @@ -130,6 +135,22 @@ function lowerDbgenerated(input: { } const trimmed = raw.trim(); const expression = NOW_SYNONYMS.has(trimmed.toLowerCase()) ? 'now()' : trimmed; + const clientGenerator = clientGeneratorSqlDefaultBody(expression); + if (clientGenerator !== undefined) { + return { + ok: false, + diagnostic: { + code: PSL_RAW_DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR, + message: clientGeneratorSqlDefaultMessage({ + generator: clientGenerator, + body: expression, + namedForm: `@default(${expression.trim()})`, + }), + sourceId: input.context.sourceId, + span: input.call.span, + }, + }; + } return { ok: true, value: { From 41cf623a6320f93d79b94d60a1b418f0446ce2b6 Mon Sep 17 00:00:00 2001 From: Rhae-Shane Date: Sun, 20 Sep 2026 23:56:20 +0530 Subject: [PATCH 03/10] feat(postgres): manage SQL functions as contract entities in migrations Add PostgresFunction authoring (PSL + pgFunction), schema-IR, and CREATE/DROP planning in the dep/dropType buckets so a declared function exists before column defaults that call it. Signed-off-by: Rhae-Shane Co-authored-by: Cursor --- .../postgres/src/contract/function.ts | 83 ++++++++ .../postgres/src/exports/contract-builder.ts | 1 + .../contract-builder/function-entity.test.ts | 37 ++++ .../3-targets/postgres/src/core/authoring.ts | 177 ++++++++++++++++++ .../postgres/src/core/entity-kinds.ts | 8 + ...ntract-to-postgres-database-schema-node.ts | 16 ++ .../src/core/migrations/control-policy.ts | 1 + .../src/core/migrations/issue-planner.ts | 59 ++++++ .../src/core/migrations/op-factory-call.ts | 105 +++++++++++ .../core/migrations/operations/functions.ts | 80 ++++++++ .../src/core/migrations/postgres-migration.ts | 30 +++ .../postgres/src/core/postgres-function.ts | 60 ++++++ .../postgres/src/core/postgres-schema.ts | 20 +- .../postgres/src/core/postgres-validators.ts | 11 ++ .../postgres-function-schema-node.ts | 81 ++++++++ .../postgres-namespace-schema-node.ts | 9 +- .../src/core/schema-ir/schema-node-kinds.ts | 3 + .../3-targets/postgres/src/exports/types.ts | 9 + .../test/migrations/function-planner.test.ts | 143 ++++++++++++++ 19 files changed, 930 insertions(+), 3 deletions(-) create mode 100644 packages/3-extensions/postgres/src/contract/function.ts create mode 100644 packages/3-extensions/postgres/test/contract-builder/function-entity.test.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/migrations/operations/functions.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/postgres-function.ts create mode 100644 packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-function-schema-node.ts create mode 100644 packages/3-targets/3-targets/postgres/test/migrations/function-planner.test.ts diff --git a/packages/3-extensions/postgres/src/contract/function.ts b/packages/3-extensions/postgres/src/contract/function.ts new file mode 100644 index 000000000000..ebe40929960d --- /dev/null +++ b/packages/3-extensions/postgres/src/contract/function.ts @@ -0,0 +1,83 @@ +import { PostgresFunction, type PostgresFunctionVolatility } from '@internal/target-postgres/types'; +import { ifDefined } from '@internal/utils/defined'; +import { postgresError } from '../errors'; + +export interface PgFunctionHandle { + readonly entityKind: 'function'; + readonly name: string; + readonly entity: PostgresFunction; + readonly namespaceId?: string; +} + +/** + * Declares a managed Postgres function for the `entities` list. Migrate plans + * `CREATE OR REPLACE FUNCTION` / `DROP FUNCTION`. Column defaults that call the + * function stay raw SQL (for example `` sql`app_nanoid(16)` ``). + */ +export function pgFunction(input: { + readonly name: string; + readonly signature: string; + readonly returns: string; + readonly body: string; + readonly language?: string; + readonly volatility?: PostgresFunctionVolatility; + readonly namespaceId?: string; + /** Overrides the Postgres function name; defaults to `name`. */ + readonly functionName?: string; +}): PgFunctionHandle { + if (input.name.trim().length === 0) { + throw postgresError('CONTRACT.ENUM_INVALID', 'pgFunction(): name must be a non-empty string.', { + fix: 'Pass a non-empty entity name.', + meta: { reason: 'empty name' }, + }); + } + const functionName = input.functionName ?? input.name; + if (functionName.trim().length === 0) { + throw postgresError( + 'CONTRACT.ENUM_INVALID', + `pgFunction("${input.name}"): functionName must be a non-empty string.`, + { fix: 'Pass a non-empty Postgres function name.', meta: { reason: 'empty functionName' } }, + ); + } + if (input.signature.trim().length === 0) { + throw postgresError( + 'CONTRACT.ENUM_INVALID', + `pgFunction("${input.name}"): signature must be a non-empty string.`, + { + fix: 'Pass the argument list, e.g. "size int DEFAULT 16".', + meta: { reason: 'empty signature' }, + }, + ); + } + if (input.returns.trim().length === 0) { + throw postgresError( + 'CONTRACT.ENUM_INVALID', + `pgFunction("${input.name}"): returns must be a non-empty string.`, + { fix: 'Pass a return type, e.g. "text".', meta: { reason: 'empty returns' } }, + ); + } + if (input.body.trim().length === 0) { + throw postgresError( + 'CONTRACT.ENUM_INVALID', + `pgFunction("${input.name}"): body must be a non-empty string.`, + { + fix: 'Pass the function body without surrounding dollar quotes.', + meta: { reason: 'empty body' }, + }, + ); + } + + return { + entityKind: 'function', + name: input.name, + ...ifDefined('namespaceId', input.namespaceId), + entity: new PostgresFunction({ + functionName, + signature: input.signature, + returns: input.returns, + body: input.body, + ...ifDefined('language', input.language), + ...ifDefined('volatility', input.volatility), + }), + }; +} diff --git a/packages/3-extensions/postgres/src/exports/contract-builder.ts b/packages/3-extensions/postgres/src/exports/contract-builder.ts index d55f725af220..47b70fd25cd4 100644 --- a/packages/3-extensions/postgres/src/exports/contract-builder.ts +++ b/packages/3-extensions/postgres/src/exports/contract-builder.ts @@ -28,6 +28,7 @@ export { } from '@internal/sql-contract-ts/contract-builder'; export { defineContract } from '../contract/define-contract'; export { enumType } from '../contract/enum-type'; +export { type PgFunctionHandle, pgFunction } from '../contract/function'; export { type NativeEnumHandle, nativeEnum, pg } from '../contract/native-enum'; export { policyAll, diff --git a/packages/3-extensions/postgres/test/contract-builder/function-entity.test.ts b/packages/3-extensions/postgres/test/contract-builder/function-entity.test.ts new file mode 100644 index 000000000000..a24ebfecfb43 --- /dev/null +++ b/packages/3-extensions/postgres/test/contract-builder/function-entity.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { defineContract, field, model, pgFunction, sql } from '../../src/exports/contract-builder'; + +const textColumn = { codecId: 'pg/text@1', nativeType: 'text' } as const; + +describe('pgFunction entity authoring', () => { + it('attaches a function entity and a column default that calls it', () => { + const AppNanoid = pgFunction({ + name: 'AppNanoid', + functionName: 'app_nanoid', + signature: 'size int DEFAULT 16', + returns: 'text', + body: 'RETURN gen_random_uuid()::text;', + }); + const contract = defineContract({ + entities: [AppNanoid], + models: { + User: model('User', { + fields: { + id: field.column(textColumn).default(sql`app_nanoid(16)`).id(), + }, + }).sql({ table: 'user' }), + }, + }); + const ns = contract.storage.namespaces['public']; + expect(ns?.entries.function?.['app_nanoid']).toMatchObject({ + kind: 'postgres-function', + functionName: 'app_nanoid', + signature: 'size int DEFAULT 16', + returns: 'text', + }); + expect(ns?.entries.table?.['user']?.columns['id']?.default).toEqual({ + kind: 'function', + expression: 'app_nanoid(16)', + }); + }); +}); diff --git a/packages/3-targets/3-targets/postgres/src/core/authoring.ts b/packages/3-targets/3-targets/postgres/src/core/authoring.ts index 9d871dc3b00a..8251e488c17d 100644 --- a/packages/3-targets/3-targets/postgres/src/core/authoring.ts +++ b/packages/3-targets/3-targets/postgres/src/core/authoring.ts @@ -40,11 +40,13 @@ import { } from './codec-ids'; import { postgresError } from './errors'; import { postgresNowGeneratorIds } from './now-generators'; +import { PostgresFunction } from './postgres-function'; import { PostgresNativeEnum } from './postgres-native-enum'; import { PostgresRlsEnablement, type PostgresRlsEnablementInput } from './postgres-rls-enablement'; import { PostgresRlsPolicy, type RlsPolicyOperation } from './postgres-rls-policy'; import { PostgresRole } from './postgres-role'; import { + PostgresFunctionSchema, PostgresNativeEnumSchema, PostgresRlsEnablementSchema, PostgresRlsPolicySchema, @@ -406,6 +408,114 @@ function lowerNativeEnumFromBlock( return new PostgresNativeEnum({ typeName, members }); } +function lowerFunctionFromBlock( + block: PslExtensionBlock, + ctx: AuthoringEntityContext, +): PostgresFunction | undefined { + const diagnostics = ctx.diagnostics; + const sourceId = ctx.sourceId ?? 'unknown'; + const params = block.parameters ?? {}; + const requiredString = (key: string): string | undefined => { + const value = params[key]; + if (value === undefined || value.kind !== 'value' || typeof value.raw !== 'string') { + diagnostics?.push({ + code: PSL_EXTENSION_INVALID_VALUE, + message: `function "${block.name}" requires parameter "${key}" as a string`, + sourceId, + span: block.span, + }); + return undefined; + } + try { + const parsed: unknown = JSON.parse(value.raw); + if (typeof parsed !== 'string' || parsed.trim().length === 0) { + diagnostics?.push({ + code: PSL_EXTENSION_INVALID_VALUE, + message: `function "${block.name}" parameter "${key}" must be a non-empty string`, + sourceId, + span: value.span, + }); + return undefined; + } + return parsed; + } catch { + diagnostics?.push({ + code: PSL_EXTENSION_INVALID_VALUE, + message: `function "${block.name}" parameter "${key}" is not valid JSON`, + sourceId, + span: value.span, + }); + return undefined; + } + }; + + const signature = requiredString('signature'); + const returns = requiredString('returns'); + const body = requiredString('body'); + if (signature === undefined || returns === undefined || body === undefined) return undefined; + + const languageParam = params['language']; + let language: string | undefined; + if (languageParam !== undefined && languageParam.kind === 'value') { + try { + const parsed: unknown = JSON.parse(languageParam.raw); + if (typeof parsed === 'string' && parsed.trim().length > 0) language = parsed; + } catch { + diagnostics?.push({ + code: PSL_EXTENSION_INVALID_VALUE, + message: `function "${block.name}" parameter "language" is not valid JSON`, + sourceId, + span: languageParam.span, + }); + return undefined; + } + } + + const volatilityParam = params['volatility']; + let volatility: 'VOLATILE' | 'STABLE' | 'IMMUTABLE' | undefined; + if (volatilityParam !== undefined && volatilityParam.kind === 'value') { + try { + const parsed: unknown = JSON.parse(volatilityParam.raw); + if (parsed === 'VOLATILE' || parsed === 'STABLE' || parsed === 'IMMUTABLE') { + volatility = parsed; + } else { + diagnostics?.push({ + code: PSL_EXTENSION_INVALID_VALUE, + message: `function "${block.name}" parameter "volatility" must be VOLATILE, STABLE, or IMMUTABLE`, + sourceId, + span: volatilityParam.span, + }); + return undefined; + } + } catch { + diagnostics?.push({ + code: PSL_EXTENSION_INVALID_VALUE, + message: `function "${block.name}" parameter "volatility" is not valid JSON`, + sourceId, + span: volatilityParam.span, + }); + return undefined; + } + } + + const mapAttr = block.attributes['map']; + let functionName = block.name; + if (mapAttr !== undefined) { + const mapped = mapAttr.args['name']; + invariant(typeof mapped === 'string', '@@map on a function block parses one string argument'); + functionName = mapped; + } + + return new PostgresFunction({ + functionName, + signature, + returns, + body, + ...ifDefined('language', language), + ...ifDefined('volatility', volatility), + }); +} + /** * `native_enum`'s entity-type factory output, checked separately from the assembled * `postgresAuthoringEntityTypes` map below: `deriveValueSet` is SQL-family surface @@ -479,6 +589,14 @@ export const postgresAuthoringEntityTypes = { validatorSchema: PostgresNativeEnumSchema, output: nativeEnumEntityTypeOutput, }, + function: { + kind: 'entity', + discriminator: 'function', + validatorSchema: PostgresFunctionSchema, + output: { + factory: lowerFunctionFromBlock, + }, + }, } as const satisfies AuthoringEntityTypeNamespace; /** @@ -679,6 +797,45 @@ export const postgresAuthoringPslBlockDescriptors = { name: { required: true }, parameters: {}, }, + function: { + kind: 'pslBlock', + keyword: 'function', + documentation: + 'Declares a PostgreSQL function whose CREATE/DROP is planned with migrations. Column defaults that call it stay raw SQL expressions.', + discriminator: 'function', + name: { required: true }, + parameters: { + signature: { + kind: 'value', + codecId: 'pg/text@1', + required: true, + documentation: 'The function argument list, e.g. size int DEFAULT 16.', + }, + returns: { + kind: 'value', + codecId: 'pg/text@1', + required: true, + documentation: 'The function return type, e.g. text.', + }, + body: { + kind: 'value', + codecId: 'pg/text@1', + required: true, + documentation: 'The function body (PL/pgSQL or SQL) without surrounding dollar quotes.', + }, + language: { + kind: 'value', + codecId: 'pg/text@1', + documentation: 'The function language. Defaults to plpgsql.', + }, + volatility: { + kind: 'value', + codecId: 'pg/text@1', + documentation: 'VOLATILE, STABLE, or IMMUTABLE. Defaults to STABLE.', + }, + }, + attributes: { map: () => nativeEnumMapAttribute }, + }, } as const satisfies AuthoringPslBlockDescriptorNamespace; const postgresRlsSpec = modelAttribute('rls', { @@ -927,6 +1084,7 @@ export function postgresLowerEntityHandles( { coordinate: RlsTargetCoordinate; entity: PostgresRlsEnablement } >(); const roles = new Map(); + const functions: LoweredPackEntity[] = []; const policies: { readonly handle: RlsPolicyHandleShape; readonly refs: Readonly>; @@ -981,6 +1139,24 @@ export function postgresLowerEntityHandles( policies.push({ handle: policyHandle, refs }); break; } + case 'function': { + const functionHandle = blindCast< + { + readonly entityKind: 'function'; + readonly name: string; + readonly entity: PostgresFunction; + readonly namespaceId?: string; + }, + 'function handles are constructed only by pgFunction()' + >(handle); + functions.push({ + namespaceId: functionHandle.namespaceId ?? input.defaultNamespaceId, + entityKind: 'function', + key: functionHandle.name, + entity: functionHandle.entity, + }); + break; + } default: throw postgresError( 'CONTRACT.ENTITY_KIND_INVALID', @@ -1049,5 +1225,6 @@ export function postgresLowerEntityHandles( for (const [name, entity] of roles) { rows.push({ namespaceId: UNBOUND_NAMESPACE_ID, entityKind: 'role', key: name, entity }); } + rows.push(...functions); return rows; } diff --git a/packages/3-targets/3-targets/postgres/src/core/entity-kinds.ts b/packages/3-targets/3-targets/postgres/src/core/entity-kinds.ts index d6eb889e1942..a51ed71644cf 100644 --- a/packages/3-targets/3-targets/postgres/src/core/entity-kinds.ts +++ b/packages/3-targets/3-targets/postgres/src/core/entity-kinds.ts @@ -1,9 +1,11 @@ import type { EntityKindDescriptor } from '@internal/framework-components/ir'; +import { PostgresFunction, type PostgresFunctionInput } from './postgres-function'; import { PostgresNativeEnum, type PostgresNativeEnumInput } from './postgres-native-enum'; import { PostgresRlsEnablement, type PostgresRlsEnablementInput } from './postgres-rls-enablement'; import { PostgresRlsPolicy } from './postgres-rls-policy'; import { PostgresRole, type PostgresRoleInput } from './postgres-role'; import { + PostgresFunctionSchema, PostgresNativeEnumSchema, PostgresRlsEnablementSchema, PostgresRlsPolicySchema, @@ -40,3 +42,9 @@ export const nativeEnumEntityKind: EntityKindDescriptor< schema: PostgresNativeEnumSchema, construct: (input) => new PostgresNativeEnum(input), }; + +export const functionEntityKind: EntityKindDescriptor = { + kind: 'function', + schema: PostgresFunctionSchema, + construct: (input) => new PostgresFunction(input), +}; diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-database-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-database-schema-node.ts index b2bca0e820a4..def52febb4a6 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-database-schema-node.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-database-schema-node.ts @@ -16,6 +16,7 @@ import type { PostgresRlsPolicy } from '../postgres-rls-policy'; import type { PostgresContract } from '../postgres-schema'; import { isPostgresSchema } from '../postgres-schema'; import { PostgresDatabaseSchemaNode } from '../schema-ir/postgres-database-schema-node'; +import { PostgresFunctionSchemaNode } from '../schema-ir/postgres-function-schema-node'; import { PostgresNamespaceSchemaNode } from '../schema-ir/postgres-namespace-schema-node'; import { PostgresNativeEnumSchemaNode } from '../schema-ir/postgres-native-enum-schema-node'; import { PostgresPolicySchemaNode } from '../schema-ir/postgres-policy-schema-node'; @@ -276,10 +277,25 @@ export function contractToPostgresDatabaseSchemaNode( }), ); + const functions = Object.values(ns.entries.function ?? {}).map( + (entity) => + new PostgresFunctionSchemaNode({ + functionName: entity.functionName, + namespaceId: ddlSchema, + signature: entity.signature, + returns: entity.returns, + body: entity.body, + language: entity.language, + volatility: entity.volatility, + ...ifDefined('control', entity.control), + }), + ); + namespaces[ddlSchema] = new PostgresNamespaceSchemaNode({ schemaName: ddlSchema, tables, nativeEnums, + functions, }); } diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/control-policy.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/control-policy.ts index 23af1a25efee..ee42c081237b 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/control-policy.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/control-policy.ts @@ -242,6 +242,7 @@ const POSTGRES_NODE_CREATION_FACTORY: Readonly> = Object. [PostgresSchemaNodeKind.namespace]: 'createSchema', [PostgresSchemaNodeKind.table]: 'createTable', [PostgresSchemaNodeKind.nativeEnum]: 'createNativeEnumType', + [PostgresSchemaNodeKind.function]: 'createFunction', }); /** diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts index 28515c4a41ba..7d2aa7c3cb08 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts @@ -45,6 +45,7 @@ import { blindCast } from '@internal/utils/casts'; import { ifDefined } from '@internal/utils/defined'; import type { Result } from '@internal/utils/result'; import { notOk, ok } from '@internal/utils/result'; +import type { PostgresFunctionSchemaNode } from '../schema-ir/postgres-function-schema-node'; import type { PostgresNamespaceSchemaNode } from '../schema-ir/postgres-namespace-schema-node'; import type { PostgresNativeEnumSchemaNode } from '../schema-ir/postgres-native-enum-schema-node'; import type { PostgresTableSchemaNode } from '../schema-ir/postgres-table-schema-node'; @@ -63,6 +64,7 @@ import { AddPrimaryKeyCall, AddUniqueCall, AlterColumnTypeCall, + CreateFunctionCall, CreateIndexCall, CreateNativeEnumTypeCall, CreateSchemaCall, @@ -72,6 +74,7 @@ import { DropColumnCall, DropConstraintCall, DropDefaultCall, + DropFunctionCall, DropIndexCall, DropNativeEnumTypeCall, DropNotNullCall, @@ -147,8 +150,10 @@ function classifyCall(call: PostgresOpFactoryCall): CallCategory { case 'createSchema': case 'createNativeEnumType': case 'addNativeEnumValue': + case 'createFunction': return 'dep'; case 'dropNativeEnumType': + case 'dropFunction': return 'dropType'; case 'dropTable': case 'dropColumn': @@ -534,6 +539,56 @@ function nativeEnumMemberChangeRefusal(options: { * refused with a NAMED diagnostic, never a silent no-op and never a * drop-and-recreate. */ +function mapFunctionNodeIssue( + issue: SchemaDiffIssue, + ctx: StrategyContext, +): Result { + const ddlSchemaName = issueSchemaName(issue); + if (ddlSchemaName === undefined) { + return notOk( + nodeConflict( + 'unsupportedOperation', + `Function issue has no schema in its path: ${issue.path.join('/')}`, + ), + ); + } + const schemaName = emissionSchemaName(ctx, ddlSchemaName); + if (issueOutcome(issue) === 'not-found') { + const expected = blindCast< + PostgresFunctionSchemaNode, + 'a not-found function issue always carries the expected PostgresFunctionSchemaNode' + >(issue.expected); + return ok([ + new CreateFunctionCall({ + schemaName, + functionName: expected.functionName, + signature: expected.signature, + returns: expected.returns, + body: expected.body, + language: expected.language, + volatility: expected.volatility, + }), + ]); + } + if (issueOutcome(issue) === 'not-expected') { + const actual = blindCast< + PostgresFunctionSchemaNode, + 'a not-expected function issue always carries the actual PostgresFunctionSchemaNode' + >(issue.actual); + return ok([new DropFunctionCall(schemaName, actual.functionName, actual.signature)]); + } + return notOk( + nodeConflict( + 'unsupportedOperation', + `Changing a Postgres function body or signature is not supported yet; drop and recreate "${ + blindCast( + issue.expected, + ).functionName + }".`, + ), + ); +} + function mapNativeEnumNodeIssue( issue: SchemaDiffIssue, ctx: StrategyContext, @@ -914,6 +969,10 @@ export function mapNodeIssueToCall( return mapNativeEnumNodeIssue(issue, ctx); } + if (node.nodeKind === PostgresSchemaNodeKind.function) { + return mapFunctionNodeIssue(issue, ctx); + } + const ddlSchemaName = issueSchemaName(issue); const tableName = issueTableName(issue); if (ddlSchemaName === undefined || tableName === undefined) { diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts index 23999c305276..0aaa7be637e7 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts @@ -76,6 +76,7 @@ import { renameCheckConstraint, } from './operations/constraints'; import { createExtension } from './operations/dependencies'; +import { createFunction, dropFunction } from './operations/functions'; import { type CreateIndexElements, type CreateIndexExtras, @@ -1558,6 +1559,108 @@ export class CreateNativeEnumTypeCall extends PostgresOpFactoryCallNode { } } +export class CreateFunctionCall extends PostgresOpFactoryCallNode { + readonly factoryName = 'createFunction' as const; + readonly operationClass = 'additive' as const; + readonly schemaName: string; + readonly functionName: string; + readonly signature: string; + readonly returns: string; + readonly body: string; + readonly language: string; + readonly volatility: 'VOLATILE' | 'STABLE' | 'IMMUTABLE'; + readonly label: string; + + constructor(options: { + readonly schemaName: string; + readonly functionName: string; + readonly signature: string; + readonly returns: string; + readonly body: string; + readonly language: string; + readonly volatility: 'VOLATILE' | 'STABLE' | 'IMMUTABLE'; + }) { + super(); + this.schemaName = options.schemaName; + this.functionName = options.functionName; + this.signature = options.signature; + this.returns = options.returns; + this.body = options.body; + this.language = options.language; + this.volatility = options.volatility; + this.label = `Create function "${options.functionName}"`; + this.freeze(); + } + + async toOp(_lowerer?: ExecuteRequestLowerer): Promise { + return createFunction({ + schemaName: this.schemaName, + functionName: this.functionName, + signature: this.signature, + returns: this.returns, + body: this.body, + language: this.language, + volatility: this.volatility, + }); + } + + renderTypeScript(): string { + const opts = [ + `schema: ${jsonToTsSource(this.schemaName)}`, + `functionName: ${jsonToTsSource(this.functionName)}`, + `signature: ${jsonToTsSource(this.signature)}`, + `returns: ${jsonToTsSource(this.returns)}`, + `body: ${jsonToTsSource(this.body)}`, + `language: ${jsonToTsSource(this.language)}`, + `volatility: ${jsonToTsSource(this.volatility)}`, + ]; + return `this.createFunction({ ${opts.join(', ')} })`; + } + + override importRequirements(): readonly ImportRequirement[] { + return []; + } +} + +export class DropFunctionCall extends PostgresOpFactoryCallNode { + readonly factoryName = 'dropFunction' as const; + readonly operationClass = 'destructive' as const; + readonly schemaName: string; + readonly functionName: string; + readonly signature: string; + readonly label: string; + + constructor(schemaName: string, functionName: string, signature: string) { + super(); + this.schemaName = schemaName; + this.functionName = functionName; + this.signature = signature; + this.label = `Drop function "${functionName}"`; + this.freeze(); + } + + async toOp(_lowerer?: ExecuteRequestLowerer): Promise { + return dropFunction({ + schemaName: this.schemaName, + functionName: this.functionName, + signature: this.signature, + }); + } + + renderTypeScript(): string { + const opts = [ + `schema: ${jsonToTsSource(this.schemaName)}`, + `functionName: ${jsonToTsSource(this.functionName)}`, + `signature: ${jsonToTsSource(this.signature)}`, + ]; + return `this.dropFunction({ ${opts.join(', ')} })`; + } + + override importRequirements(): readonly ImportRequirement[] { + return []; + } +} + export class DropNativeEnumTypeCall extends PostgresOpFactoryCallNode { readonly factoryName = 'dropNativeEnumType' as const; readonly operationClass = 'destructive' as const; @@ -1984,6 +2087,8 @@ export type PostgresOpFactoryCall = | CreateNativeEnumTypeCall | DropNativeEnumTypeCall | AddNativeEnumValueCall + | CreateFunctionCall + | DropFunctionCall | CreatePostgresRlsPolicyCall | DropPostgresRlsPolicyCall | EnableRowLevelSecurityCall diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/functions.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/functions.ts new file mode 100644 index 000000000000..e268600f8efc --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/functions.ts @@ -0,0 +1,80 @@ +import { postgresError } from '../../errors'; +import type { PostgresFunctionVolatility } from '../../postgres-function'; +import { qualifyName, quoteIdentifier } from '../../sql-utils'; +import { boundSchema } from '../bound-schema'; +import { type Op, step, targetDetails } from './shared'; + +const BODY_DOLLAR_TAG = 'prisma_fn'; + +function qualifiedFunctionName(schemaName: string, functionName: string): string { + const schema = boundSchema(schemaName); + return schema === undefined ? quoteIdentifier(functionName) : qualifyName(schema, functionName); +} + +function renderCreateFunctionSql(options: { + readonly schemaName: string; + readonly functionName: string; + readonly signature: string; + readonly returns: string; + readonly body: string; + readonly language: string; + readonly volatility: PostgresFunctionVolatility; +}): string { + if (options.body.includes(`$${BODY_DOLLAR_TAG}$`)) { + throw postgresError( + 'CONTRACT.IDENTIFIER_INVALID', + `Function body for "${options.functionName}" must not contain the delimiter $${BODY_DOLLAR_TAG}$.`, + { meta: { functionName: options.functionName, reason: 'dollar-tag-collision' } }, + ); + } + const name = qualifiedFunctionName(options.schemaName, options.functionName); + return [ + `CREATE OR REPLACE FUNCTION ${name}(${options.signature})`, + `RETURNS ${options.returns}`, + `LANGUAGE ${options.language} ${options.volatility}`, + `AS $${BODY_DOLLAR_TAG}$`, + options.body, + `$${BODY_DOLLAR_TAG}$;`, + ].join('\n'); +} + +/** `CREATE OR REPLACE FUNCTION` for a managed Postgres function entity. */ +export function createFunction(options: { + readonly schemaName: string; + readonly functionName: string; + readonly signature: string; + readonly returns: string; + readonly body: string; + readonly language: string; + readonly volatility: PostgresFunctionVolatility; +}): Op { + const sql = renderCreateFunctionSql(options); + return { + id: `createFunction.${options.functionName}`, + label: `Create function "${options.functionName}"`, + operationClass: 'additive', + target: targetDetails('type', options.functionName, options.schemaName), + precheck: [], + execute: [step(`create function "${options.functionName}"`, sql, [])], + postcheck: [], + }; +} + +/** `DROP FUNCTION` for an unclaimed managed Postgres function. */ +export function dropFunction(options: { + readonly schemaName: string; + readonly functionName: string; + readonly signature: string; +}): Op { + const name = qualifiedFunctionName(options.schemaName, options.functionName); + const sql = `DROP FUNCTION ${name}(${options.signature});`; + return { + id: `dropFunction.${options.functionName}`, + label: `Drop function "${options.functionName}"`, + operationClass: 'destructive', + target: targetDetails('type', options.functionName, options.schemaName), + precheck: [], + execute: [step(`drop function "${options.functionName}"`, sql, [])], + postcheck: [], + }; +} diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts index 32b888550f34..04c7f66881d6 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts @@ -20,6 +20,7 @@ import { AddUniqueCall, AlterColumnTypeCall, type AlterColumnTypeOptions, + CreateFunctionCall, CreateIndexCall, CreateNativeEnumTypeCall, CreatePostgresRlsPolicyCall, @@ -30,6 +31,7 @@ import { DropColumnCall, DropConstraintCall, DropDefaultCall, + DropFunctionCall, DropIndexCall, DropNativeEnumTypeCall, DropNotNullCall, @@ -216,6 +218,34 @@ export abstract class PostgresMigration< ); } + protected createFunction(options: { + readonly schema: string; + readonly functionName: string; + readonly signature: string; + readonly returns: string; + readonly body: string; + readonly language: string; + readonly volatility: 'VOLATILE' | 'STABLE' | 'IMMUTABLE'; + }): Promise> { + return new CreateFunctionCall({ + schemaName: options.schema, + functionName: options.functionName, + signature: options.signature, + returns: options.returns, + body: options.body, + language: options.language, + volatility: options.volatility, + }).toOp(); + } + + protected dropFunction(options: { + readonly schema: string; + readonly functionName: string; + readonly signature: string; + }): Promise> { + return new DropFunctionCall(options.schema, options.functionName, options.signature).toOp(); + } + /** * Emit an `ALTER TYPE ... ADD VALUE` migration operation appending one * member to a managed native enum, lowered through the stored control diff --git a/packages/3-targets/3-targets/postgres/src/core/postgres-function.ts b/packages/3-targets/3-targets/postgres/src/core/postgres-function.ts new file mode 100644 index 000000000000..adac6bfb7e76 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/postgres-function.ts @@ -0,0 +1,60 @@ +import type { ControlPolicy } from '@internal/contract/types'; +import { freezeNode } from '@internal/framework-components/ir'; +import { SqlNode } from '@internal/sql-contract/types'; + +export type PostgresFunctionVolatility = 'VOLATILE' | 'STABLE' | 'IMMUTABLE'; + +export interface PostgresFunctionInput { + /** Postgres function name (`CREATE FUNCTION …`). */ + readonly functionName: string; + /** Opaque argument list text, e.g. `size int DEFAULT 16`. */ + readonly signature: string; + /** Opaque return type text, e.g. `text`. */ + readonly returns: string; + /** Opaque function body (the part inside `AS $tag$ … $tag$`). */ + readonly body: string; + /** Defaults to `plpgsql`. */ + readonly language?: string; + /** Defaults to `STABLE`. */ + readonly volatility?: PostgresFunctionVolatility; + readonly control?: ControlPolicy; +} + +/** + * Postgres contract-IR class for a user-defined SQL function. + * + * Authored and serialized into `contract.json` under + * `storage.namespaces[ns].entries.function[HandleName]`. Schema-diff uses + * {@link PostgresFunctionSchemaNode}; this class is not a DiffableNode. + */ +export class PostgresFunction extends SqlNode { + static is(node: unknown): node is PostgresFunction { + return ( + typeof node === 'object' && + node !== null && + 'kind' in node && + node.kind === 'postgres-function' + ); + } + + override readonly kind = 'postgres-function' as const; + readonly functionName: string; + readonly signature: string; + readonly returns: string; + readonly body: string; + readonly language: string; + readonly volatility: PostgresFunctionVolatility; + declare readonly control?: ControlPolicy; + + constructor(input: PostgresFunctionInput) { + super(); + this.functionName = input.functionName; + this.signature = input.signature; + this.returns = input.returns; + this.body = input.body; + this.language = input.language ?? 'plpgsql'; + this.volatility = input.volatility ?? 'STABLE'; + if (input.control !== undefined) this.control = input.control; + freezeNode(this); + } +} diff --git a/packages/3-targets/3-targets/postgres/src/core/postgres-schema.ts b/packages/3-targets/3-targets/postgres/src/core/postgres-schema.ts index 9c08aeb0b379..00497bd9546d 100644 --- a/packages/3-targets/3-targets/postgres/src/core/postgres-schema.ts +++ b/packages/3-targets/3-targets/postgres/src/core/postgres-schema.ts @@ -21,11 +21,13 @@ import { ifDefined } from '@internal/utils/defined'; import { PostgresTableSource } from './ast/table-source'; import { PG_TEXT_CODEC_ID } from './codec-ids'; import { + functionEntityKind, nativeEnumEntityKind, policyEntityKind, rlsEnablementEntityKind, roleEntityKind, } from './entity-kinds'; +import { PostgresFunction } from './postgres-function'; import { PostgresNativeEnum } from './postgres-native-enum'; import type { PostgresRlsEnablement } from './postgres-rls-enablement'; import type { PostgresRlsPolicy } from './postgres-rls-policy'; @@ -39,6 +41,7 @@ export type PostgresNamespaceEntries = SqlNamespaceEntries & { readonly role?: Readonly>; readonly rls?: Readonly>; readonly native_enum?: Readonly>; + readonly function?: Readonly>; }; export interface PostgresSchemaInput { @@ -85,6 +88,7 @@ export class PostgresSchema extends SqlNamespaceBase { roleEntityKind, rlsEnablementEntityKind, nativeEnumEntityKind, + functionEntityKind, ]), 'carry', ); @@ -109,6 +113,20 @@ export class PostgresSchema extends SqlNamespaceBase { dispatched['native_enum'] = Object.freeze(rekeyed); } + const functionSlot = dispatched['function']; + if (functionSlot !== undefined) { + const rekeyed: Record = {}; + for (const [handle, entity] of Object.entries(functionSlot)) { + const physicalName = PostgresFunction.is(entity) ? entity.functionName : handle; + invariant( + !Object.hasOwn(rekeyed, physicalName), + `PostgresSchema "${input.id}": two function entities resolve to the same physical function name "${physicalName}".`, + ); + rekeyed[physicalName] = entity; + } + dispatched['function'] = Object.freeze(rekeyed); + } + // Drop an empty valueSet so presence signals non-emptiness. const valueSetRaw = dispatched['valueSet']; const withPresence = @@ -123,7 +141,7 @@ export class PostgresSchema extends SqlNamespaceBase { this.entries = Object.freeze( blindCast< PostgresNamespaceEntries, - 'composeSqlEntityKinds([policyEntityKind, roleEntityKind, rlsEnablementEntityKind, nativeEnumEntityKind]) supplies table→StorageTable, valueSet→StorageValueSet, policy→PostgresRlsPolicy, role→PostgresRole, rls→PostgresRlsEnablement, native_enum→PostgresNativeEnum descriptors' + 'composeSqlEntityKinds([policyEntityKind, roleEntityKind, rlsEnablementEntityKind, nativeEnumEntityKind, functionEntityKind]) supplies table→StorageTable, valueSet→StorageValueSet, policy→PostgresRlsPolicy, role→PostgresRole, rls→PostgresRlsEnablement, native_enum→PostgresNativeEnum, function→PostgresFunction descriptors' >(entriesInput), ); Object.defineProperty(this, 'kind', { diff --git a/packages/3-targets/3-targets/postgres/src/core/postgres-validators.ts b/packages/3-targets/3-targets/postgres/src/core/postgres-validators.ts index f43032259d1f..f67b6ac4472d 100644 --- a/packages/3-targets/3-targets/postgres/src/core/postgres-validators.ts +++ b/packages/3-targets/3-targets/postgres/src/core/postgres-validators.ts @@ -32,3 +32,14 @@ export const PostgresNativeEnumSchema = type({ members: type.string.array().readonly(), 'control?': "'managed' | 'tolerated' | 'external' | 'observed'", }); + +export const PostgresFunctionSchema = type({ + kind: "'postgres-function'", + functionName: 'string', + signature: 'string', + returns: 'string', + body: 'string', + 'language?': 'string', + 'volatility?': "'VOLATILE' | 'STABLE' | 'IMMUTABLE'", + 'control?': "'managed' | 'tolerated' | 'external' | 'observed'", +}); diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-function-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-function-schema-node.ts new file mode 100644 index 000000000000..7868a1d9011a --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-function-schema-node.ts @@ -0,0 +1,81 @@ +import type { ControlPolicy } from '@internal/contract/types'; +import type { DiffableNode } from '@internal/framework-components/control'; +import { freezeNode } from '@internal/framework-components/ir'; +import { assertNode, SqlSchemaIRNode } from '@internal/sql-schema-ir/types'; +import { blindCast } from '@internal/utils/casts'; +import type { PostgresFunctionVolatility } from '../postgres-function'; +import { PostgresSchemaNodeKind } from './schema-node-kinds'; + +export interface PostgresFunctionSchemaNodeInput { + readonly functionName: string; + readonly namespaceId: string; + readonly signature: string; + readonly returns: string; + readonly body: string; + readonly language: string; + readonly volatility: PostgresFunctionVolatility; + readonly control?: ControlPolicy; +} + +/** + * Schema-diff leaf for a managed Postgres function. Identity is + * `function:` within a namespace. Equality compares every + * authored field except `control`. + */ +export class PostgresFunctionSchemaNode extends SqlSchemaIRNode implements DiffableNode { + override readonly nodeKind = PostgresSchemaNodeKind.function; + + readonly functionName: string; + readonly namespaceId: string; + readonly signature: string; + readonly returns: string; + readonly body: string; + readonly language: string; + readonly volatility: PostgresFunctionVolatility; + declare readonly control?: ControlPolicy; + + constructor(input: PostgresFunctionSchemaNodeInput) { + super(); + this.functionName = input.functionName; + this.namespaceId = input.namespaceId; + this.signature = input.signature; + this.returns = input.returns; + this.body = input.body; + this.language = input.language; + this.volatility = input.volatility; + if (input.control !== undefined) this.control = input.control; + freezeNode(this); + } + + get id(): string { + return `function:${this.functionName}`; + } + + children(): readonly DiffableNode[] { + return []; + } + + isEqualTo(other: DiffableNode): boolean { + const node = blindCast< + SqlSchemaIRNode, + 'every diff-tree node the differ pairs is a SqlSchemaIRNode; the guard rejects non-function kinds' + >(other); + PostgresFunctionSchemaNode.assert(node); + return ( + this.functionName === node.functionName && + this.signature === node.signature && + this.returns === node.returns && + this.body === node.body && + this.language === node.language && + this.volatility === node.volatility + ); + } + + static is(node: SqlSchemaIRNode): node is PostgresFunctionSchemaNode { + return node.nodeKind === PostgresSchemaNodeKind.function; + } + + static assert(node: SqlSchemaIRNode): asserts node is PostgresFunctionSchemaNode { + assertNode(node, 'PostgresFunctionSchemaNode', PostgresFunctionSchemaNode.is); + } +} diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-namespace-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-namespace-schema-node.ts index bf0a8ad29afd..b6343f016baa 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-namespace-schema-node.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-namespace-schema-node.ts @@ -1,6 +1,7 @@ import type { DiffableNode } from '@internal/framework-components/control'; import { freezeNode } from '@internal/framework-components/ir'; import { assertNode, SqlSchemaIRNode } from '@internal/sql-schema-ir/types'; +import type { PostgresFunctionSchemaNode } from './postgres-function-schema-node'; import type { PostgresNativeEnumSchemaNode } from './postgres-native-enum-schema-node'; import type { PostgresTableSchemaNode } from './postgres-table-schema-node'; import { PostgresSchemaNodeKind } from './schema-node-kinds'; @@ -15,6 +16,8 @@ export interface PostgresNamespaceSchemaNodeInput { * entity's grade); introspection builds nodes with no `control`. */ readonly nativeEnums?: readonly PostgresNativeEnumSchemaNode[]; + /** Managed Postgres functions declared in this namespace. */ + readonly functions?: readonly PostgresFunctionSchemaNode[]; } /** @@ -26,7 +29,7 @@ export interface PostgresNamespaceSchemaNodeInput { * (`postgres-namespace`), distinct from `SqlSchemaIR`'s own (`sql-schema`). * * `id` is the schema name; `isEqualTo` is identity on it; `children()` returns - * the table nodes plus `nativeEnums`. + * the table nodes plus `nativeEnums` and `functions`. * * `nativeEnums` is the diff-tree representation of native enum types the * differ pairs — the sole enum carrier, built directly by both sides: the @@ -39,12 +42,14 @@ export class PostgresNamespaceSchemaNode extends SqlSchemaIRNode implements Diff readonly schemaName: string; readonly tables: Readonly>; readonly nativeEnums: readonly PostgresNativeEnumSchemaNode[]; + readonly functions: readonly PostgresFunctionSchemaNode[]; constructor(input: PostgresNamespaceSchemaNodeInput) { super(); this.schemaName = input.schemaName; this.tables = Object.freeze({ ...input.tables }); this.nativeEnums = Object.freeze([...(input.nativeEnums ?? [])]); + this.functions = Object.freeze([...(input.functions ?? [])]); freezeNode(this); } @@ -57,7 +62,7 @@ export class PostgresNamespaceSchemaNode extends SqlSchemaIRNode implements Diff } children(): readonly DiffableNode[] { - return [...Object.values(this.tables), ...this.nativeEnums]; + return [...Object.values(this.tables), ...this.nativeEnums, ...this.functions]; } static is(node: SqlSchemaIRNode): node is PostgresNamespaceSchemaNode { diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/schema-node-kinds.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/schema-node-kinds.ts index 3795c148024a..ad542618521a 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/schema-node-kinds.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/schema-node-kinds.ts @@ -27,6 +27,7 @@ export const PostgresSchemaNodeKind = { policy: 'postgres-policy', role: 'postgres-role', nativeEnum: 'postgres-native-enum', + function: 'postgres-function', } as const; export type PostgresSchemaNodeKind = @@ -53,6 +54,7 @@ const POSTGRES_NODE_GRANULARITY: Readonly>> = { [PostgresSchemaNodeKind.table]: 'table', [PostgresSchemaNodeKind.nativeEnum]: 'native_enum', + [PostgresSchemaNodeKind.function]: 'function', }; /** Looks up the storage entityKind for a Postgres-specific `nodeKind`. */ diff --git a/packages/3-targets/3-targets/postgres/src/exports/types.ts b/packages/3-targets/3-targets/postgres/src/exports/types.ts index 89938d1a256b..fe689d081869 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/types.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/types.ts @@ -4,6 +4,11 @@ export { type PostgresCheckKind, postgresRenderCheckExpressions, } from '../core/check-expressions'; +export { + PostgresFunction, + type PostgresFunctionInput, + type PostgresFunctionVolatility, +} from '../core/postgres-function'; export { PostgresNativeEnum, type PostgresNativeEnumInput } from '../core/postgres-native-enum'; export { PostgresRlsEnablement, @@ -27,6 +32,10 @@ export { PostgresDatabaseSchemaNode, type PostgresDatabaseSchemaNodeInput, } from '../core/schema-ir/postgres-database-schema-node'; +export { + PostgresFunctionSchemaNode, + type PostgresFunctionSchemaNodeInput, +} from '../core/schema-ir/postgres-function-schema-node'; export { PostgresNamespaceSchemaNode, type PostgresNamespaceSchemaNodeInput, diff --git a/packages/3-targets/3-targets/postgres/test/migrations/function-planner.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/function-planner.test.ts new file mode 100644 index 000000000000..33184fce6199 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/function-planner.test.ts @@ -0,0 +1,143 @@ +import { type Contract, coreHash, profileHash } from '@internal/contract/types'; +import { SqlStorage, StorageTable } from '@internal/sql-contract/types'; +import { describe, expect, it } from 'vitest'; +import { buildPostgresPlanDiff } from '../../src/core/migrations/diff-database-schema'; +import { coalesceSubtreeIssues, planIssues } from '../../src/core/migrations/issue-planner'; +import { + CreateFunctionCall, + CreateTableCall, + type PostgresOpFactoryCall, +} from '../../src/core/migrations/op-factory-call'; +import { createFunction } from '../../src/core/migrations/operations/functions'; +import { PostgresFunction } from '../../src/core/postgres-function'; +import { PostgresSchema } from '../../src/core/postgres-schema'; +import { PostgresDatabaseSchemaNode } from '../../src/core/schema-ir/postgres-database-schema-node'; +import { PostgresFunctionSchemaNode } from '../../src/core/schema-ir/postgres-function-schema-node'; +import { PostgresNamespaceSchemaNode } from '../../src/core/schema-ir/postgres-namespace-schema-node'; + +const APP_NANOID_BODY = `DECLARE + id text := ''; +BEGIN + RETURN id; +END`; + +function makeContract(): Contract { + const schema = new PostgresSchema({ + id: 'public', + entries: { + table: { + user: new StorageTable({ + columns: { + id: { + nativeType: 'varchar(16)', + codecId: 'pg/text@1', + nullable: false, + default: { kind: 'function', expression: 'app_nanoid(16)' }, + }, + }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }), + }, + function: { + app_nanoid: new PostgresFunction({ + functionName: 'app_nanoid', + signature: 'size int DEFAULT 16', + returns: 'text', + body: APP_NANOID_BODY, + }), + }, + }, + }); + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash('function-create'), + defaultControlPolicy: 'managed', + storage: new SqlStorage({ + storageHash: coreHash('function-create'), + namespaces: { public: schema }, + }), + roots: {}, + domain: { namespaces: { public: { models: {} } } }, + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +describe('Postgres function migration planning', () => { + it('plans CREATE FUNCTION before CREATE TABLE when both are missing', async () => { + const expected = buildPostgresPlanDiff({ contract: makeContract() }).expected; + const actual = new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: {}, + functions: [], + }), + }, + roles: {}, + existingSchemas: ['public'], + pgVersion: '', + }); + const issues = coalesceSubtreeIssues( + buildPostgresPlanDiff({ contract: makeContract(), actual }).issues, + ); + const result = await planIssues(issues, { + expected, + actual, + codecHooks: new Map(), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + const calls = result.value as readonly PostgresOpFactoryCall[]; + const createFunctionIdx = calls.findIndex((call) => call instanceof CreateFunctionCall); + const createTableIdx = calls.findIndex((call) => call instanceof CreateTableCall); + expect(createFunctionIdx).toBeGreaterThanOrEqual(0); + expect(createTableIdx).toBeGreaterThanOrEqual(0); + expect(createFunctionIdx).toBeLessThan(createTableIdx); + }); + + it('renders CREATE OR REPLACE FUNCTION with the opaque body', () => { + const op = createFunction({ + schemaName: 'public', + functionName: 'app_nanoid', + signature: 'size int DEFAULT 16', + returns: 'text', + body: APP_NANOID_BODY, + language: 'plpgsql', + volatility: 'STABLE', + }); + const sql = op.execute[0]?.sql ?? ''; + expect(sql).toContain('CREATE OR REPLACE FUNCTION "public"."app_nanoid"(size int DEFAULT 16)'); + expect(sql).toContain('RETURNS text'); + expect(sql).toContain('LANGUAGE plpgsql STABLE'); + expect(sql).toContain(APP_NANOID_BODY); + }); + + it('diff equality ignores control and matches authored fields', () => { + const a = new PostgresFunctionSchemaNode({ + functionName: 'app_nanoid', + namespaceId: 'public', + signature: 'size int DEFAULT 16', + returns: 'text', + body: APP_NANOID_BODY, + language: 'plpgsql', + volatility: 'STABLE', + }); + const b = new PostgresFunctionSchemaNode({ + functionName: 'app_nanoid', + namespaceId: 'public', + signature: 'size int DEFAULT 16', + returns: 'text', + body: APP_NANOID_BODY, + language: 'plpgsql', + volatility: 'STABLE', + control: 'managed', + }); + expect(a.isEqualTo(b)).toBe(true); + }); +}); From ff8e83ae1631ec958546b6e3385fbc68352bcb53 Mon Sep 17 00:00:00 2001 From: Rhae-Shane Date: Mon, 21 Sep 2026 00:09:53 +0530 Subject: [PATCH 04/10] fix(postgres): gate DROP FUNCTION via storage coordinates and control policy Function schema nodes were missing from postgresNodeStorageCoordinate, so ownership and control-policy partitions could not treat them as first-class entities. Wire the coordinate and add managed/external/tolerated/observed DROP coverage plus sibling-ownership selectivity. Signed-off-by: Rhae-Shane Co-authored-by: Cursor --- .../src/core/migrations/control-policy.ts | 2 +- .../postgres/src/core/migrations/planner.ts | 6 +- .../core/schema-ir/node-storage-coordinate.ts | 8 +- .../function-control-policy.test.ts | 238 ++++++++++++++++++ 4 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 packages/3-targets/3-targets/postgres/test/migrations/function-control-policy.test.ts diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/control-policy.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/control-policy.ts index ee42c081237b..73cf414221b2 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/control-policy.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/control-policy.ts @@ -229,7 +229,7 @@ export function resolvePostgresCallControlPolicySubject( /** * Node kinds whose *absence* is the creation of a whole, top-level Postgres - * object: a namespace, a table, or a native enum. Used by + * object: a namespace, a table, a native enum, or a function. Used by * {@link resolvePostgresNodeIssueCreationFactoryName} to decide whether a * `tolerated` subject permits the issue to flow into the planner * (create-if-absent) and to seed the suppressed-subject warning's diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts index bd3314b0a648..8de54185a325 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts @@ -889,9 +889,9 @@ function isPolicyDiffIssue(issue: SchemaDiffIssue): boolean { } /** - * Drops a `not-expected` issue when it is a whole extra storage entity (a table - * or a native enum) that some space in the composition owns. Each such node - * yields its own storage coordinate (see {@link postgresNodeStorageCoordinate}), + * Drops a `not-expected` issue when it is a whole extra storage entity (a table, + * native enum, or function) that some space in the composition owns. Each such + * node yields its own storage coordinate (see {@link postgresNodeStorageCoordinate}), * so `declaresEntity` answers over the whole composition on one uniform * coordinate: a positive answer means a sibling owns this entity here — leave * it; a negative answer means a genuine orphan — drop it. A node with no storage diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/node-storage-coordinate.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/node-storage-coordinate.ts index 166be6782f01..3778676848fe 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/node-storage-coordinate.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/node-storage-coordinate.ts @@ -1,5 +1,6 @@ import type { EntityCoordinate } from '@internal/framework-components/ir'; import type { SqlSchemaIRNode } from '@internal/sql-schema-ir/types'; +import { PostgresFunctionSchemaNode } from './postgres-function-schema-node'; import { PostgresNativeEnumSchemaNode } from './postgres-native-enum-schema-node'; import { PostgresTableSchemaNode } from './postgres-table-schema-node'; import { postgresNodeEntityKind } from './schema-node-kinds'; @@ -7,7 +8,8 @@ import { postgresNodeEntityKind } from './schema-node-kinds'; /** * The storage-`entries` coordinate `(entityKind, entityName)` a Postgres diff * node addresses — a table by its name, a native enum by its physical type - * name. `undefined` for a node that is not a whole storage entity (a namespace, + * name, a function by its physical function name (overloads unsupported in + * MVP). `undefined` for a node that is not a whole storage entity (a namespace, * a column, a policy). Lets ownership/subject resolution treat every entity kind * uniformly, the node self-describing its storage identity. */ @@ -22,5 +24,9 @@ export function postgresNodeStorageCoordinate( const entityKind = postgresNodeEntityKind(node.nodeKind); return entityKind === undefined ? undefined : { entityKind, entityName: node.typeName }; } + if (PostgresFunctionSchemaNode.is(node)) { + const entityKind = postgresNodeEntityKind(node.nodeKind); + return entityKind === undefined ? undefined : { entityKind, entityName: node.functionName }; + } return undefined; } diff --git a/packages/3-targets/3-targets/postgres/test/migrations/function-control-policy.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/function-control-policy.test.ts new file mode 100644 index 000000000000..7a19ef6eda2a --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/function-control-policy.test.ts @@ -0,0 +1,238 @@ +/** + * Function entity control-policy + ownership: DROP only for managed unclaimed + * extras; external / tolerated / observed must not drop live functions Prisma + * does not own. Mirrors the native-enum planner ownership tests. + */ +import type { ControlPolicy } from '@internal/contract/types'; +import { type Contract, coreHash, profileHash } from '@internal/contract/types'; +import type { MigrationOperationPolicy } from '@internal/family-sql/control'; +import type { ExecuteRequestLowerer } from '@internal/family-sql/control-adapter'; +import type { + SchemaDiffIssue, + SchemaEntityCoordinate, + SchemaOwnership, +} from '@internal/framework-components/control'; +import { APP_SPACE_ID } from '@internal/framework-components/control'; +import { coordinateKey, UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { SqlStorage, StorageTable } from '@internal/sql-contract/types'; +import { applicationDomainOf } from '@repo/test-utils'; +import { describe, expect, it } from 'vitest'; +import { resolvePostgresNodeIssueControlPolicySubject } from '../../src/core/migrations/control-policy'; +import { createPostgresMigrationPlanner } from '../../src/core/migrations/planner'; +import { PostgresFunction } from '../../src/core/postgres-function'; +import { PostgresSchema } from '../../src/core/postgres-schema'; +import { PostgresDatabaseSchemaNode } from '../../src/core/schema-ir/postgres-database-schema-node'; +import { PostgresFunctionSchemaNode } from '../../src/core/schema-ir/postgres-function-schema-node'; +import { PostgresNamespaceSchemaNode } from '../../src/core/schema-ir/postgres-namespace-schema-node'; +import { PostgresTableSchemaNode } from '../../src/core/schema-ir/postgres-table-schema-node'; +import type { SqlSchemaDiffNode } from '../../src/core/schema-ir/schema-node-kinds'; + +const BODY = 'RETURN null;'; + +const stubLowerer: ExecuteRequestLowerer = { + lower(_ast, _ctx) { + return { sql: 'stub', params: [] }; + }, + async lowerToExecuteRequest(_ast, _ctx) { + return { sql: 'stub', params: [] }; + }, +}; + +const DB_UPDATE_POLICY: MigrationOperationPolicy = { + allowedOperationClasses: ['additive', 'widening', 'destructive'], +}; + +function emptyTable(): StorageTable { + return new StorageTable({ + columns: { id: { nativeType: 'int4', codecId: 'pg/int4@1', nullable: false } }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }); +} + +function makeContract(options: { + readonly defaultControlPolicy?: ControlPolicy; + readonly withFunction?: boolean; + readonly functionControl?: ControlPolicy; +}): Contract { + const schema = new PostgresSchema({ + id: 'public', + entries: { + table: { users: emptyTable() }, + ...(options.withFunction === false + ? {} + : { + function: { + app_nanoid: new PostgresFunction({ + functionName: 'app_nanoid', + signature: 'size int DEFAULT 16', + returns: 'text', + body: BODY, + ...(options.functionControl !== undefined + ? { control: options.functionControl } + : {}), + }), + }, + }), + }, + }); + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash('function-control'), + defaultControlPolicy: options.defaultControlPolicy ?? 'managed', + storage: new SqlStorage({ + storageHash: coreHash('function-control'), + namespaces: { public: schema }, + }), + roots: {}, + domain: applicationDomainOf({ models: {} }), + capabilities: {}, + extensions: {}, + meta: {}, + }; +} + +function tableNode(): PostgresTableSchemaNode { + return new PostgresTableSchemaNode({ + name: 'users', + columns: { id: { name: 'id', nativeType: 'int4', nullable: false } }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + policies: [], + rlsEnabled: false, + }); +} + +function functionNode(name: string): PostgresFunctionSchemaNode { + return new PostgresFunctionSchemaNode({ + functionName: name, + namespaceId: 'public', + signature: 'size int DEFAULT 16', + returns: 'text', + body: BODY, + language: 'plpgsql', + volatility: 'STABLE', + }); +} + +function liveTree(functions: readonly string[]) { + return new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: { users: tableNode() }, + functions: functions.map(functionNode), + }), + }, + roles: [], + existingSchemas: ['public'], + pgVersion: 'unknown', + }); +} + +function ownsOnly(...coordinates: readonly SchemaEntityCoordinate[]): SchemaOwnership { + const owned = new Set(coordinates.map(coordinateKey)); + return { declaresEntity: (coordinate) => owned.has(coordinateKey(coordinate)) }; +} + +async function planOps( + contract: Contract, + schema: PostgresDatabaseSchemaNode, + ownership?: SchemaOwnership, +) { + const planner = createPostgresMigrationPlanner(stubLowerer); + const result = planner.plan({ + contract, + schema, + policy: DB_UPDATE_POLICY, + fromContract: null, + frameworkComponents: [], + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + ...(ownership !== undefined ? { ownership } : {}), + }); + expect(result.kind).toBe('success'); + if (result.kind !== 'success') return []; + return Promise.all(result.plan.operations); +} + +describe('resolvePostgresNodeIssueControlPolicySubject — function entities', () => { + const contract = makeContract({ withFunction: true }); + + it('a function create issue resolves entityKind function and physical name', () => { + const issue: SchemaDiffIssue = { + path: ['database', 'public', 'function:app_nanoid'], + expected: functionNode('app_nanoid'), + }; + expect(resolvePostgresNodeIssueControlPolicySubject(issue, contract)).toEqual({ + namespaceId: 'public', + entityKind: 'function', + entityName: 'app_nanoid', + createsNewObject: true, + }); + }); + + it('a dropped (not-expected) function resolves to UNBOUND like a dropped enum', () => { + const issue: SchemaDiffIssue = { + path: ['database', 'public', 'function:stray_helper'], + actual: functionNode('stray_helper'), + }; + expect(resolvePostgresNodeIssueControlPolicySubject(issue, contract)).toEqual({ + namespaceId: UNBOUND_NAMESPACE_ID, + entityKind: 'function', + entityName: 'stray_helper', + createsNewObject: false, + }); + }); +}); + +describe('DROP FUNCTION gated by control policy', () => { + it('managed: drops an unclaimed live function under a destructive policy', async () => { + const ops = await planOps(makeContract({ withFunction: false }), liveTree(['stray_helper'])); + expect(ops.some((op) => op.id.startsWith('dropFunction.'))).toBe(true); + }); + + it('external: never drops an undeclared live function', async () => { + const ops = await planOps( + makeContract({ withFunction: false, defaultControlPolicy: 'external' }), + liveTree(['stray_helper']), + ); + expect(ops.some((op) => op.id.startsWith('dropFunction.'))).toBe(false); + }); + + it('observed: never drops an undeclared live function', async () => { + const ops = await planOps( + makeContract({ withFunction: false, defaultControlPolicy: 'observed' }), + liveTree(['stray_helper']), + ); + expect(ops.some((op) => op.id.startsWith('dropFunction.'))).toBe(false); + }); + + it('tolerated: never drops an undeclared live function (drop is not create-if-absent)', async () => { + const ops = await planOps( + makeContract({ withFunction: false, defaultControlPolicy: 'tolerated' }), + liveTree(['stray_helper']), + ); + expect(ops.some((op) => op.id.startsWith('dropFunction.'))).toBe(false); + }); + + it('never drops a live function a sibling space declares', async () => { + const siblingOwns = ownsOnly({ + namespaceId: 'public', + entityKind: 'function', + entityName: 'pack_helper', + }); + const ops = await planOps( + makeContract({ withFunction: false }), + liveTree(['pack_helper', 'unowned_helper']), + siblingOwns, + ); + const dropIds = ops.filter((op) => op.id.startsWith('dropFunction.')).map((op) => op.id); + expect(dropIds).toEqual(['dropFunction.unowned_helper']); + }); +}); From d5daf63e2f1ede8ba22709f90b6512b18402e820 Mon Sep 17 00:00:00 2001 From: Rhae-Shane Date: Mon, 21 Sep 2026 00:10:19 +0530 Subject: [PATCH 05/10] fix(postgres): emit CREATE FUNCTION and refuse in-place updates Align DDL with the planner: body/signature drift is a conflict (drop and recreate), so migrate must not emit CREATE OR REPLACE and imply replace works. Signed-off-by: Rhae-Shane Co-authored-by: Cursor --- .../postgres/src/contract/function.ts | 7 +++- .../src/core/migrations/issue-planner.ts | 15 +++---- .../core/migrations/operations/functions.ts | 12 ++++-- .../test/migrations/function-planner.test.ts | 41 ++++++++++++++++++- 4 files changed, 59 insertions(+), 16 deletions(-) diff --git a/packages/3-extensions/postgres/src/contract/function.ts b/packages/3-extensions/postgres/src/contract/function.ts index ebe40929960d..4ad9ead3f13c 100644 --- a/packages/3-extensions/postgres/src/contract/function.ts +++ b/packages/3-extensions/postgres/src/contract/function.ts @@ -11,8 +11,11 @@ export interface PgFunctionHandle { /** * Declares a managed Postgres function for the `entities` list. Migrate plans - * `CREATE OR REPLACE FUNCTION` / `DROP FUNCTION`. Column defaults that call the - * function stay raw SQL (for example `` sql`app_nanoid(16)` ``). + * `CREATE FUNCTION` / `DROP FUNCTION` only — body or signature changes are not + * migrated yet (drop and recreate). The body is opaque SQL Prisma does not + * validate. Overloaded names are unsupported (one physical name per namespace). + * Column defaults that call the function stay raw SQL (for example + * `` sql`app_nanoid(16)` ``). */ export function pgFunction(input: { readonly name: string; diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts index 7d2aa7c3cb08..5a480a60d0bf 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/issue-planner.ts @@ -529,15 +529,12 @@ function nativeEnumMemberChangeRefusal(options: { } /** - * Managed native-enum issue -> op lowering. A missing declared type creates - * it; an unclaimed live type drops it (ownership-scoped upstream by - * `retainUnownedExtras`, destructiveness gated by the operation-class - * policy); a paired member-value mismatch lowers to one `ALTER TYPE ... ADD - * VALUE` per appended member when the database's members are a strict, - * order-preserving prefix of the contract's — any other change (rename, - * removal, reorder, or the database holding members the contract lacks) is - * refused with a NAMED diagnostic, never a silent no-op and never a - * drop-and-recreate. + * Managed function issue → op lowering. A missing declared function creates + * it (`CREATE FUNCTION`); an unclaimed live function drops it (ownership- + * scoped upstream by `retainUnownedExtras`, destructiveness gated by the + * operation-class policy and control-policy partition). Body or signature + * drift is refused with a named diagnostic — never silent, never + * `CREATE OR REPLACE`. */ function mapFunctionNodeIssue( issue: SchemaDiffIssue, diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/functions.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/functions.ts index e268600f8efc..91a77022495e 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/functions.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/functions.ts @@ -28,8 +28,10 @@ function renderCreateFunctionSql(options: { ); } const name = qualifiedFunctionName(options.schemaName, options.functionName); + // CREATE (not OR REPLACE): body/signature edits are planner conflicts until a + // dedicated replace/alter path exists. Authors must drop and recreate. return [ - `CREATE OR REPLACE FUNCTION ${name}(${options.signature})`, + `CREATE FUNCTION ${name}(${options.signature})`, `RETURNS ${options.returns}`, `LANGUAGE ${options.language} ${options.volatility}`, `AS $${BODY_DOLLAR_TAG}$`, @@ -38,7 +40,7 @@ function renderCreateFunctionSql(options: { ].join('\n'); } -/** `CREATE OR REPLACE FUNCTION` for a managed Postgres function entity. */ +/** `CREATE FUNCTION` for a managed Postgres function entity. */ export function createFunction(options: { readonly schemaName: string; readonly functionName: string; @@ -60,7 +62,11 @@ export function createFunction(options: { }; } -/** `DROP FUNCTION` for an unclaimed managed Postgres function. */ +/** + * `DROP FUNCTION` for an unclaimed managed Postgres function. + * Control policy / ownership must suppress this for `external` / + * `tolerated` / `observed` subjects and for entities another space owns. + */ export function dropFunction(options: { readonly schemaName: string; readonly functionName: string; diff --git a/packages/3-targets/3-targets/postgres/test/migrations/function-planner.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/function-planner.test.ts index 33184fce6199..3a1b157931e2 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/function-planner.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/function-planner.test.ts @@ -101,7 +101,7 @@ describe('Postgres function migration planning', () => { expect(createFunctionIdx).toBeLessThan(createTableIdx); }); - it('renders CREATE OR REPLACE FUNCTION with the opaque body', () => { + it('renders CREATE FUNCTION with the opaque body (not OR REPLACE)', () => { const op = createFunction({ schemaName: 'public', functionName: 'app_nanoid', @@ -112,12 +112,49 @@ describe('Postgres function migration planning', () => { volatility: 'STABLE', }); const sql = op.execute[0]?.sql ?? ''; - expect(sql).toContain('CREATE OR REPLACE FUNCTION "public"."app_nanoid"(size int DEFAULT 16)'); + expect(sql).toContain('CREATE FUNCTION "public"."app_nanoid"(size int DEFAULT 16)'); + expect(sql).not.toContain('OR REPLACE'); expect(sql).toContain('RETURNS text'); expect(sql).toContain('LANGUAGE plpgsql STABLE'); expect(sql).toContain(APP_NANOID_BODY); }); + it('rejects not-equal function drift instead of planning CREATE OR REPLACE', async () => { + const expected = buildPostgresPlanDiff({ contract: makeContract() }).expected; + const actualFn = new PostgresFunctionSchemaNode({ + functionName: 'app_nanoid', + namespaceId: 'public', + signature: 'size int DEFAULT 16', + returns: 'text', + body: 'RETURN null;', + language: 'plpgsql', + volatility: 'STABLE', + }); + const actual = new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: {}, + functions: [actualFn], + }), + }, + roles: {}, + existingSchemas: ['public'], + pgVersion: '', + }); + const issues = coalesceSubtreeIssues( + buildPostgresPlanDiff({ contract: makeContract(), actual }).issues, + ); + const result = await planIssues(issues, { + expected, + actual, + codecHooks: new Map(), + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.summary).toMatch(/not supported yet|drop and recreate/i); + }); + it('diff equality ignores control and matches authored fields', () => { const a = new PostgresFunctionSchemaNode({ functionName: 'app_nanoid', From d350691379c587389e6d7ef186d2211874b4f201 Mon Sep 17 00:00:00 2001 From: Rhae-Shane Date: Mon, 21 Sep 2026 00:10:42 +0530 Subject: [PATCH 06/10] docs(postgres): constrain SQL function MVP scope and overload policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document name-only identity (overloads unsupported), bucket-only deps for simple app-owned helpers, opaque unchecked bodies, create/drop-only updates, and that app_nanoid naming is temporary — not a permanent ban on DB nanoid. Signed-off-by: Rhae-Shane Co-authored-by: Cursor --- docs/reference/error-reference.md | 2 +- .../1-core/contract/src/default-sql-body.ts | 3 +- .../postgres/src/core/postgres-function.ts | 5 ++- .../postgres/src/core/postgres-schema.ts | 2 +- .../postgres-function-schema-node.ts | 10 +++-- .../postgres/test/postgres-schema.test.ts | 28 +++++++++++++ projects/postgres-sql-functions/README.md | 11 +++-- projects/postgres-sql-functions/plan.md | 4 +- projects/postgres-sql-functions/spec.md | 40 ++++++++++++++----- 9 files changed, 84 insertions(+), 21 deletions(-) diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index b145f80e07fc..749a004a232f 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -585,7 +585,7 @@ A `` @default(sql`...`) `` body fails the SQL family's body check: `Default SQL ### PSL_RAW_DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR -A raw SQL default (`` @default(sql`...`) `` or `@default(dbgenerated("..."))`) is a bare call to a Prisma client-side ID generator (`nanoid`, `uuid`, `cuid`, or `ulid`, optionally schema-qualified). That expression would become `DEFAULT (nanoid(16))` in migrate without creating the function in Postgres, so apply fails. Write `@default(nanoid(16))` (or the matching named generator) so Prisma generates the value before insert, or declare a Postgres function entity and call a database function name that is not a Prisma generator (for example `app_nanoid(16)`). Reported at the literal or `dbgenerated` call. TypeScript `` sql`nanoid(16)` `` raises `CONTRACT.DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR` with the same message. +A raw SQL default (`` @default(sql`...`) `` or `@default(dbgenerated("..."))`) is a bare call to a Prisma client-side ID generator (`nanoid`, `uuid`, `cuid`, or `ulid`, optionally schema-qualified). That expression would become `DEFAULT (nanoid(16))` in migrate without creating the function in Postgres, so apply fails. Write `@default(nanoid(16))` (or the matching named generator) so Prisma generates the value before insert, or declare a Postgres function entity and call a database function name that is not a Prisma generator (for example `app_nanoid(16)`). The `app_nanoid` naming is an MVP convenience so the diagnostic stays a pure string check; a later slice should allow an explicitly declared DB function named `nanoid` to coexist with the client generator. Reported at the literal or `dbgenerated` call. TypeScript `` sql`nanoid(16)` `` raises `CONTRACT.DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR` with the same message. ## ORM diff --git a/packages/2-sql/1-core/contract/src/default-sql-body.ts b/packages/2-sql/1-core/contract/src/default-sql-body.ts index 24cc846eb96e..7744d3352719 100644 --- a/packages/2-sql/1-core/contract/src/default-sql-body.ts +++ b/packages/2-sql/1-core/contract/src/default-sql-body.ts @@ -44,6 +44,7 @@ export function clientGeneratorSqlDefaultMessage(options: { `Raw SQL default "${options.body.trim()}" looks like Prisma's client-side ${options.generator}() generator. ` + `Write ${options.namedForm} so Prisma generates the value before insert, ` + 'or declare a Postgres function entity and call a database function name that is not a Prisma generator ' + - '(for example app_nanoid(16)). Migrate does not create a function from this expression alone.' + '(for example app_nanoid(16); naming around client generators is an MVP constraint). ' + + 'Migrate does not create a function from this expression alone.' ); } diff --git a/packages/3-targets/3-targets/postgres/src/core/postgres-function.ts b/packages/3-targets/3-targets/postgres/src/core/postgres-function.ts index adac6bfb7e76..b1e8142601a4 100644 --- a/packages/3-targets/3-targets/postgres/src/core/postgres-function.ts +++ b/packages/3-targets/3-targets/postgres/src/core/postgres-function.ts @@ -24,8 +24,11 @@ export interface PostgresFunctionInput { * Postgres contract-IR class for a user-defined SQL function. * * Authored and serialized into `contract.json` under - * `storage.namespaces[ns].entries.function[HandleName]`. Schema-diff uses + * `storage.namespaces[ns].entries.function[]`. Schema-diff uses * {@link PostgresFunctionSchemaNode}; this class is not a DiffableNode. + * + * MVP limits: opaque `signature`/`body` (not validated), one name per namespace + * (no overloads), create/drop only (no in-place replace). */ export class PostgresFunction extends SqlNode { static is(node: unknown): node is PostgresFunction { diff --git a/packages/3-targets/3-targets/postgres/src/core/postgres-schema.ts b/packages/3-targets/3-targets/postgres/src/core/postgres-schema.ts index 00497bd9546d..15e3a69ee289 100644 --- a/packages/3-targets/3-targets/postgres/src/core/postgres-schema.ts +++ b/packages/3-targets/3-targets/postgres/src/core/postgres-schema.ts @@ -120,7 +120,7 @@ export class PostgresSchema extends SqlNamespaceBase { const physicalName = PostgresFunction.is(entity) ? entity.functionName : handle; invariant( !Object.hasOwn(rekeyed, physicalName), - `PostgresSchema "${input.id}": two function entities resolve to the same physical function name "${physicalName}".`, + `PostgresSchema "${input.id}": two function entities resolve to the same physical function name "${physicalName}". Overloaded functions (same name, different signatures) are unsupported; give each function a distinct name.`, ); rekeyed[physicalName] = entity; } diff --git a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-function-schema-node.ts b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-function-schema-node.ts index 7868a1d9011a..d152030fe2b6 100644 --- a/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-function-schema-node.ts +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-function-schema-node.ts @@ -18,9 +18,13 @@ export interface PostgresFunctionSchemaNodeInput { } /** - * Schema-diff leaf for a managed Postgres function. Identity is - * `function:` within a namespace. Equality compares every - * authored field except `control`. + * Schema-diff leaf for a managed Postgres function. + * + * **MVP identity is name-only** (`function:` within a namespace). + * Postgres overloads (`foo(int)` vs `foo(text)`) are unsupported: the schema + * rejects two entities that share a physical `functionName`. Equality compares + * every authored field except `control`. The opaque `signature` is not part of + * `id` because the MVP cannot normalize Postgres argument-list spelling. */ export class PostgresFunctionSchemaNode extends SqlSchemaIRNode implements DiffableNode { override readonly nodeKind = PostgresSchemaNodeKind.function; diff --git a/packages/3-targets/3-targets/postgres/test/postgres-schema.test.ts b/packages/3-targets/3-targets/postgres/test/postgres-schema.test.ts index 622087392665..b02f0f144ea4 100644 --- a/packages/3-targets/3-targets/postgres/test/postgres-schema.test.ts +++ b/packages/3-targets/3-targets/postgres/test/postgres-schema.test.ts @@ -329,6 +329,34 @@ describe('PostgresSchema — native_enum entries', () => { }); }); +describe('PostgresSchema — function entries', () => { + it('rejects two function entities that share a physical function name (overloads unsupported)', () => { + const collidingInput = { + FooInt: { + kind: 'postgres-function', + functionName: 'foo', + signature: 'x int', + returns: 'int', + body: 'RETURN x;', + language: 'plpgsql', + volatility: 'STABLE', + }, + FooText: { + kind: 'postgres-function', + functionName: 'foo', + signature: 'x text', + returns: 'text', + body: 'RETURN x;', + language: 'plpgsql', + volatility: 'STABLE', + }, + }; + expect( + () => new PostgresSchema({ id: 'public', entries: { table: {}, function: collidingInput } }), + ).toThrow(/Overloaded functions|same physical function name "foo"/); + }); +}); + describe('PostgresSchema — native_enum affects storageHash', () => { function hashWithNativeEnum(members: readonly string[]): string { return computeStorageHash({ diff --git a/projects/postgres-sql-functions/README.md b/projects/postgres-sql-functions/README.md index a2402a6de855..e8e2c0faaea4 100644 --- a/projects/postgres-sql-functions/README.md +++ b/projects/postgres-sql-functions/README.md @@ -23,8 +23,11 @@ Or paste [`github-issue.md`](github-issue.md) into https://github.com/prisma/orm ## What landed in-tree (MVP) -- **Diagnostics:** raw `` sql`nanoid(...)` `` / `dbgenerated("nanoid(...)")` refused when the expression looks like a client generator (`PSL_RAW_DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR` / `CONTRACT.DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR`). -- **Entity:** `PostgresFunction` + PSL `function` block + TS `pgFunction(...)` on the entities list. -- **Migrations:** `createFunction` / `dropFunction` in the `dep` / `dropType` buckets so CREATE FUNCTION runs before CREATE TABLE. +Narrow scope — see [`spec.md`](spec.md) D4–D9: -Prefer client `@default(nanoid(16))` / `field.generated(nanoid())` when the app always inserts through Prisma. For a DB-side helper, name it something other than a Prisma generator (e.g. `app_nanoid`) and declare it with `pgFunction` / `function`. +- **Diagnostics:** raw `` sql`nanoid(...)` `` / `dbgenerated("nanoid(...)")` refused when the expression looks like a client generator (`PSL_RAW_DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR` / `CONTRACT.DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR`). Naming `app_nanoid` is an MVP convenience, not a permanent ban on a DB function named `nanoid`. +- **Entity:** `PostgresFunction` + PSL `function` block + TS `pgFunction(...)` — opaque body/signature (Prisma does not validate SQL); one physical name per namespace (no overloads). +- **Migrations:** `CREATE FUNCTION` / `DROP FUNCTION` only (no `OR REPLACE`; body/signature edits conflict until drop+recreate). `dep` / `dropType` bucket ordering — enough for simple helpers whose dependencies already exist; not a general Postgres function dependency graph. +- **Ownership:** `DROP` only for managed / unclaimed functions; mark shared or extension functions `external`. + +Prefer client `@default(nanoid(16))` / `field.generated(nanoid())` when the app always inserts through Prisma. For a DB-side helper in this MVP, declare `pgFunction` / `function` and call a non-generator name (e.g. `app_nanoid`). diff --git a/projects/postgres-sql-functions/plan.md b/projects/postgres-sql-functions/plan.md index e2bcd5843833..ecdb394ca6a0 100644 --- a/projects/postgres-sql-functions/plan.md +++ b/projects/postgres-sql-functions/plan.md @@ -39,4 +39,6 @@ Integration test: contract with `nanoid` function entity + column `` .default(sq Note: Slice A’s diagnostic will refuse bare `nanoid(16)` as a raw default. The happy path for DB-side nanoid after A must either (a) use a **different** DB function name (e.g. `app_nanoid(16)`) in the default expression, or (b) allow an authoring escape (declared function entity with matching name suppresses the diagnostic). **Decision for this project:** (b) — if a `function` entity with `functionName === matchedName` exists in the same contract namespace in scope at build time, suppress the lookalike diagnostic. Until entity authoring lands, tests for DB-side defaults use a non-client name (`app_nanoid`) or temporarily test the diagnostic only. Slice D uses `app_nanoid` for the default expression to keep A and D independent, documenting that renaming avoids the client-generator collision. -Amendment: prefer **(a) `app_nanoid`** for Slice D so Slice A stays a pure string check without a contract-wide entity scan. Authors who want DB `nanoid` name the function `nanoid` and use an escape hatch later; MVP docs recommend `app_nanoid` or client `nanoid()`. +Amendment: prefer **(a) `app_nanoid`** for Slice D so Slice A stays a pure string check without a contract-wide entity scan. Authors who want DB `nanoid` name the function `nanoid` and use an escape hatch later; MVP docs recommend `app_nanoid` or client `nanoid()`. That collision is temporary — a later slice should suppress the diagnostic when a matching function entity is declared. + +Review follow-ups landed on the same branch: create-only (no `OR REPLACE`), name-only identity with explicit overload rejection, function storage coordinates for control-policy DROP gating, and tighter MVP dependency wording in the spec. diff --git a/projects/postgres-sql-functions/spec.md b/projects/postgres-sql-functions/spec.md index d31502c929b8..0227d6e3eb46 100644 --- a/projects/postgres-sql-functions/spec.md +++ b/projects/postgres-sql-functions/spec.md @@ -28,10 +28,12 @@ This project makes **app-owned Postgres functions first-class contract entities* - Shipping a built-in Prisma `nanoid` PL/pgSQL body as a magic default. - Auto-inventing `CREATE FUNCTION` because a default mentions a name. - Introspection / `contract infer` of live functions (deferred). -- `ALTER FUNCTION` / body replace as a structured op (deferred; `CREATE OR REPLACE` may appear inside the opaque body). +- `ALTER FUNCTION` / `CREATE OR REPLACE` / body replace as a structured op (deferred; body or signature drift is a planner conflict — drop and recreate). - Views, triggers, or procedures. - SQLite or other targets. - Extension-owned large function dumps (ADR 212 contract spaces) — app contract only for MVP. +- Overloaded functions (same name, different argument types). +- Fine-grained dependency graphs from function bodies to types, extensions, tables, or other functions. ## Decisions @@ -50,34 +52,54 @@ The entity stores: - optional `language` — default `plpgsql` - optional `volatility` — default `STABLE` for MVP documentation; rendered into DDL -The planner renders a deterministic `CREATE OR REPLACE FUNCTION` / `DROP FUNCTION` from these fields. It does not validate that `body` is correct SQL; Postgres reports errors at apply. +The planner renders a deterministic `CREATE FUNCTION` / `DROP FUNCTION` from these fields. It does **not** validate that `body` or `signature` is correct SQL; Postgres reports errors at apply. Treat the entity like structured raw SQL — Prisma stores and replays it. ### D3. Planner never synthesizes functions from defaults A column default that calls `nanoid(16)` does not create a function entity. Authors must declare the function in `entities` / a PSL `function` block. Diagnostics may hint that path. -### D4. Ordering +### D4. Ordering (MVP scope) -`createFunction` classifies as `dep` (with `createNativeEnumType`). `dropFunction` classifies as `dropType`. Fine-grained `dependsOn` from columns to functions is deferred; bucket ordering is enough for the nanoid happy path when the function is created in the same plan as the table. +`createFunction` classifies as `dep` (with `createNativeEnumType`). `dropFunction` classifies as `dropType`. -### D5. Client-generator lookalike diagnostic +**MVP assumption:** managed functions are simple app-owned helpers whose dependencies (extensions, types, tables, other functions) already exist or do not need fine-grained ordering beyond the `dep` bucket relative to tables/columns. Bucket ordering is enough for the showcased `app_nanoid` → column `DEFAULT` case. Fine-grained `dependsOn` from columns to functions, and from function bodies to other catalog objects, is **out of scope** — do not use this MVP for functions that must be created after another function, table, or extension in the same plan. -When a storage default expression matches a known client generator call shape (`nanoid`, `nanoid()`, `uuid`, `uuid(4|7)`, `cuid`, `cuid(2)`, `ulid`, with optional schema qualification stripped), authoring emits a diagnostic that distinguishes client vs DB defaults. Severity: **error** at authoring time for the known bare names (authors almost always meant the client generator). Escapes: any expression that is not a simple call of those names (e.g. `public.nanoid(16)` after we strip only unprefixed forms — actually `public.nanoid` should still warn). Decision: match the **function identifier** after stripping a single optional schema qualifier; if the name is in the client-generator set, emit `PSL_RAW_DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR` / `CONTRACT.DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR`. +### D5. Client-generator lookalike diagnostic (temporary naming friction) + +When a storage default expression matches a known client generator call shape (`nanoid`, `nanoid()`, `uuid`, `uuid(4|7)`, `cuid`, `cuid(2)`, `ulid`, with optional schema qualification stripped), authoring emits a diagnostic that distinguishes client vs DB defaults. Severity: **error** at authoring time for the known bare names (authors almost always meant the client generator). Match the **function identifier** after stripping a single optional schema qualifier; if the name is in the client-generator set, emit `PSL_RAW_DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR` / `CONTRACT.DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR`. + +**Not a permanent ban on DB functions named `nanoid`.** MVP docs recommend `app_nanoid` (or client `nanoid()`) so Slice A stays a pure string check. A later slice should allow an explicitly declared function entity named `nanoid` to coexist with the client generator: `@default(nanoid(16))` stays client-side; `` sql`nanoid(16)` `` with a declared entity becomes a DB call. ### D6. No named Prisma default for DB nanoid Do not register `nanoid()` as a storage default function. Client `nanoid()` stays execution-only. +### D7. One physical function name per namespace (no overloads) + +Schema identity is `function:`. Entries re-key by `functionName` and reject collisions. Postgres overloads are unsupported until identity includes a normalized signature. + +### D8. Create-only; no in-place replace + +Emit `CREATE FUNCTION` (not `CREATE OR REPLACE`). Changing body or signature is a planner conflict: drop the entity and recreate. This matches the unsupported-update policy and avoids implying replace works. + +### D9. DROP only for managed / unclaimed ownership + +`DROP FUNCTION` applies when a previously managed function disappears from this space's contract and no sibling space owns it. Control grades `external` / `tolerated` / `observed` must not drop undeclared live functions the same way they suppress other extras. Authors mark extension- or shared-catalog functions `external` so Prisma never claims them. + ## Acceptance criteria - AC1. Raw `` @default(sql`nanoid(16)`) `` / `.default(sql\`nanoid(16)\`)` / `dbgenerated("nanoid(16)")` fails authoring with the lookalike diagnostic and a fix hint. - AC2. Declaring a `function` entity and a column default that calls it produces a migration whose first relevant ops include `CREATE FUNCTION` before `CREATE TABLE` / `ADD COLUMN` with that default. -- AC3. Dropping the function entity plans `DROP FUNCTION`. -- AC4. Docs state client vs DB defaults and link to this project / error reference. +- AC3. Dropping a **managed** function entity plans `DROP FUNCTION`; `external` / unowned live functions are not dropped. +- AC4. Docs state client vs DB defaults, MVP dependency limits, no-overload identity, create-only semantics, and opaque-body caveats. +- AC5. Two entities with the same `functionName` in one namespace fail loud at schema construction. ## Out of scope for later slices - Introspection and infer printing of functions - Referential check that a default names a declared function - Cross-schema function references -- `OR REPLACE` vs create-only policy beyond what D2 renders +- `CREATE OR REPLACE` / structured alter +- Overloads and signature-normalized identity +- Fine-grained dependency tracking +- Suppressing the client-generator diagnostic when a matching function entity is declared From c234465071182edc1d712692e6659d1bce0766ba Mon Sep 17 00:00:00 2001 From: Rhae-Shane Date: Mon, 21 Sep 2026 00:56:32 +0530 Subject: [PATCH 07/10] fix(postgres): harden function DROP identity and client-generator matching Normalize DROP FUNCTION to input types only, wire controlAdapterFor for create/dropFunction, broaden CLIENT_GENERATOR_CALL for quoted/whitespace forms, and align function planner tests with current planner APIs. Co-authored-by: Cursor --- .../1-core/contract/src/default-sql-body.ts | 12 +- .../contract/test/default-sql-body.test.ts | 32 +++- ...nterpreter.defaults.tagged-literal.test.ts | 6 + .../test/sql-default-literal.test.ts | 8 + .../test/sql-default-literal-tag.test.ts | 23 ++- .../src/core/migrations/op-factory-call.ts | 7 +- .../core/migrations/operations/functions.ts | 150 +++++++++++++++++- .../src/core/migrations/postgres-migration.ts | 6 +- .../test/migrations/function-planner.test.ts | 103 +++++++++--- .../postgres-migration-op-builders.test.ts | 44 +++++ .../test/control-mutation-defaults.test.ts | 15 ++ .../test/control-mutation-defaults.test.ts | 14 ++ 12 files changed, 382 insertions(+), 38 deletions(-) diff --git a/packages/2-sql/1-core/contract/src/default-sql-body.ts b/packages/2-sql/1-core/contract/src/default-sql-body.ts index 7744d3352719..658303cc6081 100644 --- a/packages/2-sql/1-core/contract/src/default-sql-body.ts +++ b/packages/2-sql/1-core/contract/src/default-sql-body.ts @@ -1,7 +1,12 @@ const UNSAFE_DEFAULT_BODY = /;|--|\/\*|\$\$|\bSELECT\b/i; -/** Client-side Prisma generators that authors often paste into raw SQL defaults by mistake. */ -const CLIENT_GENERATOR_CALL = /^\s*(?:[A-Za-z_][\w$]*\.)?(nanoid|uuid|cuid|ulid)\s*\([^;]*\)\s*$/i; +/** + * Client-side Prisma generators that authors often paste into raw SQL defaults + * by mistake. Allows an optional schema qualifier (quoted or bare) with + * whitespace around the dot, and a quoted or bare generator name. + */ +const CLIENT_GENERATOR_CALL = + /^\s*(?:(?:"[^"]+"|[A-Za-z_][\w$]*)\s*\.\s*)?(?:"(nanoid|uuid|cuid|ulid)"|(nanoid|uuid|cuid|ulid))\s*\([^;]*\)\s*$/i; /** Returns undefined when the body may be rendered as `DEFAULT ()`, else the reason. */ export function checkSqlDefaultBody(body: string): string | undefined { @@ -31,7 +36,8 @@ export function reservedSqlDefaultBody(body: string): 'now' | 'autoincrement' | */ export function clientGeneratorSqlDefaultBody(body: string): string | undefined { const match = CLIENT_GENERATOR_CALL.exec(body); - return match?.[1]?.toLowerCase(); + const name = match?.[1] ?? match?.[2]; + return name?.toLowerCase(); } /** Fix hint shared by PSL and TypeScript when {@link clientGeneratorSqlDefaultBody} matches. */ diff --git a/packages/2-sql/1-core/contract/test/default-sql-body.test.ts b/packages/2-sql/1-core/contract/test/default-sql-body.test.ts index d555dcd06c31..c6f850301003 100644 --- a/packages/2-sql/1-core/contract/test/default-sql-body.test.ts +++ b/packages/2-sql/1-core/contract/test/default-sql-body.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { reservedSqlDefaultBody } from '../src/default-sql-body'; +import { clientGeneratorSqlDefaultBody, reservedSqlDefaultBody } from '../src/default-sql-body'; describe('reservedSqlDefaultBody', () => { it.each([ @@ -23,3 +23,33 @@ describe('reservedSqlDefaultBody', () => { expect(reservedSqlDefaultBody(body)).toBeUndefined(); }); }); + +describe('clientGeneratorSqlDefaultBody', () => { + it.each([ + ['nanoid()', 'nanoid'], + ['nanoid(16)', 'nanoid'], + [' uuid(7) ', 'uuid'], + ['cuid(2)', 'cuid'], + ['ulid()', 'ulid'], + ['public.nanoid(16)', 'nanoid'], + ['"nanoid"(16)', 'nanoid'], + ['"uuid"()', 'uuid'], + ['"public"."nanoid"(16)', 'nanoid'], + ['public . nanoid(16)', 'nanoid'], + ['public. nanoid(16)', 'nanoid'], + ['"public" . "cuid"(2)', 'cuid'], + ])('matches %j', (body, name) => { + expect(clientGeneratorSqlDefaultBody(body)).toBe(name); + }); + + it.each([ + ['gen_random_uuid()'], + ['app_nanoid(16)'], + ['"app_nanoid"(16)'], + ['NOW()'], + ['random()'], + [''], + ])('ignores %j', (body) => { + expect(clientGeneratorSqlDefaultBody(body)).toBeUndefined(); + }); +}); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.tagged-literal.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.tagged-literal.test.ts index 032bba8febd5..0a17c308d231 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.tagged-literal.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.tagged-literal.test.ts @@ -184,6 +184,12 @@ describe('interpretPslDocumentToSqlContract tagged literal defaults', () => { ]); }); + it('rejects a quoted client-generator lookalike in a raw SQL default', () => { + expect(diagnostics('v String @default(sql`"nanoid"(16)`)')).toEqual([ + expect.objectContaining({ code: 'PSL_RAW_DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR' }), + ]); + }); + it('still rejects a client-side generator on a list column', () => { expect(diagnostics('tags String[] @default(uuid())')).toEqual([ expect.objectContaining({ code: 'PSL_LIST_EXECUTION_DEFAULT_UNSUPPORTED' }), diff --git a/packages/2-sql/2-authoring/contract-ts/test/sql-default-literal.test.ts b/packages/2-sql/2-authoring/contract-ts/test/sql-default-literal.test.ts index 873d2c1898aa..980b4f586d49 100644 --- a/packages/2-sql/2-authoring/contract-ts/test/sql-default-literal.test.ts +++ b/packages/2-sql/2-authoring/contract-ts/test/sql-default-literal.test.ts @@ -106,6 +106,14 @@ describe('sql template tag', () => { ); }); + it('rejects a quoted client-generator lookalike', () => { + expect(() => sql`"nanoid"(16)`).toThrow( + expect.objectContaining({ + code: 'CONTRACT.DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR', + }), + ); + }); + it('rejects a body the SQL check refuses with CONTRACT.DEFAULT_INVALID', () => { expect(() => sql`x; drop table t`).toThrow( expect.objectContaining({ diff --git a/packages/2-sql/9-family/test/sql-default-literal-tag.test.ts b/packages/2-sql/9-family/test/sql-default-literal-tag.test.ts index e464e273cff0..7de230c5f476 100644 --- a/packages/2-sql/9-family/test/sql-default-literal-tag.test.ts +++ b/packages/2-sql/9-family/test/sql-default-literal-tag.test.ts @@ -47,16 +47,24 @@ describe('clientGeneratorSqlDefaultBody', () => { ['cuid(2)', 'cuid'], ['ulid()', 'ulid'], ['public.nanoid(16)', 'nanoid'], + ['"nanoid"(16)', 'nanoid'], + ['"public"."nanoid"(16)', 'nanoid'], + ['public . nanoid(16)', 'nanoid'], + ['public. nanoid(16)', 'nanoid'], ])('matches %j', (body, name) => { expect(clientGeneratorSqlDefaultBody(body)).toBe(name); }); - it.each([['gen_random_uuid()'], ['app_nanoid(16)'], ['NOW()'], ['random()'], ['']])( - 'ignores %j', - (body) => { - expect(clientGeneratorSqlDefaultBody(body)).toBeUndefined(); - }, - ); + it.each([ + ['gen_random_uuid()'], + ['app_nanoid(16)'], + ['"app_nanoid"(16)'], + ['NOW()'], + ['random()'], + [''], + ])('ignores %j', (body) => { + expect(clientGeneratorSqlDefaultBody(body)).toBeUndefined(); + }); }); describe('sqlDefaultLiteralTagEntry', () => { @@ -118,6 +126,9 @@ describe('sqlDefaultLiteralTagEntry', () => { ['cuid(2)'], ['ulid()'], ['public.nanoid(16)'], + ['"nanoid"(16)'], + ['"public"."nanoid"(16)'], + ['public . nanoid(16)'], ])('refuses client-generator lookalike %s', (body) => { const result = entry.lower({ literal: { tag: 'sql', body, span }, context }); expect(result).toMatchObject({ diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts index 0aaa7be637e7..d39ecdd08654 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts @@ -76,7 +76,7 @@ import { renameCheckConstraint, } from './operations/constraints'; import { createExtension } from './operations/dependencies'; -import { createFunction, dropFunction } from './operations/functions'; +import { createFunction, dropFunction, functionIdentitySignature } from './operations/functions'; import { type CreateIndexElements, type CreateIndexExtras, @@ -1627,14 +1627,15 @@ export class DropFunctionCall extends PostgresOpFactoryCallNode { readonly operationClass = 'destructive' as const; readonly schemaName: string; readonly functionName: string; + /** Identity signature for DROP (input types only). */ readonly signature: string; readonly label: string; - constructor(schemaName: string, functionName: string, signature: string) { + constructor(schemaName: string, functionName: string, declarationSignature: string) { super(); this.schemaName = schemaName; this.functionName = functionName; - this.signature = signature; + this.signature = functionIdentitySignature(declarationSignature); this.label = `Drop function "${functionName}"`; this.freeze(); } diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/functions.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/functions.ts index 91a77022495e..911cb75c908a 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/functions.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/functions.ts @@ -6,11 +6,155 @@ import { type Op, step, targetDetails } from './shared'; const BODY_DOLLAR_TAG = 'prisma_fn'; +const ARG_MODE = /^(IN|OUT|INOUT|VARIADIC)\b/i; + +/** + * Postgres multi-word type names. Checked before stripping a leading arg name + * so `double precision` is not treated as name `double` + type `precision`. + */ +const MULTI_WORD_TYPES = [ + 'double precision', + 'character varying', + 'bit varying', + 'time with time zone', + 'time without time zone', + 'timestamp with time zone', + 'timestamp without time zone', +] as const; + function qualifiedFunctionName(schemaName: string, functionName: string): string { const schema = boundSchema(schemaName); return schema === undefined ? quoteIdentifier(functionName) : qualifyName(schema, functionName); } +function splitTopLevelArgs(signature: string): string[] { + const args: string[] = []; + let depth = 0; + let start = 0; + let quote: "'" | '"' | null = null; + for (let i = 0; i < signature.length; i++) { + const ch = signature[i]!; + if (quote !== null) { + if (ch === quote) { + if (quote === "'" && signature[i + 1] === "'") { + i++; + continue; + } + quote = null; + } + continue; + } + if (ch === "'" || ch === '"') { + quote = ch; + continue; + } + if (ch === '(' || ch === '[') { + depth++; + continue; + } + if (ch === ')' || ch === ']') { + depth = Math.max(0, depth - 1); + continue; + } + if (ch === ',' && depth === 0) { + args.push(signature.slice(start, i).trim()); + start = i + 1; + } + } + const last = signature.slice(start).trim(); + if (last !== '' || args.length > 0) args.push(last); + return args.filter((arg) => arg !== ''); +} + +function stripDefaultClause(arg: string): string { + let depth = 0; + let quote: "'" | '"' | null = null; + for (let i = 0; i < arg.length; i++) { + const ch = arg[i]!; + if (quote !== null) { + if (ch === quote) { + if (quote === "'" && arg[i + 1] === "'") { + i++; + continue; + } + quote = null; + } + continue; + } + if (ch === "'" || ch === '"') { + quote = ch; + continue; + } + if (ch === '(' || ch === '[') { + depth++; + continue; + } + if (ch === ')' || ch === ']') { + depth = Math.max(0, depth - 1); + continue; + } + if (depth !== 0) continue; + if (ch === '=') return arg.slice(0, i).trim(); + if (/^DEFAULT\b/i.test(arg.slice(i))) return arg.slice(0, i).trim(); + } + return arg.trim(); +} + +function matchesMultiWordType(typeText: string): boolean { + const lower = typeText.toLowerCase(); + return MULTI_WORD_TYPES.some( + (name) => lower === name || lower.startsWith(`${name}[`) || lower.startsWith(`${name}(`), + ); +} + +function stripLeadingArgName(argBody: string): string { + const trimmed = argBody.trim(); + if (trimmed === '' || matchesMultiWordType(trimmed)) return trimmed; + + for (const name of MULTI_WORD_TYPES) { + const named = new RegExp( + `^(?:"[^"]+"|[A-Za-z_][A-Za-z0-9_$]*)\\s+(${name.replace(/ /g, '\\s+')}(?:\\s*(?:\\[[^\\]]*\\]|\\([^)]*\\)))*)$`, + 'i', + ); + const match = named.exec(trimmed); + if (match?.[1] !== undefined) return match[1]!.replace(/\s+/g, ' ').trim(); + } + + const simple = /^(?:"[^"]+"|[A-Za-z_][A-Za-z0-9_$]*)\s+(.+)$/s.exec(trimmed); + if (simple?.[1] !== undefined) { + const rest = simple[1]!.trim(); + if (rest !== '') return rest; + } + return trimmed; +} + +/** + * Normalize a CREATE FUNCTION argument-list declaration into the identity + * form Postgres accepts for DROP FUNCTION: input argument types only + * (no names, modes, or DEFAULT clauses). OUT parameters are omitted. + */ +export function functionIdentitySignature(declarationSignature: string): string { + const trimmed = declarationSignature.trim(); + if (trimmed === '') return ''; + + const types: string[] = []; + for (const rawArg of splitTopLevelArgs(trimmed)) { + let body = stripDefaultClause(rawArg); + if (body === '') continue; + + const modeMatch = ARG_MODE.exec(body); + let mode = 'IN'; + if (modeMatch) { + mode = modeMatch[1]!.toUpperCase(); + body = body.slice(modeMatch[0].length).trim(); + } + if (mode === 'OUT' || body === '') continue; + + types.push(stripLeadingArgName(body)); + } + return types.join(', '); +} + function renderCreateFunctionSql(options: { readonly schemaName: string; readonly functionName: string; @@ -66,6 +210,9 @@ export function createFunction(options: { * `DROP FUNCTION` for an unclaimed managed Postgres function. * Control policy / ownership must suppress this for `external` / * `tolerated` / `observed` subjects and for entities another space owns. + * + * `signature` may be the CREATE declaration; DROP SQL uses the normalized + * {@link functionIdentitySignature} form (input types only). */ export function dropFunction(options: { readonly schemaName: string; @@ -73,7 +220,8 @@ export function dropFunction(options: { readonly signature: string; }): Op { const name = qualifiedFunctionName(options.schemaName, options.functionName); - const sql = `DROP FUNCTION ${name}(${options.signature});`; + const identity = functionIdentitySignature(options.signature); + const sql = `DROP FUNCTION ${name}(${identity});`; return { id: `dropFunction.${options.functionName}`, label: `Drop function "${options.functionName}"`, diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts index 04c7f66881d6..a90944f624dd 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts @@ -235,7 +235,7 @@ export abstract class PostgresMigration< body: options.body, language: options.language, volatility: options.volatility, - }).toOp(); + }).toOp(this.controlAdapterFor('createFunction')); } protected dropFunction(options: { @@ -243,7 +243,9 @@ export abstract class PostgresMigration< readonly functionName: string; readonly signature: string; }): Promise> { - return new DropFunctionCall(options.schema, options.functionName, options.signature).toOp(); + return new DropFunctionCall(options.schema, options.functionName, options.signature).toOp( + this.controlAdapterFor('dropFunction'), + ); } /** diff --git a/packages/3-targets/3-targets/postgres/test/migrations/function-planner.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/function-planner.test.ts index 3a1b157931e2..1a057d6d8e9e 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/function-planner.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/function-planner.test.ts @@ -6,9 +6,13 @@ import { coalesceSubtreeIssues, planIssues } from '../../src/core/migrations/iss import { CreateFunctionCall, CreateTableCall, - type PostgresOpFactoryCall, + DropFunctionCall, } from '../../src/core/migrations/op-factory-call'; -import { createFunction } from '../../src/core/migrations/operations/functions'; +import { + createFunction, + dropFunction, + functionIdentitySignature, +} from '../../src/core/migrations/operations/functions'; import { PostgresFunction } from '../../src/core/postgres-function'; import { PostgresSchema } from '../../src/core/postgres-schema'; import { PostgresDatabaseSchemaNode } from '../../src/core/schema-ir/postgres-database-schema-node'; @@ -69,8 +73,8 @@ function makeContract(): Contract { } describe('Postgres function migration planning', () => { - it('plans CREATE FUNCTION before CREATE TABLE when both are missing', async () => { - const expected = buildPostgresPlanDiff({ contract: makeContract() }).expected; + it('plans CREATE FUNCTION before CREATE TABLE when both are missing', () => { + const contract = makeContract(); const actual = new PostgresDatabaseSchemaNode({ namespaces: { public: new PostgresNamespaceSchemaNode({ @@ -79,21 +83,27 @@ describe('Postgres function migration planning', () => { functions: [], }), }, - roles: {}, + roles: [], existingSchemas: ['public'], pgVersion: '', }); - const issues = coalesceSubtreeIssues( - buildPostgresPlanDiff({ contract: makeContract(), actual }).issues, - ); - const result = await planIssues(issues, { - expected, - actual, + const { issues } = buildPostgresPlanDiff({ + contract, + actualSchema: actual, + frameworkComponents: [], + }); + const result = planIssues({ + issues: coalesceSubtreeIssues(issues), + toContract: contract, + fromContract: null, + schemaName: 'public', codecHooks: new Map(), + storageTypes: contract.storage.types ?? {}, + strategies: [], }); expect(result.ok).toBe(true); if (!result.ok) return; - const calls = result.value as readonly PostgresOpFactoryCall[]; + const calls = result.value.calls; const createFunctionIdx = calls.findIndex((call) => call instanceof CreateFunctionCall); const createTableIdx = calls.findIndex((call) => call instanceof CreateTableCall); expect(createFunctionIdx).toBeGreaterThanOrEqual(0); @@ -119,8 +129,8 @@ describe('Postgres function migration planning', () => { expect(sql).toContain(APP_NANOID_BODY); }); - it('rejects not-equal function drift instead of planning CREATE OR REPLACE', async () => { - const expected = buildPostgresPlanDiff({ contract: makeContract() }).expected; + it('rejects not-equal function drift instead of planning CREATE OR REPLACE', () => { + const contract = makeContract(); const actualFn = new PostgresFunctionSchemaNode({ functionName: 'app_nanoid', namespaceId: 'public', @@ -138,21 +148,27 @@ describe('Postgres function migration planning', () => { functions: [actualFn], }), }, - roles: {}, + roles: [], existingSchemas: ['public'], pgVersion: '', }); - const issues = coalesceSubtreeIssues( - buildPostgresPlanDiff({ contract: makeContract(), actual }).issues, - ); - const result = await planIssues(issues, { - expected, - actual, + const { issues } = buildPostgresPlanDiff({ + contract, + actualSchema: actual, + frameworkComponents: [], + }); + const result = planIssues({ + issues: coalesceSubtreeIssues(issues), + toContract: contract, + fromContract: null, + schemaName: 'public', codecHooks: new Map(), + storageTypes: contract.storage.types ?? {}, + strategies: [], }); expect(result.ok).toBe(false); if (result.ok) return; - expect(result.error.summary).toMatch(/not supported yet|drop and recreate/i); + expect(result.failure[0]?.summary).toMatch(/not supported yet|drop and recreate/i); }); it('diff equality ignores control and matches authored fields', () => { @@ -177,4 +193,47 @@ describe('Postgres function migration planning', () => { }); expect(a.isEqualTo(b)).toBe(true); }); + + it('renders DROP FUNCTION with identity types only (no names, modes, or DEFAULT)', () => { + const op = dropFunction({ + schemaName: 'public', + functionName: 'app_nanoid', + signature: 'size int DEFAULT 16', + }); + expect(op.execute[0]?.sql).toBe('DROP FUNCTION "public"."app_nanoid"(int);'); + }); + + it('DropFunctionCall normalizes the declaration signature for DROP SQL and TS render', async () => { + const call = new DropFunctionCall('public', 'app_nanoid', 'size int DEFAULT 16'); + expect(call.signature).toBe('int'); + expect(call.renderTypeScript()).toBe( + 'this.dropFunction({ schema: "public", functionName: "app_nanoid", signature: "int" })', + ); + const op = await call.toOp(); + expect(op.execute[0]?.sql).toBe('DROP FUNCTION "public"."app_nanoid"(int);'); + }); +}); + +describe('functionIdentitySignature', () => { + it('strips argument names and DEFAULT clauses', () => { + expect(functionIdentitySignature('size int DEFAULT 16')).toBe('int'); + expect(functionIdentitySignature("a int = 1, b text DEFAULT 'x'")).toBe('int, text'); + }); + + it('strips modes and omits OUT arguments from identity', () => { + expect(functionIdentitySignature('IN a int, OUT b text, INOUT c int')).toBe('int, int'); + expect(functionIdentitySignature('VARIADIC arr int[]')).toBe('int[]'); + }); + + it('preserves unnamed and multi-word types', () => { + expect(functionIdentitySignature('int')).toBe('int'); + expect(functionIdentitySignature('double precision')).toBe('double precision'); + expect(functionIdentitySignature('x double precision')).toBe('double precision'); + expect(functionIdentitySignature('n numeric(10, 2)')).toBe('numeric(10, 2)'); + }); + + it('returns empty for zero-arg and OUT-only declarations', () => { + expect(functionIdentitySignature('')).toBe(''); + expect(functionIdentitySignature('OUT b text')).toBe(''); + }); }); diff --git a/packages/3-targets/3-targets/postgres/test/postgres-migration-op-builders.test.ts b/packages/3-targets/3-targets/postgres/test/postgres-migration-op-builders.test.ts index 75dfbc1af704..24b59a1a0760 100644 --- a/packages/3-targets/3-targets/postgres/test/postgres-migration-op-builders.test.ts +++ b/packages/3-targets/3-targets/postgres/test/postgres-migration-op-builders.test.ts @@ -73,6 +73,26 @@ class ExposedMigration extends PostgresMigration { return this.dropNativeEnumType(options); } + callCreateFunction(options: { + readonly schema: string; + readonly functionName: string; + readonly signature: string; + readonly returns: string; + readonly body: string; + readonly language: string; + readonly volatility: 'VOLATILE' | 'STABLE' | 'IMMUTABLE'; + }): Promise { + return this.createFunction(options); + } + + callDropFunction(options: { + readonly schema: string; + readonly functionName: string; + readonly signature: string; + }): Promise { + return this.dropFunction(options); + } + callAddNativeEnumValue(options: { readonly schema: string; readonly typeName: string; @@ -267,6 +287,28 @@ const cases: ReadonlyArray<{ name: 'dropNativeEnumType', run: (m) => m.callDropNativeEnumType({ schema: 'public', typeName: 'color' }), }, + { + name: 'createFunction', + run: (m) => + m.callCreateFunction({ + schema: 'public', + functionName: 'app_nanoid', + signature: 'size int DEFAULT 16', + returns: 'text', + body: 'RETURN null;', + language: 'plpgsql', + volatility: 'STABLE', + }), + }, + { + name: 'dropFunction', + run: (m) => + m.callDropFunction({ + schema: 'public', + functionName: 'app_nanoid', + signature: 'size int DEFAULT 16', + }), + }, { name: 'addNativeEnumValue', run: (m) => m.callAddNativeEnumValue({ schema: 'public', typeName: 'color', value: 'blue' }), @@ -424,6 +466,7 @@ describe('PostgresMigration op-builder methods without a ControlStack', () => { 'addPrimaryKey', 'addUnique', 'alterColumnType', + 'createFunction', 'createIndex', 'createNativeEnumType', 'createSchema', @@ -433,6 +476,7 @@ describe('PostgresMigration op-builder methods without a ControlStack', () => { 'dropColumn', 'dropConstraint', 'dropDefault', + 'dropFunction', 'dropIndex', 'dropNativeEnumType', 'dropNotNull', diff --git a/packages/3-targets/6-adapters/postgres/test/control-mutation-defaults.test.ts b/packages/3-targets/6-adapters/postgres/test/control-mutation-defaults.test.ts index cd4b1c5bdcc8..2cdb4cc08679 100644 --- a/packages/3-targets/6-adapters/postgres/test/control-mutation-defaults.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/control-mutation-defaults.test.ts @@ -177,6 +177,21 @@ describe('createPostgresDefaultFunctionRegistry', () => { expect(result).toMatchObject({ ok: false }); }); + it.each([['nanoid(16)'], ['"nanoid"(16)'], ['public . nanoid(16)'], ['"public"."nanoid"(16)']])( + 'rejects dbgenerated(%j) as a client-generator lookalike', + (expression) => { + const handler = registry.get('dbgenerated')!; + const result = handler.lower({ + call: makeCall('dbgenerated', { expression }), + context: stubContext, + }); + expect(result).toMatchObject({ + ok: false, + diagnostic: { code: 'PSL_RAW_DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR' }, + }); + }, + ); + describe('dbgenerated keeps the raw expression verbatim, never resolving it', () => { // `lowerDbgenerated` must not resolve the raw SQL text — a literal-shaped // expression (e.g. `'{}'::jsonb`) is normalized once, at SchemaIR diff --git a/packages/3-targets/6-adapters/sqlite/test/control-mutation-defaults.test.ts b/packages/3-targets/6-adapters/sqlite/test/control-mutation-defaults.test.ts index 32928891c7f4..ec57967a4f71 100644 --- a/packages/3-targets/6-adapters/sqlite/test/control-mutation-defaults.test.ts +++ b/packages/3-targets/6-adapters/sqlite/test/control-mutation-defaults.test.ts @@ -78,6 +78,20 @@ describe('createSqliteDefaultFunctionRegistry — dbgenerated canonicalization', value: { kind: 'storage', defaultValue: { kind: 'function', expression: 'random()' } }, }); }); + + it.each([['nanoid(16)'], ['"nanoid"(16)'], ['public . nanoid(16)'], ['"public"."nanoid"(16)']])( + 'rejects dbgenerated(%j) as a client-generator lookalike', + (expression) => { + const result = dbgenerated.lower({ + call: makeCall('dbgenerated', { expression }), + context: stubContext, + }); + expect(result).toMatchObject({ + ok: false, + diagnostic: { code: 'PSL_RAW_DEFAULT_LOOKS_LIKE_CLIENT_GENERATOR' }, + }); + }, + ); }); describe('createSqliteDefaultLiteralTagRegistry', () => { From 53a6423ec81891c6366dfe9bafcb9510e2a2d31e Mon Sep 17 00:00:00 2001 From: Rhae-Shane Date: Mon, 21 Sep 2026 01:14:16 +0530 Subject: [PATCH 08/10] fix(postgres): harden DROP identity parsing and quoted generator casing Require exact lowercase for quoted client-generator names, skip dollar-quoted DEFAULT values when splitting args, keep full type expressions after stripping defaults/modes, and avoid matching DEFAULT inside identifiers. Co-authored-by: Cursor --- .../1-core/contract/src/default-sql-body.ts | 15 ++-- .../contract/test/default-sql-body.test.ts | 4 + .../src/core/migrations/op-factory-call.ts | 2 +- .../core/migrations/operations/functions.ts | 84 ++++++++----------- .../test/migrations/function-planner.test.ts | 37 +++++--- 5 files changed, 76 insertions(+), 66 deletions(-) diff --git a/packages/2-sql/1-core/contract/src/default-sql-body.ts b/packages/2-sql/1-core/contract/src/default-sql-body.ts index 658303cc6081..e49296fae260 100644 --- a/packages/2-sql/1-core/contract/src/default-sql-body.ts +++ b/packages/2-sql/1-core/contract/src/default-sql-body.ts @@ -4,9 +4,14 @@ const UNSAFE_DEFAULT_BODY = /;|--|\/\*|\$\$|\bSELECT\b/i; * Client-side Prisma generators that authors often paste into raw SQL defaults * by mistake. Allows an optional schema qualifier (quoted or bare) with * whitespace around the dot, and a quoted or bare generator name. + * + * Quoted generator names are exact lowercase only (Postgres folds unquoted + * identifiers; quoted ones are case-sensitive). Bare names stay case-insensitive. */ -const CLIENT_GENERATOR_CALL = - /^\s*(?:(?:"[^"]+"|[A-Za-z_][\w$]*)\s*\.\s*)?(?:"(nanoid|uuid|cuid|ulid)"|(nanoid|uuid|cuid|ulid))\s*\([^;]*\)\s*$/i; +const CLIENT_GENERATOR_QUOTED = + /^\s*(?:(?:"[^"]+"|[A-Za-z_][\w$]*)\s*\.\s*)?"(nanoid|uuid|cuid|ulid)"\s*\([^;]*\)\s*$/; +const CLIENT_GENERATOR_BARE = + /^\s*(?:(?:"[^"]+"|[A-Za-z_][\w$]*)\s*\.\s*)?(nanoid|uuid|cuid|ulid)\s*\([^;]*\)\s*$/i; /** Returns undefined when the body may be rendered as `DEFAULT ()`, else the reason. */ export function checkSqlDefaultBody(body: string): string | undefined { @@ -35,9 +40,9 @@ export function reservedSqlDefaultBody(body: string): 'now' | 'autoincrement' | * the function; authors almost always meant `@default(nanoid(16))` instead. */ export function clientGeneratorSqlDefaultBody(body: string): string | undefined { - const match = CLIENT_GENERATOR_CALL.exec(body); - const name = match?.[1] ?? match?.[2]; - return name?.toLowerCase(); + const quoted = CLIENT_GENERATOR_QUOTED.exec(body)?.[1]; + if (quoted !== undefined) return quoted; + return CLIENT_GENERATOR_BARE.exec(body)?.[1]?.toLowerCase(); } /** Fix hint shared by PSL and TypeScript when {@link clientGeneratorSqlDefaultBody} matches. */ diff --git a/packages/2-sql/1-core/contract/test/default-sql-body.test.ts b/packages/2-sql/1-core/contract/test/default-sql-body.test.ts index c6f850301003..c2cad5356b67 100644 --- a/packages/2-sql/1-core/contract/test/default-sql-body.test.ts +++ b/packages/2-sql/1-core/contract/test/default-sql-body.test.ts @@ -28,6 +28,7 @@ describe('clientGeneratorSqlDefaultBody', () => { it.each([ ['nanoid()', 'nanoid'], ['nanoid(16)', 'nanoid'], + ['NANOID(16)', 'nanoid'], [' uuid(7) ', 'uuid'], ['cuid(2)', 'cuid'], ['ulid()', 'ulid'], @@ -46,6 +47,9 @@ describe('clientGeneratorSqlDefaultBody', () => { ['gen_random_uuid()'], ['app_nanoid(16)'], ['"app_nanoid"(16)'], + ['"NANOID"(16)'], + ['"NanoId"(16)'], + ['"public"."NANOID"(16)'], ['NOW()'], ['random()'], [''], diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts index d39ecdd08654..7f5b429a3388 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts @@ -1627,7 +1627,7 @@ export class DropFunctionCall extends PostgresOpFactoryCallNode { readonly operationClass = 'destructive' as const; readonly schemaName: string; readonly functionName: string; - /** Identity signature for DROP (input types only). */ + /** Identity signature for DROP (defaults/modes stripped, OUT omitted). */ readonly signature: string; readonly label: string; diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/functions.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/functions.ts index 911cb75c908a..754a6a8e38f0 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/functions.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/functions.ts @@ -8,25 +8,29 @@ const BODY_DOLLAR_TAG = 'prisma_fn'; const ARG_MODE = /^(IN|OUT|INOUT|VARIADIC)\b/i; -/** - * Postgres multi-word type names. Checked before stripping a leading arg name - * so `double precision` is not treated as name `double` + type `precision`. - */ -const MULTI_WORD_TYPES = [ - 'double precision', - 'character varying', - 'bit varying', - 'time with time zone', - 'time without time zone', - 'timestamp with time zone', - 'timestamp without time zone', -] as const; +/** Tag body in `$tag$…$tag$` — empty tag is `$$…$$`. */ +const DOLLAR_TAG = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/; function qualifiedFunctionName(schemaName: string, functionName: string): string { const schema = boundSchema(schemaName); return schema === undefined ? quoteIdentifier(functionName) : qualifyName(schema, functionName); } +/** + * If `text` starts a Postgres dollar-quote at `index`, returns the index just + * past the closing delimiter; otherwise `undefined`. + */ +function skipDollarQuoted(text: string, index: number): number | undefined { + if (text[index] !== '$') return undefined; + const open = DOLLAR_TAG.exec(text.slice(index)); + if (open === null) return undefined; + const closer = open[0]; + const contentStart = index + closer.length; + const closeAt = text.indexOf(closer, contentStart); + if (closeAt < 0) return text.length; + return closeAt + closer.length; +} + function splitTopLevelArgs(signature: string): string[] { const args: string[] = []; let depth = 0; @@ -44,6 +48,11 @@ function splitTopLevelArgs(signature: string): string[] { } continue; } + const afterDollar = skipDollarQuoted(signature, i); + if (afterDollar !== undefined) { + i = afterDollar - 1; + continue; + } if (ch === "'" || ch === '"') { quote = ch; continue; @@ -81,6 +90,11 @@ function stripDefaultClause(arg: string): string { } continue; } + const afterDollar = skipDollarQuoted(arg, i); + if (afterDollar !== undefined) { + i = afterDollar - 1; + continue; + } if (ch === "'" || ch === '"') { quote = ch; continue; @@ -95,43 +109,19 @@ function stripDefaultClause(arg: string): string { } if (depth !== 0) continue; if (ch === '=') return arg.slice(0, i).trim(); - if (/^DEFAULT\b/i.test(arg.slice(i))) return arg.slice(0, i).trim(); + // Require a left token boundary so identifiers like `mydefault` are not cut. + if ((i === 0 || !/[\w$]/i.test(arg[i - 1]!)) && /^DEFAULT\b/i.test(arg.slice(i))) { + return arg.slice(0, i).trim(); + } } return arg.trim(); } -function matchesMultiWordType(typeText: string): boolean { - const lower = typeText.toLowerCase(); - return MULTI_WORD_TYPES.some( - (name) => lower === name || lower.startsWith(`${name}[`) || lower.startsWith(`${name}(`), - ); -} - -function stripLeadingArgName(argBody: string): string { - const trimmed = argBody.trim(); - if (trimmed === '' || matchesMultiWordType(trimmed)) return trimmed; - - for (const name of MULTI_WORD_TYPES) { - const named = new RegExp( - `^(?:"[^"]+"|[A-Za-z_][A-Za-z0-9_$]*)\\s+(${name.replace(/ /g, '\\s+')}(?:\\s*(?:\\[[^\\]]*\\]|\\([^)]*\\)))*)$`, - 'i', - ); - const match = named.exec(trimmed); - if (match?.[1] !== undefined) return match[1]!.replace(/\s+/g, ' ').trim(); - } - - const simple = /^(?:"[^"]+"|[A-Za-z_][A-Za-z0-9_$]*)\s+(.+)$/s.exec(trimmed); - if (simple?.[1] !== undefined) { - const rest = simple[1]!.trim(); - if (rest !== '') return rest; - } - return trimmed; -} - /** - * Normalize a CREATE FUNCTION argument-list declaration into the identity - * form Postgres accepts for DROP FUNCTION: input argument types only - * (no names, modes, or DEFAULT clauses). OUT parameters are omitted. + * Normalize a CREATE FUNCTION argument-list declaration into the form Postgres + * accepts for DROP FUNCTION: defaults and modes removed, OUT args omitted. + * Argument names and full type expressions are kept so unnamed multi-word types + * are not mangled. */ export function functionIdentitySignature(declarationSignature: string): string { const trimmed = declarationSignature.trim(); @@ -150,7 +140,7 @@ export function functionIdentitySignature(declarationSignature: string): string } if (mode === 'OUT' || body === '') continue; - types.push(stripLeadingArgName(body)); + types.push(body); } return types.join(', '); } @@ -212,7 +202,7 @@ export function createFunction(options: { * `tolerated` / `observed` subjects and for entities another space owns. * * `signature` may be the CREATE declaration; DROP SQL uses the normalized - * {@link functionIdentitySignature} form (input types only). + * {@link functionIdentitySignature} form (defaults/modes stripped, OUT omitted). */ export function dropFunction(options: { readonly schemaName: string; diff --git a/packages/3-targets/3-targets/postgres/test/migrations/function-planner.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/function-planner.test.ts index 1a057d6d8e9e..9757e7264547 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/function-planner.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/function-planner.test.ts @@ -194,42 +194,53 @@ describe('Postgres function migration planning', () => { expect(a.isEqualTo(b)).toBe(true); }); - it('renders DROP FUNCTION with identity types only (no names, modes, or DEFAULT)', () => { + it('renders DROP FUNCTION without DEFAULT clauses (identity keeps arg names + types)', () => { const op = dropFunction({ schemaName: 'public', functionName: 'app_nanoid', signature: 'size int DEFAULT 16', }); - expect(op.execute[0]?.sql).toBe('DROP FUNCTION "public"."app_nanoid"(int);'); + expect(op.execute[0]?.sql).toBe('DROP FUNCTION "public"."app_nanoid"(size int);'); }); it('DropFunctionCall normalizes the declaration signature for DROP SQL and TS render', async () => { const call = new DropFunctionCall('public', 'app_nanoid', 'size int DEFAULT 16'); - expect(call.signature).toBe('int'); + expect(call.signature).toBe('size int'); expect(call.renderTypeScript()).toBe( - 'this.dropFunction({ schema: "public", functionName: "app_nanoid", signature: "int" })', + 'this.dropFunction({ schema: "public", functionName: "app_nanoid", signature: "size int" })', ); const op = await call.toOp(); - expect(op.execute[0]?.sql).toBe('DROP FUNCTION "public"."app_nanoid"(int);'); + expect(op.execute[0]?.sql).toBe('DROP FUNCTION "public"."app_nanoid"(size int);'); }); }); describe('functionIdentitySignature', () => { - it('strips argument names and DEFAULT clauses', () => { - expect(functionIdentitySignature('size int DEFAULT 16')).toBe('int'); - expect(functionIdentitySignature("a int = 1, b text DEFAULT 'x'")).toBe('int, text'); + it('strips DEFAULT clauses and keeps names with types', () => { + expect(functionIdentitySignature('size int DEFAULT 16')).toBe('size int'); + expect(functionIdentitySignature("a int = 1, b text DEFAULT 'x'")).toBe('a int, b text'); + }); + + it('does not treat DEFAULT as a substring of an identifier', () => { + expect(functionIdentitySignature('mydefault int')).toBe('mydefault int'); + expect(functionIdentitySignature('size int DEFAULTED 16')).toBe('size int DEFAULTED 16'); }); it('strips modes and omits OUT arguments from identity', () => { - expect(functionIdentitySignature('IN a int, OUT b text, INOUT c int')).toBe('int, int'); - expect(functionIdentitySignature('VARIADIC arr int[]')).toBe('int[]'); + expect(functionIdentitySignature('IN a int, OUT b text, INOUT c int')).toBe('a int, c int'); + expect(functionIdentitySignature('VARIADIC arr int[]')).toBe('arr int[]'); }); - it('preserves unnamed and multi-word types', () => { + it('preserves unnamed and multi-word types intact', () => { expect(functionIdentitySignature('int')).toBe('int'); expect(functionIdentitySignature('double precision')).toBe('double precision'); - expect(functionIdentitySignature('x double precision')).toBe('double precision'); - expect(functionIdentitySignature('n numeric(10, 2)')).toBe('numeric(10, 2)'); + expect(functionIdentitySignature('x double precision')).toBe('x double precision'); + expect(functionIdentitySignature('n numeric(10, 2)')).toBe('n numeric(10, 2)'); + expect(functionIdentitySignature('interval day to second')).toBe('interval day to second'); + }); + + it('ignores commas inside dollar-quoted DEFAULT values', () => { + expect(functionIdentitySignature('a text DEFAULT $$x,y$$, b int')).toBe('a text, b int'); + expect(functionIdentitySignature('a text DEFAULT $tag$a,b$tag$, b int')).toBe('a text, b int'); }); it('returns empty for zero-arg and OUT-only declarations', () => { From 31aa48414bcb52f6e3d70b827d7a818851f7b2dd Mon Sep 17 00:00:00 2001 From: Rhae-Shane Date: Mon, 21 Sep 2026 01:33:50 +0530 Subject: [PATCH 09/10] fix(sql): harden quote escaping in generator and DROP identity parsers Accept doubled quotes in schema-qualified client-generator lookalikes, reject dollar-quote delimiters glued to identifiers, and treat doubled double-quotes as escapes inside double-quoted function argument names. Co-authored-by: Cursor --- .../2-sql/1-core/contract/src/default-sql-body.ts | 4 ++-- .../1-core/contract/test/default-sql-body.test.ts | 4 ++++ .../src/core/migrations/operations/functions.ts | 7 +++++-- .../postgres/test/migrations/function-planner.test.ts | 11 +++++++++++ 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/2-sql/1-core/contract/src/default-sql-body.ts b/packages/2-sql/1-core/contract/src/default-sql-body.ts index e49296fae260..fa959f7a5232 100644 --- a/packages/2-sql/1-core/contract/src/default-sql-body.ts +++ b/packages/2-sql/1-core/contract/src/default-sql-body.ts @@ -9,9 +9,9 @@ const UNSAFE_DEFAULT_BODY = /;|--|\/\*|\$\$|\bSELECT\b/i; * identifiers; quoted ones are case-sensitive). Bare names stay case-insensitive. */ const CLIENT_GENERATOR_QUOTED = - /^\s*(?:(?:"[^"]+"|[A-Za-z_][\w$]*)\s*\.\s*)?"(nanoid|uuid|cuid|ulid)"\s*\([^;]*\)\s*$/; + /^\s*(?:(?:"(?:[^"]|"")+"|[A-Za-z_][\w$]*)\s*\.\s*)?"(nanoid|uuid|cuid|ulid)"\s*\([^;]*\)\s*$/; const CLIENT_GENERATOR_BARE = - /^\s*(?:(?:"[^"]+"|[A-Za-z_][\w$]*)\s*\.\s*)?(nanoid|uuid|cuid|ulid)\s*\([^;]*\)\s*$/i; + /^\s*(?:(?:"(?:[^"]|"")+"|[A-Za-z_][\w$]*)\s*\.\s*)?(nanoid|uuid|cuid|ulid)\s*\([^;]*\)\s*$/i; /** Returns undefined when the body may be rendered as `DEFAULT ()`, else the reason. */ export function checkSqlDefaultBody(body: string): string | undefined { diff --git a/packages/2-sql/1-core/contract/test/default-sql-body.test.ts b/packages/2-sql/1-core/contract/test/default-sql-body.test.ts index c2cad5356b67..16af752f2561 100644 --- a/packages/2-sql/1-core/contract/test/default-sql-body.test.ts +++ b/packages/2-sql/1-core/contract/test/default-sql-body.test.ts @@ -39,6 +39,10 @@ describe('clientGeneratorSqlDefaultBody', () => { ['public . nanoid(16)', 'nanoid'], ['public. nanoid(16)', 'nanoid'], ['"public" . "cuid"(2)', 'cuid'], + ['"app""id".nanoid(16)', 'nanoid'], + ['"app""id"."nanoid"(16)', 'nanoid'], + ['"public" . nanoid(16)', 'nanoid'], + ['public . "nanoid"(16)', 'nanoid'], ])('matches %j', (body, name) => { expect(clientGeneratorSqlDefaultBody(body)).toBe(name); }); diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/functions.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/functions.ts index 754a6a8e38f0..f86cc8023d52 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/functions.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/functions.ts @@ -19,9 +19,12 @@ function qualifiedFunctionName(schemaName: string, functionName: string): string /** * If `text` starts a Postgres dollar-quote at `index`, returns the index just * past the closing delimiter; otherwise `undefined`. + * Delimiters immediately after an identifier character (e.g. `foo$tag$`) are + * not dollar quotes. */ function skipDollarQuoted(text: string, index: number): number | undefined { if (text[index] !== '$') return undefined; + if (index > 0 && /[\w$]/i.test(text[index - 1]!)) return undefined; const open = DOLLAR_TAG.exec(text.slice(index)); if (open === null) return undefined; const closer = open[0]; @@ -40,7 +43,7 @@ function splitTopLevelArgs(signature: string): string[] { const ch = signature[i]!; if (quote !== null) { if (ch === quote) { - if (quote === "'" && signature[i + 1] === "'") { + if ((quote === "'" || quote === '"') && signature[i + 1] === quote) { i++; continue; } @@ -82,7 +85,7 @@ function stripDefaultClause(arg: string): string { const ch = arg[i]!; if (quote !== null) { if (ch === quote) { - if (quote === "'" && arg[i + 1] === "'") { + if ((quote === "'" || quote === '"') && arg[i + 1] === quote) { i++; continue; } diff --git a/packages/3-targets/3-targets/postgres/test/migrations/function-planner.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/function-planner.test.ts index 9757e7264547..9633e5cbac57 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/function-planner.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/function-planner.test.ts @@ -243,6 +243,17 @@ describe('functionIdentitySignature', () => { expect(functionIdentitySignature('a text DEFAULT $tag$a,b$tag$, b int')).toBe('a text, b int'); }); + it('does not treat $ inside an identifier as a dollar-quote delimiter', () => { + expect(functionIdentitySignature('foo$tag$ int DEFAULT 1')).toBe('foo$tag$ int'); + }); + + it('treats doubled quotes inside double-quoted identifiers as escapes', () => { + expect(functionIdentitySignature('"a"",""b" int DEFAULT 1, c text')).toBe( + '"a"",""b" int, c text', + ); + expect(functionIdentitySignature('"a,b" int, c text')).toBe('"a,b" int, c text'); + }); + it('returns empty for zero-arg and OUT-only declarations', () => { expect(functionIdentitySignature('')).toBe(''); expect(functionIdentitySignature('OUT b text')).toBe(''); From bf1850f944a911e2a6f3e8f636e380035f719ef5 Mon Sep 17 00:00:00 2001 From: Rhae-Shane Date: Mon, 21 Sep 2026 01:46:07 +0530 Subject: [PATCH 10/10] fix(postgres): treat non-ASCII letters as identifier boundaries Use a Unicode-aware identifier predicate for dollar-quote and DEFAULT token boundaries so names like e-acute$tag$ or e-acuteDEFAULT are not misparsed. Co-authored-by: Cursor --- .../postgres/src/core/migrations/operations/functions.ts | 9 +++++++-- .../postgres/test/migrations/function-planner.test.ts | 2 ++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/functions.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/functions.ts index f86cc8023d52..833dd9942363 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/operations/functions.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/functions.ts @@ -11,6 +11,11 @@ const ARG_MODE = /^(IN|OUT|INOUT|VARIADIC)\b/i; /** Tag body in `$tag$…$tag$` — empty tag is `$$…$$`. */ const DOLLAR_TAG = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/; +/** Postgres identifier characters, including non-ASCII letters (SQL Unicode). */ +function isPostgresIdentifierChar(ch: string): boolean { + return /[\p{L}\p{N}_$]/u.test(ch); +} + function qualifiedFunctionName(schemaName: string, functionName: string): string { const schema = boundSchema(schemaName); return schema === undefined ? quoteIdentifier(functionName) : qualifyName(schema, functionName); @@ -24,7 +29,7 @@ function qualifiedFunctionName(schemaName: string, functionName: string): string */ function skipDollarQuoted(text: string, index: number): number | undefined { if (text[index] !== '$') return undefined; - if (index > 0 && /[\w$]/i.test(text[index - 1]!)) return undefined; + if (index > 0 && isPostgresIdentifierChar(text[index - 1]!)) return undefined; const open = DOLLAR_TAG.exec(text.slice(index)); if (open === null) return undefined; const closer = open[0]; @@ -113,7 +118,7 @@ function stripDefaultClause(arg: string): string { if (depth !== 0) continue; if (ch === '=') return arg.slice(0, i).trim(); // Require a left token boundary so identifiers like `mydefault` are not cut. - if ((i === 0 || !/[\w$]/i.test(arg[i - 1]!)) && /^DEFAULT\b/i.test(arg.slice(i))) { + if ((i === 0 || !isPostgresIdentifierChar(arg[i - 1]!)) && /^DEFAULT\b/i.test(arg.slice(i))) { return arg.slice(0, i).trim(); } } diff --git a/packages/3-targets/3-targets/postgres/test/migrations/function-planner.test.ts b/packages/3-targets/3-targets/postgres/test/migrations/function-planner.test.ts index 9633e5cbac57..8a431f55487a 100644 --- a/packages/3-targets/3-targets/postgres/test/migrations/function-planner.test.ts +++ b/packages/3-targets/3-targets/postgres/test/migrations/function-planner.test.ts @@ -223,6 +223,7 @@ describe('functionIdentitySignature', () => { it('does not treat DEFAULT as a substring of an identifier', () => { expect(functionIdentitySignature('mydefault int')).toBe('mydefault int'); expect(functionIdentitySignature('size int DEFAULTED 16')).toBe('size int DEFAULTED 16'); + expect(functionIdentitySignature('éDEFAULT int')).toBe('éDEFAULT int'); }); it('strips modes and omits OUT arguments from identity', () => { @@ -245,6 +246,7 @@ describe('functionIdentitySignature', () => { it('does not treat $ inside an identifier as a dollar-quote delimiter', () => { expect(functionIdentitySignature('foo$tag$ int DEFAULT 1')).toBe('foo$tag$ int'); + expect(functionIdentitySignature('é$tag$ int DEFAULT 1')).toBe('é$tag$ int'); }); it('treats doubled quotes inside double-quoted identifiers as escapes', () => {