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 @@ -58,7 +58,7 @@ model Product {
price Price
image Image
embedding Float[]?
status String @default("active")
status String
@@map("products")
@@textIndex([name, description], weights: { name: 10, description: 1 })
@@index([brand, subCategory])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ model Product {
price Price
image Image
embedding Float[]?
status ProductStatus @default(Active)
status ProductStatus
@@map("products")
@@textIndex([name, description], weights: { name: 10, description: 1 })
@@index([brand, subCategory])
Expand Down
2 changes: 1 addition & 1 deletion examples/retail-store/src/contract.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ model Product {
price Price
image Image
embedding Float[]?
status ProductStatus @default(Active)
status ProductStatus
@@map("products")
@@textIndex([name, description], weights: { name: 10, description: 1 })
@@index([brand, subCategory])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ PSL-to-Mongo contract interpreter for Prisma Next. Transforms Prisma Schema Lang
## Responsibilities

- **PSL interpretation**: `interpretPslDocumentToMongoContract()` maps a parsed PSL document to a Mongo `Contract` — scalar types, collection/field naming, `@id`/`@map`/`@@map` attributes, and N:1/1:N reference relations with backrelation disambiguation
- **Attribute registry**: `mongoAttributeSpecs` registers every Mongo built-in (`@@map`, `@@discriminator`, `@@base`, `@@index`, `@@unique`, `@@textIndex`, `@id`, `@unique`, `@map`, `@relation`) as spec factories over the uniform `AttributeSpecContext`; the family descriptor contributes it under `authoring.attributeSpecs`, and the interpreter sources every spec from it
- **Scalar type mapping**: `createMongoScalarTypeDescriptors()` provides the default PSL-type → Mongo codec ID mapping (e.g. `String` → `mongo/string@1`, `ObjectId` → `mongo/objectId@1`)
- **Contract provider**: `mongoContract()` (exported from `./provider`) integrates with the CLI's `prisma contract emit` command, reading a `.prisma` schema file and producing a `ContractConfig`
- **Diagnostics**: Emits structured diagnostics for unsupported field types (`PSL_UNSUPPORTED_FIELD_TYPE`), missing `@id` fields (`PSL_MISSING_ID_FIELD`), orphaned backrelations (`PSL_ORPHANED_BACKRELATION`), and ambiguous backrelations (`PSL_AMBIGUOUS_BACKRELATION`)
- **Diagnostics**: Emits structured diagnostics for unsupported field types (`PSL_UNSUPPORTED_FIELD_TYPE`), missing `@id` fields (`PSL_MISSING_ID_FIELD`), orphaned backrelations (`PSL_ORPHANED_BACKRELATION`), ambiguous backrelations (`PSL_AMBIGUOUS_BACKRELATION`), and attribute names outside the registered namespace (`PSL_UNSUPPORTED_MODEL_ATTRIBUTE`, `PSL_UNSUPPORTED_FIELD_ATTRIBUTE`)

## Known limitations

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export {
type InterpretPslDocumentToMongoContractInput,
interpretPslDocumentToMongoContract,
} from '../interpreter';
export { mongoAttributeSpecs } from '../mongo-attribute-specs';
157 changes: 130 additions & 27 deletions packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
isAuthoringEntityTypeDescriptor,
} from '@internal/framework-components/authoring';
import type { CodecLookup } from '@internal/framework-components/codec';
import type { ControlMutationDefaultRegistry } from '@internal/framework-components/control';
import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir';
import {
applyPolymorphicScopeToMongoIndex,
Expand All @@ -38,6 +39,7 @@ import {
import { mongoContractCanonicalizationHooks } from '@internal/mongo-contract/canonicalization-hooks';
import type { CollationOptions } from '@internal/mongo-value/mongodb-types';
import type {
AttributeSpecContext,
CompositeTypeSymbol,
FieldSymbol,
InferAttr,
Expand All @@ -56,16 +58,11 @@ import { ifDefined } from '@internal/utils/defined';
import { notOk, ok, type Result } from '@internal/utils/result';
import { deriveJsonSchema, derivePolymorphicJsonSchema } from './derive-json-schema';
import {
baseModelSpec,
buildIndexModelSpecs,
discriminatorModelSpec,
findFieldAttributeNode,
findModelAttributeNode,
interpretFieldAttribute,
interpretModelAttribute,
mapFieldSpec,
mapModelSpec,
relationFieldSpec,
mongoAttributeSpecs,
} from './mongo-attribute-specs';
import { getAttribute, lowerFirst } from './psl-helpers';

Expand All @@ -89,6 +86,7 @@ export interface InterpretPslDocumentToMongoContractInput {
readonly sourceFile: SourceFile;
readonly sourceId: string;
readonly scalarTypeCodecIds: ReadonlyMap<string, string>;
readonly controlMutationDefaults: ControlMutationDefaultRegistry;
readonly codecLookup?: CodecLookup;
readonly seedDiagnostics?: readonly ContractSourceDiagnostic[];
readonly authoringContributions?: AuthoringContributions;
Expand Down Expand Up @@ -116,6 +114,56 @@ function validateNamespaceBlocksForMongoTarget(input: {
}
}

const UNLOWERED_FIELD_ATTRIBUTE_HINTS: ReadonlyMap<string, string> = new Map([
[
'updatedAt',
'Mongo lowers no automatic timestamp updates; delete the attribute and set the timestamp in application code.',
],
]);

function unsupportedFieldAttributeMessage(
ownerName: string,
fieldName: string,
attributeName: string,
): string {
const base = `Field "${ownerName}.${fieldName}" uses unsupported attribute "@${attributeName}"`;
const hint = UNLOWERED_FIELD_ATTRIBUTE_HINTS.get(attributeName);
return hint === undefined ? base : `${base}. ${hint}`;
}

function reportUnknownAttributes(input: {
readonly models: readonly ModelSymbol[];
readonly compositeTypes: readonly CompositeTypeSymbol[];
readonly sourceId: string;
readonly diagnostics: ContractSourceDiagnostic[];
}): void {
const { sourceId, diagnostics } = input;
for (const model of input.models) {
for (const attribute of model.attributes) {
if (Object.hasOwn(mongoAttributeSpecs.model, attribute.name)) continue;
diagnostics.push({
code: 'PSL_UNSUPPORTED_MODEL_ATTRIBUTE',
message: `Model "${model.name}" uses unsupported attribute "@@${attribute.name}"`,
sourceId,
span: attribute.span,
});
}
}
for (const owner of [...input.models, ...input.compositeTypes]) {
for (const field of Object.values(owner.fields)) {
for (const attribute of field.attributes) {
if (Object.hasOwn(mongoAttributeSpecs.field, attribute.name)) continue;
diagnostics.push({
code: 'PSL_UNSUPPORTED_FIELD_ATTRIBUTE',
message: unsupportedFieldAttributeMessage(owner.name, field.name, attribute.name),
sourceId,
span: attribute.span,
});
}
Comment thread
SevInf marked this conversation as resolved.
}
}
}

interface FieldMappings {
readonly pslNameToMapped: Map<string, string>;
}
Expand All @@ -140,19 +188,20 @@ function fkRelationPairKey(declaringModel: string, targetModel: string): string

function resolveFieldMappings(input: {
readonly model: ModelSymbol;
readonly specContext: AttributeSpecContext;
readonly sourceFile: SourceFile;
readonly sourceId: string;
readonly diagnostics: ContractSourceDiagnostic[];
}): FieldMappings {
const { model, sourceFile, sourceId, diagnostics } = input;
const { model, specContext, sourceFile, sourceId, diagnostics } = input;
const pslNameToMapped = new Map<string, string>();
for (const field of Object.values(model.fields)) {
const mapNode = findFieldAttributeNode(field, 'map');
const mapped =
(mapNode
? interpretFieldAttribute({
node: mapNode,
spec: mapFieldSpec,
spec: mongoAttributeSpecs.field.map({ ...specContext, field }),
model,
field,
sourceFile,
Expand All @@ -167,16 +216,17 @@ function resolveFieldMappings(input: {

function resolveCollectionName(input: {
readonly model: ModelSymbol;
readonly specContext: AttributeSpecContext;
readonly sourceFile: SourceFile;
readonly sourceId: string;
readonly diagnostics: ContractSourceDiagnostic[];
}): string {
const { model, sourceFile, sourceId, diagnostics } = input;
const { model, specContext, sourceFile, sourceId, diagnostics } = input;
const mapNode = findModelAttributeNode(model, 'map');
const name = mapNode
? interpretModelAttribute({
node: mapNode,
spec: mapModelSpec,
spec: mongoAttributeSpecs.model.map(specContext),
model,
sourceFile,
sourceId,
Expand Down Expand Up @@ -209,6 +259,7 @@ function mongoCrossRef(modelName: string): CrossReference {

function collectPolymorphismDeclarations(
models: readonly ModelSymbol[],
specContextFor: (model: ModelSymbol) => AttributeSpecContext,
modelMetadataByName: ReadonlyMap<string, MongoModelMetadata>,
sourceFile: SourceFile,
sourceId: string,
Expand All @@ -221,11 +272,12 @@ function collectPolymorphismDeclarations(
const baseDeclarations = new Map<string, BaseDeclaration>();

for (const model of models) {
const specContext = specContextFor(model);
const discNode = findModelAttributeNode(model, 'discriminator');
if (discNode) {
const parsed = interpretModelAttribute({
node: discNode,
spec: discriminatorModelSpec,
spec: mongoAttributeSpecs.model.discriminator(specContext),
model,
sourceFile,
sourceId,
Expand Down Expand Up @@ -254,7 +306,7 @@ function collectPolymorphismDeclarations(
if (baseNode) {
const parsed = interpretModelAttribute({
node: baseNode,
spec: baseModelSpec,
spec: mongoAttributeSpecs.model.base(specContext),
model,
sourceFile,
sourceId,
Expand Down Expand Up @@ -592,9 +644,8 @@ function buildCollationFromSpec(args: SpecCollationArgs): CollationOptions | nul
return collation;
}

type IndexModelSpecs = ReturnType<typeof buildIndexModelSpecs>;
type NormalIndexArgs = InferAttr<IndexModelSpecs['index']>;
type TextIndexArgs = InferAttr<IndexModelSpecs['textIndex']>;
type NormalIndexArgs = InferAttr<ReturnType<typeof mongoAttributeSpecs.model.index>>;
type TextIndexArgs = InferAttr<ReturnType<typeof mongoAttributeSpecs.model.textIndex>>;

interface IndexBuildContext {
readonly pslModel: ModelSymbol;
Expand Down Expand Up @@ -807,6 +858,7 @@ function buildTextIndex(parsed: TextIndexArgs, ctx: IndexBuildContext): MongoInd

function collectIndexes(
pslModel: ModelSymbol,
specContext: AttributeSpecContext,
fieldMappings: FieldMappings,
modelNames: ReadonlySet<string>,
sourceId: string,
Expand All @@ -823,18 +875,27 @@ function collectIndexes(

for (const field of Object.values(pslModel.fields)) {
if (modelNames.has(field.typeName)) continue;
const uniqueAttr = getAttribute(field.attributes, 'unique');
if (!uniqueAttr) continue;
const uniqueNode = findFieldAttributeNode(field, 'unique');
if (!uniqueNode) continue;
const unique = interpretFieldAttribute({
node: uniqueNode,
spec: mongoAttributeSpecs.field.unique({ ...specContext, field }),
model: pslModel,
field,
sourceFile,
sourceId,
diagnostics,
});
if (unique === undefined) continue;
const mappedName = fieldMappings.pslNameToMapped.get(field.name) ?? field.name;
const fieldUniqueIndex = new MongoIndex({
keys: [{ field: mappedName, direction: 1 }],
unique: true,
});
indexes.push(fieldUniqueIndex);
indexSpans.set(fieldUniqueIndex, uniqueAttr.span);
indexSpans.set(fieldUniqueIndex, nodePslSpan(uniqueNode.syntax, sourceFile));
}

const specs = buildIndexModelSpecs(Object.keys(pslModel.fields));
const attributeNodes = Array.from(pslModel.node.attributes());
for (const [attrIndex, attr] of pslModel.attributes.entries()) {
if (attr.name !== 'index' && attr.name !== 'unique' && attr.name !== 'textIndex') continue;
Expand All @@ -853,7 +914,7 @@ function collectIndexes(
if (attr.name === 'textIndex') {
const parsed = interpretModelAttribute({
node,
spec: specs.textIndex,
spec: mongoAttributeSpecs.model.textIndex(specContext),
model: pslModel,
sourceFile,
sourceId,
Expand All @@ -875,7 +936,9 @@ function collectIndexes(
const unique = attr.name === 'unique';
const parsed = interpretModelAttribute({
node,
spec: unique ? specs.unique : specs.index,
spec: unique
? mongoAttributeSpecs.model.unique(specContext)
: mongoAttributeSpecs.model.index(specContext),
model: pslModel,
sourceFile,
sourceId,
Expand Down Expand Up @@ -1034,11 +1097,35 @@ export function interpretPslDocumentToMongoContract(
const allCompositeTypes: CompositeTypeSymbol[] = Object.values(topLevel.compositeTypes);
const modelNames = new Set(allModels.map((m) => m.name));
const compositeTypeNames = new Set(allCompositeTypes.map((ct) => ct.name));
reportUnknownAttributes({
models: allModels,
compositeTypes: allCompositeTypes,
sourceId,
diagnostics,
});
const specContextFor = (model: ModelSymbol): AttributeSpecContext => ({
symbols: symbolTable,
model,
controlMutationDefaults: input.controlMutationDefaults,
});
const modelMetadataByName = new Map<string, MongoModelMetadata>();
for (const model of allModels) {
const specContext = specContextFor(model);
modelMetadataByName.set(model.name, {
collectionName: resolveCollectionName({ model, sourceFile, sourceId, diagnostics }),
fieldMappings: resolveFieldMappings({ model, sourceFile, sourceId, diagnostics }),
collectionName: resolveCollectionName({
model,
specContext,
sourceFile,
sourceId,
diagnostics,
}),
fieldMappings: resolveFieldMappings({
model,
specContext,
sourceFile,
sourceId,
diagnostics,
}),
});
}

Expand Down Expand Up @@ -1092,6 +1179,7 @@ export function interpretPslDocumentToMongoContract(
const metadata = modelMetadataByName.get(pslModel.name);
if (!metadata) continue;
const { collectionName, fieldMappings } = metadata;
const specContext = specContextFor(pslModel);

const fields: Record<string, ContractField> = {};
const relations: Record<string, ContractReferenceRelation> = {};
Expand All @@ -1102,7 +1190,7 @@ export function interpretPslDocumentToMongoContract(
const relation = relationNode
? interpretFieldAttribute({
node: relationNode,
spec: relationFieldSpec,
spec: mongoAttributeSpecs.field.relation({ ...specContext, field }),
model: pslModel,
field,
sourceFile,
Expand Down Expand Up @@ -1169,9 +1257,22 @@ export function interpretPslDocumentToMongoContract(
}

const isVariantModel = pslModel.attributes.some((attr) => attr.name === 'base');
const hasIdField = Object.values(pslModel.fields).some(
(f) => getAttribute(f.attributes, 'id') !== undefined,
);
const hasIdField =
Object.values(pslModel.fields).filter((field) => {
const idNode = findFieldAttributeNode(field, 'id');
if (!idNode) return false;
return (
interpretFieldAttribute({
node: idNode,
spec: mongoAttributeSpecs.field.id({ ...specContext, field }),
model: pslModel,
field,
sourceFile,
sourceId,
diagnostics,
}) !== undefined
);
}).length > 0;
// Variant models inherit the base's identity and are validated through their base.
if (!isVariantModel) {
if (!hasIdField) {
Expand Down Expand Up @@ -1206,6 +1307,7 @@ export function interpretPslDocumentToMongoContract(
models[pslModel.name] = { fields, relations, storage: { collection: collectionName } };
const modelIndexes = collectIndexes(
pslModel,
specContext,
fieldMappings,
modelNames,
sourceId,
Expand Down Expand Up @@ -1296,6 +1398,7 @@ export function interpretPslDocumentToMongoContract(

const { discriminatorDeclarations, baseDeclarations } = collectPolymorphismDeclarations(
allModels,
specContextFor,
modelMetadataByName,
sourceFile,
sourceId,
Expand Down
Loading
Loading