diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 7d0df3a2ff85..749a004a232f 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)`). 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 ### 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..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 @@ -1,5 +1,18 @@ 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_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 { return UNSAFE_DEFAULT_BODY.test(body) @@ -20,3 +33,29 @@ 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 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. */ +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); naming around client generators is an MVP constraint). ' + + '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/1-core/contract/test/default-sql-body.test.ts b/packages/2-sql/1-core/contract/test/default-sql-body.test.ts index d555dcd06c31..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 @@ -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,41 @@ describe('reservedSqlDefaultBody', () => { expect(reservedSqlDefaultBody(body)).toBeUndefined(); }); }); + +describe('clientGeneratorSqlDefaultBody', () => { + it.each([ + ['nanoid()', 'nanoid'], + ['nanoid(16)', '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'], + ['"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); + }); + + it.each([ + ['gen_random_uuid()'], + ['app_nanoid(16)'], + ['"app_nanoid"(16)'], + ['"NANOID"(16)'], + ['"NanoId"(16)'], + ['"public"."NANOID"(16)'], + ['NOW()'], + ['random()'], + [''], + ])('ignores %j', (body) => { + expect(clientGeneratorSqlDefaultBody(body)).toBeUndefined(); + }); +}); 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..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 @@ -173,11 +173,23 @@ 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('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/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..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 @@ -90,14 +90,30 @@ 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 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/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..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 @@ -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,34 @@ describe('checkSqlDefaultBody', () => { }); }); +describe('clientGeneratorSqlDefaultBody', () => { + it.each([ + ['nanoid()', 'nanoid'], + ['nanoid(16)', 'nanoid'], + [' uuid(7) ', 'uuid'], + ['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)'], + ['"app_nanoid"(16)'], + ['NOW()'], + ['random()'], + [''], + ])('ignores %j', (body) => { + expect(clientGeneratorSqlDefaultBody(body)).toBeUndefined(); + }); +}); + describe('sqlDefaultLiteralTagEntry', () => { const entry = sqlDefaultLiteralTagEntry('pg.sql`...`'); @@ -78,8 +109,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 +119,24 @@ describe('sqlDefaultLiteralTagEntry', () => { }, ); + it.each([ + ['uuid()'], + ['nanoid()'], + ['nanoid(16)'], + ['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({ + 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-extensions/postgres/src/contract/function.ts b/packages/3-extensions/postgres/src/contract/function.ts new file mode 100644 index 000000000000..4ad9ead3f13c --- /dev/null +++ b/packages/3-extensions/postgres/src/contract/function.ts @@ -0,0 +1,86 @@ +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 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; + 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..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 @@ -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..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 @@ -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': @@ -524,16 +529,63 @@ 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, + 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 +966,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..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 @@ -76,6 +76,7 @@ import { renameCheckConstraint, } from './operations/constraints'; import { createExtension } from './operations/dependencies'; +import { createFunction, dropFunction, functionIdentitySignature } from './operations/functions'; import { type CreateIndexElements, type CreateIndexExtras, @@ -1558,6 +1559,109 @@ 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; + /** Identity signature for DROP (defaults/modes stripped, OUT omitted). */ + readonly signature: string; + readonly label: string; + + constructor(schemaName: string, functionName: string, declarationSignature: string) { + super(); + this.schemaName = schemaName; + this.functionName = functionName; + this.signature = functionIdentitySignature(declarationSignature); + 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 +2088,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..833dd9942363 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/operations/functions.ts @@ -0,0 +1,232 @@ +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'; + +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); +} + +/** + * 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 && isPostgresIdentifierChar(text[index - 1]!)) 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; + 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 === "'" || quote === '"') && signature[i + 1] === quote) { + i++; + continue; + } + quote = null; + } + continue; + } + const afterDollar = skipDollarQuoted(signature, i); + if (afterDollar !== undefined) { + i = afterDollar - 1; + 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 === "'" || quote === '"') && arg[i + 1] === quote) { + i++; + continue; + } + quote = null; + } + continue; + } + const afterDollar = skipDollarQuoted(arg, i); + if (afterDollar !== undefined) { + i = afterDollar - 1; + 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(); + // Require a left token boundary so identifiers like `mydefault` are not cut. + if ((i === 0 || !isPostgresIdentifierChar(arg[i - 1]!)) && /^DEFAULT\b/i.test(arg.slice(i))) { + return arg.slice(0, i).trim(); + } + } + return arg.trim(); +} + +/** + * 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(); + 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(body); + } + return types.join(', '); +} + +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); + // CREATE (not OR REPLACE): body/signature edits are planner conflicts until a + // dedicated replace/alter path exists. Authors must drop and recreate. + return [ + `CREATE FUNCTION ${name}(${options.signature})`, + `RETURNS ${options.returns}`, + `LANGUAGE ${options.language} ${options.volatility}`, + `AS $${BODY_DOLLAR_TAG}$`, + options.body, + `$${BODY_DOLLAR_TAG}$;`, + ].join('\n'); +} + +/** `CREATE 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. + * 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 (defaults/modes stripped, OUT omitted). + */ +export function dropFunction(options: { + readonly schemaName: string; + readonly functionName: string; + readonly signature: string; +}): Op { + const name = qualifiedFunctionName(options.schemaName, options.functionName); + const identity = functionIdentitySignature(options.signature); + const sql = `DROP FUNCTION ${name}(${identity});`; + 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/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/migrations/postgres-migration.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/postgres-migration.ts index 32b888550f34..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 @@ -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,36 @@ 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(this.controlAdapterFor('createFunction')); + } + + protected dropFunction(options: { + readonly schema: string; + readonly functionName: string; + readonly signature: string; + }): Promise> { + return new DropFunctionCall(options.schema, options.functionName, options.signature).toOp( + this.controlAdapterFor('dropFunction'), + ); + } + /** * 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..b1e8142601a4 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/postgres-function.ts @@ -0,0 +1,63 @@ +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[]`. 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 { + 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..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 @@ -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}". Overloaded functions (same name, different signatures) are unsupported; give each function a distinct name.`, + ); + 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/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/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..d152030fe2b6 --- /dev/null +++ b/packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-function-schema-node.ts @@ -0,0 +1,85 @@ +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. + * + * **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; + + 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-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']); + }); +}); 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..8a431f55487a --- /dev/null +++ b/packages/3-targets/3-targets/postgres/test/migrations/function-planner.test.ts @@ -0,0 +1,263 @@ +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, + DropFunctionCall, +} from '../../src/core/migrations/op-factory-call'; +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'; +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', () => { + const contract = makeContract(); + const actual = new PostgresDatabaseSchemaNode({ + namespaces: { + public: new PostgresNamespaceSchemaNode({ + schemaName: 'public', + tables: {}, + functions: [], + }), + }, + roles: [], + existingSchemas: ['public'], + pgVersion: '', + }); + 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.calls; + 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 FUNCTION with the opaque body (not OR REPLACE)', () => { + 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 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', () => { + const contract = makeContract(); + 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 } = 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.failure[0]?.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', + 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); + }); + + 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"(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('size int'); + expect(call.renderTypeScript()).toBe( + '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"(size int);'); + }); +}); + +describe('functionIdentitySignature', () => { + 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'); + expect(functionIdentitySignature('éDEFAULT int')).toBe('éDEFAULT int'); + }); + + it('strips modes and omits OUT arguments from identity', () => { + 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 intact', () => { + expect(functionIdentitySignature('int')).toBe('int'); + expect(functionIdentitySignature('double precision')).toBe('double precision'); + 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('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', () => { + 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(''); + }); +}); 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/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/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/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/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: { 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', () => { diff --git a/projects/postgres-sql-functions/README.md b/projects/postgres-sql-functions/README.md new file mode 100644 index 000000000000..e8e2c0faaea4 --- /dev/null +++ b/projects/postgres-sql-functions/README.md @@ -0,0 +1,33 @@ +# 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) + +Narrow scope — see [`spec.md`](spec.md) D4–D9: + +- **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/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..ecdb394ca6a0 --- /dev/null +++ b/projects/postgres-sql-functions/plan.md @@ -0,0 +1,44 @@ +# 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()`. 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 new file mode 100644 index 000000000000..0227d6e3eb46 --- /dev/null +++ b/projects/postgres-sql-functions/spec.md @@ -0,0 +1,105 @@ +# 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` / `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 + +### 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 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 (MVP scope) + +`createFunction` classifies as `dep` (with `createNativeEnumType`). `dropFunction` classifies as `dropType`. + +**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. + +### 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 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 +- `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