diff --git a/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md b/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md index 6a3d96788c01..d0f20e126bb6 100644 --- a/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md +++ b/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md @@ -50,7 +50,7 @@ The SQL and Mongo family interpreters are the first consumers. They define their The kit consumes `ExpressionAst` directly. No intermediate argument representation is introduced, and no combinator reparses flattened source text except `json()`, the deliberate quoted-JSON-object exception. -Attributes are a PSL authoring concern, so the kit is in `psl-parser` rather than framework core. Field, model, and block attributes are all constructed through it. A block descriptor declares which attributes its block accepts, and the generic block reconstruction interprets them at parse time. +Attributes are a PSL authoring concern, so the kit is in `psl-parser` rather than framework core. Field, model, and block attributes are all constructed through it. A block descriptor declares which attributes its block accepts, and symbol-table construction interprets them after collecting all declarations. --- @@ -85,6 +85,7 @@ A combinator declares what it reads. The contexts nest by what the site being pa interface AttributeCtx { readonly sourceId: string; readonly sourceFile: SourceFile; + readonly symbols: SymbolTable; } interface ModelAttributeCtx extends AttributeCtx { @@ -97,7 +98,7 @@ interface FieldAttributeCtx extends ModelAttributeCtx { } ``` -A block has no model, so a block attribute is parsed with only the source context. A combinator is usable at any level that carries the facts it declares, and rejected where those facts do not exist. +A block has no model, so a block attribute is parsed without a model context. A combinator is usable at any level that carries the facts it declares, and rejected where those facts do not exist. Checked references derive their lexical scope from the expression's syntax ancestry; the parse context carries no owner or scope field. A spec fixes the attribute level and name, declares its arguments, and may refine the parsed result: @@ -136,6 +137,7 @@ Positionals are fixed slots with an output key. Variadic positionals are not sup - `numLiteral()` parses any number literal and keeps its source text, for consumers that must not round it through a JavaScript number. - `int({ min, max })` parses an integer with optional inclusive bounds. - `bool()` parses a boolean literal. +- `identifier()` accepts any bare identifier and returns its name as a string. - `identifier(name)` matches one exact bare identifier and preserves its literal type. There is no enum-specific combinator. A fixed vocabulary is a `oneOf` over pinned matchers, making the source spelling explicit: @@ -157,9 +159,20 @@ These leaves perform direct AST checks. They do not wrap arktype schemas. `fieldRef()` parses a field-name identifier and validates it against the declaring model, so it is available to model and field attributes alike. `referencedFieldRef()` validates against the relation target, which only a field can resolve; cross-space references may defer the existence check when no referenced model is locally available. Both return the authored field name as a string. -`entityRef()` parses an unresolved model-name string. Existence and family semantics remain downstream concerns. +`entityRef(expected)` checks that the referenced declaration exists and has the expected kind: `{ kind: 'model' }`, `{ kind: 'compositeType' }`, `{ kind: 'namedType' }`, or `{ kind: 'block', keyword }`. It returns the selected declaration plus its lexical namespace (undefined at top level). Resolution prefers the containing namespace's declaration, then top level, never a sibling namespace; forward references are allowed, and missing or wrong-kind targets produce source-anchored expression diagnostics. -The current kit does not return declaration-bearing entity coordinates, provide a document-path scope, or include a codec reference combinator. Those would be separate additions if a future consumer requires them. +```ts +const baseSpec = modelAttribute('base', { + documentation: 'Declares the base model.', + positional: [ + { key: 'base', type: entityRef({ kind: 'model' }), documentation: 'The model to inherit from.' }, + ], +}); +``` + +`oneOf(entityRef(expected), identifier())` prefers a checked identity and otherwise returns an unchecked name, without leaking failed-alternative diagnostics. + +The current kit does not provide a document-path scope or include a codec reference combinator. Those would be separate additions if a future consumer requires them. ### Native collections @@ -244,7 +257,7 @@ const indexFieldElement = oneOf( fieldRef(), funcCall('wildcard', { documentation: 'Indexes document fields using a wildcard index.', - positional: [{ key: 'scope', type: optional(entityRef()), documentation: 'The field path to index recursively. Omit for all document fields.' }], + positional: [{ key: 'scope', type: optional(identifier()), documentation: 'The field path to index recursively. Omit for all document fields.' }], }), ...fieldNames.map((name) => funcCall(name, sortSig)), ); @@ -320,7 +333,6 @@ The current implementation is sufficient for interpreter consumption but not yet ## Follow-up work - Add central spec discovery and traversable combinator metadata for language-tooling consumers. -- Decide whether reference combinators should expose declaration-bearing results while preserving the interpreter's string-oriented lowering needs. - Revisit signature-derived `TypedFuncCall` output types if downstream code needs statically discriminated call unions. - Decide whether literal-to-field-type compatibility should remain in lowering or gain a dedicated field-context combinator. diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts index 7b43666db991..e4920b97a4ec 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts @@ -7,7 +7,7 @@ import type { AttributeCtx } from '../types'; export const ATTRIBUTE_DIAGNOSTIC_CODE: PslDiagnosticCode = 'PSL_INVALID_ATTRIBUTE_SYNTAX'; export function leafDiagnostic( - ctx: AttributeCtx, + ctx: Pick, node: AstNode, message: string, code: PslDiagnostic['code'] = ATTRIBUTE_DIAGNOSTIC_CODE, diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/entity-ref.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/entity-ref.ts index 9b74e5e518d0..8b01564bf2e6 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/entity-ref.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/entity-ref.ts @@ -1,25 +1,53 @@ import { notOk, ok, type Result } from '@internal/utils/result'; import type { PslDiagnostic } from '../../diagnostic'; +import type { + DeclarationFor, + EntitySelector, + ResolvedEntityReference, +} from '../../entity-reference'; +import { resolveEntityReference } from '../../entity-reference'; import { IdentifierAst } from '../../syntax/ast/identifier'; import type { AttributeCtx, EntityRefArgType } from '../types'; import { leafDiagnostic } from './diagnostic'; -// A bare model-name reference. Existence of a model with this name is resolved -// downstream (e.g. `resolvePolymorphism`), not here. -export function entityRef(): EntityRefArgType { +export function entityRef( + expected: S, +): EntityRefArgType, AttributeCtx> { + const label = `${expected.kind === 'block' ? expected.keyword : expected.kind} reference`; return { kind: 'entityRef', - label: 'model name', - parse: (arg, ctx): Result => { - const identifier = IdentifierAst.cast(arg.syntax); - if (identifier === undefined) { - return notOk([leafDiagnostic(ctx, arg, 'Expected a model name')]); - } - const name = identifier.name(); + label, + expected, + parse: ( + arg, + ctx, + ): Result>, readonly PslDiagnostic[]> => { + const name = IdentifierAst.cast(arg.syntax)?.name(); if (name === undefined) { - return notOk([leafDiagnostic(ctx, arg, 'Expected a model name')]); + return notOk([leafDiagnostic(ctx, arg, `Expected ${label}`)]); + } + const reference = resolveEntityReference(arg, name, ctx.symbols); + if (reference === undefined) { + return notOk([leafDiagnostic(ctx, arg, `Unknown ${label} "${name}"`)]); + } + if (!matchesSelector(reference, expected)) { + const actual = reference.declaration; + const kind = actual.kind === 'block' ? actual.keyword : actual.kind; + return notOk([leafDiagnostic(ctx, arg, `Expected ${label} "${name}", found ${kind}`)]); } - return ok(name); + return ok(reference); }, }; } + +function matchesSelector( + reference: ResolvedEntityReference, + expected: S, +): reference is ResolvedEntityReference> { + const declaration = reference.declaration; + return ( + declaration.kind === expected.kind && + (expected.kind !== 'block' || + (declaration.kind === 'block' && declaration.keyword === expected.keyword)) + ); +} diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts index 2cce7fef7e19..db523fa6ff67 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts @@ -1,22 +1,33 @@ import { notOk, ok, type Result } from '@internal/utils/result'; import type { PslDiagnostic } from '../../diagnostic'; import { IdentifierAst } from '../../syntax/ast/identifier'; -import type { AttributeCtx, IdentifierArgType } from '../types'; +import type { + AttributeCtx, + FixedIdentifierArgType, + IdentifierArgType, + UnrestrictedIdentifierArgType, +} from '../types'; import { leafDiagnostic } from './diagnostic'; +export function identifier(): UnrestrictedIdentifierArgType; export function identifier( name: N, options: { readonly documentation: string }, -): IdentifierArgType { +): FixedIdentifierArgType; +export function identifier( + name?: string, + options?: { readonly documentation: string }, +): IdentifierArgType { + const label = name ?? 'identifier'; return { kind: 'identifier', - label: name, + label, name, - documentation: options.documentation, - parse: (arg, ctx): Result => { - const identifier = IdentifierAst.cast(arg.syntax); - if (identifier !== undefined && identifier.name() === name) return ok(name); - return notOk([leafDiagnostic(ctx, arg, `Expected ${name}`)]); + documentation: options?.documentation ?? '', + parse: (arg, ctx): Result => { + const value = IdentifierAst.cast(arg.syntax)?.name(); + if (value !== undefined && (name === undefined || value === name)) return ok(value); + return notOk([leafDiagnostic(ctx, arg, `Expected ${label}`)]); }, }; } diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/list.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/list.ts index 0f890586cb4f..433bb986908e 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/list.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/list.ts @@ -19,7 +19,7 @@ export function list( const unique = opts?.unique ?? false; return { kind: 'list', - label: opts?.label ?? `${of.label}[]`, + label: opts?.label ?? (of.label.includes(' | ') ? `(${of.label})[]` : `${of.label}[]`), of, allowEmpty, unique, diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/types.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/types.ts index 43de50a12b10..d4576cef638d 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/types.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/types.ts @@ -3,8 +3,13 @@ import type { PslSpan } from '@internal/framework-components/psl-ast'; import type { Result } from '@internal/utils/result'; import type { Simplify, UnionToIntersection } from '@internal/utils/types'; import type { PslDiagnostic } from '../diagnostic'; +import type { + EntityDeclaration, + EntitySelector, + ResolvedEntityReference, +} from '../entity-reference'; import type { PslSources } from '../source-file'; -import type { FieldSymbol, ModelSymbol } from '../symbol-table'; +import type { FieldSymbol, ModelSymbol, SymbolTable } from '../symbol-table'; import type { ExpressionAst } from '../syntax/ast/expressions'; import type { AstNode } from '../syntax/ast-helpers'; @@ -12,6 +17,7 @@ export type AttributeLevel = 'field' | 'model' | 'block'; export interface AttributeCtx { readonly sources: PslSources; + readonly symbols: SymbolTable; } export interface ModelAttributeCtx extends AttributeCtx { @@ -53,9 +59,12 @@ export interface BoolArgType readonly kind: 'bool'; } -export interface EntityRefArgType - extends ArgTypeOutput { +export interface EntityRefArgType< + D extends EntityDeclaration = EntityDeclaration, + Ctx extends AttributeCtx = AttributeCtx, +> extends ArgTypeOutput, Ctx> { readonly kind: 'entityRef'; + readonly expected: EntitySelector; } export interface FieldRefArgType @@ -90,7 +99,7 @@ export interface FuncCallArgType< readonly signature: Signature; } -export interface IdentifierArgType< +export interface FixedIdentifierArgType< Name extends string = string, Ctx extends AttributeCtx = AttributeCtx, > extends ArgTypeOutput { @@ -99,6 +108,17 @@ export interface IdentifierArgType< readonly documentation: string; } +export interface UnrestrictedIdentifierArgType + extends ArgTypeOutput { + readonly kind: 'identifier'; + readonly name: undefined; +} + +export type IdentifierArgType< + Name extends string = string, + Ctx extends AttributeCtx = AttributeCtx, +> = FixedIdentifierArgType | UnrestrictedIdentifierArgType; + export interface IntArgType extends ArgTypeOutput { readonly kind: 'int'; @@ -227,7 +247,7 @@ export type ContextForRequirement = Req extends 'fie export type InspectableArgType = | BoolArgType - | EntityRefArgType + | EntityRefArgType | FieldRefArgType | FuncCallArgType | IdentifierArgType diff --git a/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts b/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts index 85c94770f517..b63e7ae6797a 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts @@ -13,6 +13,7 @@ import type { BlockAttributeSpecFactory } from './attribute-spec/spec-context'; import type { ParseDiagnostic } from './parse'; import { nodePslSpan } from './resolve'; import type { PslSources } from './source-file'; +import type { BlockSymbol, SymbolTable } from './symbol-table'; import type { ModelAttributeAst } from './syntax/ast/attributes'; import type { GenericBlockDeclarationAst, KeyValuePairAst } from './syntax/ast/declarations'; import { ArrayLiteralAst, type ExpressionAst } from './syntax/ast/expressions'; @@ -33,8 +34,7 @@ export function reconstructExtensionBlock( const blockName = node.name()?.name() ?? ''; const blockAttributes: PslExtensionBlockAttribute[] = []; - const attributes: Record = {}; - const seenAttributeNames = new Set(); + for (const attribute of node.attributes()) { const name = attribute.name()?.path().join('.') ?? ''; const args = Array.from(attribute.argList()?.args() ?? [], (arg) => { @@ -47,22 +47,6 @@ export function reconstructExtensionBlock( }); const span = nodePslSpan(attribute.syntax, sources); blockAttributes.push({ name, args, span }); - if (descriptor === undefined) continue; - const parsed = parseBlockAttribute( - attribute, - name, - span, - descriptor, - seenAttributeNames, - keyword, - blockName, - sources, - ); - if (parsed.ok) { - attributes[name] = parsed.value; - } else { - diagnostics.push(...parsed.diagnostics); - } } const parameters: Record = {}; @@ -97,11 +81,40 @@ export function reconstructExtensionBlock( name: blockName, parameters, blockAttributes, - attributes, + attributes: {}, span: nodePslSpan(node.syntax, sources), }; } +export function interpretBlockAttributes( + symbol: BlockSymbol, + descriptor: AuthoringPslBlockDescriptor, + sources: PslSources, + symbols: SymbolTable, + diagnostics: ParseDiagnostic[], +): void { + const seenNames = new Set(); + for (const attribute of symbol.node.attributes()) { + const name = attribute.name()?.path().join('.') ?? ''; + const parsed = parseBlockAttribute( + attribute, + name, + nodePslSpan(attribute.syntax, sources), + descriptor, + seenNames, + symbol.keyword, + symbol.name, + sources, + symbols, + ); + if (parsed.ok) { + Object.assign(symbol.block.attributes, { [name]: parsed.value }); + } else { + diagnostics.push(...parsed.diagnostics); + } + } +} + function parseBlockAttribute( attribute: ModelAttributeAst, name: string, @@ -111,6 +124,7 @@ function parseBlockAttribute( keyword: string, blockName: string, sources: PslSources, + symbols: SymbolTable, ): | { readonly ok: true; readonly value: PslExtensionBlockParsedAttribute } | { readonly ok: false; readonly diagnostics: readonly ParseDiagnostic[] } { @@ -148,7 +162,7 @@ function parseBlockAttribute( BlockAttributeSpecFactory, 'framework core cannot name AttributeSpec, so block-attribute factories transit the descriptor erased as unknown; this is the single point that restores the factory type the descriptor surface documents' >(declared[name]); - const result = interpretAttribute(attribute, factory(), { sources }); + const result = interpretAttribute(attribute, factory(), { sources, symbols }); if (!result.ok) { return { ok: false, diff --git a/packages/1-framework/2-authoring/psl-parser/src/entity-reference.ts b/packages/1-framework/2-authoring/psl-parser/src/entity-reference.ts new file mode 100644 index 000000000000..2b975a95dc3d --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/entity-reference.ts @@ -0,0 +1,89 @@ +import type { + BlockSymbol, + CompositeTypeSymbol, + ModelSymbol, + NamedTypeSymbol, + NamespaceSymbol, + SymbolTable, + TopLevelScope, +} from './symbol-table'; +import { NamespaceDeclarationAst } from './syntax/ast/declarations'; +import type { ExpressionAst } from './syntax/ast/expressions'; + +export type EntitySelector = + | { readonly kind: 'model' } + | { readonly kind: 'compositeType' } + | { readonly kind: 'namedType' } + | { readonly kind: 'block'; readonly keyword: string }; + +export type EntityDeclaration = ModelSymbol | CompositeTypeSymbol | NamedTypeSymbol | BlockSymbol; + +export type DeclarationFor = Extract< + EntityDeclaration, + { kind: S['kind'] } +>; + +export interface ResolvedEntityReference { + readonly declaration: D; + readonly namespace: NamespaceSymbol | undefined; +} + +const references = new WeakMap< + TopLevelScope | NamespaceSymbol, + WeakMap +>(); + +export function resolveEntityReference( + expression: ExpressionAst, + name: string, + symbols: SymbolTable, +): ResolvedEntityReference | undefined { + const namespaceName = expression.syntax + .findAncestor(NamespaceDeclarationAst.cast) + ?.name() + ?.name(); + const namespace = + namespaceName === undefined ? undefined : ownValue(symbols.topLevel.namespaces, namespaceName); + if (namespace !== undefined) { + const declaration = declarationIn(namespace, name); + if (declaration !== undefined) return referenceFor(namespace, declaration, namespace); + } + const declaration = declarationIn(symbols.topLevel, name); + return declaration === undefined + ? undefined + : referenceFor(symbols.topLevel, declaration, undefined); +} + +function ownValue(values: Readonly>, name: string): T | undefined { + return Object.hasOwn(values, name) ? values[name] : undefined; +} + +function declarationIn( + scope: TopLevelScope | NamespaceSymbol, + name: string, +): EntityDeclaration | undefined { + return ( + ownValue(scope.models, name) ?? + ownValue(scope.compositeTypes, name) ?? + ownValue(scope.blocks, name) ?? + ('namedTypes' in scope ? ownValue(scope.namedTypes, name) : undefined) + ); +} + +function referenceFor( + scope: TopLevelScope | NamespaceSymbol, + declaration: EntityDeclaration, + namespace: NamespaceSymbol | undefined, +): ResolvedEntityReference { + let byDeclaration = references.get(scope); + if (byDeclaration === undefined) { + byDeclaration = new WeakMap(); + references.set(scope, byDeclaration); + } + let reference = byDeclaration.get(declaration); + if (reference === undefined) { + reference = { declaration, namespace }; + byDeclaration.set(declaration, reference); + } + return reference; +} diff --git a/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts b/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts index 84526c4e5b45..728b1cd31b90 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts @@ -74,8 +74,11 @@ export type { AttributeLevel, AttributeOut, AttributeSpec, + EntityRefArgType, FieldAttributeCtx, + FixedIdentifierArgType, FuncCallSig, + IdentifierArgType, InferAttr, InspectableArgType, ModelAttributeCtx, @@ -90,6 +93,7 @@ export type { RejectingArgType, TaggedLiteralArgType, TypedFuncCall, + UnrestrictedIdentifierArgType, } from '../attribute-spec/types'; export type { DiagnosticSource, PslDiagnostic, PslDiagnosticCollector } from '../diagnostic'; export { @@ -98,6 +102,12 @@ export { diagnosticSource, mapPslDiagnostics, } from '../diagnostic'; +export type { + DeclarationFor, + EntityDeclaration, + EntitySelector, + ResolvedEntityReference, +} from '../entity-reference'; export { findBlockDescriptor, validateExtensionBlockFromSymbol } from '../extension-block'; export { keywordPslSpan, diff --git a/packages/1-framework/2-authoring/psl-parser/src/symbol-table.ts b/packages/1-framework/2-authoring/psl-parser/src/symbol-table.ts index b7644650735d..efc9fb8bddfd 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/symbol-table.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/symbol-table.ts @@ -1,6 +1,6 @@ import type { AuthoringPslBlockDescriptorNamespace } from '@internal/framework-components/authoring'; import type { PslExtensionBlock, PslSpan } from '@internal/framework-components/psl-ast'; -import { reconstructExtensionBlock } from './block-reconstruction'; +import { interpretBlockAttributes, reconstructExtensionBlock } from './block-reconstruction'; import { findBlockDescriptor } from './extension-block'; import type { ParseDiagnostic } from './parse'; import { @@ -135,12 +135,13 @@ export interface SymbolTableResult { export function buildSymbolTable(options: BuildSymbolTableOptions): SymbolTableResult { const { documents, sources, pslBlockDescriptors } = options; const diagnostics: ParseDiagnostic[] = []; + const collectedBlocks: BlockSymbol[] = []; const namespaces: Record = Object.create(null); - const namedTypes: Record = {}; - const blocks: Record = {}; - const models: Record = {}; - const compositeTypes: Record = {}; + const namedTypes: Record = Object.create(null); + const blocks: Record = Object.create(null); + const models: Record = Object.create(null); + const compositeTypes: Record = Object.create(null); const topLevelNames = new Set(); for (const document of documents) { @@ -176,7 +177,14 @@ export function buildSymbolTable(options: BuildSymbolTableOptions): SymbolTableR } else if (declaration instanceof GenericBlockDeclarationAst) { const name = claim(topLevelNames, declaration.name()); if (name !== undefined) { - blocks[name] = buildBlock(name, declaration, sources, pslBlockDescriptors, diagnostics); + blocks[name] = buildBlock( + name, + declaration, + sources, + pslBlockDescriptors, + diagnostics, + collectedBlocks, + ); } } else if (declaration instanceof NamespaceDeclarationAst) { const declaredName = declaration.name()?.name(); @@ -195,7 +203,14 @@ export function buildSymbolTable(options: BuildSymbolTableOptions): SymbolTableR }; namespaces[name] = namespace; } - extendNamespace(namespace, declaration, diagnostics, sources, pslBlockDescriptors); + extendNamespace( + namespace, + declaration, + diagnostics, + sources, + pslBlockDescriptors, + collectedBlocks, + ); } else if (declaration instanceof TypesBlockAst) { for (const binding of declaration.declarations()) { const name = claim(topLevelNames, binding.name()); @@ -211,6 +226,12 @@ export function buildSymbolTable(options: BuildSymbolTableOptions): SymbolTableR const symbolTable: SymbolTable = { topLevel: { namespaces, namedTypes, blocks, models, compositeTypes }, }; + for (const block of collectedBlocks) { + const descriptor = findBlockDescriptor(pslBlockDescriptors, block.keyword); + if (descriptor !== undefined) { + interpretBlockAttributes(block, descriptor, sources, symbolTable, diagnostics); + } + } return { symbolTable, diagnostics }; } @@ -252,10 +273,11 @@ function buildBlock( sources: PslSources, pslBlockDescriptors: AuthoringPslBlockDescriptorNamespace, diagnostics: ParseDiagnostic[], + collectedBlocks: BlockSymbol[], ): BlockSymbol { const keyword = node.keyword()?.text ?? ''; const descriptor = findBlockDescriptor(pslBlockDescriptors, keyword); - return { + const symbol: BlockSymbol = { kind: 'block', name, keyword, @@ -263,6 +285,8 @@ function buildBlock( span: nodePslSpan(node.syntax, sources), block: reconstructExtensionBlock(node, descriptor, sources, diagnostics), }; + collectedBlocks.push(symbol); + return symbol; } function extendNamespace( @@ -271,6 +295,7 @@ function extendNamespace( diagnostics: ParseDiagnostic[], sources: PslSources, pslBlockDescriptors: AuthoringPslBlockDescriptorNamespace, + collectedBlocks: BlockSymbol[], ): void { const { models, compositeTypes, blocks } = namespace; namespace.declarations.push({ node, span: nodePslSpan(node.syntax, sources) }); @@ -305,6 +330,7 @@ function extendNamespace( sources, pslBlockDescriptors, diagnostics, + collectedBlocks, ); } } @@ -316,7 +342,7 @@ function buildFields( sources: PslSources, diagnostics: ParseDiagnostic[], ): Record { - const result: Record = {}; + const result: Record = Object.create(null); for (const field of fields) { const nameNode = field.name(); const name = nameNode?.name(); diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test.ts index b3c07df2e441..a18ab583d5d7 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test.ts @@ -13,7 +13,12 @@ function blockAttr(source: string): { node: ModelAttributeAst; ctx: AttributeCtx if (!node) throw new Error('expected a block attribute'); return { node, - ctx: { sources: new PslSources([[root, cursor.sourceFile]]) }, + ctx: { + sources: new PslSources([[root, cursor.sourceFile]]), + symbols: { + topLevel: { namespaces: {}, models: {}, compositeTypes: {}, namedTypes: {}, blocks: {} }, + }, + }, }; } diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.foreign-copy.test.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.foreign-copy.test.ts index 781a69ca5cba..424c22bd9fef 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.foreign-copy.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.foreign-copy.test.ts @@ -14,13 +14,10 @@ import { record, str, } from '../src/exports'; -import { Cursor, parse, parseAttribute } from '../src/parse'; -import { PslSources } from '../src/source-file'; +import { parse } from '../src/parse'; import { buildSymbolTable } from '../src/symbol-table'; -import { FieldAttributeAst } from '../src/syntax/ast/attributes'; import type { ExpressionAst } from '../src/syntax/ast/expressions'; import type { SyntaxNode } from '../src/syntax/red'; -import { createSyntaxTree } from '../src/syntax/red'; class ForeignCopyOfAnAstNode { readonly syntax: SyntaxNode; @@ -30,12 +27,7 @@ class ForeignCopyOfAnAstNode { } function foreignArg(source: string): { arg: ExpressionAst; ctx: ModelAttributeCtx } { - const cursor = new Cursor('schema.prisma', `@demo(${source})`); - const root = createSyntaxTree(parseAttribute(cursor)); - const node = FieldAttributeAst.cast(root); - const value = Array.from(node?.argList()?.args() ?? [])[0]?.value(); - if (value === undefined) throw new Error('expected one argument'); - const { document, sources } = parse('model M {\n id Int @id\n}\n', 'test.psl'); + const { document, sources } = parse(`model M {\n id Int @demo(${source})\n}\n`, 'schema.prisma'); const { symbolTable } = buildSymbolTable({ documents: [document], sources, @@ -43,10 +35,14 @@ function foreignArg(source: string): { arg: ExpressionAst; ctx: ModelAttributeCt }); const selfModel = symbolTable.topLevel.models['M']; if (selfModel === undefined) throw new Error('expected model M'); + const node = selfModel.fields['id']?.node.attributes()[Symbol.iterator]().next().value; + const value = node?.argList()?.args()[Symbol.iterator]().next().value?.value(); + if (value === undefined) throw new Error('expected one argument'); return { arg: new ForeignCopyOfAnAstNode(value.syntax) as unknown as ExpressionAst, ctx: { - sources: new PslSources([[root, cursor.sourceFile]]), + sources, + symbols: symbolTable, selfModel, }, }; @@ -65,7 +61,7 @@ describe('combinators dispatch on syntax kind, not on AST class identity', () => 'Cascade', 'Cascade', ], - ['entityRef', entityRef(), 'User', 'User'], + ['unrestricted identifier', identifier(), 'User', 'User'], ['fieldRef', fieldRef(), 'id', 'id'], ['json', json(), '"{\\"a\\":1}"', { a: 1 }], ['list', list(str()), '["a", "b"]', ['a', 'b']], @@ -79,6 +75,14 @@ describe('combinators dispatch on syntax kind, not on AST class identity', () => if (result.ok) expect(result.value).toEqual(expected); }); + it('entityRef accepts a node from another module copy and preserves identity', () => { + const { arg, ctx } = foreignArg('M'); + const reference = { declaration: ctx.selfModel, namespace: undefined }; + const result = entityRef({ kind: 'model' }).parse(arg, ctx); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual(reference); + }); + it('funcCall accepts a node from another module copy', () => { const { arg, ctx } = foreignArg('now()'); diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.tagged-literal.test.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.tagged-literal.test.ts index dd3f0c23e741..9b0bf42321bc 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.tagged-literal.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.tagged-literal.test.ts @@ -21,6 +21,7 @@ function makeCtx(sources: PslSources): FieldAttributeCtx { if (!field) throw new Error('expected field id on model M'); return { sources, + symbols: symbolTable, selfModel, field, resolveReferencedModel: () => undefined, diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test-d.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test-d.ts index 15f2fefea7f2..61fd220d9fa8 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test-d.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test-d.ts @@ -3,10 +3,15 @@ import { expectTypeOf, test } from 'vitest'; import type { ArgType, AttributeCtx, + BlockSymbol, + CompositeTypeSymbol, FieldAttributeCtx, InspectableArgType, ModelAttributeCtx, + ModelSymbol, + NamedTypeSymbol, OutOf, + ResolvedEntityReference, TypedFuncCall, } from '../src/exports'; import { @@ -35,6 +40,36 @@ test('inspectable lists and records expose ArgType children', () => { expectTypeOf().toEqualTypeOf>(); }); +test('checked reference selectors and wrappers preserve inferred outputs', () => { + const model = entityRef({ kind: 'model' }); + const composite = entityRef({ kind: 'compositeType' }); + const named = entityRef({ kind: 'namedType' }); + const block = entityRef({ kind: 'block', keyword: 'permission' }); + const names = identifier(); + const optionalModel = optional(model); + const models = list(model); + const alternative = oneOf(model, names); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf< + ResolvedEntityReference + >(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf(names.name).toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf[]>(); + expectTypeOf>().toEqualTypeOf< + ResolvedEntityReference | string + >(); + expectTypeOf(model.parse).parameter(1).toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<'sources' | 'symbols'>(); + // @ts-expect-error checked references require an expected selector + entityRef(); + // @ts-expect-error checked references do not accept injected resolvers + entityRef({ kind: 'model' }, () => undefined); +}); + test('identifier requires semantic value documentation', () => { // @ts-expect-error identifier values require documentation identifier('Undocumented'); @@ -415,7 +450,7 @@ test('optional wrappers retain child metadata and optional markers', () => { test('field references have distinct inspectable kinds', () => { expectTypeOf(fieldRef().kind).toEqualTypeOf<'fieldRef'>(); expectTypeOf(referencedFieldRef().kind).toEqualTypeOf<'referencedFieldRef'>(); - expectTypeOf(entityRef().kind).toEqualTypeOf<'entityRef'>(); + expectTypeOf(entityRef({ kind: 'model' }).kind).toEqualTypeOf<'entityRef'>(); }); test('runtime context metadata is rejected from arg types', () => { diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test.ts index 59c32bacc853..1a9854b658c0 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test.ts @@ -42,6 +42,7 @@ function makeCtx(sources: PslSources): FieldAttributeCtx { if (!field) throw new Error('expected field id on model M'); return { sources, + symbols: symbolTable, selfModel, field, resolveReferencedModel: () => undefined, @@ -683,19 +684,34 @@ describe('fieldRef', () => { }); describe('entityRef', () => { - it('parses a bare identifier into its model name', () => { - const { expr, ctx } = argOf('Task'); - - const result = entityRef().parse(expr, ctx); + function referenceArg(value: string) { + const { document, sources } = parse(`model M {\n id Int @x(${value})\n}`, 'schema.prisma'); + const { symbolTable } = buildSymbolTable({ + documents: [document], + sources, + pslBlockDescriptors: {}, + }); + const selfModel = symbolTable.topLevel.models['M']; + const field = selfModel?.fields['id']; + const attribute = field?.node.attributes()[Symbol.iterator]().next().value; + const expr = attribute?.argList()?.args()[Symbol.iterator]().next().value?.value(); + if (!selfModel || !expr) throw new Error('Missing reference argument'); + return { expr, ctx: { sources, symbols: symbolTable, selfModel } }; + } + + it('parses a bare identifier into its resolved model', () => { + const { expr, ctx } = referenceArg('M'); + const reference = { declaration: ctx.selfModel, namespace: undefined }; + const result = entityRef({ kind: 'model' }).parse(expr, ctx); expect(result.ok).toBe(true); - if (result.ok) expect(result.value).toBe('Task'); + if (result.ok) expect(result.value).toEqual(reference); }); it('rejects a quoted string literal', () => { - const { expr, ctx } = argOf('"Task"'); + const { expr, ctx } = referenceArg('"Task"'); - const result = entityRef().parse(expr, ctx); + const result = entityRef({ kind: 'model' }).parse(expr, ctx); expect(result.ok).toBe(false); if (!result.ok) { @@ -705,18 +721,18 @@ describe('entityRef', () => { }); it('rejects a number token', () => { - const { expr, ctx } = argOf('42'); + const { expr, ctx } = referenceArg('42'); - const result = entityRef().parse(expr, ctx); + const result = entityRef({ kind: 'model' }).parse(expr, ctx); expect(result.ok).toBe(false); if (!result.ok) expect(result.failure).toHaveLength(1); }); it('rejects an array literal', () => { - const { expr, ctx } = argOf('[Task]'); + const { expr, ctx } = referenceArg('[Task]'); - const result = entityRef().parse(expr, ctx); + const result = entityRef({ kind: 'model' }).parse(expr, ctx); expect(result.ok).toBe(false); if (!result.ok) expect(result.failure).toHaveLength(1); diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-documentation.test.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-documentation.test.ts index 6cafb4e96d53..fa51d931257d 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-documentation.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-documentation.test.ts @@ -63,6 +63,9 @@ describe('declaration documentation', () => { if (!node) throw new Error('expected a block attribute'); const result = interpretAttribute(node, spec, { sources: new PslSources([[root, cursor.sourceFile]]), + symbols: { + topLevel: { namespaces: {}, models: {}, compositeTypes: {}, namedTypes: {}, blocks: {} }, + }, }); expect(result.assertOk()).toStrictEqual({ value: { diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test.ts index 7581eb774c8b..515906ca69b4 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test.ts @@ -30,6 +30,7 @@ function makeCtx(sources: PslSources): FieldAttributeCtx { if (!field) throw new Error('expected field id on model M'); return { sources, + symbols: symbolTable, selfModel, field, resolveReferencedModel: () => undefined, diff --git a/packages/1-framework/2-authoring/psl-parser/test/entity-reference.test.ts b/packages/1-framework/2-authoring/psl-parser/test/entity-reference.test.ts new file mode 100644 index 000000000000..dd23205ed7aa --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/entity-reference.test.ts @@ -0,0 +1,295 @@ +import { ok } from '@internal/utils/result'; +import { describe, expect, it } from 'vitest'; +import type { EntitySelector } from '../src/exports'; +import { blockAttribute, entityRef, identifier, list, oneOf } from '../src/exports'; +import { parse } from '../src/parse'; +import { buildSymbolTable } from '../src/symbol-table'; +import { ModelAttributeAst } from '../src/syntax/ast/attributes'; +import { IdentifierAst } from '../src/syntax/ast/identifier'; +import { SyntaxNode } from '../src/syntax/red'; + +function fixture(value: string, local = true) { + const members = [ + ...(local ? [`model Owner {\n @@test(${value})\n}`] : []), + 'model Shared {}', + 'type Global {}', + 'permission Writer {}', + ]; + const declarations = [ + 'model Shared {}', + 'model Global {}', + 'model Fallback {}', + 'type Address {}', + 'types { Email = String }', + 'permission Reader {}', + `namespace Local {\n${members.join('\n')}\n}`, + 'namespace Sibling {\n model Hidden {}\n model Shared {}\n}', + ...(local ? [] : [`model Owner {\n @@test(${value})\n}`]), + ]; + const { document, sources } = parse(declarations.join('\n'), 'references.prisma'); + const { symbolTable, diagnostics } = buildSymbolTable({ + documents: [document], + sources, + pslBlockDescriptors: {}, + }); + expect(diagnostics).toEqual([]); + const namespace = symbolTable.topLevel.namespaces['Local']; + if (!namespace) throw new Error('Missing namespace'); + for (const syntax of document.syntax.descendants()) { + if (!(syntax instanceof SyntaxNode)) continue; + const attribute = ModelAttributeAst.cast(syntax); + const expression = attribute?.argList()?.args()[Symbol.iterator]().next().value?.value(); + if (expression) + return { + expression, + ctx: { sources, symbols: symbolTable }, + sources, + table: symbolTable, + namespace, + }; + } + throw new Error('Missing expression'); +} + +describe('syntax-scoped entity resolution', () => { + it('supplies the completed table to existing block attribute rules', () => { + const { document, sources } = parse( + 'namespace Local {\n permission Reader {\n @@target(Later)\n }\n model Later {}\n}', + 'references.prisma', + ); + const target = blockAttribute('target', { + documentation: 'Names a model.', + positional: [ + { key: 'model', type: entityRef({ kind: 'model' }), documentation: 'The selected model.' }, + ], + }); + const result = buildSymbolTable({ + documents: [document], + sources, + pslBlockDescriptors: { + permission: { + name: { required: true }, + kind: 'pslBlock', + keyword: 'permission', + discriminator: 'permission', + parameters: {}, + attributes: { target: () => target }, + }, + }, + }); + expect(result.diagnostics).toEqual([]); + const namespace = result.symbolTable.topLevel.namespaces['Local']; + expect(namespace?.blocks['Reader']?.block.attributes['target']?.args).toEqual({ + model: { declaration: namespace?.models['Later'], namespace }, + }); + }); + it('selects the local declaration, including forward references', () => { + const { expression, ctx, namespace } = fixture('Shared'); + expect(entityRef({ kind: 'model' }).parse(expression, ctx)).toEqual( + ok({ + declaration: namespace.models['Shared'], + namespace, + }), + ); + }); + + it.each<[EntitySelector, string]>([ + [{ kind: 'model' }, 'Fallback'], + [{ kind: 'compositeType' }, 'Address'], + [{ kind: 'namedType' }, 'Email'], + [{ kind: 'block', keyword: 'permission' }, 'Reader'], + ])('falls back to top-level %j', (selector, name) => { + const { expression, ctx } = fixture(name); + const rule = entityRef(selector); + expect(rule.expected).toEqual(selector); + expect(rule.parse(expression, ctx)).toMatchObject({ + ok: true, + value: { declaration: { name, kind: selector.kind }, namespace: undefined }, + }); + }); + + it('selects only top-level declarations outside namespaces', () => { + const { expression, ctx, table } = fixture('Shared', false); + expect(entityRef({ kind: 'model' }).parse(expression, ctx)).toEqual( + ok({ + declaration: table.topLevel.models['Shared'], + namespace: undefined, + }), + ); + }); + + it.each(['Hidden', 'Missing', 'toString', 'constructor', '__proto__'])( + 'rejects unavailable and inherited names: %s', + (name) => { + const { expression, ctx, sources } = fixture(name); + const sourceFile = sources.sourceFileFor(expression.syntax); + expect(entityRef({ kind: 'model' }).parse(expression, ctx)).toMatchObject({ + ok: false, + failure: [ + { + message: `Unknown model reference "${name}"`, + filename: 'references.prisma', + range: { + start: sourceFile.positionAt(expression.syntax.offset), + end: sourceFile.positionAt(expression.syntax.endOffset), + }, + }, + ], + }); + }, + ); + + it('resolves a declared __proto__ model as an own map entry', () => { + const { document, sources } = parse( + 'model __proto__ {}\nmodel Owner {\n @@test(__proto__)\n}', + 'references.prisma', + ); + const { symbolTable, diagnostics } = buildSymbolTable({ + documents: [document], + sources, + pslBlockDescriptors: {}, + }); + expect(diagnostics).toEqual([]); + expect(Object.hasOwn(symbolTable.topLevel.models, '__proto__')).toBe(true); + const declaration = Object.entries(symbolTable.topLevel.models).find( + ([name]) => name === '__proto__', + )?.[1]; + expect(declaration).toBeDefined(); + for (const syntax of document.syntax.descendants()) { + if (!(syntax instanceof SyntaxNode)) continue; + const attribute = ModelAttributeAst.cast(syntax); + const expression = attribute?.argList()?.args()[Symbol.iterator]().next().value?.value(); + if (!expression) continue; + const ctx = { sources, symbols: symbolTable }; + expect(entityRef({ kind: 'model' }).parse(expression, ctx)).toEqual( + ok({ declaration, namespace: undefined }), + ); + return; + } + throw new Error('Missing expression'); + }); + + it('does not search child namespaces from top-level', () => { + const { expression, ctx } = fixture('Writer', false); + expect(entityRef({ kind: 'block', keyword: 'permission' }).parse(expression, ctx).ok).toBe( + false, + ); + }); + + it('checks kind after selecting the local binding', () => { + const { expression, ctx } = fixture('Global'); + expect(entityRef({ kind: 'model' }).parse(expression, ctx)).toMatchObject({ + ok: false, + failure: [{ message: 'Expected model reference "Global", found compositeType' }], + }); + }); + + it.each([ + { kind: 'model' }, + { kind: 'compositeType' }, + { kind: 'namedType' }, + { kind: 'block', keyword: 'permission' }, + ])('rejects a different declaration kind for %j', (selector) => { + const { expression, ctx } = fixture(selector.kind === 'model' ? 'Address' : 'Shared'); + const result = entityRef(selector).parse(expression, ctx); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure).toHaveLength(1); + }); + + it('reuses one grammar across documents without retaining another scope', () => { + const rule = entityRef({ kind: 'model' }); + const local = fixture('Shared'); + const top = fixture('Shared', false); + expect(rule.parse(local.expression, local.ctx)).toEqual( + ok({ + declaration: local.namespace.models['Shared'], + namespace: local.namespace, + }), + ); + expect(rule.parse(top.expression, top.ctx)).toEqual( + ok({ + declaration: top.table.topLevel.models['Shared'], + namespace: undefined, + }), + ); + }); + + it('checks contributed block keywords', () => { + const { expression, ctx } = fixture('Reader'); + expect(entityRef({ kind: 'block', keyword: 'other' }).parse(expression, ctx)).toMatchObject({ + ok: false, + failure: [{ message: 'Expected other reference "Reader", found permission' }], + }); + }); + + it('shares wrapper identity across rules and repeated expressions', () => { + const { expression, ctx } = fixture('[Shared, Shared]'); + expect( + list(entityRef({ kind: 'model' }), { unique: true }).parse(expression, ctx), + ).toMatchObject({ + ok: false, + failure: [{ message: 'Duplicate list entry' }], + }); + const single = fixture('Shared'); + const first = entityRef({ kind: 'model' }).parse(single.expression, single.ctx); + const second = entityRef({ kind: 'model' }).parse(single.expression, single.ctx); + if (!first.ok || !second.ok) throw new Error('Missing reference'); + expect(first.value).toBe(second.value); + }); + + it('accepts structural expressions from another AST copy', () => { + const { expression, ctx, namespace } = fixture('Shared'); + const identifier = IdentifierAst.cast(expression.syntax); + if (!identifier) throw new Error('Missing identifier'); + const foreign = { + syntax: identifier.syntax, + name: () => identifier.name(), + token: () => identifier.token(), + }; + expect(foreign).not.toBeInstanceOf(IdentifierAst); + expect(entityRef({ kind: 'model' }).parse(foreign, ctx)).toEqual( + ok({ + declaration: namespace.models['Shared'], + namespace, + }), + ); + }); +}); + +describe('checked references and unchecked identifiers', () => { + it.each(['Shared', 'Missing', 'Global'])('keeps alternatives diagnostic-pure for %s', (name) => { + const { expression, ctx, namespace } = fixture(name); + expect(oneOf(entityRef({ kind: 'model' }), identifier()).parse(expression, ctx)).toEqual( + ok(name === 'Shared' ? { declaration: namespace.models['Shared'], namespace } : name), + ); + }); + + it('aggregates all-failure alternatives at their source', () => { + const { expression, ctx } = fixture('42'); + expect(oneOf(entityRef({ kind: 'model' }), identifier()).parse(expression, ctx)).toMatchObject({ + ok: false, + failure: [ + { message: 'Expected one of: model reference | identifier', filename: 'references.prisma' }, + ], + }); + }); + + it('preserves unrestricted identifier metadata and names', () => { + const { expression, ctx } = fixture('External'); + expect(identifier()).toMatchObject({ + kind: 'identifier', + name: undefined, + label: 'identifier', + }); + expect(identifier().parse(expression, ctx)).toEqual(ok('External')); + }); + + it.each(['"Shared"', '42', '[Shared]', 'Shared()', 'true'])( + 'rejects non-identifiers: %s', + (value) => { + const { expression, ctx } = fixture(value); + expect(entityRef({ kind: 'model' }).parse(expression, ctx).ok).toBe(false); + expect(identifier().parse(expression, ctx).ok).toBe(false); + }, + ); +}); diff --git a/packages/1-framework/2-authoring/psl-parser/test/symbol-table.block-attribute-traversal.test.ts b/packages/1-framework/2-authoring/psl-parser/test/symbol-table.block-attribute-traversal.test.ts new file mode 100644 index 000000000000..5b7dcbd36175 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/symbol-table.block-attribute-traversal.test.ts @@ -0,0 +1,110 @@ +import type { AuthoringPslBlockDescriptorNamespace } from '@internal/framework-components/authoring'; +import { describe, expect, it, vi } from 'vitest'; +import { blockAttribute, str } from '../src/exports'; +import { parse } from '../src/parse'; +import { type BlockSymbol, buildSymbolTable } from '../src/symbol-table'; + +const locations = [ + { namespace: undefined, name: 'Gear' }, + { namespace: undefined, name: '__proto__' }, + { namespace: 'Local', name: '__proto__' }, + { namespace: '__proto__', name: 'Gear' }, + { namespace: '__proto__', name: '__proto__' }, +]; + +function fixture( + namespace: string | undefined, + name: string, + attributes: string, + duplicate = false, +) { + const interpretedSymbols: (BlockSymbol | undefined)[] = []; + const spec = blockAttribute('map', { + documentation: 'Maps a widget.', + positional: [{ key: 'name', type: str(), documentation: 'The storage name.' }], + refine: (_args, ctx) => { + const scope = + namespace === undefined ? ctx.symbols.topLevel : ctx.symbols.topLevel.namespaces[namespace]; + interpretedSymbols.push(scope?.blocks[name]); + return []; + }, + }); + const factory = vi.fn(() => spec); + const descriptors: AuthoringPslBlockDescriptorNamespace = { + widget: { + kind: 'pslBlock', + keyword: 'widget', + discriminator: 'widget', + name: { required: true }, + parameters: {}, + attributes: { map: factory }, + }, + }; + const block = `widget ${name} {\n${attributes}\n}`; + const declarations = duplicate ? `${block}\nwidget ${name} {\n@@missing()\n}` : block; + const source = + namespace === undefined ? declarations : `namespace ${namespace} {\n${declarations}\n}`; + const { document, sources, diagnostics: parseDiagnostics } = parse(source, 'widgets.prisma'); + expect(parseDiagnostics).toEqual([]); + const result = buildSymbolTable({ + documents: [document], + sources, + pslBlockDescriptors: descriptors, + }); + const scope = + namespace === undefined + ? result.symbolTable.topLevel + : result.symbolTable.topLevel.namespaces[namespace]; + return { ...result, scope, block: scope?.blocks[name], factory, interpretedSymbols }; +} + +describe.each(locations)( + 'block attribute traversal in $namespace for $name', + ({ namespace, name }) => { + it('interprets accepted attributes once and reports every unknown occurrence', () => { + const result = fixture( + namespace, + name, + '@@map("first")\n@@missing()\n@@missing()\n@@map("second")', + ); + expect(result.diagnostics.map(({ code, message }) => ({ code, message }))).toEqual([ + { + code: 'PSL_EXTENSION_UNKNOWN_BLOCK_ATTRIBUTE', + message: `Unknown attribute "@@missing" in "widget" block "${name}"`, + }, + { + code: 'PSL_EXTENSION_UNKNOWN_BLOCK_ATTRIBUTE', + message: `Unknown attribute "@@missing" in "widget" block "${name}"`, + }, + { + code: 'PSL_INVALID_EXTENSION_BLOCK_ATTRIBUTE', + message: `Duplicate attribute "@@map" in "widget" block "${name}"; first occurrence wins`, + }, + ]); + expect(result.block?.block.attributes['map']?.args).toEqual({ name: 'first' }); + expect(result.factory).toHaveBeenCalledTimes(1); + expect(result.scope && Object.hasOwn(result.scope.blocks, name)).toBe(true); + }); + + it('retains failed-first attribute recovery without retrying its duplicate', () => { + const result = fixture(namespace, name, '@@map()\n@@map("second")'); + expect(result.diagnostics.map(({ code }) => code)).toEqual([ + 'PSL_INVALID_ATTRIBUTE_SYNTAX', + 'PSL_INVALID_EXTENSION_BLOCK_ATTRIBUTE', + ]); + expect(result.block?.block.attributes).toEqual({}); + expect(result.factory).toHaveBeenCalledTimes(1); + }); + + it('interprets only the first accepted block declaration', () => { + const result = fixture(namespace, name, '@@map("first")', true); + expect(result.diagnostics.map(({ code, message }) => ({ code, message }))).toEqual([ + { code: 'PSL_DUPLICATE_DECLARATION', message: `Duplicate declaration of "${name}"` }, + ]); + expect(result.block?.block.attributes['map']?.args).toEqual({ name: 'first' }); + expect(result.factory).toHaveBeenCalledTimes(1); + expect(result.interpretedSymbols).toHaveLength(1); + expect(result.interpretedSymbols[0]).toBe(result.block); + }); + }, +); diff --git a/packages/1-framework/3-tooling/language-server/src/completion-values.ts b/packages/1-framework/3-tooling/language-server/src/completion-values.ts index a04020f7803a..2458de928912 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-values.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-values.ts @@ -146,7 +146,7 @@ function valueItems( } switch (type.kind) { case 'identifier': - return scalarItems(input, [type.name], type.documentation); + return type.name === undefined ? [] : scalarItems(input, [type.name], type.documentation); case 'str': return scalarItems(input, type.value === undefined ? [] : [JSON.stringify(type.value)]); case 'num': diff --git a/packages/1-framework/3-tooling/language-server/test/attribute-spec-consumability.test.ts b/packages/1-framework/3-tooling/language-server/test/attribute-spec-consumability.test.ts index f48f506b8bbd..15ab1d1ffe04 100644 --- a/packages/1-framework/3-tooling/language-server/test/attribute-spec-consumability.test.ts +++ b/packages/1-framework/3-tooling/language-server/test/attribute-spec-consumability.test.ts @@ -1,11 +1,16 @@ import type { PrismaNextConfig } from '@internal/config-loader'; import * as configLoader from '@internal/config-loader'; -import type { AttributeSpecContext } from '@internal/psl-parser'; +import { + assembleAuthoringContributions, + assembleControlMutationDefaults, +} from '@internal/framework-components/control'; +import type { AttributeSpecContext, AttributeSpecNamespace } from '@internal/psl-parser'; import { assembleAttributeSpecs, fieldAttribute, modelAttribute } from '@internal/psl-parser'; import { ok } from '@internal/utils/result'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { resolveConfigInputs } from '../src/config-resolution'; import { runPipeline } from '../src/pipeline'; +import { providePslSignatureHelp } from '../src/signature-help'; vi.mock('@internal/config-loader', { spy: true }); @@ -68,6 +73,82 @@ describe('assembled attribute specs are consumable from a resolved project', () vi.restoreAllMocks(); }); + async function importAttributeSpecs(relativePath: string, exportName: string) { + const module = (await import(new URL(relativePath, import.meta.url).href)) as Record< + string, + AttributeSpecNamespace + >; + const attributeSpecs = module[exportName]; + if (!attributeSpecs) throw new Error('missing attribute namespace'); + return attributeSpecs; + } + + function expectBaseSpecSignatureHelp(attributeSpecs: AttributeSpecNamespace) { + const authoringContributions = assembleAuthoringContributions([ + { id: 'actual-family', authoring: { attributeSpecs } }, + ]); + const controlMutationDefaults = assembleControlMutationDefaults([]); + const source = 'model Variant {\n @@base(Missing, "v")\n}\nmodel Base { id Int }'; + const pipeline = runPipeline('schema.prisma', source, { + scalarTypes: ['Int'], + pslBlockDescriptors: {}, + authoringContributions, + controlMutationDefaults, + }); + const model = pipeline.symbolTable.topLevel.models['Variant']; + if (!model) throw new Error('missing variant'); + const spec = assembleAttributeSpecs(authoringContributions).model['base']?.({ + symbols: pipeline.symbolTable, + model, + controlMutationDefaults: { + defaultFunctionRegistry: controlMutationDefaults.defaultFunctionRegistry, + dataTypeEntries: {}, + }, + }); + expect(spec).toMatchObject({ + name: 'base', + positional: [ + { + key: 'base', + type: { kind: 'entityRef', expected: { kind: 'model' }, label: 'model reference' }, + }, + { key: 'value', type: { kind: 'str' } }, + ], + }); + expect(pipeline.diagnostics).toEqual([]); + const signature = providePslSignatureHelp({ + document: pipeline.document, + sourceFile: pipeline.sourceFile, + position: pipeline.sourceFile.positionAt(source.indexOf('Missing')), + clientSupportsLabelOffsets: true, + candidates: { + symbolTable: pipeline.symbolTable, + pslBlockDescriptors: {}, + authoringContributions, + controlMutationDefaults, + }, + }); + expect(signature?.signatures[0]?.label).toBe('@@base(model reference, string)'); + } + + it('provides signature help from the SQL @@base spec factory', async () => { + expectBaseSpecSignatureHelp( + await importAttributeSpecs( + '../../../../2-sql/2-authoring/contract-psl/src/sql-attribute-specs.ts', + 'sqlAttributeSpecs', + ), + ); + }); + + it('provides signature help from the Mongo @@base spec factory', async () => { + expectBaseSpecSignatureHelp( + await importAttributeSpecs( + '../../../../2-mongo-family/2-authoring/contract-psl/src/mongo-attribute-specs.ts', + 'mongoAttributeSpecs', + ), + ); + }); + it('enumerates a contributed model attribute by its claimed name', async () => { vi.spyOn(configLoader, 'loadConfig').mockResolvedValue( ok({ config: pslProjectConfig(), diagnostics: [] }), diff --git a/packages/1-framework/3-tooling/language-server/test/completion-values.test.ts b/packages/1-framework/3-tooling/language-server/test/completion-values.test.ts index 4de7651798c8..3da3e68fe67b 100644 --- a/packages/1-framework/3-tooling/language-server/test/completion-values.test.ts +++ b/packages/1-framework/3-tooling/language-server/test/completion-values.test.ts @@ -45,6 +45,8 @@ const rejecting: RejectingArgType = { message: 'No available values', parse: rejectedParse, }; +const unchecked = { ...identifier(), parse: rejectedParse }; +const checked = { ...entityRef({ kind: 'model' }), parse: rejectedParse }; const direction = oneOf( identifier('Asc', { documentation: 'An accepted identifier in this test grammar.' }), identifier('Desc', { documentation: 'An accepted identifier in this test grammar.' }), @@ -135,6 +137,8 @@ const signature = { all: { type: oneOf( str(), + unchecked, + checked, identifier('Alpha', { documentation: 'An accepted identifier in this test grammar.' }), bool(), num(), @@ -143,13 +147,15 @@ const signature = { documentation: 'A scalar value with enumerated completion candidates.', }, none: { - type: oneOf(str(), num(), int(), json(), entityRef(), rejecting), + type: oneOf(str(), num(), int(), json(), checked, unchecked, rejecting), documentation: 'A free-form value without enumerated candidates.', }, rejected: { type: rejecting, documentation: 'A value that always fails interpretation.' }, recordValues: { type: record(bool()), documentation: 'Boolean values keyed by name.' }, unionLists: { type: oneOf( + list(unchecked), + list(checked), list(identifier('A', { documentation: 'An accepted identifier in this test grammar.' })), list(identifier('B', { documentation: 'An accepted identifier in this test grammar.' })), list(identifier('A', { documentation: 'An accepted identifier in this test grammar.' })), @@ -475,6 +481,12 @@ describe('recursive attribute values', () => { ); }); + it('offers only pinned names when unchecked names and checked references are nested alternatives', () => { + expect(field('none: |').items).toEqual([]); + expect(field('unionLists: [|]').items.map((item) => item.label)).toEqual(['A', 'B']); + expect(rejectedParse).not.toHaveBeenCalled(); + }); + it('never invokes combinator parsing to select alternatives', () => { expect(field('none: |').items).toEqual([]); expect(rejectedParse).not.toHaveBeenCalled(); diff --git a/packages/1-framework/3-tooling/language-server/test/signature-help-values.test.ts b/packages/1-framework/3-tooling/language-server/test/signature-help-values.test.ts index e9b4a5f9349f..d6998b04e7ed 100644 --- a/packages/1-framework/3-tooling/language-server/test/signature-help-values.test.ts +++ b/packages/1-framework/3-tooling/language-server/test/signature-help-values.test.ts @@ -4,9 +4,11 @@ import { } from '@internal/framework-components/control'; import { buildSymbolTable, + entityRef, fieldAttribute, funcCall, identifier, + list, oneOf, optional, } from '@internal/psl-parser'; @@ -15,6 +17,9 @@ import { expect, it, vi } from 'vitest'; import { MarkupKind } from 'vscode-languageserver'; import { providePslSignatureHelp } from '../src/signature-help'; +const referenceRule = entityRef({ kind: 'model' }); +const parseReference = vi.fn(referenceRule.parse); +const reference = { ...referenceRule, parse: parseReference }; const ascending = identifier('Asc', { documentation: 'Sort ascending.' }); const parseIdentifier = vi.fn(ascending.parse); const asc = { ...ascending, parse: parseIdentifier }; @@ -25,6 +30,13 @@ const nested = funcCall('sort', { }); it.each([ + { type: identifier(), args: '|', label: '@probe(identifier)' }, + { type: reference, args: '|', label: '@probe(model reference)' }, + { + type: optional(list(oneOf(reference, identifier()))), + args: '|', + label: '@probe(((model reference | identifier)[])?)', + }, { type: oneOf(asc, desc), args: '|', label: '@probe(Asc | Desc)' }, { type: optional(asc), args: '|', label: '@probe(Asc?)' }, { type: oneOf(asc, nested), args: 'sort(|)', label: 'sort(Desc)' }, @@ -74,5 +86,6 @@ it.each([ : '**value**\n\nThe declared value.', }); expect(parseIdentifier).not.toHaveBeenCalled(); + expect(parseReference).not.toHaveBeenCalled(); }, ); 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 20b0b2d0bfd1..32038528a3f2 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 @@ -222,6 +222,7 @@ function resolveFieldMappings(input: { const mapped = (mapNode ? interpretFieldAttribute({ + symbols: specContext.symbols, node: mapNode, spec: mongoAttributeSpecs.field.map({ ...specContext, field }), model, @@ -245,6 +246,7 @@ function resolveCollectionName(input: { const mapNode = findModelAttributeNode(model, 'map'); const name = mapNode ? interpretModelAttribute({ + symbols: specContext.symbols, node: mapNode, spec: mongoAttributeSpecs.model.map(specContext), model, @@ -270,7 +272,7 @@ type DiscriminatorDeclaration = { readonly span: PslSpan; }; type BaseDeclaration = { - readonly baseName: string; + readonly base: ModelSymbol; readonly value: string; readonly collectionName: string; readonly source: DiagnosticSource; @@ -288,17 +290,18 @@ function collectPolymorphismDeclarations( sources: PslSources, diagnostics: PslDiagnosticCollector, ): { - discriminatorDeclarations: Map; - baseDeclarations: Map; + discriminatorDeclarations: Map; + baseDeclarations: Map; } { - const discriminatorDeclarations = new Map(); - const baseDeclarations = new Map(); + const discriminatorDeclarations = new Map(); + const baseDeclarations = new Map(); for (const model of models) { const specContext = specContextFor(model); const discNode = findModelAttributeNode(model, 'discriminator'); if (discNode) { const parsed = interpretModelAttribute({ + symbols: specContext.symbols, node: discNode, spec: mongoAttributeSpecs.model.discriminator(specContext), model, @@ -316,7 +319,7 @@ function collectPolymorphismDeclarations( ...diagnosticSource(sources, discNode.syntax).at(nodePslSpan(discNode.syntax, sources)), }); } else { - discriminatorDeclarations.set(model.name, { + discriminatorDeclarations.set(model, { fieldName, source: diagnosticSource(sources, discNode.syntax), span: nodePslSpan(discNode.syntax, sources), @@ -327,8 +330,9 @@ function collectPolymorphismDeclarations( const baseNode = findModelAttributeNode(model, 'base'); if (baseNode) { const parsed = interpretModelAttribute({ + symbols: specContext.symbols, node: baseNode, - spec: mongoAttributeSpecs.model.base(specContext), + spec: mongoAttributeSpecs.model.base(), model, sources, diagnostics, @@ -336,8 +340,8 @@ function collectPolymorphismDeclarations( if (parsed) { const collectionName = modelMetadataByName.get(model.name)?.collectionName ?? defaultCollectionName(model.name); - baseDeclarations.set(model.name, { - baseName: parsed.base, + baseDeclarations.set(model, { + base: parsed.base.declaration, value: parsed.value, collectionName, source: diagnosticSource(sources, baseNode.syntax), @@ -354,10 +358,8 @@ function resolvePolymorphism(input: { models: Record; roots: Record; collections: Record>; - allModels: readonly ModelSymbol[]; - discriminatorDeclarations: Map; - baseDeclarations: Map; - modelNames: ReadonlySet; + discriminatorDeclarations: Map; + baseDeclarations: Map; indexSpans: Map; modelIndexesByName: Map; modelMetadataByName: ReadonlyMap; @@ -372,9 +374,7 @@ function resolvePolymorphism(input: { const { discriminatorDeclarations, baseDeclarations, - modelNames, modelMetadataByName, - allModels: allModelViews, indexSpans, modelIndexesByName, indexSources, @@ -384,8 +384,9 @@ function resolvePolymorphism(input: { let collections = input.collections; const diagnostics = createPslDiagnosticCollector(input.sources); - for (const [modelName, decl] of discriminatorDeclarations) { - if (baseDeclarations.has(modelName)) { + for (const [declaration, decl] of discriminatorDeclarations) { + const modelName = declaration.name; + if (baseDeclarations.has(declaration)) { diagnostics.push({ code: 'PSL_DISCRIMINATOR_AND_BASE', message: `Model "${modelName}" cannot have both @@discriminator and @@base`, @@ -411,9 +412,9 @@ function resolvePolymorphism(input: { } const variants: Record = {}; - for (const [variantName, baseDecl] of baseDeclarations) { - if (baseDecl.baseName !== modelName) continue; - variants[variantName] = { value: baseDecl.value }; + for (const [variant, baseDecl] of baseDeclarations) { + if (baseDecl.base !== declaration) continue; + variants[variant.name] = { value: baseDecl.value }; } if (Object.keys(variants).length === 0) { @@ -431,38 +432,29 @@ function resolvePolymorphism(input: { }; } - for (const [variantName, baseDecl] of baseDeclarations) { - if (!modelNames.has(baseDecl.baseName)) { - diagnostics.push({ - code: 'PSL_BASE_TARGET_NOT_FOUND', - message: `Model "${variantName}" @@base references non-existent model "${baseDecl.baseName}"`, - ...baseDecl.source.at(baseDecl.span), - }); - continue; - } - - if (!discriminatorDeclarations.has(baseDecl.baseName)) { + for (const [variant, baseDecl] of baseDeclarations) { + const variantName = variant.name; + const baseName = baseDecl.base.name; + if (!discriminatorDeclarations.has(baseDecl.base)) { diagnostics.push({ code: 'PSL_ORPHANED_BASE', - message: `Model "${variantName}" declares @@base(${baseDecl.baseName}, ...) but "${baseDecl.baseName}" has no @@discriminator`, + message: `Model "${variantName}" declares @@base(${baseName}, ...) but "${baseName}" has no @@discriminator`, ...baseDecl.source.at(baseDecl.span), }); continue; } - if (discriminatorDeclarations.has(variantName)) { + if (discriminatorDeclarations.has(variant)) { continue; } - const baseModel = patched[baseDecl.baseName]; - const variantModelView = allModelViews.find((m) => m.name === variantName); - if (!variantModelView) continue; - const hasExplicitMap = getAttribute(variantModelView.attributes, 'map') !== undefined; + const baseModel = patched[baseName]; + const hasExplicitMap = getAttribute(variant.attributes, 'map') !== undefined; if (hasExplicitMap && baseModel && baseDecl.collectionName !== baseModel.storage.collection) { diagnostics.push({ code: 'PSL_MONGO_VARIANT_SEPARATE_COLLECTION', - message: `Mongo variant "${variantName}" cannot use a different collection than its base "${baseDecl.baseName}". Mongo only supports single-collection polymorphism.`, + message: `Mongo variant "${variantName}" cannot use a different collection than its base "${baseName}". Mongo only supports single-collection polymorphism.`, ...baseDecl.source.at(baseDecl.span), }); continue; @@ -475,7 +467,7 @@ function resolvePolymorphism(input: { ...patched, [variantName]: { ...variantModel, - base: mongoCrossRef(baseDecl.baseName), + base: mongoCrossRef(baseName), storage: { collection: baseCollection }, }, }; @@ -485,7 +477,7 @@ function resolvePolymorphism(input: { modelMetadataByName.get(variantName)?.collectionName ?? defaultCollectionName(variantName); if (roots[variantCollectionName]?.model === variantName) { if (variantCollectionName === baseCollection && baseModel) { - roots = { ...roots, [variantCollectionName]: mongoCrossRef(baseDecl.baseName) }; + roots = { ...roots, [variantCollectionName]: mongoCrossRef(baseName) }; } else { roots = Object.fromEntries( Object.entries(roots).filter(([key]) => key !== variantCollectionName), @@ -496,7 +488,7 @@ function resolvePolymorphism(input: { const variantOwnIndexes = modelIndexesByName.get(variantName) ?? []; const baseColl = collections[baseCollection]; - const baseModelEntry = patched[baseDecl.baseName]; + const baseModelEntry = patched[baseName]; const discriminatorField = baseModelEntry?.discriminator?.field; const scopedVariantIndexes: MongoIndex[] = []; if (discriminatorField) { @@ -883,6 +875,7 @@ function collectIndexes( const uniqueNode = findFieldAttributeNode(field, 'unique'); if (!uniqueNode) continue; const unique = interpretFieldAttribute({ + symbols: specContext.symbols, node: uniqueNode, spec: mongoAttributeSpecs.field.unique({ ...specContext, field }), model: pslModel, @@ -919,6 +912,7 @@ function collectIndexes( let index: MongoIndex | undefined; if (attr.name === 'textIndex') { const parsed = interpretModelAttribute({ + symbols: specContext.symbols, node, spec: mongoAttributeSpecs.model.textIndex(specContext), model: pslModel, @@ -939,6 +933,7 @@ function collectIndexes( } else { const unique = attr.name === 'unique'; const parsed = interpretModelAttribute({ + symbols: specContext.symbols, node, spec: unique ? mongoAttributeSpecs.model.unique(specContext) @@ -1190,6 +1185,7 @@ export function interpretPslDocumentToMongoContract( const relationNode = findFieldAttributeNode(field, 'relation'); const relation = relationNode ? interpretFieldAttribute({ + symbols: specContext.symbols, node: relationNode, spec: mongoAttributeSpecs.field.relation({ ...specContext, field }), model: pslModel, @@ -1279,6 +1275,7 @@ export function interpretPslDocumentToMongoContract( if (!idNode) return false; return ( interpretFieldAttribute({ + symbols: specContext.symbols, node: idNode, spec: mongoAttributeSpecs.field.id({ ...specContext, field }), model: pslModel, @@ -1438,10 +1435,8 @@ export function interpretPslDocumentToMongoContract( models, roots, collections, - allModels, discriminatorDeclarations, baseDeclarations, - modelNames, indexSpans, modelIndexesByName, modelMetadataByName, diff --git a/packages/2-mongo-family/2-authoring/contract-psl/src/mongo-attribute-specs.ts b/packages/2-mongo-family/2-authoring/contract-psl/src/mongo-attribute-specs.ts index fe614533bae8..0f319faa5db6 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/src/mongo-attribute-specs.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/src/mongo-attribute-specs.ts @@ -10,6 +10,7 @@ import type { InferAttr, ModelAttributeCtx, ModelSymbol, + SymbolTable, TypedFuncCall, } from '@internal/psl-parser'; import { @@ -55,16 +56,19 @@ export function findFieldAttributeNode( } function buildModelAttributeCtx(input: { + readonly symbols: SymbolTable; readonly selfModel: ModelSymbol; readonly sources: PslSources; }): ModelAttributeCtx { return { sources: input.sources, selfModel: input.selfModel, + symbols: input.symbols, }; } function buildFieldAttributeCtx(input: { + readonly symbols: SymbolTable; readonly selfModel: ModelSymbol; readonly field: FieldSymbol; readonly sources: PslSources; @@ -75,6 +79,7 @@ function buildFieldAttributeCtx(input: { selfModel: input.selfModel, resolveReferencedModel: input.resolveReferencedModel ?? (() => undefined), field: input.field, + symbols: input.symbols, }; } @@ -82,6 +87,7 @@ function buildFieldAttributeCtx(input: { // failures into `diagnostics`. Returns the typed value, or `undefined` on // failure so the caller can apply its own default/absence handling. export function interpretModelAttribute(input: { + readonly symbols: SymbolTable; readonly node: ModelAttributeAst; readonly spec: AttributeSpec; readonly model: ModelSymbol; @@ -92,6 +98,7 @@ export function interpretModelAttribute(input: { input.node, input.spec, buildModelAttributeCtx({ + symbols: input.symbols, selfModel: input.model, sources: input.sources, }), @@ -107,6 +114,7 @@ export function interpretModelAttribute(input: { // failures into `diagnostics`. Returns the typed value, or `undefined` on // failure so the caller can apply its own default/absence handling. export function interpretFieldAttribute(input: { + readonly symbols: SymbolTable; readonly node: FieldAttributeAst; readonly spec: AttributeSpec; readonly model: ModelSymbol; @@ -119,6 +127,7 @@ export function interpretFieldAttribute(input: { input.node, input.spec, buildFieldAttributeCtx({ + symbols: input.symbols, selfModel: input.model, field: input.field, sources: input.sources, @@ -183,17 +192,23 @@ export const discriminatorModelSpec = modelAttribute('discriminator', { { key: 'field', type: fieldRef(), documentation: 'The discriminator field on this model.' }, ], }); -export const baseModelSpec = modelAttribute('base', { - documentation: 'Declares this model as a variant of a base model.', - positional: [ - { key: 'base', type: entityRef(), documentation: 'The base model to inherit from.' }, - { - key: 'value', - type: str(), - documentation: 'The discriminator value identifying this variant.', - }, - ], -}); +export function baseModelSpec() { + return modelAttribute('base', { + documentation: 'Declares this model as a variant of a base model.', + positional: [ + { + key: 'base', + type: entityRef({ kind: 'model' }), + documentation: 'The base model to inherit from.', + }, + { + key: 'value', + type: str(), + documentation: 'The discriminator value identifying this variant.', + }, + ], + }); +} const sortSig = { documentation: 'Selects an index field with an explicit sort direction.', @@ -221,7 +236,7 @@ function indexFieldElement( positional: [ { key: 'scope', - type: optional(entityRef()), + type: optional(identifier()), documentation: 'The field path to index recursively. Omit to index all document fields.', }, ], @@ -384,7 +399,7 @@ export const mongoAttributeSpecs = { model: { map: staticModelSpec(mapModelSpec), discriminator: staticModelSpec(discriminatorModelSpec), - base: staticModelSpec(baseModelSpec), + base: baseModelSpec, index: (ctx) => buildIndexModelSpec('index', modelFieldElement(ctx)), unique: (ctx) => buildIndexModelSpec('unique', modelFieldElement(ctx)), textIndex: (ctx) => buildTextIndexModelSpec(modelFieldElement(ctx)), diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.attribute-specs.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.attribute-specs.test.ts index 03123e70e1aa..2c4966ad92c8 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.attribute-specs.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.attribute-specs.test.ts @@ -1,4 +1,5 @@ import type { ContractSourceDiagnostic } from '@internal/config/config-types'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { buildSymbolTable } from '@internal/psl-parser'; import { parse } from '@internal/psl-parser/syntax'; import { describe, expect, it } from 'vitest'; @@ -10,14 +11,14 @@ const scalarTypeCodecIds: ReadonlyMap = new Map([ ['ObjectId', 'mongo/objectId@1'], ]); -function diagnosticsOf(schema: string): readonly ContractSourceDiagnostic[] { +function interpret(schema: string) { const { document, sources } = parse(schema, 'schema.prisma'); const { symbolTable } = buildSymbolTable({ documents: [document], sources, pslBlockDescriptors: {}, }); - const result = interpretPslDocumentToMongoContract({ + return interpretPslDocumentToMongoContract({ document, symbolTable, sources, @@ -27,9 +28,62 @@ function diagnosticsOf(schema: string): readonly ContractSourceDiagnostic[] { defaultFunctionRegistry: new Map(), }, }); +} + +function diagnosticsOf(schema: string): readonly ContractSourceDiagnostic[] { + const result = interpret(schema); return result.ok ? [] : result.failure.diagnostics; } +describe('wildcard scope is an unchecked identifier with independent field validation', () => { + it.each([ + ['metadata', 'stored.$**'], + ['', '$**'], + ])('accepts scope %s without a matching model', (scope, path) => { + const result = interpret(`model Event { + id ObjectId @id @map("_id") + metadata String @map("stored") + @@map("events") + @@index([wildcard(${scope})]) +}`); + expect(result.ok).toBe(true); + if (result.ok) + expect(result.value.storage).toMatchObject({ + namespaces: { + [UNBOUND_NAMESPACE_ID]: { + entries: { + collection: { + events: { + indexes: [expect.objectContaining({ keys: [{ field: path, direction: 1 }] })], + }, + }, + }, + }, + }, + }); + }); + + it.each(['missing', 'related'])('retains field/indexability validation for %s', (scope) => { + const diagnostics = diagnosticsOf(`model Related { id ObjectId @id @map("_id") } +model Event { + id ObjectId @id @map("_id") + related Related + @@index([wildcard(${scope})]) +}`); + expect(diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_INDEX_FIELD_NOT_FOUND', + message: expect.stringContaining(scope), + }), + ]), + ); + expect( + diagnostics.some((diagnostic) => diagnostic.code === 'PSL_INVALID_ATTRIBUTE_SYNTAX'), + ).toBe(false); + }); +}); + describe('field-level @id and @unique are interpreted against their specs', () => { it('rejects an argument on @id and no longer counts the field as the id', () => { const diagnostics = diagnosticsOf(` diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts index 2dea61f25cc6..42a27f39eff8 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts @@ -89,6 +89,69 @@ function interpretOk(schema: string) { } describe('interpretPslDocumentToMongoContract — polymorphism', () => { + it('preserves the whole supported contract when a mapped base is declared after its variant', () => { + const base = `model Task { + id ObjectId @id @map("_id") + kind String @map("task_kind") + @@discriminator(kind) + @@map("tasks") + }`; + const variant = `model Bug { + id ObjectId @id @map("_id") + severity String @map("level") + @@base(Task, "bug") + @@index([severity]) + }`; + const forward = interpretOk(`${variant}\n${base}`); + expect(forward).toEqual(interpretOk(`${base}\n${variant}`)); + expect(modelsOf(forward)['Bug']).toMatchObject({ + base: crossRef('Task', UNBOUND_NAMESPACE_ID), + storage: { collection: 'tasks' }, + }); + expect(modelsOf(forward)['Task']).toMatchObject({ + discriminator: { field: 'task_kind' }, + variants: { Bug: { value: 'bug' } }, + }); + expect(forward.roots).toEqual({ tasks: crossRef('Task', UNBOUND_NAMESPACE_ID) }); + expect(mongoCollectionsOf(forward)['tasks']).toMatchObject({ + indexes: [expect.objectContaining({ partialFilterExpression: { task_kind: 'bug' } })], + }); + }); + + it('reports a wrong-kind base at the reference expression', () => { + const schema = `type Base { value String } +model Variant { + id ObjectId @id @map("_id") + @@base(Base, "v") +}`; + const result = interpret(schema); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.failure.diagnostics).toEqual([ + expect.objectContaining({ + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', + message: 'Expected model reference "Base", found compositeType', + span: expect.objectContaining({ + start: expect.objectContaining({ offset: schema.indexOf('@@base(Base') + 7 }), + }), + }), + ]); + }); + + it('keeps namespace rejection even when a same-named top-level base exists', () => { + const result = interpret(`model Base { id ObjectId @id @map("_id") } +namespace scoped { + model Base { id ObjectId @id @map("_id") } + model Variant { id ObjectId @id @map("_id")\n @@base(Base, "v") } +}`); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'PSL_UNSUPPORTED_NAMESPACE_BLOCK' }), + ]), + ); + }); describe('@@discriminator and @@base — happy paths', () => { it('emits discriminator on base model', () => { const ir = interpretOk(` @@ -382,7 +445,12 @@ describe('interpretPslDocumentToMongoContract — polymorphism', () => { expect(result.ok).toBe(false); if (result.ok) return; expect(result.failure.diagnostics).toEqual( - expect.arrayContaining([expect.objectContaining({ code: 'PSL_BASE_TARGET_NOT_FOUND' })]), + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', + message: 'Unknown model reference "NonExistent"', + }), + ]), ); }); diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/mongo-attribute-specs.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/mongo-attribute-specs.test.ts index 8641a9e1b842..fadb7f8880ad 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/test/mongo-attribute-specs.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/mongo-attribute-specs.test.ts @@ -5,12 +5,18 @@ import type { FieldAttributeSpecContext, FuncCallSig, ModelAttributeCtx, + ModelSymbol, Param, + ResolvedEntityReference, } from '@internal/psl-parser'; -import { buildSymbolTable } from '@internal/psl-parser'; +import { buildSymbolTable, createPslDiagnosticCollector } from '@internal/psl-parser'; import { parse } from '@internal/psl-parser/syntax'; -import { describe, expect, it } from 'vitest'; -import { mongoAttributeSpecs } from '../src/mongo-attribute-specs'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { + findModelAttributeNode, + interpretModelAttribute, + mongoAttributeSpecs, +} from '../src/mongo-attribute-specs'; interface ListMetadata extends ArgType { readonly kind: 'list'; @@ -100,6 +106,39 @@ function contexts(): { model: AttributeSpecContext; field: FieldAttributeSpecCon } describe('mongoAttributeSpecs', () => { + it('returns the selected forward base declaration instead of a name', () => { + const { document, sources } = parse( + `model Variant { @@base(Base, "v") } +model Other { id Int } +model Base { id String }`, + 'test.prisma', + ); + const { symbolTable } = buildSymbolTable({ + documents: [document], + sources, + pslBlockDescriptors: {}, + }); + const model = symbolTable.topLevel.models['Variant']; + if (!model) throw new Error('missing variant'); + const node = findModelAttributeNode(model, 'base'); + if (!node) throw new Error('missing base'); + const diagnostics = createPslDiagnosticCollector(sources); + const value = interpretModelAttribute({ + node, + symbols: symbolTable, + spec: mongoAttributeSpecs.model.base(), + model, + sources, + diagnostics, + }); + expectTypeOf(value).toEqualTypeOf< + { base: ResolvedEntityReference; value: string } | undefined + >(); + expect(diagnostics.toExternal()).toEqual([]); + expect(value?.base.declaration).toBe(symbolTable.topLevel.models['Base']); + expect(value?.base.namespace).toBeUndefined(); + expect(value?.value).toBe('v'); + }); it('registers every Mongo built-in at its level', () => { expect({ model: Object.keys(mongoAttributeSpecs.model).sort(), @@ -166,7 +205,8 @@ describe('mongoAttributeSpecs', () => { expect(wildcard).toMatchObject({ kind: 'funcCall', name: 'wildcard' }); expect(wildcard.signature.positional?.[0]).toMatchObject({ key: 'scope' }); expect(wildcard.signature.positional?.[0]?.type).toMatchObject({ - kind: 'entityRef', + kind: 'identifier', + name: undefined, optional: true, }); expect( 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 3670cd0ac3e7..4476b091cfbb 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -933,6 +933,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult node, spec: sqlAttributeSpecs.model.control(), model, + symbols: input.symbolTable, sources: input.sources, diagnostics, }); @@ -968,6 +969,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult node, spec: sqlAttributeSpecs.model.id(), model, + symbols: input.symbolTable, sources: input.sources, diagnostics, }); @@ -1012,6 +1014,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult node, spec: sqlAttributeSpecs.model.unique(), model, + symbols: input.symbolTable, sources: input.sources, diagnostics, }); @@ -1045,6 +1048,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult node, spec: sqlAttributeSpecs.model.index(), model, + symbols: input.symbolTable, sources: input.sources, diagnostics, }); @@ -1104,6 +1108,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult node, spec: sqlAttributeSpecs.model.check(), model, + symbols: input.symbolTable, sources: input.sources, diagnostics, }); @@ -1153,6 +1158,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult }, }), model, + symbols: input.symbolTable, sources: input.sources, diagnostics, }); @@ -1686,15 +1692,22 @@ type DiscriminatorDeclaration = { readonly span: ContractSourceDiagnosticSpan; }; +type ModelIdentity = { + readonly model: ModelSymbol; + readonly namespaceId: string; + readonly key: string; +}; + type BaseDeclaration = { readonly source: DiagnosticSource; - readonly baseName: string; + readonly base: ModelIdentity; readonly value: string; readonly span: ContractSourceDiagnosticSpan; }; function collectPolymorphismDeclarations( - models: readonly ModelSymbol[], + identities: ReadonlyMap, + symbols: SymbolTable, sources: PslSources, diagnostics: PslDiagnosticCollector, ): { @@ -1704,12 +1717,13 @@ function collectPolymorphismDeclarations( const discriminatorDeclarations = new Map(); const baseDeclarations = new Map(); - for (const model of models) { + for (const { model, key } of identities.values()) { const source = diagnosticSource(sources, model.node.syntax); const discriminatorNode = findModelAttributeNode(model, 'discriminator'); if (discriminatorNode !== undefined) { const parsed = interpretModelAttribute({ node: discriminatorNode, + symbols, spec: sqlAttributeSpecs.model.discriminator(), model, sources, @@ -1725,7 +1739,7 @@ function collectPolymorphismDeclarations( ...source.at(span), }); } else { - discriminatorDeclarations.set(model.name, { fieldName: parsed.field, span, source }); + discriminatorDeclarations.set(key, { fieldName: parsed.field, span, source }); } } } @@ -1734,15 +1748,21 @@ function collectPolymorphismDeclarations( if (baseNode !== undefined) { const parsed = interpretModelAttribute({ node: baseNode, + symbols, spec: sqlAttributeSpecs.model.base(), model, sources, diagnostics, }); if (parsed !== undefined) { - baseDeclarations.set(model.name, { + const base = identities.get(parsed.base.declaration); + invariant( + base !== undefined, + `Resolved base model "${parsed.base.declaration.name}" is missing from the collected model identities`, + ); + baseDeclarations.set(key, { source, - baseName: parsed.base, + base, value: parsed.value, span: nodePslSpan(baseNode.syntax, sources), }); @@ -1757,25 +1777,18 @@ function resolvePolymorphism( models: Record, discriminatorDeclarations: Map, baseDeclarations: Map, - modelNames: Set, modelMappings: ReadonlyMap, - modelNamespaceIds: ReadonlyMap, - defaultNamespaceId: string, syntheticPkFieldsByVariant: ReadonlyMap, stiBaseFieldsByBase: ReadonlyMap, diagnostics: PslDiagnosticCollector, ): Record { let patched = models; - const coordinateFor = (modelName: string): string => - modelCoordinateKey(modelNamespaceIds.get(modelName) ?? defaultNamespaceId, modelName); - // STI variant columns were materialised onto the base storage table so the // variants' `storage.fields` resolve. They are storage-only on the base — the // domain field belongs to the variant — so strip them from the base model's // domain + storage field maps (the table column, built upstream, stays). - for (const [baseName, fieldNames] of stiBaseFieldsByBase) { - const baseKey = coordinateFor(baseName); + for (const [baseKey, fieldNames] of stiBaseFieldsByBase) { const baseModel = patched[baseKey]; if (!baseModel || fieldNames.length === 0) continue; patched = { @@ -1784,8 +1797,9 @@ function resolvePolymorphism( }; } - for (const [modelName, decl] of discriminatorDeclarations) { - if (baseDeclarations.has(modelName)) { + for (const [modelKey, decl] of discriminatorDeclarations) { + const modelName = modelMappings.get(modelKey)?.model.name; + if (baseDeclarations.has(modelKey)) { diagnostics.push({ code: 'PSL_DISCRIMINATOR_AND_BASE', message: `Model "${modelName}" cannot have both @@discriminator and @@base`, @@ -1794,14 +1808,19 @@ function resolvePolymorphism( continue; } - const model = patched[coordinateFor(modelName)]; + const model = patched[modelKey]; if (!model) continue; const variants: Record = {}; const seenValues = new Map(); - for (const [variantName, baseDecl] of baseDeclarations) { - if (baseDecl.baseName !== modelName) continue; + for (const [variantKey, baseDecl] of baseDeclarations) { + if (baseDecl.base.key !== modelKey) continue; + const variantName = modelMappings.get(variantKey)?.model.name; + invariant( + variantName !== undefined, + `Variant "${variantKey}" is missing from the model mappings`, + ); const existingVariant = seenValues.get(baseDecl.value); if (existingVariant) { @@ -1827,56 +1846,46 @@ function resolvePolymorphism( patched = { ...patched, - [coordinateFor(modelName)]: { ...model, discriminator: { field: decl.fieldName }, variants }, + [modelKey]: { ...model, discriminator: { field: decl.fieldName }, variants }, }; } - for (const [variantName, baseDecl] of baseDeclarations) { - if (!modelNames.has(baseDecl.baseName)) { - diagnostics.push({ - code: 'PSL_BASE_TARGET_NOT_FOUND', - message: `Model "${variantName}" @@base references non-existent model "${baseDecl.baseName}"`, - ...baseDecl.source.at(baseDecl.span), - }); - continue; - } - - if (!discriminatorDeclarations.has(baseDecl.baseName)) { + for (const [variantKey, baseDecl] of baseDeclarations) { + const variantMapping = modelMappings.get(variantKey); + const variantName = variantMapping?.model.name; + const baseName = baseDecl.base.model.name; + if (!discriminatorDeclarations.has(baseDecl.base.key)) { diagnostics.push({ code: 'PSL_ORPHANED_BASE', - message: `Model "${variantName}" declares @@base(${baseDecl.baseName}, ...) but "${baseDecl.baseName}" has no @@discriminator`, + message: `Model "${variantName}" declares @@base(${baseName}, ...) but "${baseName}" has no @@discriminator`, ...baseDecl.source.at(baseDecl.span), }); continue; } - if (discriminatorDeclarations.has(variantName)) { + if (discriminatorDeclarations.has(variantKey)) { continue; } - const variantModel = patched[coordinateFor(variantName)]; + const variantModel = patched[variantKey]; if (!variantModel) continue; - const baseMapping = modelMappings.get(baseDecl.baseName); - const variantMapping = modelMappings.get(variantName); + const baseMapping = modelMappings.get(baseDecl.base.key); const hasExplicitMap = variantMapping?.model.attributes.some((attr) => attr.name === 'map') ?? false; const resolvedTable = hasExplicitMap ? variantMapping?.tableName : baseMapping?.tableName; const patchedVariant: ContractModel = { ...variantModel, - base: crossRef( - baseDecl.baseName, - modelNamespaceIds.get(baseDecl.baseName) ?? defaultNamespaceId, - ), + base: crossRef(baseName, baseDecl.base.namespaceId), ...(resolvedTable ? { storage: { ...variantModel.storage, table: resolvedTable } } : {}), }; patched = { ...patched, - [coordinateFor(variantName)]: stripStorageOnlyDomainFields( + [variantKey]: stripStorageOnlyDomainFields( patchedVariant, - syntheticPkFieldsByVariant.get(variantName) ?? [], + syntheticPkFieldsByVariant.get(variantKey) ?? [], ), }; } @@ -1901,20 +1910,24 @@ function resolvePolymorphism( function materializeMtiVariantStorageLinks( modelNodes: readonly ModelNode[], baseDeclarations: ReadonlyMap, - stiVariantNames: ReadonlySet, + stiVariantKeys: ReadonlySet, + defaultNamespaceId: string, ): { modelNodes: ModelNode[]; syntheticPkFieldsByVariant: Map } { - const nodeByModel = new Map(modelNodes.map((node) => [node.modelName, node])); + const keyOf = (node: ModelNode) => + modelCoordinateKey(node.namespaceId ?? defaultNamespaceId, node.modelName); + const nodeByModel = new Map(modelNodes.map((node) => [keyOf(node), node])); const syntheticPkFieldsByVariant = new Map(); const enriched = modelNodes.map((node): ModelNode => { - const baseDecl = baseDeclarations.get(node.modelName); + const variantKey = keyOf(node); + const baseDecl = baseDeclarations.get(variantKey); if (!baseDecl) return node; - const baseNode = nodeByModel.get(baseDecl.baseName); + const baseNode = nodeByModel.get(baseDecl.base.key); if (!baseNode) return node; // Single-table inheritance (no own `@@map`) shares the base table; it gets // its columns materialised onto the base instead (see // {@link materializeStiVariantStorageColumns}), never a link column. - if (stiVariantNames.has(node.modelName)) return node; + if (stiVariantKeys.has(variantKey)) return node; const basePrimaryKey = baseNode.id; if (!basePrimaryKey || basePrimaryKey.columns.length === 0) return node; @@ -1936,7 +1949,7 @@ function materializeMtiVariantStorageLinks( if (linkFields.length === 0) return node; syntheticPkFieldsByVariant.set( - node.modelName, + variantKey, linkFields.map((field) => field.fieldName), ); @@ -1990,25 +2003,28 @@ function materializeMtiVariantStorageLinks( function materializeStiVariantStorageColumns( modelNodes: readonly ModelNode[], baseDeclarations: ReadonlyMap, - stiVariantNames: ReadonlySet, + stiVariantKeys: ReadonlySet, + defaultNamespaceId: string, ): { modelNodes: ModelNode[]; stiBaseFieldsByBase: Map } { - if (stiVariantNames.size === 0) { + if (stiVariantKeys.size === 0) { return { modelNodes: [...modelNodes], stiBaseFieldsByBase: new Map() }; } - const nodeByModel = new Map(modelNodes.map((node) => [node.modelName, node])); + const keyOf = (node: ModelNode) => + modelCoordinateKey(node.namespaceId ?? defaultNamespaceId, node.modelName); + const nodeByModel = new Map(modelNodes.map((node) => [keyOf(node), node])); type StiColumn = ModelNode['fields'][number]; const stiColumnsByBase = new Map(); - for (const variantName of stiVariantNames) { - const variantNode = nodeByModel.get(variantName); - const baseDecl = baseDeclarations.get(variantName); + for (const variantKey of stiVariantKeys) { + const variantNode = nodeByModel.get(variantKey); + const baseDecl = baseDeclarations.get(variantKey); if (!variantNode || !baseDecl) continue; - const baseNode = nodeByModel.get(baseDecl.baseName); + const baseNode = nodeByModel.get(baseDecl.base.key); if (!baseNode) continue; const baseColumns = new Set(baseNode.fields.map((field) => field.columnName)); - const claimed = stiColumnsByBase.get(baseDecl.baseName) ?? []; + const claimed = stiColumnsByBase.get(baseDecl.base.key) ?? []; const claimedColumns = new Set(claimed.map((field) => field.columnName)); for (const field of variantNode.fields) { @@ -2018,7 +2034,7 @@ function materializeStiVariantStorageColumns( claimedColumns.add(field.columnName); claimed.push({ ...field, nullable: true }); } - stiColumnsByBase.set(baseDecl.baseName, claimed); + stiColumnsByBase.set(baseDecl.base.key, claimed); } // The materialised columns exist on the base STORAGE table so the variants' @@ -2036,10 +2052,10 @@ function materializeStiVariantStorageColumns( const enriched = modelNodes.map((node): ModelNode => { // STI variant: contributes a domain model but no storage table of its own. - if (stiVariantNames.has(node.modelName)) { + if (stiVariantKeys.has(keyOf(node))) { return { ...node, sharesBaseTable: true }; } - const stiColumns = stiColumnsByBase.get(node.modelName); + const stiColumns = stiColumnsByBase.get(keyOf(node)); if (!stiColumns || stiColumns.length === 0) return node; return { ...node, fields: [...node.fields, ...stiColumns] }; }); @@ -2285,6 +2301,7 @@ export function interpretPslDocumentToSqlContract( // `modelMappingsByCoordinate` further down; this call discards its own // diagnostics so nothing is reported twice. const earlyModelMappingsByCoordinate = buildModelMappings( + input.symbolTable, modelEntries, defaultNamespaceId, createPslDiagnosticCollector(input.sources), @@ -2435,12 +2452,13 @@ export function interpretPslDocumentToSqlContract( const storageTypes = { ...namedTypeResult.storageTypes }; const modelMappingsByCoordinate = buildModelMappings( + input.symbolTable, modelEntries, defaultNamespaceId, diagnostics, input.sources, ); - // Bare-name view for unqualified relation targets and polymorphism, where + // Bare-name view for unqualified relation targets, where // resolution is by bare model name. When a bare name is shared across // namespaces this collapses to the last entry; qualified relation targets // and per-model lowering use the coordinate-keyed map above instead. @@ -2561,8 +2579,22 @@ export function interpretPslDocumentToSqlContract( } } + const modelIdentities = new Map( + modelEntries.map(({ model, namespaceId }) => { + const resolvedNamespaceId = namespaceId ?? defaultNamespaceId; + return [ + model, + { + model, + namespaceId: resolvedNamespaceId, + key: modelCoordinateKey(resolvedNamespaceId, model.name), + }, + ]; + }), + ); const { discriminatorDeclarations, baseDeclarations } = collectPolymorphismDeclarations( - models, + modelIdentities, + input.symbolTable, input.sources, diagnostics, ); @@ -2573,13 +2605,13 @@ export function interpretPslDocumentToSqlContract( // because a no-`@@map` STI variant still gets its own verbatim default table // name (`defaultTableName`) that differs from the base before // `resolvePolymorphism` rewrites it onto the base table. - const stiVariantNames = new Set(); - for (const variantName of baseDeclarations.keys()) { - const variantMapping = modelMappings.get(variantName); + const stiVariantKeys = new Set(); + for (const variantKey of baseDeclarations.keys()) { + const variantMapping = modelMappingsByCoordinate.get(variantKey); const hasExplicitMap = variantMapping?.model.attributes.some((attr) => attr.name === 'map') ?? false; if (!hasExplicitMap) { - stiVariantNames.add(variantName); + stiVariantKeys.add(variantKey); } } @@ -2589,19 +2621,20 @@ export function interpretPslDocumentToSqlContract( // dropping it at build time would defeat the whole point of `@@check`. // Catch it here, while the PSL source still has the `@@check` attribute's // span and the base model's name in hand. - for (const variantName of stiVariantNames) { - const variantMapping = modelMappings.get(variantName); + for (const variantKey of stiVariantKeys) { + const variantMapping = modelMappingsByCoordinate.get(variantKey); if (variantMapping === undefined) continue; - const baseDecl = baseDeclarations.get(variantName); + const variantName = variantMapping.model.name; + const baseDecl = baseDeclarations.get(variantKey); invariant( baseDecl !== undefined, - `stiVariantNames is derived from baseDeclarations.keys(), so "${variantName}" must have a base declaration`, + `stiVariantKeys is derived from baseDeclarations.keys(), so "${variantName}" must have a base declaration`, ); for (const attribute of variantMapping.model.node.attributes()) { if (attribute.name()?.isSimpleName('check') !== true) continue; diagnostics.push({ code: PSL_CHECK_ON_STI_VARIANT, - message: `Model "${variantName}" declares "@@check", but it shares its base model "${baseDecl.baseName}"'s storage table (single-table inheritance via @@base) and has no table of its own to declare a check constraint on. Declare the check on "${baseDecl.baseName}" instead.`, + message: `Model "${variantName}" declares "@@check", but it shares its base model "${baseDecl.base.model.name}"'s storage table (single-table inheritance via @@base) and has no table of its own to declare a check constraint on. Declare the check on "${baseDecl.base.model.name}" instead.`, ...diagnosticSource(input.sources, attribute.syntax).at( nodePslSpan(attribute.syntax, input.sources), ), @@ -2610,9 +2643,19 @@ export function interpretPslDocumentToSqlContract( } const { modelNodes: mtiLinkedModelNodes, syntheticPkFieldsByVariant } = - materializeMtiVariantStorageLinks(modelNodes, baseDeclarations, stiVariantNames); + materializeMtiVariantStorageLinks( + modelNodes, + baseDeclarations, + stiVariantKeys, + defaultNamespaceId, + ); const { modelNodes: stiColumnModelNodes, stiBaseFieldsByBase } = - materializeStiVariantStorageColumns(mtiLinkedModelNodes, baseDeclarations, stiVariantNames); + materializeStiVariantStorageColumns( + mtiLinkedModelNodes, + baseDeclarations, + stiVariantKeys, + defaultNamespaceId, + ); const valueObjects = buildValueObjects({ compositeTypes, @@ -2731,10 +2774,7 @@ export function interpretPslDocumentToSqlContract( patchedModels, discriminatorDeclarations, baseDeclarations, - modelNames, - modelMappings, - modelNamespaceIds, - input.target.defaultNamespaceId, + modelMappingsByCoordinate, syntheticPkFieldsByVariant, stiBaseFieldsByBase, polyDiagnostics, @@ -2747,10 +2787,10 @@ export function interpretPslDocumentToSqlContract( }); } - const variantModelNames = new Set(baseDeclarations.keys()); const filteredRoots = Object.fromEntries( Object.entries(contract.roots).filter( - ([, crossReference]) => !variantModelNames.has(crossReference.model), + ([, crossReference]) => + !baseDeclarations.has(modelCoordinateKey(crossReference.namespace, crossReference.model)), ), ); diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts index a69cdb9abae1..fb572004d684 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts @@ -789,6 +789,7 @@ export function lowerDefaultForField(input: { }), ); const interpreted = interpretFieldAttribute({ + symbols: input.symbolTable, node, spec, model: input.model, diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts index ecd14a416880..8288e0287805 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts @@ -79,6 +79,7 @@ function lowerEnumDefaultForField(input: { const interpreted = interpretFieldAttribute({ node, spec, + symbols: input.symbolTable, model, field, sources: input.sources, @@ -270,6 +271,7 @@ function validateFieldAttributes(input: { } function extractFieldConstraintNames(input: { + readonly symbolTable: SymbolTable; readonly model: ModelSymbol; readonly field: FieldSymbol; readonly sources: PslSources; @@ -288,6 +290,7 @@ function extractFieldConstraintNames(input: { ? undefined : interpretFieldAttribute({ node: idNode, + symbols: input.symbolTable, spec: sqlAttributeSpecs.field.id(), model: input.model, field: input.field, @@ -300,6 +303,7 @@ function extractFieldConstraintNames(input: { ? undefined : interpretFieldAttribute({ node: uniqueNode, + symbols: input.symbolTable, spec: sqlAttributeSpecs.field.unique(), model: input.model, field: input.field, @@ -321,6 +325,7 @@ type NoCheckKind = 'membership' | 'elementNotNull'; * the only form the definition tree carries. */ function lowerNoCheckForField(input: { + readonly symbolTable: SymbolTable; readonly model: ModelSymbol; readonly field: FieldSymbol; readonly sources: PslSources; @@ -333,6 +338,7 @@ function lowerNoCheckForField(input: { const interpreted = interpretFieldAttribute({ node, spec: sqlAttributeSpecs.field.noCheck(), + symbols: input.symbolTable, model: input.model, field: input.field, sources: input.sources, @@ -628,6 +634,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv } const mappedColumnName = mapping.fieldColumns.get(field.name) ?? field.name; const { idAttribute, uniqueAttribute, idName, uniqueName } = extractFieldConstraintNames({ + symbolTable: input.symbolTable, model, field, sources: input.sources, @@ -673,6 +680,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv const fieldDefaultValue = presetContributions?.default ?? loweredDefault.defaultValue; const noCheckKinds = modelDerivesChecks ? lowerNoCheckForField({ + symbolTable: input.symbolTable, model, field, sources: input.sources, @@ -707,6 +715,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv } export function buildModelMappings( + symbols: SymbolTable, modelEntries: readonly ModelNamespaceEntry[], defaultNamespaceId: string, diagnostics: PslDiagnosticCollector, @@ -720,6 +729,7 @@ export function buildModelMappings( ? defaultTableName(model.name) : (interpretModelAttribute({ node: mapNode, + symbols, spec: sqlAttributeSpecs.model.map(), model, sources, @@ -733,6 +743,7 @@ export function buildModelMappings( ? field.name : (interpretFieldAttribute({ node: fieldMapNode, + symbols, spec: sqlAttributeSpecs.field.map(), model, field, diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts index 1f456f43353f..c769c953d27f 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts @@ -96,6 +96,7 @@ export function interpretRelationAttribute(input: { const node = findFieldAttributeNode(input.field, 'relation'); if (node === undefined) return undefined; return interpretFieldAttribute({ + symbols: input.symbols, node, spec: sqlAttributeSpecs.field.relation(), model: input.selfModel, diff --git a/packages/2-sql/2-authoring/contract-psl/src/sql-attribute-specs.ts b/packages/2-sql/2-authoring/contract-psl/src/sql-attribute-specs.ts index c179535a4cd2..72fd6eff8d30 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/sql-attribute-specs.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/sql-attribute-specs.ts @@ -73,16 +73,19 @@ export function findFieldAttributeNode( } function buildModelAttributeCtx(input: { + readonly symbols: SymbolTable; readonly selfModel: ModelSymbol; readonly sources: PslSources; }): ModelAttributeCtx { return { sources: input.sources, selfModel: input.selfModel, + symbols: input.symbols, }; } function buildFieldAttributeCtx(input: { + readonly symbols: SymbolTable; readonly selfModel: ModelSymbol; readonly field: FieldSymbol; readonly sources: PslSources; @@ -93,6 +96,7 @@ function buildFieldAttributeCtx(input: { selfModel: input.selfModel, resolveReferencedModel: input.resolveReferencedModel ?? (() => undefined), field: input.field, + symbols: input.symbols, }; } @@ -100,6 +104,7 @@ function buildFieldAttributeCtx(input: { // failures into `diagnostics`. Returns the typed value, or `undefined` on // failure so the caller can apply its own default/absence handling. export function interpretModelAttribute(input: { + readonly symbols: SymbolTable; readonly node: ModelAttributeAst; readonly spec: AttributeSpec; readonly model: ModelSymbol; @@ -110,6 +115,7 @@ export function interpretModelAttribute(input: { input.node, input.spec, buildModelAttributeCtx({ + symbols: input.symbols, selfModel: input.model, sources: input.sources, }), @@ -125,6 +131,7 @@ export function interpretModelAttribute(input: { // failures into `diagnostics`. Returns the typed value, or `undefined` on // failure so the caller can apply its own default/absence handling. export function interpretFieldAttribute(input: { + readonly symbols: SymbolTable; readonly node: FieldAttributeAst; readonly spec: AttributeSpec; readonly model: ModelSymbol; @@ -137,6 +144,7 @@ export function interpretFieldAttribute(input: { input.node, input.spec, buildFieldAttributeCtx({ + symbols: input.symbols, selfModel: input.model, field: input.field, sources: input.sources, @@ -519,17 +527,23 @@ const discriminatorModelSpec = modelAttribute('discriminator', { { key: 'field', type: fieldRef(), documentation: 'The discriminator field on this model.' }, ], }); -const baseModelSpec = modelAttribute('base', { - documentation: 'Declares this model as a variant of a base model.', - positional: [ - { key: 'base', type: entityRef(), documentation: 'The base model to inherit from.' }, - { - key: 'value', - type: str(), - documentation: 'The discriminator value identifying this variant.', - }, - ], -}); +function baseModelSpec() { + return modelAttribute('base', { + documentation: 'Declares this model as a variant of a base model.', + positional: [ + { + key: 'base', + type: entityRef({ kind: 'model' }), + documentation: 'The base model to inherit from.', + }, + { + key: 'value', + type: str(), + documentation: 'The discriminator value identifying this variant.', + }, + ], + }); +} function relationAttributeSpan(ctx: FieldAttributeCtx): PslSpan { const node = findFieldAttributeNode(ctx.field, 'relation'); @@ -661,7 +675,7 @@ export const sqlAttributeSpecs = { check: () => checkModelSpec, control: () => controlModelSpec, discriminator: () => discriminatorModelSpec, - base: () => baseModelSpec, + base: baseModelSpec, }, field: { map: () => mapFieldSpec, diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts index 3a984980ad62..0e99c4d3a17e 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts @@ -1,6 +1,7 @@ import { crossRef } from '@internal/contract/types'; import { validateContractDomain } from '@internal/contract/validate-domain'; import type { SqlModelStorage, SqlStorage } from '@internal/sql-contract/types'; +import { validateSqlContractFully } from '@internal/sql-contract/validators'; import { describe, expect, it } from 'vitest'; import { createTestSqlNamespace } from '../../../1-core/contract/test/test-support'; import { @@ -41,6 +42,113 @@ describe('interpretPslDocumentToSqlContract — polymorphism', () => { ...input, }); + it('keeps same-named inheritance graphs independent across namespaces', () => { + const inheritanceSchema = (namespace: string) => `namespace ${namespace} { + model Bug { + ${namespace}Detail String @map("${namespace}_detail") + @@base(Task, "bug") + } + model Feature { + ${namespace}Priority Int + @@base(Task, "feature") + @@map("${namespace}_features") + } + model Task { + ${namespace}Id Int @id @map("${namespace}_id") + ${namespace}Kind String + @@discriminator(${namespace}Kind) + @@map("${namespace}_tasks") + } +}`; + const standalone = 'model Bug {\n id Int @id\n @@map("standalone_bug")\n}'; + const interpret = (schema: string) => { + const result = interpretPslDocumentToSqlContract({ + ...symbolTableInputFromParseArgs({ schema, sourceId: 'schema.prisma' }), + controlMutationDefaults: builtinControlMutationDefaults, + }); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error(JSON.stringify(result.failure)); + return result.value; + }; + const combined = interpret( + [standalone, inheritanceSchema('alpha'), inheritanceSchema('beta')].join('\n'), + ); + expect(() => validateContractDomain(combined)).not.toThrow(); + const envelope: unknown = JSON.parse(JSON.stringify(combined)); + expect(() => validateSqlContractFully(envelope)).not.toThrow(); + for (const namespace of ['alpha', 'beta']) { + const isolated = interpret(inheritanceSchema(namespace)); + expect(combined.domain.namespaces[namespace]).toEqual(isolated.domain.namespaces[namespace]); + const storage = combined.storage as SqlStorage; + expect(storage.namespaces[namespace]).toEqual( + (isolated.storage as SqlStorage).namespaces[namespace], + ); + expect(combined.domain.namespaces[namespace]?.models['Task']).toMatchObject({ + discriminator: { field: `${namespace}Kind` }, + variants: { Bug: { value: 'bug' }, Feature: { value: 'feature' } }, + }); + expect(combined.domain.namespaces[namespace]?.models['Bug']).toMatchObject({ + base: crossRef('Task', namespace), + storage: { table: `${namespace}_tasks` }, + }); + expect(storage.namespaces[namespace]?.entries.table?.[`${namespace}_features`]).toMatchObject( + { + primaryKey: { columns: [`${namespace}_id`] }, + foreignKeys: [ + { + target: { + tableName: `${namespace}_tasks`, + columns: [`${namespace}_id`], + namespaceId: namespace, + }, + }, + ], + }, + ); + } + expect(combined.roots).toEqual({ + standalone_bug: crossRef('Bug', 'public'), + alpha_tasks: crossRef('Task', 'alpha'), + beta_tasks: crossRef('Task', 'beta'), + }); + }); + + it.each([ + [ + 'type Base { value String }', + 'model Variant { id Int @id\n @@base(Base, "v") }', + 'Expected model reference "Base", found compositeType', + ], + [ + 'namespace sibling { model Base { id Int @id } }', + 'namespace local { model Variant { id Int @id\n @@base(Base, "v") } }', + 'Unknown model reference "Base"', + ], + [ + 'model Base { id Int @id }', + 'namespace local { type Base { value String }\n model Variant { id Int @id\n @@base(Base, "v") } }', + 'Expected model reference "Base", found compositeType', + ], + ])('reports checked-reference failure for %s', (base, variant, message) => { + const schema = `${base}\n${variant}`; + const result = interpretPslDocumentToSqlContract({ + ...symbolTableInputFromParseArgs({ schema, sourceId: 'schema.prisma' }), + controlMutationDefaults: builtinControlMutationDefaults, + }); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.failure.diagnostics).toEqual([ + expect.objectContaining({ + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', + message, + sourceId: 'schema.prisma', + span: expect.objectContaining({ + start: expect.objectContaining({ offset: schema.indexOf('@@base(Base') + 7 }), + }), + }), + ]); + }); + it('ignores polymorphism collection when the schema has no models', () => { const document = symbolTableInputFromParseArgs({ schema: `types { @@ -859,7 +967,8 @@ model Bug { expect(result.failure.diagnostics).toEqual( expect.arrayContaining([ expect.objectContaining({ - code: 'PSL_BASE_TARGET_NOT_FOUND', + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', + message: 'Unknown model reference "NonExistent"', }), ]), ); diff --git a/packages/2-sql/2-authoring/contract-psl/test/sql-attribute-specs.test.ts b/packages/2-sql/2-authoring/contract-psl/test/sql-attribute-specs.test.ts index 334acbbd081b..b9982ca881e5 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/sql-attribute-specs.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/sql-attribute-specs.test.ts @@ -15,7 +15,9 @@ import { describe, expect, it } from 'vitest'; import { fieldSpecContext, findFieldAttributeNode, + findModelAttributeNode, interpretFieldAttribute, + interpretModelAttribute, modelSpecContext, sqlAttributeSpecs, } from '../src/sql-attribute-specs'; @@ -106,6 +108,7 @@ function interpretDefault(schema: string, fieldName: string) { if (node === undefined) throw new Error('no @default on field'); const diagnostics = createPslDiagnosticCollector(sources); const value = interpretFieldAttribute({ + symbols: symbolTable, node, spec: sqlAttributeSpecs.field.default( fieldSpecContext({ symbols: symbolTable, model, field: target, controlMutationDefaults }), @@ -118,6 +121,34 @@ function interpretDefault(schema: string, fieldName: string) { return { value, diagnostics: diagnostics.toExternal() }; } +describe('checked base factory', () => { + it('returns the forward-declared local base identity', () => { + const input = buildSymbolTableInput(`model Base { id Int @id } +namespace scoped { + model Variant { @@base(Base, "variant") } + model Base { id String @id } +}`); + const namespace = input.symbolTable.topLevel.namespaces['scoped']; + const model = namespace?.models['Variant']; + if (!namespace || !model) throw new Error('missing variant'); + const node = findModelAttributeNode(model, 'base'); + if (!node) throw new Error('missing base attribute'); + const diagnostics = createPslDiagnosticCollector(input.sources); + const value = interpretModelAttribute({ + node, + symbols: input.symbolTable, + spec: sqlAttributeSpecs.model.base(), + model, + sources: input.sources, + diagnostics, + }); + expect(diagnostics.toExternal()).toEqual([]); + expect(value?.base.declaration).toBe(namespace.models['Base']); + expect(value?.base.namespace).toBe(namespace); + expect(value?.value).toBe('variant'); + }); +}); + describe('sqlAttributeSpecs', () => { const { symbolTable, model } = project( 'model Post {\n id Int @id\n tags String[]\n}\n',