From db6f34e127a8439849b60872007b62038820eada Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Mon, 19 Oct 2026 06:27:24 +0100 Subject: [PATCH] fix(query-builder): keep every where condition in deletes and updates delete(), update() and updateFrom() rebuilt their where clause through the Prisma-like schema shape, which only represents comparisons, groups and negations. Raw, JSON, relation, exists, column-comparison, date/time, full-text and expression conditions were silently dropped, widening the write to every row the remaining constraints matched. Writes now build from the same structured condition reads use. update() only takes the primary-key path when the whole condition is a primary-key equality, and combining an unconvertible object where with structured constraints throws instead of losing one of them. --- src/QueryBuilder.ts | 223 ++++++++++++++------------ tests/postgres/kysely-adapter.spec.ts | 66 ++++++++ 2 files changed, 188 insertions(+), 101 deletions(-) diff --git a/src/QueryBuilder.ts b/src/QueryBuilder.ts index dbd0170..088b0d6 100644 --- a/src/QueryBuilder.ts +++ b/src/QueryBuilder.ts @@ -332,20 +332,13 @@ export class QueryBuilder): Promise { - const where = this.buildWhere() - if (!where) - throw new QueryConstraintException('Update requires a where clause.', { - operation: 'update', - model: this.model.name, - }) - - const directSpec = this.tryBuildUpdateSpec(where, data as QuerySchemaUpdateData) + const condition = this.buildWriteCondition('update', 'Update') + const uniqueWhere = this.uniqueWhereFor(condition) const adapter = this.requireAdapter() - if ( - !this.isUniqueWhere(where as Record) && - directSpec && - typeof adapter.updateFirst === 'function' - ) { - const updated = await adapter.updateFirst(directSpec) + if (!uniqueWhere && typeof adapter.updateFirst === 'function') { + const updated = await adapter.updateFirst({ + target: this.buildQueryTarget(), + where: condition, + values: data as DatabaseRow, + }) if (!updated) throw new ModelNotFoundException( this.model.name, @@ -3653,9 +3649,8 @@ export class QueryBuilder['hydrate']>[0]) } - const uniqueWhere = await this.resolveUniqueWhere(where) const updated = await this.executeUpdateRow( - uniqueWhere!, + uniqueWhere ?? (await this.resolveUniqueWhere(condition))!, data as QuerySchemaUpdateData, ) @@ -3670,14 +3665,9 @@ export class QueryBuilder): Promise { - const where = this.buildWhere() - if (!where) - throw new QueryConstraintException('Update requires a where clause.', { - operation: 'updateFrom', - model: this.model.name, - }) + const condition = this.buildWriteCondition('updateFrom', 'Update') - return await this.executeUpdateManyRows(where, data as QuerySchemaUpdateData) + return await this.executeUpdateManyRows(condition, data as QuerySchemaUpdateData) } /** @@ -3834,14 +3824,7 @@ export class QueryBuilder { - const where = this.buildWhere() - if (!where) - throw new QueryConstraintException('Delete requires a where clause.', { - operation: 'delete', - model: this.model.name, - }) - - return this.executeDeleteManyRows(where) + return this.executeDeleteManyRows(this.buildWriteCondition('delete', 'Delete')) } /** @@ -3912,12 +3895,9 @@ export class QueryBuilder | undefined, + condition: QueryCondition, values: QuerySchemaUpdateData, - ): UpdateManySpec | null { - const condition = this.tryBuildQueryCondition(where) - if (condition === null) return null - + ): UpdateManySpec { return { target: this.buildQueryTarget(), where: condition, @@ -4867,7 +4847,29 @@ export class QueryBuilder this.isSchemaWhereRepresentable(entry)) + if (condition.type === 'not') return this.isSchemaWhereRepresentable(condition.condition) + + return false + } + private toQuerySchemaWhere(condition?: QueryCondition): QuerySchemaWhere | undefined { if (!condition) return undefined @@ -5519,20 +5537,12 @@ export class QueryBuilder | undefined, + condition: QueryCondition, values: QuerySchemaUpdateData, ): Promise { const adapter = this.requireAdapter() const normalizedValues = this.normalizeUpdatePayload(values) - const spec = this.tryBuildUpdateManySpec(where, normalizedValues) - if (!spec) - throw new UnsupportedAdapterFeatureException( - 'Update-many could not be compiled into an Arkorm update specification.', - { - operation: 'query.updateMany', - model: this.model.name, - }, - ) + const spec = this.tryBuildUpdateManySpec(condition, normalizedValues) if (typeof adapter.updateMany === 'function') return await adapter.updateMany(spec) @@ -5557,20 +5567,8 @@ export class QueryBuilder | QuerySchemaUniqueWhere, - ): Promise { + private async executeDeleteManyRows(condition: QueryCondition): Promise { const adapter = this.requireAdapter() - const condition = this.tryBuildQueryCondition(where) - if (condition === null) - throw new UnsupportedAdapterFeatureException( - 'Delete could not be compiled into an Arkorm delete specification.', - { - operation: 'query.delete', - model: this.model.name, - }, - ) - const spec: DeleteManySpec = { target: this.buildQueryTarget(), where: condition, @@ -5599,6 +5597,57 @@ export class QueryBuilder | null { + const primaryKey = this.model.getPrimaryKey() + + if ( + condition.type !== 'comparison' || + condition.column !== primaryKey || + condition.operator !== '=' || + condition.value == null + ) + return null + + return { [primaryKey]: condition.value } as unknown as QuerySchemaUniqueWhere + } + /** * Builds the where clause for the query, taking into account soft delete * settings if applicable. @@ -5631,28 +5680,15 @@ export class QueryBuilder, + condition: QueryCondition, failIfMissing = true, ): Promise | null> { - if (this.isUniqueWhere(where as Record)) - return where as unknown as QuerySchemaUniqueWhere - - const condition = this.tryBuildQueryCondition(where) - if (!condition) - throw new UniqueConstraintResolutionException( - 'Unable to resolve a unique identifier for update/delete operation from the current query shape.', - { - operation: 'resolveUniqueWhere', - model: this.model.name, - meta: { - where: where as Record, - }, - }, - ) + const unique = this.uniqueWhereFor(condition) + if (unique) return unique const row = (await this.requireAdapter().selectOne({ target: this.buildQueryTarget(), @@ -5670,7 +5706,7 @@ export class QueryBuilder, + condition, }, }, ) @@ -5686,7 +5722,7 @@ export class QueryBuilder, + condition, }, }, ) @@ -5694,21 +5730,6 @@ export class QueryBuilder } - /** - * Checks if the provided where clause is already a unique - * identifier (i.e., contains only an 'id' field). - * - * @param where - * @returns - */ - private isUniqueWhere(where: Record): boolean { - const primaryKey = this.model.getPrimaryKey() - - return ( - Object.keys(where).length === 1 && Object.prototype.hasOwnProperty.call(where, primaryKey) - ) - } - private shuffleRows(rows: TRow[]): TRow[] { const shuffled = [...rows] diff --git a/tests/postgres/kysely-adapter.spec.ts b/tests/postgres/kysely-adapter.spec.ts index ffb6a9f..20fd6d7 100644 --- a/tests/postgres/kysely-adapter.spec.ts +++ b/tests/postgres/kysely-adapter.spec.ts @@ -380,6 +380,72 @@ describe('PostgreSQL Kysely adapter', () => { expect(normalizedSql).toContain('LOWER("email") = $1 OR LOWER("email") LIKE $2') }) + it('keeps raw where clauses when deleting', async () => { + setPostgresModelAdapter(kyselyAdapter) + + const deleted = await DbUser.query() + .where({ email: { contains: '@example.com' } }) + .whereRaw('LOWER("email") = ?', ['jane@example.com']) + .delete() + + expect(deleted).toBe(1) + const remaining = await DbUser.query().orderBy({ id: 'asc' }).get() + expect(remaining.all().map((user) => user.getAttribute('email'))).toEqual(['john@example.com']) + }) + + it('keeps raw where clauses when updating every match', async () => { + setPostgresModelAdapter(kyselyAdapter) + + const updated = await DbUser.query() + .where({ email: { contains: '@example.com' } }) + .whereRaw('LOWER("email") = ?', ['jane@example.com']) + .updateFrom({ name: 'Renamed' }) + + expect(updated).toBe(1) + const users = await DbUser.query().orderBy({ id: 'asc' }).get() + expect(users.all().map((user) => user.getAttribute('name'))).toEqual(['Renamed', 'John']) + }) + + it('keeps raw where clauses when updating the first match', async () => { + setPostgresModelAdapter(kyselyAdapter) + + const updated = await DbUser.query() + .where({ email: { contains: '@example.com' } }) + .whereRaw('LOWER("email") = ?', ['john@example.com']) + .update({ name: 'Renamed' }) + + expect(updated.getAttribute('email')).toBe('john@example.com') + const users = await DbUser.query().orderBy({ id: 'asc' }).get() + expect(users.all().map((user) => user.getAttribute('name'))).toEqual(['Jane', 'Renamed']) + }) + + it('keeps raw where clauses when updating by primary key', async () => { + setPostgresModelAdapter(kyselyAdapter) + const jane = await DbUser.query().where({ email: 'jane@example.com' }).firstOrFail() + + await expect( + DbUser.query() + .where({ id: jane.getAttribute('id') }) + .whereRaw('1 = 0') + .update({ name: 'Renamed' }), + ).rejects.toThrow('Record not found') + + const users = await DbUser.query().orderBy({ id: 'asc' }).get() + expect(users.all().map((user) => user.getAttribute('name'))).toEqual(['Jane', 'John']) + }) + + it('refuses to drop constraints that cannot be combined', () => { + setPostgresModelAdapter(kyselyAdapter) + const unconvertible = { email: { mode: 'insensitive' } } as never + + expect(() => DbUser.query().whereRaw('1 = 1').where(unconvertible)).toThrow( + 'cannot be combined', + ) + expect(() => DbUser.query().where(unconvertible).whereRaw('1 = 1')).toThrow( + 'cannot be combined', + ) + }) + it('compiles a where callback into a parenthesized group', () => { setPostgresModelAdapter(kyselyAdapter)