diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 511bd9326..28481aecf 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -8,14 +8,12 @@ env: DO_NOT_TRACK: '1' on: - push: - branches: ['dev', 'main', 'canary'] pull_request: branches: ['dev', 'main', 'canary'] jobs: build-test: - runs-on: ubuntu-latest + runs-on: buildjet-4vcpu-ubuntu-2204 strategy: matrix: diff --git a/package.json b/package.json index f6b3dd555..99d47e57b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "1.0.0-alpha.85", + "version": "1.0.0-alpha.87", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/language/package.json b/packages/language/package.json index 095a4de9f..aa64b9810 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/language", - "version": "1.0.0-alpha.85", + "version": "1.0.0-alpha.87", "displayName": "ZenStack modeling language compiler", "description": "ZenStack modeling language compiler", "homepage": "https://zenstack.dev", diff --git a/packages/language/src/ast.ts b/packages/language/src/ast.ts index b58e2c737..b9888eb9d 100644 --- a/packages/language/src/ast.ts +++ b/packages/language/src/ast.ts @@ -14,6 +14,7 @@ export type ResolvedShape = ExpressionType | AbstractDeclaration; export type ResolvedType = { decl?: ResolvedShape; array?: boolean; + nullable?: boolean; }; export const BinaryExprOperatorPriority: Record = { @@ -28,10 +29,11 @@ export const BinaryExprOperatorPriority: Record '<': 3, '>=': 3, '<=': 3, + in: 4, //CollectionPredicateExpr - '^': 4, - '?': 4, - '!': 4, + '^': 5, + '?': 5, + '!': 5, }; declare module './generated/ast' { diff --git a/packages/language/src/generated/ast.ts b/packages/language/src/generated/ast.ts index 94c3c9977..8e00e671e 100644 --- a/packages/language/src/generated/ast.ts +++ b/packages/language/src/generated/ast.ts @@ -150,7 +150,7 @@ export interface BinaryExpr extends AstNode { readonly $container: Argument | ArrayExpr | AttributeArg | BinaryExpr | DataSourceField | FieldInitializer | FunctionDecl | GeneratorField | MemberAccessExpr | PluginField | UnaryExpr; readonly $type: 'BinaryExpr'; left: Expression - operator: '!' | '!=' | '&&' | '<' | '<=' | '==' | '>' | '>=' | '?' | '^' | '||' + operator: '!' | '!=' | '&&' | '<' | '<=' | '==' | '>' | '>=' | '?' | '^' | 'in' | '||' right: Expression } @@ -318,6 +318,7 @@ export interface FunctionParam extends AstNode { readonly $container: DataModel | Enum | FunctionDecl; readonly $type: 'FunctionParam'; name: string + optional: boolean type: FunctionParamType } @@ -752,6 +753,14 @@ export class ZModelAstReflection extends AbstractAstReflection { ] }; } + case 'FunctionParam': { + return { + name: 'FunctionParam', + mandatory: [ + { name: 'optional', type: 'boolean' } + ] + }; + } case 'FunctionParamType': { return { name: 'FunctionParamType', diff --git a/packages/language/src/generated/grammar.ts b/packages/language/src/generated/grammar.ts index 3c36a9435..14d2c86d4 100644 --- a/packages/language/src/generated/grammar.ts +++ b/packages/language/src/generated/grammar.ts @@ -64,28 +64,28 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@28" + "$ref": "#/rules@29" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@31" + "$ref": "#/rules@32" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@33" + "$ref": "#/rules@34" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@41" + "$ref": "#/rules@42" }, "arguments": [] } @@ -107,7 +107,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@58" + "$ref": "#/rules@59" }, "arguments": [], "cardinality": "*" @@ -123,7 +123,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@56" }, "arguments": [] } @@ -167,7 +167,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@58" + "$ref": "#/rules@59" }, "arguments": [], "cardinality": "*" @@ -179,7 +179,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@56" }, "arguments": [] } @@ -237,7 +237,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@58" + "$ref": "#/rules@59" }, "arguments": [], "cardinality": "*" @@ -253,7 +253,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@56" }, "arguments": [] } @@ -297,7 +297,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@58" + "$ref": "#/rules@59" }, "arguments": [], "cardinality": "*" @@ -309,7 +309,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@56" }, "arguments": [] } @@ -360,7 +360,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@58" + "$ref": "#/rules@59" }, "arguments": [], "cardinality": "*" @@ -376,7 +376,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@56" }, "arguments": [] } @@ -420,7 +420,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@58" + "$ref": "#/rules@59" }, "arguments": [], "cardinality": "*" @@ -432,7 +432,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@56" }, "arguments": [] } @@ -480,7 +480,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "definition": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@22" + "$ref": "#/rules@23" }, "arguments": [] }, @@ -504,21 +504,21 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@53" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@57" + "$ref": "#/rules@58" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@56" + "$ref": "#/rules@57" }, "arguments": [] } @@ -605,7 +605,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@54" + "$ref": "#/rules@55" }, "arguments": [] } @@ -627,7 +627,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@53" + "$ref": "#/rules@54" }, "arguments": [] } @@ -657,7 +657,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@56" }, "arguments": [] }, @@ -802,7 +802,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/rules@33" + "$ref": "#/rules@34" }, "deprecatedSyntax": false } @@ -814,7 +814,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@26" + "$ref": "#/rules@27" }, "arguments": [], "cardinality": "?" @@ -881,7 +881,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@23" + "$ref": "#/rules@24" }, "arguments": [] }, @@ -911,7 +911,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/rules@29" + "$ref": "#/rules@30" }, "deprecatedSyntax": false } @@ -1015,7 +1015,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel }, { "$type": "ParserRule", - "name": "ComparisonExpr", + "name": "InExpr", "inferredType": { "$type": "InferredType", "name": "Expression" @@ -1030,6 +1030,68 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel }, "arguments": [] }, + { + "$type": "Group", + "elements": [ + { + "$type": "Action", + "inferredType": { + "$type": "InferredType", + "name": "BinaryExpr" + }, + "feature": "left", + "operator": "=" + }, + { + "$type": "Assignment", + "feature": "operator", + "operator": "=", + "terminal": { + "$type": "Keyword", + "value": "in" + } + }, + { + "$type": "Assignment", + "feature": "right", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@19" + }, + "arguments": [] + } + } + ], + "cardinality": "*" + } + ] + }, + "definesHiddenTokens": false, + "entry": false, + "fragment": false, + "hiddenTokens": [], + "parameters": [], + "wildcard": false + }, + { + "$type": "ParserRule", + "name": "ComparisonExpr", + "inferredType": { + "$type": "InferredType", + "name": "Expression" + }, + "definition": { + "$type": "Group", + "elements": [ + { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@20" + }, + "arguments": [] + }, { "$type": "Group", "elements": [ @@ -1075,7 +1137,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@19" + "$ref": "#/rules@20" }, "arguments": [] } @@ -1105,7 +1167,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@20" + "$ref": "#/rules@21" }, "arguments": [] }, @@ -1146,7 +1208,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@20" + "$ref": "#/rules@21" }, "arguments": [] } @@ -1176,7 +1238,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@21" + "$ref": "#/rules@22" }, "arguments": [] }, @@ -1217,7 +1279,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@21" + "$ref": "#/rules@22" }, "arguments": [] } @@ -1316,7 +1378,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@24" + "$ref": "#/rules@25" }, "arguments": [] } @@ -1349,7 +1411,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@25" + "$ref": "#/rules@26" }, "arguments": [] } @@ -1368,7 +1430,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@25" + "$ref": "#/rules@26" }, "arguments": [] } @@ -1410,7 +1472,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@56" }, "arguments": [] } @@ -1454,7 +1516,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@27" + "$ref": "#/rules@28" }, "arguments": [] } @@ -1473,7 +1535,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@27" + "$ref": "#/rules@28" }, "arguments": [] } @@ -1505,7 +1567,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@56" }, "arguments": [] } @@ -1551,7 +1613,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@58" + "$ref": "#/rules@59" }, "arguments": [] }, @@ -1568,7 +1630,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@56" }, "arguments": [] } @@ -1587,7 +1649,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@29" + "$ref": "#/rules@30" }, "arguments": [] } @@ -1599,7 +1661,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@45" + "$ref": "#/rules@46" }, "arguments": [] } @@ -1633,7 +1695,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@58" + "$ref": "#/rules@59" }, "arguments": [] }, @@ -1646,7 +1708,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@56" }, "arguments": [] } @@ -1658,7 +1720,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@30" + "$ref": "#/rules@31" }, "arguments": [] } @@ -1670,7 +1732,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@44" + "$ref": "#/rules@45" }, "arguments": [] }, @@ -1701,7 +1763,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@50" + "$ref": "#/rules@51" }, "arguments": [] } @@ -1718,7 +1780,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@56" }, "arguments": [] }, @@ -1778,7 +1840,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@58" + "$ref": "#/rules@59" }, "arguments": [] }, @@ -1795,7 +1857,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@56" }, "arguments": [] } @@ -1814,7 +1876,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@32" + "$ref": "#/rules@33" }, "arguments": [] } @@ -1826,7 +1888,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@45" + "$ref": "#/rules@46" }, "arguments": [] } @@ -1860,7 +1922,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@58" + "$ref": "#/rules@59" }, "arguments": [] }, @@ -1873,7 +1935,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@56" }, "arguments": [] } @@ -1885,7 +1947,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@44" + "$ref": "#/rules@45" }, "arguments": [] }, @@ -1909,7 +1971,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@58" + "$ref": "#/rules@59" }, "arguments": [], "cardinality": "*" @@ -1925,7 +1987,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@56" }, "arguments": [] } @@ -1944,7 +2006,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@34" + "$ref": "#/rules@35" }, "arguments": [] } @@ -1963,7 +2025,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@34" + "$ref": "#/rules@35" }, "arguments": [] } @@ -1989,7 +2051,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@35" + "$ref": "#/rules@36" }, "arguments": [] } @@ -2033,7 +2095,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@58" + "$ref": "#/rules@59" }, "arguments": [], "cardinality": "*" @@ -2045,7 +2107,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@56" }, "arguments": [] } @@ -2061,10 +2123,20 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@35" + "$ref": "#/rules@36" }, "arguments": [] } + }, + { + "$type": "Assignment", + "feature": "optional", + "operator": "?=", + "terminal": { + "$type": "Keyword", + "value": "?" + }, + "cardinality": "?" } ] }, @@ -2091,7 +2163,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@49" + "$ref": "#/rules@50" }, "arguments": [] } @@ -2148,7 +2220,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@56" }, "arguments": [] }, @@ -2165,14 +2237,14 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@56" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@50" + "$ref": "#/rules@51" }, "arguments": [] } @@ -2204,7 +2276,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@36" + "$ref": "#/rules@37" }, "arguments": [] } @@ -2231,7 +2303,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@36" + "$ref": "#/rules@37" }, "arguments": [] } @@ -2258,7 +2330,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@36" + "$ref": "#/rules@37" }, "arguments": [] } @@ -2281,21 +2353,21 @@ 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": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@37" + "$ref": "#/rules@38" }, "arguments": [] } @@ -2317,7 +2389,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@58" + "$ref": "#/rules@59" }, "arguments": [], "cardinality": "*" @@ -2333,7 +2405,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@40" + "$ref": "#/rules@41" }, "arguments": [] } @@ -2352,7 +2424,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@42" + "$ref": "#/rules@43" }, "arguments": [] } @@ -2371,7 +2443,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@42" + "$ref": "#/rules@43" }, "arguments": [] } @@ -2393,7 +2465,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@47" }, "arguments": [] }, @@ -2417,7 +2489,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@58" + "$ref": "#/rules@59" }, "arguments": [], "cardinality": "*" @@ -2439,7 +2511,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@56" }, "arguments": [] } @@ -2455,7 +2527,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@43" + "$ref": "#/rules@44" }, "arguments": [] } @@ -2488,7 +2560,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@49" + "$ref": "#/rules@50" }, "arguments": [] }, @@ -2519,7 +2591,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@56" }, "arguments": [] }, @@ -2579,12 +2651,12 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/rules@41" + "$ref": "#/rules@42" }, "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@39" + "$ref": "#/rules@40" }, "arguments": [] }, @@ -2601,7 +2673,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@47" + "$ref": "#/rules@48" }, "arguments": [], "cardinality": "?" @@ -2631,7 +2703,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@58" + "$ref": "#/rules@59" }, "arguments": [], "cardinality": "*" @@ -2643,12 +2715,12 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/rules@41" + "$ref": "#/rules@42" }, "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@38" + "$ref": "#/rules@39" }, "arguments": [] }, @@ -2665,7 +2737,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@47" + "$ref": "#/rules@48" }, "arguments": [], "cardinality": "?" @@ -2699,12 +2771,12 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/rules@41" + "$ref": "#/rules@42" }, "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@37" + "$ref": "#/rules@38" }, "arguments": [] }, @@ -2721,7 +2793,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@47" + "$ref": "#/rules@48" }, "arguments": [], "cardinality": "?" @@ -2756,7 +2828,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@48" + "$ref": "#/rules@49" }, "arguments": [] } @@ -2775,7 +2847,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@48" + "$ref": "#/rules@49" }, "arguments": [] } @@ -2807,7 +2879,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@56" }, "arguments": [] } @@ -3065,19 +3137,19 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "SimpleType", "typeRef": { - "$ref": "#/rules@34" + "$ref": "#/rules@35" } }, { "$type": "SimpleType", "typeRef": { - "$ref": "#/rules@29" + "$ref": "#/rules@30" } }, { "$type": "SimpleType", "typeRef": { - "$ref": "#/rules@32" + "$ref": "#/rules@33" } } ] @@ -3092,13 +3164,13 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "SimpleType", "typeRef": { - "$ref": "#/rules@28" + "$ref": "#/rules@29" } }, { "$type": "SimpleType", "typeRef": { - "$ref": "#/rules@31" + "$ref": "#/rules@32" } } ] diff --git a/packages/language/src/zmodel.langium b/packages/language/src/zmodel.langium index 95da83dbd..40566e697 100644 --- a/packages/language/src/zmodel.langium +++ b/packages/language/src/zmodel.langium @@ -94,13 +94,20 @@ CollectionPredicateExpr infers Expression: // right=MultDivExpr // )*; -ComparisonExpr infers Expression: +InExpr infers Expression: CollectionPredicateExpr ( {infer BinaryExpr.left=current} - operator=('>'|'<'|'>='|'<=') + operator=('in') right=CollectionPredicateExpr )*; +ComparisonExpr infers Expression: + InExpr ( + {infer BinaryExpr.left=current} + operator=('>'|'<'|'>='|'<=') + right=InExpr + )*; + EqualityExpr infers Expression: ComparisonExpr ( {infer BinaryExpr.left=current} @@ -174,7 +181,7 @@ FunctionDecl: TRIPLE_SLASH_COMMENT* 'function' name=ID '(' (params+=FunctionParam (',' params+=FunctionParam)*)? ')' ':' returnType=FunctionParamType '{' (expression=Expression)? '}'; FunctionParam: - TRIPLE_SLASH_COMMENT* name=ID ':' type=FunctionParamType; + TRIPLE_SLASH_COMMENT* name=ID ':' type=FunctionParamType (optional?='?')?; FunctionParamType: (type=ExpressionType | reference=[TypeDeclaration]) (array?='[' ']')?; diff --git a/packages/language/syntaxes/zmodel.tmLanguage.json b/packages/language/syntaxes/zmodel.tmLanguage.json index ea60d5523..a98fc252e 100644 --- a/packages/language/syntaxes/zmodel.tmLanguage.json +++ b/packages/language/syntaxes/zmodel.tmLanguage.json @@ -10,7 +10,7 @@ }, { "name": "keyword.control.zmodel", - "match": "\\b(Any|Asc|BigInt|Boolean|Bytes|ContextType|DateTime|Decimal|Desc|FieldReference|Float|Int|Json|Null|Object|String|TransitiveFieldReference|attribute|datasource|enum|function|generator|model|plugin|sort)\\b" + "match": "\\b(Any|Asc|BigInt|Boolean|Bytes|ContextType|DateTime|Decimal|Desc|FieldReference|Float|Int|Json|Null|Object|String|TransitiveFieldReference|attribute|datasource|enum|function|generator|in|model|plugin|sort)\\b" }, { "name": "string.quoted.double.zmodel", diff --git a/packages/next/package.json b/packages/next/package.json index 5ec18fe8b..5481df120 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/next", - "version": "1.0.0-alpha.85", + "version": "1.0.0-alpha.87", "displayName": "ZenStack Next.js integration", "description": "ZenStack Next.js integration", "homepage": "https://zenstack.dev", diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index fd6a2ed2b..c9d696b18 100644 --- a/packages/plugins/openapi/package.json +++ b/packages/plugins/openapi/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/openapi", "displayName": "ZenStack Plugin and Runtime for OpenAPI", - "version": "1.0.0-alpha.85", + "version": "1.0.0-alpha.87", "description": "ZenStack plugin and runtime supporting OpenAPI", "main": "index.js", "repository": { diff --git a/packages/plugins/react/package.json b/packages/plugins/react/package.json index 631f16fcc..21670fdcd 100644 --- a/packages/plugins/react/package.json +++ b/packages/plugins/react/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/react", "displayName": "ZenStack plugin and runtime for ReactJS", - "version": "1.0.0-alpha.85", + "version": "1.0.0-alpha.87", "description": "ZenStack plugin and runtime for ReactJS", "main": "index.js", "repository": { diff --git a/packages/plugins/trpc/package.json b/packages/plugins/trpc/package.json index 43c5b7f33..50a05903c 100644 --- a/packages/plugins/trpc/package.json +++ b/packages/plugins/trpc/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/trpc", "displayName": "ZenStack plugin for tRPC", - "version": "1.0.0-alpha.85", + "version": "1.0.0-alpha.87", "description": "ZenStack plugin for tRPC", "main": "index.js", "repository": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 1686bcce9..770391323 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "1.0.0-alpha.85", + "version": "1.0.0-alpha.87", "description": "Runtime of ZenStack for both client-side and server-side environments.", "repository": { "type": "git", diff --git a/packages/runtime/src/validation.ts b/packages/runtime/src/validation.ts index ed0ddbfb7..33115f8e9 100644 --- a/packages/runtime/src/validation.ts +++ b/packages/runtime/src/validation.ts @@ -18,3 +18,17 @@ export function validate(validator: z.ZodType, data: unknown) { throw new ValidationError(fromZodError(err as z.ZodError).message); } } + +/** + * Check if the given object has all the given fields, not null or undefined + * @param obj + * @param fields + * @returns + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function hasAllFields(obj: any, fields: string[]) { + if (typeof obj !== 'object' || !obj) { + return false; + } + return fields.every((f) => obj[f] !== undefined && obj[f] !== null); +} diff --git a/packages/schema/package.json b/packages/schema/package.json index c027acc4f..f54bd8a8d 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack Language Tools", "description": "A toolkit for building secure CRUD apps with Next.js + Typescript", - "version": "1.0.0-alpha.85", + "version": "1.0.0-alpha.87", "author": { "name": "ZenStack Team" }, diff --git a/packages/schema/src/language-server/constants.ts b/packages/schema/src/language-server/constants.ts index 448a2fae1..9c62f2882 100644 --- a/packages/schema/src/language-server/constants.ts +++ b/packages/schema/src/language-server/constants.ts @@ -18,6 +18,23 @@ export const STD_LIB_MODULE_NAME = 'stdlib.zmodel'; */ export const PLUGIN_MODULE_NAME = 'plugin.zmodel'; +/** + * Validation issues + */ export enum IssueCodes { MissingOppositeRelation = 'miss-opposite-relation', } + +/** + * Filter operation function names (mapped to Prisma filter operators) + */ +export const FILTER_OPERATOR_FUNCTIONS = [ + 'contains', + 'search', + 'startsWith', + 'endsWith', + 'has', + 'hasEvery', + 'hasSome', + 'isEmpty', +]; diff --git a/packages/schema/src/language-server/validator/datamodel-validator.ts b/packages/schema/src/language-server/validator/datamodel-validator.ts index 0d8b5eeff..1468311c8 100644 --- a/packages/schema/src/language-server/validator/datamodel-validator.ts +++ b/packages/schema/src/language-server/validator/datamodel-validator.ts @@ -256,15 +256,18 @@ export default class DataModelValidator implements AstValidator { relationOwner = field; } } else { - [field, oppositeField].forEach((f) => { - if (!this.isSelfRelation(f, thisRelation.name)) { - accept( - 'error', - 'Field for one side of relation must carry @relation attribute with both "fields" and "references" fields', - { node: f } - ); - } - }); + // if both the field is array, then it's an implicit many-to-many relation + if (!(field.type.array && oppositeField.type.array)) { + [field, oppositeField].forEach((f) => { + if (!this.isSelfRelation(f, thisRelation.name)) { + accept( + 'error', + 'Field for one side of relation must carry @relation attribute with both "fields" and "references" fields', + { node: f } + ); + } + }); + } return; } diff --git a/packages/schema/src/language-server/validator/expression-validator.ts b/packages/schema/src/language-server/validator/expression-validator.ts index 2f9ca1cf2..64cdec539 100644 --- a/packages/schema/src/language-server/validator/expression-validator.ts +++ b/packages/schema/src/language-server/validator/expression-validator.ts @@ -1,6 +1,6 @@ -import { Expression, isBinaryExpr } from '@zenstackhq/language/ast'; +import { BinaryExpr, Expression, isArrayExpr, isBinaryExpr, isEnum, isLiteralExpr } from '@zenstackhq/language/ast'; import { ValidationAcceptor } from 'langium'; -import { isAuthInvocation } from '../../utils/ast-utils'; +import { getDataModelFieldReference, isAuthInvocation, isEnumFieldReference } from '../../utils/ast-utils'; import { AstValidator } from '../types'; /** @@ -8,6 +8,7 @@ import { AstValidator } from '../types'; */ export default class ExpressionValidator implements AstValidator { validate(expr: Expression, accept: ValidationAcceptor): void { + // deal with a few cases where reference resolution fail silently if (!expr.$resolvedType) { if (isAuthInvocation(expr)) { // check was done at link time @@ -20,6 +21,39 @@ export default class ExpressionValidator implements AstValidator { }); } } + + // extra validations by expression type + switch (expr.$type) { + case 'BinaryExpr': + this.validateBinaryExpr(expr, accept); + break; + } + } + + private validateBinaryExpr(expr: BinaryExpr, accept: ValidationAcceptor) { + switch (expr.operator) { + case 'in': { + if (!getDataModelFieldReference(expr.left)) { + accept('error', 'left operand of "in" must be a field reference', { node: expr.left }); + } + + if (typeof expr.left.$resolvedType?.decl !== 'string' && !isEnum(expr.left.$resolvedType?.decl)) { + accept('error', 'left operand of "in" must be of scalar type', { node: expr.left }); + } + + if ( + !( + isArrayExpr(expr.right) && + expr.right.items.every((item) => isLiteralExpr(item) || isEnumFieldReference(item)) + ) + ) { + accept('error', 'right operand of "in" must be an array of literals or enum values', { + node: expr.right, + }); + } + break; + } + } } private isCollectionPredicate(expr: Expression) { diff --git a/packages/schema/src/language-server/validator/function-invocation-validator.ts b/packages/schema/src/language-server/validator/function-invocation-validator.ts new file mode 100644 index 000000000..d02be18f4 --- /dev/null +++ b/packages/schema/src/language-server/validator/function-invocation-validator.ts @@ -0,0 +1,129 @@ +import { + Argument, + Expression, + FunctionDecl, + FunctionParam, + InvocationExpr, + isArrayExpr, + isLiteralExpr, +} from '@zenstackhq/language/ast'; +import { ValidationAcceptor } from 'langium'; +import { getDataModelFieldReference, isEnumFieldReference } from '../../utils/ast-utils'; +import { FILTER_OPERATOR_FUNCTIONS } from '../constants'; +import { AstValidator } from '../types'; +import { isFromStdlib } from '../utils'; +import { typeAssignable } from './utils'; + +/** + * InvocationExpr validation + */ +export default class FunctionInvocationValidator implements AstValidator { + validate(expr: InvocationExpr, accept: ValidationAcceptor): void { + const funcDecl = expr.function.ref; + if (!funcDecl) { + accept('error', 'function cannot be resolved', { node: expr }); + return; + } + + if (!this.validateArgs(funcDecl, expr.args, accept)) { + return; + } + + if (isFromStdlib(funcDecl)) { + // validate standard library functions + + if (FILTER_OPERATOR_FUNCTIONS.includes(funcDecl.name)) { + // filter operation functions + + // first argument must refer to a model field + const firstArg = expr.args?.[0]?.value; + if (firstArg) { + if (!getDataModelFieldReference(firstArg)) { + accept('error', 'first argument must be a field reference', { node: firstArg }); + } + } + + // second argument must be a literal or array of literal + const secondArg = expr.args?.[1]?.value; + if ( + secondArg && + // literal + !isLiteralExpr(secondArg) && + // enum field + !isEnumFieldReference(secondArg) && + // array of literal/enum + !( + isArrayExpr(secondArg) && + secondArg.items.every((item) => isLiteralExpr(item) || isEnumFieldReference(item)) + ) + ) { + accept('error', 'second argument must be a literal, an enum, or an array of them', { + node: secondArg, + }); + } + } + } + } + + private validateArgs(funcDecl: FunctionDecl, args: Argument[], accept: ValidationAcceptor) { + let success = true; + for (let i = 0; i < funcDecl.params.length; i++) { + const param = funcDecl.params[i]; + const arg = args[i]; + if (!arg) { + if (!param.optional) { + accept('error', `missing argument for parameter "${param.name}"`, { node: funcDecl }); + success = false; + } + } else { + if (!this.validateInvocationArg(arg, param, accept)) { + success = false; + } + } + } + // TODO: do we need to complain for extra arguments? + return success; + } + + private validateInvocationArg(arg: Argument, param: FunctionParam, accept: ValidationAcceptor) { + const argResolvedType = arg?.value?.$resolvedType; + if (!argResolvedType) { + accept('error', 'argument type cannot be resolved', { node: arg }); + return false; + } + + const dstType = param.type.type; + if (!dstType) { + accept('error', 'parameter type cannot be resolved', { node: param }); + return false; + } + + const dstIsArray = param.type.array; + const dstRef = param.type.reference; + + if (dstType === 'Any' && !dstIsArray) { + // scalar 'any' can be assigned with anything + return true; + } + + if (typeof argResolvedType.decl === 'string') { + // scalar type + if (!typeAssignable(dstType, argResolvedType.decl, arg.value) || dstIsArray !== argResolvedType.array) { + accept('error', `argument is not assignable to parameter`, { + node: arg, + }); + return false; + } + } else { + // enum or model type + if ((dstRef?.ref !== argResolvedType.decl && dstType !== 'Any') || dstIsArray !== argResolvedType.array) { + accept('error', `argument is not assignable to parameter`, { + node: arg, + }); + return false; + } + } + + return true; + } +} diff --git a/packages/schema/src/language-server/validator/utils.ts b/packages/schema/src/language-server/validator/utils.ts index 163bef002..7cdbc9037 100644 --- a/packages/schema/src/language-server/validator/utils.ts +++ b/packages/schema/src/language-server/validator/utils.ts @@ -122,6 +122,10 @@ export function assignableToAttributeParam( let dstIsArray = param.type.array; const dstRef = param.type.reference; + if (dstType === 'Any' && !dstIsArray) { + return true; + } + // destination is field reference or transitive field reference, check if // argument is reference or array or reference if (dstType === 'FieldReference' || dstType === 'TransitiveFieldReference') { @@ -168,13 +172,10 @@ export function assignableToAttributeParam( } } - return ( - typeAssignable(dstType, argResolvedType.decl, arg.value) && - (dstType === 'Any' || dstIsArray === argResolvedType.array) - ); + return typeAssignable(dstType, argResolvedType.decl, arg.value) && dstIsArray === argResolvedType.array; } else { // reference type - return dstRef?.ref === argResolvedType.decl && dstIsArray === argResolvedType.array; + return (dstRef?.ref === argResolvedType.decl || dstType === 'Any') && dstIsArray === argResolvedType.array; } } diff --git a/packages/schema/src/language-server/validator/zmodel-validator.ts b/packages/schema/src/language-server/validator/zmodel-validator.ts index 077add682..4ea4c59dd 100644 --- a/packages/schema/src/language-server/validator/zmodel-validator.ts +++ b/packages/schema/src/language-server/validator/zmodel-validator.ts @@ -1,5 +1,14 @@ import { AstNode, LangiumDocument, ValidationAcceptor, ValidationChecks, ValidationRegistry } from 'langium'; -import { Attribute, DataModel, DataSource, Enum, Expression, Model, ZModelAstType } from '@zenstackhq/language/ast'; +import { + Attribute, + DataModel, + DataSource, + Enum, + Expression, + InvocationExpr, + Model, + ZModelAstType, +} from '@zenstackhq/language/ast'; import type { ZModelServices } from '../zmodel-module'; import SchemaValidator from './schema-validator'; import DataSourceValidator from './datasource-validator'; @@ -7,6 +16,7 @@ import DataModelValidator from './datamodel-validator'; import AttributeValidator from './attribute-validator'; import EnumValidator from './enum-validator'; import ExpressionValidator from './expression-validator'; +import FunctionInvocationValidator from './function-invocation-validator'; /** * Registry for validation checks. @@ -22,6 +32,7 @@ export class ZModelValidationRegistry extends ValidationRegistry { Enum: validator.checkEnum, Attribute: validator.checkAttribute, Expression: validator.checkExpression, + InvocationExpr: validator.checkFunctionInvocation, }; this.register(checks, validator); } @@ -68,4 +79,8 @@ export class ZModelValidator { checkExpression(node: Expression, accept: ValidationAcceptor): void { this.shouldCheck(node) && new ExpressionValidator().validate(node, accept); } + + checkFunctionInvocation(node: InvocationExpr, accept: ValidationAcceptor): void { + this.shouldCheck(node) && new FunctionInvocationValidator().validate(node, accept); + } } diff --git a/packages/schema/src/language-server/zmodel-linker.ts b/packages/schema/src/language-server/zmodel-linker.ts index cbeffab2b..c98c0890c 100644 --- a/packages/schema/src/language-server/zmodel-linker.ts +++ b/packages/schema/src/language-server/zmodel-linker.ts @@ -15,6 +15,7 @@ import { isArrayExpr, isDataModel, isDataModelField, + isDataModelFieldType, isReferenceExpr, LiteralExpr, MemberAccessExpr, @@ -184,6 +185,7 @@ export class ZModelLinker extends DefaultLinker { case '!=': case '&&': case '||': + case 'in': this.resolve(node.left, document, extraScopes); this.resolve(node.right, document, extraScopes); this.resolveToBuiltinTypeOrDecl(node, 'Boolean'); @@ -248,7 +250,7 @@ export class ZModelLinker extends DefaultLinker { const model = getContainingModel(node); const userModel = model?.declarations.find((d) => isDataModel(d) && d.name === 'User'); if (userModel) { - node.$resolvedType = { decl: userModel }; + node.$resolvedType = { decl: userModel, nullable: true }; } } else if (funcDecl.name === 'future' && isFromStdlib(funcDecl)) { // future() function is resolved to current model @@ -446,19 +448,24 @@ export class ZModelLinker extends DefaultLinker { //#region Utils private resolveToDeclaredType(node: AstNode, type: FunctionParamType | DataModelFieldType) { + let nullable = false; + if (isDataModelFieldType(type)) { + nullable = type.optional; + } if (type.type) { const mappedType = mapBuiltinTypeToExpressionType(type.type); - node.$resolvedType = { decl: mappedType, array: type.array }; + node.$resolvedType = { decl: mappedType, array: type.array, nullable: nullable }; } else if (type.reference) { node.$resolvedType = { decl: type.reference.ref, array: type.array, + nullable: nullable, }; } } - private resolveToBuiltinTypeOrDecl(node: AstNode, type: ResolvedShape, array = false) { - node.$resolvedType = { decl: type, array }; + private resolveToBuiltinTypeOrDecl(node: AstNode, type: ResolvedShape, array = false, nullable = false) { + node.$resolvedType = { decl: type, array, nullable }; } //#endregion diff --git a/packages/schema/src/plugins/access-policy/expression-writer.ts b/packages/schema/src/plugins/access-policy/expression-writer.ts index e2449326f..03fe11fb1 100644 --- a/packages/schema/src/plugins/access-policy/expression-writer.ts +++ b/packages/schema/src/plugins/access-policy/expression-writer.ts @@ -2,6 +2,7 @@ import { BinaryExpr, DataModel, Expression, + InvocationExpr, isDataModel, isDataModelField, isEnumField, @@ -13,14 +14,30 @@ import { ReferenceExpr, UnaryExpr, } from '@zenstackhq/language/ast'; -import { GUARD_FIELD_NAME, PluginError } from '@zenstackhq/sdk'; +import { getLiteral, GUARD_FIELD_NAME, PluginError } from '@zenstackhq/sdk'; import { CodeBlockWriter } from 'ts-morph'; -import { getIdField, isAuthInvocation } from '../../utils/ast-utils'; +import { FILTER_OPERATOR_FUNCTIONS } from '../../language-server/constants'; +import { getIdFields, isAuthInvocation } from '../../utils/ast-utils'; import TypeScriptExpressionTransformer from './typescript-expression-transformer'; import { isFutureExpr } from './utils'; type ComparisonOperator = '==' | '!=' | '>' | '>=' | '<' | '<='; +type FilterOperators = + | 'is' + | 'some' + | 'every' + | 'none' + | 'in' + | 'contains' + | 'search' + | 'startsWith' + | 'endsWith' + | 'has' + | 'hasEvery' + | 'hasSome' + | 'isEmpty'; + /** * Utility for writing ZModel expression as Prisma query argument objects into a ts-morph writer */ @@ -61,6 +78,10 @@ export class ExpressionWriter { this.writeMemberAccess(expr as MemberAccessExpr); break; + case InvocationExpr: + this.writeInvocation(expr as InvocationExpr); + break; + default: throw new Error(`Not implemented: ${expr.$type}`); } @@ -78,16 +99,17 @@ export class ExpressionWriter { private writeMemberAccess(expr: MemberAccessExpr) { this.block(() => { - // must be a boolean member - this.writeFieldCondition( - expr.operand, - () => { + if (this.isAuthOrAuthMemberAccess(expr)) { + // member access of `auth()`, generate plain expression + this.guard(() => this.plain(expr), true); + } else { + // must be a boolean member + this.writeFieldCondition(expr.operand, () => { this.block(() => { this.writer.write(`${expr.member.ref?.name}: true`); }); - }, - 'is' - ); + }); + } }); } @@ -118,6 +140,10 @@ export class ExpressionWriter { this.writeComparison(expr, expr.operator); break; + case 'in': + this.writeIn(expr); + break; + case '?': case '!': case '^': @@ -126,6 +152,18 @@ export class ExpressionWriter { } } + private writeIn(expr: BinaryExpr) { + this.block(() => { + this.writeFieldCondition( + expr.left, + () => { + this.plain(expr.right); + }, + 'in' + ); + }); + } + private writeCollectionPredicate(expr: BinaryExpr, operator: string) { this.block(() => { this.writeFieldCondition( @@ -157,9 +195,14 @@ export class ExpressionWriter { return false; } - private guard(write: () => void) { + private guard(write: () => void, cast = false) { this.writer.write(`${GUARD_FIELD_NAME}: `); - write(); + if (cast) { + this.writer.write('!!'); + write(); + } else { + write(); + } } private plain(expr: Expression) { @@ -178,12 +221,9 @@ export class ExpressionWriter { // compile down to a plain expression this.block(() => { this.guard(() => { - this.plain(expr.left); - this.writer.write(' ' + operator + ' '); - this.plain(expr.right); + this.plain(expr); }); }); - return; } @@ -209,76 +249,113 @@ export class ExpressionWriter { } as ReferenceExpr; } - // if the operand refers to auth(), need to build a guard to avoid - // using undefined user as filter (which means no filter to Prisma) - // if auth() evaluates falsy, just treat the condition as false - if (this.isAuthOrAuthMemberAccess(operand)) { - this.writer.write(`!user ? { ${GUARD_FIELD_NAME}: false } : `); + // guard member access of `auth()` with null check + if (this.isAuthOrAuthMemberAccess(operand) && !fieldAccess.$resolvedType?.nullable) { + this.writer.write( + `(${this.plainExprBuilder.transform(operand)} == null) ? { ${GUARD_FIELD_NAME}: ${ + // auth().x != user.x is true when auth().x is null and user is not nullable + // other expressions are evaluated to false when null is involved + operator === '!=' ? 'true' : 'false' + } } : ` + ); } - this.block(() => { - this.writeFieldCondition( - fieldAccess, - () => { - this.block( - () => { - const dataModel = this.isModelTyped(fieldAccess); - if (dataModel) { - const idField = getIdField(dataModel); - if (!idField) { - throw new PluginError(`Data model ${dataModel.name} does not have an id field`); + this.block( + () => { + this.writeFieldCondition(fieldAccess, () => { + this.block(() => { + const dataModel = this.isModelTyped(fieldAccess); + if (dataModel && isAuthInvocation(operand)) { + // right now this branch only serves comparison with `auth`, like + // @@allow('all', owner == auth()) + + const idFields = getIdFields(dataModel); + if (!idFields || idFields.length === 0) { + throw new PluginError(`Data model ${dataModel.name} does not have an id field`); + } + + if (operator !== '==' && operator !== '!=') { + throw new PluginError('Only == and != operators are allowed'); + } + + if (!isThisExpr(fieldAccess)) { + this.writer.writeLine(operator === '==' ? 'is:' : 'isNot:'); + const fieldIsNullable = !!fieldAccess.$resolvedType?.nullable; + if (fieldIsNullable) { + // if field is nullable, we can generate "null" check condition + this.writer.write(`(user == null) ? null : `); } - // comparing with an object, convert to "id" comparison instead - this.writer.write(`${idField.name}: `); - this.block(() => { - this.writeOperator(operator, () => { - // operand ? operand.field : null - this.writer.write('('); - this.plain(operand); - this.writer.write(' ? '); + } + + this.block(() => { + idFields.forEach((idField, idx) => { + const writeIdsCheck = () => { + // id: user.id + this.writer.write(`${idField.name}:`); this.plain(operand); this.writer.write(`.${idField.name}`); - this.writer.write(' : null'); - this.writer.write(')'); - }); - }); - } else { - this.writeOperator(operator, () => { - this.plain(operand); + if (idx !== idFields.length - 1) { + this.writer.write(','); + } + }; + + if (isThisExpr(fieldAccess) && operator === '!=') { + // wrap a not + this.writer.writeLine('NOT:'); + this.block(() => writeIdsCheck()); + } else { + writeIdsCheck(); + } }); - } - }, - // "this" expression is compiled away (to .id access), so we should - // avoid generating a new layer - !isThisExpr(fieldAccess) - ); - }, - 'is' - ); - }); + }); + } else { + this.writeOperator(operator, fieldAccess, () => { + this.plain(operand); + }); + } + }, !isThisExpr(fieldAccess)); + }); + }, + // "this" expression is compiled away (to .id access), so we should + // avoid generating a new layer + !isThisExpr(fieldAccess) + ); } private isAuthOrAuthMemberAccess(expr: Expression) { return isAuthInvocation(expr) || (isMemberAccessExpr(expr) && isAuthInvocation(expr.operand)); } - private writeOperator(operator: ComparisonOperator, writeOperand: () => void) { - if (operator === '!=') { - // wrap a 'not' - this.writer.write('not: '); - this.block(() => { - this.writeOperator('==', writeOperand); - }); - } else { - this.writer.write(`${this.mapOperator(operator)}: `); + private writeOperator(operator: ComparisonOperator, fieldAccess: Expression, writeOperand: () => void) { + if (isDataModel(fieldAccess.$resolvedType?.decl)) { + if (operator === '==') { + this.writer.write('is: '); + } else if (operator === '!=') { + this.writer.write('isNot: '); + } else { + throw new PluginError('Only == and != operators are allowed for data model comparison'); + } writeOperand(); + } else { + if (operator === '!=') { + // wrap a 'not' + this.writer.write('not: '); + this.block(() => { + this.writer.write(`${this.mapOperator('==')}: `); + writeOperand(); + }); + } else { + this.writer.write(`${this.mapOperator(operator)}: `); + writeOperand(); + } } } private writeFieldCondition( fieldAccess: Expression, writeCondition: () => void, - relationOp: 'is' | 'some' | 'every' | 'none' + filterOp?: FilterOperators, + extraArgs?: Record ) { let selector: string | undefined; let operand: Expression | undefined; @@ -305,43 +382,37 @@ export class ExpressionWriter { throw new PluginError(`Failed to write FieldAccess expression`); } - if (operand) { - // member access expression - this.writeFieldCondition( - operand, - () => { - this.block( - () => { - this.writer.write(selector + ': '); - if (this.isModelTyped(fieldAccess)) { - // expression is resolved to a model, generate relation query - this.block(() => { - this.writer.write(`${relationOp}: `); - writeCondition(); - }); - } else { - // generate plain query - writeCondition(); - } - }, - // if operand is "this", it doesn't really generate a new layer of query, - // so we should avoid generating a new block - !isThisExpr(operand) - ); - }, - 'is' - ); - } else if (this.isModelTyped(fieldAccess)) { - // reference resolved to a model, generate relation query + const writerFilterOutput = () => { this.writer.write(selector + ': '); - this.block(() => { - this.writer.write(`${relationOp}: `); + if (filterOp) { + this.block(() => { + this.writer.write(`${filterOp}: `); + writeCondition(); + + if (extraArgs) { + for (const [k, v] of Object.entries(extraArgs)) { + this.writer.write(`,\n${k}: `); + this.plain(v); + } + } + }); + } else { writeCondition(); + } + }; + + if (operand) { + // member access expression + this.writeFieldCondition(operand, () => { + this.block( + writerFilterOutput, + // if operand is "this", it doesn't really generate a new layer of query, + // so we should avoid generating a new block + !isThisExpr(operand) + ); }); } else { - // generate a plain query - this.writer.write(selector + ': '); - writeCondition(); + writerFilterOutput(); } } @@ -390,10 +461,37 @@ export class ExpressionWriter { } private writeLogical(expr: BinaryExpr, operator: '&&' | '||') { - this.block(() => { - this.writer.write(`${operator === '&&' ? 'AND' : 'OR'}: `); - this.writeExprList([expr.left, expr.right]); - }); + // TODO: do we need short-circuit for logical operators? + + if (operator === '&&') { + // // && short-circuit: left && right -> left ? right : { zenstack_guard: false } + // if (!this.hasFieldAccess(expr.left)) { + // this.plain(expr.left); + // this.writer.write(' ? '); + // this.write(expr.right); + // this.writer.write(' : '); + // this.block(() => this.guard(() => this.writer.write('false'))); + // } else { + this.block(() => { + this.writer.write('AND:'); + this.writeExprList([expr.left, expr.right]); + }); + // } + } else { + // // || short-circuit: left || right -> left ? { zenstack_guard: true } : right + // if (!this.hasFieldAccess(expr.left)) { + // this.plain(expr.left); + // this.writer.write(' ? '); + // this.block(() => this.guard(() => this.writer.write('true'))); + // this.writer.write(' : '); + // this.write(expr.right); + // } else { + this.block(() => { + this.writer.write('OR:'); + this.writeExprList([expr.left, expr.right]); + }); + // } + } } private writeUnary(expr: UnaryExpr) { @@ -414,4 +512,41 @@ export class ExpressionWriter { }); }); } + + private writeInvocation(expr: InvocationExpr) { + const funcDecl = expr.function.ref; + if (!funcDecl) { + throw new PluginError(`Failed to resolve function declaration`); + } + + if (FILTER_OPERATOR_FUNCTIONS.includes(funcDecl.name)) { + let valueArg = expr.args[1]?.value; + + // isEmpty function is zero arity, it's mapped to a boolean literal + if (funcDecl.name === 'isEmpty') { + valueArg = { $type: LiteralExpr, value: true } as LiteralExpr; + } + + // contains function has a 3rd argument that indicates whether the comparison should be case-insensitive + let extraArgs: Record | undefined = undefined; + if (funcDecl.name === 'contains') { + if (getLiteral(expr.args[2]?.value) === true) { + extraArgs = { mode: { $type: LiteralExpr, value: 'insensitive' } as LiteralExpr }; + } + } + + this.block(() => { + this.writeFieldCondition( + expr.args[0].value, + () => { + this.plain(valueArg); + }, + funcDecl.name as FilterOperators, + extraArgs + ); + }); + } else { + throw new PluginError(`Unsupported function ${funcDecl.name}`); + } + } } diff --git a/packages/schema/src/plugins/access-policy/policy-guard-generator.ts b/packages/schema/src/plugins/access-policy/policy-guard-generator.ts index 9f0fcd5af..06a70336b 100644 --- a/packages/schema/src/plugins/access-policy/policy-guard-generator.ts +++ b/packages/schema/src/plugins/access-policy/policy-guard-generator.ts @@ -21,7 +21,7 @@ import path from 'path'; import { FunctionDeclaration, Project, SourceFile, VariableDeclarationKind } from 'ts-morph'; import { name } from '.'; import { isFromStdlib } from '../../language-server/utils'; -import { analyzePolicies, getIdField } from '../../utils/ast-utils'; +import { analyzePolicies, getIdFields } from '../../utils/ast-utils'; import { ALL_OPERATION_KINDS, getDefaultOutputFolder, RUNTIME_PACKAGE } from '../plugin-utils'; import { ExpressionWriter } from './expression-writer'; import { isFutureExpr } from './utils'; @@ -42,9 +42,8 @@ export default class PolicyGenerator { const sf = project.createSourceFile(path.join(output, 'policy.ts'), undefined, { overwrite: true }); sf.addImportDeclaration({ - namedImports: [{ name: 'QueryContext' }], + namedImports: [{ name: 'type QueryContext' }, { name: 'hasAllFields' }], moduleSpecifier: `${RUNTIME_PACKAGE}`, - isTypeOnly: true, }); sf.addImportDeclaration({ @@ -329,13 +328,17 @@ export default class PolicyGenerator { if (!userModel) { throw new PluginError('User model not found'); } - const userIdField = getIdField(userModel); - if (!userIdField) { + const userIdFields = getIdFields(userModel); + if (!userIdFields || userIdFields.length === 0) { throw new PluginError('User model does not have an id field'); } // normalize user to null to avoid accidentally use undefined in filter - func.addStatements(`const user = context.user ?? null;`); + func.addStatements( + `const user = hasAllFields(context.user, [${userIdFields + .map((f) => "'" + f.name + "'") + .join(', ')}]) ? context.user : null;` + ); } // r = ; diff --git a/packages/schema/src/plugins/access-policy/typescript-expression-transformer.ts b/packages/schema/src/plugins/access-policy/typescript-expression-transformer.ts index 961b3028f..cb6dfba5e 100644 --- a/packages/schema/src/plugins/access-policy/typescript-expression-transformer.ts +++ b/packages/schema/src/plugins/access-policy/typescript-expression-transformer.ts @@ -1,5 +1,6 @@ import { ArrayExpr, + BinaryExpr, Expression, InvocationExpr, isEnumField, @@ -9,6 +10,7 @@ import { NullExpr, ReferenceExpr, ThisExpr, + UnaryExpr, } from '@zenstackhq/language/ast'; import { PluginError } from '@zenstackhq/sdk'; import { isAuthInvocation } from '../../utils/ast-utils'; @@ -53,6 +55,12 @@ export default class TypeScriptExpressionTransformer { case MemberAccessExpr: return this.memberAccess(expr as MemberAccessExpr); + case UnaryExpr: + return this.unary(expr as UnaryExpr); + + case BinaryExpr: + return this.binary(expr as BinaryExpr); + default: throw new PluginError(`Unsupported expression type: ${expr.$type}`); } @@ -78,7 +86,7 @@ export default class TypeScriptExpressionTransformer { return expr.member.ref.name; } else { // normalize field access to null instead of undefined to avoid accidentally use undefined in filter - return `(${this.transform(expr.operand)} ? ${this.transform(expr.operand)}.${expr.member.ref.name} : null)`; + return `(${this.transform(expr.operand)}?.${expr.member.ref.name} ?? null)`; } } @@ -124,4 +132,12 @@ export default class TypeScriptExpressionTransformer { return expr.value.toString(); } } + + private unary(expr: UnaryExpr): string { + return `(${expr.operator} ${this.transform(expr.operand)})`; + } + + private binary(expr: BinaryExpr): string { + return `(${this.transform(expr.left)} ${expr.operator} ${this.transform(expr.right)})`; + } } diff --git a/packages/schema/src/res/stdlib.zmodel b/packages/schema/src/res/stdlib.zmodel index 984f0c1d7..f76ef7251 100644 --- a/packages/schema/src/res/stdlib.zmodel +++ b/packages/schema/src/res/stdlib.zmodel @@ -98,6 +98,54 @@ function dbgenerated(expr: String): Any { function future(): Any { } +/* + * If the field value contains the search string + */ +function contains(field: String, search: String, caseSensitive: Boolean?): Boolean { +} + +/* + * If the field value matches the search condition with [full-text-search](https://www.prisma.io/docs/concepts/components/prisma-client/full-text-search). Need to enable "fullTextSearch" preview feature to use. + */ +function search(field: String, search: String): Boolean { +} + +/* + * If the field value starts with the search string + */ +function startsWith(field: String, search: String): Boolean { +} + +/* + * If the field value ends with the search string + */ +function endsWith(field: String, search: String): Boolean { +} + +/* + * If the field value (a list) has the given search value + */ +function has(field: Any[], search: Any): Boolean { +} + +/* + * If the field value (a list) has every element of the search list + */ +function hasEvery(field: Any[], search: Any[]): Boolean { +} + +/* + * If the field value (a list) has at least one element of the search list + */ +function hasSome(field: Any[], search: Any[]): Boolean { +} + +/* + * If the field value (a list) is empty + */ +function isEmpty(field: Any[]): Boolean { +} + /** * Marks an attribute to be only applicable to certain field types. */ diff --git a/packages/schema/src/utils/ast-utils.ts b/packages/schema/src/utils/ast-utils.ts index ebde2e6c8..5456a2670 100644 --- a/packages/schema/src/utils/ast-utils.ts +++ b/packages/schema/src/utils/ast-utils.ts @@ -1,10 +1,17 @@ import { DataModel, DataModelAttribute, + DataModelField, Expression, + isArrayExpr, isDataModel, + isDataModelField, + isEnumField, isInvocationExpr, + isMemberAccessExpr, + isReferenceExpr, Model, + ReferenceExpr, } from '@zenstackhq/language/ast'; import { PolicyOperationKind } from '@zenstackhq/runtime'; import { getLiteral } from '@zenstackhq/sdk'; @@ -96,10 +103,41 @@ export const VALIDATION_ATTRIBUTES = [ '@lte', ]; -export function getIdField(dataModel: DataModel) { - return dataModel.fields.find((f) => f.attributes.some((attr) => attr.decl.$refText === '@id')); +export function getIdFields(dataModel: DataModel) { + const fieldLevelId = dataModel.fields.find((f) => f.attributes.some((attr) => attr.decl.$refText === '@id')); + if (fieldLevelId) { + return [fieldLevelId]; + } else { + // get model level @@id attribute + const modelIdAttr = dataModel.attributes.find((attr) => attr.decl?.ref?.name === '@@id'); + if (modelIdAttr) { + // get fields referenced in the attribute: @@id([field1, field2]]) + if (!isArrayExpr(modelIdAttr.args[0].value)) { + return []; + } + const argValue = modelIdAttr.args[0].value; + return argValue.items + .filter((expr): expr is ReferenceExpr => isReferenceExpr(expr) && !!getDataModelFieldReference(expr)) + .map((expr) => expr.target.ref as DataModelField); + } + } + return []; } export function isAuthInvocation(expr: Expression) { return isInvocationExpr(expr) && expr.function.ref?.name === 'auth' && isFromStdlib(expr.function.ref); } + +export function isEnumFieldReference(expr: Expression) { + return isReferenceExpr(expr) && isEnumField(expr.target.ref); +} + +export function getDataModelFieldReference(expr: Expression): DataModelField | undefined { + if (isReferenceExpr(expr) && isDataModelField(expr.target.ref)) { + return expr.target.ref; + } else if (isMemberAccessExpr(expr) && isDataModelField(expr.member.ref)) { + return expr.member.ref; + } else { + return undefined; + } +} diff --git a/packages/schema/tests/generator/expression-writer.test.ts b/packages/schema/tests/generator/expression-writer.test.ts index 79e22174f..157d4916e 100644 --- a/packages/schema/tests/generator/expression-writer.test.ts +++ b/packages/schema/tests/generator/expression-writer.test.ts @@ -119,13 +119,7 @@ describe('Expression Writer Tests', () => { } `, (model) => model.attributes[0].args[1].value, - `!user ? - { zenstack_guard: false } : - { - id: { - equals: (user ? user.id: null) - } - }` + `(user == null) ? { zenstack_guard: false } : { id: user.id }` ); await check( @@ -137,15 +131,7 @@ describe('Expression Writer Tests', () => { } `, (model) => model.attributes[0].args[1].value, - `!user ? - { zenstack_guard: false } : - { - id: { - not: { - equals: (user ? user.id: null) - } - } - }` + `(user == null) ? { zenstack_guard: true } : { NOT: { id: user.id } }` ); await check( @@ -322,10 +308,8 @@ describe('Expression Writer Tests', () => { (model) => model.attributes[0].args[1].value, `{ foo: { - is: { - x : { - lte: 0 - } + x : { + lte: 0 } } }` @@ -351,10 +335,8 @@ describe('Expression Writer Tests', () => { NOT: { foo: { - is: { - x : { - gt: 0 - } + x : { + gt: 0 } } } @@ -380,9 +362,7 @@ describe('Expression Writer Tests', () => { `{ NOT: { foo: { - is: { - x: true - } + x: true } } }` @@ -413,13 +393,9 @@ describe('Expression Writer Tests', () => { (model) => model.attributes[0].args[1].value, `{ foo: { - is: { - bar: { - is: { - x : { - lte: 0 - } - } + bar: { + x : { + lte: 0 } } } @@ -534,12 +510,10 @@ describe('Expression Writer Tests', () => { (model) => model.attributes[0].args[1].value, `{ foo: { - is: { - bars: { - some: { - x: { - lte: 0 - } + bars: { + some: { + x: { + lte: 0 } } } @@ -548,33 +522,113 @@ describe('Expression Writer Tests', () => { ); }); - it('auth check', async () => { + it('auth null check', async () => { await check( ` - model User { id String @id } + model User { + id String @id + } + model Test { id String @id - @@deny('all', auth() == null) + @@allow('all', auth() == null) } `, (model) => model.attributes[0].args[1].value, - `{ ${GUARD_FIELD_NAME}: user == null }` + `{ zenstack_guard: (user == null) }`, + '{ id: "1" }' ); await check( ` - model User { id String @id } + model User { + x String + y String + @@id([x, y]) + } + + model Test { + id String @id + @@allow('all', auth() == null) + } + `, + (model) => model.attributes[0].args[1].value, + `{ zenstack_guard: (user == null) }`, + '{ x: "1", y: "2" }' + ); + + await check( + ` + model User { + id String @id + } + + model Test { + id String @id + @@allow('all', auth() != null) + } + `, + (model) => model.attributes[0].args[1].value, + `{ zenstack_guard: (user != null) }`, + '{ id: "1" }' + ); + + await check( + ` + model User { + x String + y String + @@id([x, y]) + } + model Test { id String @id @@allow('all', auth() != null) } `, (model) => model.attributes[0].args[1].value, - `{ ${GUARD_FIELD_NAME}: user != null }` + `{ zenstack_guard: (user != null) }`, + '{ x: "1", y: "2" }' ); }); - it('auth check against field', async () => { + it('auth boolean field check', async () => { + await check( + ` + model User { + id String @id + admin Boolean + } + + model Test { + id String @id + @@allow('all', auth().admin) + } + `, + (model) => model.attributes[0].args[1].value, + `{ zenstack_guard: !!(user?.admin ?? null) }`, + '{ id: "1", admin: true }' + ); + + await check( + ` + model User { + id String @id + admin Boolean + } + + model Test { + id String @id + @@deny('all', !auth().admin) + } + `, + (model) => model.attributes[0].args[1].value, + `{ NOT: { zenstack_guard: !!(user?.admin ?? null) } }`, + '{ id: "1", admin: true }' + ); + }); + + it('auth check against field single id', async () => { await check( ` model User { @@ -590,18 +644,7 @@ describe('Expression Writer Tests', () => { } `, (model) => model.attributes[0].args[1].value, - `!user ? - { zenstack_guard : false } : - { - owner: { - is: { - id: { - equals: (user ? user.id : null) - } - } - } - } - ` + `(user==null) ? { zenstack_guard: false } : { owner: { is: { id : user.id } } }` ); await check( @@ -619,19 +662,12 @@ describe('Expression Writer Tests', () => { } `, (model) => model.attributes[0].args[1].value, - `!user ? - { zenstack_guard : false } : - { - owner: { - is: { - id: { - not: { - equals: (user ? user.id : null) - } - } - } - } - }` + `(user==null) ? { zenstack_guard: true } : + { + owner: { + isNot: { id: user.id } + } + }` ); await check( @@ -649,22 +685,456 @@ describe('Expression Writer Tests', () => { } `, (model) => model.attributes[0].args[1].value, - `!user ? + `((user?.id??null)==null) ? { zenstack_guard : false } : - { - owner: { - is: { - id: { - equals: (user ? user.id : null) - } - } - } - }` + { owner: { id: { equals: (user?.id ?? null) } } }` + ); + }); + + it('auth check against field multi-id', async () => { + await check( + ` + model User { + x String + y String + t Test? + @@id([x, y]) + } + + model Test { + id String @id + owner User @relation(fields: [ownerX, ownerY], references: [x, y]) + ownerX String + ownerY String + @@unique([ownerX, ownerY]) + @@allow('all', auth() == owner) + } + `, + (model) => model.attributes[1].args[1].value, + `(user==null) ? + { zenstack_guard: false } : + { owner: { is: { x: user.x, y: user.y } } }`, + '{ x: "1", y: "2" }' + ); + + await check( + ` + model User { + x String + y String + t Test? + @@id([x, y]) + } + + model Test { + id String @id + owner User @relation(fields: [ownerX, ownerY], references: [x, y]) + ownerX String + ownerY String + @@unique([ownerX, ownerY]) + @@allow('all', auth() != owner) + } + `, + (model) => model.attributes[1].args[1].value, + `(user==null) ? + { zenstack_guard: true } : + { owner: { isNot: { x: user.x, y: user.y } } }`, + '{ x: "1", y: "2" }' + ); + + await check( + ` + model User { + x String + y String + t Test? + @@id([x, y]) + } + + model Test { + id String @id + owner User @relation(fields: [ownerX, ownerY], references: [x, y]) + ownerX String + ownerY String + @@unique([ownerX, ownerY]) + @@allow('all', auth().x == owner.x && auth().y == owner.y) + } + `, + (model) => model.attributes[1].args[1].value, + `{ + AND: [ + ((user?.x??null)==null) ? { zenstack_guard: false } : { owner: { x: { equals: (user?.x ?? null) } } }, + ((user?.y??null)==null) ? { zenstack_guard: false } : { owner: { y: { equals: (user?.y ?? null) } } } + ] + }`, + '{ x: "1", y: "2" }' + ); + }); + + it('auth check against nullable field', async () => { + await check( + ` + model User { + id String @id + t Test? + } + + model Test { + id String @id + owner User? @relation(fields: [ownerId], references: [id]) + ownerId String? @unique + @@allow('all', auth() == owner) + } + `, + (model) => model.attributes[0].args[1].value, + `{ + owner: { + is: (user == null) ? null : { id: user.id } + } + }` + ); + + await check( + ` + model User { + id String @id + t Test? + } + + model Test { + id String @id + owner User? @relation(fields: [ownerId], references: [id]) + ownerId String? @unique + @@deny('all', auth() != owner) + } + `, + (model) => model.attributes[0].args[1].value, + `{ + owner: { + isNot: (user == null) ? null : { id: user.id } + } + }` + ); + + await check( + ` + model User { + id String @id + t Test? + } + + model Test { + id String @id + owner User? @relation(fields: [ownerId], references: [id]) + ownerId String? @unique + @@allow('all', auth().id == owner.id) + } + `, + (model) => model.attributes[0].args[1].value, + `((user?.id??null)==null) ? { zenstack_guard: false } : { owner: { id: { equals: (user?.id ?? null) } } }` + ); + }); + + it('auth check short-circuit [TBD]', async () => { + await check( + ` + model User { + id String @id + t Test? + } + + model Test { + id String @id + owner User @relation(fields: [ownerId], references: [id]) + ownerId String @unique + value Int + @@allow('all', auth() != null && auth().id == owner.id && value > 0) + } + `, + (model) => model.attributes[0].args[1].value, + `{ + AND: [ + { + AND: [ + { zenstack_guard: (user!=null) }, + ((user?.id??null)==null) ? {zenstack_guard:false} : { owner: { id: { equals: (user?.id??null) } } } + ] + }, + { value: { gt: 0 } } + ] + }` + ); + + await check( + ` + model User { + id String @id + t Test? + } + + model Test { + id String @id + owner User @relation(fields: [ownerId], references: [id]) + ownerId String @unique + value Int + @@deny('all', auth() == null || auth().id != owner.id || value <= 0) + } + `, + (model) => model.attributes[0].args[1].value, + `{ + OR: [ + { + OR: [ + { zenstack_guard:(user==null) }, + ((user?.id??null)==null) ? {zenstack_guard:true} : { owner : { id: { not: { equals: (user?.id??null) } } } } + ] + }, + { value: { lte: 0 } } + ] + }` + ); + }); + + it('relation field null check', async () => { + await check( + ` + model M { + id String @id + s String? + t Test @relation(fields: [tId], references: [id]) + tId String @unique + } + + model Test { + id String @id + m M? + @@allow('all', m == null || m.s == null) + } + `, + (model) => model.attributes[0].args[1].value, + ` + { + OR: [{ m: { is: null } }, { m: { s: { equals: null } } }] + } + ` + ); + + await check( + ` + model M { + id String @id + s String? + t Test @relation(fields: [tId], references: [id]) + tId String @unique + } + + model Test { + id String @id + m M? + @@deny('all', m != null || m.s != null) + } + `, + (model) => model.attributes[0].args[1].value, + ` + { + OR: [{ m: { isNot: null } }, { m: { s: { not: { equals: null } } } }] + } + ` + ); + }); + + it('filter operators', async () => { + await check( + ` + enum Role { + USER + ADMIN + } + model Test { + id String @id + role Role + @@allow('all', role in [USER, ADMIN]) + } + `, + (model) => model.attributes[0].args[1].value, + ` + { + role: { in: [Role.USER, Role.ADMIN] } + } + ` + ); + + await check( + ` + model Test { + id String @id + value String + @@allow('all', contains(value, 'foo')) + } + `, + (model) => model.attributes[0].args[1].value, + ` + { + value: { contains: 'foo' } + } + ` + ); + + await check( + ` + model Test { + id String @id + value String + @@allow('all', contains(value, 'foo', true)) + } + `, + (model) => model.attributes[0].args[1].value, + ` + { + value: { contains: 'foo', mode: 'insensitive' } + } + ` + ); + + await check( + ` + model Test { + id String @id + value String + @@allow('all', contains(value, 'foo', false)) + } + `, + (model) => model.attributes[0].args[1].value, + ` + { + value: { contains: 'foo' } + } + ` + ); + + await check( + ` + model Foo { + id String @id + value String + test Test @relation(fields: [testId], references: [id]) + testId String @unique + } + model Test { + id String @id + foo Foo? + @@allow('all', search(foo.value, 'foo')) + } + `, + (model) => model.attributes[0].args[1].value, + ` + { + foo: { + value: { search: 'foo' } + } + } + ` + ); + + await check( + ` + model Test { + id String @id + value String + @@allow('all', startsWith(value, 'foo') && endsWith(value, 'bar')) + } + `, + (model) => model.attributes[0].args[1].value, + ` + { + AND: [ { value: { startsWith: 'foo' } }, { value: { endsWith: 'bar' } } ] + } + ` + ); + + await check( + ` + model Test { + id String @id + value String + @@allow('all', !startsWith(value, 'foo')) + } + `, + (model) => model.attributes[0].args[1].value, + ` + { + NOT: { value: { startsWith: 'foo' } } + } + ` + ); + + await check( + ` + model Test { + id String @id + values Int[] + @@allow('all', has(values, 1)) + } + `, + (model) => model.attributes[0].args[1].value, + ` + { + values: { has: 1 } + } + ` + ); + + await check( + ` + model Test { + id String @id + values Int[] + @@allow('all', hasSome(values, [1, 2])) + } + `, + (model) => model.attributes[0].args[1].value, + ` + { + values: { hasSome: [1, 2] } + } + ` + ); + + await check( + ` + model Test { + id String @id + values Int[] + @@allow('all', hasEvery(values, [1, 2])) + } + `, + (model) => model.attributes[0].args[1].value, + ` + { + values: { hasEvery: [1, 2] } + } + ` + ); + + await check( + ` + model Test { + id String @id + values Int[] + @@allow('all', isEmpty(values)) + } + `, + (model) => model.attributes[0].args[1].value, + ` + { + values: { isEmpty: true } + } + ` ); }); }); -async function check(schema: string, getExpr: (model: DataModel) => Expression, expected: string) { +async function check(schema: string, getExpr: (model: DataModel) => Expression, expected: string, userInit?: string) { if (!schema.includes('datasource ')) { schema = ` @@ -688,7 +1158,7 @@ async function check(schema: string, getExpr: (model: DataModel) => Expression, // inject user variable sf.addVariableStatement({ declarationKind: VariableDeclarationKind.Const, - declarations: [{ name: 'user', initializer: '{ id: "user1" }' }], + declarations: [{ name: 'user', initializer: userInit ?? '{ id: "user1" }' }], }); // inject enums @@ -722,6 +1192,7 @@ async function check(schema: string, getExpr: (model: DataModel) => Expression, sf.formatText(); await project.save(); + console.log('Source saved:', sourcePath); if (project.getPreEmitDiagnostics().length > 0) { for (const d of project.getPreEmitDiagnostics()) { diff --git a/packages/schema/tests/generator/code-generator.test.ts b/packages/schema/tests/generator/zmodel-generator.test.ts similarity index 98% rename from packages/schema/tests/generator/code-generator.test.ts rename to packages/schema/tests/generator/zmodel-generator.test.ts index 6df2144bb..91ddacca2 100644 --- a/packages/schema/tests/generator/code-generator.test.ts +++ b/packages/schema/tests/generator/zmodel-generator.test.ts @@ -2,7 +2,7 @@ import { loadModel } from '../utils'; import ZModelCodeGenerator from '../../src/plugins/prisma/zmodel-code-generator'; import { DataModel, DataModelAttribute, DataModelFieldAttribute } from '@zenstackhq/language/ast'; -describe('Code Generator Tests', () => { +describe('ZModel Generator Tests', () => { const generator = new ZModelCodeGenerator(); function checkAttribute(ast: DataModelAttribute | DataModelFieldAttribute, expected: string) { diff --git a/packages/schema/tests/schema/validation/attribute-validation.test.ts b/packages/schema/tests/schema/validation/attribute-validation.test.ts index f93cccec1..d541d43a9 100644 --- a/packages/schema/tests/schema/validation/attribute-validation.test.ts +++ b/packages/schema/tests/schema/validation/attribute-validation.test.ts @@ -366,6 +366,162 @@ describe('Attribute tests', () => { ).toContain(`Value is not assignable to parameter`); }); + it('filter function check', async () => { + await loadModel(` + ${prelude} + enum E { + E1 + E2 + } + + model N { + id String @id + e E + es E[] + s String + i Int + m M @relation(fields: [mId], references: [id]) + mId String @unique + } + + model M { + id String @id + s String + e E + es E[] + n N? + + @@allow('all', e in [E1, E2]) + @@allow('all', contains(s, 'a')) + @@allow('all', contains(s, 'a', true)) + @@allow('all', search(s, 'a')) + @@allow('all', startsWith(s, 'a')) + @@allow('all', endsWith(s, 'a')) + @@allow('all', has(es, E1)) + @@allow('all', hasSome(es, [E1])) + @@allow('all', hasEvery(es, [E1])) + @@allow('all', isEmpty(es)) + + @@allow('all', n.e in [E1, E2]) + @@allow('all', n.i in [1, 2]) + @@allow('all', contains(n.s, 'a')) + @@allow('all', contains(n.s, 'a', true)) + @@allow('all', search(n.s, 'a')) + @@allow('all', startsWith(n.s, 'a')) + @@allow('all', endsWith(n.s, 'a')) + @@allow('all', has(n.es, E1)) + @@allow('all', hasSome(n.es, [E1])) + @@allow('all', hasEvery(n.es, [E1])) + @@allow('all', isEmpty(n.es)) + } + `); + + expect( + await loadModelWithError(` + ${prelude} + model M { + id String @id + s String + @@allow('all', contains(s)) + } + `) + ).toContain('missing argument for parameter "search"'); + + expect( + await loadModelWithError(` + ${prelude} + model M { + id String @id + s String + @@allow('all', contains('a', s)) + } + `) + ).toContain('first argument must be a field reference'); + + expect( + await loadModelWithError(` + ${prelude} + model M { + id String @id + s String + s1 String + @@allow('all', contains(s, s1)) + } + `) + ).toContain('second argument must be a literal, an enum, or an array of them'); + + expect( + await loadModelWithError(` + ${prelude} + model M { + id String @id + i Int + @@allow('all', contains(i, 1)) + } + `) + ).toContain('argument is not assignable to parameter'); + + expect( + await loadModelWithError(` + ${prelude} + model M { + id String @id + i Int[] + @@allow('all', 1 in i) + } + `) + ).toContain('left operand of "in" must be a field reference'); + + expect( + await loadModelWithError(` + ${prelude} + model M { + id String @id + i Int + @@allow('all', i in 1) + } + `) + ).toContain('right operand of "in" must be an array of literals or enum values'); + + expect( + await loadModelWithError(` + ${prelude} + model N { + id String @id + m M @relation(fields: [mId], references: [id]) + mId String + } + model M { + id String @id + n N? + @@allow('all', n in [1]) + } + `) + ).toContain('left operand of "in" must be of scalar type'); + + expect( + await loadModelWithError(` + ${prelude} + model M { + id String @id + x Int + @@allow('all', has(x, 1)) + } + `) + ).toContain('argument is not assignable to parameter'); + + expect( + await loadModelWithError(` + ${prelude} + model M { + id String @id + x Int[] + @@allow('all', hasSome(x, 1)) + } + `) + ).toContain('argument is not assignable to parameter'); + }); + it('auth function check', async () => { expect( await loadModelWithError(` diff --git a/packages/schema/tests/schema/validation/datamodel-validation.test.ts b/packages/schema/tests/schema/validation/datamodel-validation.test.ts index 6e0c40b1d..76c38a3f1 100644 --- a/packages/schema/tests/schema/validation/datamodel-validation.test.ts +++ b/packages/schema/tests/schema/validation/datamodel-validation.test.ts @@ -257,6 +257,23 @@ describe('Data Model Validation Tests', () => { } `); + // many-to-many implicit + //https://www.prisma.io/docs/concepts/components/prisma-schema/relations/many-to-many-relations#implicit-many-to-many-relations + await loadModel(` + ${prelude} + model Post { + id Int @id @default(autoincrement()) + title String + categories Category[] + } + + model Category { + id Int @id @default(autoincrement()) + name String + posts Post[] + } + `); + // one-to-one incomplete expect( await loadModelWithError(` diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 2e29cb334..aa93e7bac 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "1.0.0-alpha.85", + "version": "1.0.0-alpha.87", "description": "ZenStack plugin development SDK", "main": "index.js", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index 96671cd2d..813a99f41 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/server", - "version": "1.0.0-alpha.85", + "version": "1.0.0-alpha.87", "displayName": "ZenStack Server-side Adapters", "description": "ZenStack server-side adapters", "homepage": "https://zenstack.dev", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 0abfdde7a..4fb2eadae 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "1.0.0-alpha.85", + "version": "1.0.0-alpha.87", "description": "ZenStack Test Tools", "main": "index.js", "publishConfig": { diff --git a/tests/integration/test-run/package-lock.json b/tests/integration/test-run/package-lock.json index 14f918130..3f3d059d9 100644 --- a/tests/integration/test-run/package-lock.json +++ b/tests/integration/test-run/package-lock.json @@ -126,7 +126,7 @@ }, "../../../packages/runtime/dist": { "name": "@zenstackhq/runtime", - "version": "1.0.0-alpha.85", + "version": "1.0.0-alpha.87", "license": "MIT", "dependencies": { "@types/bcryptjs": "^2.4.2", @@ -158,7 +158,7 @@ }, "../../../packages/schema/dist": { "name": "zenstack", - "version": "1.0.0-alpha.85", + "version": "1.0.0-alpha.87", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -173,7 +173,7 @@ "colors": "1.4.0", "commander": "^8.3.0", "cuid": "^2.1.8", - "langium": "1.0.1", + "langium": "1.1.0", "mixpanel": "^0.17.0", "node-machine-id": "^1.1.12", "ora": "^5.4.1", @@ -221,6 +221,7 @@ "ts-node": "^10.9.1", "tsc-alias": "^1.7.0", "typescript": "^4.8.4", + "vitest": "^0.29.7", "vsce": "^2.13.0" }, "engines": { @@ -425,7 +426,7 @@ "eslint": "^8.27.0", "eslint-plugin-jest": "^27.1.7", "jest": "^29.2.1", - "langium": "1.0.1", + "langium": "1.1.0", "langium-cli": "^1.0.0", "mixpanel": "^0.17.0", "node-machine-id": "^1.1.12", @@ -444,6 +445,7 @@ "tsc-alias": "^1.7.0", "typescript": "^4.8.4", "uuid": "^9.0.0", + "vitest": "^0.29.7", "vsce": "^2.13.0", "vscode-jsonrpc": "^8.0.2", "vscode-languageclient": "^8.0.2", diff --git a/tests/integration/tests/e2e/filter-function-coverage.test.ts b/tests/integration/tests/e2e/filter-function-coverage.test.ts new file mode 100644 index 000000000..049c45e3b --- /dev/null +++ b/tests/integration/tests/e2e/filter-function-coverage.test.ts @@ -0,0 +1,63 @@ +import { loadSchema, run, type WeakDbClientContract } from '@zenstackhq/testtools'; + +describe('Filter Function Coverage Tests', () => { + it('contains case-sensitive', async () => { + const { withPresets } = await loadSchema( + ` + model Foo { + id String @id @default(cuid()) + string String + @@allow('all', contains(string, 'a')) + } + ` + ); + + await expect(withPresets().foo.create({ data: { string: 'bcd' } })).toBeRejectedByPolicy(); + await expect(withPresets().foo.create({ data: { string: 'bac' } })).toResolveTruthy(); + }); + + it('startsWith', async () => { + const { withPresets } = await loadSchema( + ` + model Foo { + id String @id @default(cuid()) + string String + @@allow('all', startsWith(string, 'a')) + } + ` + ); + + await expect(withPresets().foo.create({ data: { string: 'bac' } })).toBeRejectedByPolicy(); + await expect(withPresets().foo.create({ data: { string: 'abc' } })).toResolveTruthy(); + }); + + it('endsWith', async () => { + const { withPresets } = await loadSchema( + ` + model Foo { + id String @id @default(cuid()) + string String + @@allow('all', endsWith(string, 'a')) + } + ` + ); + + await expect(withPresets().foo.create({ data: { string: 'bac' } })).toBeRejectedByPolicy(); + await expect(withPresets().foo.create({ data: { string: 'bca' } })).toResolveTruthy(); + }); + + it('in', async () => { + const { withPresets } = await loadSchema( + ` + model Foo { + id String @id @default(cuid()) + string String + @@allow('all', string in ['a', 'b']) + } + ` + ); + + await expect(withPresets().foo.create({ data: { string: 'c' } })).toBeRejectedByPolicy(); + await expect(withPresets().foo.create({ data: { string: 'b' } })).toResolveTruthy(); + }); +}); diff --git a/tests/integration/tests/with-policy/auth.test.ts b/tests/integration/tests/with-policy/auth.test.ts index ac74f451b..57c9fff08 100644 --- a/tests/integration/tests/with-policy/auth.test.ts +++ b/tests/integration/tests/with-policy/auth.test.ts @@ -1,7 +1,7 @@ import { loadSchema } from '@zenstackhq/testtools'; import path from 'path'; -describe('With Policy:undefined user', () => { +describe('With Policy: auth() test', () => { let origDir: string; const suite = 'undefined-user'; @@ -182,13 +182,12 @@ describe('With Policy:undefined user', () => { const db = withPolicy(); await expect(db.user.create({ data: { id: 'user1', role: 'USER' } })).toResolveTruthy(); await expect(db.post.create({ data: { id: '1', title: 'abc', authorId: 'user1' } })).toResolveTruthy(); - await expect(db.post.update({ where: { id: '1' }, data: { title: 'bcd' } })).toBeRejectedByPolicy(); - const authDb = withPolicy({ role: 'USER' }); - await expect(db.post.update({ where: { id: '1' }, data: { title: 'bcd' } })).toBeRejectedByPolicy(); + const authDb = withPolicy({ id: 'user1', role: 'USER' }); + await expect(authDb.post.update({ where: { id: '1' }, data: { title: 'bcd' } })).toBeRejectedByPolicy(); - const authDb1 = withPolicy({ role: 'ADMIN' }); + const authDb1 = withPolicy({ id: 'user2', role: 'ADMIN' }); await expect(authDb1.post.update({ where: { id: '1' }, data: { title: 'bcd' } })).toResolveTruthy(); }); }); diff --git a/tests/integration/tests/with-policy/multi-id-fields.test.ts b/tests/integration/tests/with-policy/multi-id-fields.test.ts index f9984f98f..156b9e2ac 100644 --- a/tests/integration/tests/with-policy/multi-id-fields.test.ts +++ b/tests/integration/tests/with-policy/multi-id-fields.test.ts @@ -68,4 +68,89 @@ describe('With Policy: multiple id fields', () => { }) ).toResolveTruthy(); }); + + it('multi-id auth', async () => { + const { prisma, withPolicy } = await loadSchema( + ` + model User { + x String + y String + m M? + n N? + p P? + q Q? + @@id([x, y]) + @@allow('all', true) + } + + model M { + id String @id @default(cuid()) + owner User @relation(fields: [ownerX, ownerY], references: [x, y]) + ownerX String + ownerY String + @@unique([ownerX, ownerY]) + @@allow('all', auth() == owner) + } + + model N { + id String @id @default(cuid()) + owner User @relation(fields: [ownerX, ownerY], references: [x, y]) + ownerX String + ownerY String + @@unique([ownerX, ownerY]) + @@allow('all', auth().x == owner.x && auth().y == owner.y) + } + + model P { + id String @id @default(cuid()) + owner User @relation(fields: [ownerX, ownerY], references: [x, y]) + ownerX String + ownerY String + @@unique([ownerX, ownerY]) + @@allow('all', auth() != owner) + } + + model Q { + id String @id @default(cuid()) + owner User @relation(fields: [ownerX, ownerY], references: [x, y]) + ownerX String + ownerY String + @@unique([ownerX, ownerY]) + @@allow('all', auth() != null) + } + ` + ); + + await prisma.user.create({ data: { x: '1', y: '1' } }); + await prisma.user.create({ data: { x: '1', y: '2' } }); + + const anonDb = withPolicy({}); + + await expect( + anonDb.m.create({ data: { owner: { connect: { x_y: { x: '1', y: '2' } } } } }) + ).toBeRejectedByPolicy(); + await expect( + anonDb.m.create({ data: { owner: { connect: { x_y: { x: '1', y: '1' } } } } }) + ).toBeRejectedByPolicy(); + await expect( + anonDb.n.create({ data: { owner: { connect: { x_y: { x: '1', y: '2' } } } } }) + ).toBeRejectedByPolicy(); + await expect( + anonDb.n.create({ data: { owner: { connect: { x_y: { x: '1', y: '1' } } } } }) + ).toBeRejectedByPolicy(); + + const db = withPolicy({ x: '1', y: '1' }); + + await expect(db.m.create({ data: { owner: { connect: { x_y: { x: '1', y: '2' } } } } })).toBeRejectedByPolicy(); + await expect(db.m.create({ data: { owner: { connect: { x_y: { x: '1', y: '1' } } } } })).toResolveTruthy(); + await expect(db.n.create({ data: { owner: { connect: { x_y: { x: '1', y: '2' } } } } })).toBeRejectedByPolicy(); + await expect(db.n.create({ data: { owner: { connect: { x_y: { x: '1', y: '1' } } } } })).toResolveTruthy(); + await expect(db.p.create({ data: { owner: { connect: { x_y: { x: '1', y: '1' } } } } })).toBeRejectedByPolicy(); + await expect(db.p.create({ data: { owner: { connect: { x_y: { x: '1', y: '2' } } } } })).toResolveTruthy(); + + await expect( + withPolicy(undefined).q.create({ data: { owner: { connect: { x_y: { x: '1', y: '1' } } } } }) + ).toBeRejectedByPolicy(); + await expect(db.q.create({ data: { owner: { connect: { x_y: { x: '1', y: '2' } } } } })).toResolveTruthy(); + }); });