diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 90a31fea7..6ee33d99e 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -274,6 +274,14 @@ export type ModelResult< FieldIsArray, ExtResult >; + } & { + // an included parameterized computed field surfaces as its scalar return type + // (it is not auto-returned, so it only appears when explicitly included) + [Key in keyof I & NonRelationFields as I[Key] extends false | undefined + ? never + : FieldHasComputedArgs extends true + ? Key + : never]: MapModelFieldType; } & ('_count' extends keyof I ? I['_count'] extends false | undefined ? {} @@ -345,15 +353,15 @@ export type WhereInput< ScalarOnly extends boolean = false, WithAggregations extends boolean = false, > = { - // parameterized computed fields are excluded here — filtering them would require - // query-time args; they are currently only usable in `orderBy` - [Key in GetModelFields as FieldHasComputedArgs extends true - ? never - : ScalarOnly extends true - ? Key extends RelationFields - ? never - : Key - : Key]?: FieldFilter; + [Key in GetModelFields as ScalarOnly extends true + ? Key extends RelationFields + ? never + : Key + : Key]?: FieldHasComputedArgs extends true + ? // a parameterized computed field carries its query-time `args` alongside the filter + // operators; the intersection drops the bare-value shorthand (args must be supplied) + { args: ComputedFieldArgs } & FieldFilter + : FieldFilter; } & { $expr?: (eb: ExpressionBuilder, Model>) => OperandExpression; } & { @@ -1355,26 +1363,34 @@ export type IncludeInput< AllowCount extends boolean = true, ExtResult extends ExtResultBase = {}, > = { - [Key in RelationFields as RelationFieldType extends GetSlicedModels< - Schema, - Options - > - ? Key - : never]?: - | boolean - | FindArgs< - Schema, - RelationFieldType, - Options, - FieldIsArray, - // where clause is allowed only if the relation is array or optional - FieldIsArray extends true - ? true - : ModelFieldIsOptional extends true - ? true - : false, - ExtResult - >; + // A single mapped type over relations + parameterized computed fields. Keeping this as one + // object member (rather than intersecting a separate mapped type) preserves excess-property + // checking on `include`/`select` literals. A parameterized computed field is selectable by + // supplying its query-time `args` (mirrors the relation per-key-object shape); because + // `SelectInput` intersects `IncludeInput`, this also enables `select: { field: { args } }`. + [Key in GetModelFields as Key extends RelationFields + ? RelationFieldType extends GetSlicedModels + ? Key + : never + : FieldHasComputedArgs extends true + ? Key + : never]?: Key extends RelationFields + ? + | boolean + | FindArgs< + Schema, + RelationFieldType, + Options, + FieldIsArray, + // where clause is allowed only if the relation is array or optional + FieldIsArray extends true + ? true + : ModelFieldIsOptional extends true + ? true + : false, + ExtResult + > + : { args: ComputedFieldArgs }; } & (AllowCount extends true ? // _count is only allowed if the model has to-many relations HasToManyRelations extends true @@ -2289,10 +2305,10 @@ export type CountArgs< } & ExtractExtQueryArgs; type CountAggregateInput> = { - // a parameterized computed field has no `args` slot in `_count`, so it is excluded - [Key in NonRelationFields as FieldHasComputedArgs extends true - ? never - : Key]?: true; + // a parameterized computed field is counted by supplying its query-time `args` + [Key in NonRelationFields]?: FieldHasComputedArgs extends true + ? { args: ComputedFieldArgs } + : true; } & { _all?: true }; export type CountResult, Args> = Args extends { @@ -2371,15 +2387,17 @@ type NumericFields> = | 'Decimal' ? FieldIsArray extends true ? never - : // a parameterized computed field has no `args` slot in `_sum`/`_avg`, so it is excluded - FieldHasComputedArgs extends true - ? never - : Key + : Key : never]: GetModelField; }; type SumAvgInput, ValueType> = { - [Key in NumericFields]?: ValueType; + // a parameterized computed field is aggregated by supplying its query-time `args` + [Key in NumericFields]?: Key extends GetModelFields + ? FieldHasComputedArgs extends true + ? { args: ComputedFieldArgs } + : ValueType + : ValueType; }; type MinMaxInput, ValueType> = { @@ -2387,10 +2405,10 @@ type MinMaxInput, Valu ? never : FieldIsRelation extends true ? never - : // a parameterized computed field has no `args` slot in `_min`/`_max`, so it is excluded - FieldHasComputedArgs extends true - ? never - : Key]?: ValueType; + : Key]?: // a parameterized computed field is aggregated by supplying its query-time `args` + FieldHasComputedArgs extends true + ? { args: ComputedFieldArgs } + : ValueType; }; export type AggregateResult, Args> = (Args extends { @@ -2454,6 +2472,20 @@ type GroupByHaving< out Options extends QueryOptions = QueryOptions, > = Omit, '$expr'>; +// A `groupBy` `by` entry for a parameterized computed field: the field name plus its query-time +// `args` (a plain field name can't carry args). The map is keyed by the parameterized computed +// fields; indexing it yields the union of `{ field, args }` entries (or `never` when there are none). +type ParamComputedByEntryMap> = { + [Key in NonRelationFields as FieldHasComputedArgs extends true + ? Key + : never]: { field: Key; args: ComputedFieldArgs }; +}; +type GroupByComputedByEntry> = + ParamComputedByEntryMap[keyof ParamComputedByEntryMap]; + +// Extracts the grouped field name from a `by` entry (a plain name or a `{ field, args }` object). +type ByFieldName = By extends { field: infer F } ? F : By; + export type GroupByArgs< Schema extends SchemaDef, Model extends GetModels, @@ -2475,7 +2507,10 @@ export type GroupByArgs< */ by: | NonParamComputedNonRelationFields - | NonEmptyArray>; + | GroupByComputedByEntry + | NonEmptyArray< + NonParamComputedNonRelationFields | GroupByComputedByEntry + >; /** * Filter conditions for the grouped records @@ -2527,7 +2562,7 @@ export type GroupByResult< Args extends { by: unknown }, > = Array< { - [Key in NonRelationFields as Key extends ValueOfPotentialTuple + [Key in NonRelationFields as Key extends ByFieldName> ? Key : never]: MapModelFieldType; } & (Args extends { _count: infer Count } diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 180405f31..f0196bb7e 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -236,12 +236,27 @@ export abstract class BaseCrudDialect { if (fieldDef.relation) { result = this.and(result, this.buildRelationFilter(model, modelAlias, key, fieldDef, payload)); } else { + // a parameterized computed field carries its query-time `args` alongside the filter + // operators; pull them out so they reach the implementation, and filter on the rest + let computedArgs: unknown; + let filterPayload = payload; + if (fieldDef.computed && fieldDef.params && payload && typeof payload === 'object' && 'args' in payload) { + computedArgs = (payload as any).args; + const { args: _args, ...rest } = payload as any; + filterPayload = rest; + } // if the field is from a base model, build a reference from that model - const fieldRef = this.fieldRef(fieldDef.originModel ?? model, key, fieldDef.originModel ?? modelAlias); + const fieldRef = this.fieldRef( + fieldDef.originModel ?? model, + key, + fieldDef.originModel ?? modelAlias, + true, + computedArgs, + ); if (fieldDef.array) { - result = this.and(result, this.buildArrayFilter(fieldRef, fieldDef, payload)); + result = this.and(result, this.buildArrayFilter(fieldRef, fieldDef, filterPayload)); } else { - result = this.and(result, this.buildPrimitiveFilter(fieldRef, fieldDef, payload)); + result = this.and(result, this.buildPrimitiveFilter(fieldRef, fieldDef, filterPayload)); } } } @@ -1460,6 +1475,7 @@ export abstract class BaseCrudDialect { model: string, modelAlias: string, field: string, + computedArgs?: unknown, ): SelectQueryBuilder { const fieldDef = requireField(this.schema, model, field); @@ -1468,7 +1484,8 @@ export abstract class BaseCrudDialect { const fieldModel = fieldDef.originModel ?? model; const alias = fieldDef.originModel ?? modelAlias; - return query.select(() => this.fieldRef(fieldModel, field, alias).as(field)); + // `computedArgs` is set for a parameterized computed field selected via `select`/`include` + return query.select(() => this.fieldRef(fieldModel, field, alias, true, computedArgs).as(field)); } buildDelegateJoin( diff --git a/packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts b/packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts index cb5035d40..efe0ae3fc 100644 --- a/packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts +++ b/packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts @@ -261,6 +261,8 @@ export abstract class LateralJoinDialectBase extends B objArgs, ...Object.entries(relationModelDef.fields) .filter(([, value]) => !value.relation) + // a parameterized computed field is never auto-returned (it needs args) + .filter(([, value]) => !(value.computed && value.params)) .filter(([name]) => !this.shouldOmitField(omit, relationModel, name)) .map(([field]) => ({ [field]: this.fieldRef(relationModel, field, relationModelAlias, false), @@ -286,8 +288,12 @@ export abstract class LateralJoinDialectBase extends B const fieldValue = fieldDef.relation ? // reference the synthesized JSON field eb.ref(`${parentResultName}$${field}.$data`) - : // reference a plain field - this.fieldRef(relationModel, field, relationModelAlias, false); + : fieldDef.computed && fieldDef.params + ? // parameterized computed field: inline the computer with its + // query-time args (correlates to the relation's real columns) + this.fieldRef(relationModel, field, relationModelAlias, true, (value as any).args) + : // reference a plain field + this.fieldRef(relationModel, field, relationModelAlias, false); return { [field]: fieldValue }; } }), @@ -312,6 +318,13 @@ export abstract class LateralJoinDialectBase extends B ), }; } + const fieldDef = requireField(this.schema, relationModel, field); + if (fieldDef.computed && fieldDef.params) { + // parameterized computed field included on the relation: inline with args + return { + [field]: this.fieldRef(relationModel, field, relationModelAlias, true, (value as any).args), + }; + } return { [field]: eb.ref(`${parentResultName}$${field}.$data`) }; }), ); diff --git a/packages/orm/src/client/crud/dialects/sqlite.ts b/packages/orm/src/client/crud/dialects/sqlite.ts index 52a0a315d..00927f510 100644 --- a/packages/orm/src/client/crud/dialects/sqlite.ts +++ b/packages/orm/src/client/crud/dialects/sqlite.ts @@ -266,6 +266,8 @@ export class SqliteCrudDialect extends BaseCrudDialect objArgs.push( ...Object.entries(relationModelDef.fields) .filter(([, value]) => !value.relation) + // a parameterized computed field is never auto-returned (it needs args) + .filter(([, value]) => !(value.computed && value.params)) .filter(([name]) => !this.shouldOmitField(omit, relationModel, name)) .map(([field]) => [sql.lit(field), this.fieldRef(relationModel, field, subQueryName, false)]) .flatMap((v) => v), @@ -295,6 +297,20 @@ export class SqliteCrudDialect extends BaseCrudDialect value, ); return [sql.lit(field), subJson]; + } else if (fieldDef.computed && fieldDef.params) { + // parameterized computed field on the relation: inline the + // computer with its query-time args (correlates to the + // relation's real columns, which are materialized) + return [ + sql.lit(field), + this.fieldRef( + relationModel, + field, + subQueryName, + true, + (value as any).args, + ) as ArgsType, + ]; } else { return [ sql.lit(field), @@ -326,6 +342,21 @@ export class SqliteCrudDialect extends BaseCrudDialect ); return [sql.lit(field), subJson]; } + const fieldDef = requireField(this.schema, relationModel, field); + if (fieldDef.computed && fieldDef.params) { + // parameterized computed field included on the relation: inline + // the computer with its query-time args + return [ + sql.lit(field), + this.fieldRef( + relationModel, + field, + subQueryName, + true, + (value as any).args, + ) as ArgsType, + ]; + } const subJson = this.buildRelationJSON( relationModel, eb, diff --git a/packages/orm/src/client/crud/operations/aggregate.ts b/packages/orm/src/client/crud/operations/aggregate.ts index 035a19d1e..9eabfa2ca 100644 --- a/packages/orm/src/client/crud/operations/aggregate.ts +++ b/packages/orm/src/client/crud/operations/aggregate.ts @@ -1,8 +1,12 @@ import type { SchemaDef } from '@zenstackhq/schema'; import { match } from 'ts-pattern'; +import { createInvalidInputError } from '../../errors'; import { getField } from '../../query-utils'; import { BaseOperationHandler } from './base'; +// stable key for comparing two `args` payloads (bigint-safe) +const argsKey = (v: unknown) => JSON.stringify(v, (_k, val) => (typeof val === 'bigint' ? `${val}n` : val)); + export class AggregateOperationHandler extends BaseOperationHandler { async handle(_operation: 'aggregate', args: unknown | undefined) { // normalize args to strip `undefined` fields @@ -19,22 +23,46 @@ export class AggregateOperationHandler extends BaseOpe .buildSelectModel(this.model, this.model) .where(() => this.dialect.buildFilter(this.model, this.model, parsedArgs?.where)); - // select fields: collect fields from aggregation body + // select fields: collect fields (and any parameterized-computed `args`) from the + // aggregation bodies. The field is materialized once into `$sub`; a parameterized + // computed field carries a `{ args }` value instead of `true`. const selectedFields: string[] = []; + const computedArgsByField: Record = {}; for (const [key, value] of Object.entries(parsedArgs)) { if (key.startsWith('_') && value && typeof value === 'object') { - // select fields Object.entries(value) .filter(([field]) => field !== '_all') - .filter(([, val]) => val === true) - .forEach(([field]) => { + .forEach(([field, val]) => { + const args = val && typeof val === 'object' && 'args' in val ? (val as any).args : undefined; + if (val !== true && args === undefined) { + return; + } if (!selectedFields.includes(field)) selectedFields.push(field); + if (args !== undefined) { + // the field is materialized once into `$sub`, so every aggregation + // of it must agree on `args` — otherwise the result would silently + // use whichever `args` was seen last + const prev = computedArgsByField[field]; + if (prev !== undefined && argsKey(prev) !== argsKey(args)) { + throw createInvalidInputError( + `computed field "${field}" is aggregated with conflicting "args"; ` + + `the same field can only be aggregated with one set of arguments per query`, + ); + } + computedArgsByField[field] = args; + } }); } } if (selectedFields.length > 0) { for (const field of selectedFields) { - subQuery = this.dialect.buildSelectField(subQuery, this.model, this.model, field); + subQuery = this.dialect.buildSelectField( + subQuery, + this.model, + this.model, + field, + computedArgsByField[field], + ); } } else { // if no field is explicitly selected, just do a `select 1` so `_count` works @@ -72,7 +100,7 @@ export class AggregateOperationHandler extends BaseOpe query = query.select((eb) => this.dialect.castInt(eb.fn.countAll()).as('_count')); } else { Object.entries(value).forEach(([field, val]) => { - if (val === true) { + if (val === true || (val && typeof val === 'object' && 'args' in val)) { if (field === '_all') { query = query.select((eb) => this.dialect.castInt(eb.fn.countAll()).as(`_count._all`), @@ -95,7 +123,7 @@ export class AggregateOperationHandler extends BaseOpe case '_max': case '_min': { Object.entries(value).forEach(([field, val]) => { - if (val === true) { + if (val === true || (val && typeof val === 'object' && 'args' in val)) { query = query.select((eb) => { const fn = match(key) .with('_sum', () => eb.fn.sum) diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index a5ef4d6e7..8e16031e9 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -344,8 +344,12 @@ export abstract class BaseOperationHandler { const fieldDef = this.requireField(model, field); if (!fieldDef.relation) { - // scalar field - result = this.dialect.buildSelectField(result, model, parentAlias, field); + // scalar field — a parameterized computed field carries its query-time `args` + const computedArgs = + fieldDef.computed && fieldDef.params && typeof payload === 'object' && 'args' in payload + ? payload.args + : undefined; + result = this.dialect.buildSelectField(result, model, parentAlias, field, computedArgs); } else { if (!fieldDef.array && !fieldDef.optional && payload.where) { throw createInternalError(`Field "${field}" does not support filtering`, model); @@ -2091,7 +2095,7 @@ export abstract class BaseOperationHandler { // read parent's fk const fromEntity = await this.readUnique(kysely, fromRelation.model, { where: fromRelation.ids, - select: fieldsToSelectObject(keyPairs.map(({ fk }) => fk)), + select: fieldsToSelectObject(keyPairs.map(({ fk }) => fk)) as any, }); if (!fromEntity || keyPairs.some(({ fk }) => fromEntity[fk] == null)) { return; diff --git a/packages/orm/src/client/crud/operations/count.ts b/packages/orm/src/client/crud/operations/count.ts index c70b69f1e..b51d5e9e5 100644 --- a/packages/orm/src/client/crud/operations/count.ts +++ b/packages/orm/src/client/crud/operations/count.ts @@ -18,10 +18,21 @@ export class CountOperationHandler extends BaseOperati .where(() => this.dialect.buildFilter(this.model, this.model, parsedArgs?.where)); if (parsedArgs?.select && typeof parsedArgs.select === 'object') { - // select fields + // select fields (a parameterized computed field carries its query-time `args`) for (const [key, value] of Object.entries(parsedArgs.select)) { - if (key !== '_all' && value === true) { + if (key === '_all') { + continue; + } + if (value === true) { subQuery = this.dialect.buildSelectField(subQuery, this.model, this.model, key); + } else if (value && typeof value === 'object' && 'args' in value) { + subQuery = this.dialect.buildSelectField( + subQuery, + this.model, + this.model, + key, + (value as any).args, + ); } } } else { diff --git a/packages/orm/src/client/crud/operations/group-by.ts b/packages/orm/src/client/crud/operations/group-by.ts index e4ea01439..aef96feee 100644 --- a/packages/orm/src/client/crud/operations/group-by.ts +++ b/packages/orm/src/client/crud/operations/group-by.ts @@ -1,4 +1,5 @@ import type { SchemaDef } from '@zenstackhq/schema'; +import { sql } from 'kysely'; import { match } from 'ts-pattern'; import { aggregate, getField } from '../../query-utils'; import { BaseOperationHandler } from './base'; @@ -15,11 +16,29 @@ export class GroupByOperationHandler extends BaseOpera .selectFrom(this.model as string) .where(() => this.dialect.buildFilter(this.model, this.model, parsedArgs?.where)); - const fieldRef = (field: string) => this.dialect.fieldRef(this.model, field); - - // groupBy - const bys = typeof parsedArgs.by === 'string' ? [parsedArgs.by] : (parsedArgs.by as string[]); - query = query.groupBy(bys.map((by) => fieldRef(by))); + // `computedArgs` is set when aggregating a parameterized computed field; the model name + // is passed as the alias so a computed field that references `ctx.modelAlias` resolves + // against the grouped table + const fieldRef = (field: string, computedArgs?: unknown) => + this.dialect.fieldRef(this.model, field, this.model, true, computedArgs); + + // groupBy — a parameterized computed field is a `{ field, args }` entry; others are names + const rawBys = Array.isArray(parsedArgs.by) ? parsedArgs.by : [parsedArgs.by]; + const byEntries = rawBys.map((by: any) => + typeof by === 'string' + ? { field: by as string, args: undefined as unknown } + : { field: by.field as string, args: by.args as unknown }, + ); + query = query.groupBy( + byEntries.map((e) => { + const fieldDef = getField(this.schema, this.model, e.field); + // group a computed field by its SELECT output alias so GROUP BY and the projected + // expression stay identical (re-inlining a parameterized computer binds its args as + // separate placeholders, which Postgres treats as distinct expressions) + // (only parameterized computed fields carry `args`, and they take the branch above) + return fieldDef?.computed ? sql.ref(e.field) : fieldRef(e.field); + }), + ); // skip & take const skip = parsedArgs?.skip; @@ -40,8 +59,8 @@ export class GroupByOperationHandler extends BaseOpera } // select all by fields - for (const by of bys) { - query = query.select(() => fieldRef(by).as(by)); + for (const e of byEntries) { + query = query.select(() => fieldRef(e.field, e.args).as(e.field)); } // aggregations @@ -52,14 +71,15 @@ export class GroupByOperationHandler extends BaseOpera query = query.select((eb) => this.dialect.castInt(eb.fn.countAll()).as('_count')); } else { Object.entries(value).forEach(([field, val]) => { - if (val === true) { + const args = val && typeof val === 'object' && 'args' in val ? (val as any).args : undefined; + if (val === true || args !== undefined) { if (field === '_all') { query = query.select((eb) => this.dialect.castInt(eb.fn.countAll()).as(`_count._all`), ); } else { query = query.select((eb) => - this.dialect.castInt(eb.fn.count(fieldRef(field))).as(`${key}.${field}`), + this.dialect.castInt(eb.fn.count(fieldRef(field, args))).as(`${key}.${field}`), ); } } @@ -73,8 +93,9 @@ export class GroupByOperationHandler extends BaseOpera case '_max': case '_min': { Object.entries(value).forEach(([field, val]) => { - if (val === true) { - query = query.select((eb) => aggregate(eb, fieldRef(field), key).as(`${key}.${field}`)); + const args = val && typeof val === 'object' && 'args' in val ? (val as any).args : undefined; + if (val === true || args !== undefined) { + query = query.select((eb) => aggregate(eb, fieldRef(field, args), key).as(`${key}.${field}`)); } }); break; diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index 74f14d8ee..8da8c66b9 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -408,9 +408,9 @@ export class ZodSchemaFactory< } // A parameterized computed field declares query-time `params`. Unlike a plain computed - // field, it can't be evaluated without an `args` object, so it is excluded from contexts - // that have no slot to carry `args` (where/select/omit, the aggregate inputs, and - // groupBy `by`). It is currently only usable in `orderBy`. + // field, it can't be evaluated without an `args` object, so wherever it is used the `args` + // are supplied alongside it: `orderBy`, `where`/`having`, `select`/`include`, the aggregate + // inputs (`_count`/`_min`/`_max`/`_sum`/`_avg`), and groupBy `by` (as a `{ field, args }` entry). private isParameterizedComputedField(fieldDef: FieldDef): boolean { return !!(fieldDef.computed && fieldDef.params); } @@ -434,6 +434,18 @@ export class ZodSchemaFactory< ); } + // Wraps a field's normal filter schema so a parameterized computed field carries its + // query-time `args` alongside the filter operators. `args` is required and the bare-value + // shorthand is dropped (the field can't be filtered without args). Works uniformly across + // field types: the operator components are lifted from the existing filter object(s) (the + // filter schema is either `value | { ...ops }` or a bare `{ ...ops }`). + private addComputedArgsToFilter(filterSchema: ZodType, params: NonNullable): ZodType { + const members: any[] = filterSchema instanceof z.ZodUnion ? (filterSchema.options as any[]) : [filterSchema]; + const objectMembers = members.filter((m) => m instanceof z.ZodObject); + const components: Record = Object.assign({}, ...objectMembers.map((o) => o.shape)); + return z.strictObject({ args: this.makeFieldArgsSchema(params), ...components }); + } + @cache() private makeEnumSchema(_enum: string) { const enumDef = getEnum(this.schema, _enum); @@ -503,11 +515,6 @@ export class ZodSchemaFactory< const fields: Record = {}; for (const [field, fieldDef] of this.getModelFields(model)) { - // a parameterized computed field can't be filtered without `args`; excluded from `where` - if (this.isParameterizedComputedField(fieldDef)) { - continue; - } - let fieldSchema: ZodType | undefined; if (fieldDef.relation) { @@ -586,6 +593,12 @@ export class ZodSchemaFactory< } } + if (fieldSchema && this.isParameterizedComputedField(fieldDef)) { + // a parameterized computed field carries its query-time `args` alongside the + // filter operators (args required; the bare-value shorthand is dropped) + fieldSchema = this.addComputedArgsToFilter(fieldSchema, fieldDef.params!); + } + if (fieldSchema) { fields[field] = fieldSchema.optional(); } @@ -1176,8 +1189,9 @@ export class ZodSchemaFactory< private makeSelectSchema(model: string, options?: CreateSchemaOptions) { const fields: Record = {}; for (const [field, fieldDef] of this.getModelFields(model)) { - // a parameterized computed field can't be selected without `args`; excluded from `select` if (this.isParameterizedComputedField(fieldDef)) { + // a parameterized computed field is selected by supplying its query-time `args` + fields[field] = z.strictObject({ args: this.makeFieldArgsSchema(fieldDef.params!) }).optional(); continue; } if (fieldDef.relation) { @@ -1375,6 +1389,9 @@ export class ZodSchemaFactory< if (this.isModelAllowed(fieldDef.type)) { fields[field] = this.makeRelationSelectIncludeSchema(model, field, options).optional(); } + } else if (this.isParameterizedComputedField(fieldDef)) { + // a parameterized computed field is included by supplying its query-time `args` + fields[field] = z.strictObject({ args: this.makeFieldArgsSchema(fieldDef.params!) }).optional(); } } @@ -2154,10 +2171,10 @@ export class ZodSchemaFactory< _all: z.literal(true).optional(), ...this.getModelFields(model).reduce( (acc, [field, fieldDef]) => { - // a parameterized computed field has no `args` slot in `_count` - if (!this.isParameterizedComputedField(fieldDef)) { - acc[field] = z.literal(true).optional(); - } + // a parameterized computed field is counted by supplying its `args` + acc[field] = this.isParameterizedComputedField(fieldDef) + ? z.strictObject({ args: this.makeFieldArgsSchema(fieldDef.params!) }).optional() + : z.literal(true).optional(); return acc; }, {} as Record, @@ -2200,8 +2217,11 @@ export class ZodSchemaFactory< const schema = z.strictObject( this.getModelFields(model).reduce( (acc, [field, fieldDef]) => { - if (this.isNumericField(fieldDef) && !this.isParameterizedComputedField(fieldDef)) { - acc[field] = z.literal(true).optional(); + if (this.isNumericField(fieldDef)) { + // a parameterized computed field is aggregated by supplying its `args` + acc[field] = this.isParameterizedComputedField(fieldDef) + ? z.strictObject({ args: this.makeFieldArgsSchema(fieldDef.params!) }).optional() + : z.literal(true).optional(); } return acc; }, @@ -2217,8 +2237,11 @@ export class ZodSchemaFactory< const schema = z.strictObject( this.getModelFields(model).reduce( (acc, [field, fieldDef]) => { - if (!fieldDef.relation && !fieldDef.array && !this.isParameterizedComputedField(fieldDef)) { - acc[field] = z.literal(true).optional(); + if (!fieldDef.relation && !fieldDef.array) { + // a parameterized computed field is aggregated by supplying its `args` + acc[field] = this.isParameterizedComputedField(fieldDef) + ? z.strictObject({ args: this.makeFieldArgsSchema(fieldDef.params!) }).optional() + : z.literal(true).optional(); } return acc; }, @@ -2241,10 +2264,26 @@ export class ZodSchemaFactory< const nonRelationFields = this.getModelFields(model) .filter(([, def]) => !def.relation && !this.isParameterizedComputedField(def)) .map(([name]) => name); - const bySchema = - nonRelationFields.length > 0 - ? this.orArray(z.enum(nonRelationFields as [string, ...string[]]), true) - : z.never(); + const byMembers: ZodType[] = []; + if (nonRelationFields.length > 0) { + byMembers.push(z.enum(nonRelationFields as [string, ...string[]])); + } + // a parameterized computed field is grouped by a `{ field, args }` entry (it can't be + // named on its own — it needs query-time args) + for (const [field, fieldDef] of this.getModelFields(model)) { + if (this.isParameterizedComputedField(fieldDef)) { + byMembers.push( + z.strictObject({ field: z.literal(field), args: this.makeFieldArgsSchema(fieldDef.params!) }), + ); + } + } + const byElement = + byMembers.length === 0 + ? undefined + : byMembers.length === 1 + ? byMembers[0]! + : z.union(byMembers as [ZodType, ZodType, ...ZodType[]]); + const bySchema = byElement ? this.orArray(byElement, true) : z.never(); const baseSchema = z.strictObject({ where: this.makeWhereSchema(model, false, false, false, options).optional(), @@ -2264,7 +2303,8 @@ export class ZodSchemaFactory< // fields used in `having` must be either in the `by` list, or aggregations schema = schema.refine((value: any) => { - const bys = enumerate(value.by); + // normalize `by` entries to field names (a parameterized computed field is a `{ field, args }` entry) + const bys = enumerate(value.by).map((b: any) => (typeof b === 'string' ? b : b?.field)); if (value.having && typeof value.having === 'object') { for (const [key, val] of Object.entries(value.having)) { if (AggregateOperators.includes(key as any)) { @@ -2291,7 +2331,8 @@ export class ZodSchemaFactory< // fields used in `orderBy` must be either in the `by` list, or aggregations schema = schema.refine((value: any) => { - const bys = enumerate(value.by); + // normalize `by` entries to field names (a parameterized computed field is a `{ field, args }` entry) + const bys = enumerate(value.by).map((b: any) => (typeof b === 'string' ? b : b?.field)); for (const orderBy of enumerate(value.orderBy)) { if ( orderBy && diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts index d00c5ab4f..62e892203 100644 --- a/packages/schema/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -83,9 +83,9 @@ export type FieldDef = { foreignKeyFor?: readonly string[]; computed?: boolean; /** - * For a parameterized computed field, the parameters it declares (keyed by name). - * The corresponding arguments are supplied at query time when the field is used in - * `orderBy`. + * For a parameterized computed field, the parameters it declares (keyed by name). The + * corresponding arguments are supplied at query time wherever the field is used — `orderBy`, + * `where`/`having`, `select`/`include`, the aggregate inputs, and `groupBy`. */ params?: Record; originModel?: string; diff --git a/tests/e2e/orm/client-api/computed-fields.test.ts b/tests/e2e/orm/client-api/computed-fields.test.ts index 1205e5750..30ff361e7 100644 --- a/tests/e2e/orm/client-api/computed-fields.test.ts +++ b/tests/e2e/orm/client-api/computed-fields.test.ts @@ -323,6 +323,302 @@ model Post { ).resolves.toMatchObject([{ id: 2 }, { id: 1 }]); }); + it('works with parameterized computed fields in where (args alongside operators)', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + posts Post[] + popularPostCount(minViews: Int) Int @computed +} + +model Post { + id Int @id @default(autoincrement()) + viewCount Int @default(0) + author User @relation(fields: [authorId], references: [id]) + authorId Int +} +`, + { + computedFields: { + User: { + popularPostCount: (eb: any, ctx: any, args: any) => + eb + .selectFrom('Post') + .whereRef('Post.authorId', '=', sql.ref(`${ctx.modelAlias}.id`)) + .where('Post.viewCount', '>=', args.minViews) + .select(({ fn }: any) => fn.countAll().as('cnt')), + }, + }, + } as any, + ); + + // Alice: posts [300, 50, 50] → (>=100)=1, (>=40)=3 + // Bob: posts [120, 120, 10] → (>=100)=2, (>=40)=2 + await db.user.create({ + data: { id: 1, name: 'Alice', posts: { create: [{ viewCount: 300 }, { viewCount: 50 }, { viewCount: 50 }] } }, + }); + await db.user.create({ + data: { id: 2, name: 'Bob', posts: { create: [{ viewCount: 120 }, { viewCount: 120 }, { viewCount: 10 }] } }, + }); + + // minViews=100, count >= 2 ⇒ only Bob (Alice=1, Bob=2) + await expect( + db.user.findMany({ + where: { popularPostCount: { args: { minViews: 100 }, gte: 2 } }, + orderBy: { id: 'asc' }, + }), + ).resolves.toMatchObject([{ id: 2 }]); + + // minViews=40, count >= 3 ⇒ only Alice (Alice=3, Bob=2) — a different arg selects a different row + await expect( + db.user.findMany({ + where: { popularPostCount: { args: { minViews: 40 }, gte: 3 } }, + orderBy: { id: 'asc' }, + }), + ).resolves.toMatchObject([{ id: 1 }]); + + // composes with AND and other fields + await expect( + db.user.findMany({ + where: { AND: [{ popularPostCount: { args: { minViews: 100 }, gte: 1 } }, { name: 'Alice' }] }, + }), + ).resolves.toMatchObject([{ id: 1 }]); + + // the bare-value shorthand is rejected — args must be supplied + await expect(db.user.findMany({ where: { popularPostCount: 2 } as any })).toBeRejectedByValidation(); + }); + + it('works with parameterized computed fields in select and include', async () => { + const db = await createTestClient( + ` +model Product { + id Int @id @default(autoincrement()) + name String + price Int + priceTimes(factor: Int) Int @computed +} +`, + { + computedFields: { + Product: { + // a row-local arithmetic expression (price * factor); returns a plain integer + // on every dialect, so the selected value is a number (not a bigint string) + priceTimes: (_eb: any, ctx: any, args: any) => + sql`${sql.ref(`${ctx.modelAlias}.price`)} * ${args.factor}`, + }, + }, + } as any, + ); + + await db.product.create({ data: { id: 1, name: 'Widget', price: 100 } }); + + // `select` narrows to the computed value; different args ⇒ different values + await expect( + db.product.findUniqueOrThrow({ + where: { id: 1 }, + select: { id: true, priceTimes: { args: { factor: 2 } } }, + }), + ).resolves.toEqual({ id: 1, priceTimes: 200 }); + await expect( + db.product.findUniqueOrThrow({ + where: { id: 1 }, + select: { priceTimes: { args: { factor: 3 } } }, + }), + ).resolves.toEqual({ priceTimes: 300 }); + + // `include` returns it alongside the auto-selected scalar fields + await expect( + db.product.findUniqueOrThrow({ + where: { id: 1 }, + include: { priceTimes: { args: { factor: 2 } } }, + }), + ).resolves.toMatchObject({ id: 1, name: 'Widget', price: 100, priceTimes: 200 }); + + // still not auto-returned when no args are supplied + const plain = await db.product.findUniqueOrThrow({ where: { id: 1 } }); + expect(plain).not.toHaveProperty('priceTimes'); + + // the boolean shorthand is rejected — args must be supplied + await expect( + db.product.findUniqueOrThrow({ where: { id: 1 }, select: { priceTimes: true } as any }), + ).toBeRejectedByValidation(); + }); + + it('works with parameterized computed fields in aggregate (_sum/_max/_count)', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + posts Post[] + popularPostCount(minViews: Int) Int @computed +} + +model Post { + id Int @id @default(autoincrement()) + viewCount Int @default(0) + author User @relation(fields: [authorId], references: [id]) + authorId Int +} +`, + { + computedFields: { + User: { + popularPostCount: (eb: any, ctx: any, args: any) => + eb + .selectFrom('Post') + .whereRef('Post.authorId', '=', sql.ref(`${ctx.modelAlias}.id`)) + .where('Post.viewCount', '>=', args.minViews) + .select(({ fn }: any) => fn.countAll().as('cnt')), + }, + }, + } as any, + ); + + // Alice: [300, 50, 50] → (>=100)=1, (>=40)=3 ; Bob: [120, 120, 10] → (>=100)=2, (>=40)=2 + await db.user.create({ + data: { id: 1, name: 'Alice', posts: { create: [{ viewCount: 300 }, { viewCount: 50 }, { viewCount: 50 }] } }, + }); + await db.user.create({ + data: { id: 2, name: 'Bob', posts: { create: [{ viewCount: 120 }, { viewCount: 120 }, { viewCount: 10 }] } }, + }); + + // minViews=100 ⇒ Alice=1, Bob=2 ⇒ sum=3, max=2, count(non-null)=2 + await expect( + db.user.aggregate({ + _sum: { popularPostCount: { args: { minViews: 100 } } }, + _max: { popularPostCount: { args: { minViews: 100 } } }, + _count: { popularPostCount: { args: { minViews: 100 } } }, + }), + ).resolves.toMatchObject({ + _sum: { popularPostCount: 3 }, + _max: { popularPostCount: 2 }, + _count: { popularPostCount: 2 }, + }); + + // minViews=40 ⇒ Alice=3, Bob=2 ⇒ sum=5 (a different arg produces a different aggregate) + await expect( + db.user.aggregate({ _sum: { popularPostCount: { args: { minViews: 40 } } } }), + ).resolves.toMatchObject({ _sum: { popularPostCount: 5 } }); + + // the bare-`true` shorthand is rejected — args must be supplied + await expect( + db.user.aggregate({ _sum: { popularPostCount: true } as any }), + ).toBeRejectedByValidation(); + + // the field is materialized once, so aggregating it with conflicting args is rejected + await expect( + db.user.aggregate({ + _sum: { popularPostCount: { args: { minViews: 100 } } }, + _avg: { popularPostCount: { args: { minViews: 40 } } }, + }), + ).rejects.toThrow(/conflicting "args"/); + }); + + it('works with parameterized computed fields in groupBy by (keyed entry)', async () => { + // NOTE: grouping by a *row-local* parameterized computed field works on all dialects. + // A computed field backed by a *correlated subquery* is subject to the database's own + // rules for grouping by a correlated expression (Postgres rejects it, SQLite allows it) — + // that constraint is orthogonal to this feature and applies to any correlated GROUP BY. + const db = await createTestClient( + ` +model Product { + id Int @id @default(autoincrement()) + price Int + priceTier(threshold: Int) Int @computed +} +`, + { + computedFields: { + Product: { + // price tier via a row-local CASE expression (deterministic integer, no + // float-division / cast-rounding differences across dialects) + priceTier: (_eb: any, ctx: any, args: any) => + sql`case when ${sql.ref(`${ctx.modelAlias}.price`)} >= ${args.threshold} then 1 else 0 end`, + }, + }, + } as any, + ); + + // prices [10, 25, 30, 55]; priceTier(threshold=30) = [0, 0, 1, 1] + await db.product.createMany({ + data: [{ price: 10 }, { price: 25 }, { price: 30 }, { price: 55 }], + }); + + // group by the computed tier: 0→{10,25}, 1→{30,55} + const groups = await db.product.groupBy({ + by: [{ field: 'priceTier', args: { threshold: 30 } }], + _count: { _all: true }, + }); + expect(groups.sort((a: any, b: any) => a.priceTier - b.priceTier)).toEqual([ + { priceTier: 0, _count: { _all: 2 } }, + { priceTier: 1, _count: { _all: 2 } }, + ]); + }); + + it('works with parameterized computed fields in a nested include/select', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + viewCount Int @default(0) + author User @relation(fields: [authorId], references: [id]) + authorId Int + weightedViews(factor: Int) Int @computed +} +`, + { + computedFields: { + Post: { + // viewCount * factor, correlated to the row via ctx.modelAlias + weightedViews: (_eb: any, ctx: any, args: any) => + sql`${sql.ref(`${ctx.modelAlias}.viewCount`)} * ${args.factor}`, + }, + }, + } as any, + ); + + await db.user.create({ + data: { + id: 1, + name: 'Alice', + posts: { create: [{ id: 1, viewCount: 300 }, { id: 2, viewCount: 50 }] }, + }, + }); + + // nested `include` of a parameterized computed field on the related model + const u1 = await db.user.findFirstOrThrow({ + where: { id: 1 }, + include: { posts: { include: { weightedViews: { args: { factor: 10 } } }, orderBy: { id: 'asc' } } }, + }); + expect(u1.posts.map((p: any) => p.weightedViews)).toEqual([3000, 500]); + + // a different arg yields different nested values + const u2 = await db.user.findFirstOrThrow({ + where: { id: 1 }, + include: { posts: { include: { weightedViews: { args: { factor: 2 } } }, orderBy: { id: 'asc' } } }, + }); + expect(u2.posts.map((p: any) => p.weightedViews)).toEqual([600, 100]); + + // nested `select` narrows to the computed value + const u3 = await db.user.findFirstOrThrow({ + where: { id: 1 }, + include: { posts: { select: { id: true, weightedViews: { args: { factor: 2 } } }, orderBy: { id: 'asc' } } }, + }); + expect(u3.posts).toEqual([ + { id: 1, weightedViews: 600 }, + { id: 2, weightedViews: 100 }, + ]); + }); + it('excludes parameterized computed fields from contexts that cannot supply args', async () => { const db = await createTestClient( ` @@ -356,15 +652,12 @@ model Post { await db.user.create({ data: { id: 1, name: 'Alice' } }); - // none of these contexts can carry `args`, so a parameterized computed field is rejected - // by input validation (it remains usable only via `orderBy`). `as any` bypasses the - // matching compile-time exclusions in the query input types. - await expect(db.user.findMany({ where: { popularPostCount: 1 } as any })).toBeRejectedByValidation(); - await expect(db.user.findMany({ select: { popularPostCount: true } as any })).toBeRejectedByValidation(); + // `distinct` and `omit` have no `args` slot, so a parameterized computed field is rejected + // by input validation there (it's usable via orderBy, where, select/include, the aggregate + // inputs, and groupBy `by`). `as any` bypasses the matching compile-time exclusions. await expect(db.user.findMany({ distinct: ['popularPostCount'] as any })).toBeRejectedByValidation(); await expect(db.user.findMany({ omit: { popularPostCount: true } as any })).toBeRejectedByValidation(); - await expect(db.user.aggregate({ _count: { popularPostCount: true } as any })).toBeRejectedByValidation(); - await expect(db.user.aggregate({ _sum: { popularPostCount: true } as any })).toBeRejectedByValidation(); + // groupBy `by` requires the keyed `{ field, args }` entry — the bare name is rejected await expect( db.user.groupBy({ by: ['popularPostCount'], _count: true } as any), ).toBeRejectedByValidation();