jsql-neo 6.5.25 → 6.5.28

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 CHANGED
@@ -3,7 +3,7 @@
3
3
  > **One engine to rule them all** — a Rust-powered embedded database that speaks your language:
4
4
  > MySQL, PostgreSQL, MongoDB, Redis, SQL, TypeScript, **and the browser**.
5
5
 
6
- > **v6.5.25** — stable release · [github.com/vexify-org/JSQL-neo](https://github.com/vexify-org/JSQL-neo) · [jsql.vexify.top](https://jsql.vexify.top)
6
+ > **v6.5.28** — stable release · [github.com/vexify-org/JSQL-neo](https://github.com/vexify-org/JSQL-neo) · [jsql.vexify.top](https://jsql.vexify.top)
7
7
 
8
8
  ![Engines](https://img.shields.io/badge/engines-Native%20%7C%20WASM%20%7C%20Pure%20JS-7ee787)
9
9
  ![MySQL](https://img.shields.io/badge/protocol-MySQL%20compatible-1f6feb)
@@ -171,6 +171,31 @@ await db.start();
171
171
 
172
172
  ---
173
173
 
174
+ ## Query results
175
+
176
+ `db.query()` / `executeSQL()` return **one result in three shapes**:
177
+
178
+ ```js
179
+ const r = await db.query('SELECT city, COUNT(*) AS c FROM users GROUP BY city');
180
+ r.columns // ['city', 'c']
181
+ r.rows // [['LA', 1], ['NY', 2]]
182
+ r.raw // [{ city: 'LA', c: 1 }, { city: 'NY', c: 2 }]
183
+ ```
184
+
185
+ `raw` is the object form of `rows`: its keys are exactly `columns`, and that
186
+ includes computed, aggregate and scalar-subquery columns. It is **not** the
187
+ pre-projection source row. For SELECT results `raw.length === rows.length`
188
+ always holds; non-SELECT statements may omit `raw`.
189
+
190
+ To check which native addon this installation is actually using:
191
+
192
+ - `jsql native status` — tag/version match (`0` = OK, `1` = mismatch or unverified)
193
+ - `jsql native verify` — re-hashes the `.node` on disk against the checksum recorded
194
+ at install time, so a swapped or corrupted binary is caught too. `--json` prints
195
+ a structured result for CI. States: `OK` / `MISMATCH` / `CORRUPT` / `MISSING` /
196
+ `UNVERIFIED` / `UNREADABLE`.
197
+ - `jsql native refresh` — re-fetches a checksum-verified addon
198
+
174
199
  ## ✨ XQL 集成
175
200
 
176
201
  用 [XQL](https://github.com/vexify-org/XQL) 查询 JSQL 表:把 SQL 直接接到文档上,
package/bin/jsql CHANGED
@@ -531,6 +531,38 @@ const cli = (yaggs || require('./yaggs-fallback').buildYaggsFallback(_pkg))({
531
531
  .command('update', 'Update jsql-neo to the latest version', updateOptions, updateHandler)
532
532
  .command('uninstall', 'Remove jsql user data and/or the global package', uninstallOptions, uninstallHandler)
533
533
  .command('remove', 'Alias of uninstall', uninstallOptions, uninstallHandler)
534
+ .command('native', 'Inspect or refresh the Rust native addon', (sub) => {
535
+ sub.option('json', { type: 'boolean', description: 'Machine-readable output (verify only)' });
536
+ }, async (argv) => {
537
+ const action = argv._[0] || 'status';
538
+ const pi = require('../postinstall.js');
539
+ if (action === 'verify') {
540
+ // 内容级:重算 .node 的 SHA256 与安装记录比对,status 只看 tag 发现不了替换/损坏
541
+ const r = pi.verifyNative();
542
+ if (argv.json) {
543
+ console.log(JSON.stringify(r, null, 2));
544
+ } else {
545
+ console.log(`[jsql-neo] VERIFY : ${r.state}`);
546
+ console.log(`[jsql-neo] tag : ${r.installedTag || 'unknown'} (expect ${r.expectedTag})`);
547
+ console.log(`[jsql-neo] sha256 : ${r.actualSha256 || 'n/a'}`);
548
+ if (r.state !== 'OK') console.log('[jsql-neo] refresh with: jsql native refresh');
549
+ }
550
+ process.exitCode = r.state === 'OK' ? 0 : 1;
551
+ return;
552
+ }
553
+ if (action === 'status') {
554
+ // 0 = 当前 .node 与包版本匹配;1 = 错配 / 未记录(可直接当 CI 门禁)
555
+ process.exitCode = pi.nativeStatus();
556
+ return;
557
+ }
558
+ if (action === 'refresh') {
559
+ await pi.ensureNativeNode();
560
+ process.exitCode = pi.nativeStatus();
561
+ return;
562
+ }
563
+ console.error(`Unknown native action: ${action}. Usage: jsql native <status|refresh|verify>`);
564
+ process.exitCode = 1;
565
+ })
534
566
  .command('version', 'Show version', null, () => {
535
567
  console.log(require('../package.json').version);
536
568
  })
package/index.d.ts CHANGED
@@ -272,9 +272,16 @@ declare namespace JSQLNeo {
272
272
  ok: boolean;
273
273
  type: string;
274
274
  table?: string | null;
275
+ /** 投影列名;rows[i][j] 与 columns[j] 一一对应。 */
275
276
  columns?: string[];
277
+ /** 投影后的二维数组形式。 */
276
278
  rows?: unknown[][];
277
- raw?: unknown[];
279
+ /**
280
+ * 与 rows 指向**同一份结果**的对象形式:raw[i] 的键恰好是 columns,
281
+ * 值取自 rows[i]。聚合 / 计算列 / 标量子查询都包含在内(issue #43)。
282
+ * 非 SELECT 语句可能没有此字段。
283
+ */
284
+ raw?: Array<Record<string, unknown>>;
278
285
  affectedRows?: number;
279
286
  insertId?: number | null;
280
287
  ids?: unknown[];
package/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * JSQL-NEO v6.5.25 — Rust-Powered Embedded Database (WASM + HTTP)
2
+ * JSQL-NEO v6.5.28 — Rust-Powered Embedded Database (WASM + HTTP)
3
3
  *
4
4
  * @example
5
5
  * const jsql = require('jsql-neo');
@@ -153,6 +153,10 @@ module.exports = {
153
153
  SQL: sql,
154
154
  executeSQL: sql.executeSQL,
155
155
  parseSQL: sql.parseSQL,
156
+ // Issue #46:index.d.ts 早就声明了这三个顶层导出,index.js 却一直没给
157
+ applyParams: sql.applyParams,
158
+ escapeValue: sql.escapeValue,
159
+ escapeId: sql.escapeId,
156
160
  // AST 访问与改写(5.5.0+)
157
161
  AST: sql.AST,
158
162
  walk: sql.walk,
package/lib/database.js CHANGED
@@ -1837,7 +1837,11 @@ class Database {
1837
1837
 
1838
1838
  _resolveId(table, id) {
1839
1839
  const pk = this._pkOf(table);
1840
- if (pk) return table._rows.find(r => r[pk] === id) || null;
1840
+ if (pk) {
1841
+ // Issue #44:主键查询值按列类型归一('5' → 5),与索引/insert 保持一致
1842
+ const key = typeof table._coerceQueryValue === 'function' ? table._coerceQueryValue(pk, id) : id;
1843
+ return table._rows.find(r => r[pk] === key || r[pk] === id) || null;
1844
+ }
1841
1845
  // 无主键表:id 是行对象(可能是 find() 返回的浅拷贝)时,用内容匹配代替引用比较。
1842
1846
  // 做法:逐行比对所有字段值是否相等;是数字时当 1-based 行号。
1843
1847
  if (id && typeof id === 'object') {
@@ -430,9 +430,16 @@ class MongoServer {
430
430
  for (const d of docs) {
431
431
  const doc2 = { ...d };
432
432
  // 检查 _id 唯一性
433
+ // Issue #44:索引查找是严格相等的,空结果不能直接当「不存在」——
434
+ // 查询值与已存值的类型不一致时会漏判。这里再加一道按 schema 归一的直接扫描。
433
435
  if (doc2._id !== undefined) {
434
436
  const existing = await engine.find(coll, { _id: doc2._id });
435
437
  if (existing.length > 0) continue; // 跳过重复 _id
438
+ const table = typeof engine._ensureTable === 'function' ? engine._ensureTable(coll) : null;
439
+ if (table && Array.isArray(table._rows) && typeof table._coerceQueryValue === 'function') {
440
+ const idKey = table._coerceQueryValue('_id', doc2._id);
441
+ if (table._rows.some((r) => r && !r._deleted && r._id === idKey)) continue;
442
+ }
436
443
  }
437
444
  await engine.insert(coll, doc2);
438
445
  ids.push(doc2._id);
@@ -568,10 +575,13 @@ class MongoServer {
568
575
  for (const [k, dir] of sortKeys) {
569
576
  const av = a[k]; const bv = b[k];
570
577
  if (av === bv) continue;
571
- if (av == null) return 1;
572
- if (bv == null) return -1;
578
+ const desc = Number(dir) < 0;
579
+ // Issue #47:NULL 的位置随排序方向走(升序在前、降序在后),
580
+ // 与 MongoDB 一致;此前固定返回 1/-1,升序时 null 恒排最后。
581
+ if (av == null) return desc ? 1 : -1;
582
+ if (bv == null) return desc ? -1 : 1;
573
583
  const cmp = av < bv ? -1 : 1;
574
- return Number(dir) < 0 ? -cmp : cmp;
584
+ return desc ? -cmp : cmp;
575
585
  }
576
586
  return 0;
577
587
  });
@@ -643,8 +653,14 @@ class MongoServer {
643
653
  const sum = grow.reduce((s, r) => s + (Number(r[field]) || 0), 0);
644
654
  doc[k] = (doc[k] || 0) + sum;
645
655
  } else if (v.$avg !== undefined) {
646
- const vals = grow.map(r => Number(r[field]) || 0);
647
- doc[k] = vals.reduce((a, b) => a + b, 0) / vals.length;
656
+ // Issue #45:null/undefined 不计入分子的同时也不计入分母;
657
+ // 全空组返回 null(MongoDB 语义),而不是 0。
658
+ const vals = grow
659
+ .map(r => r[field])
660
+ .filter(x => x !== null && x !== undefined)
661
+ .map(Number)
662
+ .filter(x => !Number.isNaN(x));
663
+ doc[k] = vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null;
648
664
  } else if (v.$min !== undefined) {
649
665
  const vals = grow.map(r => r[field]).filter(v => v != null);
650
666
  let best = null;
@@ -20,7 +20,7 @@ function toResultPacket(r, table, rowsAsArray) {
20
20
  const fields = columns.map(name => {
21
21
  let type = 253; // VAR_STRING
22
22
  const val = rawRows[0] ? rawRows[0][columns.indexOf(name)] : null;
23
- if (typeof val === 'number') type = 8; // LONGLONG
23
+ if (typeof val === 'number') type = Number.isInteger(val) ? 8 : 5; // LONGLONG / DOUBLE(Issue #48:浮点列曾被误报为 8)
24
24
  else if (typeof val === 'boolean') type = 1; // TINY
25
25
  else if (val instanceof Date) type = 12; // DATETIME
26
26
  else if (Buffer.isBuffer(val)) type = 252; // BLOB
package/lib/sql.js CHANGED
@@ -8263,6 +8263,31 @@ function buildResultEnvelope(stmt, r, durationMs) {
8263
8263
  : (typeof out.affectedRows === 'number' ? out.affectedRows : 0);
8264
8264
  }
8265
8265
  if (out.warnings === undefined) out.warnings = [];
8266
+ /*
8267
+ * result.raw 的原始契约:与 columns / rows 指向**同一份**结果,只是对象形式。
8268
+ *
8269
+ * 证据在 lib/prepared.js 的 get():raw 缺失时它会用 columns + rows[0]
8270
+ * 现场拼出等价对象 —— 说明 raw 本就该是「columns × rows 的 zip」。
8271
+ *
8272
+ * 但 executeSelect 的多个分支直接把「投影前的源行」塞进 raw
8273
+ * (rows: mapped, raw: rows)。于是聚合 / 计算列 / 无 FROM 查询里,
8274
+ * raw 变成 undefined、{_virtual:true},或是缺了计算列的源行对象
8275
+ * (GROUP BY 还会泄露内部 _group)。见 issue #43。
8276
+ *
8277
+ * 与其逐个分支改,不如在统一出口兑现契约:只要结果是 SELECT 且有
8278
+ * columns + rows,就从投影后的元组重建对象形式。非元组行保持原样。
8279
+ */
8280
+ if (t === 'select' && Array.isArray(out.columns) && Array.isArray(out.rows)) {
8281
+ const cols = out.columns;
8282
+ out.raw = out.rows.map((row, i) => {
8283
+ if (!Array.isArray(row)) {
8284
+ return (Array.isArray(out.raw) && out.raw[i] !== undefined) ? out.raw[i] : row;
8285
+ }
8286
+ const obj = {};
8287
+ for (let j = 0; j < cols.length; j++) obj[cols[j]] = row[j];
8288
+ return obj;
8289
+ });
8290
+ }
8266
8291
  if (out.durationMs === undefined) out.durationMs = durationMs;
8267
8292
  if (out.message === undefined) {
8268
8293
  out.message = t === 'select'
@@ -8312,6 +8337,57 @@ function finalizeBigInts(value, seen) {
8312
8337
  return value;
8313
8338
  }
8314
8339
 
8340
+ /**
8341
+ * SQLResult 专用的 BigInt 收尾 —— 保留语义,去掉 20 万次递归和 Set.add。
8342
+ *
8343
+ * 通用 finalizeBigInts 会对 rows(10 万个数组)+ raw(10 万个对象)逐个
8344
+ * 深递归,每进一个对象就 seen.add 一次。node --prof 显示光
8345
+ * SetPrototypeAdd 就占 18 ticks(约 14% JS),加上 finalizeBigInts 自身
8346
+ * 共 ~23% JS ticks,整体 33% 时间花在 GC 上。
8347
+ *
8348
+ * 而结果的形状是规则的:rows 是二维数组,raw 是对象数组,元素绝大多数是
8349
+ * number / string / null —— 根本不可能是 BigInt。用紧凑循环只做 typeof
8350
+ * 检查,遇到嵌套对象才退回通用实现。语义与深递归等价,但不再给每个
8351
+ * 标量行建 Set 条目。
8352
+ */
8353
+ function finalizeResultBigInts(r) {
8354
+ if (!r || typeof r !== 'object' || Array.isArray(r)) return finalizeBigInts(r);
8355
+
8356
+ if (Array.isArray(r.rows)) {
8357
+ const rows = r.rows;
8358
+ for (let i = 0; i < rows.length; i++) {
8359
+ const row = rows[i];
8360
+ if (!Array.isArray(row)) { rows[i] = finalizeBigInts(row); continue; }
8361
+ for (let j = 0; j < row.length; j++) {
8362
+ const v = row[j];
8363
+ if (typeof v === 'bigint') row[j] = v.toString();
8364
+ else if (v && typeof v === 'object') row[j] = finalizeBigInts(v);
8365
+ }
8366
+ }
8367
+ }
8368
+
8369
+ if (Array.isArray(r.raw)) {
8370
+ const raw = r.raw;
8371
+ for (let i = 0; i < raw.length; i++) {
8372
+ const obj = raw[i];
8373
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) continue;
8374
+ for (const k of Object.keys(obj)) {
8375
+ const v = obj[k];
8376
+ if (typeof v === 'bigint') obj[k] = v.toString();
8377
+ else if (v && typeof v === 'object') obj[k] = finalizeBigInts(v);
8378
+ }
8379
+ }
8380
+ }
8381
+
8382
+ // 其余字段(columns / insertId / ids / columnTypes / 元数据)都很小,
8383
+ // 走通用实现,保证不漏掉任何可能含 BigInt 的位置。
8384
+ for (const k of Object.keys(r)) {
8385
+ if (k === 'rows' || k === 'raw') continue;
8386
+ r[k] = finalizeBigInts(r[k]);
8387
+ }
8388
+ return r;
8389
+ }
8390
+
8315
8391
  async function executeSQL(engine, sql, paramsOrOpts, opts = {}) {
8316
8392
  // 参数守卫:engine 必须是引擎对象,sql 必须是字符串。误用时报清晰错误,
8317
8393
  // 而不是在后续像 `reading 'length'` 那样抛出令人费解的 TypeError。
@@ -8385,7 +8461,7 @@ async function executeSQL(engine, sql, paramsOrOpts, opts = {}) {
8385
8461
  let r = await executor.execute(stmt);
8386
8462
  const durationMs = Number(process.hrtime.bigint() - t0) / 1e6;
8387
8463
  r = buildResultEnvelope(stmt, r, Math.round(durationMs * 1000) / 1000);
8388
- r = finalizeBigInts(r);
8464
+ r = finalizeResultBigInts(r);
8389
8465
  if (r && r.columns && r.columnTypes === undefined) {
8390
8466
  r.columnTypes = await inferColumnTypes(engine, stmt, r);
8391
8467
  }
package/lib/table.js CHANGED
@@ -456,24 +456,25 @@ class Table {
456
456
 
457
457
  findById(id) {
458
458
  if (!this._primaryKey) throw createError('ER_PARSE_ERROR', 'Table has no primary key');
459
+ // Issue #44:字符串主键查询值先按列类型归一,否则索引静默未命中
460
+ const key = this._coerceQueryValue(this._primaryKey, id);
459
461
  // Hash 索引 O(1)
460
462
  if (this._pkIndex) {
461
- const rowIndex = this._pkIndex.get(id);
463
+ const rowIndex = this._pkIndex.get(key);
462
464
  if (rowIndex !== undefined) {
463
465
  const row = this._rows[rowIndex];
464
466
  if (row && !row._deleted) return { ...row };
465
467
  }
466
- return null;
467
- }
468
- if (this._btrees[this._primaryKey]) {
469
- const indices = this._btrees[this._primaryKey].search(id);
468
+ } else if (this._btrees[this._primaryKey]) {
469
+ const indices = this._btrees[this._primaryKey].search(key);
470
470
  if (indices.length > 0) {
471
471
  const row = this._rows[indices[0]];
472
472
  if (row && !row._deleted) return { ...row };
473
473
  }
474
- return null;
475
474
  }
476
- return this.findOne({ [this._primaryKey]: id });
475
+ // 索引未命中不等于不存在:回落主键列全表匹配(类型差异 / 索引未覆盖的行)
476
+ const row = this._rows.find(r => r && !r._deleted && r[this._primaryKey] === key);
477
+ return row ? { ...row } : null;
477
478
  }
478
479
 
479
480
  findAll() {
@@ -533,24 +534,25 @@ class Table {
533
534
  updates = hook({ [this._primaryKey]: id }, { ...updates }) || updates;
534
535
  }
535
536
  updates = this._validateDataTypes(updates, true);
537
+ const pkKey = this._coerceQueryValue(this._primaryKey, id);
536
538
  let rowIndex;
537
539
  if (this._pkIndex) {
538
- rowIndex = this._pkIndex.get(id);
539
- } else {
540
- if (this._btrees[this._primaryKey]) {
541
- const indices = this._btrees[this._primaryKey].search(id);
542
- rowIndex = indices.length > 0 ? indices[0] : undefined;
543
- } else {
544
- rowIndex = this._rows.findIndex(r => r[this._primaryKey] === id);
545
- if (rowIndex === -1) rowIndex = undefined;
546
- }
540
+ rowIndex = this._pkIndex.get(pkKey);
541
+ } else if (this._btrees[this._primaryKey]) {
542
+ const indices = this._btrees[this._primaryKey].search(pkKey);
543
+ rowIndex = indices.length > 0 ? indices[0] : undefined;
544
+ }
545
+ if (rowIndex === undefined) {
546
+ // Issue #44:索引未命中时回落全表匹配
547
+ const found = this._rows.findIndex(r => r && !r._deleted && r[this._primaryKey] === pkKey);
548
+ if (found !== -1) rowIndex = found;
547
549
  }
548
550
  if (rowIndex === undefined) return 0;
549
551
  const row = this._rows[rowIndex];
550
552
  if (row && row._deleted) return 0;
551
553
  // P1-3:主键被更新时传播到外键引用(CASCADE / SET NULL / RESTRICT)
552
- if (updates[this._primaryKey] !== undefined && updates[this._primaryKey] !== id) {
553
- this._propagatePKUpdate(id, updates[this._primaryKey]);
554
+ if (updates[this._primaryKey] !== undefined && updates[this._primaryKey] !== pkKey) {
555
+ this._propagatePKUpdate(pkKey, updates[this._primaryKey]);
554
556
  }
555
557
  const oldData = { ...row };
556
558
  const merged = { ...row, ...updates };
@@ -633,17 +635,19 @@ class Table {
633
635
 
634
636
  removeById(id) {
635
637
  if (!this._primaryKey) throw createError('ER_PARSE_ERROR', 'Table has no primary key');
638
+ // Issue #44:同 findById,先按列类型归一查询值
639
+ const pkKey = this._coerceQueryValue(this._primaryKey, id);
636
640
  let rowIndex;
637
641
  if (this._pkIndex) {
638
- rowIndex = this._pkIndex.get(id);
639
- } else {
640
- if (this._btrees[this._primaryKey]) {
641
- const indices = this._btrees[this._primaryKey].search(id);
642
- rowIndex = indices.length > 0 ? indices[0] : undefined;
643
- } else {
644
- rowIndex = this._rows.findIndex(r => r[this._primaryKey] === id);
645
- if (rowIndex === -1) rowIndex = undefined;
646
- }
642
+ rowIndex = this._pkIndex.get(pkKey);
643
+ } else if (this._btrees[this._primaryKey]) {
644
+ const indices = this._btrees[this._primaryKey].search(pkKey);
645
+ rowIndex = indices.length > 0 ? indices[0] : undefined;
646
+ }
647
+ if (rowIndex === undefined) {
648
+ // Issue #44:索引未命中时回落全表匹配
649
+ const found = this._rows.findIndex(r => r && !r._deleted && r[this._primaryKey] === pkKey);
650
+ if (found !== -1) rowIndex = found;
647
651
  }
648
652
  if (rowIndex === undefined) return 0;
649
653
  const row = this._rows[rowIndex];
@@ -1037,32 +1041,129 @@ class Table {
1037
1041
  return end > 0 ? pattern.slice(0, end) : null;
1038
1042
  }
1039
1043
 
1040
- _applyFilterOptimized(rows, query) {
1044
+ /**
1045
+ * Issue #44:按列声明类型归一查询值。
1046
+ *
1047
+ * 索引(B-Tree / hash)命中用的是严格 ===,所以字符串 '5' 永远搜不到
1048
+ * INT 列里的数字 5,表现为「静默返回空」;SQL 的 WHERE 路径早就在
1049
+ * 解析阶段强转过了,文档 API 这条路径没有。
1050
+ *
1051
+ * 归一结果与 insert 时 _validateDataTypes 的产物一致(整数 → number/BigInt、
1052
+ * 浮点 → number、布尔 → boolean、字符串列 → string)。无法归一的值原样返回。
1053
+ */
1054
+ _coerceQueryValue(field, value) {
1055
+ const def = this._schema && this._schema[field];
1056
+ if (!def || typeof def === 'string' || value === null || value === undefined) return value;
1057
+ const t = String(def.type || '').toLowerCase();
1058
+ if (t === 'integer' || t === 'int' || t === 'bigint') {
1059
+ if (typeof value === 'bigint') {
1060
+ return (value >= SAFE_INT_MIN && value <= SAFE_INT_MAX) ? Number(value) : value;
1061
+ }
1062
+ if (typeof value === 'number') return value;
1063
+ if (typeof value === 'string' && DECIMAL_INT_RE.test(value.trim())) {
1064
+ try { return normalizeInteger(value.trim(), t); } catch (_) { return value; }
1065
+ }
1066
+ return value;
1067
+ }
1068
+ if (t === 'float' || t === 'double' || t === 'number' || t === 'real') {
1069
+ if (typeof value === 'number') return value;
1070
+ if (typeof value === 'string' && value.trim() !== '' && !Number.isNaN(Number(value))) return Number(value);
1071
+ return value;
1072
+ }
1073
+ if (t === 'boolean' || t === 'bool') {
1074
+ if (typeof value === 'boolean') return value;
1075
+ if (value === 0 || value === 1) return !!value;
1076
+ if (typeof value === 'string' && ['true', 'false', '1', '0'].includes(value.toLowerCase())) {
1077
+ return value.toLowerCase() === 'true' || value === '1';
1078
+ }
1079
+ return value;
1080
+ }
1081
+ if (t === 'string' || t === 'text' || t === 'varchar' || t === 'char') {
1082
+ if (typeof value === 'string') return value;
1083
+ if (typeof value === 'number' || typeof value === 'boolean' || value instanceof Date) return String(value);
1084
+ return value;
1085
+ }
1086
+ return value;
1087
+ }
1088
+
1089
+ /** 按 schema 归一 query 中的等值 / $eq / $in / 范围值(Issue #44)。 */
1090
+ _coerceQuery(query) {
1091
+ let out = null;
1092
+ const set = (field, value) => {
1093
+ if (!out) out = { ...query };
1094
+ out[field] = value;
1095
+ };
1041
1096
  for (const [field, condition] of Object.entries(query)) {
1097
+ if (field === '$or' || field === '$and') {
1098
+ if (Array.isArray(condition)) {
1099
+ const subs = condition.map(c => (c && typeof c === 'object' && !Array.isArray(c)) ? this._coerceQuery(c) : c);
1100
+ if (subs.some((s, i) => s !== condition[i])) set(field, subs);
1101
+ }
1102
+ continue;
1103
+ }
1104
+ if (field === '$not' || field[0] === '$' || !this._schema[field]) continue;
1105
+ const next = this._coerceCondition(field, condition);
1106
+ if (next !== condition) set(field, next);
1107
+ }
1108
+ return out || query;
1109
+ }
1110
+
1111
+ _coerceCondition(field, condition) {
1112
+ if (condition === null || typeof condition !== 'object' || condition instanceof RegExp) {
1113
+ return this._coerceQueryValue(field, condition);
1114
+ }
1115
+ if (Array.isArray(condition)) {
1116
+ const next = condition.map(v => this._coerceQueryValue(field, v));
1117
+ return next.some((v, i) => v !== condition[i]) ? next : condition;
1118
+ }
1119
+ const keys = Object.keys(condition);
1120
+ if (keys.length === 0 || !keys.every(k => k[0] === '$')) return condition;
1121
+ let copy = null;
1122
+ for (const [op, val] of Object.entries(condition)) {
1123
+ let next = val;
1124
+ if (op === '$eq' || op === '$ne' || op === '$gt' || op === '$gte' || op === '$lt' || op === '$lte') {
1125
+ next = this._coerceQueryValue(field, val);
1126
+ } else if ((op === '$in' || op === '$nin' || op === '$between') && Array.isArray(val)) {
1127
+ next = val.map(v => this._coerceQueryValue(field, v));
1128
+ }
1129
+ if (next !== val) {
1130
+ if (!copy) copy = { ...condition };
1131
+ copy[op] = next;
1132
+ }
1133
+ }
1134
+ return copy || condition;
1135
+ }
1136
+
1137
+ _applyFilterOptimized(rows, query) {
1138
+ // Issue #44:索引查找前先把查询值归一成列声明类型,避免 '5' 查不到 5
1139
+ const q = this._coerceQuery(query);
1140
+ for (const [field, condition] of Object.entries(q)) {
1042
1141
  if (field === '$or' || field === '$and' || field === '$not') continue;
1043
1142
  if (typeof condition !== 'object' || condition === null || Array.isArray(condition) || condition instanceof RegExp) {
1044
1143
  if (this._btrees[field]) {
1045
1144
  const indices = this._btrees[field].search(condition);
1046
- if (indices.length === 0) return [];
1047
- return this._applyFilter(this._rowsFromIndices(indices), query);
1145
+ // #44:索引未命中时回落全表过滤——索引键是严格相等的,
1146
+ // 类型/形态不一致的行会被漏掉,不能直接断言「没有数据」。
1147
+ if (indices.length === 0) return this._applyFilter(rows, q);
1148
+ return this._applyFilter(this._rowsFromIndices(indices), q);
1048
1149
  }
1049
1150
  if (this._indexes[field] && this._indexes[field].has(condition)) {
1050
- return this._applyFilter(this._rowsFromIndices(this._indexes[field].get(condition)), query);
1151
+ return this._applyFilter(this._rowsFromIndices(this._indexes[field].get(condition)), q);
1051
1152
  }
1052
- if (this._indexes[field]) return [];
1153
+ if (this._indexes[field]) return this._applyFilter(rows, q);
1053
1154
  }
1054
1155
  if (condition && typeof condition === 'object') {
1055
1156
  if (this._btrees[field] && Array.isArray(condition.$in) && condition.$in.length > 0) {
1056
1157
  const indices = this._btrees[field].searchMany(condition.$in);
1057
- if (indices.length === 0) return [];
1058
- return this._applyFilter(this._rowsFromIndices(indices), query);
1158
+ if (indices.length === 0) return this._applyFilter(rows, q);
1159
+ return this._applyFilter(this._rowsFromIndices(indices), q);
1059
1160
  }
1060
1161
  if (this._btrees[field] && typeof condition.$like === 'string') {
1061
1162
  const prefix = this._likePrefix(condition.$like);
1062
1163
  if (prefix) {
1063
1164
  const indices = this._btrees[field].prefix(prefix);
1064
- if (indices.length === 0) return [];
1065
- return this._applyFilter(this._rowsFromIndices(indices), query);
1165
+ if (indices.length === 0) return this._applyFilter(rows, q);
1166
+ return this._applyFilter(this._rowsFromIndices(indices), q);
1066
1167
  }
1067
1168
  }
1068
1169
  if (this._btrees[field] && (condition.$gt !== undefined || condition.$gte !== undefined ||
@@ -1086,11 +1187,11 @@ class Table {
1086
1187
  } else {
1087
1188
  indices = exclusiveMax ? this._btrees[field].lessThan(max) : this._btrees[field].lessThanEqual(max);
1088
1189
  }
1089
- return this._applyFilter(this._rowsFromIndices(indices), query);
1190
+ return this._applyFilter(this._rowsFromIndices(indices), q);
1090
1191
  }
1091
1192
  }
1092
1193
  }
1093
- return this._applyFilter(rows, query);
1194
+ return this._applyFilter(rows, q);
1094
1195
  }
1095
1196
 
1096
1197
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsql-neo",
3
- "version": "6.5.25",
3
+ "version": "6.5.28",
4
4
  "description": "JSQL-NEO \u2014 Rust-powered embedded database with WASM, REST API, B-Tree indexes, WAL, crash recovery",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -50,12 +50,12 @@
50
50
  "scripts": {
51
51
  "postinstall": "node postinstall.js",
52
52
  "build:native": "node scripts/build-native.js",
53
- "test": "node test/smoke.js && node test/native.test.js && node test/json-functions.test.js && node test/sql-parser.test.js && node test/readme-audit.test.js && node test/query-opt.test.js && node test/wal-recovery.test.js && node test/mysql-compat-hijack.test.js && node test/dml-constraints.test.js && node test/star-nag.test.js && node test/plugin-xql.test.js && node test/bug-regression-2.test.js && node test/issue-34-35-36.test.js && node test/issue-g4.test.js && node test/issue-38.test.js && node test/issue-39.test.js && node test/issue-40.test.js",
53
+ "test": "node test/smoke.js && node test/result-raw.test.js && node test/native-install.test.js && node test/native-cli.test.js && node test/finalize-bigint.test.js && node test/native.test.js && node test/json-functions.test.js && node test/sql-parser.test.js && node test/readme-audit.test.js && node test/query-opt.test.js && node test/wal-recovery.test.js && node test/mysql-compat-hijack.test.js && node test/dml-constraints.test.js && node test/star-nag.test.js && node test/plugin-xql.test.js && node test/bug-regression-2.test.js && node test/issue-34-35-36.test.js && node test/issue-g4.test.js && node test/issue-38.test.js && node test/issue-39.test.js && node test/issue-40.test.js && node test/issue-44.test.js && node test/issue-45.test.js && node test/issue-46.test.js && node test/issue-47.test.js && node test/issue-48.test.js",
54
54
  "test:parser": "node test/sql-parser.test.js",
55
55
  "test:btree": "node test/btree.test.js",
56
56
  "test:native": "node test/native.test.js",
57
57
  "test:regress": "node test/regress-5.1.0.js",
58
- "test:all": "node test/smoke.js && node test/sql-parser.test.js && node test/readme-audit.test.js && node test/native.test.js && node test/native-multi-instance.test.js && node test/wal-recovery.test.js && node test/mysql-compat-hijack.test.js && node test/wasm.test.js && node test/coverage.js && node test/regress-5.1.0.js && node test/join.test.js && node test/btree.test.js && node test/dml-constraints.test.js && node test/savepoint.test.js && node test/alter-table.test.js && node test/null-semantics.test.js && node test/identifier-keywords.test.js && node test/issue-34-35-36.test.js && node test/sql-semantics.test.js && node test/extra-coverage.test.js && node test/filter-compile.test.js && node test/recursive-cte.test.js && node test/index-ddl.test.js && node test/mongo-group.test.js && node test/multiprotocol.test.js && node test/issue-g4.test.js && node test/issue-38.test.js && node test/issue-39.test.js && node test/issue-40.test.js",
58
+ "test:all": "node test/smoke.js && node test/result-raw.test.js && node test/native-install.test.js && node test/native-cli.test.js && node test/finalize-bigint.test.js && node test/sql-parser.test.js && node test/readme-audit.test.js && node test/native.test.js && node test/native-multi-instance.test.js && node test/wal-recovery.test.js && node test/mysql-compat-hijack.test.js && node test/wasm.test.js && node test/coverage.js && node test/regress-5.1.0.js && node test/join.test.js && node test/btree.test.js && node test/dml-constraints.test.js && node test/savepoint.test.js && node test/alter-table.test.js && node test/null-semantics.test.js && node test/identifier-keywords.test.js && node test/issue-34-35-36.test.js && node test/sql-semantics.test.js && node test/extra-coverage.test.js && node test/filter-compile.test.js && node test/recursive-cte.test.js && node test/index-ddl.test.js && node test/mongo-group.test.js && node test/multiprotocol.test.js && node test/issue-g4.test.js && node test/issue-38.test.js && node test/issue-39.test.js && node test/issue-40.test.js && node test/issue-44.test.js && node test/issue-45.test.js && node test/issue-46.test.js && node test/issue-47.test.js && node test/issue-48.test.js",
59
59
  "test:coverage": "c8 --all --include='lib/**' --exclude='lib/web_ui.js' --reporter=text --reporter=lcov node test/coverage.js",
60
60
  "test:orms": "node examples/orms/run-all.js",
61
61
  "test:dml": "node test/dml-constraints.test.js",