diff --git a/package.json b/package.json index a51aa74aa..c1a28730e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "zenstack-v3", "displayName": "ZenStack", "description": "ZenStack", - "version": "3.9.6", + "version": "3.9.7", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/auth-adapters/better-auth/package.json b/packages/auth-adapters/better-auth/package.json index d84e5788d..7d5f27207 100644 --- a/packages/auth-adapters/better-auth/package.json +++ b/packages/auth-adapters/better-auth/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/better-auth", "displayName": "ZenStack Better Auth Adapter", "description": "ZenStack Better Auth Adapter. This adapter is modified from better-auth's Prisma adapter.", - "version": "3.9.6", + "version": "3.9.7", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/cli/package.json b/packages/cli/package.json index dfdf7436d..7c48dda00 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/cli", "displayName": "ZenStack CLI", "description": "FullStack database toolkit with built-in access control and automatic API generation.", - "version": "3.9.6", + "version": "3.9.7", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/client-helpers/package.json b/packages/clients/client-helpers/package.json index e48265ca6..61dffda90 100644 --- a/packages/clients/client-helpers/package.json +++ b/packages/clients/client-helpers/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/client-helpers", "displayName": "ZenStack Client Helpers", "description": "Helpers for implementing clients that consume ZenStack's CRUD service", - "version": "3.9.6", + "version": "3.9.7", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/fetch-client/package.json b/packages/clients/fetch-client/package.json index 8b3ce4a11..b0a22a57b 100644 --- a/packages/clients/fetch-client/package.json +++ b/packages/clients/fetch-client/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/fetch-client", "displayName": "ZenStack Fetch Client", "description": "Simple fetch-based client for consuming ZenStack's RPC-style CRUD API", - "version": "3.9.6", + "version": "3.9.7", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/tanstack-query/package.json b/packages/clients/tanstack-query/package.json index b3ad06d78..e90fde2de 100644 --- a/packages/clients/tanstack-query/package.json +++ b/packages/clients/tanstack-query/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/tanstack-query", "displayName": "ZenStack TanStack Query Integration", "description": "TanStack Query Client for consuming ZenStack v3's CRUD service", - "version": "3.9.6", + "version": "3.9.7", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/common-helpers/package.json b/packages/common-helpers/package.json index 072a7eb1f..7160b5cdf 100644 --- a/packages/common-helpers/package.json +++ b/packages/common-helpers/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/common-helpers", "displayName": "ZenStack Common Helpers", "description": "ZenStack Common Helpers", - "version": "3.9.6", + "version": "3.9.7", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/config/eslint-config/package.json b/packages/config/eslint-config/package.json index 90ca80633..302cf90da 100644 --- a/packages/config/eslint-config/package.json +++ b/packages/config/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/eslint-config", - "version": "3.9.6", + "version": "3.9.7", "type": "module", "private": true, "license": "MIT" diff --git a/packages/config/tsdown-config/package.json b/packages/config/tsdown-config/package.json index 0689946fe..5e582f10b 100644 --- a/packages/config/tsdown-config/package.json +++ b/packages/config/tsdown-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/tsdown-config", - "version": "3.9.6", + "version": "3.9.7", "private": true, "type": "module", "license": "MIT", diff --git a/packages/config/typescript-config/package.json b/packages/config/typescript-config/package.json index 28ff30647..784687951 100644 --- a/packages/config/typescript-config/package.json +++ b/packages/config/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/typescript-config", - "version": "3.9.6", + "version": "3.9.7", "private": true, "license": "MIT" } diff --git a/packages/config/vitest-config/package.json b/packages/config/vitest-config/package.json index ff8ef0928..44957b247 100644 --- a/packages/config/vitest-config/package.json +++ b/packages/config/vitest-config/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/vitest-config", "type": "module", - "version": "3.9.6", + "version": "3.9.7", "private": true, "license": "MIT", "exports": { diff --git a/packages/create-zenstack/package.json b/packages/create-zenstack/package.json index 09efe1929..ccdf5d364 100644 --- a/packages/create-zenstack/package.json +++ b/packages/create-zenstack/package.json @@ -2,7 +2,7 @@ "name": "create-zenstack", "displayName": "Create ZenStack", "description": "Create a new ZenStack project", - "version": "3.9.6", + "version": "3.9.7", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/ide/vscode/package.json b/packages/ide/vscode/package.json index be8104e15..41ce04d88 100644 --- a/packages/ide/vscode/package.json +++ b/packages/ide/vscode/package.json @@ -1,7 +1,7 @@ { "name": "zenstack-v3", "publisher": "zenstack", - "version": "3.9.6", + "version": "3.9.7", "displayName": "ZenStack V3 Language Tools", "description": "VSCode extension for ZenStack (v3) ZModel language", "private": true, diff --git a/packages/language/package.json b/packages/language/package.json index ffb090fa7..deaf5c9f0 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/language", "displayName": "ZenStack Language Tooling", "description": "ZenStack ZModel language specification", - "version": "3.9.6", + "version": "3.9.7", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/orm/package.json b/packages/orm/package.json index 2a98043fa..6f4021380 100644 --- a/packages/orm/package.json +++ b/packages/orm/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/orm", "displayName": "ZenStack ORM", "description": "ZenStack ORM", - "version": "3.9.6", + "version": "3.9.7", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index b92854807..d0e9ef940 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -2137,11 +2137,16 @@ type UpdateRelationFieldPayload< ? ToManyRelationUpdateInput : ToOneRelationUpdateInput; +// Variance-annotated for the same reason as `ToOneRelationUpdateInput` below: without the +// annotations, comparing two instantiations with different `Options` (e.g. a generated +// `XxxUpdateArgs`/`XxxUpsertArgs` type passed to a client method) makes TypeScript measure +// variance structurally through the recursive nested update/upsert types, which blows up into +// "Type instantiation is excessively deep and possibly infinite" (#2778). type ToManyRelationUpdateInput< - Schema extends SchemaDef, - Model extends GetModels, - Field extends RelationFields, - Options extends QueryOptions, + in out Schema extends SchemaDef, + in out Model extends GetModels, + in out Field extends RelationFields, + in out Options extends QueryOptions, > = Omit< { /** diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 4210f99cf..e3e852ed2 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -1431,11 +1431,22 @@ export abstract class BaseCrudDialect { query: SelectQueryBuilder, omit: Record | undefined | null, modelAlias: string, + options?: { + /** + * When false, query-level, client-level, and schema-level omit settings are all + * ignored for the model's own fields and every field is selected. Omission is + * still applied to the JSON packed from delegate descendants, since those fields + * are never referenced by joins or ordering and the packed JSON is copied to + * the final result as-is. + */ + applyOmit?: boolean; + }, ) { let result = query; + const applyOmit = options?.applyOmit ?? true; for (const fieldDef of getModelFields(this.schema, model, { inherited: true, computed: true })) { - if (this.shouldOmitField(omit, model, fieldDef.name)) { + if (applyOmit && this.shouldOmitField(omit, model, fieldDef.name)) { continue; } // parameterized computed fields can't be auto-selected — they require @@ -1498,12 +1509,11 @@ export abstract class BaseCrudDialect { let subQuery = this.buildSelectModel(model, subQueryAlias); if (selectAllFields) { - subQuery = this.buildSelectAllFields( - model, - subQuery, - typeof payload === 'object' ? payload?.omit : undefined, - subQueryAlias, - ); + // omission (query-level, client-level, or schema-level) is intentionally not + // applied here: this select feeds a derived subquery whose columns are needed by + // nested relation joins (PK/FK fields) and by ordering of the aggregated result, + // and the outer JSON object projection handles omission on its own + subQuery = this.buildSelectAllFields(model, subQuery, undefined, subQueryAlias, { applyOmit: false }); } if (payload && typeof payload === 'object') { diff --git a/packages/orm/src/client/crud/dialects/postgresql.ts b/packages/orm/src/client/crud/dialects/postgresql.ts index 75ee4f35e..5c6866b23 100644 --- a/packages/orm/src/client/crud/dialects/postgresql.ts +++ b/packages/orm/src/client/crud/dialects/postgresql.ts @@ -2,6 +2,8 @@ import { invariant } from '@zenstackhq/common-helpers'; import type { BuiltinType, FieldDef, SchemaDef } from '@zenstackhq/schema'; import Decimal from 'decimal.js'; import { + ValueNode, + type OperationNode, expressionBuilder, sql, type AliasableExpression, @@ -531,6 +533,17 @@ export class PostgresCrudDialect extends LateralJoinDi ) { const leftResolved = this.resolveFieldSqlType(leftFieldDef); const rightResolved = this.resolveFieldSqlType(rightFieldDef); + + // Fast path for comparing a column with a @db.* native type against a bound value (e.g. + // `userId == auth().id`). Casting the column would defeat index usage, so the value side is + // handled instead: PostgreSQL infers the parameter type from the column, and for uuid the + // value is validated up front so a malformed one yields a constant result rather than an + // "invalid input syntax for type uuid" error. + const valueResult = this.tryBuildNativeTypeValueComparison(left, leftResolved, op, right, rightResolved); + if (valueResult) { + return valueResult; + } + // If the resolved SQL types differ and at least one side carries a @db.* native type override, // cast that side back to its base ZModel SQL type so PostgreSQL doesn't reject the comparison // (e.g. "operator does not exist: uuid = text"). @@ -548,6 +561,65 @@ export class PostgresCrudDialect extends LateralJoinDi return super.buildComparison(left, leftFieldDef, op, right, rightFieldDef); } + // native SQL types that accept any text input, so a bound string value never fails to parse + private static readonly textLikeSqlTypes = new Set(['text', 'varchar', 'bpchar', 'citext']); + + // uuid input formats accepted by PostgreSQL (canonical 8-4-4-4-12 or 32 hex digits). This is a + // pure format check: unlike RFC 4122 validators it doesn't require specific version/variant bits, + // since PostgreSQL stores any 128-bit value (e.g. `00000000-0000-0000-0000-000000000001`). + private static readonly uuidFormatRegex = + /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32})$/i; + + private tryBuildNativeTypeValueComparison( + left: Expression, + leftResolved: ReturnType, + op: string, + right: Expression, + rightResolved: ReturnType, + ): Expression | undefined { + let valueNode: OperationNode; + let columnResolved: typeof leftResolved; + const leftNode = left.toOperationNode(); + const rightNode = right.toOperationNode(); + if (ValueNode.is(rightNode) && !ValueNode.is(leftNode)) { + valueNode = rightNode; + columnResolved = leftResolved; + } else if (ValueNode.is(leftNode) && !ValueNode.is(rightNode)) { + valueNode = leftNode; + columnResolved = rightResolved; + } else { + return undefined; + } + + if (!columnResolved.hasDbOverride || !columnResolved.sqlType) { + return undefined; + } + + if (PostgresCrudDialect.textLikeSqlTypes.has(columnResolved.sqlType)) { + // text-like column, any string value is valid input + return this.eb(left, op as any, right) as Expression; + } + + if (columnResolved.sqlType === 'uuid' && (op === '=' || op === '!=')) { + const value = (valueNode as ValueNode).value; + if (typeof value === 'string' && PostgresCrudDialect.uuidFormatRegex.test(value)) { + // well-formed uuid, compare natively without casting the column + return this.eb(left, op as any, right) as Expression; + } else if (op === '=') { + // malformed uuid can never equal a uuid column + return this.eb.lit(false) as unknown as Expression; + } else { + // malformed uuid differs from every non-null uuid; a null column must not match, matching + // the SQL semantics of `col != value` (null when col is null) + const column = valueNode === rightNode ? left : right; + return this.eb(column, 'is not', null) as Expression; + } + } + + // other native types fall back to casting the column + return undefined; + } + override getStringCasingBehavior() { // Postgres `LIKE` is case-sensitive, `ILIKE` is case-insensitive return { supportsILike: true, likeCaseSensitive: true }; diff --git a/packages/plugins/policy/package.json b/packages/plugins/policy/package.json index 979738300..8d858498b 100644 --- a/packages/plugins/policy/package.json +++ b/packages/plugins/policy/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/plugin-policy", "displayName": "ZenStack Access Policy Plugin", "description": "ZenStack plugin that enforces access control policies defined in the schema", - "version": "3.9.6", + "version": "3.9.7", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/plugins/policy/src/expression-transformer.ts b/packages/plugins/policy/src/expression-transformer.ts index 9a3761365..c885b504c 100644 --- a/packages/plugins/policy/src/expression-transformer.ts +++ b/packages/plugins/policy/src/expression-transformer.ts @@ -41,6 +41,7 @@ import { SelectionNode, SelectQueryNode, TableNode, + UnaryOperationNode, ValueListNode, ValueNode, WhereNode, @@ -91,6 +92,12 @@ export type ExpressionTransformerContext = { */ memberSelect?: SelectionNode; + /** + * In case of transforming a collection predicate's LHS, wraps the innermost relation subquery with + * `exists` or `not exists` so the predicate is evaluated as a semi-join instead of an aggregate + */ + memberExists?: 'exists' | 'not exists'; + /** * In case of transforming a collection predicate's LHS, the table alias to use for the innermost * relation (the one the predicate filter is compiled against) @@ -206,16 +213,30 @@ export class ExpressionTransformer { if (!fieldDef.relation) { return this.createColumnRef(expr.field, context); } else { - const { memberFilter, memberSelect, memberAlias, ...restContext } = context; + const { memberFilter, memberSelect, memberExists, memberAlias, ...restContext } = context; const relation = this.transformRelationAccess(expr.field, fieldDef.type, restContext, memberAlias); - return { - ...relation, - where: this.mergeWhere(relation.where, memberFilter), - selections: memberSelect ? [memberSelect] : relation.selections, - }; + return this.finalizeMemberSubquery( + { + ...relation, + where: this.mergeWhere(relation.where, memberFilter), + selections: memberSelect ? [memberSelect] : relation.selections, + }, + memberExists, + ); } } + // wraps the innermost collection-predicate subquery with `exists`/`not exists` if requested + private finalizeMemberSubquery( + node: SelectQueryNode, + memberExists: 'exists' | 'not exists' | undefined, + ): OperationNode { + if (!memberExists) { + return node; + } + return UnaryOperationNode.create(OperatorNode.create(memberExists), node); + } + private mergeWhere(where: WhereNode | undefined, memberFilter: OperationNode | undefined) { if (!where) { return WhereNode.create(memberFilter ?? trueNode(this.dialect)); @@ -347,14 +368,14 @@ export class ExpressionTransformer { const leftRelDef = this.getFieldDefFromFieldRef(expr.left, context); invariant(leftRelDef, 'failed to get relation field definition'); const idFields = QueryUtils.requireIdFields(this.schema, leftRelDef.type); - normalizedLeft = this.makeOrAppendMember(normalizedLeft, idFields[0]!); + normalizedLeft = this.appendIdOrForeignKey(normalizedLeft, idFields[0]!, context); } let normalizedRight: Expression = expr.right; if (this.isRelationField(expr.right, context)) { const rightRelDef = this.getFieldDefFromFieldRef(expr.right, context); invariant(rightRelDef, 'failed to get relation field definition'); const idFields = QueryUtils.requireIdFields(this.schema, rightRelDef.type); - normalizedRight = this.makeOrAppendMember(normalizedRight, idFields[0]!); + normalizedRight = this.appendIdOrForeignKey(normalizedRight, idFields[0]!, context); } return { normalizedLeft, normalizedRight }; } @@ -448,17 +469,23 @@ export class ExpressionTransformer { predicateFilter = logicalNot(this.dialect, predicateFilter); } - const count = FunctionNode.create('count', [ValueNode.createImmediate(1)]); - - const predicateResult = match(expr.op) - .with('?', () => BinaryOperationNode.create(count, OperatorNode.create('>'), ValueNode.createImmediate(0))) - .with('!', () => BinaryOperationNode.create(count, OperatorNode.create('='), ValueNode.createImmediate(0))) - .with('^', () => BinaryOperationNode.create(count, OperatorNode.create('='), ValueNode.createImmediate(0))) + // `?` (some) => exists(select 1 ... where filter) + // `!` (all) => not exists(select 1 ... where not filter) + // `^` (none) => not exists(select 1 ... where filter) + // `exists` lets the database plan the predicate as a semi-join that can use indexes and + // stop at the first match, unlike a correlated `count(1) > 0` aggregate + const memberExists = match(expr.op) + .with('?', () => 'exists' as const) + .with('!', () => 'not exists' as const) + .with('^', () => 'not exists' as const) .exhaustive(); return this.transform(expr.left, { ...context, - memberSelect: SelectionNode.create(AliasNode.create(predicateResult, IdentifierNode.create('_'))), + memberSelect: SelectionNode.create( + AliasNode.create(ValueNode.createImmediate(1), IdentifierNode.create('_')), + ), + memberExists, memberFilter: predicateFilter, memberAlias, }); @@ -603,12 +630,13 @@ export class ExpressionTransformer { .map((f) => f.name); invariant(idFields.length > 0, 'auth type model must have at least one id field'); - // convert `auth() == other` into `auth().id == other.id` + // convert `auth() == other` into `auth().id == other.id`, or `auth().id == other's fk` + // when `other` is a to-one relation owning the foreign key const conditions = idFields.map((fieldName) => ExpressionUtils.binary( ExpressionUtils.member(authExpr, [fieldName]), '==', - this.makeOrAppendMember(other, fieldName), + this.appendIdOrForeignKey(other, fieldName, context), ), ); let result = this.buildAnd(conditions); @@ -627,6 +655,61 @@ export class ExpressionTransformer { } } + /** + * Appends `idField` to a relation expression, i.e. `relation` -> `relation.id`. When the relation + * is SQL-backed, to-one, and owns the foreign key referencing `idField`, the terminal hop is + * replaced with the foreign key column instead (`relation.id` -> `relationId`), which avoids a + * correlated subquery into the related table that would only read back the same value. + */ + private appendIdOrForeignKey( + relationExpr: Expression, + idField: string, + context: ExpressionTransformerContext, + ): Expression { + if (this.isSqlBackedRef(relationExpr, context)) { + const resolved = this.resolveFieldRef(relationExpr, context); + const relation = resolved?.fieldDef.relation; + if (relation && !resolved.fieldDef.array && relation.fields && relation.references) { + const idx = relation.references.indexOf(idField); + const fk = idx >= 0 ? relation.fields[idx] : undefined; + if (fk) { + return this.replaceLastMember(relationExpr, fk); + } + } + } + return this.makeOrAppendMember(relationExpr, idField); + } + + // replaces the terminal segment of a field/member expression with `fieldName` + private replaceLastMember(expr: Expression, fieldName: string): Expression { + if (ExpressionUtils.isMember(expr)) { + return ExpressionUtils.member(expr.receiver, [...expr.members.slice(0, -1), fieldName]); + } else { + invariant(ExpressionUtils.isField(expr), 'expected field or member expression'); + return ExpressionUtils.field(fieldName); + } + } + + // whether a field/member expression is compiled to SQL column references (as opposed to + // being evaluated against an in-memory value tree, e.g. `auth()` members or value bindings) + private isSqlBackedRef(expr: Expression, context: ExpressionTransformerContext): boolean { + if (ExpressionUtils.isField(expr)) { + return !context.contextValue; + } + if (ExpressionUtils.isMember(expr)) { + if (ExpressionUtils.isThis(expr.receiver)) { + return true; + } + if (ExpressionUtils.isField(expr.receiver)) { + return !context.contextValue; + } + if (ExpressionUtils.isBinding(expr.receiver)) { + return context.bindingScope?.[expr.receiver.name]?.value === undefined; + } + } + return false; + } + private transformValue(value: unknown, type: BuiltinType): OperationNode { if (value === true) { return trueNode(this.dialect); @@ -743,7 +826,7 @@ export class ExpressionTransformer { let receiver: OperationNode; let receiverAlias: string; let startType: string | undefined; - const { memberFilter, memberSelect, memberAlias, ...restContext } = context; + const { memberFilter, memberSelect, memberExists, memberAlias, ...restContext } = context; if (ExpressionUtils.isThis(expr.receiver)) { if (expr.members.length === 1) { @@ -837,7 +920,7 @@ export class ExpressionTransformer { currAlias = alias; } - let currNode: SelectQueryNode | ColumnNode | ReferenceNode | undefined = undefined; + let currNode: OperationNode | undefined = undefined; for (let i = members.length - 1; i >= 0; i--) { const member = members[i]!; @@ -858,25 +941,34 @@ export class ExpressionTransformer { ); if (currNode) { - currNode = { + const outer: SelectQueryNode = { ...relation, selections: [ SelectionNode.create(AliasNode.create(currNode, IdentifierNode.create(members[i + 1]!))), ], }; + currNode = outer; } else { // inner most member, merge with member filter from the context - currNode = { - ...relation, - where: this.mergeWhere(relation.where, memberFilter), - selections: memberSelect ? [memberSelect] : relation.selections, - }; + currNode = this.finalizeMemberSubquery( + { + ...relation, + where: this.mergeWhere(relation.where, memberFilter), + selections: memberSelect ? [memberSelect] : relation.selections, + }, + memberExists, + ); } } else { invariant(i === members.length - 1, 'plain field access must be the last segment'); invariant(!currNode, 'plain field access must be the last segment'); - currNode = ReferenceNode.create(ColumnNode.create(member), TableNode.create(fromAlias)); + if (fieldDef.originModel && fieldDef.originModel !== fromModel) { + // field inherited from a delegate base model, look it up from the base table + currNode = this.buildDelegateBaseFieldSelect(fromModel, fromAlias, member, fieldDef.originModel); + } else { + currNode = ReferenceNode.create(ColumnNode.create(member), TableNode.create(fromAlias)); + } } } @@ -1104,6 +1196,15 @@ export class ExpressionTransformer { } private getFieldDefFromFieldRef(expr: Expression, context: ExpressionTransformerContext): FieldDef | undefined { + return this.resolveFieldRef(expr, context)?.fieldDef; + } + + // resolves a field/member expression to the terminal field's definition together with the + // model that declares it + private resolveFieldRef( + expr: Expression, + context: ExpressionTransformerContext, + ): { model: string; fieldDef: FieldDef } | undefined { // `this.foo` references belong to `thisType` (the outer model in collection-predicate // contexts); everything else uses `modelOrType`. const model = @@ -1111,21 +1212,26 @@ export class ExpressionTransformer { ? context.thisType : context.modelOrType; + const resolveField = (currModel: string, field: string) => { + const fieldDef = QueryUtils.getField(this.schema, currModel, field); + return fieldDef ? { model: currModel, fieldDef } : undefined; + }; + // walks a chain of member names from `startModel`, treating all but the last segment as // relation hops, and returns the terminal field's FieldDef; returns undefined if any // segment is missing or an intermediate hop is not a relation. - const walkRelationChain = (startModel: string, members: string[]): FieldDef | undefined => { + const walkRelationChain = (startModel: string, members: string[]) => { let currModel = startModel; for (let i = 0; i < members.length - 1; i++) { const hopDef = QueryUtils.getField(this.schema, currModel, members[i]!); if (!hopDef?.relation) return undefined; currModel = hopDef.type; } - return QueryUtils.getField(this.schema, currModel, members[members.length - 1]!); + return resolveField(currModel, members[members.length - 1]!); }; if (ExpressionUtils.isField(expr)) { - return QueryUtils.getField(this.schema, model, expr.field); + return resolveField(model, expr.field); } else if (ExpressionUtils.isMember(expr)) { if (ExpressionUtils.isThis(expr.receiver)) { // `this.<...>.field` chain rooted at the `this` model. @@ -1139,6 +1245,12 @@ export class ExpressionTransformer { // receiver is the first hop, so native-type info (@db.*) on the terminal field is // available for casting in buildComparison. return walkRelationChain(model, [expr.receiver.field, ...expr.members]); + } else if (this.isAuthCall(expr.receiver)) { + // `auth().<...>.field` chain rooted at the auth model. Resolving the terminal + // field lets buildComparison see matching native types on both sides (e.g. + // `userId == auth().id` with both `@db.Uuid`) and skip casting the column, + // which would otherwise defeat index usage. + return walkRelationChain(this.authType, expr.members); } } return undefined; diff --git a/packages/plugins/soft-delete/package.json b/packages/plugins/soft-delete/package.json index a3e57b9d3..5c13ff2e2 100644 --- a/packages/plugins/soft-delete/package.json +++ b/packages/plugins/soft-delete/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/plugin-soft-delete", "displayName": "ZenStack Soft Delete Plugin", "description": "ZenStack plugin that implements soft-delete by intercepting Kysely queries", - "version": "3.9.6", + "version": "3.9.7", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/schema/package.json b/packages/schema/package.json index c90f4107c..2e4ba6cfa 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/schema", "displayName": "ZenStack Schema Object Model", "description": "TypeScript representation of ZModel schema", - "version": "3.9.6", + "version": "3.9.7", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index e15a1875d..7d85ec3f8 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/sdk", "displayName": "ZenStack SDK", "description": "Utilities for building ZenStack plugins", - "version": "3.9.6", + "version": "3.9.7", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/server/package.json b/packages/server/package.json index 2948237ee..e4062f563 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/server", "displayName": "ZenStack Automatic CRUD Server", "description": "ZenStack automatic CRUD API handlers and server adapters for popular frameworks", - "version": "3.9.6", + "version": "3.9.7", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 7824503eb..2b98013fb 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/testtools", "displayName": "ZenStack Test Tools", "description": "ZenStack Test Tools", - "version": "3.9.6", + "version": "3.9.7", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/zod/package.json b/packages/zod/package.json index 57c113fc8..fa7fc5d86 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/zod", "displayName": "ZenStack Zod Integration", "description": "Automatically deriving Zod schemas from ZModel schemas", - "version": "3.9.6", + "version": "3.9.7", "type": "module", "author": { "name": "ZenStack Team", diff --git a/samples/orm/package.json b/samples/orm/package.json index 37a578b72..949a7195c 100644 --- a/samples/orm/package.json +++ b/samples/orm/package.json @@ -1,6 +1,6 @@ { "name": "sample-orm", - "version": "3.9.6", + "version": "3.9.7", "description": "", "main": "index.js", "private": true, diff --git a/samples/taskforge/package.json b/samples/taskforge/package.json index b80ec8936..7287b2122 100644 --- a/samples/taskforge/package.json +++ b/samples/taskforge/package.json @@ -1,6 +1,6 @@ { "name": "taskforge", - "version": "3.9.6", + "version": "3.9.7", "type": "module", "private": true, "description": "A CLI for a team collaboration / project-tracking platform, built on ZenStack v3 (ORM) and better-auth.", diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 94bcf4f1c..a1688bdce 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -1,6 +1,6 @@ { "name": "e2e", - "version": "3.9.6", + "version": "3.9.7", "private": true, "type": "module", "scripts": { diff --git a/tests/regression/package.json b/tests/regression/package.json index e2004b158..34cc1fae2 100644 --- a/tests/regression/package.json +++ b/tests/regression/package.json @@ -1,6 +1,6 @@ { "name": "regression", - "version": "3.9.6", + "version": "3.9.7", "private": true, "type": "module", "scripts": { diff --git a/tests/regression/test/issue-2778/regression.test.ts b/tests/regression/test/issue-2778/regression.test.ts new file mode 100644 index 000000000..35043ed60 --- /dev/null +++ b/tests/regression/test/issue-2778/regression.test.ts @@ -0,0 +1,39 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import type { UpdateArgs, UpsertArgs } from '@zenstackhq/orm'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { schema, type SchemaType } from './schema'; + +// https://github.com/zenstackhq/zenstack/issues/2778 + +// These mirror the types emitted by `zenstack generate` into `input.ts` +type TagUpsertArgs = UpsertArgs; +type TagUpdateArgs = UpdateArgs; + +describe('Regression for issue #2778', () => { + it('accepts generated UpsertArgs/UpdateArgs with many-to-many relations without deep instantiation', async () => { + const db = await createTestClient(schema, { + usePrismaPush: true, + schemaFile: path.join(__dirname, 'schema.zmodel'), + }); + + // passing the generated args type directly to the client method used to trigger + // "Type instantiation is excessively deep and possibly infinite" + const upsertTag = (args: TagUpsertArgs) => db.tag.upsert(args); + const updateTag = (args: TagUpdateArgs) => db.tag.update(args); + + const tag = await upsertTag({ + where: { name: 't1' }, + create: { name: 't1', products: { create: { name: 'p1' } } }, + update: { name: 't2' }, + }); + expect(tag.name).toBe('t1'); + + const updated = await updateTag({ + where: { id: tag.id }, + data: { name: 't3', products: { create: { name: 'p2' } } }, + }); + expect(updated.name).toBe('t3'); + await expect(db.product.count()).resolves.toBe(2); + }); +}); diff --git a/tests/regression/test/issue-2778/schema.ts b/tests/regression/test/issue-2778/schema.ts new file mode 100644 index 000000000..7b925b640 --- /dev/null +++ b/tests/regression/test/issue-2778/schema.ts @@ -0,0 +1,72 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// 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: "sqlite" + } as const; + models = { + Tag: { + name: "Tag", + fields: { + id: { + name: "id", + type: "String", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("uuid") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("uuid") as FieldDefault + }, + name: { + name: "name", + type: "String", + unique: true, + attributes: [{ name: "@unique" }] as readonly AttributeApplication[] + }, + products: { + name: "products", + type: "Product", + array: true, + relation: { opposite: "tags" } + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" }, + name: { type: "String" } + } + }, + Product: { + name: "Product", + fields: { + id: { + name: "id", + type: "String", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("uuid") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("uuid") as FieldDefault + }, + name: { + name: "name", + type: "String" + }, + tags: { + name: "tags", + type: "Tag", + array: true, + relation: { opposite: "products" } + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" } + } + } + } as const; + plugins = {}; +} +export const schema = new SchemaType(); diff --git a/tests/regression/test/issue-2778/schema.zmodel b/tests/regression/test/issue-2778/schema.zmodel new file mode 100644 index 000000000..5b90d8f76 --- /dev/null +++ b/tests/regression/test/issue-2778/schema.zmodel @@ -0,0 +1,16 @@ +datasource db { + provider = 'sqlite' + url = 'file:./dev.db' +} + +model Tag { + id String @id @default(uuid()) + name String @unique + products Product[] +} + +model Product { + id String @id @default(uuid()) + name String + tags Tag[] +} diff --git a/tests/regression/test/issue-2830.test.ts b/tests/regression/test/issue-2830.test.ts new file mode 100644 index 000000000..287ea9bcd --- /dev/null +++ b/tests/regression/test/issue-2830.test.ts @@ -0,0 +1,157 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +// https://github.com/zenstackhq/zenstack/issues/2830 +describe('Regression for issue #2830', () => { + const schema = ` +model Parent { + id Int @id @default(autoincrement()) + name String + children Child[] +} + +model Child { + id Int @id @default(autoincrement()) + position Int + parent Parent @relation(fields: [parentId], references: [id]) + parentId Int + grandchildren Grandchild[] +} + +model Grandchild { + id Int @id @default(autoincrement()) + name String + child Child @relation(fields: [childId], references: [id]) + childId Int +} +`; + + async function seed(db: any) { + await db.parent.create({ + data: { + name: 'p1', + children: { + create: [ + { position: 2, grandchildren: { create: [{ name: 'g2' }] } }, + { position: 1, grandchildren: { create: [{ name: 'g1a' }, { name: 'g1b' }] } }, + ], + }, + }, + }); + } + + it('keeps nested includes working when the PK is omitted on an ordered relation', async () => { + const db = await createTestClient(schema); + await seed(db); + + const result = await db.parent.findMany({ + include: { + children: { + omit: { id: true }, + orderBy: { position: 'asc' }, + include: { grandchildren: true }, + }, + }, + }); + + expect(result).toHaveLength(1); + const children = result[0]!.children; + expect(children.map((c: any) => c.position)).toEqual([1, 2]); + expect(children[0]).not.toHaveProperty('id'); + expect(children[0]!.grandchildren.map((g: any) => g.name).sort()).toEqual(['g1a', 'g1b']); + expect(children[1]!.grandchildren.map((g: any) => g.name)).toEqual(['g2']); + // grandchildren are not affected by the parent-level omit + expect(children[0]!.grandchildren[0]).toHaveProperty('id'); + }); + + it('keeps nested includes working when the FK is omitted on a paginated relation', async () => { + const db = await createTestClient(schema); + await seed(db); + + const result = await db.parent.findMany({ + include: { + children: { + omit: { id: true, parentId: true }, + orderBy: { position: 'desc' }, + take: 1, + include: { grandchildren: { omit: { childId: true } } }, + }, + }, + }); + + expect(result[0]!.children).toHaveLength(1); + expect(result[0]!.children[0]!.position).toBe(2); + expect(result[0]!.children[0]).not.toHaveProperty('id'); + expect(result[0]!.children[0]).not.toHaveProperty('parentId'); + expect(result[0]!.children[0]!.grandchildren).toEqual([expect.objectContaining({ name: 'g2' })]); + expect(result[0]!.children[0]!.grandchildren[0]).not.toHaveProperty('childId'); + }); + + it('respects schema-level @omit on an ordered relation with nested include', async () => { + const db = await createTestClient(schema.replace('position Int', 'position Int @omit')); + await seed(db); + + const result = await db.parent.findMany({ + include: { + children: { + orderBy: { position: 'asc' }, + include: { grandchildren: true }, + }, + }, + }); + const children = result[0]!.children; + expect(children).toHaveLength(2); + expect(children[0]).not.toHaveProperty('position'); + expect(children[0]!.grandchildren).toHaveLength(2); + expect(children[1]!.grandchildren).toHaveLength(1); + }); + + it('still omits delegate-descendant fields on an ordered relation', async () => { + const db = await createTestClient( + ` +model Parent { + id Int @id @default(autoincrement()) + name String + items Item[] +} + +model Item { + id Int @id @default(autoincrement()) + position Int + kind String + parent Parent @relation(fields: [parentId], references: [id]) + parentId Int + @@delegate(kind) +} + +model SecretItem extends Item { + secret String @omit + public String +} +`, + ); + const parent = await db.parent.create({ data: { name: 'p1' } }); + await db.secretItem.create({ + data: { parentId: parent.id, position: 2, secret: 's2', public: 'pub2' }, + }); + await db.secretItem.create({ + data: { parentId: parent.id, position: 1, secret: 's1', public: 'pub1' }, + }); + + const result = await db.parent.findMany({ + include: { + items: { + omit: { id: true }, + orderBy: { position: 'asc' }, + }, + }, + }); + + const items = result[0]!.items; + expect(items.map((i: any) => i.position)).toEqual([1, 2]); + expect(items[0]).toMatchObject({ kind: 'SecretItem', public: 'pub1' }); + // schema-level @omit on the delegate descendant must still be honored + expect(items[0]).not.toHaveProperty('secret'); + expect(items[1]).not.toHaveProperty('secret'); + }); +}); diff --git a/tests/regression/test/issue-2851.test.ts b/tests/regression/test/issue-2851.test.ts new file mode 100644 index 000000000..cc4c338cd --- /dev/null +++ b/tests/regression/test/issue-2851.test.ts @@ -0,0 +1,678 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { randomUUID } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; + +describe('Regression for issue #2851', () => { + async function createClient(schema: string) { + const sqls: string[] = []; + const db = await createPolicyTestClient(schema, { + provider: 'postgresql', + usePrismaPush: true, + log: (event) => { + if (event.level === 'query') { + sqls.push(event.query.sql); + } + }, + }); + return { db, sqls }; + } + + it('does not cast a uuid column when comparing with a uuid auth() field', async () => { + const { db, sqls } = await createClient( + ` +model User { + id String @id @default(uuid()) @db.Uuid + memberships Membership[] + @@allow('all', true) +} + +model Membership { + id String @id @default(uuid()) @db.Uuid + userID String @db.Uuid + user User @relation(fields: [userID], references: [id]) + @@allow('read', userID == auth().id) +} + `, + ); + + const rawDb = db.$unuseAll(); + const user1 = await rawDb.user.create({ data: {} }); + const user2 = await rawDb.user.create({ data: {} }); + await rawDb.membership.create({ data: { userID: user1.id } }); + await rawDb.membership.create({ data: { userID: user2.id } }); + + sqls.length = 0; + const result = await db.$setAuth({ id: user1.id }).membership.findMany(); + expect(result).toHaveLength(1); + expect(result[0]!.userID).toBe(user1.id); + + const query = sqls.find((sql) => sql.includes('from "public"."Membership"')); + expect(query).toBeDefined(); + expect(query).not.toContain('cast('); + expect(query).toContain('"Membership"."userID" = $1'); + }); + + it('does not cast a uuid column when comparing a relation with auth()', async () => { + const { db, sqls } = await createClient( + ` +model User { + id String @id @default(uuid()) @db.Uuid + memberships Membership[] + @@allow('all', true) +} + +model Membership { + id String @id @default(uuid()) @db.Uuid + userID String @db.Uuid + user User @relation(fields: [userID], references: [id]) + @@allow('read', user == auth()) +} + `, + ); + + const rawDb = db.$unuseAll(); + const user1 = await rawDb.user.create({ data: {} }); + const user2 = await rawDb.user.create({ data: {} }); + await rawDb.membership.create({ data: { userID: user1.id } }); + await rawDb.membership.create({ data: { userID: user2.id } }); + + sqls.length = 0; + const result = await db.$setAuth({ id: user1.id }).membership.findMany(); + expect(result).toHaveLength(1); + expect(result[0]!.userID).toBe(user1.id); + + const query = sqls.find((sql) => sql.includes('from "public"."Membership"')); + expect(query).toBeDefined(); + expect(query).not.toContain('as text'); + }); + + it('still works when comparing a plain string column with a uuid auth() field', async () => { + const { db } = await createClient( + ` +model User { + id String @id @default(uuid()) @db.Uuid + @@allow('all', true) +} + +model Item { + id String @id @default(uuid()) @db.Uuid + ownerId String + @@allow('all', ownerId == auth().id) +} + `, + ); + + const rawDb = db.$unuseAll(); + const uid = randomUUID(); + await rawDb.user.create({ data: { id: uid } }); + await rawDb.item.create({ data: { ownerId: uid } }); + await rawDb.item.create({ data: { ownerId: randomUUID() } }); + + const result = await db.$setAuth({ id: uid }).item.findMany(); + expect(result).toHaveLength(1); + expect(result[0]!.ownerId).toBe(uid); + }); + + it('does not cast a uuid column when comparing with a plain string auth() field', async () => { + const { db, sqls } = await createClient( + ` +model User { + id String @id @default(uuid()) + @@allow('all', true) +} + +model Item { + id String @id @default(uuid()) @db.Uuid + ownerId String @db.Uuid + @@allow('all', ownerId == auth().id) +} + `, + ); + + const rawDb = db.$unuseAll(); + const uid = randomUUID(); + await rawDb.user.create({ data: { id: uid } }); + await rawDb.item.create({ data: { ownerId: uid } }); + await rawDb.item.create({ data: { ownerId: randomUUID() } }); + + sqls.length = 0; + const result = await db.$setAuth({ id: uid }).item.findMany(); + expect(result).toHaveLength(1); + expect(result[0]!.ownerId).toBe(uid); + + // the auth value is a bound parameter, PostgreSQL infers its type from the column + const query = sqls.find((sql) => sql.includes('from "public"."Item"')); + expect(query).not.toContain('cast('); + expect(query).toContain('"Item"."ownerId" = $1'); + }); + + it('denies instead of failing when auth() id is not a well-formed uuid', async () => { + const { db } = await createClient( + ` +model User { + id String @id @default(uuid()) + @@allow('all', true) +} + +model Item { + id String @id @default(uuid()) @db.Uuid + ownerId String @db.Uuid + @@allow('read', ownerId == auth().id) +} + +model Note { + id String @id @default(uuid()) @db.Uuid + ownerId String? @db.Uuid + @@allow('read', ownerId != auth().id) +} + `, + ); + + const rawDb = db.$unuseAll(); + const uid = randomUUID(); + await rawDb.item.create({ data: { ownerId: uid } }); + const note = await rawDb.note.create({ data: { ownerId: uid } }); + // a note with null owner: `ownerId != x` is null in SQL, so it must never match + await rawDb.note.create({ data: { ownerId: null } }); + + const badAuthDb = db.$setAuth({ id: 'not-a-uuid' }); + // `==` against a malformed uuid is always false + await expect(badAuthDb.item.findMany()).resolves.toHaveLength(0); + // `!=` against a malformed uuid is true for non-null columns only + const notes = await badAuthDb.note.findMany(); + expect(notes).toHaveLength(1); + expect(notes[0]!.id).toBe(note.id); + + // well-formed uuids are still compared natively regardless of casing or dashes + await expect(db.$setAuth({ id: uid.toUpperCase() }).item.findMany()).resolves.toHaveLength(1); + await expect(db.$setAuth({ id: uid.replace(/-/g, '') }).item.findMany()).resolves.toHaveLength(1); + + // uuids that are not RFC 4122 compliant (no version/variant bits) are still valid for PostgreSQL + const seedId = '00000000-0000-0000-0000-000000000001'; + await rawDb.item.create({ data: { ownerId: seedId } }); + await expect(db.$setAuth({ id: seedId }).item.findMany()).resolves.toHaveLength(1); + }); + + it('does not cast a varchar column when comparing with an auth() field', async () => { + const { db, sqls } = await createClient( + ` +model User { + id String @id @default(uuid()) + @@allow('all', true) +} + +model Item { + id String @id @default(uuid()) + ownerId String @db.VarChar(64) + @@allow('read', ownerId == auth().id) +} + `, + ); + + const rawDb = db.$unuseAll(); + const uid = randomUUID(); + await rawDb.item.create({ data: { ownerId: uid } }); + await rawDb.item.create({ data: { ownerId: randomUUID() } }); + + sqls.length = 0; + const result = await db.$setAuth({ id: uid }).item.findMany(); + expect(result).toHaveLength(1); + + const query = sqls.find((sql) => sql.includes('from "public"."Item"')); + expect(query).not.toContain('cast('); + expect(query).toContain('"Item"."ownerId" = $1'); + }); + + describe('collection predicates compile to EXISTS', () => { + const schema = ` +model User { + id String @id @default(uuid()) @db.Uuid + memberships Membership[] + @@allow('all', true) +} + +model Team { + id String @id @default(uuid()) @db.Uuid + name String + members Membership[] + projects Project[] + @@allow('create', true) + @@allow('read', members?[userID == auth().id]) + @@allow('update', members![userID == auth().id]) + @@allow('delete', members^[userID == auth().id]) +} + +model Membership { + id String @id @default(uuid()) @db.Uuid + teamID String @db.Uuid + team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) + userID String @db.Uuid + user User @relation(fields: [userID], references: [id]) + @@allow('all', true) +} + +model Project { + id String @id @default(uuid()) @db.Uuid + teamID String @db.Uuid + team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) + @@allow('read', team.members?[userID == auth().id]) +} +`; + + async function setup() { + const { db, sqls } = await createClient(schema); + const rawDb = db.$unuseAll(); + const user1 = await rawDb.user.create({ data: {} }); + const user2 = await rawDb.user.create({ data: {} }); + // team1: only user1; team2: user1 and user2; team3: only user2 + const team1 = await rawDb.team.create({ + data: { name: 't1', members: { create: [{ userID: user1.id }] } }, + }); + const team2 = await rawDb.team.create({ + data: { name: 't2', members: { create: [{ userID: user1.id }, { userID: user2.id }] } }, + }); + const team3 = await rawDb.team.create({ + data: { name: 't3', members: { create: [{ userID: user2.id }] } }, + }); + await rawDb.project.create({ data: { teamID: team1.id } }); + await rawDb.project.create({ data: { teamID: team3.id } }); + return { db, rawDb, sqls, user1, user2, team1, team2, team3 }; + } + + it('uses EXISTS for `?` (some)', async () => { + const { db, sqls, user1, team1, team2 } = await setup(); + sqls.length = 0; + const teams = await db.$setAuth({ id: user1.id }).team.findMany(); + expect(teams.map((t: any) => t.id).sort()).toEqual([team1.id, team2.id].sort()); + + const query = sqls.find((sql) => sql.includes('from "public"."Team"')); + expect(query).toContain('exists (select 1'); + expect(query).not.toContain('count('); + expect(query).not.toContain('cast('); + }); + + it('uses EXISTS for nested relation chains', async () => { + const { db, sqls, user1, team1 } = await setup(); + sqls.length = 0; + const projects = await db.$setAuth({ id: user1.id }).project.findMany(); + expect(projects).toHaveLength(1); + expect(projects[0]!.teamID).toBe(team1.id); + + const query = sqls.find((sql) => sql.includes('from "public"."Project"')); + expect(query).toContain('exists (select 1'); + expect(query).not.toContain('count('); + }); + + it('uses NOT EXISTS for `!` (all)', async () => { + const { db, sqls, user1, team1 } = await setup(); + sqls.length = 0; + // only team1 has all members equal to user1 + const result = await db.$setAuth({ id: user1.id }).team.updateMany({ data: { name: 'updated' } }); + expect(result.count).toBe(1); + expect((await db.$unuseAll().team.findUnique({ where: { id: team1.id } }))?.name).toBe('updated'); + + const query = sqls.find((sql) => sql.startsWith('update "public"."Team"')); + expect(query).toContain('not exists (select 1'); + expect(query).not.toContain('count('); + }); + + it('uses NOT EXISTS for `^` (none)', async () => { + const { db, sqls, user1, team3 } = await setup(); + sqls.length = 0; + // only team3 has no member equal to user1 + const result = await db.$setAuth({ id: user1.id }).team.deleteMany(); + expect(result.count).toBe(1); + expect(await db.$unuseAll().team.findUnique({ where: { id: team3.id } })).toBeNull(); + + const query = sqls.find((sql) => sql.startsWith('delete from "public"."Team"')); + expect(query).toContain('not exists (select 1'); + expect(query).not.toContain('count('); + }); + }); + + describe('relation == auth() uses the foreign key instead of a subquery', () => { + it('rewrites direct and chained to-one relations owning the foreign key', async () => { + const { db, sqls } = await createClient( + ` +model User { + id String @id @default(uuid()) @db.Uuid + orgMembers OrgMember[] + posts Post[] + @@allow('all', true) +} + +model OrgMember { + id String @id @default(uuid()) @db.Uuid + userID String @db.Uuid + user User @relation(fields: [userID], references: [id]) + teamMembers TeamMember[] + @@allow('all', true) +} + +model TeamMember { + id String @id @default(uuid()) @db.Uuid + orgMemberID String @db.Uuid + orgMember OrgMember @relation(fields: [orgMemberID], references: [id]) + @@allow('read', orgMember.user == auth()) +} + +model Post { + id String @id @default(uuid()) @db.Uuid + title String + authorID String? @db.Uuid + author User? @relation(fields: [authorID], references: [id]) + @@allow('read', author == auth()) + @@allow('update', this.author == auth()) + @@allow('delete', author != auth()) +} + `, + ); + + const rawDb = db.$unuseAll(); + const user1 = await rawDb.user.create({ data: {} }); + const user2 = await rawDb.user.create({ data: {} }); + const om1 = await rawDb.orgMember.create({ data: { userID: user1.id } }); + const om2 = await rawDb.orgMember.create({ data: { userID: user2.id } }); + const tm1 = await rawDb.teamMember.create({ data: { orgMemberID: om1.id } }); + await rawDb.teamMember.create({ data: { orgMemberID: om2.id } }); + const post1 = await rawDb.post.create({ data: { title: 'p1', authorID: user1.id } }); + await rawDb.post.create({ data: { title: 'p2', authorID: user2.id } }); + const post3 = await rawDb.post.create({ data: { title: 'p3', authorID: null } }); + + const authDb = db.$setAuth({ id: user1.id }); + + // chained: `orgMember.user == auth()` -> `orgMember.userID` + sqls.length = 0; + const teamMembers = await authDb.teamMember.findMany(); + expect(teamMembers.map((t: any) => t.id)).toEqual([tm1.id]); + let query = sqls.find((sql) => sql.includes('from "public"."TeamMember"')); + expect(query).toContain('"userID"'); + expect(query).not.toContain('from "public"."User"'); + + // direct: `author == auth()` -> `authorID` + sqls.length = 0; + const posts = await authDb.post.findMany(); + expect(posts.map((p: any) => p.id)).toEqual([post1.id]); + query = sqls.find((sql) => sql.includes('from "public"."Post"')); + expect(query).toContain('"Post"."authorID"'); + expect(query).not.toContain('from "public"."User"'); + // plain column comparison, no subquery at all + expect(query).not.toContain('(select'); + + // `this.author == auth()` -> `authorID` + sqls.length = 0; + const updated = await authDb.post.updateMany({ data: { title: 'updated' } }); + expect(updated.count).toBe(1); + query = sqls.find((sql) => sql.startsWith('update "public"."Post"')); + expect(query).not.toContain('from "public"."User"'); + + // `author != auth()` with null fk: the null-author post must not match (sql null semantics) + sqls.length = 0; + const deleted = await authDb.post.deleteMany(); + expect(deleted.count).toBe(1); + const remaining = await rawDb.post.findMany(); + expect(remaining.map((p: any) => p.id).sort()).toEqual([post1.id, post3.id].sort()); + }); + + it('rewrites compound id relations field by field', async () => { + const { db, sqls } = await createClient( + ` +model User { + tenantId String + localId String + docs Doc[] + @@id([tenantId, localId]) + @@allow('all', true) +} + +model Doc { + id String @id @default(uuid()) @db.Uuid + ownerTenantId String + ownerLocalId String + owner User @relation(fields: [ownerTenantId, ownerLocalId], references: [tenantId, localId]) + @@allow('read', owner == auth()) +} + `, + ); + + const rawDb = db.$unuseAll(); + await rawDb.user.create({ data: { tenantId: 't1', localId: 'u1' } }); + await rawDb.user.create({ data: { tenantId: 't1', localId: 'u2' } }); + const doc1 = await rawDb.doc.create({ data: { ownerTenantId: 't1', ownerLocalId: 'u1' } }); + await rawDb.doc.create({ data: { ownerTenantId: 't1', ownerLocalId: 'u2' } }); + + sqls.length = 0; + const docs = await db.$setAuth({ tenantId: 't1', localId: 'u1' }).doc.findMany(); + expect(docs.map((d: any) => d.id)).toEqual([doc1.id]); + const query = sqls.find((sql) => sql.includes('from "public"."Doc"')); + expect(query).toContain('"ownerTenantId"'); + expect(query).toContain('"ownerLocalId"'); + expect(query).not.toContain('from "public"."User"'); + }); + + it('falls back to a subquery when the relation does not own the foreign key', async () => { + const { db, sqls } = await createClient( + ` +model User { + id String @id @default(uuid()) @db.Uuid + settingsId String @unique @db.Uuid + settings Settings @relation(fields: [settingsId], references: [id]) + @@allow('all', true) +} + +model Settings { + id String @id @default(uuid()) @db.Uuid + user User? + @@allow('create', true) + @@allow('read', user == auth()) +} + `, + ); + + const rawDb = db.$unuseAll(); + const s1 = await rawDb.settings.create({ data: {} }); + const s2 = await rawDb.settings.create({ data: {} }); + const user1 = await rawDb.user.create({ data: { settingsId: s1.id } }); + await rawDb.user.create({ data: { settingsId: s2.id } }); + + sqls.length = 0; + const result = await db.$setAuth({ id: user1.id }).settings.findMany(); + expect(result.map((r: any) => r.id)).toEqual([s1.id]); + const query = sqls.find((sql) => sql.includes('from "public"."Settings"')); + // fk lives on User, so the related row must still be looked up + expect(query).toContain('from "public"."User"'); + }); + + it('rewrites only the SQL side when comparing a relation with a value binding', async () => { + const { db, sqls } = await createClient( + ` +model User { + id Int @id + assignments Assignment[] + @@allow('all', true) +} + +model Scope { + id Int @id + assignments Assignment[] + documents Document[] + @@allow('all', true) +} + +model Assignment { + id Int @id + userId Int + scopeId Int + user User @relation(fields: [userId], references: [id]) + scope Scope @relation(fields: [scopeId], references: [id]) + @@allow('all', true) +} + +model Document { + id Int @id + scopeId Int + scope Scope @relation(fields: [scopeId], references: [id]) + @@allow('create', true) + @@allow('read', auth().assignments?[a, a.scope == this.scope]) +} + `, + ); + + const rawDb = db.$unuseAll(); + await rawDb.scope.createMany({ data: [{ id: 1 }, { id: 2 }] }); + await rawDb.user.create({ data: { id: 1 } }); + await rawDb.assignment.create({ data: { id: 1, userId: 1, scopeId: 1 } }); + await rawDb.document.createMany({ + data: [ + { id: 10, scopeId: 1 }, + { id: 20, scopeId: 2 }, + ], + }); + + sqls.length = 0; + // `a.scope` is read from the auth value tree (as `scope.id`), `this.scope` becomes `scopeId` + const documents = await db + .$setAuth({ id: 1, assignments: [{ id: 1, scopeId: 1, scope: { id: 1 } }] }) + .document.findMany(); + expect(documents.map((d: any) => d.id)).toEqual([10]); + const query = sqls.find((sql) => sql.includes('from "public"."Document"')); + expect(query).toContain('"Document"."scopeId"'); + expect(query).not.toContain('from "public"."Scope"'); + }); + + it('rewrites both sides of relation == relation comparisons not involving auth()', async () => { + const { db, sqls } = await createClient( + ` +model User { + id String @id @default(uuid()) @db.Uuid + owned Task[] @relation('owner') + reviewed Task[] @relation('reviewer') + memberships Membership[] + @@allow('all', true) +} + +model Tenant { + id String @id @default(uuid()) @db.Uuid + memberships Membership[] + tasks Task[] + @@allow('all', true) +} + +model Membership { + id String @id @default(uuid()) @db.Uuid + userId String @db.Uuid + user User @relation(fields: [userId], references: [id]) + tenantId String @db.Uuid + tenant Tenant @relation(fields: [tenantId], references: [id]) + @@allow('all', true) +} + +model Task { + id String @id @default(uuid()) @db.Uuid + ownerId String @db.Uuid + owner User @relation('owner', fields: [ownerId], references: [id]) + reviewerId String @db.Uuid + reviewer User @relation('reviewer', fields: [reviewerId], references: [id]) + tenantId String @db.Uuid + tenant Tenant @relation(fields: [tenantId], references: [id]) + @@allow('create', true) + @@allow('read', this.owner == this.reviewer) + @@allow('delete', owner.memberships?[m, m.tenant == this.tenant]) +} + `, + ); + + const rawDb = db.$unuseAll(); + const user1 = await rawDb.user.create({ data: {} }); + const user2 = await rawDb.user.create({ data: {} }); + const tenant1 = await rawDb.tenant.create({ data: {} }); + const tenant2 = await rawDb.tenant.create({ data: {} }); + await rawDb.membership.create({ data: { userId: user1.id, tenantId: tenant1.id } }); + const selfReviewed = await rawDb.task.create({ + data: { ownerId: user1.id, reviewerId: user1.id, tenantId: tenant1.id }, + }); + await rawDb.task.create({ data: { ownerId: user1.id, reviewerId: user2.id, tenantId: tenant2.id } }); + + // `this.owner == this.reviewer` -> `ownerId = reviewerId` + sqls.length = 0; + const tasks = await db.task.findMany(); + expect(tasks.map((t: any) => t.id)).toEqual([selfReviewed.id]); + let query = sqls.find((sql) => sql.includes('from "public"."Task"')); + expect(query).toContain('"Task"."ownerId" = "Task"."reviewerId"'); + expect(query).not.toContain('from "public"."User"'); + + // binding relation vs `this` relation inside a collection predicate, both SQL-backed: + // `m.tenant == this.tenant` -> `m.tenantId = Task.tenantId` + sqls.length = 0; + // only the task in tenant1 has its owner as a member of the same tenant + const deleted = await db.task.deleteMany(); + expect(deleted.count).toBe(1); + expect(await rawDb.task.findUnique({ where: { id: selfReviewed.id } })).toBeNull(); + query = sqls.find((sql) => sql.startsWith('delete from "public"."Task"')); + expect(query).toContain('"tenantId" = "Task"."tenantId"'); + expect(query).not.toContain('from "public"."Tenant"'); + }); + + it('resolves inherited relations and scalars of delegate sub-types through the base table', async () => { + const { db } = await createClient( + ` +model User { + id String @id @default(uuid()) @db.Uuid + contents Content[] + @@allow('all', true) +} + +model Content { + id String @id @default(uuid()) @db.Uuid + type String + ownerId String @db.Uuid + owner User @relation(fields: [ownerId], references: [id]) + @@delegate(type) +} + +model Post extends Content { + title String + comments Comment[] + likes Like[] + @@allow('create', true) + @@allow('read', owner == auth()) +} + +model Comment { + id String @id @default(uuid()) @db.Uuid + postId String @db.Uuid + post Post @relation(fields: [postId], references: [id]) + @@allow('create', true) + @@allow('read', post.owner == auth()) +} + +model Like { + id String @id @default(uuid()) @db.Uuid + postId String @db.Uuid + post Post @relation(fields: [postId], references: [id]) + @@allow('create', true) + @@allow('read', post.ownerId == auth().id) +} + `, + ); + + const rawDb = db.$unuseAll(); + const user1 = await rawDb.user.create({ data: {} }); + const user2 = await rawDb.user.create({ data: {} }); + const post1 = await rawDb.post.create({ data: { title: 'p1', ownerId: user1.id } }); + const post2 = await rawDb.post.create({ data: { title: 'p2', ownerId: user2.id } }); + const comment1 = await rawDb.comment.create({ data: { postId: post1.id } }); + await rawDb.comment.create({ data: { postId: post2.id } }); + const like1 = await rawDb.like.create({ data: { postId: post1.id } }); + await rawDb.like.create({ data: { postId: post2.id } }); + + const authDb = db.$setAuth({ id: user1.id }); + // direct inherited relation on the sub-type + expect((await authDb.post.findMany()).map((p: any) => p.id)).toEqual([post1.id]); + // chained inherited relation + expect((await authDb.comment.findMany()).map((c: any) => c.id)).toEqual([comment1.id]); + // chained inherited scalar + expect((await authDb.like.findMany()).map((l: any) => l.id)).toEqual([like1.id]); + }); + }); +}); diff --git a/tests/runtimes/bun/package.json b/tests/runtimes/bun/package.json index 068b86e82..3fdfd00e0 100644 --- a/tests/runtimes/bun/package.json +++ b/tests/runtimes/bun/package.json @@ -1,6 +1,6 @@ { "name": "bun-e2e", - "version": "3.9.6", + "version": "3.9.7", "private": true, "type": "module", "scripts": { diff --git a/tests/runtimes/edge-runtime/package.json b/tests/runtimes/edge-runtime/package.json index dbc5e747d..5e33787c0 100644 --- a/tests/runtimes/edge-runtime/package.json +++ b/tests/runtimes/edge-runtime/package.json @@ -1,6 +1,6 @@ { "name": "edge-runtime-e2e", - "version": "3.9.6", + "version": "3.9.7", "private": true, "type": "module", "scripts": {