Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -88,10 +88,23 @@ 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`. */
/**
* The target's default codec ids for an `enum` block that omits `@@type`. When omitted,
* they default to the target's PSL `String`/`Int` scalar type descriptors, so classic
* bare-member enums resolve without any explicit wiring.
*/
readonly enumInferenceCodecs?: { readonly text: string; readonly int: string };
}

function deriveEnumInferenceCodecs(
scalarTypeDescriptors: ReadonlyMap<string, string>,
): { readonly text: string; readonly int: string } | undefined {
const text = scalarTypeDescriptors.get('String');
const int = scalarTypeDescriptors.get('Int');
if (text === undefined || int === undefined) return undefined;
return { text, int };
}

/**
* Mongo's PSL surface binds the database from the connection string, so every
* explicit namespace block is invalid, including `namespace unbound { … }`.
Expand Down Expand Up @@ -971,14 +984,17 @@ export function interpretPslDocumentToMongoContract(
.filter((b) => b.keyword === 'enum')
.map((b) => b.block);

const enumInferenceCodecs =
input.enumInferenceCodecs ?? deriveEnumInferenceCodecs(scalarTypeDescriptors);

const builtEnums = processEnumDeclarations({
enumBlocks: topLevelEnumBlocks,
sourceId,
authoringContributions: input.authoringContributions,
entityContext: {
family: 'mongo',
target: 'mongo',
...ifDefined('enumInferenceCodecs', input.enumInferenceCodecs),
...ifDefined('enumInferenceCodecs', enumInferenceCodecs),
...ifDefined('codecLookup', codecLookup),
sourceId,
diagnostics: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import { interpretPslDocumentToMongoContract } from './interpreter';

export interface MongoContractOptions {
readonly output?: string;
/** The target's default codec ids for an `enum` block that omits `@@type`. */
/**
* Overrides the codec ids an `enum` block that omits `@@type` infers. When omitted,
* the interpreter defaults them to the target's PSL `String`/`Int` scalar type
* descriptors, so bare-member enums work without explicit wiring.
*/
readonly enumInferenceCodecs?: { readonly text: string; readonly int: string };
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import type { EnumTypeHandle } from '@prisma-next/contract-authoring';
import { enumType } from '@prisma-next/contract-authoring';
import type {
AuthoringContributions,
AuthoringEntityContext,
PslExtensionBlock,
} from '@prisma-next/framework-components/authoring';
import { resolveEnumCodecId } from '@prisma-next/framework-components/authoring';
import type { CodecLookup } from '@prisma-next/framework-components/codec';
import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir';
import { buildSymbolTable, type SymbolTable } from '@prisma-next/psl-parser';
import type { SourceFile } from '@prisma-next/psl-parser/syntax';
import { parse } from '@prisma-next/psl-parser/syntax';
import { describe, expect, it } from 'vitest';
import { interpretPslDocumentToMongoContract } from '../src/interpreter';

const mongoScalarTypeDescriptors: ReadonlyMap<string, string> = new Map([
['String', 'mongo/string@1'],
['Int', 'mongo/int32@1'],
['ObjectId', 'mongo/objectId@1'],
]);

const mongoTargetTypes: Record<string, readonly string[]> = {
'mongo/string@1': ['string'],
'mongo/string@2': ['string'],
'mongo/int32@1': ['int'],
'mongo/objectId@1': ['objectId'],
};

const mongoCodecLookup: CodecLookup = {
get(id: string) {
const targetTypes = mongoTargetTypes[id];
if (!targetTypes) return undefined;
return {
id,
encode: async (v: unknown) => v,
decode: async (w: unknown) => w,
encodeJson: (v: unknown) => v,
decodeJson: (j: unknown) => j,
} as ReturnType<CodecLookup['get']>;
},
targetTypesFor: (id: string) => mongoTargetTypes[id],
metaFor: () => undefined,
renderOutputTypeFor: () => undefined,
};

const enumEntityDescriptor = {
kind: 'entity',
discriminator: 'enum',
output: {
factory: (
block: PslExtensionBlock,
ctx: AuthoringEntityContext,
): EnumTypeHandle | undefined => {
const resolved = resolveEnumCodecId(block, ctx);
if (resolved === undefined) return undefined;
const nativeType = ctx.codecLookup?.targetTypesFor(resolved.codecId)?.[0];
if (nativeType === undefined) return undefined;
return enumType(
block.name,
{ codecId: resolved.codecId, nativeType },
...Object.keys(block.parameters).map((name) => ({ name, value: name })),
);
},
},
} as const;

const authoringContributions: AuthoringContributions = {
entityTypes: { enum: enumEntityDescriptor },
};

const enumPslBlockDescriptors = {
enum: {
kind: 'pslBlock',
keyword: 'enum',
discriminator: 'enum',
name: { required: true },
parameters: {},
variadicParameters: true,
},
} as const;

function buildSymbolTableInput(
schema: string,
sourceId = 'test.prisma',
): { symbolTable: SymbolTable; sourceFile: SourceFile; sourceId: string } {
const { document, sourceFile } = parse(schema);
const { table } = buildSymbolTable({
document,
sourceFile,
scalarTypes: [...mongoScalarTypeDescriptors.keys()],
pslBlockDescriptors: enumPslBlockDescriptors,
});
return { symbolTable: table, sourceFile, sourceId };
}

const bareMemberEnumSchema = `enum WhatsAppMessageDirection {
INBOUND
OUTBOUND
}

