From 572c141cb0fa8fcf5bb6e3d297ff331ebba7fd5d Mon Sep 17 00:00:00 2001 From: k70suK3-k06a7ash1 <49641703+k70suK3-k06a7ash1@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:41:17 +0900 Subject: [PATCH] fix(sql-pglite): support parameterless SQL scripts --- .../pglite-parameterless-sql-scripts.md | 5 + packages/sql/pglite/src/PgliteClient.ts | 32 ++++-- packages/sql/pglite/test/Script.test.ts | 107 ++++++++++++++++++ .../test/SqlErrorClassification.test.ts | 1 + 4 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 .changeset/pglite-parameterless-sql-scripts.md create mode 100644 packages/sql/pglite/test/Script.test.ts diff --git a/.changeset/pglite-parameterless-sql-scripts.md b/.changeset/pglite-parameterless-sql-scripts.md new file mode 100644 index 00000000000..4f41a2cc2e2 --- /dev/null +++ b/.changeset/pglite-parameterless-sql-scripts.md @@ -0,0 +1,5 @@ +--- +"@effect/sql-pglite": patch +--- + +Support parameterless SQL scripts in PGlite row execution, including unprepared and streaming queries. Execute scripts with PGlite's `exec` API and return the final statement's rows, while retaining parameter binding for parameterized statements. diff --git a/packages/sql/pglite/src/PgliteClient.ts b/packages/sql/pglite/src/PgliteClient.ts index 4efef32fdee..012329f74fa 100644 --- a/packages/sql/pglite/src/PgliteClient.ts +++ b/packages/sql/pglite/src/PgliteClient.ts @@ -12,8 +12,10 @@ * @since 4.0.0 */ import { PGlite, type PGliteInterface, type PGliteOptions } from "@electric-sql/pglite" +import * as Arr from "effect/Array" import * as Config from "effect/Config" import * as Context from "effect/Context" +import * as Data from "effect/Data" import * as Effect from "effect/Effect" import * as Fiber from "effect/Fiber" import * as Layer from "effect/Layer" @@ -282,6 +284,19 @@ export const fromClient = ( ) }) +type QueryExecution = Data.TaggedEnum<{ + Script: { readonly sql: string } + Parameterized: { readonly sql: string; readonly params: Arr.NonEmptyReadonlyArray } +}> + +const QueryExecution = Data.taggedEnum() + +const classifyQueryExecution = (sql: string, params: ReadonlyArray): QueryExecution => + Arr.match(params, { + onEmpty: () => QueryExecution.Script({ sql }), + onNonEmpty: (params) => QueryExecution.Parameterized({ sql, params }) + }) + class PgliteConnection implements Connection { readonly pglite: PGliteInterface constructor(pglite: PGliteInterface) { @@ -289,13 +304,16 @@ class PgliteConnection implements Connection { } private run(method: string, sql: string, params: ReadonlyArray) { - return Effect.map( - Effect.tryPromise({ - try: () => this.pglite.query(sql, params as Array), - catch: (cause) => new SqlError({ reason: classifyError(cause, "Failed to execute statement", method) }) - }), - (result) => result.rows - ) + return Effect.tryPromise({ + try: () => + QueryExecution.$match(classifyQueryExecution(sql, params), { + // PGlite's query API only accepts a single statement. Keep scripts + // intact and expose the final result through the SQL row interface. + Script: ({ sql }) => this.pglite.exec(sql).then((results) => results.at(-1)?.rows ?? []), + Parameterized: ({ sql, params }) => this.pglite.query(sql, [...params]).then((result) => result.rows) + }), + catch: (cause) => new SqlError({ reason: classifyError(cause, "Failed to execute statement", method) }) + }) } execute( sql: string, diff --git a/packages/sql/pglite/test/Script.test.ts b/packages/sql/pglite/test/Script.test.ts new file mode 100644 index 00000000000..cb06d109d79 --- /dev/null +++ b/packages/sql/pglite/test/Script.test.ts @@ -0,0 +1,107 @@ +import { PgliteClient } from "@effect/sql-pglite" +import { assert, describe, layer } from "@effect/vitest" +import { Effect, Stream } from "effect" + +describe("PgliteClient SQL scripts", { concurrent: false }, () => { + layer(PgliteClient.layer(), { timeout: "30 seconds" })((it) => { + it.effect("executes multiple DDL statements", () => + Effect.gen(function*() { + const sql = yield* PgliteClient.PgliteClient + const rows = yield* sql` + CREATE TABLE script_indexes (id INTEGER, value TEXT); + CREATE INDEX script_indexes_id ON script_indexes (id); + CREATE INDEX script_indexes_value ON script_indexes (value); + ` + assert.deepStrictEqual(rows, []) + const indexes = yield* sql<{ indexname: string }>` + SELECT indexname FROM pg_indexes + WHERE tablename = ${"script_indexes"} ORDER BY indexname + ` + assert.deepStrictEqual(indexes, [ + { indexname: "script_indexes_id" }, + { indexname: "script_indexes_value" } + ]) + })) + + it.effect("returns the last statement's rows through each row execution mode", () => + Effect.gen(function*() { + const sql = yield* PgliteClient.PgliteClient + const statement = sql<{ value: number }>`SELECT 1 AS discarded; SELECT 2 AS value;` + assert.deepStrictEqual(yield* statement, [{ value: 2 }]) + assert.deepStrictEqual(yield* statement.unprepared, [{ value: 2 }]) + assert.deepStrictEqual(yield* Stream.runCollect(statement.stream), [{ value: 2 }]) + })) + + it.effect("returns no rows for an empty script or a final DDL statement", () => + Effect.gen(function*() { + const sql = yield* PgliteClient.PgliteClient + assert.deepStrictEqual(yield* sql.unsafe(""), []) + assert.deepStrictEqual(yield* sql.unsafe("-- no statements\n"), []) + assert.deepStrictEqual(yield* sql`SELECT 1; CREATE TABLE script_final_ddl (id INTEGER);`, []) + })) + + it.effect("preserves semicolons in literals, comments and dollar-quoted blocks", () => + Effect.gen(function*() { + const sql = yield* PgliteClient.PgliteClient + const rows = yield* sql<{ value: string }>` + CREATE TABLE script_literals (value TEXT); + -- A semicolon here must not split the script: ; + DO $$ BEGIN + INSERT INTO script_literals VALUES ('hello; world'); + END $$; + SELECT value FROM script_literals; + ` + assert.deepStrictEqual(rows, [{ value: "hello; world" }]) + })) + + it.effect("keeps bound values on the parameterized execution path", () => + Effect.gen(function*() { + const sql = yield* PgliteClient.PgliteClient + yield* sql`CREATE TABLE script_parameters (value TEXT)` + const value = "'); DROP TABLE script_parameters; --" + yield* sql`INSERT INTO script_parameters VALUES (${value})` + const statement = sql<{ value: string }>`SELECT value FROM script_parameters WHERE value = ${value}` + assert.deepStrictEqual(yield* statement, [{ value }]) + assert.deepStrictEqual(yield* statement.unprepared, [{ value }]) + assert.deepStrictEqual(yield* Stream.runCollect(statement.stream), [{ value }]) + })) + + it.effect("classifies script failures and rolls back preceding statements", () => + Effect.gen(function*() { + const sql = yield* PgliteClient.PgliteClient + yield* sql`CREATE TABLE script_failure (id INTEGER PRIMARY KEY)` + const error = yield* Effect.flip(sql` + INSERT INTO script_failure VALUES (1); + INSERT INTO script_failure VALUES (1); + `) + assert.strictEqual(error.reason._tag, "UniqueViolation") + assert.deepStrictEqual(yield* sql`SELECT * FROM script_failure`, []) + })) + + it.effect("rolls back scripts and bound statements with the enclosing transaction", () => + Effect.gen(function*() { + const sql = yield* PgliteClient.PgliteClient + yield* sql`CREATE TABLE script_transaction (value TEXT)` + const error = yield* Effect.flip(sql.withTransaction(Effect.gen(function*() { + yield* sql`INSERT INTO script_transaction VALUES ('first'); INSERT INTO script_transaction VALUES ('second');` + yield* sql`INSERT INTO script_transaction VALUES (${"third"})` + return yield* Effect.fail("rollback") + }))) + assert.strictEqual(error, "rollback") + assert.deepStrictEqual(yield* sql`SELECT * FROM script_transaction`, []) + })) + }) + + layer(PgliteClient.layer({ transformResultNames: (name) => name.toUpperCase() }), { + timeout: "30 seconds" + })((it) => { + it.effect("applies result transformations to the final statement", () => + Effect.gen(function*() { + const sql = yield* PgliteClient.PgliteClient + const statement = sql<{ VALUE: number }>`SELECT 1 AS discarded; SELECT 2 AS value;` + assert.deepStrictEqual(yield* statement, [{ VALUE: 2 }]) + assert.deepStrictEqual(yield* statement.unprepared, [{ VALUE: 2 }]) + assert.deepStrictEqual(yield* Stream.runCollect(statement.stream), [{ VALUE: 2 }]) + })) + }) +}) diff --git a/packages/sql/pglite/test/SqlErrorClassification.test.ts b/packages/sql/pglite/test/SqlErrorClassification.test.ts index 68b9f0b9b84..89f3ed27bb6 100644 --- a/packages/sql/pglite/test/SqlErrorClassification.test.ts +++ b/packages/sql/pglite/test/SqlErrorClassification.test.ts @@ -26,6 +26,7 @@ const assertUniqueViolation = (reason: SqlError.SqlErrorReason, constraint: stri } const makeFailingClient = (cause: unknown) => ({ + exec: () => Promise.reject(cause), query: () => Promise.reject(cause) })