From fa051dd362a4fd62792704b998623c3877395253 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Wed, 23 Sep 2026 23:35:35 +0000 Subject: [PATCH 01/19] feat(language): primitive custom types --- packages/language/src/generated/ast.ts | 14 ++++- packages/language/src/generated/grammar.ts | 41 ++++++++++---- packages/language/src/utils.ts | 20 +++++++ .../attribute-application-validator.ts | 53 ++++++++++++++----- .../src/validators/datamodel-validator.ts | 25 +++++++-- .../function-invocation-validator.ts | 3 +- .../src/validators/typedef-validator.ts | 49 +++++++++++++++++ packages/language/src/zmodel-linker.ts | 32 ++++++++--- packages/language/src/zmodel.langium | 6 ++- 9 files changed, 205 insertions(+), 38 deletions(-) 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..7a9f6c20b 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 }); @@ -550,7 +564,8 @@ function assignableToAttributeParam( // If the field is JSON, and the attribute is @default, the argument must be a JSON string // (design inherited from Prisma) const dstIsJson = attr.$container.type.type === 'Json' || hasAttribute(attr.$container, '@json'); - if (dstIsJson && attr.decl.ref?.name === '@default') { + const isInPrimitiveTypeDef = isPrimitiveTypeDef(attr.$container.$container); + if (dstIsJson && isInPrimitiveTypeDef && attr.decl.ref?.name === '@default') { if (attr.$container.type.array && attr.$container.type.type === 'Json') { // Json[] default value, must be array of JSON strings if (isArrayExpr(arg.value) && arg.value.items.every((item) => isLiteralJsonString(item))) { @@ -653,10 +668,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 = 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 +721,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'; From 46004491ac6007a0c680bd88e6062c763669c1d4 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Wed, 23 Sep 2026 23:36:38 +0000 Subject: [PATCH 02/19] chore(language): add tests --- .../test/custom-type-primitive.test.ts | 374 ++++++++++++++++++ .../language/test/function-invocation.test.ts | 22 ++ 2 files changed, 396 insertions(+) create mode 100644 packages/language/test/custom-type-primitive.test.ts 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) + } + `); + }); + }); }); From 5b4131c2446df3e1638191552b0d5c8166415560 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Wed, 23 Sep 2026 23:40:47 +0000 Subject: [PATCH 03/19] feat(orm): primitive custom types --- packages/orm/src/client/crud-types.ts | 58 +++++++++++-------- .../src/client/crud/dialects/base-dialect.ts | 21 +++++-- .../orm/src/client/crud/dialects/mysql.ts | 22 ++++--- .../src/client/crud/dialects/postgresql.ts | 22 ++++--- .../src/client/helpers/schema-db-pusher.ts | 6 +- packages/orm/src/client/query-utils.ts | 4 ++ packages/orm/src/client/zod/factory.ts | 3 + 7 files changed, 90 insertions(+), 46 deletions(-) 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..b77b59b25 100644 --- a/packages/orm/src/client/helpers/schema-db-pusher.ts +++ b/packages/orm/src/client/helpers/schema-db-pusher.ts @@ -326,11 +326,13 @@ export class SchemaDbPusher { return 'serial'; } + let type: BuiltinType = fieldDef.type as BuiltinType; + if (this.isCustomType(fieldDef.type)) { - return this.jsonType; + const typeDef = Object.values(this.schema.typeDefs!).find((def) => def.name === fieldDef.type)!; + type = (typeDef.base ?? 'Json') as BuiltinType; } - 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..02e74fb50 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -462,6 +462,9 @@ 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) { + return this.makeScalarSchema(typeDef.base, typeDef.fields['this']?.attributes); + } const func = typeDef.strict ? z.strictObject : z.looseObject; const schema = func( Object.fromEntries( From 60018b374c4b4e18d2fd5e877b08f31ae6d0a952 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Wed, 23 Sep 2026 23:42:06 +0000 Subject: [PATCH 04/19] feat(sdk): primitive custom types --- packages/sdk/src/model-utils.ts | 3 ++- packages/sdk/src/prisma/prisma-schema-generator.ts | 3 ++- packages/sdk/src/ts-schema-generator.ts | 10 +++++++--- 3 files changed, 11 insertions(+), 5 deletions(-) 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..75b20d4e3 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; } 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) { From c7ca5c4ec64752d628875a52aa518a73981b7897 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Wed, 23 Sep 2026 23:42:27 +0000 Subject: [PATCH 05/19] feat(schema): primitive custom types --- packages/schema/src/schema.ts | 8 ++++++++ 1 file changed, 8 insertions(+) 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, From 98e2a2419b54cd35aaab7fdfe11c92cb43055725 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Wed, 23 Sep 2026 23:43:03 +0000 Subject: [PATCH 06/19] feat(zod): primitive custom types --- packages/zod/src/factory.ts | 72 ++++++++++++++++++++----------------- packages/zod/src/types.ts | 9 ++++- 2 files changed, 48 insertions(+), 33 deletions(-) diff --git a/packages/zod/src/factory.ts b/packages/zod/src/factory.ts index 6d2c50b8f..181381c56 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); + } + + return this.applyCardinality(this.makeScalarSchema(type as BuiltinType, attributes), def); } private makeJsonSchema(): z.ZodType { @@ -458,10 +461,15 @@ class SchemaFactory { return result; } - makeTypeSchema>( - type: Type, - ): z.ZodObject, z.core.$strict> { + makeTypeSchema>(type: Type): MapTypeDefToZod { const typeDef = this.schema.requireTypeDef(type); + if (typeDef.base) { + return addCustomValidation( + this.makeScalarSchema(typeDef.base, typeDef.fields['this']?.attributes), + typeDef.attributes, + ) as unknown as MapTypeDefToZod; + } + const fields: Record = {}; for (const [fieldName, fieldDef] of Object.entries(typeDef.fields)) { @@ -472,7 +480,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; From 74f988383c2d5858116dcc5eee0fa1672ae478cd Mon Sep 17 00:00:00 2001 From: sanny-io Date: Wed, 23 Sep 2026 23:43:25 +0000 Subject: [PATCH 07/19] chore(cli): add tests --- packages/cli/test/prisma-schema-gen.test.ts | 19 +++ packages/cli/test/ts-schema-gen.test.ts | 156 ++++++++++++++++++++ 2 files changed, 175 insertions(+) diff --git a/packages/cli/test/prisma-schema-gen.test.ts b/packages/cli/test/prisma-schema-gen.test.ts index b5a8ce733..6ba39b66f 100644 --- a/packages/cli/test/prisma-schema-gen.test.ts +++ b/packages/cli/test/prisma-schema-gen.test.ts @@ -91,4 +91,23 @@ 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); + }); }); 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: {}, + }); + }); }); From e53b8563c909c9c911d9c7bb4a563e2aba9deead Mon Sep 17 00:00:00 2001 From: sanny-io Date: Wed, 23 Sep 2026 23:44:28 +0000 Subject: [PATCH 08/19] chore: regenerate schemas --- packages/zod/test/schema/schema-lite.ts | 61 ++++++++++++- packages/zod/test/schema/schema.ts | 61 ++++++++++++- packages/zod/test/schema/schema.zmodel | 30 ++++++- .../schemas/custom-type-primitive/schema.ts | 87 +++++++++++++++++++ .../custom-type-primitive/schema.zmodel | 23 +++++ tests/e2e/orm/schemas/procedures/schema.ts | 31 +++++++ .../e2e/orm/schemas/procedures/schema.zmodel | 7 ++ 7 files changed, 288 insertions(+), 12 deletions(-) create mode 100644 tests/e2e/orm/schemas/custom-type-primitive/schema.ts create mode 100644 tests/e2e/orm/schemas/custom-type-primitive/schema.zmodel diff --git a/packages/zod/test/schema/schema-lite.ts b/packages/zod/test/schema/schema-lite.ts index 1be47cfca..59ebc5c1d 100644 --- a/packages/zod/test/schema/schema-lite.ts +++ b/packages/zod/test/schema/schema-lite.ts @@ -54,8 +54,7 @@ export class SchemaType implements SchemaDef { }, 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,54 @@ 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[] + }, + 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[] + } + } + }, + Test: { + name: "Test", + base: "String", + fields: { + this: { + name: "this", + type: "String" + } + } } } as const; enums = { diff --git a/packages/zod/test/schema/schema.ts b/packages/zod/test/schema/schema.ts index 3442d3920..2c8369380 100644 --- a/packages/zod/test/schema/schema.ts +++ b/packages/zod/test/schema/schema.ts @@ -54,8 +54,7 @@ export class SchemaType implements SchemaDef { }, 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,54 @@ 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[] + }, + 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[] + } + } + }, + Test: { + name: "Test", + base: "String", + fields: { + this: { + name: "this", + type: "String" + } + } } } as const; enums = { diff --git a/packages/zod/test/schema/schema.zmodel b/packages/zod/test/schema/schema.zmodel index 6aa265ad0..3996e6444 100644 --- a/packages/zod/test/schema/schema.zmodel +++ b/packages/zod/test/schema/schema.zmodel @@ -28,7 +28,7 @@ model User { website String? @url code String @startsWith("USR") age Int @gt(0) @lte(150) - score Float @gte(0.0) @lt(100.0) + score Score bigNum BigInt @gte(0) balance Decimal @gt(0) active Boolean @@ -41,20 +41,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()) @@ -82,3 +100,7 @@ model Image extends Asset { format String width Int } + +type Test with String { + this String +} \ No newline at end of file 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..551e4df63 100644 --- a/tests/e2e/orm/schemas/procedures/schema.ts +++ b/tests/e2e/orm/schemas/procedures/schema.ts @@ -38,6 +38,12 @@ export class SchemaType implements SchemaDef { type: "Profile", optional: true, attributes: [{ name: "@json" }] as readonly AttributeApplication[] + }, + age: { + name: "age", + type: "Age", + optional: true, + attributes: [{ name: "@gte", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }] as readonly AttributeApplication[] } }, idFields: ["id"], @@ -84,6 +90,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 +144,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..d5b82d5f3 100644 --- a/tests/e2e/orm/schemas/procedures/schema.zmodel +++ b/tests/e2e/orm/schemas/procedures/schema.zmodel @@ -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) profile Profile? @json + age Age? } 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 From 172d56345840ab6962ac0a7a025f7a239e1dd245 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Wed, 23 Sep 2026 23:44:44 +0000 Subject: [PATCH 09/19] chore(zod): add tests --- packages/zod/test/factory.test.ts | 64 +++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) 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); + }); + }); }); From d736dfac2638769d98e7f37385e1841e7ee6bc56 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Wed, 23 Sep 2026 23:44:55 +0000 Subject: [PATCH 10/19] chore: add e2e tests --- .../client-api/custom-type-primitive.test.ts | 70 +++++++++++++++++++ tests/e2e/orm/client-api/procedures.test.ts | 53 ++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 tests/e2e/orm/client-api/custom-type-primitive.test.ts 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/); + }); }); From 883936746ddbfa7957eaae185ba8b0a49bcc06c2 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Thu, 24 Sep 2026 01:17:21 +0000 Subject: [PATCH 11/19] fix(orm): do not convert json to array --- packages/orm/src/client/helpers/schema-db-pusher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/orm/src/client/helpers/schema-db-pusher.ts b/packages/orm/src/client/helpers/schema-db-pusher.ts index b77b59b25..ae57bf599 100644 --- a/packages/orm/src/client/helpers/schema-db-pusher.ts +++ b/packages/orm/src/client/helpers/schema-db-pusher.ts @@ -347,7 +347,7 @@ export class SchemaDbPusher { throw new Error(`Unsupported field type: ${type}`); }); - if (fieldDef.array) { + if (fieldDef.array && type !== 'Json') { // Kysely doesn't support array type natively return sql.raw(`${result}[]`); } else { From 094a2b7310a75ffed8297e25ba71d7bf0c355a89 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Thu, 24 Sep 2026 01:26:27 +0000 Subject: [PATCH 12/19] fix(language): remove parent primitive type def check --- .../language/src/validators/attribute-application-validator.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/language/src/validators/attribute-application-validator.ts b/packages/language/src/validators/attribute-application-validator.ts index 7a9f6c20b..8da151d2a 100644 --- a/packages/language/src/validators/attribute-application-validator.ts +++ b/packages/language/src/validators/attribute-application-validator.ts @@ -564,8 +564,7 @@ function assignableToAttributeParam( // If the field is JSON, and the attribute is @default, the argument must be a JSON string // (design inherited from Prisma) const dstIsJson = attr.$container.type.type === 'Json' || hasAttribute(attr.$container, '@json'); - const isInPrimitiveTypeDef = isPrimitiveTypeDef(attr.$container.$container); - if (dstIsJson && isInPrimitiveTypeDef && attr.decl.ref?.name === '@default') { + if (dstIsJson && attr.decl.ref?.name === '@default') { if (attr.$container.type.array && attr.$container.type.type === 'Json') { // Json[] default value, must be array of JSON strings if (isArrayExpr(arg.value) && arg.value.items.every((item) => isLiteralJsonString(item))) { From 3309af79a50cf2f8cc2e837f161175f5ad90558d Mon Sep 17 00:00:00 2001 From: sanny-io Date: Thu, 24 Sep 2026 01:32:10 +0000 Subject: [PATCH 13/19] fix(sdk): fix primitive type def arrays for prisma schema gen --- packages/cli/test/prisma-schema-gen.test.ts | 24 +++++++++++++++++++ .../sdk/src/prisma/prisma-schema-generator.ts | 6 ++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/cli/test/prisma-schema-gen.test.ts b/packages/cli/test/prisma-schema-gen.test.ts index 6ba39b66f..9d5838c6b 100644 --- a/packages/cli/test/prisma-schema-gen.test.ts +++ b/packages/cli/test/prisma-schema-gen.test.ts @@ -110,4 +110,28 @@ type UserName with String { 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/sdk/src/prisma/prisma-schema-generator.ts b/packages/sdk/src/prisma/prisma-schema-generator.ts index 75b20d4e3..8cf5757f3 100644 --- a/packages/sdk/src/prisma/prisma-schema-generator.ts +++ b/packages/sdk/src/prisma/prisma-schema-generator.ts @@ -305,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 From f7e9bdafd310c04018f8054f4a3cc41f1486dc90 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Thu, 24 Sep 2026 01:36:24 +0000 Subject: [PATCH 14/19] chore: add default age for procedures test --- tests/e2e/orm/schemas/procedures/schema.ts | 3 ++- tests/e2e/orm/schemas/procedures/schema.zmodel | 14 +++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/e2e/orm/schemas/procedures/schema.ts b/tests/e2e/orm/schemas/procedures/schema.ts index 551e4df63..6b9fd7b29 100644 --- a/tests/e2e/orm/schemas/procedures/schema.ts +++ b/tests/e2e/orm/schemas/procedures/schema.ts @@ -43,7 +43,8 @@ export class SchemaType implements SchemaDef { name: "age", type: "Age", optional: true, - attributes: [{ name: "@gte", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }] as readonly AttributeApplication[] + 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"], diff --git a/tests/e2e/orm/schemas/procedures/schema.zmodel b/tests/e2e/orm/schemas/procedures/schema.zmodel index d5b82d5f3..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 { @@ -26,11 +26,11 @@ type Age with Int { } 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? + age Age? @default(18) } procedure getUser(id: Int): User From 3aa959c95cbdc18e984a40adf6795e4a8fc4b350 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Thu, 24 Sep 2026 01:41:23 +0000 Subject: [PATCH 15/19] fix(orm): add missing custom validations --- packages/orm/src/client/zod/factory.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index 02e74fb50..518b9fa19 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -463,7 +463,8 @@ export class ZodSchemaFactory< const typeDef = getTypeDef(this.schema, type); invariant(typeDef, `Type definition "${type}" not found in schema`); if (typeDef.base) { - return this.makeScalarSchema(typeDef.base, typeDef.fields['this']?.attributes); + 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( From bc9f8f5b99fb35c2f35026ed46dbfccf15a21c56 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Thu, 24 Sep 2026 06:26:48 +0000 Subject: [PATCH 16/19] fix(orm): json arrays --- .../orm/src/client/helpers/schema-db-pusher.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/orm/src/client/helpers/schema-db-pusher.ts b/packages/orm/src/client/helpers/schema-db-pusher.ts index ae57bf599..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,15 @@ export class SchemaDbPusher { return 'serial'; } - let type: BuiltinType = fieldDef.type as BuiltinType; + let type = fieldDef.type as BuiltinType; - if (this.isCustomType(fieldDef.type)) { - const typeDef = Object.values(this.schema.typeDefs!).find((def) => def.name === fieldDef.type)!; - type = (typeDef.base ?? 'Json') 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 result = match>(type) @@ -347,7 +351,7 @@ export class SchemaDbPusher { throw new Error(`Unsupported field type: ${type}`); }); - if (fieldDef.array && type !== 'Json') { + if (fieldDef.array) { // Kysely doesn't support array type natively return sql.raw(`${result}[]`); } else { From 8743f4f3886291e3e295000d50f31ca9a29d2b05 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Thu, 24 Sep 2026 06:49:39 +0000 Subject: [PATCH 17/19] fix(zod): apply consuming field attributes for `makeTypeSchema` --- packages/zod/src/factory.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/zod/src/factory.ts b/packages/zod/src/factory.ts index 181381c56..862028a48 100644 --- a/packages/zod/src/factory.ts +++ b/packages/zod/src/factory.ts @@ -418,7 +418,7 @@ class SchemaFactory { // typedef const typedefDef = this.schema.getTypeDef(type); if (typedefDef) { - return this.applyCardinality(this.makeTypeSchema(type as GetTypeDefs), def); + return this.applyCardinality(this.makeTypeSchema(type as GetTypeDefs, def.attributes), def); } return this.applyCardinality(this.makeScalarSchema(type as BuiltinType, attributes), def); @@ -461,11 +461,14 @@ class SchemaFactory { return result; } - makeTypeSchema>(type: Type): MapTypeDefToZod { + makeTypeSchema>( + type: Type, + attributes?: readonly AttributeApplication[], + ): MapTypeDefToZod { const typeDef = this.schema.requireTypeDef(type); if (typeDef.base) { return addCustomValidation( - this.makeScalarSchema(typeDef.base, typeDef.fields['this']?.attributes), + this.makeScalarSchema(typeDef.base, attributes ?? typeDef.fields['this']?.attributes), typeDef.attributes, ) as unknown as MapTypeDefToZod; } From 812f2a595eca929e43afe14fcc9c7e2123b5dff4 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Thu, 24 Sep 2026 06:49:49 +0000 Subject: [PATCH 18/19] chore(zod): regenerate schema --- packages/zod/test/schema/schema-lite.ts | 25 +++++++++++++------------ packages/zod/test/schema/schema.ts | 25 +++++++++++++------------ packages/zod/test/schema/schema.zmodel | 10 +++++----- 3 files changed, 31 insertions(+), 29 deletions(-) diff --git a/packages/zod/test/schema/schema-lite.ts b/packages/zod/test/schema/schema-lite.ts index 59ebc5c1d..c82cf36f7 100644 --- a/packages/zod/test/schema/schema-lite.ts +++ b/packages/zod/test/schema/schema-lite.ts @@ -49,8 +49,8 @@ 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", @@ -348,6 +348,17 @@ export class SchemaType implements SchemaDef { { 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", @@ -385,16 +396,6 @@ export class SchemaType implements SchemaDef { attributes: [{ name: "@lower" }] as readonly AttributeApplication[] } } - }, - Test: { - name: "Test", - base: "String", - fields: { - this: { - name: "this", - type: "String" - } - } } } as const; enums = { diff --git a/packages/zod/test/schema/schema.ts b/packages/zod/test/schema/schema.ts index 2c8369380..e3aab2da2 100644 --- a/packages/zod/test/schema/schema.ts +++ b/packages/zod/test/schema/schema.ts @@ -49,8 +49,8 @@ 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", @@ -354,6 +354,17 @@ export class SchemaType implements SchemaDef { { 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", @@ -391,16 +402,6 @@ export class SchemaType implements SchemaDef { attributes: [{ name: "@lower" }] as readonly AttributeApplication[] } } - }, - Test: { - name: "Test", - base: "String", - fields: { - this: { - name: "this", - type: "String" - } - } } } as const; enums = { diff --git a/packages/zod/test/schema/schema.zmodel b/packages/zod/test/schema/schema.zmodel index 3996e6444..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,7 +31,7 @@ model User { username String @length(3, 50) website String? @url code String @startsWith("USR") - age Int @gt(0) @lte(150) + age Age @lte(150) score Score bigNum BigInt @gte(0) balance Decimal @gt(0) @@ -100,7 +104,3 @@ model Image extends Asset { format String width Int } - -type Test with String { - this String -} \ No newline at end of file From 905476a595b314a7681f8071e8d75eafb385f1e8 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Thu, 24 Sep 2026 06:51:05 +0000 Subject: [PATCH 19/19] fix(language): map to expression type --- .../language/src/validators/attribute-application-validator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/language/src/validators/attribute-application-validator.ts b/packages/language/src/validators/attribute-application-validator.ts index 8da151d2a..dfa907185 100644 --- a/packages/language/src/validators/attribute-application-validator.ts +++ b/packages/language/src/validators/attribute-application-validator.ts @@ -671,7 +671,7 @@ function assignableToAttributeParam( isTypeDef(attr.$container?.type?.reference?.ref) && isPrimitiveTypeDef(attr.$container.type.reference.ref) ) { - dstType = attr.$container.type.reference.ref.base!; + dstType = mapBuiltinTypeToExpressionType(attr.$container.type.reference.ref.base!); } else { if (!attr.$container?.type?.type) { return genericError;