connection: rewrote query execution, batching, added generic sqlite, added node/bun sqlite, aligned repo/mutator results

This commit is contained in:
dswbx
2025-06-12 09:02:18 +02:00
parent 88419548c7
commit 6c2e579596
40 changed files with 990 additions and 649 deletions

View File

@@ -1,4 +1,4 @@
import { afterAll, afterEach, describe, expect, test } from "bun:test"; import { afterEach, describe, test } from "bun:test";
import { App } from "../src"; import { App } from "../src";
import { getDummyConnection } from "./helper"; import { getDummyConnection } from "./helper";

View File

@@ -153,7 +153,7 @@ describe("DataApi", () => {
const oneBy = api.readOneBy("posts", { where: { title: "baz" }, select: ["title"] }); const oneBy = api.readOneBy("posts", { where: { title: "baz" }, select: ["title"] });
const oneByRes = await oneBy; const oneByRes = await oneBy;
expect(oneByRes.data).toEqual({ title: "baz" } as any); expect(oneByRes.data).toEqual({ title: "baz" } as any);
expect(oneByRes.body.meta.count).toEqual(1); expect(oneByRes.body.meta.items).toEqual(1);
}); });
it("exists/count", async () => { it("exists/count", async () => {

View File

@@ -7,13 +7,13 @@ import {
type EntityData, type EntityData,
EntityManager, EntityManager,
ManyToOneRelation, ManyToOneRelation,
type MutatorResponse,
type RepositoryResponse,
TextField, TextField,
} from "../../src/data"; } from "../../src/data";
import { DataController } from "../../src/data/api/DataController"; import { DataController } from "../../src/data/api/DataController";
import { dataConfigSchema } from "../../src/data/data-schema"; import { dataConfigSchema } from "../../src/data/data-schema";
import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper"; import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper";
import type { RepositoryResultJSON } from "data/entities/query/RepositoryResult";
import type { MutatorResultJSON } from "data/entities/mutation/MutatorResult";
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
beforeAll(() => disableConsoleLog(["log", "warn"])); beforeAll(() => disableConsoleLog(["log", "warn"]));
@@ -21,52 +21,6 @@ afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
const dataConfig = parse(dataConfigSchema, {}); const dataConfig = parse(dataConfigSchema, {});
describe("[data] DataController", async () => { describe("[data] DataController", async () => {
test("repoResult", async () => {
const em = new EntityManager<any>([], dummyConnection);
const ctx: any = { em, guard: new Guard() };
const controller = new DataController(ctx, dataConfig);
const res = controller.repoResult({
entity: null as any,
data: [] as any,
sql: "",
parameters: [] as any,
result: [] as any,
meta: {
total: 0,
count: 0,
items: 0,
},
});
expect(res).toEqual({
meta: {
total: 0,
count: 0,
items: 0,
},
data: [],
});
});
test("mutatorResult", async () => {
const em = new EntityManager([], dummyConnection);
const ctx: any = { em, guard: new Guard() };
const controller = new DataController(ctx, dataConfig);
const res = controller.mutatorResult({
entity: null as any,
data: [] as any,
sql: "",
parameters: [] as any,
result: [] as any,
});
expect(res).toEqual({
data: [],
});
});
describe("getController", async () => { describe("getController", async () => {
const users = new Entity("users", [ const users = new Entity("users", [
new TextField("name", { required: true }), new TextField("name", { required: true }),
@@ -120,8 +74,7 @@ describe("[data] DataController", async () => {
method: "POST", method: "POST",
body: JSON.stringify(_user), body: JSON.stringify(_user),
}); });
//console.log("res", { _user }, res); const result = (await res.json()) as MutatorResultJSON;
const result = (await res.json()) as MutatorResponse;
const { id, ...data } = result.data as any; const { id, ...data } = result.data as any;
expect(res.status).toBe(201); expect(res.status).toBe(201);
@@ -135,7 +88,7 @@ describe("[data] DataController", async () => {
method: "POST", method: "POST",
body: JSON.stringify(_post), body: JSON.stringify(_post),
}); });
const result = (await res.json()) as MutatorResponse; const result = (await res.json()) as MutatorResultJSON;
const { id, ...data } = result.data as any; const { id, ...data } = result.data as any;
expect(res.status).toBe(201); expect(res.status).toBe(201);
@@ -146,13 +99,13 @@ describe("[data] DataController", async () => {
test("/:entity (read many)", async () => { test("/:entity (read many)", async () => {
const res = await app.request("/entity/users"); const res = await app.request("/entity/users");
const data = (await res.json()) as RepositoryResponse; const data = (await res.json()) as RepositoryResultJSON;
expect(data.meta.total).toBe(3); //expect(data.meta.total).toBe(3);
expect(data.meta.count).toBe(3); //expect(data.meta.count).toBe(3);
expect(data.meta.items).toBe(3); expect(data.meta.items).toBe(3);
expect(data.data.length).toBe(3); expect(data.data.length).toBe(3);
expect(data.data[0].name).toBe("foo"); expect(data.data[0]?.name).toBe("foo");
}); });
test("/:entity/query (func query)", async () => { test("/:entity/query (func query)", async () => {
@@ -165,33 +118,32 @@ describe("[data] DataController", async () => {
where: { bio: { $isnull: 1 } }, where: { bio: { $isnull: 1 } },
}), }),
}); });
const data = (await res.json()) as RepositoryResponse; const data = (await res.json()) as RepositoryResultJSON;
expect(data.meta.total).toBe(3); //expect(data.meta.total).toBe(3);
expect(data.meta.count).toBe(1); //expect(data.meta.count).toBe(1);
expect(data.meta.items).toBe(1); expect(data.meta.items).toBe(1);
expect(data.data.length).toBe(1); expect(data.data.length).toBe(1);
expect(data.data[0].name).toBe("bar"); expect(data.data[0]?.name).toBe("bar");
}); });
test("/:entity (read many, paginated)", async () => { test("/:entity (read many, paginated)", async () => {
const res = await app.request("/entity/users?limit=1&offset=2"); const res = await app.request("/entity/users?limit=1&offset=2");
const data = (await res.json()) as RepositoryResponse; const data = (await res.json()) as RepositoryResultJSON;
expect(data.meta.total).toBe(3); //expect(data.meta.total).toBe(3);
expect(data.meta.count).toBe(3); //expect(data.meta.count).toBe(3);
expect(data.meta.items).toBe(1); expect(data.meta.items).toBe(1);
expect(data.data.length).toBe(1); expect(data.data.length).toBe(1);
expect(data.data[0].name).toBe("baz"); expect(data.data[0]?.name).toBe("baz");
}); });
test("/:entity/:id (read one)", async () => { test("/:entity/:id (read one)", async () => {
const res = await app.request("/entity/users/3"); const res = await app.request("/entity/users/3");
const data = (await res.json()) as RepositoryResponse<EntityData>; const data = (await res.json()) as RepositoryResultJSON<EntityData>;
console.log("data", data);
expect(data.meta.total).toBe(3); //expect(data.meta.total).toBe(3);
expect(data.meta.count).toBe(1); //expect(data.meta.count).toBe(1);
expect(data.meta.items).toBe(1); expect(data.meta.items).toBe(1);
expect(data.data).toEqual({ id: 3, ...fixtures.users[2] }); expect(data.data).toEqual({ id: 3, ...fixtures.users[2] });
}); });
@@ -201,7 +153,7 @@ describe("[data] DataController", async () => {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ name: "new name" }), body: JSON.stringify({ name: "new name" }),
}); });
const { data } = (await res.json()) as MutatorResponse; const { data } = (await res.json()) as MutatorResultJSON;
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
expect(data as any).toEqual({ id: 3, ...fixtures.users[2], name: "new name" }); expect(data as any).toEqual({ id: 3, ...fixtures.users[2], name: "new name" });
@@ -209,27 +161,26 @@ describe("[data] DataController", async () => {
test("/:entity/:id/:reference (read references)", async () => { test("/:entity/:id/:reference (read references)", async () => {
const res = await app.request("/entity/users/1/posts"); const res = await app.request("/entity/users/1/posts");
const data = (await res.json()) as RepositoryResponse; const data = (await res.json()) as RepositoryResultJSON;
console.log("data", data);
expect(data.meta.total).toBe(2); //expect(data.meta.total).toBe(2);
expect(data.meta.count).toBe(1); //expect(data.meta.count).toBe(1);
expect(data.meta.items).toBe(1); expect(data.meta.items).toBe(1);
expect(data.data.length).toBe(1); expect(data.data.length).toBe(1);
expect(data.data[0].content).toBe("post 1"); expect(data.data[0]?.content).toBe("post 1");
}); });
test("/:entity/:id (delete one)", async () => { test("/:entity/:id (delete one)", async () => {
const res = await app.request("/entity/posts/2", { const res = await app.request("/entity/posts/2", {
method: "DELETE", method: "DELETE",
}); });
const { data } = (await res.json()) as RepositoryResponse<EntityData>; const { data } = (await res.json()) as RepositoryResultJSON<EntityData>;
expect(data).toEqual({ id: 2, ...fixtures.posts[1] }); expect(data).toEqual({ id: 2, ...fixtures.posts[1] });
// verify // verify
const res2 = await app.request("/entity/posts"); const res2 = await app.request("/entity/posts");
const data2 = (await res2.json()) as RepositoryResponse; const data2 = (await res2.json()) as RepositoryResultJSON;
expect(data2.meta.total).toBe(1); //expect(data2.meta.total).toBe(1);
}); });
}); });
}); });

View File

