From 1f28e506cf5ba224c7ff34892ed18fb28f2446df Mon Sep 17 00:00:00 2001 From: vincentiusvin <54709710+vincentiusvin@users.noreply.github.com> Date: Mon, 6 Jan 2025 09:08:36 +0700 Subject: [PATCH] SQLite's OR CONFLICT clause for inserts (#976) Co-authored-by: vincentiusvin <54709710+vincentiusvin@users.noreply.github.com> Co-authored-by: Igal Klebanov --- src/dialect/sqlite/sqlite-query-compiler.ts | 6 + src/index.ts | 1 + src/operation-node/insert-query-node.ts | 4 + .../operation-node-transformer.ts | 8 + src/operation-node/operation-node-visitor.ts | 3 + src/operation-node/operation-node.ts | 1 + src/operation-node/or-action-node.ts | 23 ++ src/query-builder/insert-query-builder.ts | 206 +++++++++- src/query-compiler/default-query-compiler.ts | 15 + src/query-creator.ts | 18 +- test/node/src/insert.test.ts | 49 ++- test/node/src/replace.test.ts | 362 +++++++++--------- 12 files changed, 509 insertions(+), 187 deletions(-) create mode 100644 src/operation-node/or-action-node.ts diff --git a/src/dialect/sqlite/sqlite-query-compiler.ts b/src/dialect/sqlite/sqlite-query-compiler.ts index 0fb97399a..5272842af 100644 --- a/src/dialect/sqlite/sqlite-query-compiler.ts +++ b/src/dialect/sqlite/sqlite-query-compiler.ts @@ -1,9 +1,15 @@ import { DefaultInsertValueNode } from '../../operation-node/default-insert-value-node.js' +import { OrActionNode } from '../../operation-node/or-action-node.js' import { DefaultQueryCompiler } from '../../query-compiler/default-query-compiler.js' const ID_WRAP_REGEX = /"/g export class SqliteQueryCompiler extends DefaultQueryCompiler { + protected override visitOrAction(node: OrActionNode): void { + this.append('or ') + this.append(node.action) + } + protected override getCurrentParameterPlaceholder() { return '?' } diff --git a/src/index.ts b/src/index.ts index 6ff0e3a80..d95d35cc7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -178,6 +178,7 @@ export * from './operation-node/operation-node-transformer.js' export * from './operation-node/operation-node-visitor.js' export * from './operation-node/operation-node.js' export * from './operation-node/operator-node.js' +export * from './operation-node/or-action-node.js' export * from './operation-node/or-node.js' export * from './operation-node/order-by-item-node.js' export * from './operation-node/order-by-node.js' diff --git a/src/operation-node/insert-query-node.ts b/src/operation-node/insert-query-node.ts index a079f2e15..e0d455e0b 100644 --- a/src/operation-node/insert-query-node.ts +++ b/src/operation-node/insert-query-node.ts @@ -4,6 +4,7 @@ import { ExplainNode } from './explain-node.js' import { OnConflictNode } from './on-conflict-node.js' import { OnDuplicateKeyNode } from './on-duplicate-key-node.js' import { OperationNode } from './operation-node.js' +import { OrActionNode } from './or-action-node.js' import { OutputNode } from './output-node.js' import { ReturningNode } from './returning-node.js' import { TableNode } from './table-node.js' @@ -21,7 +22,10 @@ export interface InsertQueryNode extends OperationNode { readonly onConflict?: OnConflictNode readonly onDuplicateKey?: OnDuplicateKeyNode readonly with?: WithNode + // TODO: remove in 0.29 + /** @deprecated use {@link orAction} instead. */ readonly ignore?: boolean + readonly orAction?: OrActionNode readonly replace?: boolean readonly explain?: ExplainNode readonly defaultValues?: boolean diff --git a/src/operation-node/operation-node-transformer.ts b/src/operation-node/operation-node-transformer.ts index 1fcf5ea83..a1ce5584f 100644 --- a/src/operation-node/operation-node-transformer.ts +++ b/src/operation-node/operation-node-transformer.ts @@ -95,6 +95,7 @@ import { FetchNode } from './fetch-node.js' import { TopNode } from './top-node.js' import { OutputNode } from './output-node.js' import { RefreshMaterializedViewNode } from './refresh-materialized-view-node.js' +import { OrActionNode } from './or-action-node.js' /** * Transforms an operation node tree into another one. @@ -231,6 +232,7 @@ export class OperationNodeTransformer { FetchNode: this.transformFetch.bind(this), TopNode: this.transformTop.bind(this), OutputNode: this.transformOutput.bind(this), + OrActionNode: this.transformOrAction.bind(this), }) transformNode(node: T): T { @@ -392,6 +394,7 @@ export class OperationNodeTransformer { endModifiers: this.transformNodeList(node.endModifiers), with: this.transformNode(node.with), ignore: node.ignore, + orAction: this.transformNode(node.orAction), replace: node.replace, explain: this.transformNode(node.explain), defaultValues: node.defaultValues, @@ -1131,4 +1134,9 @@ export class OperationNodeTransformer { // An Object.freezed leaf node. No need to clone. return node } + + protected transformOrAction(node: OrActionNode): OrActionNode { + // An Object.freezed leaf node. No need to clone. + return node + } } diff --git a/src/operation-node/operation-node-visitor.ts b/src/operation-node/operation-node-visitor.ts index 533f58098..70d308ec0 100644 --- a/src/operation-node/operation-node-visitor.ts +++ b/src/operation-node/operation-node-visitor.ts @@ -97,6 +97,7 @@ import { FetchNode } from './fetch-node.js' import { TopNode } from './top-node.js' import { OutputNode } from './output-node.js' import { RefreshMaterializedViewNode } from './refresh-materialized-view-node.js' +import { OrActionNode } from './or-action-node.js' export abstract class OperationNodeVisitor { protected readonly nodeStack: OperationNode[] = [] @@ -201,6 +202,7 @@ export abstract class OperationNodeVisitor { FetchNode: this.visitFetch.bind(this), TopNode: this.visitTop.bind(this), OutputNode: this.visitOutput.bind(this), + OrActionNode: this.visitOrAction.bind(this), }) protected readonly visitNode = (node: OperationNode): void => { @@ -315,4 +317,5 @@ export abstract class OperationNodeVisitor { protected abstract visitFetch(node: FetchNode): void protected abstract visitTop(node: TopNode): void protected abstract visitOutput(node: OutputNode): void + protected abstract visitOrAction(node: OrActionNode): void } diff --git a/src/operation-node/operation-node.ts b/src/operation-node/operation-node.ts index 7786b7ec1..a893a3951 100644 --- a/src/operation-node/operation-node.ts +++ b/src/operation-node/operation-node.ts @@ -93,6 +93,7 @@ export type OperationNodeKind = | 'FetchNode' | 'TopNode' | 'OutputNode' + | 'OrActionNode' export interface OperationNode { readonly kind: OperationNodeKind diff --git a/src/operation-node/or-action-node.ts b/src/operation-node/or-action-node.ts new file mode 100644 index 000000000..eaad93705 --- /dev/null +++ b/src/operation-node/or-action-node.ts @@ -0,0 +1,23 @@ +import { freeze } from '../util/object-utils.js' +import { OperationNode } from './operation-node.js' + +export interface OrActionNode extends OperationNode { + readonly kind: 'OrActionNode' + readonly action: string +} + +/** + * @internal + */ +export const OrActionNode = freeze({ + is(node: OperationNode): node is OrActionNode { + return node.kind === 'OrActionNode' + }, + + create(action: string): OrActionNode { + return freeze({ + kind: 'OrActionNode', + action, + }) + }, +}) diff --git a/src/query-builder/insert-query-builder.ts b/src/query-builder/insert-query-builder.ts index 2d6b481c1..c8ebfbc66 100644 --- a/src/query-builder/insert-query-builder.ts +++ b/src/query-builder/insert-query-builder.ts @@ -66,6 +66,7 @@ import { SelectExpressionFromOutputCallback, SelectExpressionFromOutputExpression, } from './output-interface.js' +import { OrActionNode } from '../operation-node/or-action-node.js' export class InsertQueryBuilder implements @@ -412,15 +413,18 @@ export class InsertQueryBuilder /** * Changes an `insert into` query to an `insert ignore into` query. * + * This is only supported by some dialects like MySQL. + * + * To avoid a footgun, when invoked with the SQLite dialect, this method will + * be handled like {@link orIgnore}. See also, {@link orAbort}, {@link orFail}, + * {@link orReplace}, and {@link orRollback}. + * * If you use the ignore modifier, ignorable errors that occur while executing the * insert statement are ignored. For example, without ignore, a row that duplicates * an existing unique index or primary key value in the table causes a duplicate-key * error and the statement is aborted. With ignore, the row is discarded and no error * occurs. * - * This is only supported on some dialects like MySQL. On most dialects you should - * use the {@link onConflict} method. - * * ### Examples * * ```ts @@ -437,14 +441,206 @@ export class InsertQueryBuilder * The generated SQL (MySQL): * * ```sql - * insert ignore into `person` ("first_name", "last_name", "gender") values (?, ?, ?) + * insert ignore into `person` (`first_name`, `last_name`, `gender`) values (?, ?, ?) + * ``` + * + * The generated SQL (SQLite): + * + * ```sql + * insert or ignore into "person" ("first_name", "last_name", "gender") values (?, ?, ?) * ``` */ ignore(): InsertQueryBuilder { return new InsertQueryBuilder({ ...this.#props, queryNode: InsertQueryNode.cloneWith(this.#props.queryNode, { - ignore: true, + orAction: OrActionNode.create('ignore'), + }), + }) + } + + /** + * Changes an `insert into` query to an `insert or ignore into` query. + * + * This is only supported by some dialects like SQLite. + * + * To avoid a footgun, when invoked with the MySQL dialect, this method will + * be handled like {@link ignore}. + * + * See also, {@link orAbort}, {@link orFail}, {@link orReplace}, and {@link orRollback}. + * + * ### Examples + * + * ```ts + * await db.insertInto('person') + * .orIgnore() + * .values({ + * first_name: 'John', + * last_name: 'Doe', + * gender: 'female', + * }) + * .execute() + * ``` + * + * The generated SQL (SQLite): + * + * ```sql + * insert or ignore into "person" ("first_name", "last_name", "gender") values (?, ?, ?) + * ``` + * + * The generated SQL (MySQL): + * + * ```sql + * insert ignore into `person` (`first_name`, `last_name`, `gender`) values (?, ?, ?) + * ``` + */ + orIgnore(): InsertQueryBuilder { + return new InsertQueryBuilder({ + ...this.#props, + queryNode: InsertQueryNode.cloneWith(this.#props.queryNode, { + orAction: OrActionNode.create('ignore'), + }), + }) + } + + /** + * Changes an `insert into` query to an `insert or abort into` query. + * + * This is only supported by some dialects like SQLite. + * + * See also, {@link orIgnore}, {@link orFail}, {@link orReplace}, and {@link orRollback}. + * + * ### Examples + * + * ```ts + * await db.insertInto('person') + * .orAbort() + * .values({ + * first_name: 'John', + * last_name: 'Doe', + * gender: 'female', + * }) + * .execute() + * ``` + * + * The generated SQL (SQLite): + * + * ```sql + * insert or abort into "person" ("first_name", "last_name", "gender") values (?, ?, ?) + * ``` + */ + orAbort(): InsertQueryBuilder { + return new InsertQueryBuilder({ + ...this.#props, + queryNode: InsertQueryNode.cloneWith(this.#props.queryNode, { + orAction: OrActionNode.create('abort'), + }), + }) + } + + /** + * Changes an `insert into` query to an `insert or fail into` query. + * + * This is only supported by some dialects like SQLite. + * + * See also, {@link orIgnore}, {@link orAbort}, {@link orReplace}, and {@link orRollback}. + * + * ### Examples + * + * ```ts + * await db.insertInto('person') + * .orFail() + * .values({ + * first_name: 'John', + * last_name: 'Doe', + * gender: 'female', + * }) + * .execute() + * ``` + * + * The generated SQL (SQLite): + * + * ```sql + * insert or fail into "person" ("first_name", "last_name", "gender") values (?, ?, ?) + * ``` + */ + orFail(): InsertQueryBuilder { + return new InsertQueryBuilder({ + ...this.#props, + queryNode: InsertQueryNode.cloneWith(this.#props.queryNode, { + orAction: OrActionNode.create('fail'), + }), + }) + } + + /** + * Changes an `insert into` query to an `insert or replace into` query. + * + * This is only supported by some dialects like SQLite. + * + * You can also use {@link Kysely.replaceInto} to achieve the same result. + * + * See also, {@link orIgnore}, {@link orAbort}, {@link orFail}, and {@link orRollback}. + * + * ### Examples + * + * ```ts + * await db.insertInto('person') + * .orReplace() + * .values({ + * first_name: 'John', + * last_name: 'Doe', + * gender: 'female', + * }) + * .execute() + * ``` + * + * The generated SQL (SQLite): + * + * ```sql + * insert or replace into "person" ("first_name", "last_name", "gender") values (?, ?, ?) + * ``` + */ + orReplace(): InsertQueryBuilder { + return new InsertQueryBuilder({ + ...this.#props, + queryNode: InsertQueryNode.cloneWith(this.#props.queryNode, { + orAction: OrActionNode.create('replace'), + }), + }) + } + + /** + * Changes an `insert into` query to an `insert or rollback into` query. + * + * This is only supported by some dialects like SQLite. + * + * See also, {@link orIgnore}, {@link orAbort}, {@link orFail}, and {@link orReplace}. + * + * ### Examples + * + * ```ts + * await db.insertInto('person') + * .orRollback() + * .values({ + * first_name: 'John', + * last_name: 'Doe', + * gender: 'female', + * }) + * .execute() + * ``` + * + * The generated SQL (SQLite): + * + * ```sql + * insert or rollback into "person" ("first_name", "last_name", "gender") values (?, ?, ?) + * ``` + */ + orRollback(): InsertQueryBuilder { + return new InsertQueryBuilder({ + ...this.#props, + queryNode: InsertQueryNode.cloneWith(this.#props.queryNode, { + orAction: OrActionNode.create('rollback'), }), }) } diff --git a/src/query-compiler/default-query-compiler.ts b/src/query-compiler/default-query-compiler.ts index c0267e1e2..559ed112b 100644 --- a/src/query-compiler/default-query-compiler.ts +++ b/src/query-compiler/default-query-compiler.ts @@ -112,6 +112,8 @@ import { FetchNode } from '../operation-node/fetch-node.js' import { TopNode } from '../operation-node/top-node.js' import { OutputNode } from '../operation-node/output-node.js' import { RefreshMaterializedViewNode } from '../operation-node/refresh-materialized-view-node.js' +import { OrActionNode } from '../operation-node/or-action-node.js' +import { logOnce } from '../util/log-once.js' export class DefaultQueryCompiler extends OperationNodeVisitor @@ -311,10 +313,19 @@ export class DefaultQueryCompiler this.append(node.replace ? 'replace' : 'insert') + // TODO: remove in 0.29. if (node.ignore) { + logOnce( + '`InsertQueryNode.ignore` is deprecated. Use `InsertQueryNode.orAction` instead.', + ) this.append(' ignore') } + if (node.orAction) { + this.append(' ') + this.visitNode(node.orAction) + } + if (node.top) { this.append(' ') this.visitNode(node.top) @@ -1663,6 +1674,10 @@ export class DefaultQueryCompiler } } + protected override visitOrAction(node: OrActionNode): void { + this.append(node.action) + } + protected append(str: string): void { this.#sql += str } diff --git a/src/query-creator.ts b/src/query-creator.ts index 1b403a715..a2b09c41a 100644 --- a/src/query-creator.ts +++ b/src/query-creator.ts @@ -298,10 +298,14 @@ export class QueryCreator { } /** - * Creates a replace query. + * Creates a "replace into" query. * - * A MySQL-only statement similar to {@link InsertQueryBuilder.onDuplicateKeyUpdate} - * that deletes and inserts values on collision instead of updating existing rows. + * This is only supported by some dialects like MySQL or SQLite. + * + * Similar to MySQL's {@link InsertQueryBuilder.onDuplicateKeyUpdate} that deletes + * and inserts values on collision instead of updating existing rows. + * + * An alias of SQLite's {@link InsertQueryBuilder.orReplace}. * * The return value of this query is an instance of {@link InsertResult}. {@link InsertResult} * has the {@link InsertResult.insertId | insertId} field that holds the auto incremented id of @@ -318,10 +322,16 @@ export class QueryCreator { * first_name: 'Jennifer', * last_name: 'Aniston' * }) - * .executeTakeFirst() + * .executeTakeFirstOrThrow() * * console.log(result.insertId) * ``` + * + * The generated SQL (MySQL): + * + * ```sql + * replace into `person` (`first_name`, `last_name`) values (?, ?) + * ``` */ replaceInto( table: T, diff --git a/test/node/src/insert.test.ts b/test/node/src/insert.test.ts index 2d8069784..73d1356b3 100644 --- a/test/node/src/insert.test.ts +++ b/test/node/src/insert.test.ts @@ -16,7 +16,7 @@ import { } from './test-setup.js' for (const dialect of DIALECTS) { - describe(`${dialect}: insert`, () => { + describe(`${dialect}: insert into`, () => { let ctx: TestContext before(async function () { @@ -317,7 +317,36 @@ for (const dialect of DIALECTS) { } }) - if (dialect === 'mysql') { + if (dialect === 'sqlite') { + for (const { method, action } of [ + { method: 'orAbort', action: 'abort' }, + { method: 'orFail', action: 'fail' }, + { method: 'orIgnore', action: 'ignore' }, + { method: 'orReplace', action: 'replace' }, + { method: 'orRollback', action: 'rollback' }, + ] as const) { + it(`should insert or ${action}`, async () => { + const query = ctx.db.insertInto('person')[method]().values({ + first_name: 'foo', + gender: 'other', + }) + + testSql(query, dialect, { + mysql: NOT_SUPPORTED, + postgres: NOT_SUPPORTED, + mssql: NOT_SUPPORTED, + sqlite: { + sql: `insert or ${action} into "person" ("first_name", "gender") values (?, ?)`, + parameters: ['foo', 'other'], + }, + }) + + await query.execute() + }) + } + } + + if (dialect === 'mysql' || dialect == 'sqlite') { it('should insert one row and ignore conflicts using insert ignore', async () => { const [{ id, ...existingPet }] = await ctx.db .selectFrom('pet') @@ -338,14 +367,26 @@ for (const dialect of DIALECTS) { }, postgres: NOT_SUPPORTED, mssql: NOT_SUPPORTED, - sqlite: NOT_SUPPORTED, + sqlite: { + sql: 'insert or ignore into "pet" ("name", "owner_id", "species") values (?, ?, ?)', + parameters: [ + existingPet.name, + existingPet.owner_id, + existingPet.species, + ], + }, }) const result = await query.executeTakeFirst() expect(result).to.be.instanceOf(InsertResult) - expect(result.insertId).to.be.undefined expect(result.numInsertedOrUpdatedRows).to.equal(0n) + if (dialect === 'sqlite') { + // SQLite seems to return the last inserted id even if nothing got inserted. + expect(result.insertId! > 0n).to.be.equal(true) + } else { + expect(result.insertId).to.be.undefined + } }) } diff --git a/test/node/src/replace.test.ts b/test/node/src/replace.test.ts index 9611e68c5..facfc1385 100644 --- a/test/node/src/replace.test.ts +++ b/test/node/src/replace.test.ts @@ -1,5 +1,4 @@ import { InsertResult, Kysely, sql } from '../../../' - import { clearDatabase, destroyTest, @@ -14,198 +13,213 @@ import { DIALECTS, } from './test-setup.js' -if (DIALECTS.includes('mysql')) { - const dialect = 'mysql' as const - - describe(`mysql: replace`, () => { - let ctx: TestContext - - before(async function () { - ctx = await initTest(this, dialect) - }) - - beforeEach(async () => { - await insertDefaultDataSet(ctx) - }) - - afterEach(async () => { - await clearDatabase(ctx) - }) - - after(async () => { - await destroyTest(ctx) - }) - - it('should insert one row', async () => { - const query = ctx.db.replaceInto('person').values({ - id: 15, - first_name: 'Foo', - last_name: 'Barson', - gender: 'other', - }) +for (const dialect of DIALECTS) { + if (dialect === 'mysql' || dialect === 'sqlite') { + describe(`${dialect}: replace into`, () => { + let ctx: TestContext - testSql(query, dialect, { - postgres: NOT_SUPPORTED, - mysql: { - sql: 'replace into `person` (`id`, `first_name`, `last_name`, `gender`) values (?, ?, ?, ?)', - parameters: [15, 'Foo', 'Barson', 'other'], - }, - mssql: NOT_SUPPORTED, - sqlite: NOT_SUPPORTED, + before(async function () { + ctx = await initTest(this, dialect) }) - const result = await query.executeTakeFirst() - expect(result).to.be.instanceOf(InsertResult) - - if (dialect === 'mysql') { - expect(result.insertId).to.be.a('bigint') - } else { - expect(result.insertId).to.equal(undefined) - } - - expect(await getNewestPerson(ctx.db)).to.eql({ - first_name: 'Foo', - last_name: 'Barson', - }) - }) - - it('should insert one row with complex values', async () => { - const query = ctx.db.replaceInto('person').values({ - id: 2500, - first_name: ctx.db - .selectFrom('pet') - .select(sql`max(name)`.as('max_name')), - last_name: sql`concat('Bar', 'son')`, - gender: 'other', + beforeEach(async () => { + await insertDefaultDataSet(ctx) }) - testSql(query, dialect, { - postgres: NOT_SUPPORTED, - mysql: { - sql: "replace into `person` (`id`, `first_name`, `last_name`, `gender`) values (?, (select max(name) as `max_name` from `pet`), concat('Bar', 'son'), ?)", - parameters: [2500, 'other'], - }, - mssql: NOT_SUPPORTED, - sqlite: NOT_SUPPORTED, + afterEach(async () => { + await clearDatabase(ctx) }) - const result = await query.executeTakeFirst() - expect(result).to.be.instanceOf(InsertResult) - - expect(await getNewestPerson(ctx.db)).to.eql({ - first_name: 'Hammo', - last_name: 'Barson', + after(async () => { + await destroyTest(ctx) }) - }) - it('should insert the result of a select query', async () => { - const query = ctx.db - .replaceInto('person') - .columns(['first_name', 'gender']) - .expression((eb) => - eb.selectFrom('pet').select(['name', sql`${'other'}`.as('gender')]), - ) + it('should insert one row', async () => { + const query = ctx.db.replaceInto('person').values({ + id: 15, + first_name: 'Foo', + last_name: 'Barson', + gender: 'other', + }) + + testSql(query, dialect, { + postgres: NOT_SUPPORTED, + mysql: { + sql: 'replace into `person` (`id`, `first_name`, `last_name`, `gender`) values (?, ?, ?, ?)', + parameters: [15, 'Foo', 'Barson', 'other'], + }, + mssql: NOT_SUPPORTED, + sqlite: { + sql: 'replace into "person" ("id", "first_name", "last_name", "gender") values (?, ?, ?, ?)', + parameters: [15, 'Foo', 'Barson', 'other'], + }, + }) + + const result = await query.executeTakeFirst() + expect(result).to.be.instanceOf(InsertResult) + expect(result.insertId).to.be.a('bigint') - testSql(query, dialect, { - postgres: NOT_SUPPORTED, - mysql: { - sql: 'replace into `person` (`first_name`, `gender`) select `name`, ? as `gender` from `pet`', - parameters: ['other'], - }, - mssql: NOT_SUPPORTED, - sqlite: NOT_SUPPORTED, + expect(await getNewestPerson(ctx.db)).to.eql({ + first_name: 'Foo', + last_name: 'Barson', + }) }) - await query.execute() - - const persons = await ctx.db - .selectFrom('person') - .select('first_name') - .orderBy('first_name') - .execute() - - expect(persons.map((it) => it.first_name)).to.eql([ - 'Arnold', - 'Catto', - 'Doggo', - 'Hammo', - 'Jennifer', - 'Sylvester', - ]) - }) - - it('undefined values should be ignored', async () => { - const query = ctx.db.replaceInto('person').values({ - id: 12, - gender: 'male', - middle_name: undefined, + it('should insert one row with complex values', async () => { + const query = ctx.db.replaceInto('person').values({ + id: 2500, + first_name: ctx.db + .selectFrom('pet') + .select(sql`max(name)`.as('max_name')), + last_name: sql`concat('Bar', 'son')`, + gender: 'other', + }) + + testSql(query, dialect, { + postgres: NOT_SUPPORTED, + mysql: { + sql: "replace into `person` (`id`, `first_name`, `last_name`, `gender`) values (?, (select max(name) as `max_name` from `pet`), concat('Bar', 'son'), ?)", + parameters: [2500, 'other'], + }, + mssql: NOT_SUPPORTED, + sqlite: { + sql: `replace into "person" ("id", "first_name", "last_name", "gender") values (?, (select max(name) as "max_name" from "pet"), concat('Bar', 'son'), ?)`, + parameters: [2500, 'other'], + }, + }) + + const result = await query.executeTakeFirst() + expect(result).to.be.instanceOf(InsertResult) + + expect(await getNewestPerson(ctx.db)).to.eql({ + first_name: 'Hammo', + last_name: 'Barson', + }) }) - testSql(query, dialect, { - postgres: NOT_SUPPORTED, - mysql: { - sql: 'replace into `person` (`id`, `gender`) values (?, ?)', - parameters: [12, 'male'], - }, - mssql: NOT_SUPPORTED, - sqlite: NOT_SUPPORTED, + it('should insert the result of a select query', async () => { + const query = ctx.db + .replaceInto('person') + .columns(['first_name', 'gender']) + .expression((eb) => + eb.selectFrom('pet').select(['name', sql`${'other'}`.as('gender')]), + ) + + testSql(query, dialect, { + postgres: NOT_SUPPORTED, + mysql: { + sql: 'replace into `person` (`first_name`, `gender`) select `name`, ? as `gender` from `pet`', + parameters: ['other'], + }, + mssql: NOT_SUPPORTED, + sqlite: { + sql: 'replace into "person" ("first_name", "gender") select "name", ? as "gender" from "pet"', + parameters: ['other'], + }, + }) + + await query.execute() + + const persons = await ctx.db + .selectFrom('person') + .select('first_name') + .orderBy('first_name') + .execute() + + expect(persons.map((it) => it.first_name)).to.eql([ + 'Arnold', + 'Catto', + 'Doggo', + 'Hammo', + 'Jennifer', + 'Sylvester', + ]) }) - await query.execute() - }) - - it('should replace on conflict', async () => { - const [existingPet] = await ctx.db - .selectFrom('pet') - .selectAll() - .limit(1) - .execute() - - const query = ctx.db - .replaceInto('pet') - .values({ ...existingPet, species: 'hamster' }) - - testSql(query, dialect, { - mysql: { - sql: 'replace into `pet` (`id`, `name`, `owner_id`, `species`) values (?, ?, ?, ?)', - parameters: [ - existingPet.id, - existingPet.name, - existingPet.owner_id, - 'hamster', - ], - }, - postgres: NOT_SUPPORTED, - mssql: NOT_SUPPORTED, - sqlite: NOT_SUPPORTED, + it('undefined values should be ignored', async () => { + const query = ctx.db.replaceInto('person').values({ + id: 12, + gender: 'male', + middle_name: undefined, + }) + + testSql(query, dialect, { + postgres: NOT_SUPPORTED, + mysql: { + sql: 'replace into `person` (`id`, `gender`) values (?, ?)', + parameters: [12, 'male'], + }, + mssql: NOT_SUPPORTED, + sqlite: { + sql: 'replace into "person" ("id", "gender") values (?, ?)', + parameters: [12, 'male'], + }, + }) + + await query.execute() }) - await query.execute() - - const updatedPet = await ctx.db - .selectFrom('pet') - .selectAll() - .where('id', '=', existingPet.id) - .executeTakeFirstOrThrow() - - expect(updatedPet).to.containSubset({ - name: 'Catto', - species: 'hamster', + it('should replace on conflict', async () => { + const [existingPet] = await ctx.db + .selectFrom('pet') + .selectAll() + .limit(1) + .execute() + + const query = ctx.db + .replaceInto('pet') + .values({ ...existingPet, species: 'hamster' }) + + testSql(query, dialect, { + mysql: { + sql: 'replace into `pet` (`id`, `name`, `owner_id`, `species`) values (?, ?, ?, ?)', + parameters: [ + existingPet.id, + existingPet.name, + existingPet.owner_id, + 'hamster', + ], + }, + postgres: NOT_SUPPORTED, + mssql: NOT_SUPPORTED, + sqlite: { + sql: 'replace into "pet" ("id", "name", "owner_id", "species") values (?, ?, ?, ?)', + parameters: [ + existingPet.id, + existingPet.name, + existingPet.owner_id, + 'hamster', + ], + }, + }) + + await query.execute() + + const updatedPet = await ctx.db + .selectFrom('pet') + .selectAll() + .where('id', '=', existingPet.id) + .executeTakeFirstOrThrow() + + expect(updatedPet).to.containSubset({ + name: 'Catto', + species: 'hamster', + }) }) }) - }) - - async function getNewestPerson( - db: Kysely, - ): Promise | undefined> { - return await db - .selectFrom('person') - .select(['first_name', 'last_name']) - .where( - 'id', - '=', - db.selectFrom('person').select(sql`max(id)`.as('max_id')), - ) - .executeTakeFirst() + + async function getNewestPerson( + db: Kysely, + ): Promise | undefined> { + return await db + .selectFrom('person') + .select(['first_name', 'last_name']) + .where( + 'id', + '=', + db.selectFrom('person').select(sql`max(id)`.as('max_id')), + ) + .executeTakeFirst() + } } }