@morlay/rdbkit 0.0.0-stage → 0.0.1-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -2
  3. package/dist/index.d.mts +518 -0
  4. package/dist/index.mjs +1422 -0
  5. package/dist/testing.d.mts +28 -0
  6. package/dist/testing.mjs +42 -0
  7. package/package.json +49 -3
  8. package/src/__tests__/database-introspect.spec.ts +71 -0
  9. package/src/__tests__/expr-functions.spec.ts +64 -0
  10. package/src/__tests__/migration.spec.ts +81 -0
  11. package/src/__tests__/pg.spec.ts +123 -0
  12. package/src/__tests__/query-join.spec.ts +114 -0
  13. package/src/__tests__/query-p1.spec.ts +108 -0
  14. package/src/__tests__/query-write.spec.ts +293 -0
  15. package/src/__tests__/query.spec.ts +88 -0
  16. package/src/__tests__/schema.spec.ts +144 -0
  17. package/src/__tests__/transaction.spec.ts +83 -0
  18. package/src/catalog.ts +24 -0
  19. package/src/compound.ts +64 -0
  20. package/src/database/capabilities.ts +70 -0
  21. package/src/database/create.ts +119 -0
  22. package/src/database/index.ts +9 -0
  23. package/src/database/postgres.ts +133 -0
  24. package/src/database/queue.ts +24 -0
  25. package/src/database/sqlite.ts +66 -0
  26. package/src/database/types.ts +87 -0
  27. package/src/database/wrap.ts +48 -0
  28. package/src/defs.ts +82 -0
  29. package/src/dialect.ts +219 -0
  30. package/src/error.ts +72 -0
  31. package/src/expr.ts +346 -0
  32. package/src/fragment.ts +96 -0
  33. package/src/index.ts +114 -0
  34. package/src/migrate.ts +133 -0
  35. package/src/migration.ts +82 -0
  36. package/src/table/handle.ts +142 -0
  37. package/src/table/index.ts +14 -0
  38. package/src/table/render.ts +140 -0
  39. package/src/table/select.ts +229 -0
  40. package/src/table/types.ts +53 -0
  41. package/src/testing.ts +56 -0
