diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 944d4a59..f9f1eb9a 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -34,6 +34,7 @@ jobs: strategy: matrix: node-version: [20.x] + provider: [sqlite, postgresql] steps: - name: Checkout @@ -76,4 +77,4 @@ jobs: run: pnpm run lint - name: Test - run: pnpm run test + run: TEST_DB_PROVIDER=${{ matrix.provider }} pnpm run test diff --git a/package.json b/package.json index 0e6838b9..88f7b917 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build": "turbo run build", "watch": "turbo run watch build", "lint": "turbo run lint", - "test": "turbo run test", + "test": "vitest run", "format": "prettier --write \"**/*.{ts,tsx,md}\"", "pr": "gh pr create --fill-first --base dev", "merge-main": "gh pr create --title \"merge dev to main\" --body \"\" --base main --head dev", diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index b419ab24..c248bde0 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -123,8 +123,10 @@ function future(): Any { } @@@expressionContext([AccessPolicy]) /** - * If the field value contains the search string. By default, the search is case-sensitive, - * but you can override the behavior with the "caseInSensitive" argument. + * Checks if the field value contains the search string. By default, the search is case-sensitive, and + * "LIKE" operator is used to match. If `caseInSensitive` is true, "ILIKE" operator is used if + * supported, otherwise it still falls back to "LIKE" and delivers whatever the database's + * behavior is. */ function contains(field: String, search: String, caseInSensitive: Boolean?): Boolean { } @@@expressionContext([AccessPolicy, ValidationRule]) @@ -136,15 +138,21 @@ function search(field: String, search: String): Boolean { } @@@expressionContext([AccessPolicy]) /** - * If the field value starts with the search string + * Checks the field value starts with the search string. By default, the search is case-sensitive, and + * "LIKE" operator is used to match. If `caseInSensitive` is true, "ILIKE" operator is used if + * supported, otherwise it still falls back to "LIKE" and delivers whatever the database's + * behavior is. */ -function startsWith(field: String, search: String): Boolean { +function startsWith(field: String, search: String, caseInSensitive: Boolean?): Boolean { } @@@expressionContext([AccessPolicy, ValidationRule]) /** - * If the field value ends with the search string + * Checks if the field value ends with the search string. By default, the search is case-sensitive, and + * "LIKE" operator is used to match. If `caseInSensitive` is true, "ILIKE" operator is used if + * supported, otherwise it still falls back to "LIKE" and delivers whatever the database's + * behavior is. */ -function endsWith(field: String, search: String): Boolean { +function endsWith(field: String, search: String, caseInSensitive: Boolean?): Boolean { } @@@expressionContext([AccessPolicy, ValidationRule]) /** diff --git a/packages/language/test/delegate.test.ts b/packages/language/test/delegate.test.ts index 185be2bc..8791275f 100644 --- a/packages/language/test/delegate.test.ts +++ b/packages/language/test/delegate.test.ts @@ -5,6 +5,11 @@ import { loadSchema, loadSchemaWithError } from './utils'; describe('Delegate Tests', () => { it('supports inheriting from delegate', async () => { const model = await loadSchema(` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + model A { id Int @id @default(autoincrement()) x String @@ -24,6 +29,11 @@ describe('Delegate Tests', () => { it('rejects inheriting from non-delegate models', async () => { await loadSchemaWithError( ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + model A { id Int @id @default(autoincrement()) x String @@ -40,6 +50,11 @@ describe('Delegate Tests', () => { it('can detect cyclic inherits', async () => { await loadSchemaWithError( ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + model A extends B { x String @@delegate(x) @@ -57,6 +72,11 @@ describe('Delegate Tests', () => { it('can detect duplicated fields from base model', async () => { await loadSchemaWithError( ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + model A { id String @id x String @@ -74,6 +94,11 @@ describe('Delegate Tests', () => { it('can detect duplicated attributes from base model', async () => { await loadSchemaWithError( ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + model A { id String @id x String diff --git a/packages/language/test/import.test.ts b/packages/language/test/import.test.ts index 48cec382..98d90d22 100644 --- a/packages/language/test/import.test.ts +++ b/packages/language/test/import.test.ts @@ -12,6 +12,11 @@ describe('Import tests', () => { fs.writeFileSync( path.join(name, 'a.zmodel'), ` +datasource db { + provider = 'sqlite' + url = 'file:./dev.db' +} + model A { id Int @id name String @@ -48,6 +53,12 @@ enum Role { path.join(name, 'b.zmodel'), ` import './a' + +datasource db { + provider = 'sqlite' + url = 'file:./dev.db' +} + model User { id Int @id role Role @@ -56,7 +67,7 @@ model User { ); const model = await expectLoaded(path.join(name, 'b.zmodel')); - expect((model.declarations[0] as DataModel).fields[1].type.reference?.ref?.name).toBe('Role'); + expect((model.declarations[1] as DataModel).fields[1].type.reference?.ref?.name).toBe('Role'); }); it('supports cyclic imports', async () => { @@ -65,6 +76,12 @@ model User { path.join(name, 'a.zmodel'), ` import './b' + +datasource db { + provider = 'sqlite' + url = 'file:./dev.db' +} + model A { id Int @id b B? @@ -86,7 +103,7 @@ model B { const modelB = await expectLoaded(path.join(name, 'b.zmodel')); expect((modelB.declarations[0] as DataModel).fields[1].type.reference?.ref?.name).toBe('A'); const modelA = await expectLoaded(path.join(name, 'a.zmodel')); - expect((modelA.declarations[0] as DataModel).fields[1].type.reference?.ref?.name).toBe('B'); + expect((modelA.declarations[1] as DataModel).fields[1].type.reference?.ref?.name).toBe('B'); }); async function expectLoaded(file: string) { diff --git a/packages/language/test/mixin.test.ts b/packages/language/test/mixin.test.ts index 8e7bcd0a..8fa9e933 100644 --- a/packages/language/test/mixin.test.ts +++ b/packages/language/test/mixin.test.ts @@ -5,6 +5,11 @@ import { DataModel, TypeDef } from '../src/ast'; describe('Mixin Tests', () => { it('supports model mixing types to Model', async () => { const model = await loadSchema(` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + type A { x String } @@ -25,6 +30,11 @@ describe('Mixin Tests', () => { it('supports model mixing types to type', async () => { const model = await loadSchema(` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + type A { x String } @@ -52,6 +62,11 @@ describe('Mixin Tests', () => { it('can detect cyclic mixins', async () => { await loadSchemaWithError( ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + type A with B { x String } diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 1f261d08..561eb33f 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -8,6 +8,8 @@ "watch": "tsup-node --watch", "lint": "eslint src --ext ts", "test": "vitest run && pnpm test:typecheck", + "test:sqlite": "TEST_DB_PROVIDER=sqlite vitest run", + "test:postgresql": "TEST_DB_PROVIDER=postgresql vitest run", "test:generate": "tsx test/scripts/generate.ts", "test:typecheck": "tsc --project tsconfig.test.json", "pack": "pnpm pack" diff --git a/packages/runtime/src/client/client-impl.ts b/packages/runtime/src/client/client-impl.ts index c762700f..c0585455 100644 --- a/packages/runtime/src/client/client-impl.ts +++ b/packages/runtime/src/client/client-impl.ts @@ -61,7 +61,7 @@ export class ClientImpl { executor?: QueryExecutor, ) { this.$schema = schema; - this.$options = options ?? ({} as ClientOptions); + this.$options = options; this.$options.functions = { ...BuiltinFunctions, @@ -326,7 +326,7 @@ export class ClientImpl { function createClientProxy(client: ClientImpl): ClientImpl { const inputValidator = new InputValidator(client.$schema); - const resultProcessor = new ResultProcessor(client.$schema); + const resultProcessor = new ResultProcessor(client.$schema, client.$options); return new Proxy(client, { get: (target, prop, receiver) => { diff --git a/packages/runtime/src/client/crud/dialects/base-dialect.ts b/packages/runtime/src/client/crud/dialects/base-dialect.ts index a8a1243c..7357c8f5 100644 --- a/packages/runtime/src/client/crud/dialects/base-dialect.ts +++ b/packages/runtime/src/client/crud/dialects/base-dialect.ts @@ -45,6 +45,10 @@ export abstract class BaseCrudDialect { return value; } + transformOutput(value: unknown, _type: BuiltinType) { + return value; + } + // #region common query builders buildSelectModel(eb: ExpressionBuilder, model: string, modelAlias: string) { @@ -1255,5 +1259,15 @@ export abstract class BaseCrudDialect { */ abstract get supportInsertWithDefault(): boolean; + /** + * Gets the SQL column type for the given field definition. + */ + abstract getFieldSqlType(fieldDef: FieldDef): string; + + /* + * Gets the string casing behavior for the dialect. + */ + abstract getStringCasingBehavior(): { supportsILike: boolean; likeCaseSensitive: boolean }; + // #endregion } diff --git a/packages/runtime/src/client/crud/dialects/postgresql.ts b/packages/runtime/src/client/crud/dialects/postgresql.ts index 07606133..b6c40661 100644 --- a/packages/runtime/src/client/crud/dialects/postgresql.ts +++ b/packages/runtime/src/client/crud/dialects/postgresql.ts @@ -1,4 +1,5 @@ import { invariant } from '@zenstackhq/common-helpers'; +import Decimal from 'decimal.js'; import { sql, type Expression, @@ -11,6 +12,8 @@ import { match } from 'ts-pattern'; import type { BuiltinType, FieldDef, GetModels, SchemaDef } from '../../../schema'; import { DELEGATE_JOINED_FIELD_PREFIX } from '../../constants'; import type { FindArgs } from '../../crud-types'; +import { QueryError } from '../../errors'; +import type { ClientOptions } from '../../options'; import { buildJoinPairs, getDelegateDescendantModels, @@ -23,6 +26,10 @@ import { import { BaseCrudDialect } from './base-dialect'; export class PostgresCrudDialect extends BaseCrudDialect { + constructor(schema: Schema, options: ClientOptions) { + super(schema, options); + } + override get provider() { return 'postgresql' as const; } @@ -44,13 +51,69 @@ export class PostgresCrudDialect extends BaseCrudDiale } else { return match(type) .with('DateTime', () => - value instanceof Date ? value : typeof value === 'string' ? new Date(value) : value, + value instanceof Date + ? value.toISOString() + : typeof value === 'string' + ? new Date(value).toISOString() + : value, ) .with('Decimal', () => (value !== null ? value.toString() : value)) .otherwise(() => value); } } + override transformOutput(value: unknown, type: BuiltinType) { + if (value === null || value === undefined) { + return value; + } + return match(type) + .with('DateTime', () => this.transformOutputDate(value)) + .with('Bytes', () => this.transformOutputBytes(value)) + .with('BigInt', () => this.transformOutputBigInt(value)) + .with('Decimal', () => this.transformDecimal(value)) + .otherwise(() => super.transformOutput(value, type)); + } + + private transformOutputBigInt(value: unknown) { + if (typeof value === 'bigint') { + return value; + } + invariant( + typeof value === 'string' || typeof value === 'number', + `Expected string or number, got ${typeof value}`, + ); + return BigInt(value); + } + + private transformDecimal(value: unknown) { + if (value instanceof Decimal) { + return value; + } + invariant( + typeof value === 'string' || typeof value === 'number' || value instanceof Decimal, + `Expected string, number or Decimal, got ${typeof value}`, + ); + return new Decimal(value); + } + + private transformOutputDate(value: unknown) { + if (typeof value === 'string') { + return new Date(value); + } else if (value instanceof Date && this.options.fixPostgresTimezone !== false) { + // SPECIAL NOTES: + // node-pg has a terrible quirk that it returns the date value in local timezone + // as a `Date` object although for `DateTime` field the data in DB is stored in UTC + // see: https://github.com/brianc/node-postgres/issues/429 + return new Date(value.getTime() - value.getTimezoneOffset() * 60 * 1000); + } else { + return value; + } + } + + private transformOutputBytes(value: unknown) { + return Buffer.isBuffer(value) ? Uint8Array.from(value) : value; + } + override buildRelationSelection( query: SelectQueryBuilder, model: string, @@ -370,4 +433,42 @@ export class PostgresCrudDialect extends BaseCrudDiale override get supportInsertWithDefault() { return true; } + + override getFieldSqlType(fieldDef: FieldDef) { + // TODO: respect `@db.x` attributes + if (fieldDef.relation) { + throw new QueryError('Cannot get SQL type of a relation field'); + } + + let result: string; + + if (this.schema.enums?.[fieldDef.type]) { + // enums are treated as text + result = 'text'; + } else { + result = match(fieldDef.type) + .with('String', () => 'text') + .with('Boolean', () => 'boolean') + .with('Int', () => 'integer') + .with('BigInt', () => 'bigint') + .with('Float', () => 'double precision') + .with('Decimal', () => 'decimal') + .with('DateTime', () => 'timestamp') + .with('Bytes', () => 'bytea') + .with('Json', () => 'jsonb') + // fallback to text + .otherwise(() => 'text'); + } + + if (fieldDef.array) { + result += '[]'; + } + + return result; + } + + override getStringCasingBehavior() { + // Postgres `LIKE` is case-sensitive, `ILIKE` is case-insensitive + return { supportsILike: true, likeCaseSensitive: true }; + } } diff --git a/packages/runtime/src/client/crud/dialects/sqlite.ts b/packages/runtime/src/client/crud/dialects/sqlite.ts index 5f4515ed..5c024dfb 100644 --- a/packages/runtime/src/client/crud/dialects/sqlite.ts +++ b/packages/runtime/src/client/crud/dialects/sqlite.ts @@ -1,5 +1,5 @@ import { invariant } from '@zenstackhq/common-helpers'; -import type Decimal from 'decimal.js'; +import Decimal from 'decimal.js'; import { ExpressionWrapper, sql, @@ -9,9 +9,10 @@ import { type SelectQueryBuilder, } from 'kysely'; import { match } from 'ts-pattern'; -import type { BuiltinType, GetModels, SchemaDef } from '../../../schema'; +import type { BuiltinType, FieldDef, GetModels, SchemaDef } from '../../../schema'; import { DELEGATE_JOINED_FIELD_PREFIX } from '../../constants'; import type { FindArgs } from '../../crud-types'; +import { QueryError } from '../../errors'; import { getDelegateDescendantModels, getManyToManyRelation, @@ -41,7 +42,13 @@ export class SqliteCrudDialect extends BaseCrudDialect } else { return match(type) .with('Boolean', () => (value ? 1 : 0)) - .with('DateTime', () => (value instanceof Date ? value.toISOString() : value)) + .with('DateTime', () => + value instanceof Date + ? value.toISOString() + : typeof value === 'string' + ? new Date(value).toISOString() + : value, + ) .with('Decimal', () => (value as Decimal).toString()) .with('Bytes', () => Buffer.from(value as Uint8Array)) .with('Json', () => JSON.stringify(value)) @@ -50,6 +57,76 @@ export class SqliteCrudDialect extends BaseCrudDialect } } + override transformOutput(value: unknown, type: BuiltinType) { + if (value === null || value === undefined) { + return value; + } else if (this.schema.typeDefs && type in this.schema.typeDefs) { + // typed JSON field + return this.transformOutputJson(value); + } else { + return match(type) + .with('Boolean', () => this.transformOutputBoolean(value)) + .with('DateTime', () => this.transformOutputDate(value)) + .with('Bytes', () => this.transformOutputBytes(value)) + .with('Decimal', () => this.transformOutputDecimal(value)) + .with('BigInt', () => this.transformOutputBigInt(value)) + .with('Json', () => this.transformOutputJson(value)) + .otherwise(() => super.transformOutput(value, type)); + } + } + + private transformOutputDecimal(value: unknown) { + if (value instanceof Decimal) { + return value; + } + invariant( + typeof value === 'string' || typeof value === 'number' || value instanceof Decimal, + `Expected string, number or Decimal, got ${typeof value}`, + ); + return new Decimal(value); + } + + private transformOutputBigInt(value: unknown) { + if (typeof value === 'bigint') { + return value; + } + invariant( + typeof value === 'string' || typeof value === 'number', + `Expected string or number, got ${typeof value}`, + ); + return BigInt(value); + } + + private transformOutputBoolean(value: unknown) { + return !!value; + } + + private transformOutputDate(value: unknown) { + if (typeof value === 'number') { + return new Date(value); + } else if (typeof value === 'string') { + return new Date(value); + } else { + return value; + } + } + + private transformOutputBytes(value: unknown) { + return Buffer.isBuffer(value) ? Uint8Array.from(value) : value; + } + + private transformOutputJson(value: unknown) { + // better-sqlite3 typically returns JSON as string; be tolerant + if (typeof value === 'string') { + try { + return JSON.parse(value); + } catch (e) { + throw new QueryError('Invalid JSON returned', e); + } + } + return value; + } + override buildRelationSelection( query: SelectQueryBuilder, model: string, @@ -301,4 +378,39 @@ export class SqliteCrudDialect extends BaseCrudDialect override get supportInsertWithDefault() { return false; } + + override getFieldSqlType(fieldDef: FieldDef) { + // TODO: respect `@db.x` attributes + if (fieldDef.relation) { + throw new QueryError('Cannot get SQL type of a relation field'); + } + if (fieldDef.array) { + throw new QueryError('SQLite does not support scalar list type'); + } + + if (this.schema.enums?.[fieldDef.type]) { + // enums are stored as text + return 'text'; + } + + return ( + match(fieldDef.type) + .with('String', () => 'text') + .with('Boolean', () => 'integer') + .with('Int', () => 'integer') + .with('BigInt', () => 'integer') + .with('Float', () => 'real') + .with('Decimal', () => 'decimal') + .with('DateTime', () => 'numeric') + .with('Bytes', () => 'blob') + .with('Json', () => 'jsonb') + // fallback to text + .otherwise(() => 'text') + ); + } + + override getStringCasingBehavior() { + // SQLite `LIKE` is case-insensitive, and there is no `ILIKE` + return { supportsILike: false, likeCaseSensitive: false }; + } } diff --git a/packages/runtime/src/client/crud/operations/base.ts b/packages/runtime/src/client/crud/operations/base.ts index 9b2098a3..170b8d89 100644 --- a/packages/runtime/src/client/crud/operations/base.ts +++ b/packages/runtime/src/client/crud/operations/base.ts @@ -360,22 +360,11 @@ export abstract class BaseOperationHandler { const createdEntity = await this.executeQueryTakeFirst(kysely, query, 'create'); - // let createdEntity: any; - // try { - // createdEntity = await this.executeQueryTakeFirst(kysely, query, 'create'); - // } catch (err) { - // const { sql, parameters } = query.compile(); - // throw new QueryError(`Error during create: ${err}, sql: ${sql}, parameters: ${parameters}`); - // } - if (Object.keys(postCreateRelations).length > 0) { // process nested creates that need to happen after the current entity is created - const relationPromises = Object.entries(postCreateRelations).map(([field, subPayload]) => { - return this.processNoneOwnedRelationForCreate(kysely, model, field, subPayload, createdEntity); - }); - - // await relation creation - await Promise.all(relationPromises); + for (const [field, subPayload] of Object.entries(postCreateRelations)) { + await this.processNoneOwnedRelationForCreate(kysely, model, field, subPayload, createdEntity); + } } if (fromRelation && m2m) { @@ -605,7 +594,7 @@ export abstract class BaseOperationHandler { return result; } - private processNoneOwnedRelationForCreate( + private async processNoneOwnedRelationForCreate( kysely: ToKysely, contextModel: GetModels, relationFieldName: string, @@ -614,7 +603,6 @@ export abstract class BaseOperationHandler { ) { const relationFieldDef = this.requireField(contextModel, relationFieldName); const relationModel = relationFieldDef.type as GetModels; - const tasks: Promise[] = []; const fromRelationContext: FromRelationContext = { model: contextModel, field: relationFieldName, @@ -629,43 +617,38 @@ export abstract class BaseOperationHandler { switch (action) { case 'create': { // create with a parent entity - tasks.push( - ...enumerate(subPayload).map((item) => - this.create(kysely, relationModel, item, fromRelationContext), - ), - ); + for (const item of enumerate(subPayload)) { + await this.create(kysely, relationModel, item, fromRelationContext); + } break; } case 'createMany': { invariant(relationFieldDef.array, 'relation must be an array for createMany'); - tasks.push( - this.createMany( - kysely, - relationModel, - subPayload as { data: any; skipDuplicates: boolean }, - false, - fromRelationContext, - ), + await this.createMany( + kysely, + relationModel, + subPayload as { data: any; skipDuplicates: boolean }, + false, + fromRelationContext, ); break; } case 'connect': { - tasks.push(this.connectRelation(kysely, relationModel, subPayload, fromRelationContext)); + await this.connectRelation(kysely, relationModel, subPayload, fromRelationContext); break; } case 'connectOrCreate': { - tasks.push( - ...enumerate(subPayload).map((item) => - this.exists(kysely, relationModel, item.where).then((found) => - !found - ? this.create(kysely, relationModel, item.create, fromRelationContext) - : this.connectRelation(kysely, relationModel, found, fromRelationContext), - ), - ), - ); + for (const item of enumerate(subPayload)) { + const found = await this.exists(kysely, relationModel, item.where); + if (!found) { + await this.create(kysely, relationModel, item.create, fromRelationContext); + } else { + await this.connectRelation(kysely, relationModel, found, fromRelationContext); + } + } break; } @@ -673,8 +656,6 @@ export abstract class BaseOperationHandler { throw new QueryError(`Invalid relation action: ${action}`); } } - - return Promise.all(tasks); } protected async createMany< @@ -1366,7 +1347,6 @@ export abstract class BaseOperationHandler { args: any, throwIfNotFound: boolean, ) { - const tasks: Promise[] = []; const fieldModel = fieldDef.type as GetModels; const fromRelationContext: FromRelationContext = { model, @@ -1382,117 +1362,101 @@ export abstract class BaseOperationHandler { !Array.isArray(value) || fieldDef.array, 'relation must be an array if create is an array', ); - tasks.push( - ...enumerate(value).map((item) => this.create(kysely, fieldModel, item, fromRelationContext)), - ); + for (const item of enumerate(value)) { + await this.create(kysely, fieldModel, item, fromRelationContext); + } break; } case 'createMany': { invariant(fieldDef.array, 'relation must be an array for createMany'); - tasks.push( - this.createMany( - kysely, - fieldModel, - value as { data: any; skipDuplicates: boolean }, - false, - fromRelationContext, - ), + await this.createMany( + kysely, + fieldModel, + value as { data: any; skipDuplicates: boolean }, + false, + fromRelationContext, ); break; } case 'connect': { - tasks.push(this.connectRelation(kysely, fieldModel, value, fromRelationContext)); + await this.connectRelation(kysely, fieldModel, value, fromRelationContext); break; } case 'connectOrCreate': { - tasks.push(this.connectOrCreateRelation(kysely, fieldModel, value, fromRelationContext)); + await this.connectOrCreateRelation(kysely, fieldModel, value, fromRelationContext); break; } case 'disconnect': { - tasks.push(this.disconnectRelation(kysely, fieldModel, value, fromRelationContext)); + await this.disconnectRelation(kysely, fieldModel, value, fromRelationContext); break; } case 'set': { invariant(fieldDef.array, 'relation must be an array'); - tasks.push(this.setRelation(kysely, fieldModel, value, fromRelationContext)); + await this.setRelation(kysely, fieldModel, value, fromRelationContext); break; } case 'update': { - tasks.push( - ...(enumerate(value) as { where: any; data: any }[]).map((item) => { - let where; - let data; - if ('data' in item && typeof item.data === 'object') { - where = item.where; - data = item.data; - } else { - where = undefined; - data = item; - } - return this.update( - kysely, - fieldModel, - where, - data, - fromRelationContext, - true, - throwIfNotFound, - ); - }), - ); + for (const _item of enumerate(value)) { + const item = _item as { where: any; data: any }; + let where; + let data; + if ('data' in item && typeof item.data === 'object') { + where = item.where; + data = item.data; + } else { + where = undefined; + data = item; + } + await this.update(kysely, fieldModel, where, data, fromRelationContext, true, throwIfNotFound); + } break; } case 'upsert': { - tasks.push( - ...( - enumerate(value) as { - where: any; - create: any; - update: any; - }[] - ).map(async (item) => { - const updated = await this.update( - kysely, - fieldModel, - item.where, - item.update, - fromRelationContext, - true, - false, - ); - if (updated) { - return updated; - } else { - return this.create(kysely, fieldModel, item.create, fromRelationContext); - } - }), - ); + for (const _item of enumerate(value)) { + const item = _item as { + where: any; + create: any; + update: any; + }; + + const updated = await this.update( + kysely, + fieldModel, + item.where, + item.update, + fromRelationContext, + true, + false, + ); + if (!updated) { + await this.create(kysely, fieldModel, item.create, fromRelationContext); + } + } break; } case 'updateMany': { - tasks.push( - ...(enumerate(value) as { where: any; data: any }[]).map((item) => - this.update(kysely, fieldModel, item.where, item.data, fromRelationContext, false, false), - ), - ); + for (const _item of enumerate(value)) { + const item = _item as { where: any; data: any }; + await this.update(kysely, fieldModel, item.where, item.data, fromRelationContext, false, false); + } break; } case 'delete': { - tasks.push(this.deleteRelation(kysely, fieldModel, value, fromRelationContext, true)); + await this.deleteRelation(kysely, fieldModel, value, fromRelationContext, true); break; } case 'deleteMany': { - tasks.push(this.deleteRelation(kysely, fieldModel, value, fromRelationContext, false)); + await this.deleteRelation(kysely, fieldModel, value, fromRelationContext, false); break; } @@ -1502,8 +1466,6 @@ export abstract class BaseOperationHandler { } } - await Promise.all(tasks); - return fromRelationContext.parentUpdates; } @@ -1523,9 +1485,13 @@ export abstract class BaseOperationHandler { const m2m = getManyToManyRelation(this.schema, fromRelation.model, fromRelation.field); if (m2m) { // handle many-to-many relation - const actions = _data.map(async (d) => { + const results: (unknown | undefined)[] = []; + for (const d of _data) { const ids = await this.getEntityIds(kysely, model, d); - return this.handleManyToManyRelation( + if (!ids) { + throw new NotFoundError(model); + } + const r = await this.handleManyToManyRelation( kysely, 'connect', fromRelation.model, @@ -1536,8 +1502,8 @@ export abstract class BaseOperationHandler { ids, m2m.joinTable, ); - }); - const results = await Promise.all(actions); + results.push(r); + } // validate connect result if (_data.length > results.filter((r) => !!r).length) { @@ -1622,16 +1588,14 @@ export abstract class BaseOperationHandler { return; } - return Promise.all( - _data.map(async ({ where, create }) => { - const existing = await this.exists(kysely, model, where); - if (existing) { - return this.connectRelation(kysely, model, [where], fromRelation); - } else { - return this.create(kysely, model, create, fromRelation); - } - }), - ); + for (const { where, create } of _data) { + const existing = await this.exists(kysely, model, where); + if (existing) { + await this.connectRelation(kysely, model, [where], fromRelation); + } else { + await this.create(kysely, model, create, fromRelation); + } + } } protected async disconnectRelation( @@ -1662,13 +1626,13 @@ export abstract class BaseOperationHandler { const m2m = getManyToManyRelation(this.schema, fromRelation.model, fromRelation.field); if (m2m) { // handle many-to-many relation - const actions = disconnectConditions.map(async (d) => { + for (const d of disconnectConditions) { const ids = await this.getEntityIds(kysely, model, d); if (!ids) { // not found return; } - return this.handleManyToManyRelation( + await this.handleManyToManyRelation( kysely, 'disconnect', fromRelation.model, @@ -1679,8 +1643,7 @@ export abstract class BaseOperationHandler { ids, m2m.joinTable, ); - }); - await Promise.all(actions); + } } else { const { ownedByModel, keyPairs } = getRelationForeignKeyFieldPairs( this.schema, @@ -1769,21 +1732,26 @@ export abstract class BaseOperationHandler { await this.resetManyToManyRelation(kysely, fromRelation.model, fromRelation.field, fromRelation.ids); // connect new entities - const actions = _data.map(async (d) => { + const results: (unknown | undefined)[] = []; + for (const d of _data) { const ids = await this.getEntityIds(kysely, model, d); - return this.handleManyToManyRelation( - kysely, - 'connect', - fromRelation.model, - fromRelation.field, - fromRelation.ids, - m2m.otherModel, - m2m.otherField, - ids, - m2m.joinTable, + if (!ids) { + throw new NotFoundError(model); + } + results.push( + await this.handleManyToManyRelation( + kysely, + 'connect', + fromRelation.model, + fromRelation.field, + fromRelation.ids, + m2m.otherModel, + m2m.otherField, + ids, + m2m.joinTable, + ), ); - }); - const results = await Promise.all(actions); + } // validate connect result if (_data.length > results.filter((r) => !!r).length) { diff --git a/packages/runtime/src/client/functions.ts b/packages/runtime/src/client/functions.ts index 6b548e8d..35390916 100644 --- a/packages/runtime/src/client/functions.ts +++ b/packages/runtime/src/client/functions.ts @@ -1,49 +1,68 @@ import { invariant, lowerCaseFirst, upperCaseFirst } from '@zenstackhq/common-helpers'; -import { sql, ValueNode, type Expression, type ExpressionBuilder } from 'kysely'; +import { sql, ValueNode, type BinaryOperator, type Expression, type ExpressionBuilder } from 'kysely'; import { match } from 'ts-pattern'; import type { ZModelFunction, ZModelFunctionContext } from './options'; // TODO: migrate default value generation functions to here too -export const contains: ZModelFunction = (eb: ExpressionBuilder, args: Expression[]) => { - const [field, search, caseInsensitive = false] = args; - if (!field) { - throw new Error('"field" parameter is required'); - } - if (!search) { - throw new Error('"search" parameter is required'); - } - const searchExpr = eb.fn('CONCAT', [sql.lit('%'), search, sql.lit('%')]); - return eb(field, caseInsensitive ? 'ilike' : 'like', searchExpr); -}; +export const contains: ZModelFunction = (eb, args, context) => textMatch(eb, args, context, 'contains'); export const search: ZModelFunction = (_eb: ExpressionBuilder, _args: Expression[]) => { throw new Error(`"search" function is not implemented yet`); }; -export const startsWith: ZModelFunction = (eb: ExpressionBuilder, args: Expression[]) => { - const [field, search] = args; +export const startsWith: ZModelFunction = (eb, args, context) => textMatch(eb, args, context, 'startsWith'); + +export const endsWith: ZModelFunction = (eb, args, context) => textMatch(eb, args, context, 'endsWith'); + +const textMatch = ( + eb: ExpressionBuilder, + args: Expression[], + { dialect }: ZModelFunctionContext, + method: 'contains' | 'startsWith' | 'endsWith', +) => { + const [field, search, caseInsensitive = undefined] = args; if (!field) { throw new Error('"field" parameter is required'); } if (!search) { throw new Error('"search" parameter is required'); } - return eb(field, 'like', eb.fn('CONCAT', [search, sql.lit('%')])); -}; -export const endsWith: ZModelFunction = (eb: ExpressionBuilder, args: Expression[]) => { - const [field, search] = args; - if (!field) { - throw new Error('"field" parameter is required'); - } - if (!search) { - throw new Error('"search" parameter is required'); + const casingBehavior = dialect.getStringCasingBehavior(); + const caseInsensitiveValue = readBoolean(caseInsensitive, false); + let op: BinaryOperator; + let fieldExpr = field; + let searchExpr = search; + + if (caseInsensitiveValue) { + // case-insensitive search + if (casingBehavior.supportsILike) { + // use ILIKE if supported + op = 'ilike'; + } else { + // otherwise change both sides to lower case + op = 'like'; + if (casingBehavior.likeCaseSensitive === true) { + fieldExpr = eb.fn('LOWER', [fieldExpr]); + searchExpr = eb.fn('LOWER', [searchExpr]); + } + } + } else { + // case-sensitive search, just use LIKE and deliver whatever the database's behavior is + op = 'like'; } - return eb(field, 'like', eb.fn('CONCAT', [sql.lit('%'), search])); + + searchExpr = match(method) + .with('contains', () => eb.fn('CONCAT', [sql.lit('%'), sql`CAST(${searchExpr} as text)`, sql.lit('%')])) + .with('startsWith', () => eb.fn('CONCAT', [sql`CAST(${searchExpr} as text)`, sql.lit('%')])) + .with('endsWith', () => eb.fn('CONCAT', [sql.lit('%'), sql`CAST(${searchExpr} as text)`])) + .exhaustive(); + + return eb(fieldExpr, op, searchExpr); }; -export const has: ZModelFunction = (eb: ExpressionBuilder, args: Expression[]) => { +export const has: ZModelFunction = (eb, args) => { const [field, search] = args; if (!field) { throw new Error('"field" parameter is required'); @@ -65,7 +84,7 @@ export const hasEvery: ZModelFunction = (eb: ExpressionBuilder, a return eb(field, '@>', search); }; -export const hasSome: ZModelFunction = (eb: ExpressionBuilder, args: Expression[]) => { +export const hasSome: ZModelFunction = (eb, args) => { const [field, search] = args; if (!field) { throw new Error('"field" parameter is required'); @@ -76,11 +95,7 @@ export const hasSome: ZModelFunction = (eb: ExpressionBuilder, ar return eb(field, '&&', search); }; -export const isEmpty: ZModelFunction = ( - eb: ExpressionBuilder, - args: Expression[], - { dialect }: ZModelFunctionContext, -) => { +export const isEmpty: ZModelFunction = (eb, args, { dialect }: ZModelFunctionContext) => { const [field] = args; if (!field) { throw new Error('"field" parameter is required'); @@ -88,22 +103,9 @@ export const isEmpty: ZModelFunction = ( return eb(dialect.buildArrayLength(eb, field), '=', sql.lit(0)); }; -export const now: ZModelFunction = ( - eb: ExpressionBuilder, - _args: Expression[], - { dialect }: ZModelFunctionContext, -) => { - return match(dialect.provider) - .with('postgresql', () => eb.fn('now')) - .with('sqlite', () => sql.raw('CURRENT_TIMESTAMP')) - .exhaustive(); -}; +export const now: ZModelFunction = () => sql.raw('CURRENT_TIMESTAMP'); -export const currentModel: ZModelFunction = ( - _eb: ExpressionBuilder, - args: Expression[], - { model }: ZModelFunctionContext, -) => { +export const currentModel: ZModelFunction = (_eb, args, { model }: ZModelFunctionContext) => { let result = model; const [casing] = args; if (casing) { @@ -112,11 +114,7 @@ export const currentModel: ZModelFunction = ( return sql.lit(result); }; -export const currentOperation: ZModelFunction = ( - _eb: ExpressionBuilder, - args: Expression[], - { operation }: ZModelFunctionContext, -) => { +export const currentOperation: ZModelFunction = (_eb, args, { operation }: ZModelFunctionContext) => { let result: string = operation; const [casing] = args; if (casing) { @@ -141,3 +139,12 @@ function processCasing(casing: Expression, result: string, model: string) { }); return result; } + +function readBoolean(expr: Expression | undefined, defaultValue: boolean) { + if (expr === undefined) { + return defaultValue; + } + const opNode = expr.toOperationNode(); + invariant(ValueNode.is(opNode), 'expression must be a literal value'); + return !!opNode.value; +} diff --git a/packages/runtime/src/client/options.ts b/packages/runtime/src/client/options.ts index ad7df8f0..7d1134a3 100644 --- a/packages/runtime/src/client/options.ts +++ b/packages/runtime/src/client/options.ts @@ -62,6 +62,16 @@ export type ClientOptions = { * Logging configuration. */ log?: KyselyConfig['log']; + + /** + * Whether to automatically fix timezone for `DateTime` fields returned by node-pg. Defaults + * to `true`. + * + * Node-pg has a terrible quirk that it interprets the date value as local timezone (as a + * `Date` object) although for `DateTime` field the data in DB is stored in UTC. + * @see https://github.com/brianc/node-postgres/issues/429 + */ + fixPostgresTimezone?: boolean; } & (HasComputedFields extends true ? { /** diff --git a/packages/runtime/src/client/result-processor.ts b/packages/runtime/src/client/result-processor.ts index 96b3de64..a7870bab 100644 --- a/packages/runtime/src/client/result-processor.ts +++ b/packages/runtime/src/client/result-processor.ts @@ -1,12 +1,18 @@ -import { invariant } from '@zenstackhq/common-helpers'; -import Decimal from 'decimal.js'; -import { match } from 'ts-pattern'; import type { BuiltinType, FieldDef, GetModels, SchemaDef } from '../schema'; import { DELEGATE_JOINED_FIELD_PREFIX } from './constants'; +import { getCrudDialect } from './crud/dialects'; +import type { BaseCrudDialect } from './crud/dialects/base-dialect'; +import type { ClientOptions } from './options'; import { ensureArray, getField, getIdValues } from './query-utils'; export class ResultProcessor { - constructor(private readonly schema: Schema) {} + private dialect: BaseCrudDialect; + constructor( + private readonly schema: Schema, + options: ClientOptions, + ) { + this.dialect = getCrudDialect(schema, options); + } processResult(data: any, model: GetModels, args?: any) { const result = this.doProcessResult(data, model); @@ -43,7 +49,7 @@ export class ResultProcessor { // merge delegate descendant fields if (value) { // descendant fields are packed as JSON - const subRow = this.transformJson(value); + const subRow = this.dialect.transformOutput(value, 'Json'); // process the sub-row const subModel = key.slice(DELEGATE_JOINED_FIELD_PREFIX.length) as GetModels; @@ -87,10 +93,10 @@ export class ResultProcessor { private processFieldValue(value: unknown, fieldDef: FieldDef) { const type = fieldDef.type as BuiltinType; if (Array.isArray(value)) { - value.forEach((v, i) => (value[i] = this.transformScalar(v, type))); + value.forEach((v, i) => (value[i] = this.dialect.transformOutput(v, type))); return value; } else { - return this.transformScalar(value, type); + return this.dialect.transformOutput(value, type); } } @@ -107,62 +113,6 @@ export class ResultProcessor { return this.doProcessResult(relationData, fieldDef.type as GetModels); } - private transformScalar(value: unknown, type: BuiltinType) { - if (this.schema.typeDefs && type in this.schema.typeDefs) { - // typed JSON field - return this.transformJson(value); - } else { - return match(type) - .with('Boolean', () => this.transformBoolean(value)) - .with('DateTime', () => this.transformDate(value)) - .with('Bytes', () => this.transformBytes(value)) - .with('Decimal', () => this.transformDecimal(value)) - .with('BigInt', () => this.transformBigInt(value)) - .with('Json', () => this.transformJson(value)) - .otherwise(() => value); - } - } - - private transformDecimal(value: unknown) { - if (value instanceof Decimal) { - return value; - } - invariant( - typeof value === 'string' || typeof value === 'number' || value instanceof Decimal, - `Expected string, number or Decimal, got ${typeof value}`, - ); - return new Decimal(value); - } - - private transformBigInt(value: unknown) { - if (typeof value === 'bigint') { - return value; - } - invariant( - typeof value === 'string' || typeof value === 'number', - `Expected string or number, got ${typeof value}`, - ); - return BigInt(value); - } - - private transformBoolean(value: unknown) { - return !!value; - } - - private transformDate(value: unknown) { - if (typeof value === 'number') { - return new Date(value); - } else if (typeof value === 'string') { - return new Date(Date.parse(value)); - } else { - return value; - } - } - - private transformBytes(value: unknown) { - return Buffer.isBuffer(value) ? Uint8Array.from(value) : value; - } - private fixReversedResult(data: any, model: GetModels, args: any) { if (!data) { return; @@ -190,14 +140,4 @@ export class ResultProcessor { } } } - - private transformJson(value: unknown) { - return match(this.schema.provider.type) - .with('sqlite', () => { - // better-sqlite3 returns JSON as string - invariant(typeof value === 'string', 'Expected string, got ' + typeof value); - return JSON.parse(value as string); - }) - .otherwise(() => value); - } } diff --git a/packages/runtime/src/plugins/policy/expression-transformer.ts b/packages/runtime/src/plugins/policy/expression-transformer.ts index 8502e33b..80fa4b09 100644 --- a/packages/runtime/src/plugins/policy/expression-transformer.ts +++ b/packages/runtime/src/plugins/policy/expression-transformer.ts @@ -196,16 +196,27 @@ export class ExpressionTransformer { } if (this.isNullNode(right)) { - return expr.op === '==' - ? BinaryOperationNode.create(left, OperatorNode.create('is'), right) - : BinaryOperationNode.create(left, OperatorNode.create('is not'), right); + return this.transformNullCheck(left, expr.op); } else if (this.isNullNode(left)) { - return expr.op === '==' - ? BinaryOperationNode.create(right, OperatorNode.create('is'), ValueNode.createImmediate(null)) - : BinaryOperationNode.create(right, OperatorNode.create('is not'), ValueNode.createImmediate(null)); + return this.transformNullCheck(right, expr.op); + } else { + return BinaryOperationNode.create(left, this.transformOperator(op), right); } + } - return BinaryOperationNode.create(left, this.transformOperator(op), right); + private transformNullCheck(expr: OperationNode, operator: BinaryOperator) { + invariant(operator === '==' || operator === '!=', 'operator must be "==" or "!=" for null comparison'); + if (ValueNode.is(expr)) { + if (expr.value === null) { + return operator === '==' ? trueNode(this.dialect) : falseNode(this.dialect); + } else { + return operator === '==' ? falseNode(this.dialect) : trueNode(this.dialect); + } + } else { + return operator === '==' + ? BinaryOperationNode.create(expr, OperatorNode.create('is'), ValueNode.createImmediate(null)) + : BinaryOperationNode.create(expr, OperatorNode.create('is not'), ValueNode.createImmediate(null)); + } } private normalizeBinaryOperationOperands(expr: BinaryExpression, context: ExpressionTransformerContext) { diff --git a/packages/runtime/src/plugins/policy/policy-handler.ts b/packages/runtime/src/plugins/policy/policy-handler.ts index 2c582562..50cfc835 100644 --- a/packages/runtime/src/plugins/policy/policy-handler.ts +++ b/packages/runtime/src/plugins/policy/policy-handler.ts @@ -19,6 +19,7 @@ import { ReturningNode, SelectionNode, SelectQueryNode, + sql, TableNode, UpdateQueryNode, ValueListNode, @@ -362,11 +363,13 @@ export class PolicyHandler extends OperationNodeTransf values: OperationNode[], proceed: ProceedKyselyQueryFunction, ) { - const allFields = Object.keys(requireModel(this.client.$schema, model).fields); + const allFields = Object.entries(requireModel(this.client.$schema, model).fields).filter( + ([, def]) => !def.relation, + ); const allValues: OperationNode[] = []; - for (const fieldName of allFields) { - const index = fields.indexOf(fieldName); + for (const [name, _def] of allFields) { + const index = fields.indexOf(name); if (index >= 0) { allValues.push(values[index]!); } else { @@ -376,6 +379,8 @@ export class PolicyHandler extends OperationNodeTransf } // create a `SELECT column1 as field1, column2 as field2, ... FROM (VALUES (...))` table for policy evaluation + const eb = expressionBuilder(); + const constTable: SelectQueryNode = { kind: 'SelectQueryNode', from: FromNode.create([ @@ -384,11 +389,13 @@ export class PolicyHandler extends OperationNodeTransf IdentifierNode.create('$t'), ), ]), - selections: allFields.map((field, index) => - SelectionNode.create( - AliasNode.create(ColumnNode.create(`column${index + 1}`), IdentifierNode.create(field)), - ), - ), + selections: allFields.map(([name, def], index) => { + const castedColumnRef = + sql`CAST(${eb.ref(`column${index + 1}`)} as ${sql.raw(this.dialect.getFieldSqlType(def))})`.as( + name, + ); + return SelectionNode.create(castedColumnRef.toOperationNode()); + }), }; const filter = this.buildPolicyFilter(model, undefined, 'create'); diff --git a/packages/runtime/test/client-api/aggregate.test.ts b/packages/runtime/test/client-api/aggregate.test.ts index 0c8ffd27..6b7edd64 100644 --- a/packages/runtime/test/client-api/aggregate.test.ts +++ b/packages/runtime/test/client-api/aggregate.test.ts @@ -1,16 +1,14 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; import { schema } from '../schemas/basic'; -import { createClientSpecs } from './client-specs'; import { createUser } from './utils'; +import { createTestClient } from '../utils'; -const PG_DB_NAME = 'client-api-aggregate-tests'; - -describe.each(createClientSpecs(PG_DB_NAME))('Client aggregate tests', ({ createClient }) => { +describe('Client aggregate tests', () => { let client: ClientContract; beforeEach(async () => { - client = await createClient(); + client = await createTestClient(schema); }); afterEach(async () => { diff --git a/packages/runtime/test/client-api/client-specs.ts b/packages/runtime/test/client-api/client-specs.ts deleted file mode 100644 index 59e50d5d..00000000 --- a/packages/runtime/test/client-api/client-specs.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { LogEvent } from 'kysely'; -import { getSchema, schema } from '../schemas/basic'; -import { makePostgresClient, makeSqliteClient } from '../utils'; -import type { ClientContract } from '../../src'; - -export function createClientSpecs(dbName: string, logQueries = false, providers: string[] = ['sqlite', 'postgresql']) { - const logger = (provider: string) => (event: LogEvent) => { - if (event.level === 'query') { - console.log(`query(${provider}):`, event.query.sql, event.query.parameters); - } - }; - return [ - ...(providers.includes('sqlite') - ? [ - { - provider: 'sqlite' as const, - schema: getSchema('sqlite'), - createClient: async (): Promise> => { - // tsc perf - return makeSqliteClient(getSchema('sqlite'), { - log: logQueries ? logger('sqlite') : undefined, - }) as unknown as ClientContract; - }, - }, - ] - : []), - ...(providers.includes('postgresql') - ? [ - { - provider: 'postgresql' as const, - schema: getSchema('postgresql'), - createClient: async (): Promise> => { - // tsc perf - return makePostgresClient(getSchema('postgresql'), dbName, { - log: logQueries ? logger('postgresql') : undefined, - }) as unknown as ClientContract; - }, - }, - ] - : []), - ] as const; -} diff --git a/packages/runtime/test/client-api/computed-fields.test.ts b/packages/runtime/test/client-api/computed-fields.test.ts index 5bf3c16a..0ece9ddf 100644 --- a/packages/runtime/test/client-api/computed-fields.test.ts +++ b/packages/runtime/test/client-api/computed-fields.test.ts @@ -2,121 +2,113 @@ import { sql } from 'kysely'; import { afterEach, describe, expect, it } from 'vitest'; import { createTestClient } from '../utils'; -const TEST_DB = 'client-api-computed-fields'; +describe('Computed fields tests', () => { + let db: any; -describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as const }])( - 'Computed fields tests', - ({ provider }) => { - let db: any; - - afterEach(async () => { - await db?.$disconnect(); - }); + afterEach(async () => { + await db?.$disconnect(); + }); - it('works with non-optional fields', async () => { - db = await createTestClient( - ` + it('works with non-optional fields', async () => { + db = await createTestClient( + ` model User { id Int @id @default(autoincrement()) name String upperName String @computed } `, - { - provider, - dbName: TEST_DB, - computedFields: { - User: { - upperName: (eb: any) => eb.fn('upper', ['name']), - }, + { + computedFields: { + User: { + upperName: (eb: any) => eb.fn('upper', ['name']), }, - } as any, - ); - - await expect( - db.user.create({ - data: { id: 1, name: 'Alex' }, - }), - ).resolves.toMatchObject({ - upperName: 'ALEX', - }); + }, + } as any, + ); + + await expect( + db.user.create({ + data: { id: 1, name: 'Alex' }, + }), + ).resolves.toMatchObject({ + upperName: 'ALEX', + }); - await expect( - db.user.findUnique({ - where: { id: 1 }, - select: { upperName: true }, - }), - ).resolves.toMatchObject({ - upperName: 'ALEX', - }); + await expect( + db.user.findUnique({ + where: { id: 1 }, + select: { upperName: true }, + }), + ).resolves.toMatchObject({ + upperName: 'ALEX', + }); - await expect( - db.user.findFirst({ - where: { upperName: 'ALEX' }, - }), - ).resolves.toMatchObject({ - upperName: 'ALEX', - }); + await expect( + db.user.findFirst({ + where: { upperName: 'ALEX' }, + }), + ).resolves.toMatchObject({ + upperName: 'ALEX', + }); - await expect( - db.user.findFirst({ - where: { upperName: 'Alex' }, - }), - ).toResolveNull(); + await expect( + db.user.findFirst({ + where: { upperName: 'Alex' }, + }), + ).toResolveNull(); + + await expect( + db.user.findFirst({ + orderBy: { upperName: 'desc' }, + }), + ).resolves.toMatchObject({ + upperName: 'ALEX', + }); - await expect( - db.user.findFirst({ - orderBy: { upperName: 'desc' }, - }), - ).resolves.toMatchObject({ - upperName: 'ALEX', - }); + await expect( + db.user.findFirst({ + orderBy: { upperName: 'desc' }, + take: 1, + }), + ).resolves.toMatchObject({ + upperName: 'ALEX', + }); - await expect( - db.user.findFirst({ - orderBy: { upperName: 'desc' }, - take: 1, - }), - ).resolves.toMatchObject({ - upperName: 'ALEX', - }); + await expect( + db.user.aggregate({ + _count: { upperName: true }, + }), + ).resolves.toMatchObject({ + _count: { upperName: 1 }, + }); - await expect( - db.user.aggregate({ - _count: { upperName: true }, - }), - ).resolves.toMatchObject({ + await expect( + db.user.groupBy({ + by: ['upperName'], + _count: { upperName: true }, + _max: { upperName: true }, + }), + ).resolves.toEqual([ + expect.objectContaining({ _count: { upperName: 1 }, - }); - - await expect( - db.user.groupBy({ - by: ['upperName'], - _count: { upperName: true }, - _max: { upperName: true }, - }), - ).resolves.toEqual([ - expect.objectContaining({ - _count: { upperName: 1 }, - _max: { upperName: 'ALEX' }, - }), - ]); - }); + _max: { upperName: 'ALEX' }, + }), + ]); + }); - it('is typed correctly for non-optional fields', async () => { - db = await createTestClient( - ` + it('is typed correctly for non-optional fields', async () => { + db = await createTestClient( + ` model User { id Int @id @default(autoincrement()) name String upperName String @computed } `, - { - provider, - dbName: TEST_DB, - extraSourceFiles: { - main: ` + { + extraSourceFiles: { + main: ` import { ZenStackClient } from '@zenstackhq/runtime'; import { schema } from './schema'; @@ -140,54 +132,50 @@ async function main() { main(); `, - }, }, - ); - }); + }, + ); + }); - it('works with optional fields', async () => { - db = await createTestClient( - ` + it('works with optional fields', async () => { + db = await createTestClient( + ` model User { id Int @id @default(autoincrement()) name String upperName String? @computed } `, - { - provider, - dbName: TEST_DB, - computedFields: { - User: { - upperName: (eb: any) => eb.lit(null), - }, + { + computedFields: { + User: { + upperName: (eb: any) => eb.lit(null), }, - } as any, - ); - - await expect( - db.user.create({ - data: { id: 1, name: 'Alex' }, - }), - ).resolves.toMatchObject({ - upperName: null, - }); + }, + } as any, + ); + + await expect( + db.user.create({ + data: { id: 1, name: 'Alex' }, + }), + ).resolves.toMatchObject({ + upperName: null, }); + }); - it('is typed correctly for optional fields', async () => { - db = await createTestClient( - ` + it('is typed correctly for optional fields', async () => { + db = await createTestClient( + ` model User { id Int @id @default(autoincrement()) name String upperName String? @computed } `, - { - provider, - dbName: TEST_DB, - extraSourceFiles: { - main: ` + { + extraSourceFiles: { + main: ` import { ZenStackClient } from '@zenstackhq/runtime'; import { schema } from './schema'; @@ -210,14 +198,14 @@ async function main() { main(); `, - }, }, - ); - }); + }, + ); + }); - it('works with read from a relation', async () => { - db = await createTestClient( - ` + it('works with read from a relation', async () => { + db = await createTestClient( + ` model User { id Int @id @default(autoincrement()) name String @@ -232,28 +220,25 @@ model Post { authorId Int } `, - { - provider, - dbName: TEST_DB, - computedFields: { - User: { - postCount: (eb: any, context: { modelAlias: string }) => - eb - .selectFrom('Post') - .whereRef('Post.authorId', '=', sql.ref(`${context.modelAlias}.id`)) - .select(() => eb.fn.countAll().as('count')), - }, + { + computedFields: { + User: { + postCount: (eb: any, context: { modelAlias: string }) => + eb + .selectFrom('Post') + .whereRef('Post.authorId', '=', sql.ref(`${context.modelAlias}.id`)) + .select(() => eb.fn.countAll().as('count')), }, - } as any, - ); + }, + } as any, + ); - await db.user.create({ - data: { id: 1, name: 'Alex', posts: { create: { title: 'Post1' } } }, - }); + await db.user.create({ + data: { id: 1, name: 'Alex', posts: { create: { title: 'Post1' } } }, + }); - await expect(db.post.findFirst({ select: { id: true, author: true } })).resolves.toMatchObject({ - author: expect.objectContaining({ postCount: 1 }), - }); + await expect(db.post.findFirst({ select: { id: true, author: true } })).resolves.toMatchObject({ + author: expect.objectContaining({ postCount: 1 }), }); - }, -); + }); +}); diff --git a/packages/runtime/test/client-api/count.test.ts b/packages/runtime/test/client-api/count.test.ts index 743b4169..22a89ddc 100644 --- a/packages/runtime/test/client-api/count.test.ts +++ b/packages/runtime/test/client-api/count.test.ts @@ -1,15 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; import { schema } from '../schemas/basic'; -import { createClientSpecs } from './client-specs'; +import { createTestClient } from '../utils'; -const PG_DB_NAME = 'client-api-count-tests'; - -describe.each(createClientSpecs(PG_DB_NAME))('Client count tests', ({ createClient }) => { +describe('Client count tests', () => { let client: ClientContract; beforeEach(async () => { - client = await createClient(); + client = await createTestClient(schema); }); afterEach(async () => { diff --git a/packages/runtime/test/client-api/create-many-and-return.test.ts b/packages/runtime/test/client-api/create-many-and-return.test.ts index 29d5887e..be2a46e8 100644 --- a/packages/runtime/test/client-api/create-many-and-return.test.ts +++ b/packages/runtime/test/client-api/create-many-and-return.test.ts @@ -1,15 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; import { schema } from '../schemas/basic'; -import { createClientSpecs } from './client-specs'; +import { createTestClient } from '../utils'; -const PG_DB_NAME = 'client-api-create-many-and-return-tests'; - -describe.each(createClientSpecs(PG_DB_NAME))('Client createManyAndReturn tests', ({ createClient }) => { +describe('Client createManyAndReturn tests', () => { let client: ClientContract; beforeEach(async () => { - client = await createClient(); + client = await createTestClient(schema); }); afterEach(async () => { diff --git a/packages/runtime/test/client-api/create-many.test.ts b/packages/runtime/test/client-api/create-many.test.ts index d25d8587..3ccbbe73 100644 --- a/packages/runtime/test/client-api/create-many.test.ts +++ b/packages/runtime/test/client-api/create-many.test.ts @@ -1,15 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; import { schema } from '../schemas/basic'; -import { createClientSpecs } from './client-specs'; +import { createTestClient } from '../utils'; -const PG_DB_NAME = 'client-api-create-many-tests'; - -describe.each(createClientSpecs(PG_DB_NAME))('Client createMany tests', ({ createClient }) => { +describe('Client createMany tests', () => { let client: ClientContract; beforeEach(async () => { - client = await createClient(); + client = await createTestClient(schema); }); afterEach(async () => { diff --git a/packages/runtime/test/client-api/create.test.ts b/packages/runtime/test/client-api/create.test.ts index 8cd692fd..41ab341c 100644 --- a/packages/runtime/test/client-api/create.test.ts +++ b/packages/runtime/test/client-api/create.test.ts @@ -1,15 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; import { schema } from '../schemas/basic'; -import { createClientSpecs } from './client-specs'; +import { createTestClient } from '../utils'; -const PG_DB_NAME = 'client-api-create-tests'; - -describe.each(createClientSpecs(PG_DB_NAME))('Client create tests', ({ createClient }) => { +describe('Client create tests', () => { let client: ClientContract; beforeEach(async () => { - client = await createClient(); + client = await createTestClient(schema); }); afterEach(async () => { diff --git a/packages/runtime/test/client-api/delegate.test.ts b/packages/runtime/test/client-api/delegate.test.ts index a9ca705c..03b5b1e9 100644 --- a/packages/runtime/test/client-api/delegate.test.ts +++ b/packages/runtime/test/client-api/delegate.test.ts @@ -4,1169 +4,1162 @@ import type { ClientContract } from '../../src'; import { schema, type SchemaType } from '../schemas/delegate/schema'; import { createTestClient } from '../utils'; -const DB_NAME = `client-api-delegate-tests`; +describe('Delegate model tests ', () => { + let client: ClientContract; + + beforeEach(async () => { + client = await createTestClient( + schema, + { + usePrismaPush: true, + }, + path.join(__dirname, '../schemas/delegate/schema.zmodel'), + ); + }); + + afterEach(async () => { + await client.$disconnect(); + }); + + describe('Delegate create tests', () => { + it('works with create', async () => { + // delegate model cannot be created directly + await expect( + // @ts-expect-error + client.video.create({ + data: { + duration: 100, + url: 'abc', + videoType: 'MyVideo', + }, + }), + ).rejects.toThrow('is a delegate'); + await expect( + client.user.create({ + data: { + assets: { + // @ts-expect-error + create: { assetType: 'Video' }, + }, + }, + }), + ).rejects.toThrow('is a delegate'); -describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as const }])( - 'Delegate model tests for $provider', - ({ provider }) => { - let client: ClientContract; + // create entity with two levels of delegation + await expect( + client.ratedVideo.create({ + data: { + duration: 100, + url: 'abc', + rating: 5, + }, + }), + ).resolves.toMatchObject({ + id: expect.any(Number), + duration: 100, + url: 'abc', + rating: 5, + assetType: 'Video', + videoType: 'RatedVideo', + }); - beforeEach(async () => { - client = await createTestClient( - schema, - { - usePrismaPush: true, - provider, - dbName: provider === 'postgresql' ? DB_NAME : undefined, + // create entity with relation + await expect( + client.ratedVideo.create({ + data: { + duration: 50, + url: 'bcd', + rating: 5, + user: { create: { email: 'u1@example.com' } }, + }, + include: { user: true }, + }), + ).resolves.toMatchObject({ + userId: expect.any(Number), + user: { + email: 'u1@example.com', }, - path.join(__dirname, '../schemas/delegate/schema.zmodel'), - ); - }); - - afterEach(async () => { - await client.$disconnect(); - }); + }); - describe('Delegate create tests', () => { - it('works with create', async () => { - // delegate model cannot be created directly - await expect( - // @ts-expect-error - client.video.create({ - data: { - duration: 100, - url: 'abc', - videoType: 'MyVideo', - }, - }), - ).rejects.toThrow('is a delegate'); - await expect( - client.user.create({ - data: { - assets: { - // @ts-expect-error - create: { assetType: 'Video' }, - }, - }, - }), - ).rejects.toThrow('is a delegate'); - - // create entity with two levels of delegation - await expect( - client.ratedVideo.create({ - data: { - duration: 100, - url: 'abc', - rating: 5, - }, - }), - ).resolves.toMatchObject({ - id: expect.any(Number), - duration: 100, - url: 'abc', - rating: 5, - assetType: 'Video', - videoType: 'RatedVideo', - }); - - // create entity with relation - await expect( - client.ratedVideo.create({ - data: { - duration: 50, - url: 'bcd', - rating: 5, - user: { create: { email: 'u1@example.com' } }, + // create entity with one level of delegation + await expect( + client.image.create({ + data: { + format: 'png', + gallery: { + create: {}, }, - include: { user: true }, - }), - ).resolves.toMatchObject({ - userId: expect.any(Number), - user: { - email: 'u1@example.com', }, - }); - - // create entity with one level of delegation - await expect( - client.image.create({ - data: { - format: 'png', - gallery: { - create: {}, - }, - }, - }), - ).resolves.toMatchObject({ - id: expect.any(Number), - format: 'png', - galleryId: expect.any(Number), - assetType: 'Image', - }); + }), + ).resolves.toMatchObject({ + id: expect.any(Number), + format: 'png', + galleryId: expect.any(Number), + assetType: 'Image', }); + }); + + it('works with createMany', async () => { + await expect( + client.ratedVideo.createMany({ + data: [ + { viewCount: 1, duration: 100, url: 'abc', rating: 5 }, + { viewCount: 2, duration: 200, url: 'def', rating: 4 }, + ], + }), + ).resolves.toEqual({ count: 2 }); - it('works with createMany', async () => { - await expect( - client.ratedVideo.createMany({ - data: [ - { viewCount: 1, duration: 100, url: 'abc', rating: 5 }, - { viewCount: 2, duration: 200, url: 'def', rating: 4 }, - ], + await expect(client.ratedVideo.findMany()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + viewCount: 1, + duration: 100, + url: 'abc', + rating: 5, }), - ).resolves.toEqual({ count: 2 }); - - await expect(client.ratedVideo.findMany()).resolves.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - viewCount: 1, - duration: 100, - url: 'abc', - rating: 5, - }), - expect.objectContaining({ - viewCount: 2, - duration: 200, - url: 'def', - rating: 4, - }), - ]), - ); - - await expect( - client.ratedVideo.createMany({ - data: [ - { viewCount: 1, duration: 100, url: 'abc', rating: 5 }, - { viewCount: 2, duration: 200, url: 'def', rating: 4 }, - ], - skipDuplicates: true, + expect.objectContaining({ + viewCount: 2, + duration: 200, + url: 'def', + rating: 4, }), - ).rejects.toThrow('not supported'); - }); + ]), + ); - it('works with createManyAndReturn', async () => { - await expect( - client.ratedVideo.createManyAndReturn({ - data: [ - { viewCount: 1, duration: 100, url: 'abc', rating: 5 }, - { viewCount: 2, duration: 200, url: 'def', rating: 4 }, - ], - }), - ).resolves.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - viewCount: 1, - duration: 100, - url: 'abc', - rating: 5, - }), - expect.objectContaining({ - viewCount: 2, - duration: 200, - url: 'def', - rating: 4, - }), - ]), - ); - }); + await expect( + client.ratedVideo.createMany({ + data: [ + { viewCount: 1, duration: 100, url: 'abc', rating: 5 }, + { viewCount: 2, duration: 200, url: 'def', rating: 4 }, + ], + skipDuplicates: true, + }), + ).rejects.toThrow('not supported'); + }); - it('ensures create is atomic', async () => { - // create with a relation that fails - await expect( - client.ratedVideo.create({ - data: { - duration: 100, - url: 'abc', - rating: 5, - }, + it('works with createManyAndReturn', async () => { + await expect( + client.ratedVideo.createManyAndReturn({ + data: [ + { viewCount: 1, duration: 100, url: 'abc', rating: 5 }, + { viewCount: 2, duration: 200, url: 'def', rating: 4 }, + ], + }), + ).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + viewCount: 1, + duration: 100, + url: 'abc', + rating: 5, }), - ).toResolveTruthy(); - await expect( - client.ratedVideo.create({ - data: { - duration: 200, - url: 'abc', - rating: 3, - }, + expect.objectContaining({ + viewCount: 2, + duration: 200, + url: 'def', + rating: 4, }), - ).rejects.toThrow('constraint'); + ]), + ); + }); - await expect(client.ratedVideo.findMany()).toResolveWithLength(1); - await expect(client.video.findMany()).toResolveWithLength(1); - await expect(client.asset.findMany()).toResolveWithLength(1); - }); + it('ensures create is atomic', async () => { + // create with a relation that fails + await expect( + client.ratedVideo.create({ + data: { + duration: 100, + url: 'abc', + rating: 5, + }, + }), + ).toResolveTruthy(); + await expect( + client.ratedVideo.create({ + data: { + duration: 200, + url: 'abc', + rating: 3, + }, + }), + ).rejects.toThrow('constraint'); + + await expect(client.ratedVideo.findMany()).toResolveWithLength(1); + await expect(client.video.findMany()).toResolveWithLength(1); + await expect(client.asset.findMany()).toResolveWithLength(1); + }); + }); + + it('works with find', async () => { + const u = await client.user.create({ + data: { + email: 'u1@example.com', + }, + }); + const v = await client.ratedVideo.create({ + data: { + duration: 100, + url: 'abc', + rating: 5, + owner: { connect: { id: u.id } }, + user: { connect: { id: u.id } }, + }, + }); + + const ratedVideoContent = { + id: v.id, + createdAt: expect.any(Date), + duration: 100, + rating: 5, + assetType: 'Video', + videoType: 'RatedVideo', + }; + + // include all base fields + await expect( + client.ratedVideo.findUnique({ + where: { id: v.id }, + include: { user: true, owner: true }, + }), + ).resolves.toMatchObject({ ...ratedVideoContent, user: expect.any(Object), owner: expect.any(Object) }); + + // select fields + await expect( + client.ratedVideo.findUnique({ + where: { id: v.id }, + select: { + id: true, + viewCount: true, + url: true, + rating: true, + }, + }), + ).resolves.toEqual({ + id: v.id, + viewCount: 0, + url: 'abc', + rating: 5, + }); + + // omit fields + const r: any = await client.ratedVideo.findUnique({ + where: { id: v.id }, + omit: { + viewCount: true, + url: true, + rating: true, + }, + }); + expect(r.viewCount).toBeUndefined(); + expect(r.url).toBeUndefined(); + expect(r.rating).toBeUndefined(); + expect(r.duration).toEqual(expect.any(Number)); + + // include all sub fields + await expect( + client.video.findUnique({ + where: { id: v.id }, + }), + ).resolves.toMatchObject(ratedVideoContent); + + // include all sub fields + await expect( + client.asset.findUnique({ + where: { id: v.id }, + }), + ).resolves.toMatchObject(ratedVideoContent); + + // find as a relation + await expect( + client.user.findUnique({ + where: { id: u.id }, + include: { assets: true, ratedVideos: true }, + }), + ).resolves.toMatchObject({ + assets: [ratedVideoContent], + ratedVideos: [ratedVideoContent], + }); + + // find as a relation with selection + await expect( + client.user.findUnique({ + where: { id: u.id }, + include: { + assets: { + select: { id: true, assetType: true }, + }, + ratedVideos: { + select: { + url: true, + rating: true, + }, + }, + }, + }), + ).resolves.toMatchObject({ + assets: [{ id: v.id, assetType: 'Video' }], + ratedVideos: [{ url: 'abc', rating: 5 }], }); + }); - it('works with find', async () => { + describe('Delegate filter tests', async () => { + beforeEach(async () => { const u = await client.user.create({ data: { email: 'u1@example.com', }, }); - const v = await client.ratedVideo.create({ + await client.ratedVideo.create({ data: { + viewCount: 0, duration: 100, - url: 'abc', + url: 'v1', rating: 5, owner: { connect: { id: u.id } }, user: { connect: { id: u.id } }, + comments: { create: { content: 'c1' } }, }, }); + await client.ratedVideo.create({ + data: { + viewCount: 1, + duration: 200, + url: 'v2', + rating: 4, + owner: { connect: { id: u.id } }, + user: { connect: { id: u.id } }, + comments: { create: { content: 'c2' } }, + }, + }); + }); - const ratedVideoContent = { - id: v.id, - createdAt: expect.any(Date), - duration: 100, - rating: 5, - assetType: 'Video', - videoType: 'RatedVideo', - }; - - // include all base fields + it('works with toplevel filters', async () => { await expect( - client.ratedVideo.findUnique({ - where: { id: v.id }, - include: { user: true, owner: true }, + client.asset.findMany({ + where: { viewCount: { gt: 0 } }, }), - ).resolves.toMatchObject({ ...ratedVideoContent, user: expect.any(Object), owner: expect.any(Object) }); + ).toResolveWithLength(1); - // select fields await expect( - client.ratedVideo.findUnique({ - where: { id: v.id }, - select: { - id: true, - viewCount: true, - url: true, - rating: true, - }, + client.video.findMany({ + where: { viewCount: { gt: 0 }, url: 'v1' }, }), - ).resolves.toEqual({ - id: v.id, - viewCount: 0, - url: 'abc', - rating: 5, - }); - - // omit fields - const r: any = await client.ratedVideo.findUnique({ - where: { id: v.id }, - omit: { - viewCount: true, - url: true, - rating: true, - }, - }); - expect(r.viewCount).toBeUndefined(); - expect(r.url).toBeUndefined(); - expect(r.rating).toBeUndefined(); - expect(r.duration).toEqual(expect.any(Number)); + ).toResolveWithLength(0); - // include all sub fields await expect( - client.video.findUnique({ - where: { id: v.id }, + client.video.findMany({ + where: { viewCount: { gt: 0 }, url: 'v2' }, }), - ).resolves.toMatchObject(ratedVideoContent); + ).toResolveWithLength(1); - // include all sub fields await expect( - client.asset.findUnique({ - where: { id: v.id }, + client.ratedVideo.findMany({ + where: { viewCount: { gt: 0 }, rating: 5 }, }), - ).resolves.toMatchObject(ratedVideoContent); + ).toResolveWithLength(0); - // find as a relation await expect( - client.user.findUnique({ - where: { id: u.id }, - include: { assets: true, ratedVideos: true }, + client.ratedVideo.findMany({ + where: { viewCount: { gt: 0 }, rating: 4 }, }), - ).resolves.toMatchObject({ - assets: [ratedVideoContent], - ratedVideos: [ratedVideoContent], - }); + ).toResolveWithLength(1); + }); - // find as a relation with selection + it('works with filtering relations', async () => { await expect( - client.user.findUnique({ - where: { id: u.id }, + client.user.findFirst({ include: { assets: { - select: { id: true, assetType: true }, + where: { viewCount: { gt: 0 } }, }, + }, + }), + ).resolves.toSatisfy((user) => user.assets.length === 1); + + await expect( + client.user.findFirst({ + include: { ratedVideos: { - select: { - url: true, - rating: true, - }, + where: { viewCount: { gt: 0 }, url: 'v1' }, }, }, }), - ).resolves.toMatchObject({ - assets: [{ id: v.id, assetType: 'Video' }], - ratedVideos: [{ url: 'abc', rating: 5 }], - }); - }); + ).resolves.toSatisfy((user) => user.ratedVideos.length === 0); - describe('Delegate filter tests', async () => { - beforeEach(async () => { - const u = await client.user.create({ - data: { - email: 'u1@example.com', - }, - }); - await client.ratedVideo.create({ - data: { - viewCount: 0, - duration: 100, - url: 'v1', - rating: 5, - owner: { connect: { id: u.id } }, - user: { connect: { id: u.id } }, - comments: { create: { content: 'c1' } }, - }, - }); - await client.ratedVideo.create({ - data: { - viewCount: 1, - duration: 200, - url: 'v2', - rating: 4, - owner: { connect: { id: u.id } }, - user: { connect: { id: u.id } }, - comments: { create: { content: 'c2' } }, + await expect( + client.user.findFirst({ + include: { + ratedVideos: { + where: { viewCount: { gt: 0 }, url: 'v2' }, + }, }, - }); - }); - - it('works with toplevel filters', async () => { - await expect( - client.asset.findMany({ - where: { viewCount: { gt: 0 } }, - }), - ).toResolveWithLength(1); - - await expect( - client.video.findMany({ - where: { viewCount: { gt: 0 }, url: 'v1' }, - }), - ).toResolveWithLength(0); - - await expect( - client.video.findMany({ - where: { viewCount: { gt: 0 }, url: 'v2' }, - }), - ).toResolveWithLength(1); - - await expect( - client.ratedVideo.findMany({ - where: { viewCount: { gt: 0 }, rating: 5 }, - }), - ).toResolveWithLength(0); - - await expect( - client.ratedVideo.findMany({ - where: { viewCount: { gt: 0 }, rating: 4 }, - }), - ).toResolveWithLength(1); - }); + }), + ).resolves.toSatisfy((user) => user.ratedVideos.length === 1); - it('works with filtering relations', async () => { - await expect( - client.user.findFirst({ - include: { - assets: { - where: { viewCount: { gt: 0 } }, - }, + await expect( + client.user.findFirst({ + include: { + ratedVideos: { + where: { viewCount: { gt: 0 }, rating: 5 }, }, - }), - ).resolves.toSatisfy((user) => user.assets.length === 1); + }, + }), + ).resolves.toSatisfy((user) => user.ratedVideos.length === 0); - await expect( - client.user.findFirst({ - include: { - ratedVideos: { - where: { viewCount: { gt: 0 }, url: 'v1' }, - }, + await expect( + client.user.findFirst({ + include: { + ratedVideos: { + where: { viewCount: { gt: 0 }, rating: 4 }, }, - }), - ).resolves.toSatisfy((user) => user.ratedVideos.length === 0); + }, + }), + ).resolves.toSatisfy((user) => user.ratedVideos.length === 1); + }); - await expect( - client.user.findFirst({ - include: { - ratedVideos: { - where: { viewCount: { gt: 0 }, url: 'v2' }, - }, + it('works with filtering parents', async () => { + await expect( + client.user.findFirst({ + where: { + assets: { + some: { viewCount: { gt: 0 } }, }, - }), - ).resolves.toSatisfy((user) => user.ratedVideos.length === 1); + }, + }), + ).toResolveTruthy(); - await expect( - client.user.findFirst({ - include: { - ratedVideos: { - where: { viewCount: { gt: 0 }, rating: 5 }, - }, + await expect( + client.user.findFirst({ + where: { + assets: { + some: { viewCount: { gt: 1 } }, }, - }), - ).resolves.toSatisfy((user) => user.ratedVideos.length === 0); + }, + }), + ).toResolveFalsy(); - await expect( - client.user.findFirst({ - include: { - ratedVideos: { - where: { viewCount: { gt: 0 }, rating: 4 }, - }, + await expect( + client.user.findFirst({ + where: { + ratedVideos: { + some: { viewCount: { gt: 0 }, url: 'v1' }, }, - }), - ).resolves.toSatisfy((user) => user.ratedVideos.length === 1); - }); + }, + }), + ).toResolveFalsy(); - it('works with filtering parents', async () => { - await expect( - client.user.findFirst({ - where: { - assets: { - some: { viewCount: { gt: 0 } }, - }, + await expect( + client.user.findFirst({ + where: { + ratedVideos: { + some: { viewCount: { gt: 0 }, url: 'v2' }, }, - }), - ).toResolveTruthy(); + }, + }), + ).toResolveTruthy(); + }); - await expect( - client.user.findFirst({ - where: { - assets: { - some: { viewCount: { gt: 1 } }, - }, + it('works with filtering with relations from base', async () => { + await expect( + client.video.findFirst({ + where: { + owner: { + email: 'u1@example.com', }, - }), - ).toResolveFalsy(); + }, + }), + ).toResolveTruthy(); - await expect( - client.user.findFirst({ - where: { - ratedVideos: { - some: { viewCount: { gt: 0 }, url: 'v1' }, - }, - }, - }), - ).toResolveFalsy(); - - await expect( - client.user.findFirst({ - where: { - ratedVideos: { - some: { viewCount: { gt: 0 }, url: 'v2' }, - }, - }, - }), - ).toResolveTruthy(); - }); - - it('works with filtering with relations from base', async () => { - await expect( - client.video.findFirst({ - where: { - owner: { - email: 'u1@example.com', - }, + await expect( + client.video.findFirst({ + where: { + owner: { + email: 'u2@example.com', }, - }), - ).toResolveTruthy(); + }, + }), + ).toResolveFalsy(); - await expect( - client.video.findFirst({ - where: { - owner: { - email: 'u2@example.com', - }, - }, - }), - ).toResolveFalsy(); + await expect( + client.video.findFirst({ + where: { + owner: null, + }, + }), + ).toResolveFalsy(); - await expect( - client.video.findFirst({ - where: { - owner: null, - }, - }), - ).toResolveFalsy(); + await expect( + client.video.findFirst({ + where: { + owner: { is: null }, + }, + }), + ).toResolveFalsy(); - await expect( - client.video.findFirst({ - where: { - owner: { is: null }, - }, - }), - ).toResolveFalsy(); + await expect( + client.video.findFirst({ + where: { + owner: { isNot: null }, + }, + }), + ).toResolveTruthy(); - await expect( - client.video.findFirst({ - where: { - owner: { isNot: null }, + await expect( + client.video.findFirst({ + where: { + comments: { + some: { content: 'c1' }, }, - }), - ).toResolveTruthy(); + }, + }), + ).toResolveTruthy(); - await expect( - client.video.findFirst({ - where: { - comments: { - some: { content: 'c1' }, - }, + await expect( + client.video.findFirst({ + where: { + comments: { + every: { content: 'c2' }, }, - }), - ).toResolveTruthy(); + }, + }), + ).toResolveTruthy(); - await expect( - client.video.findFirst({ - where: { - comments: { - every: { content: 'c2' }, - }, + await expect( + client.video.findFirst({ + where: { + comments: { + none: { content: 'c1' }, }, - }), - ).toResolveTruthy(); + }, + }), + ).toResolveTruthy(); - await expect( - client.video.findFirst({ - where: { - comments: { - none: { content: 'c1' }, - }, + await expect( + client.video.findFirst({ + where: { + comments: { + none: { content: { startsWith: 'c' } }, }, - }), - ).toResolveTruthy(); + }, + }), + ).toResolveFalsy(); + }); + }); - await expect( - client.video.findFirst({ - where: { - comments: { - none: { content: { startsWith: 'c' } }, - }, - }, - }), - ).toResolveFalsy(); + describe('Delegate update tests', async () => { + beforeEach(async () => { + const u = await client.user.create({ + data: { + id: 1, + email: 'u1@example.com', + }, + }); + await client.ratedVideo.create({ + data: { + id: 1, + viewCount: 0, + duration: 100, + url: 'v1', + rating: 5, + owner: { connect: { id: u.id } }, + user: { connect: { id: u.id } }, + }, }); }); - describe('Delegate update tests', async () => { - beforeEach(async () => { - const u = await client.user.create({ - data: { - id: 1, - email: 'u1@example.com', - }, - }); - await client.ratedVideo.create({ - data: { - id: 1, - viewCount: 0, - duration: 100, - url: 'v1', - rating: 5, - owner: { connect: { id: u.id } }, - user: { connect: { id: u.id } }, - }, - }); + it('works with toplevel update', async () => { + // id filter + await expect( + client.ratedVideo.update({ + where: { id: 1 }, + data: { viewCount: { increment: 1 }, duration: 200, rating: { set: 4 } }, + }), + ).resolves.toMatchObject({ + viewCount: 1, + duration: 200, + rating: 4, + }); + await expect( + client.video.update({ + where: { id: 1 }, + data: { viewCount: { decrement: 1 }, duration: 100 }, + }), + ).resolves.toMatchObject({ + viewCount: 0, + duration: 100, + }); + await expect( + client.asset.update({ + where: { id: 1 }, + data: { viewCount: { increment: 1 } }, + }), + ).resolves.toMatchObject({ + viewCount: 1, }); - it('works with toplevel update', async () => { - // id filter - await expect( - client.ratedVideo.update({ - where: { id: 1 }, - data: { viewCount: { increment: 1 }, duration: 200, rating: { set: 4 } }, - }), - ).resolves.toMatchObject({ - viewCount: 1, - duration: 200, - rating: 4, - }); - await expect( - client.video.update({ - where: { id: 1 }, - data: { viewCount: { decrement: 1 }, duration: 100 }, - }), - ).resolves.toMatchObject({ - viewCount: 0, - duration: 100, - }); - await expect( - client.asset.update({ - where: { id: 1 }, - data: { viewCount: { increment: 1 } }, - }), - ).resolves.toMatchObject({ - viewCount: 1, - }); + // unique field filter + await expect( + client.ratedVideo.update({ + where: { url: 'v1' }, + data: { viewCount: 2, duration: 300, rating: 3 }, + }), + ).resolves.toMatchObject({ + viewCount: 2, + duration: 300, + rating: 3, + }); + await expect( + client.video.update({ + where: { url: 'v1' }, + data: { viewCount: 3 }, + }), + ).resolves.toMatchObject({ + viewCount: 3, + }); - // unique field filter - await expect( - client.ratedVideo.update({ - where: { url: 'v1' }, - data: { viewCount: 2, duration: 300, rating: 3 }, - }), - ).resolves.toMatchObject({ - viewCount: 2, - duration: 300, - rating: 3, - }); - await expect( - client.video.update({ - where: { url: 'v1' }, - data: { viewCount: 3 }, - }), - ).resolves.toMatchObject({ - viewCount: 3, - }); - - // not found - await expect( - client.ratedVideo.update({ - where: { url: 'v2' }, - data: { viewCount: 4 }, - }), - ).toBeRejectedNotFound(); + // not found + await expect( + client.ratedVideo.update({ + where: { url: 'v2' }, + data: { viewCount: 4 }, + }), + ).toBeRejectedNotFound(); - // update id - await expect( - client.ratedVideo.update({ - where: { id: 1 }, - data: { id: 2 }, - }), - ).resolves.toMatchObject({ - id: 2, - viewCount: 3, - }); + // update id + await expect( + client.ratedVideo.update({ + where: { id: 1 }, + data: { id: 2 }, + }), + ).resolves.toMatchObject({ + id: 2, + viewCount: 3, }); + }); - it('works with nested update', async () => { - await expect( - client.user.update({ - where: { id: 1 }, - data: { - assets: { - update: { - where: { id: 1 }, - data: { viewCount: { increment: 1 } }, - }, - }, - }, - include: { assets: true }, - }), - ).resolves.toMatchObject({ - assets: [{ viewCount: 1 }], - }); - - await expect( - client.user.update({ - where: { id: 1 }, - data: { - ratedVideos: { - update: { - where: { id: 1 }, - data: { viewCount: 2, rating: 4, duration: 200 }, - }, + it('works with nested update', async () => { + await expect( + client.user.update({ + where: { id: 1 }, + data: { + assets: { + update: { + where: { id: 1 }, + data: { viewCount: { increment: 1 } }, }, }, - include: { ratedVideos: true }, - }), - ).resolves.toMatchObject({ - ratedVideos: [{ viewCount: 2, rating: 4, duration: 200 }], - }); - - // unique filter - await expect( - client.user.update({ - where: { id: 1 }, - data: { - ratedVideos: { - update: { - where: { url: 'v1' }, - data: { viewCount: 3 }, - }, + }, + include: { assets: true }, + }), + ).resolves.toMatchObject({ + assets: [{ viewCount: 1 }], + }); + + await expect( + client.user.update({ + where: { id: 1 }, + data: { + ratedVideos: { + update: { + where: { id: 1 }, + data: { viewCount: 2, rating: 4, duration: 200 }, }, }, - include: { ratedVideos: true }, - }), - ).resolves.toMatchObject({ - ratedVideos: [{ viewCount: 3 }], - }); - - // deep nested - await expect( - client.user.update({ - where: { id: 1 }, - data: { - assets: { - update: { - where: { id: 1 }, - data: { comments: { create: { content: 'c1' } } }, - }, + }, + include: { ratedVideos: true }, + }), + ).resolves.toMatchObject({ + ratedVideos: [{ viewCount: 2, rating: 4, duration: 200 }], + }); + + // unique filter + await expect( + client.user.update({ + where: { id: 1 }, + data: { + ratedVideos: { + update: { + where: { url: 'v1' }, + data: { viewCount: 3 }, }, }, - include: { assets: { include: { comments: true } } }, - }), - ).resolves.toMatchObject({ - assets: [{ comments: [{ content: 'c1' }] }], - }); + }, + include: { ratedVideos: true }, + }), + ).resolves.toMatchObject({ + ratedVideos: [{ viewCount: 3 }], }); - it('works with updating a base relation', async () => { - await expect( - client.video.update({ - where: { id: 1 }, - data: { - owner: { update: { level: { increment: 1 } } }, + // deep nested + await expect( + client.user.update({ + where: { id: 1 }, + data: { + assets: { + update: { + where: { id: 1 }, + data: { comments: { create: { content: 'c1' } } }, + }, }, - include: { owner: true }, - }), - ).resolves.toMatchObject({ - owner: { level: 1 }, - }); + }, + include: { assets: { include: { comments: true } } }, + }), + ).resolves.toMatchObject({ + assets: [{ comments: [{ content: 'c1' }] }], }); + }); - it('works with updateMany', async () => { - await client.ratedVideo.create({ - data: { id: 2, viewCount: 1, duration: 200, url: 'abc', rating: 5 }, - }); - - // update from sub model - await expect( - client.ratedVideo.updateMany({ - where: { duration: { gt: 100 } }, - data: { viewCount: { increment: 1 }, duration: { increment: 1 }, rating: { set: 3 } }, - }), - ).resolves.toEqual({ count: 1 }); - - await expect(client.ratedVideo.findMany()).resolves.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - viewCount: 2, - duration: 201, - rating: 3, - }), - ]), - ); - - await expect( - client.ratedVideo.updateMany({ - where: { viewCount: { gt: 1 } }, - data: { viewCount: { increment: 1 } }, - }), - ).resolves.toEqual({ count: 1 }); - - await expect( - client.ratedVideo.updateMany({ - where: { rating: 3 }, - data: { viewCount: { increment: 1 } }, - }), - ).resolves.toEqual({ count: 1 }); + it('works with updating a base relation', async () => { + await expect( + client.video.update({ + where: { id: 1 }, + data: { + owner: { update: { level: { increment: 1 } } }, + }, + include: { owner: true }, + }), + ).resolves.toMatchObject({ + owner: { level: 1 }, + }); + }); - // update from delegate model - await expect( - client.asset.updateMany({ - where: { viewCount: { gt: 0 } }, - data: { viewCount: 100 }, - }), - ).resolves.toEqual({ count: 1 }); - await expect( - client.video.updateMany({ - where: { duration: { gt: 200 } }, - data: { viewCount: 200, duration: 300 }, - }), - ).resolves.toEqual({ count: 1 }); - await expect(client.ratedVideo.findMany()).resolves.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - viewCount: 200, - duration: 300, - }), - ]), - ); - - // updateMany with limit unsupported - await expect( - client.ratedVideo.updateMany({ - where: { duration: { gt: 200 } }, - data: { viewCount: 200, duration: 300 }, - limit: 1, - }), - ).rejects.toThrow('Updating with a limit is not supported for polymorphic models'); + it('works with updateMany', async () => { + await client.ratedVideo.create({ + data: { id: 2, viewCount: 1, duration: 200, url: 'abc', rating: 5 }, }); - it('works with updateManyAndReturn', async () => { - await client.ratedVideo.create({ - data: { id: 2, viewCount: 1, duration: 200, url: 'abc', rating: 5 }, - }); + // update from sub model + await expect( + client.ratedVideo.updateMany({ + where: { duration: { gt: 100 } }, + data: { viewCount: { increment: 1 }, duration: { increment: 1 }, rating: { set: 3 } }, + }), + ).resolves.toEqual({ count: 1 }); - // update from sub model - await expect( - client.ratedVideo.updateManyAndReturn({ - where: { duration: { gt: 100 } }, - data: { viewCount: { increment: 1 }, duration: { increment: 1 }, rating: { set: 3 } }, - }), - ).resolves.toEqual([ + await expect(client.ratedVideo.findMany()).resolves.toEqual( + expect.arrayContaining([ expect.objectContaining({ viewCount: 2, duration: 201, rating: 3, }), - ]); + ]), + ); - // update from delegate model - await expect( - client.asset.updateManyAndReturn({ - where: { viewCount: { gt: 0 } }, - data: { viewCount: 100 }, - }), - ).resolves.toEqual([ + await expect( + client.ratedVideo.updateMany({ + where: { viewCount: { gt: 1 } }, + data: { viewCount: { increment: 1 } }, + }), + ).resolves.toEqual({ count: 1 }); + + await expect( + client.ratedVideo.updateMany({ + where: { rating: 3 }, + data: { viewCount: { increment: 1 } }, + }), + ).resolves.toEqual({ count: 1 }); + + // update from delegate model + await expect( + client.asset.updateMany({ + where: { viewCount: { gt: 0 } }, + data: { viewCount: 100 }, + }), + ).resolves.toEqual({ count: 1 }); + await expect( + client.video.updateMany({ + where: { duration: { gt: 200 } }, + data: { viewCount: 200, duration: 300 }, + }), + ).resolves.toEqual({ count: 1 }); + await expect(client.ratedVideo.findMany()).resolves.toEqual( + expect.arrayContaining([ expect.objectContaining({ - viewCount: 100, - duration: 201, - rating: 3, + viewCount: 200, + duration: 300, }), - ]); + ]), + ); + + // updateMany with limit unsupported + await expect( + client.ratedVideo.updateMany({ + where: { duration: { gt: 200 } }, + data: { viewCount: 200, duration: 300 }, + limit: 1, + }), + ).rejects.toThrow('Updating with a limit is not supported for polymorphic models'); + }); + + it('works with updateManyAndReturn', async () => { + await client.ratedVideo.create({ + data: { id: 2, viewCount: 1, duration: 200, url: 'abc', rating: 5 }, }); - it('works with upsert', async () => { - await expect( - // @ts-expect-error - client.asset.upsert({ - where: { id: 2 }, - create: { - viewCount: 10, - assetType: 'Video', - }, - update: { - viewCount: { increment: 1 }, - }, - }), - ).rejects.toThrow('is a delegate'); - - // create case - await expect( - client.ratedVideo.upsert({ - where: { id: 2 }, - create: { - id: 2, - viewCount: 2, - duration: 200, - url: 'v2', - rating: 3, - }, - update: { - viewCount: { increment: 1 }, - }, - }), - ).resolves.toMatchObject({ - id: 2, + // update from sub model + await expect( + client.ratedVideo.updateManyAndReturn({ + where: { duration: { gt: 100 } }, + data: { viewCount: { increment: 1 }, duration: { increment: 1 }, rating: { set: 3 } }, + }), + ).resolves.toEqual([ + expect.objectContaining({ viewCount: 2, - }); - - // update case - await expect( - client.ratedVideo.upsert({ - where: { id: 2 }, - create: { - id: 2, - viewCount: 2, - duration: 200, - url: 'v2', - rating: 3, - }, - update: { - viewCount: 3, - duration: 300, - rating: 2, - }, - }), - ).resolves.toMatchObject({ - id: 2, - viewCount: 3, - duration: 300, - rating: 2, - }); - }); + duration: 201, + rating: 3, + }), + ]); + + // update from delegate model + await expect( + client.asset.updateManyAndReturn({ + where: { viewCount: { gt: 0 } }, + data: { viewCount: 100 }, + }), + ).resolves.toEqual([ + expect.objectContaining({ + viewCount: 100, + duration: 201, + rating: 3, + }), + ]); }); - describe('Delegate delete tests', () => { - it('works with delete', async () => { - // delete from sub model - await client.ratedVideo.create({ - data: { - id: 1, - duration: 100, - url: 'abc', - rating: 5, + it('works with upsert', async () => { + await expect( + // @ts-expect-error + client.asset.upsert({ + where: { id: 2 }, + create: { + viewCount: 10, + assetType: 'Video', }, - }); - await expect( - client.ratedVideo.delete({ - where: { url: 'abc' }, - }), - ).resolves.toMatchObject({ + update: { + viewCount: { increment: 1 }, + }, + }), + ).rejects.toThrow('is a delegate'); + + // create case + await expect( + client.ratedVideo.upsert({ + where: { id: 2 }, + create: { + id: 2, + viewCount: 2, + duration: 200, + url: 'v2', + rating: 3, + }, + update: { + viewCount: { increment: 1 }, + }, + }), + ).resolves.toMatchObject({ + id: 2, + viewCount: 2, + }); + + // update case + await expect( + client.ratedVideo.upsert({ + where: { id: 2 }, + create: { + id: 2, + viewCount: 2, + duration: 200, + url: 'v2', + rating: 3, + }, + update: { + viewCount: 3, + duration: 300, + rating: 2, + }, + }), + ).resolves.toMatchObject({ + id: 2, + viewCount: 3, + duration: 300, + rating: 2, + }); + }); + }); + + describe('Delegate delete tests', () => { + it('works with delete', async () => { + // delete from sub model + await client.ratedVideo.create({ + data: { id: 1, duration: 100, url: 'abc', rating: 5, - }); - await expect(client.ratedVideo.findMany()).toResolveWithLength(0); - await expect(client.video.findMany()).toResolveWithLength(0); - await expect(client.asset.findMany()).toResolveWithLength(0); + }, + }); + await expect( + client.ratedVideo.delete({ + where: { url: 'abc' }, + }), + ).resolves.toMatchObject({ + id: 1, + duration: 100, + url: 'abc', + rating: 5, + }); + await expect(client.ratedVideo.findMany()).toResolveWithLength(0); + await expect(client.video.findMany()).toResolveWithLength(0); + await expect(client.asset.findMany()).toResolveWithLength(0); - // delete from base model - await client.ratedVideo.create({ - data: { - id: 1, - duration: 100, - url: 'abc', - rating: 5, - }, - }); - await expect( - client.asset.delete({ - where: { id: 1 }, - }), - ).resolves.toMatchObject({ + // delete from base model + await client.ratedVideo.create({ + data: { id: 1, duration: 100, url: 'abc', rating: 5, - }); - await expect(client.ratedVideo.findMany()).toResolveWithLength(0); - await expect(client.video.findMany()).toResolveWithLength(0); - await expect(client.asset.findMany()).toResolveWithLength(0); + }, + }); + await expect( + client.asset.delete({ + where: { id: 1 }, + }), + ).resolves.toMatchObject({ + id: 1, + duration: 100, + url: 'abc', + rating: 5, + }); + await expect(client.ratedVideo.findMany()).toResolveWithLength(0); + await expect(client.video.findMany()).toResolveWithLength(0); + await expect(client.asset.findMany()).toResolveWithLength(0); - // nested delete - await client.user.create({ - data: { - id: 1, - email: 'abc', - }, - }); - await client.ratedVideo.create({ + // nested delete + await client.user.create({ + data: { + id: 1, + email: 'abc', + }, + }); + await client.ratedVideo.create({ + data: { + id: 1, + duration: 100, + url: 'abc', + rating: 5, + owner: { connect: { id: 1 } }, + }, + }); + await expect( + client.user.update({ + where: { id: 1 }, data: { - id: 1, - duration: 100, - url: 'abc', - rating: 5, - owner: { connect: { id: 1 } }, - }, - }); - await expect( - client.user.update({ - where: { id: 1 }, - data: { - assets: { - delete: { id: 1 }, - }, + assets: { + delete: { id: 1 }, }, - include: { assets: true }, - }), - ).resolves.toMatchObject({ assets: [] }); - await expect(client.ratedVideo.findMany()).toResolveWithLength(0); - await expect(client.video.findMany()).toResolveWithLength(0); - await expect(client.asset.findMany()).toResolveWithLength(0); - - // delete user should cascade to ratedVideo and in turn delete its bases - await client.ratedVideo.create({ - data: { - id: 1, - duration: 100, - url: 'abc', - rating: 5, - user: { connect: { id: 1 } }, }, - }); - await expect( - client.user.delete({ - where: { id: 1 }, - }), - ).toResolveTruthy(); - await expect(client.ratedVideo.findMany()).toResolveWithLength(0); - await expect(client.video.findMany()).toResolveWithLength(0); - await expect(client.asset.findMany()).toResolveWithLength(0); - }); - - it('works with deleteMany', async () => { - await client.ratedVideo.createMany({ - data: [ - { - id: 1, - viewCount: 1, - duration: 100, - url: 'abc', - rating: 5, - }, - { - id: 2, - viewCount: 2, - duration: 200, - url: 'def', - rating: 4, - }, - ], - }); + include: { assets: true }, + }), + ).resolves.toMatchObject({ assets: [] }); + await expect(client.ratedVideo.findMany()).toResolveWithLength(0); + await expect(client.video.findMany()).toResolveWithLength(0); + await expect(client.asset.findMany()).toResolveWithLength(0); - await expect( - client.video.deleteMany({ - where: { duration: { gt: 150 }, viewCount: 1 }, - }), - ).resolves.toMatchObject({ count: 0 }); - await expect( - client.video.deleteMany({ - where: { duration: { gt: 150 }, viewCount: 2 }, - }), - ).resolves.toMatchObject({ count: 1 }); - await expect(client.ratedVideo.findMany()).toResolveWithLength(1); - await expect(client.video.findMany()).toResolveWithLength(1); - await expect(client.asset.findMany()).toResolveWithLength(1); + // delete user should cascade to ratedVideo and in turn delete its bases + await client.ratedVideo.create({ + data: { + id: 1, + duration: 100, + url: 'abc', + rating: 5, + user: { connect: { id: 1 } }, + }, }); + await expect( + client.user.delete({ + where: { id: 1 }, + }), + ).toResolveTruthy(); + await expect(client.ratedVideo.findMany()).toResolveWithLength(0); + await expect(client.video.findMany()).toResolveWithLength(0); + await expect(client.asset.findMany()).toResolveWithLength(0); }); - describe('Delegate aggregation tests', () => { - beforeEach(async () => { - const u = await client.user.create({ - data: { - id: 1, - email: 'u1@example.com', - }, - }); - await client.ratedVideo.create({ - data: { + it('works with deleteMany', async () => { + await client.ratedVideo.createMany({ + data: [ + { id: 1, - viewCount: 0, + viewCount: 1, duration: 100, - url: 'v1', + url: 'abc', rating: 5, - owner: { connect: { id: u.id } }, - user: { connect: { id: u.id } }, - comments: { create: [{ content: 'c1' }, { content: 'c2' }] }, }, - }); - await client.ratedVideo.create({ - data: { + { id: 2, viewCount: 2, duration: 200, - url: 'v2', - rating: 3, + url: 'def', + rating: 4, }, - }); + ], }); - it('works with count', async () => { - await expect( - client.ratedVideo.count({ - where: { rating: 5 }, - }), - ).resolves.toEqual(1); - await expect( - client.ratedVideo.count({ - where: { duration: 100 }, - }), - ).resolves.toEqual(1); - await expect( - client.ratedVideo.count({ - where: { viewCount: 2 }, - }), - ).resolves.toEqual(1); - - await expect( - client.video.count({ - where: { duration: 100 }, - }), - ).resolves.toEqual(1); - await expect( - client.asset.count({ - where: { viewCount: { gt: 0 } }, - }), - ).resolves.toEqual(1); + await expect( + client.video.deleteMany({ + where: { duration: { gt: 150 }, viewCount: 1 }, + }), + ).resolves.toMatchObject({ count: 0 }); + await expect( + client.video.deleteMany({ + where: { duration: { gt: 150 }, viewCount: 2 }, + }), + ).resolves.toMatchObject({ count: 1 }); + await expect(client.ratedVideo.findMany()).toResolveWithLength(1); + await expect(client.video.findMany()).toResolveWithLength(1); + await expect(client.asset.findMany()).toResolveWithLength(1); + }); + }); - // field selection - await expect( - client.ratedVideo.count({ - select: { _all: true, viewCount: true, url: true, rating: true }, - }), - ).resolves.toMatchObject({ - _all: 2, - viewCount: 2, - url: 2, - rating: 2, - }); - await expect( - client.video.count({ - select: { _all: true, viewCount: true, url: true }, - }), - ).resolves.toMatchObject({ - _all: 2, - viewCount: 2, - url: 2, - }); - await expect( - client.asset.count({ - select: { _all: true, viewCount: true }, - }), - ).resolves.toMatchObject({ - _all: 2, + describe('Delegate aggregation tests', () => { + beforeEach(async () => { + const u = await client.user.create({ + data: { + id: 1, + email: 'u1@example.com', + }, + }); + await client.ratedVideo.create({ + data: { + id: 1, + viewCount: 0, + duration: 100, + url: 'v1', + rating: 5, + owner: { connect: { id: u.id } }, + user: { connect: { id: u.id } }, + comments: { create: [{ content: 'c1' }, { content: 'c2' }] }, + }, + }); + await client.ratedVideo.create({ + data: { + id: 2, viewCount: 2, - }); + duration: 200, + url: 'v2', + rating: 3, + }, }); + }); - it('works with aggregate', async () => { - await expect( - client.ratedVideo.aggregate({ - where: { viewCount: { gte: 0 }, duration: { gt: 0 }, rating: { gt: 0 } }, - _avg: { viewCount: true, duration: true, rating: true }, - _count: true, - }), - ).resolves.toMatchObject({ - _avg: { - viewCount: 1, - duration: 150, - rating: 4, - }, - _count: 2, - }); - await expect( - client.video.aggregate({ - where: { viewCount: { gte: 0 }, duration: { gt: 0 } }, - _avg: { viewCount: true, duration: true }, - _count: true, - }), - ).resolves.toMatchObject({ - _avg: { - viewCount: 1, - duration: 150, - }, - _count: 2, - }); - await expect( - client.asset.aggregate({ - where: { viewCount: { gte: 0 } }, - _avg: { viewCount: true }, - _count: true, - }), - ).resolves.toMatchObject({ - _avg: { - viewCount: 1, - }, - _count: 2, - }); + it('works with count', async () => { + await expect( + client.ratedVideo.count({ + where: { rating: 5 }, + }), + ).resolves.toEqual(1); + await expect( + client.ratedVideo.count({ + where: { duration: 100 }, + }), + ).resolves.toEqual(1); + await expect( + client.ratedVideo.count({ + where: { viewCount: 2 }, + }), + ).resolves.toEqual(1); - // just count - await expect( - client.ratedVideo.aggregate({ - _count: true, - }), - ).resolves.toMatchObject({ - _count: 2, - }); + await expect( + client.video.count({ + where: { duration: 100 }, + }), + ).resolves.toEqual(1); + await expect( + client.asset.count({ + where: { viewCount: { gt: 0 } }, + }), + ).resolves.toEqual(1); + + // field selection + await expect( + client.ratedVideo.count({ + select: { _all: true, viewCount: true, url: true, rating: true }, + }), + ).resolves.toMatchObject({ + _all: 2, + viewCount: 2, + url: 2, + rating: 2, + }); + await expect( + client.video.count({ + select: { _all: true, viewCount: true, url: true }, + }), + ).resolves.toMatchObject({ + _all: 2, + viewCount: 2, + url: 2, + }); + await expect( + client.asset.count({ + select: { _all: true, viewCount: true }, + }), + ).resolves.toMatchObject({ + _all: 2, + viewCount: 2, + }); + }); + + it('works with aggregate', async () => { + await expect( + client.ratedVideo.aggregate({ + where: { viewCount: { gte: 0 }, duration: { gt: 0 }, rating: { gt: 0 } }, + _avg: { viewCount: true, duration: true, rating: true }, + _count: true, + }), + ).resolves.toMatchObject({ + _avg: { + viewCount: 1, + duration: 150, + rating: 4, + }, + _count: 2, + }); + await expect( + client.video.aggregate({ + where: { viewCount: { gte: 0 }, duration: { gt: 0 } }, + _avg: { viewCount: true, duration: true }, + _count: true, + }), + ).resolves.toMatchObject({ + _avg: { + viewCount: 1, + duration: 150, + }, + _count: 2, + }); + await expect( + client.asset.aggregate({ + where: { viewCount: { gte: 0 } }, + _avg: { viewCount: true }, + _count: true, + }), + ).resolves.toMatchObject({ + _avg: { + viewCount: 1, + }, + _count: 2, + }); + + // just count + await expect( + client.ratedVideo.aggregate({ + _count: true, + }), + ).resolves.toMatchObject({ + _count: 2, }); }); - }, -); + }); +}); diff --git a/packages/runtime/test/client-api/delete-many.test.ts b/packages/runtime/test/client-api/delete-many.test.ts index df8f3ac4..b31896f0 100644 --- a/packages/runtime/test/client-api/delete-many.test.ts +++ b/packages/runtime/test/client-api/delete-many.test.ts @@ -1,15 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; import { schema } from '../schemas/basic'; -import { createClientSpecs } from './client-specs'; +import { createTestClient } from '../utils'; -const PG_DB_NAME = 'client-api-delete-many-tests'; - -describe.each(createClientSpecs(PG_DB_NAME))('Client deleteMany tests', ({ createClient }) => { +describe('Client deleteMany tests', () => { let client: ClientContract; beforeEach(async () => { - client = await createClient(); + client = await createTestClient(schema); }); afterEach(async () => { diff --git a/packages/runtime/test/client-api/delete.test.ts b/packages/runtime/test/client-api/delete.test.ts index b67216ae..4e518c07 100644 --- a/packages/runtime/test/client-api/delete.test.ts +++ b/packages/runtime/test/client-api/delete.test.ts @@ -1,15 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; import { schema } from '../schemas/basic'; -import { createClientSpecs } from './client-specs'; +import { createTestClient } from '../utils'; -const PG_DB_NAME = 'client-api-delete-tests'; - -describe.each(createClientSpecs(PG_DB_NAME))('Client delete tests', ({ createClient }) => { +describe('Client delete tests', () => { let client: ClientContract; beforeEach(async () => { - client = await createClient(); + client = await createTestClient(schema); }); afterEach(async () => { diff --git a/packages/runtime/test/client-api/filter.test.ts b/packages/runtime/test/client-api/filter.test.ts index b7ec82af..26af9dd7 100644 --- a/packages/runtime/test/client-api/filter.test.ts +++ b/packages/runtime/test/client-api/filter.test.ts @@ -1,15 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; import { schema } from '../schemas/basic'; -import { createClientSpecs } from './client-specs'; +import { createTestClient } from '../utils'; -const PG_DB_NAME = 'client-api-filter-tests'; - -describe.each(createClientSpecs(PG_DB_NAME))('Client filter tests for $provider', ({ createClient, provider }) => { +describe('Client filter tests ', () => { let client: ClientContract; beforeEach(async () => { - client = await createClient(); + client = await createTestClient(schema); }); afterEach(async () => { @@ -76,7 +74,7 @@ describe.each(createClientSpecs(PG_DB_NAME))('Client filter tests for $provider' }), ).toResolveTruthy(); - if (provider === 'sqlite') { + if (client.$schema.provider.type === 'sqlite') { // sqlite: equalities are case-sensitive, match is case-insensitive await expect( client.user.findFirst({ @@ -126,7 +124,7 @@ describe.each(createClientSpecs(PG_DB_NAME))('Client filter tests for $provider' }, }), ).toResolveTruthy(); - } else if (provider === 'postgresql') { + } else if (client.$schema.provider.type === 'postgresql') { // postgresql: default is case-sensitive, but can be toggled with "mode" await expect( diff --git a/packages/runtime/test/client-api/find.test.ts b/packages/runtime/test/client-api/find.test.ts index 36d20eba..1f8219ec 100644 --- a/packages/runtime/test/client-api/find.test.ts +++ b/packages/runtime/test/client-api/find.test.ts @@ -2,16 +2,14 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; import { InputValidationError, NotFoundError } from '../../src/client/errors'; import { schema } from '../schemas/basic'; -import { createClientSpecs } from './client-specs'; +import { createTestClient } from '../utils'; import { createPosts, createUser } from './utils'; -const PG_DB_NAME = 'client-api-find-tests'; - -describe.each(createClientSpecs(PG_DB_NAME))('Client find tests for $provider', ({ createClient, provider }) => { +describe('Client find tests ', () => { let client: ClientContract; beforeEach(async () => { - client = await createClient(); + client = await createTestClient(schema); }); afterEach(async () => { @@ -265,7 +263,7 @@ describe.each(createClientSpecs(PG_DB_NAME))('Client find tests for $provider', role: 'USER', }); - if (provider === 'sqlite') { + if (client.$schema.provider.type === 'sqlite') { await expect(client.user.findMany({ distinct: ['role'] } as any)).rejects.toThrow('not supported'); return; } @@ -675,7 +673,8 @@ describe.each(createClientSpecs(PG_DB_NAME))('Client find tests for $provider', posts: [expect.objectContaining({ title: 'Post1' })], }); - if (provider === 'postgresql') { + // @ts-ignore + if (client.$schema.provider.type === 'postgresql') { await expect( client.user.findUnique({ where: { id: user.id }, @@ -901,7 +900,12 @@ describe.each(createClientSpecs(PG_DB_NAME))('Client find tests for $provider', }, }, }); - expect(u.posts[0]).toMatchObject(post2); + expect(u.posts[0]).toMatchObject({ + title: post2.title, + published: post2.published, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + }); u = await client.user.findUniqueOrThrow({ where: { id: user.id }, include: { @@ -912,7 +916,12 @@ describe.each(createClientSpecs(PG_DB_NAME))('Client find tests for $provider', }, }, }); - expect(u.posts[0]).toMatchObject(post1); + expect(u.posts[0]).toMatchObject({ + title: post1.title, + published: post1.published, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + }); // cursor u = await client.user.findUniqueOrThrow({ diff --git a/packages/runtime/test/client-api/group-by.test.ts b/packages/runtime/test/client-api/group-by.test.ts index 859a1050..b0909e34 100644 --- a/packages/runtime/test/client-api/group-by.test.ts +++ b/packages/runtime/test/client-api/group-by.test.ts @@ -1,16 +1,14 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; import { schema } from '../schemas/basic'; -import { createClientSpecs } from './client-specs'; +import { createTestClient } from '../utils'; import { createPosts, createUser } from './utils'; -const PG_DB_NAME = 'client-api-group-by-tests'; - -describe.each(createClientSpecs(PG_DB_NAME))('Client groupBy tests', ({ createClient }) => { +describe('Client groupBy tests', () => { let client: ClientContract; beforeEach(async () => { - client = await createClient(); + client = await createTestClient(schema); }); afterEach(async () => { diff --git a/packages/runtime/test/client-api/mixin.test.ts b/packages/runtime/test/client-api/mixin.test.ts index 9f85d30a..e7b9dcac 100644 --- a/packages/runtime/test/client-api/mixin.test.ts +++ b/packages/runtime/test/client-api/mixin.test.ts @@ -75,7 +75,7 @@ model Bar with CommonFields { description: 'Bar', }, }), - ).rejects.toThrow('constraint failed'); + ).rejects.toThrow('constraint'); }); it('supports multiple-level mixins', async () => { diff --git a/packages/runtime/test/client-api/name-mapping.test.ts b/packages/runtime/test/client-api/name-mapping.test.ts index 41341f7c..cfb9bec2 100644 --- a/packages/runtime/test/client-api/name-mapping.test.ts +++ b/packages/runtime/test/client-api/name-mapping.test.ts @@ -4,84 +4,24 @@ import type { ClientContract } from '../../src'; import { schema, type SchemaType } from '../schemas/name-mapping/schema'; import { createTestClient } from '../utils'; -const TEST_DB = 'client-api-name-mapper-test'; - -describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as const }])( - 'Name mapping tests', - ({ provider }) => { - let db: ClientContract; - - beforeEach(async () => { - db = await createTestClient( - schema, - { usePrismaPush: true, provider, dbName: TEST_DB }, - path.join(__dirname, '../schemas/name-mapping/schema.zmodel'), - ); - }); - - afterEach(async () => { - await db.$disconnect(); - }); - - it('works with create', async () => { - await expect( - db.user.create({ - data: { - email: 'u1@test.com', - posts: { - create: { - title: 'Post1', - }, - }, - }, - }), - ).resolves.toMatchObject({ - id: expect.any(Number), - email: 'u1@test.com', - }); - - await expect( - db.$qb - .insertInto('User') - .values({ - email: 'u2@test.com', - }) - .returning(['id', 'email']) - .executeTakeFirst(), - ).resolves.toMatchObject({ - id: expect.any(Number), - email: 'u2@test.com', - }); - - await expect( - db.$qb - .insertInto('User') - .values({ - email: 'u3@test.com', - }) - .returning(['User.id', 'User.email']) - .executeTakeFirst(), - ).resolves.toMatchObject({ - id: expect.any(Number), - email: 'u3@test.com', - }); - - await expect( - db.$qb - .insertInto('User') - .values({ - email: 'u4@test.com', - }) - .returningAll() - .executeTakeFirst(), - ).resolves.toMatchObject({ - id: expect.any(Number), - email: 'u4@test.com', - }); - }); - - it('works with find', async () => { - const user = await db.user.create({ +describe('Name mapping tests', () => { + let db: ClientContract; + + beforeEach(async () => { + db = await createTestClient( + schema, + { usePrismaPush: true }, + path.join(__dirname, '../schemas/name-mapping/schema.zmodel'), + ); + }); + + afterEach(async () => { + await db.$disconnect(); + }); + + it('works with create', async () => { + await expect( + db.user.create({ data: { email: 'u1@test.com', posts: { @@ -90,346 +30,401 @@ describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as cons }, }, }, - }); - - await expect( - db.user.findFirst({ - where: { email: 'u1@test.com' }, - select: { - id: true, - email: true, - posts: { where: { title: { contains: 'Post1' } }, select: { title: true } }, - }, - }), - ).resolves.toMatchObject({ - id: expect.any(Number), - email: 'u1@test.com', - posts: [{ title: 'Post1' }], - }); + }), + ).resolves.toMatchObject({ + id: expect.any(Number), + email: 'u1@test.com', + }); - await expect( - db.$qb.selectFrom('User').selectAll().where('email', '=', 'u1@test.com').executeTakeFirst(), - ).resolves.toMatchObject({ - id: expect.any(Number), - email: 'u1@test.com', - }); + await expect( + db.$qb + .insertInto('User') + .values({ + email: 'u2@test.com', + }) + .returning(['id', 'email']) + .executeTakeFirst(), + ).resolves.toMatchObject({ + id: expect.any(Number), + email: 'u2@test.com', + }); - await expect( - db.$qb.selectFrom('User').select(['User.email']).where('email', '=', 'u1@test.com').executeTakeFirst(), - ).resolves.toMatchObject({ - email: 'u1@test.com', - }); - - await expect( - db.$qb - .selectFrom('User') - .select(['email']) - .whereRef('email', '=', 'email') - .orderBy(['email']) - .executeTakeFirst(), - ).resolves.toMatchObject({ - email: 'u1@test.com', - }); - - await expect( - db.$qb - .selectFrom('Post') - .innerJoin('User', 'User.id', 'Post.authorId') - .select(['User.email', 'Post.authorId', 'Post.title']) - .whereRef('Post.authorId', '=', 'User.id') - .executeTakeFirst(), - ).resolves.toMatchObject({ - authorId: user.id, - title: 'Post1', - }); - - await expect( - db.$qb - .selectFrom('Post') - .select(['id', 'title']) - .select((eb) => - eb.selectFrom('User').select(['email']).whereRef('User.id', '=', 'Post.authorId').as('email'), - ) - .executeTakeFirst(), - ).resolves.toMatchObject({ - id: user.id, - title: 'Post1', - email: 'u1@test.com', - }); + await expect( + db.$qb + .insertInto('User') + .values({ + email: 'u3@test.com', + }) + .returning(['User.id', 'User.email']) + .executeTakeFirst(), + ).resolves.toMatchObject({ + id: expect.any(Number), + email: 'u3@test.com', }); - it('works with update', async () => { - const user = await db.user.create({ - data: { - email: 'u1@test.com', - posts: { - create: { - id: 1, - title: 'Post1', - }, + await expect( + db.$qb + .insertInto('User') + .values({ + email: 'u4@test.com', + }) + .returningAll() + .executeTakeFirst(), + ).resolves.toMatchObject({ + id: expect.any(Number), + email: 'u4@test.com', + }); + }); + + it('works with find', async () => { + const user = await db.user.create({ + data: { + email: 'u1@test.com', + posts: { + create: { + title: 'Post1', }, }, - }); - - await expect( - db.user.update({ - where: { id: user.id }, - data: { - email: 'u2@test.com', - posts: { - update: { - where: { id: 1 }, - data: { title: 'Post2' }, - }, - }, - }, - include: { posts: true }, - }), - ).resolves.toMatchObject({ - id: user.id, - email: 'u2@test.com', - posts: [expect.objectContaining({ title: 'Post2' })], - }); - - await expect( - db.$qb - .updateTable('User') - .set({ email: (eb) => eb.fn('upper', [eb.ref('email')]) }) - .where('email', '=', 'u2@test.com') - .returning(['email']) - .executeTakeFirst(), - ).resolves.toMatchObject({ email: 'U2@TEST.COM' }); - - await expect( - db.$qb.updateTable('User as u').set({ email: 'u3@test.com' }).returningAll().executeTakeFirst(), - ).resolves.toMatchObject({ id: expect.any(Number), email: 'u3@test.com' }); + }, }); - it('works with delete', async () => { - const user = await db.user.create({ - data: { - email: 'u1@test.com', - posts: { - create: { - id: 1, - title: 'Post1', - }, - }, + await expect( + db.user.findFirst({ + where: { email: 'u1@test.com' }, + select: { + id: true, + email: true, + posts: { where: { title: { contains: 'Post1' } }, select: { title: true } }, }, - }); - - await expect( - db.$qb.deleteFrom('Post').where('title', '=', 'Post1').returning(['id', 'title']).executeTakeFirst(), - ).resolves.toMatchObject({ - id: user.id, - title: 'Post1', - }); - - await expect( - db.user.delete({ - where: { email: 'u1@test.com' }, - include: { posts: true }, - }), - ).resolves.toMatchObject({ - email: 'u1@test.com', - posts: [], - }); + }), + ).resolves.toMatchObject({ + id: expect.any(Number), + email: 'u1@test.com', + posts: [{ title: 'Post1' }], }); - it('works with count', async () => { - await db.user.create({ - data: { - email: 'u1@test.com', - posts: { - create: [{ title: 'Post1' }, { title: 'Post2' }], + await expect( + db.$qb.selectFrom('User').selectAll().where('email', '=', 'u1@test.com').executeTakeFirst(), + ).resolves.toMatchObject({ + id: expect.any(Number), + email: 'u1@test.com', + }); + + await expect( + db.$qb.selectFrom('User').select(['User.email']).where('email', '=', 'u1@test.com').executeTakeFirst(), + ).resolves.toMatchObject({ + email: 'u1@test.com', + }); + + await expect( + db.$qb + .selectFrom('User') + .select(['email']) + .whereRef('email', '=', 'email') + .orderBy(['email']) + .executeTakeFirst(), + ).resolves.toMatchObject({ + email: 'u1@test.com', + }); + + await expect( + db.$qb + .selectFrom('Post') + .innerJoin('User', 'User.id', 'Post.authorId') + .select(['User.email', 'Post.authorId', 'Post.title']) + .whereRef('Post.authorId', '=', 'User.id') + .executeTakeFirst(), + ).resolves.toMatchObject({ + authorId: user.id, + title: 'Post1', + }); + + await expect( + db.$qb + .selectFrom('Post') + .select(['id', 'title']) + .select((eb) => + eb.selectFrom('User').select(['email']).whereRef('User.id', '=', 'Post.authorId').as('email'), + ) + .executeTakeFirst(), + ).resolves.toMatchObject({ + id: user.id, + title: 'Post1', + email: 'u1@test.com', + }); + }); + + it('works with update', async () => { + const user = await db.user.create({ + data: { + email: 'u1@test.com', + posts: { + create: { + id: 1, + title: 'Post1', }, }, - }); + }, + }); - await db.user.create({ + await expect( + db.user.update({ + where: { id: user.id }, data: { email: 'u2@test.com', posts: { - create: [{ title: 'Post3' }], + update: { + where: { id: 1 }, + data: { title: 'Post2' }, + }, }, }, - }); + include: { posts: true }, + }), + ).resolves.toMatchObject({ + id: user.id, + email: 'u2@test.com', + posts: [expect.objectContaining({ title: 'Post2' })], + }); - // Test ORM count operations - await expect(db.user.count()).resolves.toBe(2); - await expect(db.post.count()).resolves.toBe(3); - await expect(db.user.count({ select: { email: true } })).resolves.toMatchObject({ - email: 2, - }); + await expect( + db.$qb + .updateTable('User') + .set({ email: (eb) => eb.fn('upper', [eb.ref('email')]) }) + .where('email', '=', 'u2@test.com') + .returning(['email']) + .executeTakeFirst(), + ).resolves.toMatchObject({ email: 'U2@TEST.COM' }); + + await expect( + db.$qb.updateTable('User as u').set({ email: 'u3@test.com' }).returningAll().executeTakeFirst(), + ).resolves.toMatchObject({ id: expect.any(Number), email: 'u3@test.com' }); + }); + + it('works with delete', async () => { + const user = await db.user.create({ + data: { + email: 'u1@test.com', + posts: { + create: { + id: 1, + title: 'Post1', + }, + }, + }, + }); - await expect(db.user.count({ where: { email: 'u1@test.com' } })).resolves.toBe(1); - await expect(db.post.count({ where: { title: { contains: 'Post1' } } })).resolves.toBe(1); + await expect( + db.$qb.deleteFrom('Post').where('title', '=', 'Post1').returning(['id', 'title']).executeTakeFirst(), + ).resolves.toMatchObject({ + id: user.id, + title: 'Post1', + }); - await expect(db.post.count({ where: { author: { email: 'u1@test.com' } } })).resolves.toBe(2); + await expect( + db.user.delete({ + where: { email: 'u1@test.com' }, + include: { posts: true }, + }), + ).resolves.toMatchObject({ + email: 'u1@test.com', + posts: [], + }); + }); - // Test Kysely count operations - const r = await db.$qb - .selectFrom('User') - .select((eb) => eb.fn.count('email').as('count')) - .executeTakeFirst(); - await expect(Number(r?.count)).toBe(2); + it('works with count', async () => { + await db.user.create({ + data: { + email: 'u1@test.com', + posts: { + create: [{ title: 'Post1' }, { title: 'Post2' }], + }, + }, }); - it('works with aggregate', async () => { - await db.user.create({ - data: { - id: 1, - email: 'u1@test.com', - posts: { - create: [ - { id: 1, title: 'Post1' }, - { id: 2, title: 'Post2' }, - ], - }, + await db.user.create({ + data: { + email: 'u2@test.com', + posts: { + create: [{ title: 'Post3' }], }, - }); + }, + }); - await db.user.create({ - data: { - id: 2, - email: 'u2@test.com', - posts: { - create: [{ id: 3, title: 'Post3' }], - }, + // Test ORM count operations + await expect(db.user.count()).resolves.toBe(2); + await expect(db.post.count()).resolves.toBe(3); + await expect(db.user.count({ select: { email: true } })).resolves.toMatchObject({ + email: 2, + }); + + await expect(db.user.count({ where: { email: 'u1@test.com' } })).resolves.toBe(1); + await expect(db.post.count({ where: { title: { contains: 'Post1' } } })).resolves.toBe(1); + + await expect(db.post.count({ where: { author: { email: 'u1@test.com' } } })).resolves.toBe(2); + + // Test Kysely count operations + const r = await db.$qb + .selectFrom('User') + .select((eb) => eb.fn.count('email').as('count')) + .executeTakeFirst(); + await expect(Number(r?.count)).toBe(2); + }); + + it('works with aggregate', async () => { + await db.user.create({ + data: { + id: 1, + email: 'u1@test.com', + posts: { + create: [ + { id: 1, title: 'Post1' }, + { id: 2, title: 'Post2' }, + ], }, - }); - - // Test ORM aggregate operations - await expect(db.user.aggregate({ _count: { id: true, email: true } })).resolves.toMatchObject({ - _count: { id: 2, email: 2 }, - }); - - await expect( - db.post.aggregate({ _count: { authorId: true }, _min: { authorId: true }, _max: { authorId: true } }), - ).resolves.toMatchObject({ - _count: { authorId: 3 }, - _min: { authorId: 1 }, - _max: { authorId: 2 }, - }); - - await expect( - db.post.aggregate({ - where: { author: { email: 'u1@test.com' } }, - _count: { authorId: true }, - _min: { authorId: true }, - _max: { authorId: true }, - }), - ).resolves.toMatchObject({ - _count: { authorId: 2 }, - _min: { authorId: 1 }, - _max: { authorId: 1 }, - }); - - // Test Kysely aggregate operations - const countResult = await db.$qb - .selectFrom('User') - .select((eb) => eb.fn.count('email').as('emailCount')) - .executeTakeFirst(); - expect(Number(countResult?.emailCount)).toBe(2); + }, + }); - const postAggResult = await db.$qb - .selectFrom('Post') - .select((eb) => [eb.fn.min('authorId').as('minAuthorId'), eb.fn.max('authorId').as('maxAuthorId')]) - .executeTakeFirst(); - expect(Number(postAggResult?.minAuthorId)).toBe(1); - expect(Number(postAggResult?.maxAuthorId)).toBe(2); + await db.user.create({ + data: { + id: 2, + email: 'u2@test.com', + posts: { + create: [{ id: 3, title: 'Post3' }], + }, + }, }); - it('works with groupBy', async () => { - // Create test data with multiple posts per user - await db.user.create({ - data: { - id: 1, - email: 'u1@test.com', - posts: { - create: [ - { id: 1, title: 'Post1' }, - { id: 2, title: 'Post2' }, - { id: 3, title: 'Post3' }, - ], - }, + // Test ORM aggregate operations + await expect(db.user.aggregate({ _count: { id: true, email: true } })).resolves.toMatchObject({ + _count: { id: 2, email: 2 }, + }); + + await expect( + db.post.aggregate({ _count: { authorId: true }, _min: { authorId: true }, _max: { authorId: true } }), + ).resolves.toMatchObject({ + _count: { authorId: 3 }, + _min: { authorId: 1 }, + _max: { authorId: 2 }, + }); + + await expect( + db.post.aggregate({ + where: { author: { email: 'u1@test.com' } }, + _count: { authorId: true }, + _min: { authorId: true }, + _max: { authorId: true }, + }), + ).resolves.toMatchObject({ + _count: { authorId: 2 }, + _min: { authorId: 1 }, + _max: { authorId: 1 }, + }); + + // Test Kysely aggregate operations + const countResult = await db.$qb + .selectFrom('User') + .select((eb) => eb.fn.count('email').as('emailCount')) + .executeTakeFirst(); + expect(Number(countResult?.emailCount)).toBe(2); + + const postAggResult = await db.$qb + .selectFrom('Post') + .select((eb) => [eb.fn.min('authorId').as('minAuthorId'), eb.fn.max('authorId').as('maxAuthorId')]) + .executeTakeFirst(); + expect(Number(postAggResult?.minAuthorId)).toBe(1); + expect(Number(postAggResult?.maxAuthorId)).toBe(2); + }); + + it('works with groupBy', async () => { + // Create test data with multiple posts per user + await db.user.create({ + data: { + id: 1, + email: 'u1@test.com', + posts: { + create: [ + { id: 1, title: 'Post1' }, + { id: 2, title: 'Post2' }, + { id: 3, title: 'Post3' }, + ], }, - }); + }, + }); - await db.user.create({ - data: { - id: 2, - email: 'u2@test.com', - posts: { - create: [ - { id: 4, title: 'Post4' }, - { id: 5, title: 'Post5' }, - ], - }, + await db.user.create({ + data: { + id: 2, + email: 'u2@test.com', + posts: { + create: [ + { id: 4, title: 'Post4' }, + { id: 5, title: 'Post5' }, + ], }, - }); + }, + }); - await db.user.create({ - data: { - id: 3, - email: 'u3@test.com', - posts: { - create: [{ id: 6, title: 'Post6' }], - }, + await db.user.create({ + data: { + id: 3, + email: 'u3@test.com', + posts: { + create: [{ id: 6, title: 'Post6' }], }, - }); - - // Test ORM groupBy operations - const userGroupBy = await db.user.groupBy({ - by: ['email'], - _count: { id: true }, - }); - expect(userGroupBy).toHaveLength(3); - expect(userGroupBy).toEqual( - expect.arrayContaining([ - { email: 'u1@test.com', _count: { id: 1 } }, - { email: 'u2@test.com', _count: { id: 1 } }, - { email: 'u3@test.com', _count: { id: 1 } }, - ]), - ); - - const postGroupBy = await db.post.groupBy({ - by: ['authorId'], - _count: { id: true }, - _min: { id: true }, - _max: { id: true }, - }); - expect(postGroupBy).toHaveLength(3); - expect(postGroupBy).toEqual( - expect.arrayContaining([ - { authorId: 1, _count: { id: 3 }, _min: { id: 1 }, _max: { id: 3 } }, - { authorId: 2, _count: { id: 2 }, _min: { id: 4 }, _max: { id: 5 } }, - { authorId: 3, _count: { id: 1 }, _min: { id: 6 }, _max: { id: 6 } }, - ]), - ); - - const filteredGroupBy = await db.post.groupBy({ - by: ['authorId'], - where: { title: { contains: 'Post' } }, - _count: { title: true }, - having: { title: { _count: { gte: 2 } } }, - }); - expect(filteredGroupBy).toHaveLength(2); - expect(filteredGroupBy).toEqual( - expect.arrayContaining([ - { authorId: 1, _count: { title: 3 } }, - { authorId: 2, _count: { title: 2 } }, - ]), - ); - - // Test Kysely groupBy operations - const kyselyUserGroupBy = await db.$qb - .selectFrom('User') - .select(['email', (eb) => eb.fn.count('email').as('count')]) - .groupBy('email') - .having((eb) => eb.fn.count('email'), '>=', 1) - .execute(); - expect(kyselyUserGroupBy).toHaveLength(3); + }, + }); + + // Test ORM groupBy operations + const userGroupBy = await db.user.groupBy({ + by: ['email'], + _count: { id: true }, + }); + expect(userGroupBy).toHaveLength(3); + expect(userGroupBy).toEqual( + expect.arrayContaining([ + { email: 'u1@test.com', _count: { id: 1 } }, + { email: 'u2@test.com', _count: { id: 1 } }, + { email: 'u3@test.com', _count: { id: 1 } }, + ]), + ); + + const postGroupBy = await db.post.groupBy({ + by: ['authorId'], + _count: { id: true }, + _min: { id: true }, + _max: { id: true }, + }); + expect(postGroupBy).toHaveLength(3); + expect(postGroupBy).toEqual( + expect.arrayContaining([ + { authorId: 1, _count: { id: 3 }, _min: { id: 1 }, _max: { id: 3 } }, + { authorId: 2, _count: { id: 2 }, _min: { id: 4 }, _max: { id: 5 } }, + { authorId: 3, _count: { id: 1 }, _min: { id: 6 }, _max: { id: 6 } }, + ]), + ); + + const filteredGroupBy = await db.post.groupBy({ + by: ['authorId'], + where: { title: { contains: 'Post' } }, + _count: { title: true }, + having: { title: { _count: { gte: 2 } } }, }); - }, -); + expect(filteredGroupBy).toHaveLength(2); + expect(filteredGroupBy).toEqual( + expect.arrayContaining([ + { authorId: 1, _count: { title: 3 } }, + { authorId: 2, _count: { title: 2 } }, + ]), + ); + + // Test Kysely groupBy operations + const kyselyUserGroupBy = await db.$qb + .selectFrom('User') + .select(['email', (eb) => eb.fn.count('email').as('count')]) + .groupBy('email') + .having((eb) => eb.fn.count('email'), '>=', 1) + .execute(); + expect(kyselyUserGroupBy).toHaveLength(3); + }); +}); diff --git a/packages/runtime/test/client-api/raw-query.test.ts b/packages/runtime/test/client-api/raw-query.test.ts index b1754b67..f7838641 100644 --- a/packages/runtime/test/client-api/raw-query.test.ts +++ b/packages/runtime/test/client-api/raw-query.test.ts @@ -1,15 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; import { schema } from '../schemas/basic'; -import { createClientSpecs } from './client-specs'; +import { createTestClient } from '../utils'; -const PG_DB_NAME = 'client-api-raw-query-tests'; - -describe.each(createClientSpecs(PG_DB_NAME))('Client raw query tests', ({ createClient, provider }) => { +describe('Client raw query tests', () => { let client: ClientContract; beforeEach(async () => { - client = await createClient(); + client = await createTestClient(schema); }); afterEach(async () => { @@ -39,7 +37,8 @@ describe.each(createClientSpecs(PG_DB_NAME))('Client raw query tests', ({ create }); const sql = - provider === 'postgresql' + // @ts-ignore + client.$schema.provider.type === 'postgresql' ? `UPDATE "User" SET "email" = $1 WHERE "id" = $2` : `UPDATE "User" SET "email" = ? WHERE "id" = ?`; await expect(client.$executeRawUnsafe(sql, 'u2@test.com', '1')).resolves.toBe(1); @@ -70,7 +69,8 @@ describe.each(createClientSpecs(PG_DB_NAME))('Client raw query tests', ({ create }); const sql = - provider === 'postgresql' + // @ts-ignore + client.$schema.provider.type === 'postgresql' ? `SELECT "User"."id", "User"."email" FROM "User" WHERE "User"."id" = $1` : `SELECT "User"."id", "User"."email" FROM "User" WHERE "User"."id" = ?`; const users = await client.$queryRawUnsafe<{ id: string; email: string }[]>(sql, '1'); diff --git a/packages/runtime/test/client-api/relation/many-to-many.test.ts b/packages/runtime/test/client-api/relation/many-to-many.test.ts index c951387e..dd1eacf0 100644 --- a/packages/runtime/test/client-api/relation/many-to-many.test.ts +++ b/packages/runtime/test/client-api/relation/many-to-many.test.ts @@ -1,20 +1,16 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createTestClient } from '../../utils'; -const TEST_DB = 'client-api-relation-test-many-to-many'; +describe('Many-to-many relation tests', () => { + let client: any; -describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as const }])( - 'Many-to-many relation tests for $provider', - ({ provider }) => { - let client: any; + afterEach(async () => { + await client?.$disconnect(); + }); - afterEach(async () => { - await client?.$disconnect(); - }); - - it('works with explicit many-to-many relation', async () => { - client = await createTestClient( - ` + it('works with explicit many-to-many relation', async () => { + client = await createTestClient( + ` model User { id Int @id @default(autoincrement()) name String @@ -36,54 +32,50 @@ describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as cons @@unique([userId, tagId]) } `, - { - provider, - dbName: TEST_DB, - }, - ); - - await client.user.create({ data: { id: 1, name: 'User1' } }); - await client.user.create({ data: { id: 2, name: 'User2' } }); - await client.tag.create({ data: { id: 1, name: 'Tag1' } }); - await client.tag.create({ data: { id: 2, name: 'Tag2' } }); - - await client.userTag.create({ data: { userId: 1, tagId: 1 } }); - await client.userTag.create({ data: { userId: 1, tagId: 2 } }); - await client.userTag.create({ data: { userId: 2, tagId: 1 } }); - - await expect( - client.user.findMany({ - include: { tags: { include: { tag: true } } }, - }), - ).resolves.toMatchObject([ - expect.objectContaining({ - name: 'User1', - tags: [ - expect.objectContaining({ - tag: expect.objectContaining({ name: 'Tag1' }), - }), - expect.objectContaining({ - tag: expect.objectContaining({ name: 'Tag2' }), - }), - ], - }), - expect.objectContaining({ - name: 'User2', - tags: [ - expect.objectContaining({ - tag: expect.objectContaining({ name: 'Tag1' }), - }), - ], - }), - ]); - }); - - describe.each([{ relationName: undefined }, { relationName: 'myM2M' }])( - 'Implicit many-to-many relation (relation: $relationName)', - ({ relationName }) => { - beforeEach(async () => { - client = await createTestClient( - ` + ); + + await client.user.create({ data: { id: 1, name: 'User1' } }); + await client.user.create({ data: { id: 2, name: 'User2' } }); + await client.tag.create({ data: { id: 1, name: 'Tag1' } }); + await client.tag.create({ data: { id: 2, name: 'Tag2' } }); + + await client.userTag.create({ data: { userId: 1, tagId: 1 } }); + await client.userTag.create({ data: { userId: 1, tagId: 2 } }); + await client.userTag.create({ data: { userId: 2, tagId: 1 } }); + + await expect( + client.user.findMany({ + include: { tags: { include: { tag: true } } }, + }), + ).resolves.toMatchObject([ + expect.objectContaining({ + name: 'User1', + tags: [ + expect.objectContaining({ + tag: expect.objectContaining({ name: 'Tag1' }), + }), + expect.objectContaining({ + tag: expect.objectContaining({ name: 'Tag2' }), + }), + ], + }), + expect.objectContaining({ + name: 'User2', + tags: [ + expect.objectContaining({ + tag: expect.objectContaining({ name: 'Tag1' }), + }), + ], + }), + ]); + }); + + describe.each([{ relationName: undefined }, { relationName: 'myM2M' }])( + 'Implicit many-to-many relation (relation: $relationName)', + ({ relationName }) => { + beforeEach(async () => { + client = await createTestClient( + ` model User { id Int @id @default(autoincrement()) name String @@ -104,500 +96,494 @@ describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as cons userId Int @unique } `, - { - provider, - dbName: provider === 'postgresql' ? TEST_DB : undefined, - usePrismaPush: true, + { + usePrismaPush: true, + }, + ); + }); + + it('works with find', async () => { + await client.user.create({ + data: { + id: 1, + name: 'User1', + tags: { + create: [ + { id: 1, name: 'Tag1' }, + { id: 2, name: 'Tag2' }, + ], }, - ); + profile: { + create: { + id: 1, + age: 20, + }, + }, + }, + }); + + await client.user.create({ + data: { + id: 2, + name: 'User2', + }, + }); + + // include without filter + await expect( + client.user.findFirst({ + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [expect.objectContaining({ name: 'Tag1' }), expect.objectContaining({ name: 'Tag2' })], + }); + + await expect( + client.profile.findFirst({ + include: { + user: { + include: { tags: true }, + }, + }, + }), + ).resolves.toMatchObject({ + user: expect.objectContaining({ + tags: [expect.objectContaining({ name: 'Tag1' }), expect.objectContaining({ name: 'Tag2' })], + }), }); - it('works with find', async () => { - await client.user.create({ + await expect( + client.user.findUnique({ + where: { id: 2 }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [], + }); + + // include with filter + await expect( + client.user.findFirst({ + where: { id: 1 }, + include: { tags: { where: { name: 'Tag1' } } }, + }), + ).resolves.toMatchObject({ + tags: [expect.objectContaining({ name: 'Tag1' })], + }); + + // filter with m2m + await expect( + client.user.findMany({ + where: { tags: { some: { name: 'Tag1' } } }, + }), + ).resolves.toEqual([ + expect.objectContaining({ + name: 'User1', + }), + ]); + await expect( + client.user.findMany({ + where: { tags: { none: { name: 'Tag1' } } }, + }), + ).resolves.toEqual([ + expect.objectContaining({ + name: 'User2', + }), + ]); + }); + + it('works with create', async () => { + // create + await expect( + client.user.create({ data: { id: 1, name: 'User1', tags: { create: [ - { id: 1, name: 'Tag1' }, - { id: 2, name: 'Tag2' }, + { + id: 1, + name: 'Tag1', + }, + { + id: 2, + name: 'Tag2', + }, ], }, - profile: { - create: { - id: 1, - age: 20, - }, - }, }, - }); + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [expect.objectContaining({ name: 'Tag1' }), expect.objectContaining({ name: 'Tag2' })], + }); - await client.user.create({ + // connect + await expect( + client.user.create({ data: { id: 2, name: 'User2', + tags: { connect: { id: 1 } }, }, - }); - - // include without filter - await expect( - client.user.findFirst({ - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [expect.objectContaining({ name: 'Tag1' }), expect.objectContaining({ name: 'Tag2' })], - }); + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [expect.objectContaining({ name: 'Tag1' })], + }); - await expect( - client.profile.findFirst({ - include: { - user: { - include: { tags: true }, + // connectOrCreate + await expect( + client.user.create({ + data: { + id: 3, + name: 'User3', + tags: { + connectOrCreate: { + where: { id: 1 }, + create: { id: 1, name: 'Tag1' }, }, }, - }), - ).resolves.toMatchObject({ - user: expect.objectContaining({ - tags: [ - expect.objectContaining({ name: 'Tag1' }), - expect.objectContaining({ name: 'Tag2' }), - ], - }), - }); - - await expect( - client.user.findUnique({ - where: { id: 2 }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [], - }); - - // include with filter - await expect( - client.user.findFirst({ - where: { id: 1 }, - include: { tags: { where: { name: 'Tag1' } } }, - }), - ).resolves.toMatchObject({ - tags: [expect.objectContaining({ name: 'Tag1' })], - }); - - // filter with m2m - await expect( - client.user.findMany({ - where: { tags: { some: { name: 'Tag1' } } }, - }), - ).resolves.toEqual([ - expect.objectContaining({ - name: 'User1', - }), - ]); - await expect( - client.user.findMany({ - where: { tags: { none: { name: 'Tag1' } } }, - }), - ).resolves.toEqual([ - expect.objectContaining({ - name: 'User2', - }), - ]); + }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [expect.objectContaining({ id: 1, name: 'Tag1' })], }); - it('works with create', async () => { - // create - await expect( - client.user.create({ - data: { - id: 1, - name: 'User1', - tags: { - create: [ - { - id: 1, - name: 'Tag1', - }, - { - id: 2, - name: 'Tag2', - }, - ], - }, - }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [expect.objectContaining({ name: 'Tag1' }), expect.objectContaining({ name: 'Tag2' })], - }); - - // connect - await expect( - client.user.create({ - data: { - id: 2, - name: 'User2', - tags: { connect: { id: 1 } }, - }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [expect.objectContaining({ name: 'Tag1' })], - }); - - // connectOrCreate - await expect( - client.user.create({ - data: { - id: 3, - name: 'User3', - tags: { - connectOrCreate: { - where: { id: 1 }, - create: { id: 1, name: 'Tag1' }, - }, + await expect( + client.user.create({ + data: { + id: 4, + name: 'User4', + tags: { + connectOrCreate: { + where: { id: 3 }, + create: { id: 3, name: 'Tag3' }, }, }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [expect.objectContaining({ id: 1, name: 'Tag1' })], - }); - - await expect( - client.user.create({ - data: { - id: 4, - name: 'User4', - tags: { - connectOrCreate: { - where: { id: 3 }, - create: { id: 3, name: 'Tag3' }, - }, + }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [expect.objectContaining({ id: 3, name: 'Tag3' })], + }); + }); + + it('works with update', async () => { + // create + await client.user.create({ + data: { + id: 1, + name: 'User1', + tags: { + create: [ + { + id: 1, + name: 'Tag1', }, - }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [expect.objectContaining({ id: 3, name: 'Tag3' })], - }); + ], + }, + }, + include: { tags: true }, }); - it('works with update', async () => { - // create - await client.user.create({ + // create + await expect( + client.user.update({ + where: { id: 1 }, data: { - id: 1, - name: 'User1', tags: { create: [ { - id: 1, - name: 'Tag1', + id: 2, + name: 'Tag2', }, ], }, }, include: { tags: true }, - }); - - // create - await expect( - client.user.update({ - where: { id: 1 }, - data: { - tags: { - create: [ - { - id: 2, - name: 'Tag2', - }, - ], - }, - }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [expect.objectContaining({ id: 1 }), expect.objectContaining({ id: 2 })], - }); + }), + ).resolves.toMatchObject({ + tags: [expect.objectContaining({ id: 1 }), expect.objectContaining({ id: 2 })], + }); - await client.tag.create({ + await client.tag.create({ + data: { + id: 3, + name: 'Tag3', + }, + }); + + // connect + await expect( + client.user.update({ + where: { id: 1 }, + data: { tags: { connect: { id: 3 } } }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [ + expect.objectContaining({ id: 1 }), + expect.objectContaining({ id: 2 }), + expect.objectContaining({ id: 3 }), + ], + }); + // connecting a connected entity is no-op + await expect( + client.user.update({ + where: { id: 1 }, + data: { tags: { connect: { id: 3 } } }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [ + expect.objectContaining({ id: 1 }), + expect.objectContaining({ id: 2 }), + expect.objectContaining({ id: 3 }), + ], + }); + + // disconnect - not found + await expect( + client.user.update({ + where: { id: 1 }, data: { - id: 3, - name: 'Tag3', + tags: { disconnect: { id: 3, name: 'not found' } }, }, - }); - - // connect - await expect( - client.user.update({ - where: { id: 1 }, - data: { tags: { connect: { id: 3 } } }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [ - expect.objectContaining({ id: 1 }), - expect.objectContaining({ id: 2 }), - expect.objectContaining({ id: 3 }), - ], - }); - // connecting a connected entity is no-op - await expect( - client.user.update({ - where: { id: 1 }, - data: { tags: { connect: { id: 3 } } }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [ - expect.objectContaining({ id: 1 }), - expect.objectContaining({ id: 2 }), - expect.objectContaining({ id: 3 }), - ], - }); - - // disconnect - not found - await expect( - client.user.update({ - where: { id: 1 }, - data: { - tags: { disconnect: { id: 3, name: 'not found' } }, - }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [ - expect.objectContaining({ id: 1 }), - expect.objectContaining({ id: 2 }), - expect.objectContaining({ id: 3 }), - ], - }); - - // disconnect - found - await expect( - client.user.update({ - where: { id: 1 }, - data: { tags: { disconnect: { id: 3 } } }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [expect.objectContaining({ id: 1 }), expect.objectContaining({ id: 2 })], - }); - - await expect( - client.$qbRaw - .selectFrom(relationName ? `_${relationName}` : '_TagToUser') - .selectAll() - .where('B', '=', 1) // user id - .where('A', '=', 3) // tag id - .execute(), - ).resolves.toHaveLength(0); - - await expect( - client.user.update({ - where: { id: 1 }, - data: { tags: { set: [{ id: 2 }, { id: 3 }] } }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [expect.objectContaining({ id: 2 }), expect.objectContaining({ id: 3 })], - }); - - // update - not found - await expect( - client.user.update({ - where: { id: 1 }, - data: { - tags: { - update: { - where: { id: 1 }, - data: { name: 'Tag1-updated' }, - }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [ + expect.objectContaining({ id: 1 }), + expect.objectContaining({ id: 2 }), + expect.objectContaining({ id: 3 }), + ], + }); + + // disconnect - found + await expect( + client.user.update({ + where: { id: 1 }, + data: { tags: { disconnect: { id: 3 } } }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [expect.objectContaining({ id: 1 }), expect.objectContaining({ id: 2 })], + }); + + await expect( + client.$qbRaw + .selectFrom(relationName ? `_${relationName}` : '_TagToUser') + .selectAll() + .where('B', '=', 1) // user id + .where('A', '=', 3) // tag id + .execute(), + ).resolves.toHaveLength(0); + + await expect( + client.user.update({ + where: { id: 1 }, + data: { tags: { set: [{ id: 2 }, { id: 3 }] } }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [expect.objectContaining({ id: 2 }), expect.objectContaining({ id: 3 })], + }); + + // update - not found + await expect( + client.user.update({ + where: { id: 1 }, + data: { + tags: { + update: { + where: { id: 1 }, + data: { name: 'Tag1-updated' }, }, }, - }), - ).toBeRejectedNotFound(); - - // update - found - await expect( - client.user.update({ - where: { id: 1 }, - data: { - tags: { - update: { - where: { id: 2 }, - data: { name: 'Tag2-updated' }, - }, + }, + }), + ).toBeRejectedNotFound(); + + // update - found + await expect( + client.user.update({ + where: { id: 1 }, + data: { + tags: { + update: { + where: { id: 2 }, + data: { name: 'Tag2-updated' }, }, }, - include: { tags: true }, + }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: expect.arrayContaining([ + expect.objectContaining({ + id: 2, + name: 'Tag2-updated', }), - ).resolves.toMatchObject({ - tags: expect.arrayContaining([ - expect.objectContaining({ - id: 2, - name: 'Tag2-updated', - }), - expect.objectContaining({ id: 3, name: 'Tag3' }), - ]), - }); - - // updateMany - await expect( - client.user.update({ - where: { id: 1 }, - data: { - tags: { - updateMany: { - where: { id: { not: 2 } }, - data: { name: 'Tag3-updated' }, - }, + expect.objectContaining({ id: 3, name: 'Tag3' }), + ]), + }); + + // updateMany + await expect( + client.user.update({ + where: { id: 1 }, + data: { + tags: { + updateMany: { + where: { id: { not: 2 } }, + data: { name: 'Tag3-updated' }, }, }, - include: { tags: true }, + }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [ + expect.objectContaining({ + id: 2, + name: 'Tag2-updated', }), - ).resolves.toMatchObject({ - tags: [ - expect.objectContaining({ - id: 2, - name: 'Tag2-updated', - }), - expect.objectContaining({ - id: 3, - name: 'Tag3-updated', - }), - ], - }); - - await expect(client.tag.findUnique({ where: { id: 1 } })).resolves.toMatchObject({ - name: 'Tag1', - }); - - // upsert - update - await expect( - client.user.update({ - where: { id: 1 }, - data: { - tags: { - upsert: { - where: { id: 3 }, - create: { id: 3, name: 'Tag4' }, - update: { name: 'Tag3-updated-1' }, - }, - }, - }, - include: { tags: true }, + expect.objectContaining({ + id: 3, + name: 'Tag3-updated', }), - ).resolves.toMatchObject({ - tags: [ - expect.objectContaining({ - id: 2, - name: 'Tag2-updated', - }), - expect.objectContaining({ - id: 3, - name: 'Tag3-updated-1', - }), - ], - }); - - // upsert - create - await expect( - client.user.update({ - where: { id: 1 }, - data: { - tags: { - upsert: { - where: { id: 4 }, - create: { id: 4, name: 'Tag4' }, - update: { name: 'Tag4' }, - }, + ], + }); + + await expect(client.tag.findUnique({ where: { id: 1 } })).resolves.toMatchObject({ + name: 'Tag1', + }); + + // upsert - update + await expect( + client.user.update({ + where: { id: 1 }, + data: { + tags: { + upsert: { + where: { id: 3 }, + create: { id: 3, name: 'Tag4' }, + update: { name: 'Tag3-updated-1' }, }, }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: expect.arrayContaining([expect.objectContaining({ id: 4, name: 'Tag4' })]), - }); - - // delete - not found - await expect( - client.user.update({ - where: { id: 1 }, - data: { tags: { delete: { id: 1 } } }, - }), - ).toBeRejectedNotFound(); - - // delete - found - await expect( - client.user.update({ - where: { id: 1 }, - data: { tags: { delete: { id: 2 } } }, - include: { tags: true }, + }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [ + expect.objectContaining({ + id: 2, + name: 'Tag2-updated', }), - ).resolves.toMatchObject({ - tags: [expect.objectContaining({ id: 3 }), expect.objectContaining({ id: 4 })], - }); - await expect(client.tag.findUnique({ where: { id: 2 } })).toResolveNull(); - - // deleteMany - await expect( - client.user.update({ - where: { id: 1 }, - data: { - tags: { deleteMany: { id: { in: [1, 2, 3] } } }, - }, - include: { tags: true }, + expect.objectContaining({ + id: 3, + name: 'Tag3-updated-1', }), - ).resolves.toMatchObject({ - tags: [expect.objectContaining({ id: 4 })], - }); - await expect(client.tag.findUnique({ where: { id: 3 } })).toResolveNull(); - await expect(client.tag.findUnique({ where: { id: 1 } })).toResolveTruthy(); + ], }); - it('works with delete', async () => { - await client.user.create({ + // upsert - create + await expect( + client.user.update({ + where: { id: 1 }, data: { - id: 1, - name: 'User1', tags: { - create: [ - { id: 1, name: 'Tag1' }, - { id: 2, name: 'Tag2' }, - ], + upsert: { + where: { id: 4 }, + create: { id: 4, name: 'Tag4' }, + update: { name: 'Tag4' }, + }, }, }, - }); + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: expect.arrayContaining([expect.objectContaining({ id: 4, name: 'Tag4' })]), + }); - // cascade from tag - await client.tag.delete({ + // delete - not found + await expect( + client.user.update({ where: { id: 1 }, - }); - await expect( - client.user.findUnique({ - where: { id: 1 }, - include: { tags: true }, - }), - ).resolves.toMatchObject({ - tags: [expect.objectContaining({ id: 2 })], - }); + data: { tags: { delete: { id: 1 } } }, + }), + ).toBeRejectedNotFound(); - // cascade from user - await client.user.delete({ + // delete - found + await expect( + client.user.update({ where: { id: 1 }, - }); - await expect( - client.tag.findUnique({ - where: { id: 2 }, - include: { users: true }, - }), - ).resolves.toMatchObject({ - users: [], - }); + data: { tags: { delete: { id: 2 } } }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [expect.objectContaining({ id: 3 }), expect.objectContaining({ id: 4 })], }); - }, - ); - }, -); + await expect(client.tag.findUnique({ where: { id: 2 } })).toResolveNull(); + + // deleteMany + await expect( + client.user.update({ + where: { id: 1 }, + data: { + tags: { deleteMany: { id: { in: [1, 2, 3] } } }, + }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [expect.objectContaining({ id: 4 })], + }); + await expect(client.tag.findUnique({ where: { id: 3 } })).toResolveNull(); + await expect(client.tag.findUnique({ where: { id: 1 } })).toResolveTruthy(); + }); + + it('works with delete', async () => { + await client.user.create({ + data: { + id: 1, + name: 'User1', + tags: { + create: [ + { id: 1, name: 'Tag1' }, + { id: 2, name: 'Tag2' }, + ], + }, + }, + }); + + // cascade from tag + await client.tag.delete({ + where: { id: 1 }, + }); + await expect( + client.user.findUnique({ + where: { id: 1 }, + include: { tags: true }, + }), + ).resolves.toMatchObject({ + tags: [expect.objectContaining({ id: 2 })], + }); + + // cascade from user + await client.user.delete({ + where: { id: 1 }, + }); + await expect( + client.tag.findUnique({ + where: { id: 2 }, + include: { users: true }, + }), + ).resolves.toMatchObject({ + users: [], + }); + }); + }, + ); +}); diff --git a/packages/runtime/test/client-api/relation/one-to-many.test.ts b/packages/runtime/test/client-api/relation/one-to-many.test.ts index 656c5daa..be847d5e 100644 --- a/packages/runtime/test/client-api/relation/one-to-many.test.ts +++ b/packages/runtime/test/client-api/relation/one-to-many.test.ts @@ -1,20 +1,16 @@ import { afterEach, describe, expect, it } from 'vitest'; import { createTestClient } from '../../utils'; -const TEST_DB = 'client-api-relation-test-one-to-many'; +describe('One-to-many relation tests ', () => { + let client: any; -describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as const }])( - 'One-to-many relation tests for $provider', - ({ provider }) => { - let client: any; + afterEach(async () => { + await client?.$disconnect(); + }); - afterEach(async () => { - await client?.$disconnect(); - }); - - it('works with unnamed one-to-many relation', async () => { - client = await createTestClient( - ` + it('works with unnamed one-to-many relation', async () => { + client = await createTestClient( + ` model User { id Int @id @default(autoincrement()) name String @@ -28,31 +24,27 @@ describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as cons userId Int } `, - { - provider, - dbName: TEST_DB, - }, - ); + ); - await expect( - client.user.create({ - data: { - name: 'User', - posts: { - create: [{ title: 'Post 1' }, { title: 'Post 2' }], - }, + await expect( + client.user.create({ + data: { + name: 'User', + posts: { + create: [{ title: 'Post 1' }, { title: 'Post 2' }], }, - include: { posts: true }, - }), - ).resolves.toMatchObject({ - name: 'User', - posts: [expect.objectContaining({ title: 'Post 1' }), expect.objectContaining({ title: 'Post 2' })], - }); + }, + include: { posts: true }, + }), + ).resolves.toMatchObject({ + name: 'User', + posts: [expect.objectContaining({ title: 'Post 1' }), expect.objectContaining({ title: 'Post 2' })], }); + }); - it('works with named one-to-many relation', async () => { - client = await createTestClient( - ` + it('works with named one-to-many relation', async () => { + client = await createTestClient( + ` model User { id Int @id @default(autoincrement()) name String @@ -69,30 +61,25 @@ describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as cons userId2 Int? } `, - { - provider, - dbName: TEST_DB, - }, - ); + ); - await expect( - client.user.create({ - data: { - name: 'User', - posts1: { - create: [{ title: 'Post 1' }, { title: 'Post 2' }], - }, - posts2: { - create: [{ title: 'Post 3' }, { title: 'Post 4' }], - }, + await expect( + client.user.create({ + data: { + name: 'User', + posts1: { + create: [{ title: 'Post 1' }, { title: 'Post 2' }], + }, + posts2: { + create: [{ title: 'Post 3' }, { title: 'Post 4' }], }, - include: { posts1: true, posts2: true }, - }), - ).resolves.toMatchObject({ - name: 'User', - posts1: [expect.objectContaining({ title: 'Post 1' }), expect.objectContaining({ title: 'Post 2' })], - posts2: [expect.objectContaining({ title: 'Post 3' }), expect.objectContaining({ title: 'Post 4' })], - }); + }, + include: { posts1: true, posts2: true }, + }), + ).resolves.toMatchObject({ + name: 'User', + posts1: [expect.objectContaining({ title: 'Post 1' }), expect.objectContaining({ title: 'Post 2' })], + posts2: [expect.objectContaining({ title: 'Post 3' }), expect.objectContaining({ title: 'Post 4' })], }); - }, -); + }); +}); diff --git a/packages/runtime/test/client-api/relation/one-to-one.test.ts b/packages/runtime/test/client-api/relation/one-to-one.test.ts index b1e80562..e41a0cf9 100644 --- a/packages/runtime/test/client-api/relation/one-to-one.test.ts +++ b/packages/runtime/test/client-api/relation/one-to-one.test.ts @@ -4,7 +4,7 @@ import { createTestClient } from '../../utils'; const TEST_DB = 'client-api-relation-test-one-to-one'; describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as const }])( - 'One-to-one relation tests for $provider', + 'One-to-one relation tests', ({ provider }) => { let client: any; diff --git a/packages/runtime/test/client-api/relation/self-relation.test.ts b/packages/runtime/test/client-api/relation/self-relation.test.ts index f85c20c4..65380b30 100644 --- a/packages/runtime/test/client-api/relation/self-relation.test.ts +++ b/packages/runtime/test/client-api/relation/self-relation.test.ts @@ -1,20 +1,16 @@ import { afterEach, describe, expect, it } from 'vitest'; import { createTestClient } from '../../utils'; -const TEST_DB = 'client-api-relation-test-self-relation'; +describe('Self relation tests', () => { + let client: any; -describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as const }])( - 'Self relation tests for $provider', - ({ provider }) => { - let client: any; + afterEach(async () => { + await client?.$disconnect(); + }); - afterEach(async () => { - await client?.$disconnect(); - }); - - it('works with one-to-one self relation', async () => { - client = await createTestClient( - ` + it('works with one-to-one self relation', async () => { + client = await createTestClient( + ` model User { id Int @id @default(autoincrement()) name String @@ -23,105 +19,103 @@ describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as cons spouseId Int? @unique } `, - { - provider, - dbName: TEST_DB, - usePrismaPush: true, + { + usePrismaPush: true, + }, + ); + + // Create first user + const alice = await client.user.create({ + data: { name: 'Alice' }, + }); + + // Create second user and establish marriage relationship + await expect( + client.user.create({ + data: { + name: 'Bob', + spouse: { connect: { id: alice.id } }, }, - ); - - // Create first user - const alice = await client.user.create({ - data: { name: 'Alice' }, - }); - - // Create second user and establish marriage relationship - await expect( - client.user.create({ - data: { - name: 'Bob', - spouse: { connect: { id: alice.id } }, - }, - include: { spouse: true }, - }), - ).resolves.toMatchObject({ - name: 'Bob', - spouse: { name: 'Alice' }, - }); - - // Verify the reverse relationship - await expect( - client.user.findUnique({ - where: { id: alice.id }, - include: { marriedTo: true }, - }), - ).resolves.toMatchObject({ - name: 'Alice', - marriedTo: { name: 'Bob' }, - }); - - // Test creating with nested create - await expect( - client.user.create({ - data: { - name: 'Charlie', - spouse: { - create: { name: 'Diana' }, - }, - }, - include: { spouse: true }, - }), - ).resolves.toMatchObject({ - name: 'Charlie', - spouse: { name: 'Diana' }, - }); - - // Verify Diana is married to Charlie - await expect( - client.user.findFirst({ - where: { name: 'Diana' }, - include: { marriedTo: true }, - }), - ).resolves.toMatchObject({ - name: 'Diana', - marriedTo: { name: 'Charlie' }, - }); - - // Test disconnecting relationship - const bob = await client.user.findFirst({ - where: { name: 'Bob' }, - }); - - await expect( - client.user.update({ - where: { id: bob!.id }, - data: { - spouse: { disconnect: true }, + include: { spouse: true }, + }), + ).resolves.toMatchObject({ + name: 'Bob', + spouse: { name: 'Alice' }, + }); + + // Verify the reverse relationship + await expect( + client.user.findUnique({ + where: { id: alice.id }, + include: { marriedTo: true }, + }), + ).resolves.toMatchObject({ + name: 'Alice', + marriedTo: { name: 'Bob' }, + }); + + // Test creating with nested create + await expect( + client.user.create({ + data: { + name: 'Charlie', + spouse: { + create: { name: 'Diana' }, }, - include: { spouse: true, marriedTo: true }, - }), - ).resolves.toMatchObject({ - name: 'Bob', - spouse: null, - marriedTo: null, - }); - - // Verify Alice is also disconnected - await expect( - client.user.findUnique({ - where: { id: alice.id }, - include: { spouse: true, marriedTo: true }, - }), - ).resolves.toMatchObject({ - name: 'Alice', - spouse: null, - marriedTo: null, - }); + }, + include: { spouse: true }, + }), + ).resolves.toMatchObject({ + name: 'Charlie', + spouse: { name: 'Diana' }, + }); + + // Verify Diana is married to Charlie + await expect( + client.user.findFirst({ + where: { name: 'Diana' }, + include: { marriedTo: true }, + }), + ).resolves.toMatchObject({ + name: 'Diana', + marriedTo: { name: 'Charlie' }, + }); + + // Test disconnecting relationship + const bob = await client.user.findFirst({ + where: { name: 'Bob' }, + }); + + await expect( + client.user.update({ + where: { id: bob!.id }, + data: { + spouse: { disconnect: true }, + }, + include: { spouse: true, marriedTo: true }, + }), + ).resolves.toMatchObject({ + name: 'Bob', + spouse: null, + marriedTo: null, }); - it('works with one-to-many self relation', async () => { - client = await createTestClient( - ` + // Verify Alice is also disconnected + await expect( + client.user.findUnique({ + where: { id: alice.id }, + include: { spouse: true, marriedTo: true }, + }), + ).resolves.toMatchObject({ + name: 'Alice', + spouse: null, + marriedTo: null, + }); + }); + + it('works with one-to-many self relation', async () => { + client = await createTestClient( + ` model Category { id Int @id @default(autoincrement()) name String @@ -130,181 +124,179 @@ describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as cons parentId Int? } `, - { - provider, - dbName: TEST_DB, - usePrismaPush: true, - }, - ); + { + usePrismaPush: true, + }, + ); + + // Create parent category + const parent = await client.category.create({ + data: { + name: 'Electronics', + }, + }); - // Create parent category - const parent = await client.category.create({ + // Create children with parent + await expect( + client.category.create({ data: { - name: 'Electronics', + name: 'Smartphones', + parent: { connect: { id: parent.id } }, }, - }); - - // Create children with parent - await expect( - client.category.create({ - data: { - name: 'Smartphones', - parent: { connect: { id: parent.id } }, - }, - include: { parent: true }, - }), - ).resolves.toMatchObject({ - name: 'Smartphones', - parent: { name: 'Electronics' }, - }); - - // Create child using nested create - await expect( - client.category.create({ - data: { - name: 'Gaming', - children: { - create: [{ name: 'Console Games' }, { name: 'PC Games' }], - }, - }, - include: { children: true }, - }), - ).resolves.toMatchObject({ - name: 'Gaming', - children: [ - expect.objectContaining({ name: 'Console Games' }), - expect.objectContaining({ name: 'PC Games' }), - ], - }); - - // Query with full hierarchy - await expect( - client.category.findFirst({ - where: { name: 'Electronics' }, - include: { - children: { - include: { parent: true }, - }, - }, - }), - ).resolves.toMatchObject({ - name: 'Electronics', - children: [ - expect.objectContaining({ - name: 'Smartphones', - parent: expect.objectContaining({ name: 'Electronics' }), - }), - ], - }); - - // Test relation manipulation with update - move child to different parent - const gaming = await client.category.findFirst({ where: { name: 'Gaming' } }); - const smartphone = await client.category.findFirst({ where: { name: 'Smartphones' } }); - - await expect( - client.category.update({ - where: { id: smartphone.id }, - data: { - parent: { connect: { id: gaming.id } }, - }, - include: { parent: true }, - }), - ).resolves.toMatchObject({ - name: 'Smartphones', - parent: { name: 'Gaming' }, - }); - - // Test update to disconnect parent (make orphan) - await expect( - client.category.update({ - where: { id: smartphone.id }, - data: { - parent: { disconnect: true }, + include: { parent: true }, + }), + ).resolves.toMatchObject({ + name: 'Smartphones', + parent: { name: 'Electronics' }, + }); + + // Create child using nested create + await expect( + client.category.create({ + data: { + name: 'Gaming', + children: { + create: [{ name: 'Console Games' }, { name: 'PC Games' }], }, - include: { parent: true }, - }), - ).resolves.toMatchObject({ - name: 'Smartphones', - parent: null, - }); - - // Test update to add new children to existing parent - const newChild = await client.category.create({ data: { name: 'Accessories' } }); - - await expect( - client.category.update({ - where: { id: parent.id }, - data: { - children: { connect: { id: newChild.id } }, + }, + include: { children: true }, + }), + ).resolves.toMatchObject({ + name: 'Gaming', + children: [ + expect.objectContaining({ name: 'Console Games' }), + expect.objectContaining({ name: 'PC Games' }), + ], + }); + + // Query with full hierarchy + await expect( + client.category.findFirst({ + where: { name: 'Electronics' }, + include: { + children: { + include: { parent: true }, }, - include: { children: true }, - }), - ).resolves.toMatchObject({ - name: 'Electronics', - children: expect.arrayContaining([expect.objectContaining({ name: 'Accessories' })]), - }); - - // Test nested relation delete - delete specific children via update - const consoleGames = await client.category.findFirst({ where: { name: 'Console Games' } }); - - await expect( - client.category.update({ - where: { id: gaming.id }, - data: { - children: { - delete: { id: consoleGames.id }, - }, + }, + }), + ).resolves.toMatchObject({ + name: 'Electronics', + children: [ + expect.objectContaining({ + name: 'Smartphones', + parent: expect.objectContaining({ name: 'Electronics' }), + }), + ], + }); + + // Test relation manipulation with update - move child to different parent + const gaming = await client.category.findFirst({ where: { name: 'Gaming' } }); + const smartphone = await client.category.findFirst({ where: { name: 'Smartphones' } }); + + await expect( + client.category.update({ + where: { id: smartphone.id }, + data: { + parent: { connect: { id: gaming.id } }, + }, + include: { parent: true }, + }), + ).resolves.toMatchObject({ + name: 'Smartphones', + parent: { name: 'Gaming' }, + }); + + // Test update to disconnect parent (make orphan) + await expect( + client.category.update({ + where: { id: smartphone.id }, + data: { + parent: { disconnect: true }, + }, + include: { parent: true }, + }), + ).resolves.toMatchObject({ + name: 'Smartphones', + parent: null, + }); + + // Test update to add new children to existing parent + const newChild = await client.category.create({ data: { name: 'Accessories' } }); + + await expect( + client.category.update({ + where: { id: parent.id }, + data: { + children: { connect: { id: newChild.id } }, + }, + include: { children: true }, + }), + ).resolves.toMatchObject({ + name: 'Electronics', + children: expect.arrayContaining([expect.objectContaining({ name: 'Accessories' })]), + }); + + // Test nested relation delete - delete specific children via update + const consoleGames = await client.category.findFirst({ where: { name: 'Console Games' } }); + + await expect( + client.category.update({ + where: { id: gaming.id }, + data: { + children: { + delete: { id: consoleGames.id }, }, - include: { children: true }, - }), - ).resolves.toMatchObject({ - name: 'Gaming', - children: [expect.objectContaining({ name: 'PC Games' })], - }); - - // Verify the deleted child no longer exists - await expect(client.category.findFirst({ where: { id: consoleGames.id } })).resolves.toBeNull(); - - // Test nested delete with multiple children - await expect( - client.category.update({ - where: { id: gaming.id }, - data: { - children: { - deleteMany: { - name: { startsWith: 'PC' }, - }, + }, + include: { children: true }, + }), + ).resolves.toMatchObject({ + name: 'Gaming', + children: [expect.objectContaining({ name: 'PC Games' })], + }); + + // Verify the deleted child no longer exists + await expect(client.category.findFirst({ where: { id: consoleGames.id } })).resolves.toBeNull(); + + // Test nested delete with multiple children + await expect( + client.category.update({ + where: { id: gaming.id }, + data: { + children: { + deleteMany: { + name: { startsWith: 'PC' }, }, }, - include: { children: true }, - }), - ).resolves.toMatchObject({ - name: 'Gaming', - children: [], - }); - - // Test update with nested delete using where condition - await expect( - client.category.update({ - where: { id: parent.id }, - data: { - children: { - deleteMany: { - name: 'Accessories', - }, + }, + include: { children: true }, + }), + ).resolves.toMatchObject({ + name: 'Gaming', + children: [], + }); + + // Test update with nested delete using where condition + await expect( + client.category.update({ + where: { id: parent.id }, + data: { + children: { + deleteMany: { + name: 'Accessories', }, }, - include: { children: true }, - }), - ).resolves.toMatchObject({ - name: 'Electronics', - children: [], - }); + }, + include: { children: true }, + }), + ).resolves.toMatchObject({ + name: 'Electronics', + children: [], }); + }); - it('works with many-to-many self relation', async () => { - client = await createTestClient( - ` + it('works with many-to-many self relation', async () => { + client = await createTestClient( + ` model User { id Int @id @default(autoincrement()) name String @@ -312,222 +304,217 @@ describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as cons followers User[] @relation("UserFollows") } `, - { - provider, - dbName: provider === 'postgresql' ? TEST_DB : undefined, - usePrismaPush: true, - }, - ); - - // Create users - const user1 = await client.user.create({ data: { name: 'Alice' } }); - const user2 = await client.user.create({ data: { name: 'Bob' } }); - const user3 = await client.user.create({ data: { name: 'Charlie' } }); - - // Alice follows Bob and Charlie - await expect( - client.user.update({ - where: { id: user1.id }, - data: { - following: { - connect: [{ id: user2.id }, { id: user3.id }], - }, + { + usePrismaPush: true, + }, + ); + + // Create users + const user1 = await client.user.create({ data: { name: 'Alice' } }); + const user2 = await client.user.create({ data: { name: 'Bob' } }); + const user3 = await client.user.create({ data: { name: 'Charlie' } }); + + // Alice follows Bob and Charlie + await expect( + client.user.update({ + where: { id: user1.id }, + data: { + following: { + connect: [{ id: user2.id }, { id: user3.id }], }, - include: { following: true }, - }), - ).resolves.toMatchObject({ - name: 'Alice', - following: [expect.objectContaining({ name: 'Bob' }), expect.objectContaining({ name: 'Charlie' })], - }); + }, + include: { following: true }, + }), + ).resolves.toMatchObject({ + name: 'Alice', + following: [expect.objectContaining({ name: 'Bob' }), expect.objectContaining({ name: 'Charlie' })], + }); + + // Bob follows Charlie + await client.user.update({ + where: { id: user2.id }, + data: { + following: { connect: { id: user3.id } }, + }, + }); - // Bob follows Charlie - await client.user.update({ + // Check Bob's followers (should include Alice) + await expect( + client.user.findUnique({ where: { id: user2.id }, - data: { - following: { connect: { id: user3.id } }, - }, - }); + include: { followers: true }, + }), + ).resolves.toMatchObject({ + name: 'Bob', + followers: [expect.objectContaining({ name: 'Alice' })], + }); - // Check Bob's followers (should include Alice) - await expect( - client.user.findUnique({ - where: { id: user2.id }, - include: { followers: true }, - }), - ).resolves.toMatchObject({ - name: 'Bob', - followers: [expect.objectContaining({ name: 'Alice' })], - }); - - // Check Charlie's followers (should include Alice and Bob) - await expect( - client.user.findUnique({ - where: { id: user3.id }, - include: { followers: true }, - }), - ).resolves.toMatchObject({ - name: 'Charlie', - followers: [expect.objectContaining({ name: 'Alice' }), expect.objectContaining({ name: 'Bob' })], - }); - - // Test filtering with self relation - await expect( - client.user.findMany({ - where: { - followers: { - some: { name: 'Alice' }, - }, + // Check Charlie's followers (should include Alice and Bob) + await expect( + client.user.findUnique({ + where: { id: user3.id }, + include: { followers: true }, + }), + ).resolves.toMatchObject({ + name: 'Charlie', + followers: [expect.objectContaining({ name: 'Alice' }), expect.objectContaining({ name: 'Bob' })], + }); + + // Test filtering with self relation + await expect( + client.user.findMany({ + where: { + followers: { + some: { name: 'Alice' }, }, - }), - ).resolves.toEqual([ - expect.objectContaining({ name: 'Bob' }), - expect.objectContaining({ name: 'Charlie' }), - ]); - - // Test disconnect operation - await expect( - client.user.update({ - where: { id: user1.id }, - data: { - following: { - disconnect: { id: user2.id }, - }, + }, + }), + ).resolves.toEqual([expect.objectContaining({ name: 'Bob' }), expect.objectContaining({ name: 'Charlie' })]); + + // Test disconnect operation + await expect( + client.user.update({ + where: { id: user1.id }, + data: { + following: { + disconnect: { id: user2.id }, }, - include: { following: true }, - }), - ).resolves.toMatchObject({ - name: 'Alice', - following: [expect.objectContaining({ name: 'Charlie' })], - }); - - // Verify Bob no longer has Alice as follower - await expect( - client.user.findUnique({ - where: { id: user2.id }, - include: { followers: true }, - }), - ).resolves.toMatchObject({ - name: 'Bob', - followers: [], - }); - - // Test set operation (replace all following) - await expect( - client.user.update({ - where: { id: user1.id }, - data: { - following: { - set: [{ id: user2.id }], - }, + }, + include: { following: true }, + }), + ).resolves.toMatchObject({ + name: 'Alice', + following: [expect.objectContaining({ name: 'Charlie' })], + }); + + // Verify Bob no longer has Alice as follower + await expect( + client.user.findUnique({ + where: { id: user2.id }, + include: { followers: true }, + }), + ).resolves.toMatchObject({ + name: 'Bob', + followers: [], + }); + + // Test set operation (replace all following) + await expect( + client.user.update({ + where: { id: user1.id }, + data: { + following: { + set: [{ id: user2.id }], }, - include: { following: true }, - }), - ).resolves.toMatchObject({ - name: 'Alice', - following: [expect.objectContaining({ name: 'Bob' })], - }); - - // Verify Charlie no longer has Alice as follower after set - await expect( - client.user.findUnique({ - where: { id: user3.id }, - include: { followers: true }, - }), - ).resolves.toMatchObject({ - name: 'Charlie', - followers: [expect.objectContaining({ name: 'Bob' })], - }); - - // Test connectOrCreate with existing user - await expect( - client.user.update({ - where: { id: user1.id }, - data: { - following: { - connectOrCreate: { - where: { id: user3.id }, - create: { name: 'Charlie' }, - }, + }, + include: { following: true }, + }), + ).resolves.toMatchObject({ + name: 'Alice', + following: [expect.objectContaining({ name: 'Bob' })], + }); + + // Verify Charlie no longer has Alice as follower after set + await expect( + client.user.findUnique({ + where: { id: user3.id }, + include: { followers: true }, + }), + ).resolves.toMatchObject({ + name: 'Charlie', + followers: [expect.objectContaining({ name: 'Bob' })], + }); + + // Test connectOrCreate with existing user + await expect( + client.user.update({ + where: { id: user1.id }, + data: { + following: { + connectOrCreate: { + where: { id: user3.id }, + create: { name: 'Charlie' }, }, }, - include: { following: true }, - }), - ).resolves.toMatchObject({ - name: 'Alice', - following: [expect.objectContaining({ name: 'Bob' }), expect.objectContaining({ name: 'Charlie' })], - }); - - // Test connectOrCreate with new user - await expect( - client.user.update({ - where: { id: user1.id }, - data: { - following: { - connectOrCreate: { - where: { id: 999 }, - create: { name: 'David' }, - }, + }, + include: { following: true }, + }), + ).resolves.toMatchObject({ + name: 'Alice', + following: [expect.objectContaining({ name: 'Bob' }), expect.objectContaining({ name: 'Charlie' })], + }); + + // Test connectOrCreate with new user + await expect( + client.user.update({ + where: { id: user1.id }, + data: { + following: { + connectOrCreate: { + where: { id: 999 }, + create: { name: 'David' }, }, }, - include: { following: true }, - }), - ).resolves.toMatchObject({ - name: 'Alice', - following: expect.arrayContaining([ - expect.objectContaining({ name: 'Bob' }), - expect.objectContaining({ name: 'Charlie' }), - expect.objectContaining({ name: 'David' }), - ]), - }); - - // Test create operation within update - await expect( - client.user.update({ - where: { id: user2.id }, - data: { - following: { - create: { name: 'Eve' }, - }, + }, + include: { following: true }, + }), + ).resolves.toMatchObject({ + name: 'Alice', + following: expect.arrayContaining([ + expect.objectContaining({ name: 'Bob' }), + expect.objectContaining({ name: 'Charlie' }), + expect.objectContaining({ name: 'David' }), + ]), + }); + + // Test create operation within update + await expect( + client.user.update({ + where: { id: user2.id }, + data: { + following: { + create: { name: 'Eve' }, }, - include: { following: true }, - }), - ).resolves.toMatchObject({ - name: 'Bob', - following: expect.arrayContaining([ - expect.objectContaining({ name: 'Charlie' }), - expect.objectContaining({ name: 'Eve' }), - ]), - }); - - // Test deleteMany operation (disconnect and delete) - const davidUser = await client.user.findFirst({ where: { name: 'David' } }); - const eveUser = await client.user.findFirst({ where: { name: 'Eve' } }); - - await expect( - client.user.update({ - where: { id: user1.id }, - data: { - following: { - deleteMany: { - name: { in: ['David', 'Eve'] }, - }, + }, + include: { following: true }, + }), + ).resolves.toMatchObject({ + name: 'Bob', + following: expect.arrayContaining([ + expect.objectContaining({ name: 'Charlie' }), + expect.objectContaining({ name: 'Eve' }), + ]), + }); + + // Test deleteMany operation (disconnect and delete) + const davidUser = await client.user.findFirst({ where: { name: 'David' } }); + const eveUser = await client.user.findFirst({ where: { name: 'Eve' } }); + + await expect( + client.user.update({ + where: { id: user1.id }, + data: { + following: { + deleteMany: { + name: { in: ['David', 'Eve'] }, }, }, - include: { following: true }, - }), - ).resolves.toMatchObject({ - name: 'Alice', - following: [expect.objectContaining({ name: 'Bob' }), expect.objectContaining({ name: 'Charlie' })], - }); - - // Verify David was deleted from database - await expect(client.user.findUnique({ where: { id: davidUser!.id } })).toResolveNull(); - await expect(client.user.findUnique({ where: { id: eveUser!.id } })).toResolveTruthy(); + }, + include: { following: true }, + }), + ).resolves.toMatchObject({ + name: 'Alice', + following: [expect.objectContaining({ name: 'Bob' }), expect.objectContaining({ name: 'Charlie' })], }); - it('works with explicit self-referencing many-to-many', async () => { - client = await createTestClient( - ` + // Verify David was deleted from database + await expect(client.user.findUnique({ where: { id: davidUser!.id } })).toResolveNull(); + await expect(client.user.findUnique({ where: { id: eveUser!.id } })).toResolveTruthy(); + }); + + it('works with explicit self-referencing many-to-many', async () => { + client = await createTestClient( + ` model User { id Int @id @default(autoincrement()) name String @@ -545,65 +532,61 @@ describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as cons @@unique([followerId, followingId]) } `, - { - provider, - dbName: TEST_DB, - }, - ); + ); - const user1 = await client.user.create({ data: { name: 'Alice' } }); - const user2 = await client.user.create({ data: { name: 'Bob' } }); + const user1 = await client.user.create({ data: { name: 'Alice' } }); + const user2 = await client.user.create({ data: { name: 'Bob' } }); - // Create follow relationship - await client.userFollow.create({ - data: { - followerId: user1.id, - followingId: user2.id, - }, - }); + // Create follow relationship + await client.userFollow.create({ + data: { + followerId: user1.id, + followingId: user2.id, + }, + }); - // Query following relationships - await expect( - client.user.findUnique({ - where: { id: user1.id }, - include: { - followingRelations: { - include: { following: true }, - }, + // Query following relationships + await expect( + client.user.findUnique({ + where: { id: user1.id }, + include: { + followingRelations: { + include: { following: true }, }, - }), - ).resolves.toMatchObject({ - name: 'Alice', - followingRelations: [ - expect.objectContaining({ - following: expect.objectContaining({ name: 'Bob' }), - }), - ], - }); - - // Query follower relationships - await expect( - client.user.findUnique({ - where: { id: user2.id }, - include: { - followerRelations: { - include: { follower: true }, - }, + }, + }), + ).resolves.toMatchObject({ + name: 'Alice', + followingRelations: [ + expect.objectContaining({ + following: expect.objectContaining({ name: 'Bob' }), + }), + ], + }); + + // Query follower relationships + await expect( + client.user.findUnique({ + where: { id: user2.id }, + include: { + followerRelations: { + include: { follower: true }, }, - }), - ).resolves.toMatchObject({ - name: 'Bob', - followerRelations: [ - expect.objectContaining({ - follower: expect.objectContaining({ name: 'Alice' }), - }), - ], - }); - }); - - it('works with multiple self relations on same model', async () => { - client = await createTestClient( - ` + }, + }), + ).resolves.toMatchObject({ + name: 'Bob', + followerRelations: [ + expect.objectContaining({ + follower: expect.objectContaining({ name: 'Alice' }), + }), + ], + }); + }); + + it('works with multiple self relations on same model', async () => { + client = await createTestClient( + ` model Person { id Int @id @default(autoincrement()) name String @@ -616,64 +599,60 @@ describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as cons mentorId Int? } `, - { - provider, - usePrismaPush: true, - dbName: TEST_DB, - }, - ); + { usePrismaPush: true }, + ); - // Create CEO - const ceo = await client.person.create({ - data: { name: 'CEO' }, - }); + // Create CEO + const ceo = await client.person.create({ + data: { name: 'CEO' }, + }); - // Create manager who reports to CEO and is also a mentor - const manager = await client.person.create({ + // Create manager who reports to CEO and is also a mentor + const manager = await client.person.create({ + data: { + name: 'Manager', + manager: { connect: { id: ceo.id } }, + }, + }); + + // Create employee who reports to manager and is mentored by CEO + await expect( + client.person.create({ data: { - name: 'Manager', - manager: { connect: { id: ceo.id } }, + name: 'Employee', + manager: { connect: { id: manager.id } }, + mentor: { connect: { id: ceo.id } }, }, - }); - - // Create employee who reports to manager and is mentored by CEO - await expect( - client.person.create({ - data: { - name: 'Employee', - manager: { connect: { id: manager.id } }, - mentor: { connect: { id: ceo.id } }, - }, - include: { - manager: true, - mentor: true, - }, - }), - ).resolves.toMatchObject({ - name: 'Employee', - manager: { name: 'Manager' }, - mentor: { name: 'CEO' }, - }); - - // Check CEO's reports and mentees - await expect( - client.person.findUnique({ - where: { id: ceo.id }, - include: { - reports: true, - mentees: true, - }, - }), - ).resolves.toMatchObject({ - name: 'CEO', - reports: [expect.objectContaining({ name: 'Manager' })], - mentees: [expect.objectContaining({ name: 'Employee' })], - }); + include: { + manager: true, + mentor: true, + }, + }), + ).resolves.toMatchObject({ + name: 'Employee', + manager: { name: 'Manager' }, + mentor: { name: 'CEO' }, }); - it('works with deep self relation queries', async () => { - client = await createTestClient( - ` + // Check CEO's reports and mentees + await expect( + client.person.findUnique({ + where: { id: ceo.id }, + include: { + reports: true, + mentees: true, + }, + }), + ).resolves.toMatchObject({ + name: 'CEO', + reports: [expect.objectContaining({ name: 'Manager' })], + mentees: [expect.objectContaining({ name: 'Employee' })], + }); + }); + + it('works with deep self relation queries', async () => { + client = await createTestClient( + ` model Comment { id Int @id @default(autoincrement()) content String @@ -682,76 +661,71 @@ describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as cons parentId Int? } `, - { - provider, - usePrismaPush: true, - dbName: TEST_DB, - }, - ); + { usePrismaPush: true }, + ); - // Create nested comment thread - const topComment = await client.comment.create({ - data: { - content: 'Top level comment', - replies: { - create: [ - { - content: 'First reply', - replies: { - create: [{ content: 'Nested reply 1' }, { content: 'Nested reply 2' }], - }, + // Create nested comment thread + const topComment = await client.comment.create({ + data: { + content: 'Top level comment', + replies: { + create: [ + { + content: 'First reply', + replies: { + create: [{ content: 'Nested reply 1' }, { content: 'Nested reply 2' }], }, - { content: 'Second reply' }, - ], - }, - }, - include: { - replies: { - include: { - replies: true, }, + { content: 'Second reply' }, + ], + }, + }, + include: { + replies: { + include: { + replies: true, }, }, - }); + }, + }); - expect(topComment).toMatchObject({ - content: 'Top level comment', - replies: [ - expect.objectContaining({ - content: 'First reply', - replies: [ - expect.objectContaining({ content: 'Nested reply 1' }), - expect.objectContaining({ content: 'Nested reply 2' }), - ], - }), - expect.objectContaining({ - content: 'Second reply', - replies: [], - }), - ], - }); - - // Query from nested comment up the chain - const nestedReply = await client.comment.findFirst({ - where: { content: 'Nested reply 1' }, - include: { - parent: { - include: { - parent: true, - }, + expect(topComment).toMatchObject({ + content: 'Top level comment', + replies: [ + expect.objectContaining({ + content: 'First reply', + replies: [ + expect.objectContaining({ content: 'Nested reply 1' }), + expect.objectContaining({ content: 'Nested reply 2' }), + ], + }), + expect.objectContaining({ + content: 'Second reply', + replies: [], + }), + ], + }); + + // Query from nested comment up the chain + const nestedReply = await client.comment.findFirst({ + where: { content: 'Nested reply 1' }, + include: { + parent: { + include: { + parent: true, }, }, - }); + }, + }); - expect(nestedReply).toMatchObject({ - content: 'Nested reply 1', + expect(nestedReply).toMatchObject({ + content: 'Nested reply 1', + parent: expect.objectContaining({ + content: 'First reply', parent: expect.objectContaining({ - content: 'First reply', - parent: expect.objectContaining({ - content: 'Top level comment', - }), + content: 'Top level comment', }), - }); + }), }); - }, -); + }); +}); diff --git a/packages/runtime/test/client-api/scalar-list.test.ts b/packages/runtime/test/client-api/scalar-list.test.ts index b10744e5..c9bfb0fc 100644 --- a/packages/runtime/test/client-api/scalar-list.test.ts +++ b/packages/runtime/test/client-api/scalar-list.test.ts @@ -1,8 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createTestClient } from '../utils'; -const PG_DB_NAME = 'client-api-scalar-list-tests'; - describe('Scalar list tests', () => { const schema = ` model User { @@ -18,7 +16,6 @@ describe('Scalar list tests', () => { beforeEach(async () => { client = await createTestClient(schema, { provider: 'postgresql', - dbName: PG_DB_NAME, }); }); diff --git a/packages/runtime/test/client-api/transaction.test.ts b/packages/runtime/test/client-api/transaction.test.ts index 1daac7c5..c235420a 100644 --- a/packages/runtime/test/client-api/transaction.test.ts +++ b/packages/runtime/test/client-api/transaction.test.ts @@ -1,15 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; import { schema } from '../schemas/basic'; -import { createClientSpecs } from './client-specs'; +import { createTestClient } from '../utils'; -const PG_DB_NAME = 'client-api-transaction-tests'; - -describe.each(createClientSpecs(PG_DB_NAME))('Client raw query tests', ({ createClient }) => { +describe('Client raw query tests', () => { let client: ClientContract; beforeEach(async () => { - client = await createClient(); + client = await createTestClient(schema); }); afterEach(async () => { diff --git a/packages/runtime/test/client-api/type-coverage.test.ts b/packages/runtime/test/client-api/type-coverage.test.ts index 50f3e2bb..1055c712 100644 --- a/packages/runtime/test/client-api/type-coverage.test.ts +++ b/packages/runtime/test/client-api/type-coverage.test.ts @@ -1,10 +1,8 @@ import Decimal from 'decimal.js'; import { describe, expect, it } from 'vitest'; -import { createTestClient } from '../utils'; +import { createTestClient, getTestDbProvider } from '../utils'; -const PG_DB_NAME = 'client-api-type-coverage-tests'; - -describe.each(['sqlite', 'postgresql'] as const)('zmodel type coverage tests', (provider) => { +describe('Zmodel type coverage tests', () => { it('supports all types - plain', async () => { const date = new Date(); const data = { @@ -37,7 +35,6 @@ describe.each(['sqlite', 'postgresql'] as const)('zmodel type coverage tests', ( Json Json } `, - { provider, dbName: PG_DB_NAME }, ); await db.foo.create({ data }); @@ -64,7 +61,6 @@ describe.each(['sqlite', 'postgresql'] as const)('zmodel type coverage tests', ( Json Json @default("{\\"foo\\":\\"bar\\"}") } `, - { provider, dbName: PG_DB_NAME }, ); await db.foo.create({ data: { id: '1' } }); @@ -84,7 +80,7 @@ describe.each(['sqlite', 'postgresql'] as const)('zmodel type coverage tests', ( }); it('supports all types - array', async () => { - if (provider === 'sqlite') { + if (getTestDbProvider() === 'sqlite') { return; } @@ -120,7 +116,6 @@ describe.each(['sqlite', 'postgresql'] as const)('zmodel type coverage tests', ( Json Json[] } `, - { provider, dbName: PG_DB_NAME }, ); await db.foo.create({ data }); @@ -131,7 +126,7 @@ describe.each(['sqlite', 'postgresql'] as const)('zmodel type coverage tests', ( }); it('supports all types - array for plain json field', async () => { - if (provider === 'sqlite') { + if (getTestDbProvider() === 'sqlite') { return; } @@ -149,7 +144,6 @@ describe.each(['sqlite', 'postgresql'] as const)('zmodel type coverage tests', ( Json Json } `, - { provider, dbName: PG_DB_NAME }, ); await db.foo.create({ data }); diff --git a/packages/runtime/test/client-api/typed-json-fields.test.ts b/packages/runtime/test/client-api/typed-json-fields.test.ts index 4ea57c7e..65757169 100644 --- a/packages/runtime/test/client-api/typed-json-fields.test.ts +++ b/packages/runtime/test/client-api/typed-json-fields.test.ts @@ -1,12 +1,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createTestClient } from '../utils'; -const PG_DB_NAME = 'client-api-typed-json-fields-tests'; - -describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as const }])( - 'Typed JSON fields', - ({ provider }) => { - const schema = ` +describe('Typed JSON fields', () => { + const schema = ` type Identity { providers IdentityProvider[] } @@ -22,200 +18,197 @@ model User { } `; - let client: any; + let client: any; - beforeEach(async () => { - client = await createTestClient(schema, { - usePrismaPush: true, - provider, - dbName: provider === 'postgresql' ? PG_DB_NAME : undefined, - }); + beforeEach(async () => { + client = await createTestClient(schema, { + usePrismaPush: true, }); - - afterEach(async () => { - await client?.$disconnect(); + }); + + afterEach(async () => { + await client?.$disconnect(); + }); + + it('works with create', async () => { + await expect( + client.user.create({ + data: {}, + }), + ).resolves.toMatchObject({ + identity: null, }); - it('works with create', async () => { - await expect( - client.user.create({ - data: {}, - }), - ).resolves.toMatchObject({ - identity: null, - }); - - await expect( - client.user.create({ - data: { - identity: { - providers: [ - { - id: '123', - name: 'Google', - }, - ], - }, + await expect( + client.user.create({ + data: { + identity: { + providers: [ + { + id: '123', + name: 'Google', + }, + ], }, - }), - ).resolves.toMatchObject({ - identity: { - providers: [ - { - id: '123', - name: 'Google', - }, - ], }, - }); - - await expect( - client.user.create({ - data: { - identity: { - providers: [ - { - id: '123', - }, - ], - }, + }), + ).resolves.toMatchObject({ + identity: { + providers: [ + { + id: '123', + name: 'Google', + }, + ], + }, + }); + + await expect( + client.user.create({ + data: { + identity: { + providers: [ + { + id: '123', + }, + ], }, - }), - ).resolves.toMatchObject({ - identity: { - providers: [ - { - id: '123', - }, - ], }, - }); - - await expect( - client.user.create({ - data: { - identity: { - providers: [ - { - id: '123', - foo: 1, - }, - ], - }, + }), + ).resolves.toMatchObject({ + identity: { + providers: [ + { + id: '123', + }, + ], + }, + }); + + await expect( + client.user.create({ + data: { + identity: { + providers: [ + { + id: '123', + foo: 1, + }, + ], }, - }), - ).resolves.toMatchObject({ - identity: { - providers: [ - { - id: '123', - foo: 1, - }, - ], }, - }); - - await expect( - client.user.create({ - data: { - identity: { - providers: [ - { - name: 'Google', - }, - ], - }, + }), + ).resolves.toMatchObject({ + identity: { + providers: [ + { + id: '123', + foo: 1, }, - }), - ).rejects.toThrow(/invalid/i); + ], + }, }); - it('works with find', async () => { - await expect( - client.user.create({ - data: { id: 1 }, - }), - ).toResolveTruthy(); - await expect(client.user.findUnique({ where: { id: 1 } })).resolves.toMatchObject({ - identity: null, - }); - - await expect( - client.user.create({ - data: { - id: 2, - identity: { - providers: [ - { - id: '123', - name: 'Google', - }, - ], - }, + await expect( + client.user.create({ + data: { + identity: { + providers: [ + { + name: 'Google', + }, + ], }, - }), - ).toResolveTruthy(); - - await expect(client.user.findUnique({ where: { id: 2 } })).resolves.toMatchObject({ - identity: { - providers: [ - { - id: '123', - name: 'Google', - }, - ], }, - }); + }), + ).rejects.toThrow(/invalid/i); + }); + + it('works with find', async () => { + await expect( + client.user.create({ + data: { id: 1 }, + }), + ).toResolveTruthy(); + await expect(client.user.findUnique({ where: { id: 1 } })).resolves.toMatchObject({ + identity: null, }); - it('works with update', async () => { - await expect( - client.user.create({ - data: { id: 1 }, - }), - ).toResolveTruthy(); - - await expect( - client.user.update({ - where: { id: 1 }, - data: { - identity: { - providers: [ - { - id: '123', - name: 'Google', - foo: 1, - }, - ], - }, + await expect( + client.user.create({ + data: { + id: 2, + identity: { + providers: [ + { + id: '123', + name: 'Google', + }, + ], }, - }), - ).resolves.toMatchObject({ - identity: { - providers: [ - { - id: '123', - name: 'Google', - foo: 1, - }, - ], }, - }); - - await expect( - client.user.update({ - where: { id: 1 }, - data: { - identity: { - providers: [ - { - name: 'GitHub', - }, - ], - }, + }), + ).toResolveTruthy(); + + await expect(client.user.findUnique({ where: { id: 2 } })).resolves.toMatchObject({ + identity: { + providers: [ + { + id: '123', + name: 'Google', }, - }), - ).rejects.toThrow(/invalid/i); + ], + }, }); - }, -); + }); + + it('works with update', async () => { + await expect( + client.user.create({ + data: { id: 1 }, + }), + ).toResolveTruthy(); + + await expect( + client.user.update({ + where: { id: 1 }, + data: { + identity: { + providers: [ + { + id: '123', + name: 'Google', + foo: 1, + }, + ], + }, + }, + }), + ).resolves.toMatchObject({ + identity: { + providers: [ + { + id: '123', + name: 'Google', + foo: 1, + }, + ], + }, + }); + + await expect( + client.user.update({ + where: { id: 1 }, + data: { + identity: { + providers: [ + { + name: 'GitHub', + }, + ], + }, + }, + }), + ).rejects.toThrow(/invalid/i); + }); +}); diff --git a/packages/runtime/test/client-api/undefined-values.test.ts b/packages/runtime/test/client-api/undefined-values.test.ts index 07037be7..74d2851b 100644 --- a/packages/runtime/test/client-api/undefined-values.test.ts +++ b/packages/runtime/test/client-api/undefined-values.test.ts @@ -1,16 +1,14 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; import { schema } from '../schemas/basic'; -import { createClientSpecs } from './client-specs'; +import { createTestClient } from '../utils'; import { createUser } from './utils'; -const PG_DB_NAME = 'client-api-undefined-values-tests'; - -describe.each(createClientSpecs(PG_DB_NAME))('Client undefined values tests for $provider', ({ createClient }) => { +describe('Client undefined values tests ', () => { let client: ClientContract; beforeEach(async () => { - client = await createClient(); + client = await createTestClient(schema); }); afterEach(async () => { diff --git a/packages/runtime/test/client-api/update-many.test.ts b/packages/runtime/test/client-api/update-many.test.ts index eaef7e63..934154fe 100644 --- a/packages/runtime/test/client-api/update-many.test.ts +++ b/packages/runtime/test/client-api/update-many.test.ts @@ -1,15 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; import { schema } from '../schemas/basic'; -import { createClientSpecs } from './client-specs'; +import { createTestClient } from '../utils'; -const PG_DB_NAME = 'client-api-update-many-tests'; - -describe.each(createClientSpecs(PG_DB_NAME))('Client updateMany tests', ({ createClient }) => { +describe('Client updateMany tests', () => { let client: ClientContract; beforeEach(async () => { - client = await createClient(); + client = await createTestClient(schema); }); afterEach(async () => { diff --git a/packages/runtime/test/client-api/update.test.ts b/packages/runtime/test/client-api/update.test.ts index a82a87bc..82ec4a5a 100644 --- a/packages/runtime/test/client-api/update.test.ts +++ b/packages/runtime/test/client-api/update.test.ts @@ -1,16 +1,14 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; import { schema } from '../schemas/basic'; -import { createClientSpecs } from './client-specs'; +import { createTestClient } from '../utils'; import { createUser } from './utils'; -const PG_DB_NAME = 'client-api-update-tests'; - -describe.each(createClientSpecs(PG_DB_NAME))('Client update tests', ({ createClient }) => { +describe('Client update tests', () => { let client: ClientContract; beforeEach(async () => { - client = await createClient(); + client = await createTestClient(schema); }); afterEach(async () => { diff --git a/packages/runtime/test/client-api/upsert.test.ts b/packages/runtime/test/client-api/upsert.test.ts index 02ede41c..cbb16d65 100644 --- a/packages/runtime/test/client-api/upsert.test.ts +++ b/packages/runtime/test/client-api/upsert.test.ts @@ -1,15 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '../../src/client'; import { schema } from '../schemas/basic'; -import { createClientSpecs } from './client-specs'; +import { createTestClient } from '../utils'; -const PG_DB_NAME = 'client-api-upsert-tests'; - -describe.each(createClientSpecs(PG_DB_NAME))('Client upsert tests', ({ createClient }) => { +describe('Client upsert tests', () => { let client: ClientContract; beforeEach(async () => { - client = await createClient(); + client = await createTestClient(schema); }); afterEach(async () => { diff --git a/packages/runtime/test/plugin/entity-mutation-hooks.test.ts b/packages/runtime/test/plugin/entity-mutation-hooks.test.ts index 96961c7c..9172d0f5 100644 --- a/packages/runtime/test/plugin/entity-mutation-hooks.test.ts +++ b/packages/runtime/test/plugin/entity-mutation-hooks.test.ts @@ -4,689 +4,681 @@ import { type ClientContract } from '../../src'; import { schema } from '../schemas/basic'; import { createTestClient } from '../utils'; -const TEST_DB = 'client-api-entity-mutation-hooks-test'; - -describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as const }])( - 'Entity mutation hooks tests for $provider', - ({ provider }) => { - let _client: ClientContract; - - beforeEach(async () => { - _client = await createTestClient(schema, { - provider, - dbName: TEST_DB, - }); +describe('Entity mutation hooks tests', () => { + let _client: ClientContract; + + beforeEach(async () => { + _client = await createTestClient(schema, {}); + }); + + afterEach(async () => { + await _client?.$disconnect(); + }); + + it('can intercept all mutations', async () => { + const beforeCalled = { create: false, update: false, delete: false }; + const afterCalled = { create: false, update: false, delete: false }; + + const client = _client.$use({ + id: 'test', + onEntityMutation: { + beforeEntityMutation(args) { + beforeCalled[args.action] = true; + if (args.action === 'create') { + expect(InsertQueryNode.is(args.queryNode)).toBe(true); + } + if (args.action === 'update') { + expect(UpdateQueryNode.is(args.queryNode)).toBe(true); + } + if (args.action === 'delete') { + expect(DeleteQueryNode.is(args.queryNode)).toBe(true); + } + }, + afterEntityMutation(args) { + afterCalled[args.action] = true; + }, + }, }); - afterEach(async () => { - await _client?.$disconnect(); + const user = await client.user.create({ + data: { email: 'u1@test.com' }, }); + await client.user.update({ + where: { id: user.id }, + data: { email: 'u2@test.com' }, + }); + await client.user.delete({ where: { id: user.id } }); - it('can intercept all mutations', async () => { - const beforeCalled = { create: false, update: false, delete: false }; - const afterCalled = { create: false, update: false, delete: false }; + expect(beforeCalled).toEqual({ + create: true, + update: true, + delete: true, + }); + expect(afterCalled).toEqual({ + create: true, + update: true, + delete: true, + }); + }); + + it('can intercept with loading before mutation entities', async () => { + const queryIds = { + update: { before: '', after: '' }, + delete: { before: '', after: '' }, + }; + + const client = _client.$use({ + id: 'test', + onEntityMutation: { + async beforeEntityMutation(args) { + if (args.action === 'update' || args.action === 'delete') { + await expect(args.loadBeforeMutationEntities()).resolves.toEqual([ + expect.objectContaining({ + email: args.action === 'update' ? 'u1@test.com' : 'u3@test.com', + }), + ]); + queryIds[args.action].before = args.queryId; + } + }, + async afterEntityMutation(args) { + if (args.action === 'update' || args.action === 'delete') { + queryIds[args.action].after = args.queryId; + } + }, + }, + }); - const client = _client.$use({ - id: 'test', - onEntityMutation: { - beforeEntityMutation(args) { - beforeCalled[args.action] = true; + const user = await client.user.create({ + data: { email: 'u1@test.com' }, + }); + await client.user.create({ + data: { email: 'u2@test.com' }, + }); + await client.user.update({ + where: { id: user.id }, + data: { email: 'u3@test.com' }, + }); + await client.user.delete({ where: { id: user.id } }); + + expect(queryIds.update.before).toBeTruthy(); + expect(queryIds.delete.before).toBeTruthy(); + expect(queryIds.update.before).toBe(queryIds.update.after); + expect(queryIds.delete.before).toBe(queryIds.delete.after); + }); + + it('can intercept with loading after mutation entities', async () => { + let userCreateIntercepted = false; + let userUpdateIntercepted = false; + const client = _client.$use({ + id: 'test', + onEntityMutation: { + async afterEntityMutation(args) { + if (args.action === 'create' || args.action === 'update') { if (args.action === 'create') { - expect(InsertQueryNode.is(args.queryNode)).toBe(true); + userCreateIntercepted = true; } if (args.action === 'update') { - expect(UpdateQueryNode.is(args.queryNode)).toBe(true); + userUpdateIntercepted = true; } - if (args.action === 'delete') { - expect(DeleteQueryNode.is(args.queryNode)).toBe(true); + await expect(args.loadAfterMutationEntities()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + email: args.action === 'create' ? 'u1@test.com' : 'u2@test.com', + }), + ]), + ); + } + }, + }, + }); + + const user = await client.user.create({ + data: { email: 'u1@test.com' }, + }); + await client.user.update({ + where: { id: user.id }, + data: { email: 'u2@test.com' }, + }); + + expect(userCreateIntercepted).toBe(true); + expect(userUpdateIntercepted).toBe(true); + }); + + it('can intercept multi-entity mutations', async () => { + let userCreateIntercepted = false; + let userUpdateIntercepted = false; + let userDeleteIntercepted = false; + + const client = _client.$use({ + id: 'test', + onEntityMutation: { + async afterEntityMutation(args) { + if (args.action === 'create') { + userCreateIntercepted = true; + await expect(args.loadAfterMutationEntities()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ email: 'u1@test.com' }), + expect.objectContaining({ email: 'u2@test.com' }), + ]), + ); + } else if (args.action === 'update') { + userUpdateIntercepted = true; + await expect(args.loadAfterMutationEntities()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + email: 'u1@test.com', + name: 'A user', + }), + expect.objectContaining({ + email: 'u2@test.com', + name: 'A user', + }), + ]), + ); + } else if (args.action === 'delete') { + userDeleteIntercepted = true; + await expect(args.loadAfterMutationEntities()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ email: 'u1@test.com' }), + expect.objectContaining({ email: 'u2@test.com' }), + ]), + ); + } + }, + }, + }); + + await client.user.createMany({ + data: [{ email: 'u1@test.com' }, { email: 'u2@test.com' }], + }); + await client.user.updateMany({ + data: { name: 'A user' }, + }); + + expect(userCreateIntercepted).toBe(true); + expect(userUpdateIntercepted).toBe(true); + expect(userDeleteIntercepted).toBe(false); + }); + + it('can intercept nested mutations', async () => { + let post1Intercepted = false; + let post2Intercepted = false; + const client = _client.$use({ + id: 'test', + onEntityMutation: { + async afterEntityMutation(args) { + if (args.action === 'create') { + if (args.model === 'Post') { + const afterEntities = await args.loadAfterMutationEntities(); + if ((afterEntities![0] as any).title === 'Post1') { + post1Intercepted = true; + } + if ((afterEntities![0] as any).title === 'Post2') { + post2Intercepted = true; + } } - }, - afterEntityMutation(args) { - afterCalled[args.action] = true; - }, + } }, - }); + }, + }); - const user = await client.user.create({ + const user = await client.user.create({ + data: { + email: 'u1@test.com', + posts: { create: { title: 'Post1' } }, + }, + }); + await client.user.update({ + where: { id: user.id }, + data: { + email: 'u2@test.com', + posts: { create: { title: 'Post2' } }, + }, + }); + + expect(post1Intercepted).toBe(true); + expect(post2Intercepted).toBe(true); + }); + + it('triggers multiple afterEntityMutation hooks for multiple mutations', async () => { + const triggered: any[] = []; + + const client = _client.$use({ + id: 'test', + onEntityMutation: { + async afterEntityMutation(args) { + triggered.push({ + action: args.action, + model: args.model, + afterMutationEntities: await args.loadAfterMutationEntities(), + }); + }, + }, + }); + + await client.$transaction(async (tx) => { + await tx.user.create({ data: { email: 'u1@test.com' }, }); - await client.user.update({ - where: { id: user.id }, + await tx.user.update({ + where: { email: 'u1@test.com' }, data: { email: 'u2@test.com' }, }); - await client.user.delete({ where: { id: user.id } }); - - expect(beforeCalled).toEqual({ - create: true, - update: true, - delete: true, - }); - expect(afterCalled).toEqual({ - create: true, - update: true, - delete: true, - }); + await tx.user.delete({ where: { email: 'u2@test.com' } }); }); - it('can intercept with loading before mutation entities', async () => { - const queryIds = { - update: { before: '', after: '' }, - delete: { before: '', after: '' }, - }; + expect(triggered).toEqual([ + expect.objectContaining({ + action: 'create', + model: 'User', + afterMutationEntities: [expect.objectContaining({ email: 'u1@test.com' })], + }), + expect.objectContaining({ + action: 'update', + model: 'User', + afterMutationEntities: [expect.objectContaining({ email: 'u2@test.com' })], + }), + expect.objectContaining({ + action: 'delete', + model: 'User', + afterMutationEntities: undefined, + }), + ]); + }); + + describe('Without outer transaction', () => { + it('persists hooks db side effects when run out of tx', async () => { + let intercepted = false; const client = _client.$use({ id: 'test', onEntityMutation: { - async beforeEntityMutation(args) { - if (args.action === 'update' || args.action === 'delete') { - await expect(args.loadBeforeMutationEntities()).resolves.toEqual([ - expect.objectContaining({ - email: args.action === 'update' ? 'u1@test.com' : 'u3@test.com', - }), - ]); - queryIds[args.action].before = args.queryId; - } + async beforeEntityMutation(ctx) { + await ctx.client.profile.create({ + data: { bio: 'Bio1' }, + }); }, - async afterEntityMutation(args) { - if (args.action === 'update' || args.action === 'delete') { - queryIds[args.action].after = args.queryId; - } + async afterEntityMutation(ctx) { + intercepted = true; + await ctx.client.user.update({ + where: { email: 'u1@test.com' }, + data: { email: 'u2@test.com' }, + }); }, }, }); - const user = await client.user.create({ - data: { email: 'u1@test.com' }, - }); await client.user.create({ - data: { email: 'u2@test.com' }, - }); - await client.user.update({ - where: { id: user.id }, - data: { email: 'u3@test.com' }, + data: { email: 'u1@test.com' }, }); - await client.user.delete({ where: { id: user.id } }); - - expect(queryIds.update.before).toBeTruthy(); - expect(queryIds.delete.before).toBeTruthy(); - expect(queryIds.update.before).toBe(queryIds.update.after); - expect(queryIds.delete.before).toBe(queryIds.delete.after); + expect(intercepted).toBe(true); + // both the mutation and hook's side effect are persisted + await expect(client.profile.findMany()).toResolveWithLength(1); + await expect(client.user.findFirst()).resolves.toMatchObject({ email: 'u2@test.com' }); }); - it('can intercept with loading after mutation entities', async () => { - let userCreateIntercepted = false; - let userUpdateIntercepted = false; + it('persists hooks db side effects when run within tx', async () => { + let intercepted = false; + const client = _client.$use({ id: 'test', onEntityMutation: { - async afterEntityMutation(args) { - if (args.action === 'create' || args.action === 'update') { - if (args.action === 'create') { - userCreateIntercepted = true; - } - if (args.action === 'update') { - userUpdateIntercepted = true; - } - await expect(args.loadAfterMutationEntities()).resolves.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - email: args.action === 'create' ? 'u1@test.com' : 'u2@test.com', - }), - ]), - ); - } + runAfterMutationWithinTransaction: true, + async beforeEntityMutation(ctx) { + await ctx.client.profile.create({ + data: { bio: 'Bio1' }, + }); + }, + async afterEntityMutation(ctx) { + intercepted = true; + await ctx.client.user.update({ + where: { email: 'u1@test.com' }, + data: { email: 'u2@test.com' }, + }); }, }, }); - const user = await client.user.create({ + await client.user.create({ data: { email: 'u1@test.com' }, }); - await client.user.update({ - where: { id: user.id }, - data: { email: 'u2@test.com' }, - }); - - expect(userCreateIntercepted).toBe(true); - expect(userUpdateIntercepted).toBe(true); + expect(intercepted).toBe(true); + // both the mutation and hook's side effect are persisted + await expect(client.profile.findMany()).toResolveWithLength(1); + await expect(client.user.findFirst()).resolves.toMatchObject({ email: 'u2@test.com' }); }); - it('can intercept multi-entity mutations', async () => { - let userCreateIntercepted = false; - let userUpdateIntercepted = false; - let userDeleteIntercepted = false; - + it('fails the mutation if before mutation hook throws', async () => { const client = _client.$use({ id: 'test', onEntityMutation: { - async afterEntityMutation(args) { - if (args.action === 'create') { - userCreateIntercepted = true; - await expect(args.loadAfterMutationEntities()).resolves.toEqual( - expect.arrayContaining([ - expect.objectContaining({ email: 'u1@test.com' }), - expect.objectContaining({ email: 'u2@test.com' }), - ]), - ); - } else if (args.action === 'update') { - userUpdateIntercepted = true; - await expect(args.loadAfterMutationEntities()).resolves.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - email: 'u1@test.com', - name: 'A user', - }), - expect.objectContaining({ - email: 'u2@test.com', - name: 'A user', - }), - ]), - ); - } else if (args.action === 'delete') { - userDeleteIntercepted = true; - await expect(args.loadAfterMutationEntities()).resolves.toEqual( - expect.arrayContaining([ - expect.objectContaining({ email: 'u1@test.com' }), - expect.objectContaining({ email: 'u2@test.com' }), - ]), - ); - } + async beforeEntityMutation() { + throw new Error('trigger failure'); }, }, }); - await client.user.createMany({ - data: [{ email: 'u1@test.com' }, { email: 'u2@test.com' }], - }); - await client.user.updateMany({ - data: { name: 'A user' }, - }); + await expect( + client.user.create({ + data: { email: 'u1@test.com' }, + }), + ).rejects.toThrow(); - expect(userCreateIntercepted).toBe(true); - expect(userUpdateIntercepted).toBe(true); - expect(userDeleteIntercepted).toBe(false); + // mutation is persisted + await expect(client.user.findMany()).toResolveWithLength(0); }); - it('can intercept nested mutations', async () => { - let post1Intercepted = false; - let post2Intercepted = false; + it('does not affect the database operation if after mutation hook throws', async () => { + let intercepted = false; + const client = _client.$use({ id: 'test', onEntityMutation: { - async afterEntityMutation(args) { - if (args.action === 'create') { - if (args.model === 'Post') { - const afterEntities = await args.loadAfterMutationEntities(); - if ((afterEntities![0] as any).title === 'Post1') { - post1Intercepted = true; - } - if ((afterEntities![0] as any).title === 'Post2') { - post2Intercepted = true; - } - } - } + async afterEntityMutation() { + intercepted = true; + throw new Error('trigger rollback'); }, }, }); - const user = await client.user.create({ - data: { - email: 'u1@test.com', - posts: { create: { title: 'Post1' } }, - }, - }); - await client.user.update({ - where: { id: user.id }, - data: { - email: 'u2@test.com', - posts: { create: { title: 'Post2' } }, - }, + await client.user.create({ + data: { email: 'u1@test.com' }, }); - expect(post1Intercepted).toBe(true); - expect(post2Intercepted).toBe(true); + expect(intercepted).toBe(true); + // mutation is persisted + await expect(client.user.findMany()).toResolveWithLength(1); }); - it('triggers multiple afterEntityMutation hooks for multiple mutations', async () => { - const triggered: any[] = []; + it('fails the entire transaction if specified to run inside the tx', async () => { + let intercepted = false; const client = _client.$use({ id: 'test', onEntityMutation: { - async afterEntityMutation(args) { - triggered.push({ - action: args.action, - model: args.model, - afterMutationEntities: await args.loadAfterMutationEntities(), - }); + runAfterMutationWithinTransaction: true, + async afterEntityMutation(ctx) { + intercepted = true; + await ctx.client.user.create({ data: { email: 'u2@test.com' } }); + throw new Error('trigger rollback'); }, }, }); - await client.$transaction(async (tx) => { - await tx.user.create({ + await expect( + client.user.create({ data: { email: 'u1@test.com' }, - }); - await tx.user.update({ - where: { email: 'u1@test.com' }, - data: { email: 'u2@test.com' }, - }); - await tx.user.delete({ where: { email: 'u2@test.com' } }); - }); - - expect(triggered).toEqual([ - expect.objectContaining({ - action: 'create', - model: 'User', - afterMutationEntities: [expect.objectContaining({ email: 'u1@test.com' })], }), - expect.objectContaining({ - action: 'update', - model: 'User', - afterMutationEntities: [expect.objectContaining({ email: 'u2@test.com' })], - }), - expect.objectContaining({ - action: 'delete', - model: 'User', - afterMutationEntities: undefined, - }), - ]); - }); - - describe('Without outer transaction', () => { - it('persists hooks db side effects when run out of tx', async () => { - let intercepted = false; + ).rejects.toThrow(); - const client = _client.$use({ - id: 'test', - onEntityMutation: { - async beforeEntityMutation(ctx) { - await ctx.client.profile.create({ - data: { bio: 'Bio1' }, - }); - }, - async afterEntityMutation(ctx) { - intercepted = true; - await ctx.client.user.update({ - where: { email: 'u1@test.com' }, - data: { email: 'u2@test.com' }, - }); - }, - }, - }); - - await client.user.create({ - data: { email: 'u1@test.com' }, - }); - expect(intercepted).toBe(true); - // both the mutation and hook's side effect are persisted - await expect(client.profile.findMany()).toResolveWithLength(1); - await expect(client.user.findFirst()).resolves.toMatchObject({ email: 'u2@test.com' }); - }); + expect(intercepted).toBe(true); + // mutation is not persisted + await expect(client.user.findMany()).toResolveWithLength(0); + }); - it('persists hooks db side effects when run within tx', async () => { - let intercepted = false; + it('does not trigger afterEntityMutation hook if a transaction is rolled back', async () => { + let intercepted = false; - const client = _client.$use({ - id: 'test', - onEntityMutation: { - runAfterMutationWithinTransaction: true, - async beforeEntityMutation(ctx) { - await ctx.client.profile.create({ - data: { bio: 'Bio1' }, - }); - }, - async afterEntityMutation(ctx) { - intercepted = true; - await ctx.client.user.update({ - where: { email: 'u1@test.com' }, - data: { email: 'u2@test.com' }, - }); - }, + const client = _client.$use({ + id: 'test', + onEntityMutation: { + async afterEntityMutation(ctx) { + intercepted = true; + await ctx.client.user.create({ data: { email: 'u2@test.com' } }); }, - }); - - await client.user.create({ - data: { email: 'u1@test.com' }, - }); - expect(intercepted).toBe(true); - // both the mutation and hook's side effect are persisted - await expect(client.profile.findMany()).toResolveWithLength(1); - await expect(client.user.findFirst()).resolves.toMatchObject({ email: 'u2@test.com' }); + }, }); - it('fails the mutation if before mutation hook throws', async () => { - const client = _client.$use({ - id: 'test', - onEntityMutation: { - async beforeEntityMutation() { - throw new Error('trigger failure'); - }, - }, - }); - - await expect( - client.user.create({ + try { + await client.$transaction(async (tx) => { + await tx.user.create({ data: { email: 'u1@test.com' }, - }), - ).rejects.toThrow(); + }); + throw new Error('trigger rollback'); + }); + } catch { + // noop + } - // mutation is persisted - await expect(client.user.findMany()).toResolveWithLength(0); - }); + expect(intercepted).toBe(false); + // neither the mutation nor the hook's side effect are persisted + await expect(client.user.findMany()).toResolveWithLength(0); + }); - it('does not affect the database operation if after mutation hook throws', async () => { - let intercepted = false; + it('triggers afterEntityMutation hook if a transaction is rolled back but hook runs within tx', async () => { + let intercepted = false; - const client = _client.$use({ - id: 'test', - onEntityMutation: { - async afterEntityMutation() { - intercepted = true; - throw new Error('trigger rollback'); - }, + const client = _client.$use({ + id: 'test', + onEntityMutation: { + runAfterMutationWithinTransaction: true, + async afterEntityMutation(ctx) { + intercepted = true; + await ctx.client.user.create({ data: { email: 'u2@test.com' } }); }, - }); + }, + }); - await client.user.create({ - data: { email: 'u1@test.com' }, + try { + await client.$transaction(async (tx) => { + await tx.user.create({ + data: { email: 'u1@test.com' }, + }); + throw new Error('trigger rollback'); }); + } catch { + // noop + } - expect(intercepted).toBe(true); - // mutation is persisted - await expect(client.user.findMany()).toResolveWithLength(1); - }); - - it('fails the entire transaction if specified to run inside the tx', async () => { - let intercepted = false; + expect(intercepted).toBe(true); + // neither the mutation nor the hook's side effect are persisted + await expect(client.user.findMany()).toResolveWithLength(0); + }); + }); - const client = _client.$use({ - id: 'test', - onEntityMutation: { - runAfterMutationWithinTransaction: true, - async afterEntityMutation(ctx) { + describe('With outer transaction', () => { + it('sees changes in the transaction prior to reading before mutation entities', async () => { + let intercepted = false; + const client = _client.$use({ + id: 'test', + onEntityMutation: { + async beforeEntityMutation(args) { + if (args.action === 'update') { intercepted = true; - await ctx.client.user.create({ data: { email: 'u2@test.com' } }); - throw new Error('trigger rollback'); - }, + await expect(args.loadBeforeMutationEntities()).resolves.toEqual([ + expect.objectContaining({ email: 'u1@test.com' }), + ]); + } }, - }); - - await expect( - client.user.create({ - data: { email: 'u1@test.com' }, - }), - ).rejects.toThrow(); - - expect(intercepted).toBe(true); - // mutation is not persisted - await expect(client.user.findMany()).toResolveWithLength(0); + }, }); - it('does not trigger afterEntityMutation hook if a transaction is rolled back', async () => { - let intercepted = false; - - const client = _client.$use({ - id: 'test', - onEntityMutation: { - async afterEntityMutation(ctx) { - intercepted = true; - await ctx.client.user.create({ data: { email: 'u2@test.com' } }); - }, - }, + await client.$transaction(async (tx) => { + await tx.user.create({ data: { email: 'u1@test.com' } }); + await tx.user.update({ + where: { email: 'u1@test.com' }, + data: { email: 'u2@test.com' }, }); - - try { - await client.$transaction(async (tx) => { - await tx.user.create({ - data: { email: 'u1@test.com' }, - }); - throw new Error('trigger rollback'); - }); - } catch { - // noop - } - - expect(intercepted).toBe(false); - // neither the mutation nor the hook's side effect are persisted - await expect(client.user.findMany()).toResolveWithLength(0); }); - it('triggers afterEntityMutation hook if a transaction is rolled back but hook runs within tx', async () => { - let intercepted = false; + expect(intercepted).toBe(true); + }); - const client = _client.$use({ - id: 'test', - onEntityMutation: { - runAfterMutationWithinTransaction: true, - async afterEntityMutation(ctx) { - intercepted = true; - await ctx.client.user.create({ data: { email: 'u2@test.com' } }); - }, + it('runs before mutation hook within the transaction', async () => { + let intercepted = false; + const client = _client.$use({ + id: 'test', + onEntityMutation: { + async beforeEntityMutation(ctx) { + intercepted = true; + await ctx.client.profile.create({ + data: { bio: 'Bio1' }, + }); }, - }); + }, + }); - try { - await client.$transaction(async (tx) => { - await tx.user.create({ - data: { email: 'u1@test.com' }, - }); - throw new Error('trigger rollback'); + await expect( + client.$transaction(async (tx) => { + await tx.user.create({ + data: { email: 'u1@test.com' }, }); - } catch { - // noop - } + throw new Error('trigger rollback'); + }), + ).rejects.toThrow(); - expect(intercepted).toBe(true); - // neither the mutation nor the hook's side effect are persisted - await expect(client.user.findMany()).toResolveWithLength(0); - }); + expect(intercepted).toBe(true); + await expect(client.user.findMany()).toResolveWithLength(0); + await expect(client.profile.findMany()).toResolveWithLength(0); }); - describe('With outer transaction', () => { - it('sees changes in the transaction prior to reading before mutation entities', async () => { - let intercepted = false; - const client = _client.$use({ - id: 'test', - onEntityMutation: { - async beforeEntityMutation(args) { - if (args.action === 'update') { - intercepted = true; - await expect(args.loadBeforeMutationEntities()).resolves.toEqual([ - expect.objectContaining({ email: 'u1@test.com' }), - ]); - } - }, - }, - }); + it('persists hooks db side effects when run out of tx', async () => { + let intercepted = false; + let txVisible = false; - await client.$transaction(async (tx) => { - await tx.user.create({ data: { email: 'u1@test.com' } }); - await tx.user.update({ - where: { email: 'u1@test.com' }, - data: { email: 'u2@test.com' }, - }); - }); - - expect(intercepted).toBe(true); - }); - - it('runs before mutation hook within the transaction', async () => { - let intercepted = false; - const client = _client.$use({ - id: 'test', - onEntityMutation: { - async beforeEntityMutation(ctx) { - intercepted = true; + const client = _client.$use({ + id: 'test', + onEntityMutation: { + async beforeEntityMutation(ctx) { + const r = await ctx.client.user.findUnique({ where: { email: 'u1@test.com' } }); + if (r) { + // second create + txVisible = true; + } else { + // first create await ctx.client.profile.create({ data: { bio: 'Bio1' }, }); - }, + } }, - }); - - await expect( - client.$transaction(async (tx) => { - await tx.user.create({ - data: { email: 'u1@test.com' }, + async afterEntityMutation(ctx) { + if (intercepted) { + return; + } + intercepted = true; + await ctx.client.user.update({ + where: { email: 'u1@test.com' }, + data: { email: 'u3@test.com' }, }); - throw new Error('trigger rollback'); - }), - ).rejects.toThrow(); - - expect(intercepted).toBe(true); - await expect(client.user.findMany()).toResolveWithLength(0); - await expect(client.profile.findMany()).toResolveWithLength(0); + }, + }, }); - it('persists hooks db side effects when run out of tx', async () => { - let intercepted = false; - let txVisible = false; - - const client = _client.$use({ - id: 'test', - onEntityMutation: { - async beforeEntityMutation(ctx) { - const r = await ctx.client.user.findUnique({ where: { email: 'u1@test.com' } }); - if (r) { - // second create - txVisible = true; - } else { - // first create - await ctx.client.profile.create({ - data: { bio: 'Bio1' }, - }); - } - }, - async afterEntityMutation(ctx) { - if (intercepted) { - return; - } - intercepted = true; - await ctx.client.user.update({ - where: { email: 'u1@test.com' }, - data: { email: 'u3@test.com' }, - }); - }, - }, + await client.$transaction(async (tx) => { + await tx.user.create({ + data: { email: 'u1@test.com' }, }); - - await client.$transaction(async (tx) => { - await tx.user.create({ - data: { email: 'u1@test.com' }, - }); - await tx.user.create({ - data: { email: 'u2@test.com' }, - }); + await tx.user.create({ + data: { email: 'u2@test.com' }, }); - - expect(intercepted).toBe(true); - expect(txVisible).toBe(true); - - // both the mutation and hook's side effect are persisted - await expect(client.profile.findMany()).toResolveWithLength(1); - await expect(client.user.findMany()).resolves.toEqual( - expect.arrayContaining([ - expect.objectContaining({ email: 'u2@test.com' }), - expect.objectContaining({ email: 'u3@test.com' }), - ]), - ); }); - it('persists hooks db side effects when run within tx', async () => { - let intercepted = false; + expect(intercepted).toBe(true); + expect(txVisible).toBe(true); - const client = _client.$use({ - id: 'test', - onEntityMutation: { - runAfterMutationWithinTransaction: true, - async afterEntityMutation(ctx) { - if (intercepted) { - return; - } - intercepted = true; - await ctx.client.user.update({ - where: { email: 'u1@test.com' }, - data: { email: 'u3@test.com' }, - }); - }, + // both the mutation and hook's side effect are persisted + await expect(client.profile.findMany()).toResolveWithLength(1); + await expect(client.user.findMany()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ email: 'u2@test.com' }), + expect.objectContaining({ email: 'u3@test.com' }), + ]), + ); + }); + + it('persists hooks db side effects when run within tx', async () => { + let intercepted = false; + + const client = _client.$use({ + id: 'test', + onEntityMutation: { + runAfterMutationWithinTransaction: true, + async afterEntityMutation(ctx) { + if (intercepted) { + return; + } + intercepted = true; + await ctx.client.user.update({ + where: { email: 'u1@test.com' }, + data: { email: 'u3@test.com' }, + }); }, - }); + }, + }); - await client.$transaction(async (tx) => { - await tx.user.create({ - data: { email: 'u1@test.com' }, - }); - await tx.user.create({ - data: { email: 'u2@test.com' }, - }); + await client.$transaction(async (tx) => { + await tx.user.create({ + data: { email: 'u1@test.com' }, }); + await tx.user.create({ + data: { email: 'u2@test.com' }, + }); + }); - expect(intercepted).toBe(true); + expect(intercepted).toBe(true); - // both the mutation and hook's side effect are persisted - await expect(client.user.findMany()).resolves.toEqual( - expect.arrayContaining([ - expect.objectContaining({ email: 'u2@test.com' }), - expect.objectContaining({ email: 'u3@test.com' }), - ]), - ); - }); + // both the mutation and hook's side effect are persisted + await expect(client.user.findMany()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ email: 'u2@test.com' }), + expect.objectContaining({ email: 'u3@test.com' }), + ]), + ); + }); - it('persists mutation when run out of tx and throws', async () => { - let intercepted = false; + it('persists mutation when run out of tx and throws', async () => { + let intercepted = false; - const client = _client.$use({ - id: 'test', - onEntityMutation: { - async afterEntityMutation(ctx) { - intercepted = true; - await ctx.client.user.create({ data: { email: 'u2@test.com' } }); - throw new Error('trigger error'); - }, + const client = _client.$use({ + id: 'test', + onEntityMutation: { + async afterEntityMutation(ctx) { + intercepted = true; + await ctx.client.user.create({ data: { email: 'u2@test.com' } }); + throw new Error('trigger error'); }, - }); + }, + }); - await client.$transaction(async (tx) => { - await tx.user.create({ - data: { email: 'u1@test.com' }, - }); + await client.$transaction(async (tx) => { + await tx.user.create({ + data: { email: 'u1@test.com' }, }); + }); - expect(intercepted).toBe(true); + expect(intercepted).toBe(true); - // both the mutation and hook's side effect are persisted - await expect(client.user.findMany()).toResolveWithLength(2); - }); + // both the mutation and hook's side effect are persisted + await expect(client.user.findMany()).toResolveWithLength(2); + }); - it('rolls back mutation when run within tx and throws', async () => { - let intercepted = false; + it('rolls back mutation when run within tx and throws', async () => { + let intercepted = false; - const client = _client.$use({ - id: 'test', - onEntityMutation: { - runAfterMutationWithinTransaction: true, - async afterEntityMutation(ctx) { - intercepted = true; - await ctx.client.user.create({ data: { email: 'u2@test.com' } }); - throw new Error('trigger error'); - }, + const client = _client.$use({ + id: 'test', + onEntityMutation: { + runAfterMutationWithinTransaction: true, + async afterEntityMutation(ctx) { + intercepted = true; + await ctx.client.user.create({ data: { email: 'u2@test.com' } }); + throw new Error('trigger error'); }, - }); + }, + }); - await expect( - client.$transaction(async (tx) => { - await tx.user.create({ - data: { email: 'u1@test.com' }, - }); - }), - ).rejects.toThrow(); + await expect( + client.$transaction(async (tx) => { + await tx.user.create({ + data: { email: 'u1@test.com' }, + }); + }), + ).rejects.toThrow(); - expect(intercepted).toBe(true); + expect(intercepted).toBe(true); - // both the mutation and hook's side effect are rolled back - await expect(client.user.findMany()).toResolveWithLength(0); - }); + // both the mutation and hook's side effect are rolled back + await expect(client.user.findMany()).toResolveWithLength(0); }); - }, -); + }); +}); diff --git a/packages/runtime/test/plugin/on-kysely-query.test.ts b/packages/runtime/test/plugin/on-kysely-query.test.ts index 75105927..1e97f818 100644 --- a/packages/runtime/test/plugin/on-kysely-query.test.ts +++ b/packages/runtime/test/plugin/on-kysely-query.test.ts @@ -1,17 +1,19 @@ -import SQLite from 'better-sqlite3'; -import { InsertQueryNode, Kysely, PrimitiveValueListNode, SqliteDialect, ValuesNode, type QueryResult } from 'kysely'; +import { InsertQueryNode, Kysely, PrimitiveValueListNode, ValuesNode, type QueryResult } from 'kysely'; import { beforeEach, describe, expect, it } from 'vitest'; -import { ZenStackClient, type ClientContract } from '../../src/client'; +import { type ClientContract } from '../../src/client'; import { schema } from '../schemas/basic'; +import { createTestClient } from '../utils'; +import { afterEach } from 'node:test'; describe('On kysely query tests', () => { let _client: ClientContract; beforeEach(async () => { - _client = new ZenStackClient(schema, { - dialect: new SqliteDialect({ database: new SQLite(':memory:') }), - }); - await _client.$pushSchema(); + _client = await createTestClient(schema); + }); + + afterEach(async () => { + await _client.$disconnect(); }); it('intercepts queries', async () => { diff --git a/packages/runtime/test/plugin/on-query-hooks.test.ts b/packages/runtime/test/plugin/on-query-hooks.test.ts index 3e4c478b..3a6df8ca 100644 --- a/packages/runtime/test/plugin/on-query-hooks.test.ts +++ b/packages/runtime/test/plugin/on-query-hooks.test.ts @@ -1,17 +1,13 @@ -import SQLite from 'better-sqlite3'; -import { SqliteDialect } from 'kysely'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { definePlugin, ZenStackClient, type ClientContract } from '../../src/client'; +import { definePlugin, type ClientContract } from '../../src/client'; import { schema } from '../schemas/basic'; +import { createTestClient } from '../utils'; describe('On query hooks tests', () => { let _client: ClientContract; beforeEach(async () => { - _client = new ZenStackClient(schema, { - dialect: new SqliteDialect({ database: new SQLite(':memory:') }), - }); - await _client.$pushSchema(); + _client = await createTestClient(schema); }); afterEach(async () => { diff --git a/packages/runtime/test/policy/basic-schema-read.test.ts b/packages/runtime/test/policy/basic-schema-read.test.ts index eb1ccb41..c8b8c87e 100644 --- a/packages/runtime/test/policy/basic-schema-read.test.ts +++ b/packages/runtime/test/policy/basic-schema-read.test.ts @@ -1,16 +1,14 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type ClientContract } from '../../src/client'; import { PolicyPlugin } from '../../src/plugins/policy/plugin'; -import { createClientSpecs } from '../client-api/client-specs'; import { schema } from '../schemas/basic'; +import { createTestClient } from '../utils'; -const PG_DB_NAME = 'policy-read-tests'; - -describe.each(createClientSpecs(PG_DB_NAME))('Read policy tests', ({ createClient }) => { +describe('Read policy tests', () => { let client: ClientContract; beforeEach(async () => { - client = await createClient(); + client = await createTestClient(schema); }); afterEach(async () => { @@ -74,12 +72,6 @@ describe.each(createClientSpecs(PG_DB_NAME))('Read policy tests', ({ createClien await expect(anonClient.$qb.selectFrom('User').selectAll().executeTakeFirst()).toResolveFalsy(); const authClient = anonClient.$setAuth({ id: user.id }); - const foundUser = await authClient.$qb.selectFrom('User').selectAll().executeTakeFirstOrThrow(); - - if (typeof foundUser.createdAt === 'string') { - expect(Date.parse(foundUser.createdAt)).toEqual(user.createdAt.getTime()); - } else { - expect(foundUser.createdAt).toEqual(user.createdAt); - } + await expect(authClient.$qb.selectFrom('User').selectAll().executeTakeFirstOrThrow()).toResolveTruthy(); }); }); diff --git a/packages/runtime/test/policy/crud/update.test.ts b/packages/runtime/test/policy/crud/update.test.ts index 975000f5..c092682b 100644 --- a/packages/runtime/test/policy/crud/update.test.ts +++ b/packages/runtime/test/policy/crud/update.test.ts @@ -156,7 +156,7 @@ model Profile { }); }); - it('works with to-one relation check owner side', async () => { + it('works with to-one relation check non-owner side', async () => { const db = await createPolicyTestClient( ` model User { @@ -1242,7 +1242,7 @@ model Foo { db.$qb .insertInto('Foo') .values({ id: 1, x: 5 }) - .onConflict((oc: any) => oc.column('id').doUpdateSet({ x: 5 }).where('id', '=', 1)) + .onConflict((oc: any) => oc.column('id').doUpdateSet({ x: 5 }).where('Foo.id', '=', 1)) .executeTakeFirst(), ).resolves.toMatchObject({ numInsertedOrUpdatedRows: 0n }); await expect(db.foo.count()).resolves.toBe(3); @@ -1253,7 +1253,7 @@ model Foo { db.$qb .insertInto('Foo') .values({ id: 2, x: 5 }) - .onConflict((oc: any) => oc.column('id').doUpdateSet({ x: 6 }).where('id', '=', 2)) + .onConflict((oc: any) => oc.column('id').doUpdateSet({ x: 6 }).where('Foo.id', '=', 2)) .executeTakeFirst(), ).resolves.toMatchObject({ numInsertedOrUpdatedRows: 1n }); await expect(db.foo.count()).resolves.toBe(3); diff --git a/packages/runtime/test/policy/migrated/auth.test.ts b/packages/runtime/test/policy/migrated/auth.test.ts index bc99f49f..d075e3d7 100644 --- a/packages/runtime/test/policy/migrated/auth.test.ts +++ b/packages/runtime/test/policy/migrated/auth.test.ts @@ -536,7 +536,7 @@ model Post { ); await expect(db.user.create({ data: { id: 'userId-1' } })).toResolveTruthy(); - await expect(db.post.create({ data: { title: 'title' } })).rejects.toThrow('constraint failed'); + await expect(db.post.create({ data: { title: 'title' } })).rejects.toThrow('constraint'); await expect(db.post.findMany({})).toResolveTruthy(); }); diff --git a/packages/runtime/test/policy/migrated/deep-nested.test.ts b/packages/runtime/test/policy/migrated/deep-nested.test.ts index bab02d5a..a88134ce 100644 --- a/packages/runtime/test/policy/migrated/deep-nested.test.ts +++ b/packages/runtime/test/policy/migrated/deep-nested.test.ts @@ -482,7 +482,7 @@ describe('deep nested operations tests', () => { }, }, }), - ).rejects.toThrow('constraint failed'); + ).rejects.toThrow('constraint'); // createMany skip duplicate await db.m1.update({ diff --git a/packages/runtime/test/policy/migrated/nested-to-many.test.ts b/packages/runtime/test/policy/migrated/nested-to-many.test.ts index 63d72821..03415119 100644 --- a/packages/runtime/test/policy/migrated/nested-to-many.test.ts +++ b/packages/runtime/test/policy/migrated/nested-to-many.test.ts @@ -366,7 +366,7 @@ describe('Policy tests to-many', () => { where: { id: '1' }, data: { m2: { - create: [{ value: 0 }, { value: 1 }], + create: [{ value: 5 }, { value: 0 }], }, }, }), diff --git a/packages/runtime/test/policy/migrated/toplevel-operations.test.ts b/packages/runtime/test/policy/migrated/toplevel-operations.test.ts index 2bdaac8d..f545148c 100644 --- a/packages/runtime/test/policy/migrated/toplevel-operations.test.ts +++ b/packages/runtime/test/policy/migrated/toplevel-operations.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest'; import { createPolicyTestClient } from '../utils'; -import { testLogger } from '../../utils'; describe('Policy toplevel operations tests', () => { it('read tests', async () => { @@ -221,7 +220,6 @@ describe('Policy toplevel operations tests', () => { @@allow('delete', value > 1) } `, - { log: testLogger }, ); await expect(db.model.delete({ where: { id: '1' } })).toBeRejectedNotFound(); diff --git a/packages/runtime/test/policy/migrated/view.test.ts b/packages/runtime/test/policy/migrated/view.test.ts index ead81bad..010a7f1f 100644 --- a/packages/runtime/test/policy/migrated/view.test.ts +++ b/packages/runtime/test/policy/migrated/view.test.ts @@ -35,7 +35,8 @@ describe('View Policy Test', () => { ); const rawDb = db.$unuseAll(); - await rawDb.$executeRaw`CREATE VIEW UserInfo as select user.id, user.name, user.email, user.id as userId, count(post.id) as postCount from user left join post on user.id = post.authorId group by user.id;`; + + await rawDb.$executeRaw`CREATE VIEW "UserInfo" as select "User"."id", "User"."name", "User"."email", "User"."id" as "userId", count("Post"."id") as "postCount" from "User" left join "Post" on "User"."id" = "Post"."authorId" group by "User"."id";`; await rawDb.user.create({ data: { diff --git a/packages/runtime/test/policy/policy-functions.test.ts b/packages/runtime/test/policy/policy-functions.test.ts index d37eff4b..2ac094b0 100644 --- a/packages/runtime/test/policy/policy-functions.test.ts +++ b/packages/runtime/test/policy/policy-functions.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { createPolicyTestClient } from './utils'; describe('policy functions tests', () => { - it('supports contains with case-sensitive field', async () => { + it('supports contains case-sensitive', async () => { const db = await createPolicyTestClient( ` model Foo { @@ -14,9 +14,51 @@ describe('policy functions tests', () => { ); await expect(db.foo.create({ data: { string: 'bcd' } })).toBeRejectedByPolicy(); + if (db.$schema.provider.type === 'sqlite') { + // sqlite is always case-insensitive + await expect(db.foo.create({ data: { string: 'Acd' } })).toResolveTruthy(); + } else { + await expect(db.foo.create({ data: { string: 'Acd' } })).toBeRejectedByPolicy(); + } await expect(db.foo.create({ data: { string: 'bac' } })).toResolveTruthy(); }); + it('supports contains explicit case-sensitive', async () => { + const db = await createPolicyTestClient( + ` + model Foo { + id String @id @default(cuid()) + string String + @@allow('all', contains(string, 'a', false)) + } + `, + ); + + await expect(db.foo.create({ data: { string: 'bcd' } })).toBeRejectedByPolicy(); + if (db.$schema.provider.type === 'sqlite') { + // sqlite is always case-insensitive + await expect(db.foo.create({ data: { string: 'Acd' } })).toResolveTruthy(); + } else { + await expect(db.foo.create({ data: { string: 'Acd' } })).toBeRejectedByPolicy(); + } + await expect(db.foo.create({ data: { string: 'bac' } })).toResolveTruthy(); + }); + + it('supports contains case-insensitive', async () => { + const db = await createPolicyTestClient( + ` + model Foo { + id String @id @default(cuid()) + string String + @@allow('all', contains(string, 'a', true)) + } + `, + ); + + await expect(db.foo.create({ data: { string: 'bcd' } })).toBeRejectedByPolicy(); + await expect(db.foo.create({ data: { string: 'Abc' } })).toResolveTruthy(); + }); + it('supports contains with case-sensitive non-field', async () => { const db = await createPolicyTestClient( ` @@ -35,6 +77,12 @@ describe('policy functions tests', () => { await expect(db.foo.create({ data: {} })).toBeRejectedByPolicy(); await expect(db.$setAuth({ id: 'user1', name: 'bcd' }).foo.create({ data: {} })).toBeRejectedByPolicy(); await expect(db.$setAuth({ id: 'user1', name: 'bac' }).foo.create({ data: {} })).toResolveTruthy(); + if (db.$schema.provider.type === 'sqlite') { + // sqlite is always case-insensitive + await expect(db.$setAuth({ id: 'user1', name: 'Abc' }).foo.create({ data: {} })).toResolveTruthy(); + } else { + await expect(db.$setAuth({ id: 'user1', name: 'Abc' }).foo.create({ data: {} })).toBeRejectedByPolicy(); + } }); it('supports contains with auth()', async () => { diff --git a/packages/runtime/test/query-builder/query-builder.test.ts b/packages/runtime/test/query-builder/query-builder.test.ts index 8eed03d5..32890468 100644 --- a/packages/runtime/test/query-builder/query-builder.test.ts +++ b/packages/runtime/test/query-builder/query-builder.test.ts @@ -1,18 +1,13 @@ import { createId } from '@paralleldrive/cuid2'; -import SQLite from 'better-sqlite3'; -import { SqliteDialect } from 'kysely'; import { describe, expect, it } from 'vitest'; -import { ZenStackClient } from '../../src'; import { getSchema } from '../schemas/basic'; +import { createTestClient } from '../utils'; describe('Client API tests', () => { const schema = getSchema('sqlite'); it('works with queries', async () => { - const client = new ZenStackClient(schema, { - dialect: new SqliteDialect({ database: new SQLite(':memory:') }), - }); - await client.$pushSchema(); + const client = await createTestClient(schema); const kysely = client.$qb; diff --git a/packages/runtime/test/utils.ts b/packages/runtime/test/utils.ts index 279b95d8..b7245062 100644 --- a/packages/runtime/test/utils.ts +++ b/packages/runtime/test/utils.ts @@ -6,26 +6,21 @@ import { createTestProject, generateTsSchema, getPluginModules } from '@zenstack import SQLite from 'better-sqlite3'; import { PostgresDialect, SqliteDialect, type LogEvent } from 'kysely'; import { execSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { Client as PGClient, Pool } from 'pg'; +import { expect } from 'vitest'; import type { ClientContract, ClientOptions } from '../src/client'; import { ZenStackClient } from '../src/client'; import type { SchemaDef } from '../src/schema'; -type SqliteSchema = SchemaDef & { provider: { type: 'sqlite' } }; -type PostgresSchema = SchemaDef & { provider: { type: 'postgresql' } }; - -export async function makeSqliteClient( - schema: Schema, - extraOptions?: Partial>, -): Promise> { - const client = new ZenStackClient(schema, { - ...extraOptions, - dialect: new SqliteDialect({ database: new SQLite(':memory:') }), - } as unknown as ClientOptions); - await client.$pushSchema(); - return client; +export function getTestDbProvider() { + const val = process.env['TEST_DB_PROVIDER'] ?? 'sqlite'; + if (!['sqlite', 'postgresql'].includes(val!)) { + throw new Error(`Invalid TEST_DB_PROVIDER value: ${val}`); + } + return val as 'sqlite' | 'postgresql'; } const TEST_PG_CONFIG = { @@ -35,30 +30,6 @@ const TEST_PG_CONFIG = { password: process.env['TEST_PG_PASSWORD'] ?? 'postgres', }; -export async function makePostgresClient( - schema: Schema, - dbName: string, - extraOptions?: Partial>, -): Promise> { - invariant(dbName, 'dbName is required'); - const pgClient = new PGClient(TEST_PG_CONFIG); - await pgClient.connect(); - await pgClient.query(`DROP DATABASE IF EXISTS "${dbName}"`); - await pgClient.query(`CREATE DATABASE "${dbName}"`); - - const client = new ZenStackClient(schema, { - ...extraOptions, - dialect: new PostgresDialect({ - pool: new Pool({ - ...TEST_PG_CONFIG, - database: dbName, - }), - }), - } as unknown as ClientOptions); - await client.$pushSchema(); - return client; -} - export type CreateTestClientOptions = Omit, 'dialect'> & { provider?: 'sqlite' | 'postgresql'; dbName?: string; @@ -83,16 +54,10 @@ export async function createTestClient( ): Promise { let workDir = options?.workDir; let _schema: Schema; - const provider = options?.provider ?? 'sqlite'; - - let dbName = options?.dbName; - if (!dbName) { - if (provider === 'sqlite') { - dbName = './test.db'; - } else { - throw new Error(`dbName is required for ${provider} provider`); - } - } + const provider = options?.provider ?? getTestDbProvider() ?? 'sqlite'; + + const dbName = options?.dbName ?? getTestDbName(provider); + console.log(`Using provider: ${provider}, db: ${dbName}`); const dbUrl = provider === 'sqlite' @@ -203,3 +168,26 @@ export async function createTestClient( export function testLogger(e: LogEvent) { console.log(e.query.sql, e.query.parameters); } + +function getTestDbName(provider: string) { + if (provider === 'sqlite') { + return './test.db'; + } + const testName = expect.getState().currentTestName; + const testPath = expect.getState().testPath ?? ''; + invariant(testName); + // digest test name + const digest = createHash('md5') + .update(testName + testPath) + .digest('hex'); + // compute a database name based on test name + return ( + 'test_' + + testName + .toLowerCase() + .replace(/[^a-z0-9_]/g, '_') + .replace(/_+/g, '_') + .substring(0, 30) + + digest.slice(0, 6) + ); +}