From 930258bcdbefc7655640da05c34746d726216d11 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 2 Jul 2026 09:09:25 +0200 Subject: [PATCH 1/5] docs(enums): spec + plan for inferring enum @@type from members (TML-2915) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice contract + two-dispatch plan. The parser/interpreter already handle bare names and numeric member values; the only gap is that @@type is mandatory. Infer it when omitted — text codec for bare/string members, int for integers, error otherwise — via a shared classifier + per-pack default codec ids, both families. Signed-off-by: willbot Signed-off-by: Will Madden --- .../infer-enum-type-from-members/plan.md | 78 ++++++++++++++++ .../infer-enum-type-from-members/spec.md | 92 +++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 projects/enums-as-domain-concept/slices/infer-enum-type-from-members/plan.md create mode 100644 projects/enums-as-domain-concept/slices/infer-enum-type-from-members/spec.md diff --git a/projects/enums-as-domain-concept/slices/infer-enum-type-from-members/plan.md b/projects/enums-as-domain-concept/slices/infer-enum-type-from-members/plan.md new file mode 100644 index 0000000000..8bfcab7c57 --- /dev/null +++ b/projects/enums-as-domain-concept/slices/infer-enum-type-from-members/plan.md @@ -0,0 +1,78 @@ +# Plan — Infer an enum's `@@type` from its members + +**Spec:** `./spec.md` · **Linear:** [TML-2915](https://linear.app/prisma-company/issue/TML-2915) +**Branch:** `tml-2915-enum-conveniences` (off current `main`) + +One slice, two sequential dispatches (D2 reuses the shared classifier + context field D1 lands). +Tests-first in each. + +## Grounded boundaries + +- **Change point (both families):** the `output.factory` in + `packages/2-sql/9-family/src/core/authoring-entity-types.ts` (`sqlFamilyEnumEntityDescriptor`) + and `packages/2-mongo-family/9-family/src/core/authoring-entity-types.ts`. Both find the + `@@type` attr and, when absent, push `PSL_ENUM_MISSING_TYPE` and bail. Inference replaces that + bail: classify members → pick a default codec id → fall through into the existing + codec-resolution + member loop unchanged. +- **Member shapes:** `block.parameters` entries carry a `kind`. The interpreter already switches + on `'bare'` vs value; the parser's exported param kinds are `PslExtensionBlockParamBare`, + `…ParamList`, `…ParamOption`, `…ParamRef` (`@prisma-next/psl-parser` exports). The classifier + must read the raw value (for a value param, `JSON.parse(raw)` then `typeof`/`Number.isInteger`) + and treat list/option/ref as not-inferrable. Confirm the exact value-param kind name and raw + accessor when implementing. +- **Context:** `AuthoringEntityContext` (`@prisma-next/framework-components/authoring`) currently + exposes `codecLookup`, `sourceId`, `diagnostics`. It gains the target's default enum codec ids. + Find where each pack constructs/passes this context (the CLI/build authoring path + the + control-stack path) and populate the new field there. +- **Default codec ids (already constants):** Postgres `PG_TEXT_CODEC_ID='pg/text@1'` / + `PG_INT_CODEC_ID='pg/int@1'` (`packages/3-targets/3-targets/postgres/src/core/codec-ids.ts`); + SQLite `SQLITE_TEXT_CODEC_ID` / `SQLITE_INTEGER_CODEC_ID` + (`…/sqlite/src/core/codec-ids.ts`); Mongo `MONGO_STRING_CODEC_ID` / `MONGO_INT32_CODEC_ID` + (`packages/3-mongo-target/2-mongo-adapter/src/core/codec-ids.ts`). +- **Tests:** emit-then-consume is the standard here (verify through emit, not `typeof contract`) — + author PSL → emit → assert the field types as the value union and the inferred codec is present. + Negative tests assert the `PSL_ENUM_CANNOT_INFER_TYPE` diagnostic. + +## D1 — shared classifier + framework context + SQL side + +1. **Framework:** add `enumInferenceCodecs: { text: string; int: string }` (name TBD by the + implementer to fit the existing type) to `AuthoringEntityContext`. +2. **Shared classifier:** one helper (in framework-components/authoring, next to the context type) + `classifyEnumMemberType(block): 'text' | 'int' | null` over the raw param shapes. +3. **SQL family factory:** when `@@type` is absent, call the classifier; `null` → new + `PSL_ENUM_CANNOT_INFER_TYPE` diagnostic; otherwise pick `ctx.enumInferenceCodecs.text|int` and + fall through to the existing body. Present `@@type` path untouched. +4. **Packs:** Postgres and SQLite populate `enumInferenceCodecs` from their codec-id constants + wherever they build the authoring context. +5. **Tests-first:** per Postgres + SQLite — no-`@@type` text enum, no-`@@type` int enum (emit → + value-union typed, inferred codec present), explicit-`@@type` unchanged, and the mixed/float + negative → `PSL_ENUM_CANNOT_INFER_TYPE`. +6. **Verify:** build + typecheck + test for the touched SQL/target/framework packages; + `fixtures:check` (explicit-`@@type` fixtures unchanged); `lint:deps`, `lint:casts`. + +## D2 — Mongo side (reuses D1) + +1. **Mongo family factory:** same inference in + `packages/2-mongo-family/9-family/src/core/authoring-entity-types.ts`, calling the shared + classifier + `ctx.enumInferenceCodecs`. +2. **Mongo pack:** populate `enumInferenceCodecs` from `MONGO_STRING_CODEC_ID` / + `MONGO_INT32_CODEC_ID`. +3. **Tests-first:** Mongo no-`@@type` text + int (emit → value-union typed, `$jsonSchema` + validator/value-set carries the inferred codec), explicit-`@@type` unchanged, mixed/float + negative. +4. **Verify:** Mongo package build/typecheck/test; `fixtures:check`; full gate before PR. + +## Open items to resolve during D1 + +- The exact value-param `kind` name + raw accessor (grounding note above) — verify against + `@prisma-next/psl-parser` before writing the classifier. +- Where exactly each pack builds `AuthoringEntityContext` (may be more than one call site — + CLI/build vs control-stack). All construction sites must set `enumInferenceCodecs`. +- Whether `enumInferenceCodecs` should be required (all packs must set it) or optional (absent → + inference disabled, `@@type` still required). Prefer **required** for predictability; make the + type non-optional and update every construction site. + +## Not doing + +New syntax; TS parity; float/bigint/boolean/mixed inference; any change to the present-`@@type` +path or the emitted contract shape. (Spec § Out of scope.) diff --git a/projects/enums-as-domain-concept/slices/infer-enum-type-from-members/spec.md b/projects/enums-as-domain-concept/slices/infer-enum-type-from-members/spec.md new file mode 100644 index 0000000000..45f3356db9 --- /dev/null +++ b/projects/enums-as-domain-concept/slices/infer-enum-type-from-members/spec.md @@ -0,0 +1,92 @@ +# Slice — Infer an enum's `@@type` from its members + +**Linear:** [TML-2915](https://linear.app/prisma-company/issue/TML-2915) +**Project:** enums-as-domain-concept (`../../spec.md`) +**Branch:** `tml-2915-enum-conveniences` + +## Outcome + +An `enum` block may omit `@@type`; the codec is inferred from the members — the target's +**text** codec when every member is a bare name or a string value, the target's **int** codec +when every member is an integer value. Anything else (float, bigint, boolean, or a mix) is a +clear "add an explicit `@@type(...)`" diagnostic. An explicit `@@type` still wins and behaves +exactly as today. Applies to Postgres, SQLite, and Mongo. + +```prisma +enum Role { enum Priority { + admin → pg/text@1 low = 1 → pg/int@1 + user high = 2 +} } +``` + +## Why this is small + +The parser already tags each enum member as `bare` (`admin`) or a value (`admin = 1`), and the +interpreter already lowers both — a `bare` member feeds its **name** through the codec, a value +member JSON-parses and decodes its RHS. Bare names and numeric member values already parse and +lower today. The **only** thing forcing verbosity is that `@@type` is mandatory +(`PSL_ENUM_MISSING_TYPE`, raised in `sqlFamilyEnumEntityDescriptor.output.factory` and its Mongo +sibling). This slice replaces that hard error, when `@@type` is absent, with inference. No parser +change, no new syntax. + +## Builds on + +The whole enum machinery already merged: the domain-enum authoring path, the `enum` PSL block + +member grammar (bare vs value), the per-family `authoring-entity-types.ts` factories, and +codec-typed emission (TML-2952). This slice adds only the inference step in front of the existing +factory body. + +## Requirements + +- **R1 — Inference rule.** When an `enum` block has no `@@type`, choose the codec by scanning the + members' *raw* shapes (before decoding, since decoding needs the codec): + - every member is `bare`, or a value whose JSON is a **string** → target **text** codec; + - every member is a value whose JSON is an **integer** → target **int** codec; + - otherwise → diagnostic `PSL_ENUM_CANNOT_INFER_TYPE` ("cannot infer `@@type` for enum + \"\"; add an explicit `@@type(...)`"). No float/bigint/boolean/mixed inference. +- **R2 — Explicit wins.** With `@@type` present, behavior is byte-identical to today (same codec + resolution, same member decoding, same diagnostics). Inference is reached only on omission. +- **R3 — Post-inference path is the existing path.** After a codec is chosen, the existing member + loop runs unchanged (bare → `codec.decodeJson(name)`; value → decode parsed JSON), so a bare + member under an inferred int codec still errs exactly as it does under an explicit one. +- **R4 — Target-declared defaults.** The family-level factory is target-agnostic; each target + pack supplies its default text + int enum codec ids (Postgres `pg/text@1`/`pg/int@1`, SQLite + `sqlite/text@1`/`sqlite/integer@1`, Mongo `mongo/string@1`/`mongo/int32@1`) through the + authoring context. No guessing a default from `targetTypesFor` reverse-lookups. +- **R5 — Both families.** SQL (`packages/2-sql/9-family`) and Mongo + (`packages/2-mongo-family/9-family`) infer identically; the classification logic is shared, not + duplicated per family. + +## Out of scope + +- New syntax — this is `@@type` *omission*, not a keyword form. +- TS `enumType`/`member` parity — that API keeps its explicit codec. +- Float / bigint / boolean / mixed-type inference — explicit `@@type` required. +- Any change to member-value grammar, decoding, or the emitted contract shape when `@@type` is + present. + +## Definition of done + +- PSL `enum` with no `@@type` + bare/string members emits the target's text codec; + integer + members emits the target's int codec — proven by emit-then-consume tests on Postgres, SQLite, + and Mongo (author → emit → the field types as the value union; the value set / validator carries + the inferred codec). +- A mixed/float/bigint/boolean no-`@@type` enum yields the `PSL_ENUM_CANNOT_INFER_TYPE` + diagnostic (negative test per family). +- Explicit-`@@type` fixtures are unchanged (`fixtures:check` clean). +- Build, typecheck, full test suites, `lint:deps`, `lint:casts` (no new casts) all green. + +## Design notes + +- **Inference sits in front of the factory body.** In each family's enum entity factory, when the + `@@type` attr is absent, run the classifier over `block.parameters` to pick a codec id from the + context-supplied defaults, then continue into the existing codec-resolution + member loop with + that id. Present `@@type` skips the classifier entirely (R2). +- **Carrying the defaults.** Extend `AuthoringEntityContext` + (`@prisma-next/framework-components/authoring`) with the target's default enum codec ids (e.g. + `enumInferenceCodecs: { text: string; int: string }`), populated where each pack builds the + authoring context. This is the single new framework surface. +- **Classifier is shared.** One helper classifies the member set → `'text' | 'int' | null` + (null = not inferrable) from the raw param shapes; both family factories call it. Note the + parser's param kinds are richer than bare/value (`Bare`/`List`/`Option`/`Ref`) — the classifier + treats anything that isn't a bare name or a string/integer scalar value as not-inferrable. From 38c88b64ed264ac1b362a2d8864813dceb71a6d4 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 2 Jul 2026 15:37:37 +0200 Subject: [PATCH 2/5] feat(enums): infer an enum's @@type from its members (TML-2915) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PSL enum block may omit @@type; the codec is inferred from the members — the target's text codec when every member is a bare name or string value, the int codec when every member is an integer, and a PSL_ENUM_CANNOT_INFER_TYPE diagnostic otherwise. Explicit @@type is unchanged. Works for Postgres, SQLite, and Mongo. A shared classifyEnumMemberType helper classifies the members; each family enum factory infers when @@type is absent. The target's default codec ids ride on an optional AuthoringEntityContext.enumInferenceCodecs, populated on the PSL path; the TypeScript authoring path never infers, so the field is optional there. Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/exports/authoring.ts | 1 + .../src/shared/framework-authoring.ts | 54 +++- .../framework-components.authoring.test.ts | 82 ++++++ .../contract-psl/src/interpreter.ts | 3 + .../src/core/authoring-entity-types.ts | 61 +++-- .../test/authoring-entity-types.enum.test.ts | 177 ++++++++++++ .../contract-psl/src/interpreter.ts | 4 + .../2-authoring/contract-psl/src/provider.ts | 3 + .../test/composed-mutation-defaults.test.ts | 8 +- .../2-authoring/contract-psl/test/fixtures.ts | 84 ++++-- .../test/interpreter.control-policy.test.ts | 2 + .../test/interpreter.defaults.test.ts | 10 +- .../test/interpreter.diagnostics.test.ts | 5 + .../test/interpreter.enum.test.ts | 236 +++++++++++++++- .../test/interpreter.extensions.test.ts | 2 + .../test/interpreter.namespaces.test.ts | 2 + .../test/interpreter.polymorphism.test.ts | 8 +- ...interpreter.relations.many-to-many.test.ts | 2 + .../test/interpreter.relations.test.ts | 2 + .../contract-psl/test/interpreter.test.ts | 8 +- .../test/interpreter.types.test.ts | 2 + .../test/interpreter.value-objects.test.ts | 8 +- .../contract-psl/test/provider.test.ts | 2 + .../test/psl-ts-namespace-parity.test.ts | 4 + .../contract-psl/test/ts-psl-parity.test.ts | 7 + .../src/core/authoring-entity-types.ts | 61 +++-- .../test/authoring-entity-types.enum.test.ts | 253 ++++++++++++++++++ .../postgres/src/config/define-config.ts | 2 + .../sqlite/src/config/define-config.ts | 5 + .../src/core/postgres-contract-serializer.ts | 2 + .../test/mongo/interpreter.enum.test.ts | 26 +- 31 files changed, 1028 insertions(+), 98 deletions(-) create mode 100644 packages/2-mongo-family/9-family/test/authoring-entity-types.enum.test.ts create mode 100644 packages/2-sql/9-family/test/authoring-entity-types.enum.test.ts diff --git a/packages/1-framework/1-core/framework-components/src/exports/authoring.ts b/packages/1-framework/1-core/framework-components/src/exports/authoring.ts index 3c09013796..88d96d64df 100644 --- a/packages/1-framework/1-core/framework-components/src/exports/authoring.ts +++ b/packages/1-framework/1-core/framework-components/src/exports/authoring.ts @@ -21,6 +21,7 @@ export type { } from '../shared/framework-authoring'; export { assertNoCrossRegistryCollisions, + classifyEnumMemberType, hasRegisteredFieldNamespace, instantiateAuthoringEntityType, instantiateAuthoringFieldPreset, diff --git a/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts b/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts index 3c2d3aa4ba..9a4065bb06 100644 --- a/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts +++ b/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts @@ -11,7 +11,9 @@ import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import type { Type } from 'arktype'; import type { CodecLookup } from './codec-types'; -import type { PslBlockParam } from './psl-extension-block'; +import type { PslBlockParam, PslExtensionBlock } from './psl-extension-block'; + +export type EnumInferredMemberType = 'text' | 'int'; export type AuthoringArgRef = { readonly kind: 'arg'; @@ -134,6 +136,56 @@ export interface AuthoringEntityContext { readonly sourceId?: string; /** Push channel for authoring-time diagnostics emitted by the factory. */ readonly diagnostics?: AuthoringDiagnosticSink; + /** + * The target's default codec ids for an `enum` block that omits `@@type`. + * `text` is used when every member is a bare name or a string value; + * `int` is used when every member is an integer value. Every target pack + * populates this so `@@type` omission can be inferred consistently. + */ + readonly enumInferenceCodecs?: { readonly text: string; readonly int: string }; +} + +/** + * Classifies an `enum` block's members (before codec decoding, which needs + * the codec chosen first) into which default codec an omitted `@@type` + * should resolve to: + * + * - every member is `bare`, or a `value` whose raw JSON is a string → `'text'` + * - every member is a `value` whose raw JSON is an integer → `'int'` + * - anything else (float, bigint, boolean, mixed, or a `ref`/`option`/`list` + * parameter) → `null`, meaning the caller must require an explicit `@@type`. + */ +export function classifyEnumMemberType(block: PslExtensionBlock): 'text' | 'int' | null { + let sawText = false; + let sawInt = false; + + for (const paramValue of Object.values(block.parameters)) { + if (paramValue.kind === 'bare') { + sawText = true; + continue; + } + if (paramValue.kind !== 'value') { + return null; + } + let jsonValue: unknown; + try { + jsonValue = JSON.parse(paramValue.raw); + } catch { + return null; + } + if (typeof jsonValue === 'string') { + sawText = true; + } else if (typeof jsonValue === 'number' && Number.isInteger(jsonValue)) { + sawInt = true; + } else { + return null; + } + } + + if (sawText && sawInt) return null; + if (sawText) return 'text'; + if (sawInt) return 'int'; + return null; } export interface AuthoringEntityTypeTemplateOutput { diff --git a/packages/1-framework/1-core/framework-components/test/framework-components.authoring.test.ts b/packages/1-framework/1-core/framework-components/test/framework-components.authoring.test.ts index 00ee7fd642..548582b4b7 100644 --- a/packages/1-framework/1-core/framework-components/test/framework-components.authoring.test.ts +++ b/packages/1-framework/1-core/framework-components/test/framework-components.authoring.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { AuthoringTypeConstructorDescriptor } from '../src/shared/framework-authoring'; import { + classifyEnumMemberType, hasRegisteredFieldNamespace, instantiateAuthoringFieldPreset, instantiateAuthoringTypeConstructor, @@ -10,6 +11,10 @@ import { resolveAuthoringTemplateValue, validateAuthoringHelperArguments, } from '../src/shared/framework-authoring'; +import type { + PslExtensionBlock, + PslExtensionBlockParamValue, +} from '../src/shared/psl-extension-block'; describe('authoring template resolution', () => { it('detects authoring descriptor kinds', () => { @@ -490,3 +495,80 @@ describe('authoring template resolution', () => { }); }); }); + +describe('classifyEnumMemberType', () => { + const testSpan = { + start: { offset: 0, line: 1, column: 1 }, + end: { offset: 0, line: 1, column: 1 }, + }; + + function testBlock(parameters: Record): PslExtensionBlock { + return { kind: 'enum', name: 'TestEnum', parameters, blockAttributes: [], span: testSpan }; + } + + const bare: PslExtensionBlockParamValue = { kind: 'bare', span: testSpan }; + const value = (raw: string): PslExtensionBlockParamValue => ({ + kind: 'value', + raw, + span: testSpan, + }); + const ref: PslExtensionBlockParamValue = { kind: 'ref', identifier: 'Foo', span: testSpan }; + const option: PslExtensionBlockParamValue = { kind: 'option', token: 'Foo', span: testSpan }; + const list: PslExtensionBlockParamValue = { kind: 'list', items: [], span: testSpan }; + + it('classifies all-bare members as text', () => { + expect(classifyEnumMemberType(testBlock({ Admin: bare, User: bare }))).toBe('text'); + }); + + it('classifies all-string-value members as text', () => { + expect( + classifyEnumMemberType(testBlock({ Admin: value('"admin"'), User: value('"user"') })), + ).toBe('text'); + }); + + it('classifies a mix of bare and string-value members as text', () => { + expect(classifyEnumMemberType(testBlock({ Admin: bare, User: value('"user"') }))).toBe('text'); + }); + + it('classifies all-integer-value members as int', () => { + expect(classifyEnumMemberType(testBlock({ Low: value('1'), High: value('10') }))).toBe('int'); + }); + + it('returns null for a float value', () => { + expect(classifyEnumMemberType(testBlock({ Low: value('1.5') }))).toBeNull(); + }); + + it('returns null for a boolean value', () => { + expect(classifyEnumMemberType(testBlock({ Flag: value('true') }))).toBeNull(); + }); + + it('returns null for a mix of string and integer values', () => { + expect( + classifyEnumMemberType(testBlock({ Low: value('1'), High: value('"high"') })), + ).toBeNull(); + }); + + it('returns null for a mix of bare and integer values', () => { + expect(classifyEnumMemberType(testBlock({ Admin: bare, Low: value('1') }))).toBeNull(); + }); + + it('returns null for a ref parameter', () => { + expect(classifyEnumMemberType(testBlock({ Admin: ref }))).toBeNull(); + }); + + it('returns null for an option parameter', () => { + expect(classifyEnumMemberType(testBlock({ Admin: option }))).toBeNull(); + }); + + it('returns null for a list parameter', () => { + expect(classifyEnumMemberType(testBlock({ Admin: list }))).toBeNull(); + }); + + it('returns null for invalid JSON in a value parameter', () => { + expect(classifyEnumMemberType(testBlock({ Admin: value('notjson') }))).toBeNull(); + }); + + it('returns null for an enum with no members', () => { + expect(classifyEnumMemberType(testBlock({}))).toBeNull(); + }); +}); diff --git a/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts b/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts index ca17481318..60a0f77cad 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts @@ -958,6 +958,9 @@ export function interpretPslDocumentToMongoContract( entityContext: { family: 'mongo', target: 'mongo', + // Mongo @@type inference lands in TML-2915 D2; this satisfies the + // required field until Mongo's own factory reads it. + enumInferenceCodecs: { text: 'mongo/string@1', int: 'mongo/int32@1' }, ...ifDefined('codecLookup', codecLookup), sourceId, diagnostics: { diff --git a/packages/2-mongo-family/9-family/src/core/authoring-entity-types.ts b/packages/2-mongo-family/9-family/src/core/authoring-entity-types.ts index e94796523b..abbc829b92 100644 --- a/packages/2-mongo-family/9-family/src/core/authoring-entity-types.ts +++ b/packages/2-mongo-family/9-family/src/core/authoring-entity-types.ts @@ -1,10 +1,11 @@ import type { JsonValue } from '@prisma-next/contract/types'; -import type { - AuthoringEntityContext, - AuthoringEntityTypeDescriptor, - AuthoringEntityTypeNamespace, - AuthoringPslBlockDescriptorNamespace, - PslExtensionBlock, +import { + type AuthoringEntityContext, + type AuthoringEntityTypeDescriptor, + type AuthoringEntityTypeNamespace, + type AuthoringPslBlockDescriptorNamespace, + classifyEnumMemberType, + type PslExtensionBlock, } from '@prisma-next/framework-components/authoring'; import { type EnumTypeHandle, enumType } from '@prisma-next/mongo-contract-ts/contract-builder'; import { blindCast } from '@prisma-next/utils/casts'; @@ -28,31 +29,40 @@ export const mongoFamilyEnumEntityDescriptor = { const diagnostics = ctx.diagnostics; const typeAttr = block.blockAttributes.find((a) => a.name === 'type'); + + let codecId: string; if (!typeAttr) { - diagnostics?.push({ - code: 'PSL_ENUM_MISSING_TYPE', - message: `enum "${block.name}" is missing a @@type("codecId") attribute`, - sourceId, - span: block.span, - }); - return undefined; + const inferredKind = classifyEnumMemberType(block); + if (inferredKind === null || !ctx.enumInferenceCodecs) { + diagnostics?.push({ + code: 'PSL_ENUM_CANNOT_INFER_TYPE', + message: `cannot infer @@type for enum "${block.name}"; add an explicit @@type(...)`, + sourceId, + span: block.span, + }); + return undefined; + } + codecId = ctx.enumInferenceCodecs[inferredKind]; + } else { + const rawCodecArg = typeAttr.args[0]?.value; + const explicitCodecId = + rawCodecArg !== undefined ? parseQuotedString(rawCodecArg) : undefined; + if (!explicitCodecId) { + diagnostics?.push({ + code: 'PSL_ENUM_MISSING_TYPE', + message: `enum "${block.name}" @@type attribute must have a quoted codec id argument`, + sourceId, + span: typeAttr.span, + }); + return undefined; + } + codecId = explicitCodecId; } - const rawCodecArg = typeAttr.args[0]?.value; - const codecId = rawCodecArg !== undefined ? parseQuotedString(rawCodecArg) : undefined; - if (!codecId) { - diagnostics?.push({ - code: 'PSL_ENUM_MISSING_TYPE', - message: `enum "${block.name}" @@type attribute must have a quoted codec id argument`, - sourceId, - span: typeAttr.span, - }); - return undefined; - } + const typeArgSpan = typeAttr?.args[0]?.span ?? typeAttr?.span ?? block.span; const nativeType = ctx.codecLookup?.targetTypesFor(codecId)?.[0]; if (nativeType === undefined) { - const typeArgSpan = typeAttr.args[0]?.span ?? typeAttr.span; diagnostics?.push({ code: 'PSL_EXTENSION_INVALID_VALUE', message: `enum "${block.name}" @@type references unknown codec "${codecId}"`, @@ -64,7 +74,6 @@ export const mongoFamilyEnumEntityDescriptor = { const codec = ctx.codecLookup?.get(codecId); if (codec === undefined) { - const typeArgSpan = typeAttr.args[0]?.span ?? typeAttr.span; diagnostics?.push({ code: 'PSL_EXTENSION_INVALID_VALUE', message: `enum "${block.name}" @@type codec "${codecId}" resolves in targetTypesFor but is absent from codecLookup.get`, diff --git a/packages/2-mongo-family/9-family/test/authoring-entity-types.enum.test.ts b/packages/2-mongo-family/9-family/test/authoring-entity-types.enum.test.ts new file mode 100644 index 0000000000..89fbccc7f9 --- /dev/null +++ b/packages/2-mongo-family/9-family/test/authoring-entity-types.enum.test.ts @@ -0,0 +1,177 @@ +import type { + AuthoringDiagnosticSink, + AuthoringEntityContext, + PslExtensionBlock, + PslExtensionBlockParamValue, +} from '@prisma-next/framework-components/authoring'; +import type { Codec, CodecLookup } from '@prisma-next/framework-components/codec'; +import { describe, expect, it } from 'vitest'; +import { mongoFamilyEnumEntityDescriptor } from '../src/core/authoring-entity-types'; + +const SPAN = { + start: { offset: 0, line: 1, column: 1 }, + end: { offset: 0, line: 1, column: 1 }, +}; + +function bareMember(): PslExtensionBlockParamValue { + return { kind: 'bare', span: SPAN }; +} + +function valueMember(raw: string): PslExtensionBlockParamValue { + return { kind: 'value', raw, span: SPAN }; +} + +function typeAttr(codecId: string) { + return { + name: 'type', + args: [{ kind: 'positional' as const, value: `"${codecId}"`, span: SPAN }], + span: SPAN, + }; +} + +function enumBlock(input: { + readonly name: string; + readonly parameters: Record; + readonly typeCodecId?: string; +}): PslExtensionBlock { + return { + kind: 'enum', + name: input.name, + parameters: input.parameters, + blockAttributes: input.typeCodecId !== undefined ? [typeAttr(input.typeCodecId)] : [], + span: SPAN, + }; +} + +const MONGO_STRING_CODEC_ID = 'mongo/string@1'; +const MONGO_INT_CODEC_ID = 'mongo/int32@1'; + +const mongoStringCodec: Codec = { + id: MONGO_STRING_CODEC_ID, + encode: async (v: unknown) => v, + decode: async (w: unknown) => w, + encodeJson: (value) => value as never, + decodeJson(json) { + if (typeof json !== 'string') throw new Error(`expected string, got ${typeof json}`); + return json; + }, +}; + +const mongoIntCodec: Codec = { + id: MONGO_INT_CODEC_ID, + encode: async (v: unknown) => v, + decode: async (w: unknown) => w, + encodeJson: (value) => value as never, + decodeJson(json) { + if (typeof json !== 'number') throw new Error(`expected number, got ${typeof json}`); + return json; + }, +}; + +const testCodecLookup: CodecLookup = { + get(id: string): Codec | undefined { + if (id === MONGO_STRING_CODEC_ID) return mongoStringCodec; + if (id === MONGO_INT_CODEC_ID) return mongoIntCodec; + return undefined; + }, + targetTypesFor(id: string): readonly string[] | undefined { + if (id === MONGO_STRING_CODEC_ID) return ['string']; + if (id === MONGO_INT_CODEC_ID) return ['int']; + return undefined; + }, + metaFor: () => undefined, + renderOutputTypeFor: () => undefined, +}; + +function makeContext(diagnostics: unknown[]): AuthoringEntityContext { + const sink: AuthoringDiagnosticSink = { + push: (d) => diagnostics.push(d), + }; + return { + family: 'mongo', + target: 'mongo', + codecLookup: testCodecLookup, + sourceId: 'schema.prisma', + diagnostics: sink, + enumInferenceCodecs: { text: MONGO_STRING_CODEC_ID, int: MONGO_INT_CODEC_ID }, + }; +} + +const factory = mongoFamilyEnumEntityDescriptor.output.factory; + +describe('mongoFamilyEnumEntityDescriptor: @@type omitted, inferred from members', () => { + it('bare members infer the string codec', () => { + const diagnostics: unknown[] = []; + const handle = factory( + enumBlock({ name: 'Role', parameters: { admin: bareMember(), user: bareMember() } }), + makeContext(diagnostics), + ); + + expect(diagnostics).toEqual([]); + expect(handle).toMatchObject({ + codecId: MONGO_STRING_CODEC_ID, + nativeType: 'string', + members: { admin: 'admin', user: 'user' }, + }); + }); + + it('integer-value members infer the int codec', () => { + const diagnostics: unknown[] = []; + const handle = factory( + enumBlock({ + name: 'Priority', + parameters: { low: valueMember('1'), high: valueMember('2') }, + }), + makeContext(diagnostics), + ); + + expect(diagnostics).toEqual([]); + expect(handle).toMatchObject({ + codecId: MONGO_INT_CODEC_ID, + nativeType: 'int', + members: { low: 1, high: 2 }, + }); + }); + + it('a float member cannot be inferred', () => { + const diagnostics: unknown[] = []; + const handle = factory( + enumBlock({ name: 'Priority', parameters: { low: valueMember('1.5') } }), + makeContext(diagnostics), + ); + + expect(handle).toBeUndefined(); + expect(diagnostics).toEqual([expect.objectContaining({ code: 'PSL_ENUM_CANNOT_INFER_TYPE' })]); + }); + + it('a mix of string and integer members cannot be inferred', () => { + const diagnostics: unknown[] = []; + const handle = factory( + enumBlock({ + name: 'Mixed', + parameters: { low: valueMember('"low"'), high: valueMember('2') }, + }), + makeContext(diagnostics), + ); + + expect(handle).toBeUndefined(); + expect(diagnostics).toEqual([expect.objectContaining({ code: 'PSL_ENUM_CANNOT_INFER_TYPE' })]); + }); +}); + +describe('mongoFamilyEnumEntityDescriptor: explicit @@type is unchanged', () => { + it('an explicit @@type resolving to string lowers exactly as before', () => { + const diagnostics: unknown[] = []; + const handle = factory( + enumBlock({ + name: 'Role', + parameters: { admin: valueMember('"admin"') }, + typeCodecId: MONGO_STRING_CODEC_ID, + }), + makeContext(diagnostics), + ); + + expect(diagnostics).toEqual([]); + expect(handle).toMatchObject({ codecId: MONGO_STRING_CODEC_ID, nativeType: 'string' }); + }); +}); 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 ebdedb647a..5660777b91 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -129,6 +129,8 @@ export interface InterpretPslDocumentToSqlContractInput { readonly createNamespace: (input: SqlNamespaceInput) => SqlNamespaceBase; readonly codecLookup?: CodecLookup; readonly seedDiagnostics?: readonly ContractSourceDiagnostic[]; + /** The target's default codec ids for an `enum` block that omits `@@type`. */ + readonly enumInferenceCodecs?: { readonly text: string; readonly int: string }; } function buildComposedExtensionPackRefs( @@ -1839,6 +1841,7 @@ export function interpretPslDocumentToSqlContract( ); }, }, + ...ifDefined('enumInferenceCodecs', input.enumInferenceCodecs), }, diagnostics, }); @@ -2016,6 +2019,7 @@ export function interpretPslDocumentToSqlContract( const extensionEntityContext: AuthoringEntityContext = { family: input.target.familyId, target: input.target.targetId, + ...ifDefined('enumInferenceCodecs', input.enumInferenceCodecs), }; const namespaceExtensionEntities = new Map< string, diff --git a/packages/2-sql/2-authoring/contract-psl/src/provider.ts b/packages/2-sql/2-authoring/contract-psl/src/provider.ts index 9080471228..60dae16f57 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/provider.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/provider.ts @@ -21,6 +21,8 @@ export interface PrismaContractOptions { readonly composedExtensionPackRefs?: readonly ExtensionPackRef<'sql', string>[]; readonly createNamespace: (input: SqlNamespaceInput) => SqlNamespaceBase; readonly defaultControlPolicy?: ControlPolicy; + /** The target's default codec ids for an `enum` block that omits `@@type`. */ + readonly enumInferenceCodecs?: { readonly text: string; readonly int: string }; } /** @@ -144,6 +146,7 @@ export function prismaContract(schemaPath: string, options: PrismaContractOption controlMutationDefaults: context.controlMutationDefaults, createNamespace: options.createNamespace, codecLookup: context.codecLookup, + ...ifDefined('enumInferenceCodecs', options.enumInferenceCodecs), }); if (!interpreted.ok) { return interpreted; diff --git a/packages/2-sql/2-authoring/contract-psl/test/composed-mutation-defaults.test.ts b/packages/2-sql/2-authoring/contract-psl/test/composed-mutation-defaults.test.ts index 5824b9452b..bab87571d1 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/composed-mutation-defaults.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/composed-mutation-defaults.test.ts @@ -9,6 +9,7 @@ import { interpretPslDocumentToSqlContract as interpretPslDocumentToSqlContractInternal, } from '../src/interpreter'; import { + postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, symbolTableInputFromParseArgs, @@ -18,7 +19,11 @@ describe('composed mutation default registries', () => { const interpretPslDocumentToSqlContract = ( input: Omit< InterpretPslDocumentToSqlContractInput, - 'target' | 'scalarTypeDescriptors' | 'composedExtensionContracts' | 'createNamespace' + | 'target' + | 'scalarTypeDescriptors' + | 'composedExtensionContracts' + | 'createNamespace' + | 'enumInferenceCodecs' > & Partial>, ) => @@ -27,6 +32,7 @@ describe('composed mutation default registries', () => { scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + enumInferenceCodecs: postgresEnumInferenceCodecs, ...input, }); diff --git a/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts b/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts index df220086c9..bb7b64a7fb 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts @@ -7,12 +7,13 @@ import { domainModelsAtDefaultNamespace, domainValueObjectsAtDefaultNamespace, } from '@prisma-next/contract/types'; -import type { - AuthoringContributions, - AuthoringEntityContext, - AuthoringEntityTypeNamespace, - AuthoringPslBlockDescriptorNamespace, - PslExtensionBlock, +import { + type AuthoringContributions, + type AuthoringEntityContext, + type AuthoringEntityTypeNamespace, + type AuthoringPslBlockDescriptorNamespace, + classifyEnumMemberType, + type PslExtensionBlock, } from '@prisma-next/framework-components/authoring'; import type { CodecLookup } from '@prisma-next/framework-components/codec'; import type { ExtensionPackRef, TargetPackRef } from '@prisma-next/framework-components/components'; @@ -38,26 +39,34 @@ function testEnumFactory( const diagnostics = ctx.diagnostics; const typeAttr = block.blockAttributes.find((a) => a.name === 'type'); - if (!typeAttr) { - diagnostics?.push({ - code: 'PSL_ENUM_MISSING_TYPE', - message: `enum "${block.name}" is missing a @@type("codecId") attribute`, - sourceId, - span: block.span, - }); - return undefined; - } - const rawArg = typeAttr.args[0]?.value; - const codecId = rawArg?.startsWith('"') && rawArg.endsWith('"') ? rawArg.slice(1, -1) : undefined; - if (!codecId) { - diagnostics?.push({ - code: 'PSL_ENUM_MISSING_TYPE', - message: `enum "${block.name}" @@type attribute must have a quoted codec id argument`, - sourceId, - span: typeAttr.span, - }); - return undefined; + let codecId: string; + if (!typeAttr) { + const inferredKind = classifyEnumMemberType(block); + if (inferredKind === null || !ctx.enumInferenceCodecs) { + diagnostics?.push({ + code: 'PSL_ENUM_CANNOT_INFER_TYPE', + message: `cannot infer @@type for enum "${block.name}"; add an explicit @@type(...)`, + sourceId, + span: block.span, + }); + return undefined; + } + codecId = ctx.enumInferenceCodecs[inferredKind]; + } else { + const rawArg = typeAttr.args[0]?.value; + const explicitCodecId = + rawArg?.startsWith('"') && rawArg.endsWith('"') ? rawArg.slice(1, -1) : undefined; + if (!explicitCodecId) { + diagnostics?.push({ + code: 'PSL_ENUM_MISSING_TYPE', + message: `enum "${block.name}" @@type attribute must have a quoted codec id argument`, + sourceId, + span: typeAttr.span, + }); + return undefined; + } + codecId = explicitCodecId; } const nativeType = ctx.codecLookup?.targetTypesFor(codecId)?.[0]; @@ -66,7 +75,7 @@ function testEnumFactory( code: 'PSL_EXTENSION_INVALID_VALUE', message: `enum "${block.name}" @@type references unknown codec "${codecId}"`, sourceId, - span: typeAttr.args[0]?.span ?? typeAttr.span, + span: typeAttr?.args[0]?.span ?? typeAttr?.span ?? block.span, }); return undefined; } @@ -77,7 +86,7 @@ function testEnumFactory( code: 'PSL_EXTENSION_INVALID_VALUE', message: `enum "${block.name}" @@type codec "${codecId}" resolves in targetTypesFor but is absent from codecLookup.get`, sourceId, - span: typeAttr.args[0]?.span ?? typeAttr.span, + span: typeAttr?.args[0]?.span ?? typeAttr?.span ?? block.span, }); return undefined; } @@ -236,6 +245,16 @@ function parseStringLiteral(raw: string): string | undefined { return match?.[2]; } +export const postgresEnumInferenceCodecs = { + text: 'pg/text@1', + int: 'pg/int@1', +} as const; + +export const sqliteEnumInferenceCodecs = { + text: 'sqlite/text@1', + int: 'sqlite/integer@1', +} as const; + export const postgresTarget: TargetPackRef<'sql', 'postgres'> = { kind: 'target', familyId: 'sql', @@ -312,6 +331,7 @@ export function buildSymbolTableInput( sourceFile: SourceFile; sourceId: string; seedDiagnostics: ContractSourceDiagnostic[]; + enumInferenceCodecs: { readonly text: string; readonly int: string }; } { const sourceId = options?.sourceId ?? 'schema.prisma'; const scalarTypes = options?.scalarTypes ?? [...postgresScalarTypeDescriptors.keys()]; @@ -329,7 +349,13 @@ export function buildSymbolTableInput( sourceId, span: rangeToPslSpan(diagnostic.range, sourceFile), })); - return { symbolTable: table, sourceFile, sourceId, seedDiagnostics }; + return { + symbolTable: table, + sourceFile, + sourceId, + seedDiagnostics, + enumInferenceCodecs: postgresEnumInferenceCodecs, + }; } export function symbolTableInputFromParseArgs(args: { @@ -341,6 +367,7 @@ export function symbolTableInputFromParseArgs(args: { sourceFile: SourceFile; sourceId: string; seedDiagnostics: ContractSourceDiagnostic[]; + enumInferenceCodecs: { readonly text: string; readonly int: string }; } { return buildSymbolTableInput(args.schema, { ...(args.sourceId !== undefined ? { sourceId: args.sourceId } : {}), @@ -376,6 +403,7 @@ export const postgresCodecIdOnlyDescriptors = new Map([ const targetTypesByCodecId: Record = { 'pg/text@1': ['text'], + 'pg/int@1': ['int4'], 'pg/bool@1': ['bool'], 'pg/int4@1': ['int4'], 'pg/int8@1': ['int8'], diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.control-policy.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.control-policy.test.ts index bb5cc2ed95..286f48156d 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.control-policy.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.control-policy.test.ts @@ -6,6 +6,7 @@ import { createTestSqlNamespace } from '../../../1-core/contract/test/test-suppo import { interpretPslDocumentToSqlContract } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, + postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, symbolTableInputFromParseArgs, @@ -24,6 +25,7 @@ function interpretSchema(schema: string) { composedExtensionContracts: new Map(), controlMutationDefaults: builtinControlMutationDefaults, createNamespace: createTestSqlNamespace, + enumInferenceCodecs: postgresEnumInferenceCodecs, }); } diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.test.ts index 782ab185ef..63368af2e6 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.test.ts @@ -6,8 +6,10 @@ import { } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, + postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, + sqliteEnumInferenceCodecs, sqliteScalarTypeDescriptors, sqliteTarget, symbolTableInputFromParseArgs, @@ -20,7 +22,11 @@ describe('interpretPslDocumentToSqlContract default lowering', () => { const interpretPslDocumentToSqlContract = ( input: Omit< InterpretPslDocumentToSqlContractInput, - 'target' | 'scalarTypeDescriptors' | 'composedExtensionContracts' | 'createNamespace' + | 'target' + | 'scalarTypeDescriptors' + | 'composedExtensionContracts' + | 'createNamespace' + | 'enumInferenceCodecs' > & Partial>, ) => @@ -29,6 +35,7 @@ describe('interpretPslDocumentToSqlContract default lowering', () => { scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + enumInferenceCodecs: postgresEnumInferenceCodecs, ...input, }); it('lowers supported default functions into execution and storage contract shapes', () => { @@ -498,6 +505,7 @@ model UuidNativeBad { controlMutationDefaults: builtinControlMutationDefaults, authoringContributions: sqliteTemporalContributions, createNamespace: createTestSqlNamespace, + enumInferenceCodecs: sqliteEnumInferenceCodecs, }); expect(result.ok).toBe(true); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts index 6945f4269d..bb07b42b41 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts @@ -7,8 +7,10 @@ import { import { createBuiltinLikeControlMutationDefaults, documentScopedTypes, + postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, + sqliteEnumInferenceCodecs, sqliteScalarTypeDescriptors, sqliteTarget, symbolTableInputFromParseArgs, @@ -19,6 +21,7 @@ const baseInput = { scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + enumInferenceCodecs: postgresEnumInferenceCodecs, } as const; const builtinControlMutationDefaults = createBuiltinLikeControlMutationDefaults(); @@ -1085,6 +1088,7 @@ model User { ...document, controlMutationDefaults: builtinControlMutationDefaults, createNamespace: createTestSqlNamespace, + enumInferenceCodecs: sqliteEnumInferenceCodecs, }); expect(result.ok).toBe(false); @@ -1121,6 +1125,7 @@ model User { ...document, controlMutationDefaults: builtinControlMutationDefaults, createNamespace: createTestSqlNamespace, + enumInferenceCodecs: sqliteEnumInferenceCodecs, }); expect(result.ok).toBe(false); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.enum.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.enum.test.ts index 881958c154..59bb73fec8 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.enum.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.enum.test.ts @@ -16,8 +16,12 @@ import { } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, + postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, + sqliteEnumInferenceCodecs, + sqliteScalarTypeDescriptors, + sqliteTarget, symbolTableInputFromParseArgs, testEnumEntityContributions, } from './fixtures'; @@ -48,16 +52,32 @@ const int4Codec: Codec = { }, }; +const pgIntCodec: Codec = { ...int4Codec, id: 'pg/int@1' }; +const sqliteTextCodec: Codec = { ...textCodec, id: 'sqlite/text@1' }; +const sqliteIntegerCodec: Codec = { ...int4Codec, id: 'sqlite/integer@1' }; + +const codecsById: Record = { + 'pg/text@1': textCodec, + 'pg/int4@1': int4Codec, + 'pg/int@1': pgIntCodec, + 'sqlite/text@1': sqliteTextCodec, + 'sqlite/integer@1': sqliteIntegerCodec, +}; + +const targetTypesById: Record = { + 'pg/text@1': ['text'], + 'pg/int4@1': ['int4'], + 'pg/int@1': ['int4'], + 'sqlite/text@1': ['text'], + 'sqlite/integer@1': ['integer'], +}; + const testCodecLookup: CodecLookup = { get(id: string): Codec | undefined { - if (id === 'pg/text@1') return textCodec; - if (id === 'pg/int4@1') return int4Codec; - return undefined; + return codecsById[id]; }, targetTypesFor(id: string): readonly string[] | undefined { - if (id === 'pg/text@1') return ['text']; - if (id === 'pg/int4@1') return ['int4']; - return undefined; + return targetTypesById[id]; }, metaFor: () => undefined, renderOutputTypeFor: () => undefined, @@ -98,6 +118,7 @@ function interpret(schema: string, overrides?: Partial { - it('missing @@type emits diagnostic', () => { + it('missing @@type with non-inferable members emits PSL_ENUM_CANNOT_INFER_TYPE', () => { const result = interpret(` enum Priority { - Low = "low" + Low = 1.5 } model Post { id Int @id @@ -277,7 +298,7 @@ model Post { expect(result.ok).toBe(false); if (result.ok) return; expect(result.failure.diagnostics).toEqual( - expect.arrayContaining([expect.objectContaining({ code: 'PSL_ENUM_MISSING_TYPE' })]), + expect.arrayContaining([expect.objectContaining({ code: 'PSL_ENUM_CANNOT_INFER_TYPE' })]), ); }); @@ -576,6 +597,203 @@ model User { }); }); +// --------------------------------------------------------------------------- +// @@type inference from members (TML-2915) +// --------------------------------------------------------------------------- + +describe.each([ + { + targetName: 'postgres', + target: postgresTarget, + scalarTypeDescriptors: postgresScalarTypeDescriptors, + enumInferenceCodecs: postgresEnumInferenceCodecs, + }, + { + targetName: 'sqlite', + target: sqliteTarget, + scalarTypeDescriptors: sqliteScalarTypeDescriptors, + enumInferenceCodecs: sqliteEnumInferenceCodecs, + }, +])('enum @@type inference ($targetName)', ({ + target, + scalarTypeDescriptors, + enumInferenceCodecs, +}) => { + const namespaceId = target.defaultNamespaceId; + + it('no @@type, all-bare members infers the target text codec', () => { + const result = interpret( + ` +enum Role { + Admin + User +} +model Post { + id Int @id + role Role +} +`, + { target, scalarTypeDescriptors, enumInferenceCodecs }, + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + const domainNs = result.value.domain.namespaces[namespaceId]; + expect(domainNs?.enum?.['Role']).toMatchObject({ codecId: enumInferenceCodecs.text }); + const ns = (result.value.storage as unknown as SqlStorage).namespaces[namespaceId]; + expect(ns?.entries.valueSet?.['Role']).toMatchObject({ + kind: 'valueSet', + values: ['Admin', 'User'], + }); + }); + + it('no @@type, all-string-value members infers the target text codec', () => { + const result = interpret( + ` +enum Role { + Admin = "admin" + User = "user" +} +model Post { + id Int @id + role Role +} +`, + { target, scalarTypeDescriptors, enumInferenceCodecs }, + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + const domainNs = result.value.domain.namespaces[namespaceId]; + expect(domainNs?.enum?.['Role']).toMatchObject({ codecId: enumInferenceCodecs.text }); + const ns = (result.value.storage as unknown as SqlStorage).namespaces[namespaceId]; + expect(ns?.entries.valueSet?.['Role']).toMatchObject({ + kind: 'valueSet', + values: ['admin', 'user'], + }); + }); + + it('no @@type, all-integer-value members infers the target int codec', () => { + const result = interpret( + ` +enum Priority { + Low = 1 + High = 2 +} +model Post { + id Int @id + priority Priority +} +`, + { target, scalarTypeDescriptors, enumInferenceCodecs }, + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + const domainNs = result.value.domain.namespaces[namespaceId]; + expect(domainNs?.enum?.['Priority']).toMatchObject({ codecId: enumInferenceCodecs.int }); + const ns = (result.value.storage as unknown as SqlStorage).namespaces[namespaceId]; + expect(ns?.entries.valueSet?.['Priority']).toMatchObject({ + kind: 'valueSet', + values: [1, 2], + }); + }); + + it('explicit @@type is unchanged: same codec and diagnostics as before this slice', () => { + const result = interpret( + ` +enum Priority { + @@type("${enumInferenceCodecs.text}") + Low = "low" + High = "high" +} +model Post { + id Int @id + priority Priority +} +`, + { target, scalarTypeDescriptors, enumInferenceCodecs }, + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + const domainNs = result.value.domain.namespaces[namespaceId]; + expect(domainNs?.enum?.['Priority']).toMatchObject({ codecId: enumInferenceCodecs.text }); + }); + + it('no @@type, a mix of string and integer member values cannot be inferred', () => { + const result = interpret( + ` +enum Mixed { + Low = "low" + High = 2 +} +model Post { + id Int @id + mixed Mixed +} +`, + { target, scalarTypeDescriptors, enumInferenceCodecs }, + ); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_ENUM_CANNOT_INFER_TYPE', + message: expect.stringMatching(/Mixed/), + }), + ]), + ); + expect( + result.failure.diagnostics.find((d) => d.code === 'PSL_ENUM_CANNOT_INFER_TYPE')?.message, + ).toMatch(/@@type/); + }); + + it('no @@type, a float member value cannot be inferred', () => { + const result = interpret( + ` +enum Priority { + Low = 1.5 +} +model Post { + id Int @id + priority Priority +} +`, + { target, scalarTypeDescriptors, enumInferenceCodecs }, + ); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([expect.objectContaining({ code: 'PSL_ENUM_CANNOT_INFER_TYPE' })]), + ); + }); + + it('no @@type, a boolean member value cannot be inferred', () => { + const result = interpret( + ` +enum Flag { + On = true +} +model Post { + id Int @id + flag Flag +} +`, + { target, scalarTypeDescriptors, enumInferenceCodecs }, + ); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([expect.objectContaining({ code: 'PSL_ENUM_CANNOT_INFER_TYPE' })]), + ); + }); +}); + // --------------------------------------------------------------------------- // Non-string codec happy path // --------------------------------------------------------------------------- diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.extensions.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.extensions.test.ts index 6a3f6341d4..621018242b 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.extensions.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.extensions.test.ts @@ -6,6 +6,7 @@ import { documentScopedTypes, pgvectorAuthoringContributions, pgvectorExtensionPack, + postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, symbolTableInputFromParseArgs, @@ -16,6 +17,7 @@ const baseInput = { scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + enumInferenceCodecs: postgresEnumInferenceCodecs, } as const; describe('interpretPslDocumentToSqlContract extensions', () => { diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts index fc0100565b..20d0bd02ae 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts @@ -7,6 +7,7 @@ import { createTestSqlNamespace } from '../../../1-core/contract/test/test-suppo import { interpretPslDocumentToSqlContract } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, + postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, symbolTableInputFromParseArgs, @@ -102,6 +103,7 @@ const baseInput = { controlMutationDefaults: createBuiltinLikeControlMutationDefaults(), composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + enumInferenceCodecs: postgresEnumInferenceCodecs, } as const; describe('un-namespaced PG model defaults to public namespace (TML-2916)', () => { diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts index 7cd9a2051f..dd538eddf9 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts @@ -11,6 +11,7 @@ import { createBuiltinLikeControlMutationDefaults, documentScopedTypes, modelsOf, + postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, symbolTableInputFromParseArgs, @@ -21,7 +22,11 @@ describe('interpretPslDocumentToSqlContract — polymorphism', () => { const interpretPslDocumentToSqlContract = ( input: Omit< InterpretPslDocumentToSqlContractInput, - 'target' | 'scalarTypeDescriptors' | 'composedExtensionContracts' | 'createNamespace' + | 'target' + | 'scalarTypeDescriptors' + | 'composedExtensionContracts' + | 'createNamespace' + | 'enumInferenceCodecs' > & Partial>, ) => @@ -30,6 +35,7 @@ describe('interpretPslDocumentToSqlContract — polymorphism', () => { scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + enumInferenceCodecs: postgresEnumInferenceCodecs, ...input, }); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.many-to-many.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.many-to-many.test.ts index d2aa4a706d..fe7b90c64f 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.many-to-many.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.many-to-many.test.ts @@ -8,6 +8,7 @@ import { interpretPslDocumentToSqlContract } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, modelsOf, + postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, symbolTableInputFromParseArgs, @@ -19,6 +20,7 @@ const baseInput = { controlMutationDefaults: createBuiltinLikeControlMutationDefaults(), composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + enumInferenceCodecs: postgresEnumInferenceCodecs, } as const; function interpretSchema(schema: string) { 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 62c74619f6..ff58b1fb46 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 @@ -5,6 +5,7 @@ import { interpretPslDocumentToSqlContract } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, modelsOf, + postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, symbolTableInputFromParseArgs, @@ -17,6 +18,7 @@ const baseInput = { scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + enumInferenceCodecs: postgresEnumInferenceCodecs, } as const; const builtinControlMutationDefaults = createBuiltinLikeControlMutationDefaults(); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts index 3d3b3ad30a..ff230ebf12 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts @@ -10,6 +10,7 @@ import { import { createBuiltinLikeControlMutationDefaults, modelsOf, + postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, symbolTableInputFromParseArgs, @@ -32,7 +33,11 @@ describe('interpretPslDocumentToSqlContract', () => { const interpretPslDocumentToSqlContract = ( input: Omit< InterpretPslDocumentToSqlContractInput, - 'target' | 'scalarTypeDescriptors' | 'composedExtensionContracts' | 'createNamespace' + | 'target' + | 'scalarTypeDescriptors' + | 'composedExtensionContracts' + | 'createNamespace' + | 'enumInferenceCodecs' > & Partial>, ) => @@ -42,6 +47,7 @@ describe('interpretPslDocumentToSqlContract', () => { authoringContributions: { entityTypes: testEnumEntityContributions, type: {}, field: {} }, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + enumInferenceCodecs: postgresEnumInferenceCodecs, ...input, }); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.types.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.types.test.ts index 3db4fb6c64..5e00942be1 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.types.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.types.test.ts @@ -5,6 +5,7 @@ import { interpretPslDocumentToSqlContract } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, documentScopedTypes, + postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, symbolTableInputFromParseArgs, @@ -17,6 +18,7 @@ const baseInput = { authoringContributions: { entityTypes: testEnumEntityContributions, type: {}, field: {} }, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + enumInferenceCodecs: postgresEnumInferenceCodecs, } as const; describe('interpretPslDocumentToSqlContract types', () => { diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.value-objects.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.value-objects.test.ts index 71a41b5e7e..e4ba438724 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.value-objects.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.value-objects.test.ts @@ -7,6 +7,7 @@ import { import { createBuiltinLikeControlMutationDefaults, modelsOf, + postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, symbolTableInputFromParseArgs, @@ -18,7 +19,11 @@ describe('interpretPslDocumentToSqlContract value objects and list fields', () = const interpretPslDocumentToSqlContract = ( input: Omit< InterpretPslDocumentToSqlContractInput, - 'target' | 'scalarTypeDescriptors' | 'composedExtensionContracts' | 'createNamespace' + | 'target' + | 'scalarTypeDescriptors' + | 'composedExtensionContracts' + | 'createNamespace' + | 'enumInferenceCodecs' > & Partial>, ) => @@ -27,6 +32,7 @@ describe('interpretPslDocumentToSqlContract value objects and list fields', () = scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + enumInferenceCodecs: postgresEnumInferenceCodecs, ...input, }); diff --git a/packages/2-sql/2-authoring/contract-psl/test/provider.test.ts b/packages/2-sql/2-authoring/contract-psl/test/provider.test.ts index 326daae7f8..bcee014e0b 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/provider.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/provider.test.ts @@ -11,6 +11,7 @@ import { modelsOf, pgvectorAuthoringContributions, pgvectorExtensionPack, + postgresEnumInferenceCodecs, postgresTarget, } from './fixtures'; import { sqlStorageFromSuccessfulSqlInterpretation } from './interpret-sql-contract-storage'; @@ -22,6 +23,7 @@ describe('prismaContract provider helper', () => { const baseOptions = { target: postgresTarget, createNamespace: createTestSqlNamespace, + enumInferenceCodecs: postgresEnumInferenceCodecs, } as const; afterEach(async () => { diff --git a/packages/2-sql/2-authoring/contract-psl/test/psl-ts-namespace-parity.test.ts b/packages/2-sql/2-authoring/contract-psl/test/psl-ts-namespace-parity.test.ts index d7be8ada9d..a983042171 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/psl-ts-namespace-parity.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/psl-ts-namespace-parity.test.ts @@ -14,6 +14,7 @@ import { createTestSqlNamespace } from '../../../1-core/contract/test/test-suppo import { interpretPslDocumentToSqlContract } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, + postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, symbolTableInputFromParseArgs, @@ -58,6 +59,7 @@ namespace public { composedExtensionContracts: new Map(), controlMutationDefaults: createBuiltinLikeControlMutationDefaults(), createNamespace: createTestSqlNamespace, + enumInferenceCodecs: postgresEnumInferenceCodecs, }); expect(pslResult.ok).toBe(true); @@ -183,6 +185,7 @@ namespace public { composedExtensionPacks: ['supabase'], composedExtensionContracts: new Map([['supabase', syntheticExtensionContract]]), createNamespace: createTestSqlNamespace, + enumInferenceCodecs: postgresEnumInferenceCodecs, }); expect(pslResult.ok).toBe(true); @@ -253,6 +256,7 @@ namespace public { composedExtensionPacks: ['supabase'], composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, + enumInferenceCodecs: postgresEnumInferenceCodecs, }); expect(result.ok).toBe(false); diff --git a/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts b/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts index ff577264c6..6fd53e2886 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts @@ -13,6 +13,8 @@ import { createTestSqlNamespace } from '../../../1-core/contract/test/test-suppo import { interpretPslDocumentToSqlContract } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, + postgresEnumInferenceCodecs, + sqliteEnumInferenceCodecs, symbolTableInputFromParseArgs, testEnumEntityContributions, } from './fixtures'; @@ -364,6 +366,7 @@ describe('TS and PSL authoring parity', () => { readonly targetPack: TargetPackRef<'sql', string>; readonly scalarTypeDescriptors: ReadonlyMap; readonly authoringContributions: AuthoringContributions; + readonly enumInferenceCodecs: { readonly text: string; readonly int: string }; }): void { const tsContract = target.buildTsContract(); const pslDocument = symbolTableInputFromParseArgs({ @@ -379,6 +382,7 @@ describe('TS and PSL authoring parity', () => { controlMutationDefaults: createBuiltinLikeControlMutationDefaults(), authoringContributions: target.authoringContributions, createNamespace: createTestSqlNamespace, + enumInferenceCodecs: target.enumInferenceCodecs, }); expect(interpreted.ok).toBe(true); @@ -392,6 +396,7 @@ describe('TS and PSL authoring parity', () => { targetPack: sqliteTimestampTargetPack, scalarTypeDescriptors: sqliteTimestampScalarTypeDescriptors, authoringContributions: sqliteTimestampAuthoringContributions, + enumInferenceCodecs: sqliteEnumInferenceCodecs, }); }); @@ -401,6 +406,7 @@ describe('TS and PSL authoring parity', () => { targetPack: postgresTimestampTargetPack, scalarTypeDescriptors: postgresTimestampScalarTypeDescriptors, authoringContributions: postgresTimestampAuthoringContributions, + enumInferenceCodecs: postgresEnumInferenceCodecs, }); }); @@ -430,6 +436,7 @@ model Post { controlMutationDefaults: createBuiltinLikeControlMutationDefaults(), authoringContributions, createNamespace: createTestSqlNamespace, + enumInferenceCodecs: postgresEnumInferenceCodecs, }); expect(pslContract.ok).toBe(true); diff --git a/packages/2-sql/9-family/src/core/authoring-entity-types.ts b/packages/2-sql/9-family/src/core/authoring-entity-types.ts index 2aec163d85..b5d06e90dd 100644 --- a/packages/2-sql/9-family/src/core/authoring-entity-types.ts +++ b/packages/2-sql/9-family/src/core/authoring-entity-types.ts @@ -1,10 +1,11 @@ import type { JsonValue } from '@prisma-next/contract/types'; -import type { - AuthoringEntityContext, - AuthoringEntityTypeDescriptor, - AuthoringEntityTypeNamespace, - AuthoringPslBlockDescriptorNamespace, - PslExtensionBlock, +import { + type AuthoringEntityContext, + type AuthoringEntityTypeDescriptor, + type AuthoringEntityTypeNamespace, + type AuthoringPslBlockDescriptorNamespace, + classifyEnumMemberType, + type PslExtensionBlock, } from '@prisma-next/framework-components/authoring'; import { type EnumTypeHandle, enumType } from '@prisma-next/sql-contract-ts/contract-builder'; import { blindCast } from '@prisma-next/utils/casts'; @@ -28,31 +29,40 @@ export const sqlFamilyEnumEntityDescriptor = { const diagnostics = ctx.diagnostics; const typeAttr = block.blockAttributes.find((a) => a.name === 'type'); + + let codecId: string; if (!typeAttr) { - diagnostics?.push({ - code: 'PSL_ENUM_MISSING_TYPE', - message: `enum "${block.name}" is missing a @@type("codecId") attribute`, - sourceId, - span: block.span, - }); - return undefined; + const inferredKind = classifyEnumMemberType(block); + if (inferredKind === null || !ctx.enumInferenceCodecs) { + diagnostics?.push({ + code: 'PSL_ENUM_CANNOT_INFER_TYPE', + message: `cannot infer @@type for enum "${block.name}"; add an explicit @@type(...)`, + sourceId, + span: block.span, + }); + return undefined; + } + codecId = ctx.enumInferenceCodecs[inferredKind]; + } else { + const rawCodecArg = typeAttr.args[0]?.value; + const explicitCodecId = + rawCodecArg !== undefined ? parseQuotedString(rawCodecArg) : undefined; + if (!explicitCodecId) { + diagnostics?.push({ + code: 'PSL_ENUM_MISSING_TYPE', + message: `enum "${block.name}" @@type attribute must have a quoted codec id argument`, + sourceId, + span: typeAttr.span, + }); + return undefined; + } + codecId = explicitCodecId; } - const rawCodecArg = typeAttr.args[0]?.value; - const codecId = rawCodecArg !== undefined ? parseQuotedString(rawCodecArg) : undefined; - if (!codecId) { - diagnostics?.push({ - code: 'PSL_ENUM_MISSING_TYPE', - message: `enum "${block.name}" @@type attribute must have a quoted codec id argument`, - sourceId, - span: typeAttr.span, - }); - return undefined; - } + const typeArgSpan = typeAttr?.args[0]?.span ?? typeAttr?.span ?? block.span; const nativeType = ctx.codecLookup?.targetTypesFor(codecId)?.[0]; if (nativeType === undefined) { - const typeArgSpan = typeAttr.args[0]?.span ?? typeAttr.span; diagnostics?.push({ code: 'PSL_EXTENSION_INVALID_VALUE', message: `enum "${block.name}" @@type references unknown codec "${codecId}"`, @@ -64,7 +74,6 @@ export const sqlFamilyEnumEntityDescriptor = { const codec = ctx.codecLookup?.get(codecId); if (codec === undefined) { - const typeArgSpan = typeAttr.args[0]?.span ?? typeAttr.span; diagnostics?.push({ code: 'PSL_EXTENSION_INVALID_VALUE', message: `enum "${block.name}" @@type codec "${codecId}" resolves in targetTypesFor but is absent from codecLookup.get`, diff --git a/packages/2-sql/9-family/test/authoring-entity-types.enum.test.ts b/packages/2-sql/9-family/test/authoring-entity-types.enum.test.ts new file mode 100644 index 0000000000..187ae7dee1 --- /dev/null +++ b/packages/2-sql/9-family/test/authoring-entity-types.enum.test.ts @@ -0,0 +1,253 @@ +import type { + AuthoringDiagnosticSink, + AuthoringEntityContext, + PslExtensionBlock, + PslExtensionBlockParamValue, +} from '@prisma-next/framework-components/authoring'; +import type { Codec, CodecLookup } from '@prisma-next/framework-components/codec'; +import { describe, expect, it } from 'vitest'; +import { sqlFamilyEnumEntityDescriptor } from '../src/core/authoring-entity-types'; + +const SPAN = { + start: { offset: 0, line: 1, column: 1 }, + end: { offset: 0, line: 1, column: 1 }, +}; + +function bareMember(): PslExtensionBlockParamValue { + return { kind: 'bare', span: SPAN }; +} + +function valueMember(raw: string): PslExtensionBlockParamValue { + return { kind: 'value', raw, span: SPAN }; +} + +function typeAttr(codecId: string) { + return { + name: 'type', + args: [{ kind: 'positional' as const, value: `"${codecId}"`, span: SPAN }], + span: SPAN, + }; +} + +function enumBlock(input: { + readonly name: string; + readonly parameters: Record; + readonly typeCodecId?: string; +}): PslExtensionBlock { + return { + kind: 'enum', + name: input.name, + parameters: input.parameters, + blockAttributes: input.typeCodecId !== undefined ? [typeAttr(input.typeCodecId)] : [], + span: SPAN, + }; +} + +const PG_TEXT_CODEC_ID = 'pg/text@1'; +const PG_INT_CODEC_ID = 'pg/int@1'; + +const pgTextCodec: Codec = { + id: PG_TEXT_CODEC_ID, + encode: async (v: unknown) => v, + decode: async (w: unknown) => w, + encodeJson: (value) => value as never, + decodeJson(json) { + if (typeof json !== 'string') throw new Error(`expected string, got ${typeof json}`); + return json; + }, +}; + +const pgIntCodec: Codec = { + id: PG_INT_CODEC_ID, + encode: async (v: unknown) => v, + decode: async (w: unknown) => w, + encodeJson: (value) => value as never, + decodeJson(json) { + if (typeof json !== 'number') throw new Error(`expected number, got ${typeof json}`); + return json; + }, +}; + +const testCodecLookup: CodecLookup = { + get(id: string): Codec | undefined { + if (id === PG_TEXT_CODEC_ID) return pgTextCodec; + if (id === PG_INT_CODEC_ID) return pgIntCodec; + return undefined; + }, + targetTypesFor(id: string): readonly string[] | undefined { + if (id === PG_TEXT_CODEC_ID) return ['text']; + if (id === PG_INT_CODEC_ID) return ['int']; + return undefined; + }, + metaFor: () => undefined, + renderOutputTypeFor: () => undefined, +}; + +function makeContext(diagnostics: unknown[]): AuthoringEntityContext { + const sink: AuthoringDiagnosticSink = { + push: (d) => diagnostics.push(d), + }; + return { + family: 'sql', + target: 'postgres', + codecLookup: testCodecLookup, + sourceId: 'schema.prisma', + diagnostics: sink, + enumInferenceCodecs: { text: PG_TEXT_CODEC_ID, int: PG_INT_CODEC_ID }, + }; +} + +const factory = sqlFamilyEnumEntityDescriptor.output.factory; + +describe('sqlFamilyEnumEntityDescriptor: @@type omitted, inferred from members', () => { + it('bare members infer the text codec', () => { + const diagnostics: unknown[] = []; + const handle = factory( + enumBlock({ name: 'Role', parameters: { admin: bareMember(), user: bareMember() } }), + makeContext(diagnostics), + ); + + expect(diagnostics).toEqual([]); + expect(handle).toMatchObject({ + codecId: PG_TEXT_CODEC_ID, + nativeType: 'text', + members: { admin: 'admin', user: 'user' }, + }); + }); + + it('string-value members infer the text codec', () => { + const diagnostics: unknown[] = []; + const handle = factory( + enumBlock({ + name: 'Role', + parameters: { admin: valueMember('"admin"'), user: valueMember('"user"') }, + }), + makeContext(diagnostics), + ); + + expect(diagnostics).toEqual([]); + expect(handle).toMatchObject({ + codecId: PG_TEXT_CODEC_ID, + nativeType: 'text', + members: { admin: 'admin', user: 'user' }, + }); + }); + + it('a mix of bare and string-value members still infers the text codec', () => { + const diagnostics: unknown[] = []; + const handle = factory( + enumBlock({ + name: 'Role', + parameters: { admin: bareMember(), user: valueMember('"user"') }, + }), + makeContext(diagnostics), + ); + + expect(diagnostics).toEqual([]); + expect(handle).toMatchObject({ codecId: PG_TEXT_CODEC_ID, nativeType: 'text' }); + }); + + it('integer-value members infer the int codec', () => { + const diagnostics: unknown[] = []; + const handle = factory( + enumBlock({ + name: 'Priority', + parameters: { low: valueMember('1'), high: valueMember('2') }, + }), + makeContext(diagnostics), + ); + + expect(diagnostics).toEqual([]); + expect(handle).toMatchObject({ + codecId: PG_INT_CODEC_ID, + nativeType: 'int', + members: { low: 1, high: 2 }, + }); + }); + + it('a float member cannot be inferred', () => { + const diagnostics: unknown[] = []; + const handle = factory( + enumBlock({ name: 'Priority', parameters: { low: valueMember('1.5') } }), + makeContext(diagnostics), + ); + + expect(handle).toBeUndefined(); + expect(diagnostics).toEqual([expect.objectContaining({ code: 'PSL_ENUM_CANNOT_INFER_TYPE' })]); + }); + + it('a boolean member cannot be inferred', () => { + const diagnostics: unknown[] = []; + const handle = factory( + enumBlock({ name: 'Flag', parameters: { on: valueMember('true') } }), + makeContext(diagnostics), + ); + + expect(handle).toBeUndefined(); + expect(diagnostics).toEqual([expect.objectContaining({ code: 'PSL_ENUM_CANNOT_INFER_TYPE' })]); + }); + + it('a mix of string and integer members cannot be inferred', () => { + const diagnostics: unknown[] = []; + const handle = factory( + enumBlock({ + name: 'Mixed', + parameters: { low: valueMember('"low"'), high: valueMember('2') }, + }), + makeContext(diagnostics), + ); + + expect(handle).toBeUndefined(); + expect(diagnostics).toEqual([expect.objectContaining({ code: 'PSL_ENUM_CANNOT_INFER_TYPE' })]); + }); + + it('the diagnostic names the enum and suggests an explicit @@type', () => { + const diagnostics: { code: string; message: string }[] = []; + factory( + enumBlock({ name: 'Priority', parameters: { low: valueMember('1.5') } }), + makeContext(diagnostics), + ); + + expect(diagnostics).toEqual([ + expect.objectContaining({ + code: 'PSL_ENUM_CANNOT_INFER_TYPE', + message: expect.stringMatching(/Priority/), + }), + ]); + expect(diagnostics[0]?.message).toMatch(/@@type/); + }); +}); + +describe('sqlFamilyEnumEntityDescriptor: explicit @@type is unchanged', () => { + it('an explicit @@type is used verbatim, skipping inference entirely', () => { + const diagnostics: unknown[] = []; + const handle = factory( + enumBlock({ + name: 'Priority', + parameters: { low: valueMember('1.5') }, + typeCodecId: PG_TEXT_CODEC_ID, + }), + makeContext(diagnostics), + ); + + // A float member would fail inference, but an explicit @@type("pg/text@1") + // bypasses the classifier and hits the codec's own decodeJson instead. + expect(diagnostics).toEqual([expect.objectContaining({ code: 'PSL_EXTENSION_INVALID_VALUE' })]); + expect(handle).toBeUndefined(); + }); + + it('an explicit @@type resolving to text lowers exactly as before', () => { + const diagnostics: unknown[] = []; + const handle = factory( + enumBlock({ + name: 'Role', + parameters: { admin: valueMember('"admin"') }, + typeCodecId: PG_TEXT_CODEC_ID, + }), + makeContext(diagnostics), + ); + + expect(diagnostics).toEqual([]); + expect(handle).toMatchObject({ codecId: PG_TEXT_CODEC_ID, nativeType: 'text' }); + }); +}); diff --git a/packages/3-extensions/postgres/src/config/define-config.ts b/packages/3-extensions/postgres/src/config/define-config.ts index c209649b5d..d4ab5ee3b7 100644 --- a/packages/3-extensions/postgres/src/config/define-config.ts +++ b/packages/3-extensions/postgres/src/config/define-config.ts @@ -6,6 +6,7 @@ import sql from '@prisma-next/family-sql/control'; import type { ControlExtensionDescriptor } from '@prisma-next/framework-components/control'; import { prismaContract } from '@prisma-next/sql-contract-psl/provider'; import { typescriptContractFromPath } from '@prisma-next/sql-contract-ts/config-types'; +import { PG_INT_CODEC_ID, PG_TEXT_CODEC_ID } from '@prisma-next/target-postgres/codec-ids'; import postgres from '@prisma-next/target-postgres/control'; import postgresPackRef from '@prisma-next/target-postgres/pack'; import { postgresCreateNamespace } from '@prisma-next/target-postgres/types'; @@ -47,6 +48,7 @@ export function defineConfig(options: PostgresConfigOptions): PrismaNextConfig<' output, target: postgresPackRef, createNamespace: postgresCreateNamespace, + enumInferenceCodecs: { text: PG_TEXT_CODEC_ID, int: PG_INT_CODEC_ID }, }); return coreDefineConfig({ diff --git a/packages/3-extensions/sqlite/src/config/define-config.ts b/packages/3-extensions/sqlite/src/config/define-config.ts index 1275cf7dca..408ca7f745 100644 --- a/packages/3-extensions/sqlite/src/config/define-config.ts +++ b/packages/3-extensions/sqlite/src/config/define-config.ts @@ -6,6 +6,10 @@ import sql from '@prisma-next/family-sql/control'; import type { ControlExtensionDescriptor } from '@prisma-next/framework-components/control'; import { prismaContract } from '@prisma-next/sql-contract-psl/provider'; import { typescriptContractFromPath } from '@prisma-next/sql-contract-ts/config-types'; +import { + SQLITE_INTEGER_CODEC_ID, + SQLITE_TEXT_CODEC_ID, +} from '@prisma-next/target-sqlite/codec-ids'; import sqlite, { sqliteCreateNamespace } from '@prisma-next/target-sqlite/control'; import sqlitePackRef from '@prisma-next/target-sqlite/pack'; import { ifDefined } from '@prisma-next/utils/defined'; @@ -46,6 +50,7 @@ export function defineConfig(options: SqliteConfigOptions): PrismaNextConfig<'sq output, target: sqlitePackRef, createNamespace: sqliteCreateNamespace, + enumInferenceCodecs: { text: SQLITE_TEXT_CODEC_ID, int: SQLITE_INTEGER_CODEC_ID }, }); return coreDefineConfig({ diff --git a/packages/3-targets/3-targets/postgres/src/core/postgres-contract-serializer.ts b/packages/3-targets/3-targets/postgres/src/core/postgres-contract-serializer.ts index 71c9dbed50..d1420d6d09 100644 --- a/packages/3-targets/3-targets/postgres/src/core/postgres-contract-serializer.ts +++ b/packages/3-targets/3-targets/postgres/src/core/postgres-contract-serializer.ts @@ -18,12 +18,14 @@ import type { SqlNamespaceInput, SqlStorage } from '@prisma-next/sql-contract/ty import { blindCast } from '@prisma-next/utils/casts'; import type { JsonObject, JsonValue } from '@prisma-next/utils/json'; import { postgresAuthoringEntityTypes } from './authoring'; +import { PG_INT_CODEC_ID, PG_TEXT_CODEC_ID } from './codec-ids'; import { policyEntityKind, roleEntityKind } from './entity-kinds'; import { isPostgresSchema, PostgresSchema } from './postgres-schema'; const POSTGRES_AUTHORING_CTX: AuthoringEntityContext = { family: 'sql', target: 'postgres', + enumInferenceCodecs: { text: PG_TEXT_CODEC_ID, int: PG_INT_CODEC_ID }, }; function isAuthoringEntityTypeFactoryOutput( diff --git a/test/integration/test/mongo/interpreter.enum.test.ts b/test/integration/test/mongo/interpreter.enum.test.ts index 411d87eecd..886b802f57 100644 --- a/test/integration/test/mongo/interpreter.enum.test.ts +++ b/test/integration/test/mongo/interpreter.enum.test.ts @@ -208,8 +208,8 @@ model Account { expect(roleField?.valueSet).toBeDefined(); }); - it('fails when enum is missing @@type attribute', () => { - const result = interpret(` + it('infers the string codec when @@type is omitted and members are strings', () => { + const contract = interpretOk(` enum Role { User = "user" } @@ -217,11 +217,31 @@ model Account { id ObjectId @id @map("_id") role Role } +`); + + const ns = contract.domain.namespaces[UNBOUND_NAMESPACE_ID]; + expect(ns?.enum?.['Role']).toEqual({ + codecId: 'mongo/string@1', + members: [{ name: 'User', value: 'user' }], + }); + }); + + it('fails to infer @@type when a member is neither a string nor an integer', () => { + const result = interpret(` +enum Ratio { + Half = 1.5 +} +model Account { + id ObjectId @id @map("_id") + ratio Ratio +} `); expect(result.ok).toBe(false); if (result.ok) return; expect( - result.failure.diagnostics.some((d: { code: string }) => d.code === 'PSL_ENUM_MISSING_TYPE'), + result.failure.diagnostics.some( + (d: { code: string }) => d.code === 'PSL_ENUM_CANNOT_INFER_TYPE', + ), ).toBe(true); }); From 374855e2c89e14d120929ec2a8b623757aebeefa Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 2 Jul 2026 18:04:38 +0200 Subject: [PATCH 3/5] chore(enums): drop a stale scaffolding comment in the mongo interpreter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment referenced a transient dispatch id and described a temporary "required field" state that no longer exists — the mongo factory reads the inferred codecs in this PR, and the field is optional. Signed-off-by: willbot Signed-off-by: Will Madden --- .../2-mongo-family/2-authoring/contract-psl/src/interpreter.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts b/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts index 60a0f77cad..930040019e 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts @@ -958,8 +958,6 @@ export function interpretPslDocumentToMongoContract( entityContext: { family: 'mongo', target: 'mongo', - // Mongo @@type inference lands in TML-2915 D2; this satisfies the - // required field until Mongo's own factory reads it. enumInferenceCodecs: { text: 'mongo/string@1', int: 'mongo/int32@1' }, ...ifDefined('codecLookup', codecLookup), sourceId, From 1f46d431ea73a9581a763479345180301501eafb Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 2 Jul 2026 18:13:14 +0200 Subject: [PATCH 4/5] refactor(enums): the mongo facade supplies the enum-inference codecs, not the interpreter Mirror the Postgres/SQLite pattern: the mongo defineConfig injects enumInferenceCodecs into the provider, which threads it into the interpreter as input; the family-layer interpreter no longer hardcodes mongo codec ids. Adds a @prisma-next/adapter-mongo/codec-ids entrypoint (the mongo analogue of target-postgres/codec-ids) so the config imports the constants. Signed-off-by: willbot Signed-off-by: Will Madden --- .../2-authoring/contract-psl/src/interpreter.ts | 4 +++- .../2-mongo-family/2-authoring/contract-psl/src/provider.ts | 3 +++ packages/3-extensions/mongo/src/config/define-config.ts | 6 +++++- packages/3-mongo-target/2-mongo-adapter/package.json | 1 + .../3-mongo-target/2-mongo-adapter/src/exports/codec-ids.ts | 1 + packages/3-mongo-target/2-mongo-adapter/tsdown.config.ts | 1 + test/integration/test/mongo/interpreter.enum.test.ts | 2 ++ 7 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 packages/3-mongo-target/2-mongo-adapter/src/exports/codec-ids.ts diff --git a/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts b/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts index 930040019e..9bb5aef273 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts @@ -70,6 +70,8 @@ export interface InterpretPslDocumentToMongoContractInput { readonly codecLookup?: CodecLookup; readonly seedDiagnostics?: readonly ContractSourceDiagnostic[]; readonly authoringContributions?: AuthoringContributions; + /** The target's default codec ids for an `enum` block that omits `@@type`. */ + readonly enumInferenceCodecs?: { readonly text: string; readonly int: string }; } /** @@ -958,7 +960,7 @@ export function interpretPslDocumentToMongoContract( entityContext: { family: 'mongo', target: 'mongo', - enumInferenceCodecs: { text: 'mongo/string@1', int: 'mongo/int32@1' }, + ...ifDefined('enumInferenceCodecs', input.enumInferenceCodecs), ...ifDefined('codecLookup', codecLookup), sourceId, diagnostics: { diff --git a/packages/2-mongo-family/2-authoring/contract-psl/src/provider.ts b/packages/2-mongo-family/2-authoring/contract-psl/src/provider.ts index d25e6e4449..7b85cbd122 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/src/provider.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/src/provider.ts @@ -10,6 +10,8 @@ import { interpretPslDocumentToMongoContract } from './interpreter'; export interface MongoContractOptions { readonly output?: string; + /** The target's default codec ids for an `enum` block that omits `@@type`. */ + readonly enumInferenceCodecs?: { readonly text: string; readonly int: string }; } function mapParseDiagnostics( @@ -78,6 +80,7 @@ export function mongoContract(schemaPath: string, options?: MongoContractOptions scalarTypeDescriptors: context.scalarTypeDescriptors, codecLookup: context.codecLookup, authoringContributions: context.authoringContributions, + ...ifDefined('enumInferenceCodecs', options?.enumInferenceCodecs), }); if (!interpreted.ok) { return interpreted; diff --git a/packages/3-extensions/mongo/src/config/define-config.ts b/packages/3-extensions/mongo/src/config/define-config.ts index 0d40372feb..74cc0e1b35 100644 --- a/packages/3-extensions/mongo/src/config/define-config.ts +++ b/packages/3-extensions/mongo/src/config/define-config.ts @@ -1,3 +1,4 @@ +import { MONGO_INT32_CODEC_ID, MONGO_STRING_CODEC_ID } from '@prisma-next/adapter-mongo/codec-ids'; import mongoAdapter from '@prisma-next/adapter-mongo/control'; import type { PrismaNextConfig } from '@prisma-next/config/config-types'; import { defineConfig as coreDefineConfig } from '@prisma-next/config/config-types'; @@ -41,7 +42,10 @@ export function defineConfig(options: MongoConfigOptions): PrismaNextConfig<'mon const contractConfig = ext === '.ts' ? typescriptContractFromPath(options.contract, output) - : mongoContract(options.contract, { output }); + : mongoContract(options.contract, { + output, + enumInferenceCodecs: { text: MONGO_STRING_CODEC_ID, int: MONGO_INT32_CODEC_ID }, + }); return coreDefineConfig({ family: mongoFamilyDescriptor, diff --git a/packages/3-mongo-target/2-mongo-adapter/package.json b/packages/3-mongo-target/2-mongo-adapter/package.json index bc27dda36a..af4de1c8cf 100644 --- a/packages/3-mongo-target/2-mongo-adapter/package.json +++ b/packages/3-mongo-target/2-mongo-adapter/package.json @@ -65,6 +65,7 @@ "types": "./dist/index.d.mts", "exports": { ".": "./dist/index.mjs", + "./codec-ids": "./dist/codec-ids.mjs", "./codec-types": "./dist/codec-types.mjs", "./control": "./dist/control.mjs", "./runtime": "./dist/runtime.mjs", diff --git a/packages/3-mongo-target/2-mongo-adapter/src/exports/codec-ids.ts b/packages/3-mongo-target/2-mongo-adapter/src/exports/codec-ids.ts new file mode 100644 index 0000000000..76259d90e3 --- /dev/null +++ b/packages/3-mongo-target/2-mongo-adapter/src/exports/codec-ids.ts @@ -0,0 +1 @@ +export * from '../core/codec-ids'; diff --git a/packages/3-mongo-target/2-mongo-adapter/tsdown.config.ts b/packages/3-mongo-target/2-mongo-adapter/tsdown.config.ts index 58963b3361..ecaea5ae52 100644 --- a/packages/3-mongo-target/2-mongo-adapter/tsdown.config.ts +++ b/packages/3-mongo-target/2-mongo-adapter/tsdown.config.ts @@ -6,5 +6,6 @@ export default defineConfig({ control: 'src/exports/control.ts', runtime: 'src/exports/runtime.ts', 'codec-types': 'src/exports/codec-types.ts', + 'codec-ids': 'src/exports/codec-ids.ts', }, }); diff --git a/test/integration/test/mongo/interpreter.enum.test.ts b/test/integration/test/mongo/interpreter.enum.test.ts index 886b802f57..22d933763a 100644 --- a/test/integration/test/mongo/interpreter.enum.test.ts +++ b/test/integration/test/mongo/interpreter.enum.test.ts @@ -1,3 +1,4 @@ +import { MONGO_INT32_CODEC_ID, MONGO_STRING_CODEC_ID } from '@prisma-next/adapter-mongo/codec-ids'; import { mongoFamilyEntityTypes, mongoFamilyPslBlockDescriptors, @@ -73,6 +74,7 @@ function interpret( scalarTypeDescriptors: mongoScalarTypeDescriptors, codecLookup: mongoCodecLookup, authoringContributions: contributions, + enumInferenceCodecs: { text: MONGO_STRING_CODEC_ID, int: MONGO_INT32_CODEC_ID }, ...overrides, }); } From 681e8e05ba9a26ff56e3f63a3b086ce31bd87eba Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 2 Jul 2026 18:42:05 +0200 Subject: [PATCH 5/5] refactor(enums): hoist enum codec resolution to one helper; drop test churn Address review: collapse the duplicated infer/explicit @@type resolution from both family factories (and the test fixture) into a single resolveEnumCodecId in framework-components, so SQL/Mongo symmetry is structural, not copied. Revert the ~15 test files that only carried a vestigial enumInferenceCodecs line from the abandoned "required-field" iteration (the field is optional). Record the additive change as incidental for the 0.14->0.15 extension-author upgrade. Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/exports/authoring.ts | 1 + .../src/shared/framework-authoring.ts | 48 ++++++++++++++++++- .../src/core/authoring-entity-types.ts | 48 +++---------------- .../test/composed-mutation-defaults.test.ts | 8 +--- .../2-authoring/contract-psl/test/fixtures.ts | 39 +++------------ .../test/interpreter.control-policy.test.ts | 2 - .../test/interpreter.defaults.test.ts | 10 +--- .../test/interpreter.diagnostics.test.ts | 5 -- .../test/interpreter.extensions.test.ts | 2 - .../test/interpreter.namespaces.test.ts | 2 - .../test/interpreter.polymorphism.test.ts | 8 +--- ...interpreter.relations.many-to-many.test.ts | 2 - .../test/interpreter.relations.test.ts | 2 - .../contract-psl/test/interpreter.test.ts | 8 +--- .../test/interpreter.types.test.ts | 2 - .../test/interpreter.value-objects.test.ts | 8 +--- .../contract-psl/test/provider.test.ts | 2 - .../test/psl-ts-namespace-parity.test.ts | 4 -- .../contract-psl/test/ts-psl-parity.test.ts | 7 --- .../src/core/authoring-entity-types.ts | 48 +++---------------- .../upgrades/0.14-to-0.15/instructions.md | 10 ++++ 21 files changed, 84 insertions(+), 182 deletions(-) diff --git a/packages/1-framework/1-core/framework-components/src/exports/authoring.ts b/packages/1-framework/1-core/framework-components/src/exports/authoring.ts index 88d96d64df..1e69bbab45 100644 --- a/packages/1-framework/1-core/framework-components/src/exports/authoring.ts +++ b/packages/1-framework/1-core/framework-components/src/exports/authoring.ts @@ -33,6 +33,7 @@ export { isAuthoringTypeConstructorDescriptor, mergeAuthoringNamespaces, resolveAuthoringTemplateValue, + resolveEnumCodecId, validateAuthoringHelperArguments, } from '../shared/framework-authoring'; export type { diff --git a/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts b/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts index 9a4065bb06..96b9f732a5 100644 --- a/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts +++ b/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts @@ -11,7 +11,7 @@ import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import type { Type } from 'arktype'; import type { CodecLookup } from './codec-types'; -import type { PslBlockParam, PslExtensionBlock } from './psl-extension-block'; +import type { PslBlockParam, PslExtensionBlock, PslSpan } from './psl-extension-block'; export type EnumInferredMemberType = 'text' | 'int'; @@ -188,6 +188,52 @@ export function classifyEnumMemberType(block: PslExtensionBlock): 'text' | 'int' return null; } +/** + * Resolves the codec id for an `enum` block. When `@@type` is absent, the codec + * is inferred from the members via {@link classifyEnumMemberType}; otherwise the + * explicit `@@type("codec")` argument is parsed. Pushes the appropriate + * diagnostic and returns `undefined` when neither yields a codec. `codecSpan` is + * the span downstream codec-validation diagnostics should anchor to. Shared by + * every family's enum factory so inference and the explicit path stay identical. + */ +export function resolveEnumCodecId( + block: PslExtensionBlock, + ctx: AuthoringEntityContext, +): { readonly codecId: string; readonly codecSpan: PslSpan } | undefined { + const sourceId = ctx.sourceId ?? 'unknown'; + const typeAttr = block.blockAttributes.find((a) => a.name === 'type'); + + if (typeAttr === undefined) { + const inferredKind = classifyEnumMemberType(block); + if (inferredKind === null || ctx.enumInferenceCodecs === undefined) { + ctx.diagnostics?.push({ + code: 'PSL_ENUM_CANNOT_INFER_TYPE', + message: `cannot infer @@type for enum "${block.name}"; add an explicit @@type(...)`, + sourceId, + span: block.span, + }); + return undefined; + } + return { codecId: ctx.enumInferenceCodecs[inferredKind], codecSpan: block.span }; + } + + const rawCodecArg = typeAttr.args[0]?.value; + const codecId = + rawCodecArg?.startsWith('"') && rawCodecArg.endsWith('"') && rawCodecArg.length >= 2 + ? rawCodecArg.slice(1, -1) + : undefined; + if (codecId === undefined) { + ctx.diagnostics?.push({ + code: 'PSL_ENUM_MISSING_TYPE', + message: `enum "${block.name}" @@type attribute must have a quoted codec id argument`, + sourceId, + span: typeAttr.span, + }); + return undefined; + } + return { codecId, codecSpan: typeAttr.args[0]?.span ?? typeAttr.span }; +} + export interface AuthoringEntityTypeTemplateOutput { readonly template: AuthoringTemplateValue; } diff --git a/packages/2-mongo-family/9-family/src/core/authoring-entity-types.ts b/packages/2-mongo-family/9-family/src/core/authoring-entity-types.ts index abbc829b92..5bf81ba84b 100644 --- a/packages/2-mongo-family/9-family/src/core/authoring-entity-types.ts +++ b/packages/2-mongo-family/9-family/src/core/authoring-entity-types.ts @@ -4,19 +4,12 @@ import { type AuthoringEntityTypeDescriptor, type AuthoringEntityTypeNamespace, type AuthoringPslBlockDescriptorNamespace, - classifyEnumMemberType, type PslExtensionBlock, + resolveEnumCodecId, } from '@prisma-next/framework-components/authoring'; import { type EnumTypeHandle, enumType } from '@prisma-next/mongo-contract-ts/contract-builder'; import { blindCast } from '@prisma-next/utils/casts'; -function parseQuotedString(raw: string): string | undefined { - if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) { - return raw.slice(1, -1); - } - return undefined; -} - export const mongoFamilyEnumEntityDescriptor = { kind: 'entity' as const, discriminator: 'enum', @@ -28,38 +21,11 @@ export const mongoFamilyEnumEntityDescriptor = { const sourceId = ctx.sourceId ?? 'unknown'; const diagnostics = ctx.diagnostics; - const typeAttr = block.blockAttributes.find((a) => a.name === 'type'); - - let codecId: string; - if (!typeAttr) { - const inferredKind = classifyEnumMemberType(block); - if (inferredKind === null || !ctx.enumInferenceCodecs) { - diagnostics?.push({ - code: 'PSL_ENUM_CANNOT_INFER_TYPE', - message: `cannot infer @@type for enum "${block.name}"; add an explicit @@type(...)`, - sourceId, - span: block.span, - }); - return undefined; - } - codecId = ctx.enumInferenceCodecs[inferredKind]; - } else { - const rawCodecArg = typeAttr.args[0]?.value; - const explicitCodecId = - rawCodecArg !== undefined ? parseQuotedString(rawCodecArg) : undefined; - if (!explicitCodecId) { - diagnostics?.push({ - code: 'PSL_ENUM_MISSING_TYPE', - message: `enum "${block.name}" @@type attribute must have a quoted codec id argument`, - sourceId, - span: typeAttr.span, - }); - return undefined; - } - codecId = explicitCodecId; + const resolved = resolveEnumCodecId(block, ctx); + if (resolved === undefined) { + return undefined; } - - const typeArgSpan = typeAttr?.args[0]?.span ?? typeAttr?.span ?? block.span; + const { codecId, codecSpan } = resolved; const nativeType = ctx.codecLookup?.targetTypesFor(codecId)?.[0]; if (nativeType === undefined) { @@ -67,7 +33,7 @@ export const mongoFamilyEnumEntityDescriptor = { code: 'PSL_EXTENSION_INVALID_VALUE', message: `enum "${block.name}" @@type references unknown codec "${codecId}"`, sourceId, - span: typeArgSpan, + span: codecSpan, }); return undefined; } @@ -78,7 +44,7 @@ export const mongoFamilyEnumEntityDescriptor = { code: 'PSL_EXTENSION_INVALID_VALUE', message: `enum "${block.name}" @@type codec "${codecId}" resolves in targetTypesFor but is absent from codecLookup.get`, sourceId, - span: typeArgSpan, + span: codecSpan, }); return undefined; } diff --git a/packages/2-sql/2-authoring/contract-psl/test/composed-mutation-defaults.test.ts b/packages/2-sql/2-authoring/contract-psl/test/composed-mutation-defaults.test.ts index bab87571d1..5824b9452b 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/composed-mutation-defaults.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/composed-mutation-defaults.test.ts @@ -9,7 +9,6 @@ import { interpretPslDocumentToSqlContract as interpretPslDocumentToSqlContractInternal, } from '../src/interpreter'; import { - postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, symbolTableInputFromParseArgs, @@ -19,11 +18,7 @@ describe('composed mutation default registries', () => { const interpretPslDocumentToSqlContract = ( input: Omit< InterpretPslDocumentToSqlContractInput, - | 'target' - | 'scalarTypeDescriptors' - | 'composedExtensionContracts' - | 'createNamespace' - | 'enumInferenceCodecs' + 'target' | 'scalarTypeDescriptors' | 'composedExtensionContracts' | 'createNamespace' > & Partial>, ) => @@ -32,7 +27,6 @@ describe('composed mutation default registries', () => { scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, - enumInferenceCodecs: postgresEnumInferenceCodecs, ...input, }); diff --git a/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts b/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts index bb7b64a7fb..99012366bc 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts @@ -12,8 +12,8 @@ import { type AuthoringEntityContext, type AuthoringEntityTypeNamespace, type AuthoringPslBlockDescriptorNamespace, - classifyEnumMemberType, type PslExtensionBlock, + resolveEnumCodecId, } from '@prisma-next/framework-components/authoring'; import type { CodecLookup } from '@prisma-next/framework-components/codec'; import type { ExtensionPackRef, TargetPackRef } from '@prisma-next/framework-components/components'; @@ -38,36 +38,11 @@ function testEnumFactory( const sourceId = ctx.sourceId ?? 'unknown'; const diagnostics = ctx.diagnostics; - const typeAttr = block.blockAttributes.find((a) => a.name === 'type'); - - let codecId: string; - if (!typeAttr) { - const inferredKind = classifyEnumMemberType(block); - if (inferredKind === null || !ctx.enumInferenceCodecs) { - diagnostics?.push({ - code: 'PSL_ENUM_CANNOT_INFER_TYPE', - message: `cannot infer @@type for enum "${block.name}"; add an explicit @@type(...)`, - sourceId, - span: block.span, - }); - return undefined; - } - codecId = ctx.enumInferenceCodecs[inferredKind]; - } else { - const rawArg = typeAttr.args[0]?.value; - const explicitCodecId = - rawArg?.startsWith('"') && rawArg.endsWith('"') ? rawArg.slice(1, -1) : undefined; - if (!explicitCodecId) { - diagnostics?.push({ - code: 'PSL_ENUM_MISSING_TYPE', - message: `enum "${block.name}" @@type attribute must have a quoted codec id argument`, - sourceId, - span: typeAttr.span, - }); - return undefined; - } - codecId = explicitCodecId; + const resolved = resolveEnumCodecId(block, ctx); + if (resolved === undefined) { + return undefined; } + const { codecId, codecSpan } = resolved; const nativeType = ctx.codecLookup?.targetTypesFor(codecId)?.[0]; if (nativeType === undefined) { @@ -75,7 +50,7 @@ function testEnumFactory( code: 'PSL_EXTENSION_INVALID_VALUE', message: `enum "${block.name}" @@type references unknown codec "${codecId}"`, sourceId, - span: typeAttr?.args[0]?.span ?? typeAttr?.span ?? block.span, + span: codecSpan, }); return undefined; } @@ -86,7 +61,7 @@ function testEnumFactory( code: 'PSL_EXTENSION_INVALID_VALUE', message: `enum "${block.name}" @@type codec "${codecId}" resolves in targetTypesFor but is absent from codecLookup.get`, sourceId, - span: typeAttr?.args[0]?.span ?? typeAttr?.span ?? block.span, + span: codecSpan, }); return undefined; } diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.control-policy.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.control-policy.test.ts index 286f48156d..bb5cc2ed95 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.control-policy.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.control-policy.test.ts @@ -6,7 +6,6 @@ import { createTestSqlNamespace } from '../../../1-core/contract/test/test-suppo import { interpretPslDocumentToSqlContract } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, - postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, symbolTableInputFromParseArgs, @@ -25,7 +24,6 @@ function interpretSchema(schema: string) { composedExtensionContracts: new Map(), controlMutationDefaults: builtinControlMutationDefaults, createNamespace: createTestSqlNamespace, - enumInferenceCodecs: postgresEnumInferenceCodecs, }); } diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.test.ts index 63368af2e6..782ab185ef 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.defaults.test.ts @@ -6,10 +6,8 @@ import { } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, - postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, - sqliteEnumInferenceCodecs, sqliteScalarTypeDescriptors, sqliteTarget, symbolTableInputFromParseArgs, @@ -22,11 +20,7 @@ describe('interpretPslDocumentToSqlContract default lowering', () => { const interpretPslDocumentToSqlContract = ( input: Omit< InterpretPslDocumentToSqlContractInput, - | 'target' - | 'scalarTypeDescriptors' - | 'composedExtensionContracts' - | 'createNamespace' - | 'enumInferenceCodecs' + 'target' | 'scalarTypeDescriptors' | 'composedExtensionContracts' | 'createNamespace' > & Partial>, ) => @@ -35,7 +29,6 @@ describe('interpretPslDocumentToSqlContract default lowering', () => { scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, - enumInferenceCodecs: postgresEnumInferenceCodecs, ...input, }); it('lowers supported default functions into execution and storage contract shapes', () => { @@ -505,7 +498,6 @@ model UuidNativeBad { controlMutationDefaults: builtinControlMutationDefaults, authoringContributions: sqliteTemporalContributions, createNamespace: createTestSqlNamespace, - enumInferenceCodecs: sqliteEnumInferenceCodecs, }); expect(result.ok).toBe(true); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts index bb07b42b41..6945f4269d 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts @@ -7,10 +7,8 @@ import { import { createBuiltinLikeControlMutationDefaults, documentScopedTypes, - postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, - sqliteEnumInferenceCodecs, sqliteScalarTypeDescriptors, sqliteTarget, symbolTableInputFromParseArgs, @@ -21,7 +19,6 @@ const baseInput = { scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, - enumInferenceCodecs: postgresEnumInferenceCodecs, } as const; const builtinControlMutationDefaults = createBuiltinLikeControlMutationDefaults(); @@ -1088,7 +1085,6 @@ model User { ...document, controlMutationDefaults: builtinControlMutationDefaults, createNamespace: createTestSqlNamespace, - enumInferenceCodecs: sqliteEnumInferenceCodecs, }); expect(result.ok).toBe(false); @@ -1125,7 +1121,6 @@ model User { ...document, controlMutationDefaults: builtinControlMutationDefaults, createNamespace: createTestSqlNamespace, - enumInferenceCodecs: sqliteEnumInferenceCodecs, }); expect(result.ok).toBe(false); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.extensions.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.extensions.test.ts index 621018242b..6a3f6341d4 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.extensions.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.extensions.test.ts @@ -6,7 +6,6 @@ import { documentScopedTypes, pgvectorAuthoringContributions, pgvectorExtensionPack, - postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, symbolTableInputFromParseArgs, @@ -17,7 +16,6 @@ const baseInput = { scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, - enumInferenceCodecs: postgresEnumInferenceCodecs, } as const; describe('interpretPslDocumentToSqlContract extensions', () => { diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts index 20d0bd02ae..fc0100565b 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts @@ -7,7 +7,6 @@ import { createTestSqlNamespace } from '../../../1-core/contract/test/test-suppo import { interpretPslDocumentToSqlContract } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, - postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, symbolTableInputFromParseArgs, @@ -103,7 +102,6 @@ const baseInput = { controlMutationDefaults: createBuiltinLikeControlMutationDefaults(), composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, - enumInferenceCodecs: postgresEnumInferenceCodecs, } as const; describe('un-namespaced PG model defaults to public namespace (TML-2916)', () => { diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts index dd538eddf9..7cd9a2051f 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts @@ -11,7 +11,6 @@ import { createBuiltinLikeControlMutationDefaults, documentScopedTypes, modelsOf, - postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, symbolTableInputFromParseArgs, @@ -22,11 +21,7 @@ describe('interpretPslDocumentToSqlContract — polymorphism', () => { const interpretPslDocumentToSqlContract = ( input: Omit< InterpretPslDocumentToSqlContractInput, - | 'target' - | 'scalarTypeDescriptors' - | 'composedExtensionContracts' - | 'createNamespace' - | 'enumInferenceCodecs' + 'target' | 'scalarTypeDescriptors' | 'composedExtensionContracts' | 'createNamespace' > & Partial>, ) => @@ -35,7 +30,6 @@ describe('interpretPslDocumentToSqlContract — polymorphism', () => { scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, - enumInferenceCodecs: postgresEnumInferenceCodecs, ...input, }); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.many-to-many.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.many-to-many.test.ts index fe7b90c64f..d2aa4a706d 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.many-to-many.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.many-to-many.test.ts @@ -8,7 +8,6 @@ import { interpretPslDocumentToSqlContract } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, modelsOf, - postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, symbolTableInputFromParseArgs, @@ -20,7 +19,6 @@ const baseInput = { controlMutationDefaults: createBuiltinLikeControlMutationDefaults(), composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, - enumInferenceCodecs: postgresEnumInferenceCodecs, } as const; function interpretSchema(schema: string) { 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 ff58b1fb46..62c74619f6 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 @@ -5,7 +5,6 @@ import { interpretPslDocumentToSqlContract } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, modelsOf, - postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, symbolTableInputFromParseArgs, @@ -18,7 +17,6 @@ const baseInput = { scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, - enumInferenceCodecs: postgresEnumInferenceCodecs, } as const; const builtinControlMutationDefaults = createBuiltinLikeControlMutationDefaults(); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts index ff230ebf12..3d3b3ad30a 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.test.ts @@ -10,7 +10,6 @@ import { import { createBuiltinLikeControlMutationDefaults, modelsOf, - postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, symbolTableInputFromParseArgs, @@ -33,11 +32,7 @@ describe('interpretPslDocumentToSqlContract', () => { const interpretPslDocumentToSqlContract = ( input: Omit< InterpretPslDocumentToSqlContractInput, - | 'target' - | 'scalarTypeDescriptors' - | 'composedExtensionContracts' - | 'createNamespace' - | 'enumInferenceCodecs' + 'target' | 'scalarTypeDescriptors' | 'composedExtensionContracts' | 'createNamespace' > & Partial>, ) => @@ -47,7 +42,6 @@ describe('interpretPslDocumentToSqlContract', () => { authoringContributions: { entityTypes: testEnumEntityContributions, type: {}, field: {} }, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, - enumInferenceCodecs: postgresEnumInferenceCodecs, ...input, }); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.types.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.types.test.ts index 5e00942be1..3db4fb6c64 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.types.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.types.test.ts @@ -5,7 +5,6 @@ import { interpretPslDocumentToSqlContract } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, documentScopedTypes, - postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, symbolTableInputFromParseArgs, @@ -18,7 +17,6 @@ const baseInput = { authoringContributions: { entityTypes: testEnumEntityContributions, type: {}, field: {} }, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, - enumInferenceCodecs: postgresEnumInferenceCodecs, } as const; describe('interpretPslDocumentToSqlContract types', () => { diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.value-objects.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.value-objects.test.ts index e4ba438724..71a41b5e7e 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.value-objects.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.value-objects.test.ts @@ -7,7 +7,6 @@ import { import { createBuiltinLikeControlMutationDefaults, modelsOf, - postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, symbolTableInputFromParseArgs, @@ -19,11 +18,7 @@ describe('interpretPslDocumentToSqlContract value objects and list fields', () = const interpretPslDocumentToSqlContract = ( input: Omit< InterpretPslDocumentToSqlContractInput, - | 'target' - | 'scalarTypeDescriptors' - | 'composedExtensionContracts' - | 'createNamespace' - | 'enumInferenceCodecs' + 'target' | 'scalarTypeDescriptors' | 'composedExtensionContracts' | 'createNamespace' > & Partial>, ) => @@ -32,7 +27,6 @@ describe('interpretPslDocumentToSqlContract value objects and list fields', () = scalarTypeDescriptors: postgresScalarTypeDescriptors, composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, - enumInferenceCodecs: postgresEnumInferenceCodecs, ...input, }); diff --git a/packages/2-sql/2-authoring/contract-psl/test/provider.test.ts b/packages/2-sql/2-authoring/contract-psl/test/provider.test.ts index bcee014e0b..326daae7f8 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/provider.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/provider.test.ts @@ -11,7 +11,6 @@ import { modelsOf, pgvectorAuthoringContributions, pgvectorExtensionPack, - postgresEnumInferenceCodecs, postgresTarget, } from './fixtures'; import { sqlStorageFromSuccessfulSqlInterpretation } from './interpret-sql-contract-storage'; @@ -23,7 +22,6 @@ describe('prismaContract provider helper', () => { const baseOptions = { target: postgresTarget, createNamespace: createTestSqlNamespace, - enumInferenceCodecs: postgresEnumInferenceCodecs, } as const; afterEach(async () => { diff --git a/packages/2-sql/2-authoring/contract-psl/test/psl-ts-namespace-parity.test.ts b/packages/2-sql/2-authoring/contract-psl/test/psl-ts-namespace-parity.test.ts index a983042171..d7be8ada9d 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/psl-ts-namespace-parity.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/psl-ts-namespace-parity.test.ts @@ -14,7 +14,6 @@ import { createTestSqlNamespace } from '../../../1-core/contract/test/test-suppo import { interpretPslDocumentToSqlContract } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, - postgresEnumInferenceCodecs, postgresScalarTypeDescriptors, postgresTarget, symbolTableInputFromParseArgs, @@ -59,7 +58,6 @@ namespace public { composedExtensionContracts: new Map(), controlMutationDefaults: createBuiltinLikeControlMutationDefaults(), createNamespace: createTestSqlNamespace, - enumInferenceCodecs: postgresEnumInferenceCodecs, }); expect(pslResult.ok).toBe(true); @@ -185,7 +183,6 @@ namespace public { composedExtensionPacks: ['supabase'], composedExtensionContracts: new Map([['supabase', syntheticExtensionContract]]), createNamespace: createTestSqlNamespace, - enumInferenceCodecs: postgresEnumInferenceCodecs, }); expect(pslResult.ok).toBe(true); @@ -256,7 +253,6 @@ namespace public { composedExtensionPacks: ['supabase'], composedExtensionContracts: new Map(), createNamespace: createTestSqlNamespace, - enumInferenceCodecs: postgresEnumInferenceCodecs, }); expect(result.ok).toBe(false); diff --git a/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts b/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts index 6fd53e2886..ff577264c6 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts @@ -13,8 +13,6 @@ import { createTestSqlNamespace } from '../../../1-core/contract/test/test-suppo import { interpretPslDocumentToSqlContract } from '../src/interpreter'; import { createBuiltinLikeControlMutationDefaults, - postgresEnumInferenceCodecs, - sqliteEnumInferenceCodecs, symbolTableInputFromParseArgs, testEnumEntityContributions, } from './fixtures'; @@ -366,7 +364,6 @@ describe('TS and PSL authoring parity', () => { readonly targetPack: TargetPackRef<'sql', string>; readonly scalarTypeDescriptors: ReadonlyMap; readonly authoringContributions: AuthoringContributions; - readonly enumInferenceCodecs: { readonly text: string; readonly int: string }; }): void { const tsContract = target.buildTsContract(); const pslDocument = symbolTableInputFromParseArgs({ @@ -382,7 +379,6 @@ describe('TS and PSL authoring parity', () => { controlMutationDefaults: createBuiltinLikeControlMutationDefaults(), authoringContributions: target.authoringContributions, createNamespace: createTestSqlNamespace, - enumInferenceCodecs: target.enumInferenceCodecs, }); expect(interpreted.ok).toBe(true); @@ -396,7 +392,6 @@ describe('TS and PSL authoring parity', () => { targetPack: sqliteTimestampTargetPack, scalarTypeDescriptors: sqliteTimestampScalarTypeDescriptors, authoringContributions: sqliteTimestampAuthoringContributions, - enumInferenceCodecs: sqliteEnumInferenceCodecs, }); }); @@ -406,7 +401,6 @@ describe('TS and PSL authoring parity', () => { targetPack: postgresTimestampTargetPack, scalarTypeDescriptors: postgresTimestampScalarTypeDescriptors, authoringContributions: postgresTimestampAuthoringContributions, - enumInferenceCodecs: postgresEnumInferenceCodecs, }); }); @@ -436,7 +430,6 @@ model Post { controlMutationDefaults: createBuiltinLikeControlMutationDefaults(), authoringContributions, createNamespace: createTestSqlNamespace, - enumInferenceCodecs: postgresEnumInferenceCodecs, }); expect(pslContract.ok).toBe(true); diff --git a/packages/2-sql/9-family/src/core/authoring-entity-types.ts b/packages/2-sql/9-family/src/core/authoring-entity-types.ts index b5d06e90dd..42db6a246f 100644 --- a/packages/2-sql/9-family/src/core/authoring-entity-types.ts +++ b/packages/2-sql/9-family/src/core/authoring-entity-types.ts @@ -4,19 +4,12 @@ import { type AuthoringEntityTypeDescriptor, type AuthoringEntityTypeNamespace, type AuthoringPslBlockDescriptorNamespace, - classifyEnumMemberType, type PslExtensionBlock, + resolveEnumCodecId, } from '@prisma-next/framework-components/authoring'; import { type EnumTypeHandle, enumType } from '@prisma-next/sql-contract-ts/contract-builder'; import { blindCast } from '@prisma-next/utils/casts'; -function parseQuotedString(raw: string): string | undefined { - if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) { - return raw.slice(1, -1); - } - return undefined; -} - export const sqlFamilyEnumEntityDescriptor = { kind: 'entity' as const, discriminator: 'enum', @@ -28,38 +21,11 @@ export const sqlFamilyEnumEntityDescriptor = { const sourceId = ctx.sourceId ?? 'unknown'; const diagnostics = ctx.diagnostics; - const typeAttr = block.blockAttributes.find((a) => a.name === 'type'); - - let codecId: string; - if (!typeAttr) { - const inferredKind = classifyEnumMemberType(block); - if (inferredKind === null || !ctx.enumInferenceCodecs) { - diagnostics?.push({ - code: 'PSL_ENUM_CANNOT_INFER_TYPE', - message: `cannot infer @@type for enum "${block.name}"; add an explicit @@type(...)`, - sourceId, - span: block.span, - }); - return undefined; - } - codecId = ctx.enumInferenceCodecs[inferredKind]; - } else { - const rawCodecArg = typeAttr.args[0]?.value; - const explicitCodecId = - rawCodecArg !== undefined ? parseQuotedString(rawCodecArg) : undefined; - if (!explicitCodecId) { - diagnostics?.push({ - code: 'PSL_ENUM_MISSING_TYPE', - message: `enum "${block.name}" @@type attribute must have a quoted codec id argument`, - sourceId, - span: typeAttr.span, - }); - return undefined; - } - codecId = explicitCodecId; + const resolved = resolveEnumCodecId(block, ctx); + if (resolved === undefined) { + return undefined; } - - const typeArgSpan = typeAttr?.args[0]?.span ?? typeAttr?.span ?? block.span; + const { codecId, codecSpan } = resolved; const nativeType = ctx.codecLookup?.targetTypesFor(codecId)?.[0]; if (nativeType === undefined) { @@ -67,7 +33,7 @@ export const sqlFamilyEnumEntityDescriptor = { code: 'PSL_EXTENSION_INVALID_VALUE', message: `enum "${block.name}" @@type references unknown codec "${codecId}"`, sourceId, - span: typeArgSpan, + span: codecSpan, }); return undefined; } @@ -78,7 +44,7 @@ export const sqlFamilyEnumEntityDescriptor = { code: 'PSL_EXTENSION_INVALID_VALUE', message: `enum "${block.name}" @@type codec "${codecId}" resolves in targetTypesFor but is absent from codecLookup.get`, sourceId, - span: typeArgSpan, + span: codecSpan, }); return undefined; } diff --git a/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md b/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md index b259d909c2..e060e32d0a 100644 --- a/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md +++ b/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md @@ -176,3 +176,13 @@ it from there instead of defining a local copy. Behaviour-preserving — the run facade surface (`db.enums`, `sql`, `orm`) is byte-identical. No extension-author API or surface change; nothing to migrate. Incidental substrate diff only. --> + +