@morlay/rdbkit 0.0.1 → 0.0.3
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.
- package/README.md +13 -1
- package/dist/index.mjs +64 -49
- package/package.json +2 -2
- package/src/__tests__/pg.spec.ts +84 -8
- package/src/__tests__/schema.spec.ts +129 -0
- package/src/database/capabilities.ts +2 -1
- package/src/database/create.ts +13 -8
- package/src/database/types.ts +5 -0
- package/src/database/wrap.ts +3 -0
- package/src/dialect.ts +20 -8
- package/src/migrate.ts +12 -6
- package/src/table/handle.ts +13 -9
- package/src/table/render.ts +23 -13
- package/src/table/select.ts +6 -3
package/README.md
CHANGED
|
@@ -84,10 +84,22 @@ await database.setSqlitePragma("user_version", 4); // 写库身份(值只允
|
|
|
84
84
|
```
|
|
85
85
|
|
|
86
86
|
|
|
87
|
+
## PostgreSQL schema
|
|
88
|
+
|
|
89
|
+
连接配置里的 `schema` 是**唯一事实来源**:结构探测、DDL、表句柄的读写、迁移账本与 `objectNames` 都从
|
|
90
|
+
`Database.schema` 取,一律渲染成 `"<schema>"."<表>"`(SQLite 没有 schema 概念,照旧不带前缀)。省略即
|
|
91
|
+
`public`,不依赖连接串的 `search_path`。schema 本身要由部署方先建好(`CREATE SCHEMA ...`),rdbkit 不替你建。
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
const database = await openDatabase({ dialect: "postgres", connectionString, schema: "dsh_test" });
|
|
95
|
+
await ensureSchema(database, catalog); // CREATE TABLE "dsh_test"."t_sessions" ...
|
|
96
|
+
await runMigrations(database, { catalog, migrations }); // 账本也在 dsh_test
|
|
97
|
+
```
|
|
98
|
+
|
|
87
99
|
## 迁移
|
|
88
100
|
|
|
89
101
|
结构(建表 / 补列 / 补索引)由 `ensureSchema` 从表定义算差异;**数据迁移**走注册表,账本是内置的
|
|
90
|
-
`t_schema_migrations
|
|
102
|
+
`t_schema_migrations`(与业务表同 schema,见上节):
|
|
91
103
|
|
|
92
104
|
```ts
|
|
93
105
|
const migrations = new MigrationRegistry().register({
|
package/dist/index.mjs
CHANGED
|
@@ -98,8 +98,8 @@ function postgresDialect() {
|
|
|
98
98
|
}
|
|
99
99
|
};
|
|
100
100
|
}
|
|
101
|
-
/** 一列的定义 SQL(`"name" TEXT NOT NULL
|
|
102
|
-
function columnSql$1(dialect, name, column) {
|
|
101
|
+
/** 一列的定义 SQL(`"name" TEXT NOT NULL ...`);外键引用跟着表名一起落在同一 schema。 */
|
|
102
|
+
function columnSql$1(dialect, name, column, schema) {
|
|
103
103
|
assertIdentifier("column", name);
|
|
104
104
|
const parts = [dialect.quote(name), dialect.columnType(column)];
|
|
105
105
|
if (column.type === "serial" && dialect.name === "sqlite") parts.push("PRIMARY KEY AUTOINCREMENT");
|
|
@@ -111,7 +111,7 @@ function columnSql$1(dialect, name, column) {
|
|
|
111
111
|
const { table, column: referenced, onDelete } = column.references;
|
|
112
112
|
assertIdentifier("table", table);
|
|
113
113
|
assertIdentifier("column", referenced);
|
|
114
|
-
parts.push(`REFERENCES ${dialect.tableName(table)}(${dialect.quote(referenced)})` + (onDelete === void 0 ? "" : ` ON DELETE ${onDelete.toUpperCase()}`));
|
|
114
|
+
parts.push(`REFERENCES ${dialect.tableName(table, schema)}(${dialect.quote(referenced)})` + (onDelete === void 0 ? "" : ` ON DELETE ${onDelete.toUpperCase()}`));
|
|
115
115
|
}
|
|
116
116
|
return parts.join(" ");
|
|
117
117
|
}
|
|
@@ -125,7 +125,7 @@ function literal(dialect, value, type) {
|
|
|
125
125
|
}
|
|
126
126
|
/** 建表语句(IF NOT EXISTS;列与表级约束都由定义算出来)。 */
|
|
127
127
|
function createTableSql(dialect, table, schema) {
|
|
128
|
-
const parts = Object.entries(table.columns).map(([name, column]) => columnSql$1(dialect, name, column));
|
|
128
|
+
const parts = Object.entries(table.columns).map(([name, column]) => columnSql$1(dialect, name, column, schema));
|
|
129
129
|
const primaryKey = table.primaryKey === void 0 ? void 0 : table.primaryKey.map((name) => dialect.quote(name)).join(", ");
|
|
130
130
|
if (primaryKey !== void 0) parts.push(`PRIMARY KEY (${primaryKey})`);
|
|
131
131
|
for (const [name, check] of Object.entries(table.checks ?? {})) {
|
|
@@ -139,14 +139,14 @@ function createTableSql(dialect, table, schema) {
|
|
|
139
139
|
return `CREATE TABLE IF NOT EXISTS ${dialect.tableName(table.name, schema)} (\n ${parts.join(",\n ")}\n)`;
|
|
140
140
|
}
|
|
141
141
|
/** 索引语句(`unique` 时是唯一索引)。 */
|
|
142
|
-
function createIndexSql(dialect, table, name, columns, unique = false) {
|
|
142
|
+
function createIndexSql(dialect, table, name, columns, unique = false, schema) {
|
|
143
143
|
assertIdentifier("index", name);
|
|
144
|
-
return `CREATE ${unique ? "UNIQUE " : ""}INDEX IF NOT EXISTS ${dialect.quote(name)} ON ${dialect.tableName(table.name)} (${columns.map((column) => dialect.quote(column)).join(", ")})`;
|
|
144
|
+
return `CREATE ${unique ? "UNIQUE " : ""}INDEX IF NOT EXISTS ${dialect.quote(name)} ON ${dialect.tableName(table.name, schema)} (${columns.map((column) => dialect.quote(column)).join(", ")})`;
|
|
145
145
|
}
|
|
146
146
|
/** 删列(只在显式允许破坏性变更时用)。 */
|
|
147
|
-
function dropColumnSql(dialect, table, name) {
|
|
147
|
+
function dropColumnSql(dialect, table, name, schema) {
|
|
148
148
|
assertIdentifier("column", name);
|
|
149
|
-
return `ALTER TABLE ${dialect.tableName(table)} DROP COLUMN ${dialect.quote(name)}`;
|
|
149
|
+
return `ALTER TABLE ${dialect.tableName(table, schema)} DROP COLUMN ${dialect.quote(name)}`;
|
|
150
150
|
}
|
|
151
151
|
/** 删索引(只在显式允许破坏性变更时用)。 */
|
|
152
152
|
function dropIndexSql(dialect, name) {
|
|
@@ -154,8 +154,8 @@ function dropIndexSql(dialect, name) {
|
|
|
154
154
|
return `DROP INDEX IF EXISTS ${dialect.quote(name)}`;
|
|
155
155
|
}
|
|
156
156
|
/** 加列语句(启动迁移只做「缺列则补」,不改类型、不删列)。 */
|
|
157
|
-
function addColumnSql(dialect, table, name, column) {
|
|
158
|
-
return `ALTER TABLE ${dialect.tableName(table)} ADD COLUMN ${columnSql$1(dialect, name, column)}`;
|
|
157
|
+
function addColumnSql(dialect, table, name, column, schema) {
|
|
158
|
+
return `ALTER TABLE ${dialect.tableName(table, schema)} ADD COLUMN ${columnSql$1(dialect, name, column, schema)}`;
|
|
159
159
|
}
|
|
160
160
|
// src/fragment.ts
|
|
161
161
|
/** 一段纯文本;`only` 限定只在某个方言里出现(SQLite 不支持的行锁等)。 */
|
|
@@ -708,15 +708,16 @@ function selectQuery(database, table, projection) {
|
|
|
708
708
|
if (name === table.name) return table;
|
|
709
709
|
for (const join of state.joins) if ((join.alias ?? join.table.name) === name) return join.table;
|
|
710
710
|
};
|
|
711
|
+
const targetOf = (definition) => database.dialect.tableName(definition.name, database.schema);
|
|
711
712
|
const toFragment = () => {
|
|
712
713
|
const items = Object.entries(projection).map(([alias, entry]) => entry.kind === "column" ? text(`${entry.table === void 0 ? "" : `"${entry.table}".`}"${entry.name}" AS "${alias}"`) : concat([exprFragment(entry), text(`AS "${alias}"`)], " "));
|
|
713
714
|
const parts = [
|
|
714
715
|
state.distinct ? text("SELECT DISTINCT") : text("SELECT"),
|
|
715
716
|
concat(items, ", "),
|
|
716
|
-
text(`FROM
|
|
717
|
+
text(`FROM ${targetOf(table)}`)
|
|
717
718
|
];
|
|
718
719
|
for (const join of state.joins) {
|
|
719
|
-
const target = join.alias === void 0 ?
|
|
720
|
+
const target = join.alias === void 0 ? targetOf(join.table) : `${targetOf(join.table)} AS "${join.alias}"`;
|
|
720
721
|
if (join.kind === "cross") {
|
|
721
722
|
parts.push(text(`CROSS JOIN ${target}`));
|
|
722
723
|
continue;
|
|
@@ -805,31 +806,31 @@ function selectQuery(database, table, projection) {
|
|
|
805
806
|
function returningClause(returning) {
|
|
806
807
|
return returning === void 0 || returning.length === 0 ? "" : text(`RETURNING ${returning.map((name) => `"${name}"`).join(", ")}`);
|
|
807
808
|
}
|
|
808
|
-
function insertOf(table, row, mode, conflict, update, returning) {
|
|
809
|
+
function insertOf(table, target, row, mode, conflict, update, returning) {
|
|
809
810
|
const columns = Object.keys(row).filter((name) => table.columns[name] !== void 0);
|
|
810
811
|
if (columns.length === 0) throw new Error(`rdbkit: insert into "${table.name}" has no columns`);
|
|
811
|
-
const base = fragment(`INSERT INTO
|
|
812
|
+
const base = fragment(`INSERT INTO ${target} (${columns.map((name) => `"${name}"`).join(", ")}) VALUES (${columns.map(() => "?").join(", ")})`, columns.map((name) => row[name]));
|
|
812
813
|
if (mode === "insert") return concat([base, returningClause(returning)], " ");
|
|
813
|
-
const
|
|
814
|
-
if (mode === "ignore") return concat([base, `ON CONFLICT (${
|
|
814
|
+
const conflictTarget = (conflict ?? []).map((name) => `"${name}"`).join(", ");
|
|
815
|
+
if (mode === "ignore") return concat([base, `ON CONFLICT (${conflictTarget}) DO NOTHING`], " ");
|
|
815
816
|
return concat([
|
|
816
817
|
base,
|
|
817
|
-
`ON CONFLICT (${
|
|
818
|
+
`ON CONFLICT (${conflictTarget}) ${(update ?? []).length === 0 ? "DO NOTHING" : `DO UPDATE SET ${(update ?? []).map((name) => `"${name}" = excluded."${name}"`).join(", ")}`}`,
|
|
818
819
|
returningClause(returning)
|
|
819
820
|
], " ");
|
|
820
821
|
}
|
|
821
|
-
function insertManyOf(table, rows, options = {}) {
|
|
822
|
+
function insertManyOf(table, target, rows, options = {}) {
|
|
822
823
|
if (rows.length === 0) return fragment("SELECT 1 WHERE 1 = 0");
|
|
823
824
|
const columns = Object.keys(rows[0]).filter((name) => table.columns[name] !== void 0);
|
|
824
825
|
if (columns.length === 0) throw new Error(`rdbkit: insert into "${table.name}" has no columns`);
|
|
825
826
|
for (const row of rows) for (const column of columns) if (row[column] === void 0) throw new Error(`rdbkit: insertMany into "${table.name}" is missing column '${column}'`);
|
|
826
827
|
const tuple = `(${columns.map(() => "?").join(", ")})`;
|
|
827
|
-
return concat([fragment(`INSERT INTO
|
|
828
|
+
return concat([fragment(`INSERT INTO ${target} (${columns.map((name) => `"${name}"`).join(", ")}) VALUES ${rows.map(() => tuple).join(", ")}`, rows.flatMap((row) => columns.map((name) => row[name]))), conflictClause(options)], " ");
|
|
828
829
|
}
|
|
829
|
-
function insertFromSelectOf(table, columns, source, options = {}) {
|
|
830
|
+
function insertFromSelectOf(table, target, columns, source, options = {}) {
|
|
830
831
|
if (columns.length === 0) throw new Error(`rdbkit: insert into "${table.name}" has no columns`);
|
|
831
832
|
return concat([
|
|
832
|
-
text(`INSERT INTO
|
|
833
|
+
text(`INSERT INTO ${target} (${columns.map((name) => `"${name}"`).join(", ")})`),
|
|
833
834
|
source,
|
|
834
835
|
conflictClause(options)
|
|
835
836
|
], " ");
|
|
@@ -840,17 +841,17 @@ function conflictClause(options) {
|
|
|
840
841
|
if (conflict.length === 0) return "";
|
|
841
842
|
return text(`ON CONFLICT (${conflict.map((name) => `"${name}"`).join(", ")}) ${(options.update ?? []).length === 0 ? "DO NOTHING" : `DO UPDATE SET ${(options.update ?? []).map((name) => `"${name}" = excluded."${name}"`).join(", ")}`}`);
|
|
842
843
|
}
|
|
843
|
-
function upsertExprOf(table, row, conflict, assignments) {
|
|
844
|
-
const base = insertOf(table, row, "insert");
|
|
845
|
-
const
|
|
844
|
+
function upsertExprOf(table, target, row, conflict, assignments) {
|
|
845
|
+
const base = insertOf(table, target, row, "insert");
|
|
846
|
+
const conflictTarget = conflict.map((name) => `"${name}"`).join(", ");
|
|
846
847
|
const sets = Object.entries(assignments).map(([name, ref]) => concat([text(`"${name}" =`), operandSql(ref)], " "));
|
|
847
848
|
return concat([
|
|
848
849
|
base,
|
|
849
|
-
text(`ON CONFLICT (${
|
|
850
|
+
text(`ON CONFLICT (${conflictTarget}) DO UPDATE SET`),
|
|
850
851
|
concat(sets, ", ")
|
|
851
852
|
], " ");
|
|
852
853
|
}
|
|
853
|
-
function updateOf(table, patch, condition, returning) {
|
|
854
|
+
function updateOf(table, target, patch, condition, returning) {
|
|
854
855
|
const columns = Object.keys(patch).filter((name) => table.columns[name] !== void 0);
|
|
855
856
|
if (columns.length === 0) throw new Error(`rdbkit: update "${table.name}" has no columns`);
|
|
856
857
|
const sets = columns.map((name) => {
|
|
@@ -858,15 +859,15 @@ function updateOf(table, patch, condition, returning) {
|
|
|
858
859
|
const assigned = typeof value === "object" && value !== null && "kind" in value ? operandSql(value) : param(value);
|
|
859
860
|
return concat([text(`"${name}" =`), assigned], " ");
|
|
860
861
|
});
|
|
861
|
-
const head = concat([text(`UPDATE
|
|
862
|
+
const head = concat([text(`UPDATE ${target} SET`), concat(sets, ", ")], " ");
|
|
862
863
|
return concat([condition === void 0 ? head : concat([
|
|
863
864
|
head,
|
|
864
865
|
text("WHERE"),
|
|
865
866
|
condition
|
|
866
867
|
], " "), returningClause(returning)], " ");
|
|
867
868
|
}
|
|
868
|
-
function deleteOf(table, condition, returning) {
|
|
869
|
-
const head = text(`DELETE FROM
|
|
869
|
+
function deleteOf(table, target, condition, returning) {
|
|
870
|
+
const head = text(`DELETE FROM ${target}`);
|
|
870
871
|
return concat([condition === void 0 ? head : concat([
|
|
871
872
|
head,
|
|
872
873
|
text("WHERE"),
|
|
@@ -876,6 +877,7 @@ function deleteOf(table, condition, returning) {
|
|
|
876
877
|
// src/table/handle.ts
|
|
877
878
|
function createTableHandle(database, definition) {
|
|
878
879
|
const name = definition.name;
|
|
880
|
+
const target = database.dialect.tableName(name, database.schema);
|
|
879
881
|
return new Proxy({
|
|
880
882
|
name,
|
|
881
883
|
definition,
|
|
@@ -890,29 +892,29 @@ function createTableHandle(database, definition) {
|
|
|
890
892
|
name
|
|
891
893
|
}]))),
|
|
892
894
|
async insert(row, returning) {
|
|
893
|
-
return await database.write(insertOf(definition, row, "insert", void 0, void 0, returning));
|
|
895
|
+
return await database.write(insertOf(definition, target, row, "insert", void 0, void 0, returning));
|
|
894
896
|
},
|
|
895
897
|
async insertIgnore(row, conflict) {
|
|
896
|
-
await database.run(insertOf(definition, row, "ignore", conflict));
|
|
898
|
+
await database.run(insertOf(definition, target, row, "ignore", conflict));
|
|
897
899
|
},
|
|
898
900
|
async insertFromSelect(columns, source, options) {
|
|
899
|
-
await database.run(insertFromSelectOf(definition, columns, source, options));
|
|
901
|
+
await database.run(insertFromSelectOf(definition, target, columns, source, options));
|
|
900
902
|
},
|
|
901
903
|
async insertMany(rows, options) {
|
|
902
|
-
await database.run(insertManyOf(definition, rows, options));
|
|
904
|
+
await database.run(insertManyOf(definition, target, rows, options));
|
|
903
905
|
},
|
|
904
906
|
async upsert(row, conflict, update) {
|
|
905
|
-
await database.run(insertOf(definition, row, "upsert", conflict, update));
|
|
907
|
+
await database.run(insertOf(definition, target, row, "upsert", conflict, update));
|
|
906
908
|
},
|
|
907
909
|
async update(patch, condition, returning) {
|
|
908
|
-
return await database.write(updateOf(definition, patch, condition, returning));
|
|
910
|
+
return await database.write(updateOf(definition, target, patch, condition, returning));
|
|
909
911
|
},
|
|
910
912
|
async upsertExpr(row, conflict, assignments) {
|
|
911
|
-
await database.run(upsertExprOf(definition, row, conflict, assignments));
|
|
913
|
+
await database.run(upsertExprOf(definition, target, row, conflict, assignments));
|
|
912
914
|
},
|
|
913
915
|
async delete(condition, returning) {
|
|
914
|
-
if (returning !== void 0 && returning.length > 0) return await database.write(deleteOf(definition, condition, returning));
|
|
915
|
-
await database.run(deleteOf(definition, condition));
|
|
916
|
+
if (returning !== void 0 && returning.length > 0) return await database.write(deleteOf(definition, target, condition, returning));
|
|
917
|
+
await database.run(deleteOf(definition, target, condition));
|
|
916
918
|
return [];
|
|
917
919
|
}
|
|
918
920
|
}, { get(target, property, receiver) {
|
|
@@ -993,7 +995,7 @@ function assertPragmaValue(name, value) {
|
|
|
993
995
|
}
|
|
994
996
|
/** 现有的表与视图名(迁移探测旧表、拒绝别人的库都要它)。 */
|
|
995
997
|
async function objectNames(database) {
|
|
996
|
-
const query = database.dialect.name === "sqlite" ? fragment("SELECT name FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%'") : fragment("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()");
|
|
998
|
+
const query = database.dialect.name === "sqlite" ? fragment("SELECT name FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%'") : fragment("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = COALESCE(?, current_schema())", [database.schema ?? null]);
|
|
997
999
|
return (await database.read(query)).map((row) => row.name);
|
|
998
1000
|
}
|
|
999
1001
|
/** 回收空间与统计(SQLite `VACUUM` / PostgreSQL `VACUUM ANALYZE`);不得在事务内调用。 */
|
|
@@ -1197,7 +1199,10 @@ function withCatalog(config) {
|
|
|
1197
1199
|
async function openDatabase(config) {
|
|
1198
1200
|
const catalog = withCatalog(config);
|
|
1199
1201
|
const executor = config.dialect === "sqlite" ? openSqlite(config) : openPostgres(config);
|
|
1200
|
-
return createDatabase(createDialect(config.dialect), executor, catalog,
|
|
1202
|
+
return createDatabase(createDialect(config.dialect), executor, catalog, {
|
|
1203
|
+
schema: config.dialect === "postgres" ? config.schema : void 0,
|
|
1204
|
+
onQuery: config.onQuery
|
|
1205
|
+
});
|
|
1201
1206
|
}
|
|
1202
1207
|
async function* iterateWrapped(rows, dialect) {
|
|
1203
1208
|
try {
|
|
@@ -1206,7 +1211,9 @@ async function* iterateWrapped(rows, dialect) {
|
|
|
1206
1211
|
throw classifyError(error, dialect);
|
|
1207
1212
|
}
|
|
1208
1213
|
}
|
|
1209
|
-
function createDatabase(dialect, executor, catalog,
|
|
1214
|
+
function createDatabase(dialect, executor, catalog, options = {}) {
|
|
1215
|
+
const { onQuery } = options;
|
|
1216
|
+
const schema = dialect.name === "postgres" ? options.schema ?? "public" : void 0;
|
|
1210
1217
|
const observe = async (query, run) => {
|
|
1211
1218
|
const { text, params } = render(query, dialect.name);
|
|
1212
1219
|
const started = performance.now();
|
|
@@ -1233,6 +1240,7 @@ function createDatabase(dialect, executor, catalog, onQuery) {
|
|
|
1233
1240
|
const database = {
|
|
1234
1241
|
dialect,
|
|
1235
1242
|
catalog,
|
|
1243
|
+
schema,
|
|
1236
1244
|
table: (source) => {
|
|
1237
1245
|
const definition = typeof source === "string" ? catalog.get(source) : source;
|
|
1238
1246
|
if (definition === void 0) throw new Error(`rdbkit: no table '${typeof source === "string" ? source : source.name}' in catalog`);
|
|
@@ -1256,7 +1264,10 @@ function createDatabase(dialect, executor, catalog, onQuery) {
|
|
|
1256
1264
|
return observe(query, (text, params) => executor.run(text, params));
|
|
1257
1265
|
},
|
|
1258
1266
|
transaction(fn) {
|
|
1259
|
-
return executor.transaction((scoped) => fn(createDatabase(dialect, scoped, catalog,
|
|
1267
|
+
return executor.transaction((scoped) => fn(createDatabase(dialect, scoped, catalog, {
|
|
1268
|
+
schema,
|
|
1269
|
+
onQuery
|
|
1270
|
+
})));
|
|
1260
1271
|
},
|
|
1261
1272
|
objectNames: () => objectNames(database),
|
|
1262
1273
|
vacuum: () => vacuum(database),
|
|
@@ -1280,7 +1291,7 @@ function wrapDatabase(dialectName, ports) {
|
|
|
1280
1291
|
close: async () => {
|
|
1281
1292
|
await ports.close?.();
|
|
1282
1293
|
}
|
|
1283
|
-
}, indexCatalog([]));
|
|
1294
|
+
}, indexCatalog([]), { schema: ports.schema });
|
|
1284
1295
|
return ports.transaction === void 0 ? database : {
|
|
1285
1296
|
...database,
|
|
1286
1297
|
transaction: ports.transaction
|
|
@@ -1319,7 +1330,7 @@ function planSchema(dialect, catalog, shapes, options = {}) {
|
|
|
1319
1330
|
changes.push({
|
|
1320
1331
|
table: table.name,
|
|
1321
1332
|
kind: "add-column",
|
|
1322
|
-
statement: addColumnSql(dialect, table.name, name, assertAddable(table.name, name, column))
|
|
1333
|
+
statement: addColumnSql(dialect, table.name, name, assertAddable(table.name, name, column), options.schema)
|
|
1323
1334
|
});
|
|
1324
1335
|
}
|
|
1325
1336
|
if (options.allowDestructive === true) for (const name of shape.columns) {
|
|
@@ -1327,7 +1338,7 @@ function planSchema(dialect, catalog, shapes, options = {}) {
|
|
|
1327
1338
|
changes.push({
|
|
1328
1339
|
table: table.name,
|
|
1329
1340
|
kind: "drop-column",
|
|
1330
|
-
statement: dropColumnSql(dialect, table.name, name)
|
|
1341
|
+
statement: dropColumnSql(dialect, table.name, name, options.schema)
|
|
1331
1342
|
});
|
|
1332
1343
|
}
|
|
1333
1344
|
}
|
|
@@ -1336,7 +1347,7 @@ function planSchema(dialect, catalog, shapes, options = {}) {
|
|
|
1336
1347
|
changes.push({
|
|
1337
1348
|
table: table.name,
|
|
1338
1349
|
kind: "create-index",
|
|
1339
|
-
statement: createIndexSql(dialect, table, name, index.columns)
|
|
1350
|
+
statement: createIndexSql(dialect, table, name, index.columns, false, options.schema)
|
|
1340
1351
|
});
|
|
1341
1352
|
}
|
|
1342
1353
|
for (const [name, unique] of Object.entries(table.uniques ?? {})) {
|
|
@@ -1344,7 +1355,7 @@ function planSchema(dialect, catalog, shapes, options = {}) {
|
|
|
1344
1355
|
changes.push({
|
|
1345
1356
|
table: table.name,
|
|
1346
1357
|
kind: "create-index",
|
|
1347
|
-
statement: createIndexSql(dialect, table, name, unique.columns, true)
|
|
1358
|
+
statement: createIndexSql(dialect, table, name, unique.columns, true, options.schema)
|
|
1348
1359
|
});
|
|
1349
1360
|
}
|
|
1350
1361
|
}
|
|
@@ -1356,9 +1367,13 @@ function assertAddable(table, name, column) {
|
|
|
1356
1367
|
}
|
|
1357
1368
|
/** 让库结构与表定义对齐,并返回实际执行的语句。 */
|
|
1358
1369
|
async function ensureSchema(database, catalog, options = {}) {
|
|
1370
|
+
const schema = options.schema ?? database.schema;
|
|
1359
1371
|
const shapes = /* @__PURE__ */ new Map();
|
|
1360
|
-
for (const table of catalog) shapes.set(table.name, await database.dialect.readShape(shapeReader(database), table.name,
|
|
1361
|
-
const changes = planSchema(database.dialect, catalog, shapes,
|
|
1372
|
+
for (const table of catalog) shapes.set(table.name, await database.dialect.readShape(shapeReader(database), table.name, schema));
|
|
1373
|
+
const changes = planSchema(database.dialect, catalog, shapes, {
|
|
1374
|
+
...options,
|
|
1375
|
+
...schema === void 0 ? {} : { schema }
|
|
1376
|
+
});
|
|
1362
1377
|
for (const change of changes) {
|
|
1363
1378
|
options.onStatement?.(change.statement);
|
|
1364
1379
|
await database.run(fragment(change.statement));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@morlay/rdbkit",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
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
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
},
|
|
45
45
|
"engines": {
|
|
46
46
|
"node": ">=24.11.0",
|
|
47
|
-
"dsh": ">= 0.2.2-
|
|
47
|
+
"dsh": ">= 0.2.2-rc.1 < 0.2.3"
|
|
48
48
|
},
|
|
49
49
|
"scripts": {
|
|
50
50
|
"build": "pnpm exec tsdown"
|
package/src/__tests__/pg.spec.ts
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
createPostgresTestDatabase,
|
|
4
|
-
type PostgresTestDatabase,
|
|
5
|
-
} from "../testing.ts";
|
|
2
|
+
import { createPostgresTestDatabase, type PostgresTestDatabase } from "../testing.ts";
|
|
6
3
|
import {
|
|
7
4
|
count,
|
|
8
5
|
defineCatalog,
|
|
@@ -10,7 +7,9 @@ import {
|
|
|
10
7
|
ensureSchema,
|
|
11
8
|
fragment,
|
|
12
9
|
openDatabase,
|
|
10
|
+
runMigrations,
|
|
13
11
|
sum,
|
|
12
|
+
type Database,
|
|
14
13
|
} from "../index.ts";
|
|
15
14
|
|
|
16
15
|
const items = defineTable({
|
|
@@ -62,10 +61,11 @@ describe.skipIf(!process.env.TEST_PG_URL)("PostgreSQL 上同一套构建", () =>
|
|
|
62
61
|
|
|
63
62
|
const table = database.table("t_items");
|
|
64
63
|
const partTable = database.table("t_parts");
|
|
65
|
-
const [created] = await table.insert(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
const [created] = await table.insert({ f_owner: "a", f_count: 1, f_at: 1_788_852_417_123 }, [
|
|
65
|
+
"f_id",
|
|
66
|
+
"f_owner",
|
|
67
|
+
"f_at",
|
|
68
|
+
]);
|
|
69
69
|
expect(created).toMatchObject({ f_owner: "a" });
|
|
70
70
|
// 64 位整数读回来是 BigInt:超过 2^53 的 uint64 / int64 不会丢精度。
|
|
71
71
|
expect(created?.f_at).toBe(1_788_852_417_123n);
|
|
@@ -114,6 +114,82 @@ describe.skipIf(!process.env.TEST_PG_URL)("PostgreSQL 上同一套构建", () =>
|
|
|
114
114
|
await testDatabase.drop();
|
|
115
115
|
}
|
|
116
116
|
});
|
|
117
|
+
|
|
118
|
+
it("自定义 schema:DDL / 读写 / 迁移账本 / 结构探测都在同一处,public 不留副本", async () => {
|
|
119
|
+
const testDatabase: PostgresTestDatabase = await createPostgresTestDatabase();
|
|
120
|
+
const catalog = defineCatalog([items, parts]);
|
|
121
|
+
const config = {
|
|
122
|
+
dialect: "postgres" as const,
|
|
123
|
+
connectionString: testDatabase.connectionString,
|
|
124
|
+
schema: "dsh_test",
|
|
125
|
+
catalog,
|
|
126
|
+
};
|
|
127
|
+
const migration = {
|
|
128
|
+
id: "20261109-pg-schema-probe",
|
|
129
|
+
up: async (tx: Database) => {
|
|
130
|
+
await tx.table("t_items").insert({ f_owner: "migrated" });
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const database = await openDatabase(config);
|
|
135
|
+
try {
|
|
136
|
+
await database.run(fragment('CREATE SCHEMA IF NOT EXISTS "dsh_test"'));
|
|
137
|
+
|
|
138
|
+
// 结构:建表与索引都限定到配置的 schema,且重复对齐是幂等的。
|
|
139
|
+
const statements = await ensureSchema(database, [items, parts]);
|
|
140
|
+
expect(statements.length).toBeGreaterThan(0);
|
|
141
|
+
expect(statements.every((statement) => statement.includes('"dsh_test".'))).toBe(true);
|
|
142
|
+
expect(await ensureSchema(database, [items, parts])).toEqual([]);
|
|
143
|
+
|
|
144
|
+
// 读写:句柄不依赖连接串的 search_path,直接落在配置的 schema。
|
|
145
|
+
const table = database.table("t_items");
|
|
146
|
+
await table.insert({ f_owner: "a", f_count: 1 });
|
|
147
|
+
const owners = await collect(table.select({ owner: table.column("f_owner") }));
|
|
148
|
+
expect(owners).toEqual([{ owner: "a" }]);
|
|
149
|
+
|
|
150
|
+
// 迁移账本:建表、读、记账都在同一 schema,重跑只跑没记过的。
|
|
151
|
+
expect(await runMigrations(database, { catalog, migrations: [migration] })).toEqual([
|
|
152
|
+
migration.id,
|
|
153
|
+
]);
|
|
154
|
+
expect(await runMigrations(database, { catalog, migrations: [migration] })).toEqual([]);
|
|
155
|
+
const ledger = await database.read<{ f_id: string }>(
|
|
156
|
+
fragment('SELECT f_id FROM "dsh_test"."t_schema_migrations"'),
|
|
157
|
+
);
|
|
158
|
+
expect(ledger.map((row) => row.f_id)).toEqual([migration.id]);
|
|
159
|
+
|
|
160
|
+
// 结构探测跟着配置的 schema(旧数据迁移靠它判旧表在不在)。
|
|
161
|
+
expect(await database.objectNames()).toEqual(
|
|
162
|
+
expect.arrayContaining(["t_items", "t_schema_migrations"]),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// 表与账本都在 dsh_test,public 不留副本。
|
|
166
|
+
const placements = await database.read<{ table_schema: string; table_name: string }>(
|
|
167
|
+
fragment(
|
|
168
|
+
"SELECT table_schema, table_name FROM information_schema.tables " +
|
|
169
|
+
"WHERE table_name IN ('t_items', 't_parts', 't_schema_migrations') ORDER BY table_name",
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
expect(placements.map((row) => `${row.table_schema}.${row.table_name}`)).toEqual([
|
|
173
|
+
"dsh_test.t_items",
|
|
174
|
+
"dsh_test.t_parts",
|
|
175
|
+
"dsh_test.t_schema_migrations",
|
|
176
|
+
]);
|
|
177
|
+
} finally {
|
|
178
|
+
await database.close();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 再次打开:结构已对齐、迁移已记账,数据还在。
|
|
182
|
+
const reopened = await openDatabase(config);
|
|
183
|
+
try {
|
|
184
|
+
expect(await ensureSchema(reopened, [items, parts])).toEqual([]);
|
|
185
|
+
expect(await runMigrations(reopened, { catalog, migrations: [migration] })).toEqual([]);
|
|
186
|
+
const [total] = await collect(reopened.table("t_items").select({ n: count() }));
|
|
187
|
+
expect(Number(total?.n)).toBe(2);
|
|
188
|
+
} finally {
|
|
189
|
+
await reopened.close();
|
|
190
|
+
await testDatabase.drop();
|
|
191
|
+
}
|
|
192
|
+
});
|
|
117
193
|
});
|
|
118
194
|
|
|
119
195
|
async function collect<T>(rows: AsyncIterable<T>): Promise<T[]> {
|
|
@@ -4,9 +4,15 @@ import {
|
|
|
4
4
|
createTableSql,
|
|
5
5
|
defineCatalog,
|
|
6
6
|
defineTable,
|
|
7
|
+
E,
|
|
7
8
|
ensureSchema,
|
|
8
9
|
fragment,
|
|
9
10
|
openDatabase,
|
|
11
|
+
planSchema,
|
|
12
|
+
render,
|
|
13
|
+
runMigrations,
|
|
14
|
+
wrapDatabase,
|
|
15
|
+
type Database,
|
|
10
16
|
} from "../index.ts";
|
|
11
17
|
|
|
12
18
|
const catalog = defineCatalog([
|
|
@@ -142,3 +148,126 @@ describe("SQLite 连接调优选项", () => {
|
|
|
142
148
|
}
|
|
143
149
|
});
|
|
144
150
|
});
|
|
151
|
+
/** 记录 SQL 的假 PostgreSQL 执行面:不需要真服务就能看表句柄 / 迁移渲染出来的语句。 */
|
|
152
|
+
function recordingPostgres(schema?: string): {
|
|
153
|
+
database: Database;
|
|
154
|
+
statements: { text: string; params: readonly unknown[] }[];
|
|
155
|
+
reply: (rows: unknown[]) => void;
|
|
156
|
+
} {
|
|
157
|
+
const statements: { text: string; params: readonly unknown[] }[] = [];
|
|
158
|
+
let rows: unknown[] = [];
|
|
159
|
+
let database: Database;
|
|
160
|
+
database = wrapDatabase("postgres", {
|
|
161
|
+
...(schema === undefined ? {} : { schema }),
|
|
162
|
+
async *iterate(text, params) {
|
|
163
|
+
statements.push({ text, params });
|
|
164
|
+
yield* rows;
|
|
165
|
+
},
|
|
166
|
+
async run(text, params) {
|
|
167
|
+
statements.push({ text, params });
|
|
168
|
+
},
|
|
169
|
+
transaction: (fn) => fn(database),
|
|
170
|
+
});
|
|
171
|
+
return {
|
|
172
|
+
database,
|
|
173
|
+
statements,
|
|
174
|
+
reply(next) {
|
|
175
|
+
rows = next;
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
describe("PostgreSQL 自定义 schema", () => {
|
|
181
|
+
const postgres = createDialect("postgres");
|
|
182
|
+
const items = catalog[0]!;
|
|
183
|
+
const links = catalog[1]!;
|
|
184
|
+
|
|
185
|
+
it("DDL 的建表 / 外键引用 / 补列 / 索引都带 schema", () => {
|
|
186
|
+
const createLinks = createTableSql(postgres, links, "dsh_test");
|
|
187
|
+
expect(createLinks).toContain('CREATE TABLE IF NOT EXISTS "dsh_test"."t_links"');
|
|
188
|
+
expect(createLinks).toContain('REFERENCES "dsh_test"."t_items"("f_owner")');
|
|
189
|
+
|
|
190
|
+
const existing = new Map([
|
|
191
|
+
[
|
|
192
|
+
"t_items",
|
|
193
|
+
{ columns: new Set(["f_id", "f_owner", "f_active"]), indexes: new Set<string>() },
|
|
194
|
+
],
|
|
195
|
+
]);
|
|
196
|
+
const statements = planSchema(postgres, catalog, existing, { schema: "dsh_test" }).map(
|
|
197
|
+
(change) => change.statement,
|
|
198
|
+
);
|
|
199
|
+
expect(statements).toContain(
|
|
200
|
+
'ALTER TABLE "dsh_test"."t_items" ADD COLUMN "f_count" INTEGER NOT NULL DEFAULT 0',
|
|
201
|
+
);
|
|
202
|
+
expect(statements).toContain(
|
|
203
|
+
'CREATE INDEX IF NOT EXISTS "idx_items_owner" ON "dsh_test"."t_items" ("f_owner")',
|
|
204
|
+
);
|
|
205
|
+
expect(statements.every((statement) => statement.includes('"dsh_test".'))).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("表句柄的读写与 join 都指向配置的 schema", async () => {
|
|
209
|
+
const { database, statements } = recordingPostgres("dsh_test");
|
|
210
|
+
const table = database.table(items);
|
|
211
|
+
await table.insert({ f_owner: "a", f_count: 1, f_active: true });
|
|
212
|
+
await table.update({ f_count: 2 }, E.eq(table.f_owner, "a"));
|
|
213
|
+
await table.delete(E.eq(table.f_owner, "a"));
|
|
214
|
+
|
|
215
|
+
const sql = statements.map((statement) => statement.text);
|
|
216
|
+
expect(sql[0]).toContain('INSERT INTO "dsh_test"."t_items"');
|
|
217
|
+
expect(sql[1]).toContain('UPDATE "dsh_test"."t_items"');
|
|
218
|
+
expect(sql[2]).toContain('DELETE FROM "dsh_test"."t_items"');
|
|
219
|
+
expect(render(table.select().toFragment(), "postgres").text).toContain(
|
|
220
|
+
'FROM "dsh_test"."t_items"',
|
|
221
|
+
);
|
|
222
|
+
const linksTable = database.table(links);
|
|
223
|
+
expect(
|
|
224
|
+
render(
|
|
225
|
+
table
|
|
226
|
+
.select({ owner: table.f_owner })
|
|
227
|
+
.join(links, E.eq(table.f_owner, linksTable.f_owner))
|
|
228
|
+
.toFragment(),
|
|
229
|
+
"postgres",
|
|
230
|
+
).text,
|
|
231
|
+
).toContain('JOIN "dsh_test"."t_links"');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("ensureSchema 与结构探测缺省用连接上的 schema", async () => {
|
|
235
|
+
const { database, statements } = recordingPostgres("dsh_test");
|
|
236
|
+
const applied = await ensureSchema(database, [items]);
|
|
237
|
+
expect(
|
|
238
|
+
applied.some((statement) =>
|
|
239
|
+
statement.includes('CREATE TABLE IF NOT EXISTS "dsh_test"."t_items"'),
|
|
240
|
+
),
|
|
241
|
+
).toBe(true);
|
|
242
|
+
expect(applied.every((statement) => statement.includes('"dsh_test".'))).toBe(true);
|
|
243
|
+
// 结构探测查的是配置的 schema,不是硬编码的 public。
|
|
244
|
+
expect(statements[0]!.text).toContain("information_schema.columns");
|
|
245
|
+
expect(statements[0]!.params).toEqual(["dsh_test", "t_items"]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("迁移账本的建表 / 读 / 记账都在配置的 schema", async () => {
|
|
249
|
+
const { database, statements } = recordingPostgres("dsh_test");
|
|
250
|
+
const migrations = [{ id: "20261109-probe", up: async () => {} }];
|
|
251
|
+
expect(await runMigrations(database, { catalog: [items], migrations })).toEqual([
|
|
252
|
+
"20261109-probe",
|
|
253
|
+
]);
|
|
254
|
+
const sql = statements.map((statement) => statement.text);
|
|
255
|
+
expect(
|
|
256
|
+
sql.some((text) =>
|
|
257
|
+
text.includes('CREATE TABLE IF NOT EXISTS "dsh_test"."t_schema_migrations"'),
|
|
258
|
+
),
|
|
259
|
+
).toBe(true);
|
|
260
|
+
expect(sql.some((text) => text.includes('FROM "dsh_test"."t_schema_migrations"'))).toBe(true);
|
|
261
|
+
expect(sql.some((text) => text.includes('INSERT INTO "dsh_test"."t_schema_migrations"'))).toBe(
|
|
262
|
+
true,
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("objectNames 查配置的 schema", async () => {
|
|
267
|
+
const { database, statements } = recordingPostgres("dsh_test");
|
|
268
|
+
await database.objectNames();
|
|
269
|
+
expect(statements).toHaveLength(1);
|
|
270
|
+
expect(statements[0]!.text).toContain("information_schema.tables");
|
|
271
|
+
expect(statements[0]!.params).toEqual(["dsh_test"]);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
@@ -29,7 +29,8 @@ export async function objectNames(database: Database): Promise<string[]> {
|
|
|
29
29
|
"SELECT name FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%'",
|
|
30
30
|
)
|
|
31
31
|
: fragment(
|
|
32
|
-
"SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()",
|
|
32
|
+
"SELECT table_name AS name FROM information_schema.tables WHERE table_schema = COALESCE(?, current_schema())",
|
|
33
|
+
[database.schema ?? null],
|
|
33
34
|
);
|
|
34
35
|
const rows = await database.read<{ name: string }>(query);
|
|
35
36
|
return rows.map((row) => row.name);
|
package/src/database/create.ts
CHANGED
|
@@ -21,12 +21,10 @@ export async function openDatabase<const C extends Catalog = Catalog>(
|
|
|
21
21
|
const catalog = withCatalog(config);
|
|
22
22
|
const executor =
|
|
23
23
|
config.dialect === "sqlite" ? openSqlite(config) : openPostgres(config);
|
|
24
|
-
return createDatabase(
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
config.onQuery,
|
|
29
|
-
) as Database<C>;
|
|
24
|
+
return createDatabase(createDialect(config.dialect), executor, catalog, {
|
|
25
|
+
schema: config.dialect === "postgres" ? config.schema : undefined,
|
|
26
|
+
onQuery: config.onQuery,
|
|
27
|
+
}) as Database<C>;
|
|
30
28
|
}
|
|
31
29
|
|
|
32
30
|
async function* iterateWrapped<T>(
|
|
@@ -44,8 +42,14 @@ export function createDatabase(
|
|
|
44
42
|
dialect: Dialect,
|
|
45
43
|
executor: Executor,
|
|
46
44
|
catalog: CatalogIndex,
|
|
47
|
-
|
|
45
|
+
options: {
|
|
46
|
+
schema?: string | undefined;
|
|
47
|
+
onQuery?: ((event: QueryEvent) => void) | undefined;
|
|
48
|
+
} = {},
|
|
48
49
|
): Database {
|
|
50
|
+
const { onQuery } = options;
|
|
51
|
+
// PostgreSQL 的目标 schema 只在这里定一次(缺省 public);SQLite 没有 schema 概念。
|
|
52
|
+
const schema = dialect.name === "postgres" ? (options.schema ?? "public") : undefined;
|
|
49
53
|
const observe = async <T>(
|
|
50
54
|
query: Fragment,
|
|
51
55
|
run: (text: string, params: readonly unknown[]) => Promise<T>,
|
|
@@ -75,6 +79,7 @@ export function createDatabase(
|
|
|
75
79
|
const database: Database = {
|
|
76
80
|
dialect,
|
|
77
81
|
catalog,
|
|
82
|
+
schema,
|
|
78
83
|
table: (source: TableDef | string) => {
|
|
79
84
|
const definition = typeof source === "string" ? catalog.get(source) : source;
|
|
80
85
|
if (definition === undefined) {
|
|
@@ -104,7 +109,7 @@ export function createDatabase(
|
|
|
104
109
|
transaction(fn) {
|
|
105
110
|
// catalog 跟着事务走:事务里按表名取句柄与外面一致。
|
|
106
111
|
return executor.transaction((scoped) =>
|
|
107
|
-
fn(createDatabase(dialect, scoped, catalog, onQuery)),
|
|
112
|
+
fn(createDatabase(dialect, scoped, catalog, { schema, onQuery })),
|
|
108
113
|
);
|
|
109
114
|
},
|
|
110
115
|
objectNames: () => objectNames(database),
|
package/src/database/types.ts
CHANGED
|
@@ -14,6 +14,11 @@ export interface Database<C extends Catalog = Catalog> {
|
|
|
14
14
|
readonly dialect: Dialect;
|
|
15
15
|
/** 装配层注册的 catalog(按表名解析;没注册时只能传定义)。 */
|
|
16
16
|
readonly catalog: CatalogIndex;
|
|
17
|
+
/**
|
|
18
|
+
* 生成 SQL 的目标 schema:PostgreSQL 恒有值(配置缺省 `public`),SQLite 是 `undefined`。
|
|
19
|
+
* DDL、DML、迁移账本与结构探测都从这里取,不各自猜。
|
|
20
|
+
*/
|
|
21
|
+
readonly schema: string | undefined;
|
|
17
22
|
/** 按表名取(catalog 里有定义时保留强类型)。 */
|
|
18
23
|
table<K extends C[number]["name"] & string>(
|
|
19
24
|
name: K,
|
package/src/database/wrap.ts
CHANGED
|
@@ -8,6 +8,8 @@ import type { Database } from "./types.ts";
|
|
|
8
8
|
export function wrapDatabase(
|
|
9
9
|
dialectName: DialectName,
|
|
10
10
|
ports: {
|
|
11
|
+
/** 宿主连接的目标 schema(PostgreSQL;省略即 `public`,与 rdbkit 默认一致)。 */
|
|
12
|
+
schema?: string | undefined;
|
|
11
13
|
iterate(text: string, params: readonly unknown[]): AsyncIterable<unknown>;
|
|
12
14
|
run(text: string, params: readonly unknown[]): Promise<void>;
|
|
13
15
|
/** 宿主自己管事务时提供:把当前事务的执行面做成 scoped `Database`(对应 Go 的 `Tx(ctx, fn)`)。 */
|
|
@@ -30,6 +32,7 @@ export function wrapDatabase(
|
|
|
30
32
|
},
|
|
31
33
|
},
|
|
32
34
|
indexCatalog([]),
|
|
35
|
+
{ schema: ports.schema },
|
|
33
36
|
);
|
|
34
37
|
return ports.transaction === undefined
|
|
35
38
|
? database
|
package/src/dialect.ts
CHANGED
|
@@ -115,8 +115,13 @@ function postgresDialect(): Dialect {
|
|
|
115
115
|
};
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
/** 一列的定义 SQL(`"name" TEXT NOT NULL
|
|
119
|
-
export function columnSql(
|
|
118
|
+
/** 一列的定义 SQL(`"name" TEXT NOT NULL ...`);外键引用跟着表名一起落在同一 schema。 */
|
|
119
|
+
export function columnSql(
|
|
120
|
+
dialect: Dialect,
|
|
121
|
+
name: string,
|
|
122
|
+
column: ColumnDef,
|
|
123
|
+
schema?: string,
|
|
124
|
+
): string {
|
|
120
125
|
assertIdentifier("column", name);
|
|
121
126
|
const parts = [dialect.quote(name), dialect.columnType(column)];
|
|
122
127
|
if (column.type === "serial" && dialect.name === "sqlite")
|
|
@@ -131,7 +136,7 @@ export function columnSql(dialect: Dialect, name: string, column: ColumnDef): st
|
|
|
131
136
|
assertIdentifier("table", table);
|
|
132
137
|
assertIdentifier("column", referenced);
|
|
133
138
|
parts.push(
|
|
134
|
-
`REFERENCES ${dialect.tableName(table)}(${dialect.quote(referenced)})` +
|
|
139
|
+
`REFERENCES ${dialect.tableName(table, schema)}(${dialect.quote(referenced)})` +
|
|
135
140
|
(onDelete === undefined ? "" : ` ON DELETE ${onDelete.toUpperCase()}`),
|
|
136
141
|
);
|
|
137
142
|
}
|
|
@@ -156,7 +161,7 @@ function literal(dialect: Dialect, value: string | number, type: ColumnDef["type
|
|
|
156
161
|
/** 建表语句(IF NOT EXISTS;列与表级约束都由定义算出来)。 */
|
|
157
162
|
export function createTableSql(dialect: Dialect, table: TableDef, schema?: string): string {
|
|
158
163
|
const parts = Object.entries(table.columns).map(([name, column]) =>
|
|
159
|
-
columnSql(dialect, name, column),
|
|
164
|
+
columnSql(dialect, name, column, schema),
|
|
160
165
|
);
|
|
161
166
|
const primaryKey =
|
|
162
167
|
table.primaryKey === undefined
|
|
@@ -188,18 +193,24 @@ export function createIndexSql(
|
|
|
188
193
|
name: string,
|
|
189
194
|
columns: readonly string[],
|
|
190
195
|
unique = false,
|
|
196
|
+
schema?: string,
|
|
191
197
|
): string {
|
|
192
198
|
assertIdentifier("index", name);
|
|
193
199
|
return (
|
|
194
200
|
`CREATE ${unique ? "UNIQUE " : ""}INDEX IF NOT EXISTS ${dialect.quote(name)} ` +
|
|
195
|
-
`ON ${dialect.tableName(table.name)} (${columns.map((column) => dialect.quote(column)).join(", ")})`
|
|
201
|
+
`ON ${dialect.tableName(table.name, schema)} (${columns.map((column) => dialect.quote(column)).join(", ")})`
|
|
196
202
|
);
|
|
197
203
|
}
|
|
198
204
|
|
|
199
205
|
/** 删列(只在显式允许破坏性变更时用)。 */
|
|
200
|
-
export function dropColumnSql(
|
|
206
|
+
export function dropColumnSql(
|
|
207
|
+
dialect: Dialect,
|
|
208
|
+
table: string,
|
|
209
|
+
name: string,
|
|
210
|
+
schema?: string,
|
|
211
|
+
): string {
|
|
201
212
|
assertIdentifier("column", name);
|
|
202
|
-
return `ALTER TABLE ${dialect.tableName(table)} DROP COLUMN ${dialect.quote(name)}`;
|
|
213
|
+
return `ALTER TABLE ${dialect.tableName(table, schema)} DROP COLUMN ${dialect.quote(name)}`;
|
|
203
214
|
}
|
|
204
215
|
|
|
205
216
|
/** 删索引(只在显式允许破坏性变更时用)。 */
|
|
@@ -214,6 +225,7 @@ export function addColumnSql(
|
|
|
214
225
|
table: string,
|
|
215
226
|
name: string,
|
|
216
227
|
column: ColumnDef,
|
|
228
|
+
schema?: string,
|
|
217
229
|
): string {
|
|
218
|
-
return `ALTER TABLE ${dialect.tableName(table)} ADD COLUMN ${columnSql(dialect, name, column)}`;
|
|
230
|
+
return `ALTER TABLE ${dialect.tableName(table, schema)} ADD COLUMN ${columnSql(dialect, name, column, schema)}`;
|
|
219
231
|
}
|
package/src/migrate.ts
CHANGED
|
@@ -19,7 +19,7 @@ export interface SchemaChange {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export interface EnsureSchemaOptions {
|
|
22
|
-
/** PostgreSQL 的 schema
|
|
22
|
+
/** PostgreSQL 的 schema(省略即 `database.schema`,再缺省是 `public`);SQLite 忽略。 */
|
|
23
23
|
schema?: string;
|
|
24
24
|
/** 允许删掉定义里已经不存在、且确实多出来的列(默认关闭:升级不丢数据)。 */
|
|
25
25
|
allowDestructive?: boolean;
|
|
@@ -64,6 +64,7 @@ export function planSchema(
|
|
|
64
64
|
table.name,
|
|
65
65
|
name,
|
|
66
66
|
assertAddable(table.name, name, column),
|
|
67
|
+
options.schema,
|
|
67
68
|
),
|
|
68
69
|
});
|
|
69
70
|
}
|
|
@@ -73,7 +74,7 @@ export function planSchema(
|
|
|
73
74
|
changes.push({
|
|
74
75
|
table: table.name,
|
|
75
76
|
kind: "drop-column",
|
|
76
|
-
statement: dropColumnSql(dialect, table.name, name),
|
|
77
|
+
statement: dropColumnSql(dialect, table.name, name, options.schema),
|
|
77
78
|
});
|
|
78
79
|
}
|
|
79
80
|
}
|
|
@@ -83,7 +84,7 @@ export function planSchema(
|
|
|
83
84
|
changes.push({
|
|
84
85
|
table: table.name,
|
|
85
86
|
kind: "create-index",
|
|
86
|
-
statement: createIndexSql(dialect, table, name, index.columns),
|
|
87
|
+
statement: createIndexSql(dialect, table, name, index.columns, false, options.schema),
|
|
87
88
|
});
|
|
88
89
|
}
|
|
89
90
|
for (const [name, unique] of Object.entries(table.uniques ?? {})) {
|
|
@@ -92,7 +93,7 @@ export function planSchema(
|
|
|
92
93
|
changes.push({
|
|
93
94
|
table: table.name,
|
|
94
95
|
kind: "create-index",
|
|
95
|
-
statement: createIndexSql(dialect, table, name, unique.columns, true),
|
|
96
|
+
statement: createIndexSql(dialect, table, name, unique.columns, true, options.schema),
|
|
96
97
|
});
|
|
97
98
|
}
|
|
98
99
|
}
|
|
@@ -115,14 +116,19 @@ export async function ensureSchema(
|
|
|
115
116
|
catalog: Catalog,
|
|
116
117
|
options: EnsureSchemaOptions = {},
|
|
117
118
|
): Promise<string[]> {
|
|
119
|
+
// schema 是连接上的事实:配置给了就用配置的,没给就跟着连接打开时的有效 schema。
|
|
120
|
+
const schema = options.schema ?? database.schema;
|
|
118
121
|
const shapes = new Map<string, TableShape | undefined>();
|
|
119
122
|
for (const table of catalog) {
|
|
120
123
|
shapes.set(
|
|
121
124
|
table.name,
|
|
122
|
-
await database.dialect.readShape(shapeReader(database), table.name,
|
|
125
|
+
await database.dialect.readShape(shapeReader(database), table.name, schema),
|
|
123
126
|
);
|
|
124
127
|
}
|
|
125
|
-
const changes = planSchema(database.dialect, catalog, shapes,
|
|
128
|
+
const changes = planSchema(database.dialect, catalog, shapes, {
|
|
129
|
+
...options,
|
|
130
|
+
...(schema === undefined ? {} : { schema }),
|
|
131
|
+
});
|
|
126
132
|
for (const change of changes) {
|
|
127
133
|
options.onStatement?.(change.statement);
|
|
128
134
|
await database.run(fragment(change.statement));
|
package/src/table/handle.ts
CHANGED
|
@@ -83,6 +83,8 @@ export function createTableHandle<T extends TableDef>(
|
|
|
83
83
|
definition: T,
|
|
84
84
|
): TableHandle<T> {
|
|
85
85
|
const name = definition.name;
|
|
86
|
+
// 目标表名按连接的方言算一次(PostgreSQL 带 schema,SQLite 就是表名)。
|
|
87
|
+
const target = database.dialect.tableName(name, database.schema);
|
|
86
88
|
const handle: TableHandleBase<T> = {
|
|
87
89
|
name,
|
|
88
90
|
definition,
|
|
@@ -101,32 +103,34 @@ export function createTableHandle<T extends TableDef>(
|
|
|
101
103
|
) as never,
|
|
102
104
|
async insert(row, returning) {
|
|
103
105
|
return await database.write<RowOf<T>>(
|
|
104
|
-
insertOf(definition, row, "insert", undefined, undefined, returning),
|
|
106
|
+
insertOf(definition, target, row, "insert", undefined, undefined, returning),
|
|
105
107
|
);
|
|
106
108
|
},
|
|
107
109
|
async insertIgnore(row, conflict) {
|
|
108
|
-
await database.run(insertOf(definition, row, "ignore", conflict));
|
|
110
|
+
await database.run(insertOf(definition, target, row, "ignore", conflict));
|
|
109
111
|
},
|
|
110
112
|
async insertFromSelect(columns, source, options) {
|
|
111
|
-
await database.run(insertFromSelectOf(definition, columns, source, options));
|
|
113
|
+
await database.run(insertFromSelectOf(definition, target, columns, source, options));
|
|
112
114
|
},
|
|
113
115
|
async insertMany(rows, options) {
|
|
114
|
-
await database.run(insertManyOf(definition, rows, options));
|
|
116
|
+
await database.run(insertManyOf(definition, target, rows, options));
|
|
115
117
|
},
|
|
116
118
|
async upsert(row, conflict, update) {
|
|
117
|
-
await database.run(insertOf(definition, row, "upsert", conflict, update));
|
|
119
|
+
await database.run(insertOf(definition, target, row, "upsert", conflict, update));
|
|
118
120
|
},
|
|
119
121
|
async update(patch, condition, returning) {
|
|
120
|
-
return await database.write<RowOf<T>>(
|
|
122
|
+
return await database.write<RowOf<T>>(
|
|
123
|
+
updateOf(definition, target, patch, condition, returning),
|
|
124
|
+
);
|
|
121
125
|
},
|
|
122
126
|
async upsertExpr(row, conflict, assignments) {
|
|
123
|
-
await database.run(upsertExprOf(definition, row, conflict, assignments));
|
|
127
|
+
await database.run(upsertExprOf(definition, target, row, conflict, assignments));
|
|
124
128
|
},
|
|
125
129
|
async delete(condition, returning) {
|
|
126
130
|
if (returning !== undefined && returning.length > 0) {
|
|
127
|
-
return await database.write<RowOf<T>>(deleteOf(definition, condition, returning));
|
|
131
|
+
return await database.write<RowOf<T>>(deleteOf(definition, target, condition, returning));
|
|
128
132
|
}
|
|
129
|
-
await database.run(deleteOf(definition, condition));
|
|
133
|
+
await database.run(deleteOf(definition, target, condition));
|
|
130
134
|
return [];
|
|
131
135
|
},
|
|
132
136
|
};
|
package/src/table/render.ts
CHANGED
|
@@ -10,6 +10,7 @@ export function returningClause(returning?: readonly string[]): Fragment | strin
|
|
|
10
10
|
|
|
11
11
|
export function insertOf(
|
|
12
12
|
table: TableDef,
|
|
13
|
+
target: string,
|
|
13
14
|
row: Record<string, unknown>,
|
|
14
15
|
mode: "insert" | "ignore" | "upsert",
|
|
15
16
|
conflict?: readonly string[],
|
|
@@ -19,26 +20,30 @@ export function insertOf(
|
|
|
19
20
|
const columns = Object.keys(row).filter((name) => table.columns[name] !== undefined);
|
|
20
21
|
if (columns.length === 0) throw new Error(`rdbkit: insert into "${table.name}" has no columns`);
|
|
21
22
|
const sql =
|
|
22
|
-
`INSERT INTO
|
|
23
|
+
`INSERT INTO ${target} (${columns.map((name) => `"${name}"`).join(", ")}) ` +
|
|
23
24
|
`VALUES (${columns.map(() => "?").join(", ")})`;
|
|
24
25
|
const base = fragment(
|
|
25
26
|
sql,
|
|
26
27
|
columns.map((name) => row[name]),
|
|
27
28
|
);
|
|
28
29
|
if (mode === "insert") return concat([base, returningClause(returning)], " ");
|
|
29
|
-
const
|
|
30
|
-
if (mode === "ignore") return concat([base, `ON CONFLICT (${
|
|
30
|
+
const conflictTarget = (conflict ?? []).map((name) => `"${name}"`).join(", ");
|
|
31
|
+
if (mode === "ignore") return concat([base, `ON CONFLICT (${conflictTarget}) DO NOTHING`], " ");
|
|
31
32
|
const updates =
|
|
32
33
|
(update ?? []).length === 0
|
|
33
34
|
? "DO NOTHING"
|
|
34
35
|
: `DO UPDATE SET ${(update ?? [])
|
|
35
36
|
.map((name) => `"${name}" = excluded."${name}"`)
|
|
36
37
|
.join(", ")}`;
|
|
37
|
-
return concat(
|
|
38
|
+
return concat(
|
|
39
|
+
[base, `ON CONFLICT (${conflictTarget}) ${updates}`, returningClause(returning)],
|
|
40
|
+
" ",
|
|
41
|
+
);
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
export function insertManyOf(
|
|
41
45
|
table: TableDef,
|
|
46
|
+
target: string,
|
|
42
47
|
rows: readonly Record<string, unknown>[],
|
|
43
48
|
options: { conflict?: readonly string[]; update?: readonly string[] } = {},
|
|
44
49
|
): Fragment {
|
|
@@ -54,7 +59,7 @@ export function insertManyOf(
|
|
|
54
59
|
}
|
|
55
60
|
const tuple = `(${columns.map(() => "?").join(", ")})`;
|
|
56
61
|
const sql =
|
|
57
|
-
`INSERT INTO
|
|
62
|
+
`INSERT INTO ${target} (${columns.map((name) => `"${name}"`).join(", ")}) ` +
|
|
58
63
|
`VALUES ${rows.map(() => tuple).join(", ")}`;
|
|
59
64
|
const insert = fragment(
|
|
60
65
|
sql,
|
|
@@ -65,14 +70,13 @@ export function insertManyOf(
|
|
|
65
70
|
|
|
66
71
|
export function insertFromSelectOf(
|
|
67
72
|
table: TableDef,
|
|
73
|
+
target: string,
|
|
68
74
|
columns: readonly string[],
|
|
69
75
|
source: Fragment,
|
|
70
76
|
options: { conflict?: readonly string[]; update?: readonly string[] } = {},
|
|
71
77
|
): Fragment {
|
|
72
78
|
if (columns.length === 0) throw new Error(`rdbkit: insert into "${table.name}" has no columns`);
|
|
73
|
-
const head = text(
|
|
74
|
-
`INSERT INTO "${table.name}" (${columns.map((name) => `"${name}"`).join(", ")})`,
|
|
75
|
-
);
|
|
79
|
+
const head = text(`INSERT INTO ${target} (${columns.map((name) => `"${name}"`).join(", ")})`);
|
|
76
80
|
return concat([head, source, conflictClause(options)], " ");
|
|
77
81
|
}
|
|
78
82
|
|
|
@@ -95,20 +99,25 @@ export function conflictClause(options: {
|
|
|
95
99
|
|
|
96
100
|
export function upsertExprOf(
|
|
97
101
|
table: TableDef,
|
|
102
|
+
target: string,
|
|
98
103
|
row: Record<string, unknown>,
|
|
99
104
|
conflict: readonly string[],
|
|
100
105
|
assignments: Record<string, Operand>,
|
|
101
106
|
): Fragment {
|
|
102
|
-
const base = insertOf(table, row, "insert");
|
|
103
|
-
const
|
|
107
|
+
const base = insertOf(table, target, row, "insert");
|
|
108
|
+
const conflictTarget = conflict.map((name) => `"${name}"`).join(", ");
|
|
104
109
|
const sets = Object.entries(assignments).map(([name, ref]) =>
|
|
105
110
|
concat([text(`"${name}" =`), operandSql(ref)], " "),
|
|
106
111
|
);
|
|
107
|
-
return concat(
|
|
112
|
+
return concat(
|
|
113
|
+
[base, text(`ON CONFLICT (${conflictTarget}) DO UPDATE SET`), concat(sets, ", ")],
|
|
114
|
+
" ",
|
|
115
|
+
);
|
|
108
116
|
}
|
|
109
117
|
|
|
110
118
|
export function updateOf(
|
|
111
119
|
table: TableDef,
|
|
120
|
+
target: string,
|
|
112
121
|
patch: Record<string, unknown>,
|
|
113
122
|
condition?: Condition,
|
|
114
123
|
returning?: readonly string[],
|
|
@@ -124,17 +133,18 @@ export function updateOf(
|
|
|
124
133
|
: param(value);
|
|
125
134
|
return concat([text(`"${name}" =`), assigned], " ");
|
|
126
135
|
});
|
|
127
|
-
const head = concat([text(`UPDATE
|
|
136
|
+
const head = concat([text(`UPDATE ${target} SET`), concat(sets, ", ")], " ");
|
|
128
137
|
const body = condition === undefined ? head : concat([head, text("WHERE"), condition], " ");
|
|
129
138
|
return concat([body, returningClause(returning)], " ");
|
|
130
139
|
}
|
|
131
140
|
|
|
132
141
|
export function deleteOf(
|
|
133
142
|
table: TableDef,
|
|
143
|
+
target: string,
|
|
134
144
|
condition?: Condition,
|
|
135
145
|
returning?: readonly string[],
|
|
136
146
|
): Fragment {
|
|
137
|
-
const head = text(`DELETE FROM
|
|
147
|
+
const head = text(`DELETE FROM ${target}`);
|
|
138
148
|
const body = condition === undefined ? head : concat([head, text("WHERE"), condition], " ");
|
|
139
149
|
return concat([body, returningClause(returning)], " ");
|
|
140
150
|
}
|
package/src/table/select.ts
CHANGED
|
@@ -122,6 +122,9 @@ export function selectQuery<T>(
|
|
|
122
122
|
return undefined;
|
|
123
123
|
};
|
|
124
124
|
|
|
125
|
+
// 表名按连接的方言算(PostgreSQL 带 schema):FROM / JOIN 与其余引用保持同一处。
|
|
126
|
+
const targetOf = (definition: TableDef): string =>
|
|
127
|
+
database.dialect.tableName(definition.name, database.schema);
|
|
125
128
|
const toFragment = (): Fragment => {
|
|
126
129
|
const items = Object.entries(projection).map(([alias, entry]) =>
|
|
127
130
|
entry.kind === "column"
|
|
@@ -133,13 +136,13 @@ export function selectQuery<T>(
|
|
|
133
136
|
const parts: (Fragment | string)[] = [
|
|
134
137
|
state.distinct ? text("SELECT DISTINCT") : text("SELECT"),
|
|
135
138
|
concat(items, ", "),
|
|
136
|
-
text(`FROM
|
|
139
|
+
text(`FROM ${targetOf(table)}`),
|
|
137
140
|
];
|
|
138
141
|
for (const join of state.joins) {
|
|
139
142
|
const target =
|
|
140
143
|
join.alias === undefined
|
|
141
|
-
?
|
|
142
|
-
:
|
|
144
|
+
? targetOf(join.table)
|
|
145
|
+
: `${targetOf(join.table)} AS "${join.alias}"`;
|
|
143
146
|
if (join.kind === "cross") {
|
|
144
147
|
parts.push(text(`CROSS JOIN ${target}`));
|
|
145
148
|
continue;
|