model WhatsAppMessages {
id ObjectId @id @map("_id")
direction WhatsAppMessageDirection
}
`;

describe('mongo PSL interpreter: enum fields', () => {
it('resolves a bare-member enum without explicit enumInferenceCodecs', () => {
const result = interpretPslDocumentToMongoContract({
...buildSymbolTableInput(bareMemberEnumSchema),
scalarTypeDescriptors: mongoScalarTypeDescriptors,
codecLookup: mongoCodecLookup,
authoringContributions,
});

expect(result.ok).toBe(true);
if (!result.ok) return;

const namespace = result.value.domain.namespaces[UNBOUND_NAMESPACE_ID];
expect(namespace?.enum).toEqual({
WhatsAppMessageDirection: {
codecId: 'mongo/string@1',
members: [
{ name: 'INBOUND', value: 'INBOUND' },
{ name: 'OUTBOUND', value: 'OUTBOUND' },
],
},
});
expect(namespace?.models['WhatsAppMessages']?.fields['direction']).toEqual({
type: { kind: 'scalar', codecId: 'mongo/string@1' },
nullable: false,
valueSet: {
plane: 'domain',
entityKind: 'enum',
namespaceId: UNBOUND_NAMESPACE_ID,
entityName: 'WhatsAppMessageDirection',
},
});

const storage = result.value.storage as unknown as {
namespaces: Record<
string,
{ entries: { valueSet?: Record<string, { kind: string; values: unknown[] }> } }
>;
};
expect(storage.namespaces[UNBOUND_NAMESPACE_ID]?.entries.valueSet).toEqual({
WhatsAppMessageDirection: { kind: 'valueSet', values: ['INBOUND', 'OUTBOUND'] },
});
});

it('explicit enumInferenceCodecs overrides the scalar-descriptor default', () => {
const result = interpretPslDocumentToMongoContract({
...buildSymbolTableInput(bareMemberEnumSchema),
scalarTypeDescriptors: mongoScalarTypeDescriptors,
codecLookup: mongoCodecLookup,
authoringContributions,
enumInferenceCodecs: { text: 'mongo/string@2', int: 'mongo/int32@1' },
});

expect(result.ok).toBe(true);
if (!result.ok) return;

const namespace = result.value.domain.namespaces[UNBOUND_NAMESPACE_ID];
expect(namespace?.enum?.['WhatsAppMessageDirection']?.codecId).toBe('mongo/string@2');
});

it('reports PSL_ENUM_CANNOT_INFER_TYPE when no inference codecs can be derived', () => {
const descriptorsWithoutInt: ReadonlyMap<string, string> = new Map(
[...mongoScalarTypeDescriptors].filter(([name]) => name !== 'Int'),
);
const { document, sourceFile } = parse(bareMemberEnumSchema);
const { table } = buildSymbolTable({
document,
sourceFile,
scalarTypes: [...descriptorsWithoutInt.keys()],
pslBlockDescriptors: enumPslBlockDescriptors,
});
const result = interpretPslDocumentToMongoContract({
symbolTable: table,
sourceFile,
sourceId: 'test.prisma',
scalarTypeDescriptors: descriptorsWithoutInt,
codecLookup: mongoCodecLookup,
authoringContributions,
});

expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.failure.diagnostics.map((d) => d.code)).toEqual([
'PSL_ENUM_CANNOT_INFER_TYPE',
'PSL_UNSUPPORTED_FIELD_TYPE',
]);
});
});
6 changes: 1 addition & 5 deletions packages/3-extensions/mongo/src/config/define-config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
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';
Expand Down Expand Up @@ -42,10 +41,7 @@ export function defineConfig(options: MongoConfigOptions): PrismaNextConfig<'mon
const contractConfig =
ext === '.ts'
? typescriptContractFromPath(options.contract, output)
: mongoContract(options.contract, {
output,
enumInferenceCodecs: { text: MONGO_STRING_CODEC_ID, int: MONGO_INT32_CODEC_ID },
});
: mongoContract(options.contract, { output });

return coreDefineConfig({
family: mongoFamilyDescriptor,
Expand Down
83 changes: 83 additions & 0 deletions test/integration/test/cli.emit-command.additional.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,89 @@ model Post {
}
});

for (const configName of ['prisma-next.config.mongo.ts', 'prisma-next.config.mongo-define.ts']) {
it(`emits a Mongo contract for a bare-member enum schema via ${configName}`, {
timeout: timeouts.typeScriptCompilation,
}, async () => {
const command = createContractEmitCommand();
const testSetup = setupIntegrationTestDirectoryFromFixtures(fixtureSubdir, configName);

try {
writeFileSync(
join(testSetup.testDir, 'contract.prisma'),
`enum WhatsAppMessageDirection {
INBOUND
OUTBOUND
}

