diff --git a/packages/language/src/generated/ast.ts b/packages/language/src/generated/ast.ts index 7d6a589c..527868d4 100644 --- a/packages/language/src/generated/ast.ts +++ b/packages/language/src/generated/ast.ts @@ -144,7 +144,7 @@ export function isMemberAccessTarget(item: unknown): item is MemberAccessTarget return reflection.isInstance(item, MemberAccessTarget); } -export type ReferenceTarget = DataField | EnumField | FunctionParam; +export type ReferenceTarget = CollectionPredicateBinding | DataField | EnumField | FunctionParam; export const ReferenceTarget = 'ReferenceTarget'; @@ -258,6 +258,7 @@ export function isAttributeParamType(item: unknown): item is AttributeParamType export interface BinaryExpr extends langium.AstNode { readonly $container: Argument | ArrayExpr | AttributeArg | BinaryExpr | FieldInitializer | FunctionDecl | MemberAccessExpr | ReferenceArg | UnaryExpr; readonly $type: 'BinaryExpr'; + binding?: CollectionPredicateBinding; left: Expression; operator: '!' | '!=' | '&&' | '<' | '<=' | '==' | '>' | '>=' | '?' | '^' | 'in' | '||'; right: Expression; @@ -281,6 +282,18 @@ export function isBooleanLiteral(item: unknown): item is BooleanLiteral { return reflection.isInstance(item, BooleanLiteral); } +export interface CollectionPredicateBinding extends langium.AstNode { + readonly $container: BinaryExpr; + readonly $type: 'CollectionPredicateBinding'; + name: RegularID; +} + +export const CollectionPredicateBinding = 'CollectionPredicateBinding'; + +export function isCollectionPredicateBinding(item: unknown): item is CollectionPredicateBinding { + return reflection.isInstance(item, CollectionPredicateBinding); +} + export interface ConfigArrayExpr extends langium.AstNode { readonly $container: ConfigField; readonly $type: 'ConfigArrayExpr'; @@ -775,6 +788,7 @@ export type ZModelAstType = { AttributeParamType: AttributeParamType BinaryExpr: BinaryExpr BooleanLiteral: BooleanLiteral + CollectionPredicateBinding: CollectionPredicateBinding ConfigArrayExpr: ConfigArrayExpr ConfigExpr: ConfigExpr ConfigField: ConfigField @@ -822,7 +836,7 @@ export type ZModelAstType = { export class ZModelAstReflection extends langium.AbstractAstReflection { getAllTypes(): string[] { - return [AbstractDeclaration, Argument, ArrayExpr, Attribute, AttributeArg, AttributeParam, AttributeParamType, BinaryExpr, BooleanLiteral, ConfigArrayExpr, ConfigExpr, ConfigField, ConfigInvocationArg, ConfigInvocationExpr, DataField, DataFieldAttribute, DataFieldType, DataModel, DataModelAttribute, DataSource, Enum, EnumField, Expression, FieldInitializer, FunctionDecl, FunctionParam, FunctionParamType, GeneratorDecl, InternalAttribute, InvocationExpr, LiteralExpr, MemberAccessExpr, MemberAccessTarget, Model, ModelImport, NullExpr, NumberLiteral, ObjectExpr, Plugin, PluginField, Procedure, ProcedureParam, ReferenceArg, ReferenceExpr, ReferenceTarget, StringLiteral, ThisExpr, TypeDeclaration, TypeDef, UnaryExpr, UnsupportedFieldType]; + return [AbstractDeclaration, Argument, ArrayExpr, Attribute, AttributeArg, AttributeParam, AttributeParamType, BinaryExpr, BooleanLiteral, CollectionPredicateBinding, ConfigArrayExpr, ConfigExpr, ConfigField, ConfigInvocationArg, ConfigInvocationExpr, DataField, DataFieldAttribute, DataFieldType, DataModel, DataModelAttribute, DataSource, Enum, EnumField, Expression, FieldInitializer, FunctionDecl, FunctionParam, FunctionParamType, GeneratorDecl, InternalAttribute, InvocationExpr, LiteralExpr, MemberAccessExpr, MemberAccessTarget, Model, ModelImport, NullExpr, NumberLiteral, ObjectExpr, Plugin, PluginField, Procedure, ProcedureParam, ReferenceArg, ReferenceExpr, ReferenceTarget, StringLiteral, ThisExpr, TypeDeclaration, TypeDef, UnaryExpr, UnsupportedFieldType]; } protected override computeIsSubtype(subtype: string, supertype: string): boolean { @@ -850,6 +864,11 @@ export class ZModelAstReflection extends langium.AbstractAstReflection { case StringLiteral: { return this.isSubtype(LiteralExpr, supertype); } + case CollectionPredicateBinding: + case EnumField: + case FunctionParam: { + return this.isSubtype(ReferenceTarget, supertype); + } case ConfigArrayExpr: { return this.isSubtype(ConfigExpr, supertype); } @@ -861,10 +880,6 @@ export class ZModelAstReflection extends langium.AbstractAstReflection { case TypeDef: { return this.isSubtype(AbstractDeclaration, supertype) || this.isSubtype(TypeDeclaration, supertype); } - case EnumField: - case FunctionParam: { - return this.isSubtype(ReferenceTarget, supertype); - } case InvocationExpr: case LiteralExpr: { return this.isSubtype(ConfigExpr, supertype) || this.isSubtype(Expression, supertype); @@ -975,6 +990,7 @@ export class ZModelAstReflection extends langium.AbstractAstReflection { return { name: BinaryExpr, properties: [ + { name: 'binding' }, { name: 'left' }, { name: 'operator' }, { name: 'right' } @@ -989,6 +1005,14 @@ export class ZModelAstReflection extends langium.AbstractAstReflection { ] }; } + case CollectionPredicateBinding: { + return { + name: CollectionPredicateBinding, + properties: [ + { name: 'name' } + ] + }; + } case ConfigArrayExpr: { return { name: ConfigArrayExpr, diff --git a/packages/language/src/generated/grammar.ts b/packages/language/src/generated/grammar.ts index 53688b82..925d4dcb 100644 --- a/packages/language/src/generated/grammar.ts +++ b/packages/language/src/generated/grammar.ts @@ -70,7 +70,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@69" + "$ref": "#/rules@70" }, "arguments": [] } @@ -119,42 +119,42 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@37" + "$ref": "#/rules@38" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@42" + "$ref": "#/rules@43" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@44" + "$ref": "#/rules@45" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@47" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@53" + "$ref": "#/rules@54" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@50" + "$ref": "#/rules@51" }, "arguments": [] } @@ -176,7 +176,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@71" + "$ref": "#/rules@72" }, "arguments": [], "cardinality": "*" @@ -192,7 +192,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@51" + "$ref": "#/rules@52" }, "arguments": [] } @@ -236,7 +236,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@71" + "$ref": "#/rules@72" }, "arguments": [], "cardinality": "*" @@ -252,7 +252,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@51" + "$ref": "#/rules@52" }, "arguments": [] } @@ -296,7 +296,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@71" + "$ref": "#/rules@72" }, "arguments": [], "cardinality": "*" @@ -308,7 +308,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@51" + "$ref": "#/rules@52" }, "arguments": [] } @@ -347,7 +347,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@71" + "$ref": "#/rules@72" }, "arguments": [], "cardinality": "*" @@ -363,7 +363,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@51" + "$ref": "#/rules@52" }, "arguments": [] } @@ -407,7 +407,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@71" + "$ref": "#/rules@72" }, "arguments": [], "cardinality": "*" @@ -419,7 +419,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@51" + "$ref": "#/rules@52" }, "arguments": [] } @@ -474,7 +474,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "definition": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@33" + "$ref": "#/rules@34" }, "arguments": [] }, @@ -495,7 +495,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@70" + "$ref": "#/rules@71" }, "arguments": [] } @@ -517,7 +517,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@69" + "$ref": "#/rules@70" }, "arguments": [] } @@ -539,7 +539,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@63" + "$ref": "#/rules@64" }, "arguments": [] } @@ -663,7 +663,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@68" + "$ref": "#/rules@69" }, "arguments": [] } @@ -761,7 +761,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@68" + "$ref": "#/rules@69" }, "arguments": [] } @@ -970,7 +970,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@53" }, "arguments": [] }, @@ -1069,7 +1069,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@68" + "$ref": "#/rules@69" }, "arguments": [] } @@ -1183,14 +1183,14 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@51" + "$ref": "#/rules@52" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@69" + "$ref": "#/rules@70" }, "arguments": [] } @@ -1235,7 +1235,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/rules@46" + "$ref": "#/rules@47" }, "deprecatedSyntax": false } @@ -1247,7 +1247,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@35" + "$ref": "#/rules@36" }, "arguments": [], "cardinality": "?" @@ -1278,7 +1278,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@34" + "$ref": "#/rules@35" }, "arguments": [] }, @@ -1418,6 +1418,28 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "$type": "Keyword", "value": "[" }, + { + "$type": "Group", + "elements": [ + { + "$type": "Assignment", + "feature": "binding", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@30" + }, + "arguments": [] + } + }, + { + "$type": "Keyword", + "value": "," + } + ], + "cardinality": "?" + }, { "$type": "Assignment", "feature": "right", @@ -1446,6 +1468,28 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "parameters": [], "wildcard": false }, + { + "$type": "ParserRule", + "name": "CollectionPredicateBinding", + "definition": { + "$type": "Assignment", + "feature": "name", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@52" + }, + "arguments": [] + } + }, + "definesHiddenTokens": false, + "entry": false, + "fragment": false, + "hiddenTokens": [], + "parameters": [], + "wildcard": false + }, { "$type": "ParserRule", "name": "InExpr", @@ -1521,7 +1565,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@30" + "$ref": "#/rules@31" }, "arguments": [] }, @@ -1570,7 +1614,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@30" + "$ref": "#/rules@31" }, "arguments": [] } @@ -1600,7 +1644,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@31" + "$ref": "#/rules@32" }, "arguments": [] }, @@ -1641,7 +1685,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@31" + "$ref": "#/rules@32" }, "arguments": [] } @@ -1671,7 +1715,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@32" + "$ref": "#/rules@33" }, "arguments": [] }, @@ -1712,7 +1756,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@32" + "$ref": "#/rules@33" }, "arguments": [] } @@ -1838,7 +1882,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@36" + "$ref": "#/rules@37" }, "arguments": [] } @@ -1857,7 +1901,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@36" + "$ref": "#/rules@37" }, "arguments": [] } @@ -1908,7 +1952,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@71" + "$ref": "#/rules@72" }, "arguments": [] }, @@ -1931,7 +1975,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@51" + "$ref": "#/rules@52" }, "arguments": [] } @@ -1942,14 +1986,14 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@38" + "$ref": "#/rules@39" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@39" + "$ref": "#/rules@40" }, "arguments": [] }, @@ -1959,14 +2003,14 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@39" + "$ref": "#/rules@40" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@38" + "$ref": "#/rules@39" }, "arguments": [] } @@ -1978,14 +2022,14 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@38" + "$ref": "#/rules@39" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@39" + "$ref": "#/rules@40" }, "arguments": [] } @@ -2015,7 +2059,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@51" + "$ref": "#/rules@52" }, "arguments": [] } @@ -2038,7 +2082,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@40" + "$ref": "#/rules@41" }, "arguments": [] } @@ -2050,7 +2094,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@57" + "$ref": "#/rules@58" }, "arguments": [] } @@ -2089,7 +2133,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/rules@42" + "$ref": "#/rules@43" }, "deprecatedSyntax": false } @@ -2109,7 +2153,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/rules@42" + "$ref": "#/rules@43" }, "deprecatedSyntax": false } @@ -2143,7 +2187,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/rules@37" + "$ref": "#/rules@38" }, "deprecatedSyntax": false } @@ -2169,7 +2213,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@71" + "$ref": "#/rules@72" }, "arguments": [] }, @@ -2182,7 +2226,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@53" }, "arguments": [] } @@ -2194,7 +2238,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@41" + "$ref": "#/rules@42" }, "arguments": [] } @@ -2206,7 +2250,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@56" + "$ref": "#/rules@57" }, "arguments": [] }, @@ -2237,7 +2281,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@62" + "$ref": "#/rules@63" }, "arguments": [] } @@ -2249,7 +2293,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@43" + "$ref": "#/rules@44" }, "arguments": [] } @@ -2266,7 +2310,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@51" + "$ref": "#/rules@52" }, "arguments": [] }, @@ -2326,7 +2370,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@71" + "$ref": "#/rules@72" }, "arguments": [] }, @@ -2343,7 +2387,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@51" + "$ref": "#/rules@52" }, "arguments": [] } @@ -2351,7 +2395,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@38" + "$ref": "#/rules@39" }, "arguments": [], "cardinality": "?" @@ -2370,7 +2414,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@40" + "$ref": "#/rules@41" }, "arguments": [] } @@ -2382,7 +2426,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@57" + "$ref": "#/rules@58" }, "arguments": [] } @@ -2455,7 +2499,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@71" + "$ref": "#/rules@72" }, "arguments": [] }, @@ -2472,7 +2516,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@51" + "$ref": "#/rules@52" }, "arguments": [] } @@ -2491,7 +2535,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@45" + "$ref": "#/rules@46" }, "arguments": [] } @@ -2503,7 +2547,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@57" + "$ref": "#/rules@58" }, "arguments": [] } @@ -2537,7 +2581,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@71" + "$ref": "#/rules@72" }, "arguments": [] }, @@ -2550,7 +2594,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@53" }, "arguments": [] } @@ -2562,7 +2606,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@56" + "$ref": "#/rules@57" }, "arguments": [] }, @@ -2586,7 +2630,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@71" + "$ref": "#/rules@72" }, "arguments": [], "cardinality": "*" @@ -2602,7 +2646,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@51" + "$ref": "#/rules@52" }, "arguments": [] } @@ -2621,7 +2665,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@47" + "$ref": "#/rules@48" }, "arguments": [] } @@ -2640,7 +2684,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@47" + "$ref": "#/rules@48" }, "arguments": [] } @@ -2666,7 +2710,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@48" + "$ref": "#/rules@49" }, "arguments": [] } @@ -2699,7 +2743,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@58" + "$ref": "#/rules@59" }, "arguments": [] }, @@ -2723,7 +2767,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@71" + "$ref": "#/rules@72" }, "arguments": [], "cardinality": "*" @@ -2735,7 +2779,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@51" + "$ref": "#/rules@52" }, "arguments": [] } @@ -2751,7 +2795,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@48" + "$ref": "#/rules@49" }, "arguments": [] } @@ -2791,7 +2835,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@61" + "$ref": "#/rules@62" }, "arguments": [] } @@ -2808,7 +2852,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@51" + "$ref": "#/rules@52" }, "arguments": [] }, @@ -2854,7 +2898,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@71" + "$ref": "#/rules@72" }, "arguments": [], "cardinality": "*" @@ -2866,7 +2910,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@51" + "$ref": "#/rules@52" }, "arguments": [] } @@ -2882,7 +2926,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@48" + "$ref": "#/rules@49" }, "arguments": [] } @@ -2915,7 +2959,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@71" + "$ref": "#/rules@72" }, "arguments": [], "cardinality": "*" @@ -2941,7 +2985,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@51" + "$ref": "#/rules@52" }, "arguments": [] } @@ -2960,7 +3004,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@49" + "$ref": "#/rules@50" }, "arguments": [] } @@ -2979,7 +3023,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@49" + "$ref": "#/rules@50" }, "arguments": [] } @@ -3005,7 +3049,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@48" + "$ref": "#/rules@49" }, "arguments": [] } @@ -3017,7 +3061,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@58" + "$ref": "#/rules@59" }, "arguments": [] }, @@ -3042,7 +3086,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@68" + "$ref": "#/rules@69" }, "arguments": [] }, @@ -3105,7 +3149,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@51" + "$ref": "#/rules@52" }, "arguments": [] }, @@ -3187,7 +3231,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@71" + "$ref": "#/rules@72" }, "arguments": [] }, @@ -3207,21 +3251,21 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@65" + "$ref": "#/rules@66" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@67" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@67" + "$ref": "#/rules@68" }, "arguments": [] } @@ -3242,7 +3286,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@54" + "$ref": "#/rules@55" }, "arguments": [] } @@ -3261,7 +3305,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@54" + "$ref": "#/rules@55" }, "arguments": [] } @@ -3283,7 +3327,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@58" + "$ref": "#/rules@59" }, "arguments": [] }, @@ -3311,7 +3355,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@71" + "$ref": "#/rules@72" }, "arguments": [] }, @@ -3334,7 +3378,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@51" + "$ref": "#/rules@52" }, "arguments": [] } @@ -3350,7 +3394,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@56" }, "arguments": [] } @@ -3362,7 +3406,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@58" + "$ref": "#/rules@59" }, "arguments": [] }, @@ -3396,7 +3440,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@61" + "$ref": "#/rules@62" }, "arguments": [] }, @@ -3427,7 +3471,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@51" + "$ref": "#/rules@52" }, "arguments": [] }, @@ -3487,12 +3531,12 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/rules@53" + "$ref": "#/rules@54" }, "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@67" + "$ref": "#/rules@68" }, "arguments": [] }, @@ -3509,7 +3553,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@59" + "$ref": "#/rules@60" }, "arguments": [], "cardinality": "?" @@ -3539,7 +3583,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@71" + "$ref": "#/rules@72" }, "arguments": [], "cardinality": "*" @@ -3551,12 +3595,12 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/rules@53" + "$ref": "#/rules@54" }, "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@67" }, "arguments": [] }, @@ -3573,7 +3617,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@59" + "$ref": "#/rules@60" }, "arguments": [], "cardinality": "?" @@ -3607,12 +3651,12 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/rules@53" + "$ref": "#/rules@54" }, "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@65" + "$ref": "#/rules@66" }, "arguments": [] }, @@ -3629,7 +3673,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@59" + "$ref": "#/rules@60" }, "arguments": [], "cardinality": "?" @@ -3664,7 +3708,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@60" + "$ref": "#/rules@61" }, "arguments": [] } @@ -3683,7 +3727,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@60" + "$ref": "#/rules@61" }, "arguments": [] } @@ -3715,7 +3759,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@51" + "$ref": "#/rules@52" }, "arguments": [] } @@ -4011,19 +4055,25 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "SimpleType", "typeRef": { - "$ref": "#/rules@47" + "$ref": "#/rules@48" } }, { "$type": "SimpleType", "typeRef": { - "$ref": "#/rules@40" + "$ref": "#/rules@41" } }, { "$type": "SimpleType", "typeRef": { - "$ref": "#/rules@45" + "$ref": "#/rules@46" + } + }, + { + "$type": "SimpleType", + "typeRef": { + "$ref": "#/rules@30" } } ] @@ -4035,7 +4085,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "type": { "$type": "SimpleType", "typeRef": { - "$ref": "#/rules@40" + "$ref": "#/rules@41" } } }, @@ -4048,19 +4098,19 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "SimpleType", "typeRef": { - "$ref": "#/rules@37" + "$ref": "#/rules@38" } }, { "$type": "SimpleType", "typeRef": { - "$ref": "#/rules@42" + "$ref": "#/rules@43" } }, { "$type": "SimpleType", "typeRef": { - "$ref": "#/rules@44" + "$ref": "#/rules@45" } } ] diff --git a/packages/language/src/validators/attribute-application-validator.ts b/packages/language/src/validators/attribute-application-validator.ts index 79db61cc..28983e82 100644 --- a/packages/language/src/validators/attribute-application-validator.ts +++ b/packages/language/src/validators/attribute-application-validator.ts @@ -11,7 +11,6 @@ import { DataFieldAttribute, DataModelAttribute, InternalAttribute, - ReferenceExpr, isArrayExpr, isAttribute, isConfigArrayExpr, @@ -554,9 +553,16 @@ function isValidAttributeTarget(attrDecl: Attribute, targetDecl: DataField) { return true; } - const fieldTypes = (targetField.args[0].value as ArrayExpr).items.map( - (item) => (item as ReferenceExpr).target.ref?.name, - ); + const fieldTypes = (targetField.args[0].value as ArrayExpr).items + .map((item) => { + if (!isReferenceExpr(item)) { + return undefined; + } + + const ref = item.target.ref; + return ref && 'name' in ref && typeof ref.name === 'string' ? ref.name : undefined; + }) + .filter((name): name is string => !!name); let allowed = false; for (const allowedType of fieldTypes) { diff --git a/packages/language/src/validators/expression-validator.ts b/packages/language/src/validators/expression-validator.ts index c2848c14..3efe6d91 100644 --- a/packages/language/src/validators/expression-validator.ts +++ b/packages/language/src/validators/expression-validator.ts @@ -3,6 +3,7 @@ import { BinaryExpr, Expression, isArrayExpr, + isCollectionPredicateBinding, isDataModel, isDataModelAttribute, isEnum, @@ -12,6 +13,7 @@ import { isReferenceExpr, isThisExpr, MemberAccessExpr, + ReferenceExpr, UnaryExpr, type ExpressionType, } from '../generated/ast'; @@ -51,7 +53,7 @@ export default class ExpressionValidator implements AstValidator { } return false; }); - if (!hasReferenceResolutionError) { + if (hasReferenceResolutionError) { // report silent errors not involving linker errors accept('error', 'Expression cannot be resolved', { node: expr, @@ -62,6 +64,9 @@ export default class ExpressionValidator implements AstValidator { // extra validations by expression type switch (expr.$type) { + case 'ReferenceExpr': + this.validateReferenceExpr(expr, accept); + break; case 'MemberAccessExpr': this.validateMemberAccessExpr(expr, accept); break; @@ -74,6 +79,16 @@ export default class ExpressionValidator implements AstValidator { } } + private validateReferenceExpr(expr: ReferenceExpr, accept: ValidationAcceptor) { + // reference to collection predicate's binding can't be used standalone like: + // `items?[e, e]`, `items?[e, e != null]`, etc. + if (isCollectionPredicateBinding(expr.target.ref) && !isMemberAccessExpr(expr.$container)) { + accept('error', 'Collection predicate binding cannot be used without a member access', { + node: expr, + }); + } + } + private validateMemberAccessExpr(expr: MemberAccessExpr, accept: ValidationAcceptor) { if (isBeforeInvocation(expr.operand) && isDataModel(expr.$resolvedType?.decl)) { accept('error', 'relation fields cannot be accessed from `before()`', { node: expr }); diff --git a/packages/language/src/zmodel-code-generator.ts b/packages/language/src/zmodel-code-generator.ts index 55efb5fc..1e0366ed 100644 --- a/packages/language/src/zmodel-code-generator.ts +++ b/packages/language/src/zmodel-code-generator.ts @@ -252,13 +252,15 @@ ${ast.fields.map((x) => this.indent + this.generate(x)).join('\n')}${ const { left: isLeftParenthesis, right: isRightParenthesis } = this.isParenthesesNeededForBinaryExpr(ast); + const collectionPredicate = isCollectionPredicate + ? `[${ast.binding ? `${ast.binding}, ${rightExpr}` : rightExpr}]` + : rightExpr; + return `${isLeftParenthesis ? '(' : ''}${this.generate(ast.left)}${ isLeftParenthesis ? ')' : '' }${isCollectionPredicate ? '' : this.binaryExprSpace}${operator}${ isCollectionPredicate ? '' : this.binaryExprSpace - }${isRightParenthesis ? '(' : ''}${ - isCollectionPredicate ? `[${rightExpr}]` : rightExpr - }${isRightParenthesis ? ')' : ''}`; + }${isRightParenthesis ? '(' : ''}${collectionPredicate}${isRightParenthesis ? ')' : ''}`; } @gen(ReferenceExpr) diff --git a/packages/language/src/zmodel-linker.ts b/packages/language/src/zmodel-linker.ts index 3bb45134..fc3a7f0d 100644 --- a/packages/language/src/zmodel-linker.ts +++ b/packages/language/src/zmodel-linker.ts @@ -24,10 +24,8 @@ import { DataFieldType, DataModel, Enum, - EnumField, type ExpressionType, FunctionDecl, - FunctionParam, FunctionParamType, InvocationExpr, LiteralExpr, @@ -43,10 +41,13 @@ import { UnaryExpr, isArrayExpr, isBooleanLiteral, + isCollectionPredicateBinding, isDataField, isDataFieldType, isDataModel, isEnum, + isEnumField, + isFunctionParam, isNumberLiteral, isReferenceExpr, isStringLiteral, @@ -249,13 +250,21 @@ export class ZModelLinker extends DefaultLinker { private resolveReference(node: ReferenceExpr, document: LangiumDocument, extraScopes: ScopeProvider[]) { this.resolveDefault(node, document, extraScopes); - - if (node.target.ref) { - // resolve type - if (node.target.ref.$type === EnumField) { - this.resolveToBuiltinTypeOrDecl(node, node.target.ref.$container); - } else { - this.resolveToDeclaredType(node, (node.target.ref as DataField | FunctionParam).type); + const target = node.target.ref; + + if (target) { + if (isCollectionPredicateBinding(target)) { + // collection predicate's binding is resolved to the element type of the collection + const collectionType = target.$container.left.$resolvedType; + if (collectionType) { + node.$resolvedType = { ...collectionType, array: false }; + } + } else if (isEnumField(target)) { + // enum field is resolved to its containing enum + this.resolveToBuiltinTypeOrDecl(node, target.$container); + } else if (isDataField(target) || isFunctionParam(target)) { + // other references are resolved to their declared type + this.resolveToDeclaredType(node, target.type); } } } @@ -506,6 +515,9 @@ export class ZModelLinker extends DefaultLinker { //#region Utils private resolveToDeclaredType(node: AstNode, type: FunctionParamType | DataFieldType) { + if (!type) { + return; + } let nullable = false; if (isDataFieldType(type)) { nullable = type.optional; diff --git a/packages/language/src/zmodel-scope.ts b/packages/language/src/zmodel-scope.ts index 6fd866f0..30b77e29 100644 --- a/packages/language/src/zmodel-scope.ts +++ b/packages/language/src/zmodel-scope.ts @@ -18,7 +18,9 @@ import { import { match } from 'ts-pattern'; import { BinaryExpr, + Expression, MemberAccessExpr, + isCollectionPredicateBinding, isDataField, isDataModel, isEnumField, @@ -124,7 +126,7 @@ export class ZModelScopeProvider extends DefaultScopeProvider { // when reference expression is resolved inside a collection predicate, the scope is the collection const containerCollectionPredicate = getCollectionPredicateContext(context.container); if (containerCollectionPredicate) { - return this.getCollectionPredicateScope(context, containerCollectionPredicate as BinaryExpr); + return this.getCollectionPredicateScope(context, containerCollectionPredicate); } } @@ -145,10 +147,20 @@ export class ZModelScopeProvider extends DefaultScopeProvider { .when(isReferenceExpr, (operand) => { // operand is a reference, it can only be a model/type-def field const ref = operand.target.ref; - if (isDataField(ref)) { - return this.createScopeForContainer(ref.type.reference?.ref, globalScope, allowTypeDefScope); - } - return EMPTY_SCOPE; + return match(ref) + .when(isDataField, (r) => + // build a scope with model/typedef members + this.createScopeForContainer(r.type.reference?.ref, globalScope, allowTypeDefScope), + ) + .when(isCollectionPredicateBinding, (r) => + // build a scope from the collection predicate's collection + this.createScopeForCollectionPredicateCollection( + r.$container.left, + globalScope, + allowTypeDefScope, + ), + ) + .otherwise(() => EMPTY_SCOPE); }) .when(isMemberAccessExpr, (operand) => { // operand is a member access, it must be resolved to a non-array model/typedef type @@ -179,15 +191,44 @@ export class ZModelScopeProvider extends DefaultScopeProvider { .otherwise(() => EMPTY_SCOPE); } - private getCollectionPredicateScope(context: ReferenceInfo, collectionPredicate: BinaryExpr) { - const referenceType = this.reflection.getReferenceType(context); - const globalScope = this.getGlobalScope(referenceType, context); + private getCollectionPredicateScope(context: ReferenceInfo, collectionPredicate: BinaryExpr): Scope { + // walk up to collect all collection predicate bindings, which are all available in the scope + let currPredicate: BinaryExpr | undefined = collectionPredicate; + const bindingStack: AstNode[] = []; + while (currPredicate) { + if (currPredicate.binding) { + bindingStack.unshift(currPredicate.binding); + } + currPredicate = AstUtils.getContainerOfType(currPredicate.$container, isCollectionPredicate); + } + + // build a scope chain: global scope -> bindings' scope -> collection scope + const globalScope = this.getGlobalScope(this.reflection.getReferenceType(context), context); + const parentScope = bindingStack.reduce( + (scope, binding) => this.createScopeForNodes([binding], scope), + globalScope, + ); + const collection = collectionPredicate.left; // TODO: full support of typedef member access - // // typedef's fields are only added to the scope if the access starts with `auth().` + // typedef's fields are only added to the scope if the access starts with `auth().` const allowTypeDefScope = isAuthOrAuthMemberAccess(collection); + const collectionScope = this.createScopeForCollectionPredicateCollection( + collection, + parentScope, + allowTypeDefScope, + ); + + return collectionScope; + } + + private createScopeForCollectionPredicateCollection( + collection: Expression, + globalScope: Scope, + allowTypeDefScope: boolean, + ) { return match(collection) .when(isReferenceExpr, (expr) => { // collection is a reference - model or typedef field diff --git a/packages/language/src/zmodel.langium b/packages/language/src/zmodel.langium index 9f09dbf8..e7c33ad2 100644 --- a/packages/language/src/zmodel.langium +++ b/packages/language/src/zmodel.langium @@ -66,7 +66,7 @@ ConfigArrayExpr: ConfigExpr: LiteralExpr | InvocationExpr | ConfigArrayExpr; -type ReferenceTarget = FunctionParam | DataField | EnumField; +type ReferenceTarget = FunctionParam | DataField | EnumField | CollectionPredicateBinding; ThisExpr: value='this'; @@ -109,13 +109,17 @@ UnaryExpr: // binary operator precedence follow Javascript's rules: // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Operator_Precedence#table +// TODO: promote CollectionPredicateExpr to a first-class expression type CollectionPredicateExpr infers Expression: MemberAccessExpr ( {infer BinaryExpr.left=current} operator=('?'|'!'|'^') - '[' right=Expression ']' + '[' (binding=CollectionPredicateBinding ',')? right=Expression ']' )*; +CollectionPredicateBinding: + name=RegularID; + InExpr infers Expression: CollectionPredicateExpr ( {infer BinaryExpr.left=current} diff --git a/packages/language/test/expression-validation.test.ts b/packages/language/test/expression-validation.test.ts index 100f02b2..40bad771 100644 --- a/packages/language/test/expression-validation.test.ts +++ b/packages/language/test/expression-validation.test.ts @@ -2,9 +2,10 @@ import { describe, it } from 'vitest'; import { loadSchema, loadSchemaWithError } from './utils'; describe('Expression Validation Tests', () => { - it('should reject model comparison1', async () => { - await loadSchemaWithError( - ` + describe('Model Comparison Tests', () => { + it('should reject model comparison1', async () => { + await loadSchemaWithError( + ` model User { id Int @id name String @@ -19,13 +20,13 @@ describe('Expression Validation Tests', () => { @@allow('all', author == this) } `, - 'comparison between models is not supported', - ); - }); + 'comparison between models is not supported', + ); + }); - it('should reject model comparison2', async () => { - await loadSchemaWithError( - ` + it('should reject model comparison2', async () => { + await loadSchemaWithError( + ` model User { id Int @id name String @@ -48,13 +49,13 @@ describe('Expression Validation Tests', () => { userId Int @unique } `, - 'comparison between models is not supported', - ); - }); + 'comparison between models is not supported', + ); + }); - it('should allow auth comparison with auth type', async () => { - await loadSchema( - ` + it('should allow auth comparison with auth type', async () => { + await loadSchema( + ` datasource db { provider = 'sqlite' url = 'file:./dev.db' @@ -75,12 +76,12 @@ describe('Expression Validation Tests', () => { @@allow('read', auth() == user) } `, - ); - }); + ); + }); - it('should reject auth comparison with non-auth type', async () => { - await loadSchemaWithError( - ` + it('should reject auth comparison with non-auth type', async () => { + await loadSchemaWithError( + ` model User { id Int @id name String @@ -95,7 +96,265 @@ describe('Expression Validation Tests', () => { @@allow('read', auth() == this) } `, - 'incompatible operand types', - ); + 'incompatible operand types', + ); + }); + }); + + describe('Collection Predicate Tests', () => { + it('should reject standalone binding access', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id Int @id + memberships Membership[] + @@allow('read', memberships?[m, m != null]) + } + + model Membership { + id Int @id + tenantId Int + user User @relation(fields: [userId], references: [id]) + userId Int + } + `, + 'binding cannot be used without a member access', + ); + }); + + it('should allow referencing binding', async () => { + await loadSchema(` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id Int @id + memberships Membership[] + @@allow('read', memberships?[m, m.tenantId == id]) + } + + model Membership { + id Int @id + tenantId Int + user User @relation(fields: [userId], references: [id]) + userId Int + } + `); + }); + + it('should keep supporting unbound collection predicate syntax', async () => { + await loadSchema(` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id Int @id + memberships Membership[] + @@allow('read', memberships?[tenantId == id]) + } + + model Membership { + id Int @id + tenantId Int + user User @relation(fields: [userId], references: [id]) + userId Int + } + `); + }); + + it('should support mixing bound and unbound syntax', async () => { + await loadSchema(` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id Int @id + memberships Membership[] + @@allow('read', memberships?[m, m.tenantId == id && tenantId == id]) + } + + model Membership { + id Int @id + tenantId Int + user User @relation(fields: [userId], references: [id]) + userId Int + } + `); + }); + + it('should allow disambiguation with this', async () => { + await loadSchema(` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id Int @id + memberships Membership[] + value Int + @@allow('read', memberships?[m, m.value == this.value]) + } + + model Membership { + id Int @id + tenantId Int + user User @relation(fields: [userId], references: [id]) + userId Int + value Int + } + `); + + await loadSchemaWithError( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id Int @id + memberships Membership[] + value String + @@allow('read', memberships?[m, m.value == this.value]) + } + + model Membership { + id Int @id + tenantId Int + user User @relation(fields: [userId], references: [id]) + userId Int + value Int + } + `, + 'incompatible operand types', + ); + + await loadSchemaWithError( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id Int @id + memberships Membership[] + value String + @@allow('read', memberships?[value == this.value]) + } + + model Membership { + id Int @id + tenantId Int + user User @relation(fields: [userId], references: [id]) + userId Int + value Int + } + `, + 'incompatible operand types', + ); + }); + + it('should support accessing binding from deep context', async () => { + await loadSchema(` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id Int @id + memberships Membership[] + @@allow('read', memberships?[m, roles?[value == m.value]]) + } + + model Membership { + id Int @id + tenantId Int + user User @relation(fields: [userId], references: [id]) + userId Int + value Int + roles Role[] + } + + model Role { + id Int @id + membership Membership @relation(fields: [membershipId], references: [id]) + membershipId Int + value Int + } + `); + + await loadSchema(` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id Int @id + memberships Membership[] + @@allow('read', memberships?[m, roles?[r, r.value == m.value]]) + } + + model Membership { + id Int @id + tenantId Int + user User @relation(fields: [userId], references: [id]) + userId Int + value Int + roles Role[] + } + + model Role { + id Int @id + membership Membership @relation(fields: [membershipId], references: [id]) + membershipId Int + value Int + } + `); + + await loadSchema(` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id Int @id + memberships Membership[] + x Int + @@allow('read', memberships?[m, roles?[this.x == m.value]]) + } + + model Membership { + id Int @id + tenantId Int + user User @relation(fields: [userId], references: [id]) + userId Int + value Int + roles Role[] + } + + model Role { + id Int @id + membership Membership @relation(fields: [membershipId], references: [id]) + membershipId Int + value Int + } + `); + }); }); }); diff --git a/packages/orm/src/client/crud/validator/utils.ts b/packages/orm/src/client/crud/validator/utils.ts index bbd35900..c9909a70 100644 --- a/packages/orm/src/client/crud/validator/utils.ts +++ b/packages/orm/src/client/crud/validator/utils.ts @@ -310,6 +310,9 @@ function evalExpression(data: any, expr: Expression): unknown { .with({ kind: 'call' }, (e) => evalCall(data, e)) .with({ kind: 'this' }, () => data ?? null) .with({ kind: 'null' }, () => null) + .with({ kind: 'binding' }, () => { + throw new Error('Binding expression is not supported in validation expressions'); + }) .exhaustive(); } diff --git a/packages/orm/src/utils/schema-utils.ts b/packages/orm/src/utils/schema-utils.ts index b928b078..17fb3149 100644 --- a/packages/orm/src/utils/schema-utils.ts +++ b/packages/orm/src/utils/schema-utils.ts @@ -2,6 +2,7 @@ import { match } from 'ts-pattern'; import type { ArrayExpression, BinaryExpression, + BindingExpression, CallExpression, Expression, FieldExpression, @@ -24,6 +25,7 @@ export class ExpressionVisitor { .with({ kind: 'binary' }, (e) => this.visitBinary(e)) .with({ kind: 'unary' }, (e) => this.visitUnary(e)) .with({ kind: 'call' }, (e) => this.visitCall(e)) + .with({ kind: 'binding' }, (e) => this.visitBinding(e)) .with({ kind: 'this' }, (e) => this.visitThis(e)) .with({ kind: 'null' }, (e) => this.visitNull(e)) .exhaustive(); @@ -68,6 +70,8 @@ export class ExpressionVisitor { } } + protected visitBinding(_e: BindingExpression): VisitResult {} + protected visitThis(_e: ThisExpression): VisitResult {} protected visitNull(_e: NullExpression): VisitResult {} diff --git a/packages/plugins/policy/src/expression-evaluator.ts b/packages/plugins/policy/src/expression-evaluator.ts index 45c7b855..85e97a03 100644 --- a/packages/plugins/policy/src/expression-evaluator.ts +++ b/packages/plugins/policy/src/expression-evaluator.ts @@ -1,9 +1,9 @@ import { invariant } from '@zenstackhq/common-helpers'; -import { match } from 'ts-pattern'; import { ExpressionUtils, type ArrayExpression, type BinaryExpression, + type BindingExpression, type CallExpression, type Expression, type FieldExpression, @@ -11,10 +11,13 @@ import { type MemberExpression, type UnaryExpression, } from '@zenstackhq/orm/schema'; +import { match } from 'ts-pattern'; type ExpressionEvaluatorContext = { auth?: any; thisValue?: any; + // scope for resolving references to collection predicate bindings + bindingScope?: Record; }; /** @@ -30,6 +33,7 @@ export class ExpressionEvaluator { .when(ExpressionUtils.isMember, (expr) => this.evaluateMember(expr, context)) .when(ExpressionUtils.isUnary, (expr) => this.evaluateUnary(expr, context)) .when(ExpressionUtils.isCall, (expr) => this.evaluateCall(expr, context)) + .when(ExpressionUtils.isBinding, (expr) => this.evaluateBinding(expr, context)) .when(ExpressionUtils.isThis, () => context.thisValue) .when(ExpressionUtils.isNull, () => null) .exhaustive(); @@ -64,6 +68,9 @@ export class ExpressionEvaluator { } private evaluateField(expr: FieldExpression, context: ExpressionEvaluatorContext): any { + if (context.bindingScope && expr.field in context.bindingScope) { + return context.bindingScope[expr.field]; + } return context.thisValue?.[expr.field]; } @@ -113,8 +120,28 @@ export class ExpressionEvaluator { invariant(Array.isArray(left), 'expected array'); return match(op) - .with('?', () => left.some((item: any) => this.evaluate(expr.right, { ...context, thisValue: item }))) - .with('!', () => left.every((item: any) => this.evaluate(expr.right, { ...context, thisValue: item }))) + .with('?', () => + left.some((item: any) => + this.evaluate(expr.right, { + ...context, + thisValue: item, + bindingScope: expr.binding + ? { ...(context.bindingScope ?? {}), [expr.binding]: item } + : context.bindingScope, + }), + ), + ) + .with('!', () => + left.every((item: any) => + this.evaluate(expr.right, { + ...context, + thisValue: item, + bindingScope: expr.binding + ? { ...(context.bindingScope ?? {}), [expr.binding]: item } + : context.bindingScope, + }), + ), + ) .with( '^', () => @@ -122,9 +149,19 @@ export class ExpressionEvaluator { this.evaluate(expr.right, { ...context, thisValue: item, + bindingScope: expr.binding + ? { ...(context.bindingScope ?? {}), [expr.binding]: item } + : context.bindingScope, }), ), ) .exhaustive(); } + + private evaluateBinding(expr: BindingExpression, context: ExpressionEvaluatorContext): any { + if (!context.bindingScope || !(expr.name in context.bindingScope)) { + throw new Error(`Unresolved binding: ${expr.name}`); + } + return context.bindingScope[expr.name]; + } } diff --git a/packages/plugins/policy/src/expression-transformer.ts b/packages/plugins/policy/src/expression-transformer.ts index 18320c69..7977ccb2 100644 --- a/packages/plugins/policy/src/expression-transformer.ts +++ b/packages/plugins/policy/src/expression-transformer.ts @@ -11,6 +11,7 @@ import { import type { BinaryExpression, BinaryOperator, + BindingExpression, BuiltinType, FieldDef, GetModels, @@ -59,6 +60,8 @@ import { trueNode, } from './utils'; +type BindingScope = Record; + /** * Context for transforming a policy expression */ @@ -93,6 +96,11 @@ export type ExpressionTransformerContext = { */ contextValue?: Record; + /** + * Additional named collection predicate bindings available during transformation + */ + bindingScope?: BindingScope; + /** * The model or type name that `this` keyword refers to */ @@ -311,7 +319,11 @@ export class ExpressionTransformer { // LHS of the expression is evaluated as a value const evaluator = new ExpressionEvaluator(); - const receiver = evaluator.evaluate(expr.left, { thisValue: context.contextValue, auth: this.auth }); + const receiver = evaluator.evaluate(expr.left, { + thisValue: context.contextValue, + auth: this.auth, + bindingScope: this.getEvaluationBindingScope(context.bindingScope), + }); // get LHS's type const baseType = this.isAuthMember(expr.left) ? this.authType : context.modelOrType; @@ -335,21 +347,38 @@ export class ExpressionTransformer { newContextModel = fieldDef.type; } else { invariant( - ExpressionUtils.isMember(expr.left) && ExpressionUtils.isField(expr.left.receiver), + ExpressionUtils.isMember(expr.left) && + (ExpressionUtils.isField(expr.left.receiver) || ExpressionUtils.isBinding(expr.left.receiver)), 'left operand must be member access with field receiver', ); - const fieldDef = QueryUtils.requireField(this.schema, context.modelOrType, expr.left.receiver.field); - newContextModel = fieldDef.type; + if (ExpressionUtils.isField(expr.left.receiver)) { + // collection is a field access, context model is the field's type + const fieldDef = QueryUtils.requireField(this.schema, context.modelOrType, expr.left.receiver.field); + newContextModel = fieldDef.type; + } else { + // collection is a binding reference, get type from binding scope + const binding = this.requireBindingScope(expr.left.receiver, context); + newContextModel = binding.type; + } + for (const member of expr.left.members) { const memberDef = QueryUtils.requireField(this.schema, newContextModel, member); newContextModel = memberDef.type; } } + const bindingScope = expr.binding + ? { + ...(context.bindingScope ?? {}), + [expr.binding]: { type: newContextModel, alias: newContextModel }, + } + : context.bindingScope; + let predicateFilter = this.transform(expr.right, { ...context, modelOrType: newContextModel, alias: undefined, + bindingScope: bindingScope, }); if (expr.op === '!') { @@ -392,6 +421,7 @@ export class ExpressionTransformer { const value = new ExpressionEvaluator().evaluate(expr, { auth: this.auth, thisValue: context.contextValue, + bindingScope: this.getEvaluationBindingScope(context.bindingScope), }); return this.transformValue(value, 'Boolean'); } else { @@ -403,15 +433,27 @@ export class ExpressionTransformer { // e.g.: `auth().profiles[age == this.age]`, each `auth().profiles` element (which is a value) // is used to build an expression for the RHS `age == this.age` // the transformation happens recursively for nested collection predicates - const components = receiver.map((item) => - this.transform(expr.right, { + const components = receiver.map((item) => { + const bindingScope = expr.binding + ? { + ...(context.bindingScope ?? {}), + [expr.binding]: { + type: context.modelOrType, + alias: context.thisAlias ?? context.modelOrType, + value: item, + }, + } + : context.bindingScope; + + return this.transform(expr.right, { operation: context.operation, thisType: context.thisType, thisAlias: context.thisAlias, modelOrType: context.modelOrType, contextValue: item, - }), - ); + bindingScope: bindingScope, + }); + }); // compose the components based on the operator return ( @@ -601,6 +643,14 @@ export class ExpressionTransformer { @expr('member') // @ts-ignore private _member(expr: MemberExpression, context: ExpressionTransformerContext) { + if (ExpressionUtils.isBinding(expr.receiver)) { + // if the binding has a plain value in the scope, evaluate directly + const scope = this.requireBindingScope(expr.receiver, context); + if (scope.value !== undefined) { + return this.valueMemberAccess(scope.value, expr, scope.type); + } + } + // `auth()` member access if (this.isAuthCall(expr.receiver)) { return this.valueMemberAccess(this.auth, expr, this.authType); @@ -616,12 +666,15 @@ export class ExpressionTransformer { } invariant( - ExpressionUtils.isField(expr.receiver) || ExpressionUtils.isThis(expr.receiver), - 'expect receiver to be field expression or "this"', + ExpressionUtils.isField(expr.receiver) || + ExpressionUtils.isThis(expr.receiver) || + ExpressionUtils.isBinding(expr.receiver), + 'expect receiver to be field expression, collection predicate binding, or "this"', ); let members = expr.members; let receiver: OperationNode; + let startType: string | undefined; const { memberFilter, memberSelect, ...restContext } = context; if (ExpressionUtils.isThis(expr.receiver)) { @@ -639,6 +692,32 @@ export class ExpressionTransformer { const firstMemberFieldDef = QueryUtils.requireField(this.schema, context.thisType, expr.members[0]!); receiver = this.transformRelationAccess(expr.members[0]!, firstMemberFieldDef.type, restContext); members = expr.members.slice(1); + // startType should be the type of the relation access + startType = firstMemberFieldDef.type; + } + } else if (ExpressionUtils.isBinding(expr.receiver)) { + if (expr.members.length === 1) { + const bindingScope = this.requireBindingScope(expr.receiver, context); + // `binding.relation` case, equivalent to field access + return this._field(ExpressionUtils.field(expr.members[0]!), { + ...context, + modelOrType: bindingScope.type, + alias: bindingScope.alias, + thisType: context.thisType, + contextValue: undefined, + }); + } else { + // transform the first segment into a relation access, then continue with the rest of the members + const bindingScope = this.requireBindingScope(expr.receiver, context); + const firstMemberFieldDef = QueryUtils.requireField(this.schema, bindingScope.type, expr.members[0]!); + receiver = this.transformRelationAccess(expr.members[0]!, firstMemberFieldDef.type, { + ...restContext, + modelOrType: bindingScope.type, + alias: bindingScope.alias, + }); + members = expr.members.slice(1); + // startType should be the type of the relation access + startType = firstMemberFieldDef.type; } } else { receiver = this.transform(expr.receiver, restContext); @@ -646,13 +725,14 @@ export class ExpressionTransformer { invariant(SelectQueryNode.is(receiver), 'expected receiver to be select query'); - let startType: string; - if (ExpressionUtils.isField(expr.receiver)) { - const receiverField = QueryUtils.requireField(this.schema, context.modelOrType, expr.receiver.field); - startType = receiverField.type; - } else { - // "this." case - startType = context.thisType; + if (startType === undefined) { + if (ExpressionUtils.isField(expr.receiver)) { + const receiverField = QueryUtils.requireField(this.schema, context.modelOrType, expr.receiver.field); + startType = receiverField.type; + } else { + // "this." case - already handled above if members were sliced + startType = context.thisType; + } } // traverse forward to collect member types @@ -706,6 +786,12 @@ export class ExpressionTransformer { }; } + private requireBindingScope(expr: BindingExpression, context: ExpressionTransformerContext) { + const binding = context.bindingScope?.[expr.name]; + invariant(binding, `binding not found: ${expr.name}`); + return binding; + } + private valueMemberAccess(receiver: any, expr: MemberExpression, receiverType: string) { if (!receiver) { return ValueNode.createImmediate(null); @@ -834,6 +920,22 @@ export class ExpressionTransformer { return this.buildDelegateBaseFieldSelect(context.modelOrType, tableName, column, fieldDef.originModel); } + // convert transformer's binding scope to equivalent expression evaluator binding scope + private getEvaluationBindingScope(scope?: BindingScope) { + if (!scope) { + return undefined; + } + + const result: Record = {}; + for (const [key, value] of Object.entries(scope)) { + if (value.value !== undefined) { + result[key] = value.value; + } + } + + return Object.keys(result).length > 0 ? result : undefined; + } + private buildDelegateBaseFieldSelect(model: string, modelAlias: string, field: string, baseModel: string) { const idFields = QueryUtils.requireIdFields(this.client.$schema, model); return { diff --git a/packages/schema/src/expression-utils.ts b/packages/schema/src/expression-utils.ts index ee48aecc..07ee1c11 100644 --- a/packages/schema/src/expression-utils.ts +++ b/packages/schema/src/expression-utils.ts @@ -2,6 +2,7 @@ import type { ArrayExpression, BinaryExpression, BinaryOperator, + BindingExpression, CallExpression, Expression, FieldExpression, @@ -39,12 +40,13 @@ export const ExpressionUtils = { }; }, - binary: (left: Expression, op: BinaryOperator, right: Expression): BinaryExpression => { + binary: (left: Expression, op: BinaryOperator, right: Expression, binding?: string): BinaryExpression => { return { kind: 'binary', op, left, right, + binding, }; }, @@ -71,6 +73,13 @@ export const ExpressionUtils = { }; }, + binding: (name: string): BindingExpression => { + return { + kind: 'binding', + name, + }; + }, + _this: (): ThisExpression => { return { kind: 'this', @@ -117,6 +126,8 @@ export const ExpressionUtils = { isMember: (value: unknown): value is MemberExpression => ExpressionUtils.is(value, 'member'), + isBinding: (value: unknown): value is BindingExpression => ExpressionUtils.is(value, 'binding'), + getLiteralValue: (expr: Expression): string | number | boolean | undefined => { return ExpressionUtils.isLiteral(expr) ? expr.value : undefined; }, diff --git a/packages/schema/src/expression.ts b/packages/schema/src/expression.ts index 3ce3c2d1..1828b9cc 100644 --- a/packages/schema/src/expression.ts +++ b/packages/schema/src/expression.ts @@ -6,6 +6,7 @@ export type Expression = | CallExpression | UnaryExpression | BinaryExpression + | BindingExpression | ThisExpression | NullExpression; @@ -30,6 +31,11 @@ export type MemberExpression = { members: string[]; }; +export type BindingExpression = { + kind: 'binding'; + name: string; +}; + export type UnaryExpression = { kind: 'unary'; op: UnaryOperator; @@ -41,6 +47,7 @@ export type BinaryExpression = { op: BinaryOperator; left: Expression; right: Expression; + binding?: string; }; export type CallExpression = { diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index 6baeff42..4a5e0a54 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -13,6 +13,7 @@ import { InvocationExpr, isArrayExpr, isBinaryExpr, + isCollectionPredicateBinding, isDataField, isDataModel, isDataSource, @@ -1257,11 +1258,17 @@ export class TsSchemaGenerator { } private createBinaryExpression(expr: BinaryExpr) { - return this.createExpressionUtilsCall('binary', [ + const args = [ this.createExpression(expr.left), this.createLiteralNode(expr.operator), this.createExpression(expr.right), - ]); + ]; + + if (expr.binding) { + args.push(this.createLiteralNode(expr.binding.name)); + } + + return this.createExpressionUtilsCall('binary', args); } private createUnaryExpression(expr: UnaryExpr) { @@ -1278,13 +1285,18 @@ export class TsSchemaGenerator { } private createRefExpression(expr: ReferenceExpr): any { - if (isDataField(expr.target.ref)) { - return this.createExpressionUtilsCall('field', [this.createLiteralNode(expr.target.$refText)]); - } else if (isEnumField(expr.target.ref)) { - return this.createLiteralExpression('StringLiteral', expr.target.$refText); - } else { - throw new Error(`Unsupported reference type: ${expr.target.$refText}`); - } + const target = expr.target.ref; + return match(target) + .when(isDataField, () => + this.createExpressionUtilsCall('field', [this.createLiteralNode(expr.target.$refText)]), + ) + .when(isEnumField, () => this.createLiteralExpression('StringLiteral', expr.target.$refText)) + .when(isCollectionPredicateBinding, () => + this.createExpressionUtilsCall('binding', [this.createLiteralNode(expr.target.$refText)]), + ) + .otherwise(() => { + throw Error(`Unsupported reference type: ${expr.target.$refText}`); + }); } private createCallExpression(expr: InvocationExpr) { diff --git a/tests/e2e/orm/policy/auth-access.test.ts b/tests/e2e/orm/policy/auth-access.test.ts index b994324f..56942de4 100644 --- a/tests/e2e/orm/policy/auth-access.test.ts +++ b/tests/e2e/orm/policy/auth-access.test.ts @@ -130,6 +130,48 @@ model Foo { await expect(db.$setAuth({ profiles: [{ age: 15 }, { age: 20 }] }).foo.findFirst()).toResolveTruthy(); }); + it('uses iterator binding inside collection predicate for auth model', async () => { + const db = await createPolicyTestClient( + ` +model User { + id Int @id + tenantId Int + memberships Membership[] @relation("UserMemberships") +} + +model Membership { + id Int @id + tenantId Int + userId Int + user User @relation("UserMemberships", fields: [userId], references: [id]) +} + +model Foo { + id Int @id + tenantId Int + @@allow('read', auth().memberships?[m, m.tenantId == auth().tenantId]) +} +`, + ); + + await db.$unuseAll().foo.createMany({ + data: [ + { id: 1, tenantId: 1 }, + { id: 2, tenantId: 2 }, + ], + }); + + // allowed because iterator binding matches tenantId = 1 + await expect( + db.$setAuth({ tenantId: 1, memberships: [{ id: 10, tenantId: 1 }] }).foo.findMany(), + ).toResolveWithLength(2); + + // denied because membership tenantId doesn't match + await expect( + db.$setAuth({ tenantId: 1, memberships: [{ id: 20, tenantId: 3 }] }).foo.findMany(), + ).toResolveWithLength(0); + }); + it('works with shallow auth model collection predicates involving fields - some', async () => { const db = await createPolicyTestClient( ` diff --git a/tests/e2e/orm/policy/collection-predicate.test.ts b/tests/e2e/orm/policy/collection-predicate.test.ts new file mode 100644 index 00000000..09e22228 --- /dev/null +++ b/tests/e2e/orm/policy/collection-predicate.test.ts @@ -0,0 +1,447 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +describe('Collection Predicate Tests', () => { + it('should support collection predicates without binding', async () => { + const db = await createPolicyTestClient( + ` + model User { + id Int @id + memberships Membership[] + @@allow('create', true) + @@allow('read', memberships?[tenantId == id]) + } + + model Membership { + id Int @id + tenantId Int + user User @relation(fields: [userId], references: [id]) + userId Int + @@allow('all', true) + } +`, + ); + await db.$unuseAll().user.create({ + data: { id: 1, memberships: { create: [{ id: 1, tenantId: 1 }] } }, + }); + await db.$unuseAll().user.create({ + data: { id: 2, memberships: { create: [{ id: 2, tenantId: 1 }] } }, + }); + await expect(db.user.findUnique({ where: { id: 1 } })).toResolveTruthy(); + await expect(db.user.findUnique({ where: { id: 2 } })).toResolveNull(); + }); + + it('should support referencing binding', async () => { + const db = await createPolicyTestClient( + ` + model User { + id Int @id + memberships Membership[] + @@allow('create', true) + @@allow('read', memberships?[m, m.tenantId == id]) + } + + model Membership { + id Int @id + tenantId Int + user User @relation(fields: [userId], references: [id]) + userId Int + @@allow('all', true) + } +`, + ); + await db.$unuseAll().user.create({ + data: { id: 1, memberships: { create: [{ id: 1, tenantId: 1 }] } }, + }); + await db.$unuseAll().user.create({ + data: { id: 2, memberships: { create: [{ id: 2, tenantId: 1 }] } }, + }); + await expect(db.user.findUnique({ where: { id: 1 } })).toResolveTruthy(); + await expect(db.user.findUnique({ where: { id: 2 } })).toResolveNull(); + }); + + it('should support mixing bound and unbound syntax', async () => { + const db = await createPolicyTestClient( + ` + model User { + id Int @id + memberships Membership[] + @@allow('create', true) + @@allow('read', memberships?[m, m.tenantId == id && tenantId == id]) + } + + model Membership { + id Int @id + tenantId Int + user User @relation(fields: [userId], references: [id]) + userId Int + @@allow('all', true) + } +`, + ); + await db.$unuseAll().user.create({ + data: { id: 1, memberships: { create: [{ id: 1, tenantId: 1 }] } }, + }); + await db.$unuseAll().user.create({ + data: { id: 2, memberships: { create: [{ id: 2, tenantId: 1 }] } }, + }); + await expect(db.user.findUnique({ where: { id: 1 } })).toResolveTruthy(); + await expect(db.user.findUnique({ where: { id: 2 } })).toResolveNull(); + }); + + it('should allow disambiguation with this', async () => { + const db = await createPolicyTestClient( + ` + model User { + id Int @id + memberships Membership[] + tenantId Int + @@allow('create', true) + @@allow('read', memberships?[m, m.tenantId == this.tenantId]) + } + + model Membership { + id Int @id + tenantId Int + user User @relation(fields: [userId], references: [id]) + userId Int + @@allow('all', true) + } +`, + ); + await db.$unuseAll().user.create({ + data: { id: 1, tenantId: 1, memberships: { create: [{ id: 1, tenantId: 1 }] } }, + }); + await db.$unuseAll().user.create({ + data: { id: 2, tenantId: 2, memberships: { create: [{ id: 2, tenantId: 1 }] } }, + }); + await expect(db.user.findUnique({ where: { id: 1 } })).toResolveTruthy(); + await expect(db.user.findUnique({ where: { id: 2 } })).toResolveNull(); + }); + + it('should support accessing binding from deep context - case 1', async () => { + const db = await createPolicyTestClient( + ` + model User { + id Int @id + memberships Membership[] + @@allow('create', true) + @@allow('read', memberships?[m, roles?[tenantId == m.tenantId]]) + } + + model Membership { + id Int @id + user User @relation(fields: [userId], references: [id]) + userId Int + tenantId Int + roles Role[] + @@allow('all', true) + } + + model Role { + id Int @id + membership Membership @relation(fields: [membershipId], references: [id]) + membershipId Int + tenantId Int + @@allow('all', true) + } +`, + ); + await db.$unuseAll().user.create({ + data: { + id: 1, + memberships: { create: [{ id: 1, tenantId: 1, roles: { create: { id: 1, tenantId: 1 } } }] }, + }, + }); + await db.$unuseAll().user.create({ + data: { + id: 2, + memberships: { create: [{ id: 2, tenantId: 2, roles: { create: { id: 2, tenantId: 1 } } }] }, + }, + }); + await expect(db.user.findUnique({ where: { id: 1 } })).toResolveTruthy(); + await expect(db.user.findUnique({ where: { id: 2 } })).toResolveNull(); + }); + + it('should support accessing binding from deep context - case 2', async () => { + const db = await createPolicyTestClient( + ` + model User { + id Int @id + memberships Membership[] + tenantId Int + @@allow('create', true) + @@allow('read', memberships?[m, roles?[this.tenantId == m.tenantId]]) + } + + model Membership { + id Int @id + user User @relation(fields: [userId], references: [id]) + userId Int + tenantId Int + roles Role[] + @@allow('all', true) + } + + model Role { + id Int @id + membership Membership @relation(fields: [membershipId], references: [id]) + membershipId Int + @@allow('all', true) + } +`, + ); + await db.$unuseAll().user.create({ + data: { + id: 1, + tenantId: 1, + memberships: { create: [{ id: 1, tenantId: 1, roles: { create: { id: 1 } } }] }, + }, + }); + await db.$unuseAll().user.create({ + data: { + id: 2, + tenantId: 2, + memberships: { create: [{ id: 2, tenantId: 1, roles: { create: { id: 2 } } }] }, + }, + }); + await expect(db.user.findUnique({ where: { id: 1 } })).toResolveTruthy(); + await expect(db.user.findUnique({ where: { id: 2 } })).toResolveNull(); + }); + + it('should support accessing to-one relation from binding', async () => { + const db = await createPolicyTestClient( + ` + model User { + id Int @id + memberships Membership[] + tenants Tenant[] + @@allow('create', true) + @@allow('read', memberships?[m, m.tenant.ownerId == id]) + } + + model Tenant { + id Int @id + ownerId Int + owner User @relation(fields: [ownerId], references: [id]) + memberships Membership[] + @@allow('all', true) + } + + model Membership { + id Int @id + tenant Tenant @relation(fields: [tenantId], references: [id]) + tenantId Int + user User @relation(fields: [userId], references: [id]) + userId Int + @@allow('all', true) + } +`, + ); + await db.$unuseAll().user.create({ + data: { + id: 1, + memberships: { + create: [{ id: 1, tenant: { create: { id: 1, ownerId: 1 } } }], + }, + }, + }); + await db.$unuseAll().user.create({ + data: { + id: 2, + memberships: { + create: [{ id: 2, tenant: { create: { id: 2, ownerId: 1 } } }], + }, + }, + }); + await expect(db.user.findUnique({ where: { id: 1 } })).toResolveTruthy(); + await expect(db.user.findUnique({ where: { id: 2 } })).toResolveNull(); + }); + + it('should support multiple bindings in nested predicates', async () => { + const db = await createPolicyTestClient( + ` + model User { + id Int @id + memberships Membership[] + @@allow('create', true) + @@allow('read', memberships?[m, m.roles?[r, r.tenantId == m.tenantId]]) + } + + model Membership { + id Int @id + tenantId Int + user User @relation(fields: [userId], references: [id]) + userId Int + roles Role[] + @@allow('all', true) + } + + model Role { + id Int @id + tenantId Int + membership Membership @relation(fields: [membershipId], references: [id]) + membershipId Int + @@allow('all', true) + } +`, + ); + await db.$unuseAll().user.create({ + data: { + id: 1, + memberships: { + create: [{ id: 1, tenantId: 1, roles: { create: { id: 1, tenantId: 1 } } }], + }, + }, + }); + await db.$unuseAll().user.create({ + data: { + id: 2, + memberships: { + create: [{ id: 2, tenantId: 2, roles: { create: { id: 2, tenantId: 1 } } }], + }, + }, + }); + await expect(db.user.findUnique({ where: { id: 1 } })).toResolveTruthy(); + await expect(db.user.findUnique({ where: { id: 2 } })).toResolveNull(); + }); + + it('should work with inner binding masking outer binding names', async () => { + const db = await createPolicyTestClient( + ` + model User { + id Int @id + memberships Membership[] + tenantId Int + @@allow('create', true) + @@allow('read', memberships?[m, m.roles?[m, m.tenantId == this.tenantId]]) + } + + model Membership { + id Int @id + user User @relation(fields: [userId], references: [id]) + userId Int + roles Role[] + @@allow('all', true) + } + + model Role { + id Int @id + tenantId Int + membership Membership @relation(fields: [membershipId], references: [id]) + membershipId Int + @@allow('all', true) + } +`, + ); + await db.$unuseAll().user.create({ + data: { + id: 1, + tenantId: 1, + memberships: { create: [{ id: 1, roles: { create: { id: 1, tenantId: 1 } } }] }, + }, + }); + await db.$unuseAll().user.create({ + data: { + id: 2, + tenantId: 2, + memberships: { create: [{ id: 2, roles: { create: { id: 2, tenantId: 1 } } }] }, + }, + }); + await expect(db.user.findUnique({ where: { id: 1 } })).toResolveTruthy(); + await expect(db.user.findUnique({ where: { id: 2 } })).toResolveNull(); + }); + + it('should work with bindings with auth collection predicates', async () => { + const db = await createPolicyTestClient( + ` + model User { + id Int @id + companies Company[] + test Int + + @@allow('read', auth().companies?[c, c.staff?[s, s.companyId == this.test]]) + } + + model Company { + id Int @id + user User @relation(fields: [userId], references: [id]) + userId Int + + staff Staff[] + @@allow('read', true) + } + + model Staff { + id Int @id + + company Company @relation(fields: [companyId], references: [id]) + companyId Int + + @@allow('read', true) + } + `, + ); + await db.$unuseAll().user.create({ + data: { + id: 1, + test: 1, + companies: { create: { id: 1, staff: { create: { id: 1 } } } }, + }, + }); + + await expect( + db + .$setAuth({ id: 1, companies: [{ id: 1, staff: [{ id: 1, companyId: 1 }] }], test: 1 }) + .user.findUnique({ where: { id: 1 } }), + ).toResolveTruthy(); + }); + + it('should work with bindings with auth collection predicates - pure value', async () => { + const db = await createPolicyTestClient( + ` + model User { + id Int @id + companies Company[] + + @@allow('read', auth().companies?[c, c.staff?[s, s.companyId == c.id]]) + } + + model Company { + id Int @id + user User @relation(fields: [userId], references: [id]) + userId Int + + staff Staff[] + @@allow('read', true) + } + + model Staff { + id Int @id + + company Company @relation(fields: [companyId], references: [id]) + companyId Int + + @@allow('read', true) + } + `, + ); + await db.$unuseAll().user.create({ + data: { + id: 1, + companies: { create: { id: 1, staff: { create: { id: 1 } } } }, + }, + }); + + await expect( + db + .$setAuth({ id: 1, companies: [{ id: 1, staff: [{ id: 1, companyId: 1 }] }] }) + .user.findUnique({ where: { id: 1 } }), + ).toResolveTruthy(); + await expect( + db + .$setAuth({ id: 1, companies: [{ id: 1, staff: [{ id: 1, companyId: 2 }] }] }) + .user.findUnique({ where: { id: 1 } }), + ).toResolveNull(); + }); +});