diff --git a/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md b/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md index 9eb9140dae..1149f32878 100644 --- a/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md +++ b/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md @@ -30,8 +30,8 @@ const sqlRelation = fieldAttribute('relation', { fields: optional(list(fieldRef('self'), { nonEmpty: true })), references: optional(list(fieldRef('referenced'), { nonEmpty: true })), map: optional(str()), - onDelete: optional(enumOf('NoAction', 'Restrict', 'Cascade', 'SetNull', 'SetDefault')), - onUpdate: optional(enumOf('NoAction', 'Restrict', 'Cascade', 'SetNull', 'SetDefault')), + onDelete: optional(oneOf(identifier('NoAction'), identifier('Restrict'), identifier('Cascade'), identifier('SetNull'), identifier('SetDefault'))), + onUpdate: optional(oneOf(identifier('NoAction'), identifier('Restrict'), identifier('Cascade'), identifier('SetNull'), identifier('SetDefault'))), }, refine: relationInvariants, }); @@ -51,9 +51,9 @@ At runtime, `interpretAttribute(node, sqlRelation, ctx)` turns the parsed AST no } ``` -Notice three things that the rest of this document builds up. The argument value types (`str()`, `enumOf(...)`, `list(fieldRef('self'))`) are **combinators** drawn from a fixed framework kit. A combinator like `fieldRef` carries a **scope** that says which entity a field name resolves against. And cross-argument rules that no single argument can express — `fields` and `references` must appear together — live in a `refine` step. +Notice three things that the rest of this document builds up. The argument value types (`str()`, `oneOf(identifier(...))`, `list(fieldRef('self'))`) are **combinators** drawn from a fixed framework kit. A combinator like `fieldRef` carries a **scope** that says which entity a field name resolves against. And cross-argument rules that no single argument can express — `fields` and `references` must appear together — live in a `refine` step. -That same spec is what the language server reads. Because `onDelete` is declared as `enumOf('NoAction', ...)`, the editor can complete its values; because `fields` is declared as `list(fieldRef('self'))`, the editor knows each entry names a field of the model and can resolve it to a definition or find its other uses — none of which the interpreter's hand-written validation could ever expose. +That same spec is what the language server reads. Because `onDelete` is declared as `oneOf(identifier('NoAction'), ...)`, the editor enumerates the alternatives' pinned values; because `fields` is declared as `list(fieldRef('self'))`, the editor knows each entry names a field of the model and can resolve it to a definition or find its other uses — none of which the interpreter's hand-written validation could ever expose. The design covers the full spectrum of the current attribute syntax with one exception, `@db.*` native types, which are not attributes on fields or models at all (see [Out of scope](#out-of-scope-db-native-types)). @@ -94,9 +94,9 @@ interface ArgType { The kit divides into four groups. -A combinator owns the work arktype cannot: parsing a PSL AST argument from source, resolving names against the symbol table and registries, anchoring diagnostics to source spans, and carrying the domain metadata (its `kind`, a reference's scope) the language server switches on. Where a leaf reduces to a context-free check on an already-parsed value — a literal set, a numeric range, the shape of a JSON object — it delegates that check, and its type inference, to an arktype `Type` it wraps. So `enumOf` is backed by an arktype literal union, `int({ min, max })` by arktype's numeric constraints, and `json()` by an arktype object schema, while the combinator around them supplies the parse, the context, the spans, and the metadata. This mirrors the existing authoring pattern, where a contributed entity's `validatorSchema` is an arktype `Type` that validates structured input before a factory runs. +A combinator owns the work arktype cannot: parsing a PSL AST argument from source, resolving names against the symbol table and registries, anchoring diagnostics to source spans, and carrying the domain metadata (its `kind`, a reference's scope) the language server switches on. Where a leaf reduces to a context-free check on an already-parsed value — a literal, a numeric range, the shape of a JSON object — it delegates that check, and its type inference, to an arktype `Type` it wraps. So a pinned `identifier(name)` / `str(value)` / `num(value)` is backed by an arktype literal, `int({ min, max })` by arktype's numeric constraints, and `json()` by an arktype object schema, while the combinator around them supplies the parse, the context, the spans, and the metadata. This mirrors the existing authoring pattern, where a contributed entity's `validatorSchema` is an arktype `Type` that validates structured input before a factory runs. -**Scalars** read a single token. `str()` parses a quoted string. `int({ min, max })` parses a number. `bool()` parses a boolean. `enumOf(...values)` parses a member of a fixed literal set — and because a member may be a string or a number, the set may be homogeneous *or* mixed. Mongo's index `type`, which accepts the numbers `1`/`-1` and the strings `"text"`/`"2dsphere"`/`"2d"`/`"hashed"`, is a single `enumOf(1, -1, 'text', '2dsphere', '2d', 'hashed')`. `json()` reads an opaque JSON value from a quoted string; it is the one place a structured value is text-encoded (see [Surface policy](#surface-policy-native-literals-with-one-text-exception)). +**Scalars** read a single token. `str()` parses any quoted string, and `str(value)` pins a specific one. `int({ min, max })` parses a number. `bool()` parses a boolean. `identifier(name)` matches a specific bare identifier, typed `ArgType`, and `num(value)` matches a specific number literal. There is no dedicated enum leaf: a fixed literal set is `oneOf` over these pinned matchers, and because each member is its own matcher the set may be homogeneous *or* mixed — with the quoted-vs-bare surface explicit per member rather than guessed from a value's JS type. Mongo's index `type`, which accepts the numbers `1`/`-1` and the strings `"text"`/`"2dsphere"`/`"2d"`/`"hashed"`, is `oneOf(num(1), num(-1), str('text'), str('2dsphere'), str('2d'), str('hashed'))`. `json()` reads an opaque JSON value from a quoted string; it is the one place a structured value is text-encoded (see [Surface policy](#surface-policy-native-literals-with-one-text-exception)). **References** resolve a name to an **entity coordinate** — the contract's uniform `(namespace, kind, name)` address for *any* entity, whether a model, an enum, or a pack-contributed entity kind (ADR 221, ADR 224). A reference is not special-cased to models. The coordinate carries one optional extension, a **`field`** element, for a reference that names a field *within* an entity. So `entityRef({ scope })` resolves an entity by name, and its field-bearing form `fieldRef({ scope })` resolves a field within the scoped entity and fills in the coordinate's `field` element; `scope` is `'self'` (the entity declaring the attribute), `'referenced'` (a relation's target entity), or `'document'` (a free path, for wildcard projections). `codecRef()` resolves a registered codec id — a registry reference, not a contract-entity coordinate. @@ -201,7 +201,7 @@ The primary reason a spec is *declarative* rather than a parsing function is tha Today attribute arguments are opaque to the language server. It can offer little more than the attribute name, because everything past the opening parenthesis is validated by interpreter code it cannot introspect. The same spec the interpreters run answers the questions an editor needs to ask: -- **Autocompletion.** The `named` map lists the legal argument names for an attribute, so the editor completes `fie` to `fields:` inside `@relation(...)`. A value typed `enumOf('NoAction', 'Restrict', 'Cascade', 'SetNull', 'SetDefault')` enumerates its own completions, so the editor offers exactly those after `onDelete:`. The combinator's `label` supplies the hover text. +- **Autocompletion.** The `named` map lists the legal argument names for an attribute, so the editor completes `fie` to `fields:` inside `@relation(...)`. A value typed `oneOf(identifier('NoAction'), identifier('Restrict'), identifier('Cascade'), identifier('SetNull'), identifier('SetDefault'))` enumerates its alternatives' pinned values, so the editor offers exactly those after `onDelete:`. The combinator's `label` supplies the hover text. - **Go-to-definition and find-usages.** A combinator declares not just that an argument is a name, but *what kind of name and where it resolves*. `fields: list(fieldRef('self'))` says each entry names a field of the enclosing model; `references: list(fieldRef('referenced'))` says each names a field of the relation's target model; `@@base`'s `entityRef()` names another entity. From that, the language server resolves the symbol under the cursor to its declaration, and finds every other attribute argument that references the same field or entity — neither of which the interpreter's hand-written validation could ever expose, because it discards that structure as soon as it has checked it. - **Diagnostics parity.** The editor reports the *same* errors the interpreter would, from the same spec, rather than a thinner approximation maintained separately. @@ -243,7 +243,7 @@ The cost is a new layer of indirection. Reading what an attribute accepts means **Dispatch `oneOf` with a recognition predicate.** Give each combinator a `recognizes(arg)` method so `oneOf` commits to one branch by AST shape before parsing, yielding more targeted errors. Rejected for now: it doubles the leaf contract (a recognizer that must stay in sync with the parser) for a benefit — sharper errors on malformed input — that a small closed grammar does not need. Ordered try-each over diagnostic-pure branches is simpler, and a recognizer can be added later without changing the leaf contract if error quality demands it. -**Separate `enumOf` and `numEnum`.** A string-literal enum and a numeric-literal enum as distinct combinators. Rejected: the only real difference is which token surface each member reads, which a single `enumOf` decides per member from the member's own type. Collapsing them also handles mixed string/number sets — Mongo's index `type` — that two separate combinators cannot express cleanly. +**A dedicated enum leaf.** A single combinator for a fixed literal set, deciding each member's token surface from its JS type. Rejected in favour of `oneOf` over `identifier` / pinned `str` / `num`: composition (principle #4) expresses homogeneous and mixed sets uniformly, makes the quoted-vs-bare surface explicit per member rather than inferred from a value's type, and reuses the `oneOf` sum the design already needs for `@default` and index elements. Mixed string/number sets — Mongo's index `type` — remain expressible, now as `oneOf(num(1), num(-1), str('text'), …)`. **`json(codecId)` validated by a codec.** Let the JSON leaf decode through a named codec. Rejected: no attribute in scope needs codec-validated JSON — `filter` is opaque pass-through, and `@@type` takes a codec *id* (a `codecRef`), not a codec-validated value. Codec-bound decoding is a separate concern that belongs to generic-block parameters and enum member values, and if those are folded in later they get their own primitive rather than overloading `json()`. @@ -268,7 +268,7 @@ The cost is a new layer of indirection. Reading what an attribute accepts means ## References - [ADR 225 — Three-layer extensibility for pack-contributed entity kinds](ADR%20225%20-%20Three-layer%20extensibility%20for%20pack-contributed%20entity%20kinds.md) — the contribution model this design follows: a framework-defined extension point that families and targets register into, dispatched structurally so the framework learns no per-kind names. Attribute specs are registered the same way entity kinds are. -- [ADR 224 — Control policy: a framework-locked vocabulary with family-owned dispatch](ADR%20224%20-%20Control%20Policy%20—%20framework-locked%20vocabulary%20and%20family-owned%20dispatch.md) — the `@@control()` attribute whose value set this design types as `enumOf('managed', 'tolerated', 'external', 'observed')`, and the framework-vocabulary / family-dispatch split this design mirrors, here as a PSL-layer kit with family-owned specs. +- [ADR 224 — Control policy: a framework-locked vocabulary with family-owned dispatch](ADR%20224%20-%20Control%20Policy%20—%20framework-locked%20vocabulary%20and%20family-owned%20dispatch.md) — the `@@control()` attribute whose value set this design types as `oneOf(identifier('managed'), identifier('tolerated'), identifier('external'), identifier('observed'))`, and the framework-vocabulary / family-dispatch split this design mirrors, here as a PSL-layer kit with family-owned specs. - [ADR 221 — Contract IR: two planes with a uniform entity coordinate](ADR%20221%20-%20Contract%20IR%20two%20planes%20with%20uniform%20entity%20coordinate%20and%20pack-contributed%20entity%20kinds.md) — the coordinate model attribute resolution writes into. - [ADR 126 — PSL top-level block SPI](ADR%20126%20-%20PSL%20top-level%20block%20SPI.md) — the descriptor SPI for generic blocks, whose `key = value` parameters are the subject of an open question above. - [Pattern: Frozen-class AST + visitor](../patterns/frozen-class-ast.md) — the dispatch pattern for the `ArgType` combinator union across parse, print, and completion sites. diff --git a/packages/1-framework/0-foundation/utils/package.json b/packages/1-framework/0-foundation/utils/package.json index 7b94a8ff3c..41284cda81 100644 --- a/packages/1-framework/0-foundation/utils/package.json +++ b/packages/1-framework/0-foundation/utils/package.json @@ -45,6 +45,7 @@ "./redact-db-url": "./dist/redact-db-url.mjs", "./result": "./dist/result.mjs", "./simplify-deep": "./dist/simplify-deep.mjs", + "./types": "./dist/types.mjs", "./package.json": "./package.json" }, "engines": { diff --git a/packages/1-framework/0-foundation/utils/src/exports/types.ts b/packages/1-framework/0-foundation/utils/src/exports/types.ts new file mode 100644 index 0000000000..ed1538047c --- /dev/null +++ b/packages/1-framework/0-foundation/utils/src/exports/types.ts @@ -0,0 +1 @@ +export type { Simplify, UnionToIntersection } from '../types'; diff --git a/packages/1-framework/0-foundation/utils/src/types.ts b/packages/1-framework/0-foundation/utils/src/types.ts new file mode 100644 index 0000000000..7d179368a2 --- /dev/null +++ b/packages/1-framework/0-foundation/utils/src/types.ts @@ -0,0 +1,9 @@ +/** Flattens an intersection of mapped types into a single readable object type. */ +export type Simplify = { [K in keyof T]: T[K] } & {}; + +/** Collapses a union into the intersection of its members. */ +export type UnionToIntersection = (U extends unknown ? (k: U) => void : never) extends ( + k: infer I, +) => void + ? I + : never; diff --git a/packages/1-framework/0-foundation/utils/test/types.test-d.ts b/packages/1-framework/0-foundation/utils/test/types.test-d.ts new file mode 100644 index 0000000000..ac0845a2ba --- /dev/null +++ b/packages/1-framework/0-foundation/utils/test/types.test-d.ts @@ -0,0 +1,17 @@ +import { expectTypeOf, test } from 'vitest'; +import type { Simplify, UnionToIntersection } from '../src/types'; + +test('Simplify flattens an intersection into a single object type', () => { + type Input = { a: number } & { b: string }; + expectTypeOf>().toEqualTypeOf<{ a: number; b: string }>(); +}); + +test('Simplify preserves optional modifiers', () => { + type Input = { a: number } & { b?: string }; + expectTypeOf>().toEqualTypeOf<{ a: number; b?: string }>(); +}); + +test('UnionToIntersection collapses a union of objects into their intersection', () => { + type Input = { a: number } | { b: string }; + expectTypeOf>().toEqualTypeOf<{ a: number } & { b: string }>(); +}); diff --git a/packages/1-framework/0-foundation/utils/tsdown.config.ts b/packages/1-framework/0-foundation/utils/tsdown.config.ts index 3cea866a51..f5053808d1 100644 --- a/packages/1-framework/0-foundation/utils/tsdown.config.ts +++ b/packages/1-framework/0-foundation/utils/tsdown.config.ts @@ -14,5 +14,6 @@ export default defineConfig({ 'src/exports/result.ts', 'src/exports/redact-db-url.ts', 'src/exports/simplify-deep.ts', + 'src/exports/types.ts', ], }); diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts new file mode 100644 index 0000000000..eab4a25a66 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts @@ -0,0 +1,18 @@ +import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; +import { nodePslSpan } from '../../resolve'; +import type { AstNode } from '../../syntax/ast-helpers'; +import type { InterpretCtx } from '../types'; + +/** + * Builds a leaf diagnostic anchored to the offending `node`, stamped with the + * code threaded through `ctx`. Combinators emit through this helper so every + * leaf carries the active attribute's code rather than a hard-coded generic. + */ +export function leafDiagnostic(ctx: InterpretCtx, node: AstNode, message: string): PslDiagnostic { + return { + code: ctx.diagnosticCode, + message, + sourceId: ctx.sourceId, + span: nodePslSpan(node.syntax, ctx.sourceFile), + }; +} diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/field-ref.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/field-ref.ts new file mode 100644 index 0000000000..303557fdbd --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/field-ref.ts @@ -0,0 +1,50 @@ +import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; +import { notOk, ok, type Result } from '@prisma-next/utils/result'; +import { IdentifierAst } from '../../syntax/ast/identifier'; +import type { ArgType } from '../types'; +import { leafDiagnostic } from './diagnostic'; + +/** + * The entity a field name resolves against: the declaring model (`'self'`) or a + * relation's target model (`'referenced'`). Carried for the language server; + * the value parsed at runtime is just the name. + */ +export type FieldRefScope = 'self' | 'referenced'; + +/** A field-name combinator tagged with the scope its name resolves against. */ +export interface FieldRefArgType extends ArgType { + readonly scope: FieldRefScope; +} + +/** + * Parses a bare identifier into a field name and resolves it against the scoped + * model: `'self'` against the declaring model, `'referenced'` against a + * relation's target. A name absent from the resolved model emits the + * field-existence diagnostic here, anchored to the identifier. When the + * referenced model is out of scope (e.g. a cross-space target the parser cannot + * see), existence cannot be checked, so the name is carried through unchecked + * and validated where the target is known. The parsed value is always the name. + */ +export function fieldRef(scope: FieldRefScope): FieldRefArgType { + return { + kind: 'fieldRef', + label: 'field name', + scope, + parse: (arg, ctx): Result => { + if (!(arg instanceof IdentifierAst)) { + return notOk([leafDiagnostic(ctx, arg, 'Expected a field name')]); + } + const name = arg.name(); + if (name === undefined) { + return notOk([leafDiagnostic(ctx, arg, 'Expected a field name')]); + } + const model = scope === 'self' ? ctx.selfModel : ctx.resolveReferencedModel(); + if (model !== undefined && !Object.hasOwn(model.fields, name)) { + return notOk([ + leafDiagnostic(ctx, arg, `Field "${name}" does not exist on model "${model.name}"`), + ]); + } + return ok(name); + }, + }; +} diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts new file mode 100644 index 0000000000..061d2c195f --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts @@ -0,0 +1,23 @@ +import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; +import { notOk, ok, type Result } from '@prisma-next/utils/result'; +import { IdentifierAst } from '../../syntax/ast/identifier'; +import type { ArgType } from '../types'; +import { leafDiagnostic } from './diagnostic'; + +/** + * Matches a bare identifier whose name equals `name`, returning that name with + * its literal type preserved. Pinned-only: there is no open form, so several + * `identifier`s composed under `oneOf` infer the precise union of names. A + * non-identifier token, or an identifier with a different name, is rejected with + * the threaded code, anchored to the argument node. + */ +export function identifier(name: N): ArgType { + return { + kind: 'identifier', + label: name, + parse: (arg, ctx): Result => { + if (arg instanceof IdentifierAst && arg.name() === name) return ok(name); + return notOk([leafDiagnostic(ctx, arg, `Expected ${name}`)]); + }, + }; +} diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/list.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/list.ts new file mode 100644 index 0000000000..7e0bc49a37 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/list.ts @@ -0,0 +1,50 @@ +import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; +import { notOk, ok, type Result } from '@prisma-next/utils/result'; +import { ArrayLiteralAst, type ExpressionAst } from '../../syntax/ast/expressions'; +import type { ArgType } from '../types'; +import { leafDiagnostic } from './diagnostic'; + +/** Surface constraints a `list` enforces over its parsed elements. */ +export interface ListOptions { + readonly nonEmpty?: boolean; + readonly unique?: boolean; +} + +/** + * Lifts an element combinator over a `[…]` array literal into `T[]`, threading + * each element through `of` and enforcing the optional surface constraints. + * Element errors are collected and propagated; `nonEmpty` and `unique` add their + * own diagnostics, anchored to the array and to each offending element. + */ +export function list(of: ArgType, opts?: ListOptions): ArgType { + return { + kind: 'list', + label: `${of.label}[]`, + parse: (arg, ctx): Result => { + if (!(arg instanceof ArrayLiteralAst)) { + return notOk([leafDiagnostic(ctx, arg, `Expected a list of ${of.label}`)]); + } + const diagnostics: PslDiagnostic[] = []; + const parsed: { node: ExpressionAst; value: T }[] = []; + let count = 0; + for (const element of arg.elements()) { + count += 1; + const result = of.parse(element, ctx); + if (result.ok) parsed.push({ node: element, value: result.value }); + else diagnostics.push(...result.failure); + } + if (opts?.nonEmpty === true && count === 0) { + diagnostics.push(leafDiagnostic(ctx, arg, 'Expected a non-empty list')); + } + if (opts?.unique === true) { + const seen = new Set(); + for (const { node, value } of parsed) { + if (seen.has(value)) diagnostics.push(leafDiagnostic(ctx, node, 'Duplicate list entry')); + else seen.add(value); + } + } + if (diagnostics.length > 0) return notOk(diagnostics); + return ok(parsed.map((entry) => entry.value)); + }, + }; +} diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/one-of.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/one-of.ts new file mode 100644 index 0000000000..1521bf1049 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/one-of.ts @@ -0,0 +1,38 @@ +import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; +import { blindCast } from '@prisma-next/utils/casts'; +import { notOk, ok, type Result } from '@prisma-next/utils/result'; +import type { ArgType, OutOf } from '../types'; +import { leafDiagnostic } from './diagnostic'; + +/** + * Tries each alternative in order and returns the first one that succeeds; the + * output type is the union of the alternatives' output types. Because leaves are + * `Result`-pure — a failed branch returns its diagnostics rather than pushing + * them into a sink — a discarded alternative leaves no stray errors behind, so + * when every alternative fails `oneOf` emits a single aggregate diagnostic + * (listing the alternatives' labels) with the threaded code, anchored to the + * argument node, rather than leaking the branches' internal failures. + */ +export function oneOf, ...ArgType[]]>( + ...alts: Alts +): ArgType> { + const label = alts.map((alt) => alt.label).join(' | '); + return { + kind: 'oneOf', + label, + parse: (arg, ctx): Result, readonly PslDiagnostic[]> => { + for (const alt of alts) { + const result = alt.parse(arg, ctx); + if (result.ok) { + return ok( + blindCast< + OutOf, + 'The matched value comes from an alternative whose output type is a member of the union, but iterating the tuple widens each element to ArgType, erasing that relationship.' + >(result.value), + ); + } + } + return notOk([leafDiagnostic(ctx, arg, `Expected one of: ${label}`)]); + }, + }; +} diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/str.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/str.ts new file mode 100644 index 0000000000..2b87d724c0 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/str.ts @@ -0,0 +1,20 @@ +import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; +import { notOk, ok, type Result } from '@prisma-next/utils/result'; +import { StringLiteralExprAst } from '../../syntax/ast/expressions'; +import type { ArgType } from '../types'; +import { leafDiagnostic } from './diagnostic'; + +/** Parses a quoted string-literal argument into its decoded value. */ +export function str(): ArgType { + return { + kind: 'str', + label: 'string', + parse: (arg, ctx): Result => { + if (arg instanceof StringLiteralExprAst) { + const value = arg.value(); + if (value !== undefined) return ok(value); + } + return notOk([leafDiagnostic(ctx, arg, 'Expected a string literal')]); + }, + }; +} diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/field-attribute.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/field-attribute.ts new file mode 100644 index 0000000000..596000c327 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/field-attribute.ts @@ -0,0 +1,34 @@ +import type { PslDiagnostic, PslDiagnosticCode } from '@prisma-next/framework-components/psl-ast'; +import type { AttributeOut, AttributeSpec, InterpretCtx, Param, PositionalParam } from './types'; + +interface FieldAttributeConfig< + Pos extends readonly PositionalParam[], + Named extends Record>, +> { + readonly positional?: Pos; + readonly named?: Named; + readonly refine?: ( + parsed: AttributeOut, + ctx: InterpretCtx, + ) => readonly PslDiagnostic[]; + readonly diagnosticCode?: PslDiagnosticCode; +} + +/** + * Builds a field-level `AttributeSpec`. The output type is inferred from the + * positional and named parameters, so there is no hand-written output interface + * to drift from the spec. + */ +export function fieldAttribute< + const Pos extends readonly PositionalParam[] = readonly [], + const Named extends Record> = Record, +>(name: string, config: FieldAttributeConfig): AttributeSpec> { + return { + level: 'field', + name, + positional: config.positional ?? [], + named: config.named ?? {}, + ...(config.refine !== undefined ? { refine: config.refine } : {}), + ...(config.diagnosticCode !== undefined ? { diagnosticCode: config.diagnosticCode } : {}), + }; +} diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts new file mode 100644 index 0000000000..ba16ced6dc --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts @@ -0,0 +1,225 @@ +import type { + PslDiagnostic, + PslDiagnosticCode, + PslSpan, +} from '@prisma-next/framework-components/psl-ast'; +import { blindCast } from '@prisma-next/utils/casts'; +import { notOk, ok, type Result } from '@prisma-next/utils/result'; +import { nodePslSpan } from '../resolve'; +import type { FieldAttributeAst, ModelAttributeAst } from '../syntax/ast/attributes'; +import type { AttributeArgAst } from '../syntax/ast/expressions'; +import type { ArgType, AttributeSpec, InterpretCtx, OptionalArgType, Param } from './types'; + +const DEFAULT_STRUCTURAL_CODE: PslDiagnosticCode = 'PSL_INVALID_ATTRIBUTE_SYNTAX'; + +/** How an output key was bound, so a later collision is reported as the right kind. */ +type Origin = 'positional' | 'named'; + +/** + * Interprets an attribute node against its spec in a single pass over the source + * arguments. A positional argument binds to the next unconsumed positional + * slot's key; a named argument binds to its key. Both write into one flat output + * map guarded by one `seen` map: a key bound twice — whether positional-then-named + * (the alias) or a repeated named key — is reported inline and skipped. Unknown + * named keys and excess positional arguments are reported as they are + * encountered. After the pass, optional defaults fill absent keys, missing + * required keys are reported, then `refine` runs. Returns the spec-inferred + * output or the accumulated diagnostics. + */ +export function interpretAttribute( + attrNode: FieldAttributeAst | ModelAttributeAst, + spec: AttributeSpec, + ctx: InterpretCtx, +): Result { + const diagnostics: PslDiagnostic[] = []; + const code = spec.diagnosticCode ?? DEFAULT_STRUCTURAL_CODE; + const leafCtx: InterpretCtx = { ...ctx, diagnosticCode: code }; + const attributeSpan = nodePslSpan(attrNode.syntax, ctx.sourceFile); + + const output: Record = {}; + const seen = new Map(); + let positionalSlot = 0; + let reportedExcess = false; + + for (const arg of attrNode.argList()?.args() ?? []) { + const name = arg.name()?.name(); + + if (name === undefined) { + const param = spec.positional[positionalSlot]; + if (param === undefined) { + if (!reportedExcess) { + diagnostics.push( + diagnostic( + code, + `Attribute "${spec.name}" received too many positional arguments`, + ctx, + attributeSpan, + ), + ); + reportedExcess = true; + } + continue; + } + positionalSlot += 1; + if (seen.has(param.key)) { + diagnostics.push( + duplicateDiagnostic( + param.key, + seen.get(param.key), + false, + spec.name, + ctx, + arg, + attributeSpan, + code, + ), + ); + continue; + } + seen.set(param.key, 'positional'); + const result = parseArgValue(arg, param.type, leafCtx, diagnostics, code); + if (result.ok) output[param.key] = result.value; + continue; + } + + const param = Object.hasOwn(spec.named, name) ? spec.named[name] : undefined; + if (param === undefined) { + diagnostics.push( + diagnostic( + code, + `Attribute "${spec.name}" received unknown argument "${name}"`, + ctx, + nodePslSpan(arg.syntax, ctx.sourceFile), + ), + ); + continue; + } + if (seen.has(name)) { + diagnostics.push( + duplicateDiagnostic(name, seen.get(name), true, spec.name, ctx, arg, attributeSpan, code), + ); + continue; + } + seen.set(name, 'named'); + const result = parseArgValue(arg, param, leafCtx, diagnostics, code); + if (result.ok) output[name] = result.value; + } + + const finalized = new Set(); + const finalizeAbsentKey = ( + key: string, + positionalParam: Param | undefined, + namedParam: Param | undefined, + ): void => { + if (finalized.has(key) || seen.has(key)) return; + finalized.add(key); + const effective = namedParam ?? positionalParam; + if (effective === undefined) return; + if (isOptionalArgType(effective)) { + if (effective.hasDefault) output[key] = effective.defaultValue; + return; + } + diagnostics.push( + diagnostic( + code, + `Attribute "${spec.name}" is missing required argument "${key}"`, + ctx, + attributeSpan, + ), + ); + }; + + for (const param of spec.positional) { + const namedParam = Object.hasOwn(spec.named, param.key) ? spec.named[param.key] : undefined; + finalizeAbsentKey(param.key, param.type, namedParam); + } + for (const key of Object.keys(spec.named)) { + finalizeAbsentKey(key, undefined, spec.named[key]); + } + + if (diagnostics.length > 0) { + return notOk(diagnostics); + } + + const value = blindCast< + Out, + 'The engine builds the output object structurally from the spec; TypeScript cannot relate the dynamically-keyed record to the spec-inferred output type.' + >(output); + if (spec.refine !== undefined) { + const refineDiagnostics = spec.refine(value, leafCtx); + if (refineDiagnostics.length > 0) { + return notOk(refineDiagnostics); + } + } + return ok(value); +} + +/** + * Reports a key bound twice. A repeated named key is anchored to the offending + * argument; an alias collision between a positional and a named binding (in + * either order) is anchored to the whole attribute, keeping each error path's + * span no coarser than the previous engine. + */ +function duplicateDiagnostic( + key: string, + storedOrigin: Origin | undefined, + currentIsNamed: boolean, + attributeName: string, + ctx: InterpretCtx, + arg: AttributeArgAst, + attributeSpan: PslSpan, + code: PslDiagnosticCode, +): PslDiagnostic { + if (currentIsNamed && storedOrigin === 'named') { + return diagnostic( + code, + `Attribute "${attributeName}" received duplicate argument "${key}"`, + ctx, + nodePslSpan(arg.syntax, ctx.sourceFile), + ); + } + return diagnostic( + code, + `Attribute "${attributeName}" received duplicate values for "${key}" both positionally and by name`, + ctx, + attributeSpan, + ); +} + +function parseArgValue( + arg: AttributeArgAst, + argType: ArgType, + ctx: InterpretCtx, + diagnostics: PslDiagnostic[], + code: PslDiagnosticCode, +): Result { + const value = arg.value(); + if (value === undefined) { + const missing = diagnostic( + code, + 'Attribute argument is missing a value', + ctx, + nodePslSpan(arg.syntax, ctx.sourceFile), + ); + diagnostics.push(missing); + return notOk([missing]); + } + const result = argType.parse(value, ctx); + if (!result.ok) { + for (const failure of result.failure) diagnostics.push(failure); + } + return result; +} + +function isOptionalArgType(param: Param): param is OptionalArgType { + return 'optional' in param && param.optional === true; +} + +function diagnostic( + code: PslDiagnosticCode, + message: string, + ctx: InterpretCtx, + span: PslSpan, +): PslDiagnostic { + return { code, message, sourceId: ctx.sourceId, span }; +} diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/optional.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/optional.ts new file mode 100644 index 0000000000..ef4aa9a37c --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/optional.ts @@ -0,0 +1,14 @@ +import type { ArgType, OptionalArgType } from './types'; + +/** + * Marks a parameter optional, returning a flavoured `ArgType` that parses like + * the wrapped type. With a second argument, the value is applied as a default + * when the argument is absent; without one, an absent argument leaves the output + * property unset. + */ +export function optional(type: ArgType, ...rest: [defaultValue: T] | []): OptionalArgType { + if (rest.length === 0) { + return { ...type, optional: true, hasDefault: false }; + } + return { ...type, optional: true, hasDefault: true, defaultValue: rest[0] }; +} diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/types.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/types.ts new file mode 100644 index 0000000000..0835b16c21 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/types.ts @@ -0,0 +1,125 @@ +import type { PslDiagnostic, PslDiagnosticCode } from '@prisma-next/framework-components/psl-ast'; +import type { Result } from '@prisma-next/utils/result'; +import type { Simplify, UnionToIntersection } from '@prisma-next/utils/types'; +import type { SourceFile } from '../source-file'; +import type { FieldSymbol, ModelSymbol, SymbolTable } from '../symbol-table'; +import type { ExpressionAst } from '../syntax/ast/expressions'; + +export type AttributeLevel = 'field' | 'model' | 'block'; + +/** + * Parses one attribute argument from the parser's CST into a value of type `T`, + * carrying that type at the type level so an attribute's output object can be + * inferred from its spec. + * + * Parsing is pure: a leaf returns its diagnostics in the `Result` rather than + * pushing them into a shared sink, so an alternative can be tried and discarded + * without leaving stray errors behind. + */ +export interface ArgType { + /** Discriminant for visitor dispatch (print, completion, doc generation). */ + readonly kind: string; + /** Human-readable label, for "expected …" diagnostics. */ + readonly label: string; + /** Phantom carrier for `T`; never read at runtime. */ + readonly _out?: T; + parse(arg: ExpressionAst, ctx: InterpretCtx): Result; +} + +/** + * The resolution context threaded through every `parse`. It carries the source + * coordinates the engine anchors diagnostics to (`sourceId` + `sourceFile`) and + * the PSL symbol-table handles reference combinators resolve against. + * + * Deliberately lean: codec-lookup / default-function-registry handles are added + * only once a combinator needs them, so the kit does not pull those dependencies + * into the parser layer before they are used. + */ +export interface InterpretCtx { + readonly level: AttributeLevel; + /** + * The code a leaf stamps onto the diagnostics it emits. `interpretAttribute` + * populates it from the active spec's `diagnosticCode` before calling any + * leaf, so a combinator emits with the attribute's code rather than a + * hard-coded generic. When a leaf is exercised directly (outside the engine), + * the caller sets it. + */ + readonly diagnosticCode: PslDiagnosticCode; + /** Identifier of the source the attribute was parsed from; stamped onto diagnostics. */ + readonly sourceId: string; + /** The parsed source, used to resolve node offsets into diagnostic spans. */ + readonly sourceFile: SourceFile; + readonly symbols: SymbolTable; + /** The declaring model; the resolution target for a self-scoped field reference. */ + readonly selfModel: ModelSymbol; + /** A relation's target model; the resolution target for a referenced-scoped field reference. */ + resolveReferencedModel(): ModelSymbol | undefined; + /** The resolved declaring field; present only at field level. */ + readonly field?: FieldSymbol; +} + +/** + * An optional parameter: a flavoured `ArgType` carrying the optionality marker + * and, when `hasDefault`, the default value applied when the argument is absent. + * Because it extends `ArgType`, the engine parses it directly and detects + * optionality from the `optional` marker. + */ +export interface OptionalArgType extends ArgType { + readonly optional: true; + readonly hasDefault: boolean; + readonly defaultValue?: T; +} + +/** A parameter slot's arg type; an `optional(...)` param is a flavoured `ArgType`. */ +export type Param = ArgType; + +export interface PositionalParam { + /** The output key this slot writes into. */ + readonly key: string; + readonly type: Param; +} + +export interface AttributeSpec { + readonly level: AttributeLevel; + readonly name: string; + readonly positional: readonly PositionalParam[]; + readonly named: Readonly>>; + readonly refine?: (parsed: Out, ctx: InterpretCtx) => readonly PslDiagnostic[]; + /** + * Code for engine-emitted structural diagnostics (unknown argument, excess + * positional argument, alias conflict). Defaults to `PSL_INVALID_ATTRIBUTE_SYNTAX`; + * an attribute that must preserve a specific code overrides it here. + */ + readonly diagnosticCode?: PslDiagnosticCode; +} + +export type OutOf

= P extends ArgType ? T : never; + +export type NamedOut>> = Simplify< + { [K in keyof N as N[K] extends OptionalArgType ? never : K]: OutOf } & { + [K in keyof N as N[K] extends OptionalArgType ? K : never]?: OutOf; + } +>; + +type PosEntryObject = + E['type'] extends OptionalArgType + ? { [K in E['key']]?: OutOf } + : { [K in E['key']]: OutOf }; + +export type PosOut = Simplify< + UnionToIntersection<{ [I in keyof Pos]: PosEntryObject }[number]> +>; + +export type AttributeOut< + Pos extends readonly PositionalParam[], + Named extends Record>, +> = Simplify & NamedOut>; + +/** + * Recovers a spec's inferred output type. The parameter is intentionally + * unconstrained: `Out` sits contravariantly in `AttributeSpec.refine`, so a + * `refine`-carrying `AttributeSpec` is not assignable to + * `AttributeSpec` and a constraint would reject every spec that uses a + * cross-argument `refine`. Inference still recovers `Out` precisely. + */ +export type InferAttr = S extends AttributeSpec ? Out : never; diff --git a/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts b/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts index 7ff2425a76..f48f3ec85d 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts @@ -36,6 +36,30 @@ export { namespacePslExtensionBlocks, } from '@prisma-next/framework-components/psl-ast'; export { getPositionalArgument, parseQuotedStringLiteral } from '../attribute-helpers'; +export type { FieldRefArgType, FieldRefScope } from '../attribute-spec/combinators/field-ref'; +export { fieldRef } from '../attribute-spec/combinators/field-ref'; +export { identifier } from '../attribute-spec/combinators/identifier'; +export type { ListOptions } from '../attribute-spec/combinators/list'; +export { list } from '../attribute-spec/combinators/list'; +export { oneOf } from '../attribute-spec/combinators/one-of'; +export { str } from '../attribute-spec/combinators/str'; +export { fieldAttribute } from '../attribute-spec/field-attribute'; +export { interpretAttribute } from '../attribute-spec/interpret'; +export { optional } from '../attribute-spec/optional'; +export type { + ArgType, + AttributeLevel, + AttributeOut, + AttributeSpec, + InferAttr, + InterpretCtx, + NamedOut, + OptionalArgType, + OutOf, + Param, + PositionalParam, + PosOut, +} from '../attribute-spec/types'; export { findBlockDescriptor, validateExtensionBlockFromSymbol } from '../extension-block'; export { keywordPslSpan, diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test-d.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test-d.ts new file mode 100644 index 0000000000..c875dfb2fa --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test-d.ts @@ -0,0 +1,22 @@ +import { expectTypeOf, test } from 'vitest'; +import type { ArgType } from '../src/exports'; +import { identifier, list, oneOf, str } from '../src/exports'; + +test('identifier pins its name as the output literal type', () => { + expectTypeOf(identifier('NoAction')).toEqualTypeOf>(); +}); + +test('oneOf infers the union of its alternatives output types', () => { + expectTypeOf(oneOf(identifier('NoAction'), identifier('Cascade'))).toEqualTypeOf< + ArgType<'NoAction' | 'Cascade'> + >(); +}); + +test('oneOf with no alternatives is a compile error', () => { + // @ts-expect-error oneOf requires at least one alternative + oneOf(); +}); + +test('list infers an array of its element type', () => { + expectTypeOf(list(str())).toEqualTypeOf>(); +}); diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test.ts new file mode 100644 index 0000000000..ff0ba56a78 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test.ts @@ -0,0 +1,290 @@ +import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; +import { ok } from '@prisma-next/utils/result'; +import { describe, expect, it } from 'vitest'; +import type { ArgType, InterpretCtx } from '../src/exports'; +import { + fieldAttribute, + fieldRef, + identifier, + interpretAttribute, + list, + nodePslSpan, + oneOf, + str, +} from '../src/exports'; +import { Cursor, parse, parseAttribute } from '../src/parse'; +import type { SourceFile } from '../src/source-file'; +import { buildSymbolTable } from '../src/symbol-table'; +import { FieldAttributeAst } from '../src/syntax/ast/attributes'; +import type { ExpressionAst } from '../src/syntax/ast/expressions'; +import { createSyntaxTree } from '../src/syntax/red'; + +function makeCtx( + sourceFile: SourceFile, + diagnosticCode: PslDiagnostic['code'] = 'PSL_INVALID_ATTRIBUTE_SYNTAX', +): InterpretCtx { + const { document, sourceFile: modelSource } = parse('model M {\n id Int @id\n}\n'); + const { table } = buildSymbolTable({ + document, + sourceFile: modelSource, + scalarTypes: ['String', 'Int'], + pslBlockDescriptors: {}, + }); + const selfModel = table.topLevel.models.M; + if (!selfModel) throw new Error('expected model M in the symbol table'); + return { + level: 'field', + sourceId: 'schema.prisma', + sourceFile, + symbols: table, + selfModel, + resolveReferencedModel: () => undefined, + diagnosticCode, + }; +} + +/** Parses `@x()` and returns the first argument's expression plus a context. */ +function argOf(exprSource: string): { expr: ExpressionAst; ctx: InterpretCtx } { + const cursor = new Cursor(`@x(${exprSource})`); + const node = FieldAttributeAst.cast(createSyntaxTree(parseAttribute(cursor))); + if (!node) throw new Error('expected a field attribute'); + const first = [...(node.argList()?.args() ?? [])][0]; + const expr = first?.value(); + if (!expr) throw new Error('expected an argument expression'); + return { expr, ctx: makeCtx(cursor.sourceFile) }; +} + +describe('str', () => { + it('parses a quoted string into its value', () => { + const { expr, ctx } = argOf('"Posts"'); + + const result = str().parse(expr, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe('Posts'); + }); + + it('rejects a non-string token with the threaded code', () => { + const { expr, ctx } = argOf('42'); + + const result = str().parse(expr, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toHaveLength(1); + expect(result.failure[0]?.code).toBe('PSL_INVALID_ATTRIBUTE_SYNTAX'); + } + }); +}); + +describe('identifier', () => { + it('matches a bare identifier equal to the pinned name', () => { + const { expr, ctx } = argOf('Cascade'); + + const result = identifier('Cascade').parse(expr, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe('Cascade'); + }); + + it('rejects a bare identifier with a different name', () => { + const { expr, ctx } = argOf('Cascade'); + + const result = identifier('NoAction').parse(expr, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toHaveLength(1); + expect(result.failure[0]?.code).toBe('PSL_INVALID_ATTRIBUTE_SYNTAX'); + } + }); + + it('rejects a quoted string with the same characters', () => { + const { expr, ctx } = argOf('"Cascade"'); + + const result = identifier('Cascade').parse(expr, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure).toHaveLength(1); + }); + + it('rejects a number token', () => { + const { expr, ctx } = argOf('1'); + + const result = identifier('Cascade').parse(expr, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure).toHaveLength(1); + }); +}); + +describe('oneOf', () => { + it('returns the first alternative that succeeds', () => { + const { expr, ctx } = argOf('Cascade'); + const first: ArgType<'first'> = { kind: 'const', label: 'first', parse: () => ok('first') }; + const second: ArgType<'second'> = { kind: 'const', label: 'second', parse: () => ok('second') }; + + const result = oneOf(first, second).parse(expr, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe('first'); + }); + + it('matches whichever alternative accepts the argument', () => { + const { expr, ctx } = argOf('SetNull'); + + const result = oneOf(identifier('Cascade'), identifier('SetNull')).parse(expr, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe('SetNull'); + }); + + it('emits a single aggregate diagnostic anchored to the arg node when every alternative fails', () => { + const { expr, ctx } = argOf('WeirdAction'); + const relationCtx: InterpretCtx = { ...ctx, diagnosticCode: 'PSL_INVALID_RELATION_ATTRIBUTE' }; + + const result = oneOf(identifier('Cascade'), identifier('SetNull')).parse(expr, relationCtx); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toHaveLength(1); + expect(result.failure[0]?.code).toBe('PSL_INVALID_RELATION_ATTRIBUTE'); + expect(result.failure[0]?.span).toEqual(nodePslSpan(expr.syntax, ctx.sourceFile)); + expect(result.failure[0]?.message).toContain('Cascade'); + expect(result.failure[0]?.message).toContain('SetNull'); + } + }); +}); + +describe('fieldRef', () => { + it('resolves a field that exists on the self model', () => { + const { expr, ctx } = argOf('id'); + + const result = fieldRef('self').parse(expr, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe('id'); + }); + + it('emits an existence diagnostic for a field missing from the self model', () => { + const { expr, ctx } = argOf('ghostField'); + + const result = fieldRef('self').parse(expr, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toHaveLength(1); + expect(result.failure[0]?.code).toBe('PSL_INVALID_ATTRIBUTE_SYNTAX'); + } + }); + + it('resolves a field against the referenced model when it is in scope', () => { + const { expr, ctx } = argOf('id'); + const referencedCtx: InterpretCtx = { ...ctx, resolveReferencedModel: () => ctx.selfModel }; + + const result = fieldRef('referenced').parse(expr, referencedCtx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe('id'); + }); + + it('carries a referenced name through when the referenced model is out of scope', () => { + const { expr, ctx } = argOf('ghostField'); + + const result = fieldRef('referenced').parse(expr, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe('ghostField'); + }); + + it('carries the scope as combinator metadata', () => { + expect(fieldRef('self').scope).toBe('self'); + expect(fieldRef('referenced').scope).toBe('referenced'); + }); + + it('rejects a non-identifier token', () => { + const { expr, ctx } = argOf('"title"'); + + const result = fieldRef('self').parse(expr, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure[0]?.code).toBe('PSL_INVALID_ATTRIBUTE_SYNTAX'); + }); +}); + +describe('list', () => { + it('maps each element through the element combinator', () => { + const { expr, ctx } = argOf('["a", "b"]'); + + const result = list(str()).parse(expr, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual(['a', 'b']); + }); + + it('rejects an empty list when nonEmpty is set', () => { + const { expr, ctx } = argOf('[]'); + + const result = list(str(), { nonEmpty: true }).parse(expr, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure).toHaveLength(1); + }); + + it('accepts a populated list when nonEmpty is set', () => { + const { expr, ctx } = argOf('["a", "b"]'); + + const result = list(str(), { nonEmpty: true }).parse(expr, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual(['a', 'b']); + }); + + it('rejects duplicates when unique is set, anchored per offending element', () => { + const { expr, ctx } = argOf('["a", "a"]'); + + const result = list(str(), { unique: true }).parse(expr, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure).toHaveLength(1); + }); + + it('propagates an element parse error', () => { + const { expr, ctx } = argOf('["a", 1]'); + + const result = list(str()).parse(expr, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure[0]?.code).toBe('PSL_INVALID_ATTRIBUTE_SYNTAX'); + }); + + it('rejects a non-array argument', () => { + const { expr, ctx } = argOf('"a"'); + + const result = list(str()).parse(expr, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure).toHaveLength(1); + }); +}); + +describe('combinator code parity through interpretAttribute', () => { + it('emits a leaf diagnostic carrying the spec diagnostic code', () => { + const cursor = new Cursor('@rel(1)'); + const node = FieldAttributeAst.cast(createSyntaxTree(parseAttribute(cursor))); + if (!node) throw new Error('expected a field attribute'); + const ctx = makeCtx(cursor.sourceFile); + const spec = fieldAttribute('rel', { + positional: [{ key: 'name', type: str() }], + diagnosticCode: 'PSL_INVALID_RELATION_ATTRIBUTE', + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toHaveLength(1); + expect(result.failure[0]?.code).toBe('PSL_INVALID_RELATION_ATTRIBUTE'); + } + }); +}); diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test-d.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test-d.ts new file mode 100644 index 0000000000..454f639160 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test-d.ts @@ -0,0 +1,66 @@ +import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; +import { ok, type Result } from '@prisma-next/utils/result'; +import { expectTypeOf, test } from 'vitest'; +import type { ArgType, InferAttr } from '../src/exports'; +import { fieldAttribute, optional } from '../src/exports'; + +function leaf(kind: string, value: T): ArgType { + return { + kind, + label: kind, + parse: (): Result => ok(value), + }; +} + +const str = (): ArgType => leaf('str', ''); +const int = (): ArgType => leaf('int', 0); + +test('a required named param becomes a required property', () => { + const spec = fieldAttribute('demo', { named: { name: str() } }); + expectTypeOf>().toEqualTypeOf<{ name: string }>(); +}); + +test('an optional named param becomes an optional property', () => { + const spec = fieldAttribute('demo', { named: { name: optional(str()) } }); + expectTypeOf>().toEqualTypeOf<{ name?: string }>(); +}); + +test('mixed required and optional named params keep their modifiers', () => { + const spec = fieldAttribute('demo', { + named: { name: str(), count: optional(int()) }, + }); + expectTypeOf>().toEqualTypeOf<{ name: string; count?: number }>(); +}); + +test('a positional slot contributes its key into the same keyspace', () => { + const spec = fieldAttribute('demo', { + positional: [{ key: 'name', type: str() }], + }); + expectTypeOf>().toEqualTypeOf<{ name: string }>(); +}); + +test('an optional positional slot contributes an optional property', () => { + const spec = fieldAttribute('demo', { + positional: [{ key: 'name', type: optional(str()) }], + }); + expectTypeOf>().toEqualTypeOf<{ name?: string }>(); +}); + +test('a positional-or-named alias collapses to one property', () => { + const spec = fieldAttribute('demo', { + positional: [{ key: 'name', type: optional(str()) }], + named: { name: optional(str()), map: optional(str()) }, + }); + expectTypeOf>().toEqualTypeOf<{ name?: string; map?: string }>(); +}); + +test('a spec carrying a refine still infers its output', () => { + const spec = fieldAttribute('demo', { + named: { name: optional(str()) }, + refine: (parsed) => { + void parsed.name; + return []; + }, + }); + expectTypeOf>().toEqualTypeOf<{ name?: string }>(); +}); diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test.ts new file mode 100644 index 0000000000..b4e3a19a45 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test.ts @@ -0,0 +1,371 @@ +import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; +import { notOk, ok, type Result } from '@prisma-next/utils/result'; +import { describe, expect, it } from 'vitest'; +import type { ArgType, InterpretCtx } from '../src/exports'; +import { fieldAttribute, interpretAttribute, nodePslSpan, optional } from '../src/exports'; +import { Cursor, parse, parseAttribute } from '../src/parse'; +import type { SourceFile } from '../src/source-file'; +import { buildSymbolTable } from '../src/symbol-table'; +import { FieldAttributeAst } from '../src/syntax/ast/attributes'; +import { StringLiteralExprAst } from '../src/syntax/ast/expressions'; +import { createSyntaxTree } from '../src/syntax/red'; + +function makeCtx( + sourceFile: SourceFile, + diagnosticCode: PslDiagnostic['code'] = 'PSL_INVALID_ATTRIBUTE_SYNTAX', +): InterpretCtx { + const { document, sourceFile: modelSource } = parse('model M {\n id Int @id\n}\n'); + const { table } = buildSymbolTable({ + document, + sourceFile: modelSource, + scalarTypes: ['String', 'Int'], + pslBlockDescriptors: {}, + }); + const selfModel = table.topLevel.models.M; + if (!selfModel) throw new Error('expected model M in the symbol table'); + return { + level: 'field', + sourceId: 'schema.prisma', + sourceFile, + symbols: table, + selfModel, + resolveReferencedModel: () => undefined, + diagnosticCode, + }; +} + +function fieldAttr(source: string): { node: FieldAttributeAst; ctx: InterpretCtx } { + const cursor = new Cursor(source); + const node = FieldAttributeAst.cast(createSyntaxTree(parseAttribute(cursor))); + if (!node) throw new Error('expected a field attribute'); + return { node, ctx: makeCtx(cursor.sourceFile) }; +} + +/** Parses a quoted-string argument into its decoded value; otherwise fails purely. */ +function str(): ArgType { + return { + kind: 'str', + label: 'string', + parse: (arg, ctx): Result => { + if (arg instanceof StringLiteralExprAst) { + const value = arg.value(); + if (value !== undefined) return ok(value); + } + return notOk([ + { + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', + message: 'expected a quoted string', + sourceId: ctx.sourceId, + span: nodePslSpan(arg.syntax, ctx.sourceFile), + }, + ]); + }, + }; +} + +const FAILING_DIAGNOSTIC: PslDiagnostic = { + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', + message: 'this leaf always fails', + sourceId: 'schema.prisma', + span: { start: { offset: 0, line: 1, column: 1 }, end: { offset: 0, line: 1, column: 1 } }, +}; + +/** A leaf that always fails, returning its diagnostic in the Result rather than a sink. */ +function failing(): ArgType { + return { + kind: 'failing', + label: 'failing', + parse: (): Result => notOk([FAILING_DIAGNOSTIC]), + }; +} + +describe('interpretAttribute positional binding', () => { + it('binds a positional argument into its slot key', () => { + const { node, ctx } = fieldAttr('@rel("Posts")'); + const spec = fieldAttribute('rel', { positional: [{ key: 'name', type: str() }] }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual({ name: 'Posts' }); + }); + + it('rejects more positional arguments than declared slots', () => { + const { node, ctx } = fieldAttr('@rel("a", "b")'); + const spec = fieldAttribute('rel', { + positional: [{ key: 'name', type: optional(str()) }], + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toHaveLength(1); + expect(result.failure[0]?.code).toBe('PSL_INVALID_ATTRIBUTE_SYNTAX'); + expect(result.failure[0]?.span).toEqual(nodePslSpan(node.syntax, ctx.sourceFile)); + } + }); +}); + +describe('interpretAttribute named binding', () => { + it('binds named arguments by key', () => { + const { node, ctx } = fieldAttr('@rel(name: "Posts", map: "fk")'); + const spec = fieldAttribute('rel', { + named: { name: optional(str()), map: optional(str()) }, + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual({ name: 'Posts', map: 'fk' }); + }); + + it('rejects an unknown named argument anchored to the argument span', () => { + const { node, ctx } = fieldAttr('@rel(foo: "x")'); + const spec = fieldAttribute('rel', { + named: { name: optional(str()) }, + diagnosticCode: 'PSL_INVALID_RELATION_ATTRIBUTE', + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toHaveLength(1); + expect(result.failure[0]?.code).toBe('PSL_INVALID_RELATION_ATTRIBUTE'); + expect(result.failure[0]?.message).toContain('foo'); + expect(result.failure[0]?.span).not.toEqual(nodePslSpan(node.syntax, ctx.sourceFile)); + } + }); +}); + +describe('interpretAttribute positional-or-named duplicate', () => { + it('rejects a key supplied both positionally and by name even when the values agree', () => { + const { node, ctx } = fieldAttr('@rel("Foo", name: "Foo")'); + const spec = fieldAttribute('rel', { + positional: [{ key: 'name', type: optional(str()) }], + named: { name: optional(str()) }, + diagnosticCode: 'PSL_INVALID_RELATION_ATTRIBUTE', + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toHaveLength(1); + expect(result.failure[0]?.code).toBe('PSL_INVALID_RELATION_ATTRIBUTE'); + expect(result.failure[0]?.span).toEqual(nodePslSpan(node.syntax, ctx.sourceFile)); + } + }); + + it('rejects a key supplied both positionally and by name when the values disagree', () => { + const { node, ctx } = fieldAttr('@rel("A", name: "B")'); + const spec = fieldAttribute('rel', { + positional: [{ key: 'name', type: optional(str()) }], + named: { name: optional(str()) }, + diagnosticCode: 'PSL_INVALID_RELATION_ATTRIBUTE', + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toHaveLength(1); + expect(result.failure[0]?.code).toBe('PSL_INVALID_RELATION_ATTRIBUTE'); + expect(result.failure[0]?.span).toEqual(nodePslSpan(node.syntax, ctx.sourceFile)); + } + }); +}); + +describe('interpretAttribute duplicate named arguments', () => { + it('rejects a named key supplied twice with differing values, anchored to the duplicate', () => { + const { node, ctx } = fieldAttr('@rel(name: "A", name: "B")'); + const spec = fieldAttribute('rel', { + named: { name: optional(str()) }, + diagnosticCode: 'PSL_INVALID_RELATION_ATTRIBUTE', + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toHaveLength(1); + expect(result.failure[0]?.code).toBe('PSL_INVALID_RELATION_ATTRIBUTE'); + expect(result.failure[0]?.span).not.toEqual(nodePslSpan(node.syntax, ctx.sourceFile)); + } + }); + + it('rejects a named key supplied twice even when the values are equal', () => { + const { node, ctx } = fieldAttr('@rel(name: "A", name: "A")'); + const spec = fieldAttribute('rel', { + named: { name: optional(str()) }, + diagnosticCode: 'PSL_INVALID_RELATION_ATTRIBUTE', + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toHaveLength(1); + expect(result.failure[0]?.code).toBe('PSL_INVALID_RELATION_ATTRIBUTE'); + } + }); +}); + +describe('interpretAttribute optional and default application', () => { + it('applies a default for an absent optional argument', () => { + const { node, ctx } = fieldAttr('@rel()'); + const spec = fieldAttribute('rel', { + named: { map: optional(str(), 'default_fk') }, + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual({ map: 'default_fk' }); + }); + + it('omits an absent optional argument with no default', () => { + const { node, ctx } = fieldAttr('@rel()'); + const spec = fieldAttribute('rel', { named: { name: optional(str()) } }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual({}); + }); + + it('overrides a default when the argument is present', () => { + const { node, ctx } = fieldAttr('@rel(map: "explicit")'); + const spec = fieldAttribute('rel', { + named: { map: optional(str(), 'default_fk') }, + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual({ map: 'explicit' }); + }); + + it('reports a missing required argument', () => { + const { node, ctx } = fieldAttr('@rel()'); + const spec = fieldAttribute('rel', { named: { name: str() } }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure).toHaveLength(1); + }); +}); + +describe('interpretAttribute refine', () => { + it('runs refine on the parsed output and surfaces its diagnostics', () => { + const { node, ctx } = fieldAttr('@rel(name: "bad")'); + const seen: string[] = []; + const spec = fieldAttribute('rel', { + named: { name: optional(str()) }, + refine: (parsed, refineCtx): readonly PslDiagnostic[] => { + if (parsed.name !== undefined) seen.push(parsed.name); + return [ + { + code: 'PSL_INVALID_RELATION_ATTRIBUTE', + message: 'refine rejected the value', + sourceId: refineCtx.sourceId, + span: nodePslSpan(node.syntax, refineCtx.sourceFile), + }, + ]; + }, + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(seen).toEqual(['bad']); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toHaveLength(1); + expect(result.failure[0]?.message).toBe('refine rejected the value'); + } + }); + + it('returns ok when refine reports no diagnostics', () => { + const { node, ctx } = fieldAttr('@rel(name: "ok")'); + const spec = fieldAttribute('rel', { + named: { name: optional(str()) }, + refine: (): readonly PslDiagnostic[] => [], + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual({ name: 'ok' }); + }); + + it('does not run refine when an argument fails to parse', () => { + const { node, ctx } = fieldAttr('@rel(name: "x")'); + let refined = false; + const spec = fieldAttribute('rel', { + named: { name: failing() }, + refine: (): readonly PslDiagnostic[] => { + refined = true; + return []; + }, + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(refined).toBe(false); + expect(result.ok).toBe(false); + }); +}); + +describe('interpretAttribute leaf purity', () => { + it('threads a failing leaf diagnostic through the Result rather than a sink', () => { + const { node, ctx } = fieldAttr('@rel(name: "x")'); + const spec = fieldAttribute('rel', { named: { name: failing() } }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toEqual([FAILING_DIAGNOSTIC]); + } + }); +}); + +/** A leaf that emits a diagnostic carrying whatever code the context threads to it. */ +function codeEcho(): ArgType { + return { + kind: 'codeEcho', + label: 'codeEcho', + parse: (arg, ctx): Result => + notOk([ + { + code: ctx.diagnosticCode, + message: 'leaf emitted with the threaded code', + sourceId: ctx.sourceId, + span: nodePslSpan(arg.syntax, ctx.sourceFile), + }, + ]), + }; +} + +describe('interpretAttribute diagnostic-code threading', () => { + it('drives the leaf context code from the spec, overriding the caller baseline', () => { + const cursor = new Cursor('@rel(name: "x")'); + const node = FieldAttributeAst.cast(createSyntaxTree(parseAttribute(cursor))); + if (!node) throw new Error('expected a field attribute'); + const ctx = makeCtx(cursor.sourceFile, 'PSL_INVALID_ATTRIBUTE_SYNTAX'); + const spec = fieldAttribute('rel', { + named: { name: codeEcho() }, + diagnosticCode: 'PSL_INVALID_RELATION_ATTRIBUTE', + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toHaveLength(1); + expect(result.failure[0]?.code).toBe('PSL_INVALID_RELATION_ATTRIBUTE'); + } + }); +}); diff --git a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts index 3b26cd9c70..0147d1f272 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -101,9 +101,9 @@ import { applyBackrelationCandidates, type FkRelationMetadata, indexFkRelations, + interpretRelationAttribute, type ModelBackrelationCandidate, normalizeReferentialAction, - parseRelationAttribute, validateNavigationListFieldAttributes, } from './psl-relation-resolution'; @@ -446,6 +446,8 @@ interface BuildModelNodeInput { readonly generatorDescriptorById: ReadonlyMap; readonly scalarTypeDescriptors: ReadonlyMap; readonly sourceId: string; + readonly sourceFile: SourceFile; + readonly symbolTable: SymbolTable; readonly diagnostics: ContractSourceDiagnostic[]; /** Resolved namespace id keyed by model name — used to stamp the target namespace on FKs. */ readonly modelNamespaceIds: ReadonlyMap; @@ -525,10 +527,11 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult const relationAttribute = getAttribute(field.attributes, 'relation'); let relationName: string | undefined; if (relationAttribute) { - const parsedRelation = parseRelationAttribute({ - attribute: relationAttribute, - modelName: model.name, - fieldName: field.name, + const parsedRelation = interpretRelationAttribute({ + selfModel: model, + field, + symbols: input.symbolTable, + sourceFile: input.sourceFile, sourceId, diagnostics, }); @@ -553,7 +556,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult }); continue; } - relationName = parsedRelation.relationName; + relationName = parsedRelation.name; } if (!attributesValid) { continue; @@ -854,10 +857,11 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult continue; } - const parsedRelation = parseRelationAttribute({ - attribute: relationAttribute.relation, - modelName: model.name, - fieldName: relationAttribute.field.name, + const parsedRelation = interpretRelationAttribute({ + selfModel: model, + field: relationAttribute.field, + symbols: input.symbolTable, + sourceFile: input.sourceFile, sourceId, diagnostics, }); @@ -903,26 +907,10 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult } const onDelete = parsedRelation.onDelete - ? normalizeReferentialAction({ - modelName: model.name, - fieldName: relationAttribute.field.name, - actionName: 'onDelete', - actionToken: parsedRelation.onDelete, - sourceId, - span: relationAttribute.field.span, - diagnostics, - }) + ? normalizeReferentialAction(parsedRelation.onDelete) : undefined; const onUpdate = parsedRelation.onUpdate - ? normalizeReferentialAction({ - modelName: model.name, - fieldName: relationAttribute.field.name, - actionName: 'onUpdate', - actionToken: parsedRelation.onUpdate, - sourceId, - span: relationAttribute.field.span, - diagnostics, - }) + ? normalizeReferentialAction(parsedRelation.onUpdate) : undefined; // Target namespace: use the colon-prefix namespace qualifier, or `__unbound__` when the @@ -966,7 +954,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult namespaceId: crossTargetNamespaceId, spaceId: fieldTypeContractSpaceId, }, - ...ifDefined('name', parsedRelation.constraintName), + ...ifDefined('name', parsedRelation.map), ...ifDefined('onDelete', onDelete), ...ifDefined('onUpdate', onUpdate), }); @@ -1025,10 +1013,11 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult continue; } - const parsedRelation = parseRelationAttribute({ - attribute: relationAttribute.relation, - modelName: model.name, - fieldName: relationAttribute.field.name, + const parsedRelation = interpretRelationAttribute({ + selfModel: model, + field: relationAttribute.field, + symbols: input.symbolTable, + sourceFile: input.sourceFile, sourceId, diagnostics, }); @@ -1096,26 +1085,10 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult } const onDelete = parsedRelation.onDelete - ? normalizeReferentialAction({ - modelName: model.name, - fieldName: relationAttribute.field.name, - actionName: 'onDelete', - actionToken: parsedRelation.onDelete, - sourceId, - span: relationAttribute.field.span, - diagnostics, - }) + ? normalizeReferentialAction(parsedRelation.onDelete) : undefined; const onUpdate = parsedRelation.onUpdate - ? normalizeReferentialAction({ - modelName: model.name, - fieldName: relationAttribute.field.name, - actionName: 'onUpdate', - actionToken: parsedRelation.onUpdate, - sourceId, - span: relationAttribute.field.span, - diagnostics, - }) + ? normalizeReferentialAction(parsedRelation.onUpdate) : undefined; const targetNamespaceId = @@ -1130,7 +1103,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult columns: referencedColumns, ...ifDefined('namespaceId', targetNamespaceId), }, - ...ifDefined('name', parsedRelation.constraintName), + ...ifDefined('name', parsedRelation.map), ...ifDefined('onDelete', onDelete), ...ifDefined('onUpdate', onUpdate), }); @@ -1143,7 +1116,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult targetModelName: targetMapping.model.name, targetTableName: targetMapping.tableName, ...ifDefined('targetNamespaceId', targetNamespaceId), - ...ifDefined('relationName', parsedRelation.relationName), + ...ifDefined('relationName', parsedRelation.name), localColumns, referencedColumns, }); @@ -1927,6 +1900,8 @@ export function interpretPslDocumentToSqlContract( generatorDescriptorById, scalarTypeDescriptors: input.scalarTypeDescriptors, sourceId, + sourceFile, + symbolTable: input.symbolTable, diagnostics, modelNamespaceIds, ...(enumHandlesByName.size > 0 ? { enumHandles: enumHandlesByName } : {}), diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts index 9c8f8ddd7b..d276f6e53f 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts @@ -1,18 +1,31 @@ import type { ContractSourceDiagnostic } from '@prisma-next/config/config-types'; import type { AuthoringContributions } from '@prisma-next/framework-components/authoring'; -import type { FieldSymbol, PslSpan, ResolvedAttribute } from '@prisma-next/psl-parser'; +import type { + FieldSymbol, + InferAttr, + InterpretCtx, + ModelSymbol, + PslDiagnostic, + PslSpan, + SymbolTable, +} from '@prisma-next/psl-parser'; +import { + fieldAttribute, + fieldRef, + identifier, + interpretAttribute, + list, + nodePslSpan, + oneOf, + optional, + str, +} from '@prisma-next/psl-parser'; +import type { FieldAttributeAst, SourceFile } from '@prisma-next/psl-parser/syntax'; import type { ReferentialAction } from '@prisma-next/sql-contract/types'; import type { RelationNode } from '@prisma-next/sql-contract-ts/contract-builder'; import { assertDefined, invariant } from '@prisma-next/utils/assertions'; import { ifDefined } from '@prisma-next/utils/defined'; -import { - getNamedArgument, - getPositionalArgumentEntry, - parseFieldList, - parseQuotedStringLiteral, - unquoteStringLiteral, -} from './psl-attribute-parsing'; import { checkUncomposedNamespace, reportUncomposedNamespace } from './psl-column-resolution'; export const REFERENTIAL_ACTION_MAP: Record = { @@ -28,15 +41,6 @@ export const REFERENTIAL_ACTION_MAP: Record arg.kind === 'positional'); - if (positionalEntries.length > 1) { - input.diagnostics.push({ - code: 'PSL_INVALID_RELATION_ATTRIBUTE', - message: `Relation field "${input.modelName}.${input.fieldName}" has too many positional arguments`, - sourceId: input.sourceId, - span: input.attribute.span, - }); - return undefined; +/** + * Cross-argument rules the engine cannot express through per-argument leaves: + * `fields` and `references` are both-or-neither. Anchored to the attribute span + * with the legacy code, preserving codes-and-spans parity for that error path. + */ +function relationInvariants( + parsed: { readonly fields?: readonly string[]; readonly references?: readonly string[] }, + ctx: InterpretCtx, +): readonly PslDiagnostic[] { + const hasFields = parsed.fields !== undefined; + const hasReferences = parsed.references !== undefined; + if (hasFields !== hasReferences) { + return [ + { + code: 'PSL_INVALID_RELATION_ATTRIBUTE', + message: `Relation field "${ctx.selfModel.name}.${ctx.field?.name ?? ''}" requires fields and references arguments`, + sourceId: ctx.sourceId, + span: relationAttributeSpan(ctx), + }, + ]; } + return []; +} - let relationNameFromPositional: string | undefined; - const positionalNameEntry = getPositionalArgumentEntry(input.attribute); - if (positionalNameEntry) { - const parsedName = parseQuotedStringLiteral(positionalNameEntry.value); - if (!parsedName) { - input.diagnostics.push({ - code: 'PSL_INVALID_RELATION_ATTRIBUTE', - message: `Relation field "${input.modelName}.${input.fieldName}" positional relation name must be a quoted string literal`, - sourceId: input.sourceId, - span: positionalNameEntry.span, - }); - return undefined; +/** + * Declarative replacement for the hand-written `@relation` parser. The engine + * binds the positional-or-named `name`, the `fields`/`references` field lists, + * `map`, and the bare-identifier referential actions; `relationInvariants` + * holds the both-or-neither rule. The action set is validated at parse via + * `oneOf` over one `identifier` per action, which accepts the bare identifier + * and rejects an unknown action with the attribute's own code; + * `normalizeReferentialAction` then maps the validated token to its contract action. + */ +const sqlRelation = fieldAttribute('relation', { + positional: [{ key: 'name', type: optional(str()) }], + named: { + name: optional(str()), + fields: optional(list(fieldRef('self'), { nonEmpty: true, unique: true })), + references: optional(list(fieldRef('referenced'), { nonEmpty: true, unique: true })), + map: optional(str()), + onDelete: optional( + oneOf( + identifier('NoAction'), + identifier('Restrict'), + identifier('Cascade'), + identifier('SetNull'), + identifier('SetDefault'), + ), + ), + onUpdate: optional( + oneOf( + identifier('NoAction'), + identifier('Restrict'), + identifier('Cascade'), + identifier('SetNull'), + identifier('SetDefault'), + ), + ), + }, + refine: relationInvariants, + diagnosticCode: 'PSL_INVALID_RELATION_ATTRIBUTE', +}); + +export type SqlRelationOutput = InferAttr; + +function findRelationAttributeNode(field: FieldSymbol): FieldAttributeAst | undefined { + for (const attribute of field.node.attributes()) { + if (attribute.name()?.path().join('.') === 'relation') { + return attribute; } - relationNameFromPositional = parsedName; } + return undefined; +} - for (const arg of input.attribute.args) { - if (arg.kind === 'positional') { - continue; - } - if ( - arg.name !== 'name' && - arg.name !== 'fields' && - arg.name !== 'references' && - arg.name !== 'map' && - arg.name !== 'onDelete' && - arg.name !== 'onUpdate' - ) { - input.diagnostics.push({ - code: 'PSL_INVALID_RELATION_ATTRIBUTE', - message: `Relation field "${input.modelName}.${input.fieldName}" has unsupported argument "${arg.name}"`, - sourceId: input.sourceId, - span: arg.span, - }); - return undefined; +function relationAttributeSpan(ctx: InterpretCtx): PslSpan { + const field = ctx.field; + if (field !== undefined) { + const node = findRelationAttributeNode(field); + if (node !== undefined) { + return nodePslSpan(node.syntax, ctx.sourceFile); } + return field.span; } + return ctx.selfModel.span; +} - const namedRelationNameRaw = getNamedArgument(input.attribute, 'name'); - const namedRelationName = namedRelationNameRaw - ? parseQuotedStringLiteral(namedRelationNameRaw) - : undefined; - if (namedRelationNameRaw && !namedRelationName) { - input.diagnostics.push({ - code: 'PSL_INVALID_RELATION_ATTRIBUTE', - message: `Relation field "${input.modelName}.${input.fieldName}" named relation name must be a quoted string literal`, - sourceId: input.sourceId, - span: input.attribute.span, - }); - return undefined; +function resolveReferencedModel(symbols: SymbolTable, field: FieldSymbol): ModelSymbol | undefined { + const topLevel = symbols.topLevel.models[field.typeName]; + if (topLevel !== undefined) { + return topLevel; } - - if ( - relationNameFromPositional && - namedRelationName && - relationNameFromPositional !== namedRelationName - ) { - input.diagnostics.push({ - code: 'PSL_INVALID_RELATION_ATTRIBUTE', - message: `Relation field "${input.modelName}.${input.fieldName}" has conflicting positional and named relation names`, - sourceId: input.sourceId, - span: input.attribute.span, - }); - return undefined; + for (const namespace of Object.values(symbols.topLevel.namespaces)) { + const model = namespace.models[field.typeName]; + if (model !== undefined) { + return model; + } } - const relationName = namedRelationName ?? relationNameFromPositional; + return undefined; +} - const constraintNameRaw = getNamedArgument(input.attribute, 'map'); - const constraintName = constraintNameRaw - ? parseQuotedStringLiteral(constraintNameRaw) - : undefined; - if (constraintNameRaw && !constraintName) { - input.diagnostics.push({ - code: 'PSL_INVALID_RELATION_ATTRIBUTE', - message: `Relation field "${input.modelName}.${input.fieldName}" map argument must be a quoted string literal`, - sourceId: input.sourceId, - span: input.attribute.span, - }); - return undefined; - } +function buildRelationInterpretCtx(input: { + readonly selfModel: ModelSymbol; + readonly field: FieldSymbol; + readonly symbols: SymbolTable; + readonly sourceFile: SourceFile; + readonly sourceId: string; +}): InterpretCtx { + return { + level: 'field', + diagnosticCode: 'PSL_INVALID_RELATION_ATTRIBUTE', + sourceId: input.sourceId, + sourceFile: input.sourceFile, + symbols: input.symbols, + selfModel: input.selfModel, + field: input.field, + resolveReferencedModel: () => resolveReferencedModel(input.symbols, input.field), + }; +} - const fieldsRaw = getNamedArgument(input.attribute, 'fields'); - const referencesRaw = getNamedArgument(input.attribute, 'references'); - if ((fieldsRaw && !referencesRaw) || (!fieldsRaw && referencesRaw)) { - input.diagnostics.push({ - code: 'PSL_INVALID_RELATION_ATTRIBUTE', - message: `Relation field "${input.modelName}.${input.fieldName}" requires fields and references arguments`, - sourceId: input.sourceId, - span: input.attribute.span, - }); +/** + * Validates and lowers a field's `@relation` attribute through the declarative + * `sqlRelation` spec, returning the spec's inferred output directly. The engine + * aggregates every error path's diagnostics; like the previous first-error + * parser, the caller skips the field when this returns `undefined`. + */ +export function interpretRelationAttribute(input: { + readonly selfModel: ModelSymbol; + readonly field: FieldSymbol; + readonly symbols: SymbolTable; + readonly sourceFile: SourceFile; + readonly sourceId: string; + readonly diagnostics: ContractSourceDiagnostic[]; +}): SqlRelationOutput | undefined { + const attributeNode = findRelationAttributeNode(input.field); + if (attributeNode === undefined) { return undefined; } - - let fields: readonly string[] | undefined; - let references: readonly string[] | undefined; - if (fieldsRaw && referencesRaw) { - const parsedFields = parseFieldList(fieldsRaw); - const parsedReferences = parseFieldList(referencesRaw); - if ( - !parsedFields || - !parsedReferences || - parsedFields.length === 0 || - parsedReferences.length === 0 - ) { - input.diagnostics.push({ - code: 'PSL_INVALID_RELATION_ATTRIBUTE', - message: `Relation field "${input.modelName}.${input.fieldName}" requires bracketed fields and references lists`, - sourceId: input.sourceId, - span: input.attribute.span, - }); - return undefined; + const ctx = buildRelationInterpretCtx(input); + const result = interpretAttribute(attributeNode, sqlRelation, ctx); + if (!result.ok) { + for (const failure of result.failure) { + input.diagnostics.push(failure); } - fields = parsedFields; - references = parsedReferences; + return undefined; } - - const onDeleteArgument = getNamedArgument(input.attribute, 'onDelete'); - const onUpdateArgument = getNamedArgument(input.attribute, 'onUpdate'); - - return { - ...ifDefined('relationName', relationName), - ...ifDefined('fields', fields), - ...ifDefined('references', references), - ...ifDefined('constraintName', constraintName), - ...ifDefined('onDelete', onDeleteArgument ? unquoteStringLiteral(onDeleteArgument) : undefined), - ...ifDefined('onUpdate', onUpdateArgument ? unquoteStringLiteral(onUpdateArgument) : undefined), - }; + return result.value; } export function indexFkRelations(input: { diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts index d335fac908..cc87086e87 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts @@ -312,7 +312,7 @@ model Post { expect(result.failure.diagnostics).toEqual( expect.arrayContaining([ expect.objectContaining({ - code: 'PSL_UNSUPPORTED_REFERENTIAL_ACTION', + code: 'PSL_INVALID_RELATION_ATTRIBUTE', sourceId: 'schema.prisma', }), ]), @@ -346,10 +346,8 @@ model Post { expect(result.failure.diagnostics).toEqual( expect.arrayContaining([ expect.objectContaining({ - code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', - message: expect.stringContaining( - 'Relation field "Post.user" references unknown field "Post.missingUserId"', - ), + code: 'PSL_INVALID_RELATION_ATTRIBUTE', + message: expect.stringContaining('Field "missingUserId" does not exist on model "Post"'), }), ]), ); @@ -382,10 +380,42 @@ model Post { expect(result.failure.diagnostics).toEqual( expect.arrayContaining([ expect.objectContaining({ - code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', - message: expect.stringContaining( - 'Relation field "Post.user" references unknown field "User.missingId"', - ), + code: 'PSL_INVALID_RELATION_ATTRIBUTE', + message: expect.stringContaining('Field "missingId" does not exist on model "User"'), + }), + ]), + ); + }); + + it('returns diagnostics when relation fields repeats a column name', () => { + const document = symbolTableInputFromParseArgs({ + schema: `model User { + id Int @id +} + +model Post { + id Int @id + userId Int + user User @relation(fields: [userId, userId], references: [id]) +} +`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.summary).toBe('PSL to SQL contract interpretation failed'); + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_INVALID_RELATION_ATTRIBUTE', + message: expect.stringContaining('Duplicate'), }), ]), ); diff --git a/projects/typed-attribute-parsers/plan.md b/projects/typed-attribute-parsers/plan.md new file mode 100644 index 0000000000..ec0b873fa4 --- /dev/null +++ b/projects/typed-attribute-parsers/plan.md @@ -0,0 +1,46 @@ +# typed-attribute-parsers — Plan + +**Spec:** `projects/typed-attribute-parsers/spec.md` +**Linear issue:** [TML-2956](https://linear.app/prisma-company/issue/TML-2956) (under project _Language Tools Support Prisma Next PSL_) + +## At a glance + +Three slices in a substrate-then-consumers shape: slice 1 lands the combinator kit + `interpretAttribute` in `psl-parser`, proven by migrating `@relation` end-to-end in the SQL family; slices 2 and 3 then migrate the remaining SQL and Mongo attributes respectively, in parallel, each deleting its family's legacy parsing helpers. + +## Composition + +### Stack (deliver in order) + +1. **Slice `attribute-spec-kit`** — Linear: _TBD_ + - **Outcome:** The combinator kit (`str`, `int`, `bool`, `enumOf`, `json`, `entityRef`, `fieldRef`, `codecRef`, `list`, `map`, `record`, `oneOf`, `funcCall`/`funcCallFrom`), `AttributeSpec`, the three constructors (`fieldAttribute`/`modelAttribute`/`blockAttribute`), `interpretAttribute`, `InferAttr`, and the `InterpretCtx` contract exist in `psl-parser`, consuming the parser's `ExpressionAst` directly. `@relation` in the **SQL** family is validated and lowered via a spec through `interpretAttribute`, producing byte-identical contract output and identical diagnostics to the hand-written path it replaces. + - **Builds on:** None. + - **Hands to:** (a) the kit + `interpretAttribute` + `InferAttr` API exported from `psl-parser`; (b) the `InterpretCtx` wiring recipe — how a family interpreter assembles `SymbolTable` / declaring model / referenced-model resolver / declaring field / codec lookup / default-fn registry at an attribute call site; (c) the migration recipe — route a call site from `readResolvedArgList` + string helpers to `interpretAttribute(cstNode, spec, ctx)`, then retire the now-dead helper. + - **Focus:** The generic engine and exactly one representative attribute (`@relation` — the richest: positional+named alias, `fieldRef('self')`/`fieldRef('referenced')` scopes, `enumOf` actions, `list` with `nonEmpty`, and a `refine` for the both-or-neither rule). The remaining SQL attributes and all Mongo attributes are deliberately left to slices 2 and 3. No language-server consumer (project non-goal). + +### Parallel group A (builds on slice 1; independent of group B) + +- **Slice `sql-attributes`** — Linear: _TBD_ + - **Outcome:** Every remaining field-, model-, and block-level attribute the **SQL** family interprets — `@id`/`@@id`, `@unique`/`@@unique`, `@@index`, `@default`, `@map`/`@@map`, `@@control`, `@@discriminator`, `@@base` — is described by a spec and lowered via `interpretAttribute`. The SQL family's hand-written argument-parsing helpers (`psl-attribute-parsing.ts` string parsers, and the per-attribute `getNamedArgument`/`getPositionalArgument` re-parsing in `interpreter.ts`, `psl-field-resolution.ts`, `psl-relation-resolution.ts`) are deleted for every migrated attribute. + - **Builds on:** Slice 1's kit API + `InterpretCtx` wiring recipe + migration recipe. + - **Hands to:** SQL family fully spec-driven; no legacy SQL attribute-argument parser remains (grep gate). + - **Focus:** SQL family only (`packages/2-sql/2-authoring/contract-psl`). The model-level aggregation that enforces cross-attribute rules stays untouched (spec decision); only single-attribute cross-argument rules move into each spec's `refine`. `@db.*` native types remain out of scope. + +### Parallel group B (builds on slice 1; independent of group A) + +- **Slice `mongo-attributes`** — Linear: _TBD_ + - **Outcome:** Every field-, model-, and block-level attribute the **Mongo** family interprets — `@id`, `@unique`/`@@unique`, `@@index`, `@@textIndex`, `@relation`, `@map`/`@@map`, `@@discriminator`, `@@base` — is described by a spec and lowered via `interpretAttribute`, including the mixed string/number index `type` as a single `enumOf` and the index-element `oneOf`. The Mongo family's hand-written helpers (`psl-helpers.ts` parsers, `parseIndexFieldList`, the local `parseRelationAttribute`) are deleted for every migrated attribute. + - **Builds on:** Slice 1's kit API + `InterpretCtx` wiring recipe + migration recipe. + - **Hands to:** Mongo family fully spec-driven; no legacy Mongo attribute-argument parser remains (grep gate). + - **Focus:** Mongo family only (`packages/2-mongo-family/2-authoring/contract-psl`). Mongo migrates its own `@relation` spec (distinct value shapes from SQL's). The "at most one `@@textIndex` per collection" rule stays in Mongo's existing model-level aggregation, not in a per-attribute `refine` (spec decision). + - **Carry-in from slice 1 (D6):** `enumOf` was **removed**; enums are now `oneOf` over per-member matchers (`identifier(name)` for bare identifiers; pinned `str(value)` / `num(value)` for literals). Mongo's index `type` set (`1`, `-1`, `"text"`, `"2dsphere"`, `"2d"`, `"hashed"`) becomes `oneOf(num(1), num(-1), str('text'), str('2dsphere'), str('2d'), str('hashed'))`. Slice 1 built `oneOf` + `identifier`; **slice 3 builds the pinned `str(value)` / `num(value)` forms** (their first consumer is this index-type set — digit-leading members like `"2dsphere"` can't be bare identifiers, so they're quoted-string literals). + +## Dependencies (external) + +- [x] **Linear tracking** — single umbrella issue [TML-2956](https://linear.app/prisma-company/issue/TML-2956) under the _Language Tools Support Prisma Next PSL_ project, assigned to @tatarintsev. (Per-slice sub-issues not yet created; the operator opted for one umbrella ticket over a Linear Project + three sub-issues.) +- [x] **ADR 231 — Declarative attribute specifications** — settled (`Proposed`); this project is its first implementation and advances its status at close-out. + +## Sequencing rationale + +Slice 1 is the substrate every consumer depends on, so it must land first — this is the migration-shaped "substrate change → consumer migration" pattern, which always serialises at the substrate boundary. `@relation` is folded into slice 1 (rather than a pure kit-only slice) so the slice is *Valuable* on its own: it ships a working consumer and proves the seam end-to-end, not "preparation for slice 2." + +Slices 2 and 3 run in parallel because they touch **disjoint family packages** (`packages/2-sql` vs `packages/2-mongo-family`) and share no mutable surface beyond slice 1's already-merged kit — the "different operation families parallelise well" heuristic. Neither consumes the other's hand-off. Serializing them would forfeit throughput the dependency graph permits. diff --git a/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/02-combinators.md b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/02-combinators.md new file mode 100644 index 0000000000..be8b5bad0d --- /dev/null +++ b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/02-combinators.md @@ -0,0 +1,47 @@ +# Brief: D2 — the `@relation` combinators + +> Implementer note: you are a **fresh** implementer (the prior D1 implementer's session became inaccessible). You have no project transcript — read the context paths below, especially the on-disk D1 engine, before editing. + +## Context paths (read before editing) +- **The D1 engine you build on** (committed, on disk): `packages/1-framework/2-authoring/psl-parser/src/attribute-spec/` — read `types.ts`, `interpret.ts`, `optional.ts`, `field-attribute.ts`, and exports in `src/exports/index.ts`. Tests: `packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test.ts` + `attribute-spec.test-d.ts`. +- Slice spec: `projects/typed-attribute-parsers/slices/attribute-spec-kit/spec.md`; slice plan §Dispatch 2: `projects/typed-attribute-parsers/slices/attribute-spec-kit/plan.md`. +- ADR 231: `docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md`. +- CST types exported from `packages/1-framework/2-authoring/psl-parser/src/exports/syntax.ts` (`StringLiteralExprAst`, `NumberLiteralExprAst`, `ArrayLiteralAst`, `IdentifierAst`, `ExpressionAst`, …). Span helper `nodePslSpan(node, sourceFile)` in `src/resolve.ts`. Diagnostics are `PslDiagnostic`; failure channel `Result` from `@prisma-next/utils/result`. + +Engine facts (verify against the code): `ArgType { kind; label; _out?; parse(arg: ExpressionAst, ctx: InterpretCtx): Result }`. `InterpretCtx` currently `{ level, sourceId, sourceFile, symbols, selfModel, resolveReferencedModel(), field? }`. `AttributeSpec` has `diagnosticCode?` (defaults `PSL_INVALID_ATTRIBUTE_SYNTAX`). + +## Task +Author the four domain combinators `@relation` needs, as `ArgType`s over `ExpressionAst`, in a new module beside the engine (e.g. `src/attribute-spec/combinators/`); export each from the package public surface; unit-test each: + +- **`str()`** — `StringLiteralExprAst` → string value; non-string-literal → diagnostic. +- **`enumOf(...values)`** — `StringLiteralExprAst` or `NumberLiteralExprAst` whose value is a member of the fixed set (members may be mixed string/number per ADR 231); non-member / wrong-token → diagnostic. Build it generically; whether `@relation` uses it for `onDelete`/`onUpdate` is a D3 wiring decision. +- **`fieldRef(scope)`**, scope `'self' | 'referenced'` — bare `IdentifierAst` → the field **name string**. **Do NOT resolve or validate field existence at parse time** — the SQL interpreter validates existence downstream; a parse-time check would emit new diagnostics and break `@relation` parity. Carry `scope` as combinator metadata (for the future language server); the parsed value is just the name. Non-identifier → diagnostic. +- **`list(of, opts?)`**, `opts?: { nonEmpty?: boolean; unique?: boolean }` — reads an `ArrayLiteralAst`, maps each element through the element `ArgType` `of`, returns `T[]`; `nonEmpty` → diagnostic on empty; `unique` → diagnostic on duplicates; non-array → diagnostic. Build `unique` too (slices 2–3 need it). + +## Codes parity (load-bearing) +Diagnostic **codes** must stay identical; legacy `@relation` errors all use `PSL_INVALID_RELATION_ATTRIBUTE`. Leaf-emitted diagnostics must carry the **attribute's** code, not a hard-coded generic. Thread the spec's `diagnosticCode` to the leaves — cleanest shape: add `diagnosticCode` to `InterpretCtx` and have `interpretAttribute` populate it from the spec before calling any leaf's `parse`, so each combinator emits with `ctx.diagnosticCode`. Pick the cleanest shape against the D1 engine; name it in your report (it's the D3 hand-off). Leaf-diagnostic spans anchor to the offending element/arg node via `nodePslSpan(node, ctx.sourceFile)`. + +## Scope +**In:** the four combinators + their unit tests; the `diagnosticCode` threading (or equivalent); exports in `src/exports/`. +**Out:** ANY interpreter change and the `sqlRelation` spec itself (that's D3); the rest of ADR 231's alphabet (`int`, `bool`, `json`, `map`, `record`, `entityRef`, `codecRef`, `oneOf`, `funcCall`, `modelAttribute`, `blockAttribute`); `@db.*`; field-existence resolution. + +## Completed when +- [ ] `str`, `enumOf`, `fieldRef`, `list` exported from `psl-parser` and usable as `Param`s in an `AttributeSpec`. +- [ ] Unit tests per combinator: parse success + each diagnostic path; `enumOf` covers a mixed string/number set; `list` covers `nonEmpty` + `unique` + element-error propagation; `fieldRef` returns the name and emits NO existence diagnostic. +- [ ] A test proves a leaf diagnostic carries the attribute's `diagnosticCode` end-to-end through `interpretAttribute`. +- [ ] Gate green: `pnpm --filter @prisma-next/psl-parser typecheck && pnpm --filter @prisma-next/psl-parser test && pnpm --filter @prisma-next/psl-parser lint`. + +## Standing instruction +Stay focused on the goal; control scope. Trivial-and-related fixes that serve the goal go in with a one-line note in your wrap-up; anything pulling you off the goal halts and surfaces. + +## Constraints +- No `any`; no bare `as` (narrow `blindCast`/`castAs` from `@prisma-next/utils/casts` with a reason, or types that avoid the cast); arktype not zod — where a leaf reduces to a context-free value check (`enumOf`'s literal set), ADR 231 suggests backing it with an arktype `Type`; use judgment for a small fixed set vs a plain membership check, and note the choice; no file-extension imports; tests-first. +- Explicit-staging commits (`git add `, never `-A`/`.`); no amend; **no push**. +- Read-only on `projects/typed-attribute-parsers/reviews/**`, `spec.md`, plan files. +- Run the "no transient project IDs in code" scan on your `+` diff before declaring done. + +## Operational metadata +- **Model tier:** mid (routine combinators against a settled contract). +- **Halt conditions:** a combinator can't emit code-parity diagnostics without an engine change you can't make cleanly; the diff drifts into interpreter / `sqlRelation` territory (that's D3); an `ExpressionAst` shape you need isn't exported. + +Return the structured report per your persona's § Return shape; note the `diagnosticCode`-threading shape you landed and the final exported combinator signatures (the D3 hand-off). diff --git a/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/03-migrate-relation.md b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/03-migrate-relation.md new file mode 100644 index 0000000000..1c364b3db4 --- /dev/null +++ b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/03-migrate-relation.md @@ -0,0 +1,66 @@ +# Brief: D3 — migrate SQL `@relation` to a spec; delete `parseRelationAttribute` + +> Fresh implementer (session resume is unavailable). Read the context paths first; all prior work is committed. + +## Context paths (read before editing) +- **The kit you consume** (committed): `packages/1-framework/2-authoring/psl-parser/src/attribute-spec/` — `types.ts` (`ArgType`, `AttributeSpec`, `Param`, `InterpretCtx`, `InferAttr`), `interpret.ts` (`interpretAttribute`), `field-attribute.ts` (`fieldAttribute`), `optional.ts`, `combinators/` (`str`, `enumOf`, `fieldRef`, `list`). All exported from `@prisma-next/psl-parser` (`src/exports/index.ts`). +- **The code you replace:** `packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts` — `parseRelationAttribute` (the hand-written parser) and `normalizeReferentialAction` (KEEP this — it stays the referential-action validator). Read both in full. +- **The call sites:** `packages/2-sql/2-authoring/contract-psl/src/interpreter.ts` — three `parseRelationAttribute({ attribute, modelName, fieldName, sourceId, diagnostics })` calls (around the `buildModelNodeFromPsl` relation paths). Also `psl-field-resolution.ts` / `psl-relation-resolution.ts` `validateNavigationListFieldAttributes` for surrounding context. +- Slice spec (esp. § Resolved decisions): `projects/typed-attribute-parsers/slices/attribute-spec-kit/spec.md`. ADR 231. +- The interpreter receives `symbolTable: SymbolTable` + `sourceFile: SourceFile` (see `InterpretPslDocumentToSqlContractInput`) — so the CST attribute node and everything `InterpretCtx` needs is in reach. Diagnostics here are `ContractSourceDiagnostic` (`{ code, message, sourceId, span }`); `PslDiagnostic` has the same shape — map between them at the call site as needed. + +## Task +Replace the hand-written `parseRelationAttribute` with a declarative `sqlRelation` `AttributeSpec` lowered through `interpretAttribute`, preserving **byte-identical diagnostic codes and spans** for every `@relation` error path (message text may change per the project parity bar). Then delete `parseRelationAttribute` and any helper it alone used. + +### The spec +Define `sqlRelation` (e.g. in a new `src/attribute-specs.ts` or co-located in `psl-relation-resolution.ts`): +``` +fieldAttribute('relation', { + positional: [{ key: 'name', type: optional(str()) }], // positional-or-named alias for name + named: { + name: optional(str()), + fields: optional(list(fieldRef('self'), { nonEmpty: true })), + references: optional(list(fieldRef('referenced'), { nonEmpty: true })), + map: optional(str()), + onDelete: optional(), + onUpdate: optional(), + }, + refine: relationInvariants, + diagnosticCode: 'PSL_INVALID_RELATION_ATTRIBUTE', +}) +``` +- **`onDelete`/`onUpdate` (resolved decision):** these are **bare identifiers** (`onDelete: Cascade`), and the action set is validated **downstream** by the existing `normalizeReferentialAction` (which emits `PSL_UNSUPPORTED_REFERENTIAL_ACTION`). Do NOT use `enumOf` for them (it would change the code at parse time and break parity). Parse them to the **raw identifier name string** (no set check) and route the result to `normalizeReferentialAction` unchanged. If the kit has no bare-identifier-name leaf, add a small one in `psl-parser` combinators (reads an `IdentifierAst` → its name, no validation; analogous to `fieldRef` minus the scope) and export it; unit-test it. +- **`refine: relationInvariants`** holds the cross-argument rules: `fields`/`references` both-or-neither (legacy code `PSL_INVALID_RELATION_ATTRIBUTE`, anchored to the attribute span). The positional-vs-named `name` conflict is handled by the **engine's alias mechanism** (already built) — verify it emits with `diagnosticCode` + attribute-span; if its span/anchoring diverges from legacy, reconcile. +- The interpreted output (`InferAttr` = `{ name?, fields?, references?, map?, onDelete?, onUpdate? }`) is mapped at the call site to today's `ParsedRelationAttribute` (`name → relationName`, `map → constraintName`, rest 1:1). + +### InterpretCtx assembly +At each call site, build an `InterpretCtx` from interpreter state: `level: 'field'`, `sourceId`, `sourceFile`, `symbols` (the SymbolTable), `selfModel` (the declaring model symbol), `resolveReferencedModel()` (the relation's target model — use the field's type name to resolve, as the interpreter already does elsewhere), optional `field`, and a baseline `diagnosticCode` (the engine overrides it from the spec). Factor the assembly into a small helper if it's repeated across the three call sites. + +## Parity reconciliation (the load-bearing carry-overs) +Verify against the diagnostics + relations fixtures/tests and reconcile: +1. **Codes + spans byte-identical** for every `@relation` error path: positional-name-not-a-string, named-name-not-a-string, conflicting names, unknown argument, fields-xor-references, empty/non-bracketed fields or references, map-not-a-string, too-many-positional, bad referential action. For each, confirm the legacy code + span are reproduced. Where the engine anchors a span differently than legacy, prefer adjusting the spec/call-site; a minimal, noted engine span tweak is acceptable only if unavoidable. +2. **Aggregate-all vs first-error:** the engine returns ALL diagnostics; legacy returned on the FIRST error (then the caller skipped the field). If a fixture has a `@relation` with multiple simultaneous errors, the diagnostic SET may grow. If you find such a case, **halt and surface** the specific fixture delta rather than silently rewriting it — the orchestrator decides whether the richer diagnostics are an acceptable, intentional fixture update. +3. **Duplicate named args:** the engine silently drops duplicates (no diagnostic). Confirm this matches legacy `@relation` behaviour (legacy used `getNamedArgument` = first match) or that the upstream parser already rejects duplicates. Note the finding. + +## Scope +**In:** `sqlRelation` spec; route the three `@relation` call sites through `interpretAttribute` + map the output to `ParsedRelationAttribute`; `InterpretCtx` assembly helper; delete `parseRelationAttribute` and any now-dead helper it alone used (check `getPositionalArgumentEntry`, `parseFieldList`, etc. — delete only if `@relation` was their sole caller; otherwise leave for slice 2); add the bare-identifier-name leaf to `psl-parser` if needed. Keep `normalizeReferentialAction`. +**Out:** all other SQL attributes (`@id`, `@unique`, `@@index`, `@default`, `@map`, `@@control`, `@@discriminator`, `@@base`) — slice 2; Mongo — slice 3; the rest of ADR 231's alphabet; `@db.*`. + +## Completed when +- [ ] `@relation` is validated + lowered via `interpretAttribute(sqlRelation)`; `parseRelationAttribute` deleted. +- [ ] `rg "parseRelationAttribute"` returns zero results (outside this brief's own text). +- [ ] Diagnostic **codes + spans** byte-identical for every `@relation` error path (verified against `interpreter.relations.test.ts`, `interpreter.relations.many-to-many.test.ts`, `interpreter.diagnostics.test.ts`). +- [ ] Gate green: `pnpm --filter @prisma-next/contract-psl-sql test` (or the package's actual name — confirm via its `package.json`); `pnpm fixtures:check`; and after `pnpm --filter @prisma-next/psl-parser build`, a workspace `pnpm typecheck` (cross-package consumer check, since `psl-parser`'s exported types changed). `pnpm --filter @prisma-next/psl-parser test` + lint if you added the bare-identifier leaf. + +## Standing instruction +Stay focused on the goal; control scope. Trivial-and-related fixes serving the goal go in with a one-line note; anything pulling you off the goal — especially migrating a second attribute — halts and surfaces. + +## Constraints +- No `any`; no bare `as` (narrow `blindCast`/`castAs` with reason, or types that avoid it); no file-extension imports; no reexport outside `exports/`; tests-first where you add new kit surface. +- Explicit-staging commits, no amend, **no push**. Read-only on `projects/typed-attribute-parsers/reviews/**`, `spec.md`, plan files. Run the transient-ID scan on your `+` diff. + +## Operational metadata +- **Model tier:** thorough (parity-critical, judgment-heavy migration across packages). +- **Halt conditions:** a fixture's `@relation` diagnostic SET changes (aggregate-all case — surface it, decision #2 above); a span can't be reproduced without a non-trivial engine change; deleting a helper would break a non-`@relation` caller (leave it, note it for slice 2); the diff drifts into a second attribute. + +Return the structured report per § Return shape; explicitly report the parity verification (each error path: code + span identical?), the duplicate-named-arg finding, and any fixture delta you surfaced. diff --git a/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/04-address-review-r1.md b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/04-address-review-r1.md new file mode 100644 index 0000000000..f00387a0b1 --- /dev/null +++ b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/04-address-review-r1.md @@ -0,0 +1,57 @@ +# Brief: D4 — address PR #891 review round 1 + +> Fresh implementer (session resume unavailable). Read the committed kit + the commented files first. This addresses a review round on the open slice-1 PR; changes land on branch `tml-2956-typed-attribute-parsers` and update the PR. **Do NOT post to GitHub or resolve review threads** — only push commits. + +## Context +- PR #891 (slice 1). Reviewers: CodeRabbit (bot) + the operator (SevInf). Files: `packages/1-framework/2-authoring/psl-parser/src/attribute-spec/` (engine + combinators), `packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts` (the `sqlRelation` spec), `interpreter.ts`. +- Slice spec + the project parity bar: `projects/typed-attribute-parsers/slices/attribute-spec-kit/spec.md`, `projects/typed-attribute-parsers/spec.md` (codes byte-identical; spans no-coarser; messages may change; stricter rejection of malformed input is allowed). + +## Tasks (address each; commit coherently) + +### T1 — Reject duplicate arguments unconditionally (interpret.ts) +- The named-argument loop (`if (namedSeen.has(key)) continue;`, ~line 55) currently **silently drops** a duplicate named key. Change it to emit a structural diagnostic (code `ctx`/`spec.diagnosticCode`, anchored to the duplicate arg's span via `nodePslSpan(arg.syntax, ctx.sourceFile)`) and not count the duplicate as a successful parse. +- The positional-vs-named alias merge in `resolveKey` (~lines 107-123) currently only emits a conflict when the two values **differ** (`!argValuesEqual(...)`). Per the operator: a key supplied **both** positionally and by name is a duplicate — reject it **regardless of value equality**. Emit the conflict/duplicate diagnostic whenever `fromPositional && fromNamed`. +- **Remove the now-dead `argValuesEqual` + `isPlainRecord` helpers** (they existed only for the value-equality escape hatch). +- Add/adjust unit tests: `name: "A", name: "B"` and `name: "A", name: "A"` both rejected; `@rel("Foo", name: "Foo")` (positional + named, equal) rejected. + +### T2 — `unique: true` on relation lists (psl-relation-resolution.ts, the `sqlRelation` spec) +- Change `fields: optional(list(fieldRef('self'), { nonEmpty: true }))` → add `unique: true`; same for `references`. So duplicate FK column names can't reach `foreignKeyNodes`. + +### T3 — Drop the unnecessary list copy (list.ts:27) +- `const elements = [...arg.elements()]` copies the iterable. If `ArrayLiteralAst.elements()` already returns an array, iterate it directly (drop the spread). If it's a generator and you only need it for the `nonEmpty` length check, track an element count in the existing parse loop instead of materialising a second array. Keep behaviour identical; just remove the redundant allocation. + +### T4 — Use `enumOf` for referential actions; delete `identifierName` (operator question identifier-name.ts:13) +- The operator's point: a referential action (`onDelete: Cascade`) is a bare-identifier enum and should go through `enumOf`, not a bespoke `identifierName` leaf. **Extend `enumOf`** to also accept a bare `IdentifierAst` whose text matches a **string** member (in addition to the existing `StringLiteralExprAst`/`NumberLiteralExprAst` handling) — additive, must not regress existing `enumOf` tests. +- In `sqlRelation`, change `onDelete`/`onUpdate` to `optional(enumOf('NoAction', 'Restrict', 'Cascade', 'SetNull', 'SetDefault'))`. Map the validated action to the `ReferentialAction` via the existing `REFERENTIAL_ACTION_MAP` (keep `normalizeReferentialAction` as the pure token→action mapper, or inline the map — your call; do not keep a redundant second validation path). +- **Delete `identifierName` + its tests + its export.** +- **Parity flag (report this):** a bad referential action now errors at parse via `enumOf` with the attribute's code (`PSL_INVALID_RELATION_ATTRIBUTE`) instead of downstream `PSL_UNSUPPORTED_REFERENTIAL_ACTION`. If any test/fixture asserts `PSL_UNSUPPORTED_REFERENTIAL_ACTION`, update it intentionally and **report the exact count + files** so the orchestrator can relay to the operator. If that code turns out to be load-bearing elsewhere (non-`@relation`), **halt and surface** instead of deleting its only producer. + +### T5 — `fieldRef` resolves via the symbol table (operator question field-ref.ts:30) +- The operator wants `fieldRef` to actually resolve the field against the symbol table it has in `ctx` (`selfModel` for `'self'`, `resolveReferencedModel()` for `'referenced'`), not treat the name as opaque. Implement resolution: look the field up on the scoped model; **if it doesn't resolve, emit the field-existence diagnostic here** (code = `ctx.diagnosticCode`, span = the identifier's span). +- **Reconcile downstream to avoid double diagnostics:** the SQL interpreter currently validates relation `fields`/`references` existence downstream (the `localColumns`/`referencedColumns` resolution in `interpreter.ts`). With `fieldRef` now validating, remove/skip that **duplicate** existence check **for the relation `@relation` path only**, so a missing field yields exactly one diagnostic. Keep the column-name mapping (the resolved field still maps to its column). +- Preserve diagnostic **code + span** parity for the missing-field case (verify against `interpreter.relations.test.ts` / `interpreter.diagnostics.test.ts`); if the diagnostic's code/span/source must shift, update assertions intentionally and report. +- **Halt and surface if** this reconciliation requires large interpreter surgery, touches non-relation field resolution, or can't preserve the cross-space/referenced-model resolution the interpreter already does. Better to surface than to sprawl. +- Keep `fieldRef`'s parsed value as the **name string** (so the `ParsedRelationAttribute` mapping is unchanged); resolution drives validation, not the return shape. + +## Scope +**In:** the five tasks above (psl-parser engine + `list`/`enumOf`/`field-ref` combinators, delete `identifier-name`; the `sqlRelation` spec + the relation call-site existence-check reconciliation in `interpreter.ts`). Tests for each. +**Out:** other attributes (slices 2–3); the rest of ADR 231's alphabet; Mongo; `@db.*`. Do not migrate a second attribute. + +## Completed when +- [ ] T1–T5 done; `identifierName` fully removed (`rg identifierName` zero). +- [ ] `rg "argValuesEqual"` zero (helper removed). +- [ ] Unit tests updated/added for T1, T3, T4 (enumOf bare-identifier), T5 (fieldRef resolution: resolves a real field; emits one diagnostic for a missing field). +- [ ] Gates green: `pnpm --filter @prisma-next/psl-parser typecheck && test && lint`; `pnpm --filter @prisma-next/sql-contract-psl test` (or the package's real name — confirm); `pnpm fixtures:check`; after `pnpm --filter @prisma-next/psl-parser build`, workspace `pnpm typecheck`. +- [ ] Report: the T4 parity flag (code change + any updated assertions/fixtures, with counts) and the T5 reconciliation (what downstream check was removed, diagnostic parity result). + +## Standing instruction +Stay focused on the goal; control scope. Halt + surface (don't sprawl) if T5's downstream reconciliation balloons or T4's code change hits load-bearing non-relation uses. + +## Constraints +No `any`; no bare `as` (narrow `blindCast`/`castAs` with reason, or types that avoid it); no file-ext imports; no reexport outside `exports/`; tests-first for new behaviour. Explicit-staging commits, no amend, **no push** (the orchestrator pushes). Read-only on `projects/**/reviews/**`, `spec.md`, plan files. Run the transient-ID scan on your `+` diff. Do NOT post to GitHub or touch review threads. + +## Operational metadata +- **Model tier:** thorough (parity-sensitive, cross-package, two design changes). +- **Halt conditions:** T5 downstream reconciliation requires large surgery / touches non-relation paths; T4 reveals `PSL_UNSUPPORTED_REFERENTIAL_ACTION` is load-bearing elsewhere; any fixture's contract output (not just diagnostics) changes. + +Return the structured report per § Return shape, with explicit per-task results (T1–T5), the parity flags, and commit SHAs. diff --git a/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/06-oneof-identifier.md b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/06-oneof-identifier.md new file mode 100644 index 0000000000..7d4450fd46 --- /dev/null +++ b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/06-oneof-identifier.md @@ -0,0 +1,49 @@ +# Brief: D6 — replace `enumOf` with `oneOf` + `identifier` + +> Fresh implementer (session resume unavailable). On the open slice-1 PR #891 branch `tml-2956-typed-attribute-parsers`. Operator-directed design change. Do NOT push or touch GitHub — the orchestrator pushes. + +## Context +- Kit: `packages/1-framework/2-authoring/psl-parser/src/attribute-spec/` — engine (`interpret.ts`), combinators (`combinators/`: `str`, `enumOf`, `fieldRef`, `list`, `diagnostic`), types (`types.ts`), exports (`src/exports/index.ts`). Tests under the package's `test/`. +- `enumOf`'s only consumer is `sqlRelation`'s `onDelete`/`onUpdate` (`packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts`), matching bare-identifier actions; the validated name maps to a `ReferentialAction` via `normalizeReferentialAction` (now a pure token→action map). +- Leaves are `Result`-pure (return diagnostics, never push a sink) — this is what lets `oneOf` backtrack. Leaf diagnostics carry `ctx.diagnosticCode` and anchor via `nodePslSpan(node, ctx.sourceFile)`. + +## Decision (operator) +Replace the bespoke `enumOf` with two composable primitives joined by `oneOf` — ADR 231 principle #4 (compose, don't special-case). + +## Tasks + +### T1 — `oneOf(...alts)` combinator +- New combinator: tries each alternative's `parse` in order; **first success wins**; if all fail, emits **one** diagnostic (e.g. `Expected one of: `, aggregating the alternatives' `label`s) with `ctx.diagnosticCode`, anchored to the arg node. Because leaves are `Result`-pure, a failed branch leaves no diagnostics behind — do not leak the alternatives' internal failures; emit only the single aggregate. +- Type: `oneOf[]>(...alts: Alts): ArgType>` (union of the alternatives' output types; `OutOf = A extends ArgType ? X : never`). Confirm the inferred output is the union of members. +- Unit tests: first-match-wins; all-fail → single aggregate diagnostic (code = the threaded `diagnosticCode`, span = arg node); type-level test that the output is the union of the alternatives. + +### T2 — `identifier(name)` combinator +- New combinator: matches a bare `IdentifierAst` whose name **equals** `name`; returns that name. Pinned-only (no open form). Non-identifier OR identifier with a different name → diagnostic (`ctx.diagnosticCode`, span = arg node). +- Type: `identifier(name: N): ArgType` (so `oneOf` over several `identifier`s infers the precise union). +- Unit tests: matches the exact identifier; rejects a different identifier; rejects a non-identifier (e.g. a quoted string / number); the returned value is the pinned literal type. + +### T3 — Rewire referential actions +- In `sqlRelation`, change `onDelete`/`onUpdate` from `optional(enumOf('NoAction', …))` to: + `optional(oneOf(identifier('NoAction'), identifier('Restrict'), identifier('Cascade'), identifier('SetNull'), identifier('SetDefault')))`. +- The inferred output union (`'NoAction' | 'Restrict' | 'Cascade' | 'SetNull' | 'SetDefault'`) must be unchanged from what `enumOf` produced, so the call-site mapping through `normalizeReferentialAction` is untouched. Verify `onDelete: Cascade` parses and maps; a bad action (`WeirdAction`) yields one diagnostic with code `PSL_INVALID_RELATION_ATTRIBUTE` (the existing assertion in `interpreter.relations.test.ts` — message may differ, code must hold). + +### T4 — Delete `enumOf` +- Remove `combinators/enum-of.ts`, its export, and its unit tests (the behaviour is now covered by `oneOf` + `identifier` tests). `rg "enumOf"` → zero. + +## Scope +**In:** `oneOf` + `identifier` combinators + tests; rewire `sqlRelation` actions; delete `enumOf`; exports. **Out:** the pinned `str(value)`/`num(value)` literal matchers (their first consumer is Mongo's index `type` in slice 3 — do NOT build them now, no caller); `str()` stays the open string matcher unchanged; everything else (other attributes, Mongo, `@db.*`). + +## Completed when +- [ ] `oneOf` + `identifier` exported and usable as `Param`s; `enumOf` gone (`rg enumOf` zero). +- [ ] `onDelete`/`onUpdate` use `oneOf(identifier(...))`; output union unchanged; SQL relations + diagnostics suites green (the bad-action case still emits `PSL_INVALID_RELATION_ATTRIBUTE`). +- [ ] Unit + type-level tests for `oneOf` (union inference, first-match, aggregate diagnostic) and `identifier` (pinned match/mismatch, literal type). +- [ ] Gates: `pnpm --filter @prisma-next/psl-parser typecheck && test && lint`; `pnpm --filter @prisma-next/sql-contract-psl test`; `pnpm fixtures:check`; after `pnpm --filter @prisma-next/psl-parser build`, workspace `pnpm typecheck`. + +## Constraints +No `any`; no bare `as` (narrow `blindCast`/`castAs` with reason, or types that avoid it); no file-ext imports; no reexport outside `exports/`; tests-first. Explicit-staging commits, no amend, **no push**. Read-only on `projects/**/reviews/**`, `spec.md`, plan files. Transient-ID scan on the `+` diff. Do NOT post to GitHub. + +## Operational metadata +- **Model tier:** mid (two combinators against a settled contract + a one-line spec rewire). +- **Halt conditions:** `oneOf`'s union type inference can't be expressed without `any`/a broad cast; deleting `enumOf` breaks a consumer you didn't expect (grep first); the spec rewire changes the `onDelete`/`onUpdate` output type (it must stay the same union). + +Return the structured report per § Return shape: per-task results, the `oneOf` diagnostic shape + type-inference approach, confirmation the action union is unchanged, and commit SHA(s). diff --git a/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/07-adr-reconcile.md b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/07-adr-reconcile.md new file mode 100644 index 0000000000..74d0f4a309 --- /dev/null +++ b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/07-adr-reconcile.md @@ -0,0 +1,43 @@ +# Brief: D7 — reconcile ADR 231 with the shipped `oneOf`/`identifier` design + +> Fresh implementer. Documentation-only dispatch on the slice-1 PR branch. Editing the architecture doc `docs/architecture docs/ADR 231 - Declarative attribute specifications.md` is operator-authorised (overrides the `AGENTS.md` "ask first" default for this edit). Do NOT push or touch GitHub. + +## Why +The shipped kit replaced the bespoke `enumOf` combinator with **composition**: enums are now `oneOf` over per-member matchers — `identifier(name)` for bare-identifier members, and pinned `str(value)` / `num(value)` for quoted-string / number-literal members. ADR 231 still documents `enumOf` throughout. Update the ADR so it matches what shipped (ADR principle #4, "compose, don't special-case", taken to its conclusion: there is no enum leaf). + +## The shipped design (what the ADR should now say) +- **No `enumOf` combinator.** An enum is expressed by `oneOf` over matchers: + - `identifier('Cascade')` — matches the bare identifier `Cascade` (typed `ArgType<'Cascade'>`). + - `str('text')` — matches the quoted string `"text"`; `str()` (no arg) remains the open "any string literal" matcher. + - `num(1)` / `num(-1)` — matches a specific number literal. + - (No `bool` matcher — not needed.) +- A bare-identifier enum (referential actions): `oneOf(identifier('NoAction'), identifier('Restrict'), identifier('Cascade'), identifier('SetNull'), identifier('SetDefault'))`. +- A mixed quoted-string/number set (Mongo index `type`): `oneOf(num(1), num(-1), str('text'), str('2dsphere'), str('2d'), str('hashed'))`. The quoted-vs-bare surface is now **explicit per member** (`str('text')` vs `identifier('Cascade')`), which is strictly more precise than `enumOf` guessing from the member's JS type. +- `oneOf` (already in the ADR) is the sum: ordered try-each over `Result`-pure leaves, first success wins, one aggregate `expected one of …` diagnostic on total failure. Its output type is the union of the alternatives' output types — so an editor can still enumerate the legal completions (each alternative is a pinned matcher with a known value). + +## Edits to make (find every `enumOf` mention; these are the known sites — search for `enumOf` to be exhaustive) +1. **§ At a glance** — the `sqlRelation` code sample: `onDelete`/`onUpdate` change from `optional(enumOf('NoAction', …))` to `optional(oneOf(identifier('NoAction'), identifier('Restrict'), identifier('Cascade'), identifier('SetNull'), identifier('SetDefault')))`. Update the `InferAttr` comment block if it references the enum shape (the union type is unchanged). +2. **§ At a glance** narrative ("Notice three things…") — where it lists `enumOf(...)` as an example combinator, replace with the `oneOf(identifier(...))` composition; keep the point that the value types are combinators. +3. **§ At a glance** — "because `onDelete` is declared as `enumOf(...)`, the editor can complete its values" → reframe: declared as `oneOf(identifier('NoAction'), …)`, the editor enumerates the alternatives' pinned values. +4. **§ The combinator kit → Scalars** — the sentence introducing `enumOf(...values)` and the Mongo `enumOf(1, -1, 'text', …)` example. Replace with the `str(value?)` / `num(value?)` / `identifier(name)` matchers and the `oneOf(...)`-composes-enums explanation; Mongo index `type` becomes the `oneOf(num(1), num(-1), str('text'), …)` form. Note `str()` open vs `str(value)` pinned. +5. **§ One spec, two consumers (language-server)** — the `enumOf('NoAction', …)` completion example → `oneOf(identifier('NoAction'), …)`; the editor still derives completions from the alternatives' pinned values. +6. **§ Alternatives considered → "Separate `enumOf` and `numEnum`"** — this rejected-alternative is now obsolete (there is no enum leaf at all). Replace it with a rejected-alternative entry that records the actual decision: **"A dedicated `enumOf` leaf"** — rejected in favour of `oneOf` over `identifier` / pinned `str` / `num`, because composition (principle #4) expresses homogeneous and mixed sets uniformly, makes the quoted-vs-bare surface explicit per member, and reuses the `oneOf` sum the design already needs for `@default` and index elements. (Preserve the insight that mixed string/number sets must be expressible — now via `oneOf(num(...), str(...))`.) +7. **§ References (ADR 224 line)** — `@@control(...)` "value set this design types as `enumOf('managed', 'tolerated', 'external', 'observed')`" → `oneOf(identifier('managed'), identifier('tolerated'), identifier('external'), identifier('observed'))`. +8. Any other `enumOf` occurrence the search turns up — reconcile consistently. + +## Scope +**In:** `docs/architecture docs/ADR 231 - Declarative attribute specifications.md` only — prose + code samples reconciled to the `oneOf`/`identifier`/`str`/`num` design. **Out:** code, tests, other docs, ADR status line (leave `Status: Proposed` as-is unless it already says otherwise — implementation tracking is a close-out concern). Do not invent design beyond what's described here; if you find an `enumOf` use case this model can't express, **halt and surface** rather than guessing. + +## Completed when +- [ ] `rg "enumOf" "docs/architecture docs/ADR 231 - Declarative attribute specifications.md"` → zero. +- [ ] Every code sample + narrative uses `oneOf` / `identifier` / `str` / `num` consistently; the doc reads coherently (no dangling references to a removed leaf). +- [ ] No other file changed. + +## Constraints +- Markdown only; no code/test changes. Follow `markdown-no-artificial-line-wraps` (don't hard-wrap prose). Explicit-staging commit (`git add` the ADR path only), no amend, **no push**. Do NOT touch GitHub. Transient-ID scan is N/A (no source), but don't introduce `projects/…` paths into the ADR. + +## Operational metadata +- **Model tier:** mid (bounded doc reconciliation, but requires faithful design understanding). +- **Halt conditions:** an `enumOf` use case that `oneOf` + the matchers can't express; any temptation to change code/tests to match the doc (the doc follows the code, not vice-versa). + +Return: the list of sites changed, the `rg enumOf` result, and the commit SHA. diff --git a/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/08-address-review-r2.md b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/08-address-review-r2.md new file mode 100644 index 0000000000..9c4b78bcf2 --- /dev/null +++ b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/08-address-review-r2.md @@ -0,0 +1,54 @@ +# Brief: D8 — address PR #891 review round 2 (engine simplification) + +> Fresh implementer. On the slice-1 PR branch `tml-2956-typed-attribute-parsers`. These are the operator's (and CodeRabbit's) **unresolved** review comments — all to be addressed. The 509-test psl-parser suite + the SQL suites are your safety net; keep them green and update them where the reshape demands. Do NOT push or touch GitHub. + +## Context +- Engine + types: `packages/1-framework/2-authoring/psl-parser/src/attribute-spec/` — `interpret.ts`, `types.ts`, `optional.ts`, `combinators/one-of.ts`. Tests under the package's `test/`. +- The SQL `@relation` consumer: `packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts` (the `sqlRelation` spec + `interpretRelationAttribute` wrapper) and its call sites in `interpreter.ts`. +- Parity bar (operator-amended): contract output + diagnostic **codes** identical; **spans** no-coarser; **messages** may change. + +## Tasks + +### T1 — Single-pass engine (`interpret.ts`) — operator's main note +The engine currently does one pass over positional args, one over named args, then a third `resolveKey` merge pass. Rewrite `interpretAttribute` as **a single pass that builds one output map and reports duplicates as it goes**: +- Walk the attribute's args in source order. A positional arg binds to the next unconsumed positional slot's `key`; a named arg binds to its `key`. +- Maintain one `output` map + a `seen` set. If a key is already `seen` when you go to set it (whether from positional-then-named alias, or a repeated named key), emit the duplicate/conflict diagnostic **inline** and skip — this subsumes the old alias-conflict and duplicate-named handling (already operator-confirmed: reject duplicates regardless of value equality). +- Unknown named key → diagnostic; excess positional (no slot left) → diagnostic. +- After the pass: apply `optional` defaults for absent keys; emit missing-required diagnostics; then run `refine`. +- Delete the now-dead `positionalParsed`/`namedParsed`/`resolveKey` machinery. The behaviour (codes, spans, the set of diagnostics for each error path) must stay within the parity bar; keep all engine + relation tests green. + +### T2 — Reuse shared type helpers (`types.ts:8,10`) +`Simplify` and `UnionToIntersection` are redefined locally but exist in ~7 places across the repo with no canonical home. **Centralize** them in `@prisma-next/utils` (psl-parser already depends on it — add a small `types` module/export there, e.g. `@prisma-next/utils/types`) and import them in `attribute-spec/types.ts`; do not keep the local redefinitions. Scope: just centralize + import here — do NOT migrate the other 6 copies (out of scope; note as a possible future cleanup). If `@prisma-next/utils` is the wrong home per `lint:deps` layering, surface the finding rather than forcing a bad dependency. + +### T3 — Remove variadic positional support (`types.ts:85`) +No attribute uses a variadic positional (`@@index([a,b])` is a single positional bound to a `list`, not a variadic; `@@base(Base, "v")` is two fixed positionals). Remove `PositionalParam.variadic`, the variadic branch in `PosEntryObject`/`PosOut`, and the engine's variadic handling. YAGNI — re-add only when a real variadic attribute appears. + +### T4 — `optional` is an `ArgType` (`optional.ts:8`) +Model `optional(t)` as an `ArgType`, not a separate `OptionalParam`/`Param` union member. Target shape: an `OptionalArgType extends ArgType` carrying `{ optional: true; hasDefault: boolean; defaultValue?: T }`, so `Param` collapses to just `ArgType` (an optional param is a flavoured `ArgType`). The engine detects optionality via the marker on the `ArgType`; `NamedOut`/`PosOut` key their optional-property mapping off `OptionalArgType` instead of `OptionalParam`. Keep `optional(t)` / `optional(t, default)` call-shape and inferred types unchanged for consumers (`sqlRelation` must still type-check identically and infer the same output union). + +### T5 — Remove the `map`→`constraintName` rename wrapper (`psl-relation-resolution.ts:217`) +`interpretRelationAttribute`'s output mapping only renames `name`→`relationName` and `map`→`constraintName`. Remove that renaming layer and consume `interpretAttribute`'s result **directly**: align the downstream shape with the spec output keys (`name`, `map`, `fields`, `references`, `onDelete`, `onUpdate`) — update `ParsedRelationAttribute` (rename its `relationName`→`name`, `constraintName`→`map`, or drop it in favour of `SqlRelationOutput`) and the downstream field accesses across `interpreter.ts`. Keep the genuinely-needed plumbing (`findRelationAttributeNode`, `InterpretCtx` assembly) — inline or as small helpers — but no value-renaming pass. (The "same for Mongo" note is slice 3 — Mongo isn't migrated yet; ignore here.) **Halt and surface** if the downstream `relationName`/`constraintName` consumers fan out further than the relation path expects. + +### T6 — `oneOf` requires ≥1 alternative (`one-of.ts:19`) +Make the rest parameter a non-empty tuple so `oneOf()` (zero args) is a **compile error**: `oneOf, ...ArgType[]]>(...alts: Alts)`. Keep the union-output typing. + +## Orchestrator decision on the remaining CodeRabbit comment (do NOT re-add) +CodeRabbit `psl-relation-resolution.ts:89` asks to restore `PSL_UNSUPPORTED_REFERENTIAL_ACTION`. **Decision: do not restore it.** The operator deliberately moved referential-action validation into `oneOf(identifier(...))` (D6); a bad action now reports the attribute's `PSL_INVALID_RELATION_ATTRIBUTE` at parse, which is consistent with the operator's simplification and the amended parity bar. Restoring the specific code would re-add downstream validation or a per-argument code override — exactly the special-casing being removed. Leave as-is; do not change the test assertion back. + +## Completed when +- [ ] Engine is single-pass; `resolveKey`/`positionalParsed`/`namedParsed` gone (`rg "resolveKey\|positionalParsed\|namedParsed"` zero). +- [ ] `Simplify`/`UnionToIntersection` imported from a shared home; not redefined in `attribute-spec/types.ts`. +- [ ] `variadic` removed everywhere (`rg variadic packages/1-framework/2-authoring/psl-parser` zero). +- [ ] `optional` returns an `ArgType`; `OptionalParam`/`Param`-union collapsed; `sqlRelation` infers the same output type. +- [ ] `interpretRelationAttribute` renaming layer gone; downstream uses the spec output keys; relation suites green. +- [ ] `oneOf()` with zero args is a compile error. +- [ ] Gates: `pnpm --filter @prisma-next/psl-parser typecheck && test && lint`; `pnpm --filter @prisma-next/sql-contract-psl test`; `pnpm fixtures:check`; after `pnpm --filter @prisma-next/psl-parser build` (and `@prisma-next/utils` build if you added an export there), workspace `pnpm typecheck`; `pnpm lint:deps` (T2 adds a dependency edge — verify it's clean). + +## Constraints +No `any`; no bare `as` (narrow `blindCast`/`castAs` with reason, or types that avoid it); no file-ext imports; no reexport outside `exports/`; tests-first for the reshaped surfaces. Explicit-staging commits (one per task or coherent group), no amend, **no push**. Read-only on `projects/**/reviews/**`, `spec.md`, plan files. Transient-ID scan on the `+` diff. Do NOT post to GitHub or resolve threads. + +## Operational metadata +- **Model tier:** thorough (core-engine reshape + cross-package consumer change). +- **Halt conditions:** T4 (optional-as-ArgType) can't preserve the inferred output types without `any`/broad casts; T5 downstream renaming fans out beyond the relation path; T2 reuse would force a layering violation; any task changes contract output (not just diagnostics). + +Return the structured report per § Return shape: per-task (T1–T6) results, the single-pass + optional-as-ArgType designs you landed, confirmation `sqlRelation` infers the same output, the CodeRabbit:89 disposition restated, and commit SHAs. diff --git a/projects/typed-attribute-parsers/slices/attribute-spec-kit/plan.md b/projects/typed-attribute-parsers/slices/attribute-spec-kit/plan.md new file mode 100644 index 0000000000..3e9b69aea8 --- /dev/null +++ b/projects/typed-attribute-parsers/slices/attribute-spec-kit/plan.md @@ -0,0 +1,31 @@ +# Slice: attribute-spec-kit — Dispatch plan + +**Slice spec:** `projects/typed-attribute-parsers/slices/attribute-spec-kit/spec.md` + +Sandwich shape: engine → combinators → consumer migration. 3 dispatches, sequential. + +### Dispatch 1: Engine + core types + +- **Outcome:** `psl-parser` exports `ArgType`, `AttributeSpec`, `Param`/`optional`, `fieldAttribute`, `interpretAttribute`, `InferAttr`, and `InterpretCtx`. The engine parses positional + named arguments (including the positional-or-named alias) into a flat typed object, runs the optional `refine`, and returns `Result, Diagnostic[]>` — proven against a trivial in-test stub `ArgType` and `InferAttr` type-level tests. No domain combinators yet. +- **Builds on:** The spec's chosen design; the `ExpressionAst` exports. +- **Hands to:** The `ArgType` contract (`parse(arg: ExpressionAst, ctx) → Result`) + the engine, so dispatch 2 can author real combinators against a stable interface. +- **Focus:** Engine + types only. Message-templating machinery is included only if Open Question 1 resolves to "strict message parity." +- **Gate:** `cd packages/1-framework/2-authoring/psl-parser && pnpm typecheck && pnpm test`; `pnpm --filter @prisma-next/psl-parser lint`. + +### Dispatch 2: The `@relation` combinators + +- **Outcome:** `str`, `enumOf(...values)`, `fieldRef(scope)`, and `list(of, { nonEmpty })` exist as `ArgType`s over `ExpressionAst`, each with unit tests covering parse success + each diagnostic path. `fieldRef` carries its scope (`'self'` / `'referenced'`) and resolves against `InterpretCtx`. +- **Builds on:** Dispatch 1's `ArgType` contract + engine. +- **Hands to:** The combinator set sufficient to express `sqlRelation`. +- **Focus:** Only the four combinators `@relation` needs. The rest of ADR 231's alphabet is out (slices 2–3). +- **Gate:** psl-parser typecheck + test + lint. + +### Dispatch 3: Migrate SQL `@relation`; delete the legacy parser + +- **Outcome:** `sqlRelation` spec defined; the `@relation` call sites in `packages/2-sql/.../interpreter.ts` + `psl-relation-resolution.ts` route through `interpretAttribute` with an assembled `InterpretCtx`; `parseRelationAttribute` (and helpers it alone used) deleted; diagnostic codes + spans byte-identical (message-text per Open Question 1). +- **Builds on:** Dispatch 2's combinator set. +- **Hands to:** SQL `@relation` validated via spec; legacy parser gone — the migration recipe slices 2–3 follow. +- **Focus:** `@relation` only. Other SQL attributes stay on their legacy paths (slice 2). +- **Gate:** `pnpm --filter @prisma-next/contract-psl-sql test` (relations + diagnostics suites); `pnpm fixtures:check`; `rg "parseRelationAttribute"` empty; workspace `pnpm typecheck` after `psl-parser` build (cross-package consumer check). + +_(Final `hands to` ⊇ slice-DoD: legacy parser removed (D3), kit exported + tested (D1–D2), parity gates green (D3). Complete.)_ diff --git a/projects/typed-attribute-parsers/slices/attribute-spec-kit/spec.md b/projects/typed-attribute-parsers/slices/attribute-spec-kit/spec.md new file mode 100644 index 0000000000..a959ad46c5 --- /dev/null +++ b/projects/typed-attribute-parsers/slices/attribute-spec-kit/spec.md @@ -0,0 +1,83 @@ +# Slice: attribute-spec-kit + +_(In-project slice. Parent: `projects/typed-attribute-parsers/`. Outcome it contributes: stands up the declarative-attribute engine the whole project builds on, proven by one real attribute.)_ + +## At a glance + +Build the combinator kit + `interpretAttribute` + `InferAttr` + `InterpretCtx` in `psl-parser`, and migrate the SQL family's `@relation` from the hand-written `parseRelationAttribute` (in `packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts`) to a declarative `AttributeSpec` lowered through `interpretAttribute`. After this slice, `@relation` (SQL) is validated and lowered via a spec, and the engine + the combinators `@relation` needs exist for slices 2–3 to consume. + +## Chosen design + +The engine consumes the parser's `ExpressionAst` CST directly (already exported from `psl-parser`). An `ArgType` parses one argument; an `AttributeSpec` lists positional params + named params + an optional `refine`; `interpretAttribute(attrNode, spec, ctx)` returns `Result, Diagnostic[]>`. + +The SQL `@relation` spec replaces `parseRelationAttribute`: + +```ts +const sqlRelation = fieldAttribute('relation', { + positional: [{ key: 'name', type: optional(str()) }], // positional-or-named alias for `name` + named: { + name: optional(str()), + fields: optional(list(fieldRef('self'), { nonEmpty: true })), + references: optional(list(fieldRef('referenced'), { nonEmpty: true })), + map: optional(str()), + onDelete: optional(enumOf('NoAction', 'Restrict', 'Cascade', 'SetNull', 'SetDefault')), + onUpdate: optional(enumOf('NoAction', 'Restrict', 'Cascade', 'SetNull', 'SetDefault')), + }, + refine: relationInvariants, // fields/references both-or-neither; positional/named name conflict +}); +``` + +`interpretAttribute(relationNode, sqlRelation, ctx)` yields the same shape `ParsedRelationAttribute` carries today (`{ relationName?, fields?, references?, constraintName?, onDelete?, onUpdate? }`), mapped at the call site. `InterpretCtx` is assembled from data the SQL interpreter already holds (symbol table, declaring model, referenced-model resolver, declaring field, source id). + +**Minimal kit, grown by consumers.** Slice 1 ships the engine plus only the combinators `@relation` needs — `str`, `enumOf`, `fieldRef(scope)`, `list({ nonEmpty })`, `optional`, `fieldAttribute`. The rest of ADR 231's alphabet (`int`, `bool`, `json`, `map`, `record`, `entityRef`, `codecRef`, `oneOf`, `funcCall`/`funcCallFrom`, `modelAttribute`, `blockAttribute`) is added by slices 2–3 as the attributes they migrate require it. This keeps slice 1 reviewable. + +## Coherence rationale + +One reviewer holds it in one sitting: a new authoring surface (`psl-parser` kit) plus its first consumer (`@relation`), reviewed together so the engine is judged against a real attribute rather than in the abstract. The legacy `parseRelationAttribute` is deleted in the same PR, so there is never a second live validation path for `@relation`. + +## Scope + +**In:** +- `psl-parser`: `ArgType`, `AttributeSpec`, `Param`/`optional`, `fieldAttribute`, `interpretAttribute`, `InferAttr`, `InterpretCtx`, and the combinators `str`, `enumOf`, `fieldRef`, `list` — with unit + type-level tests; exported from `psl-parser`'s public surface. +- `packages/2-sql/2-authoring/contract-psl`: `sqlRelation` spec; route the `@relation` call sites in `interpreter.ts` / `psl-relation-resolution.ts` through `interpretAttribute`; assemble `InterpretCtx`; delete `parseRelationAttribute` (and any now-dead helpers it alone used). + +**Out:** +- All other SQL attributes (`@id`, `@unique`, `@@index`, `@default`, `@map`, `@@control`, `@@discriminator`, `@@base`) — slice 2. +- All Mongo attributes — slice 3. +- The unused-by-`@relation` combinators (`int`, `bool`, `json`, `map`, `record`, `entityRef`, `codecRef`, `oneOf`, `funcCall`, `modelAttribute`, `blockAttribute`). +- Language-server consumers; `@db.*`; the TS builder surface. + +## Pre-investigated edge cases + +| Edge case | Disposition | Notes | +| --------- | ----------- | ----- | +| Positional + named `name` both present and disagreeing | Must preserve | Existing code emits `PSL_INVALID_RELATION_ATTRIBUTE` "conflicting positional and named relation names"; reproduce via `refine` or the alias merge. | +| `fields` without `references` (or vice-versa) | Must preserve | Existing both-or-neither check, code `PSL_INVALID_RELATION_ATTRIBUTE`; lives in `refine`. | +| Unknown named argument (e.g. `@relation(foo: 1)`) | Must preserve | Existing code rejects with `PSL_INVALID_RELATION_ATTRIBUTE`; the engine's named-map closedness must reject it (see Open Question on message text). | +| `onDelete`/`onUpdate` value not in the action set | Must preserve code | Today `PSL_UNSUPPORTED_REFERENTIAL_ACTION` is raised *downstream* by `normalizeReferentialAction`, not at parse; decide whether `enumOf` raises at parse with the same code or the value passes through to the existing normaliser. | + +## Slice-specific done conditions + +- [ ] `rg "parseRelationAttribute"` returns zero results outside its deleted definition. +- [ ] `pnpm fixtures:check` clean and the SQL interpreter relations suites pass (`interpreter.relations.test.ts`, `interpreter.relations.many-to-many.test.ts`, `interpreter.diagnostics.test.ts`). +- [ ] Diagnostic **codes and spans** for every `@relation` error path are byte-identical to pre-slice behaviour. Message text may change to the kit's phrasing (see Resolved decision 1) but must stay clear and actionable; updated test assertions are reviewed as intentional. + +## Resolved decisions + +1. **Diagnostic-message parity — codes + spans only (operator-authorised, 2026-06-29).** Codes + spans are the hard parity gate. Cross-argument messages emitted from hand-written `refine` (both-or-neither, name-conflict) should stay close to the existing text, but generic-combinator-emitted messages (unknown-arg, malformed-list, bad-enum-value) may adopt the kit's phrasing as long as they remain clear. Affected interpreter-test message assertions are updated as intentional, reviewer-approved changes. The engine does **not** need subject-label message-templating in slice 1. This relaxes the project's original "identical messages" cross-cutting requirement, which has been amended accordingly. + +## Resolved decisions (cont.) + +2. **`onDelete`/`onUpdate` keep `normalizeReferentialAction` as validator (resolved at D3).** `onDelete: Cascade` is a **bare identifier**, and legacy validates the action set *downstream* via `normalizeReferentialAction`, emitting `PSL_UNSUPPORTED_REFERENTIAL_ACTION`. Using `enumOf` (set-validation at parse) would change that code and break the codes-parity bar. So the `sqlRelation` spec parses `onDelete`/`onUpdate` to the **raw identifier name** (a bare-identifier leaf, no parse-time set check) and routes the value to the existing `normalizeReferentialAction` unchanged — exact code/span/message parity. `enumOf` is NOT used for `@relation` actions (it remains for slices 2–3). D3 adds the small bare-identifier-name leaf if one isn't already present. + +## Open Questions + +None — all resolved. + +## References + +- Parent project: `projects/typed-attribute-parsers/spec.md` +- Linear issue: [TML-2956](https://linear.app/prisma-company/issue/TML-2956) +- ADR 231 — `docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md` +- Legacy parser being replaced: `packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts` (`parseRelationAttribute`, `normalizeReferentialAction`) +- Engine input type: `ExpressionAst` and friends, exported from `packages/1-framework/2-authoring/psl-parser/src/exports/syntax.ts` diff --git a/projects/typed-attribute-parsers/spec.md b/projects/typed-attribute-parsers/spec.md new file mode 100644 index 0000000000..0b5309cb2d --- /dev/null +++ b/projects/typed-attribute-parsers/spec.md @@ -0,0 +1,103 @@ +# typed-attribute-parsers + +## Purpose + +Give every PSL attribute a single declarative description that the family interpreters read to validate and lower its arguments, so that "what an attribute accepts" lives as inspectable data in one place instead of as hand-written parsing code duplicated across the SQL and Mongo interpreters. The why is permanence-of-knowledge: the same description must later be readable by other consumers (the language server) without re-deriving it — this project earns that by making the interpreter the first consumer. + +## At a glance + +Today each interpreter pulls raw argument text out of the AST and re-checks its shape by hand. `@relation`'s `fields` argument is a string that gets `split(',')`; `onDelete` is a string compared against a literal list; an unknown named argument is rejected by ad-hoc code — the same patterns repeated in slightly different ways across `packages/2-sql/.../interpreter.ts` (2155 lines) and the Mongo interpreter, backed by string helpers like `parseFieldList` and `parseQuotedStringLiteral`. + +This project replaces that with one declarative `AttributeSpec` per attribute, composed from a fixed kit of argument combinators (ADR 231), and a single `interpretAttribute(node, spec, ctx)` that returns a strongly-typed object whose shape is **inferred from the spec** (`InferAttr`) — or structured diagnostics. + +``` +// before — hand-written, per attribute, per family +const raw = getNamedArgument(attr, 'fields'); // string +const fields = parseFieldList(raw); // split(',') + trim +const onDelete = normalizeReferentialAction(getNamedArgument(attr, 'onDelete')); +// … unknown-argument rejection, span anchoring, both-or-neither rule, all by hand + +// after — one description the interpreter reads +const sqlRelation = fieldAttribute('relation', { + positional: [{ key: 'name', type: optional(str()) }], + named: { + fields: optional(list(fieldRef('self'), { nonEmpty: true })), + references: optional(list(fieldRef('referenced'), { nonEmpty: true })), + onDelete: optional(enumOf('NoAction', 'Restrict', 'Cascade', 'SetNull', 'SetDefault')), + onUpdate: optional(enumOf('NoAction', 'Restrict', 'Cascade', 'SetNull', 'SetDefault')), + map: optional(str()), + }, + refine: relationInvariants, // fields + references are both-or-neither +}); +const parsed = interpretAttribute(relationNode, sqlRelation, ctx); // typed result | diagnostics +``` + +The emitted contract is unchanged; what changes is how the interpreter arrives at it. The output type is derived from the spec, so it cannot drift from the validation. + +## Non-goals + +- **Language-server integration.** Completion, go-to-definition, find-usages, and hovers over attribute arguments are the follow-up that this project's specs are designed to enable, but no language-server consumer is built here. Specs must carry the structure those features need (reference scopes, enum value sets), but wiring them into the editor is out of scope. +- **`@db.*` native types.** These are attributes on named-type declarations, not on fields or models, and are handled by a separate resolver path (ADR 231 § Out of scope). Untouched. +- **The TypeScript builder authoring surface.** Attributes are a PSL-only concept; the TS builders never use them. No combinator appears in the builder API. +- **New attribute syntax or new attributes.** This project re-expresses the *existing* attribute surface as specs; it does not add, remove, or change which attributes or argument shapes are accepted. +- **Generic-block `key = value` parameters and enum member values.** ADR 231 floats unifying these with the kit as an open question; this project does not pursue it. + +## Place in the larger world + +- **ADR 231 — Declarative attribute specifications** is the architectural driver; this project is its first (interpreter-only) implementation. The ADR is `Proposed`; this project's close-out should move it toward `Accepted` or record divergences. +- **The combinator kit lives in `psl-parser`** (`packages/1-framework/2-authoring/psl-parser`) — the PSL authoring-layer package that already owns the parser, the `ExpressionAst` CST, and the `SymbolTable`. It is not in the target-agnostic framework core, because attributes are PSL-specific. `psl-parser` exports the kit, `AttributeSpec`, `interpretAttribute`, and `InferAttr` alongside its existing AST exports. +- **The two consumers** are the family interpreters: `packages/2-sql/2-authoring/contract-psl` and `packages/2-mongo-family/2-authoring/contract-psl`. Each contributes the specs for the attributes it understands, registered by `(level, name)`; the kit dispatches generically and never learns an attribute's name (the ADR-225 contribution model). +- **Argument representation.** Combinators parse the parser's `ExpressionAst` directly — the CST union (`ArrayLiteralAst`, `ObjectLiteralExprAst`, `StringLiteralExprAst`, `FunctionCallAst`, …) that `psl-parser` already exports, which carries native `[…]` / `{…}` literals and real spans. No new intermediate argument representation is introduced. The migration routes each interpreter call site away from the string-flattened `ResolvedAttribute` (`readResolvedArgList`, which collapses arguments to `value: string`) and toward passing the CST attribute node (`FieldAttributeAst` / `ModelAttributeAst`) into `interpretAttribute`. The interpreter already receives the `SymbolTable` and `SourceFile` rather than a pre-flattened document, so the CST is in reach at every call site. +- **Resolution context.** Reference combinators draw on an `InterpretCtx` carrying the parser's `SymbolTable`, the declaring model, a referenced-model resolver, the declaring field (field level only), a codec lookup, and the default-function registry — all already present in the interpreters' existing wiring. + +## Cross-cutting requirements + +- **Behavioural parity, end to end.** For every attribute migrated, the interpreter produces the identical contract output and identical diagnostic **codes** it produced before. Diagnostic **spans** must be **no coarser** than before — narrower/more-precise spans (the natural result of the kit's per-argument anchoring, per ADR 231's native-literal-spans benefit) are acceptable; widening a span is not. Diagnostic **message text** may change to the combinator kit's phrasing, provided each message stays clear and actionable. Malformed inputs that legacy tolerated by coincidence (e.g. a quoted referential action `onDelete: "Cascade"`) may be rejected by the stricter typed leaves. `pnpm fixtures:check` and the interpreter test suites are the parity gate; no contract-output or diagnostic-code drift is acceptable without an explicit, reviewed rationale. _(Messages-may-change + spans-no-coarser + stricter-malformed-rejection relaxations authorised by operator, 2026-06-29.)_ +- **The spec is the only source of an attribute's argument shape.** Once an attribute is migrated, no hand-written argument-parsing path for it remains. Its output type is `InferAttr`, not a separately maintained interface. +- **Leaf parsing is pure.** A combinator returns diagnostics in a `Result`, never into a shared sink, so `oneOf` can try and discard branches cleanly. No combinator mutates a diagnostics array passed by reference. +- **Generic dispatch.** The kit and `interpretAttribute` never branch on a specific attribute name; families register specs and the engine dispatches structurally. + +## Transitional-shape constraints + +- **Every slice keeps CI green on `main`** — `pnpm typecheck`, `pnpm lint`, `pnpm test:packages`, and `pnpm fixtures:check` all pass at every merge. +- **Incremental, attribute-by-attribute migration.** Specs and hand-written parsing coexist while the migration is in flight; the interpreter may route some attributes through specs and others through legacy code simultaneously. A slice migrates a coherent group of attributes (e.g. all of `@relation`) and deletes the legacy path for exactly that group — never leaving two live validation paths for the same attribute. +- **`interpretAttribute` and the kit land with the first migrated attribute**, leaving the existing `ResolvedAttribute` string-flattening path (`readResolvedArgList` and the string helpers) in place for every not-yet-migrated attribute, so legacy parsing keeps working until its attribute is migrated. Those legacy paths are deleted only when their last caller is migrated. + +## Project Definition of Done + +- [ ] Team-DoD floor items (inherited from [`drive/calibration/dod.md`](../../drive/calibration/dod.md) — repo-wide gates, doc/migration, Linear close-out, manual-QA roll-up, ADR audit). +- [ ] The combinator kit, `AttributeSpec`, `interpretAttribute`, and `InferAttr` exist in `psl-parser` (exported alongside its existing AST exports) with unit tests covering each combinator's parse + diagnostic behaviour. +- [ ] Every field-, model-, and block-level attribute interpreted by the **SQL** family is described by a spec and lowered via `interpretAttribute`; the corresponding hand-written argument-parsing helpers are deleted. +- [ ] Every field-, model-, and block-level attribute interpreted by the **Mongo** family is described by a spec and lowered via `interpretAttribute`; the corresponding hand-written argument-parsing helpers are deleted. +- [ ] `pnpm fixtures:check` is clean and the SQL + Mongo interpreter test suites pass with no diagnostic-parity regressions. +- [ ] No remaining caller of the removed string helpers (`parseFieldList`, `parseAttributeFieldList`, per-attribute `getNamedArgument`/`getPositionalArgument` re-parsing) for any migrated attribute; a grep gate confirms this. +- [ ] ADR 231 updated to reflect what shipped (status advanced and/or divergences recorded). + +### Contract-impact + +The **emitted contract is unchanged** — this is a refactor of how interpreters validate arguments, gated by `fixtures:check`. There is no new argument representation: combinators consume the existing `ExpressionAst` CST. The internal change is that migrated interpreter call sites stop flattening attributes to `ResolvedAttribute` strings and instead pass CST attribute nodes into `interpretAttribute`. `ResolvedAttribute` / `readResolvedArgList` and the string helpers remain until their last caller is migrated, then are deleted. + +### Adapter-impact + +No `packages/3-targets/**` adapter is touched. The interpreters being migrated live in the family **authoring** layer (`packages/2-sql`, `packages/2-mongo-family`), upstream of the target adapters; adapter behaviour is reached only through the unchanged contract. + +## Resolved decisions + +- **Argument representation — `ExpressionAst`, no intermediate form.** Combinators consume the parser's `ExpressionAst` CST directly. No `PslArgAst` or other intermediate value is introduced. (Folded into _Place in the larger world_ and _Contract-impact_.) +- **Kit package — inside `psl-parser`.** The kit, `AttributeSpec`, `interpretAttribute`, and `InferAttr` ship from `psl-parser`, which already owns `ExpressionAst` and the `SymbolTable`. No new package. +- **Migration ordering — deferred to planning.** Which attribute groups become slices, and in what order, is a `drive-plan-project` concern, not a spec-level decision. +- **`refine` vs. model-level aggregation — single-attribute rules only.** A single-attribute cross-argument rule — `@relation`'s "`fields` and `references` are both-or-neither" — is implemented as the spec's `refine(parsed, ctx)` callback: a function that runs *after* every argument has parsed, receives the fully-typed result object, and returns diagnostics that no single combinator could produce (each combinator sees only its own argument). That is what "the rule lives in `refine`" means — it is a field on the `AttributeSpec`, not inline interpreter code. A rule that spans *several attributes on one model* — "at most one `@@textIndex` per collection" — is not attribute-level and stays in the existing model-level aggregation that runs above the individual `interpretAttribute` calls. Decision: move single-attribute cross-argument rules into `refine`; build no new aggregator and leave today's model-level checks untouched. + +## Open Questions + +None — design settled. Migration sequencing is handed to `drive-plan-project`. + +## References + +- ADR 231 — [Declarative attribute specifications](../../docs/architecture%20docs/adrs/ADR%20231%20-%20Declarative%20attribute%20specifications.md) (the architectural driver; advance its status at close-out). +- ADR 225 — Three-layer extensibility for pack-contributed entity kinds (the contribution/registration model the kit follows). +- ADR 224 — Control policy: framework-locked vocabulary, family-owned dispatch (the `@@control` value set this kit types as `enumOf(...)`). +- ADR 221 — Contract IR: uniform entity coordinate (the coordinate model reference combinators write into). +- Current interpreters: `packages/2-sql/2-authoring/contract-psl/src/interpreter.ts`, `packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts`. +- Current arg flattening: `packages/1-framework/2-authoring/psl-parser/src/resolve.ts` (`readResolvedArgList`); string helpers in `packages/2-sql/2-authoring/contract-psl/src/psl-attribute-parsing.ts`. +- Linear issue: [TML-2956](https://linear.app/prisma-company/issue/TML-2956) (under project _Language Tools Support Prisma Next PSL_, Terminal team).