@zhin.js/database 1.0.77 → 1.0.79

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.
@@ -23,6 +23,10 @@ export declare abstract class Database<D = any, S extends Record<string, object>
23
23
  private _logger?;
24
24
  private _logging;
25
25
  constructor(dialect: Dialect<D, S, Q>, definitions?: Database.DefinitionObj<S>);
26
+ /**
27
+ * 若方言支持按列声明类型处理(如 SQLite 读路径的 json 反序列化),向其注册表结构
28
+ */
29
+ private registerDialectSchema;
26
30
  /**
27
31
  * 启用查询日志
28
32
  * @param handler 自定义日志处理器,不传则使用默认控制台输出
@@ -123,6 +127,6 @@ export declare namespace Database {
123
127
  get<K extends keyof S>(key: K): Model<D, S, Q, K> | undefined;
124
128
  }
125
129
  type DefinitionObj<S extends Record<string, object>> = {
126
- [K in keyof S]: Definition<S[K]>;
130
+ [K in keyof S]?: Definition<S[K]>;
127
131
  };
128
132
  }
@@ -24,7 +24,20 @@ export class Database {
24
24
  constructor(dialect, definitions) {
25
25
  this.dialect = dialect;
26
26
  for (const key in definitions) {
27
- this.definitions.set(key, definitions[key]);
27
+ const definition = definitions[key];
28
+ if (!definition)
29
+ continue;
30
+ this.definitions.set(key, definition);
31
+ this.registerDialectSchema(key, definition);
32
+ }
33
+ }
34
+ /**
35
+ * 若方言支持按列声明类型处理(如 SQLite 读路径的 json 反序列化),向其注册表结构
36
+ */
37
+ registerDialectSchema(name, definition) {
38
+ const d = this.dialect;
39
+ if (typeof d.registerTableSchema === 'function') {
40
+ d.registerTableSchema(String(name), definition);
28
41
  }
29
42
  }
30
43
  // ============================================================================
@@ -126,6 +139,7 @@ export class Database {
126
139
  }
127
140
  define(name, definition) {
128
141
  this.definitions.set(name, definition);
142
+ this.registerDialectSchema(name, definition);
129
143
  }
130
144
  /**
131
145
  * 删除表定义
@@ -16,8 +16,16 @@ export declare class SQLiteDialect<S extends Record<string, object> = Record<str
16
16
  /** node:sqlite 仅支持 number/string/bigint/buffer/null;JSON 列在库内为 TEXT,对象需序列化 */
17
17
  private prepareBindParams;
18
18
  query<U = any>(sql: string, params?: any[]): Promise<U>;
19
+ /** 声明为 json 的列名集合(由 Database 层注册表结构时填充),读路径仅对这些列做 JSON 反序列化 */
20
+ private readonly jsonColumns;
19
21
  /**
20
- * 处理查询结果,移除字符串字段的多余引号
22
+ * 注册表的列声明类型,用于读路径按声明类型反序列化(避免对 TEXT 列做启发式 JSON 解析破坏往返)
23
+ */
24
+ registerTableSchema(table: string, definition: Record<string, {
25
+ type: string;
26
+ }>): void;
27
+ /**
28
+ * 处理查询结果,按列声明类型反序列化(json 列)
21
29
  */
22
30
  private processQueryResults;
23
31
  /**
@@ -25,7 +33,8 @@ export declare class SQLiteDialect<S extends Record<string, object> = Record<str
25
33
  */
26
34
  private processRowData;
27
35
  /**
28
- * 处理字段值,移除多余的引号并解析 JSON
36
+ * 处理字段值:仅对声明为 json 的列做 JSON 反序列化;
37
+ * 普通 TEXT 列原样返回,保证写入什么读出什么(不剥离引号、不启发式 JSON.parse)
29
38
  */
30
39
  private processFieldValue;
31
40
  dispose(): Promise<void>;
