From aa15baade077d7bf155e5ffb269b69cdf9cb1abc Mon Sep 17 00:00:00 2001 From: productdevbook Date: Fri, 21 Aug 2026 19:24:10 +0200 Subject: [PATCH] feat(builder): MERGE and EXPLAIN can be run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `db.mergeInto(...)` was a public entry point producing a query nobody could run. The builder carried no executor, so there was no `.run()`, no `.many()`, and a compiled MERGE rejected — callers had to take the SQL and drive the connection themselves, which is the one thing the rest of the API does for them. `.explain()` was the same: a plan you could compile and not read, which is the only reason to ask for one. Both now carry the instance they were built from. MERGE gets `.run()`, and its RETURNING form gets `.many()` / `.first()` / `.run()`; EXPLAIN gets `.many()`, returning whatever the engine prints as `Record[]` rather than a shape that would only be right for one dialect. Result plugins are deliberately not applied to a plan — it is not a row of your table. `every-builder-compiles.test.ts` grows a second check: no builder that emits SQL may be unable to run. Like the first, it reads `src/builder` rather than a list, because the hole appears when somebody adds a builder. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HAwiBVhLmNhWjC9Ro6UMpb --- src/builder/explain.ts | 36 +++++- src/builder/typed-delete.ts | 2 +- src/builder/typed-insert.ts | 2 +- src/builder/typed-merge.ts | 127 ++++++++++++++++--- src/builder/typed-select.ts | 2 +- src/builder/typed-update.ts | 2 +- src/sumak.ts | 2 + test/builder/every-builder-compiles.test.ts | 20 ++- test/builder/merge-execute.test.ts | 134 ++++++++++++++++++++ 9 files changed, 301 insertions(+), 26 deletions(-) create mode 100644 test/builder/merge-execute.test.ts diff --git a/src/builder/explain.ts b/src/builder/explain.ts index 9e991df..aa94039 100644 --- a/src/builder/explain.ts +++ b/src/builder/explain.ts @@ -1,4 +1,6 @@ import type { ASTNode, ExplainNode } from "../ast/nodes.ts" +import type { SumakExecutor } from "../driver/execute.ts" +import { listenerFor, runQuery } from "../driver/execute.ts" import type { Printer } from "../printer/types.ts" import type { CompiledQuery } from "../types.ts" import type { CompiledQueryFn } from "./compiled.ts" @@ -17,11 +19,43 @@ export class ExplainBuilder { readonly _printer?: Printer /** @internal */ readonly _compile?: (node: ASTNode) => CompiledQuery + /** @internal */ + readonly _executor?: SumakExecutor - constructor(node: ExplainNode, printer?: Printer, compile?: (node: ASTNode) => CompiledQuery) { + constructor( + node: ExplainNode, + printer?: Printer, + compile?: (node: ASTNode) => CompiledQuery, + executor?: SumakExecutor, + ) { this._node = node this._printer = printer this._compile = compile + this._executor = executor + } + + /** + * Run the EXPLAIN and return the plan. + * + * The rows are whatever the engine prints, which differs per dialect and per + * option — `Record` rather than a shape that would only be + * right for one of them. Result plugins are deliberately not applied: a query + * plan is not a row of your table. + */ + async many(options?: { signal?: AbortSignal }): Promise[]> { + if (!this._executor) { + throw new Error( + "explain().many() needs an instance to run against. " + + "Build it from db.selectFrom(...).explain(...) so the driver is wired up.", + ) + } + return runQuery( + this._executor.driver(), + this.toSQL(), + (rows) => rows, + options, + listenerFor(this._executor), + ) } build(): ExplainNode { diff --git a/src/builder/typed-delete.ts b/src/builder/typed-delete.ts index d4c746c..56a19f5 100644 --- a/src/builder/typed-delete.ts +++ b/src/builder/typed-delete.ts @@ -285,7 +285,7 @@ export class TypedDeleteBuilder { analyze: options?.analyze, format: options?.format, } - return new ExplainBuilder(explainNode, this._printer, this._compile) + return new ExplainBuilder(explainNode, this._printer, this._compile, this._executor) } /** Pre-compile the SQL with placeholders. See `TypedSelectBuilder.toCompiled()`. */ diff --git a/src/builder/typed-insert.ts b/src/builder/typed-insert.ts index 37a165e..af4acf5 100644 --- a/src/builder/typed-insert.ts +++ b/src/builder/typed-insert.ts @@ -383,7 +383,7 @@ export class TypedInsertBuilder { analyze: options?.analyze, format: options?.format, } - return new ExplainBuilder(explainNode, this._printer, this._compile) + return new ExplainBuilder(explainNode, this._printer, this._compile, this._executor) } /** Pre-compile the SQL with placeholders. See `TypedSelectBuilder.toCompiled()`. */ diff --git a/src/builder/typed-merge.ts b/src/builder/typed-merge.ts index f96f76a..994f3d5 100644 --- a/src/builder/typed-merge.ts +++ b/src/builder/typed-merge.ts @@ -2,6 +2,9 @@ import { param, star } from "../ast/expression.ts" import type { ASTNode, ExpressionNode, MergeNode, SelectNode } from "../ast/nodes.ts" import type { Expression } from "../ast/typed-expression.ts" import { unwrap } from "../ast/typed-expression.ts" +import type { SumakExecutor } from "../driver/execute.ts" +import { listenerFor, resultTransformer, runExecute, runQuery } from "../driver/execute.ts" +import { deriveResultContext } from "../plugin/result-context.ts" import type { Printer } from "../printer/types.ts" import type { Insertable, SelectRow, Updateable } from "../schema/types.ts" import type { CompiledQuery } from "../types.ts" @@ -9,6 +12,7 @@ import type { CompiledQueryFn } from "./compiled.ts" import { compileQuery } from "./compiled.ts" import { Col } from "./eb.ts" import { MergeBuilder } from "./merge.ts" +import { runnersFor } from "./runners.ts" type MergeProxies = { target: { [K in keyof DB[Target] & string]: Col } @@ -42,6 +46,8 @@ export class TypedMergeBuilder CompiledQuery + /** @internal */ + readonly _executor?: SumakExecutor private readonly _targetTable: Target & string private readonly _sourceAlias: string @@ -62,11 +68,14 @@ export class TypedMergeBuilder CompiledQuery, /** @internal */ existingBuilder?: MergeBuilder, + /** @internal */ + executor?: SumakExecutor, ) { this._targetTable = targetTable this._sourceAlias = sourceAlias this._printer = printer this._compile = compile + this._executor = executor this._builder = existingBuilder ?? new MergeBuilder().into(targetTable).using(sourceTable, sourceAlias).on(unwrap(on)) @@ -82,6 +91,7 @@ export class TypedMergeBuilder ({ type: "column_ref" as const, column: c })) } const builder = this._builder.returning(...exprs) - return new TypedMergeReturningBuilder(builder, this._printer, this._compile) + return new TypedMergeReturningBuilder(builder, this._printer, this._compile, this._executor) } /** @@ -278,7 +288,7 @@ export class TypedMergeBuilder> { const builder = this._builder.returning(star()) - return new TypedMergeReturningBuilder(builder, this._printer, this._compile) + return new TypedMergeReturningBuilder(builder, this._printer, this._compile, this._executor) } build(): MergeNode { @@ -291,25 +301,51 @@ export class TypedMergeBuilder { + const exec = this._requireExecutor() + const result = await runExecute( + exec.driver(), + this._compileNode(this.build()), + options, + listenerFor(exec), + ) + return result.affected + } + + /** Pre-compile the SQL with placeholders. See `TypedSelectBuilder.toCompiled()`. */ toCompiled