@@ -34,19 +34,12 @@ describe("some tests", async () => {
test("findId", async () => { test("findId", async () => {
const query = await em.repository(users).findId(1); const query = await em.repository(users).findId(1);
/*const { result, total, count, time } = query;
console.log("query", query.result, {
result,
total,
count,
time,
});*/
expect(query.sql).toBe( expect(query.sql).toBe(
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" where "id" = ? limit ?', 'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" where "id" = ? limit ?',
); );
expect(query.parameters).toEqual([1, 1]); expect(query.parameters).toEqual([1, 1]);
expect(query.result).toEqual([]); expect(query.data).toBeUndefined();
}); });
test("findMany", async () => { test("findMany", async () => {
@@ -56,7 +49,7 @@ describe("some tests", async () => {
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" order by "users"."id" asc limit ? offset ?', 'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" order by "users"."id" asc limit ? offset ?',
); );
expect(query.parameters).toEqual([10, 0]); expect(query.parameters).toEqual([10, 0]);
expect(query.result).toEqual([]); expect(query.data).toEqual([]);
}); });
test("findMany with number", async () => { test("findMany with number", async () => {
@@ -66,7 +59,7 @@ describe("some tests", async () => {
'select "posts"."id" as "id", "posts"."title" as "title", "posts"."content" as "content", "posts"."created_at" as "created_at", "posts"."likes" as "likes" from "posts" order by "posts"."id" asc limit ? offset ?', 'select "posts"."id" as "id", "posts"."title" as "title", "posts"."content" as "content", "posts"."created_at" as "created_at", "posts"."likes" as "likes" from "posts" order by "posts"."id" asc limit ? offset ?',
); );
expect(query.parameters).toEqual([10, 0]); expect(query.parameters).toEqual([10, 0]);
expect(query.result).toEqual([]); expect(query.data).toEqual([]);
}); });
test("try adding an existing field name", async () => { test("try adding an existing field name", async () => {

View File

@@ -45,7 +45,7 @@ describe("Mutator simple", async () => {
}, },
}); });
expect(query.result).toEqual([{ id: 1, label: "test", count: 1 }]); expect(query.data).toEqual([{ id: 1, label: "test", count: 1 }]);
}); });
test("update inserted row", async () => { test("update inserted row", async () => {
@@ -87,7 +87,7 @@ describe("Mutator simple", async () => {
expect(mutation.data).toEqual({ id, label: "new label", count: 100 }); expect(mutation.data).toEqual({ id, label: "new label", count: 100 });
const query2 = await em.repository(items).findId(id); const query2 = await em.repository(items).findId(id);
expect(query2.result.length).toBe(0); expect(query2.data).toBeUndefined();
}); });
test("validation: insert incomplete row", async () => { test("validation: insert incomplete row", async () => {
@@ -177,13 +177,13 @@ describe("Mutator simple", async () => {
}); });
test("insertMany", async () => { test("insertMany", async () => {
const oldCount = (await em.repo(items).count()).count; const oldCount = (await em.repo(items).count()).data.count;
const inserts = [{ label: "insert 1" }, { label: "insert 2" }]; const inserts = [{ label: "insert 1" }, { label: "insert 2" }];
const { data } = await em.mutator(items).insertMany(inserts); const { data } = await em.mutator(items).insertMany(inserts);
expect(data.length).toBe(2); expect(data.length).toBe(2);
expect(data.map((d) => ({ label: d.label }))).toEqual(inserts); expect(data.map((d) => ({ label: d.label }))).toEqual(inserts);
const newCount = (await em.repo(items).count()).count; const newCount = (await em.repo(items).count()).data.count;
expect(newCount).toBe(oldCount + inserts.length); expect(newCount).toBe(oldCount + inserts.length);
const { data: data2 } = await em.repo(items).findMany({ offset: oldCount }); const { data: data2 } = await em.repo(items).findMany({ offset: oldCount });

View File

@@ -1,4 +1,4 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { EventManager } from "../../../src/core/events"; import type { EventManager } from "../../../src/core/events";
import { import {
Entity, Entity,
@@ -12,11 +12,14 @@ import {
TextField, TextField,
} from "../../../src/data"; } from "../../../src/data";
import * as proto from "../../../src/data/prototype"; import * as proto from "../../../src/data/prototype";
import { getDummyConnection } from "../helper"; import { getDummyConnection, disableConsoleLog, enableConsoleLog } from "../../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup); afterAll(afterAllCleanup);
beforeAll(() => disableConsoleLog(["log", "warn"]));
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
describe("[data] Mutator (base)", async () => { describe("[data] Mutator (base)", async () => {
const entity = new Entity("items", [ const entity = new Entity("items", [
new TextField("label", { required: true }), new TextField("label", { required: true }),

View File

@@ -26,120 +26,6 @@ async function sleep(ms: number) {
} }
describe("[Repository]", async () => { describe("[Repository]", async () => {
test.skip("bulk", async () => {
//const connection = dummyConnection;
//const connection = getLocalLibsqlConnection();
const credentials = null as any; // @todo: determine what to do here
const connection = new LibsqlConnection(credentials);
const em = new EntityManager([], connection);
/*const emLibsql = new EntityManager([], {
url: connection.url.replace("https", "libsql"),
authToken: connection.authToken,
});*/
const table = "posts";
const client = connection.getClient();
if (!client) {
console.log("Cannot perform test without libsql connection");
return;
}
const conn = em.connection.kysely;
const selectQ = (e: E) => e.selectFrom(table).selectAll().limit(2);
const countQ = (e: E) => e.selectFrom(table).select(e.fn.count("*").as("count"));
async function executeTransaction(em: EntityManager<any>) {
return await em.connection.kysely.transaction().execute(async (e) => {
const res = await selectQ(e).execute();
const count = await countQ(e).execute();
return [res, count];
});
}
async function executeBatch(em: EntityManager<any>) {
const queries = [selectQ(conn), countQ(conn)];
return await em.connection.batchQuery(queries);
}
async function executeSingleKysely(em: EntityManager<any>) {
const res = await selectQ(conn).execute();
const count = await countQ(conn).execute();
return [res, count];
}
async function executeSingleClient(em: EntityManager<any>) {
const q1 = selectQ(conn).compile();
const res = await client.execute({
sql: q1.sql,
args: q1.parameters as any,
});
const q2 = countQ(conn).compile();
const count = await client.execute({
sql: q2.sql,
args: q2.parameters as any,
});
return [res, count];
}
const transaction = await executeTransaction(em);
const batch = await executeBatch(em);
expect(batch).toEqual(transaction as any);
const testperf = false;
if (testperf) {
const times = 5;
const exec = async (
name: string,
fn: (em: EntityManager<any>) => Promise<any>,
em: EntityManager<any>,
) => {
const res = await Perf.execute(() => fn(em), times);
await sleep(1000);
const info = {
name,
total: res.total.toFixed(2),
avg: (res.total / times).toFixed(2),
first: res.marks[0].time.toFixed(2),
last: res.marks[res.marks.length - 1].time.toFixed(2),
};
console.log(info.name, info, res.marks);
return info;
};
const data: any[] = [];
data.push(await exec("transaction.http", executeTransaction, em));
data.push(await exec("bulk.http", executeBatch, em));
data.push(await exec("singleKy.http", executeSingleKysely, em));
data.push(await exec("singleCl.http", executeSingleClient, em));
/*data.push(await exec("transaction.libsql", executeTransaction, emLibsql));
data.push(await exec("bulk.libsql", executeBatch, emLibsql));
data.push(await exec("singleKy.libsql", executeSingleKysely, emLibsql));
data.push(await exec("singleCl.libsql", executeSingleClient, emLibsql));*/
console.table(data);
/**
* ┌───┬────────────────────┬────────┬────────┬────────┬────────┐
* │ │ name │ total │ avg │ first │ last │
* ├───┼────────────────────┼────────┼────────┼────────┼────────┤
* │ 0 │ transaction.http │ 681.29 │ 136.26 │ 136.46 │ 396.09 │
* │ 1 │ bulk.http │ 164.82 │ 32.96 │ 32.95 │ 99.91 │
* │ 2 │ singleKy.http │ 330.01 │ 66.00 │ 65.86 │ 195.41 │
* │ 3 │ singleCl.http │ 326.17 │ 65.23 │ 61.32 │ 198.08 │
* │ 4 │ transaction.libsql │ 856.79 │ 171.36 │ 132.31 │ 595.24 │
* │ 5 │ bulk.libsql │ 180.63 │ 36.13 │ 35.39 │ 107.71 │
* │ 6 │ singleKy.libsql │ 347.11 │ 69.42 │ 65.00 │ 207.14 │
* │ 7 │ singleCl.libsql │ 328.60 │ 65.72 │ 62.19 │ 195.04 │
* └───┴────────────────────┴────────┴────────┴────────┴────────┘
*/
}
});
test("count & exists", async () => { test("count & exists", async () => {
const items = new Entity("items", [new TextField("label")]); const items = new Entity("items", [new TextField("label")]);
const em = new EntityManager([items], dummyConnection); const em = new EntityManager([items], dummyConnection);
@@ -160,25 +46,44 @@ describe("[Repository]", async () => {
// count all // count all
const res = await em.repository(items).count(); const res = await em.repository(items).count();
expect(res.sql).toBe('select count(*) as "count" from "items"'); expect(res.sql).toBe('select count(*) as "count" from "items"');
expect(res.data.count).toBe(3);
//
{
const res = await em.repository(items).findMany();
expect(res.count).toBeUndefined();
}
{
const res = await em
.repository(items, {
includeCounts: true,
})
.findMany();
expect(res.count).toBe(3); expect(res.count).toBe(3);
}
// count filtered // count filtered
const res2 = await em.repository(items).count({ label: { $in: ["a", "b"] } }); const res2 = await em
.repository(items, {
includeCounts: true,
})
.count({ label: { $in: ["a", "b"] } });
expect(res2.sql).toBe('select count(*) as "count" from "items" where "label" in (?, ?)'); expect(res2.sql).toBe('select count(*) as "count" from "items" where "label" in (?, ?)');
expect(res2.parameters).toEqual(["a", "b"]); expect(res2.parameters).toEqual(["a", "b"]);
expect(res2.count).toBe(2); expect(res2.data.count).toBe(2);
// check exists // check exists
const res3 = await em.repository(items).exists({ label: "a" }); const res3 = await em.repository(items).exists({ label: "a" });
expect(res3.exists).toBe(true); expect(res3.data.exists).toBe(true);
const res4 = await em.repository(items).exists({ label: "d" }); const res4 = await em.repository(items).exists({ label: "d" });
expect(res4.exists).toBe(false); expect(res4.data.exists).toBe(false);
// for now, allow empty filter // for now, allow empty filter
const res5 = await em.repository(items).exists({}); const res5 = await em.repository(items).exists({});
expect(res5.exists).toBe(true); expect(res5.data.exists).toBe(true);
}); });
test("option: silent", async () => { test("option: silent", async () => {
@@ -191,6 +96,9 @@ describe("[Repository]", async () => {
// should throw because table doesn't exist // should throw because table doesn't exist
expect(em.repo("items").findMany({})).rejects.toThrow(/no such table/); expect(em.repo("items").findMany({})).rejects.toThrow(/no such table/);
// should silently return empty result // should silently return empty result
em.repo("items", { silent: true })
.findMany({})
.then((r) => r.data);
expect( expect(
em em
.repo("items", { silent: true }) .repo("items", { silent: true })
@@ -209,16 +117,16 @@ describe("[Repository]", async () => {
expect( expect(
em em
.repo("items") .repo("items", { includeCounts: true })
.findMany({}) .findMany({})
.then((r) => [r.meta.count, r.meta.total]), .then((r) => [r.count, r.total]),
).resolves.toEqual([0, 0]); ).resolves.toEqual([0, 0]);
expect( expect(
em em
.repo("items", { includeCounts: false }) .repo("items", { includeCounts: false })
.findMany({}) .findMany({})
.then((r) => [r.meta.count, r.meta.total]), .then((r) => [r.count, r.total]),
).resolves.toEqual([undefined, undefined]); ).resolves.toEqual([undefined, undefined]);
}); });
}); });

View File

@@ -38,14 +38,15 @@ export function getLocalLibsqlConnection() {
return { url: "http://127.0.0.1:8080" }; return { url: "http://127.0.0.1:8080" };
} }
type ConsoleSeverity = "log" | "warn" | "error"; type ConsoleSeverity = "debug" | "log" | "warn" | "error";
const _oldConsoles = { const _oldConsoles = {
debug: console.debug,
log: console.log, log: console.log,
warn: console.warn, warn: console.warn,
error: console.error, error: console.error,
}; };
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"]) { export function disableConsoleLog(severities: ConsoleSeverity[] = ["debug", "log", "warn"]) {
severities.forEach((severity) => { severities.forEach((severity) => {
console[severity] = () => null; console[severity] = () => null;
}); });

View File

@@ -76,6 +76,7 @@
"devDependencies": { "devDependencies": {
"@aws-sdk/client-s3": "^3.758.0", "@aws-sdk/client-s3": "^3.758.0",
"@bluwy/giget-core": "^0.1.2", "@bluwy/giget-core": "^0.1.2",
"@cloudflare/workers-types": "^4.20250606.0",
"@dagrejs/dagre": "^1.1.4", "@dagrejs/dagre": "^1.1.4",
"@hono/typebox-validator": "^0.3.3", "@hono/typebox-validator": "^0.3.3",
"@hono/vite-dev-server": "^0.19.1", "@hono/vite-dev-server": "^0.19.1",
@@ -102,6 +103,7 @@
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"jsonv-ts": "^0.1.0", "jsonv-ts": "^0.1.0",
"kysely-d1": "^0.3.0", "kysely-d1": "^0.3.0",
"kysely-generic-sqlite": "^1.2.1",
"open": "^10.1.0", "open": "^10.1.0",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -124,8 +126,7 @@
"vite": "^6.3.5", "vite": "^6.3.5",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.9", "vitest": "^3.0.9",
"wouter": "^3.6.0", "wouter": "^3.6.0"
"@cloudflare/workers-types": "^4.20250606.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@hono/node-server": "^1.14.3" "@hono/node-server": "^1.14.3"

View File

@@ -0,0 +1,12 @@
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { bunSqlite } from "./BunSqliteConnection";
import { bunTestRunner } from "adapter/bun/test";
import { describe } from "bun:test";
import { Database } from "bun:sqlite";
describe("BunSqliteConnection", () => {
connectionTestSuite(bunTestRunner, {
makeConnection: () => bunSqlite({ database: new Database(":memory:") }),
rawDialectDetails: [],
});
});

View File

@@ -0,0 +1,41 @@
import type { Database } from "bun:sqlite";
import {
buildQueryFn,
GenericSqliteConnection,
parseBigInt,
type IGenericSqlite,
} from "data/connection/sqlite/GenericSqliteConnection";
export type BunSqliteConnectionConfig = {
database: Database;
};
function bunSqliteExecutor(db: Database, cache: boolean): IGenericSqlite<Database> {
const fn = cache ? "query" : "prepare";
const getStmt = (sql: string) => db[fn](sql);
return {
db,
query: buildQueryFn({
all: (sql, parameters) => getStmt(sql).all(...(parameters || [])),
run: (sql, parameters) => {
const { changes, lastInsertRowid } = getStmt(sql).run(...(parameters || []));
return {
insertId: parseBigInt(lastInsertRowid),
numAffectedRows: parseBigInt(changes),
};
},
}),
close: () => db.close(),
};
}
export function bunSqlite(config: BunSqliteConnectionConfig) {
return new GenericSqliteConnection(
config.database,
() => bunSqliteExecutor(config.database, false),
{
name: "bun-sqlite",
},
);
}

View File

@@ -1,6 +1,7 @@
import { expect, test, mock } from "bun:test"; import { expect, test, mock, describe } from "bun:test";
export const bunTestRunner = { export const bunTestRunner = {
describe,
expect, expect,
test, test,
mock, mock,

View File

@@ -1,65 +1,42 @@
/// <reference types="@cloudflare/workers-types" /> /// <reference types="@cloudflare/workers-types" />
import { KyselyPluginRunner, SqliteConnection, SqliteIntrospector } from "bknd/data"; import { SqliteConnection } from "bknd/data";
import type { QB } from "data/connection/Connection"; import type { ConnQuery, ConnQueryResults } from "data/connection/Connection";
import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin } from "kysely";
import { D1Dialect } from "kysely-d1"; import { D1Dialect } from "kysely-d1";
export type D1ConnectionConfig<DB extends D1Database | D1DatabaseSession = D1Database> = { export type D1ConnectionConfig<DB extends D1Database | D1DatabaseSession = D1Database> = {
binding: DB; binding: DB;
}; };
class CustomD1Dialect extends D1Dialect {
override createIntrospector(db: Kysely<any>): DatabaseIntrospector {
return new SqliteIntrospector(db, {
excludeTables: ["_cf_KV", "_cf_METADATA"],
});
}
}
export class D1Connection< export class D1Connection<
DB extends D1Database | D1DatabaseSession = D1Database, DB extends D1Database | D1DatabaseSession = D1Database,
> extends SqliteConnection { > extends SqliteConnection<DB> {
override name = "sqlite-d1";
protected override readonly supported = { protected override readonly supported = {
batching: true, batching: true,
softscans: false,
}; };
constructor(private config: D1ConnectionConfig<DB>) { constructor(private config: D1ConnectionConfig<DB>) {
const plugins = [new ParseJSONResultsPlugin()]; super({
excludeTables: ["_cf_KV", "_cf_METADATA"],
const kysely = new Kysely({ dialect: D1Dialect,
dialect: new CustomD1Dialect({ database: config.binding as D1Database }), dialectArgs: [{ database: config.binding as D1Database }],
plugins,
}); });
super(kysely, {}, plugins);
} }
get client(): DB { override async executeQueries<O extends ConnQuery[]>(...qbs: O): Promise<ConnQueryResults<O>> {
return this.config.binding; const compiled = this.getCompiled(...qbs);
}
protected override async batch<Queries extends QB[]>(
queries: [...Queries],
): Promise<{
[K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>;
}> {
const db = this.config.binding; const db = this.config.binding;
const res = await db.batch( const res = await db.batch(
queries.map((q) => { compiled.map(({ sql, parameters }) => {
const { sql, parameters } = q.compile();
return db.prepare(sql).bind(...parameters); return db.prepare(sql).bind(...parameters);
}), }),
); );
// let it run through plugins return this.withTransformedRows(res, "results") as any;
const kyselyPlugins = new KyselyPluginRunner(this.plugins);
const data: any = [];
for (const r of res) {
const rows = await kyselyPlugins.transformResultRows(r.results);
data.push(rows);
}
return data;
} }
} }

View File

@@ -0,0 +1,46 @@
import {
buildQueryFn,
GenericSqliteConnection,
parseBigInt,
type IGenericSqlite,
} from "../../../data/connection/sqlite/GenericSqliteConnection";
import type { DatabaseSync } from "node:sqlite";
export type NodeSqliteConnectionConfig = {
database: DatabaseSync;
};
function nodeSqliteExecutor(db: DatabaseSync): IGenericSqlite<DatabaseSync> {
const getStmt = (sql: string) => {
const stmt = db.prepare(sql);
//stmt.setReadBigInts(true);
return stmt;
};
return {
db,
query: buildQueryFn({
all: (sql, parameters = []) => getStmt(sql).all(...parameters),
run: (sql, parameters = []) => {
const { changes, lastInsertRowid } = getStmt(sql).run(...parameters);
return {
insertId: parseBigInt(lastInsertRowid),
numAffectedRows: parseBigInt(changes),
};
},
}),
close: () => db.close(),
iterator: (isSelect, sql, parameters = []) => {
if (!isSelect) {
throw new Error("Only support select in stream()");
}
return getStmt(sql).iterate(...parameters) as any;
},
};
}
export function nodeSqlite(config: NodeSqliteConnectionConfig) {
return new GenericSqliteConnection(config.database, () => nodeSqliteExecutor(config.database), {
name: "node-sqlite",
});
}

View File

@@ -0,0 +1,11 @@
import { nodeSqlite } from "./NodeSqliteConnection";
import { DatabaseSync } from "node:sqlite";
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { describe, test, expect } from "vitest";
describe("NodeSqliteConnection", () => {
connectionTestSuite({ describe, test, expect } as any, {
makeConnection: () => nodeSqlite({ database: new DatabaseSync(":memory:") }),
rawDialectDetails: [],
});
});

View File

@@ -1,14 +1,14 @@
import { describe, before, after } from "node:test"; import { describe, beforeAll, afterAll } from "vitest";
import * as node from "./node.adapter"; import * as node from "./node.adapter";
import { adapterTestSuite } from "adapter/adapter-test-suite"; import { adapterTestSuite } from "adapter/adapter-test-suite";
import { nodeTestRunner } from "adapter/node/test"; import { viTestRunner } from "adapter/node/vitest";
import { disableConsoleLog, enableConsoleLog } from "core/utils"; import { disableConsoleLog, enableConsoleLog } from "core/utils";
before(() => disableConsoleLog()); beforeAll(() => disableConsoleLog());
after(enableConsoleLog); afterAll(enableConsoleLog);
describe("node adapter", () => { describe("node adapter", () => {
adapterTestSuite(nodeTestRunner, { adapterTestSuite(viTestRunner, {
makeApp: node.createApp, makeApp: node.createApp,
makeHandler: node.createHandler, makeHandler: node.createHandler,
}); });

View File

@@ -1,5 +1,5 @@
import nodeAssert from "node:assert/strict"; import nodeAssert from "node:assert/strict";
import { test } from "node:test"; import { test, describe } from "node:test";
import type { Matcher, Test, TestFn, TestRunner } from "core/test"; import type { Matcher, Test, TestFn, TestRunner } from "core/test";
// Track mock function calls // Track mock function calls
@@ -85,6 +85,7 @@ nodeTest.skipIf = (condition: boolean): Test => {
}; };
export const nodeTestRunner: TestRunner = { export const nodeTestRunner: TestRunner = {
describe,
test: nodeTest, test: nodeTest,
mock: createMockFunction, mock: createMockFunction,
expect: <T = unknown>(actual?: T, failMsg?: string) => ({ expect: <T = unknown>(actual?: T, failMsg?: string) => ({

View File

@@ -0,0 +1,50 @@
import type { TestFn, TestRunner, Test } from "core/test";
import { describe, test, expect, vi } from "vitest";
function vitestTest(label: string, fn: TestFn, options?: any) {
return test(label, fn as any);
}
vitestTest.if = (condition: boolean): Test => {
if (condition) {
return vitestTest;
}
return (() => {}) as any;
};
vitestTest.skip = (label: string, fn: TestFn) => {
return test.skip(label, fn as any);
};
vitestTest.skipIf = (condition: boolean): Test => {
if (condition) {
return (() => {}) as any;
}
return vitestTest;
};
const vitestExpect = <T = unknown>(actual: T, parentFailMsg?: string) => {
return {
toEqual: (expected: T, failMsg = parentFailMsg) => {
expect(actual, failMsg).toEqual(expected);
},
toBe: (expected: T, failMsg = parentFailMsg) => {
expect(actual, failMsg).toBe(expected);
},
toBeString: () => expect(typeof actual, parentFailMsg).toBe("string"),
toBeUndefined: () => expect(actual, parentFailMsg).toBeUndefined(),
toBeDefined: () => expect(actual, parentFailMsg).toBeDefined(),
toBeOneOf: (expected: T | Array<T> | Iterable<T>, failMsg = parentFailMsg) => {
const e = Array.isArray(expected) ? expected : [expected];
expect(actual, failMsg).toBeOneOf(e);
},
toHaveBeenCalled: () => expect(actual, parentFailMsg).toHaveBeenCalled(),
toHaveBeenCalledTimes: (expected: number, failMsg = parentFailMsg) => {
expect(actual, failMsg).toHaveBeenCalledTimes(expected);
},
};
};
export const viTestRunner: TestRunner = {
describe,
test: vitestTest,
expect: vitestExpect as any,
mock: (fn) => vi.fn(fn),
};

View File

@@ -16,6 +16,7 @@ export interface Test {
skipIf: (condition: boolean) => (label: string, fn: TestFn) => void; skipIf: (condition: boolean) => (label: string, fn: TestFn) => void;
} }
export type TestRunner = { export type TestRunner = {
describe: (label: string, asyncFn: () => Promise<void>) => void;
test: Test; test: Test;
mock: <T extends (...args: any[]) => any>(fn: T) => T | any; mock: <T extends (...args: any[]) => any>(fn: T) => T | any;
expect: <T = unknown>( expect: <T = unknown>(

View File

@@ -3,9 +3,7 @@ import {
DataPermissions, DataPermissions,
type EntityData, type EntityData,
type EntityManager, type EntityManager,
type MutatorResponse,
type RepoQuery, type RepoQuery,
type RepositoryResponse,
repoQuery, repoQuery,
} from "data"; } from "data";
import type { Handler } from "hono/types"; import type { Handler } from "hono/types";
@@ -32,33 +30,6 @@ export class DataController extends Controller {
return this.ctx.guard; return this.ctx.guard;
} }
repoResult<T extends RepositoryResponse<any> = RepositoryResponse>(
res: T,
): Pick<T, "meta" | "data"> {
let meta: Partial<RepositoryResponse["meta"]> = {};
if ("meta" in res) {
const { query, ...rest } = res.meta;
meta = rest;
if (isDebug()) meta.query = query;
}
const template = { data: res.data, meta };
// @todo: this works but it breaks in FE (need to improve DataTable)
// filter empty
return Object.fromEntries(
Object.entries(template).filter(([_, v]) => typeof v !== "undefined" && v !== null),
) as any;
}
mutatorResult(res: MutatorResponse | MutatorResponse<EntityData>) {
const template = { data: res.data };
// filter empty
return Object.fromEntries(Object.entries(template).filter(([_, v]) => v !== undefined));
}
entityExists(entity: string) { entityExists(entity: string) {
try { try {
return !!this.em.entity(entity); return !!this.em.entity(entity);
@@ -257,7 +228,7 @@ export class DataController extends Controller {
const where = c.req.valid("json") as any; const where = c.req.valid("json") as any;
const result = await this.em.repository(entity).count(where); const result = await this.em.repository(entity).count(where);
return c.json({ entity, count: result.count }); return c.json({ entity, ...result.data });
}, },
); );
@@ -279,7 +250,7 @@ export class DataController extends Controller {
const where = c.req.valid("json") as any; const where = c.req.valid("json") as any;
const result = await this.em.repository(entity).exists(where); const result = await this.em.repository(entity).exists(where);
return c.json({ entity, exists: result.exists }); return c.json({ entity, ...result.data });
}, },
); );
@@ -318,7 +289,7 @@ export class DataController extends Controller {
const options = c.req.valid("query") as RepoQuery; const options = c.req.valid("query") as RepoQuery;
const result = await this.em.repository(entity).findMany(options); const result = await this.em.repository(entity).findMany(options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 }); return c.json(result, { status: result.data ? 200 : 404 });
}, },
); );
@@ -347,7 +318,7 @@ export class DataController extends Controller {
const options = c.req.valid("query") as RepoQuery; const options = c.req.valid("query") as RepoQuery;
const result = await this.em.repository(entity).findId(id, options); const result = await this.em.repository(entity).findId(id, options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 }); return c.json(result, { status: result.data ? 200 : 404 });
}, },
); );
@@ -380,7 +351,7 @@ export class DataController extends Controller {
.repository(entity) .repository(entity)
.findManyByReference(id, reference, options); .findManyByReference(id, reference, options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 }); return c.json(result, { status: result.data ? 200 : 404 });
}, },
); );
@@ -414,7 +385,7 @@ export class DataController extends Controller {
const options = (await c.req.json()) as RepoQuery; const options = (await c.req.json()) as RepoQuery;
const result = await this.em.repository(entity).findMany(options); const result = await this.em.repository(entity).findMany(options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 }); return c.json(result, { status: result.data ? 200 : 404 });
}, },
); );
@@ -440,11 +411,11 @@ export class DataController extends Controller {
if (Array.isArray(body)) { if (Array.isArray(body)) {
const result = await this.em.mutator(entity).insertMany(body); const result = await this.em.mutator(entity).insertMany(body);
return c.json(this.mutatorResult(result), 201); return c.json(result, 201);
} }
const result = await this.em.mutator(entity).insertOne(body); const result = await this.em.mutator(entity).insertOne(body);
return c.json(this.mutatorResult(result), 201); return c.json(result, 201);
}, },
); );
@@ -475,7 +446,7 @@ export class DataController extends Controller {
}; };
const result = await this.em.mutator(entity).updateWhere(update, where); const result = await this.em.mutator(entity).updateWhere(update, where);
return c.json(this.mutatorResult(result)); return c.json(result);
}, },
); );
@@ -497,7 +468,7 @@ export class DataController extends Controller {
const body = (await c.req.json()) as EntityData; const body = (await c.req.json()) as EntityData;
const result = await this.em.mutator(entity).updateOne(id, body); const result = await this.em.mutator(entity).updateOne(id, body);
return c.json(this.mutatorResult(result)); return c.json(result);
}, },
); );
@@ -517,7 +488,7 @@ export class DataController extends Controller {
} }
const result = await this.em.mutator(entity).deleteOne(id); const result = await this.em.mutator(entity).deleteOne(id);
return c.json(this.mutatorResult(result)); return c.json(result);
}, },
); );
@@ -539,7 +510,7 @@ export class DataController extends Controller {
const where = (await c.req.json()) as RepoQuery["where"]; const where = (await c.req.json()) as RepoQuery["where"];
const result = await this.em.mutator(entity).deleteWhere(where); const result = await this.em.mutator(entity).deleteWhere(where);
return c.json(this.mutatorResult(result)); return c.json(result);
}, },
); );

