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..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 @@ -21,6 +21,7 @@ export type { } from '../shared/framework-authoring'; export { assertNoCrossRegistryCollisions, + classifyEnumMemberType, hasRegisteredFieldNamespace, instantiateAuthoringEntityType, instantiateAuthoringFieldPreset, @@ -32,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 3c2d3aa4ba..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,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, PslSpan } from './psl-extension-block'; + +export type EnumInferredMemberType = 'text' | 'int'; export type AuthoringArgRef = { readonly kind: 'arg'; @@ -134,6 +136,102 @@ 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; +} + +/** + * 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 { 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 cbdc3ffd2b..5a8258813f 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 @@ -88,6 +88,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 }; } /** @@ -976,6 +978,7 @@ export function interpretPslDocumentToMongoContract( entityContext: { family: 'mongo', target: 'mongo', + ...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/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..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 @@ -1,21 +1,15 @@ import type { JsonValue } from '@prisma-next/contract/types'; -import type { - AuthoringEntityContext, - AuthoringEntityTypeDescriptor, - AuthoringEntityTypeNamespace, - AuthoringPslBlockDescriptorNamespace, - PslExtensionBlock, +import { + type AuthoringEntityContext, + type AuthoringEntityTypeDescriptor, + type AuthoringEntityTypeNamespace, + type AuthoringPslBlockDescriptorNamespace, + 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', @@ -27,49 +21,30 @@ export const mongoFamilyEnumEntityDescriptor = { const sourceId = ctx.sourceId ?? 'unknown'; 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 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, - }); + const resolved = resolveEnumCodecId(block, ctx); + if (resolved === undefined) { return undefined; } + const { codecId, codecSpan } = resolved; 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}"`, sourceId, - span: typeArgSpan, + span: codecSpan, }); return undefined; } 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`, sourceId, - span: typeArgSpan, + span: codecSpan, }); return undefined; } 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 3d548b4c4f..3b26cd9c70 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -133,6 +133,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 }; readonly capabilities: CapabilityMatrix; } @@ -1849,6 +1851,7 @@ export function interpretPslDocumentToSqlContract( ); }, }, + ...ifDefined('enumInferenceCodecs', input.enumInferenceCodecs), }, diagnostics, }); @@ -2027,6 +2030,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 95e0d27831..6db554fa97 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 }; } /** @@ -145,6 +147,7 @@ export function prismaContract(schemaPath: string, options: PrismaContractOption createNamespace: options.createNamespace, capabilities: context.capabilities, codecLookup: context.codecLookup, + ...ifDefined('enumInferenceCodecs', options.enumInferenceCodecs), }); if (!interpreted.ok) { return interpreted; 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 a6286d0367..bf62b0b678 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, + 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'; @@ -37,28 +38,11 @@ function testEnumFactory( const sourceId = ctx.sourceId ?? 'unknown'; 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, - }); + const resolved = resolveEnumCodecId(block, ctx); + if (resolved === undefined) { return undefined; } + const { codecId, codecSpan } = resolved; const nativeType = ctx.codecLookup?.targetTypesFor(codecId)?.[0]; if (nativeType === undefined) { @@ -66,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, + span: codecSpan, }); return undefined; } @@ -77,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, + span: codecSpan, }); return undefined; } @@ -236,6 +220,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 +306,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 +324,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 +342,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 +378,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.enum.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.enum.test.ts index 899aa17750..455c98507b 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 @@ -278,7 +299,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' })]), ); }); @@ -577,6 +598,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/9-family/src/core/authoring-entity-types.ts b/packages/2-sql/9-family/src/core/authoring-entity-types.ts index 2aec163d85..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 @@ -1,21 +1,15 @@ import type { JsonValue } from '@prisma-next/contract/types'; -import type { - AuthoringEntityContext, - AuthoringEntityTypeDescriptor, - AuthoringEntityTypeNamespace, - AuthoringPslBlockDescriptorNamespace, - PslExtensionBlock, +import { + type AuthoringEntityContext, + type AuthoringEntityTypeDescriptor, + type AuthoringEntityTypeNamespace, + type AuthoringPslBlockDescriptorNamespace, + 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', @@ -27,49 +21,30 @@ export const sqlFamilyEnumEntityDescriptor = { const sourceId = ctx.sourceId ?? 'unknown'; 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 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, - }); + const resolved = resolveEnumCodecId(block, ctx); + if (resolved === undefined) { return undefined; } + const { codecId, codecSpan } = resolved; 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}"`, sourceId, - span: typeArgSpan, + span: codecSpan, }); return undefined; } 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`, sourceId, - span: typeArgSpan, + span: codecSpan, }); return undefined; } 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/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-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-mongo-target/2-mongo-adapter/package.json b/packages/3-mongo-target/2-mongo-adapter/package.json index 237d215919..63ff9e6434 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", "./codecs": "./dist/codecs.mjs", "./control": "./dist/control.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 83626694c0..f9fdf07854 100644 --- a/packages/3-mongo-target/2-mongo-adapter/tsdown.config.ts +++ b/packages/3-mongo-target/2-mongo-adapter/tsdown.config.ts @@ -7,5 +7,6 @@ export default defineConfig({ runtime: 'src/exports/runtime.ts', codecs: 'src/exports/codecs.ts', 'codec-types': 'src/exports/codec-types.ts', + 'codec-ids': 'src/exports/codec-ids.ts', }, }); 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/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. 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 d42e04a798..b5130ec0c2 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 @@ -195,6 +195,16 @@ facade surface (`db.enums`, `sql`, `orm`) is byte-identical. No extension-author API or surface change; nothing to migrate. Incidental substrate diff only. --> + +