Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-kysely-results.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect/sql-kysely": patch
---

Apply Kysely result plugins when executing queries through the Effect SQL client.
2 changes: 1 addition & 1 deletion packages/sql-kysely/src/internal/kysely.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export const makeWithSql = <DB>(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)
})

/**
Expand Down
52 changes: 40 additions & 12 deletions packages/sql-kysely/src/internal/patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -33,14 +33,15 @@ export const patch = (prototype: any) => {
*/
function effectifyWith(
obj: any,
commit: () => Effect.Effect<ReadonlyArray<unknown>, SqlError>,
whitelist: Array<string>
commit: (plugins: ReadonlyArray<KyselyPlugin>) => Effect.Effect<ReadonlyArray<unknown>, SqlError>,
whitelist: Array<string>,
plugins: ReadonlyArray<KyselyPlugin> = []
) {
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)
Expand All @@ -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<any>) => effectifyWith(target[prop].call(target, ...args), commit, whitelist)
return (...args: Array<unknown>) => {
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<KyselyPlugin>) {
const { parameters, queryId, sql } = this.compile()
const execute = client.unsafe<Record<string, unknown>>(sql, parameters)
if (plugins.length === 0) return execute
return Effect.flatMap(execute, (rows) =>
Effect.map(
Effect.reduce(plugins, { rows: Array.from(rows) } as QueryResult<Record<string, unknown>>, (result, plugin) =>
Effect.tryPromise({
try: () =>
plugin.transformResult({ queryId, result }),
catch: (cause) =>
new SqlError({ cause })
})),
(result) =>
result.rows
))
}
}

Expand All @@ -87,8 +111,12 @@ function executeCommit(this: Executable) {
/**
* @internal
*/
export const effectifyWithSql = <T>(obj: T, client: Client.SqlClient, whitelist: Array<string> = []): T =>
effectifyWith(obj, makeSqlCommit(client), whitelist)
export const effectifyWithSql = <T>(
obj: T,
client: Client.SqlClient,
whitelist: Array<string> = [],
plugins: ReadonlyArray<KyselyPlugin> = []
): T => effectifyWith(obj, makeSqlCommit(client), whitelist, plugins)

/**
* @internal
Expand Down
73 changes: 71 additions & 2 deletions packages/sql-kysely/test/Sqlite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>
Expand All @@ -24,6 +24,75 @@ const SqliteLive = Sqlite.SqliteClient.layer({
const KyselyLive = Layer.effect(SqliteDB, SqliteKysely.make<Database>()).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<unknown>(yield* camel.selectFrom("users").selectAll(), [{ userName: "Alice" }])
assert.deepStrictEqual(yield* camel.withoutPlugins().selectFrom("users").selectAll(), [{ user_name: "Alice" }])
assert.deepStrictEqual<unknown>(yield* db.selectFrom("users").selectAll().withPlugin(new CamelCasePlugin()), [
{ userName: "Alice" }
])
const query = db.selectFrom("users").selectAll()
assert.deepStrictEqual<unknown>(yield* query.$call((q) => q.withPlugin(new CamelCasePlugin())), [
{ userName: "Alice" }
])
assert.deepStrictEqual<unknown>(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<QueryId>()
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
Expand Down
Loading