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 06a1cab57..5c228c341 100644 --- a/packages/cli/test/ts-schema-gen.test.ts +++ b/packages/cli/test/ts-schema-gen.test.ts @@ -895,4 +895,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 545fdb944..70438ba08 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 0f7f341a4..9e0188267 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 aa2ecfe3e..e8b35ca64 100644 --- a/packages/language/src/utils.ts +++ b/packages/language/src/utils.ts @@ -201,6 +201,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. @@ -689,6 +696,19 @@ export function getAllFields( return fields; } +export function getAllFieldAttributes(field: DataField) { + const attributes: DataFieldAttribute[] = [...field.attributes]; + if (isTypeDef(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 cdb0ed969..dfa907185 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, @@ -39,6 +40,7 @@ import { isDataFieldReference, isDelegateModel, isNativeTypeMappingAttribute, + isPrimitiveTypeDef, isRelationshipField, mapBuiltinTypeToExpressionType, resolved, @@ -141,6 +143,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 }); @@ -653,10 +667,19 @@ 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 ( + isTypeDef(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'; @@ -697,34 +720,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..d6fa457a0 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'; @@ -46,6 +48,15 @@ export default class DataModelValidator implements AstValidator { } this.validateInherits(dm, accept); this.validateDelegateMap(dm, accept); + this.validateInheritance(dm, accept); + } + + private validateInheritance(dm: DataModel, accept: ValidationAcceptor) { + if (dm.base) { + accept('error', `model "${dm.name}" cannot inherit from a primitive type`, { + node: dm, + }); + } } private validateFields(dm: DataModel, accept: ValidationAcceptor) { @@ -87,7 +98,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 +134,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 +173,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) ?? []; @@ -537,6 +550,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,51 @@ 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) { + accept('error', 'primitive type def is missing "this" field', { + node: typeDef, + }); + } else { + 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 6766a6afc..2bcbb2fd4 100644 --- a/packages/language/src/zmodel-linker.ts +++ b/packages/language/src/zmodel-linker.ts @@ -51,6 +51,7 @@ import { isNumberLiteral, isReferenceExpr, isStringLiteral, + isTypeDef, } from './ast'; import { getAllFields, @@ -60,6 +61,7 @@ import { isAuthInvocation, isBeforeInvocation, isMemberContainer, + isPrimitiveTypeDef, mapBuiltinTypeToExpressionType, } from './utils'; @@ -366,12 +368,16 @@ export class ZModelLinker extends DefaultLinker { let decl: AstNode | undefined = node.$container; - while (decl && !isDataModel(decl)) { + while (decl && !isDataModel(decl) && !isPrimitiveTypeDef(decl)) { decl = decl.$container; } if (decl) { - this.resolveToBuiltinTypeOrDecl(node, decl); + if (isPrimitiveTypeDef(decl)) { + this.resolveToBuiltinTypeOrDecl(node, decl.base!); + } else { + this.resolveToBuiltinTypeOrDecl(node, decl); + } } } @@ -492,10 +498,18 @@ 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 (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 (isTypeDef(node.type.reference.ref) && 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); @@ -540,6 +554,12 @@ export class ZModelLinker extends DefaultLinker { array: type.array, nullable: nullable, }; + } else if (isTypeDef(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 4f39ea28e..26c191a8d 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..8a540296f --- /dev/null +++ b/packages/language/test/custom-type-primitive.test.ts @@ -0,0 +1,374 @@ +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('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 there is no "this" field', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + model User { + id String @id + email Email + } + + type Email with String { + this2 String + } + `, + 'primitive type def is missing "this" 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', + ); + }); +}); 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 b92854807..4bceb5b93 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -32,6 +32,7 @@ import type { SchemaDef, TypeDefFieldIsArray, TypeDefFieldIsOptional, + TypeDefIsPrimitive, UpdatedAtInfo, } from '@zenstackhq/schema'; import type { ExpressionBuilder, OperandExpression, SqlBool } from 'kysely'; @@ -318,27 +319,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 @@ -1485,8 +1495,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 4210f99cf..3ebe3ee9d 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), 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 75ee4f35e..d765a7b60 100644 --- a/packages/orm/src/client/crud/dialects/postgresql.ts +++ b/packages/orm/src/client/crud/dialects/postgresql.ts @@ -13,7 +13,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'; @@ -185,15 +185,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); 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..518b9fa19 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -462,6 +462,10 @@ export class ZodSchemaFactory< private makeTypeDefSchema(type: string): ZodType { const typeDef = getTypeDef(this.schema, type); invariant(typeDef, `Type definition "${type}" not found in schema`); + if (typeDef.base) { + const schema = this.makeScalarSchema(typeDef.base, typeDef.fields['this']?.attributes); + return this.extraValidationsEnabled ? ZodUtils.addCustomValidation(schema, typeDef.attributes) : schema; + } const func = typeDef.strict ? z.strictObject : z.looseObject; const schema = func( Object.fromEntries( 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 b3c70eaca..d427753a6 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -39,6 +39,7 @@ import { } from '@zenstackhq/language/ast'; import { getAllAttributes, + getAllFieldAttributes, getAllFields, getAttributeArg, isDataFieldReference, @@ -507,6 +508,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', @@ -651,7 +655,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( @@ -1087,8 +1092,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..862028a48 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,7 +18,7 @@ import type { GetModelFieldsShape, GetModelSchemaShapeWithOptions, GetModelUpdateFieldsShape, - GetTypeDefFieldsShape, + MapTypeDefToZod, ModelSchemaOptions, } from './types'; import { @@ -361,64 +362,66 @@ 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; + } - return this.applyCardinality(base, fieldDef); + private makeScalarFieldSchema(def: FieldDef): z.ZodType { + const { type, attributes } = def; + // enum + const enumDef = this.schema.getEnum(type); + if (enumDef) { + return this.applyCardinality(this.makeEnumSchema(type as GetEnums), def); + } + + // typedef + const typedefDef = this.schema.getTypeDef(type); + if (typedefDef) { + return this.applyCardinality(this.makeTypeSchema(type as GetTypeDefs, def.attributes), def); + } + + return this.applyCardinality(this.makeScalarSchema(type as BuiltinType, attributes), def); } private makeJsonSchema(): z.ZodType { @@ -460,8 +463,16 @@ 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( + this.makeScalarSchema(typeDef.base, attributes ?? typeDef.fields['this']?.attributes), + typeDef.attributes, + ) as unknown as MapTypeDefToZod; + } + const fields: Record = {}; for (const [fieldName, fieldDef] of Object.entries(typeDef.fields)) { @@ -472,7 +483,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 8a21945f5..b392f0794 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) @@ -679,6 +680,7 @@ describe.each([ metadata: null, status: 'ACTIVE', address: { residents: [], street: '123 Main', city: 'Springfield', zip: '90210' }, + contacts: [], }; expect(userSchema.safeParse(validUser).success).toBe(true); expect( @@ -1572,4 +1574,66 @@ 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'] }); + expect(result.success).toBe(true); + expect(result.data?.tags).toMatchObject(['lowercased']); + }); + + 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('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); + }); + }); }); diff --git a/packages/zod/test/schema/schema-lite.ts b/packages/zod/test/schema/schema-lite.ts index 1be47cfca..c82cf36f7 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: "@lower" }] as readonly AttributeApplication[] }, author: { name: "author", @@ -342,6 +347,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" }] as readonly AttributeApplication[] + } + } } } as const; enums = { diff --git a/packages/zod/test/schema/schema.ts b/packages/zod/test/schema/schema.ts index 3442d3920..e3aab2da2 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: "@lower" }] as readonly AttributeApplication[] }, author: { name: "author", @@ -348,6 +353,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" }] as readonly AttributeApplication[] + } + } } } as const; enums = { diff --git a/packages/zod/test/schema/schema.zmodel b/packages/zod/test/schema/schema.zmodel index 6aa265ad0..6c74d8b6d 100644 --- a/packages/zod/test/schema/schema.zmodel +++ b/packages/zod/test/schema/schema.zmodel @@ -20,6 +20,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") @@ -27,8 +31,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 @@ -41,20 +45,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[] + author User? @relation(fields: [authorId], references: [id]) authorId String? } +type PostTag with String { + this String @lower +} + // --- 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..d7245929e --- /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..6b9fd7b29 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