diff --git a/package-lock.json b/package-lock.json index dd2c3d483..24b4185cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kysely", - "version": "0.27.0", + "version": "0.27.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kysely", - "version": "0.27.0", + "version": "0.27.2", "license": "MIT", "devDependencies": { "@types/better-sqlite3": "^7.6.4", @@ -23,8 +23,6 @@ "chai": "^4.3.7", "chai-as-promised": "^7.1.1", "chai-subset": "^1.6.0", - "concurrently": "^8.1.0", - "cross-env": "^7.0.3", "esbuild": "^0.17.19", "mocha": "^10.2.0", "mysql2": "^3.3.3", @@ -391,18 +389,6 @@ "node": ">=4" } }, - "node_modules/@babel/runtime": { - "version": "7.22.3", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.3.tgz", - "integrity": "sha512-XsDuspWKLUsxwCp6r7EhsExHtYfbe5oAGQ19kqngTdCPUoPQzOPdUbD/pB9PJiwb2ptYKQDjSJT3R6dC+EPqfQ==", - "dev": true, - "dependencies": { - "regenerator-runtime": "^0.13.11" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@esbuild/darwin-arm64": { "version": "0.17.19", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz", @@ -1110,20 +1096,6 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "dev": true }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1160,81 +1132,6 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, - "node_modules/concurrently": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.1.0.tgz", - "integrity": "sha512-0AB6eOAtaW/r/kX2lCdolaWtT191ICeuJjEJvI9hT3zbPFuZ/iZaJwMRKwbuwADome7OKxk73L7od+fsveZ7tA==", - "dev": true, - "dependencies": { - "chalk": "^4.1.2", - "date-fns": "^2.29.3", - "lodash": "^4.17.21", - "rxjs": "^7.8.0", - "shell-quote": "^1.8.0", - "spawn-command": "0.0.2-1", - "supports-color": "^8.1.1", - "tree-kill": "^1.2.2", - "yargs": "^17.7.1" - }, - "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" - }, - "engines": { - "node": "^14.13.0 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" - } - }, - "node_modules/cross-env": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", - "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.1" - }, - "bin": { - "cross-env": "src/bin/cross-env.js", - "cross-env-shell": "src/bin/cross-env-shell.js" - }, - "engines": { - "node": ">=10.14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/date-fns": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", - "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", - "dev": true, - "dependencies": { - "@babel/runtime": "^7.21.0" - }, - "engines": { - "node": ">=0.11" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/date-fns" - } - }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -2531,12 +2428,6 @@ "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", "dev": true }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, "node_modules/jest-diff": { "version": "29.5.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.5.0.tgz", @@ -3250,15 +3141,6 @@ "node": ">=0.10.0" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -3889,12 +3771,6 @@ "node": ">=8" } }, - "node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "dev": true - }, "node_modules/regexp.prototype.flags": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.0.tgz", @@ -3971,15 +3847,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/safe-array-concat": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.0.tgz", @@ -4086,36 +3953,6 @@ "randombytes": "^2.1.0" } }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/shell-quote": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", - "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", @@ -4223,12 +4060,6 @@ "node": ">=8" } }, - "node_modules/spawn-command": { - "version": "0.0.2-1", - "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2-1.tgz", - "integrity": "sha512-n98l9E2RMSJ9ON1AKisHzz7V42VDiBQGY6PB1BwRglz99wpVsSuGzQ+jOi6lFXBGVTCrRpltvjm+/XA+tpeJrg==", - "dev": true - }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -4574,15 +4405,6 @@ "node": ">=8.0" } }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "bin": { - "tree-kill": "cli.js" - } - }, "node_modules/trim-newlines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", @@ -4770,21 +4592,6 @@ "spdx-expression-parse": "^3.0.0" } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/which-boxed-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", @@ -4873,24 +4680,6 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/yargs-parser": { "version": "20.2.4", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", @@ -4948,15 +4737,6 @@ "node": ">=8" } }, - "node_modules/yargs/node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "engines": { - "node": ">=12" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/src/dialect/mssql/mssql-query-compiler.ts b/src/dialect/mssql/mssql-query-compiler.ts index 8bc1181ef..73f0731b5 100644 --- a/src/dialect/mssql/mssql-query-compiler.ts +++ b/src/dialect/mssql/mssql-query-compiler.ts @@ -1,6 +1,7 @@ import { AddColumnNode } from '../../operation-node/add-column-node.js' import { AlterTableColumnAlterationNode } from '../../operation-node/alter-table-node.js' import { DropColumnNode } from '../../operation-node/drop-column-node.js' +import { MergeQueryNode } from '../../operation-node/merge-query-node.js' import { DefaultQueryCompiler } from '../../query-compiler/default-query-compiler.js' export class MssqlQueryCompiler extends DefaultQueryCompiler { @@ -73,6 +74,11 @@ export class MssqlQueryCompiler extends DefaultQueryCompiler { this.visitNode(node.column) } + protected override visitMergeQuery(node: MergeQueryNode): void { + super.visitMergeQuery(node) + this.append(';') + } + protected override announcesNewColumnDataType(): boolean { return false } diff --git a/src/dialect/postgres/postgres-dialect-config.ts b/src/dialect/postgres/postgres-dialect-config.ts index 945f2ac6f..e56655056 100644 --- a/src/dialect/postgres/postgres-dialect-config.ts +++ b/src/dialect/postgres/postgres-dialect-config.ts @@ -66,7 +66,7 @@ export type PostgresCursorConstructor = new ( ) => PostgresCursor export interface PostgresQueryResult { - command: 'UPDATE' | 'DELETE' | 'INSERT' | 'SELECT' + command: 'UPDATE' | 'DELETE' | 'INSERT' | 'SELECT' | 'MERGE' rowCount: number rows: R[] } diff --git a/src/dialect/postgres/postgres-driver.ts b/src/dialect/postgres/postgres-driver.ts index 829dc8619..2c3192f3d 100644 --- a/src/dialect/postgres/postgres-driver.ts +++ b/src/dialect/postgres/postgres-driver.ts @@ -109,7 +109,8 @@ class PostgresConnection implements DatabaseConnection { if ( result.command === 'INSERT' || result.command === 'UPDATE' || - result.command === 'DELETE' + result.command === 'DELETE' || + result.command === 'MERGE' ) { const numAffectedRows = BigInt(result.rowCount) diff --git a/src/driver/database-connection.ts b/src/driver/database-connection.ts index 8739c78a7..7645216c2 100644 --- a/src/driver/database-connection.ts +++ b/src/driver/database-connection.ts @@ -21,7 +21,7 @@ export interface QueryResult { readonly numUpdatedOrDeletedRows?: bigint /** - * This is defined for insert, update and delete queries and contains + * This is defined for insert, update, delete and merge queries and contains * the number of rows the query inserted/updated/deleted. */ readonly numAffectedRows?: bigint diff --git a/src/index.ts b/src/index.ts index 4aec54bb0..7213c01d2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,8 @@ export * from './query-builder/on-conflict-builder.js' export * from './query-builder/aggregate-function-builder.js' export * from './query-builder/case-builder.js' export * from './query-builder/json-path-builder.js' +export * from './query-builder/merge-query-builder.js' +export * from './query-builder/merge-result.js' export * from './raw-builder/raw-builder.js' export * from './raw-builder/sql.js' @@ -197,6 +199,8 @@ export * from './operation-node/json-path-leg-node.js' export * from './operation-node/json-path-node.js' export * from './operation-node/json-operator-chain-node.js' export * from './operation-node/tuple-node.js' +export * from './operation-node/merge-query-node.js' +export * from './operation-node/matched-node.js' export * from './util/column-type.js' export * from './util/compilable.js' @@ -238,6 +242,7 @@ export { ValueExpressionOrList, } from './parser/value-parser.js' export { + SimpleTableReference, TableExpression, TableExpressionOrList, } from './parser/table-parser.js' diff --git a/src/operation-node/insert-query-node.ts b/src/operation-node/insert-query-node.ts index 7d2ace57f..5a348125a 100644 --- a/src/operation-node/insert-query-node.ts +++ b/src/operation-node/insert-query-node.ts @@ -12,7 +12,7 @@ export type InsertQueryNodeProps = Omit export interface InsertQueryNode extends OperationNode { readonly kind: 'InsertQueryNode' - readonly into: TableNode + readonly into?: TableNode readonly columns?: ReadonlyArray readonly values?: OperationNode readonly returning?: ReturningNode @@ -46,6 +46,12 @@ export const InsertQueryNode = freeze({ }) }, + createWithoutInto(): InsertQueryNode { + return freeze({ + kind: 'InsertQueryNode', + }) + }, + cloneWith( insertQuery: InsertQueryNode, props: InsertQueryNodeProps diff --git a/src/operation-node/join-node.ts b/src/operation-node/join-node.ts index 5bc1d7f55..2451dcabe 100644 --- a/src/operation-node/join-node.ts +++ b/src/operation-node/join-node.ts @@ -1,8 +1,6 @@ import { freeze } from '../util/object-utils.js' -import { AliasNode } from './alias-node.js' import { OnNode } from './on-node.js' import { OperationNode } from './operation-node.js' -import { TableNode } from './table-node.js' export type JoinType = | 'InnerJoin' @@ -11,6 +9,7 @@ export type JoinType = | 'FullJoin' | 'LateralInnerJoin' | 'LateralLeftJoin' + | 'Using' export interface JoinNode extends OperationNode { readonly kind: 'JoinNode' diff --git a/src/operation-node/matched-node.ts b/src/operation-node/matched-node.ts new file mode 100644 index 000000000..f6c4ddd4f --- /dev/null +++ b/src/operation-node/matched-node.ts @@ -0,0 +1,25 @@ +import { freeze } from '../util/object-utils.js' +import { OperationNode } from './operation-node.js' + +export interface MatchedNode extends OperationNode { + readonly kind: 'MatchedNode' + readonly not: boolean + readonly bySource: boolean +} + +/** + * @internal + */ +export const MatchedNode = freeze({ + is(node: OperationNode): node is MatchedNode { + return node.kind === 'MatchedNode' + }, + + create(not: boolean, bySource: boolean = false): MatchedNode { + return freeze({ + kind: 'MatchedNode', + not, + bySource, + }) + }, +}) diff --git a/src/operation-node/merge-query-node.ts b/src/operation-node/merge-query-node.ts new file mode 100644 index 000000000..c159e205d --- /dev/null +++ b/src/operation-node/merge-query-node.ts @@ -0,0 +1,66 @@ +import { freeze } from '../util/object-utils.js' +import { AliasNode } from './alias-node.js' +import { JoinNode } from './join-node.js' +import { OperationNode } from './operation-node.js' +import { TableNode } from './table-node.js' +import { WhenNode } from './when-node.js' +import { WithNode } from './with-node.js' + +export interface MergeQueryNode extends OperationNode { + readonly kind: 'MergeQueryNode' + readonly into: TableNode | AliasNode + readonly using?: JoinNode + readonly whens?: ReadonlyArray + readonly with?: WithNode +} + +/** + * @internal + */ +export const MergeQueryNode = freeze({ + is(node: OperationNode): node is MergeQueryNode { + return node.kind === 'MergeQueryNode' + }, + + create(into: TableNode | AliasNode, withNode?: WithNode): MergeQueryNode { + return freeze({ + kind: 'MergeQueryNode', + into, + ...(withNode && { with: withNode }), + }) + }, + + cloneWithUsing(mergeNode: MergeQueryNode, using: JoinNode): MergeQueryNode { + return freeze({ + ...mergeNode, + using, + }) + }, + + cloneWithWhen(mergeNode: MergeQueryNode, when: WhenNode): MergeQueryNode { + return freeze({ + ...mergeNode, + whens: mergeNode.whens + ? freeze([...mergeNode.whens, when]) + : freeze([when]), + }) + }, + + cloneWithThen( + mergeNode: MergeQueryNode, + then: OperationNode + ): MergeQueryNode { + return freeze({ + ...mergeNode, + whens: mergeNode.whens + ? freeze([ + ...mergeNode.whens.slice(0, -1), + WhenNode.cloneWithResult( + mergeNode.whens[mergeNode.whens.length - 1], + then + ), + ]) + : undefined, + }) + }, +}) diff --git a/src/operation-node/operation-node-transformer.ts b/src/operation-node/operation-node-transformer.ts index 727ea2753..759bb34a9 100644 --- a/src/operation-node/operation-node-transformer.ts +++ b/src/operation-node/operation-node-transformer.ts @@ -87,6 +87,8 @@ import { JSONPathNode } from './json-path-node.js' import { JSONPathLegNode } from './json-path-leg-node.js' import { JSONOperatorChainNode } from './json-operator-chain-node.js' import { TupleNode } from './tuple-node.js' +import { MergeQueryNode } from './merge-query-node.js' +import { MatchedNode } from './matched-node.js' import { AddIndexNode } from './add-index-node.js' /** @@ -209,6 +211,8 @@ export class OperationNodeTransformer { JSONPathLegNode: this.transformJSONPathLeg.bind(this), JSONOperatorChainNode: this.transformJSONOperatorChain.bind(this), TupleNode: this.transformTuple.bind(this), + MergeQueryNode: this.transformMergeQuery.bind(this), + MatchedNode: this.transformMatched.bind(this), AddIndexNode: this.transformAddIndex.bind(this), }) @@ -982,6 +986,24 @@ export class OperationNodeTransformer { }) } + protected transformMergeQuery(node: MergeQueryNode): MergeQueryNode { + return requireAllProps({ + kind: 'MergeQueryNode', + into: this.transformNode(node.into), + using: this.transformNode(node.using), + whens: this.transformNodeList(node.whens), + with: this.transformNode(node.with), + }) + } + + protected transformMatched(node: MatchedNode): MatchedNode { + return requireAllProps({ + kind: 'MatchedNode', + not: node.not, + bySource: node.bySource, + }) + } + protected transformAddIndex(node: AddIndexNode): AddIndexNode { return requireAllProps({ kind: 'AddIndexNode', diff --git a/src/operation-node/operation-node-visitor.ts b/src/operation-node/operation-node-visitor.ts index 4e48300bc..58cf349fe 100644 --- a/src/operation-node/operation-node-visitor.ts +++ b/src/operation-node/operation-node-visitor.ts @@ -89,6 +89,8 @@ import { JSONPathNode } from './json-path-node.js' import { JSONPathLegNode } from './json-path-leg-node.js' import { JSONOperatorChainNode } from './json-operator-chain-node.js' import { TupleNode } from './tuple-node.js' +import { MergeQueryNode } from './merge-query-node.js' +import { MatchedNode } from './matched-node.js' import { AddIndexNode } from './add-index-node.js' export abstract class OperationNodeVisitor { @@ -186,6 +188,8 @@ export abstract class OperationNodeVisitor { JSONPathLegNode: this.visitJSONPathLeg.bind(this), JSONOperatorChainNode: this.visitJSONOperatorChain.bind(this), TupleNode: this.visitTuple.bind(this), + MergeQueryNode: this.visitMergeQuery.bind(this), + MatchedNode: this.visitMatched.bind(this), AddIndexNode: this.visitAddIndex.bind(this), }) @@ -291,5 +295,7 @@ export abstract class OperationNodeVisitor { protected abstract visitJSONPathLeg(node: JSONPathLegNode): void protected abstract visitJSONOperatorChain(node: JSONOperatorChainNode): void protected abstract visitTuple(node: TupleNode): void + protected abstract visitMergeQuery(node: MergeQueryNode): void + protected abstract visitMatched(node: MatchedNode): void protected abstract visitAddIndex(node: AddIndexNode): void } diff --git a/src/operation-node/operation-node.ts b/src/operation-node/operation-node.ts index d40bb551c..faf6acbaa 100644 --- a/src/operation-node/operation-node.ts +++ b/src/operation-node/operation-node.ts @@ -85,6 +85,8 @@ export type OperationNodeKind = | 'JSONPathLegNode' | 'JSONOperatorChainNode' | 'TupleNode' + | 'MergeQueryNode' + | 'MatchedNode' | 'AddIndexNode' export interface OperationNode { diff --git a/src/operation-node/query-node.ts b/src/operation-node/query-node.ts index 90c3a5565..12a412484 100644 --- a/src/operation-node/query-node.ts +++ b/src/operation-node/query-node.ts @@ -11,12 +11,14 @@ import { OperationNode } from './operation-node.js' import { ExplainNode } from './explain-node.js' import { ExplainFormat } from '../util/explainable.js' import { Expression } from '../expression/expression.js' +import { MergeQueryNode } from './merge-query-node.js' export type QueryNode = | SelectQueryNode | InsertQueryNode | UpdateQueryNode | DeleteQueryNode + | MergeQueryNode type HasJoins = { joins?: ReadonlyArray } type HasWhere = { where?: WhereNode } @@ -32,7 +34,8 @@ export const QueryNode = freeze({ SelectQueryNode.is(node) || InsertQueryNode.is(node) || UpdateQueryNode.is(node) || - DeleteQueryNode.is(node) + DeleteQueryNode.is(node) || + MergeQueryNode.is(node) ) }, diff --git a/src/operation-node/schemable-identifier-node.ts b/src/operation-node/schemable-identifier-node.ts index a29ca3ebf..c0980a4e8 100644 --- a/src/operation-node/schemable-identifier-node.ts +++ b/src/operation-node/schemable-identifier-node.ts @@ -25,7 +25,7 @@ export const SchemableIdentifierNode = freeze({ createWithSchema( schema: string, - identifier: string + identifier: string, ): SchemableIdentifierNode { return freeze({ kind: 'SchemableIdentifierNode', diff --git a/src/operation-node/update-query-node.ts b/src/operation-node/update-query-node.ts index d249c8cc0..9f548a7c3 100644 --- a/src/operation-node/update-query-node.ts +++ b/src/operation-node/update-query-node.ts @@ -14,7 +14,7 @@ export type UpdateValuesNode = ValueListNode | PrimitiveValueListNode export interface UpdateQueryNode extends OperationNode { readonly kind: 'UpdateQueryNode' - readonly table: OperationNode + readonly table?: OperationNode readonly from?: FromNode readonly joins?: ReadonlyArray readonly where?: WhereNode @@ -40,6 +40,12 @@ export const UpdateQueryNode = freeze({ }) }, + createWithoutTable(): UpdateQueryNode { + return freeze({ + kind: 'UpdateQueryNode', + }) + }, + cloneWithFromItems( updateQuery: UpdateQueryNode, fromItems: ReadonlyArray diff --git a/src/parser/binary-operation-parser.ts b/src/parser/binary-operation-parser.ts index 981b59fa2..b775675b5 100644 --- a/src/parser/binary-operation-parser.ts +++ b/src/parser/binary-operation-parser.ts @@ -36,6 +36,8 @@ import { SelectType } from '../util/column-type.js' import { AndNode } from '../operation-node/and-node.js' import { ParensNode } from '../operation-node/parens-node.js' import { OrNode } from '../operation-node/or-node.js' +import { WhenNode } from '../operation-node/when-node.js' +import { RawNode } from '../operation-node/raw-node.js' export type OperandValueExpression< DB, @@ -128,7 +130,8 @@ export function parseFilterObject( export function parseFilterList( list: ReadonlyArray, - combinator: 'and' | 'or' + combinator: 'and' | 'or', + withParens = true ): OperationNode { const combine = combinator === 'and' ? AndNode.create : OrNode.create @@ -146,7 +149,7 @@ export function parseFilterList( node = combine(node, toOperationNode(list[i])) } - if (list.length > 1) { + if (list.length > 1 && withParens) { return ParensNode.create(node) } diff --git a/src/parser/insert-values-parser.ts b/src/parser/insert-values-parser.ts index ba37a293b..d847dc4d3 100644 --- a/src/parser/insert-values-parser.ts +++ b/src/parser/insert-values-parser.ts @@ -37,16 +37,20 @@ export type InsertObjectOrList = | InsertObject | ReadonlyArray> -export type InsertObjectOrListFactory = ( - eb: ExpressionBuilder -) => InsertObjectOrList - -export type InsertExpression = - | InsertObjectOrList - | InsertObjectOrListFactory +export type InsertObjectOrListFactory< + DB, + TB extends keyof DB, + UT extends keyof DB = never +> = (eb: ExpressionBuilder) => InsertObjectOrList + +export type InsertExpression< + DB, + TB extends keyof DB, + UT extends keyof DB = never +> = InsertObjectOrList | InsertObjectOrListFactory export function parseInsertExpression( - arg: InsertExpression + arg: InsertExpression ): [ReadonlyArray, ValuesNode] { const objectOrList = isFunction(arg) ? arg(expressionBuilder()) : arg const list = isReadonlyArray(objectOrList) diff --git a/src/parser/merge-parser.ts b/src/parser/merge-parser.ts new file mode 100644 index 000000000..629c520cd --- /dev/null +++ b/src/parser/merge-parser.ts @@ -0,0 +1,55 @@ +import { InsertQueryNode } from '../operation-node/insert-query-node.js' +import { MatchedNode } from '../operation-node/matched-node.js' +import { + OperationNodeSource, + isOperationNodeSource, +} from '../operation-node/operation-node-source.js' +import { OperationNode } from '../operation-node/operation-node.js' +import { RawNode } from '../operation-node/raw-node.js' +import { WhenNode } from '../operation-node/when-node.js' +import { isString } from '../util/object-utils.js' +import { + parseFilterList, + parseReferentialBinaryOperation, + parseValueBinaryOperationOrExpression, +} from './binary-operation-parser.js' + +export function parseMergeWhen( + type: { + isMatched: boolean + bySource?: boolean + }, + args?: any[], + refRight?: boolean +): WhenNode { + return WhenNode.create( + parseFilterList( + [ + MatchedNode.create(!type.isMatched, type.bySource), + ...(args && args.length > 0 + ? [ + args.length === 3 && refRight + ? parseReferentialBinaryOperation(args[0], args[1], args[2]) + : parseValueBinaryOperationOrExpression(args), + ] + : []), + ], + 'and', + false + ) + ) +} + +export function parseMergeThen( + result: 'delete' | 'do nothing' | OperationNodeSource | InsertQueryNode +): OperationNode { + if (isString(result)) { + return RawNode.create([result], []) + } + + if (isOperationNodeSource(result)) { + return result.toOperationNode() + } + + return result +} diff --git a/src/parser/table-parser.ts b/src/parser/table-parser.ts index 4c641b02b..65f6f57cc 100644 --- a/src/parser/table-parser.ts +++ b/src/parser/table-parser.ts @@ -20,10 +20,11 @@ export type TableExpressionOrList = | ReadonlyArray> export type TableReference = - | AnyAliasedTable - | AnyTable + | SimpleTableReference | AliasedExpression +export type SimpleTableReference = AnyAliasedTable | AnyTable + export type AnyAliasedTable = `${AnyTable} as ${string}` export type TableReferenceOrList = diff --git a/src/plugin/with-schema/with-schema-transformer.ts b/src/plugin/with-schema/with-schema-transformer.ts index a25aea9f7..84a602463 100644 --- a/src/plugin/with-schema/with-schema-transformer.ts +++ b/src/plugin/with-schema/with-schema-transformer.ts @@ -31,6 +31,7 @@ const ROOT_OPERATION_NODES: Record = freeze({ RawNode: true, SelectQueryNode: true, UpdateQueryNode: true, + MergeQueryNode: true, }) export class WithSchemaTransformer extends OperationNodeTransformer { @@ -135,6 +136,10 @@ export class WithSchemaTransformer extends OperationNodeTransformer { } } + if ('using' in node && node.using) { + this.#collectSchemableIdsFromTableExpr(node.using, schemableIds) + } + return schemableIds } diff --git a/src/query-builder/merge-query-builder.ts b/src/query-builder/merge-query-builder.ts new file mode 100644 index 000000000..95874846a --- /dev/null +++ b/src/query-builder/merge-query-builder.ts @@ -0,0 +1,922 @@ +import { AliasedExpression } from '../expression/expression.js' +import { InsertQueryNode } from '../operation-node/insert-query-node.js' +import { MergeQueryNode } from '../operation-node/merge-query-node.js' +import { OperationNodeSource } from '../operation-node/operation-node-source.js' +import { QueryNode } from '../operation-node/query-node.js' +import { UpdateQueryNode } from '../operation-node/update-query-node.js' +import { + ComparisonOperatorExpression, + OperandValueExpressionOrList, +} from '../parser/binary-operation-parser.js' +import { ExpressionOrFactory } from '../parser/expression-parser.js' +import { + InsertExpression, + InsertObjectOrList, + InsertObjectOrListFactory, + parseInsertExpression, +} from '../parser/insert-values-parser.js' +import { + JoinCallbackExpression, + JoinReferenceExpression, + parseJoin, +} from '../parser/join-parser.js' +import { parseMergeThen, parseMergeWhen } from '../parser/merge-parser.js' +import { ReferenceExpression } from '../parser/reference-parser.js' +import { TableExpression } from '../parser/table-parser.js' +import { + ExtractUpdateTypeFromReferenceExpression, + UpdateObject, + UpdateObjectFactory, +} from '../parser/update-set-parser.js' +import { ValueExpression } from '../parser/value-parser.js' +import { CompiledQuery } from '../query-compiler/compiled-query.js' +import { NOOP_QUERY_EXECUTOR } from '../query-executor/noop-query-executor.js' +import { QueryExecutor } from '../query-executor/query-executor.js' +import { Compilable } from '../util/compilable.js' +import { freeze } from '../util/object-utils.js' +import { preventAwait } from '../util/prevent-await.js' +import { QueryId } from '../util/query-id.js' +import { + ShallowRecord, + SimplifyResult, + SimplifySingleResult, + SqlBool, +} from '../util/type-utils.js' +import { InsertQueryBuilder } from './insert-query-builder.js' +import { MergeResult } from './merge-result.js' +import { + NoResultError, + NoResultErrorConstructor, + isNoResultErrorConstructor, +} from './no-result-error.js' +import { UpdateQueryBuilder } from './update-query-builder.js' + +export class MergeQueryBuilder { + readonly #props: MergeQueryBuilderProps + + constructor(props: MergeQueryBuilderProps) { + this.#props = freeze(props) + } + + /** + * Adds the `using` clause to the query. + * + * This method is similar to {@link SelectQueryBuilder.innerJoin}, so see the + * documentation for that method for more examples. + * + * ### Examples + * + * ```ts + * const result = await db.mergeInto('person') + * .using('pet', 'person.id', 'pet.owner_id') + * .whenMatched() + * .thenDelete() + * .execute() + * ``` + * + * The generated SQL (PostgreSQL): + * + * ```sql + * merge into "person" + * using "pet" on "person"."id" = "pet"."owner_id" + * when matched then + * delete + * ``` + */ + using< + TE extends TableExpression, + K1 extends JoinReferenceExpression, + K2 extends JoinReferenceExpression + >( + sourceTable: TE, + k1: K1, + k2: K2 + ): ExtractWheneableMergeQueryBuilder + + using< + TE extends TableExpression, + FN extends JoinCallbackExpression + >( + sourceTable: TE, + callback: FN + ): ExtractWheneableMergeQueryBuilder + + using(...args: any): any { + return new WheneableMergeQueryBuilder({ + ...this.#props, + queryNode: MergeQueryNode.cloneWithUsing( + this.#props.queryNode, + parseJoin('Using', args) + ), + }) + } +} + +preventAwait( + MergeQueryBuilder, + "don't await MergeQueryBuilder instances directly. To execute the query you need to call `execute` when available." +) + +export interface MergeQueryBuilderProps { + readonly queryId: QueryId + readonly queryNode: MergeQueryNode + readonly executor: QueryExecutor +} + +export class WheneableMergeQueryBuilder< + DB, + TT extends keyof DB, + ST extends keyof DB, + O +> implements Compilable, OperationNodeSource +{ + readonly #props: MergeQueryBuilderProps + + constructor(props: MergeQueryBuilderProps) { + this.#props = freeze(props) + } + + /** + * Adds a simple `when matched` clause to the query. + * + * For a `when matched` clause with an `and` condition, see {@link whenMatchedAnd}. + * + * For a simple `when not matched` clause, see {@link whenNotMatched}. + * + * For a `when not matched` clause with an `and` condition, see {@link whenNotMatchedAnd}. + * + * ### Examples + * + * ```ts + * const result = await db.mergeInto('person') + * .using('pet', 'person.id', 'pet.owner_id') + * .whenMatched() + * .thenDelete() + * .execute() + * ``` + * + * The generated SQL (PostgreSQL): + * + * ```sql + * merge into "person" + * using "pet" on "person"."id" = "pet"."owner_id" + * when matched then + * delete + * ``` + */ + whenMatched(): MatchedThenableMergeQueryBuilder { + return this.#whenMatched([]) + } + + /** + * Adds the `when matched` clause to the query with an `and` condition. + * + * This method is similar to {@link SelectQueryBuilder.where}, so see the documentation + * for that method for more examples. + * + * For a simple `when matched` clause (without an `and` condition) see {@link whenMatched}. + * + * ### Examples + * + * ```ts + * const result = await db.mergeInto('person') + * .using('pet', 'person.id', 'pet.owner_id') + * .whenMatchedAnd('person.first_name', '=', 'John') + * .thenDelete() + * .execute() + * ``` + * + * The generated SQL (PostgreSQL): + * + * ```sql + * merge into "person" + * using "pet" on "person"."id" = "pet"."owner_id" + * when matched and "person"."first_name" = $1 then + * delete + * ``` + */ + whenMatchedAnd< + RE extends ReferenceExpression, + VE extends OperandValueExpressionOrList + >( + lhs: RE, + op: ComparisonOperatorExpression, + rhs: VE + ): MatchedThenableMergeQueryBuilder + + whenMatchedAnd>( + expression: E + ): MatchedThenableMergeQueryBuilder + + whenMatchedAnd( + ...args: any[] + ): MatchedThenableMergeQueryBuilder { + return this.#whenMatched(args) + } + + /** + * Adds the `when matched` clause to the query with an `and` condition. But unlike + * {@link whenMatchedAnd}, this method accepts a column reference as the 3rd argument. + * + * This method is similar to {@link SelectQueryBuilder.whereRef}, so see the documentation + * for that method for more examples. + */ + whenMatchedAndRef< + LRE extends ReferenceExpression, + RRE extends ReferenceExpression + >( + lhs: LRE, + op: ComparisonOperatorExpression, + rhs: RRE + ): MatchedThenableMergeQueryBuilder { + return this.#whenMatched([lhs, op, rhs], true) + } + + #whenMatched( + args: any[], + refRight?: boolean + ): MatchedThenableMergeQueryBuilder { + return new MatchedThenableMergeQueryBuilder({ + ...this.#props, + queryNode: MergeQueryNode.cloneWithWhen( + this.#props.queryNode, + parseMergeWhen({ isMatched: true }, args, refRight) + ), + }) + } + + /** + * Adds a simple `when not matched` clause to the query. + * + * For a `when not matched` clause with an `and` condition, see {@link whenNotMatchedAnd}. + * + * For a simple `when matched` clause, see {@link whenMatched}. + * + * For a `when matched` clause with an `and` condition, see {@link whenMatchedAnd}. + * + * ### Examples + * + * ```ts + * const result = await db.mergeInto('person') + * .using('pet', 'person.id', 'pet.owner_id') + * .whenNotMatched() + * .thenInsertValues({ + * first_name: 'John', + * last_name: 'Doe', + * }) + * .execute() + * ``` + * + * The generated SQL (PostgreSQL): + * + * ```sql + * merge into "person" + * using "pet" on "person"."id" = "pet"."owner_id" + * when not matched then + * insert ("first_name", "last_name") values ($1, $2) + * ``` + */ + whenNotMatched(): NotMatchedThenableMergeQueryBuilder { + return this.#whenNotMatched([]) + } + + /** + * Adds the `when not matched` clause to the query with an `and` condition. + * + * This method is similar to {@link SelectQueryBuilder.where}, so see the documentation + * for that method for more examples. + * + * For a simple `when not matched` clause (without an `and` condition) see {@link whenNotMatched}. + * + * Unlike {@link whenMatchedAnd}, you cannot reference columns from the table merged into. + * + * ### Examples + * + * ```ts + * const result = await db.mergeInto('person') + * .using('pet', 'person.id', 'pet.owner_id') + * .whenNotMatchedAnd('pet.name', '=', 'Lucky') + * .thenInsertValues({ + * first_name: 'John', + * last_name: 'Doe', + * }) + * .execute() + * ``` + * + * The generated SQL (PostgreSQL): + * + * ```sql + * merge into "person" + * using "pet" on "person"."id" = "pet"."owner_id" + * when not matched and "pet"."name" = $1 then + * insert ("first_name", "last_name") values ($2, $3) + * ``` + */ + whenNotMatchedAnd< + RE extends ReferenceExpression, + VE extends OperandValueExpressionOrList + >( + lhs: RE, + op: ComparisonOperatorExpression, + rhs: VE + ): NotMatchedThenableMergeQueryBuilder + + whenNotMatchedAnd>( + expression: E + ): NotMatchedThenableMergeQueryBuilder + + whenNotMatchedAnd( + ...args: any[] + ): NotMatchedThenableMergeQueryBuilder { + return this.#whenNotMatched(args) + } + + /** + * Adds the `when not matched` clause to the query with an `and` condition. But unlike + * {@link whenNotMatchedAnd}, this method accepts a column reference as the 3rd argument. + * + * Unlike {@link whenMatchedAndRef}, you cannot reference columns from the target table. + * + * This method is similar to {@link SelectQueryBuilder.whereRef}, so see the documentation + * for that method for more examples. + */ + whenNotMatchedAndRef< + LRE extends ReferenceExpression, + RRE extends ReferenceExpression + >( + lhs: LRE, + op: ComparisonOperatorExpression, + rhs: RRE + ): NotMatchedThenableMergeQueryBuilder { + return this.#whenNotMatched([lhs, op, rhs], true) + } + + /** + * Adds a simple `when not matched by source` clause to the query. + * + * Supported in MS SQL Server. + * + * Similar to {@link whenNotMatched}, but returns a {@link MatchedThenableMergeQueryBuilder}. + */ + whenNotMatchedBySource(): MatchedThenableMergeQueryBuilder< + DB, + TT, + ST, + TT, + O + > { + return this.#whenNotMatched([], false, true) + } + + /** + * Adds the `when not matched by source` clause to the query with an `and` condition. + * + * Supported in MS SQL Server. + * + * Similar to {@link whenNotMatchedAnd}, but returns a {@link MatchedThenableMergeQueryBuilder}. + */ + whenNotMatchedBySourceAnd< + RE extends ReferenceExpression, + VE extends OperandValueExpressionOrList + >( + lhs: RE, + op: ComparisonOperatorExpression, + rhs: VE + ): MatchedThenableMergeQueryBuilder + + whenNotMatchedBySourceAnd>( + expression: E + ): MatchedThenableMergeQueryBuilder + + whenNotMatchedBySourceAnd( + ...args: any[] + ): MatchedThenableMergeQueryBuilder { + return this.#whenNotMatched(args, false, true) + } + + /** + * Adds the `when not matched by source` clause to the query with an `and` condition. + * + * Similar to {@link whenNotMatchedAndRef}, but you can reference columns from + * the target table, and not from source table and returns a {@link MatchedThenableMergeQueryBuilder}. + */ + whenNotMatchedBySourceAndRef< + LRE extends ReferenceExpression, + RRE extends ReferenceExpression + >( + lhs: LRE, + op: ComparisonOperatorExpression, + rhs: RRE + ): MatchedThenableMergeQueryBuilder { + return this.#whenNotMatched([lhs, op, rhs], true, true) + } + + #whenNotMatched( + args: any[], + refRight: boolean = false, + bySource: boolean = false + ): any { + const props: MergeQueryBuilderProps = { + ...this.#props, + queryNode: MergeQueryNode.cloneWithWhen( + this.#props.queryNode, + parseMergeWhen({ isMatched: false, bySource }, args, refRight) + ), + } + + const Builder: any = bySource + ? MatchedThenableMergeQueryBuilder + : NotMatchedThenableMergeQueryBuilder + + return new Builder(props) + } + + /** + * Simply calls the provided function passing `this` as the only argument. `$call` returns + * what the provided function returns. + * + * If you want to conditionally call a method on `this`, see + * the {@link $if} method. + * + * ### Examples + * + * The next example uses a helper function `log` to log a query: + * + * ```ts + * function log(qb: T): T { + * console.log(qb.compile()) + * return qb + * } + * + * db.updateTable('person') + * .set(values) + * .$call(log) + * .execute() + * ``` + */ + $call(func: (qb: this) => T): T { + return func(this) + } + + /** + * Call `func(this)` if `condition` is true. + * + * This method is especially handy with optional selects. Any `returning` or `returningAll` + * method calls add columns as optional fields to the output type when called inside + * the `func` callback. This is because we can't know if those selections were actually + * made before running the code. + * + * You can also call any other methods inside the callback. + * + * ### Examples + * + * ```ts + * async function updatePerson(id: number, updates: UpdateablePerson, returnLastName: boolean) { + * return await db + * .updateTable('person') + * .set(updates) + * .where('id', '=', id) + * .returning(['id', 'first_name']) + * .$if(returnLastName, (qb) => qb.returning('last_name')) + * .executeTakeFirstOrThrow() + * } + * ``` + * + * Any selections added inside the `if` callback will be added as optional fields to the + * output type since we can't know if the selections were actually made before running + * the code. In the example above the return type of the `updatePerson` function is: + * + * ```ts + * { + * id: number + * first_name: string + * last_name?: string + * } + * ``` + */ + $if( + condition: boolean, + func: (qb: this) => WheneableMergeQueryBuilder + ): O2 extends MergeResult + ? WheneableMergeQueryBuilder + : O2 extends O & infer E + ? WheneableMergeQueryBuilder> + : WheneableMergeQueryBuilder> { + if (condition) { + return func(this) as any + } + + return new WheneableMergeQueryBuilder({ + ...this.#props, + }) as any + } + + toOperationNode(): MergeQueryNode { + return this.#props.executor.transformQuery( + this.#props.queryNode, + this.#props.queryId + ) + } + + compile(): CompiledQuery { + return this.#props.executor.compileQuery( + this.#props.queryNode, + this.#props.queryId + ) + } + + /** + * Executes the query and returns an array of rows. + * + * Also see the {@link executeTakeFirst} and {@link executeTakeFirstOrThrow} methods. + */ + async execute(): Promise[]> { + const compiledQuery = this.compile() + + const result = await this.#props.executor.executeQuery( + compiledQuery, + this.#props.queryId + ) + + return [new MergeResult(result.numAffectedRows) as any] + } + + /** + * Executes the query and returns the first result or undefined if + * the query returned no result. + */ + async executeTakeFirst(): Promise> { + const [result] = await this.execute() + return result as SimplifySingleResult + } + + /** + * Executes the query and returns the first result or throws if + * the query returned no result. + * + * By default an instance of {@link NoResultError} is thrown, but you can + * provide a custom error class, or callback as the only argument to throw a different + * error. + */ + async executeTakeFirstOrThrow( + errorConstructor: + | NoResultErrorConstructor + | ((node: QueryNode) => Error) = NoResultError + ): Promise> { + const result = await this.executeTakeFirst() + + if (result === undefined) { + const error = isNoResultErrorConstructor(errorConstructor) + ? new errorConstructor(this.toOperationNode()) + : errorConstructor(this.toOperationNode()) + + throw error + } + + return result as SimplifyResult + } +} + +preventAwait( + WheneableMergeQueryBuilder, + "don't await WheneableMergeQueryBuilder instances directly. To execute the query you need to call `execute`." +) + +export class MatchedThenableMergeQueryBuilder< + DB, + TT extends keyof DB, + ST extends keyof DB, + UT extends TT | ST, + O +> { + readonly #props: MergeQueryBuilderProps + + constructor(props: MergeQueryBuilderProps) { + this.#props = freeze(props) + } + + /** + * Performs the `delete` action. + * + * To perform the `do nothing` action, see {@link thenDoNothing}. + * + * To perform the `update` action, see {@link thenUpdate} or {@link thenUpdateSet}. + * + * ### Examples + * + * ```ts + * const result = await db.mergeInto('person') + * .using('pet', 'person.id', 'pet.owner_id') + * .whenMatched() + * .thenDelete() + * .execute() + * ``` + * + * The generated SQL (PostgreSQL): + * + * ```sql + * merge into "person" + * using "pet" on "person"."id" = "pet"."owner_id" + * when matched then + * delete + * ``` + */ + thenDelete(): WheneableMergeQueryBuilder { + return new WheneableMergeQueryBuilder({ + ...this.#props, + queryNode: MergeQueryNode.cloneWithThen( + this.#props.queryNode, + parseMergeThen('delete') + ), + }) + } + + /** + * Performs the `do nothing` action. + * + * This is supported in PostgreSQL. + * + * To perform the `delete` action, see {@link thenDelete}. + * + * To perform the `update` action, see {@link thenUpdate} or {@link thenUpdateSet}. + * + * ### Examples + * + * ```ts + * const result = await db.mergeInto('person') + * .using('pet', 'person.id', 'pet.owner_id') + * .whenMatched() + * .thenDoNothing() + * .execute() + * ``` + * + * The generated SQL (PostgreSQL): + * + * ```sql + * merge into "person" + * using "pet" on "person"."id" = "pet"."owner_id" + * when matched then + * do nothing + * ``` + */ + thenDoNothing(): WheneableMergeQueryBuilder { + return new WheneableMergeQueryBuilder({ + ...this.#props, + queryNode: MergeQueryNode.cloneWithThen( + this.#props.queryNode, + parseMergeThen('do nothing') + ), + }) + } + + /** + * Perform an `update` operation with a full-fledged {@link UpdateQueryBuilder}. + * This is handy when multiple `set` invocations are needed. + * + * For a shorthand version of this method, see {@link thenUpdateSet}. + * + * To perform the `delete` action, see {@link thenDelete}. + * + * To perform the `do nothing` action, see {@link thenDoNothing}. + * + * ### Examples + * + * ```ts + * import { sql } from 'kysely' + * + * const result = await db.mergeInto('person') + * .using('pet', 'person.id', 'pet.owner_id') + * .whenMatched() + * .thenUpdate((ub) => ub + * .set(sql`metadata['has_pets']`, 'Y') + * .set({ + * updated_at: Date.now(), + * }) + * ) + * .execute() + * ``` + * + * The generated SQL (PostgreSQL): + * + * ```sql + * merge into "person" + * using "pet" on "person"."id" = "pet"."owner_id" + * when matched then + * update set metadata['has_pets'] = $1, "updated_at" = $2 + * ``` + */ + thenUpdate>( + set: (ub: QB) => QB + ): WheneableMergeQueryBuilder { + return new WheneableMergeQueryBuilder({ + ...this.#props, + queryNode: MergeQueryNode.cloneWithThen( + this.#props.queryNode, + parseMergeThen( + set( + new UpdateQueryBuilder({ + queryId: this.#props.queryId, + executor: NOOP_QUERY_EXECUTOR, + queryNode: UpdateQueryNode.createWithoutTable(), + }) as QB + ) + ) + ), + }) + } + + /** + * Performs an `update set` action, similar to {@link UpdateQueryBuilder.set}. + * + * For a full-fledged update query builder, see {@link thenUpdate}. + * + * To perform the `delete` action, see {@link thenDelete}. + * + * To perform the `do nothing` action, see {@link thenDoNothing}. + * + * ### Examples + * + * ```ts + * const result = await db.mergeInto('person') + * .using('pet', 'person.id', 'pet.owner_id') + * .whenMatched() + * .thenUpdateSet({ + * middle_name: 'dog owner', + * }) + * .execute() + * ``` + * + * The generate SQL (PostgreSQL): + * + * ```sql + * merge into "person" + * using "pet" on "person"."id" = "pet"."owner_id" + * when matched then + * update set "middle_name" = $1 + * ``` + */ + thenUpdateSet>( + update: UO + ): WheneableMergeQueryBuilder + + thenUpdateSet>( + update: U + ): WheneableMergeQueryBuilder + + thenUpdateSet< + RE extends ReferenceExpression, + VE extends ValueExpression< + DB, + UT, + ExtractUpdateTypeFromReferenceExpression + > + >(key: RE, value: VE): WheneableMergeQueryBuilder + + thenUpdateSet(...args: any[]): any { + // @ts-ignore not sure how to type this so it won't complain about set(...args). + return this.thenUpdate((ub) => ub.set(...args)) + } +} + +preventAwait( + MatchedThenableMergeQueryBuilder, + "don't await MatchedThenableMergeQueryBuilder instances directly. To execute the query you need to call `execute` when available." +) + +export class NotMatchedThenableMergeQueryBuilder< + DB, + TT extends keyof DB, + ST extends keyof DB, + O +> { + readonly #props: MergeQueryBuilderProps + + constructor(props: MergeQueryBuilderProps) { + this.#props = freeze(props) + } + + /** + * Performs the `do nothing` action. + * + * This is supported in PostgreSQL. + * + * To perform the `insert` action, see {@link thenInsertValues}. + * + * ### Examples + * + * ```ts + * const result = await db.mergeInto('person') + * .using('pet', 'person.id', 'pet.owner_id') + * .whenNotMatched() + * .thenDoNothing() + * .execute() + * ``` + * + * The generated SQL (PostgreSQL): + * + * ```sql + * merge into "person" + * using "pet" on "person"."id" = "pet"."owner_id" + * when not matched then + * do nothing + * ``` + */ + thenDoNothing(): WheneableMergeQueryBuilder { + return new WheneableMergeQueryBuilder({ + ...this.#props, + queryNode: MergeQueryNode.cloneWithThen( + this.#props.queryNode, + parseMergeThen('do nothing') + ), + }) + } + + /** + * Performs the `insert (...) values` action. + * + * This method is similar to {@link InsertQueryBuilder.values}, so see the documentation + * for that method for more examples. + * + * To perform the `do nothing` action, see {@link thenDoNothing}. + * + * ### Examples + * + * ```ts + * const result = await db.mergeInto('person') + * .using('pet', 'person.id', 'pet.owner_id') + * .whenNotMatched() + * .thenInsertValues({ + * first_name: 'John', + * last_name: 'Doe', + * }) + * .execute() + * ``` + * + * The generated SQL (PostgreSQL): + * + * ```sql + * merge into "person" + * using "pet" on "person"."id" = "pet"."owner_id" + * when not matched then + * insert ("first_name", "last_name") values ($1, $2) + * ``` + */ + thenInsertValues>( + insert: I + ): WheneableMergeQueryBuilder + + thenInsertValues>( + insert: IO + ): WheneableMergeQueryBuilder + + thenInsertValues>( + insert: IE + ): WheneableMergeQueryBuilder { + const [columns, values] = parseInsertExpression(insert) + + return new WheneableMergeQueryBuilder({ + ...this.#props, + queryNode: MergeQueryNode.cloneWithThen( + this.#props.queryNode, + parseMergeThen( + InsertQueryNode.cloneWith(InsertQueryNode.createWithoutInto(), { + columns, + values, + }) + ) + ), + }) + } +} + +preventAwait( + NotMatchedThenableMergeQueryBuilder, + "don't await NotMatchedThenableMergeQueryBuilder instances directly. To execute the query you need to call `execute` when available." +) + +export type ExtractWheneableMergeQueryBuilder< + DB, + TT extends keyof DB, + TE extends TableExpression, + O +> = TE extends `${infer T} as ${infer A}` + ? T extends keyof DB + ? UsingBuilder + : never + : TE extends keyof DB + ? WheneableMergeQueryBuilder + : TE extends AliasedExpression + ? UsingBuilder + : TE extends (qb: any) => AliasedExpression + ? UsingBuilder + : never + +type UsingBuilder< + DB, + TT extends keyof DB, + A extends string, + R, + O +> = A extends keyof DB + ? WheneableMergeQueryBuilder + : WheneableMergeQueryBuilder, TT, A, O> diff --git a/src/query-builder/merge-result.ts b/src/query-builder/merge-result.ts new file mode 100644 index 000000000..8425e0ed4 --- /dev/null +++ b/src/query-builder/merge-result.ts @@ -0,0 +1,7 @@ +export class MergeResult { + readonly numChangedRows: bigint | undefined + + constructor(numChangedRows: bigint | undefined) { + this.numChangedRows = numChangedRows + } +} diff --git a/src/query-compiler/default-query-compiler.ts b/src/query-compiler/default-query-compiler.ts index 64b214931..a634369e9 100644 --- a/src/query-compiler/default-query-compiler.ts +++ b/src/query-compiler/default-query-compiler.ts @@ -104,6 +104,8 @@ import { JSONPathNode } from '../operation-node/json-path-node.js' import { JSONPathLegNode } from '../operation-node/json-path-leg-node.js' import { JSONOperatorChainNode } from '../operation-node/json-operator-chain-node.js' import { TupleNode } from '../operation-node/tuple-node.js' +import { MergeQueryNode } from '../operation-node/merge-query-node.js' +import { MatchedNode } from '../operation-node/matched-node.js' import { AddIndexNode } from '../operation-node/add-index-node.js' export class DefaultQueryCompiler @@ -274,14 +276,15 @@ export class DefaultQueryCompiler } protected override visitInsertQuery(node: InsertQueryNode): void { - const isSubQuery = this.nodeStack.find(QueryNode.is) !== node + const rootQueryNode = this.nodeStack.find(QueryNode.is)! + const isSubQuery = rootQueryNode !== node if (!isSubQuery && node.explain) { this.visitNode(node.explain) this.append(' ') } - if (isSubQuery) { + if (isSubQuery && !MergeQueryNode.is(rootQueryNode)) { this.append('(') } @@ -296,8 +299,10 @@ export class DefaultQueryCompiler this.append(' ignore') } - this.append(' into ') - this.visitNode(node.into) + if (node.into) { + this.append(' into ') + this.visitNode(node.into) + } if (node.columns) { this.append(' (') @@ -330,7 +335,7 @@ export class DefaultQueryCompiler this.visitNode(node.returning) } - if (isSubQuery) { + if (isSubQuery && !MergeQueryNode.is(rootQueryNode)) { this.append(')') } } @@ -703,14 +708,15 @@ export class DefaultQueryCompiler } protected override visitUpdateQuery(node: UpdateQueryNode): void { - const isSubQuery = this.nodeStack.find(QueryNode.is) !== node + const rootQueryNode = this.nodeStack.find(QueryNode.is)! + const isSubQuery = rootQueryNode !== node if (!isSubQuery && node.explain) { this.visitNode(node.explain) this.append(' ') } - if (isSubQuery) { + if (isSubQuery && !MergeQueryNode.is(rootQueryNode)) { this.append('(') } @@ -720,8 +726,13 @@ export class DefaultQueryCompiler } this.append('update ') - this.visitNode(node.table) - this.append(' set ') + + if (node.table) { + this.visitNode(node.table) + this.append(' ') + } + + this.append('set ') if (node.updates) { this.compileList(node.updates) @@ -747,7 +758,7 @@ export class DefaultQueryCompiler this.visitNode(node.returning) } - if (isSubQuery) { + if (isSubQuery && !MergeQueryNode.is(rootQueryNode)) { this.append(')') } } @@ -1443,6 +1454,38 @@ export class DefaultQueryCompiler } } + protected override visitMergeQuery(node: MergeQueryNode): void { + if (node.with) { + this.visitNode(node.with) + this.append(' ') + } + + this.append('merge into ') + this.visitNode(node.into) + + if (node.using) { + this.append(' ') + this.visitNode(node.using) + } + + if (node.whens) { + this.append(' ') + this.compileList(node.whens) + } + } + + protected override visitMatched(node: MatchedNode): void { + if (node.not) { + this.append('not ') + } + + this.append('matched') + + if (node.bySource) { + this.append(' by source') + } + } + protected override visitAddIndex(node: AddIndexNode): void { this.append('add ') @@ -1597,4 +1640,5 @@ const JOIN_TYPE_SQL: Readonly> = freeze({ FullJoin: 'full join', LateralInnerJoin: 'inner join lateral', LateralLeftJoin: 'left join lateral', + Using: 'using', }) diff --git a/src/query-compiler/query-compiler.ts b/src/query-compiler/query-compiler.ts index 9aa5e90c0..ec3ed9a63 100644 --- a/src/query-compiler/query-compiler.ts +++ b/src/query-compiler/query-compiler.ts @@ -9,6 +9,7 @@ import { DropSchemaNode } from '../operation-node/drop-schema-node.js' import { DropTableNode } from '../operation-node/drop-table-node.js' import { DropTypeNode } from '../operation-node/drop-type-node.js' import { DropViewNode } from '../operation-node/drop-view-node.js' +import { MergeQueryNode } from '../operation-node/merge-query-node.js' import { QueryNode } from '../operation-node/query-node.js' import { RawNode } from '../operation-node/raw-node.js' import { CompiledQuery } from './compiled-query.js' @@ -27,6 +28,7 @@ export type RootOperationNode = | RawNode | CreateTypeNode | DropTypeNode + | MergeQueryNode /** * a `QueryCompiler` compiles a query expressed as a tree of `OperationNodes` into SQL. diff --git a/src/query-creator.ts b/src/query-creator.ts index 0d801660f..75df57377 100644 --- a/src/query-creator.ts +++ b/src/query-creator.ts @@ -22,6 +22,8 @@ import { ExtractTableAlias, AnyAliasedTable, PickTableWithAlias, + SimpleTableReference, + parseAliasedTable, } from './parser/table-parser.js' import { QueryExecutor } from './query-executor/query-executor.js' import { @@ -47,6 +49,9 @@ import { Selection, parseSelectArg, } from './parser/select-parser.js' +import { MergeQueryBuilder } from './query-builder/merge-query-builder.js' +import { MergeQueryNode } from './operation-node/merge-query-node.js' +import { MergeResult } from './query-builder/merge-result.js' export class QueryCreator { readonly #props: QueryCreatorProps @@ -497,6 +502,63 @@ export class QueryCreator { }) } + /** + * Creates a merge query. + * + * The return value of the query is a {@link MergeResult}. + * + * See the {@link MergeQueryBuilder.using} method for examples on how to specify + * the other table. + * + * ### Examples + * + * ```ts + * const result = await db + * .mergeInto('person') + * .using('pet', 'pet.owner_id', 'person.id') + * .whenMatched((and) => and('has_pets', '!=', 'Y')) + * .thenUpdateSet({ has_pets: 'Y' }) + * .whenNotMatched() + * .thenDoNothing() + * .executeTakeFirstOrThrow() + * + * console.log(result.numChangedRows) + * ``` + * + * The generated SQL (PostgreSQL): + * + * ```sql + * merge into "person" + * using "pet" on "pet"."owner_id" = "person"."id" + * when matched and "has_pets" != $1 then + * update set "has_pets" = $2 + * when not matched then + * do nothing + * ``` + */ + mergeInto( + targetTable: TR + ): MergeQueryBuilder + + mergeInto>( + targetTable: TR + ): MergeQueryBuilder< + DB & PickTableWithAlias, + ExtractTableAlias, + MergeResult + > + + mergeInto>(targetTable: TR): any { + return new MergeQueryBuilder({ + queryId: createQueryId(), + executor: this.#props.executor, + queryNode: MergeQueryNode.create( + parseAliasedTable(targetTable), + this.#props.withNode + ), + }) + } + /** * Creates a `with` query (Common Table Expression). * diff --git a/src/util/infer-result.ts b/src/util/infer-result.ts index 1241fc082..0c8255850 100644 --- a/src/util/infer-result.ts +++ b/src/util/infer-result.ts @@ -1,5 +1,6 @@ import { DeleteResult } from '../query-builder/delete-result.js' import { InsertResult } from '../query-builder/insert-result.js' +import { MergeResult } from '../query-builder/merge-result.js' import { UpdateResult } from '../query-builder/update-result.js' import { CompiledQuery } from '../query-compiler/compiled-query.js' import { Compilable } from './compilable.js' @@ -50,6 +51,10 @@ export type InferResult | CompiledQuery> = ? ResolveResult : never -type ResolveResult = O extends InsertResult | UpdateResult | DeleteResult +type ResolveResult = O extends + | InsertResult + | UpdateResult + | DeleteResult + | MergeResult ? O : Simplify[] diff --git a/src/util/type-utils.ts b/src/util/type-utils.ts index 01b54b931..1634e1b98 100644 --- a/src/util/type-utils.ts +++ b/src/util/type-utils.ts @@ -2,6 +2,7 @@ import { InsertResult } from '../query-builder/insert-result.js' import { DeleteResult } from '../query-builder/delete-result.js' import { UpdateResult } from '../query-builder/update-result.js' import { KyselyTypeError } from './type-error.js' +import { MergeResult } from '../query-builder/merge-result.js' /** * Given a database type and a union of table names in that db, returns @@ -108,6 +109,8 @@ export type SimplifySingleResult = O extends InsertResult ? O : O extends UpdateResult ? O + : O extends MergeResult + ? O : Simplify | undefined export type SimplifyResult = O extends InsertResult @@ -116,6 +119,8 @@ export type SimplifyResult = O extends InsertResult ? O : O extends UpdateResult ? O + : O extends MergeResult + ? O : Simplify export type Simplify = DrainOuterGeneric<{ [K in keyof T]: T[K] } & {}> diff --git a/test/node/src/merge.test.ts b/test/node/src/merge.test.ts new file mode 100644 index 000000000..a50ddefec --- /dev/null +++ b/test/node/src/merge.test.ts @@ -0,0 +1,959 @@ +import { MergeResult } from '../../..' +import { + DIALECTS, + NOT_SUPPORTED, + TestContext, + clearDatabase, + destroyTest, + expect, + initTest, + insertDefaultDataSet, + testSql, +} from './test-setup.js' + +for (const dialect of DIALECTS.filter( + (dialect) => dialect === 'postgres' || dialect === 'mssql' +)) { + describe(`merge (${dialect})`, () => { + 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) + }) + + describe('using', () => { + it('should perform a merge...using table simple on...when matched then delete query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenMatched() + .thenDelete() + + testSql(query, dialect, { + postgres: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when matched then delete', + parameters: [], + }, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when matched then delete;', + parameters: [], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(3n) + }) + + it('should perform a merge...using table alias simple on alias...when matched then delete query', async () => { + const query = ctx.db + .mergeInto('person as pr') + .using('pet as pt', 'pt.owner_id', 'pr.id') + .whenMatched() + .thenDelete() + + testSql(query, dialect, { + postgres: { + sql: 'merge into "person" as "pr" using "pet" as "pt" on "pt"."owner_id" = "pr"."id" when matched then delete', + parameters: [], + }, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" as "pr" using "pet" as "pt" on "pt"."owner_id" = "pr"."id" when matched then delete;', + parameters: [], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(3n) + }) + + it('should perform a merge...using table complex on...when matched then delete query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', (on) => + on + .onRef('pet.owner_id', '=', 'person.id') + .on('pet.name', '=', 'Lucky') + ) + .whenMatched() + .thenDelete() + + testSql(query, dialect, { + postgres: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" and "pet"."name" = $1 when matched then delete', + parameters: ['Lucky'], + }, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" and "pet"."name" = @1 when matched then delete;', + parameters: ['Lucky'], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(0n) + }) + + it('should perform a merge...using subquery simple on...when matched then delete query', async () => { + const query = ctx.db + .mergeInto('person') + .using( + ctx.db + .selectFrom('pet') + .select('owner_id') + .where('name', '=', 'Lucky') + .as('pet'), + 'pet.owner_id', + 'person.id' + ) + .whenMatched() + .thenDelete() + + testSql(query, dialect, { + postgres: { + sql: 'merge into "person" using (select "owner_id" from "pet" where "name" = $1) as "pet" on "pet"."owner_id" = "person"."id" when matched then delete', + parameters: ['Lucky'], + }, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using (select "owner_id" from "pet" where "name" = @1) as "pet" on "pet"."owner_id" = "person"."id" when matched then delete;', + parameters: ['Lucky'], + }, + sqlite: NOT_SUPPORTED, + }) + }) + }) + + describe('whenMatched', () => { + it('should perform a merge...using table simple on...when matched and simple binary then delete query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenMatchedAnd('person.gender', '=', 'female') + .thenDelete() + + testSql(query, dialect, { + postgres: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when matched and "person"."gender" = $1 then delete', + parameters: ['female'], + }, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when matched and "person"."gender" = @1 then delete;', + parameters: ['female'], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(1n) + }) + + it('should perform a merge...using table simple on...when matched and simple binary cross ref then delete query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenMatchedAndRef('person.first_name', '=', 'pet.name') + .thenDelete() + + testSql(query, dialect, { + postgres: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when matched and "person"."first_name" = "pet"."name" then delete', + parameters: [], + }, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when matched and "person"."first_name" = "pet"."name" then delete;', + parameters: [], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(0n) + }) + + it('should perform a merge...using table simple on...when matched and complex and then delete query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenMatchedAnd((eb) => + eb('person.gender', '=', 'female').and( + 'person.first_name', + '=', + eb.ref('pet.name') + ) + ) + .thenDelete() + + testSql(query, dialect, { + postgres: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when matched and ("person"."gender" = $1 and "person"."first_name" = "pet"."name") then delete', + parameters: ['female'], + }, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when matched and ("person"."gender" = @1 and "person"."first_name" = "pet"."name") then delete;', + parameters: ['female'], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(0n) + }) + + it('should perform a merge...using table simple on...when matched and complex or then delete query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenMatchedAnd((eb) => + eb('person.gender', '=', 'female').or( + 'person.first_name', + '=', + eb.ref('pet.name') + ) + ) + .thenDelete() + + testSql(query, dialect, { + postgres: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when matched and ("person"."gender" = $1 or "person"."first_name" = "pet"."name") then delete', + parameters: ['female'], + }, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when matched and ("person"."gender" = @1 or "person"."first_name" = "pet"."name") then delete;', + parameters: ['female'], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(1n) + }) + + if (dialect === 'postgres') { + it('should perform a merge...using table...when matched then do nothing query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenMatched() + .thenDoNothing() + + testSql(query, dialect, { + postgres: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when matched then do nothing', + parameters: [], + }, + mysql: NOT_SUPPORTED, + mssql: NOT_SUPPORTED, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(0n) + }) + } + + describe('update', () => { + it('should perform a merge...using table simple on...when matched then update set object query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenMatched() + .thenUpdateSet({ + middle_name: 'pet owner', + }) + + testSql(query, dialect, { + postgres: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when matched then update set "middle_name" = $1', + parameters: ['pet owner'], + }, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when matched then update set "middle_name" = @1;', + parameters: ['pet owner'], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(3n) + }) + + it('should perform a merge...using table simple on...when matched then update set object ref query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenMatched() + .thenUpdateSet((eb) => ({ + first_name: eb.ref('person.last_name'), + middle_name: eb.ref('pet.name'), + })) + + testSql(query, dialect, { + postgres: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when matched then update set "first_name" = "person"."last_name", "middle_name" = "pet"."name"', + parameters: [], + }, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when matched then update set "first_name" = "person"."last_name", "middle_name" = "pet"."name";', + parameters: [], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(3n) + }) + + it('should perform a merge...using table simple on...when matched then update set column query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenMatched() + .thenUpdateSet('middle_name', 'pet owner') + + testSql(query, dialect, { + postgres: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when matched then update set "middle_name" = $1', + parameters: ['pet owner'], + }, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when matched then update set "middle_name" = @1;', + parameters: ['pet owner'], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(3n) + }) + + it('should perform a merge...using table simple on...when matched then update set column ref query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenMatched() + .thenUpdateSet('first_name', (eb) => eb.ref('person.last_name')) + + testSql(query, dialect, { + postgres: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when matched then update set "first_name" = "person"."last_name"', + parameters: [], + }, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when matched then update set "first_name" = "person"."last_name";', + parameters: [], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(3n) + }) + + it('should perform a merge...using table simple on...when matched then update set column cross ref query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenMatched() + .thenUpdateSet('middle_name', (eb) => eb.ref('pet.name')) + + testSql(query, dialect, { + postgres: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when matched then update set "middle_name" = "pet"."name"', + parameters: [], + }, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when matched then update set "middle_name" = "pet"."name";', + parameters: [], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(3n) + }) + + it('should perform a merge...using table simple on...when matched then update set complex query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenMatched() + .thenUpdate((ub) => + ub + .set('first_name', (eb) => eb.ref('person.last_name')) + .set('middle_name', (eb) => eb.ref('pet.name')) + .set({ + marital_status: 'single', + }) + ) + + testSql(query, dialect, { + postgres: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when matched then update set "first_name" = "person"."last_name", "middle_name" = "pet"."name", "marital_status" = $1', + parameters: ['single'], + }, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when matched then update set "first_name" = "person"."last_name", "middle_name" = "pet"."name", "marital_status" = @1;', + parameters: ['single'], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(3n) + }) + }) + }) + + describe('whenNotMatched', () => { + if (dialect === 'postgres') { + it('should perform a merge...using table simple on...when not matched then do nothing query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenNotMatched() + .thenDoNothing() + + testSql(query, dialect, { + postgres: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when not matched then do nothing', + parameters: [], + }, + mysql: NOT_SUPPORTED, + mssql: NOT_SUPPORTED, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(0n) + }) + } + + describe('insert', () => { + it('should perform a merge...using table complex on...when not matched then insert values query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', (on) => + on + .onRef('pet.owner_id', '=', 'person.id') + .on('pet.name', '=', 'NO_SUCH_PET_NAME') + ) + .whenNotMatched() + .thenInsertValues({ + gender: 'male', + first_name: 'Dingo', + middle_name: 'the', + last_name: 'Dog', + }) + + testSql(query, dialect, { + postgres: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" and "pet"."name" = $1 when not matched then insert ("gender", "first_name", "middle_name", "last_name") values ($2, $3, $4, $5)', + parameters: ['NO_SUCH_PET_NAME', 'male', 'Dingo', 'the', 'Dog'], + }, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" and "pet"."name" = @1 when not matched then insert ("gender", "first_name", "middle_name", "last_name") values (@2, @3, @4, @5);', + parameters: ['NO_SUCH_PET_NAME', 'male', 'Dingo', 'the', 'Dog'], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(3n) + }) + + describe('And', () => { + it('should perform a merge...using table simple on...when not matched and simple binary then insert values query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenNotMatchedAnd('pet.name', '=', 'Dingo') + .thenInsertValues({ + gender: 'male', + first_name: 'Dingo', + middle_name: 'the', + last_name: 'Dog', + }) + + testSql(query, dialect, { + postgres: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when not matched and "pet"."name" = $1 then insert ("gender", "first_name", "middle_name", "last_name") values ($2, $3, $4, $5)', + parameters: ['Dingo', 'male', 'Dingo', 'the', 'Dog'], + }, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when not matched and "pet"."name" = @1 then insert ("gender", "first_name", "middle_name", "last_name") values (@2, @3, @4, @5);', + parameters: ['Dingo', 'male', 'Dingo', 'the', 'Dog'], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(0n) + }) + + it('should perform a merge...using table simple on...when not matched and simple binary ref then insert values query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenNotMatchedAndRef('pet.name', '=', 'pet.species') + .thenInsertValues({ + gender: 'male', + first_name: 'Dingo', + middle_name: 'the', + last_name: 'Dog', + }) + + testSql(query, dialect, { + postgres: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when not matched and "pet"."name" = "pet"."species" then insert ("gender", "first_name", "middle_name", "last_name") values ($1, $2, $3, $4)', + parameters: ['male', 'Dingo', 'the', 'Dog'], + }, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when not matched and "pet"."name" = "pet"."species" then insert ("gender", "first_name", "middle_name", "last_name") values (@1, @2, @3, @4);', + parameters: ['male', 'Dingo', 'the', 'Dog'], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(0n) + }) + + it('should perform a merge...using table simple on...when not matched and complex and then insert values query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenNotMatchedAnd((eb) => + eb('pet.name', '=', 'Dingo').and( + 'pet.name', + '=', + eb.ref('pet.name') + ) + ) + .thenInsertValues({ + gender: 'male', + first_name: 'Dingo', + middle_name: 'the', + last_name: 'Dog', + }) + + testSql(query, dialect, { + postgres: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when not matched and ("pet"."name" = $1 and "pet"."name" = "pet"."name") then insert ("gender", "first_name", "middle_name", "last_name") values ($2, $3, $4, $5)', + parameters: ['Dingo', 'male', 'Dingo', 'the', 'Dog'], + }, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when not matched and ("pet"."name" = @1 and "pet"."name" = "pet"."name") then insert ("gender", "first_name", "middle_name", "last_name") values (@2, @3, @4, @5);', + parameters: ['Dingo', 'male', 'Dingo', 'the', 'Dog'], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(0n) + }) + + it('should perform a merge...using table simple on...when not matched and complex or then insert values query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenNotMatchedAnd((eb) => + eb('pet.name', '=', 'Dingo').or( + 'pet.name', + '=', + eb.ref('pet.name') + ) + ) + .thenInsertValues({ + gender: 'male', + first_name: 'Dingo', + middle_name: 'the', + last_name: 'Dog', + }) + + testSql(query, dialect, { + postgres: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when not matched and ("pet"."name" = $1 or "pet"."name" = "pet"."name") then insert ("gender", "first_name", "middle_name", "last_name") values ($2, $3, $4, $5)', + parameters: ['Dingo', 'male', 'Dingo', 'the', 'Dog'], + }, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when not matched and ("pet"."name" = @1 or "pet"."name" = "pet"."name") then insert ("gender", "first_name", "middle_name", "last_name") values (@2, @3, @4, @5);', + parameters: ['Dingo', 'male', 'Dingo', 'the', 'Dog'], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(0n) + }) + }) + + it('should perform a merge...using table complex on...when not matched then insert values cross ref query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', (on) => on.on('pet.owner_id', 'is', null)) + .whenNotMatched() + .thenInsertValues((eb) => ({ + gender: 'other', + first_name: eb.ref('pet.name'), + middle_name: 'the', + last_name: eb.ref('pet.species'), + })) + + testSql(query, dialect, { + postgres: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" is null when not matched then insert ("gender", "first_name", "middle_name", "last_name") values ($1, "pet"."name", $2, "pet"."species")', + parameters: ['other', 'the'], + }, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" is null when not matched then insert ("gender", "first_name", "middle_name", "last_name") values (@1, "pet"."name", @2, "pet"."species");', + parameters: ['other', 'the'], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(3n) + }) + }) + + if (dialect === 'mssql') { + describe('BySource', () => { + it('should perform a merge...using table simple on...when not matched by source then delete query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenNotMatchedBySource() + .thenDelete() + + testSql(query, dialect, { + postgres: NOT_SUPPORTED, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when not matched by source then delete;', + parameters: [], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(0n) + }) + + describe('And', () => { + it('should perform a merge...using table simple on...when not matched by source and simple binary then delete query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenNotMatchedBySourceAnd('person.first_name', '=', 'Jennifer') + .thenDelete() + + testSql(query, dialect, { + postgres: NOT_SUPPORTED, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when not matched by source and "person"."first_name" = @1 then delete;', + parameters: ['Jennifer'], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(0n) + }) + + it('should perform a merge...using table simple on...when not matched by source and simple binary ref then delete query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenNotMatchedBySourceAndRef( + 'person.first_name', + '=', + 'person.last_name' + ) + .thenDelete() + + testSql(query, dialect, { + postgres: NOT_SUPPORTED, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when not matched by source and "person"."first_name" = "person"."last_name" then delete;', + parameters: [], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(0n) + }) + + it('should perform a merge...using table simple on...when not matched by source and complex and then delete query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenNotMatchedBySourceAnd((eb) => + eb('person.gender', '=', 'female').and( + 'person.first_name', + '=', + eb.ref('person.last_name') + ) + ) + .thenDelete() + + testSql(query, dialect, { + postgres: NOT_SUPPORTED, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when not matched by source and ("person"."gender" = @1 and "person"."first_name" = "person"."last_name") then delete;', + parameters: ['female'], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(0n) + }) + + it('should perform a merge...using table simple on...when not matched by source and complex or then delete query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenNotMatchedBySourceAnd((eb) => + eb('person.gender', '=', 'female').or( + 'person.first_name', + '=', + eb.ref('person.last_name') + ) + ) + .thenDelete() + + testSql(query, dialect, { + postgres: NOT_SUPPORTED, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when not matched by source and ("person"."gender" = @1 or "person"."first_name" = "person"."last_name") then delete;', + parameters: ['female'], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(0n) + }) + }) + + describe('update', () => { + it('should perform a merge...using table simple on...when not matched by source then update set object query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenNotMatchedBySource() + .thenUpdateSet({ + middle_name: 'pet owner', + }) + + testSql(query, dialect, { + postgres: NOT_SUPPORTED, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when not matched by source then update set "middle_name" = @1;', + parameters: ['pet owner'], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(0n) + }) + + it('should perform a merge...using table simple on...when not matched by source then update set object ref query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenNotMatchedBySource() + .thenUpdateSet((eb) => ({ + first_name: eb.ref('person.last_name'), + })) + + testSql(query, dialect, { + postgres: NOT_SUPPORTED, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when not matched by source then update set "first_name" = "person"."last_name";', + parameters: [], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(0n) + }) + + it('should perform a merge...using table simple on...when not matched by source then update set column query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenNotMatchedBySource() + .thenUpdateSet('middle_name', 'pet owner') + + testSql(query, dialect, { + postgres: NOT_SUPPORTED, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when not matched by source then update set "middle_name" = @1;', + parameters: ['pet owner'], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(0n) + }) + + it('should perform a merge...using table simple on...when not matched by source then update set column ref query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenNotMatchedBySource() + .thenUpdateSet('first_name', (eb) => eb.ref('person.last_name')) + + testSql(query, dialect, { + postgres: NOT_SUPPORTED, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when not matched by source then update set "first_name" = "person"."last_name";', + parameters: [], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(0n) + }) + + it('should perform a merge...using table simple on...when not matched by source then update set complex query', async () => { + const query = ctx.db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenNotMatchedBySource() + .thenUpdate((ub) => + ub + .set('first_name', (eb) => eb.ref('person.last_name')) + .set({ + marital_status: 'single', + }) + ) + + testSql(query, dialect, { + postgres: NOT_SUPPORTED, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "person" using "pet" on "pet"."owner_id" = "person"."id" when not matched by source then update set "first_name" = "person"."last_name", "marital_status" = @1;', + parameters: ['single'], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result).to.be.instanceOf(MergeResult) + expect(result.numChangedRows).to.equal(0n) + }) + }) + }) + } + }) + }) +} diff --git a/test/node/src/test-setup.ts b/test/node/src/test-setup.ts index 3a683abe9..96dbd7c0e 100644 --- a/test/node/src/test-setup.ts +++ b/test/node/src/test-setup.ts @@ -440,7 +440,7 @@ export async function insert( const table = query.kind === 'InsertQueryNode' && - [query.into.table.schema?.name, query.into.table.identifier.name] + [query.into!.table.schema?.name, query.into!.table.identifier.name] .filter(Boolean) .join('.') diff --git a/test/typings/test-d/merge.test-d.ts b/test/typings/test-d/merge.test-d.ts new file mode 100644 index 000000000..722e1625f --- /dev/null +++ b/test/typings/test-d/merge.test-d.ts @@ -0,0 +1,408 @@ +import { expectError, expectType } from 'tsd' +import { + ExpressionBuilder, + JoinBuilder, + Kysely, + MatchedThenableMergeQueryBuilder, + MergeQueryBuilder, + MergeResult, + NotMatchedThenableMergeQueryBuilder, + UpdateQueryBuilder, + WheneableMergeQueryBuilder, + sql, +} from '..' +import { Database } from '../shared' + +async function testMergeInto(db: Kysely) { + db.mergeInto('person') + db.mergeInto('person as p') + expectError(db.mergeInto('NO_SUCH_TABLE')) + expectError(db.mergeInto('NO_SUCH_TABLE as n')) + expectError(db.mergeInto(['person'])) + expectError(db.mergeInto(['person as p'])) + expectError(db.mergeInto(db.selectFrom('person').selectAll().as('person'))) + expectError( + db.mergeInto((eb: ExpressionBuilder) => + eb.selectFrom('person').selectAll().as('person') + ) + ) + + expectType>( + db.mergeInto('person') + ) +} + +async function testUsing(db: Kysely) { + db.mergeInto('person').using('pet', 'pet.owner_id', 'person.id') + db.mergeInto('person as p').using('pet as p2', 'p2.owner_id', 'p.id') + expectError(db.mergeInto('person').using('pet')) + expectError(db.mergeInto('person').using('pet', 'pet')) + expectError(db.mergeInto('person').using('pet', 'pet.NO_SUCH_COLUMN')) + expectError(db.mergeInto('person').using('pet', 'pet.owner_id', 'person')) + expectError( + db.mergeInto('person').using('pet', 'pet.owner_id', 'person.NO_SUCH_COLUMN') + ) + expectError( + db + .mergeInto('person') + .using('NO_SUCH_TABLE as n', 'n.owner_id', 'person.id') + ) + db.mergeInto('person').using('pet', (join) => { + // already tested in join.test-d.ts + expectType>(join) + + return join.onTrue() + }) + + expectType< + WheneableMergeQueryBuilder + >(db.mergeInto('person').using('pet', 'pet.owner_id', 'person.id')) + + expectType( + await db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .executeTakeFirstOrThrow() + ) +} + +async function testWhenMatched( + baseQuery: WheneableMergeQueryBuilder +) { + baseQuery.whenMatched() + expectError(baseQuery.whenMatched('age')) + expectError(baseQuery.whenMatchedAnd('age')) + expectError(baseQuery.whenMatchedAnd('NO_SUCH_COLUMN')) + expectError(baseQuery.whenMatchedAnd('age', '>')) + expectError(baseQuery.whenMatchedAnd('age', '>', 'string')) + baseQuery.whenMatchedAnd('age', '>', 2) + expectError(baseQuery.whenMatchedAnd('age', 'NO_SUCH_OPERATOR', 2)) + baseQuery.whenMatchedAnd('person.age', sql`>`, 2) + baseQuery.whenMatchedAnd('pet.species', '>', 'cat') + baseQuery.whenMatchedAnd('age', '>', (eb) => { + expectType>(eb) + return eb.ref('person.age') + }) + expectError( + baseQuery.whenMatchedAnd('age', '>', (eb) => eb.ref('person.first_name')) + ) + baseQuery.whenMatchedAnd((eb) => { + // already tested in many places + expectType>(eb) + return eb.and([]) + }) + expectError(baseQuery.whenMatchedAndRef('age')) + expectError(baseQuery.whenMatchedAndRef('NO_SUCH_COLUMN')) + expectError(baseQuery.whenMatchedAndRef('age', '>')) + expectError(baseQuery.whenMatchedAndRef('age', '>', 'string')) + expectError(baseQuery.whenMatchedAndRef('age', '>', 2)) + baseQuery.whenMatchedAndRef('pet.name', '>', 'person.age') + baseQuery.whenMatchedAndRef('person.age', '>', 'pet.name') + baseQuery.whenMatchedAndRef('age', '>', sql`person.age`) + baseQuery.whenMatchedAndRef('age', sql`>`, 'person.age') + expectError(baseQuery.whenMatchedAndRef('age', 'NO_SUCH_OPERATOR', 'age')) + + type ExpectedReturnType = MatchedThenableMergeQueryBuilder< + Database, + 'person', + 'pet', + 'person' | 'pet', + MergeResult + > + expectType(baseQuery.whenMatched()) + expectType(baseQuery.whenMatchedAnd('age', '>', 2)) + expectType( + baseQuery.whenMatchedAndRef('pet.name', '>', 'person.age') + ) +} + +async function testWhenNotMatched( + baseQuery: WheneableMergeQueryBuilder +) { + baseQuery.whenNotMatched() + expectError(baseQuery.whenNotMatched('species')) + expectError(baseQuery.whenNotMatchedAnd('species')) + expectError(baseQuery.whenNotMatchedAnd('NO_SUCH_COLUMN')) + expectError(baseQuery.whenNotMatchedAnd('species', '>')) + expectError(baseQuery.whenNotMatchedAnd('species', '>', 'string')) + expectError(baseQuery.whenNotMatchedAnd('species', '>', 2)) + baseQuery.whenNotMatchedAnd('species', '>', 'dog') + expectError( + baseQuery.whenNotMatchedAnd('species', 'NOT_SUCH_OPERATOR', 'dog') + ) + // when not matched can only reference the source table's columns. + expectError(baseQuery.whenNotMatchedAnd('age', '>', 'dog')) + baseQuery.whenNotMatchedAnd('species', sql`>`, 'dog') + baseQuery.whenNotMatchedAnd('pet.species', '>', sql<'dog'>`dog`) + baseQuery.whenNotMatchedAnd('species', '>', (eb) => { + // already tested in many places + expectType>(eb) + return eb.ref('pet.species') + }) + expectError( + baseQuery.whenNotMatchedAnd('species', '>', (eb) => eb.ref('pet.owner_id')) + ) + baseQuery.whenNotMatchedAnd((eb) => { + // already tested in many places + expectType>(eb) + return eb.and([]) + }) + expectError(baseQuery.whenNotMatchedAndRef('species')) + expectError(baseQuery.whenNotMatchedAndRef('NO_SUCH_COLUMN')) + expectError(baseQuery.whenNotMatchedAndRef('species', '>')) + expectError(baseQuery.whenNotMatchedAndRef('species', '>', 'string')) + expectError(baseQuery.whenNotMatchedAndRef('species', '>', 2)) + baseQuery.whenNotMatchedAndRef('pet.name', '>', 'pet.species') + // when not matched can only reference the source table's columns. + expectError( + baseQuery.whenNotMatchedAndRef('pet.name', '>', 'person.first_name') + ) + expectError( + baseQuery.whenNotMatchedAndRef('person.first_name', '>', 'pet.species') + ) + baseQuery.whenNotMatchedAndRef('species', '>', sql`person.age`) + baseQuery.whenNotMatchedAndRef('species', sql`>`, 'pet.species') + expectError( + baseQuery.whenNotMatchedAndRef('species', 'NO_SUCH_OPERATOR', 'name') + ) + + type ExpectedReturnType = NotMatchedThenableMergeQueryBuilder< + Database, + 'person', + 'pet', + MergeResult + > + expectType(baseQuery.whenNotMatched()) + expectType( + baseQuery.whenNotMatchedAnd('species', '>', 'dog') + ) + expectType( + baseQuery.whenNotMatchedAndRef('pet.name', '>', 'pet.species') + ) +} + +async function testWhenNotMatchedBySource( + baseQuery: WheneableMergeQueryBuilder +) { + baseQuery.whenNotMatchedBySource() + expectError(baseQuery.whenNotMatchedBySource('age')) + expectError(baseQuery.whenNotMatchedBySourceAnd('age')) + expectError(baseQuery.whenNotMatchedBySourceAnd('NO_SUCH_COLUMN')) + expectError(baseQuery.whenNotMatchedBySourceAnd('age', '>')) + expectError(baseQuery.whenNotMatchedBySourceAnd('age', '>', 'string')) + baseQuery.whenNotMatchedBySourceAnd('age', '>', 2) + expectError( + baseQuery.whenNotMatchedBySourceAnd('age', 'NOT_SUCH_OPERATOR', 'dog') + ) + // when not matched by source can only reference the target table's columns. + expectError(baseQuery.whenNotMatchedBySourceAnd('species', '>', 'dog')) + baseQuery.whenNotMatchedBySourceAnd('age', sql`>`, 2) + baseQuery.whenNotMatchedBySourceAnd('person.age', '>', sql<2>`2`) + baseQuery.whenNotMatchedBySourceAnd('age', '>', (eb) => { + // already tested in many places + expectType>(eb) + return eb.ref('person.age') + }) + expectError( + baseQuery.whenNotMatchedBySourceAnd('age', '>', (eb) => + eb.ref('person.gender') + ) + ) + baseQuery.whenNotMatchedBySourceAnd((eb) => { + // already tested in many places + expectType>(eb) + return eb.and([]) + }) + expectError(baseQuery.whenNotMatchedBySourceAndRef('age')) + expectError(baseQuery.whenNotMatchedBySourceAndRef('NO_SUCH_COLUMN')) + expectError(baseQuery.whenNotMatchedBySourceAndRef('age', '>')) + expectError(baseQuery.whenNotMatchedBySourceAndRef('age', '>', 'string')) + expectError(baseQuery.whenNotMatchedBySourceAndRef('age', '>', 2)) + baseQuery.whenNotMatchedBySourceAndRef( + 'person.first_name', + '>', + 'person.last_name' + ) + // when not matched by source can only reference the target table's columns. + expectError( + baseQuery.whenNotMatchedBySourceAndRef('person.first_name', '>', 'pet.name') + ) + expectError( + baseQuery.whenNotMatchedBySourceAndRef('pet.name', '>', 'person.first_name') + ) + + type ExpectedReturnType = MatchedThenableMergeQueryBuilder< + Database, + 'person', + 'pet', + 'person', + MergeResult + > + expectType(baseQuery.whenNotMatchedBySource()) + expectType( + baseQuery.whenNotMatchedBySourceAnd('age', '>', 2) + ) + expectType( + baseQuery.whenNotMatchedBySourceAndRef( + 'person.first_name', + '>', + 'person.last_name' + ) + ) +} + +async function testThenDelete( + baseQuery: MatchedThenableMergeQueryBuilder< + Database, + 'person', + 'pet', + 'person' | 'pet', + MergeResult + > +) { + baseQuery.thenDelete() + expectError(baseQuery.thenDelete('person')) + expectError(baseQuery.thenDelete(['person'])) + + expectType< + WheneableMergeQueryBuilder + >(baseQuery.thenDelete()) +} + +async function testThenDoNothing( + matchedBaseQuery: MatchedThenableMergeQueryBuilder< + Database, + 'person', + 'pet', + 'person' | 'pet', + MergeResult + >, + notMatchedBaseQuery: NotMatchedThenableMergeQueryBuilder< + Database, + 'person', + 'pet', + MergeResult + > +) { + matchedBaseQuery.thenDoNothing() + expectError(matchedBaseQuery.thenDoNothing('person')) + expectError(matchedBaseQuery.thenDoNothing(['person'])) + notMatchedBaseQuery.thenDoNothing() + expectError(notMatchedBaseQuery.thenDoNothing('person')) + expectError(notMatchedBaseQuery.thenDoNothing(['person'])) + + expectType< + WheneableMergeQueryBuilder + >(matchedBaseQuery.thenDoNothing()) + expectType< + WheneableMergeQueryBuilder + >(notMatchedBaseQuery.thenDoNothing()) +} + +async function testThenUpdate( + baseQuery: MatchedThenableMergeQueryBuilder< + Database, + 'person', + 'pet', + 'person' | 'pet', + MergeResult + >, + limitedBaseQuery: MatchedThenableMergeQueryBuilder< + Database, + 'person', + 'pet', + 'person', + MergeResult + > +) { + expectError(baseQuery.thenUpdate()) + expectError(baseQuery.thenUpdate('person')) + expectError(baseQuery.thenUpdate(['person'])) + expectError(baseQuery.thenUpdate({ age: 2 })) + baseQuery.thenUpdate((ub) => { + expectType>( + ub + ) + return ub + }) + limitedBaseQuery.thenUpdate((ub) => { + expectType>(ub) + return ub + }) + + baseQuery.thenUpdateSet({ age: 2 }) + expectError(baseQuery.thenUpdateSet({ age: 'not_a_number' })) + baseQuery.thenUpdateSet((eb) => { + expectType>(eb) + return { first_name: eb.ref('pet.name') } + }) + limitedBaseQuery.thenUpdateSet((eb) => { + expectType>(eb) + return { last_name: eb.ref('person.first_name') } + }) + baseQuery.thenUpdateSet('age', 2) + expectError(baseQuery.thenUpdateSet('age', 'not_a_number')) + baseQuery.thenUpdateSet('first_name', (eb) => { + expectType>(eb) + return eb.ref('pet.name') + }) + limitedBaseQuery.thenUpdateSet('last_name', (eb) => { + expectType>(eb) + return eb.ref('person.first_name') + }) + + type ExpectedReturnType = WheneableMergeQueryBuilder< + Database, + 'person', + 'pet', + MergeResult + > + expectType(baseQuery.thenUpdate((ub) => ub)) + expectType(baseQuery.thenUpdateSet({ age: 2 })) + expectType( + baseQuery.thenUpdateSet((eb) => ({ first_name: eb.ref('pet.name') })) + ) + expectType(baseQuery.thenUpdateSet('age', 2)) +} + +async function testThenInsert( + baseQuery: NotMatchedThenableMergeQueryBuilder< + Database, + 'person', + 'pet', + MergeResult + > +) { + expectError(baseQuery.thenInsertValues()) + expectError(baseQuery.thenInsertValues('person')) + expectError(baseQuery.thenInsertValues(['person'])) + expectError(baseQuery.thenInsertValues({ age: 2 })) + baseQuery.thenInsertValues({ age: 2, first_name: 'Moshe', gender: 'other' }) + expectError( + baseQuery.thenInsertValues({ + age: 'not_a_number', + first_name: 'Moshe', + gender: 'other', + }) + ) + baseQuery.thenInsertValues((eb) => { + expectType>(eb) + return { age: 2, first_name: eb.ref('pet.name'), gender: 'other' } + }) + expectError( + baseQuery.thenInsertValues((eb) => { + expectType>(eb) + return { + age: 'not_a_number', + first_name: eb.ref('pet.name'), + gender: 'other', + } + }) + ) + + expectType< + WheneableMergeQueryBuilder + >( + baseQuery.thenInsertValues({ age: 2, first_name: 'Moshe', gender: 'other' }) + ) +}