diff --git a/src/dialect/dialect-adapter-base.ts b/src/dialect/dialect-adapter-base.ts index 8eda859b3..7910bf394 100644 --- a/src/dialect/dialect-adapter-base.ts +++ b/src/dialect/dialect-adapter-base.ts @@ -20,6 +20,10 @@ export abstract class DialectAdapterBase implements DialectAdapter { return false } + get supportsOutput(): boolean { + return false + } + abstract acquireMigrationLock( db: Kysely, options: MigrationLockOptions, diff --git a/src/dialect/dialect-adapter.ts b/src/dialect/dialect-adapter.ts index 0a8159dc0..d4020278b 100644 --- a/src/dialect/dialect-adapter.ts +++ b/src/dialect/dialect-adapter.ts @@ -31,6 +31,12 @@ export interface DialectAdapter { */ readonly supportsReturning: boolean + /** + * Whether or not this dialect supports the `output` clause in inserts + * updates and deletes. + */ + readonly supportsOutput?: boolean + /** * This method is used to acquire a lock for the migrations so that * it's not possible for two migration operations to run in parallel. diff --git a/src/dialect/mssql/mssql-adapter.ts b/src/dialect/mssql/mssql-adapter.ts index 52b81d194..721178907 100644 --- a/src/dialect/mssql/mssql-adapter.ts +++ b/src/dialect/mssql/mssql-adapter.ts @@ -12,10 +12,8 @@ export class MssqlAdapter extends DialectAdapterBase { return true } - get supportsReturning(): boolean { - // mssql should support returning with the `output` clause. - // we need to figure this out when we'll introduce support for it. - return false + get supportsOutput(): boolean { + return true } async acquireMigrationLock(db: Kysely): Promise { diff --git a/src/index.ts b/src/index.ts index ba895445f..b27a601d2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ export * from './expression/expression-wrapper.js' export * from './query-builder/where-interface.js' export * from './query-builder/returning-interface.js' +export * from './query-builder/output-interface.js' export * from './query-builder/having-interface.js' export * from './query-builder/select-query-builder.js' export * from './query-builder/insert-query-builder.js' @@ -203,6 +204,7 @@ export * from './operation-node/merge-query-node.js' export * from './operation-node/matched-node.js' export * from './operation-node/fetch-node.js' export * from './operation-node/top-node.js' +export * from './operation-node/output-node.js' export * from './util/column-type.js' export * from './util/compilable.js' diff --git a/src/operation-node/delete-query-node.ts b/src/operation-node/delete-query-node.ts index c949fc33f..af587c7b4 100644 --- a/src/operation-node/delete-query-node.ts +++ b/src/operation-node/delete-query-node.ts @@ -11,6 +11,7 @@ import { OrderByItemNode } from './order-by-item-node.js' import { ExplainNode } from './explain-node.js' import { UsingNode } from './using-node.js' import { TopNode } from './top-node.js' +import { OutputNode } from './output-node.js' export interface DeleteQueryNode extends OperationNode { readonly kind: 'DeleteQueryNode' @@ -24,6 +25,7 @@ export interface DeleteQueryNode extends OperationNode { readonly limit?: LimitNode readonly explain?: ExplainNode readonly top?: TopNode + readonly output?: OutputNode } /** diff --git a/src/operation-node/insert-query-node.ts b/src/operation-node/insert-query-node.ts index 5a69dccfe..e95240a22 100644 --- a/src/operation-node/insert-query-node.ts +++ b/src/operation-node/insert-query-node.ts @@ -4,6 +4,7 @@ import { ExplainNode } from './explain-node.js' import { OnConflictNode } from './on-conflict-node.js' import { OnDuplicateKeyNode } from './on-duplicate-key-node.js' import { OperationNode } from './operation-node.js' +import { OutputNode } from './output-node.js' import { ReturningNode } from './returning-node.js' import { TableNode } from './table-node.js' import { TopNode } from './top-node.js' @@ -25,6 +26,7 @@ export interface InsertQueryNode extends OperationNode { readonly explain?: ExplainNode readonly defaultValues?: boolean readonly top?: TopNode + readonly output?: OutputNode } /** diff --git a/src/operation-node/merge-query-node.ts b/src/operation-node/merge-query-node.ts index fff9eaa5a..7b202d4c0 100644 --- a/src/operation-node/merge-query-node.ts +++ b/src/operation-node/merge-query-node.ts @@ -2,6 +2,7 @@ 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 { OutputNode } from './output-node.js' import { TableNode } from './table-node.js' import { TopNode } from './top-node.js' import { WhenNode } from './when-node.js' @@ -14,6 +15,7 @@ export interface MergeQueryNode extends OperationNode { readonly whens?: ReadonlyArray readonly with?: WithNode readonly top?: TopNode + readonly output?: OutputNode } /** diff --git a/src/operation-node/operation-node-transformer.ts b/src/operation-node/operation-node-transformer.ts index 063ba46ba..bd4b74ef1 100644 --- a/src/operation-node/operation-node-transformer.ts +++ b/src/operation-node/operation-node-transformer.ts @@ -93,6 +93,7 @@ import { AddIndexNode } from './add-index-node.js' import { CastNode } from './cast-node.js' import { FetchNode } from './fetch-node.js' import { TopNode } from './top-node.js' +import { OutputNode } from './output-node.js' /** * Transforms an operation node tree into another one. @@ -220,6 +221,7 @@ export class OperationNodeTransformer { CastNode: this.transformCast.bind(this), FetchNode: this.transformFetch.bind(this), TopNode: this.transformTop.bind(this), + OutputNode: this.transformOutput.bind(this), }) transformNode(node: T): T { @@ -384,6 +386,7 @@ export class OperationNodeTransformer { explain: this.transformNode(node.explain), defaultValues: node.defaultValues, top: this.transformNode(node.top), + output: this.transformNode(node.output), }) } @@ -407,6 +410,7 @@ export class OperationNodeTransformer { limit: this.transformNode(node.limit), explain: this.transformNode(node.explain), top: this.transformNode(node.top), + output: this.transformNode(node.output), }) } @@ -514,6 +518,7 @@ export class OperationNodeTransformer { explain: this.transformNode(node.explain), limit: this.transformNode(node.limit), top: this.transformNode(node.top), + output: this.transformNode(node.output), }) } @@ -1008,6 +1013,7 @@ export class OperationNodeTransformer { whens: this.transformNodeList(node.whens), with: this.transformNode(node.with), top: this.transformNode(node.top), + output: this.transformNode(node.output), }) } @@ -1054,6 +1060,13 @@ export class OperationNodeTransformer { }) } + protected transformOutput(node: OutputNode): OutputNode { + return requireAllProps({ + kind: 'OutputNode', + selections: this.transformNodeList(node.selections), + }) + } + protected transformDataType(node: DataTypeNode): DataTypeNode { // An Object.freezed leaf node. No need to clone. return node diff --git a/src/operation-node/operation-node-visitor.ts b/src/operation-node/operation-node-visitor.ts index a3ea9d740..f88f12539 100644 --- a/src/operation-node/operation-node-visitor.ts +++ b/src/operation-node/operation-node-visitor.ts @@ -95,6 +95,7 @@ import { AddIndexNode } from './add-index-node.js' import { CastNode } from './cast-node.js' import { FetchNode } from './fetch-node.js' import { TopNode } from './top-node.js' +import { OutputNode } from './output-node.js' export abstract class OperationNodeVisitor { protected readonly nodeStack: OperationNode[] = [] @@ -197,6 +198,7 @@ export abstract class OperationNodeVisitor { CastNode: this.visitCast.bind(this), FetchNode: this.visitFetch.bind(this), TopNode: this.visitTop.bind(this), + OutputNode: this.visitOutput.bind(this), }) protected readonly visitNode = (node: OperationNode): void => { @@ -307,4 +309,5 @@ export abstract class OperationNodeVisitor { protected abstract visitCast(node: CastNode): void protected abstract visitFetch(node: FetchNode): void protected abstract visitTop(node: TopNode): void + protected abstract visitOutput(node: OutputNode): void } diff --git a/src/operation-node/operation-node.ts b/src/operation-node/operation-node.ts index c58f3c75e..6476a24eb 100644 --- a/src/operation-node/operation-node.ts +++ b/src/operation-node/operation-node.ts @@ -91,6 +91,7 @@ export type OperationNodeKind = | 'CastNode' | 'FetchNode' | 'TopNode' + | 'OutputNode' export interface OperationNode { readonly kind: OperationNodeKind diff --git a/src/operation-node/output-node.ts b/src/operation-node/output-node.ts new file mode 100644 index 000000000..6f28c6b4b --- /dev/null +++ b/src/operation-node/output-node.ts @@ -0,0 +1,35 @@ +import { freeze } from '../util/object-utils.js' +import { OperationNode } from './operation-node.js' + +export interface OutputNode extends OperationNode { + readonly kind: 'OutputNode' + readonly selections: ReadonlyArray +} + +/** + * @internal + */ +export const OutputNode = freeze({ + is(node: OperationNode): node is OutputNode { + return node.kind === 'OutputNode' + }, + + create(selections: ReadonlyArray): OutputNode { + return freeze({ + kind: 'OutputNode', + selections: freeze(selections), + }) + }, + + cloneWithSelections( + output: OutputNode, + selections: ReadonlyArray + ): OutputNode { + return freeze({ + ...output, + selections: output.selections + ? freeze([...output.selections, ...selections]) + : freeze(selections), + }) + }, +}) diff --git a/src/operation-node/query-node.ts b/src/operation-node/query-node.ts index 0d0848eee..38cfdf995 100644 --- a/src/operation-node/query-node.ts +++ b/src/operation-node/query-node.ts @@ -13,6 +13,7 @@ import { ExplainFormat } from '../util/explainable.js' import { Expression } from '../expression/expression.js' import { MergeQueryNode } from './merge-query-node.js' import { TopNode } from './top-node.js' +import { OutputNode } from './output-node.js' export type QueryNode = | SelectQueryNode @@ -26,6 +27,7 @@ type HasWhere = { where?: WhereNode } type HasReturning = { returning?: ReturningNode } type HasExplain = { explain?: ExplainNode } type HasTop = { top?: TopNode } +type HasOutput = { output?: OutputNode } /** * @internal @@ -100,4 +102,16 @@ export const QueryNode = freeze({ top, }) }, + + cloneWithOutput( + node: T, + selections: ReadonlyArray, + ): T { + return freeze({ + ...node, + output: node.output + ? OutputNode.cloneWithSelections(node.output, selections) + : OutputNode.create(selections), + }) + }, }) diff --git a/src/operation-node/update-query-node.ts b/src/operation-node/update-query-node.ts index 8bdb9a73f..82d4b842a 100644 --- a/src/operation-node/update-query-node.ts +++ b/src/operation-node/update-query-node.ts @@ -11,6 +11,7 @@ import { FromNode } from './from-node.js' import { ExplainNode } from './explain-node.js' import { LimitNode } from './limit-node.js' import { TopNode } from './top-node.js' +import { OutputNode } from './output-node.js' export type UpdateValuesNode = ValueListNode | PrimitiveValueListNode @@ -26,6 +27,7 @@ export interface UpdateQueryNode extends OperationNode { readonly explain?: ExplainNode readonly limit?: LimitNode readonly top?: TopNode + readonly output?: OutputNode } /** diff --git a/src/parser/returning-parser.ts b/src/parser/returning-parser.ts index abc8f8cf3..dac0eee72 100644 --- a/src/parser/returning-parser.ts +++ b/src/parser/returning-parser.ts @@ -1,38 +1,29 @@ 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 { Selection, AllSelection, CallbackSelection } from './select-parser.js' -export type ReturningRow< - DB, - TB extends keyof DB, - O, - SE, -> = O extends InsertResult +export type ReturningRow = O extends + | InsertResult + | DeleteResult + | UpdateResult + | MergeResult ? Selection - : O extends DeleteResult - ? Selection - : O extends UpdateResult - ? Selection - : O & Selection + : O & Selection -export type ReturningCallbackRow< - DB, - TB extends keyof DB, - O, - CB, -> = O extends InsertResult +export type ReturningCallbackRow = O extends + | InsertResult + | DeleteResult + | UpdateResult + | MergeResult ? CallbackSelection - : O extends DeleteResult - ? CallbackSelection - : O extends UpdateResult - ? CallbackSelection - : O & CallbackSelection + : O & CallbackSelection -export type ReturningAllRow = O extends InsertResult +export type ReturningAllRow = O extends + | InsertResult + | DeleteResult + | UpdateResult + | MergeResult ? AllSelection - : O extends DeleteResult - ? AllSelection - : O extends UpdateResult - ? AllSelection - : O & AllSelection + : O & AllSelection diff --git a/src/query-builder/delete-query-builder.ts b/src/query-builder/delete-query-builder.ts index 529dc8091..239a78991 100644 --- a/src/query-builder/delete-query-builder.ts +++ b/src/query-builder/delete-query-builder.ts @@ -72,11 +72,19 @@ import { parseValueExpression, } from '../parser/value-parser.js' import { parseTop } from '../parser/top-parser.js' +import { + OutputCallback, + OutputExpression, + OutputInterface, + SelectExpressionFromOutputCallback, + SelectExpressionFromOutputExpression, +} from './output-interface.js' export class DeleteQueryBuilder implements WhereInterface, ReturningInterface, + OutputInterface, OperationNodeSource, Compilable, Explainable, @@ -617,6 +625,52 @@ export class DeleteQueryBuilder }) } + output>( + selections: readonly OE[] + ): DeleteQueryBuilder< + DB, + TB, + ReturningRow> + > + + output>( + callback: CB + ): DeleteQueryBuilder< + DB, + TB, + ReturningRow> + > + + output>( + selection: OE + ): DeleteQueryBuilder< + DB, + TB, + ReturningRow> + > + + output(args: any): any { + return new DeleteQueryBuilder({ + ...this.#props, + queryNode: QueryNode.cloneWithOutput( + this.#props.queryNode, + parseSelectArg(args) + ), + }) + } + + outputAll( + table: 'deleted' + ): DeleteQueryBuilder> { + return new DeleteQueryBuilder({ + ...this.#props, + queryNode: QueryNode.cloneWithOutput( + this.#props.queryNode, + parseSelectAll(table) + ), + }) + } + /** * Clears all `returning` clauses from the query. * @@ -980,14 +1034,19 @@ export class DeleteQueryBuilder */ async execute(): Promise[]> { const compiledQuery = this.compile() - const query = compiledQuery.query as DeleteQueryNode const result = await this.#props.executor.executeQuery( compiledQuery, this.#props.queryId, ) - if (this.#props.executor.adapter.supportsReturning && query.returning) { + const { adapter } = this.#props.executor + const query = compiledQuery.query as DeleteQueryNode + + if ( + (query.returning && adapter.supportsReturning) || + (query.output && adapter.supportsOutput) + ) { return result.rows as any } diff --git a/src/query-builder/insert-query-builder.ts b/src/query-builder/insert-query-builder.ts index f1c40130b..e40905128 100644 --- a/src/query-builder/insert-query-builder.ts +++ b/src/query-builder/insert-query-builder.ts @@ -31,6 +31,7 @@ import { OnDuplicateKeyNode } from '../operation-node/on-duplicate-key-node.js' import { InsertResult } from './insert-result.js' import { KyselyPlugin } from '../plugin/kysely-plugin.js' import { + ReturningAllRow, ReturningCallbackRow, ReturningRow, } from '../parser/returning-parser.js' @@ -59,10 +60,18 @@ import { Expression } from '../expression/expression.js' import { KyselyTypeError } from '../util/type-error.js' import { Streamable } from '../util/streamable.js' import { parseTop } from '../parser/top-parser.js' +import { + OutputCallback, + OutputExpression, + OutputInterface, + SelectExpressionFromOutputCallback, + SelectExpressionFromOutputExpression, +} from './output-interface.js' export class InsertQueryBuilder implements ReturningInterface, + OutputInterface, OperationNodeSource, Compilable, Explainable, @@ -665,6 +674,52 @@ export class InsertQueryBuilder }) } + output>( + selections: readonly OE[] + ): InsertQueryBuilder< + DB, + TB, + ReturningRow> + > + + output>( + callback: CB + ): InsertQueryBuilder< + DB, + TB, + ReturningRow> + > + + output>( + selection: OE + ): InsertQueryBuilder< + DB, + TB, + ReturningRow> + > + + output(args: any): any { + return new InsertQueryBuilder({ + ...this.#props, + queryNode: QueryNode.cloneWithOutput( + this.#props.queryNode, + parseSelectArg(args) + ), + }) + } + + outputAll( + table: 'inserted' + ): InsertQueryBuilder> { + return new InsertQueryBuilder({ + ...this.#props, + queryNode: QueryNode.cloneWithOutput( + this.#props.queryNode, + parseSelectAll(table) + ), + }) + } + /** * Clears all `returning` clauses from the query. * @@ -901,14 +956,19 @@ export class InsertQueryBuilder */ async execute(): Promise[]> { const compiledQuery = this.compile() - const query = compiledQuery.query as InsertQueryNode const result = await this.#props.executor.executeQuery( compiledQuery, this.#props.queryId, ) - if (this.#props.executor.adapter.supportsReturning && query.returning) { + const { adapter } = this.#props.executor + const query = compiledQuery.query as InsertQueryNode + + if ( + (query.returning && adapter.supportsReturning) || + (query.output && adapter.supportsOutput) + ) { return result.rows as any } diff --git a/src/query-builder/merge-query-builder.ts b/src/query-builder/merge-query-builder.ts index fc3c7a1cb..f9e7e61b7 100644 --- a/src/query-builder/merge-query-builder.ts +++ b/src/query-builder/merge-query-builder.ts @@ -22,6 +22,8 @@ import { } from '../parser/join-parser.js' import { parseMergeThen, parseMergeWhen } from '../parser/merge-parser.js' import { ReferenceExpression } from '../parser/reference-parser.js' +import { ReturningAllRow, ReturningRow } from '../parser/returning-parser.js' +import { parseSelectAll, parseSelectArg } from '../parser/select-parser.js' import { TableExpression } from '../parser/table-parser.js' import { parseTop } from '../parser/top-parser.js' import { @@ -50,9 +52,19 @@ import { NoResultErrorConstructor, isNoResultErrorConstructor, } from './no-result-error.js' +import { + OutputCallback, + OutputExpression, + OutputInterface, + OutputPrefix, + SelectExpressionFromOutputCallback, + SelectExpressionFromOutputExpression, +} from './output-interface.js' import { UpdateQueryBuilder } from './update-query-builder.js' -export class MergeQueryBuilder { +export class MergeQueryBuilder + implements OutputInterface +{ readonly #props: MergeQueryBuilderProps constructor(props: MergeQueryBuilderProps) { @@ -171,6 +183,52 @@ export class MergeQueryBuilder { ), }) } + + output>( + selections: readonly OE[], + ): MergeQueryBuilder< + DB, + TT, + ReturningRow> + > + + output>( + callback: CB, + ): MergeQueryBuilder< + DB, + TT, + ReturningRow> + > + + output>( + selection: OE, + ): MergeQueryBuilder< + DB, + TT, + ReturningRow> + > + + output(args: any): any { + return new MergeQueryBuilder({ + ...this.#props, + queryNode: QueryNode.cloneWithOutput( + this.#props.queryNode, + parseSelectArg(args), + ), + }) + } + + outputAll( + table: OutputPrefix, + ): MergeQueryBuilder> { + return new MergeQueryBuilder({ + ...this.#props, + queryNode: QueryNode.cloneWithOutput( + this.#props.queryNode, + parseSelectAll(table), + ), + }) + } } preventAwait( @@ -190,7 +248,7 @@ export class WheneableMergeQueryBuilder< ST extends keyof DB, O, > - implements Compilable, OperationNodeSource + implements Compilable, OutputInterface, OperationNodeSource { readonly #props: MergeQueryBuilderProps @@ -489,6 +547,55 @@ export class WheneableMergeQueryBuilder< return this.#whenNotMatched([lhs, op, rhs], true, true) } + output>( + selections: readonly OE[], + ): WheneableMergeQueryBuilder< + DB, + TT, + ST, + ReturningRow> + > + + output>( + callback: CB, + ): WheneableMergeQueryBuilder< + DB, + TT, + ST, + ReturningRow> + > + + output>( + selection: OE, + ): WheneableMergeQueryBuilder< + DB, + TT, + ST, + ReturningRow> + > + + output(args: any): any { + return new WheneableMergeQueryBuilder({ + ...this.#props, + queryNode: QueryNode.cloneWithOutput( + this.#props.queryNode, + parseSelectArg(args), + ), + }) + } + + outputAll( + table: OutputPrefix, + ): WheneableMergeQueryBuilder> { + return new WheneableMergeQueryBuilder({ + ...this.#props, + queryNode: QueryNode.cloneWithOutput( + this.#props.queryNode, + parseSelectAll(table), + ), + }) + } + #whenNotMatched( args: any[], refRight: boolean = false, @@ -616,6 +723,13 @@ export class WheneableMergeQueryBuilder< this.#props.queryId, ) + if ( + (compiledQuery.query as MergeQueryNode).output && + this.#props.executor.adapter.supportsOutput + ) { + return result.rows as any + } + return [new MergeResult(result.numAffectedRows) as any] } diff --git a/src/query-builder/output-interface.ts b/src/query-builder/output-interface.ts new file mode 100644 index 000000000..8a44502fe --- /dev/null +++ b/src/query-builder/output-interface.ts @@ -0,0 +1,158 @@ +import { ExpressionBuilder } from '../expression/expression-builder.js' +import { AliasedExpressionOrFactory } from '../parser/expression-parser.js' +import { ReturningAllRow, ReturningRow } from '../parser/returning-parser.js' +import { + AnyAliasedColumnWithTable, + AnyColumnWithTable, +} from '../util/type-utils.js' + +export interface OutputInterface< + DB, + TB extends keyof DB, + O, + OP extends OutputPrefix = OutputPrefix, +> { + /** + * Allows you to return data from modified rows. + * + * On supported databases like MS SQL Server (MSSQL), this method can be chained + * to `insert`, `update`, `delete` and `merge` queries to return data. + * + * Also see the {@link outputAll} method. + * + * ### Examples + * + * Return one column: + * + * ```ts + * const { id } = await db + * .insertInto('person') + * .output('inserted.id') + * .values({ + * first_name: 'Jennifer', + * last_name: 'Aniston' + * }) + * .executeTakeFirst() + * ``` + * + * Return multiple columns: + * + * ```ts + * const { id, first_name } = await db + * .updateTable('person') + * .set({ first_name: 'John', last_name: 'Doe' }) + * .output([ + * 'deleted.first_name as old_first_name', + * 'deleted.last_name as old_last_name', + * 'inserted.first_name as new_first_name', + * 'inserted.last_name as new_last_name', + * ]) + * .where('created_at', '<', new Date()) + * .executeTakeFirst() + * ``` + * + * Return arbitrary expressions: + * + * ```ts + * import { sql } from 'kysely' + * + * const { id, full_name } = await db + * .deleteFrom('person') + * .output((eb) => sql`concat(${eb.ref('deleted.first_name')}, ' ', ${eb.ref('deleted.last_name')})`.as('full_name') + * .where('created_at', '<', new Date()) + * .executeTakeFirst() + * ``` + * + * Return the action performed on the row: + * + * ```ts + * await db + * .mergeInto('person') + * .using('pet', 'pet.owner_id', 'person.id') + * .whenMatched() + * .thenDelete() + * .whenNotMatched() + * .thenInsertValues({ + * first_name: 'John', + * last_name: 'Doe', + * gender: 'male' + * }) + * .output([ + * 'inserted.id as inserted_id', + * 'deleted.id as deleted_id', + * ]) + * ``` + */ + output>( + selections: ReadonlyArray, + ): OutputInterface< + DB, + TB, + ReturningRow>, + OP + > + + output>( + callback: CB, + ): OutputInterface< + DB, + TB, + ReturningRow>, + OP + > + + output>( + selection: OE, + ): OutputInterface< + DB, + TB, + ReturningRow>, + OP + > + + /** + * Adds an `output {prefix}.*` to an `insert`/`update`/`delete`/`merge` query on databases + * that support `output` such as MS SQL Server (MSSQL). + * + * Also see the {@link output} method. + */ + outputAll(table: OP): OutputInterface, OP> +} + +export type OutputPrefix = 'deleted' | 'inserted' + +export type OutputDatabase< + DB, + TB extends keyof DB, + OP extends OutputPrefix = OutputPrefix, +> = { + [K in OP]: DB[TB] +} + +export type OutputExpression< + DB, + TB extends keyof DB, + OP extends OutputPrefix = OutputPrefix, + ODB = OutputDatabase, + OTB extends keyof ODB = keyof ODB, +> = + | AnyAliasedColumnWithTable + | AnyColumnWithTable + | AliasedExpressionOrFactory + +export type OutputCallback< + DB, + TB extends keyof DB, + OP extends OutputPrefix = OutputPrefix, +> = ( + eb: ExpressionBuilder, OP>, +) => ReadonlyArray> + +export type SelectExpressionFromOutputExpression = + OE extends `${OutputPrefix}.${infer C}` ? C : OE + +export type SelectExpressionFromOutputCallback = CB extends ( + eb: ExpressionBuilder, +) => ReadonlyArray + ? SelectExpressionFromOutputExpression + : never diff --git a/src/query-builder/returning-interface.ts b/src/query-builder/returning-interface.ts index 3cd07d375..b89cc6bbe 100644 --- a/src/query-builder/returning-interface.ts +++ b/src/query-builder/returning-interface.ts @@ -80,6 +80,8 @@ export interface ReturningInterface { /** * Adds a `returning *` to an insert/update/delete query on databases * that support `returning` such as PostgreSQL. + * + * Also see the {@link returning} method. */ returningAll(): ReturningInterface> } diff --git a/src/query-builder/update-query-builder.ts b/src/query-builder/update-query-builder.ts index 937918b8d..443c0b382 100644 --- a/src/query-builder/update-query-builder.ts +++ b/src/query-builder/update-query-builder.ts @@ -20,6 +20,7 @@ import { SelectCallback, } from '../parser/select-parser.js' import { + ReturningAllRow, ReturningCallbackRow, ReturningRow, } from '../parser/returning-parser.js' @@ -72,11 +73,20 @@ import { } from '../parser/value-parser.js' import { LimitNode } from '../operation-node/limit-node.js' import { parseTop } from '../parser/top-parser.js' +import { + OutputCallback, + OutputExpression, + OutputInterface, + OutputPrefix, + SelectExpressionFromOutputCallback, + SelectExpressionFromOutputExpression, +} from './output-interface.js' export class UpdateQueryBuilder implements WhereInterface, ReturningInterface, + OutputInterface, OperationNodeSource, Compilable, Explainable, @@ -682,6 +692,55 @@ export class UpdateQueryBuilder }) } + output>( + selections: readonly OE[] + ): UpdateQueryBuilder< + DB, + UT, + TB, + ReturningRow> + > + + output>( + callback: CB + ): UpdateQueryBuilder< + DB, + UT, + TB, + ReturningRow> + > + + output>( + selection: OE + ): UpdateQueryBuilder< + DB, + UT, + TB, + ReturningRow> + > + + output(args: any): any { + return new UpdateQueryBuilder({ + ...this.#props, + queryNode: QueryNode.cloneWithOutput( + this.#props.queryNode, + parseSelectArg(args) + ), + }) + } + + outputAll( + table: OutputPrefix + ): UpdateQueryBuilder> { + return new UpdateQueryBuilder({ + ...this.#props, + queryNode: QueryNode.cloneWithOutput( + this.#props.queryNode, + parseSelectAll(table) + ), + }) + } + /** * Clears all `returning` clauses from the query. * @@ -926,14 +985,19 @@ export class UpdateQueryBuilder */ async execute(): Promise[]> { const compiledQuery = this.compile() - const query = compiledQuery.query as UpdateQueryNode const result = await this.#props.executor.executeQuery( compiledQuery, this.#props.queryId, ) - if (this.#props.executor.adapter.supportsReturning && query.returning) { + const { adapter } = this.#props.executor + const query = compiledQuery.query as UpdateQueryNode + + if ( + (query.returning && adapter.supportsReturning) || + (query.output && adapter.supportsOutput) + ) { return result.rows as any } diff --git a/src/query-compiler/default-query-compiler.ts b/src/query-compiler/default-query-compiler.ts index dbec7eaa8..46494d197 100644 --- a/src/query-compiler/default-query-compiler.ts +++ b/src/query-compiler/default-query-compiler.ts @@ -110,6 +110,7 @@ import { AddIndexNode } from '../operation-node/add-index-node.js' import { CastNode } from '../operation-node/cast-node.js' import { FetchNode } from '../operation-node/fetch-node.js' import { TopNode } from '../operation-node/top-node.js' +import { OutputNode } from '../operation-node/output-node.js' export class DefaultQueryCompiler extends OperationNodeVisitor @@ -328,6 +329,11 @@ export class DefaultQueryCompiler this.append(')') } + if (node.output) { + this.append(' ') + this.visitNode(node.output) + } + if (node.values) { this.append(' ') this.visitNode(node.values) @@ -389,6 +395,11 @@ export class DefaultQueryCompiler this.visitNode(node.from) + if (node.output) { + this.append(' ') + this.visitNode(node.output) + } + if (node.using) { this.append(' ') this.visitNode(node.using) @@ -775,6 +786,11 @@ export class DefaultQueryCompiler this.compileList(node.updates) } + if (node.output) { + this.append(' ') + this.visitNode(node.output) + } + if (node.from) { this.append(' ') this.visitNode(node.from) @@ -1521,6 +1537,11 @@ export class DefaultQueryCompiler this.append(' ') this.compileList(node.whens) } + + if (node.output) { + this.append(' ') + this.visitNode(node.output) + } } protected override visitMatched(node: MatchedNode): void { @@ -1565,13 +1586,18 @@ export class DefaultQueryCompiler this.visitNode(node.dataType) this.append(')') } - + protected override visitFetch(node: FetchNode): void { this.append('fetch next ') this.visitNode(node.rowCount) this.append(` rows ${node.modifier}`) } + protected override visitOutput(node: OutputNode): void { + this.append('output ') + this.compileList(node.selections) + } + protected override visitTop(node: TopNode): void { this.append(`top(${node.expression})`) diff --git a/test/node/src/delete.test.ts b/test/node/src/delete.test.ts index 2ed2d3f50..efa0c61d4 100644 --- a/test/node/src/delete.test.ts +++ b/test/node/src/delete.test.ts @@ -912,5 +912,56 @@ for (const dialect of DIALECTS) { expect(result).to.be.instanceOf(DeleteResult) }) } + + if (dialect === 'mssql') { + it('should return deleted rows when `output` is used', async () => { + const query = ctx.db + .deleteFrom('person') + .output(['deleted.first_name', 'deleted.last_name as last']) + .where('gender', '=', 'male') + + testSql(query, dialect, { + postgres: NOT_SUPPORTED, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'delete from "person" output "deleted"."first_name", "deleted"."last_name" as "last" where "gender" = @1', + parameters: ['male'], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.execute() + + expect(result).to.have.length(2) + expect(Object.keys(result[0]).sort()).to.eql(['first_name', 'last']) + expect(result).to.containSubset([ + { first_name: 'Arnold', last: 'Schwarzenegger' }, + { first_name: 'Sylvester', last: 'Stallone' }, + ]) + }) + + it('conditional `output` statement should add optional fields', async () => { + const condition = true + + const query = ctx.db + .deleteFrom('person') + .output('deleted.first_name') + .$if(condition, (qb) => qb.output('deleted.last_name')) + .where('gender', '=', 'female') + + testSql(query, dialect, { + postgres: NOT_SUPPORTED, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'delete from "person" output "deleted"."first_name", "deleted"."last_name" where "gender" = @1', + parameters: ['female'], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.executeTakeFirstOrThrow() + expect(result.last_name).to.equal('Aniston') + }) + } }) } diff --git a/test/node/src/insert.test.ts b/test/node/src/insert.test.ts index 49057105e..2b69a6c09 100644 --- a/test/node/src/insert.test.ts +++ b/test/node/src/insert.test.ts @@ -141,7 +141,7 @@ for (const dialect of DIALECTS) { }) }) - if (dialect !== 'mssql') { + if (dialect === 'postgres' || dialect === 'mysql' || dialect === 'sqlite') { it('should insert one row with expressions', async () => { const query = ctx.db.insertInto('person').values(({ selectFrom }) => ({ first_name: selectFrom('pet') @@ -231,9 +231,7 @@ for (const dialect of DIALECTS) { ]) }) - // TODO: revisit this when mssql has output clause support as it also supports values expression - // https://database.guide/values-clause-in-sql-server/#:~:text=In%20SQL%20Server%2C%20VALUES%20is,statement%20or%20the%20FROM%20clause. - if (dialect === 'postgres') { + if (dialect === 'postgres' || dialect === 'mssql') { it('should insert the result of a values expression', async () => { const query = ctx.db .insertInto('person') @@ -251,7 +249,11 @@ for (const dialect of DIALECTS) { ) .select(['t.a', 't.b']), ) - .returning(['first_name', 'gender']) + .$call((qb) => + dialect === 'postgres' + ? qb.returning(['first_name', 'gender']) + : qb.output(['inserted.first_name', 'inserted.gender']), + ) testSql(query, dialect, { postgres: { @@ -259,7 +261,10 @@ for (const dialect of DIALECTS) { parameters: [1, 'foo', 2, 'bar'], }, mysql: NOT_SUPPORTED, - mssql: NOT_SUPPORTED, + mssql: { + sql: 'insert into "person" ("first_name", "gender") output "inserted"."first_name", "inserted"."gender" select "t"."a", "t"."b" from (values (@1, @2), (@3, @4)) as t(a, b)', + parameters: [1, 'foo', 2, 'bar'], + }, sqlite: NOT_SUPPORTED, }) @@ -822,24 +827,24 @@ for (const dialect of DIALECTS) { first_name: 'Sylvester', last_name: 'Barson', }) + }) - it('should insert a row, returning some fields of inserted row and conditionally returning additional fields', async () => { - const condition = true + it('should insert a row, returning some fields of inserted row and conditionally returning additional fields', async () => { + const condition = true - const query = ctx.db - .insertInto('person') - .values({ - first_name: 'Foo', - last_name: 'Barson', - gender: 'other', - }) - .returning('first_name') - .$if(condition, (qb) => qb.returning('last_name')) + const query = ctx.db + .insertInto('person') + .values({ + first_name: 'Foo', + last_name: 'Barson', + gender: 'other', + }) + .returning('first_name') + .$if(condition, (qb) => qb.returning('last_name')) - const result = await query.executeTakeFirstOrThrow() + const result = await query.executeTakeFirstOrThrow() - expect(result.last_name).to.equal('Barson') - }) + expect(result.last_name).to.equal('Barson') }) it('should insert a row and return data using `returningAll`', async () => { @@ -910,7 +915,7 @@ for (const dialect of DIALECTS) { .top(1) .columns(['first_name', 'gender']) .expression((eb) => - eb.selectFrom('pet').select(['name', eb.val('other').as('gender')]) + eb.selectFrom('pet').select(['name', eb.val('other').as('gender')]), ) testSql(query, dialect, { @@ -932,7 +937,7 @@ for (const dialect of DIALECTS) { .top(50, 'percent') .columns(['first_name', 'gender']) .expression((eb) => - eb.selectFrom('pet').select(['name', eb.val('other').as('gender')]) + eb.selectFrom('pet').select(['name', eb.val('other').as('gender')]), ) testSql(query, dialect, { @@ -948,6 +953,80 @@ for (const dialect of DIALECTS) { await query.executeTakeFirstOrThrow() }) } + + if (dialect === 'mssql') { + it('should insert a row and return data using `output`', async () => { + const result = await ctx.db + .insertInto('person') + .output([ + 'inserted.first_name', + 'inserted.last_name', + 'inserted.gender', + ]) + .values({ + gender: 'other', + first_name: ctx.db + .selectFrom('person') + .select(sql`max(first_name)`.as('max_first_name')), + last_name: sql`concat(cast(${'Bar'} as varchar), cast(${'son'} as varchar))`, + }) + .executeTakeFirst() + + expect(result).to.eql({ + first_name: 'Sylvester', + last_name: 'Barson', + gender: 'other', + }) + + expect(await getNewestPerson(ctx.db)).to.eql({ + first_name: 'Sylvester', + last_name: 'Barson', + }) + }) + + it('should insert a row, returning some fields of inserted row and conditionally returning additional fields', async () => { + const condition = true + + const query = ctx.db + .insertInto('person') + .output('inserted.first_name') + .$if(condition, (qb) => qb.output('inserted.last_name')) + .values({ + first_name: 'Foo', + last_name: 'Barson', + gender: 'other', + }) + + const result = await query.executeTakeFirstOrThrow() + + expect(result.last_name).to.equal('Barson') + }) + + it('should insert a row and return data using `outputAll`', async () => { + const result = await ctx.db + .insertInto('person') + .outputAll('inserted') + .values({ + gender: 'other', + first_name: ctx.db + .selectFrom('person') + .select(sql`max(first_name)`.as('max_first_name')), + last_name: sql`concat(cast(${'Bar'} as varchar), cast(${'son'} as varchar))`, + }) + .executeTakeFirst() + + expect(result).to.containSubset({ + first_name: 'Sylvester', + last_name: 'Barson', + gender: 'other', + }) + + expect(await getNewestPerson(ctx.db)).to.eql({ + first_name: 'Sylvester', + last_name: 'Barson', + }) + }) + } }) async function getNewestPerson( diff --git a/test/node/src/merge.test.ts b/test/node/src/merge.test.ts index 9fbe1bb22..2083b0fc5 100644 --- a/test/node/src/merge.test.ts +++ b/test/node/src/merge.test.ts @@ -1,4 +1,4 @@ -import { MergeResult } from '../../..' +import { MergeResult, sql } from '../../..' import { DIALECTS, NOT_SUPPORTED, @@ -1004,6 +1004,60 @@ for (const dialect of DIALECTS.filter( expect(result).to.be.instanceOf(MergeResult) expect(result.numChangedRows).to.equal(2n) }) + + it('should perform a merge...using table simple on...when matched then delete output id query', async () => { + const expected = await ctx.db.selectFrom('pet').select('id').execute() + + const query = ctx.db + .mergeInto('pet') + .using('person', 'pet.owner_id', 'person.id') + .whenMatched() + .thenDelete() + .output('deleted.id') + + testSql(query, dialect, { + postgres: NOT_SUPPORTED, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "pet" using "person" on "pet"."owner_id" = "person"."id" when matched then delete output "deleted"."id";', + parameters: [], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.execute() + + expect(result).to.eql(expected) + }) + + it('should perform a merge...using table simple on...when matched then update set name output deleted.name, inserted.name query', async () => { + const query = ctx.db + .mergeInto('pet') + .using('person', 'pet.owner_id', 'person.id') + .whenMatched() + .thenUpdateSet((eb) => ({ + name: sql`${eb.ref('person.first_name')} + '''s pet'`, + })) + .output(['deleted.name as old_name', 'inserted.name as new_name']) + + testSql(query, dialect, { + postgres: NOT_SUPPORTED, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'merge into "pet" using "person" on "pet"."owner_id" = "person"."id" when matched then update set "name" = "person"."first_name" + \'\'\'s pet\' output "deleted"."name" as "old_name", "inserted"."name" as "new_name";', + parameters: [], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.execute() + + expect(result).to.eql([ + { old_name: 'Catto', new_name: "Jennifer's pet" }, + { old_name: 'Doggo', new_name: "Arnold's pet" }, + { old_name: 'Hammo', new_name: "Sylvester's pet" }, + ]) + }) } }) } diff --git a/test/node/src/test-setup.ts b/test/node/src/test-setup.ts index 222834578..a85d6e273 100644 --- a/test/node/src/test-setup.ts +++ b/test/node/src/test-setup.ts @@ -457,26 +457,12 @@ export async function insert( } if (dialect === 'mssql') { - // TODO: use insert into "table" (...) output inserted.id values (...) when its implemented - return await ctx.db.connection().execute(async (db) => { - await qb.executeTakeFirstOrThrow() - - const { query } = qb.compile() - - const table = - query.kind === 'InsertQueryNode' && - [query.into!.table.schema?.name, query.into!.table.identifier.name] - .filter(Boolean) - .join('.') - - const { - rows: [{ id }], - } = await sql<{ id: number }>`select IDENT_CURRENT(${sql.lit( - table, - )}) as id`.execute(db) - - return Number(id) - }) + const { id } = await qb + .output('inserted.id' as any) + .$castTo<{ id: number }>() + .executeTakeFirstOrThrow() + + return id } const { insertId } = await qb.executeTakeFirstOrThrow() diff --git a/test/node/src/update.test.ts b/test/node/src/update.test.ts index 73f7e272f..7a5b69c18 100644 --- a/test/node/src/update.test.ts +++ b/test/node/src/update.test.ts @@ -711,5 +711,51 @@ for (const dialect of DIALECTS) { await query.execute() }) } + + if (dialect === 'mssql') { + it('should update some rows and return updated rows when `output` is used', async () => { + const query = ctx.db + .updateTable('person') + .set({ last_name: 'Barson' }) + .output(['inserted.first_name', 'inserted.last_name']) + .where('gender', '=', 'male') + + testSql(query, dialect, { + postgres: NOT_SUPPORTED, + mysql: NOT_SUPPORTED, + mssql: { + sql: 'update "person" set "last_name" = @1 output "inserted"."first_name", "inserted"."last_name" where "gender" = @2', + parameters: ['Barson', 'male'], + }, + sqlite: NOT_SUPPORTED, + }) + + const result = await query.execute() + + expect(result).to.have.length(2) + expect(Object.keys(result[0]).sort()).to.eql([ + 'first_name', + 'last_name', + ]) + expect(result).to.containSubset([ + { first_name: 'Arnold', last_name: 'Barson' }, + { first_name: 'Sylvester', last_name: 'Barson' }, + ]) + }) + + it('should update all rows, returning some fields of updated rows, and conditionally returning additional fields', async () => { + const condition = true + + const query = ctx.db + .updateTable('person') + .set({ last_name: 'Barson' }) + .output('inserted.first_name') + .$if(condition, (qb) => qb.output('inserted.last_name')) + + const result = await query.executeTakeFirstOrThrow() + + expect(result.last_name).to.equal('Barson') + }) + } }) } diff --git a/test/typings/test-d/delete-query-builder.test-d.ts b/test/typings/test-d/delete-query-builder.test-d.ts index 4abafba46..f827d0a3b 100644 --- a/test/typings/test-d/delete-query-builder.test-d.ts +++ b/test/typings/test-d/delete-query-builder.test-d.ts @@ -1,5 +1,5 @@ import { expectError, expectType } from 'tsd' -import { Kysely, DeleteResult, Selectable } from '..' +import { Kysely, DeleteResult, Selectable, sql } from '..' import { Database, Person, Pet } from '../shared' async function testDelete(db: Kysely) { @@ -76,8 +76,10 @@ async function testDelete(db: Kysely) { .using('pet') .leftJoin('person', 'NO_SUCH_COLUMN', 'pet.owner_id'), ) +} - const r8 = await db +async function testReturning(db: Kysely) { + const r1 = await db .deleteFrom('person') .using(['person', 'pet']) .leftJoin('toy', 'toy.pet_id', 'pet.id') @@ -86,16 +88,16 @@ async function testDelete(db: Kysely) { ) .returningAll('person') .execute() - expectType[]>(r8) + expectType[]>(r1) - const r9 = await db + const r2 = await db .deleteFrom('pet') .where('pet.species', '=', 'cat') .returningAll('pet') .execute() - expectType[]>(r9) + expectType[]>(r2) - const r10 = await db + const r3 = await db .deleteFrom('person') .using(['person', 'pet']) .leftJoin('toy', 'toy.pet_id', 'pet.id') @@ -120,9 +122,9 @@ async function testDelete(db: Kysely) { price: number | null pet_id: string | null }[] - >(r10) + >(r3) - const r11 = await db + const r4 = await db .deleteFrom('person') .innerJoin('pet', 'pet.owner_id', 'person.id') .where('pet.species', '=', 'dog') @@ -143,23 +145,23 @@ async function testDelete(db: Kysely) { owner_id: number species: 'dog' | 'cat' }[] - >(r11) + >(r4) - const r12 = await db + const r5 = await db .deleteFrom('pet') .where('pet.species', '=', 'cat') .returningAll(['pet']) .execute() - expectType[]>(r12) + expectType[]>(r5) - const r13 = await db + const r6 = await db .deleteFrom('pet') .where('pet.species', '=', 'dog') .returningAll() .execute() - expectType[]>(r13) + expectType[]>(r6) - const r14 = await db + const r7 = await db .deleteFrom('person') .using(['person', 'pet']) .leftJoin('toy', 'toy.pet_id', 'pet.id') @@ -184,14 +186,14 @@ async function testDelete(db: Kysely) { price: number | null pet_id: string | null }[] - >(r14) + >(r7) - const r15 = await db + const r8 = await db .deleteFrom('person as p') .where('p.first_name', '=', 'Jennifer') .returning('p.id') .executeTakeFirstOrThrow() - expectType<{ id: number }>(r15) + expectType<{ id: number }>(r8) } async function testIf(db: Kysely) { @@ -244,3 +246,61 @@ async function testIf(db: Kysely) { f20?: string }>(r) } + +async function testOutput(db: Kysely) { + const r1 = await db + .deleteFrom('pet') + .outputAll('deleted') + .where('pet.species', '=', 'cat') + .execute() + expectType[]>(r1) + + const r2 = await db + .deleteFrom('person as p') + .output('deleted.id') + .where('p.first_name', '=', 'Jennifer') + .executeTakeFirstOrThrow() + expectType<{ id: number }>(r2) + + const r3 = await db + .deleteFrom('person as p') + .output(['deleted.id', 'deleted.last_name as surname']) + .where('p.first_name', '=', 'Jennifer') + .executeTakeFirstOrThrow() + expectType<{ id: number; surname: string | null }>(r3) + + const r4 = await db + .deleteFrom('person') + .output((eb) => [ + 'deleted.age', + eb + .fn('concat', [ + eb.ref('deleted.first_name'), + sql.lit(' '), + 'deleted.last_name', + ]) + .as('full_name'), + ]) + .where('deleted_at', '<', new Date()) + .executeTakeFirstOrThrow() + expectType<{ age: number; full_name: string }>(r4) + + // Non-existent column + expectError(db.deleteFrom('person').output('deleted.NO_SUCH_COLUMN')) + + // Wrong prefix + expectError(db.deleteFrom('person').output('inserted.id')) + expectError(db.deleteFrom('person').outputAll('inserted')) + + // Non-existent prefix + expectError(db.deleteFrom('person').output('NO_SUCH_PREFIX.id')) + expectError(db.deleteFrom('person').outputAll('NO_SUCH_PREFIX')) + + // table prefix + expectError(db.deleteFrom('person').output('person.id')) + expectError(db.deleteFrom('person').outputAll('person')) + + // No prefix + expectError(db.deleteFrom('person').output('id')) + expectError(db.deleteFrom('person').outputAll()) +} diff --git a/test/typings/test-d/insert.test-d.ts b/test/typings/test-d/insert.test-d.ts index 4db3a0862..371b06260 100644 --- a/test/typings/test-d/insert.test-d.ts +++ b/test/typings/test-d/insert.test-d.ts @@ -202,3 +202,69 @@ async function testReturning(db: Kysely) { // Non-existent column expectError(db.insertInto('person').values(person).returning('not_column')) } + +async function testOutput(db: Kysely) { + const person = { + first_name: 'Jennifer', + last_name: 'Aniston', + gender: 'other' as const, + age: 30, + } + + // One returning expression + const r1 = await db + .insertInto('person') + .output('inserted.id') + .values(person) + .executeTakeFirst() + + expectType<{ id: number } | undefined>(r1) + + // Multiple returning expressions + const r2 = await db + .insertInto('person') + .output(['inserted.id', 'inserted.first_name as fn']) + .values(person) + .execute() + + expectType<{ id: number; fn: string }[]>(r2) + + // Non-column reference returning expressions + const r3 = await db + .insertInto('person') + .output([ + 'inserted.id', + sql`concat(inserted.first_name, ' ', inserted.last_name)`.as( + 'full_name' + ), + ]) + .values(person) + .execute() + + expectType<{ id: number; full_name: string }[]>(r3) + + const r4 = await db + .insertInto('movie') + .outputAll('inserted') + .values({ stars: 5 }) + .executeTakeFirstOrThrow() + + expectType<{ id: string; stars: number }>(r4) + + // Non-existent column + expectError( + db.insertInto('person').output('inserted.not_column').values(person) + ) + + // Without prefix + expectError(db.insertInto('person').output('age').values(person)) + expectError(db.insertInto('person').outputAll().values(person)) + + // Non-existent prefix + expectError(db.insertInto('person').output('foo.age').values(person)) + expectError(db.insertInto('person').outputAll('foo').values(person)) + + // Wrong prefix + expectError(db.insertInto('person').output('deleted.age').values(person)) + expectError(db.insertInto('person').outputAll('deleted').values(person)) +} diff --git a/test/typings/test-d/merge.test-d.ts b/test/typings/test-d/merge.test-d.ts index 95b811781..bff938a03 100644 --- a/test/typings/test-d/merge.test-d.ts +++ b/test/typings/test-d/merge.test-d.ts @@ -7,11 +7,12 @@ import { MergeQueryBuilder, MergeResult, NotMatchedThenableMergeQueryBuilder, + Selectable, UpdateQueryBuilder, WheneableMergeQueryBuilder, sql, } from '..' -import { Database } from '../shared' +import { Database, Person } from '../shared' async function testMergeInto(db: Kysely) { db.mergeInto('person') @@ -420,3 +421,104 @@ async function testThenInsert( }), ) } + +async function testOutput(db: Kysely) { + // One returning expression + const r1 = await db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenMatched() + .thenDelete() + .output('deleted.id') + .executeTakeFirst() + + expectType<{ id: number } | undefined>(r1) + + // Multiple returning expressions + const r2 = await db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenMatched() + .thenDelete() + .output(['deleted.id', 'deleted.first_name as fn']) + .execute() + + expectType<{ id: number; fn: string }[]>(r2) + + // Non-column reference returning expressions + const r3 = await db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenMatched() + .thenUpdateSet('age', (eb) => eb(eb.ref('age'), '+', 20)) + .output([ + 'inserted.age', + sql`concat(deleted.first_name, ' ', deleted.last_name)`.as( + 'full_name', + ), + ]) + .execute() + + expectType<{ age: number; full_name: string }[]>(r3) + + // Return all columns + const r4 = await db + .mergeInto('person') + .using('pet', 'person.id', 'pet.owner_id') + .whenNotMatched() + .thenInsertValues({ + gender: 'female', + age: 15, + first_name: 'Jane', + }) + .outputAll('inserted') + .executeTakeFirstOrThrow() + + expectType>(r4) + + // Non-existent column + expectError( + db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenMatched() + .thenDelete() + .output('inserted.not_column'), + ) + + // Without prefix + expectError( + db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenMatched() + .thenDelete() + .output('age'), + ) + expectError( + db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenMatched() + .thenDelete() + .outputAll(), + ) + + // Non-existent prefix + expectError( + db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenMatched() + .thenDelete() + .output('foo.age'), + ) + expectError( + db + .mergeInto('person') + .using('pet', 'pet.owner_id', 'person.id') + .whenMatched() + .thenDelete() + .outputAll('foo'), + ) +}