diff --git a/packages/cli/test/prisma-schema-gen.test.ts b/packages/cli/test/prisma-schema-gen.test.ts index b5a8ce733..9d5838c6b 100644 --- a/packages/cli/test/prisma-schema-gen.test.ts +++ b/packages/cli/test/prisma-schema-gen.test.ts @@ -91,4 +91,47 @@ model User { expect(prismaSchemaText.includes('@ds.JsonB')).toBe(true); expect(prismaSchemaText.includes('@ds.ByteA')).toBe(true); }); + + it('renames primitive type defs to match the base type', async () => { + const model = await loadSchema(` +model User { + id String @id + name UserName +} + +type UserName with String { + this String +} + `); + + const generator = new PrismaSchemaGenerator(model); + const prismaSchemaText = await generator.generate(); + + expect(prismaSchemaText.includes('name UserName')).toBe(false); + expect(prismaSchemaText.includes('name String')).toBe(true); + }); + + it('renames primitive type defs to match the base type when used with lists', async () => { + const model = await loadSchema(` +datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') +} + +model User { + id String @id + name UserName[] +} + +type UserName with String { + this String +} + `); + + const generator = new PrismaSchemaGenerator(model); + const prismaSchemaText = await generator.generate(); + + expect(prismaSchemaText.includes('name UserName[]')).toBe(false); + expect(prismaSchemaText.includes('name String[]')).toBe(true); + }); }); diff --git a/packages/cli/test/ts-schema-gen.test.ts b/packages/cli/test/ts-schema-gen.test.ts index e84b1e054..7b7fa3ee8 100644 --- a/packages/cli/test/ts-schema-gen.test.ts +++ b/packages/cli/test/ts-schema-gen.test.ts @@ -1015,4 +1015,160 @@ type Profile with Strict { }, }); }); + + it('supports primitive type defs', async () => { + const { schema } = await generateTsSchema(` + model User { + id Int @id + name UserName + } + + type UserName with String { + this String + } + `); + + expect(schema).toMatchObject({ + models: { + User: { + name: 'User', + fields: { + id: { + name: 'id', + type: 'Int', + id: true, + attributes: [ + { + name: '@id', + }, + ], + }, + name: { + name: 'name', + type: 'UserName', + }, + }, + idFields: ['id'], + uniqueFields: { + id: { + type: 'Int', + }, + }, + }, + }, + typeDefs: { + UserName: { + name: 'UserName', + base: 'String', + fields: { + this: { + name: 'this', + type: 'String', + }, + }, + }, + }, + authType: 'User', + plugins: {}, + }); + }); + + it('supports primitive type defs with validation attributes', async () => { + const { schema } = await generateTsSchema(` + model User { + id Int @id + name UserName + } + + type UserName with String { + this String @length(2, 16) + } + `); + + expect(schema).toMatchObject({ + models: { + User: { + name: 'User', + fields: { + id: { + name: 'id', + type: 'Int', + id: true, + attributes: [ + { + name: '@id', + }, + ], + }, + name: { + name: 'name', + type: 'UserName', + attributes: [ + { + name: '@length', + args: [ + { + name: 'min', + value: { + kind: 'literal', + value: 2, + }, + }, + { + name: 'max', + value: { + kind: 'literal', + value: 16, + }, + }, + ], + }, + ], + }, + }, + idFields: ['id'], + uniqueFields: { + id: { + type: 'Int', + }, + }, + }, + }, + typeDefs: { + UserName: { + name: 'UserName', + base: 'String', + fields: { + this: { + name: 'this', + type: 'String', + attributes: [ + { + name: '@length', + args: [ + { + name: 'min', + value: { + kind: 'literal', + value: 2, + }, + }, + { + name: 'max', + value: { + kind: 'literal', + value: 16, + }, + }, + ], + }, + ], + }, + }, + }, + }, + authType: 'User', + plugins: {}, + }); + }); }); diff --git a/packages/language/src/generated/ast.ts b/packages/language/src/generated/ast.ts index c5ecbe0fd..5676b8889 100644 --- a/packages/language/src/generated/ast.ts +++ b/packages/language/src/generated/ast.ts @@ -435,6 +435,7 @@ export interface DataModel extends langium.AstNode { readonly $container: Model; readonly $type: 'DataModel'; attributes: Array; + base?: BuiltinType; baseModel?: langium.Reference; comments: Array; fields: Array; @@ -448,6 +449,7 @@ export interface DataModel extends langium.AstNode { export const DataModel = { $type: 'DataModel', attributes: 'attributes', + base: 'base', baseModel: 'baseModel', comments: 'comments', fields: 'fields', @@ -912,10 +914,10 @@ export function isReferenceTarget(item: unknown): item is ReferenceTarget { return reflection.isInstance(item, ReferenceTarget.$type); } -export type RegularID = 'abstract' | 'attribute' | 'datasource' | 'enum' | 'import' | 'in' | 'model' | 'plugin' | 'type' | 'view' | string; +export type RegularID = 'abstract' | 'attribute' | 'datasource' | 'enum' | 'import' | 'in' | 'model' | 'plugin' | 'this' | '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' || item === 'type' || (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' || item === 'this' || (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' | 'Void' | RegularID; @@ -968,6 +970,7 @@ export interface TypeDef extends langium.AstNode { readonly $container: Model; readonly $type: 'TypeDef'; attributes: Array; + base?: BuiltinType; comments: Array; fields: Array; mixins: Array>; @@ -977,6 +980,7 @@ export interface TypeDef extends langium.AstNode { export const TypeDef = { $type: 'TypeDef', attributes: 'attributes', + base: 'base', comments: 'comments', fields: 'fields', mixins: 'mixins', @@ -1354,6 +1358,9 @@ export class ZModelAstReflection extends langium.AbstractAstReflection { name: DataModel.attributes, defaultValue: [] }, + base: { + name: DataModel.base + }, baseModel: { name: DataModel.baseModel, referenceType: DataModel.$type @@ -1765,6 +1772,9 @@ export class ZModelAstReflection extends langium.AbstractAstReflection { name: TypeDef.attributes, defaultValue: [] }, + base: { + name: TypeDef.base + }, comments: { name: TypeDef.comments, defaultValue: [] diff --git a/packages/language/src/generated/grammar.ts b/packages/language/src/generated/grammar.ts index ac3ae2b27..14d288069 100644 --- a/packages/language/src/generated/grammar.ts +++ b/packages/language/src/generated/grammar.ts @@ -2013,17 +2013,34 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "value": "with" }, { - "$type": "Assignment", - "feature": "mixins", - "operator": "+=", - "terminal": { - "$type": "CrossReference", - "type": { - "$ref": "#/rules@44" + "$type": "Alternatives", + "elements": [ + { + "$type": "Assignment", + "feature": "mixins", + "operator": "+=", + "terminal": { + "$type": "CrossReference", + "type": { + "$ref": "#/rules@44" + }, + "deprecatedSyntax": false, + "isMulti": false + } }, - "deprecatedSyntax": false, - "isMulti": false - } + { + "$type": "Assignment", + "feature": "base", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@64" + }, + "arguments": [] + } + } + ] }, { "$type": "Group", @@ -3087,6 +3104,10 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "Keyword", "value": "type" + }, + { + "$type": "Keyword", + "value": "this" } ] }, diff --git a/packages/language/src/utils.ts b/packages/language/src/utils.ts index 0e2f690c1..256e3c637 100644 --- a/packages/language/src/utils.ts +++ b/packages/language/src/utils.ts @@ -249,6 +249,13 @@ export function isLiteAttribute(node: AstNode): node is Attribute { return isAttribute(node) && hasAttribute(node, '@@@lite'); } +/** + * Returns if the given node is primitive type def. + */ +export function isPrimitiveTypeDef(node: AstNode): node is TypeDef { + return isTypeDef(node) && !!node.base; +} + /** * Returns the datasource provider literal (e.g. `'postgresql'`) declared in the schema, or undefined * if no datasource is found or its provider is not a literal. @@ -737,6 +744,19 @@ export function getAllFields( return fields; } +export function getAllFieldAttributes(field: DataField) { + const attributes: DataFieldAttribute[] = [...field.attributes]; + if (field.type?.reference?.ref && isPrimitiveTypeDef(field.type.reference.ref)) { + const thisField = getPrimitiveTypeDefThisField(field.type.reference.ref); + attributes.push(...(thisField?.attributes ?? [])); + } + return attributes; +} + +export function getPrimitiveTypeDefThisField(td: TypeDef) { + return td.fields.find((f) => f.name === 'this'); +} + /** * Gets all attributes of a data model or type def, including inherited attributes * from base models and mixins. diff --git a/packages/language/src/validators/attribute-application-validator.ts b/packages/language/src/validators/attribute-application-validator.ts index 1be7fc6c8..0472b8f9c 100644 --- a/packages/language/src/validators/attribute-application-validator.ts +++ b/packages/language/src/validators/attribute-application-validator.ts @@ -26,6 +26,7 @@ import { } from '../generated/ast'; import { getAllAttributes, + getAllFieldAttributes, getAttributeArg, getContainingDataModel, getDataSourceProvider, @@ -40,6 +41,7 @@ import { isDelegateModel, isManyToManyField, isNativeTypeMappingAttribute, + isPrimitiveTypeDef, isRelationshipField, mapBuiltinTypeToExpressionType, resolved, @@ -142,6 +144,14 @@ export default class AttributeApplicationValidator implements AstValidator a.decl.ref === attrDecl && a !== attr); if (duplicates.length > 0) { accept('error', `Attribute "${attrDecl.name}" can only be applied once`, { node: attr }); @@ -664,10 +678,16 @@ function assignableToAttributeParam( // attribute parameter type is ContextType, need to infer type from // the attribute's container if (isDataField(attr.$container)) { - if (!attr.$container?.type?.type) { - return genericError; + if (attr.$container.type.reference?.ref && isPrimitiveTypeDef(attr.$container.type.reference.ref)) { + dstType = mapBuiltinTypeToExpressionType(attr.$container.type.reference.ref.base!); + } else { + if (!attr.$container?.type?.type) { + return genericError; + } + + dstType = mapBuiltinTypeToExpressionType(attr.$container.type.type); } - dstType = mapBuiltinTypeToExpressionType(attr.$container.type.type); + dstIsArray = attr.$container.type.array; } else { dstType = 'Any'; @@ -708,34 +728,35 @@ function isValidAttributeTarget(attrDecl: Attribute, targetDecl: DataField) { .filter((name): name is string => !!name); let allowed = false; + const targetDeclType = targetDecl.$resolvedType?.decl ?? targetDecl.type.type; for (const allowedType of fieldTypes) { switch (allowedType) { case 'StringField': - allowed = allowed || targetDecl.type.type === 'String'; + allowed = allowed || targetDeclType === 'String'; break; case 'IntField': - allowed = allowed || targetDecl.type.type === 'Int'; + allowed = allowed || targetDeclType === 'Int'; break; case 'BigIntField': - allowed = allowed || targetDecl.type.type === 'BigInt'; + allowed = allowed || targetDeclType === 'BigInt'; break; case 'FloatField': - allowed = allowed || targetDecl.type.type === 'Float'; + allowed = allowed || targetDeclType === 'Float'; break; case 'DecimalField': - allowed = allowed || targetDecl.type.type === 'Decimal'; + allowed = allowed || targetDeclType === 'Decimal'; break; case 'BooleanField': - allowed = allowed || targetDecl.type.type === 'Boolean'; + allowed = allowed || targetDeclType === 'Boolean'; break; case 'DateTimeField': - allowed = allowed || targetDecl.type.type === 'DateTime'; + allowed = allowed || targetDeclType === 'DateTime'; break; case 'JsonField': - allowed = allowed || targetDecl.type.type === 'Json'; + allowed = allowed || targetDeclType === 'Json'; break; case 'BytesField': - allowed = allowed || targetDecl.type.type === 'Bytes'; + allowed = allowed || targetDeclType === 'Bytes'; break; case 'ModelField': allowed = allowed || isDataModel(targetDecl.type.reference?.ref); diff --git a/packages/language/src/validators/datamodel-validator.ts b/packages/language/src/validators/datamodel-validator.ts index 577599390..4927360ea 100644 --- a/packages/language/src/validators/datamodel-validator.ts +++ b/packages/language/src/validators/datamodel-validator.ts @@ -19,6 +19,7 @@ import { } from '../generated/ast'; import { getAllAttributes, + getAllFieldAttributes, getAllFields, getAttribute, getAttributeArg, @@ -28,6 +29,7 @@ import { hasAttribute, isDelegateModel, isEnumFieldReference, + isPrimitiveTypeDef, } from '../utils'; import { validateAttributeApplication } from './attribute-application-validator'; import { validateDuplicatedDeclarations, type AstValidator } from './common'; @@ -41,13 +43,12 @@ export default class DataModelValidator implements AstValidator { this.validateAttributes(dm, accept); this.validateFields(dm, accept); this.validateOnceInModelAttributes(dm, accept); - if (dm.mixins.length > 0) { + if (dm.mixins.length > 0 || dm.base) { this.validateMixins(dm, accept); } this.validateInherits(dm, accept); this.validateDelegateMap(dm, accept); } - private validateFields(dm: DataModel, accept: ValidationAcceptor) { const allFields = getAllFields(dm); const idFields = allFields.filter((f) => f.attributes.find((attr) => attr.decl.ref?.name === '@id')); @@ -87,7 +88,9 @@ export default class DataModelValidator implements AstValidator { } const isArray = idField.type.array; - const isScalar = SCALAR_TYPES.includes(idField.type.type as (typeof SCALAR_TYPES)[number]); + const isScalar = SCALAR_TYPES.includes( + (idField.$resolvedType?.decl ?? idField.type.type) as (typeof SCALAR_TYPES)[number], + ); const isValidType = isScalar || isEnum(idField.type.reference?.ref); if (isArray || !isValidType) { @@ -121,7 +124,7 @@ export default class DataModelValidator implements AstValidator { field.attributes.forEach((attr) => validateAttributeApplication(attr, accept)); - if (isTypeDef(field.type.reference?.ref)) { + if (isTypeDef(field.type.reference?.ref) && !isPrimitiveTypeDef(field.type.reference.ref)) { if (!hasAttribute(field, '@json')) { accept('error', 'Custom-typed field must have @json attribute', { node: field }); } @@ -160,7 +163,7 @@ export default class DataModelValidator implements AstValidator { // group field attributes carrying `@@@onceInModel` by their attribute declaration const occurrences = new Map(); for (const field of getAllFields(dm)) { - for (const attr of field.attributes) { + for (const attr of getAllFieldAttributes(field)) { const decl = attr.decl.ref; if (decl && hasAttribute(decl, '@@@onceInModel')) { const list = occurrences.get(decl) ?? []; @@ -525,6 +528,12 @@ export default class DataModelValidator implements AstValidator { } private validateMixins(dm: DataModel, accept: ValidationAcceptor) { + if (dm.base) { + accept('error', `model "${dm.name}" cannot use a primitive type as a mixin`, { + node: dm, + }); + return; + } const seen: TypeDef[] = []; const todo: TypeDef[] = dm.mixins.map((mixin) => mixin.ref!); while (todo.length > 0) { @@ -537,6 +546,12 @@ export default class DataModelValidator implements AstValidator { } seen.push(current); todo.push(...current.mixins.map((mixin) => mixin.ref!)); + + if (current.base) { + accept('error', `cannot use primitive type def "${current.name}" as a mixin`, { + node: dm, + }); + } } } diff --git a/packages/language/src/validators/function-invocation-validator.ts b/packages/language/src/validators/function-invocation-validator.ts index 9f722b490..ba6c3efc7 100644 --- a/packages/language/src/validators/function-invocation-validator.ts +++ b/packages/language/src/validators/function-invocation-validator.ts @@ -14,6 +14,7 @@ import { isDataModel, isDataModelAttribute, isStringLiteral, + isThisExpr, } from '../generated/ast'; import { getFunctionExpressionContext, @@ -266,7 +267,7 @@ export default class FunctionInvocationValidator implements AstValidator { validateDuplicatedDeclarations(typeDef, typeDef.fields, accept); this.validateAttributes(typeDef, accept); this.validateFields(typeDef, accept); + this.validatePrimitiveTypeDef(typeDef, accept); } private validateAttributes(typeDef: TypeDef, accept: ValidationAcceptor) { @@ -24,4 +26,47 @@ export default class TypeDefValidator implements AstValidator { private validateField(field: DataField, accept: ValidationAcceptor): void { field.attributes.forEach((attr) => validateAttributeApplication(attr, accept)); } + + private validatePrimitiveTypeDef(typeDef: TypeDef, accept: ValidationAcceptor) { + if (typeDef.base) { + if (typeDef.fields.length > 1) { + accept('error', 'primitive type def must only declare 1 field', { + node: typeDef, + }); + } + const thisField = getPrimitiveTypeDefThisField(typeDef); + if (thisField) { + if (thisField.type.type !== typeDef.base) { + accept('error', 'primitive type def\'s "this" field must match the declared type', { + node: thisField, + }); + } + + if (thisField.type.array) { + accept('error', 'primitive type def\'s "this" field must be scalar', { + node: thisField, + }); + } + + if (thisField.type.optional) { + accept('error', 'primitive type def\'s "this" field must not be optional', { + node: thisField, + }); + } + } + + if (typeDef.mixins.length > 0) { + accept('error', `primitive type def cannot use mixins`, { + node: typeDef, + }); + } + } else { + const primitiveTypeDefs = typeDef.mixins.filter((m) => !!m.ref?.base); + for (const primitiveTypeDef of primitiveTypeDefs) { + accept('error', `cannot use primitive type def "${primitiveTypeDef.$refText}" as a mixin`, { + node: typeDef, + }); + } + } + } } diff --git a/packages/language/src/zmodel-linker.ts b/packages/language/src/zmodel-linker.ts index 0908f7c20..782496112 100644 --- a/packages/language/src/zmodel-linker.ts +++ b/packages/language/src/zmodel-linker.ts @@ -60,6 +60,7 @@ import { isAuthInvocation, isBeforeInvocation, isMemberContainer, + isPrimitiveTypeDef, mapBuiltinTypeToExpressionType, } from './utils'; @@ -372,12 +373,18 @@ export class ZModelLinker extends DefaultLinker { let decl: AstNode | undefined = node.$container; - while (decl && !isDataModel(decl)) { - decl = decl.$container; - } + while (decl) { + if (isPrimitiveTypeDef(decl)) { + this.resolveToBuiltinTypeOrDecl(node, decl.base!); + return; + } - if (decl) { - this.resolveToBuiltinTypeOrDecl(node, decl); + if (isDataModel(decl)) { + this.resolveToBuiltinTypeOrDecl(node, decl); + return; + } + + decl = decl.$container; } } @@ -497,11 +504,19 @@ export class ZModelLinker extends DefaultLinker { let scopes = extraScopes; - // if the field has enum declaration type, resolve the rest with that enum's fields on top of the scopes - if (node.type.reference?.ref && isEnum(node.type.reference.ref)) { - const contextEnum = node.type.reference.ref as Enum; - const enumScope: ScopeProvider = (name) => contextEnum.fields.find((f) => f.name === name); - scopes = [enumScope, ...scopes]; + if (node.type.reference?.ref) { + // if the field has enum declaration type, resolve the rest with that enum's fields on top of the scopes + if (isEnum(node.type.reference.ref)) { + const contextEnum = node.type.reference.ref as Enum; + const enumScope: ScopeProvider = (name) => contextEnum.fields.find((f) => f.name === name); + scopes = [enumScope, ...scopes]; + } else if (isPrimitiveTypeDef(node.type.reference.ref)) { + node.$resolvedType = { + decl: node.type.reference.ref.base, + array: node.type.array, + nullable: node.type.optional, + }; + } } this.resolveDefault(node, document, scopes); @@ -546,6 +561,12 @@ export class ZModelLinker extends DefaultLinker { array: type.array, nullable: nullable, }; + } else if (type.reference?.ref && isPrimitiveTypeDef(type.reference.ref)) { + node.$resolvedType = { + decl: type.reference.ref.base, + array: type.array, + nullable: nullable, + }; } else if (type.reference) { node.$resolvedType = { decl: type.reference.ref, diff --git a/packages/language/src/zmodel.langium b/packages/language/src/zmodel.langium index 6856e6183..c2845aac1 100644 --- a/packages/language/src/zmodel.langium +++ b/packages/language/src/zmodel.langium @@ -179,7 +179,9 @@ DataModel: '}'; fragment WithClause: - 'with' mixins+=[TypeDef] (','? mixins+=[TypeDef])*; + 'with' + (mixins+=[TypeDef] | base=BuiltinType) + (','? mixins+=[TypeDef])*; fragment ExtendsClause: 'extends' baseModel=[DataModel]; @@ -240,7 +242,7 @@ Procedure: // 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' | 'type'; + ID | 'model' | 'enum' | 'attribute' | 'datasource' | 'plugin' | 'abstract' | 'in' | 'view' | 'import' | 'type' | 'this'; RegularIDWithTypeNames returns string: RegularID | 'String' | 'Boolean' | 'Int' | 'BigInt' | 'Float' | 'Decimal' | 'DateTime' | 'Json' | 'Bytes' | 'Null' | 'Object' | 'Any' | 'Void' | 'Unsupported'; diff --git a/packages/language/test/custom-type-primitive.test.ts b/packages/language/test/custom-type-primitive.test.ts new file mode 100644 index 000000000..d0c0579e5 --- /dev/null +++ b/packages/language/test/custom-type-primitive.test.ts @@ -0,0 +1,385 @@ +import { describe, it } from 'vitest'; +import { loadSchema, loadSchemaWithError } from './utils'; + +describe('Custom type primitive tests', () => { + it('supports custom type primitives', async () => { + await loadSchema(` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + model User { + id Int @id + name UserName + } + + type UserName with String { + this String + } + `); + }); + + it('supports custom type primitives in validation', async () => { + await loadSchema(` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + model User { + id Int @id + age Age + + @@validate(age >= 18) + } + + type Age with Int { + this Int + } + `); + }); + + it('supports custom type primitives with default values', async () => { + await loadSchema(` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + model User { + id String @id + name UserName @default('') + } + + type UserName with String { + this String + } + `); + }); + + it('detects duplicate attributes', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + model User { + id String @id + name UserName @onlyOnce + } + + type UserName with String { + this String @onlyOnce + } + + attribute @onlyOnce() @@@targetField([StringField]) @@@once @@@validation + `, + 'can only be applied once', + ); + + await loadSchemaWithError( + ` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + model User { + id String @id + name UserName + name2 UserName + } + + type UserName with String { + this String @onlyOnce + } + + attribute @onlyOnce() @@@targetField([StringField]) @@@onceInModel @@@validation + `, + 'can only be applied to one field per model', + ); + }); + + it('resolves `this` to the base type', async () => { + await loadSchema(` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + model User { + id String @id + email Email + } + + type Email with String { + this String + + @@validate(isEmail(this)) + } + `); + }); + + it('rejects invalid attributes', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + model User { + id String @id + email Email + } + + type Email with String { + this String @db.Text + } + `, + 'attribute "@db.Text" cannot be used with primitive type defs', + ); + + await loadSchemaWithError( + ` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + model User { + id String @id + email Email @gt(5) + } + + type Email with String { + this String + } + `, + 'cannot be used on this type of field', + ); + }); + + it('accepts attributes on the field declaration', async () => { + await loadSchema(` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + model User { + id String @id + email Email @length(1, 2) @db.Text + } + + type Email with String { + this String + } + `); + }); + + it('accepts attributes on the `this` declaration', async () => { + await loadSchema(` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + model User { + id String @id + email Email + } + + type Email with String { + this String @email + } + `); + }); + + it('does not require a "this" field', async () => { + await loadSchema( + ` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + model User { + id String @id + email Email + } + + type Email with String {} + `, + ); + }); + + it('rejects when there is more than 1 field', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + model User { + id String @id + email Email + } + + type Email with String { + this String + this2 String + } + `, + 'primitive type def must only declare 1 field', + ); + }); + + it('rejects when "this" field does not match declared type', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + model User { + id String @id + email Email + } + + type Email with String { + this Int + } + `, + 'primitive type def\'s "this" field must match the declared type', + ); + }); + + it('rejects when "this" field is not scalar', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + model User { + id String @id + email Email + } + + type Email with String { + this String[] + } + `, + 'primitive type def\'s "this" field must be scalar', + ); + }); + + it('rejects when "this" field is optional', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + model User { + id String @id + email Email + } + + type Email with String { + this String? + } + `, + 'primitive type def\'s "this" field must not be optional', + ); + }); + + it('rejects when trying to use mixins', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + model User { + id String @id + email Email + } + + type Email with String, Mixin { + this String + } + + type Mixin { + } + `, + 'primitive type def cannot use mixins', + ); + }); + + it('rejects when used as a mixin', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + model User with Email { + id String @id + email Email + } + + type Email with String { + this String + } + `, + 'cannot use primitive type def "Email" as a mixin', + ); + + await loadSchemaWithError( + ` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + model User { + id String @id + email Email + } + + type Email with String { + this String + } + + type Mixin with Email { + } + `, + 'cannot use primitive type def "Email" as a mixin', + ); + + await loadSchemaWithError( + ` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + model User with String { + id String @id + } + `, + 'model "User" cannot use a primitive type as a mixin', + ); + }); +}); diff --git a/packages/language/test/function-invocation.test.ts b/packages/language/test/function-invocation.test.ts index 68d20a092..e182b697c 100644 --- a/packages/language/test/function-invocation.test.ts +++ b/packages/language/test/function-invocation.test.ts @@ -462,4 +462,26 @@ describe('Function Invocation Tests', () => { ); }); }); + + describe('length()', () => { + it('should accept primitive type defs of String', async () => { + await loadSchema(` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id + name UserName + } + + type UserName with String { + this String + + @@validate(length(this) >= 2) + } + `); + }); + }); }); diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index d0e9ef940..b4b137e4c 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -17,6 +17,7 @@ import type { GetModelFieldType, GetModels, GetSubModels, + GetTypeDef, GetTypeDefField, GetTypeDefFields, GetTypeDefFieldType, @@ -32,6 +33,7 @@ import type { SchemaDef, TypeDefFieldIsArray, TypeDefFieldIsOptional, + TypeDefIsPrimitive, UpdatedAtInfo, } from '@zenstackhq/schema'; import type { ExpressionBuilder, OperandExpression, SqlBool } from 'kysely'; @@ -318,27 +320,36 @@ export type TypeDefResult< Schema extends SchemaDef, TypeDef extends GetTypeDefs, Partial extends boolean = false, -> = PartialIf< - Optional< - { - [Key in GetTypeDefFields]: MapFieldDefType< - Schema, - GetTypeDefField, - Partial - >; - }, - // optionality - Partial extends true - ? never - : keyof { - [Key in GetTypeDefFields as TypeDefFieldIsOptional extends true - ? Key - : never]: true; - } - >, - Partial -> & - (IsTypeDefStrict extends true ? {} : Record); +> = + Schema['typeDefs'] extends Record + ? Schema['typeDefs'][TypeDef]['base'] extends string + ? TypeMap[Schema['typeDefs'][TypeDef]['base']] + : PartialIf< + Optional< + { + [Key in GetTypeDefFields]: MapFieldDefType< + Schema, + GetTypeDefField, + Partial + >; + }, + // optionality + Partial extends true + ? never + : keyof { + [Key in GetTypeDefFields as TypeDefFieldIsOptional< + Schema, + TypeDef, + Key + > extends true + ? Key + : never]: true; + } + >, + Partial + > & + (IsTypeDefStrict extends true ? {} : Record) + : never; export type IsTypeDefStrict> = Schema['typeDefs'] extends Record @@ -401,14 +412,22 @@ type FieldFilter< AllowedKinds > : GetModelFieldType extends GetTypeDefs - ? // typedef - TypedJsonFilter< - Schema, - GetModelFieldType, - FieldIsArray, - ModelFieldIsOptional, - AllowedKinds - > + ? TypeDefIsPrimitive> extends true + ? // primitive typedef — filter like its base type + PrimitiveFilter< + TypeDefBaseType>, + ModelFieldIsOptional, + WithAggregations, + AllowedKinds + > + : // JSON typedef + TypedJsonFilter< + Schema, + GetModelFieldType, + FieldIsArray, + ModelFieldIsOptional, + AllowedKinds + > : // primitive GetModelFieldType extends 'String' ? // string — additionally consider fuzzy / full-text augmentations @@ -584,9 +603,23 @@ type ArrayFilter< } : {}); +// the base builtin type of a primitive typedef (e.g. `Int` for `type Age with Int`) +type TypeDefBaseType> = GetTypeDef< + Schema, + TypeDef +>['base'] extends infer B extends string + ? B + : never; + // map a scalar type (primitive and enum) to TS type type MapScalarType = - Type extends GetEnums ? keyof GetEnum : MapBaseType; + Type extends GetEnums + ? keyof GetEnum + : Type extends GetTypeDefs + ? TypeDefIsPrimitive extends true + ? MapBaseType> + : MapBaseType + : MapBaseType; type PrimitiveFilter< T extends string, @@ -973,14 +1006,24 @@ type TypedJsonFieldsFilter< TypeDefName, Key > extends GetTypeDefs - ? // nested typedef - recurse - TypedJsonFilter< - Schema, - GetTypeDefFieldType, - TypeDefFieldIsArray, - TypeDefFieldIsOptional, - AllowedKinds - > + ? TypeDefIsPrimitive> extends true + ? // primitive typedef — filter like its base type + TypeDefFieldIsArray extends true + ? ArrayFilter, AllowedKinds> + : PrimitiveFilter< + TypeDefBaseType>, + TypeDefFieldIsOptional, + false, + AllowedKinds + > + : // nested JSON typedef - recurse + TypedJsonFilter< + Schema, + GetTypeDefFieldType, + TypeDefFieldIsArray, + TypeDefFieldIsOptional, + AllowedKinds + > : // array TypeDefFieldIsArray extends true ? ArrayFilter, AllowedKinds> @@ -1485,8 +1528,10 @@ type MapFieldDefType< T['type'] extends GetEnums ? keyof GetEnum : T['type'] extends GetTypeDefs - ? TypeDefResult & - (IsTypeDefStrict extends true ? {} : Record) + ? TypeDefIsPrimitive extends true + ? TypeDefResult + : TypeDefResult & + (IsTypeDefStrict extends true ? {} : Record) : MapBaseType, T['optional'], T['array'] diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index e3e852ed2..2fa4b4d37 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -28,6 +28,7 @@ import { getModelFields, getRelationForeignKeyFieldPairs, isEnum, + isPrimitiveTypeDef, isTypeDef, makeDefaultOrderBy, requireField, @@ -658,15 +659,25 @@ export abstract class BaseCrudDialect { return this.buildEnumFilter(fieldRef, fieldDef, payload); } + let type = fieldDef.type; + if (isTypeDef(this.schema, fieldDef.type)) { - if (payload instanceof DbNullClass || payload instanceof JsonNullClass || payload instanceof AnyNullClass) { - // null sentinel passed directly (e.g. where: { field: DbNull }) — treat like { equals: sentinel } - return this.buildJsonValueFilterClause(fieldRef, payload); + if (isPrimitiveTypeDef(this.schema, fieldDef.type)) { + type = this.schema['typeDefs']![fieldDef.type]!['base']!; + } else { + if ( + payload instanceof DbNullClass || + payload instanceof JsonNullClass || + payload instanceof AnyNullClass + ) { + // null sentinel passed directly (e.g. where: { field: DbNull }) — treat like { equals: sentinel } + return this.buildJsonValueFilterClause(fieldRef, payload); + } + return this.buildJsonFilter(fieldRef, payload, fieldDef); } - return this.buildJsonFilter(fieldRef, payload, fieldDef); } - return match(fieldDef.type as BuiltinType) + return match(type as BuiltinType) .with('String', () => this.buildStringFilter(fieldRef, payload, fieldDef)) .with(P.union('Int', 'Float', 'Decimal', 'BigInt'), (type) => this.buildNumberFilter(fieldRef, type, payload), @@ -839,14 +850,17 @@ export abstract class BaseCrudDialect { const fieldDef = typeDef.fields[key]; invariant(fieldDef, `Field "${key}" not found in type definition "${typeDefName}"`); const fieldReceiver = this.buildJsonPathSelection(receiver, `$.${key}`); - if (isTypeDef(this.schema, fieldDef.type)) { + if (isTypeDef(this.schema, fieldDef.type) && !isPrimitiveTypeDef(this.schema, fieldDef.type)) { clauses.push(this.buildTypedJsonFilter(fieldReceiver, value, fieldDef.type, !!fieldDef.array)); } else { if (fieldDef.array) { clauses.push(this.buildArrayFilter(fieldReceiver, fieldDef, value)); } else { + const effectiveType = isPrimitiveTypeDef(this.schema, fieldDef.type) + ? this.schema.typeDefs![fieldDef.type]!.base! + : fieldDef.type; let _receiver = fieldReceiver; - if (fieldDef.type === 'String' || isEnum(this.schema, fieldDef.type)) { + if (effectiveType === 'String' || isEnum(this.schema, effectiveType)) { // trim quotes for string and enum fields (enums are stored as JSON strings) _receiver = this.trimTextQuotes(this.castText(fieldReceiver)); } diff --git a/packages/orm/src/client/crud/dialects/mysql.ts b/packages/orm/src/client/crud/dialects/mysql.ts index 498a431fc..075f41e17 100644 --- a/packages/orm/src/client/crud/dialects/mysql.ts +++ b/packages/orm/src/client/crud/dialects/mysql.ts @@ -14,7 +14,7 @@ import { import { AnyNullClass, DbNullClass, JsonNullClass } from '../../../common-types'; import type { NullsOrder, SortOrder } from '../../crud-types'; import { createInvalidInputError, createNotSupportedError } from '../../errors'; -import { isTypeDef } from '../../query-utils'; +import { isPrimitiveTypeDef, isTypeDef } from '../../query-utils'; import type { FuzzyFilterOptions } from './base-dialect'; import { LateralJoinDialectBase } from './lateral-join-dialect-base'; @@ -72,15 +72,21 @@ export class MySqlCrudDialect extends LateralJoinDiale } if (isTypeDef(this.schema, type)) { - // type-def fields (regardless array or scalar) are stored as scalar `Json` and - // their input values need to be stringified if not already (i.e., provided in - // default values) - if (typeof value !== 'string') { - return this.transformInput(value, 'Json', forArrayField); + if (isPrimitiveTypeDef(this.schema, type)) { + type = this.schema['typeDefs']![type]!['base']!; } else { - return value; + // type-def fields (regardless array or scalar) are stored as scalar `Json` and + // their input values need to be stringified if not already (i.e., provided in + // default values) + if (typeof value !== 'string') { + return this.transformInput(value, 'Json', forArrayField); + } else { + return value; + } } - } else if (Array.isArray(value)) { + } + + if (Array.isArray(value)) { if (type === 'Json') { // type-def arrays reach here return JSON.stringify(value); diff --git a/packages/orm/src/client/crud/dialects/postgresql.ts b/packages/orm/src/client/crud/dialects/postgresql.ts index 5c6866b23..fb6780af0 100644 --- a/packages/orm/src/client/crud/dialects/postgresql.ts +++ b/packages/orm/src/client/crud/dialects/postgresql.ts @@ -15,7 +15,7 @@ import { parse as parsePostgresArray } from 'postgres-array'; import { AnyNullClass, DbNullClass, JsonNullClass } from '../../../common-types'; import type { NullsOrder, SortOrder } from '../../crud-types'; import { createInvalidInputError } from '../../errors'; -import { isEnum, isTypeDef } from '../../query-utils'; +import { isEnum, isPrimitiveTypeDef, isTypeDef } from '../../query-utils'; import type { CrudDialectArgs, FuzzyFilterOptions } from './base-dialect'; import { LateralJoinDialectBase } from './lateral-join-dialect-base'; @@ -187,15 +187,21 @@ export class PostgresCrudDialect extends LateralJoinDi // https://github.com/brianc/node-postgres/issues/374 if (isTypeDef(this.schema, type)) { - // type-def fields (regardless array or scalar) are stored as scalar `Json` and - // their input values need to be stringified if not already (i.e., provided in - // default values) - if (typeof value !== 'string') { - return JSON.stringify(value); + if (isPrimitiveTypeDef(this.schema, type)) { + type = this.schema['typeDefs']![type]!['base']!; } else { - return value; + // type-def fields (regardless array or scalar) are stored as scalar `Json` and + // their input values need to be stringified if not already (i.e., provided in + // default values) + if (typeof value !== 'string') { + return JSON.stringify(value); + } else { + return value; + } } - } else if (Array.isArray(value)) { + } + + if (Array.isArray(value)) { if (type === 'Json' && !forArrayField) { // scalar `Json` fields need their input stringified return JSON.stringify(value); @@ -500,6 +506,10 @@ export class PostgresCrudDialect extends LateralJoinDi } } } + // a primitive typedef maps to its base type's SQL type + if (isPrimitiveTypeDef(this.schema, zmodelType)) { + zmodelType = this.schema.typeDefs![zmodelType]!.base!; + } if (isEnum(this.schema, zmodelType)) { // reduce enum to text for type compatibility return 'text'; diff --git a/packages/orm/src/client/crud/dialects/sqlite.ts b/packages/orm/src/client/crud/dialects/sqlite.ts index a7ceac7b2..f7e321cc8 100644 --- a/packages/orm/src/client/crud/dialects/sqlite.ts +++ b/packages/orm/src/client/crud/dialects/sqlite.ts @@ -21,6 +21,8 @@ import { getDelegateDescendantModels, getManyToManyRelation, getRelationForeignKeyFieldPairs, + isPrimitiveTypeDef, + isTypeDef, requireField, requireIdFields, requireModel, @@ -81,7 +83,17 @@ export class SqliteCrudDialect extends BaseCrudDialect invariant(false, 'should not reach here: AnyNull is not a valid input value'); } - if (type === 'Json' || (this.schema.typeDefs && type in this.schema.typeDefs)) { + if (isTypeDef(this.schema, type)) { + if (isPrimitiveTypeDef(this.schema, type)) { + // a primitive typedef is stored as its base type + type = this.schema.typeDefs![type]!.base!; + } else { + // JSON data should be stringified + return JSON.stringify(value); + } + } + + if (type === 'Json') { // JSON data should be stringified return JSON.stringify(value); } @@ -108,10 +120,13 @@ export class SqliteCrudDialect extends BaseCrudDialect } } - override transformOutput(value: unknown, type: BuiltinType, array: boolean) { + override transformOutput(value: unknown, type: BuiltinType, array: boolean): unknown { if (value === null || value === undefined) { return value; } else if (this.schema.typeDefs && type in this.schema.typeDefs) { + if (isPrimitiveTypeDef(this.schema, type)) { + return this.transformOutput(value, this.schema.typeDefs[type]!.base!, array); + } // typed JSON field return this.transformOutputJson(value); } else { diff --git a/packages/orm/src/client/crud/operations/aggregate.ts b/packages/orm/src/client/crud/operations/aggregate.ts index 53e8e3640..343117084 100644 --- a/packages/orm/src/client/crud/operations/aggregate.ts +++ b/packages/orm/src/client/crud/operations/aggregate.ts @@ -1,7 +1,7 @@ import type { SchemaDef } from '@zenstackhq/schema'; import { match } from 'ts-pattern'; import { createInvalidInputError } from '../../errors'; -import { getField } from '../../query-utils'; +import { getField, isPrimitiveTypeDef } from '../../query-utils'; import { BaseOperationHandler } from './base'; // stable key for comparing two `args` payloads (bigint-safe) @@ -162,7 +162,9 @@ export class AggregateOperationHandler extends BaseOpe if (typeof value === 'string') { const fieldDef = getField(this.schema, this.model, field); if (fieldDef) { - const type = fieldDef.type; + const type = isPrimitiveTypeDef(this.schema, fieldDef.type) + ? this.schema.typeDefs![fieldDef.type]!.base! + : fieldDef.type; if (op === '_avg') { val = parseFloat(val); } else { diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index ce8b651e7..a885afeda 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -44,6 +44,7 @@ import { getModel, getRelationForeignKeyFieldPairs, isForeignKeyField, + isPrimitiveTypeDef, isRelationField, isScalarField, requireField, @@ -1448,7 +1449,10 @@ export abstract class BaseOperationHandler { } private isNumericField(fieldDef: FieldDef) { - return NUMERIC_FIELD_TYPES.includes(fieldDef.type) && !fieldDef.array; + const type = isPrimitiveTypeDef(this.schema, fieldDef.type) + ? this.schema.typeDefs![fieldDef.type]!.base! + : fieldDef.type; + return NUMERIC_FIELD_TYPES.includes(type) && !fieldDef.array; } private makeContextComment(_context: { model: string; operation: CRUD }) { diff --git a/packages/orm/src/client/crud/operations/group-by.ts b/packages/orm/src/client/crud/operations/group-by.ts index 377519454..2b819027a 100644 --- a/packages/orm/src/client/crud/operations/group-by.ts +++ b/packages/orm/src/client/crud/operations/group-by.ts @@ -1,7 +1,7 @@ import type { SchemaDef } from '@zenstackhq/schema'; import { sql } from 'kysely'; import { match } from 'ts-pattern'; -import { aggregate, getField } from '../../query-utils'; +import { aggregate, getField, isPrimitiveTypeDef } from '../../query-utils'; import { BaseOperationHandler } from './base'; export class GroupByOperationHandler extends BaseOperationHandler { @@ -132,7 +132,9 @@ export class GroupByOperationHandler extends BaseOpera if (typeof value === 'string') { const fieldDef = getField(this.schema, this.model, field); if (fieldDef) { - const type = fieldDef.type; + const type = isPrimitiveTypeDef(this.schema, fieldDef.type) + ? this.schema.typeDefs![fieldDef.type]!.base! + : fieldDef.type; if (op === '_avg') { val = parseFloat(val); } else { diff --git a/packages/orm/src/client/helpers/schema-db-pusher.ts b/packages/orm/src/client/helpers/schema-db-pusher.ts index 569f65162..bcddf2314 100644 --- a/packages/orm/src/client/helpers/schema-db-pusher.ts +++ b/packages/orm/src/client/helpers/schema-db-pusher.ts @@ -326,11 +326,17 @@ export class SchemaDbPusher { return 'serial'; } - if (this.isCustomType(fieldDef.type)) { - return this.jsonType; + let type = fieldDef.type as BuiltinType; + + if (this.isCustomType(type)) { + const typeDef = Object.values(this.schema.typeDefs!).find((def) => def.name === type)!; + if (typeDef.base) { + type = typeDef.base; + } else { + return this.jsonType; + } } - const type = fieldDef.type as BuiltinType; const result = match>(type) .with('String', () => this.stringType) .with('Boolean', () => this.booleanType) diff --git a/packages/orm/src/client/query-utils.ts b/packages/orm/src/client/query-utils.ts index 7941b7faf..382176834 100644 --- a/packages/orm/src/client/query-utils.ts +++ b/packages/orm/src/client/query-utils.ts @@ -299,6 +299,10 @@ export function isTypeDef(schema: SchemaDef, type: string) { return !!schema.typeDefs?.[type]; } +export function isPrimitiveTypeDef(schema: SchemaDef, type: string) { + return !!schema.typeDefs?.[type]?.base; +} + export function buildJoinPairs( schema: SchemaDef, model: string, diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index 0d335551a..53014d4b1 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -48,6 +48,7 @@ import { getTypeDef, getUniqueFields, isEnum, + isPrimitiveTypeDef, isTypeDef, requireField, requireModel, @@ -367,7 +368,10 @@ export class ZodSchemaFactory< private makeScalarSchema(type: string, attributes?: readonly AttributeApplication[]) { if (this.schema.typeDefs && type in this.schema.typeDefs) { - return this.makeTypeDefSchema(type); + // attributes declared on the consuming field only apply to primitive typedefs + return this.schema.typeDefs[type]!.base + ? this.makeTypeDefSchema(type, attributes) + : this.makeTypeDefSchema(type); } else if (this.schema.enums && type in this.schema.enums) { return this.makeEnumSchema(type); } else { @@ -458,10 +462,26 @@ export class ZodSchemaFactory< return schema; } + /** + * Attributes to validate each value of a scalar field against. For typedef list fields, the + * field's own attributes (e.g. `@length`) validate the list rather than its items. + */ + private getItemAttributes(fieldDef: FieldDef) { + return fieldDef.array && isTypeDef(this.schema, fieldDef.type) ? undefined : fieldDef.attributes; + } + @cache() - private makeTypeDefSchema(type: string): ZodType { + private makeTypeDefSchema(type: string, fieldAttributes?: readonly AttributeApplication[]): ZodType { const typeDef = getTypeDef(this.schema, type); invariant(typeDef, `Type definition "${type}" not found in schema`); + if (typeDef.base) { + // validate against both the typedef's own attributes and those of the consuming field + const schema = this.makeScalarSchema(typeDef.base, [ + ...(typeDef.fields['this']?.attributes ?? []), + ...(fieldAttributes ?? []), + ]); + return this.extraValidationsEnabled ? ZodUtils.addCustomValidation(schema, typeDef.attributes) : schema; + } const func = typeDef.strict ? z.strictObject : z.looseObject; const schema = func( Object.fromEntries( @@ -577,7 +597,7 @@ export class ZodSchemaFactory< } else if (fieldDef.array) { // array field fieldSchema = this.makeArrayFilterSchema(fieldDef.type, allowedFilterKinds); - } else if (this.isTypeDefType(fieldDef.type)) { + } else if (this.isTypeDefType(fieldDef.type) && !isPrimitiveTypeDef(this.schema, fieldDef.type)) { fieldSchema = this.makeTypedJsonFilterSchema( fieldDef.type, !!fieldDef.optional, @@ -585,9 +605,9 @@ export class ZodSchemaFactory< allowedFilterKinds, ); } else { - // primitive field + // primitive field (or a primitive typedef, which filters like its base type) fieldSchema = this.makePrimitiveFilterSchema( - fieldDef.type as BuiltinType, + this.getEffectiveFieldType(fieldDef.type) as BuiltinType, !!fieldDef.optional, withAggregations, allowedFilterKinds, @@ -714,7 +734,7 @@ export class ZodSchemaFactory< // fields filter — typedef sub-fields are not model fields, no slicing applies const fieldSchemas: Record = {}; for (const [fieldName, fieldDef] of Object.entries(typeDef.fields)) { - if (this.isTypeDefType(fieldDef.type)) { + if (this.isTypeDefType(fieldDef.type) && !isPrimitiveTypeDef(this.schema, fieldDef.type)) { fieldSchemas[fieldName] = this.makeTypedJsonFilterSchema( fieldDef.type, !!fieldDef.optional, @@ -735,7 +755,7 @@ export class ZodSchemaFactory< fieldSchemas[fieldName] = this.makeArrayFilterSchema(fieldDef.type, undefined).optional(); } else { fieldSchemas[fieldName] = this.makePrimitiveFilterSchema( - fieldDef.type as BuiltinType, + this.getEffectiveFieldType(fieldDef.type) as BuiltinType, !!fieldDef.optional, false, undefined, @@ -815,6 +835,13 @@ export class ZodSchemaFactory< return this.schema.typeDefs && type in this.schema.typeDefs; } + private getEffectiveFieldType(type: string): string { + if (isPrimitiveTypeDef(this.schema, type)) { + return this.schema.typeDefs![type]!.base!; + } + return type; + } + @cache() private makeEnumFilterSchema( enumName: string, @@ -1676,7 +1703,7 @@ export class ZodSchemaFactory< uncheckedVariantFields[field] = fieldSchema; } } else { - let fieldSchema = this.makeScalarSchema(fieldDef.type, fieldDef.attributes); + let fieldSchema = this.makeScalarSchema(fieldDef.type, this.getItemAttributes(fieldDef)); if (fieldDef.array) { fieldSchema = ZodUtils.addListValidation(fieldSchema.array(), fieldDef.attributes); @@ -2034,7 +2061,7 @@ export class ZodSchemaFactory< uncheckedVariantFields[field] = fieldSchema; } } else { - let fieldSchema = this.makeScalarSchema(fieldDef.type, fieldDef.attributes); + let fieldSchema = this.makeScalarSchema(fieldDef.type, this.getItemAttributes(fieldDef)); if (this.isNumericField(fieldDef)) { fieldSchema = z.union([ @@ -2561,7 +2588,7 @@ export class ZodSchemaFactory< } private isNumericField(fieldDef: FieldDef) { - return NUMERIC_FIELD_TYPES.includes(fieldDef.type) && !fieldDef.array; + return NUMERIC_FIELD_TYPES.includes(this.getEffectiveFieldType(fieldDef.type)) && !fieldDef.array; } private get providerSupportsCaseSensitivity() { diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts index b6689dace..42ca76075 100644 --- a/packages/schema/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -129,6 +129,7 @@ export type EnumDef = { export type TypeDefDef = { name: string; strict?: boolean; + base?: BuiltinType; fields: Record; attributes?: readonly AttributeApplication[]; }; @@ -284,6 +285,13 @@ export type TypeDefFieldIsArray< Field extends GetTypeDefFields, > = GetTypeDefField['array'] extends true ? true : false; +export type TypeDefIsPrimitive> = GetTypeDef< + Schema, + TypeDef +>['base'] extends string + ? true + : false; + export type FieldIsRelation< Schema extends SchemaDef, Model extends GetModels, diff --git a/packages/sdk/src/model-utils.ts b/packages/sdk/src/model-utils.ts index 5c1370370..367d56bc0 100644 --- a/packages/sdk/src/model-utils.ts +++ b/packages/sdk/src/model-utils.ts @@ -77,7 +77,8 @@ export function isDelegateModel(node: AstNode) { */ export function getOwnedFields(model: DataModel | TypeDef): DataField[] { const fields: DataField[] = [...model.fields]; - for (const mixin of model.mixins) { + const mixins = model.mixins.filter((m) => !m.ref?.base); + for (const mixin of mixins) { if (mixin.ref) { fields.push(...getOwnedFields(mixin.ref)); } diff --git a/packages/sdk/src/prisma/prisma-schema-generator.ts b/packages/sdk/src/prisma/prisma-schema-generator.ts index 8034900ad..8cf5757f3 100644 --- a/packages/sdk/src/prisma/prisma-schema-generator.ts +++ b/packages/sdk/src/prisma/prisma-schema-generator.ts @@ -38,6 +38,7 @@ import { getStringLiteral, isAuthInvocation, isDelegateModel, + isPrimitiveTypeDef, } from '@zenstackhq/language/utils'; import { AstUtils } from 'langium'; import { match } from 'ts-pattern'; @@ -286,7 +287,7 @@ export class PrismaSchemaGenerator { } else if (field.type.reference?.ref) { // model, enum, or type-def if (isTypeDef(field.type.reference.ref)) { - fieldType = 'Json'; + fieldType = isPrimitiveTypeDef(field.type.reference.ref) ? field.type.reference.ref.base! : 'Json'; } else { fieldType = field.type.reference.ref.name; } @@ -304,7 +305,11 @@ export class PrismaSchemaGenerator { const isArray = // typed-JSON fields should be translated to scalar Json type - isTypeDef(field.type.reference?.ref) ? false : field.type.array; + isTypeDef(field.type.reference?.ref) + ? isPrimitiveTypeDef(field.type.reference.ref) + ? field.type.array + : false + : field.type.array; const type = new ModelFieldType(fieldType, isArray, field.type.optional); const attributes = field.attributes diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index d94393a8c..ccf6a3891 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -39,11 +39,13 @@ import { } from '@zenstackhq/language/ast'; import { getAllAttributes, + getAllFieldAttributes, getAllFields, getAttributeArg, getRelationName, isDataFieldReference, isLiteAttribute, + isPrimitiveTypeDef, } from '@zenstackhq/language/utils'; import fs from 'node:fs'; import path from 'node:path'; @@ -508,6 +510,9 @@ export class TsSchemaGenerator { // name ts.factory.createPropertyAssignment('name', ts.factory.createStringLiteral(td.name)), + // base + ...(td.base ? [ts.factory.createPropertyAssignment('base', ts.factory.createStringLiteral(td.base))] : []), + // fields ts.factory.createPropertyAssignment( 'fields', @@ -652,7 +657,8 @@ export class TsSchemaGenerator { objectFields.push(ts.factory.createPropertyAssignment('isDiscriminator', ts.factory.createTrue())); } - const attributes = lite ? field.attributes.filter((attr) => isLiteAttribute(attr.decl.ref!)) : field.attributes; + const fieldAttributes = getAllFieldAttributes(field); + const attributes = lite ? fieldAttributes.filter((attr) => isLiteAttribute(attr.decl.ref!)) : fieldAttributes; if (attributes.length > 0) { objectFields.push( @@ -813,9 +819,12 @@ export class TsSchemaGenerator { ): string | number | boolean | unknown[] | { call: string; args: any[] } | { authMember: string[] } | null { if (isLiteralExpr(expr)) { const lit = (expr as LiteralExpr).value; - return fieldType.type === 'Boolean' + // resolve the effective base type for primitive typedef references so that + // numeric/boolean defaults are emitted with the correct JS type + const effectiveType = this.getMappedValueBaseType(fieldType); + return effectiveType === 'Boolean' ? (lit as boolean) - : ['Int', 'Float', 'Decimal', 'BigInt'].includes(fieldType.type!) + : ['Int', 'Float', 'Decimal', 'BigInt'].includes(effectiveType!) ? Number(lit) : lit; } else if (isArrayExpr(expr)) { @@ -838,6 +847,19 @@ export class TsSchemaGenerator { } } + /** + * The primitive type used to map a literal default value to its JS representation. For a + * primitive typedef reference (e.g. `type Age with Int`), this resolves to the base type so + * that e.g. an `Int`-based typedef default is emitted as a number instead of a string. + */ + private getMappedValueBaseType(fieldType: DataFieldType): string | undefined { + if (fieldType.type) { + return fieldType.type; + } + const ref = fieldType.reference?.ref; + return ref && isPrimitiveTypeDef(ref) ? ref.base : undefined; + } + private getMemberAccessChain(expr: MemberAccessExpr): string[] { if (!isMemberAccessExpr(expr.operand)) { return [expr.member.$refText]; @@ -1076,8 +1098,7 @@ export class TsSchemaGenerator { ? ts.factory.createStringLiteral(field.type.type) : field.type.reference ? ts.factory.createStringLiteral(field.type.reference.$refText) - : // `Unsupported` type - ts.factory.createStringLiteral('Unsupported'); + : ts.factory.createStringLiteral('Unsupported'); } private createEnumObject(e: Enum) { diff --git a/packages/zod/src/factory.ts b/packages/zod/src/factory.ts index 6d2c50b8f..6d768e1ca 100644 --- a/packages/zod/src/factory.ts +++ b/packages/zod/src/factory.ts @@ -2,6 +2,7 @@ import { ExpressionUtils, SchemaAccessor, type AttributeApplication, + type BuiltinType, type FieldDef, type GetEnum, type GetEnums, @@ -17,13 +18,14 @@ import type { GetModelFieldsShape, GetModelSchemaShapeWithOptions, GetModelUpdateFieldsShape, - GetTypeDefFieldsShape, + MapTypeDefToZod, ModelSchemaOptions, } from './types'; import { addBigIntValidation, addCustomValidation, addDecimalValidation, + addListValidation, addNumberValidation, addStringValidation, } from './utils'; @@ -361,64 +363,70 @@ class SchemaFactory { return z.lazy(() => this.makeModelSchema(relatedModelName)); } - private makeScalarFieldSchema(fieldDef: FieldDef): z.ZodType { - const { type, attributes } = fieldDef; - - // enum - const enumDef = this.schema.getEnum(type); - if (enumDef) { - return this.applyCardinality(this.makeEnumSchema(type as GetEnums), fieldDef); - } - - // typedef - const typedefDef = this.schema.getTypeDef(type); - if (typedefDef) { - return this.applyCardinality(this.makeTypeSchema(type as GetTypeDefs), fieldDef); - } - - let base: z.ZodType; + private makeScalarSchema(type: BuiltinType, attributes: readonly AttributeApplication[] | undefined): z.ZodType { + let schema: z.ZodType; switch (type) { case 'String': - base = addStringValidation(z.string(), attributes); + schema = addStringValidation(z.string(), attributes); break; case 'Int': - base = addNumberValidation(z.number().int(), attributes); + schema = addNumberValidation(z.number().int(), attributes); break; case 'Float': - base = addNumberValidation(z.number(), attributes); + schema = addNumberValidation(z.number(), attributes); break; case 'Boolean': - base = z.boolean(); + schema = z.boolean(); break; case 'BigInt': - base = addBigIntValidation(z.bigint(), attributes); + schema = addBigIntValidation(z.bigint(), attributes); break; case 'Decimal': - base = z.union([ + schema = z.union([ addNumberValidation(z.number(), attributes) as z.ZodNumber, addDecimalValidation(z.string(), attributes, true) as z.ZodString, addDecimalValidation(z.instanceof(Decimal), attributes, true), ]); break; case 'DateTime': - base = z.union([z.date(), z.iso.datetime()]); + schema = z.union([z.date(), z.iso.datetime()]); break; case 'Bytes': - base = z.instanceof(Uint8Array); + schema = z.instanceof(Uint8Array); break; case 'Json': - base = this.makeJsonSchema(); + schema = this.makeJsonSchema(); break; case 'Unsupported': - base = z.unknown(); + schema = z.unknown(); break; default: { const _exhaustive: never = type as never; throw new SchemaFactoryError(`Unsupported field type: ${_exhaustive}`); } } + return schema; + } + + private makeScalarFieldSchema(def: FieldDef): z.ZodType { + const { type } = def; + // for list fields, field-level attributes (e.g. `@length`) validate the list itself + // rather than its items + const itemAttributes = def.array ? undefined : def.attributes; + + // enum + const enumDef = this.schema.getEnum(type); + if (enumDef) { + return this.applyCardinality(this.makeEnumSchema(type as GetEnums), def); + } - return this.applyCardinality(base, fieldDef); + // typedef + const typedefDef = this.schema.getTypeDef(type); + if (typedefDef) { + return this.applyCardinality(this.makeTypeSchema(type as GetTypeDefs, itemAttributes), def); + } + + return this.applyCardinality(this.makeScalarSchema(type as BuiltinType, itemAttributes), def); } private makeJsonSchema(): z.ZodType { @@ -450,7 +458,7 @@ class SchemaFactory { private applyCardinality(schema: z.ZodType, fieldDef: FieldDef): z.ZodType { let result = schema; if (fieldDef.array) { - result = result.array(); + result = addListValidation(result.array(), fieldDef.attributes); } if (fieldDef.optional) { result = result.nullable().optional(); @@ -460,8 +468,20 @@ class SchemaFactory { makeTypeSchema>( type: Type, - ): z.ZodObject, z.core.$strict> { + attributes?: readonly AttributeApplication[], + ): MapTypeDefToZod { const typeDef = this.schema.requireTypeDef(type); + if (typeDef.base) { + return addCustomValidation( + // validate against both the typedef's own attributes and those of the consuming field + this.makeScalarSchema(typeDef.base, [ + ...(typeDef.fields['this']?.attributes ?? []), + ...(attributes ?? []), + ]), + typeDef.attributes, + ) as unknown as MapTypeDefToZod; + } + const fields: Record = {}; for (const [fieldName, fieldDef] of Object.entries(typeDef.fields)) { @@ -472,7 +492,7 @@ class SchemaFactory { return this.applyDescription( addCustomValidation(shape, typeDef.attributes), typeDef.attributes, - ) as unknown as z.ZodObject, z.core.$strict>; + ) as unknown as MapTypeDefToZod; } makeEnumSchema>( diff --git a/packages/zod/src/types.ts b/packages/zod/src/types.ts index 8258363c1..224df0e30 100644 --- a/packages/zod/src/types.ts +++ b/packages/zod/src/types.ts @@ -127,12 +127,19 @@ type MapTypeDefFieldToZod< FieldType = GetTypeDefFieldType, > = MapFieldTypeToZod; +export type MapTypeDefToZod> = + Schema['typeDefs'] extends Record + ? Schema['typeDefs'][Type]['base'] extends keyof FieldTypeZodMap + ? FieldTypeZodMap[Schema['typeDefs'][Type]['base']] + : z.ZodObject, z.core.$strict> + : never; + type MapFieldTypeToZod = FieldType extends keyof FieldTypeZodMap ? FieldTypeZodMap[FieldType] : FieldType extends GetEnums ? EnumZodType : FieldType extends GetTypeDefs - ? z.ZodObject, z.core.$strict> + ? MapTypeDefToZod : z.ZodUnknown; export type JsonValue = string | number | boolean | JsonObject | JsonArray | null; diff --git a/packages/zod/test/factory.test.ts b/packages/zod/test/factory.test.ts index 33d21d011..a33903ba4 100644 --- a/packages/zod/test/factory.test.ts +++ b/packages/zod/test/factory.test.ts @@ -33,6 +33,7 @@ describe.each([ status: 'ACTIVE', address: null, extId: null, + contacts: [], }; // A fully valid Post object (without relations) @@ -748,6 +749,7 @@ describe.each([ zip: '90210', type: 'RESIDENTIAL', }, + contacts: [], }; expect(userSchema.safeParse(validUser).success).toBe(true); expect( @@ -1680,4 +1682,116 @@ describe.each([ }); }); }); + + describe('custom type primitives', () => { + it('supports model validation using `this` attributes', () => { + const postSchema = factory.makeModelSchema('Post'); + const result = postSchema.safeParse({ ...validPost, tags: ['LOWERCASED', 'Other'] }); + expect(result.success).toBe(true); + expect(result.data?.tags).toMatchObject(['lowercased', 'other']); + }); + + it('supports model validation using `@@validate`', () => { + const userSchema = factory.makeModelSchema('User'); + let result = userSchema.safeParse({ ...validUser, score: 0 }); + expect(result.success).toBe(true); + + result = userSchema.safeParse({ ...validUser, score: 99.0 }); + expect(result.success).toBe(true); + + result = userSchema.safeParse({ ...validUser, score: 100.0 }); + expect(result.success).toBe(false); + + result = userSchema.safeParse({ ...validUser, score: -1.0 }); + expect(result.success).toBe(false); + }); + + it('supports field validation using `this` attributes', () => { + const postTagSchema = factory.makeTypeSchema('PostTag'); + const result = postTagSchema.safeParse('LOWERCASE'); + expect(result.success).toBe(true); + expect(result.data).toBe('lowercase'); + }); + + it('applies string `@length` on `this` field', () => { + const postTagSchema = factory.makeTypeSchema('PostTag'); + expect(postTagSchema.safeParse('').success).toBe(false); + expect(postTagSchema.safeParse('a').success).toBe(true); + expect(postTagSchema.safeParse('abcdefghijkl').success).toBe(true); + expect(postTagSchema.safeParse('abcdefghijklm').success).toBe(false); + }); + + it('supports `@@validate` attributes', () => { + const contactSchema = factory.makeTypeSchema('Contact'); + let result = contactSchema.safeParse('test@mail.com'); + expect(result.success).toBe(true); + expect(result.data).toBe('test@mail.com'); + + result = contactSchema.safeParse('+15555555555'); + expect(result.success).toBe(true); + expect(result.data).toBe('+15555555555'); + + result = contactSchema.safeParse('not-a-contact'); + expect(result.success).toBe(false); + }); + + it('supports multiple `@@validate` attributes', () => { + const contactSchema = factory.makeTypeSchema('Score'); + let result = contactSchema.safeParse(0); + expect(result.success).toBe(true); + expect(result.data).toBe(0); + + result = contactSchema.safeParse(99.0); + expect(result.success).toBe(true); + expect(result.data).toBe(99.0); + + result = contactSchema.safeParse(100.0); + expect(result.success).toBe(false); + + result = contactSchema.safeParse(-1.0); + expect(result.success).toBe(false); + }); + + describe('lists', () => { + it('applies field attributes to the list', () => { + const postSchema = factory.makeModelSchema('Post'); + let result = postSchema.safeParse({ ...validPost, tags: ['a', 'b'] }); + expect(result.success).toBe(true); + + result = postSchema.safeParse({ ...validPost, tags: ['a', 'b', 'c'] }); + expect(result.success).toBe(true); + + result = postSchema.safeParse({ ...validPost, tags: ['a'] }); + expect(result.success).toBe(false); + + result = postSchema.safeParse({ ...validPost, tags: ['a', 'b', 'c', 'd'] }); + expect(result.success).toBe(false); + }); + + it('applies both the list length and the element string length', () => { + const postSchema = factory.makeModelSchema('Post'); + let result = postSchema.safeParse({ ...validPost, tags: ['a', 'abcdefghijkl'] }); + expect(result.success).toBe(true); + + result = postSchema.safeParse({ ...validPost, tags: ['a', ''] }); + expect(result.success).toBe(false); + + result = postSchema.safeParse({ ...validPost, tags: ['a', 'abcdefghijklm'] }); + expect(result.success).toBe(false); + + result = postSchema.safeParse({ ...validPost, tags: ['a'] }); + expect(result.success).toBe(false); + + result = postSchema.safeParse({ ...validPost, tags: ['a', 'b', 'c', 'd'] }); + expect(result.success).toBe(false); + }); + + it('applies typedef attributes to each item', () => { + const postSchema = factory.makeModelSchema('Post'); + const result = postSchema.safeParse({ ...validPost, tags: ['A', 'B'] }); + expect(result.success).toBe(true); + expect(result.data?.tags).toEqual(['a', 'b']); + }); + }); + }); }); diff --git a/packages/zod/test/schema/schema-lite.ts b/packages/zod/test/schema/schema-lite.ts index 29472e365..0fdcb86d7 100644 --- a/packages/zod/test/schema/schema-lite.ts +++ b/packages/zod/test/schema/schema-lite.ts @@ -49,13 +49,12 @@ export class SchemaType implements SchemaDef { }, age: { name: "age", - type: "Int", - attributes: [{ name: "@gt", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }, { name: "@lte", args: [{ name: "value", value: ExpressionUtils.literal(150) }] }] as readonly AttributeApplication[] + type: "Age", + attributes: [{ name: "@lte", args: [{ name: "value", value: ExpressionUtils.literal(150) }] }, { name: "@gt", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }] as readonly AttributeApplication[] }, score: { name: "score", - type: "Float", - attributes: [{ name: "@gte", args: [{ name: "value", value: ExpressionUtils.literal(0.0) }] }, { name: "@lt", args: [{ name: "value", value: ExpressionUtils.literal(100.0) }] }] as readonly AttributeApplication[] + type: "Score" }, bigNum: { name: "bigNum", @@ -118,6 +117,11 @@ export class SchemaType implements SchemaDef { type: "Post", array: true, relation: { opposite: "author" } + }, + contacts: { + name: "contacts", + type: "Contact", + array: true } }, attributes: [ @@ -149,8 +153,9 @@ export class SchemaType implements SchemaDef { }, tags: { name: "tags", - type: "String", - array: true + type: "PostTag", + array: true, + attributes: [{ name: "@length", args: [{ name: "min", value: ExpressionUtils.literal(2) }, { name: "max", value: ExpressionUtils.literal(3) }] }, { name: "@lower" }, { name: "@length", args: [{ name: "min", value: ExpressionUtils.literal(1) }, { name: "max", value: ExpressionUtils.literal(12) }] }] as readonly AttributeApplication[] }, author: { name: "author", @@ -347,6 +352,55 @@ export class SchemaType implements SchemaDef { { name: "@@validate", args: [{ name: "value", value: ExpressionUtils.binary(ExpressionUtils.binary(ExpressionUtils.field("zip"), "==", ExpressionUtils._null()), "||", ExpressionUtils.binary(ExpressionUtils.call("length", [ExpressionUtils.field("zip")]), "==", ExpressionUtils.literal(5))) }, { name: "message", value: ExpressionUtils.literal("Zip code must be exactly 5 characters") }, { name: "path", value: ExpressionUtils.array("String", [ExpressionUtils.literal("zip")]) }] }, { name: "@@meta", args: [{ name: "name", value: ExpressionUtils.literal("description") }, { name: "value", value: ExpressionUtils.literal("A mailing address") }] } ] as readonly AttributeApplication[] + }, + Age: { + name: "Age", + base: "Int", + fields: { + this: { + name: "this", + type: "Int", + attributes: [{ name: "@gt", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }] as readonly AttributeApplication[] + } + } + }, + Score: { + name: "Score", + base: "Float", + fields: { + this: { + name: "this", + type: "Float" + } + }, + attributes: [ + { name: "@@validate", args: [{ name: "value", value: ExpressionUtils.binary(ExpressionUtils._this(), ">=", ExpressionUtils.literal(0.0)) }] }, + { name: "@@validate", args: [{ name: "value", value: ExpressionUtils.binary(ExpressionUtils._this(), "<", ExpressionUtils.literal(100.0)) }] } + ] as readonly AttributeApplication[] + }, + Contact: { + name: "Contact", + base: "String", + fields: { + this: { + name: "this", + type: "String" + } + }, + attributes: [ + { name: "@@validate", args: [{ name: "value", value: ExpressionUtils.binary(ExpressionUtils.call("isPhone", [ExpressionUtils._this()]), "||", ExpressionUtils.call("isEmail", [ExpressionUtils._this()])) }] } + ] as readonly AttributeApplication[] + }, + PostTag: { + name: "PostTag", + base: "String", + fields: { + this: { + name: "this", + type: "String", + attributes: [{ name: "@lower" }, { name: "@length", args: [{ name: "min", value: ExpressionUtils.literal(1) }, { name: "max", value: ExpressionUtils.literal(12) }] }] as readonly AttributeApplication[] + } + } } } as const; enums = { diff --git a/packages/zod/test/schema/schema.ts b/packages/zod/test/schema/schema.ts index 275b25dcf..38c4db9ef 100644 --- a/packages/zod/test/schema/schema.ts +++ b/packages/zod/test/schema/schema.ts @@ -49,13 +49,12 @@ export class SchemaType implements SchemaDef { }, age: { name: "age", - type: "Int", - attributes: [{ name: "@gt", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }, { name: "@lte", args: [{ name: "value", value: ExpressionUtils.literal(150) }] }] as readonly AttributeApplication[] + type: "Age", + attributes: [{ name: "@lte", args: [{ name: "value", value: ExpressionUtils.literal(150) }] }, { name: "@gt", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }] as readonly AttributeApplication[] }, score: { name: "score", - type: "Float", - attributes: [{ name: "@gte", args: [{ name: "value", value: ExpressionUtils.literal(0.0) }] }, { name: "@lt", args: [{ name: "value", value: ExpressionUtils.literal(100.0) }] }] as readonly AttributeApplication[] + type: "Score" }, bigNum: { name: "bigNum", @@ -119,6 +118,11 @@ export class SchemaType implements SchemaDef { type: "Post", array: true, relation: { opposite: "author" } + }, + contacts: { + name: "contacts", + type: "Contact", + array: true } }, attributes: [ @@ -150,8 +154,9 @@ export class SchemaType implements SchemaDef { }, tags: { name: "tags", - type: "String", - array: true + type: "PostTag", + array: true, + attributes: [{ name: "@length", args: [{ name: "min", value: ExpressionUtils.literal(2) }, { name: "max", value: ExpressionUtils.literal(3) }] }, { name: "@lower" }, { name: "@length", args: [{ name: "min", value: ExpressionUtils.literal(1) }, { name: "max", value: ExpressionUtils.literal(12) }] }] as readonly AttributeApplication[] }, author: { name: "author", @@ -353,6 +358,55 @@ export class SchemaType implements SchemaDef { { name: "@@validate", args: [{ name: "value", value: ExpressionUtils.binary(ExpressionUtils.binary(ExpressionUtils.field("zip"), "==", ExpressionUtils._null()), "||", ExpressionUtils.binary(ExpressionUtils.call("length", [ExpressionUtils.field("zip")]), "==", ExpressionUtils.literal(5))) }, { name: "message", value: ExpressionUtils.literal("Zip code must be exactly 5 characters") }, { name: "path", value: ExpressionUtils.array("String", [ExpressionUtils.literal("zip")]) }] }, { name: "@@meta", args: [{ name: "name", value: ExpressionUtils.literal("description") }, { name: "value", value: ExpressionUtils.literal("A mailing address") }] } ] as readonly AttributeApplication[] + }, + Age: { + name: "Age", + base: "Int", + fields: { + this: { + name: "this", + type: "Int", + attributes: [{ name: "@gt", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }] as readonly AttributeApplication[] + } + } + }, + Score: { + name: "Score", + base: "Float", + fields: { + this: { + name: "this", + type: "Float" + } + }, + attributes: [ + { name: "@@validate", args: [{ name: "value", value: ExpressionUtils.binary(ExpressionUtils._this(), ">=", ExpressionUtils.literal(0.0)) }] }, + { name: "@@validate", args: [{ name: "value", value: ExpressionUtils.binary(ExpressionUtils._this(), "<", ExpressionUtils.literal(100.0)) }] } + ] as readonly AttributeApplication[] + }, + Contact: { + name: "Contact", + base: "String", + fields: { + this: { + name: "this", + type: "String" + } + }, + attributes: [ + { name: "@@validate", args: [{ name: "value", value: ExpressionUtils.binary(ExpressionUtils.call("isPhone", [ExpressionUtils._this()]), "||", ExpressionUtils.call("isEmail", [ExpressionUtils._this()])) }] } + ] as readonly AttributeApplication[] + }, + PostTag: { + name: "PostTag", + base: "String", + fields: { + this: { + name: "this", + type: "String", + attributes: [{ name: "@lower" }, { name: "@length", args: [{ name: "min", value: ExpressionUtils.literal(1) }, { name: "max", value: ExpressionUtils.literal(12) }] }] as readonly AttributeApplication[] + } + } } } as const; enums = { diff --git a/packages/zod/test/schema/schema.zmodel b/packages/zod/test/schema/schema.zmodel index 3ceba9c35..4798ae28b 100644 --- a/packages/zod/test/schema/schema.zmodel +++ b/packages/zod/test/schema/schema.zmodel @@ -27,6 +27,10 @@ type Address { @@meta("description", "A mailing address") } +type Age with Int { + this Int @gt(0) +} + model User { id String @id @default(cuid()) email String @email @meta("description", "The user's email address") @@ -34,8 +38,8 @@ model User { username String @length(3, 50) website String? @url code String @startsWith("USR") - age Int @gt(0) @lte(150) - score Float @gte(0.0) @lt(100.0) + age Age @lte(150) + score Score bigNum BigInt @gte(0) balance Decimal @gt(0) active Boolean @@ -48,20 +52,38 @@ model User { status Status address Address? @json posts Post[] + contacts Contact[] @@validate(age >= 18, "Must be adult", ["age"]) @@meta("description", "A user of the system") } +type Score with Float { + this Float + + @@validate(this >= 0.0) + @@validate(this < 100.0) +} + +type Contact with String { + this String + + @@validate(isPhone(this) || isEmail(this)) +} + model Post { - id String @id @default(cuid()) + id String @id @default(cuid()) title String published Boolean - tags String[] - author User? @relation(fields: [authorId], references: [id]) + tags PostTag[] @length(2, 3) + author User? @relation(fields: [authorId], references: [id]) authorId String? } +type PostTag with String { + this String @lower @length(1, 12) +} + // --- Computed fields --- model Product { id String @id @default(cuid()) diff --git a/tests/e2e/orm/client-api/custom-type-primitive.test.ts b/tests/e2e/orm/client-api/custom-type-primitive.test.ts new file mode 100644 index 000000000..69cf21f9b --- /dev/null +++ b/tests/e2e/orm/client-api/custom-type-primitive.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { ClientContract } from '@zenstackhq/orm'; +import { schema } from '../schemas/custom-type-primitive/schema'; +import { createTestClient } from '@zenstackhq/testtools'; + +describe('Custom type primitive tests', () => { + let client: ClientContract; + + beforeEach(async () => { + client = await createTestClient(schema, { + provider: 'postgresql', + }); + }); + + afterEach(async () => { + await client?.$disconnect(); + }); + + it('works with scalars', async () => { + await expect( + client.user.create({ + data: { + name: 'test', + }, + }), + ).resolves.toMatchObject({ + name: 'test', + }); + + await expect( + client.user.create({ + data: { + name: 't', + }, + }), + ).rejects.toThrow(/Too small/); + + await expect( + client.user.create({ + data: { + name: 'test', + age: 17, + }, + }), + ).rejects.toThrow(/Too small/); + }); + + it('works with arrays', async () => { + await expect( + client.user.create({ + data: { + name: 'test', + contacts: ['+15555555555'], + }, + }), + ).resolves.toMatchObject({ + name: 'test', + contacts: ['+15555555555'], + }); + + await expect( + client.user.create({ + data: { + name: 'test', + contacts: ['15555555555'], + }, + }), + ).rejects.toThrow(/Invalid E.164/); + }); +}); diff --git a/tests/e2e/orm/client-api/procedures.test.ts b/tests/e2e/orm/client-api/procedures.test.ts index a92e1e87a..cbace96fb 100644 --- a/tests/e2e/orm/client-api/procedures.test.ts +++ b/tests/e2e/orm/client-api/procedures.test.ts @@ -76,6 +76,27 @@ describe('Procedures tests', () => { }, }); }, + + getAge: async ({ client, args: { id } }) => { + const user = await client.$procs.getUser({ + args: { + id, + }, + }); + return user.age; + }, + + setAge: async ({ client, args: { id, age } }) => { + return await client.user.update({ + data: { + age, + }, + + where: { + id, + }, + }); + }, }, }); }); @@ -242,4 +263,36 @@ describe('Procedures tests', () => { }), ).rejects.toThrow(/Unrecognized key: "unknown"/); }); + + it('supports primitive type defs', async () => { + const user = await client.$procs.signUp({ args: { name: 'Alice' } }); + await expect( + client.$procs.setAge({ + args: { + id: user.id, + age: 18, + }, + }), + ).resolves.toMatchObject({ + id: user.id, + age: 18, + }); + + await expect( + client.$procs.getAge({ + args: { + id: user.id, + }, + }), + ).resolves.toBe(18); + + await expect( + client.$procs.setAge({ + args: { + id: user.id, + age: -1, + }, + }), + ).rejects.toThrow(/Validation error: Too small/); + }); }); diff --git a/tests/e2e/orm/schemas/custom-type-primitive/schema.ts b/tests/e2e/orm/schemas/custom-type-primitive/schema.ts new file mode 100644 index 000000000..50e423149 --- /dev/null +++ b/tests/e2e/orm/schemas/custom-type-primitive/schema.ts @@ -0,0 +1,87 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaDef, type AttributeApplication, type FieldDefault, ExpressionUtils } from "@zenstackhq/schema"; +export class SchemaType implements SchemaDef { + provider = { + type: "postgresql" + } as const; + models = { + User: { + name: "User", + fields: { + id: { + name: "id", + type: "String", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("nanoid") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("nanoid") as FieldDefault + }, + name: { + name: "name", + type: "UserName", + attributes: [{ name: "@length", args: [{ name: "min", value: ExpressionUtils.literal(2) }, { name: "max", value: ExpressionUtils.literal(16) }] }] as readonly AttributeApplication[] + }, + contacts: { + name: "contacts", + type: "Contact", + array: true, + attributes: [{ name: "@phone" }] as readonly AttributeApplication[] + }, + age: { + name: "age", + type: "Age", + optional: true, + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(18) }] }, { name: "@gte", args: [{ name: "value", value: ExpressionUtils.literal(18) }] }] as readonly AttributeApplication[], + default: 18 as FieldDefault + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" } + } + } + } as const; + typeDefs = { + UserName: { + name: "UserName", + base: "String", + fields: { + this: { + name: "this", + type: "String", + attributes: [{ name: "@length", args: [{ name: "min", value: ExpressionUtils.literal(2) }, { name: "max", value: ExpressionUtils.literal(16) }] }] as readonly AttributeApplication[] + } + } + }, + Contact: { + name: "Contact", + base: "String", + fields: { + this: { + name: "this", + type: "String", + attributes: [{ name: "@phone" }] as readonly AttributeApplication[] + } + } + }, + Age: { + name: "Age", + base: "Int", + fields: { + this: { + name: "this", + type: "Int", + attributes: [{ name: "@gte", args: [{ name: "value", value: ExpressionUtils.literal(18) }] }] as readonly AttributeApplication[] + } + } + } + } as const; + authType = "User" as const; + plugins = {}; +} +export const schema = new SchemaType(); diff --git a/tests/e2e/orm/schemas/custom-type-primitive/schema.zmodel b/tests/e2e/orm/schemas/custom-type-primitive/schema.zmodel new file mode 100644 index 000000000..3bc3eee67 --- /dev/null +++ b/tests/e2e/orm/schemas/custom-type-primitive/schema.zmodel @@ -0,0 +1,23 @@ +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(nanoid()) + name UserName + contacts Contact[] + age Age? @default(18) +} + +type UserName with String { + this String @length(2, 16) +} + +type Contact with String { + this String @phone +} + +type Age with Int { + this Int @gte(18) +} diff --git a/tests/e2e/orm/schemas/procedures/schema.ts b/tests/e2e/orm/schemas/procedures/schema.ts index 9b84da04c..2e9e28a45 100644 --- a/tests/e2e/orm/schemas/procedures/schema.ts +++ b/tests/e2e/orm/schemas/procedures/schema.ts @@ -38,6 +38,13 @@ export class SchemaType implements SchemaDef { type: "Profile", optional: true, attributes: [{ name: "@json" }] as readonly AttributeApplication[] + }, + age: { + name: "age", + type: "Age", + optional: true, + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(18) }] }, { name: "@gte", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }] as readonly AttributeApplication[], + default: 18 as FieldDefault } }, idFields: ["id"], @@ -84,6 +91,17 @@ export class SchemaType implements SchemaDef { { name: "@@strict" } ] as readonly AttributeApplication[], strict: true + }, + Age: { + name: "Age", + base: "Int", + fields: { + this: { + name: "this", + type: "Int", + attributes: [{ name: "@gte", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }] as readonly AttributeApplication[] + } + } } } as const; enums = { @@ -127,6 +145,20 @@ export class SchemaType implements SchemaDef { params: {}, returnType: "Overview" }, + getAge: { + params: { + id: { name: "id", type: "Int" } + }, + returnType: "Age" + }, + setAge: { + params: { + id: { name: "id", type: "Int" }, + age: { name: "age", type: "Age" } + }, + returnType: "User", + mutation: true + }, createMultiple: { params: { names: { name: "names", array: true, type: "String" } diff --git a/tests/e2e/orm/schemas/procedures/schema.zmodel b/tests/e2e/orm/schemas/procedures/schema.zmodel index 66f9c6804..d90c2b7ca 100644 --- a/tests/e2e/orm/schemas/procedures/schema.zmodel +++ b/tests/e2e/orm/schemas/procedures/schema.zmodel @@ -10,9 +10,9 @@ enum Role { type Overview { userIds Int[] - total Int - roles Role[] - meta Json? + total Int + roles Role[] + meta Json? } type Profile { @@ -21,11 +21,16 @@ type Profile { @@strict } +type Age with Int { + this Int @gte(0) +} + model User { - id Int @id @default(autoincrement()) - name String @unique - role Role @default(USER) + id Int @id @default(autoincrement()) + name String @unique + role Role @default(USER) profile Profile? @json + age Age? @default(18) } procedure getUser(id: Int): User @@ -33,5 +38,7 @@ procedure listUsers(): User[] mutation procedure signUp(name: String, role: Role?): User mutation procedure setAdmin(userId: Int): Void procedure getOverview(): Overview +procedure getAge(id: Int): Age +mutation procedure setAge(id: Int, age: Age): User mutation procedure createMultiple(names: String[]): User[] mutation procedure updateProfile(userId: Int, profile: Profile): Void