model WhatsAppMessages {
id ObjectId @id @map("_id")
direction WhatsAppMessageDirection
}
`,
'utf-8',
);

const originalCwd = process.cwd();
try {
process.chdir(testSetup.testDir);
const exitCode = await executeCommand(command, [
'--config',
'prisma-next.config.ts',
'--json',
]);
expect(exitCode).toBe(0);
} finally {
process.chdir(originalCwd);
}

const contractJson = JSON.parse(
readFileSync(join(testSetup.outputDir, 'contract.json'), 'utf-8'),
);
expect(contractJson).toMatchObject({
targetFamily: 'mongo',
target: 'mongo',
domain: {
namespaces: {
__unbound__: {
enum: {
WhatsAppMessageDirection: {
codecId: 'mongo/string@1',
members: [
{ name: 'INBOUND', value: 'INBOUND' },
{ name: 'OUTBOUND', value: 'OUTBOUND' },
],
},
},
models: {
WhatsAppMessages: expect.objectContaining({
fields: expect.objectContaining({
direction: {
type: { kind: 'scalar', codecId: 'mongo/string@1' },
nullable: false,
valueSet: {
plane: 'domain',
entityKind: 'enum',
namespaceId: '__unbound__',
entityName: 'WhatsAppMessageDirection',
},
},
}),
}),
},
},
},
},
});
expect(
contractJson.storage.namespaces.__unbound__.entries.valueSet.WhatsAppMessageDirection,
).toEqual({ kind: 'valueSet', values: ['INBOUND', 'OUTBOUND'] });
} finally {
testSetup.cleanup();
}
});
}

it('emits contract.json and contract.d.ts with Mongo contract.ts config', {
timeout: timeouts.typeScriptCompilation,
}, async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineConfig } from '@prisma-next/mongo/config';

export default defineConfig({
contract: './contract.prisma',
outputPath: 'output',
});
Loading