@@ -62,36 +62,42 @@ export class SQLiteDialect extends Dialect {
62
62
  if (!this.db)
63
63
  throw new Error('SQLite 未连接');
64
64
  const trimmedSql = sql.trim().toLowerCase();
65
- const isSelect = trimmedSql.startsWith('select');
66
65
  const isInsert = trimmedSql.startsWith('insert');
67
66
  const isUpdateOrDelete = trimmedSql.startsWith('update') || trimmedSql.startsWith('delete');
67
+ // 带 RETURNING 的 DML 有结果集,不能走 run 丢行
68
+ const hasReturning = /\breturning\b/.test(trimmedSql);
68
69
  const args = this.prepareBindParams(params ?? []);
69
- try {
70
- const stmt = this.db.prepare(sql);
71
- if (isSelect) {
72
- const rows = stmt.all(...args);
73
- return this.processQueryResults(rows);
74
- }
75
- if (isInsert) {
76
- const result = stmt.run(...args);
77
- return {
78
- lastID: Number(result.lastInsertRowid),
79
- changes: Number(result.changes),
80
- };
81
- }
82
- if (isUpdateOrDelete) {
83
- const result = stmt.run(...args);
84
- return Number(result.changes);
85
- }
86
- stmt.run(...args);
87
- return undefined;
70
+ const stmt = this.db.prepare(sql);
71
+ if (isInsert && !hasReturning) {
72
+ const result = stmt.run(...args);
73
+ return {
74
+ lastID: Number(result.lastInsertRowid),
75
+ changes: Number(result.changes),
76
+ };
88
77
  }
89
- catch (err) {
90
- throw err;
78
+ if (isUpdateOrDelete && !hasReturning) {
79
+ const result = stmt.run(...args);
80
+ return Number(result.changes);
81
+ }
82
+ // SELECT / WITH / PRAGMA / DDL / 带 RETURNING 的 DML:按可能有结果集处理。
83
+ // node:sqlite 的 all() 对无结果集语句返回空数组,不会抛错。
84
+ const rows = stmt.all(...args);
85
+ return this.processQueryResults(rows);
86
+ }
87
+ /** 声明为 json 的列名集合(由 Database 层注册表结构时填充),读路径仅对这些列做 JSON 反序列化 */
88
+ jsonColumns = new Set();
89
+ /**
90
+ * 注册表的列声明类型,用于读路径按声明类型反序列化(避免对 TEXT 列做启发式 JSON 解析破坏往返)
91
+ */
92
+ registerTableSchema(table, definition) {
93
+ for (const [columnName, column] of Object.entries(definition)) {
94
+ if (column && column.type === 'json') {
95
+ this.jsonColumns.add(columnName);
96
+ }
91
97
  }
92
98
  }
93
99
  /**
94
- * 处理查询结果,移除字符串字段的多余引号
100
+ * 处理查询结果,按列声明类型反序列化(json 列)
95
101
  */
96
102
  processQueryResults(data) {
97
103
  if (!data)
@@ -112,42 +118,26 @@ export class SQLiteDialect extends Dialect {
112
118
  return row;
113
119
  const processedRow = {};
114
120
  for (const [key, value] of Object.entries(row)) {
115
- processedRow[key] = this.processFieldValue(value);
121
+ processedRow[key] = this.processFieldValue(key, value);
116
122
  }
117
123
  return processedRow;
118
124
  }
119
125
  /**
120
- * 处理字段值,移除多余的引号并解析 JSON
126
+ * 处理字段值:仅对声明为 json 的列做 JSON 反序列化;
127
+ * 普通 TEXT 列原样返回,保证写入什么读出什么(不剥离引号、不启发式 JSON.parse)
121
128
  */
122
- processFieldValue(value) {
129
+ processFieldValue(key, value) {
123
130
  if (typeof value !== 'string')
124
131
  return value;
125
- // 移除字符串两端的引号(如果存在)
126
- if ((value.startsWith("'") && value.endsWith("'")) ||
127
- (value.startsWith('"') && value.endsWith('"'))) {
128
- const unquoted = value.slice(1, -1);
129
- // 尝试解析为 JSON(用于 json 类型字段)
130
- if (unquoted.startsWith('{') || unquoted.startsWith('[')) {
131
- try {
132
- return JSON.parse(unquoted);
133
- }
134
- catch {
135
- // 如果解析失败,返回去除引号的字符串
136
- return unquoted;
137
- }
138
- }
139
- return unquoted;
132
+ if (!this.jsonColumns.has(key))
133
+ return value;
134
+ try {
135
+ return JSON.parse(value);
140
136
  }
141
- // 尝试解析没有引号包裹但看起来像 JSON 的值(SQLite 驱动返回的 json 类型字段)
142
- if (value.startsWith('{') || value.startsWith('[')) {
143
- try {
144
- return JSON.parse(value);
145
- }
146
- catch {
147
- // 解析失败,返回原始字符串
148
- }
137
+ catch {
138
+ // 解析失败,返回原始字符串
139
+ return value;
149
140
  }
150
- return value;
151
141
  }
152
142
  async dispose() {
153
143
  if (this.db) {
package/lib/migration.js CHANGED
@@ -4,49 +4,65 @@
4
4
  */
5
5
  class RecordingMigrationContext {
6
6
  baseContext;
7
+ execute;
7
8
  _operations = [];
8
- constructor(baseContext) {
9
+ /**
10
+ * @param baseContext 实际执行的上下文
11
+ * @param execute 是否真正执行操作;false 时为 dry-run,仅记录操作不触碰数据库
12
+ */
13
+ constructor(baseContext, execute = true) {
9
14
  this.baseContext = baseContext;
15
+ this.execute = execute;
10
16
  }
11
17
  get operations() {
12
18
  return this._operations;
13
19
  }
14
20
  async createTable(tableName, columns) {
15
21
  this._operations.push({ type: 'createTable', tableName, columns });
16
- await this.baseContext.createTable(tableName, columns);
22
+ if (this.execute)
23
+ await this.baseContext.createTable(tableName, columns);
17
24
  }
18
25
  async dropTable(tableName) {
19
26
  this._operations.push({ type: 'dropTable', tableName });
20
- await this.baseContext.dropTable(tableName);
27
+ if (this.execute)
28
+ await this.baseContext.dropTable(tableName);
21
29
  }
22
30
  async addColumn(tableName, columnName, column) {
23
31
  this._operations.push({ type: 'addColumn', tableName, columnName, column });
24
- await this.baseContext.addColumn(tableName, columnName, column);
32
+ if (this.execute)
33
+ await this.baseContext.addColumn(tableName, columnName, column);
25
34
  }
26
35
  async dropColumn(tableName, columnName) {
27
36
  this._operations.push({ type: 'dropColumn', tableName, columnName });
28
- await this.baseContext.dropColumn(tableName, columnName);
37
+ if (this.execute)
38
+ await this.baseContext.dropColumn(tableName, columnName);
29
39
  }
30
40
  async modifyColumn(tableName, columnName, column) {
31
41
  // modifyColumn 不能自动反向,需要原始列定义
32
- await this.baseContext.modifyColumn(tableName, columnName, column);
42
+ if (this.execute)
43
+ await this.baseContext.modifyColumn(tableName, columnName, column);
33
44
  }
34
45
  async renameColumn(tableName, oldName, newName) {
35
46
  this._operations.push({ type: 'renameColumn', tableName, oldName, newName });
36
- await this.baseContext.renameColumn(tableName, oldName, newName);
47
+ if (this.execute)
48
+ await this.baseContext.renameColumn(tableName, oldName, newName);
37
49
  }
38
50
  async addIndex(tableName, indexName, columns, unique) {
39
51
  this._operations.push({ type: 'addIndex', tableName, indexName, columns, unique });
40
- await this.baseContext.addIndex(tableName, indexName, columns, unique);
52
+ if (this.execute)
53
+ await this.baseContext.addIndex(tableName, indexName, columns, unique);
41
54
  }
42
55
  async dropIndex(tableName, indexName) {
43
56
  this._operations.push({ type: 'dropIndex', tableName, indexName });
44
- await this.baseContext.dropIndex(tableName, indexName);
57
+ if (this.execute)
58
+ await this.baseContext.dropIndex(tableName, indexName);
45
59
  }
46
60
  async query(sql, params) {
47
61
  // query 操作记录但无法自动反向
48
62
  this._operations.push({ type: 'query', sql, params });
49
- return this.baseContext.query(sql, params);
63
+ if (this.execute)
64
+ return this.baseContext.query(sql, params);
65
+ return undefined;
50
66
  }
51
67
  }
52
68
  /**
@@ -333,12 +349,15 @@ export class MigrationRunner {
333
349
  const migrated = [];
334
350
  for (const migration of pending) {
335
351
  try {
336
- // 如果没有显式的 down,使用记录型上下文来记录操作
352
+ // 如果没有显式的 down,先 dry-run 记录操作并验证可以自动反向,通过后再真正执行
337
353
  if (!migration.down) {
354
+ const dryRunContext = new RecordingMigrationContext(baseContext, false);
355
+ await migration.up(dryRunContext);
356
+ // 提前校验操作可反向(不可反向时抛错,此时数据库尚未被修改)
357
+ generateReverseOperations(dryRunContext.operations);
358
+ // 校验通过,真正执行并记录操作
338
359
  const recordingContext = new RecordingMigrationContext(baseContext);
339
360
  await migration.up(recordingContext);
340
- // 验证操作可以反向(提前检查)
341
- generateReverseOperations(recordingContext.operations);
342
361
  await this.recordMigration(migration.name, batch, recordingContext.operations);
343
362
  }
344
363
  else {
@@ -438,8 +457,9 @@ function mapColumnType(type) {
438
457
  function formatDefault(value) {
439
458
  if (value === null)
440
459
  return 'NULL';
460
+ // 与各方言 escapeString 一致:单引号 doubling,避免默认值中的引号破坏 SQL
441
461
  if (typeof value === 'string')
442
- return `'${value}'`;
462
+ return `'${value.replace(/'/g, "''")}'`;
443
463
  if (typeof value === 'boolean')
444
464
  return value ? '1' : '0';
445
465
  return String(value);
@@ -400,7 +400,13 @@ export class RelatedDatabase extends Database {
400
400
  }
401
401
  else {
402
402
  const value = condition[key];
403
- if (value && typeof value === 'object' && !Array.isArray(value)) {
403
+ // 只有普通对象才视为操作符集合;Date 等带原型的对象按标量等值处理,避免条件被静默丢弃
404
+ const isPlainObject = value !== null &&
405
+ typeof value === 'object' &&
406
+ !Array.isArray(value) &&
407
+ !isSubquery(value) &&
408
+ (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
409
+ if (isPlainObject) {
404
410
  for (const op in value) {
405
411
  const quotedKey = formatField(key);
406
412
  const placeholder = this.dialect.getParameterPlaceholder(params.length);
@@ -476,6 +482,8 @@ export class RelatedDatabase extends Database {
476
482
  clauses.push(`${quotedKey} NOT LIKE ${placeholder}`);
477
483
  params.push(value[op]);
478
484
  break;
485
+ default:
486
+ throw new Error(`parseCondition: unknown operator "${op}" for field "${String(key)}"`);
479
487
  }
480
488
  }
481
489
  }
@@ -323,7 +323,8 @@ export class RelatedModel extends Model {
323
323
  const { table: pivotTable, foreignPivotKey, relatedPivotKey, pivotFields } = relation.pivot;
324
324
  const targetKey = relation.targetKey || 'id';
325
325
  // 查询中间表获取关联的目标ID
326
- const pivotRecords = await targetDb.query(`SELECT * FROM "${pivotTable}" WHERE "${foreignPivotKey}" = ?`, [localValue]);
326
+ const pivotDialect = targetDb.dialect;
327
+ const pivotRecords = await targetDb.query(`SELECT * FROM ${pivotDialect.quoteIdentifier(pivotTable)} WHERE ${pivotDialect.quoteIdentifier(foreignPivotKey)} = ${pivotDialect.getParameterPlaceholder(0)}`, [localValue]);
327
328
  if (pivotRecords.length === 0) {
328
329
  return [];
329
330
  }
@@ -420,8 +421,9 @@ export class RelatedModel extends Model {
420
421
  return;
421
422
  }
422
423
  // 批量查询中间表
423
- const placeholders = localValues.map(() => '?').join(', ');
424
- const pivotRecords = await targetDb.query(`SELECT * FROM "${pivotTable}" WHERE "${foreignPivotKey}" IN (${placeholders})`, localValues);
424
+ const batchDialect = targetDb.dialect;
425
+ const placeholders = localValues.map((_, i) => batchDialect.getParameterPlaceholder(i)).join(', ');
426
+ const pivotRecords = await targetDb.query(`SELECT * FROM ${batchDialect.quoteIdentifier(pivotTable)} WHERE ${batchDialect.quoteIdentifier(foreignPivotKey)} IN (${placeholders})`, localValues);
425
427
  if (pivotRecords.length === 0) {
426
428
  records.forEach(r => r[relationName] = []);
427
429
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/database",
3
- "version": "1.0.77",
3
+ "version": "1.0.79",
4
4
  "description": "Universal database abstraction layer for Zhin.js - supports SQLite, MySQL, PostgreSQL, MongoDB, Redis",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -20,7 +20,7 @@
20
20
  "node": ">=22.5.0"
21
21
  },
22
22
  "dependencies": {
23
- "@zhin.js/logger": "1.0.75"
23
+ "@zhin.js/logger": "1.0.76"
24
24
  },
25
25
  "peerDependencies": {
26
26
  "mongodb": ">=3.1.13",
@@ -43,9 +43,9 @@
43
43
  }
44
44
  },
45
45
  "devDependencies": {
46
- "@types/pg": "^8.15.0",
46
+ "@types/pg": "^8.20.0",
47
47
  "mongodb": "^7.5.0",
48
- "mysql2": "^3.22.6",
48
+ "mysql2": "^3.23.2",
49
49
  "pg": "^8.22.0",
50
50
  "redis": "latest",
51
51
  "typescript": "^6.0.3"