= Record>(): CompiledQueryFn

{ if (!this._printer) { throw new Error( "toCompiled() requires a printer. Use db.mergeInto() to construct the builder.", ) } - return compileQuery

(this.build(), this._printer, this._compile) + const ast = this.build() + const executor = this._executor + if (executor === undefined) { + return compileQuery

(ast, this._printer, this._compile) + } + return compileQuery

(ast, this._printer, this._compile, runnersFor(executor, ast)) } } @@ -328,15 +364,19 @@ export class TypedMergeReturningBuilder { readonly _printer?: Printer /** @internal */ readonly _compile?: (node: ASTNode) => CompiledQuery + /** @internal */ + readonly _executor?: SumakExecutor constructor( builder: MergeBuilder, printer?: Printer, compile?: (node: ASTNode) => CompiledQuery, + executor?: SumakExecutor, ) { this._builder = builder this._printer = printer this._compile = compile + this._executor = executor } /** Stack additional RETURNING expressions onto the projection. */ @@ -345,6 +385,7 @@ export class TypedMergeReturningBuilder { this._builder.returning(...exprs), this._printer, this._compile, + this._executor, ) } @@ -358,24 +399,70 @@ export class TypedMergeReturningBuilder { /** Compile to SQL using the dialect's printer. */ toSQL(): CompiledQuery { - if (this._compile) return this._compile(this.build()) + return this._compileNode(this.build()) + } + + /** Compile an AST this builder already produced, without building it twice. */ + private _compileNode(ast: MergeNode): CompiledQuery { + if (this._compile) return this._compile(ast) if (!this._printer) { throw new Error("toSQL() requires a printer. Use db.mergeInto() to construct the builder.") } - return this._printer.print(this.build()) + return this._printer.print(ast) } - /** - * Pre-compile the SQL with placeholders. See `TypedSelectBuilder.toCompiled()`. - * - * There is no executor behind this builder, so the compiled query carries the - * SQL and fills parameters but cannot run itself. - */ + private _requireExecutor(): SumakExecutor { + if (!this._executor) { + throw new Error( + "MERGE needs an instance to run against. Use db.mergeInto(...) so the driver is wired up.", + ) + } + return this._executor + } + + /** Run the MERGE and return every row produced by `RETURNING`. */ + async many(options?: { signal?: AbortSignal }): Promise<_R[]> { + const exec = this._requireExecutor() + const ast = this.build() + const rows = await runQuery( + exec.driver(), + this._compileNode(ast), + resultTransformer(exec, deriveResultContext(ast)), + options, + listenerFor(exec), + ) + return rows as unknown as _R[] + } + + /** Run the MERGE and return the first row produced by `RETURNING`, or null. */ + async first(options?: { signal?: AbortSignal }): Promise<_R | null> { + const rows = await this.many(options) + return rows[0] ?? null + } + + /** Run the MERGE and return the number of rows it touched. */ + async run(options?: { signal?: AbortSignal }): Promise { + const exec = this._requireExecutor() + const result = await runExecute( + exec.driver(), + this._compileNode(this.build()), + options, + listenerFor(exec), + ) + return result.affected + } + + /** Pre-compile the SQL with placeholders. See `TypedSelectBuilder.toCompiled()`. */ toCompiled

= Record>(): CompiledQueryFn

{ if (!this._printer) { throw new Error( "toCompiled() requires a printer. Use db.mergeInto() to construct the builder.", ) } - return compileQuery

(this.build(), this._printer, this._compile) + const ast = this.build() + const executor = this._executor + if (executor === undefined) { + return compileQuery

(ast, this._printer, this._compile) + } + return compileQuery

(ast, this._printer, this._compile, runnersFor(executor, ast)) } } diff --git a/src/builder/typed-select.ts b/src/builder/typed-select.ts index 866d82d..ec05fae 100644 --- a/src/builder/typed-select.ts +++ b/src/builder/typed-select.ts @@ -870,7 +870,7 @@ export class TypedSelectBuilder { analyze: options?.analyze, format: options?.format, } - return new ExplainBuilder(explainNode, this._printer, this._compile) + return new ExplainBuilder(explainNode, this._printer, this._compile, this._executor) } /** diff --git a/src/builder/typed-update.ts b/src/builder/typed-update.ts index f303abb..58b53d9 100644 --- a/src/builder/typed-update.ts +++ b/src/builder/typed-update.ts @@ -297,7 +297,7 @@ export class TypedUpdateBuilder { analyze: options?.analyze, format: options?.format, } - return new ExplainBuilder(explainNode, this._printer, this._compile) + return new ExplainBuilder(explainNode, this._printer, this._compile, this._executor) } /** Pre-compile the SQL with placeholders. See `TypedSelectBuilder.toCompiled()`. */ diff --git a/src/sumak.ts b/src/sumak.ts index 488f8b8..722a2c0 100644 --- a/src/sumak.ts +++ b/src/sumak.ts @@ -761,6 +761,8 @@ export class Sumak { onExpr, this._dialect.createPrinter(), (node: ASTNode) => this.compile(node), + undefined, + this, ) } diff --git a/test/builder/every-builder-compiles.test.ts b/test/builder/every-builder-compiles.test.ts index 90cee76..5c1d42e 100644 --- a/test/builder/every-builder-compiles.test.ts +++ b/test/builder/every-builder-compiles.test.ts @@ -25,6 +25,7 @@ interface Builder { file: string name: string compiles: boolean + runs: boolean } function builders(): Builder[] { @@ -37,7 +38,12 @@ function builders(): Builder[] { const end = classes[index + 1]?.index ?? source.length const body = source.slice(start, end) if (!/\n {2}toSQL\(/.test(body)) continue - found.push({ file, name: match[1] as string, compiles: /\n {2}toCompiled/.test(body) }) + found.push({ + file, + name: match[1] as string, + compiles: /\n {2}toCompiled/.test(body), + runs: /\n {2}async (many|one|first|run|exec)\(/.test(body), + }) } } return found @@ -57,4 +63,16 @@ describe("every builder that emits SQL can be compiled", () => { expect(missing).toEqual([]) }) + + it("leaves none of them unable to run", () => { + // `db.mergeInto(...)` was a public entry point producing a query nobody + // could run — no executor, so no `.run()` and a compiled MERGE rejected. + // A builder the API hands out and cannot execute is a feature that only + // half exists. + const missing = builders() + .filter((b) => !b.runs) + .map((b) => `${b.name} (${b.file})`) + + expect(missing).toEqual([]) + }) }) diff --git a/test/builder/merge-execute.test.ts b/test/builder/merge-execute.test.ts new file mode 100644 index 0000000..388fa42 --- /dev/null +++ b/test/builder/merge-execute.test.ts @@ -0,0 +1,134 @@ +import { PGlite } from "@electric-sql/pglite" +import { beforeEach, describe, expect, it } from "vitest" + +import { pgDialect } from "../../src/dialect/pg.ts" +import { sumak } from "../../src/index.ts" +import { integer, serial, text } from "../../src/schema/column.ts" +import { pgliteDriver } from "../integration/pglite-driver.ts" + +// `db.mergeInto(...)` was a public entry point producing a query nobody could +// run: the builder carried no executor, so there was no `.run()`, no `.many()`, +// and a compiled MERGE rejected. Callers had to take the SQL and drive the +// connection themselves — which is the one thing the rest of the API does for +// them. + +let pg: PGlite +let db: ReturnType + +function make(engine: PGlite) { + return sumak({ + dialect: pgDialect(), + driver: pgliteDriver(engine), + tables: { + users: { id: serial().primaryKey(), name: text().notNull(), score: integer().notNull() }, + staging: { id: serial().primaryKey(), name: text().notNull(), score: integer().notNull() }, + }, + }) +} + +beforeEach(async () => { + pg = new PGlite() + await pg.exec(` + CREATE TABLE users (id integer primary key, name text not null, score integer not null); + CREATE TABLE staging (id integer primary key, name text not null, score integer not null); + INSERT INTO users VALUES (1, 'ada', 10); + INSERT INTO staging VALUES (1, 'ada', 99), (2, 'grace', 50); + `) + db = make(pg) +}, 60_000) + +function merge() { + return db + .mergeInto("users", { + source: "staging", + on: ({ target, source }) => target.id.eq(source.id), + }) + .whenMatchedThenUpdate({ score: 0 }) + .whenNotMatchedThenInsert({ id: 0, name: "", score: 0 }) +} + +describe("MERGE runs", () => { + it("reports how many rows it touched", async () => { + const affected = await db + .mergeInto("users", { + source: "staging", + on: ({ target, source }) => target.id.eq(source.id), + }) + .whenMatchedThenUpdate({ score: 1 }) + .run() + + expect(affected).toBe(1) + const rows = await pg.query<{ score: number }>("SELECT score FROM users WHERE id = 1") + expect(rows.rows[0]?.score).toBe(1) + }) + + it("returns the rows a RETURNING clause produces", async () => { + const rows = await db + .mergeInto("users", { + source: "staging", + on: ({ target, source }) => target.id.eq(source.id), + }) + .whenMatchedThenUpdate({ score: 7 }) + .returningAll() + .many() + + expect(rows).toHaveLength(1) + expect((rows[0] as { score: number }).score).toBe(7) + }) + + it("compiles once and runs", async () => { + const compiled = db + .mergeInto("users", { + source: "staging", + on: ({ target, source }) => target.id.eq(source.id), + }) + .whenMatchedThenUpdate({ score: 3 }) + .toCompiled() + + expect(await compiled.run({})).toBe(1) + expect(compiled.sql).toContain("MERGE INTO") + + const rows = await pg.query<{ score: number }>("SELECT score FROM users WHERE id = 1") + expect(rows.rows[0]?.score).toBe(3) + }) + + it("says what is missing when there is no driver", async () => { + const detached = sumak({ + dialect: pgDialect(), + tables: { + users: { id: serial().primaryKey(), name: text().notNull(), score: integer().notNull() }, + staging: { id: serial().primaryKey(), name: text().notNull(), score: integer().notNull() }, + }, + }) + .mergeInto("users", { + source: "staging", + on: ({ target, source }) => target.id.eq(source.id), + }) + .whenMatchedThenUpdate({ score: 1 }) + + await expect(detached.run()).rejects.toThrow(/No driver configured/) + expect(merge().build().type).toBe("merge") + }) +}) + +describe("EXPLAIN reads its plan", () => { + it("returns the rows the engine prints", async () => { + const plan = await db.selectFrom("users").selectAll().explain().many() + + expect(plan.length).toBeGreaterThan(0) + expect(JSON.stringify(plan)).toMatch(/users/i) + }) + + it("still compiles without a driver, and says so when run", async () => { + const detached = sumak({ + dialect: pgDialect(), + tables: { users: { id: serial().primaryKey(), name: text().notNull() } }, + }) + .selectFrom("users") + .selectAll() + .explain() + + await expect(detached.many()).rejects.toThrow(/No driver configured/) + expect(detached.toSQL().sql).toContain("EXPLAIN") + }) +})