From fa051dd362a4fd62792704b998623c3877395253 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Wed, 23 Sep 2026 23:35:35 +0000 Subject: [PATCH 01/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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; From 6d9ab06e3e6bcbc15919458891de990793ebea3f Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:04:28 -0700 Subject: [PATCH 20/38] test(orm): add counter tests for primitive custom type review findings Each test fails on the current head and should pass once the corresponding finding is fixed: scalar where-filters, SQLite JSON encoding, numeric defaults, atomic update ops, consuming-field attributes, WhereInput types, nested primitive typedefs, and PostgreSQL array casts. Co-Authored-By: Claude Fable 5.1 --- .../custom-type-primitive-findings.test.ts | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 tests/e2e/orm/client-api/custom-type-primitive-findings.test.ts diff --git a/tests/e2e/orm/client-api/custom-type-primitive-findings.test.ts b/tests/e2e/orm/client-api/custom-type-primitive-findings.test.ts new file mode 100644 index 000000000..76f0d6b9f --- /dev/null +++ b/tests/e2e/orm/client-api/custom-type-primitive-findings.test.ts @@ -0,0 +1,272 @@ +import type { ClientContract } from '@zenstackhq/orm'; +import { createTestClient } from '@zenstackhq/testtools'; +import { afterEach, describe, expect, it } from 'vitest'; +import { schema } from '../schemas/custom-type-primitive/schema'; + +/** + * Counter test cases for review findings on primitive custom types (PR #2852). + * Each test is expected to FAIL on the current PR head and pass once the + * corresponding finding is fixed. + */ + +const BASE_SCHEMA = ` +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) +} +`; + +// SQLite doesn't support list fields, so drop `contacts` for SQLite cases +const SQLITE_SCHEMA = BASE_SCHEMA.replace(' contacts Contact[]\n', ''); + +describe('Custom type primitive review findings', () => { + let client: any; + + afterEach(async () => { + await client?.$disconnect(); + }); + + // Finding 1: zod/factory.ts makeWhereSchema routes primitive typedef fields to the + // typed-JSON filter schema, so plain scalar filters are rejected at validation time. + describe('finding 1: where-input uses scalar filters for primitive typedef fields', () => { + it('accepts plain value, string and numeric filters', async () => { + client = await createTestClient(BASE_SCHEMA, { provider: 'postgresql' }); + await client.user.create({ data: { name: 'test', age: 20 } }); + + await expect(client.user.findMany({ where: { name: 'test' } })).resolves.toHaveLength(1); + await expect(client.user.findMany({ where: { name: { contains: 'es' } } })).resolves.toHaveLength(1); + await expect(client.user.findMany({ where: { name: { startsWith: 'te' } } })).resolves.toHaveLength(1); + await expect(client.user.findMany({ where: { age: { gte: 18 } } })).resolves.toHaveLength(1); + await expect(client.user.findMany({ where: { age: { lt: 18 } } })).resolves.toHaveLength(0); + }); + }); + + // Finding 2: sqlite.ts transformInput/transformOutput treat every typedef name as JSON, + // so primitive typedef values are stored JSON-quoted on SQLite. + describe('finding 2: SQLite stores primitive typedef values as raw scalars', () => { + it('stores and reads back scalars without JSON encoding', async () => { + client = await createTestClient(SQLITE_SCHEMA, { provider: 'sqlite' }); + const user = await client.user.create({ data: { name: 'test', age: 20 } }); + expect(user.name).toBe('test'); + expect(user.age).toBe(20); + + // raw row must hold the bare scalar, not a JSON string + const raw = await client.$qb + .selectFrom('User') + .select(['name', 'age']) + .where('id', '=', user.id) + .executeTakeFirstOrThrow(); + expect(raw.name).toBe('test'); + expect(raw.age).toBe(20); + + await expect(client.user.findMany({ where: { name: { equals: 'test' } } })).resolves.toHaveLength(1); + await expect(client.user.aggregate({ _max: { age: true } })).resolves.toMatchObject({ + _max: { age: 20 }, + }); + }); + }); + + // Finding 3: ts-schema-generator getMappedValue keys numeric conversion off fieldType.type, + // which is undefined for typedef references, so @default(18) is emitted as "18". + describe('finding 3: numeric defaults on primitive typedef fields are emitted as numbers', () => { + it('emits a numeric default in the generated schema', () => { + expect(schema.models.User.fields.age.default).toBe(18); + expect(typeof schema.models.User.fields.age.default).toBe('number'); + }); + + it('applies the default as a number on SQLite', async () => { + client = await createTestClient(SQLITE_SCHEMA, { provider: 'sqlite' }); + const user = await client.user.create({ data: { name: 'test' } }); + expect(user.age).toBe(18); + expect(typeof user.age).toBe('number'); + }); + }); + + // Finding 4: zod/factory.ts isNumericField compares fieldDef.type against the builtin + // numeric type names, so Int-based typedef fields get no atomic update operators. + describe('finding 4: numeric primitive typedef fields support atomic update ops', () => { + it('accepts increment/decrement/multiply', async () => { + client = await createTestClient(BASE_SCHEMA, { provider: 'postgresql' }); + const user = await client.user.create({ data: { name: 'test', age: 20 } }); + + await expect( + client.user.update({ where: { id: user.id }, data: { age: { increment: 1 } } }), + ).resolves.toMatchObject({ age: 21 }); + await expect( + client.user.update({ where: { id: user.id }, data: { age: { decrement: 1 } } }), + ).resolves.toMatchObject({ age: 20 }); + await expect(client.user.updateMany({ data: { age: { multiply: 2 } } })).resolves.toMatchObject({ + count: 1, + }); + await expect(client.user.findUnique({ where: { id: user.id } })).resolves.toMatchObject({ age: 40 }); + }); + + it('supports numeric aggregation', async () => { + client = await createTestClient(BASE_SCHEMA, { provider: 'postgresql' }); + await client.user.create({ data: { name: 'test', age: 20 } }); + await client.user.create({ data: { name: 'test', age: 30 } }); + await expect(client.user.aggregate({ _sum: { age: true }, _avg: { age: true } })).resolves.toMatchObject({ + _sum: { age: 50 }, + _avg: { age: 25 }, + }); + }); + }); + + // Finding 5: zod/factory.ts makeScalarSchema discards the `attributes` argument when the + // type is a typedef, so attributes declared on the consuming model field are ignored. + describe('finding 5: ORM applies attributes declared on the consuming field', () => { + it('enforces field-level @length on a primitive typedef field', async () => { + client = await createTestClient( + ` +model User { + id String @id @default(nanoid()) + email Email @length(1, 5) +} + +type Email with String { + this String +} +`, + { provider: 'postgresql' }, + ); + + await expect(client.user.create({ data: { email: 'ab' } })).resolves.toMatchObject({ email: 'ab' }); + await expect(client.user.create({ data: { email: 'abcdefgh' } })).toBeRejectedByValidation(); + }); + + it('enforces both typedef-level and field-level attributes', async () => { + client = await createTestClient( + ` +model User { + id String @id @default(nanoid()) + email Email @length(1, 5) +} + +type Email with String { + this String @email +} +`, + { provider: 'postgresql' }, + ); + + // too long: rejected by field-level @length + await expect(client.user.create({ data: { email: 'a@b.com' } })).toBeRejectedByValidation(); + // not an email: rejected by typedef-level @email + await expect(client.user.create({ data: { email: 'abc' } })).toBeRejectedByValidation(); + }); + }); + + // Finding 6: crud-types.ts WhereInput maps any field whose type is a typedef to + // TypedJsonFilter, so scalar filters on primitive typedef fields are TypeScript errors. + // Verified by `pnpm test:typecheck` (tsc --noEmit) in tests/e2e; vitest's typecheck only + // covers *.test-d.ts files. On the current head tsc reports TS2353 on the three scalar + // filters below and an unused @ts-expect-error on the JSON-shaped one. + describe('finding 6: WhereInput types primitive typedef fields as scalar filters', () => { + it('type-checks string and numeric filters without casts', async () => { + const typed: ClientContract = await createTestClient(schema, { + provider: 'postgresql', + }); + client = typed; + await typed.user.create({ data: { name: 'test', age: 20 } }); + + // these must compile without `as any` + await expect(typed.user.findMany({ where: { name: { contains: 'es' } } })).resolves.toHaveLength(1); + await expect(typed.user.findMany({ where: { name: { startsWith: 'te' } } })).resolves.toHaveLength(1); + await expect(typed.user.findMany({ where: { age: { gte: 18 } } })).resolves.toHaveLength(1); + await expect(typed.user.findMany({ where: { contacts: { has: '+15555555555' } } })).resolves.toHaveLength( + 0, + ); + + // and JSON-filter shapes must be rejected at the type level + await expect( + // @ts-expect-error `path` is a JSON filter key, not valid for a String-based typedef + typed.user.findMany({ where: { name: { path: '$.foo' } } }), + ).rejects.toThrow(); + }); + }); + + // Finding 7: base-dialect.ts buildTypedJsonFilter and zod/factory.ts + // makeTypedJsonFilterSchema recurse into a primitive typedef used as a field of a JSON + // typedef as if it had sub-fields, so filtering on nested primitive typedef fields breaks. + describe('finding 7: primitive typedef nested in a JSON typedef is filterable', () => { + it('filters on a nested primitive typedef field', async () => { + client = await createTestClient( + ` +model User { + id String @id @default(nanoid()) + profile Profile @json +} + +type Profile { + email Email + age Age +} + +type Email with String { + this String @email +} + +type Age with Int { + this Int @gte(18) +} +`, + { provider: 'postgresql' }, + ); + + await client.user.create({ data: { profile: { email: 'alice@example.com', age: 25 } } }); + + await expect( + client.user.findMany({ where: { profile: { email: 'alice@example.com' } } }), + ).resolves.toHaveLength(1); + await expect( + client.user.findMany({ where: { profile: { email: { contains: 'alice' } } } }), + ).resolves.toHaveLength(1); + await expect(client.user.findMany({ where: { profile: { age: { gte: 18 } } } })).resolves.toHaveLength(1); + await expect(client.user.findMany({ where: { profile: { age: { lt: 18 } } } })).resolves.toHaveLength(0); + }); + }); + + // Finding 8: postgresql.ts getSqlType falls through to 'text' for a non-String primitive + // typedef name, so array filters on an Int-based typedef array cast to text[] against an + // integer[] column. + describe('finding 8: PostgreSQL array filters cast to the typedef base type', () => { + it('filters an Int-based typedef array', async () => { + client = await createTestClient( + ` +model User { + id String @id @default(nanoid()) + ages Age[] +} + +type Age with Int { + this Int @gte(18) +} +`, + { provider: 'postgresql' }, + ); + + await client.user.create({ data: { ages: [18, 25] } }); + await client.user.create({ data: { ages: [30] } }); + + await expect(client.user.findMany({ where: { ages: { has: 18 } } })).resolves.toHaveLength(1); + await expect(client.user.findMany({ where: { ages: { hasSome: [18, 30] } } })).resolves.toHaveLength(2); + await expect(client.user.findMany({ where: { ages: { hasEvery: [18, 25] } } })).resolves.toHaveLength(1); + await expect(client.user.findMany({ where: { ages: { equals: [30] } } })).resolves.toHaveLength(1); + await expect(client.user.findMany({ where: { ages: { isEmpty: true } } })).resolves.toHaveLength(0); + }); + }); +}); From e2e44c6769a82c38e3134cd4cbd1bf0f9dd4dba7 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Tue, 13 Oct 2026 11:16:26 +0000 Subject: [PATCH 21/38] do not require `this` field --- .../language/src/validators/typedef-validator.ts | 6 +----- .../language/test/custom-type-primitive.test.ts | 15 ++++++--------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/language/src/validators/typedef-validator.ts b/packages/language/src/validators/typedef-validator.ts index e23a662f7..851701f81 100644 --- a/packages/language/src/validators/typedef-validator.ts +++ b/packages/language/src/validators/typedef-validator.ts @@ -35,11 +35,7 @@ export default class TypeDefValidator implements AstValidator { }); } const thisField = getPrimitiveTypeDefThisField(typeDef); - if (!thisField) { - accept('error', 'primitive type def is missing "this" field', { - node: typeDef, - }); - } else { + if (thisField) { if (thisField.type.type !== typeDef.base) { accept('error', 'primitive type def\'s "this" field must match the declared type', { node: thisField, diff --git a/packages/language/test/custom-type-primitive.test.ts b/packages/language/test/custom-type-primitive.test.ts index 8a540296f..960011ebe 100644 --- a/packages/language/test/custom-type-primitive.test.ts +++ b/packages/language/test/custom-type-primitive.test.ts @@ -199,8 +199,8 @@ describe('Custom type primitive tests', () => { `); }); - it('rejects when there is more than 1 field', async () => { - await loadSchemaWithError( + it('does not require a "this" field', async () => { + await loadSchema( ` datasource db { provider = 'postgresql' @@ -212,16 +212,12 @@ describe('Custom type primitive tests', () => { email Email } - type Email with String { - this String - this2 String - } + type Email with String {} `, - 'primitive type def must only declare 1 field', ); }); - it('rejects when there is no "this" field', async () => { + it('rejects when there is more than 1 field', async () => { await loadSchemaWithError( ` datasource db { @@ -235,10 +231,11 @@ describe('Custom type primitive tests', () => { } type Email with String { + this String this2 String } `, - 'primitive type def is missing "this" field', + 'primitive type def must only declare 1 field', ); }); From 5714175ae02d73ae7263fcfe1ba2956f63a3699f Mon Sep 17 00:00:00 2001 From: sanny-io Date: Tue, 13 Oct 2026 11:18:06 +0000 Subject: [PATCH 22/38] update datamodel error message --- packages/language/src/validators/datamodel-validator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/language/src/validators/datamodel-validator.ts b/packages/language/src/validators/datamodel-validator.ts index d6fa457a0..ed56db385 100644 --- a/packages/language/src/validators/datamodel-validator.ts +++ b/packages/language/src/validators/datamodel-validator.ts @@ -53,7 +53,7 @@ export default class DataModelValidator implements AstValidator { private validateInheritance(dm: DataModel, accept: ValidationAcceptor) { if (dm.base) { - accept('error', `model "${dm.name}" cannot inherit from a primitive type`, { + accept('error', `model "${dm.name}" cannot use a primitive type as mix-in`, { node: dm, }); } From b1cfaea8942194993036e5190910ba4d890273e0 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Tue, 13 Oct 2026 11:24:41 +0000 Subject: [PATCH 23/38] simplify linker primitive typedef check --- packages/language/src/zmodel-linker.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/language/src/zmodel-linker.ts b/packages/language/src/zmodel-linker.ts index a6d868b06..e291fcfd9 100644 --- a/packages/language/src/zmodel-linker.ts +++ b/packages/language/src/zmodel-linker.ts @@ -374,16 +374,18 @@ export class ZModelLinker extends DefaultLinker { let decl: AstNode | undefined = node.$container; - while (decl && !isDataModel(decl) && !isPrimitiveTypeDef(decl)) { - decl = decl.$container; - } - - if (decl) { + while (decl) { if (isPrimitiveTypeDef(decl)) { this.resolveToBuiltinTypeOrDecl(node, decl.base!); - } else { + return; + } + + if (isDataModel(decl)) { this.resolveToBuiltinTypeOrDecl(node, decl); + return; } + + decl = decl.$container; } } From 4e93325a12d11aefcf95b6dbda8025bba23a2c75 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Tue, 13 Oct 2026 11:26:37 +0000 Subject: [PATCH 24/38] remove redundant `isTypeDef` call --- packages/language/src/zmodel-linker.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/language/src/zmodel-linker.ts b/packages/language/src/zmodel-linker.ts index e291fcfd9..4ec0da9e7 100644 --- a/packages/language/src/zmodel-linker.ts +++ b/packages/language/src/zmodel-linker.ts @@ -505,13 +505,13 @@ 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) { + // if the field has enum declaration type, resolve the rest with that enum's fields on top of the scopes if (isEnum(node.type.reference.ref)) { const contextEnum = node.type.reference.ref as Enum; const enumScope: ScopeProvider = (name) => contextEnum.fields.find((f) => f.name === name); scopes = [enumScope, ...scopes]; - } else if (isTypeDef(node.type.reference.ref) && isPrimitiveTypeDef(node.type.reference.ref)) { + } else if (isPrimitiveTypeDef(node.type.reference.ref)) { node.$resolvedType = { decl: node.type.reference.ref.base, array: node.type.array, From e734d7eb871b20d65d58374796d76cb6ee45caee Mon Sep 17 00:00:00 2001 From: sanny-io Date: Tue, 13 Oct 2026 12:28:12 +0000 Subject: [PATCH 25/38] fix(orm): get zmodel type for primitive --- packages/orm/src/client/crud/dialects/postgresql.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/orm/src/client/crud/dialects/postgresql.ts b/packages/orm/src/client/crud/dialects/postgresql.ts index 6406fa88e..fb6780af0 100644 --- a/packages/orm/src/client/crud/dialects/postgresql.ts +++ b/packages/orm/src/client/crud/dialects/postgresql.ts @@ -506,6 +506,10 @@ export class PostgresCrudDialect extends LateralJoinDi } } } + // a primitive typedef maps to its base type's SQL type + if (isPrimitiveTypeDef(this.schema, zmodelType)) { + zmodelType = this.schema.typeDefs![zmodelType]!.base!; + } if (isEnum(this.schema, zmodelType)) { // reduce enum to text for type compatibility return 'text'; From 3cb912bd6bb95a8dfe438aa56fdfb1f7f41bf106 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Tue, 13 Oct 2026 12:30:24 +0000 Subject: [PATCH 26/38] fix(orm): primitive filtering --- packages/orm/src/client/crud-types.ts | 67 ++++++++++++++----- .../src/client/crud/dialects/base-dialect.ts | 7 +- .../src/client/crud/operations/aggregate.ts | 6 +- .../src/client/crud/operations/group-by.ts | 6 +- 4 files changed, 63 insertions(+), 23 deletions(-) diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 181d2fbc8..b4b137e4c 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -17,6 +17,7 @@ import type { GetModelFieldType, GetModels, GetSubModels, + GetTypeDef, GetTypeDefField, GetTypeDefFields, GetTypeDefFieldType, @@ -411,14 +412,22 @@ type FieldFilter< AllowedKinds > : GetModelFieldType extends GetTypeDefs - ? // typedef - TypedJsonFilter< - Schema, - GetModelFieldType, - FieldIsArray, - ModelFieldIsOptional, - AllowedKinds - > + ? TypeDefIsPrimitive> extends true + ? // primitive typedef — filter like its base type + PrimitiveFilter< + TypeDefBaseType>, + ModelFieldIsOptional, + WithAggregations, + AllowedKinds + > + : // JSON typedef + TypedJsonFilter< + Schema, + GetModelFieldType, + FieldIsArray, + ModelFieldIsOptional, + AllowedKinds + > : // primitive GetModelFieldType extends 'String' ? // string — additionally consider fuzzy / full-text augmentations @@ -594,9 +603,23 @@ type ArrayFilter< } : {}); +// the base builtin type of a primitive typedef (e.g. `Int` for `type Age with Int`) +type TypeDefBaseType> = GetTypeDef< + Schema, + TypeDef +>['base'] extends infer B extends string + ? B + : never; + // map a scalar type (primitive and enum) to TS type type MapScalarType = - Type extends GetEnums ? keyof GetEnum : MapBaseType; + Type extends GetEnums + ? keyof GetEnum + : Type extends GetTypeDefs + ? TypeDefIsPrimitive extends true + ? MapBaseType> + : MapBaseType + : MapBaseType; type PrimitiveFilter< T extends string, @@ -983,14 +1006,24 @@ type TypedJsonFieldsFilter< TypeDefName, Key > extends GetTypeDefs - ? // nested typedef - recurse - TypedJsonFilter< - Schema, - GetTypeDefFieldType, - TypeDefFieldIsArray, - TypeDefFieldIsOptional, - AllowedKinds - > + ? TypeDefIsPrimitive> extends true + ? // primitive typedef — filter like its base type + TypeDefFieldIsArray extends true + ? ArrayFilter, AllowedKinds> + : PrimitiveFilter< + TypeDefBaseType>, + TypeDefFieldIsOptional, + false, + AllowedKinds + > + : // nested JSON typedef - recurse + TypedJsonFilter< + Schema, + GetTypeDefFieldType, + TypeDefFieldIsArray, + TypeDefFieldIsOptional, + AllowedKinds + > : // array TypeDefFieldIsArray extends true ? ArrayFilter, AllowedKinds> diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index fa866a1f0..2fa4b4d37 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -850,14 +850,17 @@ export abstract class BaseCrudDialect { const fieldDef = typeDef.fields[key]; invariant(fieldDef, `Field "${key}" not found in type definition "${typeDefName}"`); const fieldReceiver = this.buildJsonPathSelection(receiver, `$.${key}`); - if (isTypeDef(this.schema, fieldDef.type)) { + if (isTypeDef(this.schema, fieldDef.type) && !isPrimitiveTypeDef(this.schema, fieldDef.type)) { clauses.push(this.buildTypedJsonFilter(fieldReceiver, value, fieldDef.type, !!fieldDef.array)); } else { if (fieldDef.array) { clauses.push(this.buildArrayFilter(fieldReceiver, fieldDef, value)); } else { + const effectiveType = isPrimitiveTypeDef(this.schema, fieldDef.type) + ? this.schema.typeDefs![fieldDef.type]!.base! + : fieldDef.type; let _receiver = fieldReceiver; - if (fieldDef.type === 'String' || isEnum(this.schema, fieldDef.type)) { + if (effectiveType === 'String' || isEnum(this.schema, effectiveType)) { // trim quotes for string and enum fields (enums are stored as JSON strings) _receiver = this.trimTextQuotes(this.castText(fieldReceiver)); } diff --git a/packages/orm/src/client/crud/operations/aggregate.ts b/packages/orm/src/client/crud/operations/aggregate.ts index 53e8e3640..343117084 100644 --- a/packages/orm/src/client/crud/operations/aggregate.ts +++ b/packages/orm/src/client/crud/operations/aggregate.ts @@ -1,7 +1,7 @@ import type { SchemaDef } from '@zenstackhq/schema'; import { match } from 'ts-pattern'; import { createInvalidInputError } from '../../errors'; -import { getField } from '../../query-utils'; +import { getField, isPrimitiveTypeDef } from '../../query-utils'; import { BaseOperationHandler } from './base'; // stable key for comparing two `args` payloads (bigint-safe) @@ -162,7 +162,9 @@ export class AggregateOperationHandler extends BaseOpe if (typeof value === 'string') { const fieldDef = getField(this.schema, this.model, field); if (fieldDef) { - const type = fieldDef.type; + const type = isPrimitiveTypeDef(this.schema, fieldDef.type) + ? this.schema.typeDefs![fieldDef.type]!.base! + : fieldDef.type; if (op === '_avg') { val = parseFloat(val); } else { diff --git a/packages/orm/src/client/crud/operations/group-by.ts b/packages/orm/src/client/crud/operations/group-by.ts index 377519454..2b819027a 100644 --- a/packages/orm/src/client/crud/operations/group-by.ts +++ b/packages/orm/src/client/crud/operations/group-by.ts @@ -1,7 +1,7 @@ import type { SchemaDef } from '@zenstackhq/schema'; import { sql } from 'kysely'; import { match } from 'ts-pattern'; -import { aggregate, getField } from '../../query-utils'; +import { aggregate, getField, isPrimitiveTypeDef } from '../../query-utils'; import { BaseOperationHandler } from './base'; export class GroupByOperationHandler extends BaseOperationHandler { @@ -132,7 +132,9 @@ export class GroupByOperationHandler extends BaseOpera if (typeof value === 'string') { const fieldDef = getField(this.schema, this.model, field); if (fieldDef) { - const type = fieldDef.type; + const type = isPrimitiveTypeDef(this.schema, fieldDef.type) + ? this.schema.typeDefs![fieldDef.type]!.base! + : fieldDef.type; if (op === '_avg') { val = parseFloat(val); } else { From 673d567c5e535ee5dbc8bb215415d088f104e9db Mon Sep 17 00:00:00 2001 From: sanny-io Date: Tue, 13 Oct 2026 12:30:49 +0000 Subject: [PATCH 27/38] fix(orm): sqlite typedef primitives --- .../orm/src/client/crud/dialects/sqlite.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/orm/src/client/crud/dialects/sqlite.ts b/packages/orm/src/client/crud/dialects/sqlite.ts index a7ceac7b2..f7e321cc8 100644 --- a/packages/orm/src/client/crud/dialects/sqlite.ts +++ b/packages/orm/src/client/crud/dialects/sqlite.ts @@ -21,6 +21,8 @@ import { getDelegateDescendantModels, getManyToManyRelation, getRelationForeignKeyFieldPairs, + isPrimitiveTypeDef, + isTypeDef, requireField, requireIdFields, requireModel, @@ -81,7 +83,17 @@ export class SqliteCrudDialect extends BaseCrudDialect invariant(false, 'should not reach here: AnyNull is not a valid input value'); } - if (type === 'Json' || (this.schema.typeDefs && type in this.schema.typeDefs)) { + if (isTypeDef(this.schema, type)) { + if (isPrimitiveTypeDef(this.schema, type)) { + // a primitive typedef is stored as its base type + type = this.schema.typeDefs![type]!.base!; + } else { + // JSON data should be stringified + return JSON.stringify(value); + } + } + + if (type === 'Json') { // JSON data should be stringified return JSON.stringify(value); } @@ -108,10 +120,13 @@ export class SqliteCrudDialect extends BaseCrudDialect } } - override transformOutput(value: unknown, type: BuiltinType, array: boolean) { + override transformOutput(value: unknown, type: BuiltinType, array: boolean): unknown { if (value === null || value === undefined) { return value; } else if (this.schema.typeDefs && type in this.schema.typeDefs) { + if (isPrimitiveTypeDef(this.schema, type)) { + return this.transformOutput(value, this.schema.typeDefs[type]!.base!, array); + } // typed JSON field return this.transformOutputJson(value); } else { From 37795b3d7dc30d2c4d215822434c4c40c554aa15 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Tue, 13 Oct 2026 12:31:11 +0000 Subject: [PATCH 28/38] fix(orm): consider primitive base for numeric check --- packages/orm/src/client/crud/operations/base.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index a9b9e572e..a5cf09654 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -44,6 +44,7 @@ import { getModel, getRelationForeignKeyFieldPairs, isForeignKeyField, + isPrimitiveTypeDef, isRelationField, isScalarField, requireField, @@ -1561,7 +1562,10 @@ export abstract class BaseOperationHandler { } private isNumericField(fieldDef: FieldDef) { - return NUMERIC_FIELD_TYPES.includes(fieldDef.type) && !fieldDef.array; + const type = isPrimitiveTypeDef(this.schema, fieldDef.type) + ? this.schema.typeDefs![fieldDef.type]!.base! + : fieldDef.type; + return NUMERIC_FIELD_TYPES.includes(type) && !fieldDef.array; } private makeContextComment(_context: { model: string; operation: CRUD }) { From c15aae6838c23c10a98c5fb9d78bf97b7be000f7 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Tue, 13 Oct 2026 13:18:48 +0000 Subject: [PATCH 29/38] fix(orm): skip typed json schema if primitive --- packages/orm/src/client/zod/factory.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index 518b9fa19..346a2d111 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -48,6 +48,7 @@ import { getTypeDef, getUniqueFields, isEnum, + isPrimitiveTypeDef, isTypeDef, requireField, requireModel, @@ -581,7 +582,7 @@ export class ZodSchemaFactory< } else if (fieldDef.array) { // array field fieldSchema = this.makeArrayFilterSchema(fieldDef.type, allowedFilterKinds); - } else if (this.isTypeDefType(fieldDef.type)) { + } else if (this.isTypeDefType(fieldDef.type) && !isPrimitiveTypeDef(this.schema, fieldDef.type)) { fieldSchema = this.makeTypedJsonFilterSchema( fieldDef.type, !!fieldDef.optional, @@ -589,9 +590,9 @@ export class ZodSchemaFactory< allowedFilterKinds, ); } else { - // primitive field + // primitive field (or a primitive typedef, which filters like its base type) fieldSchema = this.makePrimitiveFilterSchema( - fieldDef.type as BuiltinType, + this.getEffectiveFieldType(fieldDef.type) as BuiltinType, !!fieldDef.optional, withAggregations, allowedFilterKinds, @@ -718,7 +719,7 @@ export class ZodSchemaFactory< // fields filter — typedef sub-fields are not model fields, no slicing applies const fieldSchemas: Record = {}; for (const [fieldName, fieldDef] of Object.entries(typeDef.fields)) { - if (this.isTypeDefType(fieldDef.type)) { + if (this.isTypeDefType(fieldDef.type) && !isPrimitiveTypeDef(this.schema, fieldDef.type)) { fieldSchemas[fieldName] = this.makeTypedJsonFilterSchema( fieldDef.type, !!fieldDef.optional, @@ -739,7 +740,7 @@ export class ZodSchemaFactory< fieldSchemas[fieldName] = this.makeArrayFilterSchema(fieldDef.type, undefined).optional(); } else { fieldSchemas[fieldName] = this.makePrimitiveFilterSchema( - fieldDef.type as BuiltinType, + this.getEffectiveFieldType(fieldDef.type) as BuiltinType, !!fieldDef.optional, false, undefined, @@ -819,6 +820,13 @@ export class ZodSchemaFactory< return this.schema.typeDefs && type in this.schema.typeDefs; } + private getEffectiveFieldType(type: string): string { + if (isPrimitiveTypeDef(this.schema, type)) { + return this.schema.typeDefs![type]!.base!; + } + return type; + } + @cache() private makeEnumFilterSchema( enumName: string, @@ -2565,7 +2573,7 @@ export class ZodSchemaFactory< } private isNumericField(fieldDef: FieldDef) { - return NUMERIC_FIELD_TYPES.includes(fieldDef.type) && !fieldDef.array; + return NUMERIC_FIELD_TYPES.includes(this.getEffectiveFieldType(fieldDef.type)) && !fieldDef.array; } private get providerSupportsCaseSensitivity() { From 05cee036404b93da0d176627f17c2fe523c6500b Mon Sep 17 00:00:00 2001 From: sanny-io Date: Tue, 13 Oct 2026 13:19:45 +0000 Subject: [PATCH 30/38] fix(sdk): primitive mapping in schema generation --- packages/sdk/src/ts-schema-generator.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index a35f13787..ccf6a3891 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -45,6 +45,7 @@ import { getRelationName, isDataFieldReference, isLiteAttribute, + isPrimitiveTypeDef, } from '@zenstackhq/language/utils'; import fs from 'node:fs'; import path from 'node:path'; @@ -818,9 +819,12 @@ export class TsSchemaGenerator { ): string | number | boolean | unknown[] | { call: string; args: any[] } | { authMember: string[] } | null { if (isLiteralExpr(expr)) { const lit = (expr as LiteralExpr).value; - return fieldType.type === 'Boolean' + // resolve the effective base type for primitive typedef references so that + // numeric/boolean defaults are emitted with the correct JS type + const effectiveType = this.getMappedValueBaseType(fieldType); + return effectiveType === 'Boolean' ? (lit as boolean) - : ['Int', 'Float', 'Decimal', 'BigInt'].includes(fieldType.type!) + : ['Int', 'Float', 'Decimal', 'BigInt'].includes(effectiveType!) ? Number(lit) : lit; } else if (isArrayExpr(expr)) { @@ -843,6 +847,19 @@ export class TsSchemaGenerator { } } + /** + * The primitive type used to map a literal default value to its JS representation. For a + * primitive typedef reference (e.g. `type Age with Int`), this resolves to the base type so + * that e.g. an `Int`-based typedef default is emitted as a number instead of a string. + */ + private getMappedValueBaseType(fieldType: DataFieldType): string | undefined { + if (fieldType.type) { + return fieldType.type; + } + const ref = fieldType.reference?.ref; + return ref && isPrimitiveTypeDef(ref) ? ref.base : undefined; + } + private getMemberAccessChain(expr: MemberAccessExpr): string[] { if (!isMemberAccessExpr(expr.operand)) { return [expr.member.$refText]; From 1dea6c7a9e830cacd7af31b7af22a05799cd05bd Mon Sep 17 00:00:00 2001 From: sanny-io Date: Tue, 13 Oct 2026 13:20:31 +0000 Subject: [PATCH 31/38] chore: regenerate schemas --- tests/e2e/orm/schemas/custom-type-primitive/schema.ts | 2 +- tests/e2e/orm/schemas/procedures/schema.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/orm/schemas/custom-type-primitive/schema.ts b/tests/e2e/orm/schemas/custom-type-primitive/schema.ts index d7245929e..50e423149 100644 --- a/tests/e2e/orm/schemas/custom-type-primitive/schema.ts +++ b/tests/e2e/orm/schemas/custom-type-primitive/schema.ts @@ -37,7 +37,7 @@ export class SchemaType implements SchemaDef { 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 + default: 18 as FieldDefault } }, idFields: ["id"], diff --git a/tests/e2e/orm/schemas/procedures/schema.ts b/tests/e2e/orm/schemas/procedures/schema.ts index 6b9fd7b29..2e9e28a45 100644 --- a/tests/e2e/orm/schemas/procedures/schema.ts +++ b/tests/e2e/orm/schemas/procedures/schema.ts @@ -44,7 +44,7 @@ export class SchemaType implements SchemaDef { 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 + default: 18 as FieldDefault } }, idFields: ["id"], From 11114df18921badc23e0b797ef8e9aeccae8460a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 00:58:35 +0000 Subject: [PATCH 32/38] fix(zod): apply list field attributes to the array instead of its items For list fields, field-level attributes such as @length now validate the array itself, while a typedef's `this` attributes validate each item. Previously the field attributes were passed to the item schema, so `tags Tag[] @length(2, 3)` checked each string's length instead of the number of items. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MSCjoH6qWta2ebMHCwmZjZ --- packages/zod/src/factory.ts | 13 +++-- packages/zod/test/list-validation.test.ts | 60 +++++++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 packages/zod/test/list-validation.test.ts diff --git a/packages/zod/src/factory.ts b/packages/zod/src/factory.ts index 862028a48..0bf294edd 100644 --- a/packages/zod/src/factory.ts +++ b/packages/zod/src/factory.ts @@ -25,6 +25,7 @@ import { addBigIntValidation, addCustomValidation, addDecimalValidation, + addListValidation, addNumberValidation, addStringValidation, } from './utils'; @@ -408,7 +409,11 @@ class SchemaFactory { } private makeScalarFieldSchema(def: FieldDef): z.ZodType { - const { type, attributes } = def; + const { type } = def; + // for list fields, field-level attributes (e.g. `@length`) validate the list itself + // rather than its items + const itemAttributes = def.array ? undefined : def.attributes; + // enum const enumDef = this.schema.getEnum(type); if (enumDef) { @@ -418,10 +423,10 @@ class SchemaFactory { // typedef const typedefDef = this.schema.getTypeDef(type); if (typedefDef) { - return this.applyCardinality(this.makeTypeSchema(type as GetTypeDefs, def.attributes), def); + return this.applyCardinality(this.makeTypeSchema(type as GetTypeDefs, itemAttributes), def); } - return this.applyCardinality(this.makeScalarSchema(type as BuiltinType, attributes), def); + return this.applyCardinality(this.makeScalarSchema(type as BuiltinType, itemAttributes), def); } private makeJsonSchema(): z.ZodType { @@ -453,7 +458,7 @@ class SchemaFactory { private applyCardinality(schema: z.ZodType, fieldDef: FieldDef): z.ZodType { let result = schema; if (fieldDef.array) { - result = result.array(); + result = addListValidation(result.array(), fieldDef.attributes); } if (fieldDef.optional) { result = result.nullable().optional(); diff --git a/packages/zod/test/list-validation.test.ts b/packages/zod/test/list-validation.test.ts new file mode 100644 index 000000000..81bbfe076 --- /dev/null +++ b/packages/zod/test/list-validation.test.ts @@ -0,0 +1,60 @@ +import { ExpressionUtils, type AttributeApplication, type SchemaDef } from '@zenstackhq/schema'; +import { describe, expect, it } from 'vitest'; +import { createSchemaFactory } from '../src'; + +function length(min: number, max: number): AttributeApplication { + return { + name: '@length', + args: [ + { name: 'min', value: ExpressionUtils.literal(min) }, + { name: 'max', value: ExpressionUtils.literal(max) }, + ], + }; +} + +const schema = { + provider: { type: 'postgresql' }, + models: { + Post: { + name: 'Post', + fields: { + id: { name: 'id', type: 'Int', id: true, attributes: [{ name: '@id' }] }, + tags: { name: 'tags', type: 'Tag', array: true, attributes: [length(2, 3)] }, + labels: { name: 'labels', type: 'String', array: true, optional: true, attributes: [length(1, 2)] }, + }, + idFields: ['id'], + uniqueFields: { id: { type: 'Int' } }, + }, + }, + typeDefs: { + Tag: { + name: 'Tag', + base: 'String', + fields: { + this: { name: 'this', type: 'String', attributes: [length(1, 10)] }, + }, + }, + }, + plugins: {}, +} as const satisfies SchemaDef; + +describe('list field validation', () => { + const postSchema = createSchemaFactory(schema).makeModelSchema('Post'); + + it('applies field attributes to the list and typedef attributes to each item', () => { + expect(postSchema.safeParse({ id: 1, tags: ['a', 'b'] }).success).toBe(true); + expect(postSchema.safeParse({ id: 1, tags: ['a', 'b', 'c'] }).success).toBe(true); + expect(postSchema.safeParse({ id: 1, tags: ['ab'] }).success).toBe(false); + expect(postSchema.safeParse({ id: 1, tags: ['a', 'b', 'c', 'd'] }).success).toBe(false); + expect(postSchema.safeParse({ id: 1, tags: ['a', ''] }).success).toBe(false); + expect(postSchema.safeParse({ id: 1, tags: ['a', 'abcdefghijk'] }).success).toBe(false); + }); + + it('applies field attributes to the list for scalar list fields', () => { + expect(postSchema.safeParse({ id: 1, tags: ['a', 'b'], labels: ['abc'] }).success).toBe(true); + expect(postSchema.safeParse({ id: 1, tags: ['a', 'b'], labels: ['', 'abcdef'] }).success).toBe(true); + expect(postSchema.safeParse({ id: 1, tags: ['a', 'b'], labels: [] }).success).toBe(false); + expect(postSchema.safeParse({ id: 1, tags: ['a', 'b'], labels: ['a', 'b', 'c'] }).success).toBe(false); + expect(postSchema.safeParse({ id: 1, tags: ['a', 'b'], labels: null }).success).toBe(true); + }); +}); From 5b4e2c4c2330f53a1199f9c3d6c9e642c28feb01 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 04:16:48 +0000 Subject: [PATCH 33/38] fix(orm,zod): enforce both typedef and field attributes on primitive typedef fields Attributes declared on a field whose type is a primitive typedef (e.g. `email Email @length(1, 5)`) are now validated together with the typedef's own `this` attributes. The ORM previously ignored the field's attributes, and the zod factory replaced the typedef's attributes with them. For typedef list fields, the field's attributes still validate the list rather than its items. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MSCjoH6qWta2ebMHCwmZjZ --- packages/orm/src/client/zod/factory.ts | 25 +++++++++++++++---- packages/zod/src/factory.ts | 6 ++++- ...ion.test.ts => typedef-validation.test.ts} | 18 ++++++++++++- 3 files changed, 42 insertions(+), 7 deletions(-) rename packages/zod/test/{list-validation.test.ts => typedef-validation.test.ts} (74%) diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index 346a2d111..53014d4b1 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -368,7 +368,10 @@ export class ZodSchemaFactory< private makeScalarSchema(type: string, attributes?: readonly AttributeApplication[]) { if (this.schema.typeDefs && type in this.schema.typeDefs) { - return this.makeTypeDefSchema(type); + // attributes declared on the consuming field only apply to primitive typedefs + return this.schema.typeDefs[type]!.base + ? this.makeTypeDefSchema(type, attributes) + : this.makeTypeDefSchema(type); } else if (this.schema.enums && type in this.schema.enums) { return this.makeEnumSchema(type); } else { @@ -459,12 +462,24 @@ export class ZodSchemaFactory< return schema; } + /** + * Attributes to validate each value of a scalar field against. For typedef list fields, the + * field's own attributes (e.g. `@length`) validate the list rather than its items. + */ + private getItemAttributes(fieldDef: FieldDef) { + return fieldDef.array && isTypeDef(this.schema, fieldDef.type) ? undefined : fieldDef.attributes; + } + @cache() - private makeTypeDefSchema(type: string): ZodType { + private makeTypeDefSchema(type: string, fieldAttributes?: readonly AttributeApplication[]): ZodType { const typeDef = getTypeDef(this.schema, type); invariant(typeDef, `Type definition "${type}" not found in schema`); if (typeDef.base) { - const schema = this.makeScalarSchema(typeDef.base, typeDef.fields['this']?.attributes); + // validate against both the typedef's own attributes and those of the consuming field + const schema = this.makeScalarSchema(typeDef.base, [ + ...(typeDef.fields['this']?.attributes ?? []), + ...(fieldAttributes ?? []), + ]); return this.extraValidationsEnabled ? ZodUtils.addCustomValidation(schema, typeDef.attributes) : schema; } const func = typeDef.strict ? z.strictObject : z.looseObject; @@ -1688,7 +1703,7 @@ export class ZodSchemaFactory< uncheckedVariantFields[field] = fieldSchema; } } else { - let fieldSchema = this.makeScalarSchema(fieldDef.type, fieldDef.attributes); + let fieldSchema = this.makeScalarSchema(fieldDef.type, this.getItemAttributes(fieldDef)); if (fieldDef.array) { fieldSchema = ZodUtils.addListValidation(fieldSchema.array(), fieldDef.attributes); @@ -2046,7 +2061,7 @@ export class ZodSchemaFactory< uncheckedVariantFields[field] = fieldSchema; } } else { - let fieldSchema = this.makeScalarSchema(fieldDef.type, fieldDef.attributes); + let fieldSchema = this.makeScalarSchema(fieldDef.type, this.getItemAttributes(fieldDef)); if (this.isNumericField(fieldDef)) { fieldSchema = z.union([ diff --git a/packages/zod/src/factory.ts b/packages/zod/src/factory.ts index 0bf294edd..6d768e1ca 100644 --- a/packages/zod/src/factory.ts +++ b/packages/zod/src/factory.ts @@ -473,7 +473,11 @@ class SchemaFactory { const typeDef = this.schema.requireTypeDef(type); if (typeDef.base) { return addCustomValidation( - this.makeScalarSchema(typeDef.base, attributes ?? typeDef.fields['this']?.attributes), + // validate against both the typedef's own attributes and those of the consuming field + this.makeScalarSchema(typeDef.base, [ + ...(typeDef.fields['this']?.attributes ?? []), + ...(attributes ?? []), + ]), typeDef.attributes, ) as unknown as MapTypeDefToZod; } diff --git a/packages/zod/test/list-validation.test.ts b/packages/zod/test/typedef-validation.test.ts similarity index 74% rename from packages/zod/test/list-validation.test.ts rename to packages/zod/test/typedef-validation.test.ts index 81bbfe076..340091bc9 100644 --- a/packages/zod/test/list-validation.test.ts +++ b/packages/zod/test/typedef-validation.test.ts @@ -21,6 +21,7 @@ const schema = { id: { name: 'id', type: 'Int', id: true, attributes: [{ name: '@id' }] }, tags: { name: 'tags', type: 'Tag', array: true, attributes: [length(2, 3)] }, labels: { name: 'labels', type: 'String', array: true, optional: true, attributes: [length(1, 2)] }, + email: { name: 'email', type: 'Email', optional: true, attributes: [length(1, 7)] }, }, idFields: ['id'], uniqueFields: { id: { type: 'Int' } }, @@ -34,11 +35,18 @@ const schema = { this: { name: 'this', type: 'String', attributes: [length(1, 10)] }, }, }, + Email: { + name: 'Email', + base: 'String', + fields: { + this: { name: 'this', type: 'String', attributes: [{ name: '@email' }] }, + }, + }, }, plugins: {}, } as const satisfies SchemaDef; -describe('list field validation', () => { +describe('primitive typedef field validation', () => { const postSchema = createSchemaFactory(schema).makeModelSchema('Post'); it('applies field attributes to the list and typedef attributes to each item', () => { @@ -57,4 +65,12 @@ describe('list field validation', () => { expect(postSchema.safeParse({ id: 1, tags: ['a', 'b'], labels: ['a', 'b', 'c'] }).success).toBe(false); expect(postSchema.safeParse({ id: 1, tags: ['a', 'b'], labels: null }).success).toBe(true); }); + + it('applies both typedef and field attributes to a non-list typedef field', () => { + expect(postSchema.safeParse({ id: 1, tags: ['a', 'b'], email: 'a@b.com' }).success).toBe(true); + // too long: rejected by the field's @length + expect(postSchema.safeParse({ id: 1, tags: ['a', 'b'], email: 'ab@cd.com' }).success).toBe(false); + // not an email: rejected by the typedef's @email + expect(postSchema.safeParse({ id: 1, tags: ['a', 'b'], email: 'abc' }).success).toBe(false); + }); }); From 6b10725348033c77710e540277a0a68aa8154daa Mon Sep 17 00:00:00 2001 From: sanny-io Date: Sat, 17 Oct 2026 08:45:27 +0000 Subject: [PATCH 34/38] chore(language): cleanup --- packages/language/src/utils.ts | 2 +- .../attribute-application-validator.ts | 5 +---- .../src/validators/datamodel-validator.ts | 18 +++++++----------- packages/language/src/zmodel-linker.ts | 2 +- .../test/custom-type-primitive.test.ts | 14 ++++++++++++++ 5 files changed, 24 insertions(+), 17 deletions(-) diff --git a/packages/language/src/utils.ts b/packages/language/src/utils.ts index 42729a076..256e3c637 100644 --- a/packages/language/src/utils.ts +++ b/packages/language/src/utils.ts @@ -746,7 +746,7 @@ export function getAllFields( export function getAllFieldAttributes(field: DataField) { const attributes: DataFieldAttribute[] = [...field.attributes]; - if (isTypeDef(field.type?.reference?.ref) && isPrimitiveTypeDef(field.type.reference.ref)) { + if (field.type?.reference?.ref && isPrimitiveTypeDef(field.type.reference.ref)) { const thisField = getPrimitiveTypeDefThisField(field.type.reference.ref); attributes.push(...(thisField?.attributes ?? [])); } diff --git a/packages/language/src/validators/attribute-application-validator.ts b/packages/language/src/validators/attribute-application-validator.ts index f3090c38f..0472b8f9c 100644 --- a/packages/language/src/validators/attribute-application-validator.ts +++ b/packages/language/src/validators/attribute-application-validator.ts @@ -678,10 +678,7 @@ function assignableToAttributeParam( // attribute parameter type is ContextType, need to infer type from // the attribute's container if (isDataField(attr.$container)) { - if ( - isTypeDef(attr.$container?.type?.reference?.ref) && - isPrimitiveTypeDef(attr.$container.type.reference.ref) - ) { + if (attr.$container.type.reference?.ref && isPrimitiveTypeDef(attr.$container.type.reference.ref)) { dstType = mapBuiltinTypeToExpressionType(attr.$container.type.reference.ref.base!); } else { if (!attr.$container?.type?.type) { diff --git a/packages/language/src/validators/datamodel-validator.ts b/packages/language/src/validators/datamodel-validator.ts index ed56db385..4927360ea 100644 --- a/packages/language/src/validators/datamodel-validator.ts +++ b/packages/language/src/validators/datamodel-validator.ts @@ -43,22 +43,12 @@ export default class DataModelValidator implements AstValidator { this.validateAttributes(dm, accept); this.validateFields(dm, accept); this.validateOnceInModelAttributes(dm, accept); - if (dm.mixins.length > 0) { + if (dm.mixins.length > 0 || dm.base) { this.validateMixins(dm, accept); } this.validateInherits(dm, accept); this.validateDelegateMap(dm, accept); - this.validateInheritance(dm, accept); } - - private validateInheritance(dm: DataModel, accept: ValidationAcceptor) { - if (dm.base) { - accept('error', `model "${dm.name}" cannot use a primitive type as mix-in`, { - node: dm, - }); - } - } - private validateFields(dm: DataModel, accept: ValidationAcceptor) { const allFields = getAllFields(dm); const idFields = allFields.filter((f) => f.attributes.find((attr) => attr.decl.ref?.name === '@id')); @@ -538,6 +528,12 @@ export default class DataModelValidator implements AstValidator { } private validateMixins(dm: DataModel, accept: ValidationAcceptor) { + if (dm.base) { + accept('error', `model "${dm.name}" cannot use a primitive type as a mixin`, { + node: dm, + }); + return; + } const seen: TypeDef[] = []; const todo: TypeDef[] = dm.mixins.map((mixin) => mixin.ref!); while (todo.length > 0) { diff --git a/packages/language/src/zmodel-linker.ts b/packages/language/src/zmodel-linker.ts index 4ec0da9e7..09a18055b 100644 --- a/packages/language/src/zmodel-linker.ts +++ b/packages/language/src/zmodel-linker.ts @@ -562,7 +562,7 @@ export class ZModelLinker extends DefaultLinker { array: type.array, nullable: nullable, }; - } else if (isTypeDef(type.reference?.ref) && isPrimitiveTypeDef(type.reference.ref)) { + } else if (type.reference?.ref && isPrimitiveTypeDef(type.reference.ref)) { node.$resolvedType = { decl: type.reference.ref.base, array: type.array, diff --git a/packages/language/test/custom-type-primitive.test.ts b/packages/language/test/custom-type-primitive.test.ts index 960011ebe..d0c0579e5 100644 --- a/packages/language/test/custom-type-primitive.test.ts +++ b/packages/language/test/custom-type-primitive.test.ts @@ -367,5 +367,19 @@ describe('Custom type primitive tests', () => { `, 'cannot use primitive type def "Email" as a mixin', ); + + await loadSchemaWithError( + ` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + model User with String { + id String @id + } + `, + 'model "User" cannot use a primitive type as a mixin', + ); }); }); From 257de9f62599b2fb68793da2ab778750e4b7da27 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Sat, 17 Oct 2026 09:05:58 +0000 Subject: [PATCH 35/38] chore(zod): cleanup --- packages/zod/test/factory.test.ts | 28 +++++++- packages/zod/test/typedef-validation.test.ts | 76 -------------------- 2 files changed, 26 insertions(+), 78 deletions(-) delete mode 100644 packages/zod/test/typedef-validation.test.ts diff --git a/packages/zod/test/factory.test.ts b/packages/zod/test/factory.test.ts index 7994fda5e..88f005d5b 100644 --- a/packages/zod/test/factory.test.ts +++ b/packages/zod/test/factory.test.ts @@ -1686,9 +1686,9 @@ 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'] }); + const result = postSchema.safeParse({ ...validPost, tags: ['LOWERCASED', 'Other'] }); expect(result.success).toBe(true); - expect(result.data?.tags).toMatchObject(['lowercased']); + expect(result.data?.tags).toMatchObject(['lowercased', 'other']); }); it('supports model validation using `@@validate`', () => { @@ -1743,5 +1743,29 @@ describe.each([ result = contactSchema.safeParse(-1.0); expect(result.success).toBe(false); }); + + describe('lists', () => { + it('applies field attributes to the list', () => { + const postSchema = factory.makeModelSchema('Post'); + let result = postSchema.safeParse({ ...validPost, tags: ['a', 'b'] }); + expect(result.success).toBe(true); + + result = postSchema.safeParse({ ...validPost, tags: ['a', 'b', 'c'] }); + expect(result.success).toBe(true); + + result = postSchema.safeParse({ ...validPost, tags: ['a'] }); + expect(result.success).toBe(false); + + result = postSchema.safeParse({ ...validPost, tags: ['a', 'b', 'c', 'd'] }); + expect(result.success).toBe(false); + }); + + it('applies typedef attributes to each item', () => { + const postSchema = factory.makeModelSchema('Post'); + const result = postSchema.safeParse({ ...validPost, tags: ['A', 'B'] }); + expect(result.success).toBe(true); + expect(result.data?.tags).toEqual(['a', 'b']); + }); + }); }); }); diff --git a/packages/zod/test/typedef-validation.test.ts b/packages/zod/test/typedef-validation.test.ts deleted file mode 100644 index 340091bc9..000000000 --- a/packages/zod/test/typedef-validation.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { ExpressionUtils, type AttributeApplication, type SchemaDef } from '@zenstackhq/schema'; -import { describe, expect, it } from 'vitest'; -import { createSchemaFactory } from '../src'; - -function length(min: number, max: number): AttributeApplication { - return { - name: '@length', - args: [ - { name: 'min', value: ExpressionUtils.literal(min) }, - { name: 'max', value: ExpressionUtils.literal(max) }, - ], - }; -} - -const schema = { - provider: { type: 'postgresql' }, - models: { - Post: { - name: 'Post', - fields: { - id: { name: 'id', type: 'Int', id: true, attributes: [{ name: '@id' }] }, - tags: { name: 'tags', type: 'Tag', array: true, attributes: [length(2, 3)] }, - labels: { name: 'labels', type: 'String', array: true, optional: true, attributes: [length(1, 2)] }, - email: { name: 'email', type: 'Email', optional: true, attributes: [length(1, 7)] }, - }, - idFields: ['id'], - uniqueFields: { id: { type: 'Int' } }, - }, - }, - typeDefs: { - Tag: { - name: 'Tag', - base: 'String', - fields: { - this: { name: 'this', type: 'String', attributes: [length(1, 10)] }, - }, - }, - Email: { - name: 'Email', - base: 'String', - fields: { - this: { name: 'this', type: 'String', attributes: [{ name: '@email' }] }, - }, - }, - }, - plugins: {}, -} as const satisfies SchemaDef; - -describe('primitive typedef field validation', () => { - const postSchema = createSchemaFactory(schema).makeModelSchema('Post'); - - it('applies field attributes to the list and typedef attributes to each item', () => { - expect(postSchema.safeParse({ id: 1, tags: ['a', 'b'] }).success).toBe(true); - expect(postSchema.safeParse({ id: 1, tags: ['a', 'b', 'c'] }).success).toBe(true); - expect(postSchema.safeParse({ id: 1, tags: ['ab'] }).success).toBe(false); - expect(postSchema.safeParse({ id: 1, tags: ['a', 'b', 'c', 'd'] }).success).toBe(false); - expect(postSchema.safeParse({ id: 1, tags: ['a', ''] }).success).toBe(false); - expect(postSchema.safeParse({ id: 1, tags: ['a', 'abcdefghijk'] }).success).toBe(false); - }); - - it('applies field attributes to the list for scalar list fields', () => { - expect(postSchema.safeParse({ id: 1, tags: ['a', 'b'], labels: ['abc'] }).success).toBe(true); - expect(postSchema.safeParse({ id: 1, tags: ['a', 'b'], labels: ['', 'abcdef'] }).success).toBe(true); - expect(postSchema.safeParse({ id: 1, tags: ['a', 'b'], labels: [] }).success).toBe(false); - expect(postSchema.safeParse({ id: 1, tags: ['a', 'b'], labels: ['a', 'b', 'c'] }).success).toBe(false); - expect(postSchema.safeParse({ id: 1, tags: ['a', 'b'], labels: null }).success).toBe(true); - }); - - it('applies both typedef and field attributes to a non-list typedef field', () => { - expect(postSchema.safeParse({ id: 1, tags: ['a', 'b'], email: 'a@b.com' }).success).toBe(true); - // too long: rejected by the field's @length - expect(postSchema.safeParse({ id: 1, tags: ['a', 'b'], email: 'ab@cd.com' }).success).toBe(false); - // not an email: rejected by the typedef's @email - expect(postSchema.safeParse({ id: 1, tags: ['a', 'b'], email: 'abc' }).success).toBe(false); - }); -}); From b065838762b83ec739bc0e4db3128c022446aab3 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Sat, 17 Oct 2026 09:06:07 +0000 Subject: [PATCH 36/38] chore(zod): regenerate schemas --- packages/zod/test/schema/schema-lite.ts | 2 +- packages/zod/test/schema/schema.ts | 2 +- packages/zod/test/schema/schema.zmodel | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/zod/test/schema/schema-lite.ts b/packages/zod/test/schema/schema-lite.ts index 204cdbece..dcf208724 100644 --- a/packages/zod/test/schema/schema-lite.ts +++ b/packages/zod/test/schema/schema-lite.ts @@ -155,7 +155,7 @@ export class SchemaType implements SchemaDef { name: "tags", type: "PostTag", array: true, - attributes: [{ name: "@lower" }] as readonly AttributeApplication[] + attributes: [{ name: "@length", args: [{ name: "min", value: ExpressionUtils.literal(2) }, { name: "max", value: ExpressionUtils.literal(3) }] }, { name: "@lower" }] as readonly AttributeApplication[] }, author: { name: "author", diff --git a/packages/zod/test/schema/schema.ts b/packages/zod/test/schema/schema.ts index 271df730b..a5843c3dd 100644 --- a/packages/zod/test/schema/schema.ts +++ b/packages/zod/test/schema/schema.ts @@ -156,7 +156,7 @@ export class SchemaType implements SchemaDef { name: "tags", type: "PostTag", array: true, - attributes: [{ name: "@lower" }] as readonly AttributeApplication[] + attributes: [{ name: "@length", args: [{ name: "min", value: ExpressionUtils.literal(2) }, { name: "max", value: ExpressionUtils.literal(3) }] }, { name: "@lower" }] as readonly AttributeApplication[] }, author: { name: "author", diff --git a/packages/zod/test/schema/schema.zmodel b/packages/zod/test/schema/schema.zmodel index 2c8dc4da9..75bf17a1a 100644 --- a/packages/zod/test/schema/schema.zmodel +++ b/packages/zod/test/schema/schema.zmodel @@ -75,7 +75,7 @@ model Post { id String @id @default(cuid()) title String published Boolean - tags PostTag[] + tags PostTag[] @length(2, 3) author User? @relation(fields: [authorId], references: [id]) authorId String? } From 48f943b270df7fa59ac969eb7e99d8459a3ed533 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Sat, 17 Oct 2026 09:15:55 +0000 Subject: [PATCH 37/38] fix(language): remove unused import --- packages/language/src/zmodel-linker.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/language/src/zmodel-linker.ts b/packages/language/src/zmodel-linker.ts index 09a18055b..782496112 100644 --- a/packages/language/src/zmodel-linker.ts +++ b/packages/language/src/zmodel-linker.ts @@ -51,7 +51,6 @@ import { isNumberLiteral, isReferenceExpr, isStringLiteral, - isTypeDef, } from './ast'; import { getAllFields, From c6173002d3aebd8cf47c28bbfe4e23fe96a9f74f Mon Sep 17 00:00:00 2001 From: sanny-io Date: Sat, 17 Oct 2026 10:23:41 +0000 Subject: [PATCH 38/38] chore: delete findings file --- .../custom-type-primitive-findings.test.ts | 272 ------------------ 1 file changed, 272 deletions(-) delete mode 100644 tests/e2e/orm/client-api/custom-type-primitive-findings.test.ts diff --git a/tests/e2e/orm/client-api/custom-type-primitive-findings.test.ts b/tests/e2e/orm/client-api/custom-type-primitive-findings.test.ts deleted file mode 100644 index 76f0d6b9f..000000000 --- a/tests/e2e/orm/client-api/custom-type-primitive-findings.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -import type { ClientContract } from '@zenstackhq/orm'; -import { createTestClient } from '@zenstackhq/testtools'; -import { afterEach, describe, expect, it } from 'vitest'; -import { schema } from '../schemas/custom-type-primitive/schema'; - -/** - * Counter test cases for review findings on primitive custom types (PR #2852). - * Each test is expected to FAIL on the current PR head and pass once the - * corresponding finding is fixed. - */ - -const BASE_SCHEMA = ` -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) -} -`; - -// SQLite doesn't support list fields, so drop `contacts` for SQLite cases -const SQLITE_SCHEMA = BASE_SCHEMA.replace(' contacts Contact[]\n', ''); - -describe('Custom type primitive review findings', () => { - let client: any; - - afterEach(async () => { - await client?.$disconnect(); - }); - - // Finding 1: zod/factory.ts makeWhereSchema routes primitive typedef fields to the - // typed-JSON filter schema, so plain scalar filters are rejected at validation time. - describe('finding 1: where-input uses scalar filters for primitive typedef fields', () => { - it('accepts plain value, string and numeric filters', async () => { - client = await createTestClient(BASE_SCHEMA, { provider: 'postgresql' }); - await client.user.create({ data: { name: 'test', age: 20 } }); - - await expect(client.user.findMany({ where: { name: 'test' } })).resolves.toHaveLength(1); - await expect(client.user.findMany({ where: { name: { contains: 'es' } } })).resolves.toHaveLength(1); - await expect(client.user.findMany({ where: { name: { startsWith: 'te' } } })).resolves.toHaveLength(1); - await expect(client.user.findMany({ where: { age: { gte: 18 } } })).resolves.toHaveLength(1); - await expect(client.user.findMany({ where: { age: { lt: 18 } } })).resolves.toHaveLength(0); - }); - }); - - // Finding 2: sqlite.ts transformInput/transformOutput treat every typedef name as JSON, - // so primitive typedef values are stored JSON-quoted on SQLite. - describe('finding 2: SQLite stores primitive typedef values as raw scalars', () => { - it('stores and reads back scalars without JSON encoding', async () => { - client = await createTestClient(SQLITE_SCHEMA, { provider: 'sqlite' }); - const user = await client.user.create({ data: { name: 'test', age: 20 } }); - expect(user.name).toBe('test'); - expect(user.age).toBe(20); - - // raw row must hold the bare scalar, not a JSON string - const raw = await client.$qb - .selectFrom('User') - .select(['name', 'age']) - .where('id', '=', user.id) - .executeTakeFirstOrThrow(); - expect(raw.name).toBe('test'); - expect(raw.age).toBe(20); - - await expect(client.user.findMany({ where: { name: { equals: 'test' } } })).resolves.toHaveLength(1); - await expect(client.user.aggregate({ _max: { age: true } })).resolves.toMatchObject({ - _max: { age: 20 }, - }); - }); - }); - - // Finding 3: ts-schema-generator getMappedValue keys numeric conversion off fieldType.type, - // which is undefined for typedef references, so @default(18) is emitted as "18". - describe('finding 3: numeric defaults on primitive typedef fields are emitted as numbers', () => { - it('emits a numeric default in the generated schema', () => { - expect(schema.models.User.fields.age.default).toBe(18); - expect(typeof schema.models.User.fields.age.default).toBe('number'); - }); - - it('applies the default as a number on SQLite', async () => { - client = await createTestClient(SQLITE_SCHEMA, { provider: 'sqlite' }); - const user = await client.user.create({ data: { name: 'test' } }); - expect(user.age).toBe(18); - expect(typeof user.age).toBe('number'); - }); - }); - - // Finding 4: zod/factory.ts isNumericField compares fieldDef.type against the builtin - // numeric type names, so Int-based typedef fields get no atomic update operators. - describe('finding 4: numeric primitive typedef fields support atomic update ops', () => { - it('accepts increment/decrement/multiply', async () => { - client = await createTestClient(BASE_SCHEMA, { provider: 'postgresql' }); - const user = await client.user.create({ data: { name: 'test', age: 20 } }); - - await expect( - client.user.update({ where: { id: user.id }, data: { age: { increment: 1 } } }), - ).resolves.toMatchObject({ age: 21 }); - await expect( - client.user.update({ where: { id: user.id }, data: { age: { decrement: 1 } } }), - ).resolves.toMatchObject({ age: 20 }); - await expect(client.user.updateMany({ data: { age: { multiply: 2 } } })).resolves.toMatchObject({ - count: 1, - }); - await expect(client.user.findUnique({ where: { id: user.id } })).resolves.toMatchObject({ age: 40 }); - }); - - it('supports numeric aggregation', async () => { - client = await createTestClient(BASE_SCHEMA, { provider: 'postgresql' }); - await client.user.create({ data: { name: 'test', age: 20 } }); - await client.user.create({ data: { name: 'test', age: 30 } }); - await expect(client.user.aggregate({ _sum: { age: true }, _avg: { age: true } })).resolves.toMatchObject({ - _sum: { age: 50 }, - _avg: { age: 25 }, - }); - }); - }); - - // Finding 5: zod/factory.ts makeScalarSchema discards the `attributes` argument when the - // type is a typedef, so attributes declared on the consuming model field are ignored. - describe('finding 5: ORM applies attributes declared on the consuming field', () => { - it('enforces field-level @length on a primitive typedef field', async () => { - client = await createTestClient( - ` -model User { - id String @id @default(nanoid()) - email Email @length(1, 5) -} - -type Email with String { - this String -} -`, - { provider: 'postgresql' }, - ); - - await expect(client.user.create({ data: { email: 'ab' } })).resolves.toMatchObject({ email: 'ab' }); - await expect(client.user.create({ data: { email: 'abcdefgh' } })).toBeRejectedByValidation(); - }); - - it('enforces both typedef-level and field-level attributes', async () => { - client = await createTestClient( - ` -model User { - id String @id @default(nanoid()) - email Email @length(1, 5) -} - -type Email with String { - this String @email -} -`, - { provider: 'postgresql' }, - ); - - // too long: rejected by field-level @length - await expect(client.user.create({ data: { email: 'a@b.com' } })).toBeRejectedByValidation(); - // not an email: rejected by typedef-level @email - await expect(client.user.create({ data: { email: 'abc' } })).toBeRejectedByValidation(); - }); - }); - - // Finding 6: crud-types.ts WhereInput maps any field whose type is a typedef to - // TypedJsonFilter, so scalar filters on primitive typedef fields are TypeScript errors. - // Verified by `pnpm test:typecheck` (tsc --noEmit) in tests/e2e; vitest's typecheck only - // covers *.test-d.ts files. On the current head tsc reports TS2353 on the three scalar - // filters below and an unused @ts-expect-error on the JSON-shaped one. - describe('finding 6: WhereInput types primitive typedef fields as scalar filters', () => { - it('type-checks string and numeric filters without casts', async () => { - const typed: ClientContract = await createTestClient(schema, { - provider: 'postgresql', - }); - client = typed; - await typed.user.create({ data: { name: 'test', age: 20 } }); - - // these must compile without `as any` - await expect(typed.user.findMany({ where: { name: { contains: 'es' } } })).resolves.toHaveLength(1); - await expect(typed.user.findMany({ where: { name: { startsWith: 'te' } } })).resolves.toHaveLength(1); - await expect(typed.user.findMany({ where: { age: { gte: 18 } } })).resolves.toHaveLength(1); - await expect(typed.user.findMany({ where: { contacts: { has: '+15555555555' } } })).resolves.toHaveLength( - 0, - ); - - // and JSON-filter shapes must be rejected at the type level - await expect( - // @ts-expect-error `path` is a JSON filter key, not valid for a String-based typedef - typed.user.findMany({ where: { name: { path: '$.foo' } } }), - ).rejects.toThrow(); - }); - }); - - // Finding 7: base-dialect.ts buildTypedJsonFilter and zod/factory.ts - // makeTypedJsonFilterSchema recurse into a primitive typedef used as a field of a JSON - // typedef as if it had sub-fields, so filtering on nested primitive typedef fields breaks. - describe('finding 7: primitive typedef nested in a JSON typedef is filterable', () => { - it('filters on a nested primitive typedef field', async () => { - client = await createTestClient( - ` -model User { - id String @id @default(nanoid()) - profile Profile @json -} - -type Profile { - email Email - age Age -} - -type Email with String { - this String @email -} - -type Age with Int { - this Int @gte(18) -} -`, - { provider: 'postgresql' }, - ); - - await client.user.create({ data: { profile: { email: 'alice@example.com', age: 25 } } }); - - await expect( - client.user.findMany({ where: { profile: { email: 'alice@example.com' } } }), - ).resolves.toHaveLength(1); - await expect( - client.user.findMany({ where: { profile: { email: { contains: 'alice' } } } }), - ).resolves.toHaveLength(1); - await expect(client.user.findMany({ where: { profile: { age: { gte: 18 } } } })).resolves.toHaveLength(1); - await expect(client.user.findMany({ where: { profile: { age: { lt: 18 } } } })).resolves.toHaveLength(0); - }); - }); - - // Finding 8: postgresql.ts getSqlType falls through to 'text' for a non-String primitive - // typedef name, so array filters on an Int-based typedef array cast to text[] against an - // integer[] column. - describe('finding 8: PostgreSQL array filters cast to the typedef base type', () => { - it('filters an Int-based typedef array', async () => { - client = await createTestClient( - ` -model User { - id String @id @default(nanoid()) - ages Age[] -} - -type Age with Int { - this Int @gte(18) -} -`, - { provider: 'postgresql' }, - ); - - await client.user.create({ data: { ages: [18, 25] } }); - await client.user.create({ data: { ages: [30] } }); - - await expect(client.user.findMany({ where: { ages: { has: 18 } } })).resolves.toHaveLength(1); - await expect(client.user.findMany({ where: { ages: { hasSome: [18, 30] } } })).resolves.toHaveLength(2); - await expect(client.user.findMany({ where: { ages: { hasEvery: [18, 25] } } })).resolves.toHaveLength(1); - await expect(client.user.findMany({ where: { ages: { equals: [30] } } })).resolves.toHaveLength(1); - await expect(client.user.findMany({ where: { ages: { isEmpty: true } } })).resolves.toHaveLength(0); - }); - }); -});