nodejs-store 2.0.3 → 2.1.0
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 +36 -4
- package/README.zh-CN.md +36 -4
- package/package.json +2 -3
- package/src/datasource.js +35 -0
- package/src/ddl.js +124 -0
- package/src/index.js +32 -0
package/README.md
CHANGED
|
@@ -98,7 +98,7 @@ Typical concrete scenarios (see [`doc/use-cases/`](doc/use-cases/) for full walk
|
|
|
98
98
|
|
|
99
99
|
Being explicit about the boundary saves you time:
|
|
100
100
|
|
|
101
|
-
- **You want a full ORM with a migration engine.** `nodejs-store` is a *data layer*, not a migration tool. It can **read** a SQL backend's physical structure (`syncSchema` → introspection) but it never writes DDL back. Pair it with your migration tool of choice.
|
|
101
|
+
- **You want a full ORM with a migration engine.** `nodejs-store` is a *data layer*, not a migration tool. It can **read** a SQL backend's physical structure (`syncSchema` → introspection) but it never writes DDL back. Pair it with your migration tool of choice. There is an optional `generateDdl()` that renders `CREATE TABLE` text from your registered schemas — pure text, it never connects to or writes to the database.
|
|
102
102
|
- **You need a type-safe generated client.** Schemas are runtime JSON, not TypeScript types. You get flexibility and cross-language parity (same schema runs in Node and Python), not compile-time type inference.
|
|
103
103
|
- **You only ever use one database and rarely join.** A plain driver (or a single-database ODM/ORM) will be simpler.
|
|
104
104
|
- **You need raw aggregation escape hatches.** `$pipeline` passthrough and `store.aggregate()` were deliberately removed. Use `$condition` / `$group` / `$having` / relations; anything that cannot be safely translated fails **explicitly** rather than silently.
|
|
@@ -117,7 +117,7 @@ General positioning, not a benchmark — always verify against each tool's curre
|
|
|
117
117
|
| Built-in role / field-level RBAC + owner injection | ✅ | ➖ | ➖ (via extensions) | ➖ | ➖ |
|
|
118
118
|
| Read-time computed columns (sync / async / relation-agg) | ✅ | ➖ (getters) | ➖ | ➖ | ➖ |
|
|
119
119
|
| Soft-delete archive table auto-provisioned | ✅ | ➖ | ➖ | ➖ | ➖ |
|
|
120
|
-
| Migration / DDL engine | ➖ (introspection read-only) | ➖ | ✅ | ✅ | ✅ |
|
|
120
|
+
| Migration / DDL engine | ➖ (introspection read-only; optional `generateDdl` text) | ➖ | ✅ | ✅ | ✅ |
|
|
121
121
|
| Static type generation | ➖ (runtime JSON, cross-language parity) | ➖ | ✅ | ⚠️ (decorators + TS) | ✅ |
|
|
122
122
|
| Shared native core across Node & Python | ✅ (Rust `rust-store`) | ➖ | ➖ | ➖ | ➖ |
|
|
123
123
|
|
|
@@ -128,7 +128,7 @@ Positioning only, based on those projects' public documentation at the time of w
|
|
|
128
128
|
- **vs Mongoose** — Mongoose is MongoDB-only. `nodejs-store` uses a similar MongoDB-style query syntax (`$gt`, `$or`, `$set`, `$inc`) but the same query also runs unchanged against MySQL, SQLite and PostgreSQL.
|
|
129
129
|
- **vs `mongoosql-core`** — the closest in spirit: it also runs Mongoose-style queries on MongoDB, PostgreSQL and MySQL. `nodejs-store` additionally targets SQLite, ships schema-level permissions (role/field whitelists plus `creator` owner-condition injection), read-time computed columns (`fn` / `asyncFn` / relation-`agg`), an auto-provisioned `<Model>Deleted` soft-delete archive, and shares one Rust engine with a Python host so Node.js and Python cannot drift apart.
|
|
130
130
|
- **vs `unsql`** — `unsql` generates SQL from plain JavaScript objects for MySQL, PostgreSQL and SQLite. It does not target MongoDB, and it is a query/CRUD helper rather than a schema-driven data layer with permissions and computed columns.
|
|
131
|
-
- **vs Prisma** — Prisma is a schema DSL plus generated client with a migration engine and compile-time types. `nodejs-store` is a runtime JSON schema with no
|
|
131
|
+
- **vs Prisma** — Prisma is a schema DSL plus generated client with a migration engine and compile-time types. `nodejs-store` is a runtime JSON schema with no migration engine (it only *reads* physical structure via introspection, and `generateDdl()` only *renders* `CREATE TABLE` text without touching the database) and no type generation — in exchange for one query dialect spanning a document store and three relational stores.
|
|
132
132
|
- **vs TypeORM / Sequelize / Drizzle** — Sequelize and Drizzle are SQL-only; TypeORM models MongoDB separately from its SQL entities. `nodejs-store` treats MongoDB as the primary dialect and compiles the same GQL to SQL for the other three backends.
|
|
133
133
|
|
|
134
134
|
Short version: use an ORM when you want **compile-time types and migrations**; use `nodejs-store` when you want **one runtime schema + one query dialect spanning MongoDB and SQL**, with RBAC and computed columns built in.
|
|
@@ -302,6 +302,38 @@ Notes:
|
|
|
302
302
|
- `queryWithCount` accepts `page`/`pageSize` (recommended) or the traditional `$skip`/`$limit` params.
|
|
303
303
|
- `updateMany` / `remove` with an **empty condition** (`{}`, `null`, `{ "$and": [] }`) is rejected outright — it never falls through to a full-table write.
|
|
304
304
|
|
|
305
|
+
### Transactions and raw SQL
|
|
306
|
+
|
|
307
|
+
```js
|
|
308
|
+
async function transfer() {
|
|
309
|
+
const rows = await store.executeRaw(
|
|
310
|
+
'default', 'SELECT * FROM accounts WHERE _id = ? FOR UPDATE', [accId]);
|
|
311
|
+
await store.executeRaw(
|
|
312
|
+
'default', 'UPDATE accounts SET balance = ? WHERE _id = ?', [newBalance, accId],
|
|
313
|
+
true);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
await store.transaction('default', transfer);
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
- `store.transaction(source, fn)` opens a transaction scope on one SQL source: every `executeRaw` / CRUD call inside `fn` lands on that source's transaction connection, with `commit` / `rollback` as one unit (reuses the internal `runInTransaction`). Mongo sources or executors without transactions run `fn` as-is — it never pretends to be atomic.
|
|
320
|
+
- `store.executeRaw(source, sql, params, isWrite)` runs raw SQL, bypassing GQL parsing and dialect translation. Placeholders follow each backend's native style: `?` for MySQL / SQLite, `$1..$n` for PostgreSQL. SQL sources only — a Mongo source throws `RawSqlError` (`store.RawSqlError`).
|
|
321
|
+
- `isWrite=false` (default) returns `{ rows, affectedRows }` with the result-set rows; `isWrite=true` returns the affected-row count.
|
|
322
|
+
|
|
323
|
+
### DDL generation
|
|
324
|
+
|
|
325
|
+
```js
|
|
326
|
+
let sql = store.generateDdl('mysql'); // every registered model
|
|
327
|
+
sql = store.generateDdl('postgres', ['Course', 'CourseDeleted']);
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
`store.generateDdl(backend, names)` maps one registered schema def to one `CREATE TABLE` — the inverse of `syncSchema()`, which only *reads*. The generator is **pure text**: it never connects to, or writes to, the database (iron rule 6 still holds).
|
|
331
|
+
|
|
332
|
+
- Only scalar fields become columns; `object` / `array` fields do not.
|
|
333
|
+
- Every table gets the `__present` sentinel column; `timestamps` models also get `createdAt` / `updatedAt`; the `<collection>_deleted` archive table is generated like any other registered def.
|
|
334
|
+
- No `CREATE INDEX` is emitted — SQL backends keep indexes as metadata only.
|
|
335
|
+
- MySQL `__present` is `VARCHAR(255)`; a schema whose present-token string would overflow emits a `ddlPresentOverflow` feedback event rather than failing silently.
|
|
336
|
+
|
|
305
337
|
## Multi-datasource connections
|
|
306
338
|
|
|
307
339
|
Every schema is located by the triple `(source, namespace, collection)` — the triple must be
|
|
@@ -528,7 +560,7 @@ Every registered model automatically gets a `<Model>Deleted` archive collection/
|
|
|
528
560
|
Yes. Bind a schema to `(source, namespace, collection)` and pass a `{ source, namespace }` route override per request. Treat `routeOverride` as trusted server-side input only.
|
|
529
561
|
|
|
530
562
|
**Does it run migrations?**
|
|
531
|
-
No. `syncSchema()` only *reads* physical structure via introspection (introspect → merge overlay → register). Schema changes / DDL are your migration tool's job.
|
|
563
|
+
No. `syncSchema()` only *reads* physical structure via introspection (introspect → merge overlay → register). Schema changes / DDL are your migration tool's job. If you want a starting point, `store.generateDdl(backend)` renders `CREATE TABLE` text from the registered schemas — but it is pure text generation: it never runs or writes DDL.
|
|
532
564
|
|
|
533
565
|
**Can I see the generated query without running it?**
|
|
534
566
|
Yes — `store.buildPipeline(gql, params)` returns the compiled plan (`{ tokens, ast, pipeline, projection }`) with no execution and no permission/compute application.
|
package/README.zh-CN.md
CHANGED
|
@@ -97,7 +97,7 @@ MongoDB 是*主方言*:查询以 MongoDB 风格的 GQL 编写,三种关系
|
|
|
97
97
|
|
|
98
98
|
明确边界能为你省下时间:
|
|
99
99
|
|
|
100
|
-
- **你想要带迁移引擎的完整 ORM。** `nodejs-store` 是*数据层*,不是迁移工具。它可以**读取** SQL 后端的物理结构(`syncSchema` → introspection),但从不把 DDL
|
|
100
|
+
- **你想要带迁移引擎的完整 ORM。** `nodejs-store` 是*数据层*,不是迁移工具。它可以**读取** SQL 后端的物理结构(`syncSchema` → introspection),但从不把 DDL 写回。请与你自选的迁移工具搭配使用。另有一个可选的 `generateDdl()`,可从已注册 schema 渲染出 `CREATE TABLE` 文本 —— 纯文本,绝不连接或写入数据库。
|
|
101
101
|
- **你需要带类型安全、自动生成的客户端。** schema 是运行时 JSON,而非 TypeScript 类型。你获得的是灵活性与跨语言一致性(同一份 schema 在 Node 与 Python 中都可用),而不是编译期类型推断。
|
|
102
102
|
- **你只用一种数据库,且几乎不做关联。** 直接用裸驱动(或只针对单一数据库的 ODM/ORM)会更简单。
|
|
103
103
|
- **你需要原生聚合逃生舱。** `$pipeline` 透传与 `store.aggregate()` 已被有意移除。请使用 `$condition` / `$group` / `$having` / 关系;任何无法安全翻译的内容都会**显式**失败,而不会静默降级。
|
|
@@ -116,7 +116,7 @@ MongoDB 是*主方言*:查询以 MongoDB 风格的 GQL 编写,三种关系
|
|
|
116
116
|
| 内置角色 / 字段级 RBAC + 属主注入 | ✅ | ➖ | ➖(通过扩展) | ➖ | ➖ |
|
|
117
117
|
| 读时计算列(同步 / 异步 / 关系聚合) | ✅ | ➖(getter) | ➖ | ➖ | ➖ |
|
|
118
118
|
| 自动置备软删除归档表 | ✅ | ➖ | ➖ | ➖ | ➖ |
|
|
119
|
-
| 迁移 / DDL 引擎 | ➖(introspection
|
|
119
|
+
| 迁移 / DDL 引擎 | ➖(introspection 只读;可选 `generateDdl` 文本) | ➖ | ✅ | ✅ | ✅ |
|
|
120
120
|
| 静态类型生成 | ➖(运行时 JSON,跨语言一致) | ➖ | ✅ | ⚠️(装饰器 + TS) | ✅ |
|
|
121
121
|
| Node 与 Python 共享原生核心 | ✅(Rust `rust-store`) | ➖ | ➖ | ➖ | ➖ |
|
|
122
122
|
|
|
@@ -127,7 +127,7 @@ MongoDB 是*主方言*:查询以 MongoDB 风格的 GQL 编写,三种关系
|
|
|
127
127
|
- **vs Mongoose** —— Mongoose 仅支持 MongoDB。`nodejs-store` 使用类似的 MongoDB 风格查询语法(`$gt`、`$or`、`$set`、`$inc`),但同一条查询也能原样跑在 MySQL、SQLite 与 PostgreSQL 上。
|
|
128
128
|
- **vs `mongoosql-core`** —— 精神上最接近:它同样能在 MongoDB、PostgreSQL 与 MySQL 上运行 Mongoose 风格的查询。`nodejs-store` 还额外面向 SQLite,内置 schema 级权限(角色/字段白名单,外加 `creator` 属主条件注入)、读时计算列(`fn` / `asyncFn` / 关系 `agg`)、自动置备的 `<Model>Deleted` 软删除归档,并与 Python 宿主共享同一个 Rust 引擎,因此 Node.js 与 Python 不会产生漂移。
|
|
129
129
|
- **vs `unsql`** —— `unsql` 从普通 JavaScript 对象为 MySQL、PostgreSQL 与 SQLite 生成 SQL。它不面向 MongoDB,且只是一个查询/CRUD 辅助工具,而非带权限与计算列的 schema 驱动数据层。
|
|
130
|
-
- **vs Prisma** —— Prisma 是 schema DSL 加生成的客户端,配有迁移引擎与编译期类型。`nodejs-store` 是运行时 JSON schema
|
|
130
|
+
- **vs Prisma** —— Prisma 是 schema DSL 加生成的客户端,配有迁移引擎与编译期类型。`nodejs-store` 是运行时 JSON schema,不承担迁移引擎职责(仅通过 introspection *读取*物理结构,`generateDdl()` 也只*渲染* `CREATE TABLE` 文本而不触碰数据库),也不生成类型 —— 以此换取一套横跨文档库与三种关系库的查询方言。
|
|
131
131
|
- **vs TypeORM / Sequelize / Drizzle** —— Sequelize 与 Drizzle 仅支持 SQL;TypeORM 把 MongoDB 与其 SQL 实体分开建模。`nodejs-store` 以 MongoDB 为主方言,并把同一份 GQL 编译为其余三种后端的 SQL。
|
|
132
132
|
|
|
133
133
|
一句话:想要**编译期类型与迁移**就用 ORM;想要**一份运行时 schema + 一套横跨 MongoDB 与 SQL 的查询方言**,并内置 RBAC 与计算列,就用 `nodejs-store`。
|
|
@@ -301,6 +301,38 @@ await store.upsert('Post', { code: 'A1' }, { ... }); // 显式条件的 ups
|
|
|
301
301
|
- `queryWithCount` 接受 `page`/`pageSize`(推荐)或传统的 `$skip`/`$limit` 参数。
|
|
302
302
|
- 带**空条件**(`{}`、`null`、`{ "$and": [] }`)的 `updateMany` / `remove` 会被直接拒绝 —— 它绝不会退化为全表写入。
|
|
303
303
|
|
|
304
|
+
### 事务与原生 SQL
|
|
305
|
+
|
|
306
|
+
```js
|
|
307
|
+
async function transfer() {
|
|
308
|
+
const rows = await store.executeRaw(
|
|
309
|
+
'default', 'SELECT * FROM accounts WHERE _id = ? FOR UPDATE', [accId]);
|
|
310
|
+
await store.executeRaw(
|
|
311
|
+
'default', 'UPDATE accounts SET balance = ? WHERE _id = ?', [newBalance, accId],
|
|
312
|
+
true);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
await store.transaction('default', transfer);
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
- `store.transaction(source, fn)` 在单个 SQL 源上开启事务作用域:`fn` 内的每个 `executeRaw` / CRUD 调用都落到该源的事务连接,`commit` / `rollback` 作为一个整体(复用内部的 `runInTransaction`)。Mongo 源或不支持事务的执行器会按原样执行 `fn` —— 绝不假装已原子。
|
|
319
|
+
- `store.executeRaw(source, sql, params, isWrite)` 执行原生 SQL,绕开 GQL 解析与方言翻译。占位符沿用各后端原生风格:MySQL / SQLite 用 `?`,PostgreSQL 用 `$1..$n`。仅限 SQL 源 —— Mongo 源会抛出 `RawSqlError`(`store.RawSqlError`)。
|
|
320
|
+
- `isWrite=false`(默认)返回 `{ rows, affectedRows }` 含结果集行;`isWrite=true` 返回影响行数。
|
|
321
|
+
|
|
322
|
+
### DDL 生成
|
|
323
|
+
|
|
324
|
+
```js
|
|
325
|
+
let sql = store.generateDdl('mysql'); // 所有已注册模型
|
|
326
|
+
sql = store.generateDdl('postgres', ['Course', 'CourseDeleted']);
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
`store.generateDdl(backend, names)` 把一个已注册 schema def 映射为一条 `CREATE TABLE` —— 是 `syncSchema()`(只*读取*)的逆操作。生成器是**纯文本**:它绝不连接、也绝不写入数据库(铁律 6 依然成立)。
|
|
330
|
+
|
|
331
|
+
- 只有标量字段成为列;`object` / `array` 字段不建列。
|
|
332
|
+
- 每张表都会获得 `__present` 哨兵列;`timestamps` 模型还会获得 `createdAt` / `updatedAt`;`<collection>_deleted` 归档表与其它已注册 def 一样生成。
|
|
333
|
+
- 不生成 `CREATE INDEX` —— SQL 后端仅把索引保留为元数据。
|
|
334
|
+
- MySQL 的 `__present` 为 `VARCHAR(255)`;若某 schema 的 present 令牌串会超限,则发出 `ddlPresentOverflow` 反馈事件,而非静默失败。
|
|
335
|
+
|
|
304
336
|
## 多数据源连接
|
|
305
337
|
|
|
306
338
|
每个 schema 由三元组 `(source, namespace, collection)` 定位 —— 该三元组在 registry 内必须
|
|
@@ -525,7 +557,7 @@ await init({ default: db, pg_a: executors.createConnection('postgres', pgPool) }
|
|
|
525
557
|
可以。把 schema 绑定到 `(source, namespace, collection)`,并按请求传入 `{ source, namespace }` 路由覆盖。仅把 `routeOverride` 当作受信的服务端输入。
|
|
526
558
|
|
|
527
559
|
**它会执行迁移吗?**
|
|
528
|
-
不会。`syncSchema()` 只通过 introspection *读取*物理结构(introspect → 合并 overlay → 注册)。schema 变更 / DDL
|
|
560
|
+
不会。`syncSchema()` 只通过 introspection *读取*物理结构(introspect → 合并 overlay → 注册)。schema 变更 / DDL 是你所用迁移工具的职责。若想要一个起点,`store.generateDdl(backend)` 可从已注册 schema 渲染 `CREATE TABLE` 文本 —— 但它只是纯文本生成:绝不执行、也不写入 DDL。
|
|
529
561
|
|
|
530
562
|
**能否在不运行的情况下查看生成的查询?**
|
|
531
563
|
可以 —— `store.buildPipeline(gql, params)` 返回编译后的计划(`{ tokens, ast, pipeline, projection }`),不执行,也不应用权限/计算列。
|
package/package.json
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nodejs-store",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Multi-backend data layer for Node.js (MongoDB, MySQL, SQLite, PostgreSQL): pure JSON schemas, GQL tree queries compiled to a single native query, GROUP BY/HAVING aggregation, computed columns, soft-delete and role-based access control",
|
|
5
5
|
"main": "src/index.js",
|
|
6
|
-
"homepage": "https://coenddt.github.io/nodejs-store/",
|
|
7
6
|
"files": [
|
|
8
7
|
"src",
|
|
9
8
|
"README.md",
|
|
@@ -57,7 +56,7 @@
|
|
|
57
56
|
"bugs": {
|
|
58
57
|
"url": "https://github.com/coenddt/nodejs-store/issues"
|
|
59
58
|
},
|
|
60
|
-
"homepage": "https://github.
|
|
59
|
+
"homepage": "https://coenddt.github.io/nodejs-store/",
|
|
61
60
|
"engines": {
|
|
62
61
|
"node": ">=18"
|
|
63
62
|
},
|
package/src/datasource.js
CHANGED
|
@@ -235,6 +235,39 @@ async function execSql(source, connection, cmd) {
|
|
|
235
235
|
return executors.shapeResult(cmd, out);
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
/** 原生 SQL 入口的显式错误(非 SQL 源 / 执行器未接入) */
|
|
239
|
+
class RawSqlError extends Error {
|
|
240
|
+
constructor(message) {
|
|
241
|
+
super(message);
|
|
242
|
+
this.name = 'RawSqlError';
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 在指定 SQL 源上执行原生 SQL(Host 层逃生口,绕开 core 的 dialectTranslate)
|
|
248
|
+
*
|
|
249
|
+
* - 事务作用域内经 `connectionFor` 落到事务专用连接 → 支持 SELECT ... FOR UPDATE;
|
|
250
|
+
* - 占位符沿用各后端原生风格(mysql/sqlite 用 `?`,postgres 用 `$1..$n`);
|
|
251
|
+
* - 仅支持 SQL 源;Mongo 源显式报错(绝不静默);
|
|
252
|
+
* - `isWrite=false` 视为读(取行);`true` 视为写(取影响行数);
|
|
253
|
+
* - 返回 `{ rows, affectedRows }`。
|
|
254
|
+
* 对齐 py_store/datasource.py#execute_raw。
|
|
255
|
+
*/
|
|
256
|
+
async function executeRaw(source, sql, params = [], isWrite = false) {
|
|
257
|
+
const conn = connectionFor(source);
|
|
258
|
+
if (!conn || typeof conn.kind !== 'string') {
|
|
259
|
+
throw new RawSqlError(
|
|
260
|
+
`数据源 ${source} 不是 SQL 源(原生 SQL 入口仅支持 mysql/postgres/sqlite)`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
if (typeof conn.exec !== 'function') {
|
|
264
|
+
throw new RawSqlError(`SQL 数据源 ${source}(${conn.kind}) 的执行器未接入`);
|
|
265
|
+
}
|
|
266
|
+
const stmt = { text: sql, params: Array.from(params || []), isWrite: Boolean(isWrite) };
|
|
267
|
+
const out = await conn.exec({ stmts: [stmt] });
|
|
268
|
+
return { rows: out.rows ?? null, affectedRows: Number(out.affectedRows || 0) };
|
|
269
|
+
}
|
|
270
|
+
|
|
238
271
|
module.exports = {
|
|
239
272
|
DEFAULT_SOURCE,
|
|
240
273
|
setConnections,
|
|
@@ -250,5 +283,7 @@ module.exports = {
|
|
|
250
283
|
dbOfSchema,
|
|
251
284
|
route,
|
|
252
285
|
execSql,
|
|
286
|
+
executeRaw,
|
|
253
287
|
PushdownUnsupportedError,
|
|
288
|
+
RawSqlError,
|
|
254
289
|
};
|
package/src/ddl.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DDL 生成(schema def → CREATE TABLE 文本;纯函数,不连库、不回写)
|
|
5
|
+
*
|
|
6
|
+
* 与 core 契约严格对齐(schema→DDL 单向映射):
|
|
7
|
+
* - 只对 scalar 字段建列(object/array 不建列;同 core dialect::scalar_column);
|
|
8
|
+
* - 每表必建 __present 哨兵列(形态 ,f1,f2,;同 core write/insert.rs::present_value);
|
|
9
|
+
* - timestamps !== false → 追加 createdAt / updatedAt(同 core schema/registry.rs::add_timestamp_fields);
|
|
10
|
+
* - 归档表 <collection>_deleted 由 registry 自动派生,本模块按已注册 def 逐表生成(不特判);
|
|
11
|
+
* - 不生成 CREATE INDEX(SQL 后端不建索引,schema.indexes 仅元数据,铁律 6)。
|
|
12
|
+
*
|
|
13
|
+
* 生成器只产出文本、不执行 —— 不违反铁律 6(绝不写 DDL 回库)。
|
|
14
|
+
* 对齐 py_store/ddl.py(两端输出逐字节一致)。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { emit: _emitFeedback } = require('./feedback');
|
|
18
|
+
const schema = require('./schema');
|
|
19
|
+
|
|
20
|
+
const BACKENDS = ['mysql', 'postgres', 'sqlite'];
|
|
21
|
+
|
|
22
|
+
// schema 声明类型 → [mysql, postgres, sqlite] 列类型
|
|
23
|
+
const TYPES = {
|
|
24
|
+
string: ['VARCHAR(255)', 'TEXT', 'TEXT'],
|
|
25
|
+
int: ['INT', 'INTEGER', 'INTEGER'],
|
|
26
|
+
long: ['BIGINT', 'BIGINT', 'INTEGER'],
|
|
27
|
+
number: ['BIGINT', 'BIGINT', 'INTEGER'],
|
|
28
|
+
float: ['DOUBLE', 'DOUBLE PRECISION', 'REAL'],
|
|
29
|
+
double: ['DOUBLE', 'DOUBLE PRECISION', 'REAL'],
|
|
30
|
+
bool: ['TINYINT(1)', 'BOOLEAN', 'INTEGER'],
|
|
31
|
+
boolean: ['TINYINT(1)', 'BOOLEAN', 'INTEGER'],
|
|
32
|
+
datetime: ['BIGINT', 'BIGINT', 'INTEGER'],
|
|
33
|
+
date: ['BIGINT', 'BIGINT', 'INTEGER'],
|
|
34
|
+
};
|
|
35
|
+
const NON_COLUMN = ['object', 'array'];
|
|
36
|
+
const ID_TYPE = ['VARCHAR(64)', 'TEXT', 'TEXT'];
|
|
37
|
+
const PRESENT_TYPE = ['VARCHAR(255)', 'TEXT', 'TEXT'];
|
|
38
|
+
const TIMESTAMP_FIELDS = ['createdAt', 'updatedAt'];
|
|
39
|
+
const MYSQL_PRESENT_MAX = 255;
|
|
40
|
+
|
|
41
|
+
function idx(backend) {
|
|
42
|
+
return BACKENDS.indexOf(backend);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 标识符引用(与 core Backend::quote_ident 一致:mysql 反引号,其余双引号) */
|
|
46
|
+
function q(backend, ident) {
|
|
47
|
+
if (backend === 'mysql') return '`' + ident.replace(/`/g, '``') + '`';
|
|
48
|
+
return '"' + ident.replace(/"/g, '""') + '"';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function declaredType(fieldDef) {
|
|
52
|
+
return fieldDef && typeof fieldDef === 'object' ? fieldDef.type : fieldDef;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 返回 [[name, sqlType, pk]],顺序:声明的标量字段 → timestamps → __present */
|
|
56
|
+
function columns(defn, backend) {
|
|
57
|
+
const i = idx(backend);
|
|
58
|
+
const cols = [];
|
|
59
|
+
const fields = defn.fields || {};
|
|
60
|
+
for (const [name, fdef] of Object.entries(fields)) {
|
|
61
|
+
const ftype = declaredType(fdef);
|
|
62
|
+
if (NON_COLUMN.includes(ftype)) continue;
|
|
63
|
+
if (name === '_id') {
|
|
64
|
+
cols.push([name, ID_TYPE[i], true]);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (!Object.prototype.hasOwnProperty.call(TYPES, ftype)) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`DDL 生成:字段 "${defn.name}.${name}" 类型 ${JSON.stringify(ftype)} 未知,支持 ${Object.keys(TYPES).sort()}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
cols.push([name, TYPES[ftype][i], false]);
|
|
73
|
+
}
|
|
74
|
+
if (!cols.some((c) => c[2])) {
|
|
75
|
+
throw new Error(`DDL 生成:schema "${defn.name}" 缺少 _id 字段`);
|
|
76
|
+
}
|
|
77
|
+
if (defn.timestamps !== false) {
|
|
78
|
+
for (const ts of TIMESTAMP_FIELDS) {
|
|
79
|
+
if (!cols.some((c) => c[0] === ts)) cols.push([ts, TYPES.number[i], false]);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
cols.push(['__present', PRESENT_TYPE[i], false]);
|
|
83
|
+
return cols;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** MySQL __present VARCHAR(255) 容量校验:超限即告警(不静默) */
|
|
87
|
+
function warnPresentOverflow(defn, cols) {
|
|
88
|
+
const length = cols.reduce((n, c) => n + c[0].length, 0) + cols.length + 1;
|
|
89
|
+
if (length > MYSQL_PRESENT_MAX) {
|
|
90
|
+
_emitFeedback({
|
|
91
|
+
type: 'ddl_present_overflow',
|
|
92
|
+
code: 'ddlPresentOverflow',
|
|
93
|
+
layer: 'host',
|
|
94
|
+
message: `表 ${defn.collection} 的 __present 预估长度 ${length} 超过 MySQL VARCHAR(255)`,
|
|
95
|
+
hint: '为该表改用 TEXT 列,或减少标量字段;否则写入会被截断/报错,导致 $eq:null / $exists 三态判定错误',
|
|
96
|
+
schema: defn.name,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function createTable(defn, backend) {
|
|
102
|
+
const table = defn.collection || defn.name;
|
|
103
|
+
const cols = columns(defn, backend);
|
|
104
|
+
if (backend === 'mysql') warnPresentOverflow(defn, cols);
|
|
105
|
+
const lines = [];
|
|
106
|
+
for (const [name, ctype, pk] of cols) {
|
|
107
|
+
if (pk && backend === 'mysql') lines.push(` ${q(backend, name)} ${ctype} NOT NULL`);
|
|
108
|
+
else if (pk) lines.push(` ${q(backend, name)} ${ctype} PRIMARY KEY`);
|
|
109
|
+
else lines.push(` ${q(backend, name)} ${ctype}`);
|
|
110
|
+
}
|
|
111
|
+
if (backend === 'mysql') lines.push(` PRIMARY KEY (${q(backend, '_id')})`);
|
|
112
|
+
return `CREATE TABLE ${q(backend, table)} (\n` + lines.join(',\n') + '\n);';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 生成 DDL 文本(多表以空行分隔);backend ∈ mysql/postgres/sqlite */
|
|
116
|
+
function generate(backend, names) {
|
|
117
|
+
if (!BACKENDS.includes(backend)) {
|
|
118
|
+
throw new Error(`DDL 生成:不支持的后端 ${JSON.stringify(backend)}(支持 ${BACKENDS.join('/')})`);
|
|
119
|
+
}
|
|
120
|
+
const targets = names && names.length ? Array.from(names) : schema.list();
|
|
121
|
+
return targets.map((n) => createTable(schema.get(n), backend)).join('\n\n');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { generate };
|
package/src/index.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
const crud = require('./crud');
|
|
25
25
|
const datasource = require('./datasource');
|
|
26
|
+
const ddl = require('./ddl');
|
|
26
27
|
const executors = require('./executors');
|
|
27
28
|
const feedback = require('./feedback');
|
|
28
29
|
const introspect = require('./introspect');
|
|
@@ -113,6 +114,33 @@ class Store {
|
|
|
113
114
|
return syncSchema(opts);
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
// ── 事务 + 原生 SQL(复用 datasource.runInTransaction;见 README「事务边界」)──
|
|
118
|
+
/**
|
|
119
|
+
* 事务作用域:单 SQL 源「同连接 + 同事务」执行 fn(复用 runInTransaction)
|
|
120
|
+
*
|
|
121
|
+
* fn 内 executeRaw / CRUD 均落到该源的事务连接(commit/rollback 一体);
|
|
122
|
+
* Mongo 源或执行器未实现 withTransaction 时按原样执行(跨源无法原子),
|
|
123
|
+
* 绝不静默假装已事务化。单源场景 source 传 'default'。
|
|
124
|
+
*/
|
|
125
|
+
async transaction(source, fn) {
|
|
126
|
+
return datasource.runInTransaction(source, fn);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 在指定 SQL 源执行原生 SQL(事务内可用;占位符按各后端原生风格)
|
|
131
|
+
*
|
|
132
|
+
* mysql/sqlite 用 `?`,postgres 用 `$1..$n`;仅支持 SQL 源(Mongo 源抛 RawSqlError)。
|
|
133
|
+
* isWrite=false 取行(rows),true 取影响行数(affectedRows)。对齐 py-store store.execute_raw。
|
|
134
|
+
*/
|
|
135
|
+
async executeRaw(source, sql, params, isWrite) {
|
|
136
|
+
return datasource.executeRaw(source, sql, params, isWrite);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 从已注册 schema def 生成指定后端 DDL 文本(纯函数,不连库、不回写;铁律 6) */
|
|
140
|
+
generateDdl(backend, names) {
|
|
141
|
+
return ddl.generate(backend, names);
|
|
142
|
+
}
|
|
143
|
+
|
|
116
144
|
// ── 底层工具(调试/高级用法) ──
|
|
117
145
|
/** 解析 GQL 并构建 pipeline,返回 `{tokens, ast, pipeline, projection}` */
|
|
118
146
|
buildPipeline(gql, params) {
|
|
@@ -163,6 +191,8 @@ class Store {
|
|
|
163
191
|
|
|
164
192
|
/** 自定义权限错误(实例可被 store.PermissionError 捕获) */
|
|
165
193
|
Store.prototype.PermissionError = permission.PermissionError;
|
|
194
|
+
/** 原生 SQL 入口错误(实例可被 store.RawSqlError 捕获) */
|
|
195
|
+
Store.prototype.RawSqlError = datasource.RawSqlError;
|
|
166
196
|
|
|
167
197
|
const store = new Store();
|
|
168
198
|
|
|
@@ -255,7 +285,9 @@ module.exports = {
|
|
|
255
285
|
Store,
|
|
256
286
|
PermissionError: permission.PermissionError,
|
|
257
287
|
PushdownUnsupportedError: datasource.PushdownUnsupportedError,
|
|
288
|
+
RawSqlError: datasource.RawSqlError,
|
|
258
289
|
datasource,
|
|
290
|
+
ddl,
|
|
259
291
|
schema,
|
|
260
292
|
permission,
|
|
261
293
|
crud,
|