diff --git a/packages/language/src/generated/ast.ts b/packages/language/src/generated/ast.ts index 98b461285..b4d7e6d82 100644 --- a/packages/language/src/generated/ast.ts +++ b/packages/language/src/generated/ast.ts @@ -20,7 +20,7 @@ export const ZModelTerminals = { SL_COMMENT: /\/\/[^\n\r]*/, }; -export type AbstractDeclaration = Attribute | DataModel | DataSource | Enum | FunctionDecl | GeneratorDecl | Plugin; +export type AbstractDeclaration = Attribute | DataModel | DataSource | Enum | FunctionDecl | GeneratorDecl | Plugin | TypeDef; export const AbstractDeclaration = 'AbstractDeclaration'; @@ -78,10 +78,10 @@ export function isReferenceTarget(item: unknown): item is ReferenceTarget { return reflection.isInstance(item, ReferenceTarget); } -export type RegularID = 'abstract' | 'attribute' | 'datasource' | 'enum' | 'import' | 'in' | 'model' | 'plugin' | 'view' | string; +export type RegularID = 'abstract' | 'attribute' | 'datasource' | 'enum' | 'import' | 'in' | 'model' | 'plugin' | 'type' | 'view' | string; export function isRegularID(item: unknown): item is RegularID { - return item === 'model' || item === 'enum' || item === 'attribute' || item === 'datasource' || item === 'plugin' || item === 'abstract' || item === 'in' || item === 'view' || item === 'import' || (typeof item === 'string' && (/[_a-zA-Z][\w_]*/.test(item))); + return item === 'model' || item === 'enum' || item === 'attribute' || item === 'datasource' || item === 'plugin' || item === 'abstract' || item === 'in' || item === 'view' || item === 'import' || item === 'type' || (typeof item === 'string' && (/[_a-zA-Z][\w_]*/.test(item))); } export type RegularIDWithTypeNames = 'Any' | 'BigInt' | 'Boolean' | 'Bytes' | 'DateTime' | 'Decimal' | 'Float' | 'Int' | 'Json' | 'Null' | 'Object' | 'String' | 'Unsupported' | RegularID; @@ -90,7 +90,7 @@ export function isRegularIDWithTypeNames(item: unknown): item is RegularIDWithTy return isRegularID(item) || item === 'String' || item === 'Boolean' || item === 'Int' || item === 'BigInt' || item === 'Float' || item === 'Decimal' || item === 'DateTime' || item === 'Json' || item === 'Bytes' || item === 'Null' || item === 'Object' || item === 'Any' || item === 'Unsupported'; } -export type TypeDeclaration = DataModel | Enum; +export type TypeDeclaration = DataModel | Enum | TypeDef; export const TypeDeclaration = 'TypeDeclaration'; @@ -305,7 +305,7 @@ export function isDataModelField(item: unknown): item is DataModelField { } export interface DataModelFieldAttribute extends AstNode { - readonly $container: DataModelField | EnumField; + readonly $container: DataModelField | EnumField | TypeDefField; readonly $type: 'DataModelFieldAttribute'; args: Array decl: Reference @@ -620,6 +620,50 @@ export function isThisExpr(item: unknown): item is ThisExpr { return reflection.isInstance(item, ThisExpr); } +export interface TypeDef extends AstNode { + readonly $container: Model; + readonly $type: 'TypeDef'; + comments: Array + fields: Array + name: RegularID +} + +export const TypeDef = 'TypeDef'; + +export function isTypeDef(item: unknown): item is TypeDef { + return reflection.isInstance(item, TypeDef); +} + +export interface TypeDefField extends AstNode { + readonly $container: TypeDef; + readonly $type: 'TypeDefField'; + attributes: Array + comments: Array + name: RegularIDWithTypeNames + type: TypeDefFieldType +} + +export const TypeDefField = 'TypeDefField'; + +export function isTypeDefField(item: unknown): item is TypeDefField { + return reflection.isInstance(item, TypeDefField); +} + +export interface TypeDefFieldType extends AstNode { + readonly $container: TypeDefField; + readonly $type: 'TypeDefFieldType'; + array: boolean + optional: boolean + reference?: Reference + type?: BuiltinType +} + +export const TypeDefFieldType = 'TypeDefFieldType'; + +export function isTypeDefFieldType(item: unknown): item is TypeDefFieldType { + return reflection.isInstance(item, TypeDefFieldType); +} + export interface UnaryExpr extends AstNode { readonly $container: Argument | ArrayExpr | AttributeArg | BinaryExpr | ConfigArrayExpr | ConfigField | ConfigInvocationArg | FieldInitializer | FunctionDecl | MemberAccessExpr | PluginField | ReferenceArg | UnaryExpr | UnsupportedFieldType; readonly $type: 'UnaryExpr'; @@ -691,6 +735,9 @@ export type ZModelAstType = { StringLiteral: StringLiteral ThisExpr: ThisExpr TypeDeclaration: TypeDeclaration + TypeDef: TypeDef + TypeDefField: TypeDefField + TypeDefFieldType: TypeDefFieldType UnaryExpr: UnaryExpr UnsupportedFieldType: UnsupportedFieldType } @@ -698,7 +745,7 @@ export type ZModelAstType = { export class ZModelAstReflection extends AbstractAstReflection { getAllTypes(): string[] { - return ['AbstractDeclaration', 'Argument', 'ArrayExpr', 'Attribute', 'AttributeArg', 'AttributeParam', 'AttributeParamType', 'BinaryExpr', 'BooleanLiteral', 'ConfigArrayExpr', 'ConfigExpr', 'ConfigField', 'ConfigInvocationArg', 'ConfigInvocationExpr', 'DataModel', 'DataModelAttribute', 'DataModelField', 'DataModelFieldAttribute', 'DataModelFieldType', 'DataSource', 'Enum', 'EnumField', 'Expression', 'FieldInitializer', 'FunctionDecl', 'FunctionParam', 'FunctionParamType', 'GeneratorDecl', 'InternalAttribute', 'InvocationExpr', 'LiteralExpr', 'MemberAccessExpr', 'Model', 'ModelImport', 'NullExpr', 'NumberLiteral', 'ObjectExpr', 'Plugin', 'PluginField', 'ReferenceArg', 'ReferenceExpr', 'ReferenceTarget', 'StringLiteral', 'ThisExpr', 'TypeDeclaration', 'UnaryExpr', 'UnsupportedFieldType']; + return ['AbstractDeclaration', 'Argument', 'ArrayExpr', 'Attribute', 'AttributeArg', 'AttributeParam', 'AttributeParamType', 'BinaryExpr', 'BooleanLiteral', 'ConfigArrayExpr', 'ConfigExpr', 'ConfigField', 'ConfigInvocationArg', 'ConfigInvocationExpr', 'DataModel', 'DataModelAttribute', 'DataModelField', 'DataModelFieldAttribute', 'DataModelFieldType', 'DataSource', 'Enum', 'EnumField', 'Expression', 'FieldInitializer', 'FunctionDecl', 'FunctionParam', 'FunctionParamType', 'GeneratorDecl', 'InternalAttribute', 'InvocationExpr', 'LiteralExpr', 'MemberAccessExpr', 'Model', 'ModelImport', 'NullExpr', 'NumberLiteral', 'ObjectExpr', 'Plugin', 'PluginField', 'ReferenceArg', 'ReferenceExpr', 'ReferenceTarget', 'StringLiteral', 'ThisExpr', 'TypeDeclaration', 'TypeDef', 'TypeDefField', 'TypeDefFieldType', 'UnaryExpr', 'UnsupportedFieldType']; } protected override computeIsSubtype(subtype: string, supertype: string): boolean { @@ -729,7 +776,8 @@ export class ZModelAstReflection extends AbstractAstReflection { return this.isSubtype(ConfigExpr, supertype); } case DataModel: - case Enum: { + case Enum: + case TypeDef: { return this.isSubtype(AbstractDeclaration, supertype) || this.isSubtype(TypeDeclaration, supertype); } case DataModelField: @@ -772,6 +820,9 @@ export class ZModelAstReflection extends AbstractAstReflection { case 'ReferenceExpr:target': { return ReferenceTarget; } + case 'TypeDefFieldType:reference': { + return TypeDef; + } default: { throw new Error(`${referenceId} is not a valid reference id.`); } @@ -989,6 +1040,33 @@ export class ZModelAstReflection extends AbstractAstReflection { ] }; } + case 'TypeDef': { + return { + name: 'TypeDef', + mandatory: [ + { name: 'comments', type: 'array' }, + { name: 'fields', type: 'array' } + ] + }; + } + case 'TypeDefField': { + return { + name: 'TypeDefField', + mandatory: [ + { name: 'attributes', type: 'array' }, + { name: 'comments', type: 'array' } + ] + }; + } + case 'TypeDefFieldType': { + return { + name: 'TypeDefFieldType', + mandatory: [ + { name: 'array', type: 'boolean' }, + { name: 'optional', type: 'boolean' } + ] + }; + } default: { return { name: type, diff --git a/packages/language/src/generated/grammar.ts b/packages/language/src/generated/grammar.ts index 3b172570d..01c492bd5 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@64" + "$ref": "#/rules@67" }, "arguments": [] } @@ -126,21 +126,28 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@41" + "$ref": "#/rules@40" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@43" + "$ref": "#/rules@44" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@48" + "$ref": "#/rules@46" + }, + "arguments": [] + }, + { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@51" }, "arguments": [] } @@ -162,7 +169,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [], "cardinality": "*" @@ -178,7 +185,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] } @@ -222,7 +229,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [], "cardinality": "*" @@ -238,7 +245,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] } @@ -282,7 +289,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [], "cardinality": "*" @@ -294,7 +301,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] } @@ -333,7 +340,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [], "cardinality": "*" @@ -349,7 +356,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] } @@ -393,7 +400,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [], "cardinality": "*" @@ -405,7 +412,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] } @@ -481,7 +488,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@65" + "$ref": "#/rules@68" }, "arguments": [] } @@ -503,7 +510,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@64" + "$ref": "#/rules@67" }, "arguments": [] } @@ -525,7 +532,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@58" + "$ref": "#/rules@61" }, "arguments": [] } @@ -649,7 +656,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@63" + "$ref": "#/rules@66" }, "arguments": [] } @@ -747,7 +754,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@63" + "$ref": "#/rules@66" }, "arguments": [] } @@ -956,7 +963,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@47" + "$ref": "#/rules@50" }, "arguments": [] }, @@ -1055,7 +1062,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@63" + "$ref": "#/rules@66" }, "arguments": [] } @@ -1169,14 +1176,14 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@64" + "$ref": "#/rules@67" }, "arguments": [] } @@ -1221,7 +1228,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/rules@43" + "$ref": "#/rules@46" }, "deprecatedSyntax": false } @@ -1894,7 +1901,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [] }, @@ -1927,7 +1934,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] } @@ -1997,7 +2004,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] } @@ -2032,7 +2039,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@55" }, "arguments": [] } @@ -2066,7 +2073,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [] }, @@ -2079,7 +2086,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@47" + "$ref": "#/rules@50" }, "arguments": [] } @@ -2103,7 +2110,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@51" + "$ref": "#/rules@54" }, "arguments": [] }, @@ -2134,7 +2141,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@57" + "$ref": "#/rules@60" }, "arguments": [] } @@ -2146,7 +2153,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@40" + "$ref": "#/rules@43" }, "arguments": [] } @@ -2163,7 +2170,217 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" + }, + "arguments": [] + }, + "deprecatedSyntax": false + } + } + ] + }, + { + "$type": "Group", + "elements": [ + { + "$type": "Assignment", + "feature": "array", + "operator": "?=", + "terminal": { + "$type": "Keyword", + "value": "[" + } + }, + { + "$type": "Keyword", + "value": "]" + } + ], + "cardinality": "?" + }, + { + "$type": "Assignment", + "feature": "optional", + "operator": "?=", + "terminal": { + "$type": "Keyword", + "value": "?" + }, + "cardinality": "?" + } + ] + }, + "definesHiddenTokens": false, + "entry": false, + "fragment": false, + "hiddenTokens": [], + "parameters": [], + "wildcard": false + }, + { + "$type": "ParserRule", + "name": "TypeDef", + "definition": { + "$type": "Group", + "elements": [ + { + "$type": "Assignment", + "feature": "comments", + "operator": "+=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@69" + }, + "arguments": [] + }, + "cardinality": "*" + }, + { + "$type": "Keyword", + "value": "type" + }, + { + "$type": "Assignment", + "feature": "name", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@49" + }, + "arguments": [] + } + }, + { + "$type": "Keyword", + "value": "{" + }, + { + "$type": "Assignment", + "feature": "fields", + "operator": "+=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@41" + }, + "arguments": [] + }, + "cardinality": "+" + }, + { + "$type": "Keyword", + "value": "}" + } + ] + }, + "definesHiddenTokens": false, + "entry": false, + "fragment": false, + "hiddenTokens": [], + "parameters": [], + "wildcard": false + }, + { + "$type": "ParserRule", + "name": "TypeDefField", + "definition": { + "$type": "Group", + "elements": [ + { + "$type": "Assignment", + "feature": "comments", + "operator": "+=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@69" + }, + "arguments": [] + }, + "cardinality": "*" + }, + { + "$type": "Assignment", + "feature": "name", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@50" + }, + "arguments": [] + } + }, + { + "$type": "Assignment", + "feature": "type", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@42" + }, + "arguments": [] + } + }, + { + "$type": "Assignment", + "feature": "attributes", + "operator": "+=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@54" + }, + "arguments": [] + }, + "cardinality": "*" + } + ] + }, + "definesHiddenTokens": false, + "entry": false, + "fragment": false, + "hiddenTokens": [], + "parameters": [], + "wildcard": false + }, + { + "$type": "ParserRule", + "name": "TypeDefFieldType", + "definition": { + "$type": "Group", + "elements": [ + { + "$type": "Alternatives", + "elements": [ + { + "$type": "Assignment", + "feature": "type", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@60" + }, + "arguments": [] + } + }, + { + "$type": "Assignment", + "feature": "reference", + "operator": "=", + "terminal": { + "$type": "CrossReference", + "type": { + "$ref": "#/rules@40" + }, + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@49" }, "arguments": [] }, @@ -2262,7 +2479,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [] }, @@ -2279,7 +2496,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] } @@ -2298,7 +2515,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@42" + "$ref": "#/rules@45" }, "arguments": [] } @@ -2310,7 +2527,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@55" }, "arguments": [] } @@ -2344,7 +2561,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [] }, @@ -2357,7 +2574,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@47" + "$ref": "#/rules@50" }, "arguments": [] } @@ -2369,7 +2586,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@51" + "$ref": "#/rules@54" }, "arguments": [] }, @@ -2393,7 +2610,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [], "cardinality": "*" @@ -2409,7 +2626,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] } @@ -2428,7 +2645,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@44" + "$ref": "#/rules@47" }, "arguments": [] } @@ -2447,7 +2664,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@44" + "$ref": "#/rules@47" }, "arguments": [] } @@ -2473,7 +2690,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@45" + "$ref": "#/rules@48" }, "arguments": [] } @@ -2506,7 +2723,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@53" + "$ref": "#/rules@56" }, "arguments": [] }, @@ -2530,7 +2747,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [], "cardinality": "*" @@ -2542,7 +2759,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] } @@ -2558,7 +2775,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@45" + "$ref": "#/rules@48" }, "arguments": [] } @@ -2598,7 +2815,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@56" + "$ref": "#/rules@59" }, "arguments": [] } @@ -2615,7 +2832,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] }, @@ -2662,7 +2879,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@63" + "$ref": "#/rules@66" }, "arguments": [] }, @@ -2701,6 +2918,10 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "Keyword", "value": "import" + }, + { + "$type": "Keyword", + "value": "type" } ] }, @@ -2721,7 +2942,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] }, @@ -2799,7 +3020,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [] }, @@ -2819,21 +3040,21 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@60" + "$ref": "#/rules@63" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@61" + "$ref": "#/rules@64" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@62" + "$ref": "#/rules@65" }, "arguments": [] } @@ -2854,7 +3075,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@49" + "$ref": "#/rules@52" }, "arguments": [] } @@ -2873,7 +3094,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@49" + "$ref": "#/rules@52" }, "arguments": [] } @@ -2895,7 +3116,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@53" + "$ref": "#/rules@56" }, "arguments": [] }, @@ -2923,7 +3144,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [] }, @@ -2946,7 +3167,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] } @@ -2962,7 +3183,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@50" + "$ref": "#/rules@53" }, "arguments": [] } @@ -2974,7 +3195,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@53" + "$ref": "#/rules@56" }, "arguments": [] }, @@ -3008,7 +3229,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@56" + "$ref": "#/rules@59" }, "arguments": [] }, @@ -3039,7 +3260,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] }, @@ -3099,12 +3320,12 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/rules@48" + "$ref": "#/rules@51" }, "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@62" + "$ref": "#/rules@65" }, "arguments": [] }, @@ -3121,7 +3342,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@54" + "$ref": "#/rules@57" }, "arguments": [], "cardinality": "?" @@ -3151,7 +3372,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [], "cardinality": "*" @@ -3163,12 +3384,12 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/rules@48" + "$ref": "#/rules@51" }, "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@61" + "$ref": "#/rules@64" }, "arguments": [] }, @@ -3185,7 +3406,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@54" + "$ref": "#/rules@57" }, "arguments": [], "cardinality": "?" @@ -3219,12 +3440,12 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/rules@48" + "$ref": "#/rules@51" }, "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@60" + "$ref": "#/rules@63" }, "arguments": [] }, @@ -3241,7 +3462,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@54" + "$ref": "#/rules@57" }, "arguments": [], "cardinality": "?" @@ -3276,7 +3497,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@58" }, "arguments": [] } @@ -3295,7 +3516,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@58" }, "arguments": [] } @@ -3327,7 +3548,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] } @@ -3599,7 +3820,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "SimpleType", "typeRef": { - "$ref": "#/rules@44" + "$ref": "#/rules@47" } }, { @@ -3611,7 +3832,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "SimpleType", "typeRef": { - "$ref": "#/rules@42" + "$ref": "#/rules@45" } } ] @@ -3632,7 +3853,13 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "SimpleType", "typeRef": { - "$ref": "#/rules@41" + "$ref": "#/rules@40" + } + }, + { + "$type": "SimpleType", + "typeRef": { + "$ref": "#/rules@44" } } ] diff --git a/packages/language/src/zmodel.langium b/packages/language/src/zmodel.langium index 4f12159d6..d66ea5f32 100644 --- a/packages/language/src/zmodel.langium +++ b/packages/language/src/zmodel.langium @@ -11,7 +11,7 @@ ModelImport: 'import' path=STRING ';'?; AbstractDeclaration: - DataSource | GeneratorDecl| Plugin | DataModel | Enum | FunctionDecl | Attribute; + DataSource | GeneratorDecl| Plugin | DataModel | TypeDef | Enum | FunctionDecl | Attribute; // datasource DataSource: @@ -113,22 +113,6 @@ CollectionPredicateExpr infers Expression: '[' right=Expression ']' )*; -// TODO: support arithmetics? -// -// MultDivExpr infers Expression: -// CollectionPredicateExpr ( -// {infer BinaryExpr.left=current} -// operator=('*'|'/') -// right=CollectionPredicateExpr -// )*; - -// AddSubExpr infers Expression: -// MultDivExpr ( -// {infer BinaryExpr.left=current} -// operator=('+'|'-') -// right=MultDivExpr -// )*; - InExpr infers Expression: CollectionPredicateExpr ( {infer BinaryExpr.left=current} @@ -195,6 +179,20 @@ DataModelField: DataModelFieldType: (type=BuiltinType | unsupported=UnsupportedFieldType | reference=[TypeDeclaration:RegularID]) (array?='[' ']')? (optional?='?')?; +TypeDef: + (comments+=TRIPLE_SLASH_COMMENT)* + 'type' name=RegularID '{' ( + fields+=TypeDefField + )+ + '}'; + +TypeDefField: + (comments+=TRIPLE_SLASH_COMMENT)* + name=RegularIDWithTypeNames type=TypeDefFieldType (attributes+=DataModelFieldAttribute)*; + +TypeDefFieldType: + (type=BuiltinType | reference=[TypeDef:RegularID]) (array?='[' ']')? (optional?='?')?; + UnsupportedFieldType: 'Unsupported' '(' (value=LiteralExpr) ')'; @@ -224,7 +222,7 @@ FunctionParamType: // https://github.com/langium/langium/discussions/1012 RegularID returns string: // include keywords that we'd like to work as ID in most places - ID | 'model' | 'enum' | 'attribute' | 'datasource' | 'plugin' | 'abstract' | 'in' | 'view' | 'import'; + ID | 'model' | 'enum' | 'attribute' | 'datasource' | 'plugin' | 'abstract' | 'in' | 'view' | 'import' | 'type'; RegularIDWithTypeNames returns string: RegularID | 'String' | 'Boolean' | 'Int' | 'BigInt' | 'Float' | 'Decimal' | 'DateTime' | 'Json' | 'Bytes' | 'Null' | 'Object' | 'Any' | 'Unsupported'; @@ -241,7 +239,7 @@ AttributeParam: AttributeParamType: (type=(ExpressionType | 'FieldReference' | 'TransitiveFieldReference' | 'ContextType') | reference=[TypeDeclaration:RegularID]) (array?='[' ']')? (optional?='?')?; -type TypeDeclaration = DataModel | Enum; +type TypeDeclaration = DataModel | TypeDef | Enum; DataModelFieldAttribute: decl=[Attribute:FIELD_ATTRIBUTE_NAME] ('(' AttributeArgList? ')')?; diff --git a/packages/language/syntaxes/zmodel.tmLanguage b/packages/language/syntaxes/zmodel.tmLanguage index 6102b919d..40b92fb9a 100644 --- a/packages/language/syntaxes/zmodel.tmLanguage +++ b/packages/language/syntaxes/zmodel.tmLanguage @@ -20,7 +20,7 @@ name keyword.control.zmodel match - \b(Any|BigInt|Boolean|Bytes|ContextType|DateTime|Decimal|FieldReference|Float|Int|Json|Null|Object|String|TransitiveFieldReference|Unsupported|abstract|attribute|datasource|enum|extends|false|function|generator|import|in|model|null|plugin|this|true|view)\b + \b(Any|BigInt|Boolean|Bytes|ContextType|DateTime|Decimal|FieldReference|Float|Int|Json|Null|Object|String|TransitiveFieldReference|Unsupported|abstract|attribute|datasource|enum|extends|false|function|generator|import|in|model|null|plugin|this|true|type|view)\b name diff --git a/packages/language/syntaxes/zmodel.tmLanguage.json b/packages/language/syntaxes/zmodel.tmLanguage.json index aad6a38c7..0fb0227e5 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|BigInt|Boolean|Bytes|ContextType|DateTime|Decimal|FieldReference|Float|Int|Json|Null|Object|String|TransitiveFieldReference|Unsupported|abstract|attribute|datasource|enum|extends|false|function|generator|import|in|model|null|plugin|this|true|view)\\b" + "match": "\\b(Any|BigInt|Boolean|Bytes|ContextType|DateTime|Decimal|FieldReference|Float|Int|Json|Null|Object|String|TransitiveFieldReference|Unsupported|abstract|attribute|datasource|enum|extends|false|function|generator|import|in|model|null|plugin|this|true|type|view)\\b" }, { "name": "string.quoted.double.zmodel", diff --git a/packages/plugins/swr/src/generator.ts b/packages/plugins/swr/src/generator.ts index 46d18d296..967be9727 100644 --- a/packages/plugins/swr/src/generator.ts +++ b/packages/plugins/swr/src/generator.ts @@ -10,7 +10,7 @@ import { resolvePath, saveProject, } from '@zenstackhq/sdk'; -import { DataModel, DataModelFieldType, Model, isEnum } from '@zenstackhq/sdk/ast'; +import { DataModel, DataModelFieldType, Model, isEnum, isTypeDef } from '@zenstackhq/sdk/ast'; import { getPrismaClientImportSpec, supportCreateMany, type DMMF } from '@zenstackhq/sdk/prisma'; import { paramCase } from 'change-case'; import path from 'path'; @@ -28,8 +28,9 @@ export async function generate(model: Model, options: PluginOptions, dmmf: DMMF. const warnings: string[] = []; const models = getDataModels(model); + const typeDefs = model.declarations.filter(isTypeDef); - await generateModelMeta(project, models, { + await generateModelMeta(project, models, typeDefs, { output: path.join(outDir, '__model_meta.ts'), generateAttributes: false, }); diff --git a/packages/plugins/tanstack-query/src/generator.ts b/packages/plugins/tanstack-query/src/generator.ts index afb86f9c7..8833f77f9 100644 --- a/packages/plugins/tanstack-query/src/generator.ts +++ b/packages/plugins/tanstack-query/src/generator.ts @@ -11,7 +11,7 @@ import { resolvePath, saveProject, } from '@zenstackhq/sdk'; -import { DataModel, DataModelFieldType, Model, isEnum } from '@zenstackhq/sdk/ast'; +import { DataModel, DataModelFieldType, Model, isEnum, isTypeDef } from '@zenstackhq/sdk/ast'; import { getPrismaClientImportSpec, supportCreateMany, type DMMF } from '@zenstackhq/sdk/prisma'; import { paramCase } from 'change-case'; import { lowerCaseFirst } from 'lower-case-first'; @@ -29,6 +29,7 @@ export async function generate(model: Model, options: PluginOptions, dmmf: DMMF. const project = createProject(); const warnings: string[] = []; const models = getDataModels(model); + const typeDefs = model.declarations.filter(isTypeDef); const target = requireOption(options, 'target', name); if (!supportedTargets.includes(target)) { @@ -44,7 +45,7 @@ export async function generate(model: Model, options: PluginOptions, dmmf: DMMF. outDir = resolvePath(outDir, options); ensureEmptyDir(outDir); - await generateModelMeta(project, models, { + await generateModelMeta(project, models, typeDefs, { output: path.join(outDir, '__model_meta.ts'), generateAttributes: false, }); diff --git a/packages/runtime/src/cross/model-meta.ts b/packages/runtime/src/cross/model-meta.ts index 3eb6b3786..3b27f1686 100644 --- a/packages/runtime/src/cross/model-meta.ts +++ b/packages/runtime/src/cross/model-meta.ts @@ -44,6 +44,11 @@ export type FieldInfo = { */ isDataModel?: boolean; + /** + * If the field type is a type def (or an optional/array of type def) + */ + isTypeDef?: boolean; + /** * If the field is an array */ @@ -143,6 +148,21 @@ export type ModelInfo = { discriminator?: string; }; +/** + * Metadata for a type def + */ +export type TypeDefInfo = { + /** + * TypeDef name + */ + name: string; + + /** + * Fields + */ + fields: Record; +}; + /** * ZModel data model metadata */ @@ -152,6 +172,11 @@ export type ModelMeta = { */ models: Record; + /** + * Type defs + */ + typeDefs?: Record; + /** * Mapping from model name to models that will be deleted because of it due to cascade delete */ @@ -171,15 +196,21 @@ export type ModelMeta = { /** * Resolves a model field to its metadata. Returns undefined if not found. */ -export function resolveField(modelMeta: ModelMeta, model: string, field: string): FieldInfo | undefined { - return modelMeta.models[lowerCaseFirst(model)]?.fields?.[field]; +export function resolveField( + modelMeta: ModelMeta, + modelOrTypeDef: string, + field: string, + isTypeDef = false +): FieldInfo | undefined { + const container = isTypeDef ? modelMeta.typeDefs : modelMeta.models; + return container?.[lowerCaseFirst(modelOrTypeDef)]?.fields?.[field]; } /** * Resolves a model field to its metadata. Throws an error if not found. */ -export function requireField(modelMeta: ModelMeta, model: string, field: string) { - const f = resolveField(modelMeta, model, field); +export function requireField(modelMeta: ModelMeta, model: string, field: string, isTypeDef = false) { + const f = resolveField(modelMeta, model, field, isTypeDef); if (!f) { throw new Error(`Field ${model}.${field} cannot be resolved`); } diff --git a/packages/runtime/src/cross/utils.ts b/packages/runtime/src/cross/utils.ts index 304b9b618..b1cb67e12 100644 --- a/packages/runtime/src/cross/utils.ts +++ b/packages/runtime/src/cross/utils.ts @@ -1,5 +1,5 @@ import { lowerCaseFirst } from 'lower-case-first'; -import { requireField, type ModelInfo, type ModelMeta } from '.'; +import { requireField, type ModelInfo, type ModelMeta, type TypeDefInfo } from '.'; /** * Gets field names in a data model entity, filtering out internal fields. @@ -46,6 +46,9 @@ export function zip(x: Enumerable, y: Enumerable): Array<[T1, T2 } } +/** + * Gets ID fields of a model. + */ export function getIdFields(modelMeta: ModelMeta, model: string, throwIfNotFound = false) { const uniqueConstraints = modelMeta.models[lowerCaseFirst(model)]?.uniqueConstraints ?? {}; @@ -60,6 +63,9 @@ export function getIdFields(modelMeta: ModelMeta, model: string, throwIfNotFound return entries[0].fields.map((f) => requireField(modelMeta, model, f)); } +/** + * Gets info for a model. + */ export function getModelInfo( modelMeta: ModelMeta, model: string, @@ -72,6 +78,25 @@ export function getModelInfo( return info; } +/** + * Gets info for a type def. + */ +export function getTypeDefInfo( + modelMeta: ModelMeta, + typeDef: string, + throwIfNotFound: Throw = false as Throw +): Throw extends true ? TypeDefInfo : TypeDefInfo | undefined { + const info = modelMeta.typeDefs?.[lowerCaseFirst(typeDef)]; + if (!info && throwIfNotFound) { + throw new Error(`Unable to load info for ${typeDef}`); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return info as any; +} + +/** + * Checks if a model is a delegate model. + */ export function isDelegateModel(modelMeta: ModelMeta, model: string) { return !!getModelInfo(modelMeta, model)?.attributes?.some((attr) => attr.name === '@@delegate'); } diff --git a/packages/runtime/src/enhancements/edge/json-processor.ts b/packages/runtime/src/enhancements/edge/json-processor.ts new file mode 120000 index 000000000..4144fc6f4 --- /dev/null +++ b/packages/runtime/src/enhancements/edge/json-processor.ts @@ -0,0 +1 @@ +../node/json-processor.ts \ No newline at end of file diff --git a/packages/runtime/src/enhancements/node/create-enhancement.ts b/packages/runtime/src/enhancements/node/create-enhancement.ts index 263e12192..adec1fdf2 100644 --- a/packages/runtime/src/enhancements/node/create-enhancement.ts +++ b/packages/runtime/src/enhancements/node/create-enhancement.ts @@ -10,6 +10,7 @@ import type { } from '../../types'; import { withDefaultAuth } from './default-auth'; import { withDelegate } from './delegate'; +import { withJsonProcessor } from './json-processor'; import { Logger } from './logger'; import { withOmit } from './omit'; import { withPassword } from './password'; @@ -90,10 +91,18 @@ export function createEnhancement( // TODO: move the detection logic into each enhancement // TODO: how to properly cache the detection result? + const allFields = Object.values(options.modelMeta.models).flatMap((modelInfo) => Object.values(modelInfo.fields)); + if (options.modelMeta.typeDefs) { + allFields.push( + ...Object.values(options.modelMeta.typeDefs).flatMap((typeDefInfo) => Object.values(typeDefInfo.fields)) + ); + } + const hasPassword = allFields.some((field) => field.attributes?.some((attr) => attr.name === '@password')); const hasOmit = allFields.some((field) => field.attributes?.some((attr) => attr.name === '@omit')); const hasDefaultAuth = allFields.some((field) => field.defaultValueProvider); + const hasTypeDefField = allFields.some((field) => field.isTypeDef); const kinds = options.kinds ?? ALL_ENHANCEMENTS; let result = prisma; @@ -142,5 +151,9 @@ export function createEnhancement( result = withOmit(result, options); } + if (hasTypeDefField) { + result = withJsonProcessor(result, options); + } + return result; } diff --git a/packages/runtime/src/enhancements/node/default-auth.ts b/packages/runtime/src/enhancements/node/default-auth.ts index 3852069c8..03ce3750c 100644 --- a/packages/runtime/src/enhancements/node/default-auth.ts +++ b/packages/runtime/src/enhancements/node/default-auth.ts @@ -8,6 +8,7 @@ import { clone, enumerate, getFields, + getTypeDefInfo, requireField, } from '../../cross'; import { DbClientContract, EnhancementContext } from '../../types'; @@ -70,6 +71,11 @@ class DefaultAuthHandler extends DefaultPrismaProxyHandler { const processCreatePayload = (model: string, data: any) => { const fields = getFields(this.options.modelMeta, model); for (const fieldInfo of Object.values(fields)) { + if (fieldInfo.isTypeDef) { + this.setDefaultValueForTypeDefData(fieldInfo.type, data[fieldInfo.name]); + continue; + } + if (fieldInfo.name in data) { // create payload already sets field value continue; @@ -80,10 +86,10 @@ class DefaultAuthHandler extends DefaultPrismaProxyHandler { continue; } - const authDefaultValue = this.getDefaultValueFromAuth(fieldInfo); - if (authDefaultValue !== undefined) { + const defaultValue = this.getDefaultValue(fieldInfo); + if (defaultValue !== undefined) { // set field value extracted from `auth()` - this.setAuthDefaultValue(fieldInfo, model, data, authDefaultValue); + this.setDefaultValueForModelData(fieldInfo, model, data, defaultValue); } } }; @@ -109,7 +115,7 @@ class DefaultAuthHandler extends DefaultPrismaProxyHandler { return newArgs; } - private setAuthDefaultValue(fieldInfo: FieldInfo, model: string, data: any, authDefaultValue: unknown) { + private setDefaultValueForModelData(fieldInfo: FieldInfo, model: string, data: any, authDefaultValue: unknown) { if (fieldInfo.isForeignKey && fieldInfo.relationField && fieldInfo.relationField in data) { // if the field is a fk, and the relation field is already set, we should not override it return; @@ -155,7 +161,7 @@ class DefaultAuthHandler extends DefaultPrismaProxyHandler { return entry?.[0]; } - private getDefaultValueFromAuth(fieldInfo: FieldInfo) { + private getDefaultValue(fieldInfo: FieldInfo) { if (!this.userContext) { throw prismaClientValidationError( this.prisma, @@ -165,4 +171,34 @@ class DefaultAuthHandler extends DefaultPrismaProxyHandler { } return fieldInfo.defaultValueProvider?.(this.userContext); } + + private setDefaultValueForTypeDefData(type: string, data: any) { + if (!data || (typeof data !== 'object' && !Array.isArray(data))) { + return; + } + + const typeDef = getTypeDefInfo(this.options.modelMeta, type); + if (!typeDef) { + return; + } + + enumerate(data).forEach((item) => { + if (!item || typeof item !== 'object') { + return; + } + + for (const fieldInfo of Object.values(typeDef.fields)) { + if (fieldInfo.isTypeDef) { + // recurse + this.setDefaultValueForTypeDefData(fieldInfo.type, item[fieldInfo.name]); + } else if (!(fieldInfo.name in item)) { + // set default value if the payload doesn't set the field + const defaultValue = this.getDefaultValue(fieldInfo); + if (defaultValue !== undefined) { + item[fieldInfo.name] = defaultValue; + } + } + } + }); + } } diff --git a/packages/runtime/src/enhancements/node/json-processor.ts b/packages/runtime/src/enhancements/node/json-processor.ts new file mode 100644 index 000000000..6cf204a6f --- /dev/null +++ b/packages/runtime/src/enhancements/node/json-processor.ts @@ -0,0 +1,92 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { enumerate, getModelFields, resolveField } from '../../cross'; +import { DbClientContract } from '../../types'; +import { InternalEnhancementOptions } from './create-enhancement'; +import { DefaultPrismaProxyHandler, makeProxy, PrismaProxyActions } from './proxy'; +import { QueryUtils } from './query-utils'; + +/** + * Gets an enhanced Prisma client that post-processes JSON values. + * + * @private + */ +export function withJsonProcessor( + prisma: DbClient, + options: InternalEnhancementOptions +): DbClient { + return makeProxy( + prisma, + options.modelMeta, + (_prisma, model) => new JsonProcessorHandler(_prisma as DbClientContract, model, options), + 'json-processor' + ); +} + +class JsonProcessorHandler extends DefaultPrismaProxyHandler { + private queryUtils: QueryUtils; + + constructor(prisma: DbClientContract, model: string, options: InternalEnhancementOptions) { + super(prisma, model, options); + this.queryUtils = new QueryUtils(prisma, options); + } + + protected override async processResultEntity(_method: PrismaProxyActions, data: T): Promise { + for (const value of enumerate(data)) { + await this.doPostProcess(value, this.model); + } + return data; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async doPostProcess(entityData: any, model: string) { + const realModel = this.queryUtils.getDelegateConcreteModel(model, entityData); + + for (const field of getModelFields(entityData)) { + const fieldInfo = await resolveField(this.options.modelMeta, realModel, field); + if (!fieldInfo) { + continue; + } + + if (fieldInfo.isTypeDef) { + this.fixJsonDateFields(entityData[field], fieldInfo.type); + } else if (fieldInfo.isDataModel) { + const items = + fieldInfo.isArray && Array.isArray(entityData[field]) ? entityData[field] : [entityData[field]]; + for (const item of items) { + // recurse + await this.doPostProcess(item, fieldInfo.type); + } + } + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private fixJsonDateFields(entityData: any, typeDef: string) { + if (typeof entityData !== 'object' && !Array.isArray(entityData)) { + return; + } + + enumerate(entityData).forEach((item) => { + if (!item || typeof item !== 'object') { + return; + } + + for (const [key, value] of Object.entries(item)) { + const fieldInfo = resolveField(this.options.modelMeta, typeDef, key, true); + if (!fieldInfo) { + continue; + } + if (fieldInfo.isTypeDef) { + // recurse + this.fixJsonDateFields(value, fieldInfo.type); + } else if (fieldInfo.type === 'DateTime' && typeof value === 'string') { + // convert to Date + const parsed = Date.parse(value); + if (!isNaN(parsed)) { + item[key] = new Date(parsed); + } + } + } + }); + } +} diff --git a/packages/schema/src/cli/plugin-runner.ts b/packages/schema/src/cli/plugin-runner.ts index 2912bfb60..4158bd256 100644 --- a/packages/schema/src/cli/plugin-runner.ts +++ b/packages/schema/src/cli/plugin-runner.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-var-requires */ -import { isPlugin, Model, Plugin } from '@zenstackhq/language/ast'; +import { DataModel, isPlugin, isTypeDef, Model, Plugin } from '@zenstackhq/language/ast'; import { createProject, emitProject, @@ -311,7 +311,11 @@ export class PluginRunner { } private hasValidation(schema: Model) { - return getDataModels(schema).some((model) => hasValidationAttributes(model)); + return getDataModels(schema).some((model) => hasValidationAttributes(model) || this.hasTypeDefFields(model)); + } + + private hasTypeDefFields(model: DataModel) { + return model.fields.some((f) => isTypeDef(f.type.reference?.ref)); } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/schema/src/language-server/validator/attribute-application-validator.ts b/packages/schema/src/language-server/validator/attribute-application-validator.ts index 9ec2074be..2a7f43c29 100644 --- a/packages/schema/src/language-server/validator/attribute-application-validator.ts +++ b/packages/schema/src/language-server/validator/attribute-application-validator.ts @@ -14,8 +14,11 @@ import { isDataModelField, isEnum, isReferenceExpr, + isTypeDef, + isTypeDefField, } from '@zenstackhq/language/ast'; import { + hasAttribute, isDataModelFieldReference, isDelegateModel, isFutureExpr, @@ -62,6 +65,10 @@ export default class AttributeApplicationValidator implements AstValidator(); for (const arg of attr.args) { @@ -345,6 +352,9 @@ function isValidAttributeTarget(attrDecl: Attribute, targetDecl: DataModelField) case 'ModelField': allowed = allowed || isDataModel(targetDecl.type.reference?.ref); break; + case 'TypeDefField': + allowed = allowed || isTypeDef(targetDecl.type.reference?.ref); + break; default: break; } diff --git a/packages/schema/src/language-server/validator/datamodel-validator.ts b/packages/schema/src/language-server/validator/datamodel-validator.ts index e463f740f..f2a3d6737 100644 --- a/packages/schema/src/language-server/validator/datamodel-validator.ts +++ b/packages/schema/src/language-server/validator/datamodel-validator.ts @@ -6,8 +6,16 @@ import { isDataModel, isEnum, isStringLiteral, + isTypeDef, } from '@zenstackhq/language/ast'; -import { getModelFieldsWithBases, getModelIdFields, getModelUniqueFields, isDelegateModel } from '@zenstackhq/sdk'; +import { + getDataSourceProvider, + getModelFieldsWithBases, + getModelIdFields, + getModelUniqueFields, + hasAttribute, + isDelegateModel, +} from '@zenstackhq/sdk'; import { AstNode, DiagnosticInfo, ValidationAcceptor, getDocument } from 'langium'; import { findUpInheritance } from '../../utils/ast-utils'; import { IssueCodes, SCALAR_TYPES } from '../constants'; @@ -95,6 +103,16 @@ export default class DataModelValidator implements AstValidator { } field.attributes.forEach((attr) => validateAttributeApplication(attr, accept)); + + if (isTypeDef(field.type.reference?.ref)) { + if (!hasAttribute(field, '@json')) { + accept('error', 'Custom-typed field must have @json attribute', { node: field }); + } + + if (getDataSourceProvider(field.$container.$container) !== 'postgresql') { + accept('error', 'Custom-typed field is only supported with "postgresql" provider', { node: field }); + } + } } private validateAttributes(dm: DataModel, accept: ValidationAcceptor) { diff --git a/packages/schema/src/language-server/validator/typedef-validator.ts b/packages/schema/src/language-server/validator/typedef-validator.ts new file mode 100644 index 000000000..55c127d7d --- /dev/null +++ b/packages/schema/src/language-server/validator/typedef-validator.ts @@ -0,0 +1,23 @@ +import { TypeDef, TypeDefField } from '@zenstackhq/language/ast'; +import { ValidationAcceptor } from 'langium'; +import { AstValidator } from '../types'; +import { validateAttributeApplication } from './attribute-application-validator'; +import { validateDuplicatedDeclarations } from './utils'; + +/** + * Validates type def declarations. + */ +export default class TypeDefValidator implements AstValidator { + validate(typeDef: TypeDef, accept: ValidationAcceptor): void { + validateDuplicatedDeclarations(typeDef, typeDef.fields, accept); + this.validateFields(typeDef, accept); + } + + private validateFields(typeDef: TypeDef, accept: ValidationAcceptor) { + typeDef.fields.forEach((field) => this.validateField(field, accept)); + } + + private validateField(field: TypeDefField, accept: ValidationAcceptor): void { + field.attributes.forEach((attr) => validateAttributeApplication(attr, accept)); + } +} diff --git a/packages/schema/src/language-server/validator/zmodel-validator.ts b/packages/schema/src/language-server/validator/zmodel-validator.ts index 493bc5f89..c1dcbb09e 100644 --- a/packages/schema/src/language-server/validator/zmodel-validator.ts +++ b/packages/schema/src/language-server/validator/zmodel-validator.ts @@ -7,6 +7,7 @@ import { FunctionDecl, InvocationExpr, Model, + TypeDef, ZModelAstType, } from '@zenstackhq/language/ast'; import { AstNode, LangiumDocument, ValidationAcceptor, ValidationChecks, ValidationRegistry } from 'langium'; @@ -19,6 +20,7 @@ import ExpressionValidator from './expression-validator'; import FunctionDeclValidator from './function-decl-validator'; import FunctionInvocationValidator from './function-invocation-validator'; import SchemaValidator from './schema-validator'; +import TypeDefValidator from './typedef-validator'; /** * Registry for validation checks. @@ -31,6 +33,7 @@ export class ZModelValidationRegistry extends ValidationRegistry { Model: validator.checkModel, DataSource: validator.checkDataSource, DataModel: validator.checkDataModel, + TypeDef: validator.checkTypeDef, Enum: validator.checkEnum, Attribute: validator.checkAttribute, Expression: validator.checkExpression, @@ -73,6 +76,10 @@ export class ZModelValidator { this.shouldCheck(node) && new DataModelValidator().validate(node, accept); } + checkTypeDef(node: TypeDef, accept: ValidationAcceptor): void { + this.shouldCheck(node) && new TypeDefValidator().validate(node, accept); + } + checkEnum(node: Enum, accept: ValidationAcceptor): void { this.shouldCheck(node) && new EnumValidator().validate(node, accept); } diff --git a/packages/schema/src/plugins/enhancer/enhance/index.ts b/packages/schema/src/plugins/enhancer/enhance/index.ts index 34cf26640..3230d72f9 100644 --- a/packages/schema/src/plugins/enhancer/enhance/index.ts +++ b/packages/schema/src/plugins/enhancer/enhance/index.ts @@ -19,6 +19,7 @@ import { isDataModel, isGeneratorDecl, isReferenceExpr, + isTypeDef, type Model, } from '@zenstackhq/sdk/ast'; import { getDMMF, getPrismaClientImportSpec, getPrismaVersion, type DMMF } from '@zenstackhq/sdk/prisma'; @@ -45,6 +46,7 @@ import { PrismaSchemaGenerator } from '../../prisma/schema-generator'; import { isDefaultWithAuth } from '../enhancer-utils'; import { generateAuthType } from './auth-type-generator'; import { generateCheckerType } from './checker-type-generator'; +import { generateTypeDefType } from './model-typedef-generator'; // information of delegate models and their sub models type DelegateInfo = [DataModel, DataModel[]][]; @@ -60,35 +62,27 @@ export class EnhancerGenerator { ) {} async generate(): Promise<{ dmmf: DMMF.Document | undefined }> { - let logicalPrismaClientDir: string | undefined; let dmmf: DMMF.Document | undefined; const prismaImport = getPrismaClientImportSpec(this.outDir, this.options); + let prismaTypesFixed = false; + let resultPrismaImport = prismaImport; - if (this.needsLogicalClient()) { - // schema contains delegate models, need to generate a logical prisma schema + if (this.needsLogicalClient || this.needsPrismaClientTypeFixes) { + prismaTypesFixed = true; + resultPrismaImport = `${LOGICAL_CLIENT_GENERATION_PATH}/index-fixed`; const result = await this.generateLogicalPrisma(); - - logicalPrismaClientDir = LOGICAL_CLIENT_GENERATION_PATH; dmmf = result.dmmf; - - // create a reexport of the logical prisma client - const prismaDts = this.project.createSourceFile( - path.join(this.outDir, 'models.d.ts'), - `export type * from '${logicalPrismaClientDir}/index-fixed';`, - { overwrite: true } - ); - await prismaDts.save(); - } else { - // just reexport the prisma client - const prismaDts = this.project.createSourceFile( - path.join(this.outDir, 'models.d.ts'), - `export type * from '${prismaImport}';`, - { overwrite: true } - ); - await prismaDts.save(); } + // reexport PrismaClient types (original or fixed) + const prismaDts = this.project.createSourceFile( + path.join(this.outDir, 'models.d.ts'), + `export type * from '${resultPrismaImport}';`, + { overwrite: true } + ); + await prismaDts.save(); + const authModel = getAuthModel(getDataModels(this.model)); const authTypes = authModel ? generateAuthType(this.model, authModel) : ''; const authTypeParam = authModel ? `auth.${authModel.name}` : 'AuthUser'; @@ -112,8 +106,8 @@ ${ } ${ - logicalPrismaClientDir - ? this.createLogicalPrismaImports(prismaImport, logicalPrismaClientDir) + prismaTypesFixed + ? this.createLogicalPrismaImports(prismaImport, resultPrismaImport) : this.createSimplePrismaImports(prismaImport) } @@ -122,7 +116,7 @@ ${authTypes} ${checkerTypes} ${ - logicalPrismaClientDir + prismaTypesFixed ? this.createLogicalPrismaEnhanceFunction(authTypeParam) : this.createSimplePrismaEnhanceFunction(authTypeParam) } @@ -185,11 +179,11 @@ export function enhance(prisma: DbClient, context?: Enh `; } - private createLogicalPrismaImports(prismaImport: string, logicalPrismaClientDir: string) { + private createLogicalPrismaImports(prismaImport: string, prismaClientImport: string) { return `import { Prisma as _Prisma, PrismaClient as _PrismaClient } from '${prismaImport}'; import type { InternalArgs, DynamicClientExtensionThis } from '${prismaImport}/runtime/library'; -import type * as _P from '${logicalPrismaClientDir}/index-fixed'; -import type { Prisma, PrismaClient } from '${logicalPrismaClientDir}/index-fixed'; +import type * as _P from '${prismaClientImport}'; +import type { Prisma, PrismaClient } from '${prismaClientImport}'; export type { PrismaClient }; `; } @@ -229,10 +223,14 @@ export function enhance(prisma: any, context?: EnhancementContext<${authTypePara `; } - private needsLogicalClient() { + private get needsLogicalClient() { return this.hasDelegateModel(this.model) || this.hasAuthInDefault(this.model); } + private get needsPrismaClientTypeFixes() { + return this.hasTypeDef(this.model); + } + private hasDelegateModel(model: Model) { const dataModels = getDataModels(model); return dataModels.some( @@ -246,6 +244,10 @@ export function enhance(prisma: any, context?: EnhancementContext<${authTypePara ); } + private hasTypeDef(model: Model) { + return model.declarations.some(isTypeDef); + } + private async generateLogicalPrisma() { const prismaGenerator = new PrismaSchemaGenerator(this.model); @@ -349,18 +351,27 @@ export function enhance(prisma: any, context?: EnhancementContext<${authTypePara overwrite: true, }); - if (delegateInfo.length > 0) { - // transform types for delegated models - this.transformDelegate(sf, sfNew, delegateInfo); - sfNew.formatText(); - } else { - // just copy - sfNew.replaceWithText(sf.getFullText()); - } + this.transformPrismaTypes(sf, sfNew, delegateInfo); + + this.generateExtraTypes(sfNew); + sfNew.formatText(); + + // if (delegateInfo.length > 0) { + // // transform types for delegated models + // this.transformDelegate(sf, sfNew, delegateInfo); + // sfNew.formatText(); + // } else { + + // this.transformJsonFields(sf, sfNew); + + // // // just copy + // // sfNew.replaceWithText(sf.getFullText()); + // } + await sfNew.save(); } - private transformDelegate(sf: SourceFile, sfNew: SourceFile, delegateInfo: DelegateInfo) { + private transformPrismaTypes(sf: SourceFile, sfNew: SourceFile, delegateInfo: DelegateInfo) { // copy toplevel imports sfNew.addImportDeclarations(sf.getImportDeclarations().map((n) => n.getStructure())); @@ -493,10 +504,72 @@ export function enhance(prisma: any, context?: EnhancementContext<${authTypePara // fix delegate payload union type source = this.fixDelegatePayloadType(typeAlias, delegateInfo, source); + // fix json field type + source = this.fixJsonFieldType(typeAlias, source); + structure.type = source; return structure; } + private fixJsonFieldType(typeAlias: TypeAliasDeclaration, source: string) { + const modelsWithTypeField = this.model.declarations.filter( + (d): d is DataModel => isDataModel(d) && d.fields.some((f) => isTypeDef(f.type.reference?.ref)) + ); + const typeName = typeAlias.getName(); + + const getTypedJsonFields = (model: DataModel) => { + return model.fields.filter((f) => isTypeDef(f.type.reference?.ref)); + }; + + const replacePrismaJson = (source: string, field: DataModelField) => { + return source.replace( + new RegExp(`(${field.name}\\??\\s*):[^\\n]+`), + `$1: ${field.type.reference!.$refText}${field.type.array ? '[]' : ''}${ + field.type.optional ? ' | null' : '' + }` + ); + }; + + // fix "$[Model]Payload" type + const payloadModelMatch = modelsWithTypeField.find((m) => `$${m.name}Payload` === typeName); + if (payloadModelMatch) { + const scalars = typeAlias + .getDescendantsOfKind(SyntaxKind.PropertySignature) + .find((p) => p.getName() === 'scalars'); + if (!scalars) { + return source; + } + + const fieldsToFix = getTypedJsonFields(payloadModelMatch); + for (const field of fieldsToFix) { + source = replacePrismaJson(source, field); + } + } + + // fix input/output types, "[Model]CreateInput", etc. + const inputOutputModelMatch = modelsWithTypeField.find((m) => typeName.startsWith(m.name)); + if (inputOutputModelMatch) { + const relevantTypePatterns = [ + 'GroupByOutputType', + '(Unchecked)?Create(\\S+?)?Input', + '(Unchecked)?Update(\\S+?)?Input', + 'CreateManyInput', + '(Unchecked)?UpdateMany(Mutation)?Input', + ]; + const typeRegex = modelsWithTypeField.map( + (m) => new RegExp(`^(${m.name})(${relevantTypePatterns.join('|')})$`) + ); + if (typeRegex.some((r) => r.test(typeName))) { + const fieldsToFix = getTypedJsonFields(inputOutputModelMatch); + for (const field of fieldsToFix) { + source = replacePrismaJson(source, field); + } + } + } + + return source; + } + private fixDelegatePayloadType(typeAlias: TypeAliasDeclaration, delegateInfo: DelegateInfo, source: string) { // change the type of `$Payload` type of delegate model to a union of concrete types const typeName = typeAlias.getName(); @@ -677,4 +750,12 @@ export function enhance(prisma: any, context?: EnhancementContext<${authTypePara private get generatePermissionChecker() { return this.options.generatePermissionChecker === true; } + + private async generateExtraTypes(sf: SourceFile) { + for (const decl of this.model.declarations) { + if (isTypeDef(decl)) { + generateTypeDefType(sf, decl); + } + } + } } diff --git a/packages/schema/src/plugins/enhancer/enhance/model-typedef-generator.ts b/packages/schema/src/plugins/enhancer/enhance/model-typedef-generator.ts new file mode 100644 index 000000000..40bc3e5b4 --- /dev/null +++ b/packages/schema/src/plugins/enhancer/enhance/model-typedef-generator.ts @@ -0,0 +1,63 @@ +import { PluginError } from '@zenstackhq/sdk'; +import { BuiltinType, TypeDef, TypeDefFieldType } from '@zenstackhq/sdk/ast'; +import { SourceFile } from 'ts-morph'; +import { match } from 'ts-pattern'; +import { name } from '..'; + +export function generateTypeDefType(sourceFile: SourceFile, decl: TypeDef) { + sourceFile.addTypeAlias({ + name: decl.name, + isExported: true, + docs: decl.comments.map((c) => unwrapTripleSlashComment(c)), + type: (writer) => { + writer.block(() => { + decl.fields.forEach((field) => { + if (field.comments.length > 0) { + writer.writeLine(` /**`); + field.comments.forEach((c) => writer.writeLine(` * ${unwrapTripleSlashComment(c)}`)); + writer.writeLine(` */`); + } + writer.writeLine( + ` ${field.name}${field.type.optional ? '?' : ''}: ${zmodelTypeToTsType(field.type)};` + ); + }); + }); + }, + }); +} + +function unwrapTripleSlashComment(c: string): string { + return c.replace(/^[/]*\s*/, ''); +} + +function zmodelTypeToTsType(type: TypeDefFieldType) { + let result: string; + + if (type.type) { + result = builtinTypeToTsType(type.type); + } else if (type.reference?.ref) { + result = type.reference.ref.name; + } else { + throw new PluginError(name, `Unsupported field type: ${type}`); + } + + if (type.array) { + result += '[]'; + } + + return result; +} + +function builtinTypeToTsType(type: BuiltinType) { + return match(type) + .with('Boolean', () => 'boolean') + .with('BigInt', () => 'bigint') + .with('Int', () => 'number') + .with('Float', () => 'number') + .with('Decimal', () => 'Prisma.Decimal') + .with('String', () => 'string') + .with('Bytes', () => 'Uint8Array') + .with('DateTime', () => 'Date') + .with('Json', () => 'unknown') + .exhaustive(); +} diff --git a/packages/schema/src/plugins/enhancer/model-meta/index.ts b/packages/schema/src/plugins/enhancer/model-meta/index.ts index 38757ae6f..53fecdb82 100644 --- a/packages/schema/src/plugins/enhancer/model-meta/index.ts +++ b/packages/schema/src/plugins/enhancer/model-meta/index.ts @@ -1,15 +1,16 @@ import { generateModelMeta, getDataModels, type PluginOptions } from '@zenstackhq/sdk'; -import type { Model } from '@zenstackhq/sdk/ast'; +import { isTypeDef, type Model } from '@zenstackhq/sdk/ast'; import path from 'path'; import type { Project } from 'ts-morph'; export async function generate(model: Model, options: PluginOptions, project: Project, outDir: string) { const outFile = path.join(outDir, 'model-meta.ts'); const dataModels = getDataModels(model); + const typeDefs = model.declarations.filter(isTypeDef); // save ts files if requested explicitly or the user provided const preserveTsFiles = options.preserveTsFiles === true || !!options.output; - await generateModelMeta(project, dataModels, { + await generateModelMeta(project, dataModels, typeDefs, { output: outFile, generateAttributes: true, preserveTsFiles, diff --git a/packages/schema/src/plugins/enhancer/policy/policy-guard-generator.ts b/packages/schema/src/plugins/enhancer/policy/policy-guard-generator.ts index aa54c80d8..62469f744 100644 --- a/packages/schema/src/plugins/enhancer/policy/policy-guard-generator.ts +++ b/packages/schema/src/plugins/enhancer/policy/policy-guard-generator.ts @@ -11,6 +11,7 @@ import { isMemberAccessExpr, isReferenceExpr, isThisExpr, + isTypeDef, } from '@zenstackhq/language/ast'; import { PolicyCrudKind, type PolicyOperationKind } from '@zenstackhq/runtime'; import { @@ -747,7 +748,14 @@ export class PolicyGenerator { for (const model of models) { writer.write(`${lowerCaseFirst(model.name)}:`); writer.inlineBlock(() => { - writer.write(`hasValidation: ${hasValidationAttributes(model)}`); + writer.write( + `hasValidation: ${ + // explicit validation rules + hasValidationAttributes(model) || + // type-def fields require schema validation + this.hasTypeDefFields(model) + }` + ); }); writer.writeLine(','); } @@ -755,5 +763,9 @@ export class PolicyGenerator { writer.writeLine(','); } + private hasTypeDefFields(model: DataModel): boolean { + return model.fields.some((f) => isTypeDef(f.type.reference?.ref)); + } + // #endregion } diff --git a/packages/schema/src/plugins/prisma/schema-generator.ts b/packages/schema/src/plugins/prisma/schema-generator.ts index f3dcba460..9a787ab8b 100644 --- a/packages/schema/src/plugins/prisma/schema-generator.ts +++ b/packages/schema/src/plugins/prisma/schema-generator.ts @@ -23,6 +23,7 @@ import { isNullExpr, isReferenceExpr, isStringLiteral, + isTypeDef, LiteralExpr, Model, NumberLiteral, @@ -785,13 +786,34 @@ export class PrismaSchemaGenerator { } private generateModelField(model: PrismaDataModel, field: DataModelField, addToFront = false) { - const fieldType = - field.type.type || field.type.reference?.ref?.name || this.getUnsupportedFieldType(field.type); + let fieldType: string | undefined; + + if (field.type.type) { + // intrinsic type + fieldType = field.type.type; + } else if (field.type.reference?.ref) { + // model, enum, or type-def + if (isTypeDef(field.type.reference.ref)) { + fieldType = 'Json'; + } else { + fieldType = field.type.reference.ref.name; + } + } else { + // Unsupported type + const unsupported = this.getUnsupportedFieldType(field.type); + if (unsupported) { + fieldType = unsupported; + } + } + if (!fieldType) { throw new PluginError(name, `Field type is not resolved: ${field.$container.name}.${field.name}`); } - const type = new ModelFieldType(fieldType, field.type.array, field.type.optional); + const isArray = + // typed-JSON fields should be translated to scalar Json type + isTypeDef(field.type.reference?.ref) ? false : field.type.array; + const type = new ModelFieldType(fieldType, isArray, field.type.optional); const attributes = field.attributes .filter((attr) => this.isPrismaAttribute(attr)) diff --git a/packages/schema/src/plugins/zod/generator.ts b/packages/schema/src/plugins/zod/generator.ts index ca26ffabe..01a5920ff 100644 --- a/packages/schema/src/plugins/zod/generator.ts +++ b/packages/schema/src/plugins/zod/generator.ts @@ -14,12 +14,12 @@ import { parseOptionAsStrings, resolvePath, } from '@zenstackhq/sdk'; -import { DataModel, EnumField, Model, isDataModel, isEnum } from '@zenstackhq/sdk/ast'; +import { DataModel, EnumField, Model, TypeDef, isDataModel, isEnum, isTypeDef } from '@zenstackhq/sdk/ast'; import { addMissingInputObjectTypes, resolveAggregateOperationSupport } from '@zenstackhq/sdk/dmmf-helpers'; import { getPrismaClientImportSpec, supportCreateMany, type DMMF } from '@zenstackhq/sdk/prisma'; import { streamAllContents } from 'langium'; import path from 'path'; -import type { SourceFile } from 'ts-morph'; +import type { CodeBlockWriter, SourceFile } from 'ts-morph'; import { upperCaseFirst } from 'upper-case-first'; import { name } from '.'; import { getDefaultOutputFolder } from '../plugin-utils'; @@ -274,6 +274,12 @@ export class ZodSchemaGenerator { } } + for (const typeDef of this.model.declarations.filter(isTypeDef)) { + if (!excludedModels.includes(typeDef.name)) { + schemaNames.push(await this.generateTypeDefSchema(typeDef, output)); + } + } + this.sourceFiles.push( this.project.createSourceFile( path.join(output, 'models', 'index.ts'), @@ -283,6 +289,89 @@ export class ZodSchemaGenerator { ); } + private generateTypeDefSchema(typeDef: TypeDef, output: string) { + const schemaName = `${upperCaseFirst(typeDef.name)}.schema`; + const sf = this.project.createSourceFile(path.join(output, 'models', `${schemaName}.ts`), undefined, { + overwrite: true, + }); + this.sourceFiles.push(sf); + sf.replaceWithText((writer) => { + this.addPreludeAndImports(typeDef, writer, output); + + writer.write(`export const ${typeDef.name}Schema = z.object(`); + writer.inlineBlock(() => { + typeDef.fields.forEach((field) => { + writer.writeLine(`${field.name}: ${makeFieldSchema(field)},`); + }); + }); + + switch (this.options.mode) { + case 'strip': + // zod strips by default + writer.writeLine(')'); + break; + case 'passthrough': + writer.writeLine(').passthrough();'); + break; + default: + writer.writeLine(').strict();'); + break; + } + }); + + // TODO: "@@validate" refinements + + return schemaName; + } + + private addPreludeAndImports(decl: DataModel | TypeDef, writer: CodeBlockWriter, output: string) { + writer.writeLine('/* eslint-disable */'); + writer.writeLine(`import { z } from 'zod';`); + + // import user-defined enums from Prisma as they might be referenced in the expressions + const importEnums = new Set(); + for (const node of streamAllContents(decl)) { + if (isEnumFieldReference(node)) { + const field = node.target.ref as EnumField; + if (!isFromStdlib(field.$container)) { + importEnums.add(field.$container.name); + } + } + } + if (importEnums.size > 0) { + const prismaImport = computePrismaClientImport(path.join(output, 'models'), this.options); + writer.writeLine(`import { ${[...importEnums].join(', ')} } from '${prismaImport}';`); + } + + // import enum schemas + const importedEnumSchemas = new Set(); + for (const field of decl.fields) { + if (field.type.reference?.ref && isEnum(field.type.reference?.ref)) { + const name = upperCaseFirst(field.type.reference?.ref.name); + if (!importedEnumSchemas.has(name)) { + writer.writeLine(`import { ${name}Schema } from '../enums/${name}.schema';`); + importedEnumSchemas.add(name); + } + } + } + + // import Decimal + if (decl.fields.some((field) => field.type.type === 'Decimal')) { + writer.writeLine(`import { DecimalSchema } from '../common';`); + writer.writeLine(`import { Decimal } from 'decimal.js';`); + } + + // import referenced types' schemas + const referencedTypes = new Set( + decl.fields + .filter((field) => isTypeDef(field.type.reference?.ref) && field.type.reference?.ref.name !== decl.name) + .map((field) => field.type.reference!.ref!.name) + ); + for (const refType of referencedTypes) { + writer.writeLine(`import { ${upperCaseFirst(refType)}Schema } from './${upperCaseFirst(refType)}.schema';`); + } + } + private async generateModelSchema(model: DataModel, output: string) { const schemaName = `${upperCaseFirst(model.name)}.schema`; const sf = this.project.createSourceFile(path.join(output, 'models', `${schemaName}.ts`), undefined, { @@ -301,41 +390,7 @@ export class ZodSchemaGenerator { const relations = model.fields.filter((field) => isDataModel(field.type.reference?.ref)); const fkFields = model.fields.filter((field) => isForeignKeyField(field)); - writer.writeLine('/* eslint-disable */'); - writer.writeLine(`import { z } from 'zod';`); - - // import user-defined enums from Prisma as they might be referenced in the expressions - const importEnums = new Set(); - for (const node of streamAllContents(model)) { - if (isEnumFieldReference(node)) { - const field = node.target.ref as EnumField; - if (!isFromStdlib(field.$container)) { - importEnums.add(field.$container.name); - } - } - } - if (importEnums.size > 0) { - const prismaImport = computePrismaClientImport(path.join(output, 'models'), this.options); - writer.writeLine(`import { ${[...importEnums].join(', ')} } from '${prismaImport}';`); - } - - // import enum schemas - const importedEnumSchemas = new Set(); - for (const field of scalarFields) { - if (field.type.reference?.ref && isEnum(field.type.reference?.ref)) { - const name = upperCaseFirst(field.type.reference?.ref.name); - if (!importedEnumSchemas.has(name)) { - writer.writeLine(`import { ${name}Schema } from '../enums/${name}.schema';`); - importedEnumSchemas.add(name); - } - } - } - - // import Decimal - if (scalarFields.some((field) => field.type.type === 'Decimal')) { - writer.writeLine(`import { DecimalSchema } from '../common';`); - writer.writeLine(`import { Decimal } from 'decimal.js';`); - } + this.addPreludeAndImports(model, writer, output); // base schema - including all scalar fields, with optionality following the schema writer.write(`const baseSchema = z.object(`); diff --git a/packages/schema/src/plugins/zod/transformer.ts b/packages/schema/src/plugins/zod/transformer.ts index 698ad2ac6..6b83e5723 100644 --- a/packages/schema/src/plugins/zod/transformer.ts +++ b/packages/schema/src/plugins/zod/transformer.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ import { indentString, isDiscriminatorField, type PluginOptions } from '@zenstackhq/sdk'; -import { DataModel, isDataModel, type Model } from '@zenstackhq/sdk/ast'; +import { DataModel, isDataModel, isTypeDef, type Model } from '@zenstackhq/sdk/ast'; import { checkModelHasModelRelation, findModelByName, isAggregateInputType } from '@zenstackhq/sdk/dmmf-helpers'; import { supportCreateMany, type DMMF as PrismaDMMF } from '@zenstackhq/sdk/prisma'; import path from 'path'; @@ -88,29 +88,33 @@ export default class Transformer { } generateObjectSchema(generateUnchecked: boolean, options: PluginOptions) { - const zodObjectSchemaFields = this.generateObjectSchemaFields(generateUnchecked); - const objectSchema = this.prepareObjectSchema(zodObjectSchemaFields, options); + const { schemaFields, extraImports } = this.generateObjectSchemaFields(generateUnchecked); + const objectSchema = this.prepareObjectSchema(schemaFields, options); const filePath = path.join(Transformer.outputPath, `objects/${this.name}.schema.ts`); - const content = '/* eslint-disable */\n' + objectSchema; + const content = '/* eslint-disable */\n' + extraImports.join('\n\n') + objectSchema; this.sourceFiles.push(this.project.createSourceFile(filePath, content, { overwrite: true })); return `${this.name}.schema`; } - private delegateCreateUpdateInputRegex = /(\S+)(Unchecked)?(Create|Update).*Input/; + private createUpdateInputRegex = /(\S+?)(Unchecked)?(Create|Update|CreateMany|UpdateMany).*Input/; generateObjectSchemaFields(generateUnchecked: boolean) { let fields = this.fields; + let contextDataModel: DataModel | undefined; + const extraImports: string[] = []; // exclude discriminator fields from create/update input schemas - const createUpdateMatch = this.delegateCreateUpdateInputRegex.exec(this.name); + const createUpdateMatch = this.createUpdateInputRegex.exec(this.name); if (createUpdateMatch) { const modelName = createUpdateMatch[1]; - const dataModel = this.zmodel.declarations.find( + contextDataModel = this.zmodel.declarations.find( (d): d is DataModel => isDataModel(d) && d.name === modelName ); - if (dataModel) { - const discriminatorFields = dataModel.fields.filter(isDiscriminatorField); + + if (contextDataModel) { + // exclude discriminator fields from create/update input schemas + const discriminatorFields = contextDataModel.fields.filter(isDiscriminatorField); if (discriminatorFields.length > 0) { fields = fields.filter((field) => { return !discriminatorFields.some( @@ -118,11 +122,23 @@ export default class Transformer { ); }); } + + // import type-def's schemas + const typeDefFields = contextDataModel.fields.filter((f) => isTypeDef(f.type.reference?.ref)); + typeDefFields.forEach((field) => { + const typeName = upperCaseFirst(field.type.reference!.$refText); + const importLine = `import { ${typeName}Schema } from '../models/${typeName}.schema';`; + if (!extraImports.includes(importLine)) { + extraImports.push(importLine); + } + }); } } const zodObjectSchemaFields = fields - .map((field) => this.generateObjectSchemaField(field, generateUnchecked)) + .map((field) => + this.generateObjectSchemaField(field, contextDataModel, generateUnchecked, !!createUpdateMatch) + ) .flatMap((item) => item) .map((item) => { const [zodStringWithMainType, field, skipValidators] = item; @@ -133,12 +149,14 @@ export default class Transformer { return value.trim(); }); - return zodObjectSchemaFields; + return { schemaFields: zodObjectSchemaFields, extraImports }; } generateObjectSchemaField( field: PrismaDMMF.SchemaArg, - generateUnchecked: boolean + contextDataModel: DataModel | undefined, + generateUnchecked: boolean, + replaceJsonWithTypeDef = false ): [string, PrismaDMMF.SchemaArg, boolean][] { const lines = field.inputTypes; @@ -146,64 +164,75 @@ export default class Transformer { return []; } - let alternatives = lines.reduce((result, inputType) => { - if (!generateUnchecked && typeof inputType.type === 'string' && inputType.type.includes('Unchecked')) { - return result; - } + let alternatives: string[] | undefined = undefined; - if (inputType.type.includes('CreateMany') && !supportCreateMany(this.zmodel)) { - return result; + if (replaceJsonWithTypeDef) { + const dmField = contextDataModel?.fields.find((f) => f.name === field.name); + if (isTypeDef(dmField?.type.reference?.ref)) { + alternatives = [`z.lazy(() => ${upperCaseFirst(dmField?.type.reference!.$refText)}Schema)`]; } + } - // TODO: unify the following with `schema-gen.ts` - - if (inputType.type === 'String') { - result.push(this.wrapWithZodValidators('z.string()', field, inputType)); - } else if (inputType.type === 'Int' || inputType.type === 'Float') { - result.push(this.wrapWithZodValidators('z.number()', field, inputType)); - } else if (inputType.type === 'Decimal') { - this.hasDecimal = true; - result.push(this.wrapWithZodValidators('DecimalSchema', field, inputType)); - } else if (inputType.type === 'BigInt') { - result.push(this.wrapWithZodValidators('z.bigint()', field, inputType)); - } else if (inputType.type === 'Boolean') { - result.push(this.wrapWithZodValidators('z.boolean()', field, inputType)); - } else if (inputType.type === 'DateTime') { - result.push(this.wrapWithZodValidators(['z.date()', 'z.string().datetime()'], field, inputType)); - } else if (inputType.type === 'Bytes') { - result.push( - this.wrapWithZodValidators( - `z.custom(data => data instanceof Uint8Array)`, - field, - inputType - ) - ); - } else if (inputType.type === 'Json') { - this.hasJson = true; - result.push(this.wrapWithZodValidators('jsonSchema', field, inputType)); - } else if (inputType.type === 'True') { - result.push(this.wrapWithZodValidators('z.literal(true)', field, inputType)); - } else if (inputType.type === 'Null') { - result.push(this.wrapWithZodValidators('z.null()', field, inputType)); - } else { - const isEnum = inputType.location === 'enumTypes'; - const isFieldRef = inputType.location === 'fieldRefTypes'; - - if ( - // fieldRefTypes refer to other fields in the model and don't need to be generated as part of schema - !isFieldRef && - (inputType.namespace === 'prisma' || isEnum) - ) { - if (inputType.type !== this.originalName && typeof inputType.type === 'string') { - this.addSchemaImport(inputType.type); - } + if (!alternatives) { + alternatives = lines.reduce((result, inputType) => { + if (!generateUnchecked && typeof inputType.type === 'string' && inputType.type.includes('Unchecked')) { + return result; + } - result.push(this.generatePrismaStringLine(field, inputType, lines.length)); + if (inputType.type.includes('CreateMany') && !supportCreateMany(this.zmodel)) { + return result; + } + + // TODO: unify the following with `schema-gen.ts` + + if (inputType.type === 'String') { + result.push(this.wrapWithZodValidators('z.string()', field, inputType)); + } else if (inputType.type === 'Int' || inputType.type === 'Float') { + result.push(this.wrapWithZodValidators('z.number()', field, inputType)); + } else if (inputType.type === 'Decimal') { + this.hasDecimal = true; + result.push(this.wrapWithZodValidators('DecimalSchema', field, inputType)); + } else if (inputType.type === 'BigInt') { + result.push(this.wrapWithZodValidators('z.bigint()', field, inputType)); + } else if (inputType.type === 'Boolean') { + result.push(this.wrapWithZodValidators('z.boolean()', field, inputType)); + } else if (inputType.type === 'DateTime') { + result.push(this.wrapWithZodValidators(['z.date()', 'z.string().datetime()'], field, inputType)); + } else if (inputType.type === 'Bytes') { + result.push( + this.wrapWithZodValidators( + `z.custom(data => data instanceof Uint8Array)`, + field, + inputType + ) + ); + } else if (inputType.type === 'Json') { + this.hasJson = true; + result.push(this.wrapWithZodValidators('jsonSchema', field, inputType)); + } else if (inputType.type === 'True') { + result.push(this.wrapWithZodValidators('z.literal(true)', field, inputType)); + } else if (inputType.type === 'Null') { + result.push(this.wrapWithZodValidators('z.null()', field, inputType)); + } else { + const isEnum = inputType.location === 'enumTypes'; + const isFieldRef = inputType.location === 'fieldRefTypes'; + + if ( + // fieldRefTypes refer to other fields in the model and don't need to be generated as part of schema + !isFieldRef && + (inputType.namespace === 'prisma' || isEnum) + ) { + if (inputType.type !== this.originalName && typeof inputType.type === 'string') { + this.addSchemaImport(inputType.type); + } + + result.push(this.generatePrismaStringLine(field, inputType, lines.length)); + } } - } - return result; - }, []); + return result; + }, []); + } if (alternatives.length === 0) { return []; diff --git a/packages/schema/src/plugins/zod/utils/schema-gen.ts b/packages/schema/src/plugins/zod/utils/schema-gen.ts index ee46390ff..c130934b2 100644 --- a/packages/schema/src/plugins/zod/utils/schema-gen.ts +++ b/packages/schema/src/plugins/zod/utils/schema-gen.ts @@ -1,32 +1,34 @@ import { ExpressionContext, - PluginError, - TypeScriptExpressionTransformer, - TypeScriptExpressionTransformerError, getAttributeArg, getAttributeArgLiteral, getLiteral, getLiteralArray, isDataModelFieldReference, isFromStdlib, + PluginError, + TypeScriptExpressionTransformer, + TypeScriptExpressionTransformerError, } from '@zenstackhq/sdk'; import { DataModel, DataModelField, DataModelFieldAttribute, - isDataModel, isArrayExpr, + isBooleanLiteral, + isDataModel, isEnum, isInvocationExpr, isNumberLiteral, isStringLiteral, - isBooleanLiteral + isTypeDef, + TypeDefField, } from '@zenstackhq/sdk/ast'; import { upperCaseFirst } from 'upper-case-first'; import { name } from '..'; import { isDefaultWithAuth } from '../../enhancer/enhancer-utils'; -export function makeFieldSchema(field: DataModelField) { +export function makeFieldSchema(field: DataModelField | TypeDefField) { if (isDataModel(field.type.reference?.ref)) { if (field.type.array) { // array field is always optional @@ -172,11 +174,17 @@ export function makeFieldSchema(field: DataModelField) { return schema; } -function makeZodSchema(field: DataModelField) { +function makeZodSchema(field: DataModelField | TypeDefField) { let schema: string; - if (field.type.reference?.ref && isEnum(field.type.reference?.ref)) { - schema = `${upperCaseFirst(field.type.reference.ref.name)}Schema`; + if (field.type.reference?.ref) { + if (isEnum(field.type.reference?.ref)) { + schema = `${upperCaseFirst(field.type.reference.ref.name)}Schema`; + } else if (isTypeDef(field.type.reference?.ref)) { + schema = `z.lazy(() => ${upperCaseFirst(field.type.reference.ref.name)}Schema)`; + } else { + schema = 'z.any()'; + } } else { switch (field.type.type) { case 'Int': @@ -227,7 +235,8 @@ export function makeValidationRefinements(model: DataModel) { const message = messageArg ? `message: ${JSON.stringify(messageArg)},` : ''; const pathArg = getAttributeArg(attr, 'path'); - const path = pathArg && isArrayExpr(pathArg) ? `path: ['${getLiteralArray(pathArg)?.join(`', '`)}'],` : ''; + const path = + pathArg && isArrayExpr(pathArg) ? `path: ['${getLiteralArray(pathArg)?.join(`', '`)}'],` : ''; const options = `, { ${message} ${path} }`; @@ -272,7 +281,7 @@ function refineDecimal(op: 'gt' | 'gte' | 'lt' | 'lte', value: number, messageAr }${messageArg})`; } -export function getFieldSchemaDefault(field: DataModelField) { +export function getFieldSchemaDefault(field: DataModelField | TypeDefField) { const attr = field.attributes.find((attr) => attr.decl.ref?.name === '@default'); if (!attr) { return undefined; diff --git a/packages/schema/src/res/stdlib.zmodel b/packages/schema/src/res/stdlib.zmodel index a436ea4a8..62cefd36e 100644 --- a/packages/schema/src/res/stdlib.zmodel +++ b/packages/schema/src/res/stdlib.zmodel @@ -47,6 +47,7 @@ enum AttributeTargetField { JsonField BytesField ModelField + TypeDefField } /** @@ -175,6 +176,11 @@ function isEmpty(field: Any[]): Boolean { */ attribute @@@targetField(_ targetField: AttributeTargetField[]) +/** + * Marks an attribute to be applicable to type defs and fields. + */ +attribute @@@supportTypeDef() + /** * Marks an attribute to be used for data validation. */ @@ -209,7 +215,7 @@ attribute @id(map: String?, length: Int?, sort: SortOrder?, clustered: Boolean?) * Defines a default value for a field. * @param value: An expression (e.g. 5, true, now(), auth()). */ -attribute @default(_ value: ContextType, map: String?) @@@prisma +attribute @default(_ value: ContextType, map: String?) @@@prisma @@@supportTypeDef /** * Defines a unique constraint for this field. @@ -558,77 +564,77 @@ attribute @omit() /** * Validates length of a string field. */ -attribute @length(_ min: Int?, _ max: Int?, _ message: String?) @@@targetField([StringField]) @@@validation +attribute @length(_ min: Int?, _ max: Int?, _ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef /** * Validates a string field value starts with the given text. */ -attribute @startsWith(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation +attribute @startsWith(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef /** * Validates a string field value ends with the given text. */ -attribute @endsWith(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation +attribute @endsWith(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef /** * Validates a string field value contains the given text. */ -attribute @contains(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation +attribute @contains(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef /** * Validates a string field value matches a regex. */ -attribute @regex(_ regex: String, _ message: String?) @@@targetField([StringField]) @@@validation +attribute @regex(_ regex: String, _ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef /** * Validates a string field value is a valid email address. */ -attribute @email(_ message: String?) @@@targetField([StringField]) @@@validation +attribute @email(_ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef /** * Validates a string field value is a valid ISO datetime. */ -attribute @datetime(_ message: String?) @@@targetField([StringField]) @@@validation +attribute @datetime(_ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef /** * Validates a string field value is a valid url. */ -attribute @url(_ message: String?) @@@targetField([StringField]) @@@validation +attribute @url(_ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef /** * Trims whitespaces from the start and end of the string. */ -attribute @trim() @@@targetField([StringField]) @@@validation +attribute @trim() @@@targetField([StringField]) @@@validation @@@supportTypeDef /** * Transform entire string toLowerCase. */ -attribute @lower() @@@targetField([StringField]) @@@validation +attribute @lower() @@@targetField([StringField]) @@@validation @@@supportTypeDef /** * Transform entire string toUpperCase. */ -attribute @upper() @@@targetField([StringField]) @@@validation +attribute @upper() @@@targetField([StringField]) @@@validation @@@supportTypeDef /** * Validates a number field is greater than the given value. */ -attribute @gt(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation +attribute @gt(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation @@@supportTypeDef /** * Validates a number field is greater than or equal to the given value. */ -attribute @gte(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation +attribute @gte(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation @@@supportTypeDef /** * Validates a number field is less than the given value. */ -attribute @lt(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation +attribute @lt(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation @@@supportTypeDef /** * Validates a number field is less than or equal to the given value. */ -attribute @lte(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation +attribute @lte(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation @@@supportTypeDef /** * Validates the entity with a complex condition. @@ -700,3 +706,8 @@ attribute @@delegate(_ discriminator: FieldReference) */ function raw(value: String): Any { } @@@expressionContext([Index]) + +/** + * Marks a field to be strong-typed JSON. + */ +attribute @json() @@@targetField([TypeDefField]) diff --git a/packages/schema/tests/schema/validation/attribute-validation.test.ts b/packages/schema/tests/schema/validation/attribute-validation.test.ts index 0c89c61ae..4d86837d0 100644 --- a/packages/schema/tests/schema/validation/attribute-validation.test.ts +++ b/packages/schema/tests/schema/validation/attribute-validation.test.ts @@ -1354,4 +1354,19 @@ describe('Attribute tests', () => { 'Field-level policy rules with "update" or "all" kind are not allowed for relation fields. Put rules on foreign-key fields instead.' ); }); + + it('type def field attribute', async () => { + await expect( + loadModelWithError(` + model User { + id String @id + profile Profile + } + + type Profile { + email String @omit + } + `) + ).resolves.toContain(`attribute "@omit" cannot be used on type declaration fields`); + }); }); diff --git a/packages/sdk/src/model-meta-generator.ts b/packages/sdk/src/model-meta-generator.ts index 469cfa888..859399673 100644 --- a/packages/sdk/src/model-meta-generator.ts +++ b/packages/sdk/src/model-meta-generator.ts @@ -6,11 +6,15 @@ import { isArrayExpr, isBooleanLiteral, isDataModel, + isDataModelField, isInvocationExpr, isNumberLiteral, isReferenceExpr, isStringLiteral, + isTypeDef, ReferenceExpr, + TypeDef, + TypeDefField, } from '@zenstackhq/language/ast'; import type { RuntimeAttribute } from '@zenstackhq/runtime'; import { streamAst } from 'langium'; @@ -62,13 +66,18 @@ export type ModelMetaGeneratorOptions = { shortNameMap?: Map; }; -export async function generate(project: Project, models: DataModel[], options: ModelMetaGeneratorOptions) { +export async function generate( + project: Project, + models: DataModel[], + typeDefs: TypeDef[], + options: ModelMetaGeneratorOptions +) { const sf = project.createSourceFile(options.output, undefined, { overwrite: true }); sf.addStatements('/* eslint-disable */'); sf.addVariableStatement({ declarationKind: VariableDeclarationKind.Const, declarations: [ - { name: 'metadata', initializer: (writer) => generateModelMetadata(models, sf, writer, options) }, + { name: 'metadata', initializer: (writer) => generateModelMetadata(models, typeDefs, sf, writer, options) }, ], }); sf.addStatements('export default metadata;'); @@ -82,12 +91,14 @@ export async function generate(project: Project, models: DataModel[], options: M function generateModelMetadata( dataModels: DataModel[], + typeDefs: TypeDef[], sourceFile: SourceFile, writer: CodeBlockWriter, options: ModelMetaGeneratorOptions ) { writer.block(() => { writeModels(sourceFile, writer, dataModels, options); + writeTypeDefs(sourceFile, writer, typeDefs, options); writeDeleteCascade(writer, dataModels); writeShortNameMap(options, writer); writeAuthModel(writer, dataModels); @@ -120,6 +131,29 @@ function writeModels( writer.writeLine(','); } +function writeTypeDefs( + sourceFile: SourceFile, + writer: CodeBlockWriter, + typedDefs: TypeDef[], + options: ModelMetaGeneratorOptions +) { + if (typedDefs.length === 0) { + return; + } + writer.write('typeDefs:'); + writer.block(() => { + for (const typeDef of typedDefs) { + writer.write(`${lowerCaseFirst(typeDef.name)}:`); + writer.block(() => { + writer.write(`name: '${typeDef.name}',`); + writeFields(sourceFile, writer, typeDef, options); + }); + writer.writeLine(','); + } + }); + writer.writeLine(','); +} + function writeBaseTypes(writer: CodeBlockWriter, model: DataModel) { if (model.superTypes.length > 0) { writer.write('baseTypes: ['); @@ -189,14 +223,14 @@ function writeDiscriminator(writer: CodeBlockWriter, model: DataModel) { function writeFields( sourceFile: SourceFile, writer: CodeBlockWriter, - model: DataModel, + container: DataModel | TypeDef, options: ModelMetaGeneratorOptions ) { writer.write('fields:'); writer.block(() => { - for (const f of model.fields) { - const backlink = getBackLink(f); - const fkMapping = generateForeignKeyMapping(f); + for (const f of container.fields) { + const dmField = isDataModelField(f) ? f : undefined; + writer.write(`${f.name}: {`); writer.write(` @@ -208,7 +242,7 @@ function writeFields( f.type.type! }",`); - if (isIdField(f)) { + if (dmField && isIdField(dmField)) { writer.write(` isId: true,`); } @@ -216,6 +250,9 @@ function writeFields( if (isDataModel(f.type.reference?.ref)) { writer.write(` isDataModel: true,`); + } else if (isTypeDef(f.type.reference?.ref)) { + writer.write(` + isTypeDef: true,`); } if (f.type.array) { @@ -243,46 +280,53 @@ function writeFields( } } - if (backlink) { + const defaultValueProvider = generateDefaultValueProvider(f, sourceFile); + if (defaultValueProvider) { writer.write(` - backLink: '${backlink.name}',`); + defaultValueProvider: ${defaultValueProvider},`); } - if (isRelationOwner(f, backlink)) { - writer.write(` + if (dmField) { + // metadata specific to DataModelField + + const backlink = getBackLink(dmField); + const fkMapping = generateForeignKeyMapping(dmField); + + if (backlink) { + writer.write(` + backLink: '${backlink.name}',`); + } + + if (isRelationOwner(dmField, backlink)) { + writer.write(` isRelationOwner: true,`); - } + } - if (isForeignKeyField(f)) { - writer.write(` - isForeignKey: true,`); - const relationField = getRelationField(f); - if (relationField) { + if (isForeignKeyField(dmField)) { writer.write(` + isForeignKey: true,`); + const relationField = getRelationField(dmField); + if (relationField) { + writer.write(` relationField: '${relationField.name}',`); + } } - } - if (fkMapping && Object.keys(fkMapping).length > 0) { - writer.write(` + if (fkMapping && Object.keys(fkMapping).length > 0) { + writer.write(` foreignKeyMapping: ${JSON.stringify(fkMapping)},`); - } - - const defaultValueProvider = generateDefaultValueProvider(f, sourceFile); - if (defaultValueProvider) { - writer.write(` - defaultValueProvider: ${defaultValueProvider},`); - } + } - const inheritedFromDelegate = getInheritedFromDelegate(f); - if (inheritedFromDelegate && !isIdField(f)) { - writer.write(` + const inheritedFromDelegate = getInheritedFromDelegate(dmField); + if (inheritedFromDelegate && !isIdField(dmField)) { + writer.write(` inheritedFrom: ${JSON.stringify(inheritedFromDelegate.name)},`); - } + } - if (isAutoIncrement(f)) { - writer.write(` + if (isAutoIncrement(dmField)) { + writer.write(` isAutoIncrement: true,`); + } } writer.write(` @@ -337,7 +381,7 @@ function getRelationName(field: DataModelField) { return getAttributeArgLiteral(relAttr, 'name'); } -function getAttributes(target: DataModelField | DataModel): RuntimeAttribute[] { +function getAttributes(target: DataModelField | DataModel | TypeDefField): RuntimeAttribute[] { return target.attributes .map((attr) => { const args: Array<{ name?: string; value: unknown }> = []; @@ -498,7 +542,7 @@ function getDeleteCascades(model: DataModel): string[] { .map((m) => m.name); } -function generateDefaultValueProvider(field: DataModelField, sourceFile: SourceFile) { +function generateDefaultValueProvider(field: DataModelField | TypeDefField, sourceFile: SourceFile) { const defaultAttr = getAttribute(field, '@default'); if (!defaultAttr) { return undefined; diff --git a/packages/sdk/src/utils.ts b/packages/sdk/src/utils.ts index 6fbcfcbf3..6b2bfe868 100644 --- a/packages/sdk/src/utils.ts +++ b/packages/sdk/src/utils.ts @@ -30,6 +30,7 @@ import { Model, Reference, ReferenceExpr, + TypeDefField, } from '@zenstackhq/language/ast'; import fs from 'node:fs'; import path from 'path'; @@ -123,7 +124,7 @@ export function hasAttribute( } export function getAttribute( - decl: DataModel | DataModelField | Enum | EnumField | FunctionDecl | Attribute | AttributeParam, + decl: DataModel | DataModelField | TypeDefField | Enum | EnumField | FunctionDecl | Attribute | AttributeParam, name: string ) { return (decl.attributes as (DataModelAttribute | DataModelFieldAttribute)[]).find( @@ -460,6 +461,9 @@ export function isDelegateModel(node: AstNode) { } export function isDiscriminatorField(field: DataModelField) { + if (!isDataModel(field.$container)) { + return false; + } const model = field.$inheritedFrom ?? field.$container; const delegateAttr = getAttribute(model, '@@delegate'); if (!delegateAttr) { diff --git a/packages/sdk/src/validation.ts b/packages/sdk/src/validation.ts index e7edc21fc..8872b667e 100644 --- a/packages/sdk/src/validation.ts +++ b/packages/sdk/src/validation.ts @@ -1,4 +1,11 @@ -import type { DataModel, DataModelAttribute, DataModelFieldAttribute } from './ast'; +import { + isDataModel, + isTypeDef, + type DataModel, + type DataModelAttribute, + type DataModelFieldAttribute, + type TypeDef, +} from './ast'; function isValidationAttribute(attr: DataModelAttribute | DataModelFieldAttribute) { return attr.decl.ref?.attributes.some((attr) => attr.decl.$refText === '@@@validation'); @@ -8,12 +15,30 @@ function isValidationAttribute(attr: DataModelAttribute | DataModelFieldAttribut * Returns if the given model contains any data validation rules (both at the model * level and at the field level). */ -export function hasValidationAttributes(model: DataModel) { - if (model.attributes.some((attr) => isValidationAttribute(attr))) { - return true; +export function hasValidationAttributes( + decl: DataModel | TypeDef, + seen: Set = new Set() +): boolean { + if (seen.has(decl)) { + return false; + } + seen.add(decl); + + if (isDataModel(decl)) { + if (decl.attributes.some((attr) => isValidationAttribute(attr))) { + return true; + } } - if (model.fields.some((field) => field.attributes.some((attr) => isValidationAttribute(attr)))) { + if ( + decl.fields.some((field) => { + if (isTypeDef(field.type.reference?.ref)) { + return hasValidationAttributes(field.type.reference?.ref); + } else { + return field.attributes.some((attr) => isValidationAttribute(attr)); + } + }) + ) { return true; } diff --git a/tests/integration/tests/enhancements/json/crud.test.ts b/tests/integration/tests/enhancements/json/crud.test.ts new file mode 100644 index 000000000..af3705a95 --- /dev/null +++ b/tests/integration/tests/enhancements/json/crud.test.ts @@ -0,0 +1,270 @@ +import { createPostgresDb, dropPostgresDb, loadSchema } from '@zenstackhq/testtools'; + +describe('Json field CRUD', () => { + let dbUrl: string; + let prisma: any; + + beforeEach(async () => { + dbUrl = await createPostgresDb('json-field-typing'); + }); + + afterEach(async () => { + if (prisma) { + await prisma.$disconnect(); + } + await dropPostgresDb(dbUrl); + }); + + it('works with simple cases', async () => { + const params = await loadSchema( + ` + type Address { + city String + } + + type Profile { + age Int + address Address? + } + + model User { + id Int @id @default(autoincrement()) + profile Profile @json + posts Post[] + } + + model Post { + id Int @id @default(autoincrement()) + title String + user User @relation(fields: [userId], references: [id]) + userId Int + } + `, + { + provider: 'postgresql', + dbUrl, + enhancements: ['validation'], + } + ); + + prisma = params.prisma; + const db = params.enhance(); + + // expecting object + await expect(db.user.create({ data: { profile: 1 } })).toBeRejectedByPolicy(); + await expect(db.user.create({ data: { profile: [{ age: 18 }] } })).toBeRejectedByPolicy(); + await expect(db.user.create({ data: { profile: { myAge: 18 } } })).toBeRejectedByPolicy(); + await expect(db.user.create({ data: { profile: { address: { city: 'NY' } } } })).toBeRejectedByPolicy(); + await expect(db.user.create({ data: { profile: { age: 18, address: { x: 1 } } } })).toBeRejectedByPolicy(); + + await expect( + db.user.create({ data: { profile: { age: 18 }, posts: { create: { title: 'Post1' } } } }) + ).resolves.toMatchObject({ + profile: { age: 18 }, + }); + await expect( + db.user.create({ + data: { profile: { age: 20, address: { city: 'NY' } }, posts: { create: { title: 'Post1' } } }, + }) + ).resolves.toMatchObject({ + profile: { age: 20, address: { city: 'NY' } }, + }); + }); + + it('works with array', async () => { + const params = await loadSchema( + ` + type Address { + city String + } + + type Profile { + age Int + address Address? + } + + model User { + id Int @id @default(autoincrement()) + profiles Profile[] @json + @@allow('all', true) + } + `, + { + provider: 'postgresql', + dbUrl, + } + ); + + prisma = params.prisma; + const db = params.enhance(); + + // expecting array + await expect( + db.user.create({ data: { profiles: { age: 18, address: { city: 'NY' } } } }) + ).toBeRejectedByPolicy(); + + await expect( + db.user.create({ data: { profiles: [{ age: 18, address: { city: 'NY' } }] } }) + ).resolves.toMatchObject({ + profiles: expect.arrayContaining([expect.objectContaining({ age: 18, address: { city: 'NY' } })]), + }); + }); + + it('respects validation rules', async () => { + const params = await loadSchema( + ` + type Address { + city String @length(2, 10) + } + + type Profile { + age Int @gte(18) + address Address? + } + + model User { + id Int @id @default(autoincrement()) + profile Profile @json + foo Foo? + @@allow('all', true) + } + + model Foo { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id]) + userId Int @unique + @@allow('all', true) + } + `, + { + provider: 'postgresql', + dbUrl, + } + ); + + prisma = params.prisma; + const db = params.enhance(); + + // create + await expect(db.user.create({ data: { profile: { age: 10 } } })).toBeRejectedByPolicy(); + await expect(db.user.create({ data: { profile: { age: 18, address: { city: 'N' } } } })).toBeRejectedByPolicy(); + const u1 = await db.user.create({ data: { profile: { age: 18, address: { city: 'NY' } } } }); + expect(u1).toMatchObject({ + profile: { age: 18, address: { city: 'NY' } }, + }); + + // update + await expect(db.user.update({ where: { id: u1.id }, data: { profile: { age: 10 } } })).toBeRejectedByPolicy(); + await expect( + db.user.update({ where: { id: u1.id }, data: { profile: { age: 20, address: { city: 'B' } } } }) + ).toBeRejectedByPolicy(); + await expect( + db.user.update({ where: { id: u1.id }, data: { profile: { age: 20, address: { city: 'BJ' } } } }) + ).resolves.toMatchObject({ + profile: { age: 20, address: { city: 'BJ' } }, + }); + + // nested create + await expect(db.foo.create({ data: { user: { create: { profile: { age: 10 } } } } })).toBeRejectedByPolicy(); + await expect(db.foo.create({ data: { user: { create: { profile: { age: 20 } } } } })).toResolveTruthy(); + + // upsert + await expect( + db.user.upsert({ where: { id: 10 }, create: { id: 10, profile: { age: 10 } }, update: {} }) + ).toBeRejectedByPolicy(); + await expect( + db.user.upsert({ where: { id: 10 }, create: { id: 10, profile: { age: 20 } }, update: {} }) + ).toResolveTruthy(); + await expect( + db.user.upsert({ + where: { id: 10 }, + create: { id: 10, profile: { age: 20 } }, + update: { profile: { age: 10 } }, + }) + ).toBeRejectedByPolicy(); + await expect( + db.user.upsert({ + where: { id: 10 }, + create: { id: 10, profile: { age: 20 } }, + update: { profile: { age: 20 } }, + }) + ).toResolveTruthy(); + }); + + it('respects @default', async () => { + const params = await loadSchema( + ` + type Address { + state String + city String @default('Issaquah') + } + + type Profile { + createdAt DateTime @default(now()) + address Address? + } + + model User { + id Int @id @default(autoincrement()) + profile Profile @json + @@allow('all', true) + } + `, + { + provider: 'postgresql', + dbUrl, + } + ); + + prisma = params.prisma; + const db = params.enhance(); + + // default value + await expect(db.user.create({ data: { profile: { address: { state: 'WA' } } } })).resolves.toMatchObject({ + profile: { address: { state: 'WA', city: 'Issaquah' }, createdAt: expect.any(Date) }, + }); + + // override default + await expect( + db.user.create({ data: { profile: { address: { state: 'WA', city: 'Seattle' } } } }) + ).resolves.toMatchObject({ + profile: { address: { state: 'WA', city: 'Seattle' } }, + }); + }); + + it('works auth() in @default', async () => { + const params = await loadSchema( + ` + type NestedProfile { + userId Int @default(auth().id) + } + + type Profile { + ownerId Int @default(auth().id) + nested NestedProfile + } + + model User { + id Int @id @default(autoincrement()) + profile Profile @json + @@allow('all', true) + } + `, + { + provider: 'postgresql', + dbUrl, + } + ); + + prisma = params.prisma; + + const db = params.enhance({ id: 1 }); + const u1 = await db.user.create({ data: { profile: { nested: {} } } }); + expect(u1.profile.ownerId).toBe(1); + expect(u1.profile.nested.userId).toBe(1); + + const u2 = await db.user.create({ data: { profile: { ownerId: 2, nested: { userId: 3 } } } }); + expect(u2.profile.ownerId).toBe(2); + expect(u2.profile.nested.userId).toBe(3); + }); +}); diff --git a/tests/integration/tests/enhancements/json/typing.test.ts b/tests/integration/tests/enhancements/json/typing.test.ts new file mode 100644 index 000000000..1905a179e --- /dev/null +++ b/tests/integration/tests/enhancements/json/typing.test.ts @@ -0,0 +1,181 @@ +import { loadSchema } from '@zenstackhq/testtools'; + +describe('JSON field typing', () => { + it('works with simple field', async () => { + await loadSchema( + ` + type Profile { + age Int @gt(0) + } + + model User { + id Int @id @default(autoincrement()) + profile Profile @json + posts Post[] + @@allow('all', true) + } + + model Post { + id Int @id @default(autoincrement()) + title String + user User @relation(fields: [userId], references: [id]) + userId Int + } + `, + { + provider: 'postgresql', + pushDb: false, + compile: true, + extraSourceFiles: [ + { + name: 'main.ts', + content: ` +import { enhance } from '.zenstack/enhance'; +import { PrismaClient } from '@prisma/client'; +const prisma = new PrismaClient(); +const db = enhance(prisma); + +async function main() { + const u = await db.user.create({ data: { profile: { age: 18 }, posts: { create: { title: 'Post1' }} } }); + console.log(u.profile.age); + const u1 = await db.user.findUnique({ where: { id: u.id } }); + console.log(u1?.profile.age); + const u2 = await db.user.findMany({include: { posts: true }}); + console.log(u2[0].profile.age); +} + `, + }, + ], + } + ); + }); + + it('works with optional field', async () => { + await loadSchema( + ` + type Profile { + age Int @gt(0) + } + + model User { + id Int @id @default(autoincrement()) + profile Profile? @json + @@allow('all', true) + } + `, + { + provider: 'postgresql', + pushDb: false, + compile: true, + extraSourceFiles: [ + { + name: 'main.ts', + content: ` +import { enhance } from '.zenstack/enhance'; +import { PrismaClient } from '@prisma/client'; +const prisma = new PrismaClient(); +const db = enhance(prisma); + +async function main() { + const u = await db.user.create({ data: { profile: { age: 18 } } }); + console.log(u.profile?.age); + const u1 = await db.user.findUnique({ where: { id: u.id } }); + console.log(u1?.profile?.age); + const u2 = await db.user.findMany(); + console.log(u2[0].profile?.age); +} + `, + }, + ], + } + ); + }); + + it('works with array field', async () => { + await loadSchema( + ` + type Profile { + age Int @gt(0) + } + + model User { + id Int @id @default(autoincrement()) + profiles Profile[] @json + @@allow('all', true) + } + `, + { + provider: 'postgresql', + pushDb: false, + compile: true, + extraSourceFiles: [ + { + name: 'main.ts', + content: ` +import { enhance } from '.zenstack/enhance'; +import { PrismaClient } from '@prisma/client'; +const prisma = new PrismaClient(); +const db = enhance(prisma); + +async function main() { + const u = await db.user.create({ data: { profiles: [{ age: 18 }] } }); + console.log(u.profiles[0].age); + const u1 = await db.user.findUnique({ where: { id: u.id } }); + console.log(u1?.profiles[0].age); + const u2 = await db.user.findMany(); + console.log(u2[0].profiles[0].age); +} + `, + }, + ], + } + ); + }); + + it('works with type nesting', async () => { + await loadSchema( + ` + type Profile { + age Int @gt(0) + address Address? + } + + type Address { + city String + } + + model User { + id Int @id @default(autoincrement()) + profile Profile @json + @@allow('all', true) + } + `, + { + provider: 'postgresql', + pushDb: false, + compile: true, + extraSourceFiles: [ + { + name: 'main.ts', + content: ` +import { enhance } from '.zenstack/enhance'; +import { PrismaClient } from '@prisma/client'; +const prisma = new PrismaClient(); +const db = enhance(prisma); + +async function main() { + const u = await db.user.create({ data: { profile: { age: 18, address: { city: 'Issaquah' } } } }); + console.log(u.profile.address?.city); + const u1 = await db.user.findUnique({ where: { id: u.id } }); + console.log(u1?.profile.address?.city); + const u2 = await db.user.findMany(); + console.log(u2[0].profile.address?.city); + await db.user.create({ data: { profile: { age: 20 } } }); +} + `, + }, + ], + } + ); + }); +}); diff --git a/tests/integration/tests/enhancements/json/validation.test.ts b/tests/integration/tests/enhancements/json/validation.test.ts new file mode 100644 index 000000000..2643056fe --- /dev/null +++ b/tests/integration/tests/enhancements/json/validation.test.ts @@ -0,0 +1,39 @@ +import { loadModelWithError } from '@zenstackhq/testtools'; + +describe('JSON field typing', () => { + it('is only supported by postgres', async () => { + await expect( + loadModelWithError( + ` + type Profile { + age Int @gt(0) + } + + model User { + id Int @id @default(autoincrement()) + profile Profile @json + @@allow('all', true) + } + ` + ) + ).resolves.toContain('Custom-typed field is only supported with "postgresql" provider'); + }); + + it('requires field to have @json attribute', async () => { + await expect( + loadModelWithError( + ` + type Profile { + age Int @gt(0) + } + + model User { + id Int @id @default(autoincrement()) + profile Profile + @@allow('all', true) + } + ` + ) + ).resolves.toContain('Custom-typed field must have @json attribute'); + }); +}); diff --git a/tests/integration/tests/plugins/zod.test.ts b/tests/integration/tests/plugins/zod.test.ts index 5af7f4077..aba94261c 100644 --- a/tests/integration/tests/plugins/zod.test.ts +++ b/tests/integration/tests/plugins/zod.test.ts @@ -1025,4 +1025,60 @@ describe('Zod plugin tests', () => { ) ).rejects.toThrow(/Invalid mode/); }); + + it('supports type def', async () => { + const { zodSchemas } = await loadSchema( + ` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + generator js { + provider = 'prisma-client-js' + } + + plugin zod { + provider = '@core/zod' + } + + type Address { + city String @length(2, 20) + } + + type Profile { + age Int @gte(18) + address Address? + } + + model User { + id Int @id @default(autoincrement()) + profile Profile @json + } + `, + { addPrelude: false, pushDb: false } + ); + + const schemas = zodSchemas.models; + + let parsed = schemas.ProfileSchema.safeParse({ age: 18, address: { city: 'NY' } }); + expect(parsed.success).toBeTruthy(); + expect(parsed.data).toEqual({ age: 18, address: { city: 'NY' } }); + + expect(schemas.ProfileSchema.safeParse({ age: 18 })).toMatchObject({ success: true }); + expect(schemas.ProfileSchema.safeParse({ age: 10 })).toMatchObject({ success: false }); + expect(schemas.ProfileSchema.safeParse({ address: { city: 'NY' } })).toMatchObject({ success: false }); + expect(schemas.ProfileSchema.safeParse({ address: { age: 18, city: 'N' } })).toMatchObject({ success: false }); + + expect(schemas.UserSchema.safeParse({ id: 1, profile: { age: 18 } })).toMatchObject({ success: true }); + expect(schemas.UserSchema.safeParse({ id: 1, profile: { age: 10 } })).toMatchObject({ success: false }); + + const objectSchemas = zodSchemas.objects; + expect(objectSchemas.UserCreateInputObjectSchema.safeParse({ profile: { age: 18 } })).toMatchObject({ + success: true, + }); + expect(objectSchemas.UserCreateInputObjectSchema.safeParse({ profile: { age: 10 } })).toMatchObject({ + success: false, + }); + }); });