diff --git a/.changeset/quiet-kysely-results.md b/.changeset/quiet-kysely-results.md new file mode 100644 index 00000000000..e542a00bd82 --- /dev/null +++ b/.changeset/quiet-kysely-results.md @@ -0,0 +1,5 @@ +--- +"@effect/sql-kysely": patch +--- + +Apply Kysely result plugins when executing queries through the Effect SQL client. diff --git a/packages/sql-kysely/src/internal/kysely.ts b/packages/sql-kysely/src/internal/kysely.ts index b275038ae54..d1892e396c1 100644 --- a/packages/sql-kysely/src/internal/kysely.ts +++ b/packages/sql-kysely/src/internal/kysely.ts @@ -63,7 +63,7 @@ export const makeWithSql = (config: KyselyConfig) => const selectPrototype = Object.getPrototypeOf(db.selectFrom("" as any)) patch(selectPrototype) - return effectifyWithSql(db, client, ["withTransaction", "compile"]) + return effectifyWithSql(db, client, ["withTransaction", "compile"], config.plugins) }) /** diff --git a/packages/sql-kysely/src/internal/patch.ts b/packages/sql-kysely/src/internal/patch.ts index fb4b0ddcac3..ff3971ce85e 100644 --- a/packages/sql-kysely/src/internal/patch.ts +++ b/packages/sql-kysely/src/internal/patch.ts @@ -2,7 +2,7 @@ import type * as Client from "@effect/sql/SqlClient" import { SqlError } from "@effect/sql/SqlError" import * as Effect from "effect/Effect" import * as Effectable from "effect/Effectable" -import type { Compilable } from "kysely" +import type { Compilable, KyselyPlugin, QueryResult } from "kysely" const ATTR_DB_QUERY_TEXT = "db.query.text" @@ -33,14 +33,15 @@ export const patch = (prototype: any) => { */ function effectifyWith( obj: any, - commit: () => Effect.Effect, SqlError>, - whitelist: Array + commit: (plugins: ReadonlyArray) => Effect.Effect, SqlError>, + whitelist: Array, + plugins: ReadonlyArray = [] ) { if (typeof obj !== "object" || obj === null) { return obj } return new Proxy(obj, { - get(target, prop): any { + get(target, prop, receiver) { // Respect the proxy invariant: non-configurable, non-writable // properties must return their actual value. const desc = Object.getOwnPropertyDescriptor(target, prop) @@ -49,24 +50,47 @@ function effectifyWith( } const prototype = Object.getPrototypeOf(target) if (Effect.EffectTypeId in prototype && prop === "commit") { - return commit.bind(target) + return commit.bind(target, plugins) } if (typeof (target[prop]) === "function") { if (typeof prop === "string" && whitelist.includes(prop)) { return target[prop].bind(target) } - return (...args: Array) => effectifyWith(target[prop].call(target, ...args), commit, whitelist) + return (...args: Array) => { + if (prop === "$call" || (prop === "$if" && args[0])) { + return target[prop].call(receiver, ...args) + } + return effectifyWith( + target[prop].call(target, ...args), + commit, + whitelist, + prop === "withPlugin" ? [...plugins, args[0] as KyselyPlugin] : prop === "withoutPlugins" ? [] : plugins + ) + } } - return effectifyWith(target[prop], commit, whitelist) + return effectifyWith(target[prop], commit, whitelist, plugins) } }) } /** @internal */ const makeSqlCommit = (client: Client.SqlClient) => { - return function(this: Compilable) { - const { parameters, sql } = this.compile() - return client.unsafe(sql, parameters as any) + return function(this: Compilable, plugins: ReadonlyArray) { + const { parameters, queryId, sql } = this.compile() + const execute = client.unsafe>(sql, parameters) + if (plugins.length === 0) return execute + return Effect.flatMap(execute, (rows) => + Effect.map( + Effect.reduce(plugins, { rows: Array.from(rows) } as QueryResult>, (result, plugin) => + Effect.tryPromise({ + try: () => + plugin.transformResult({ queryId, result }), + catch: (cause) => + new SqlError({ cause }) + })), + (result) => + result.rows + )) } } @@ -87,8 +111,12 @@ function executeCommit(this: Executable) { /** * @internal */ -export const effectifyWithSql = (obj: T, client: Client.SqlClient, whitelist: Array = []): T => - effectifyWith(obj, makeSqlCommit(client), whitelist) +export const effectifyWithSql = ( + obj: T, + client: Client.SqlClient, + whitelist: Array = [], + plugins: ReadonlyArray = [] +): T => effectifyWith(obj, makeSqlCommit(client), whitelist, plugins) /** * @internal diff --git a/packages/sql-kysely/test/Sqlite.test.ts b/packages/sql-kysely/test/Sqlite.test.ts index 9a6816c3ed6..4c5e6250dc4 100644 --- a/packages/sql-kysely/test/Sqlite.test.ts +++ b/packages/sql-kysely/test/Sqlite.test.ts @@ -2,8 +2,8 @@ import { SqlResolver } from "@effect/sql" import * as SqliteKysely from "@effect/sql-kysely/Sqlite" import * as Sqlite from "@effect/sql-sqlite-node" import { assert, describe, it } from "@effect/vitest" -import { Context, Effect, Exit, Layer, Option, Schema } from "effect" -import type { Generated } from "kysely" +import { Context, Effect, Either, Exit, Layer, Option, Schema } from "effect" +import { CamelCasePlugin, type Generated, type KyselyPlugin, type QueryId } from "kysely" export interface User { id: Generated @@ -24,6 +24,75 @@ const SqliteLive = Sqlite.SqliteClient.layer({ const KyselyLive = Layer.effect(SqliteDB, SqliteKysely.make()).pipe(Layer.provide(SqliteLive)) describe("SqliteKysely", () => { + it.effect("result plugins", () => + Effect.gen(function*() { + const db = yield* SqliteKysely.make<{ users: { userName: string } }>({ + plugins: [new CamelCasePlugin()] + }) + yield* db.schema.createTable("users").addColumn("userName", "text", (c) => c.notNull()) + assert.deepStrictEqual(yield* db.insertInto("users").values({ userName: "Alice" }).returningAll(), [ + { userName: "Alice" } + ]) + assert.deepStrictEqual(yield* db.selectFrom("users").selectAll(), [{ userName: "Alice" }]) + const failure = "rollback" + const result = yield* db.withTransaction(Effect.gen(function*() { + assert.deepStrictEqual(yield* db.updateTable("users").set({ userName: "Bob" }).returningAll(), [ + { userName: "Bob" } + ]) + return yield* Effect.fail(failure) + })).pipe(Effect.either) + assert.deepStrictEqual(result, Either.left(failure)) + assert.deepStrictEqual(yield* db.deleteFrom("users").returningAll(), [{ userName: "Alice" }]) + }).pipe(Effect.provide(SqliteLive))) + + it.effect("scoped result plugins", () => + Effect.gen(function*() { + const db = yield* SqliteKysely.make<{ users: { user_name: string } }>() + yield* db.schema.createTable("users").addColumn("user_name", "text") + yield* db.insertInto("users").values({ user_name: "Alice" }) + const camel = db.withPlugin(new CamelCasePlugin()) + assert.deepStrictEqual(yield* camel.selectFrom("users").selectAll(), [{ userName: "Alice" }]) + assert.deepStrictEqual(yield* camel.withoutPlugins().selectFrom("users").selectAll(), [{ user_name: "Alice" }]) + assert.deepStrictEqual(yield* db.selectFrom("users").selectAll().withPlugin(new CamelCasePlugin()), [ + { userName: "Alice" } + ]) + const query = db.selectFrom("users").selectAll() + assert.deepStrictEqual(yield* query.$call((q) => q.withPlugin(new CamelCasePlugin())), [ + { userName: "Alice" } + ]) + assert.deepStrictEqual(yield* query.$if(true, (q) => q.withPlugin(new CamelCasePlugin())), [ + { userName: "Alice" } + ]) + assert.deepStrictEqual(yield* query.$if(false, (q) => q.withPlugin(new CamelCasePlugin())), [ + { user_name: "Alice" } + ]) + assert.deepStrictEqual(yield* db.selectFrom("users").selectAll(), [{ user_name: "Alice" }]) + }).pipe(Effect.provide(SqliteLive))) + + it.effect("result plugin order and query identity", () => + Effect.gen(function*() { + const queries = new WeakSet() + const plugin: KyselyPlugin = { + transformQuery: ({ node, queryId }) => { + queries.add(queryId) + return node + }, + transformResult: ({ queryId, result }) => { + assert.isTrue(queries.has(queryId)) + return Promise.resolve({ + ...result, + rows: result.rows.map((row) => ({ ...row, userName: `${row.userName}!` })) + }) + } + } + const db = yield* SqliteKysely.make<{ users: { userName: string } }>({ + plugins: [new CamelCasePlugin(), plugin] + }) + yield* db.schema.createTable("users").addColumn("userName", "text") + yield* db.insertInto("users").values({ userName: "Alice" }) + assert.deepStrictEqual(yield* db.selectFrom("users").selectAll(), [{ userName: "Alice!" }]) + }).pipe(Effect.provide(SqliteLive))) + it.effect("queries", () => Effect.gen(function*() { const db = yield* SqliteDB