@@ -0,0 +1,28 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ /**
3
+ * 测试专用句柄:造损坏数据、读 `PRAGMA` 这类只有同步驱动才做得到的事。
4
+ *
5
+ * 生产路径不经过这里——业务侧拿的是 `Database`(`openDatabase`),没有同步口。
6
+ * @param path - SQLite 库文件路径。
7
+ * @param options - 与打开库时一致的 busy timeout / journal mode。
8
+ * @returns 同步句柄(用完要 `close()`)。
9
+ */
10
+ export declare function openSqliteHandle(path: string, options?: {
11
+ journalMode?: string;
12
+ busyTimeout?: number;
13
+ }): DatabaseSync;
14
+ /** 一个临时的 PostgreSQL 测试库:用完必须 `drop()`。 */
15
+ export interface PostgresTestDatabase {
16
+ connectionString: string;
17
+ drop: () => Promise<void>;
18
+ }
19
+ /**
20
+ * 建一个随机名的 PostgreSQL 测试库(用完 drop)。
21
+ *
22
+ * 建库是管理面操作:业务侧的 `Database` 不提供它,测试要独立库时用这里。
23
+ * @param options - `adminUrl` 指向有建库权限的连接串;省略时读 `TEST_PG_URL`。
24
+ * @returns 指向新库的连接串与清理函数。
25
+ */
26
+ export declare function createPostgresTestDatabase(options?: {
27
+ adminUrl?: string;
28
+ }): Promise<PostgresTestDatabase>;
@@ -0,0 +1,42 @@
1
+ import { Client } from "pg";
2
+ import { DatabaseSync } from "node:sqlite";
3
+ import { randomUUID } from "node:crypto";
4
+ // src/testing.ts
5
+ /**
6
+ * 测试专用句柄:造损坏数据、读 `PRAGMA` 这类只有同步驱动才做得到的事。
7
+ *
8
+ * 生产路径不经过这里——业务侧拿的是 `Database`(`openDatabase`),没有同步口。
9
+ * @param path - SQLite 库文件路径。
10
+ * @param options - 与打开库时一致的 busy timeout / journal mode。
11
+ * @returns 同步句柄(用完要 `close()`)。
12
+ */
13
+ function openSqliteHandle(path, options = {}) {
14
+ const client = new DatabaseSync(path);
15
+ client.exec(`PRAGMA busy_timeout = ${options.busyTimeout ?? 5e3}`);
16
+ if (options.journalMode !== void 0) client.exec(`PRAGMA journal_mode = ${options.journalMode}`);
17
+ return client;
18
+ }
19
+ /**
20
+ * 建一个随机名的 PostgreSQL 测试库(用完 drop)。
21
+ *
22
+ * 建库是管理面操作:业务侧的 `Database` 不提供它,测试要独立库时用这里。
23
+ * @param options - `adminUrl` 指向有建库权限的连接串;省略时读 `TEST_PG_URL`。
24
+ * @returns 指向新库的连接串与清理函数。
25
+ */
26
+ async function createPostgresTestDatabase(options = {}) {
27
+ const adminUrl = options.adminUrl ?? process.env["TEST_PG_URL"] ?? "postgres://postgres:postgres@localhost:25433/postgres";
28
+ const name = `dsh_test_${randomUUID().replaceAll("-", "")}`;
29
+ const admin = new Client({ connectionString: adminUrl });
30
+ await admin.connect();
31
+ await admin.query(`CREATE DATABASE "${name}"`);
32
+ const url = new URL(adminUrl);
33
+ url.pathname = `/${name}`;
34
+ return {
35
+ connectionString: url.toString(),
36
+ drop: async () => {
37
+ await admin.query(`DROP DATABASE IF EXISTS "${name}" WITH (FORCE)`);
38
+ await admin.end();
39
+ }
40
+ };
41
+ }
42
+ export { createPostgresTestDatabase, openSqliteHandle };
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "@morlay/rdbkit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.1-alpha.2",
4
+ "description": "Schema-first RDB kit for DeepSeek Harness plugins: one table definition drives the DDL, the typed table handles and the startup migration for SQLite and PostgreSQL alike; business code only ever sees an injected Database.",
5
+ "keywords": [
6
+ "dsh",
7
+ "dsh-plugin",
8
+ "postgres",
9
+ "rdb",
10
+ "schema",
11
+ "sqlite"
12
+ ],
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/morlay/dsh-plugin.git"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src"
21
+ ],
22
+ "type": "module",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.mts",
26
+ "default": "./dist/index.mjs"
27
+ },
28
+ "./testing": {
29
+ "types": "./dist/testing.d.mts",
30
+ "default": "./dist/testing.mjs"
31
+ },
32
+ "./package.json": "./package.json"
33
+ },
34
+ "dependencies": {
35
+ "pg": "^8.23.0",
36
+ "pg-cursor": "^2.22.1"
37
+ },
38
+ "devDependencies": {
39
+ "@local/devkit": "0.1.0",
40
+ "@local/tsdown-plugin-cordis": "0.1.0",
41
+ "@types/node": "^26.6.5",
42
+ "@types/pg": "^8.23.1",
43
+ "@types/pg-cursor": "^2.7.2"
44
+ },
45
+ "engines": {
46
+ "node": ">=24.11.0",
47
+ "dsh": ">= 0.2.2-alpha.2 < 0.2.3"
48
+ },
49
+ "scripts": {
50
+ "build": "pnpm exec tsdown"
51
+ }
6
52
  }