View File

@@ -2,12 +2,15 @@ import {
type AliasableExpression, type AliasableExpression,
type ColumnBuilderCallback, type ColumnBuilderCallback,
type ColumnDataType, type ColumnDataType,
type Compilable,
type CompiledQuery,
type DatabaseIntrospector, type DatabaseIntrospector,
type Dialect, type Dialect,
type Expression, type Expression,
type Kysely, type Kysely,
type KyselyPlugin, type KyselyPlugin,
type OnModifyForeignAction, type OnModifyForeignAction,
type QueryResult,
type RawBuilder, type RawBuilder,
type SelectQueryBuilder, type SelectQueryBuilder,
type SelectQueryNode, type SelectQueryNode,
@@ -15,7 +18,8 @@ import {
sql, sql,
} from "kysely"; } from "kysely";
import type { BaseIntrospector, BaseIntrospectorConfig } from "./BaseIntrospector"; import type { BaseIntrospector, BaseIntrospectorConfig } from "./BaseIntrospector";
import type { Constructor } from "core"; import type { Constructor, DB } from "core";
import { KyselyPluginRunner } from "data/plugins/KyselyPluginRunner";
export type QB = SelectQueryBuilder<any, any, any>; export type QB = SelectQueryBuilder<any, any, any>;
@@ -75,22 +79,44 @@ export type DbFunctions = {
>; >;
}; };
const CONN_SYMBOL = Symbol.for("bknd:connection"); export type ConnQuery = CompiledQuery | Compilable;
export abstract class Connection<DB = any> { export type ConnQueryResult<T extends ConnQuery> = T extends CompiledQuery<infer R>
protected initialized = false; ? QueryResult<R>
kysely: Kysely<DB>; : T extends Compilable<infer R>
protected readonly supported = { ? QueryResult<R>
batching: false, : never;
export type ConnQueryResults<T extends ConnQuery[]> = {
[K in keyof T]: ConnQueryResult<T[K]>;
}; };
const CONN_SYMBOL = Symbol.for("bknd:connection");
export type Features = {
batching: boolean;
softscans: boolean;
};
export abstract class Connection<Client = unknown> {
abstract name: string;
protected initialized = false;
protected pluginRunner: KyselyPluginRunner;
protected readonly supported: Partial<Features> = {
batching: false,
softscans: true,
};
kysely: Kysely<DB>;
client!: Client;
constructor( constructor(
kysely: Kysely<DB>, kysely: Kysely<any>,
public fn: Partial<DbFunctions> = {}, public fn: Partial<DbFunctions> = {},
protected plugins: KyselyPlugin[] = [], protected plugins: KyselyPlugin[] = [],
) { ) {
this.kysely = kysely; this.kysely = kysely;
this[CONN_SYMBOL] = true; this[CONN_SYMBOL] = true;
this.pluginRunner = new KyselyPluginRunner(plugins);
} }
// @todo: consider moving constructor logic here, required by sqlocal // @todo: consider moving constructor logic here, required by sqlocal
@@ -121,30 +147,46 @@ export abstract class Connection<DB = any> {
return res.rows.length > 0; return res.rows.length > 0;
} }
protected async batch<Queries extends QB[]>( protected async transformResultRows(result: any[]): Promise<any[]> {
queries: [...Queries], return await this.pluginRunner.transformResultRows(result);
): Promise<{
[K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>;
}> {
throw new Error("Batching not supported");
} }
async batchQuery<Queries extends QB[]>( /**
queries: [...Queries], * Execute a query and return the result including all metadata
): Promise<{ * returned from the dialect.
[K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>; */
}> { async executeQueries<O extends ConnQuery[]>(...qbs: O): Promise<ConnQueryResults<O>> {
// bypass if no client support return Promise.all(qbs.map(async (qb) => await this.kysely.executeQuery(qb))) as any;
if (!this.supports("batching")) {
const data: any = [];
for (const q of queries) {
const result = await q.execute();
data.push(result);
}
return data;
} }
return await this.batch(queries); async executeQuery<O extends ConnQuery>(qb: O): Promise<ConnQueryResult<O>> {
const res = await this.executeQueries(qb);
return res[0] as any;
}
protected getCompiled(...qbs: ConnQuery[]): CompiledQuery[] {
return qbs.map((qb) => {
if ("compile" in qb) {
return qb.compile();
}
return qb;
});
}
protected async withTransformedRows<
Key extends string = "rows",
O extends { [K in Key]: any[] }[] = [],
>(result: O, _key?: Key): Promise<O> {
return (await Promise.all(
result.map(async (row) => {
const key = _key ?? "rows";
const { [key]: rows, ...r } = row;
return {
...r,
rows: await this.transformResultRows(rows),
};
}),
)) as any;
} }
protected validateFieldSpecType(type: string): type is FieldSpec["type"] { protected validateFieldSpecType(type: string): type is FieldSpec["type"] {

View File

@@ -0,0 +1,187 @@
import type { TestRunner } from "core/test";
import { Connection, type FieldSpec } from "./Connection";
export function connectionTestSuite(
testRunner: TestRunner,
{
makeConnection,
rawDialectDetails,
}: {
makeConnection: () => Connection;
rawDialectDetails: string[];
},
) {
const { test, expect, describe } = testRunner;
test("pings", async () => {
const connection = makeConnection();
const res = await connection.ping();
expect(res).toBe(true);
});
test("initializes", async () => {
const connection = makeConnection();
await connection.init();
// @ts-expect-error
expect(connection.initialized).toBe(true);
expect(connection.client).toBeDefined();
});
test("isConnection", async () => {
const connection = makeConnection();
expect(Connection.isConnection(connection)).toBe(true);
});
test("getFieldSchema", async () => {
const c = makeConnection();
const specToNode = (spec: FieldSpec) => {
// @ts-expect-error
const schema = c.kysely.schema.createTable("test").addColumn(...c.getFieldSchema(spec));
return schema.toOperationNode();
};
{
// primary
const node = specToNode({
type: "integer",
name: "id",
primary: true,
});
const col = node.columns[0]!;
expect(col.primaryKey).toBe(true);
expect(col.notNull).toBe(true);
}
{
// normal
const node = specToNode({
type: "text",
name: "text",
});
const col = node.columns[0]!;
expect(!col.primaryKey).toBe(true);
expect(!col.notNull).toBe(true);
}
{
// nullable (expect to be same as normal)
const node = specToNode({
type: "text",
name: "text",
nullable: true,
});
const col = node.columns[0]!;
expect(!col.primaryKey).toBe(true);
expect(!col.notNull).toBe(true);
}
});
describe("schema", async () => {
const connection = makeConnection();
const fields = [
{
type: "integer",
name: "id",
primary: true,
},
{
type: "text",
name: "text",
},
{
type: "json",
name: "json",
},
] as const satisfies FieldSpec[];
let b = connection.kysely.schema.createTable("test");
for (const field of fields) {
// @ts-expect-error
b = b.addColumn(...connection.getFieldSchema(field));
}
await b.execute();
// add index
await connection.kysely.schema.createIndex("test_index").on("test").columns(["id"]).execute();
test("executes query", async () => {
await connection.kysely
.insertInto("test")
.values({ id: 1, text: "test", json: JSON.stringify({ a: 1 }) })
.execute();
const expected = { id: 1, text: "test", json: { a: 1 } };
const qb = connection.kysely.selectFrom("test").selectAll();
const res = await connection.executeQuery(qb);
expect(res.rows).toEqual([expected]);
expect(rawDialectDetails.every((detail) => detail in res)).toBe(true);
{
const res = await connection.executeQueries(qb, qb);
expect(res.length).toBe(2);
res.map((r) => {
expect(r.rows).toEqual([expected]);
expect(rawDialectDetails.every((detail) => detail in r)).toBe(true);
});
}
});
test("introspects", async () => {
const tables = await connection.getIntrospector().getTables({
withInternalKyselyTables: false,
});
const clean = tables.map((t) => ({
...t,
columns: t.columns.map((c) => ({
...c,
dataType: undefined,
})),
}));
expect(clean).toEqual([
{
name: "test",
isView: false,
columns: [
{
name: "id",
dataType: undefined,
isNullable: false,
isAutoIncrementing: true,
hasDefaultValue: false,
},
{
name: "text",
dataType: undefined,
isNullable: true,
isAutoIncrementing: false,
hasDefaultValue: false,
},
{
name: "json",
dataType: undefined,
isNullable: true,
isAutoIncrementing: false,
hasDefaultValue: false,
},
],
},
]);
});
expect(await connection.getIntrospector().getIndices()).toEqual([
{
name: "test_index",
table: "test",
isUnique: false,
columns: [
{
name: "id",
order: 0,
},
],
},
]);
});
}

View File

@@ -0,0 +1,37 @@
import type { KyselyPlugin } from "kysely";
import {
type IGenericSqlite,
type OnCreateConnection,
type Promisable,
parseBigInt,
buildQueryFn,
GenericSqliteDialect,
} from "kysely-generic-sqlite";
import { SqliteConnection } from "./SqliteConnection";
export type GenericSqliteConnectionConfig = {
name: string;
additionalPlugins?: KyselyPlugin[];
excludeTables?: string[];
onCreateConnection?: OnCreateConnection;
};
export { parseBigInt, buildQueryFn, GenericSqliteDialect, type IGenericSqlite };
export class GenericSqliteConnection<DB = unknown> extends SqliteConnection<DB> {
override name = "generic-sqlite";
constructor(
db: DB,
executor: () => Promisable<IGenericSqlite>,
config?: GenericSqliteConnectionConfig,
) {
super({
dialect: GenericSqliteDialect,
dialectArgs: [executor, config?.onCreateConnection],
additionalPlugins: config?.additionalPlugins,
excludeTables: config?.excludeTables,
});
this.client = db;
}
}

View File

@@ -0,0 +1,11 @@
import { connectionTestSuite } from "../connection-test-suite";
import { LibsqlConnection } from "./LibsqlConnection";
import { bunTestRunner } from "adapter/bun/test";
import { describe } from "bun:test";
describe("LibsqlConnection", () => {
connectionTestSuite(bunTestRunner, {
makeConnection: () => new LibsqlConnection({ url: ":memory:" }),
rawDialectDetails: ["rowsAffected", "lastInsertRowid"],
});
});

View File

@@ -1,40 +1,26 @@
import { type Client, type Config, type InStatement, createClient } from "@libsql/client"; import { createClient, type Client, type Config, type InStatement } from "@libsql/client";
import { LibsqlDialect } from "@libsql/kysely-libsql"; import { LibsqlDialect } from "@libsql/kysely-libsql";
import { FilterNumericKeysPlugin } from "data/plugins/FilterNumericKeysPlugin";
import { KyselyPluginRunner } from "data/plugins/KyselyPluginRunner";
import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin } from "kysely";
import type { QB } from "../Connection";
import { SqliteConnection } from "./SqliteConnection";
import { SqliteIntrospector } from "./SqliteIntrospector";
import { $console } from "core"; import { $console } from "core";
import { FilterNumericKeysPlugin } from "data/plugins/FilterNumericKeysPlugin";
import type { ConnQuery, ConnQueryResults } from "../Connection";
import { SqliteConnection } from "./SqliteConnection";
export const LIBSQL_PROTOCOLS = ["wss", "https", "libsql"] as const; export const LIBSQL_PROTOCOLS = ["wss", "https", "libsql"] as const;
export type LibSqlCredentials = Config & { export type LibSqlCredentials = Config & {
protocol?: (typeof LIBSQL_PROTOCOLS)[number]; protocol?: (typeof LIBSQL_PROTOCOLS)[number];
}; };
const plugins = [new FilterNumericKeysPlugin(), new ParseJSONResultsPlugin()]; export class LibsqlConnection extends SqliteConnection<Client> {
override name = "libsql";
class CustomLibsqlDialect extends LibsqlDialect {
override createIntrospector(db: Kysely<any>): DatabaseIntrospector {
return new SqliteIntrospector(db, {
excludeTables: ["libsql_wasm_func_table"],
plugins,
});
}
}
export class LibsqlConnection extends SqliteConnection {
private client: Client;
protected override readonly supported = { protected override readonly supported = {
batching: true, batching: true,
softscans: true,
}; };
constructor(client: Client); constructor(client: Client);
constructor(credentials: LibSqlCredentials); constructor(credentials: LibSqlCredentials);
constructor(clientOrCredentials: Client | LibSqlCredentials) { constructor(clientOrCredentials: Client | LibSqlCredentials) {
let client: Client; let client: Client;
let batching_enabled = true;
if (clientOrCredentials && "url" in clientOrCredentials) { if (clientOrCredentials && "url" in clientOrCredentials) {
let { url, authToken, protocol } = clientOrCredentials; let { url, authToken, protocol } = clientOrCredentials;
if (protocol && LIBSQL_PROTOCOLS.includes(protocol)) { if (protocol && LIBSQL_PROTOCOLS.includes(protocol)) {
@@ -48,45 +34,25 @@ export class LibsqlConnection extends SqliteConnection {
client = clientOrCredentials; client = clientOrCredentials;
} }
const kysely = new Kysely({ super({
// @ts-expect-error libsql has type issues excludeTables: ["libsql_wasm_func_table"],
dialect: new CustomLibsqlDialect({ client }), dialect: LibsqlDialect,
plugins, dialectArgs: [{ client }],
additionalPlugins: [new FilterNumericKeysPlugin()],
}); });
super(kysely, {}, plugins);
this.client = client; this.client = client;
this.supported.batching = batching_enabled;
} }
getClient(): Client { override async executeQueries<O extends ConnQuery[]>(...qbs: O): Promise<ConnQueryResults<O>> {
return this.client; const compiled = this.getCompiled(...qbs);
} const stms: InStatement[] = compiled.map((q) => {
protected override async batch<Queries extends QB[]>(
queries: [...Queries],
): Promise<{
[K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>;
}> {
const stms: InStatement[] = queries.map((q) => {
const compiled = q.compile();
return { return {
sql: compiled.sql, sql: q.sql,
args: compiled.parameters as any[], args: q.parameters as any[],
}; };
}); });
const res = await this.client.batch(stms); return this.withTransformedRows(await this.client.batch(stms)) as any;
// let it run through plugins
const kyselyPlugins = new KyselyPluginRunner(this.plugins);
const data: any = [];
for (const r of res) {
const rows = await kyselyPlugins.transformResultRows(r.rows);
data.push(rows);
}
return data;
} }
} }

View File

@@ -1,16 +1,49 @@
import type { ColumnDataType, ColumnDefinitionBuilder, Kysely, KyselyPlugin } from "kysely"; import {
ParseJSONResultsPlugin,
type ColumnDataType,
type ColumnDefinitionBuilder,
type Dialect,
Kysely,
type KyselyPlugin,
} from "kysely";
import { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite"; import { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite";
import { Connection, type DbFunctions, type FieldSpec, type SchemaResponse } from "../Connection"; import { Connection, type DbFunctions, type FieldSpec, type SchemaResponse } from "../Connection";
import type { Constructor } from "core";
import { customIntrospector } from "../Connection";
import { SqliteIntrospector } from "./SqliteIntrospector";
export type SqliteConnectionConfig<
CustomDialect extends Constructor<Dialect> = Constructor<Dialect>,
> = {
excludeTables?: string[];
dialect: CustomDialect;
dialectArgs?: ConstructorParameters<CustomDialect>;
additionalPlugins?: KyselyPlugin[];
customFn?: Partial<DbFunctions>;
};
export abstract class SqliteConnection<Client = unknown> extends Connection<Client> {
override name = "sqlite";
constructor(config: SqliteConnectionConfig) {
const { excludeTables, dialect, dialectArgs = [], additionalPlugins } = config;
const plugins = [new ParseJSONResultsPlugin(), ...(additionalPlugins ?? [])];
const kysely = new Kysely({
dialect: customIntrospector(dialect, SqliteIntrospector, {
excludeTables,
plugins,
}).create(...dialectArgs),
plugins,
});
export class SqliteConnection extends Connection {
constructor(kysely: Kysely<any>, fn: Partial<DbFunctions> = {}, plugins: KyselyPlugin[] = []) {
super( super(
kysely, kysely,
{ {
...fn,
jsonArrayFrom, jsonArrayFrom,
jsonObjectFrom, jsonObjectFrom,
jsonBuildObject, jsonBuildObject,
...(config.customFn ?? {}),
}, },
plugins, plugins,
); );
@@ -43,7 +76,7 @@ export class SqliteConnection extends Connection {
if (spec.onUpdate) relCol = relCol.onUpdate(spec.onUpdate); if (spec.onUpdate) relCol = relCol.onUpdate(spec.onUpdate);
return relCol; return relCol;
} }
return spec.nullable ? col : col.notNull(); return col;
}, },
] as const; ] as const;
} }

View File

@@ -1,31 +1,14 @@
import { import { type SqliteDatabase, SqliteDialect } from "kysely";
type DatabaseIntrospector,
Kysely,
ParseJSONResultsPlugin,
type SqliteDatabase,
SqliteDialect,
} from "kysely";
import { SqliteConnection } from "./SqliteConnection"; import { SqliteConnection } from "./SqliteConnection";
import { SqliteIntrospector } from "./SqliteIntrospector";
const plugins = [new ParseJSONResultsPlugin()]; export class SqliteLocalConnection extends SqliteConnection<SqliteDatabase> {
override name = "sqlite-local";
class CustomSqliteDialect extends SqliteDialect { constructor(database: SqliteDatabase) {
override createIntrospector(db: Kysely<any>): DatabaseIntrospector { super({
return new SqliteIntrospector(db, { dialect: SqliteDialect,
excludeTables: ["test_table"], dialectArgs: [{ database }],
plugins,
}); });
} this.client = database;
}
export class SqliteLocalConnection extends SqliteConnection {
constructor(private database: SqliteDatabase) {
const kysely = new Kysely({
dialect: new CustomSqliteDialect({ database }),
plugins,
});
super(kysely, {}, plugins);
} }
} }

View File

@@ -207,8 +207,9 @@ export class EntityManager<TBD extends object = DefaultDB> {
repository<E extends Entity | keyof TBD | string>( repository<E extends Entity | keyof TBD | string>(
entity: E, entity: E,
opts: Omit<RepositoryOptions, "emgr"> = {},
): Repository<TBD, EntitySchema<TBD, E>> { ): Repository<TBD, EntitySchema<TBD, E>> {
return this.repo(entity); return this.repo(entity, opts);
} }
repo<E extends Entity | keyof TBD | string>( repo<E extends Entity | keyof TBD | string>(

View File

@@ -0,0 +1,126 @@
import { isDebug } from "core";
import { pick } from "core/utils";
import type { Connection } from "data/connection";
import type {
Compilable,
CompiledQuery,
QueryResult as KyselyQueryResult,
SelectQueryBuilder,
} from "kysely";
export type ResultHydrator<T = any> = (rows: T[]) => any;
export type ResultOptions<T = any> = {
hydrator?: ResultHydrator<T>;
beforeExecute?: (compiled: CompiledQuery) => void | Promise<void>;
onError?: (error: Error) => void | Promise<void>;
single?: boolean;
};
export type ResultJSON<T = any> = {
data: T;
meta: {
items: number;
time: number;
sql?: string;
parameters?: any[];
[key: string]: any;
};
};
export interface QueryResult<T = any> extends Omit<KyselyQueryResult<T>, "rows"> {
time: number;
items: number;
data: T;
rows: unknown[];
sql: string;
parameters: any[];
count?: number;
total?: number;
}
export class Result<T = unknown> {
results: QueryResult<T>[] = [];
time: number = 0;
constructor(
protected conn: Connection,
protected options: ResultOptions<T> = {},
) {}
get(): QueryResult<T> {
if (!this.results) {
throw new Error("Result not executed");
}
if (Array.isArray(this.results)) {
return (this.results ?? []) as any;
}
return this.results[0] as any;
}
first(): QueryResult<T> {
const res = this.get();
const first = Array.isArray(res) ? res[0] : res;
return first ?? ({} as any);
}
get sql() {
return this.first().sql;
}
get parameters() {
return this.first().parameters;
}
get data() {
if (this.options.single) {
return this.first().data?.[0];
}
return this.first().data ?? [];
}
async execute(qb: Compilable | Compilable[]) {
const qbs = Array.isArray(qb) ? qb : [qb];
for (const qb of qbs) {
const compiled = qb.compile();
await this.options.beforeExecute?.(compiled);
try {
const start = performance.now();
const res = await this.conn.executeQuery(compiled);
this.time = Number.parseFloat((performance.now() - start).toFixed(2));
this.results.push({
...res,
data: this.options.hydrator?.(res.rows as T[]),
items: res.rows.length,
time: this.time,
sql: compiled.sql,
parameters: [...compiled.parameters],
});
} catch (e) {
if (this.options.onError) {
await this.options.onError(e as Error);
} else {
throw e;
}
}
}
return this;
}
protected additionalMetaKeys(): string[] {
return [];
}
toJSON(): ResultJSON<T> {
const { rows, data, ...metaRaw } = this.first();
const keys = isDebug() ? ["items", "time", "sql", "parameters"] : ["items", "time"];
const meta = pick(metaRaw, [...keys, ...this.additionalMetaKeys()] as any);
return {
data: this.data,
meta,
};
}
}

View File

@@ -1,6 +1,6 @@
export * from "./Entity"; export * from "./Entity";
export * from "./EntityManager"; export * from "./EntityManager";
export * from "./Mutator"; export * from "./mutation/Mutator";
export * from "./query/Repository"; export * from "./query/Repository";
export * from "./query/WhereBuilder"; export * from "./query/WhereBuilder";
export * from "./query/WithBuilder"; export * from "./query/WithBuilder";

View File

@@ -1,12 +1,13 @@
import { $console, type DB as DefaultDB, type PrimaryFieldType } from "core"; import { $console, type DB as DefaultDB, type PrimaryFieldType } from "core";
import { type EmitsEvents, EventManager } from "core/events"; import { type EmitsEvents, EventManager } from "core/events";
import type { DeleteQueryBuilder, InsertQueryBuilder, UpdateQueryBuilder } from "kysely"; import type { DeleteQueryBuilder, InsertQueryBuilder, UpdateQueryBuilder } from "kysely";
import { type TActionContext, WhereBuilder } from ".."; import { type TActionContext, WhereBuilder } from "../..";
import type { Entity, EntityData, EntityManager } from "../entities"; import type { Entity, EntityData, EntityManager } from "../../entities";
import { InvalidSearchParamsException } from "../errors"; import { InvalidSearchParamsException } from "../../errors";
import { MutatorEvents } from "../events"; import { MutatorEvents } from "../../events";
import { RelationMutator } from "../relations"; import { RelationMutator } from "../../relations";
import type { RepoQuery } from "../server/query"; import type { RepoQuery } from "../../server/query";
import { MutatorResult, type MutatorResultOptions } from "./MutatorResult";
type MutatorQB = type MutatorQB =
| InsertQueryBuilder<any, any, any> | InsertQueryBuilder<any, any, any>
@@ -17,14 +18,6 @@ type MutatorUpdateOrDelete =
| UpdateQueryBuilder<any, any, any, any> | UpdateQueryBuilder<any, any, any, any>
| DeleteQueryBuilder<any, any, any>; | DeleteQueryBuilder<any, any, any>;
export type MutatorResponse<T = EntityData[]> = {
entity: Entity;
sql: string;
parameters: any[];
result: EntityData[];
data: T;
};
export class Mutator< export class Mutator<
TBD extends object = DefaultDB, TBD extends object = DefaultDB,
TB extends keyof TBD = any, TB extends keyof TBD = any,
@@ -103,35 +96,18 @@ export class Mutator<
return validatedData as Given; return validatedData as Given;
} }
protected async many(qb: MutatorQB): Promise<MutatorResponse> { protected async performQuery<T = EntityData[]>(
const entity = this.entity; qb: MutatorQB,
const { sql, parameters } = qb.compile(); opts?: MutatorResultOptions,
): Promise<MutatorResult<T>> {
try { const result = new MutatorResult(this.em, this.entity, {
const result = await qb.execute(); silent: false,
...opts,
const data = this.em.hydrate(entity.name, result) as EntityData[]; });
return (await result.execute(qb)) as any;
return {
entity,
sql,
parameters: [...parameters],
result: result,
data,
};
} catch (e) {
// @todo: redact
$console.error("[Error in query]", sql);
throw e;
}
} }
protected async single(qb: MutatorQB): Promise<MutatorResponse<EntityData>> { async insertOne(data: Input): Promise<MutatorResult<Output>> {
const { data, ...response } = await this.many(qb);
return { ...response, data: data[0]! };
}
async insertOne(data: Input): Promise<MutatorResponse<Output>> {
const entity = this.entity; const entity = this.entity;
if (entity.type === "system" && this.__unstable_disable_system_entity_creation) { if (entity.type === "system" && this.__unstable_disable_system_entity_creation) {
throw new Error(`Creation of system entity "${entity.name}" is disabled`); throw new Error(`Creation of system entity "${entity.name}" is disabled`);
@@ -174,7 +150,7 @@ export class Mutator<
.values(validatedData) .values(validatedData)
.returning(entity.getSelect()); .returning(entity.getSelect());
const res = await this.single(query); const res = await this.performQuery(query, { single: true });
await this.emgr.emit( await this.emgr.emit(
new Mutator.Events.MutatorInsertAfter({ entity, data: res.data, changed: validatedData }), new Mutator.Events.MutatorInsertAfter({ entity, data: res.data, changed: validatedData }),
@@ -183,7 +159,7 @@ export class Mutator<
return res as any; return res as any;
} }
async updateOne(id: PrimaryFieldType, data: Partial<Input>): Promise<MutatorResponse<Output>> { async updateOne(id: PrimaryFieldType, data: Partial<Input>): Promise<MutatorResult<Output>> {
const entity = this.entity; const entity = this.entity;
if (!id) { if (!id) {
throw new Error("ID must be provided for update"); throw new Error("ID must be provided for update");
@@ -206,7 +182,7 @@ export class Mutator<
.where(entity.id().name, "=", id) .where(entity.id().name, "=", id)
.returning(entity.getSelect()); .returning(entity.getSelect());
const res = await this.single(query); const res = await this.performQuery(query, { single: true });
await this.emgr.emit( await this.emgr.emit(
new Mutator.Events.MutatorUpdateAfter({ new Mutator.Events.MutatorUpdateAfter({
@@ -220,7 +196,7 @@ export class Mutator<
return res as any; return res as any;
} }
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<Output>> { async deleteOne(id: PrimaryFieldType): Promise<MutatorResult<Output>> {
const entity = this.entity; const entity = this.entity;
if (!id) { if (!id) {
throw new Error("ID must be provided for deletion"); throw new Error("ID must be provided for deletion");
@@ -233,7 +209,7 @@ export class Mutator<
.where(entity.id().name, "=", id) .where(entity.id().name, "=", id)
.returning(entity.getSelect()); .returning(entity.getSelect());
const res = await this.single(query); const res = await this.performQuery(query, { single: true });
await this.emgr.emit( await this.emgr.emit(
new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data }), new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data }),
@@ -286,7 +262,7 @@ export class Mutator<
} }
// @todo: decide whether entries should be deleted all at once or one by one (for events) // @todo: decide whether entries should be deleted all at once or one by one (for events)
async deleteWhere(where: RepoQuery["where"]): Promise<MutatorResponse<Output[]>> { async deleteWhere(where: RepoQuery["where"]): Promise<MutatorResult<Output[]>> {
const entity = this.entity; const entity = this.entity;
// @todo: add a way to delete all by adding force? // @todo: add a way to delete all by adding force?
@@ -298,13 +274,13 @@ export class Mutator<
entity.getSelect(), entity.getSelect(),
); );
return (await this.many(qb)) as any; return await this.performQuery(qb);
} }
async updateWhere( async updateWhere(
data: Partial<Input>, data: Partial<Input>,
where: RepoQuery["where"], where: RepoQuery["where"],
): Promise<MutatorResponse<Output[]>> { ): Promise<MutatorResult<Output[]>> {
const entity = this.entity; const entity = this.entity;
const validatedData = await this.getValidatedData(data, "update"); const validatedData = await this.getValidatedData(data, "update");
@@ -317,10 +293,10 @@ export class Mutator<
.set(validatedData as any) .set(validatedData as any)
.returning(entity.getSelect()); .returning(entity.getSelect());
return (await this.many(query)) as any; return await this.performQuery(query);
} }
async insertMany(data: Input[]): Promise<MutatorResponse<Output[]>> { async insertMany(data: Input[]): Promise<MutatorResult<Output[]>> {
const entity = this.entity; const entity = this.entity;
if (entity.type === "system" && this.__unstable_disable_system_entity_creation) { if (entity.type === "system" && this.__unstable_disable_system_entity_creation) {
throw new Error(`Creation of system entity "${entity.name}" is disabled`); throw new Error(`Creation of system entity "${entity.name}" is disabled`);
@@ -352,6 +328,6 @@ export class Mutator<
.values(validated) .values(validated)
.returning(entity.getSelect()); .returning(entity.getSelect());
return (await this.many(query)) as any; return await this.performQuery(query);
} }
} }

View File

@@ -0,0 +1,33 @@
import { $console } from "core/console";
import type { Entity, EntityData } from "../Entity";
import type { EntityManager } from "../EntityManager";
import { Result, type ResultJSON, type ResultOptions } from "../Result";
export type MutatorResultOptions = ResultOptions & {
silent?: boolean;
};
export type MutatorResultJSON<T = EntityData[]> = ResultJSON<T>;
export class MutatorResult<T = EntityData[]> extends Result<T> {
constructor(
protected em: EntityManager<any>,
public entity: Entity,
options?: MutatorResultOptions,
) {
super(em.connection, {
hydrator: (rows) => em.hydrate(entity.name, rows as any),
beforeExecute: (compiled) => {
if (!options?.silent) {
$console.debug(`[Mutation]\n${compiled.sql}\n`, compiled.parameters);
}
},
onError: (error) => {
if (!options?.silent) {
$console.error("[ERROR] Mutator:", error.message);
}
},
...options,
});
}
}

View File

@@ -13,37 +13,11 @@ import {
WithBuilder, WithBuilder,
} from "../index"; } from "../index";
import { JoinBuilder } from "./JoinBuilder"; import { JoinBuilder } from "./JoinBuilder";
import { ensureInt } from "core/utils"; import { RepositoryResult, type RepositoryResultOptions } from "./RepositoryResult";
import type { ResultOptions } from "../Result";
export type RepositoryQB = SelectQueryBuilder<any, any, any>; export type RepositoryQB = SelectQueryBuilder<any, any, any>;
export type RepositoryRawResponse = {
sql: string;
parameters: any[];
result: EntityData[];
};
export type RepositoryResponse<T = EntityData[]> = RepositoryRawResponse & {
entity: Entity;
data: T;
meta: {
items: number;
total?: number;
count?: number;
time?: number;
query?: {
sql: string;
parameters: readonly any[];
};
};
};
export type RepositoryCountResponse = RepositoryRawResponse & {
count: number;
};
export type RepositoryExistsResponse = RepositoryRawResponse & {
exists: boolean;
};
export type RepositoryOptions = { export type RepositoryOptions = {
silent?: boolean; silent?: boolean;
includeCounts?: boolean; includeCounts?: boolean;
@@ -182,126 +156,18 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
return validated; return validated;
} }
protected async executeQb(qb: RepositoryQB) { protected async performQuery<T = EntityData[]>(
const compiled = qb.compile(); qb: RepositoryQB,
if (this.options?.silent !== true) { opts?: RepositoryResultOptions,
$console.debug(`Repository: query\n${compiled.sql}\n`, compiled.parameters); execOpts?: { includeCounts?: boolean },
} ): Promise<RepositoryResult<T>> {
const result = new RepositoryResult(this.em, this.entity, {
let result: any; silent: this.options.silent,
try { ...opts,
result = await qb.execute(); });
} catch (e) { return (await result.execute(qb, {
if (this.options?.silent !== true) { includeCounts: execOpts?.includeCounts ?? this.options.includeCounts,
if (e instanceof Error) { })) as any;
$console.error("[ERROR] Repository.executeQb", e.message);
}
throw e;
}
}
return {
result,
sql: compiled.sql,
parameters: [...compiled.parameters],
};
}
protected async performQuery(qb: RepositoryQB): Promise<RepositoryResponse> {
const entity = this.entity;
const compiled = qb.compile();
const payload = {
entity,
sql: compiled.sql,
parameters: [...compiled.parameters],
result: [],
data: [],
meta: {
total: 0,
count: 0,
items: 0,
time: 0,
query: { sql: compiled.sql, parameters: compiled.parameters },
},
};
// don't batch (add counts) if `includeCounts` is set to false
// or when explicitly set to true and batching is not supported
if (
this.options?.includeCounts === false ||
(this.options?.includeCounts === true && !this.em.connection.supports("batching"))
) {
const start = performance.now();
const res = await this.executeQb(qb);
const time = Number.parseFloat((performance.now() - start).toFixed(2));
const result = res.result ?? [];
const data = this.em.hydrate(entity.name, result);
return {
...payload,
result,
data,
meta: {
...payload.meta,
total: undefined,
count: undefined,
items: data.length,
time,
},
};
}
if (this.options?.silent !== true) {
$console.debug(`Repository: query\n${compiled.sql}\n`, compiled.parameters);
}
const selector = (as = "count") => this.conn.fn.countAll<number>().as(as);
const countQuery = qb
.clearSelect()
.select(selector())
.clearLimit()
.clearOffset()
.clearGroupBy()
.clearOrderBy();
const totalQuery = this.conn.selectFrom(entity.name).select(selector());
try {
const start = performance.now();
const [_count, _total, result] = await this.em.connection.batchQuery([
countQuery,
totalQuery,
qb,
]);
const time = Number.parseFloat((performance.now() - start).toFixed(2));
const data = this.em.hydrate(entity.name, result);
return {
...payload,
result,
data,
meta: {
...payload.meta,
// parsing is important since pg returns string
total: ensureInt(_total[0]?.count),
count: ensureInt(_count[0]?.count),
items: result.length,
time,
},
};
} catch (e) {
if (this.options?.silent !== true) {
if (e instanceof Error) {
$console.error("[ERROR] Repository.performQuery", e.message);
}
throw e;
} else {
return payload;
}
}
} }
private async triggerFindBefore(entity: Entity, options: RepoQuery): Promise<void> { private async triggerFindBefore(entity: Entity, options: RepoQuery): Promise<void> {
@@ -319,7 +185,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
): Promise<void> { ): Promise<void> {
if (options.limit === 1) { if (options.limit === 1) {
await this.emgr.emit( await this.emgr.emit(
new Repository.Events.RepositoryFindOneAfter({ entity, options, data: data[0]! }), new Repository.Events.RepositoryFindOneAfter({ entity, options, data }),
); );
} else { } else {
await this.emgr.emit( await this.emgr.emit(
@@ -331,12 +197,11 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
protected async single( protected async single(
qb: RepositoryQB, qb: RepositoryQB,
options: RepoQuery, options: RepoQuery,
): Promise<RepositoryResponse<EntityData>> { ): Promise<RepositoryResult<TBD[TB] | undefined>> {
await this.triggerFindBefore(this.entity, options); await this.triggerFindBefore(this.entity, options);
const { data, ...response } = await this.performQuery(qb); const result = await this.performQuery(qb, { single: true });
await this.triggerFindAfter(this.entity, options, result.data);
await this.triggerFindAfter(this.entity, options, data); return result as any;
return { ...response, data: data[0]! };
} }
addOptionsToQueryBuilder( addOptionsToQueryBuilder(
@@ -413,7 +278,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
async findId( async findId(
id: PrimaryFieldType, id: PrimaryFieldType,
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>, _options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>,
): Promise<RepositoryResponse<TBD[TB] | undefined>> { ): Promise<RepositoryResult<TBD[TB] | undefined>> {
const { qb, options } = this.buildQuery( const { qb, options } = this.buildQuery(
{ {
..._options, ..._options,
@@ -429,7 +294,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
async findOne( async findOne(
where: RepoQuery["where"], where: RepoQuery["where"],
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>, _options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>,
): Promise<RepositoryResponse<TBD[TB] | undefined>> { ): Promise<RepositoryResult<TBD[TB] | undefined>> {
const { qb, options } = this.buildQuery({ const { qb, options } = this.buildQuery({
..._options, ..._options,
where, where,
@@ -439,7 +304,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
return (await this.single(qb, options)) as any; return (await this.single(qb, options)) as any;
} }
async findMany(_options?: Partial<RepoQuery>): Promise<RepositoryResponse<TBD[TB][]>> { async findMany(_options?: Partial<RepoQuery>): Promise<RepositoryResult<TBD[TB][]>> {
const { qb, options } = this.buildQuery(_options); const { qb, options } = this.buildQuery(_options);
await this.triggerFindBefore(this.entity, options); await this.triggerFindBefore(this.entity, options);
@@ -454,7 +319,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
id: PrimaryFieldType, id: PrimaryFieldType,
reference: string, reference: string,
_options?: Partial<Omit<RepoQuery, "limit" | "offset">>, _options?: Partial<Omit<RepoQuery, "limit" | "offset">>,
): Promise<RepositoryResponse<EntityData>> { ): Promise<RepositoryResult<EntityData>> {
const entity = this.entity; const entity = this.entity;
const listable_relations = this.em.relations.listableRelationsOf(entity); const listable_relations = this.em.relations.listableRelationsOf(entity);
const relation = listable_relations.find((r) => r.ref(reference).reference === reference); const relation = listable_relations.find((r) => r.ref(reference).reference === reference);
@@ -482,10 +347,10 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
}, },
}; };
return this.cloneFor(newEntity).findMany(findManyOptions); return this.cloneFor(newEntity).findMany(findManyOptions) as any;
} }
async count(where?: RepoQuery["where"]): Promise<RepositoryCountResponse> { async count(where?: RepoQuery["where"]): Promise<RepositoryResult<{ count: number }>> {
const entity = this.entity; const entity = this.entity;
const options = this.getValidOptions({ where }); const options = this.getValidOptions({ where });
@@ -497,17 +362,18 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
qb = WhereBuilder.addClause(qb, options.where); qb = WhereBuilder.addClause(qb, options.where);
} }
const { result, ...compiled } = await this.executeQb(qb); return await this.performQuery(
qb,
return { {
sql: compiled.sql, hydrator: (rows) => ({ count: rows[0]?.count ?? 0 }),
parameters: [...compiled.parameters], },
result, { includeCounts: false },
count: result[0]?.count ?? 0, );
};
} }
async exists(where: Required<RepoQuery>["where"]): Promise<RepositoryExistsResponse> { async exists(
where: Required<RepoQuery>["where"],
): Promise<RepositoryResult<{ exists: boolean }>> {
const entity = this.entity; const entity = this.entity;
const options = this.getValidOptions({ where }); const options = this.getValidOptions({ where });
@@ -517,13 +383,8 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
// add mandatory where // add mandatory where
qb = WhereBuilder.addClause(qb, options.where!).limit(1); qb = WhereBuilder.addClause(qb, options.where!).limit(1);
const { result, ...compiled } = await this.executeQb(qb); return await this.performQuery(qb, {
hydrator: (rows) => ({ exists: rows[0]?.count > 0 }),
return { });
sql: compiled.sql,
parameters: [...compiled.parameters],
result,
exists: result[0]!.count > 0,
};
} }
} }

View File

@@ -0,0 +1,105 @@
import { $console } from "core/console";
import type { Entity, EntityData } from "../Entity";
import type { EntityManager } from "../EntityManager";
import { Result, type ResultJSON, type ResultOptions } from "../Result";
import type { Compilable, SelectQueryBuilder } from "kysely";
import { ensureInt } from "core/utils";
export type RepositoryResultOptions = ResultOptions & {
silent?: boolean;
};
export type RepositoryResultJSON<T = EntityData[]> = ResultJSON<T>;
export class RepositoryResult<T = EntityData[]> extends Result<T> {
constructor(
protected em: EntityManager<any>,
public entity: Entity,
options?: RepositoryResultOptions,
) {
super(em.connection, {
hydrator: (rows) => em.hydrate(entity.name, rows as any),
beforeExecute: (compiled) => {
if (!options?.silent) {
$console.debug(`Query:\n${compiled.sql}\n`, compiled.parameters);
}
},
onError: (error) => {
if (options?.silent !== true) {
$console.error("Repository:", String(error));
throw error;
}
},
...options,
});
}
private shouldIncludeCounts(intent?: boolean) {
if (intent === undefined) return this.conn.supports("softscans");
return intent;
}
override async execute(
qb: SelectQueryBuilder<any, any, any>,
opts?: { includeCounts?: boolean },
) {
const includeCounts = this.shouldIncludeCounts(opts?.includeCounts);
if (includeCounts) {
const selector = (as = "count") => this.conn.kysely.fn.countAll<number>().as(as);
const countQuery = qb
.clearSelect()
.select(selector())
.clearLimit()
.clearOffset()
.clearGroupBy()
.clearOrderBy();
const totalQuery = this.conn.kysely.selectFrom(this.entity.name).select(selector());
const compiled = qb.compile();
this.options.beforeExecute?.(compiled);
try {
const start = performance.now();
const [main, count, total] = await this.em.connection.executeQueries(
compiled,
countQuery,
totalQuery,
);
this.time = Number.parseFloat((performance.now() - start).toFixed(2));
this.results.push({
...main,
data: this.options.hydrator?.(main.rows as T[]),
items: main.rows.length,
count: ensureInt(count.rows[0]?.count ?? 0),
total: ensureInt(total.rows[0]?.count ?? 0),
time: this.time,
sql: compiled.sql,
parameters: [...compiled.parameters],
});
} catch (e) {
if (this.options.onError) {
await this.options.onError(e as Error);
} else {
throw e;
}
}
return this;
}
return await super.execute(qb);
}
get count() {
return this.first().count;
}
get total() {
return this.first().total;
}
protected override additionalMetaKeys(): string[] {
return ["count", "total"];
}
}

View File

@@ -208,7 +208,7 @@ export class ManyToOneRelation extends EntityRelation<typeof ManyToOneRelation.s
[field.targetField()]: primaryReference as any, [field.targetField()]: primaryReference as any,
}); });
if (!query.exists) { if (!query.data.exists) {
const idProp = field.targetField(); const idProp = field.targetField();
throw new Error( throw new Error(
`Cannot connect "${entity.name}.${key}" to ` + `Cannot connect "${entity.name}.${key}" to ` +

View File

@@ -70,7 +70,7 @@ export class RelationMutator {
[field.targetField()]: value, [field.targetField()]: value,
}); });
if (!query.exists) { if (!query.data.exists) {
const idProp = field.targetField(); const idProp = field.targetField();
throw new Error( throw new Error(
`Cannot connect "${this.entity.name}.${key}" to ` + `Cannot connect "${this.entity.name}.${key}" to ` +

View File

@@ -311,6 +311,11 @@ export class SystemController extends Controller {
c.json({ c.json({
version: c.get("app")?.version(), version: c.get("app")?.version(),
runtime: getRuntimeKey(), runtime: getRuntimeKey(),
connection: {
name: this.app.em.connection.name,
// @ts-expect-error
supports: this.app.em.connection.supported,
},
timezone: { timezone: {
name: getTimezone(), name: getTimezone(),
offset: getTimezoneOffset(), offset: getTimezoneOffset(),

View File

@@ -5,6 +5,9 @@ import { App, registries } from "./src";
import { StorageLocalAdapter } from "./src/adapter/node"; import { StorageLocalAdapter } from "./src/adapter/node";
import { EntityManager, LibsqlConnection } from "data"; import { EntityManager, LibsqlConnection } from "data";
import { __bknd } from "modules/ModuleManager"; import { __bknd } from "modules/ModuleManager";
import { $console } from "core";
//import { DatabaseSync } from "node:sqlite";
//import { nodeSqlite } from "./src/adapter/node/connection/NodeSqliteConnection";
registries.media.register("local", StorageLocalAdapter); registries.media.register("local", StorageLocalAdapter);
@@ -58,6 +61,7 @@ export default {
if (!app || recreate) { if (!app || recreate) {
app = App.create({ app = App.create({
connection: credentials, connection: credentials,
//connection: nodeSqlite({ database: new DatabaseSync(":memory:") }),
}); });
app.emgr.onEvent( app.emgr.onEvent(
App.Events.AppBuiltEvent, App.Events.AppBuiltEvent,

View File

@@ -8,7 +8,7 @@ export default defineConfig({
globals: true, globals: true,
environment: "jsdom", environment: "jsdom",
setupFiles: ["./__test__/vitest/setup.ts"], setupFiles: ["./__test__/vitest/setup.ts"],
include: ["**/*.vi-test.ts"], include: ["**/*.vi-test.ts", "**/*.vitest.ts"],
coverage: { coverage: {
provider: "v8", provider: "v8",
reporter: ["text", "json", "html"], reporter: ["text", "json", "html"],

View File

@@ -26,7 +26,7 @@
}, },
"app": { "app": {
"name": "bknd", "name": "bknd",
"version": "0.14.0-rc.0", "version": "0.14.0-rc.2",
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"dependencies": { "dependencies": {
"@cfworker/json-schema": "^4.1.1", "@cfworker/json-schema": "^4.1.1",
@@ -87,6 +87,7 @@
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"jsonv-ts": "^0.1.0", "jsonv-ts": "^0.1.0",
"kysely-d1": "^0.3.0", "kysely-d1": "^0.3.0",
"kysely-generic-sqlite": "^1.2.1",
"open": "^10.1.0", "open": "^10.1.0",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -2554,6 +2555,8 @@
"kysely-d1": ["kysely-d1@0.3.0", "", { "peerDependencies": { "kysely": "*" } }, "sha512-9wTbE6ooLiYtBa4wPg9e4fjfcmvRtgE/2j9pAjYrIq+iz+EsH/Hj9YbtxpEXA6JoRgfulVQ1EtGj6aycGGRpYw=="], "kysely-d1": ["kysely-d1@0.3.0", "", { "peerDependencies": { "kysely": "*" } }, "sha512-9wTbE6ooLiYtBa4wPg9e4fjfcmvRtgE/2j9pAjYrIq+iz+EsH/Hj9YbtxpEXA6JoRgfulVQ1EtGj6aycGGRpYw=="],
"kysely-generic-sqlite": ["kysely-generic-sqlite@1.2.1", "", { "peerDependencies": { "kysely": ">=0.26" } }, "sha512-/Bs3/Uktn04nQ9g/4oSphLMEtSHkS5+j5hbKjK5gMqXQfqr/v3V3FKtoN4pLTmo2W35hNdrIpQnBukGL1zZc6g=="],
"kysely-neon": ["kysely-neon@1.3.0", "", { "peerDependencies": { "@neondatabase/serverless": "^0.4.3", "kysely": "0.x.x", "ws": "^8.13.0" }, "optionalPeers": ["ws"] }, "sha512-CIIlbmqpIXVJDdBEYtEOwbmALag0jmqYrGfBeM4cHKb9AgBGs+X1SvXUZ8TqkDacQEqEZN2XtsDoUkcMIISjHw=="], "kysely-neon": ["kysely-neon@1.3.0", "", { "peerDependencies": { "@neondatabase/serverless": "^0.4.3", "kysely": "0.x.x", "ws": "^8.13.0" }, "optionalPeers": ["ws"] }, "sha512-CIIlbmqpIXVJDdBEYtEOwbmALag0jmqYrGfBeM4cHKb9AgBGs+X1SvXUZ8TqkDacQEqEZN2XtsDoUkcMIISjHw=="],
"kysely-postgres-js": ["kysely-postgres-js@2.0.0", "", { "peerDependencies": { "kysely": ">= 0.24.0 < 1", "postgres": ">= 3.4.0 < 4" } }, "sha512-R1tWx6/x3tSatWvsmbHJxpBZYhNNxcnMw52QzZaHKg7ZOWtHib4iZyEaw4gb2hNKVctWQ3jfMxZT/ZaEMK6kBQ=="], "kysely-postgres-js": ["kysely-postgres-js@2.0.0", "", { "peerDependencies": { "kysely": ">= 0.24.0 < 1", "postgres": ">= 3.4.0 < 4" } }, "sha512-R1tWx6/x3tSatWvsmbHJxpBZYhNNxcnMw52QzZaHKg7ZOWtHib4iZyEaw4gb2hNKVctWQ3jfMxZT/ZaEMK6kBQ=="],