diff --git a/TODO.md b/TODO.md index 1d75312e..d3a1be9a 100644 --- a/TODO.md +++ b/TODO.md @@ -16,7 +16,7 @@ - [x] Create many - [x] ID generation - [x] CreateManyAndReturn - - [ ] Find + - [x] Find - [x] Input validation - [x] Field selection - [x] Omit @@ -56,15 +56,15 @@ - [ ] Prisma client extension - [ ] Misc - [ ] Cache validation schemas - - [ ] Compound ID + - [x] Compound ID - [ ] Cross field comparison - [x] Many-to-many relation - [ ] Empty AND/OR/NOT behavior - [?] Logging - - [?] Error system + - [ ] Error system - [x] Custom table name - [x] Custom field name - - [?] Strict undefined check + - [ ] Strict undefined check - [ ] Access Policy - [ ] Short-circuit pre-create check for scalar-field only policies - [ ] Inject "replace into" @@ -74,4 +74,4 @@ - [ ] Databases - [x] SQLite - [x] PostgreSQL - - [ ] Schema + - [x] Multi-schema diff --git a/packages/runtime/src/client/crud-types.ts b/packages/runtime/src/client/crud-types.ts index 999b1606..da112e41 100644 --- a/packages/runtime/src/client/crud-types.ts +++ b/packages/runtime/src/client/crud-types.ts @@ -323,7 +323,7 @@ export type OrderBy< }) : {}); -export type WhereUnique< +export type WhereUniqueInput< Schema extends SchemaDef, Model extends GetModels > = AtLeast< @@ -336,7 +336,8 @@ export type WhereUnique< Schema, GetModel['uniqueFields'][Key] > - : { + : // multi-field unique + { [Key1 in keyof GetModel< Schema, Model @@ -373,7 +374,7 @@ type Distinct> = { }; type Cursor> = { - cursor?: WhereUnique; + cursor?: WhereUniqueInput; }; type Select< @@ -591,7 +592,7 @@ export type FindUniqueArgs< Schema extends SchemaDef, Model extends GetModels > = { - where?: WhereUnique; + where?: WhereUniqueInput; } & SelectIncludeOmit; //#endregion @@ -711,7 +712,7 @@ type ConnectOrCreatePayload< Schema extends SchemaDef, Model extends GetModels > = { - where: WhereUnique; + where: WhereUniqueInput; create: CreateInput; }; @@ -768,7 +769,7 @@ export type UpdateArgs< Model extends GetModels > = { data: UpdateInput; - where: WhereUnique; + where: WhereUniqueInput; select?: Select; include?: Include; omit?: OmitFields; @@ -789,7 +790,7 @@ export type UpsertArgs< > = { create: CreateInput; update: UpdateInput; - where: WhereUnique; + where: WhereUniqueInput; select?: Select; include?: Include; omit?: OmitFields; @@ -858,25 +859,44 @@ type UpdateRelationFieldPayload< Schema extends SchemaDef, Model extends GetModels, Field extends RelationFields -> = Omit< - { - create?: NestedCreateInput; - createMany?: NestedCreateManyInput; - connect?: ConnectInput; - connectOrCreate?: ConnectOrCreateInput; - disconnect?: DisconnectInput; - set?: SetInput; - update?: NestedUpdateInput; - upsert?: NestedUpsertInput; - updateMany?: NestedUpdateManyInput; - delete?: NestedDeleteInput; - deleteMany?: NestedDeleteManyInput; - }, - // no "createMany" for non-array fields - FieldIsArray extends true - ? never - : 'createMany' | 'set' ->; +> = FieldIsArray extends true + ? ToManyRelationUpdateInput + : ToOneRelationUpdateInput; + +type ToManyRelationUpdateInput< + Schema extends SchemaDef, + Model extends GetModels, + Field extends RelationFields +> = { + create?: NestedCreateInput; + createMany?: NestedCreateManyInput; + connect?: ConnectInput; + connectOrCreate?: ConnectOrCreateInput; + disconnect?: DisconnectInput; + update?: NestedUpdateInput; + upsert?: NestedUpsertInput; + updateMany?: NestedUpdateManyInput; + delete?: NestedDeleteInput; + deleteMany?: NestedDeleteManyInput; + set?: SetRelationInput; +}; + +type ToOneRelationUpdateInput< + Schema extends SchemaDef, + Model extends GetModels, + Field extends RelationFields +> = { + create?: NestedCreateInput; + connect?: ConnectInput; + connectOrCreate?: ConnectOrCreateInput; + update?: NestedUpdateInput; + upsert?: NestedUpsertInput; +} & (FieldIsOptional extends true + ? { + disconnect?: DisconnectInput; + delete?: NestedDeleteInput; + } + : {}); // #endregion @@ -886,7 +906,7 @@ export type DeleteArgs< Schema extends SchemaDef, Model extends GetModels > = { - where: WhereUnique; + where: WhereUniqueInput; select?: Select; include?: Include; omit?: OmitFields; @@ -1099,8 +1119,8 @@ type ConnectInput< Model extends GetModels, Field extends RelationFields > = FieldIsArray extends true - ? OrArray>> - : WhereUnique>; + ? OrArray>> + : WhereUniqueInput>; type ConnectOrCreateInput< Schema extends SchemaDef, @@ -1121,16 +1141,16 @@ type DisconnectInput< Field extends RelationFields > = FieldIsArray extends true ? OrArray< - WhereUnique>, + WhereUniqueInput>, true > : boolean | WhereInput>; -type SetInput< +type SetRelationInput< Schema extends SchemaDef, Model extends GetModels, Field extends RelationFields -> = OrArray>>; +> = OrArray>>; type NestedUpdateInput< Schema extends SchemaDef, @@ -1139,7 +1159,7 @@ type NestedUpdateInput< > = FieldIsArray extends true ? OrArray< { - where: WhereUnique< + where: WhereUniqueInput< Schema, RelationFieldType >; @@ -1153,7 +1173,7 @@ type NestedUpdateInput< > : XOR< { - where: WhereUnique< + where: WhereUniqueInput< Schema, RelationFieldType >; @@ -1176,7 +1196,7 @@ type NestedUpsertInput< Field extends RelationFields > = OrArray< { - where: WhereUnique; + where: WhereUniqueInput; create: CreateInput< Schema, RelationFieldType, @@ -1213,7 +1233,7 @@ type NestedDeleteInput< Field extends RelationFields > = FieldIsArray extends true ? OrArray< - WhereUnique>, + WhereUniqueInput>, true > : boolean | WhereInput>; diff --git a/packages/runtime/src/client/crud/dialects/base.ts b/packages/runtime/src/client/crud/dialects/base.ts index 58d0ac6d..72975fc5 100644 --- a/packages/runtime/src/client/crud/dialects/base.ts +++ b/packages/runtime/src/client/crud/dialects/base.ts @@ -31,6 +31,7 @@ import type { ClientOptions } from '../../options'; import { buildFieldRef, buildJoinPairs, + flattenCompoundUniqueFilters, getField, getIdFields, getManyToManyRelation, @@ -81,8 +82,9 @@ export abstract class BaseCrudDialect { } let result = this.true(eb); + let _where = flattenCompoundUniqueFilters(this.schema, model, where); - for (const [key, payload] of Object.entries(where)) { + for (const [key, payload] of Object.entries(_where)) { if (payload === undefined) { continue; } @@ -150,13 +152,8 @@ export abstract class BaseCrudDialect { } // call expression builder and combine the results - if ( - typeof where === 'object' && - where !== null && - '$expr' in where && - typeof where['$expr'] === 'function' - ) { - result = this.and(eb, result, where['$expr'](eb)); + if ('$expr' in _where && typeof _where['$expr'] === 'function') { + result = this.and(eb, result, _where['$expr'](eb)); } return result; diff --git a/packages/runtime/src/client/crud/operations/base.ts b/packages/runtime/src/client/crud/operations/base.ts index 10d28798..a4996572 100644 --- a/packages/runtime/src/client/crud/operations/base.ts +++ b/packages/runtime/src/client/crud/operations/base.ts @@ -15,6 +15,7 @@ import { match } from 'ts-pattern'; import { ulid } from 'ulid'; import * as uuid from 'uuid'; import type { ClientContract } from '../..'; +import { PolicyPlugin } from '../../../plugins/policy'; import { Expression, type GetModels, @@ -42,6 +43,7 @@ import { buildFieldRef, buildJoinPairs, ensureArray, + flattenCompoundUniqueFilters, getField, getIdFields, getIdValues, @@ -115,6 +117,13 @@ export abstract class BaseOperationHandler { ); } + // TODO: this is not clean, needs a better solution + protected get hasPolicyEnabled() { + return this.options.plugins?.some( + (plugin) => plugin instanceof PolicyPlugin + ); + } + protected requireModel(model: string) { return requireModel(this.schema, model); } @@ -137,9 +146,10 @@ export abstract class BaseOperationHandler { filter: any ): Promise { const idFields = getIdFields(this.schema, model); + let _filter = flattenCompoundUniqueFilters(this.schema, model, filter); const query = kysely .selectFrom(model) - .where((eb) => eb.and(filter)) + .where((eb) => eb.and(_filter)) .select(idFields.map((f) => kysely.dynamic.ref(f))) .limit(1) .modifyEnd(this.makeContextComment({ model, operation: 'read' })); @@ -1727,13 +1737,15 @@ export abstract class BaseOperationHandler { await Promise.all(tasks); } + // #region relation manipulation + protected async connectRelation( kysely: ToKysely, model: GetModels, data: any, fromRelation: FromRelationContext ) { - const _data = enumerate(data); + const _data = this.normalizeRelationManipulationInput(model, data); if (_data.length === 0) { return; } @@ -1866,25 +1878,6 @@ export abstract class BaseOperationHandler { } } - private getEntityIds( - kysely: ToKysely, - model: GetModels, - uniqueFilter: any - ) { - const idFields = getIdFields(this.schema, model); - if ( - idFields.every( - (f) => f in uniqueFilter && uniqueFilter[f] !== undefined - ) - ) { - return uniqueFilter; - } - - return this.readUnique(kysely, model, { - where: uniqueFilter, - }); - } - protected async connectOrCreateRelation( kysely: ToKysely, model: GetModels, @@ -1920,20 +1913,21 @@ export abstract class BaseOperationHandler { fromRelation: FromRelationContext ) { let disconnectConditions: any[] = []; - let expectedUpdateCount: number; if (typeof data === 'boolean') { if (data === false) { return; } else { disconnectConditions = [true]; - expectedUpdateCount = 1; } } else { - disconnectConditions = enumerate(data); + disconnectConditions = this.normalizeRelationManipulationInput( + model, + data + ); + if (disconnectConditions.length === 0) { return; } - expectedUpdateCount = disconnectConditions.length; } if (disconnectConditions.length === 0) { @@ -1949,6 +1943,10 @@ export abstract class BaseOperationHandler { // handle many-to-many relation const actions = disconnectConditions.map(async (d) => { const ids = await this.getEntityIds(kysely, model, d); + if (!ids) { + // not found + return; + } return this.handleManyToManyRelation( kysely, 'disconnect', @@ -1961,39 +1959,47 @@ export abstract class BaseOperationHandler { m2m.joinTable ); }); - const results = await Promise.all(actions); - - // validate disconnect result - if (expectedUpdateCount > results.filter((r) => !!r).length) { - throw new NotFoundError(model); - } + await Promise.all(actions); } else { - let updateResult: UpdateResult; - const { ownedByModel, keyPairs } = getRelationForeignKeyFieldPairs( this.schema, fromRelation.model, fromRelation.field ); + const eb = expressionBuilder(); if (ownedByModel) { // set parent fk directly invariant( disconnectConditions.length === 1, 'only one entity can be disconnected' ); - const target = await this.readUnique(kysely, model, { - where: - disconnectConditions[0] === true - ? {} - : disconnectConditions[0], - }); - if (!target) { - throw new NotFoundError(model); - } + const condition = disconnectConditions[0]; const query = kysely .updateTable(fromRelation.model) - .where((eb) => eb.and(fromRelation.ids)) + // id filter + .where(eb.and(fromRelation.ids)) + // merge extra disconnect conditions + .$if(condition !== true, (qb) => + qb.where( + eb( + // @ts-ignore + eb.refTuple(...keyPairs.map(({ fk }) => fk)), + 'in', + eb + .selectFrom(model) + .select(keyPairs.map(({ pk }) => pk)) + .where( + this.dialect.buildFilter( + eb, + model, + model, + condition + ) + ) + ) + ) + ) .set( keyPairs.reduce( (acc, { fk }) => ({ ...acc, [fk]: null }), @@ -2006,13 +2012,25 @@ export abstract class BaseOperationHandler { operation: 'update', }) ); - updateResult = await query.executeTakeFirstOrThrow(); + await query.executeTakeFirstOrThrow(); } else { // disconnect const query = kysely .updateTable(model) - .where((eb) => - eb.or(disconnectConditions.map((d) => eb.and(d))) + .where( + eb.and([ + // fk filter + eb.and( + Object.fromEntries( + keyPairs.map(({ fk, pk }) => [ + fk, + fromRelation.ids[pk], + ]) + ) + ), + // merge extra disconnect conditions + eb.or(disconnectConditions.map((d) => eb.and(d))), + ]) ) .set( keyPairs.reduce( @@ -2026,13 +2044,7 @@ export abstract class BaseOperationHandler { operation: 'update', }) ); - updateResult = await query.executeTakeFirstOrThrow(); - } - - // validate disconnect result - if (expectedUpdateCount > updateResult.numUpdatedRows!) { - // some entities were not connected - throw new NotFoundError(model); + await query.executeTakeFirstOrThrow(); } } } @@ -2043,7 +2055,7 @@ export abstract class BaseOperationHandler { data: any, fromRelation: FromRelationContext ) { - const _data = enumerate(data); + const _data = this.normalizeRelationManipulationInput(model, data); const m2m = getManyToManyRelation( this.schema, @@ -2159,6 +2171,7 @@ export abstract class BaseOperationHandler { } } } + protected async deleteRelation( kysely: ToKysely, model: GetModels, @@ -2176,7 +2189,10 @@ export abstract class BaseOperationHandler { expectedDeleteCount = 1; } } else { - deleteConditions = enumerate(data); + deleteConditions = this.normalizeRelationManipulationInput( + model, + data + ); if (deleteConditions.length === 0) { return; } @@ -2244,16 +2260,13 @@ export abstract class BaseOperationHandler { model, { AND: [ - { - // filter for parent - [fieldDef.relation.opposite]: - Object.fromEntries( - keyPairs.map(({ fk, pk }) => [ - fk, - fromEntity[pk], - ]) - ), - }, + // filter for parent + Object.fromEntries( + keyPairs.map(({ fk, pk }) => [ + pk, + fromEntity[fk], + ]) + ), { OR: deleteConditions, }, @@ -2290,6 +2303,17 @@ export abstract class BaseOperationHandler { } } + private normalizeRelationManipulationInput( + model: GetModels, + data: any + ) { + return enumerate(data).map((item) => + flattenCompoundUniqueFilters(this.schema, model, item) + ); + } + + // #endregion + protected async delete< ReturnData extends boolean, Result = ReturnData extends true ? unknown[] : { count: number } @@ -2369,4 +2393,29 @@ export abstract class BaseOperationHandler { .execute(callback); } } + + // Given a unique filter of a model, return the entity ids by trying to + // reused the filter if it's a complete id filter (without extra fields) + // otherwise, read the entity by the filter + private getEntityIds( + kysely: ToKysely, + model: GetModels, + uniqueFilter: any + ) { + const idFields: string[] = getIdFields(this.schema, model); + if ( + // all id fields are provided + idFields.every( + (f) => f in uniqueFilter && uniqueFilter[f] !== undefined + ) && + // no non-id filter exists + Object.keys(uniqueFilter).every((k) => idFields.includes(k)) + ) { + return uniqueFilter; + } + + return this.readUnique(kysely, model, { + where: uniqueFilter, + }); + } } diff --git a/packages/runtime/src/client/crud/operations/update.ts b/packages/runtime/src/client/crud/operations/update.ts index 4407b564..56ff4fbe 100644 --- a/packages/runtime/src/client/crud/operations/update.ts +++ b/packages/runtime/src/client/crud/operations/update.ts @@ -53,13 +53,16 @@ export class UpdateOperationHandler< }); }); - if (!result) { + if (!result && this.hasPolicyEnabled) { throw new RejectedByPolicyError( this.model, 'result is not allowed to be read back' ); } + // NOTE: update can actually return null if the entity being updated is deleted + // due to cascade when a relation is deleted during update. This doesn't comply + // with `update`'s method signature, but we'll allow it to be consistent with Prisma. return result; } diff --git a/packages/runtime/src/client/crud/validator.ts b/packages/runtime/src/client/crud/validator.ts index 4dfa710c..1aa7bc8b 100644 --- a/packages/runtime/src/client/crud/validator.ts +++ b/packages/runtime/src/client/crud/validator.ts @@ -271,6 +271,30 @@ export class InputValidator { } } + if (unique) { + // add compound unique fields, e.g. `{ id1_id2: { id1: 1, id2: 1 } }` + const uniqueFields = getUniqueFields(this.schema, model); + for (const uniqueField of uniqueFields) { + if ('defs' in uniqueField) { + fields[uniqueField.name] = z + .object( + Object.fromEntries( + Object.entries(uniqueField.defs).map( + ([key, def]) => [ + key, + this.makePrimitiveFilterSchema( + def.type as BuiltinType, + !!def.optional + ), + ] + ) + ) + ) + .optional(); + } + } + } + // expression builder fields['$expr'] = z.function().optional(); @@ -308,20 +332,14 @@ export class InputValidator { if (uniqueFields.length === 1) { // only one unique field (set), mark the field(s) required - result = baseWhere.required( - uniqueFields[0]!.reduce( - (acc, k) => ({ - ...acc, - [k.name]: true, - }), - {} - ) - ); + result = baseWhere.required({ + [uniqueFields[0]!.name]: true, + } as any); } else { result = baseWhere.refine((value) => { // check that at least one unique field is set - return uniqueFields.some((fields) => - fields.every(({ name }) => value[name] !== undefined) + return uniqueFields.some( + ({ name }) => value[name] !== undefined ); }, `At least one unique field or field set must be set`); } @@ -810,7 +828,7 @@ export class InputValidator { ) { const fieldType = fieldDef.type; const array = !!fieldDef.array; - let fields: Record = { + const fields: Record = { create: this.makeCreateDataSchema( fieldDef.type, !!fieldDef.array, @@ -824,17 +842,6 @@ export class InputValidator { array, withoutFields ).optional(), - - disconnect: this.makeDisconnectDataSchema( - fieldType, - array - ).optional(), - - delete: this.makeDeleteRelationDataSchema( - fieldType, - array, - true - ).optional(), }; if (array) { @@ -845,6 +852,20 @@ export class InputValidator { } if (mode === 'update') { + if (fieldDef.optional || fieldDef.array) { + // disconnect and delete are only available for optional/to-many relations + fields['disconnect'] = this.makeDisconnectDataSchema( + fieldType, + array + ).optional(); + + fields['delete'] = this.makeDeleteRelationDataSchema( + fieldType, + array, + true + ).optional(); + } + fields['update'] = array ? this.orArray( z.object({ @@ -883,6 +904,7 @@ export class InputValidator { ).optional(); if (array) { + // to-many relation specifics fields['set'] = this.makeSetDataSchema( fieldType, true @@ -926,9 +948,12 @@ export class InputValidator { private makeDisconnectDataSchema(model: string, canBeArray: boolean) { if (canBeArray) { + // to-many relation, must be unique filters return this.orArray(this.makeWhereSchema(model, true), canBeArray); } else { - return z.union([z.boolean(), this.makeWhereSchema(model, true)]); + // to-one relation, can be boolean or a regular filter - the entity + // being disconnected is already uniquely identified by its parent + return z.union([z.boolean(), this.makeWhereSchema(model, false)]); } } diff --git a/packages/runtime/src/client/helpers/schema-db-pusher.ts b/packages/runtime/src/client/helpers/schema-db-pusher.ts index 8032e84c..78abe871 100644 --- a/packages/runtime/src/client/helpers/schema-db-pusher.ts +++ b/packages/runtime/src/client/helpers/schema-db-pusher.ts @@ -69,7 +69,12 @@ export class SchemaDbPusher { fieldDef ); } else { - table = this.createModelField(table, fieldName, fieldDef); + table = this.createModelField( + table, + fieldName, + fieldDef, + modelDef + ); } } @@ -127,14 +132,15 @@ export class SchemaDbPusher { private createModelField( table: CreateTableBuilder, fieldName: string, - fieldDef: FieldDef + fieldDef: FieldDef, + modelDef: ModelDef ) { return table.addColumn( fieldName, this.mapFieldType(fieldDef), (col) => { // @id - if (fieldDef.id) { + if (fieldDef.id && modelDef.idFields.length === 1) { col = col.primaryKey(); } diff --git a/packages/runtime/src/client/query-utils.ts b/packages/runtime/src/client/query-utils.ts index 96ee2bfb..7eb45fc1 100644 --- a/packages/runtime/src/client/query-utils.ts +++ b/packages/runtime/src/client/query-utils.ts @@ -148,7 +148,12 @@ export function isRelationField( export function getUniqueFields(schema: SchemaDef, model: string) { const modelDef = requireModel(schema, model); - const result: Array<{ name: string; def: FieldDef }[]> = []; + const result: Array< + // single field unique + | { name: string; def: FieldDef } + // multi-field unique + | { name: string; defs: Record } + > = []; for (const [key, value] of Object.entries(modelDef.uniqueFields)) { if (typeof value !== 'object') { throw new InternalError( @@ -158,15 +163,18 @@ export function getUniqueFields(schema: SchemaDef, model: string) { if (typeof value.type === 'string') { // singular unique field - result.push([{ name: key, def: requireField(schema, model, key) }]); + result.push({ name: key, def: requireField(schema, model, key) }); } else { // compound unique field - result.push( - Object.keys(value).map((k) => ({ - name: k, - def: requireField(schema, model, k), - })) - ); + result.push({ + name: key, + defs: Object.fromEntries( + Object.keys(value).map((k) => [ + k, + requireField(schema, model, k), + ]) + ), + }); } } return result; @@ -297,6 +305,36 @@ export function getManyToManyRelation( } } +/** + * Convert filter like `{ id1_id2: { id1: 1, id2: 1 } }` to `{ id1: 1, id2: 1 }` + */ +export function flattenCompoundUniqueFilters( + schema: SchemaDef, + model: string, + filter: unknown +) { + if (typeof filter !== 'object' || !filter) { + return filter; + } + + const uniqueFields = getUniqueFields(schema, model); + const compoundUniques = uniqueFields.filter((u) => 'defs' in u); + if (compoundUniques.length === 0) { + return filter; + } + + const result: any = {}; + for (const [key, value] of Object.entries(filter)) { + if (compoundUniques.some(({ name }) => name === key)) { + // flatten the compound field + Object.assign(result, value); + } else { + result[key] = value; + } + } + return result; +} + export function ensureArray(value: T | T[]): T[] { if (Array.isArray(value)) { return value; diff --git a/packages/runtime/test/client-api/compound-id.test.ts b/packages/runtime/test/client-api/compound-id.test.ts new file mode 100644 index 00000000..6504b7a3 --- /dev/null +++ b/packages/runtime/test/client-api/compound-id.test.ts @@ -0,0 +1,677 @@ +import { describe, expect, it } from 'vitest'; +import { createTestClient } from '../utils'; + +describe('Compound ID tests', () => { + describe('to-one relation', () => { + const schema = ` + model User { + id1 Int + id2 Int + name String + posts Post[] + @@id([id1, id2]) + } + + model Post { + id Int @id + title String + author User? @relation(fields: [authorId1, authorId2], references: [id1, id2], onDelete: Cascade, onUpdate: Cascade) + authorId1 Int? + authorId2 Int? + } + `; + + it('works with create', async () => { + const client = await createTestClient(schema); + await expect( + client.user.create({ + data: { + id1: 1, + id2: 1, + name: 'User1', + }, + }) + ).resolves.toMatchObject({ + id1: 1, + id2: 1, + name: 'User1', + }); + + await expect( + client.post.create({ + data: { + id: 1, + title: 'Post1', + author: { + connect: { id1_id2: { id1: 1, id2: 2 } }, + }, + }, + }) + ).toBeRejectedNotFound(); + + await expect( + client.post.create({ + data: { + id: 1, + title: 'Post1', + author: { + connect: { id1_id2: { id1: 1, id2: 1 } }, + }, + }, + }) + ).resolves.toMatchObject({ + authorId1: 1, + authorId2: 1, + }); + }); + + it('works with findUnique', async () => { + const client = await createTestClient(schema); + + await client.user.create({ + data: { + id1: 1, + id2: 1, + name: 'User1', + posts: { + create: { + id: 1, + title: 'Post1', + }, + }, + }, + }); + + await expect( + client.user.findUnique({ + where: { + id1_id2: { + id1: 1, + id2: 2, + }, + }, + }) + ).toResolveNull(); + + await expect( + client.user.findUnique({ + where: { + id1_id2: { + id1: 1, + id2: 1, + }, + }, + }) + ).toResolveTruthy(); + + await expect( + client.user.findUnique({ + where: { + id1: 1, + }, + }) + ).rejects.toThrow(/Required/); + }); + + it('works with update', async () => { + const client = await createTestClient(schema); + + await client.user.create({ + data: { id1: 1, id2: 1, name: 'User1' }, + }); + + // toplevel + await expect( + client.user.update({ + where: { id1_id2: { id1: 1, id2: 1 } }, + data: { name: 'User1-1' }, + }) + ).resolves.toMatchObject({ name: 'User1-1' }); + + // toplevel, not found + await expect( + client.user.update({ + where: { id1_id2: { id1: 1, id2: 1 }, id1: 2 }, + data: { name: 'User1-1' }, + }) + ).toBeRejectedNotFound(); + + await client.post.create({ + data: { + id: 1, + title: 'Post1', + }, + }); + + // connect + await expect( + client.post.update({ + where: { id: 1 }, + data: { + author: { + connect: { id1_id2: { id1: 1, id2: 1 } }, + }, + }, + }) + ).resolves.toMatchObject({ authorId1: 1, authorId2: 1 }); + + // disconnect not found + await expect( + client.post.update({ + where: { id: 1 }, + data: { author: { disconnect: { id1: 1, id2: 2 } } }, + }) + ).resolves.toMatchObject({ authorId1: 1, authorId2: 1 }); + + // disconnect found + await expect( + client.post.update({ + where: { id: 1 }, + data: { author: { disconnect: { id1: 1, id2: 1 } } }, + }) + ).resolves.toMatchObject({ authorId1: null, authorId2: null }); + + // reconnect + client.post.update({ + where: { id: 1 }, + data: { + author: { + connect: { id1_id2: { id1: 1, id2: 1 } }, + }, + }, + }); + + // disconnect + await expect( + client.post.update({ + where: { id: 1 }, + data: { author: { disconnect: true } }, + }) + ).resolves.toMatchObject({ authorId1: null, authorId2: null }); + + // connectOrCreate - connect + await expect( + client.post.update({ + where: { id: 1 }, + data: { + author: { + connectOrCreate: { + where: { id1_id2: { id1: 1, id2: 1 } }, + create: { + id1: 1, + id2: 1, + name: 'User1-new', + }, + }, + }, + }, + include: { + author: true, + }, + }) + ).resolves.toMatchObject({ + author: { + id1: 1, + id2: 1, + name: 'User1-1', + }, + }); + + // connectOrCreate - create + await expect( + client.post.update({ + where: { id: 1 }, + data: { + author: { + connectOrCreate: { + where: { id1_id2: { id1: 2, id2: 2 } }, + create: { + id1: 2, + id2: 2, + name: 'User2', + }, + }, + }, + }, + include: { + author: true, + }, + }) + ).resolves.toMatchObject({ + author: { + id1: 2, + id2: 2, + name: 'User2', + }, + }); + + // upsert - create + await expect( + client.post.update({ + where: { id: 1 }, + data: { + author: { + upsert: { + where: { id1_id2: { id1: 3, id2: 3 } }, + create: { id1: 3, id2: 3, name: 'User3' }, + update: { name: 'User3-1' }, + }, + }, + }, + include: { author: true }, + }) + ).resolves.toMatchObject({ author: { name: 'User3' } }); + + // upsert - update + await expect( + client.post.update({ + where: { id: 1 }, + data: { + author: { + upsert: { + where: { id1_id2: { id1: 3, id2: 3 } }, + create: { id1: 3, id2: 3, name: 'User3' }, + update: { name: 'User3-1' }, + }, + }, + }, + include: { author: true }, + }) + ).resolves.toMatchObject({ author: { name: 'User3-1' } }); + + // delete, and post is cascade deleted + await expect( + client.post.update({ + where: { id: 1 }, + data: { author: { delete: true } }, + }) + ).toResolveNull(); + + // delete not found + await expect( + client.post.update({ + where: { id: 1 }, + data: { author: { delete: true } }, + }) + ).toBeRejectedNotFound(); + }); + + it('works with upsert', async () => { + const client = await createTestClient(schema); + + // toplevel, create + await expect( + client.user.upsert({ + where: { id1_id2: { id1: 1, id2: 1 } }, + create: { id1: 1, id2: 1, name: 'User1' }, + update: { name: 'User1-1' }, + }) + ).resolves.toMatchObject({ name: 'User1' }); + + // toplevel, update + await expect( + client.user.upsert({ + where: { id1_id2: { id1: 1, id2: 1 } }, + create: { id1: 1, id2: 1, name: 'User1' }, + update: { name: 'User1-1' }, + }) + ).resolves.toMatchObject({ name: 'User1-1' }); + }); + + it('works with delete', async () => { + const client = await createTestClient(schema); + + await client.user.create({ + data: { id1: 1, id2: 1, name: 'User1' }, + }); + + // toplevel + await expect( + client.user.delete({ + where: { id1_id2: { id1: 1, id2: 1 } }, + }) + ).resolves.toMatchObject({ name: 'User1' }); + + // toplevel + await expect( + client.user.delete({ + where: { id1_id2: { id1: 1, id2: 1 } }, + }) + ).toBeRejectedNotFound(); + }); + }); + + describe('to-many-relation', () => { + const schema = ` + model User { + id Int @id + name String + posts Post[] + } + + model Post { + id1 Int + id2 Int + title String + author User? @relation(fields: [authorId], references: [id]) + authorId Int? + @@id([id1, id2]) + } + `; + it('works with create', async () => { + const client = await createTestClient(schema); + + await client.post.create({ + data: { + id1: 1, + id2: 1, + title: 'Post1', + }, + }); + + await expect( + client.user.create({ + data: { + id: 1, + name: 'User1', + posts: { connect: { id1_id2: { id1: 1, id2: 1 } } }, + }, + include: { posts: true }, + }) + ).resolves.toMatchObject({ + posts: [expect.objectContaining({ id1: 1, id2: 1 })], + }); + + await expect( + client.user.create({ + data: { + id: 2, + name: 'User2', + posts: { connect: { id1_id2: { id1: 1, id2: 2 } } }, + }, + include: { posts: true }, + }) + ).toBeRejectedNotFound(); + + // connectOrCreate - connect + await expect( + client.user.create({ + data: { + id: 2, + name: 'User2', + posts: { + connectOrCreate: { + where: { id1_id2: { id1: 1, id2: 1 } }, + create: { + id1: 1, + id2: 1, + title: 'Post1-new', + }, + }, + }, + }, + include: { posts: true }, + }) + ).resolves.toMatchObject({ + posts: [expect.objectContaining({ title: 'Post1' })], + }); + + // connectOrCreate - create + await expect( + client.user.create({ + data: { + id: 3, + name: 'User3', + posts: { + connectOrCreate: { + where: { id1_id2: { id1: 2, id2: 2 } }, + create: { + id1: 2, + id2: 2, + title: 'Post2', + }, + }, + }, + }, + include: { posts: true }, + }) + ).resolves.toMatchObject({ + posts: [expect.objectContaining({ title: 'Post2' })], + }); + }); + + it('works with update', async () => { + const client = await createTestClient(schema); + + await client.user.create({ + data: { + id: 1, + name: 'User1', + posts: { + create: { + id1: 1, + id2: 1, + title: 'Post1', + }, + }, + }, + }); + + // toplevel + await expect( + client.post.update({ + where: { id1_id2: { id1: 1, id2: 1 } }, + data: { + title: 'Post1-1', + }, + }) + ).resolves.toMatchObject({ title: 'Post1-1' }); + + // create + await expect( + client.user.update({ + where: { id: 1 }, + data: { + posts: { + create: { + id1: 2, + id2: 2, + title: 'Post2', + }, + }, + }, + include: { posts: true }, + }) + ).resolves.toMatchObject({ + posts: [ + expect.objectContaining({ title: 'Post1-1' }), + expect.objectContaining({ title: 'Post2' }), + ], + }); + + // connect - not found + await expect( + client.user.update({ + where: { id: 1 }, + data: { + posts: { + connect: { id1_id2: { id1: 3, id2: 3 } }, + }, + }, + include: { posts: true }, + }) + ).toBeRejectedNotFound(); + + await client.post.create({ + data: { + id1: 3, + id2: 3, + title: 'Post3', + }, + }); + + // connect + await expect( + client.user.update({ + where: { id: 1 }, + data: { + posts: { + connect: { id1_id2: { id1: 3, id2: 3 } }, + }, + }, + include: { posts: true }, + }) + ).resolves.toMatchObject({ + posts: [ + expect.objectContaining({ title: 'Post1-1' }), + expect.objectContaining({ title: 'Post2' }), + expect.objectContaining({ title: 'Post3' }), + ], + }); + + // disconnect - not giving unique filter + await expect( + client.user.update({ + where: { id: 1 }, + data: { + posts: { + disconnect: { id1: 1, id2: 1 }, + }, + }, + }) + ).rejects.toThrow(/Invalid/); + + // disconnect + await expect( + client.user.update({ + where: { id: 1 }, + data: { + posts: { + disconnect: { id1_id2: { id1: 1, id2: 1 } }, + }, + }, + include: { posts: true }, + }) + ).resolves.toMatchObject({ + posts: [ + expect.objectContaining({ title: 'Post2' }), + expect.objectContaining({ title: 'Post3' }), + ], + }); + + // disconnect not found + await expect( + client.user.update({ + where: { id: 1 }, + data: { + posts: { + disconnect: { id1_id2: { id1: 10, id2: 10 } }, + }, + }, + }) + ).toResolveTruthy(); + + // update + await expect( + client.user.update({ + where: { id: 1 }, + data: { + posts: { + update: { + where: { + id1_id2: { id1: 2, id2: 2 }, + }, + data: { title: 'Post2-new' }, + }, + }, + }, + include: { posts: true }, + }) + ).resolves.toMatchObject({ + posts: expect.arrayContaining([ + expect.objectContaining({ title: 'Post2-new' }), + ]), + }); + + // delete + await expect( + client.user.update({ + where: { id: 1 }, + data: { + posts: { + delete: { id1_id2: { id1: 3, id2: 3 } }, + }, + }, + include: { posts: true }, + }) + ).resolves.toMatchObject({ + posts: expect.not.arrayContaining([{ title: 'Post3' }]), + }); + + // set + await expect( + client.user.update({ + where: { id: 1 }, + data: { + posts: { + set: [ + { id1_id2: { id1: 1, id2: 1 } }, + { id1_id2: { id1: 2, id2: 2 } }, + ], + }, + }, + include: { posts: true }, + }) + ).resolves.toMatchObject({ + posts: [ + expect.objectContaining({ id1: 1, id2: 1 }), + expect.objectContaining({ id1: 2, id2: 2 }), + ], + }); + }); + + it('works with upsert', async () => { + const client = await createTestClient(schema); + + // create + await expect( + client.post.upsert({ + where: { id1_id2: { id1: 1, id2: 1 } }, + create: { id1: 1, id2: 1, title: 'Post1' }, + update: { title: 'Post1-1' }, + }) + ).resolves.toMatchObject({ title: 'Post1' }); + + // update + await expect( + client.post.upsert({ + where: { id1_id2: { id1: 1, id2: 1 } }, + create: { id1: 1, id2: 1, title: 'Post1' }, + update: { title: 'Post1-1' }, + }) + ).resolves.toMatchObject({ title: 'Post1-1' }); + }); + + it('works with delete', async () => { + const client = await createTestClient(schema); + + await client.post.create({ + data: { id1: 1, id2: 1, title: 'Post1' }, + }); + + // toplevel + await expect( + client.post.delete({ + where: { id1_id2: { id1: 1, id2: 1 } }, + }) + ).resolves.toMatchObject({ title: 'Post1' }); + + // toplevel + await expect( + client.post.delete({ + where: { id1_id2: { id1: 1, id2: 1 } }, + }) + ).toBeRejectedNotFound(); + }); + }); +}); diff --git a/packages/runtime/test/client-api/relation.test.ts b/packages/runtime/test/client-api/relation.test.ts index da688b14..73bf591a 100644 --- a/packages/runtime/test/client-api/relation.test.ts +++ b/packages/runtime/test/client-api/relation.test.ts @@ -543,7 +543,24 @@ describe.each([ ], }); - // disconnect + // 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 }, diff --git a/packages/runtime/test/client-api/update.test.ts b/packages/runtime/test/client-api/update.test.ts index 83da8dae..5fc8d667 100644 --- a/packages/runtime/test/client-api/update.test.ts +++ b/packages/runtime/test/client-api/update.test.ts @@ -535,6 +535,23 @@ describe.each(createClientSpecs(PG_DB_NAME))( }); // single + await expect( + client.post.update({ + where: { id: post.id }, + data: { + comments: { + disconnect: { id: '1', content: 'non found' }, + }, + }, + include: { comments: true }, + }) + ).resolves.toMatchObject({ + comments: [ + expect.objectContaining({ id: '1' }), + expect.objectContaining({ id: '2' }), + expect.objectContaining({ id: '3' }), + ], + }); await expect( client.post.update({ where: { id: post.id }, @@ -577,7 +594,9 @@ describe.each(createClientSpecs(PG_DB_NAME))( }, include: { comments: true }, }) - ).toBeRejectedNotFound(); + ).resolves.toMatchObject({ + comments: [], + }); // multiple await expect( @@ -1428,7 +1447,7 @@ describe.each(createClientSpecs(PG_DB_NAME))( }, include: { profile: true }, }) - ).toBeRejectedNotFound(); + ).toResolveTruthy(); }); it('works with nested to-one relation update', async () => { @@ -1851,7 +1870,7 @@ describe.each(createClientSpecs(PG_DB_NAME))( }, }, }) - ).toBeRejectedNotFound(); + ).toResolveTruthy(); // null relation await expect( diff --git a/packages/runtime/test/policy/connect-disconnect.test.ts b/packages/runtime/test/policy/connect-disconnect.test.ts index 20191779..5ed4997d 100644 --- a/packages/runtime/test/policy/connect-disconnect.test.ts +++ b/packages/runtime/test/policy/connect-disconnect.test.ts @@ -64,8 +64,11 @@ describe('connect and disconnect tests', () => { disconnect: { id: 'm2-1' }, }, }, + include: { m2: true }, }) - ).toBeRejectedNotFound(); + ).resolves.toMatchObject({ + m2: [expect.objectContaining({ id: 'm2-1' })], + }); // reset m2-1 delete await rawDb.m2.update({ where: { id: 'm2-1' }, @@ -221,8 +224,11 @@ describe('connect and disconnect tests', () => { disconnect: { id: 'm2-1' }, }, }, + include: { m2: true }, }) - ).toBeRejectedNotFound(); + ).resolves.toMatchObject({ + m2: expect.objectContaining({ id: 'm2-1' }), + }); await rawDb.m2.update({ where: { id: 'm2-1' }, data: { deleted: false },