diff --git a/deno.check.d.ts b/deno.check.d.ts index b534ec3f6..403306475 100644 --- a/deno.check.d.ts +++ b/deno.check.d.ts @@ -12,6 +12,7 @@ import type { export interface Database { audit: AuditTable person: PersonTable + person_backup: PersonTable pet: PetTable toy: ToyTable wine: WineTable diff --git a/src/helpers/postgres.ts b/src/helpers/postgres.ts index b1452e77c..3e010c2d6 100644 --- a/src/helpers/postgres.ts +++ b/src/helpers/postgres.ts @@ -169,3 +169,55 @@ export function jsonBuildObject>>( Object.keys(obj).flatMap((k) => [sql.lit(k), obj[k]]), )})` } + +export type MergeAction = 'INSERT' | 'UPDATE' | 'DELETE' + +/** + * The PostgreSQL `merge_action` function. + * + * This function can be used in a `returning` clause to get the action that was + * performed in a `mergeInto` query. The function returns one of the following + * strings: `'INSERT'`, `'UPDATE'`, or `'DELETE'`. + * + * ### Examples + * + * ```ts + * import { mergeAction } from 'kysely/helpers/postgres' + * + * const result = await db + * .mergeInto('person as p') + * .using('person_backup as pb', 'p.id', 'pb.id') + * .whenMatched() + * .thenUpdateSet(({ ref }) => ({ + * first_name: ref('pb.first_name'), + * updated_at: ref('pb.updated_at').$castTo(), + * })) + * .whenNotMatched() + * .thenInsertValues(({ ref}) => ({ + * id: ref('pb.id'), + * first_name: ref('pb.first_name'), + * created_at: ref('pb.updated_at'), + * updated_at: ref('pb.updated_at').$castTo(), + * })) + * .returning([mergeAction().as('action'), 'p.id', 'p.updated_at']) + * .execute() + * + * result[0].action + * ``` + * + * The generated SQL (PostgreSQL): + * + * ```sql + * merge into "person" as "p" + * using "person_backup" as "pb" on "p"."id" = "pb"."id" + * when matched then update set + * "first_name" = "pb"."first_name", + * "updated_at" = "pb"."updated_at"::text + * when not matched then insert values ("id", "first_name", "created_at", "updated_at") + * values ("pb"."id", "pb"."first_name", "pb"."updated_at", "pb"."updated_at") + * returning merge_action() as "action", "p"."id", "p"."updated_at" + * ``` + */ +export function mergeAction(): RawBuilder { + return sql`merge_action()` +} diff --git a/src/operation-node/merge-query-node.ts b/src/operation-node/merge-query-node.ts index 1e659d418..f99839b7c 100644 --- a/src/operation-node/merge-query-node.ts +++ b/src/operation-node/merge-query-node.ts @@ -3,6 +3,7 @@ import { AliasNode } from './alias-node.js' import { JoinNode } from './join-node.js' import { OperationNode } from './operation-node.js' import { OutputNode } from './output-node.js' +import { ReturningNode } from './returning-node.js' import { TableNode } from './table-node.js' import { TopNode } from './top-node.js' import { WhenNode } from './when-node.js' @@ -15,6 +16,7 @@ export interface MergeQueryNode extends OperationNode { readonly whens?: ReadonlyArray readonly with?: WithNode readonly top?: TopNode + readonly returning?: ReturningNode readonly output?: OutputNode readonly endModifiers?: ReadonlyArray } diff --git a/src/operation-node/operation-node-transformer.ts b/src/operation-node/operation-node-transformer.ts index b5cadff5f..1fcf5ea83 100644 --- a/src/operation-node/operation-node-transformer.ts +++ b/src/operation-node/operation-node-transformer.ts @@ -1039,6 +1039,7 @@ export class OperationNodeTransformer { top: this.transformNode(node.top), endModifiers: this.transformNodeList(node.endModifiers), output: this.transformNode(node.output), + returning: this.transformNode(node.returning), }) } diff --git a/src/query-builder/delete-query-builder.ts b/src/query-builder/delete-query-builder.ts index a0ece541a..069fbb7e4 100644 --- a/src/query-builder/delete-query-builder.ts +++ b/src/query-builder/delete-query-builder.ts @@ -41,7 +41,7 @@ import { QueryId } from '../util/query-id.js' import { freeze } from '../util/object-utils.js' import { KyselyPlugin } from '../plugin/kysely-plugin.js' import { WhereInterface } from './where-interface.js' -import { ReturningInterface } from './returning-interface.js' +import { MultiTableReturningInterface } from './returning-interface.js' import { isNoResultErrorConstructor, NoResultError, @@ -82,7 +82,7 @@ import { export class DeleteQueryBuilder implements WhereInterface, - ReturningInterface, + MultiTableReturningInterface, OutputInterface, OperationNodeSource, Compilable, diff --git a/src/query-builder/merge-query-builder.ts b/src/query-builder/merge-query-builder.ts index e4ea17841..b1b397bf4 100644 --- a/src/query-builder/merge-query-builder.ts +++ b/src/query-builder/merge-query-builder.ts @@ -22,8 +22,17 @@ import { } from '../parser/join-parser.js' import { parseMergeThen, parseMergeWhen } from '../parser/merge-parser.js' import { ReferenceExpression } from '../parser/reference-parser.js' -import { ReturningAllRow, ReturningRow } from '../parser/returning-parser.js' -import { parseSelectAll, parseSelectArg } from '../parser/select-parser.js' +import { + ReturningAllRow, + ReturningCallbackRow, + ReturningRow, +} from '../parser/returning-parser.js' +import { + parseSelectAll, + parseSelectArg, + SelectCallback, + SelectExpression, +} from '../parser/select-parser.js' import { TableExpression } from '../parser/table-parser.js' import { parseTop } from '../parser/top-parser.js' import { @@ -58,10 +67,13 @@ import { SelectExpressionFromOutputCallback, SelectExpressionFromOutputExpression, } from './output-interface.js' +import { MultiTableReturningInterface } from './returning-interface.js' import { UpdateQueryBuilder } from './update-query-builder.js' export class MergeQueryBuilder - implements OutputInterface + implements + MultiTableReturningInterface, + OutputInterface { readonly #props: MergeQueryBuilderProps @@ -215,6 +227,44 @@ export class MergeQueryBuilder }) } + returning>( + selections: ReadonlyArray, + ): MergeQueryBuilder> + + returning>( + callback: CB, + ): MergeQueryBuilder> + + returning>( + selection: SE, + ): MergeQueryBuilder> + + returning(args: any): any { + return new MergeQueryBuilder({ + ...this.#props, + queryNode: QueryNode.cloneWithReturning( + this.#props.queryNode, + parseSelectArg(args), + ), + }) + } + + returningAll( + table: T, + ): MergeQueryBuilder> + + returningAll(): MergeQueryBuilder> + + returningAll(table?: any): any { + return new MergeQueryBuilder({ + ...this.#props, + queryNode: QueryNode.cloneWithReturning( + this.#props.queryNode, + parseSelectAll(table), + ), + }) + } + output>( selections: readonly OE[], ): MergeQueryBuilder< @@ -274,7 +324,11 @@ export class WheneableMergeQueryBuilder< ST extends keyof DB, O, > - implements Compilable, OutputInterface, OperationNodeSource + implements + Compilable, + MultiTableReturningInterface, + OutputInterface, + OperationNodeSource { readonly #props: MergeQueryBuilderProps @@ -608,6 +662,54 @@ export class WheneableMergeQueryBuilder< return this.#whenNotMatched([lhs, op, rhs], true, true) } + returning>( + selections: ReadonlyArray, + ): WheneableMergeQueryBuilder> + + returning>( + callback: CB, + ): WheneableMergeQueryBuilder< + DB, + TT, + ST, + ReturningCallbackRow + > + + returning>( + selection: SE, + ): WheneableMergeQueryBuilder> + + returning(args: any): any { + return new WheneableMergeQueryBuilder({ + ...this.#props, + queryNode: QueryNode.cloneWithReturning( + this.#props.queryNode, + parseSelectArg(args), + ), + }) + } + + returningAll( + table: T, + ): WheneableMergeQueryBuilder> + + returningAll(): WheneableMergeQueryBuilder< + DB, + TT, + ST, + ReturningAllRow + > + + returningAll(table?: any): any { + return new WheneableMergeQueryBuilder({ + ...this.#props, + queryNode: QueryNode.cloneWithReturning( + this.#props.queryNode, + parseSelectAll(table), + ), + }) + } + output>( selections: readonly OE[], ): WheneableMergeQueryBuilder< @@ -788,9 +890,12 @@ export class WheneableMergeQueryBuilder< this.#props.queryId, ) + const { adapter } = this.#props.executor + const query = compiledQuery.query as MergeQueryNode + if ( - (compiledQuery.query as MergeQueryNode).output && - this.#props.executor.adapter.supportsOutput + (query.returning && adapter.supportsReturning) || + (query.output && adapter.supportsOutput) ) { return result.rows as any } diff --git a/src/query-builder/returning-interface.ts b/src/query-builder/returning-interface.ts index 8803677f7..1a7a7f0c3 100644 --- a/src/query-builder/returning-interface.ts +++ b/src/query-builder/returning-interface.ts @@ -1,4 +1,5 @@ import { + ReturningAllRow, ReturningCallbackRow, ReturningRow, } from '../parser/returning-parser.js' @@ -10,7 +11,7 @@ export interface ReturningInterface { * Allows you to return data from modified rows. * * On supported databases like PostgreSQL, this method can be chained to - * `insert`, `update` and `delete` queries to return data. + * `insert`, `update`, `delete` and `merge` queries to return data. * * Note that on SQLite you need to give aliases for the expressions to avoid * [this bug](https://sqlite.org/forum/forumpost/033daf0b32) in SQLite. @@ -78,10 +79,29 @@ export interface ReturningInterface { ): ReturningInterface> /** - * Adds a `returning *` to an insert/update/delete query on databases + * Adds a `returning *` to an insert/update/delete/merge query on databases * that support `returning` such as PostgreSQL. * * Also see the {@link returning} method. */ returningAll(): ReturningInterface> } + +export interface MultiTableReturningInterface + extends ReturningInterface { + /** + * Adds a `returning *` or `returning table.*` to an insert/update/delete/merge + * query on databases that support `returning` such as PostgreSQL. + * + * Also see the {@link returning} method. + */ + returningAll( + tables: ReadonlyArray, + ): MultiTableReturningInterface> + + returningAll( + table: T, + ): MultiTableReturningInterface> + + returningAll(): ReturningInterface> +} diff --git a/src/query-builder/update-query-builder.ts b/src/query-builder/update-query-builder.ts index dba84a5a3..d35615cad 100644 --- a/src/query-builder/update-query-builder.ts +++ b/src/query-builder/update-query-builder.ts @@ -48,7 +48,7 @@ import { freeze } from '../util/object-utils.js' import { UpdateResult } from './update-result.js' import { KyselyPlugin } from '../plugin/kysely-plugin.js' import { WhereInterface } from './where-interface.js' -import { ReturningInterface } from './returning-interface.js' +import { MultiTableReturningInterface } from './returning-interface.js' import { isNoResultErrorConstructor, NoResultError, @@ -83,7 +83,7 @@ import { export class UpdateQueryBuilder implements WhereInterface, - ReturningInterface, + MultiTableReturningInterface, OutputInterface, OperationNodeSource, Compilable, diff --git a/src/query-compiler/default-query-compiler.ts b/src/query-compiler/default-query-compiler.ts index 5a04313d6..c0267e1e2 100644 --- a/src/query-compiler/default-query-compiler.ts +++ b/src/query-compiler/default-query-compiler.ts @@ -1585,6 +1585,11 @@ export class DefaultQueryCompiler this.compileList(node.whens, ' ') } + if (node.returning) { + this.append(' ') + this.visitNode(node.returning) + } + if (node.output) { this.append(' ') this.visitNode(node.output) diff --git a/test/node/src/merge.test.ts b/test/node/src/merge.test.ts index cc4767e58..136385079 100644 --- a/test/node/src/merge.test.ts +++ b/test/node/src/merge.test.ts @@ -1,4 +1,5 @@ import { MergeResult, sql } from '../../..' +import { mergeAction } from '../../../helpers/postgres' import { DIALECTS, NOT_SUPPORTED, @@ -1013,6 +1014,206 @@ for (const dialect of DIALECTS.filter( }) }) + if (dialect === 'postgres') { + it('should perform a merge...using table simple on...when matched then delete returning id query', async () => { + const expected = await ctx.db.selectFrom('pet').select('id').execute() + + const query = ctx.db + .mergeInto('pet') + .using('person', 'pet.owner_id', 'person.id') + .whenMatched() + .thenDelete() + .returning('pet.id') + + testSql(query, dialect, { + postgres: { + sql: 'merge into "pet" using "person" on "pet"."owner_id" = "person"."id" when matched then delete returning "pet"."id"', + parameters: [], + }, + mysql: NOT_SUPPORTED, + mssql: NOT_SUPPORTED, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.execute() + + expect(result).to.eql(expected) + }) + + it('should perform a merge...using table simple on...when matched then update set name returning {target}.name, {source}.first_name query', async () => { + const query = ctx.db + .mergeInto('pet') + .using('person', 'pet.owner_id', 'person.id') + .whenMatched() + .thenUpdateSet((eb) => ({ + name: sql`${eb.ref('person.first_name')} || '''s pet'`, + })) + .returning([ + 'pet.name as pet_name', + 'person.first_name as owner_name', + ]) + + testSql(query, dialect, { + postgres: { + sql: 'merge into "pet" using "person" on "pet"."owner_id" = "person"."id" when matched then update set "name" = "person"."first_name" || \'\'\'s pet\' returning "pet"."name" as "pet_name", "person"."first_name" as "owner_name"', + parameters: [], + }, + mysql: NOT_SUPPORTED, + mssql: NOT_SUPPORTED, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.execute() + + expect(result).to.eql([ + { owner_name: 'Jennifer', pet_name: "Jennifer's pet" }, + { owner_name: 'Arnold', pet_name: "Arnold's pet" }, + { owner_name: 'Sylvester', pet_name: "Sylvester's pet" }, + ]) + }) + + it('should perform a merge...using table simple on...when matched then delete returning * query', async () => { + const expected = await ctx.db + .selectFrom('pet') + .innerJoin('person', 'pet.owner_id', 'person.id') + .selectAll() + .execute() + + const query = ctx.db + .mergeInto('pet') + .using('person', 'pet.owner_id', 'person.id') + .whenMatched() + .thenDelete() + .returningAll() + + testSql(query, dialect, { + postgres: { + sql: 'merge into "pet" using "person" on "pet"."owner_id" = "person"."id" when matched then delete returning *', + parameters: [], + }, + mysql: NOT_SUPPORTED, + mssql: NOT_SUPPORTED, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.execute() + + expect(result).to.eql(expected) + }) + + it('should perform a merge...using table simple on...when matched then delete returning {target}.* query', async () => { + const expected = await ctx.db.selectFrom('pet').selectAll().execute() + + const query = ctx.db + .mergeInto('pet') + .using('person', 'pet.owner_id', 'person.id') + .whenMatched() + .thenDelete() + .returningAll('pet') + + testSql(query, dialect, { + postgres: { + sql: 'merge into "pet" using "person" on "pet"."owner_id" = "person"."id" when matched then delete returning "pet".*', + parameters: [], + }, + mysql: NOT_SUPPORTED, + mssql: NOT_SUPPORTED, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.execute() + + expect(result).to.eql(expected) + }) + + it('should perform a merge...using table simple on...when matched then delete returning {source}.* query', async () => { + const expected = await ctx.db + .selectFrom('pet') + .innerJoin('person', 'pet.owner_id', 'person.id') + .selectAll('person') + .execute() + + const query = ctx.db + .mergeInto('pet') + .using('person', 'pet.owner_id', 'person.id') + .whenMatched() + .thenDelete() + .returningAll('person') + + testSql(query, dialect, { + postgres: { + sql: 'merge into "pet" using "person" on "pet"."owner_id" = "person"."id" when matched then delete returning "person".*', + parameters: [], + }, + mysql: NOT_SUPPORTED, + mssql: NOT_SUPPORTED, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.execute() + + expect(result).to.eql(expected) + }) + + it('should perform a merge...using table simple on...when matched then delete returning merge_action(), {target}.name', async () => { + await ctx.db.connection().execute(async (db) => { + await ctx.db + .insertInto('person') + .values({ first_name: 'Moshe', gender: 'other' }) + .execute() + + await sql`SET session_replication_role = 'replica'`.execute(db) + await db + .insertInto('pet') + .values({ + name: 'Ralph', + owner_id: 9999, + species: 'hamster', + }) + .execute() + await sql`SET session_replication_role = 'origin'`.execute(db) + }) + + const query = ctx.db + .mergeInto('pet') + .using('person', 'pet.owner_id', 'person.id') + .whenMatched() + .thenUpdateSet( + 'name', + (eb) => sql`${eb.ref('person.first_name')} || '''s pet'`, + ) + .whenNotMatched() + .thenInsertValues((eb) => ({ + name: sql`${eb.ref('person.first_name')} || '''s pet'`, + owner_id: eb.ref('person.id'), + species: 'hamster', + })) + .whenNotMatchedBySource() + .thenDelete() + .returning([mergeAction().as('action'), 'pet.name']) + + testSql(query, dialect, { + postgres: { + sql: 'merge into "pet" using "person" on "pet"."owner_id" = "person"."id" when matched then update set "name" = "person"."first_name" || \'\'\'s pet\' when not matched then insert ("name", "owner_id", "species") values ("person"."first_name" || \'\'\'s pet\', "person"."id", $1) when not matched by source then delete returning merge_action() as "action", "pet"."name"', + parameters: ['hamster'], + }, + mysql: NOT_SUPPORTED, + mssql: NOT_SUPPORTED, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.execute() + + expect(result).to.eql([ + { action: 'UPDATE', name: "Jennifer's pet" }, + { action: 'UPDATE', name: "Arnold's pet" }, + { action: 'UPDATE', name: "Sylvester's pet" }, + { action: 'DELETE', name: 'Ralph' }, + { action: 'INSERT', name: "Moshe's pet" }, + ]) + }) + } + if (dialect === 'mssql') { it('should perform a merge top...using table simple on...when matched then delete query', async () => { const query = ctx.db diff --git a/test/typings/test-d/merge.test-d.ts b/test/typings/test-d/merge.test-d.ts index bff938a03..3a86030e3 100644 --- a/test/typings/test-d/merge.test-d.ts +++ b/test/typings/test-d/merge.test-d.ts @@ -7,12 +7,14 @@ import { MergeQueryBuilder, MergeResult, NotMatchedThenableMergeQueryBuilder, + SelectType, Selectable, UpdateQueryBuilder, WheneableMergeQueryBuilder, + mergeAction, sql, } from '..' -import { Database, Person } from '../shared' +import { Database, Person, Pet } from '../shared' async function testMergeInto(db: Kysely) { db.mergeInto('person') @@ -422,35 +424,105 @@ async function testThenInsert( ) } -async function testOutput(db: Kysely) { - // One returning expression - const r1 = await db - .mergeInto('person') - .using('pet', 'pet.owner_id', 'person.id') - .whenMatched() - .thenDelete() - .output('deleted.id') - .executeTakeFirst() +async function testReturning( + baseQuery: WheneableMergeQueryBuilder, +) { + // One returning expression, target table + const r1 = await baseQuery.returning('person.id').execute() + + expectType<{ id: number }[]>(r1) - expectType<{ id: number } | undefined>(r1) + // One returning expression, source table + const r2 = await baseQuery.returning('pet.name').execute() + + expectType<{ name: string }[]>(r2) // Multiple returning expressions - const r2 = await db - .mergeInto('person') - .using('pet', 'pet.owner_id', 'person.id') - .whenMatched() - .thenDelete() - .output(['deleted.id', 'deleted.first_name as fn']) + const r3 = await baseQuery + .returning(['person.id', 'pet.name as pet_name']) .execute() - expectType<{ id: number; fn: string }[]>(r2) + expectType<{ id: number; pet_name: string }[]>(r3) // Non-column reference returning expressions - const r3 = await db - .mergeInto('person') - .using('pet', 'pet.owner_id', 'person.id') - .whenMatched() - .thenUpdateSet('age', (eb) => eb(eb.ref('age'), '+', 20)) + const r4 = await baseQuery + .returning([ + 'person.age', + sql`concat(person.first_name, ' ', person.last_name)`.as( + 'full_name', + ), + ]) + .execute() + + expectType<{ age: number; full_name: string }[]>(r4) + + // Return all columns + const r5 = await baseQuery.returningAll().executeTakeFirstOrThrow() + + expectType<{ + [K in keyof Person | keyof Pet]: + | (K extends keyof Person ? SelectType : never) + | (K extends keyof Pet ? SelectType : never) + }>(r5) + + // Return all target columns + const r6 = await baseQuery.returningAll('person').executeTakeFirstOrThrow() + + expectType>(r6) + + // Return all source columns + const r7 = await baseQuery.returningAll('pet').executeTakeFirstOrThrow() + + expectType>(r7) + + // Return single merge_action + const r8 = await baseQuery.returning(mergeAction().as('action')).execute() + + expectType<{ action: 'INSERT' | 'UPDATE' | 'DELETE' }[]>(r8) + + // Return multi merge_action + const r9 = await baseQuery + .returning([mergeAction().as('action'), 'person.id']) + .execute() + + expectType<{ action: 'INSERT' | 'UPDATE' | 'DELETE'; id: number }[]>(r9) + + // Non-existent column + expectError(baseQuery.returning('not_column')) + expectError(baseQuery.returning('person.not_column')) + expectError(baseQuery.returning('pet.not_column')) + + // Non-existent prefix + expectError(baseQuery.returning('foo.age')) + expectError(baseQuery.returningAll('foo')) + + // unaliased merge_action + expectError(baseQuery.returning(mergeAction()).execute()) + expectError(baseQuery.returning([mergeAction(), 'person.id']).execute()) +} + +async function testOutput( + baseQuery: WheneableMergeQueryBuilder, +) { + // One returning expression, deleted values + const r1 = await baseQuery.output('deleted.id').execute() + + expectType<{ id: number }[]>(r1) + + // One returning expression, inserted values + const r2 = await baseQuery.output('inserted.id').execute() + + expectType<{ id: number }[]>(r2) + + // Multiple returning expressions + const r3 = await baseQuery + .output(['deleted.id', 'inserted.first_name as fn']) + .execute() + + expectType<{ id: number; fn: string }[]>(r3) + + // Non-column reference returning expressions + const r4 = await baseQuery .output([ 'inserted.age', sql`concat(deleted.first_name, ' ', deleted.last_name)`.as( @@ -459,66 +531,21 @@ async function testOutput(db: Kysely) { ]) .execute() - expectType<{ age: number; full_name: string }[]>(r3) + expectType<{ age: number; full_name: string }[]>(r4) // Return all columns - const r4 = await db - .mergeInto('person') - .using('pet', 'person.id', 'pet.owner_id') - .whenNotMatched() - .thenInsertValues({ - gender: 'female', - age: 15, - first_name: 'Jane', - }) - .outputAll('inserted') - .executeTakeFirstOrThrow() - - expectType>(r4) + const r5 = await baseQuery.outputAll('inserted').executeTakeFirstOrThrow() + + expectType>(r5) // Non-existent column - expectError( - db - .mergeInto('person') - .using('pet', 'pet.owner_id', 'person.id') - .whenMatched() - .thenDelete() - .output('inserted.not_column'), - ) + expectError(baseQuery.output('inserted.not_column')) // Without prefix - expectError( - db - .mergeInto('person') - .using('pet', 'pet.owner_id', 'person.id') - .whenMatched() - .thenDelete() - .output('age'), - ) - expectError( - db - .mergeInto('person') - .using('pet', 'pet.owner_id', 'person.id') - .whenMatched() - .thenDelete() - .outputAll(), - ) + expectError(baseQuery.output('age')) + expectError(baseQuery.outputAll()) // Non-existent prefix - expectError( - db - .mergeInto('person') - .using('pet', 'pet.owner_id', 'person.id') - .whenMatched() - .thenDelete() - .output('foo.age'), - ) - expectError( - db - .mergeInto('person') - .using('pet', 'pet.owner_id', 'person.id') - .whenMatched() - .thenDelete() - .outputAll('foo'), - ) + expectError(baseQuery.output('foo.age')) + expectError(baseQuery.outputAll('foo')) }