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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/reference/error-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,10 @@ A list column declares `@default(autoincrement())`: `Field "<Model>.<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
Expand Down
6 changes: 6 additions & 0 deletions packages/1-framework/2-authoring/ids/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
39 changes: 39 additions & 0 deletions packages/2-sql/1-core/contract/src/default-sql-body.ts
Original file line number Diff line number Diff line change
@@ -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 (<body>)`, else the reason. */
export function checkSqlDefaultBody(body: string): string | undefined {
return UNSAFE_DEFAULT_BODY.test(body)
Expand All @@ -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.'
);
}
7 changes: 6 additions & 1 deletion packages/2-sql/1-core/contract/src/exports/validators.ts
Original file line number Diff line number Diff line change
@@ -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';
40 changes: 39 additions & 1 deletion packages/2-sql/1-core/contract/test/default-sql-body.test.ts
Original file line number Diff line number Diff line change
@@ -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([
Expand All @@ -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();
});
});
4 changes: 2 additions & 2 deletions packages/2-sql/2-authoring/contract-psl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
31 changes: 27 additions & 4 deletions packages/2-sql/9-family/src/core/sql-default-literal-tag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/2-sql/9-family/src/exports/control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
55 changes: 52 additions & 3 deletions packages/2-sql/9-family/test/sql-default-literal-tag.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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`...`');

Expand Down Expand Up @@ -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,
Expand All @@ -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({
Expand Down
Loading