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)