From 968220cb04fbda106846d66b517f0eb9237bb329 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:40:18 -0700 Subject: [PATCH 1/2] fix(orm): exclude Unsupported fields from ORM client types and runtime Unsupported fields (e.g., Unsupported("xml")) are database-specific types that cannot be meaningfully read or written through the ORM. This change excludes them from TypeScript types, Zod validation schemas, and SQL queries. Models with required Unsupported fields (no default) have their create/upsert operations disabled since values cannot be provided. - Add FieldIsUnsupported and ModelHasRequiredUnsupportedField type utils - Exclude Unsupported fields from result, where, select, omit, orderBy, create, update, distinct, aggregate, and groupBy types - Skip Unsupported fields in all Zod schema generation - Skip Unsupported fields in SELECT queries and schema pusher - Disable create/upsert operations for models with required Unsupported fields (both at type level and runtime) - Add isUnsupportedField shared helper in query-utils - Add comprehensive type tests, Zod tests, and ORM call tests Co-Authored-By: Claude Opus 4.6 --- packages/orm/src/client/contract.ts | 13 +- .../src/client/crud/dialects/base-dialect.ts | 11 +- .../orm/src/client/crud/operations/base.ts | 17 +- .../orm/src/client/crud/operations/create.ts | 2 + .../orm/src/client/crud/operations/update.ts | 2 + .../orm/src/client/executor/name-mapper.ts | 19 +- .../src/client/helpers/schema-db-pusher.ts | 7 +- packages/orm/src/client/query-utils.ts | 14 +- packages/orm/src/client/type-utils.ts | 23 +- packages/orm/src/client/zod/factory.ts | 131 +++++---- packages/schema/src/schema.ts | 23 +- .../e2e/orm/client-api/unsupported.test-d.ts | 134 +++++++++ tests/e2e/orm/client-api/unsupported.test.ts | 264 ++++++++++++++++++ tests/e2e/orm/schemas/unsupported/input.ts | 73 +++++ tests/e2e/orm/schemas/unsupported/models.ts | 12 + tests/e2e/orm/schemas/unsupported/schema.ts | 92 ++++++ .../e2e/orm/schemas/unsupported/schema.zmodel | 25 ++ 17 files changed, 769 insertions(+), 93 deletions(-) create mode 100644 tests/e2e/orm/client-api/unsupported.test-d.ts create mode 100644 tests/e2e/orm/client-api/unsupported.test.ts create mode 100644 tests/e2e/orm/schemas/unsupported/input.ts create mode 100644 tests/e2e/orm/schemas/unsupported/models.ts create mode 100644 tests/e2e/orm/schemas/unsupported/schema.ts create mode 100644 tests/e2e/orm/schemas/unsupported/schema.zmodel diff --git a/packages/orm/src/client/contract.ts b/packages/orm/src/client/contract.ts index c6b772aa4..3fdc53c86 100644 --- a/packages/orm/src/client/contract.ts +++ b/packages/orm/src/client/contract.ts @@ -43,7 +43,12 @@ import type { ClientOptions, QueryOptions } from './options'; import type { ExtClientMembersBase, ExtQueryArgsBase, RuntimePlugin } from './plugin'; import type { ZenStackPromise } from './promise'; import type { ToKysely } from './query-builder'; -import type { GetSlicedModels, GetSlicedOperations, GetSlicedProcedures } from './type-utils'; +import type { + GetSlicedModels, + GetSlicedOperations, + GetSlicedProcedures, + ModelHasRequiredUnsupportedField, +} from './type-utils'; import type { ZodSchemaFactory } from './zod/factory'; type TransactionUnsupportedMethods = (typeof TRANSACTION_UNSUPPORTED_METHODS)[number]; @@ -285,7 +290,9 @@ type SliceOperations< [Key in keyof T as Key extends GetSlicedOperations ? Key : never]: T[Key]; }, // exclude operations not applicable to delegate models - IsDelegateModel extends true ? OperationsIneligibleForDelegateModels : never + | (IsDelegateModel extends true ? OperationsIneligibleForDelegateModels : never) + // exclude create operations for models with required Unsupported fields + | (ModelHasRequiredUnsupportedField extends true ? OperationsIneligibleForUnsupportedModels : never) >; export type AllModelOperations< @@ -882,6 +889,8 @@ type CommonModelOperations< export type OperationsIneligibleForDelegateModels = 'create' | 'createMany' | 'createManyAndReturn' | 'upsert'; +export type OperationsIneligibleForUnsupportedModels = 'create' | 'createMany' | 'createManyAndReturn' | 'upsert'; + export type ModelOperations< Schema extends SchemaDef, Model extends GetModels, diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 1f5102121..24081069b 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -29,6 +29,7 @@ import { isInheritedField, isRelationField, isTypeDef, + getModelFields, makeDefaultOrderBy, requireField, requireIdFields, @@ -1117,17 +1118,13 @@ export abstract class BaseCrudDialect { omit: Record | undefined | null, modelAlias: string, ) { - const modelDef = requireModel(this.schema, model); let result = query; - for (const field of Object.keys(modelDef.fields)) { - if (isRelationField(this.schema, model, field)) { - continue; - } - if (this.shouldOmitField(omit, model, field)) { + for (const fieldDef of getModelFields(this.schema, model, { inherited: true, computed: true })) { + if (this.shouldOmitField(omit, model, fieldDef.name)) { continue; } - result = this.buildSelectField(result, model, modelAlias, field); + result = this.buildSelectField(result, model, modelAlias, fieldDef.name); } // select all fields from delegate descendants and pack into a JSON field `$delegate$Model` diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index a12f0fe2a..85e36c9f6 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -37,6 +37,7 @@ import type { ToKysely } from '../../query-builder'; import { ensureArray, extractIdFields, + fieldHasDefaultValue, flattenCompoundUniqueFilters, getDiscriminatorField, getField, @@ -47,6 +48,7 @@ import { isForeignKeyField, isRelationField, isScalarField, + isUnsupportedField, requireField, requireIdFields, requireModel, @@ -1148,7 +1150,7 @@ export abstract class BaseOperationHandler { const parentWhere = await this.buildUpdateParentRelationFilter(kysely, fromRelation); - let combinedWhere: WhereInput, any, false> = where ?? {}; + let combinedWhere: Record = where ?? {}; if (Object.keys(parentWhere).length > 0) { combinedWhere = Object.keys(combinedWhere).length > 0 ? { AND: [parentWhere, combinedWhere] } : parentWhere; } @@ -1210,7 +1212,7 @@ export abstract class BaseOperationHandler { if (needIdRead) { const readResult = await this.readUnique(kysely, model, { - where: combinedWhere, + where: combinedWhere as WhereInput>, select: this.makeIdSelect(model), }); if (!readResult && throwIfNotFound) { @@ -2507,6 +2509,17 @@ export abstract class BaseOperationHandler { return newArgs; } + protected checkNoRequiredUnsupportedFields() { + const modelDef = requireModel(this.schema, this.model); + for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) { + if (isUnsupportedField(fieldDef) && !fieldDef.optional && !fieldHasDefaultValue(fieldDef)) { + throw createNotSupportedError( + `Model "${this.model}" has a required Unsupported field "${fieldName}" and cannot be created/upserted through the ORM client`, + ); + } + } + } + private doNormalizeArgs(args: unknown) { if (args && typeof args === 'object') { for (const [key, value] of Object.entries(args)) { diff --git a/packages/orm/src/client/crud/operations/create.ts b/packages/orm/src/client/crud/operations/create.ts index af3c85994..66d337dab 100644 --- a/packages/orm/src/client/crud/operations/create.ts +++ b/packages/orm/src/client/crud/operations/create.ts @@ -6,6 +6,8 @@ import { BaseOperationHandler } from './base'; export class CreateOperationHandler extends BaseOperationHandler { async handle(operation: 'create' | 'createMany' | 'createManyAndReturn', args: unknown | undefined) { + this.checkNoRequiredUnsupportedFields(); + // normalize args to strip `undefined` fields const normalizedArgs = this.normalizeArgs(args); diff --git a/packages/orm/src/client/crud/operations/update.ts b/packages/orm/src/client/crud/operations/update.ts index 424945708..49d53a979 100644 --- a/packages/orm/src/client/crud/operations/update.ts +++ b/packages/orm/src/client/crud/operations/update.ts @@ -137,6 +137,8 @@ export class UpdateOperationHandler extends BaseOperat } private async runUpsert(args: any) { + this.checkNoRequiredUnsupportedFields(); + // analyze if we need to read back the updated record, or just return the update result const { needReadBack, selectedFields } = this.needReadBack(args); diff --git a/packages/orm/src/client/executor/name-mapper.ts b/packages/orm/src/client/executor/name-mapper.ts index 6a7535fe0..369526cd6 100644 --- a/packages/orm/src/client/executor/name-mapper.ts +++ b/packages/orm/src/client/executor/name-mapper.ts @@ -37,8 +37,8 @@ import { getEnum, getField, getModel, + getModelFields, isEnum, - requireModel, stripAlias, } from '../query-utils'; @@ -66,7 +66,7 @@ export class QueryNameMapper extends OperationNodeTransformer { this.modelToTableMap.set(modelName, mappedName); } - for (const fieldDef of this.getModelFields(modelDef)) { + for (const fieldDef of getModelFields(this.schema, modelName)) { const mappedName = this.getMappedName(fieldDef); if (mappedName) { this.fieldToColumnMap.set(`${modelName}.${fieldDef.name}`, mappedName); @@ -431,7 +431,7 @@ export class QueryNameMapper extends OperationNodeTransformer { if (!modelDef) { continue; } - if (this.getModelFields(modelDef).some((f) => f.name === name)) { + if (getModelFields(this.schema, scope.model).some((f) => f.name === name)) { return scope; } } @@ -560,8 +560,7 @@ export class QueryNameMapper extends OperationNodeTransformer { } private createSelectAllFields(model: string, alias: OperationNode | undefined) { - const modelDef = requireModel(this.schema, model); - return this.getModelFields(modelDef).map((fieldDef) => { + return getModelFields(this.schema, model).map((fieldDef) => { const columnName = this.mapFieldName(model, fieldDef.name); const columnRef = ReferenceNode.create( ColumnNode.create(columnName), @@ -576,9 +575,6 @@ export class QueryNameMapper extends OperationNodeTransformer { }); } - private getModelFields(modelDef: ModelDef) { - return Object.values(modelDef.fields).filter((f) => !f.relation && !f.computed && !f.originModel); - } private processSelections(selections: readonly SelectionNode[]) { const result: SelectionNode[] = []; @@ -627,9 +623,8 @@ export class QueryNameMapper extends OperationNodeTransformer { } // expand select all to a list of selections with name mapping - const modelDef = requireModel(this.schema, scope.model); - return this.getModelFields(modelDef).map((fieldDef) => { - const columnName = this.mapFieldName(modelDef.name, fieldDef.name); + return getModelFields(this.schema, scope.model).map((fieldDef) => { + const columnName = this.mapFieldName(scope.model!, fieldDef.name); const columnRef = ReferenceNode.create(ColumnNode.create(columnName)); // process enum value mapping @@ -660,7 +655,7 @@ export class QueryNameMapper extends OperationNodeTransformer { if (!modelDef) { return false; } - return this.getModelFields(modelDef).some((fieldDef) => { + return getModelFields(this.schema, model).some((fieldDef) => { const enumDef = getEnum(this.schema, fieldDef.type); if (!enumDef) { return false; diff --git a/packages/orm/src/client/helpers/schema-db-pusher.ts b/packages/orm/src/client/helpers/schema-db-pusher.ts index 4886687ef..f84a29004 100644 --- a/packages/orm/src/client/helpers/schema-db-pusher.ts +++ b/packages/orm/src/client/helpers/schema-db-pusher.ts @@ -12,7 +12,7 @@ import { type SchemaDef, } from '../../schema'; import type { ToKysely } from '../query-builder'; -import { requireModel } from '../query-utils'; +import { isUnsupportedField, requireModel } from '../query-utils'; /** * This class is for testing purposes only. It should never be used in production. @@ -117,6 +117,11 @@ export class SchemaDbPusher { continue; } + if (isUnsupportedField(fieldDef)) { + // Unsupported fields cannot be represented in the ORM's schema pusher + continue; + } + if (fieldDef.relation) { table = this.addForeignKeyConstraint(table, modelDef.name, fieldName, fieldDef); } else if (!this.isComputedField(fieldDef)) { diff --git a/packages/orm/src/client/query-utils.ts b/packages/orm/src/client/query-utils.ts index 0ea7ea58a..2e8b134b3 100644 --- a/packages/orm/src/client/query-utils.ts +++ b/packages/orm/src/client/query-utils.ts @@ -70,12 +70,12 @@ export function requireField(schema: SchemaDef, modelOrType: string, field: stri } /** - * Gets all model fields, by default non-relation, non-computed, non-inherited fields only. + * Gets all model fields, by default non-relation, non-computed, non-inherited, non-unsupported fields only. */ export function getModelFields( schema: SchemaDef, model: string, - options?: { relations?: boolean; computed?: boolean; inherited?: boolean }, + options?: { relations?: boolean; computed?: boolean; inherited?: boolean; unsupported?: boolean }, ) { const modelDef = requireModel(schema, model); return Object.values(modelDef.fields).filter((f) => { @@ -88,10 +88,20 @@ export function getModelFields( if (f.originModel && !options?.inherited) { return false; } + if (f.type === 'Unsupported' && !options?.unsupported) { + return false; + } return true; }); } +/** + * Checks if a field is of `Unsupported` type. + */ +export function isUnsupportedField(fieldDef: FieldDef) { + return fieldDef.type === 'Unsupported'; +} + export function getIdFields(schema: SchemaDef, model: GetModels) { const modelDef = getModel(schema, model); return modelDef?.idFields; diff --git a/packages/orm/src/client/type-utils.ts b/packages/orm/src/client/type-utils.ts index 3a4589f71..1ed972fb9 100644 --- a/packages/orm/src/client/type-utils.ts +++ b/packages/orm/src/client/type-utils.ts @@ -1,8 +1,29 @@ -import type { GetModels, SchemaDef } from '@zenstackhq/schema'; +import type { FieldDef, GetModel, GetModels, SchemaDef } from '@zenstackhq/schema'; import type { GetProcedureNames } from './crud-types'; import type { AllCrudOperations } from './crud/operations/base'; import type { FilterKind, QueryOptions, SlicingOptions } from './options'; +/** + * Checks if a model has any required Unsupported fields (non-optional, no default). + * Uses raw field access since `GetModelFields` excludes Unsupported fields. + */ +export type ModelHasRequiredUnsupportedField> = true extends { + [Key in Extract['fields'], string>]: GetModel< + Schema, + Model + >['fields'][Key] extends infer F extends FieldDef + ? F['type'] extends 'Unsupported' + ? F['optional'] extends true + ? false + : 'default' extends keyof F + ? false + : true + : false + : false; +}[Extract['fields'], string>] + ? true + : false; + type IsNever = [T] extends [never] ? true : false; // #region Model slicing diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index 0f0eb61e8..6e1c5db2c 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -129,6 +129,14 @@ export class ZodSchemaFactory< return this.options.validateInput !== false; } + /** + * Returns model field entries, excluding Unsupported fields. + */ + private getModelFields(model: string): [string, FieldDef][] { + const modelDef = requireModel(this.schema, model); + return Object.entries(modelDef.fields).filter(([, def]) => def.type !== 'Unsupported'); + } + private shouldIncludeRelations(options?: CreateSchemaOptions): boolean { return options?.relationDepth === undefined || options.relationDepth > 0; } @@ -337,8 +345,6 @@ export class ZodSchemaFactory< withAggregations = false, options?: CreateSchemaOptions, ): ZodType { - const modelDef = requireModel(this.schema, model); - // unique field used in unique filters bypass filter slicing const uniqueFieldNames = unique ? getUniqueFields(this.schema, model) @@ -353,8 +359,7 @@ export class ZodSchemaFactory< const nextOpts = this.nextOptions(options); const fields: Record = {}; - for (const field of Object.keys(modelDef.fields)) { - const fieldDef = requireField(this.schema, model, field); + for (const [field, fieldDef] of this.getModelFields(model)) { let fieldSchema: ZodType | undefined; if (fieldDef.relation) { @@ -877,10 +882,8 @@ export class ZodSchemaFactory< @cache() private makeSelectSchema(model: string, options?: CreateSchemaOptions) { - const modelDef = requireModel(this.schema, model); const fields: Record = {}; - for (const field of Object.keys(modelDef.fields)) { - const fieldDef = requireField(this.schema, model, field); + for (const [field, fieldDef] of this.getModelFields(model)) { if (fieldDef.relation) { if (!this.shouldIncludeRelations(options)) { continue; @@ -992,10 +995,8 @@ export class ZodSchemaFactory< @cache() private makeOmitSchema(model: string) { - const modelDef = requireModel(this.schema, model); const fields: Record = {}; - for (const field of Object.keys(modelDef.fields)) { - const fieldDef = requireField(this.schema, model, field); + for (const [field, fieldDef] of this.getModelFields(model)) { if (!fieldDef.relation) { if (this.options.allowQueryTimeOmitOverride !== false) { // if override is allowed, use boolean @@ -1043,12 +1044,10 @@ export class ZodSchemaFactory< WithAggregation: boolean, options?: CreateSchemaOptions, ) { - const modelDef = requireModel(this.schema, model); const fields: Record = {}; const sort = z.union([z.literal('asc'), z.literal('desc')]); const nextOpts = this.nextOptions(options); - for (const field of Object.keys(modelDef.fields)) { - const fieldDef = requireField(this.schema, model, field); + for (const [field, fieldDef] of this.getModelFields(model)) { if (fieldDef.relation) { // relations if (withRelation && this.shouldIncludeRelations(options)) { @@ -1098,8 +1097,9 @@ export class ZodSchemaFactory< @cache() private makeDistinctSchema(model: string) { - const modelDef = requireModel(this.schema, model); - const nonRelationFields = Object.keys(modelDef.fields).filter((field) => !modelDef.fields[field]?.relation); + const nonRelationFields = this.getModelFields(model) + .filter(([, def]) => !def.relation) + .map(([name]) => name); return nonRelationFields.length > 0 ? this.orArray(z.enum(nonRelationFields as any), true) : z.never(); } @@ -1170,20 +1170,19 @@ export class ZodSchemaFactory< const uncheckedVariantFields: Record = {}; const checkedVariantFields: Record = {}; const modelDef = requireModel(this.schema, model); + const modelFields = this.getModelFields(model); const hasRelation = !skipRelations && - Object.entries(modelDef.fields).some(([f, def]) => !withoutFields.includes(f) && def.relation); + modelFields.some(([f, def]) => !withoutFields.includes(f) && def.relation); const nextOpts = this.nextOptions(options); - Object.keys(modelDef.fields).forEach((field) => { + modelFields.forEach(([field, fieldDef]) => { if (withoutFields.includes(field)) { return; } - const fieldDef = requireField(this.schema, model, field); - - // skip computed fields and discriminator fields, they cannot be set on create + // skip computed fields and discriminator fields if (fieldDef.computed || fieldDef.isDiscriminator) { return; } @@ -1302,21 +1301,28 @@ export class ZodSchemaFactory< const fieldDef = requireField(this.schema, model, field); const fieldType = fieldDef.type; const array = !!fieldDef.array; + const canCreateModel = this.canCreateModel(fieldType); const fields: Record = { - create: this.makeCreateDataSchema( + connect: this.makeConnectDataSchema(fieldType, array, options).optional(), + }; + + if (canCreateModel) { + fields['create'] = this.makeCreateDataSchema( fieldDef.type, !!fieldDef.array, withoutFields, false, options, - ).optional(), - - connect: this.makeConnectDataSchema(fieldType, array, options).optional(), - - connectOrCreate: this.makeConnectOrCreateDataSchema(fieldType, array, withoutFields, options).optional(), - }; + ).optional(); + fields['connectOrCreate'] = this.makeConnectOrCreateDataSchema( + fieldType, + array, + withoutFields, + options, + ).optional(); + } - if (array) { + if (array && canCreateModel) { fields['createMany'] = this.makeCreateManyPayloadSchema(fieldType, withoutFields, options).optional(); } @@ -1346,19 +1352,21 @@ export class ZodSchemaFactory< ]) .optional(); - let upsertWhere = this.makeWhereSchema(fieldType, true, false, false, options); - if (!fieldDef.array) { - // to-one relation, can upsert without where clause - upsertWhere = upsertWhere.optional(); + if (canCreateModel) { + let upsertWhere = this.makeWhereSchema(fieldType, true, false, false, options); + if (!fieldDef.array) { + // to-one relation, can upsert without where clause + upsertWhere = upsertWhere.optional(); + } + fields['upsert'] = this.orArray( + z.strictObject({ + where: upsertWhere, + create: this.makeCreateDataSchema(fieldType, false, withoutFields, false, options), + update: this.makeUpdateDataSchema(fieldType, withoutFields, false, options), + }), + true, + ).optional(); } - fields['upsert'] = this.orArray( - z.strictObject({ - where: upsertWhere, - create: this.makeCreateDataSchema(fieldType, false, withoutFields, false, options), - update: this.makeUpdateDataSchema(fieldType, withoutFields, false, options), - }), - true, - ).optional(); if (array) { // to-many relation specifics @@ -1524,21 +1532,20 @@ export class ZodSchemaFactory< const uncheckedVariantFields: Record = {}; const checkedVariantFields: Record = {}; const modelDef = requireModel(this.schema, model); + const modelFields = this.getModelFields(model); const hasRelation = !skipRelations && - Object.entries(modelDef.fields).some(([key, value]) => value.relation && !withoutFields.includes(key)); + modelFields.some(([key, value]) => value.relation && !withoutFields.includes(key)); const nextOpts = this.nextOptions(options); - Object.keys(modelDef.fields).forEach((field) => { + modelFields.forEach(([field, fieldDef]) => { if (withoutFields.includes(field)) { return; } - const fieldDef = requireField(this.schema, model, field); - + // skip computed fields and discriminator fields if (fieldDef.computed || fieldDef.isDiscriminator) { - // skip computed fields and discriminator fields, they cannot be updated return; } @@ -1698,13 +1705,12 @@ export class ZodSchemaFactory< @cache() private makeCountAggregateInputSchema(model: string) { - const modelDef = requireModel(this.schema, model); return z.union([ z.literal(true), z.strictObject({ _all: z.literal(true).optional(), - ...Object.keys(modelDef.fields).reduce( - (acc, field) => { + ...this.getModelFields(model).reduce( + (acc, [field]) => { acc[field] = z.literal(true).optional(); return acc; }, @@ -1741,11 +1747,9 @@ export class ZodSchemaFactory< @cache() private makeSumAvgInputSchema(model: string) { - const modelDef = requireModel(this.schema, model); return z.strictObject( - Object.keys(modelDef.fields).reduce( - (acc, field) => { - const fieldDef = requireField(this.schema, model, field); + this.getModelFields(model).reduce( + (acc, [field, fieldDef]) => { if (this.isNumericField(fieldDef)) { acc[field] = z.literal(true).optional(); } @@ -1758,11 +1762,9 @@ export class ZodSchemaFactory< @cache() private makeMinMaxInputSchema(model: string) { - const modelDef = requireModel(this.schema, model); return z.strictObject( - Object.keys(modelDef.fields).reduce( - (acc, field) => { - const fieldDef = requireField(this.schema, model, field); + this.getModelFields(model).reduce( + (acc, [field, fieldDef]) => { if (!fieldDef.relation && !fieldDef.array) { acc[field] = z.literal(true).optional(); } @@ -1782,8 +1784,9 @@ export class ZodSchemaFactory< model: Model, options?: CreateSchemaOptions, ): ZodType> { - const modelDef = requireModel(this.schema, model); - const nonRelationFields = Object.keys(modelDef.fields).filter((field) => !modelDef.fields[field]?.relation); + const nonRelationFields = this.getModelFields(model) + .filter(([, def]) => !def.relation) + .map(([name]) => name); const bySchema = nonRelationFields.length > 0 ? this.orArray(z.enum(nonRelationFields as [string, ...string[]]), true) @@ -2185,10 +2188,14 @@ export class ZodSchemaFactory< } } - /** - * Checks if a model is included in the slicing configuration. - * Returns true if the model is allowed, false if it's excluded. - */ + private canCreateModel(model: string) { + const modelDef = requireModel(this.schema, model); + const hasRequiredUnsupportedFields = Object.values(modelDef.fields).some( + (fieldDef) => fieldDef.type === 'Unsupported' && !fieldDef.optional && !fieldHasDefaultValue(fieldDef), + ); + return !hasRequiredUnsupportedFields; + } + private isModelAllowed(targetModel: string): boolean { const slicing = this.options.slicing; if (!slicing) { diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts index e21b5e30e..b93ea9355 100644 --- a/packages/schema/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -150,10 +150,15 @@ export type GetTypeDefs = Extract> = Schema['typeDefs'] extends Record ? Schema['typeDefs'][TypeDef] : never; -export type GetModelFields> = Extract< - keyof GetModel['fields'], - string ->; +export type GetModelFields> = keyof { + [Key in Extract['fields'], string> as FieldIsUnsupported< + Schema, + Model, + Key + > extends true + ? never + : Key]: never; +}; export type GetModelField< Schema extends SchemaDef, @@ -281,6 +286,16 @@ export type FieldIsComputed< Field extends GetModelFields, > = GetModelField['computed'] extends true ? true : false; +export type FieldIsUnsupported< + Schema extends SchemaDef, + Model extends GetModels, + Field extends string, +> = Field extends keyof GetModel['fields'] + ? GetModel['fields'][Field]['type'] extends 'Unsupported' + ? true + : false + : never; + export type FieldHasDefault< Schema extends SchemaDef, Model extends GetModels, diff --git a/tests/e2e/orm/client-api/unsupported.test-d.ts b/tests/e2e/orm/client-api/unsupported.test-d.ts new file mode 100644 index 000000000..a9797cc90 --- /dev/null +++ b/tests/e2e/orm/client-api/unsupported.test-d.ts @@ -0,0 +1,134 @@ +import type { ClientContract, CreateArgs, FindManyArgs, ModelResult, UpdateArgs } from '@zenstackhq/orm'; +import { describe, expectTypeOf, it } from 'vitest'; +import z from 'zod'; +import { schema } from '../schemas/unsupported/schema'; + +type Schema = typeof schema; + +declare const client: ClientContract; + +describe('Unsupported field exclusion - typing', () => { + // #region Result types + + it('excludes Unsupported fields from result type (optional Unsupported)', () => { + type ItemResult = ModelResult; + expectTypeOf().toHaveProperty('id'); + expectTypeOf().toHaveProperty('name'); + // Unsupported field should be excluded + expectTypeOf().not.toHaveProperty('data'); + }); + + it('excludes Unsupported fields from result type (required Unsupported)', () => { + type GeoResult = ModelResult; + expectTypeOf().toHaveProperty('id'); + expectTypeOf().toHaveProperty('title'); + // Unsupported field should be excluded + expectTypeOf().not.toHaveProperty('extra'); + }); + + // #endregion + + // #region Find/Where types + + it('excludes Unsupported fields from where filter', () => { + type FindArgs = FindManyArgs; + type Where = NonNullable; + expectTypeOf().toHaveProperty('id'); + expectTypeOf().toHaveProperty('name'); + // Unsupported field should not be filterable + expectTypeOf().not.toHaveProperty('data'); + }); + + // #endregion + + // #region Select/Omit types + + it('excludes Unsupported fields from select', () => { + type FindArgs = FindManyArgs; + type Select = NonNullable; + expectTypeOf().toHaveProperty('name'); + // Unsupported field should not be selectable + expectTypeOf