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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type {
} from '../shared/framework-authoring';
export {
assertNoCrossRegistryCollisions,
classifyEnumMemberType,
hasRegisteredFieldNamespace,
instantiateAuthoringEntityType,
instantiateAuthoringFieldPreset,
Expand All @@ -32,6 +33,7 @@ export {
isAuthoringTypeConstructorDescriptor,
mergeAuthoringNamespaces,
resolveAuthoringTemplateValue,
resolveEnumCodecId,
validateAuthoringHelperArguments,
} from '../shared/framework-authoring';
export type {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { AuthoringTypeConstructorDescriptor } from '../src/shared/framework-authoring';
import {
classifyEnumMemberType,
hasRegisteredFieldNamespace,
instantiateAuthoringFieldPreset,
instantiateAuthoringTypeConstructor,
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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<string, PslExtensionBlockParamValue>): 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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

/**
Expand Down Expand Up @@ -976,6 +978,7 @@ export function interpretPslDocumentToMongoContract(
entityContext: {
family: 'mongo',
target: 'mongo',
...ifDefined('enumInferenceCodecs', input.enumInferenceCodecs),
...ifDefined('codecLookup', codecLookup),
sourceId,
diagnostics: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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;
}
Expand Down
Loading
Loading