@@ -0,0 +1,71 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { defineTable, ensureSchema, openDatabase } from "../index.ts";
6
+
7
+ const dirs: string[] = [];
8
+ afterEach(async () => {
9
+ for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true });
10
+ });
11
+
12
+ async function tempFile(): Promise<string> {
13
+ const dir = await mkdtemp(join(tmpdir(), "rdbkit-pragma-"));
14
+ dirs.push(dir);
15
+ return join(dir, "store.sqlite");
16
+ }
17
+
18
+ const notes = defineTable({
19
+ name: "t_introspect_notes",
20
+ columns: { f_id: { type: "text", primaryKey: true, notNull: true } },
21
+ });
22
+
23
+ describe("库级操作(业务侧不写 PRAGMA / VACUUM)", () => {
24
+ it("objectNames:返回现有的表与视图名", async () => {
25
+ const database = await openDatabase({ dialect: "sqlite", path: ":memory:" });
26
+ try {
27
+ await ensureSchema(database, [notes]);
28
+ expect(await database.objectNames()).toContain("t_introspect_notes");
29
+ } finally {
30
+ await database.close();
31
+ }
32
+ });
33
+
34
+ it("sqlitePragma:读写库身份(user_version / application_id / journal_mode)", async () => {
35
+ // journal_mode 在 `:memory:` 上只能是 `memory`,所以用文件库。
36
+ const database = await openDatabase({ dialect: "sqlite", path: await tempFile() });
37
+ try {
38
+ expect(await database.sqlitePragma("user_version")).toBe(0);
39
+ await database.setSqlitePragma("user_version", 7);
40
+ expect(await database.sqlitePragma("user_version")).toBe(7);
41
+ await database.setSqlitePragma("application_id", 12345);
42
+ expect(await database.sqlitePragma("application_id")).toBe(12345);
43
+ await database.setSqlitePragma("journal_mode", "wal");
44
+ expect(await database.sqlitePragma("journal_mode")).toBe("wal");
45
+ } finally {
46
+ await database.close();
47
+ }
48
+ });
49
+
50
+ it("setSqlitePragma 拒绝拼进 SQL 的值", async () => {
51
+ const database = await openDatabase({ dialect: "sqlite", path: ":memory:" });
52
+ try {
53
+ await expect(database.setSqlitePragma("user_version", "0; DROP TABLE x")).rejects.toThrow(
54
+ /invalid pragma value/,
55
+ );
56
+ } finally {
57
+ await database.close();
58
+ }
59
+ });
60
+
61
+ it("vacuum:两种方言各有自己的写法,业务侧只调方法", async () => {
62
+ const database = await openDatabase({ dialect: "sqlite", path: ":memory:" });
63
+ try {
64
+ await ensureSchema(database, [notes]);
65
+ await database.vacuum();
66
+ expect(await database.objectNames()).toContain("t_introspect_notes");
67
+ } finally {
68
+ await database.close();
69
+ }
70
+ });
71
+ });
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { column, E, operandSql, render } from "../index.ts";
3
+
4
+ /** 表达式 → 某方言的 SQL 文本(不需要连库)。 */
5
+ function sqlOf(expression: import("../index.ts").Operand, dialect: "sqlite" | "postgres"): string {
6
+ return render(operandSql(expression), dialect).text;
7
+ }
8
+
9
+ describe("业务表达式(方言相关的由 rdbkit 渲染)", () => {
10
+ it("add:列自增与 upsert 里的 excluded 累加", () => {
11
+ expect(sqlOf(E.add(column("f_revision"), 1), "sqlite")).toBe('"f_revision" + ?');
12
+ expect(sqlOf(E.add(column("f_count"), E.excluded("f_count")), "postgres")).toBe(
13
+ '"f_count" + excluded."f_count"',
14
+ );
15
+ });
16
+
17
+ it("toInt / countIf:布尔条件当整数列与条件计数", () => {
18
+ expect(sqlOf(E.toInt(E.eq(column("f_origin", "t_sessions"), "subagent")), "sqlite")).toBe(
19
+ "CASE WHEN \"t_sessions\".\"f_origin\" = ? THEN 1 ELSE 0 END",
20
+ );
21
+ expect(sqlOf(E.countIf(E.eq(column("f_type"), "turn/start")), "postgres")).toBe(
22
+ 'SUM(CASE WHEN "f_type" = $1 THEN 1 ELSE 0 END)',
23
+ );
24
+ });
25
+
26
+ it("maxOf:只增不减(两方言同一份 CASE 表达式)", () => {
27
+ expect(sqlOf(E.maxOf(column("f_last_event_at"), 42), "sqlite")).toBe(
28
+ 'CASE WHEN COALESCE("f_last_event_at", 0) < ? THEN ? ELSE COALESCE("f_last_event_at", 0) END',
29
+ );
30
+ });
31
+
32
+ it("localDay:按本地时区把毫秒时间戳渲染成 YYYY-MM-DD", () => {
33
+ expect(sqlOf(E.localDay(column("f_created_at")), "sqlite")).toBe(
34
+ "date(\"f_created_at\" / 1000, 'unixepoch', 'localtime')",
35
+ );
36
+ expect(sqlOf(E.localDay(column("f_created_at")), "postgres")).toBe(
37
+ "to_char(to_timestamp(\"f_created_at\" / 1000.0), 'YYYY-MM-DD')",
38
+ );
39
+ });
40
+
41
+ it("jsonText / jsonInt:JSON 提取按方言渲染,路径不拼进 SQL 文本", () => {
42
+ expect(sqlOf(E.jsonText(column("f_data"), "$.data.message.source.provider"), "sqlite")).toBe(
43
+ 'json_extract("f_data", ?)',
44
+ );
45
+ expect(sqlOf(E.jsonText(column("f_data"), "$.data.message.source.provider"), "postgres")).toBe(
46
+ `"f_data"::json #>> '{data,message,source,provider}'`,
47
+ );
48
+ expect(sqlOf(E.jsonInt(column("f_data"), "$.data.usage.inputTokens"), "sqlite")).toBe(
49
+ 'json_extract("f_data", ?)',
50
+ );
51
+ expect(sqlOf(E.jsonInt(column("f_data"), "$.data.usage.inputTokens"), "postgres")).toBe(
52
+ `("f_data"::json #>> '{data,usage,inputTokens}')::integer`,
53
+ );
54
+ });
55
+
56
+ it("方言分支只保留本方言的占位符(不产生多余绑定参数)", () => {
57
+ const expression = E.localDay(column("f_created_at"));
58
+ expect(render(operandSql(expression), "sqlite").params).toEqual([]);
59
+ expect(render(operandSql(expression), "postgres").params).toEqual([]);
60
+ const withParam = E.add(column("f_count"), 7);
61
+ expect(render(operandSql(withParam), "sqlite").params).toEqual([7]);
62
+ expect(render(operandSql(withParam), "postgres").params).toEqual([7]);
63
+ });
64
+ });
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ MigrationRegistry,
4
+ defineTable,
5
+ openDatabase,
6
+ runMigrations,
7
+ schemaMigrations,
8
+ } from "../index.ts";
9
+
10
+ const notes = defineTable({
11
+ name: "t_notes",
12
+ columns: {
13
+ f_id: { type: "text", primaryKey: true, notNull: true },
14
+ f_body: { type: "text" },
15
+ },
16
+ });
17
+
18
+ describe("迁移注册表", () => {
19
+ it("按 id 顺序执行一次、记账、幂等;失败的迁移不记账", async () => {
20
+ const database = await openDatabase({ dialect: "sqlite", path: ":memory:" });
21
+ try {
22
+ const ran: string[] = [];
23
+ const registry = new MigrationRegistry()
24
+ .register({
25
+ id: "002-second",
26
+ up: async (tx) => {
27
+ ran.push("002");
28
+ await tx.table(notes).insert({ f_id: "b", f_body: "second" });
29
+ },
30
+ })
31
+ .register({
32
+ id: "001-first",
33
+ up: async (tx) => {
34
+ ran.push("001");
35
+ await tx.table(notes).insert({ f_id: "a", f_body: "first" });
36
+ },
37
+ })
38
+ .register({
39
+ id: "003-broken",
40
+ up: async () => {
41
+ ran.push("003");
42
+ throw new Error("boom");
43
+ },
44
+ });
45
+
46
+ await expect(
47
+ runMigrations(database, { catalog: [notes], migrations: registry }),
48
+ ).rejects.toThrow("boom");
49
+ // 001、002 已记账;003 失败回滚,不记账。
50
+ expect(ran).toEqual(["001", "002", "003"]);
51
+
52
+ const notesTable = database.table(notes);
53
+ const bodyById = new Map<string, string | null>();
54
+ for await (const row of notesTable.select({ id: notesTable.f_id, body: notesTable.f_body })) {
55
+ bodyById.set(row.id, row.body);
56
+ }
57
+ expect(bodyById.get("a")).toBe("first");
58
+ expect(bodyById.get("b")).toBe("second");
59
+
60
+ // 已经记过账的不会再跑;003 仍然失败(账本里没有它)。
61
+ ran.length = 0;
62
+ await expect(
63
+ runMigrations(database, { catalog: [notes], migrations: registry }),
64
+ ).rejects.toThrow("boom");
65
+ expect(ran).toEqual(["003"]);
66
+
67
+ // 去掉失败的迁移后,只剩「无需执行」。
68
+ const healthy = new MigrationRegistry().register({
69
+ id: "001-first",
70
+ up: async () => {},
71
+ });
72
+ expect(await runMigrations(database, { catalog: [notes], migrations: healthy })).toEqual([]);
73
+ const ledger = database.table(schemaMigrations);
74
+ const ledgerRows: string[] = [];
75
+ for await (const row of ledger.select({ id: ledger.f_id })) ledgerRows.push(row.id);
76
+ expect(ledgerRows.sort()).toEqual(["001-first", "002-second"]);
77
+ } finally {
78
+ await database.close();
79
+ }
80
+ });
81
+ });
@@ -0,0 +1,123 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ createPostgresTestDatabase,
4
+ type PostgresTestDatabase,
5
+ } from "../testing.ts";
6
+ import {
7
+ count,
8
+ defineCatalog,
9
+ defineTable,
10
+ ensureSchema,
11
+ fragment,
12
+ openDatabase,
13
+ sum,
14
+ } from "../index.ts";
15
+
16
+ const items = defineTable({
17
+ name: "t_items",
18
+ columns: {
19
+ f_id: { type: "serial", primaryKey: true },
20
+ f_owner: { type: "text", notNull: true },
21
+ f_count: { type: "integer", notNull: true, default: 0 },
22
+ f_active: { type: "boolean", notNull: true, default: 1 },
23
+ // `bigint` 在 PostgreSQL 上默认由驱动给字符串;rdbkit 的值类型是 bigint(BigInt),要有用例盯住。
24
+ f_at: { type: "bigint", notNull: true, default: 0 },
25
+ },
26
+ indexes: { idx_items_owner: { columns: ["f_owner"] } },
27
+ });
28
+
29
+ const parts = defineTable({
30
+ name: "t_parts",
31
+ columns: {
32
+ f_id: { type: "serial", primaryKey: true },
33
+ f_owner: { type: "text", notNull: true },
34
+ f_amount: { type: "integer", notNull: true },
35
+ // `SUM(bigint)` 在 PostgreSQL 上返回 numeric(驱动给字符串),rdbkit 的 sum 要 cast 回 int8。
36
+ f_big: { type: "bigint", notNull: true, default: 0 },
37
+ },
38
+ });
39
+
40
+ describe.skipIf(!process.env.TEST_PG_URL)("PostgreSQL 上同一套构建", () => {
41
+ it("建表 / 投影聚合 / join / 批量插入 / 事务回滚 / 迁移差异", async () => {
42
+ const testDatabase: PostgresTestDatabase = await createPostgresTestDatabase();
43
+ const database = await openDatabase({
44
+ dialect: "postgres",
45
+ connectionString: testDatabase.connectionString,
46
+ catalog: defineCatalog([items, parts]),
47
+ });
48
+ try {
49
+ const statements = await ensureSchema(database, [items, parts]);
50
+ expect(
51
+ statements.some((statement) =>
52
+ statement.includes('CREATE TABLE IF NOT EXISTS "public"."t_items"'),
53
+ ),
54
+ ).toBe(true);
55
+ expect(
56
+ statements.some((statement) =>
57
+ statement.includes('CREATE TABLE IF NOT EXISTS "public"."t_parts"'),
58
+ ),
59
+ ).toBe(true);
60
+ // 幂等
61
+ expect(await ensureSchema(database, [items, parts])).toEqual([]);
62
+
63
+ const table = database.table("t_items");
64
+ const partTable = database.table("t_parts");
65
+ const [created] = await table.insert(
66
+ { f_owner: "a", f_count: 1, f_at: 1_788_852_417_123 },
67
+ ["f_id", "f_owner", "f_at"],
68
+ );
69
+ expect(created).toMatchObject({ f_owner: "a" });
70
+ // 64 位整数读回来是 BigInt:超过 2^53 的 uint64 / int64 不会丢精度。
71
+ expect(created?.f_at).toBe(1_788_852_417_123n);
72
+ await table.insertMany([{ f_owner: "b", f_count: 2 }]);
73
+ await partTable.insertMany([
74
+ { f_owner: "a", f_amount: 5, f_big: 5_000_000_000n },
75
+ { f_owner: "a", f_amount: 6, f_big: 6n },
76
+ ]);
77
+
78
+ const totals: { owner: string; total: number | bigint; big: number | bigint }[] = [];
79
+ for await (const row of table
80
+ .select({
81
+ owner: table.column("f_owner"),
82
+ total: sum("f_amount", "t_parts"),
83
+ big: sum("f_big", "t_parts"),
84
+ })
85
+ .join(parts, fragment('"t_parts"."f_owner" = "t_items"."f_owner"'))
86
+ .groupBy("f_owner")
87
+ .orderBy("f_owner")) {
88
+ totals.push(row);
89
+ }
90
+ // PostgreSQL 的 SUM 返回 int8 → BigInt;`SUM(bigint)` 是 numeric,sum() 会 cast 回 int8。
91
+ expect(totals).toEqual([{ owner: "a", total: 11n, big: 5_000_000_006n }]);
92
+
93
+ // 真游标:逐行读,提前 break 也要把连接还回去(再开新查询不挂)。
94
+ const streamed: string[] = [];
95
+ for await (const row of table.select({ owner: table.column("f_owner") }).orderBy("f_owner")) {
96
+ streamed.push(row.owner);
97
+ break;
98
+ }
99
+ expect(streamed).toEqual(["a"]);
100
+
101
+ const [before] = await collect(table.select({ n: count() }));
102
+ expect(Number(before?.n)).toBe(2);
103
+
104
+ await expect(
105
+ database.transaction(async (tx) => {
106
+ await tx.table("t_items").insert({ f_owner: "c" });
107
+ throw new Error("boom");
108
+ }),
109
+ ).rejects.toThrow("boom");
110
+ const [after] = await collect(table.select({ n: count() }));
111
+ expect(Number(after?.n)).toBe(2);
112
+ } finally {
113
+ await database.close();
114
+ await testDatabase.drop();
115
+ }
116
+ });
117
+ });
118
+
119
+ async function collect<T>(rows: AsyncIterable<T>): Promise<T[]> {
120
+ const result: T[] = [];
121
+ for await (const row of rows) result.push(row);
122
+ return result;
123
+ }
@@ -0,0 +1,114 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ and,
4
+ between,
5
+ column,
6
+ defineCatalog,
7
+ defineTable,
8
+ eq,
9
+ fragment,
10
+ gt,
11
+ inList,
12
+ not,
13
+ openDatabase,
14
+ raw,
15
+ sum,
16
+ value,
17
+ } from "../index.ts";
18
+
19
+ const users = defineTable({
20
+ name: "t_users",
21
+ columns: {
22
+ f_id: { type: "serial", primaryKey: true },
23
+ f_name: { type: "text", notNull: true },
24
+ f_active: { type: "boolean", notNull: true, default: 1 },
25
+ },
26
+ });
27
+
28
+ const orders = defineTable({
29
+ name: "t_orders",
30
+ columns: {
31
+ f_id: { type: "serial", primaryKey: true },
32
+ f_user: { type: "text", notNull: true },
33
+ f_amount: { type: "integer", notNull: true },
34
+ },
35
+ });
36
+
37
+ describe("关联 / 聚合 / 条件(两方言同一套构建)", () => {
38
+ it("join + group by + having、distinct、between、not、raw", async () => {
39
+ const database = await openDatabase({
40
+ dialect: "sqlite",
41
+ path: ":memory:",
42
+ catalog: defineCatalog([users, orders]),
43
+ });
44
+ try {
45
+ await database.run(
46
+ fragment(
47
+ 'CREATE TABLE "t_users" ("f_id" INTEGER PRIMARY KEY AUTOINCREMENT, "f_name" TEXT NOT NULL, "f_active" INTEGER NOT NULL DEFAULT 1)',
48
+ ),
49
+ );
50
+ await database.run(
51
+ fragment(
52
+ 'CREATE TABLE "t_orders" ("f_id" INTEGER PRIMARY KEY AUTOINCREMENT, "f_user" TEXT NOT NULL, "f_amount" INTEGER NOT NULL)',
53
+ ),
54
+ );
55
+
56
+ const userTable = database.table("t_users");
57
+ const orderTable = database.table("t_orders");
58
+ await userTable.insertMany([{ f_name: "ada" }, { f_name: "bob" }, { f_name: "carol" }]);
59
+ await orderTable.insertMany([
60
+ { f_user: "ada", f_amount: 3 },
61
+ { f_user: "ada", f_amount: 4 },
62
+ { f_user: "bob", f_amount: 1 },
63
+ ]);
64
+
65
+ // join + 聚合 + having + 排序
66
+ const totals: { name: string; total: number | bigint }[] = [];
67
+ for await (const row of userTable
68
+ .select({ name: userTable.column("f_name"), total: sum("f_amount", "t_orders") })
69
+ .join(orders, eq(column("f_user", "t_orders"), column("f_name", "t_users")))
70
+ .groupBy("f_name")
71
+ .having(gt(sum("f_amount", "t_orders"), value(2)))
72
+ .orderBy("f_name")) {
73
+ totals.push(row);
74
+ }
75
+ expect(totals).toEqual([{ name: "ada", total: 7 }]);
76
+
77
+ // distinct 投影
78
+ const actives: boolean[] = [];
79
+ for await (const row of userTable
80
+ .select({ active: userTable.column("f_active") })
81
+ .distinct()) {
82
+ actives.push(row.active);
83
+ }
84
+ expect(actives).toEqual([1]);
85
+
86
+ // between / not / raw / inList
87
+ const picked: number[] = [];
88
+ for await (const row of orderTable
89
+ .select({ id: orderTable.column("f_id") })
90
+ .where(
91
+ and(
92
+ between(column("f_amount"), value(2), value(5)),
93
+ not(inList(column("f_user"), ["bob"])),
94
+ raw('"f_amount" <> ?', [99]),
95
+ ),
96
+ )) {
97
+ picked.push(row.id);
98
+ }
99
+ expect(picked).toEqual([1, 2]);
100
+
101
+ // 左连接 + 计数:没有订单的用户也出现
102
+ const counts: { name: string; total: number | bigint }[] = [];
103
+ for await (const row of userTable
104
+ .select({ name: userTable.column("f_name"), total: sum("f_amount", "t_orders") })
105
+ .join(orders, eq(column("f_user", "t_orders"), column("f_name", "t_users")), "left")
106
+ .groupBy("f_name")) {
107
+ counts.push(row);
108
+ }
109
+ expect(counts.some((row) => row.name === "bob" && Number(row.total) === 1)).toBe(true);
110
+ } finally {
111
+ await database.close();
112
+ }
113
+ });
114
+ });