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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion packages/1-framework/2-authoring/psl-parser/src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,10 +188,26 @@ export function parseExpression(cursor: Cursor): GreenNode | undefined {
parseObjectLiteralExpr(cursor) ??
parseFunctionCall(cursor) ??
parseBooleanLiteralExpr(cursor) ??
parseIdentifierExpr(cursor)
parseMemberAccessExpr(cursor)
);
}

/**
* Parses an identifier-leading value: a member-access `Foo.bar` (the dotted form
* `through: Junction.relationField` uses to pin a relation field) as a
* {@link parseQualifiedName} chain that preserves every segment, or a bare
* `Foo` as a single `Identifier`. Returns `undefined` when no identifier leads,
* leaving the `parseExpression` chain to fall through.
*/
export function parseMemberAccessExpr(cursor: Cursor): GreenNode | undefined {
if (cursor.peekKind() !== 'Ident') return undefined;
if (cursor.peekKind(1) !== 'Dot') return parseIdentifierExpr(cursor);
cursor.startNode('QualifiedName');
parseIdentifier(cursor);
parseQualifiedSegments(cursor, 'Dot');
return cursor.finishNode();
}

export function parseStringLiteralExpr(cursor: Cursor): GreenNode | undefined {
if (cursor.peekKind() !== 'StringLiteral') return undefined;
const stringMark = cursor.mark();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ export type ExpressionAst =
| NumberLiteralExprAst
| BooleanLiteralExprAst
| ObjectLiteralExprAst
| QualifiedNameAst
| IdentifierAst;

export function castExpression(node: SyntaxNode): ExpressionAst | undefined {
Expand All @@ -322,6 +323,7 @@ export function castExpression(node: SyntaxNode): ExpressionAst | undefined {
NumberLiteralExprAst.cast(node) ??
BooleanLiteralExprAst.cast(node) ??
ObjectLiteralExprAst.cast(node) ??
QualifiedNameAst.cast(node) ??
IdentifierAst.cast(node)
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { describe, expect, it } from 'vitest';
import { Cursor, parse, parseAttributeArg, parseExpression } from '../src/parse';
import { readResolvedAttribute } from '../src/resolve';
import type { ModelDeclarationAst } from '../src/syntax/ast/declarations';
import { ArrayLiteralAst, AttributeArgAst } from '../src/syntax/ast/expressions';
import { IdentifierAst } from '../src/syntax/ast/identifier';
import { QualifiedNameAst } from '../src/syntax/ast/qualified-name';
import type { GreenElement, GreenNode } from '../src/syntax/green';
import { createSyntaxTree } from '../src/syntax/red';
import { printTree } from './support';

function greenText(element: GreenElement): string {
if (element.type === 'token') return element.text;
return element.children.map(greenText).join('');
}

function parseArg(source: string) {
const cursor = new Cursor(source);
cursor.startNode('AttributeArgList');
parseAttributeArg(cursor);
const list = cursor.finishNode();
const node = list.children.find(
(child): child is GreenNode => child.type === 'node' && child.kind === 'AttributeArg',
);
if (node === undefined) {
throw new Error(`no AttributeArg parsed from ${JSON.stringify(source)}`);
}
return { node, diagnostics: cursor.diagnostics };
}

/** The resolved value string `getNamedArgument(attr, name)` returns downstream. */
function resolvedNamedArg(
source: string,
attributeName: string,
argName: string,
): string | undefined {
const result = parse(source);
const model = Array.from(result.document.declarations())[0] as ModelDeclarationAst;
for (const field of model.fields()) {
for (const attribute of field.attributes()) {
const resolved = readResolvedAttribute(attribute, result.sourceFile);
if (resolved.name !== attributeName) continue;
const arg = resolved.args.find((a) => a.kind === 'named' && a.name === argName);
if (arg) return arg.value;
}
}
return undefined;
}

describe('member-access argument value', () => {
it('parses a qualified Identifier.Identifier value into a QualifiedName node', () => {
const source = 'through: Foo.bar';
const { node, diagnostics } = parseArg(source);

expect(printTree(node)).toMatchInlineSnapshot(`
"AttributeArg
Identifier
Ident "through"
Colon ":"
Whitespace " "
QualifiedName
Identifier
Ident "Foo"
Dot "."
Identifier
Ident "bar""
`);
expect(greenText(node)).toBe(source);
expect(diagnostics).toEqual([]);
});

it('exposes both segments of the qualified value through the AST', () => {
const { node } = parseArg('through: Foo.bar');
const value = AttributeArgAst.cast(createSyntaxTree(node))!.value();
expect(value).toBeInstanceOf(QualifiedNameAst);
if (value instanceof QualifiedNameAst) {
expect(value.path()).toEqual(['Foo', 'bar']);
}
});

it('surfaces the full dotted string to the resolver (what getNamedArgument returns)', () => {
const source = [
'model Follow {',
' follower User @relation(through: Follow.follower)',
'}',
].join('\n');
expect(resolvedNamedArg(source, 'relation', 'through')).toBe('Follow.follower');
});
});

describe('member-access value — no regression on simpler forms', () => {
it('keeps a bare identifier value as an Identifier node', () => {
const source = 'through: Foo';
const { node, diagnostics } = parseArg(source);

expect(printTree(node)).toMatchInlineSnapshot(`
"AttributeArg
Identifier
Ident "through"
Colon ":"
Whitespace " "
Identifier
Ident "Foo""
`);
expect(greenText(node)).toBe(source);
expect(diagnostics).toEqual([]);

const value = AttributeArgAst.cast(createSyntaxTree(node))!.value();
expect(value).toBeInstanceOf(IdentifierAst);
});

it('resolves a bare identifier value to its name', () => {
const source = ['model M {', ' rel Other @relation(through: Foo)', '}'].join('\n');
expect(resolvedNamedArg(source, 'relation', 'through')).toBe('Foo');
});

it('keeps a bracketed list value as an ArrayLiteral node', () => {
const source = 'from: [a, b]';
const { node, diagnostics } = parseArg(source);

expect(printTree(node)).toMatchInlineSnapshot(`
"AttributeArg
Identifier
Ident "from"
Colon ":"
Whitespace " "
ArrayLiteral
LBracket "["
Identifier
Ident "a"
Comma ","
Whitespace " "
Identifier
Ident "b"
RBracket "]""
`);
expect(greenText(node)).toBe(source);
expect(diagnostics).toEqual([]);

const value = AttributeArgAst.cast(createSyntaxTree(node))!.value();
expect(value).toBeInstanceOf(ArrayLiteralAst);
});

it('resolves a bracketed list value to its rendered source', () => {
const source = ['model M {', ' rel Other @relation(from: [a, b])', '}'].join('\n');
expect(resolvedNamedArg(source, 'relation', 'from')).toBe('[a, b]');
});
});

describe('member-access value — expression entry point', () => {
it('parseExpression yields a QualifiedName for a dotted value', () => {
const cursor = new Cursor('Foo.bar');
const node = parseExpression(cursor) as GreenNode;
expect(node.kind).toBe('QualifiedName');
expect(greenText(node)).toBe('Foo.bar');
expect(cursor.diagnostics).toEqual([]);
});

it('parseExpression yields a bare Identifier when no dot follows', () => {
const cursor = new Cursor('Foo');
const node = parseExpression(cursor) as GreenNode;
expect(node.kind).toBe('Identifier');
expect(greenText(node)).toBe('Foo');
expect(cursor.diagnostics).toEqual([]);
});
});

describe('member-access value — three-segment bound', () => {
// Two segments is the disambiguation form (`through: J.field`). A third
// segment round-trips but is over-qualified: the shared qualified-name
// mechanism flags it with PSL_INVALID_QUALIFIED_NAME, the same bound the
// type-annotation and call grammars carry.
it('round-trips a.b.c but flags the extra segment, still exposing all segments', () => {
const source = 'through: a.b.c';
const { node, diagnostics } = parseArg(source);

expect(greenText(node)).toBe(source);
expect(diagnostics).toHaveLength(1);
expect(diagnostics[0]!.code).toBe('PSL_INVALID_QUALIFIED_NAME');

const value = AttributeArgAst.cast(createSyntaxTree(node))!.value();
expect(value).toBeInstanceOf(QualifiedNameAst);
if (value instanceof QualifiedNameAst) {
expect(value.path()).toEqual(['a', 'b', 'c']);
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
NamespaceDeclarationAst,
NumberLiteralExprAst,
ObjectLiteralExprAst,
type QualifiedNameAst,
QualifiedNameAst,
type SourceFile,
StringLiteralExprAst,
type SyntaxToken,
Expand Down Expand Up @@ -447,6 +447,13 @@ function collectExpression(
return;
}

if (expression instanceof QualifiedNameAst) {
for (const segment of filterChildren(expression.syntax, IdentifierAst.cast)) {
collectIdentifierExpression(segment, source, tokens, namespace, context);
}
return;
}

collectIdentifierExpression(expression, source, tokens, namespace, context);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,6 @@ interface FkRelation {
readonly declaringModel: string;
readonly fieldName: string;
readonly targetModel: string;
readonly relationName?: string;
readonly localFields: readonly string[];
readonly targetFields: readonly string[];
}
Expand Down Expand Up @@ -1008,7 +1007,7 @@ export function interpretPslDocumentToMongoContract(
readonly modelName: string;
readonly fieldName: string;
readonly targetModelName: string;
readonly relationName?: string;
readonly inverse?: string;
readonly cardinality: '1:1' | '1:N';
readonly field: FieldSymbol;
}
Expand Down Expand Up @@ -1060,7 +1059,7 @@ export function interpretPslDocumentToMongoContract(
modelName: pslModel.name,
fieldName: field.name,
targetModelName: field.typeName,
...ifDefined('relationName', relation?.relationName),
...(relation?.inverse !== undefined ? { inverse: relation.inverse } : {}),
cardinality: field.list ? '1:N' : '1:1',
field,
});
Expand All @@ -1087,7 +1086,6 @@ export function interpretPslDocumentToMongoContract(
declaringModel: pslModel.name,
fieldName: field.name,
targetModel: field.typeName,
...(relation.relationName !== undefined ? { relationName: relation.relationName } : {}),
localFields: localMapped,
targetFields: targetMapped,
});
Expand Down Expand Up @@ -1197,30 +1195,48 @@ export function interpretPslDocumentToMongoContract(
for (const candidate of backrelationCandidates) {
const pairKey = fkRelationPairKey(candidate.targetModelName, candidate.modelName);
const pairMatches = fkRelationsByPair.get(pairKey) ?? [];
const matches = candidate.relationName
? pairMatches.filter((r) => r.relationName === candidate.relationName)
: [...pairMatches];

if (matches.length === 0) {
diagnostics.push({
code: 'PSL_ORPHANED_BACKRELATION',
message: `Backrelation list field "${candidate.modelName}.${candidate.fieldName}" has no matching FK-side relation on model "${candidate.targetModelName}". Add @relation(from: [...], to: [...]) on the FK-side relation or use an explicit join model for many-to-many.`,
sourceId,
span: candidate.field.span,
});
continue;
}
if (matches.length > 1) {
diagnostics.push({
code: 'PSL_AMBIGUOUS_BACKRELATION',
message: `Backrelation list field "${candidate.modelName}.${candidate.fieldName}" matches multiple FK-side relations on model "${candidate.targetModelName}". Add @relation("...") to both sides to disambiguate.`,
sourceId,
span: candidate.field.span,
});
continue;
// `inverse:` pins a one-to-many back-relation to the FK-side relation whose
// declaring field it names, the directional disambiguator across multiple
// relations between the same pair of models. A relation field name is unique
// within its model, so at most one FK-side relation matches. When `inverse:`
// names a field that is not an FK-side relation back to the candidate, report
// it rather than letting recognition fall into the generic ambiguity path.
let fk: FkRelation | undefined;
if (candidate.inverse !== undefined) {
const inverseMatched = pairMatches.find((r) => r.fieldName === candidate.inverse);
if (!inverseMatched) {
diagnostics.push({
code: 'PSL_INVERSE_FIELD_NOT_FK',
message: `Backrelation list field "${candidate.modelName}.${candidate.fieldName}" pins FK-side relation field "${candidate.inverse}" via inverse: ${candidate.inverse}, but "${candidate.targetModelName}" has no relation field "${candidate.inverse}" with a foreign key back to "${candidate.modelName}". Name an FK-side relation field whose foreign key references "${candidate.modelName}".`,
sourceId,
span: candidate.field.span,
});
continue;
}
fk = inverseMatched;
} else {
if (pairMatches.length === 0) {
diagnostics.push({
code: 'PSL_ORPHANED_BACKRELATION',
message: `Backrelation list field "${candidate.modelName}.${candidate.fieldName}" has no matching FK-side relation on model "${candidate.targetModelName}". Add @relation(from: [...], to: [...]) on the FK-side relation or use an explicit join model for many-to-many.`,
sourceId,
span: candidate.field.span,
});
continue;
}
if (pairMatches.length > 1) {
diagnostics.push({
code: 'PSL_AMBIGUOUS_BACKRELATION',
message: `Backrelation list field "${candidate.modelName}.${candidate.fieldName}" matches multiple FK-side relations on model "${candidate.targetModelName}". Add inverse: <fkField> to the list field, naming the FK-side relation field it pairs with, to disambiguate.`,
sourceId,
span: candidate.field.span,
});
continue;
}
fk = pairMatches[0];
}

const fk = matches[0];
if (!fk) continue;
const modelEntry = models[candidate.modelName];
if (!modelEntry) continue;
Expand Down
Loading
Loading