jsql-neo 5.1.1 → 5.1.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/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * JSQL-NEO v5.1.1 — Rust-Powered Embedded Database (WASM + HTTP)
2
+ * JSQL-NEO v5.1.2 — Rust-Powered Embedded Database (WASM + HTTP)
3
3
  *
4
4
  * @example
5
5
  * const jsql = require('jsql-neo');
package/lib/btree.js CHANGED
@@ -24,6 +24,7 @@ class BTree {
24
24
  this._unique = unique;
25
25
  this._root = new BTreeNode();
26
26
  this._size = 0;
27
+ this._minKeys = Math.floor(order / 2) - 1;
27
28
  this._minDegree = Math.ceil(order / 2);
28
29
  }
29
30
 
@@ -50,14 +51,17 @@ class BTree {
50
51
  }
51
52
 
52
53
  _insertNonFull(node, key, rowIndex) {
53
- let i = node.keys.length - 1;
54
-
55
54
  if (node.leaf) {
56
55
  // 查找插入位置
56
+ let i = node.keys.length - 1;
57
57
  while (i >= 0 && key < node.keys[i]) i--;
58
58
  i++;
59
59
 
60
60
  if (i > 0 && node.keys[i - 1] === key) {
61
+ if (this._unique) {
62
+ this._size--;
63
+ return;
64
+ }
61
65
  if (!node.values[i - 1].includes(rowIndex)) {
62
66
  node.values[i - 1].push(rowIndex);
63
67
  }
@@ -68,12 +72,10 @@ class BTree {
68
72
  node.keys.splice(i, 0, key);
69
73
  node.values.splice(i, 0, [rowIndex]);
70
74
  } else {
71
- while (i >= 0 && key < node.keys[i]) i--;
72
- i++;
73
-
75
+ let i = this._route(node, key);
74
76
  if (node.children[i] && node.children[i].keys.length === this._order - 1) {
75
77
  this._splitChild(node, i);
76
- if (key >= node.keys[i] && i + 1 < node.children.length) i++;
78
+ i = this._route(node, key);
77
79
  }
78
80
  this._insertNonFull(node.children[i], key, rowIndex);
79
81
  }
@@ -84,23 +86,22 @@ class BTree {
84
86
  const newChild = new BTreeNode(child.leaf);
85
87
  const mid = Math.floor((this._order - 1) / 2);
86
88
 
87
- // 右半部分移到新节点
88
- newChild.keys = child.keys.splice(mid + 1);
89
- newChild.values = child.values.splice(mid + 1);
90
-
91
- // 中间键提升到父节点
92
89
  const midKey = child.keys[mid];
93
90
  const midVal = child.values[mid];
94
91
 
95
- // 非叶子节点:移动子节点
96
- if (!child.leaf) {
97
- newChild.children = child.children.splice(mid + 1);
98
- }
99
-
100
- // 叶子节点:维护链表
101
92
  if (child.leaf) {
93
+ // 叶子:右半移到新节点;分隔键保留在左叶(它是真实数据键),父节点只存副本
94
+ newChild.keys = child.keys.splice(mid + 1);
95
+ newChild.values = child.values.splice(mid + 1);
102
96
  newChild.next = child.next;
103
97
  child.next = newChild;
98
+ } else {
99
+ // 内部:分隔键提升到父节点并从子节点移除,保持 children = keys + 1
100
+ newChild.keys = child.keys.splice(mid + 1);
101
+ newChild.values = child.values.splice(mid + 1);
102
+ child.keys.splice(mid, 1);
103
+ child.values.splice(mid, 1);
104
+ newChild.children = child.children.splice(mid + 1);
104
105
  }
105
106
 
106
107
  parent.keys.splice(index, 0, midKey);
@@ -113,23 +114,28 @@ class BTree {
113
114
  // ============================================================
114
115
 
115
116
  /**
116
- * 精确查找
117
- * @returns {number[]} 行号数组
117
+ * 内部节点路由:返回 key 应下降到的子节点下标。
118
+ * 命中分隔键副本时,根据左子树最大键判定真实数据所在子树
119
+ * (分隔键副本可能在左子树作为最大值,或在右子树作为最小值)。
120
+ */
121
+ _route(node, key) {
122
+ let i = 0;
123
+ while (i < node.keys.length && key > node.keys[i]) i++;
124
+ if (i < node.keys.length && node.keys[i] === key) {
125
+ return this._getMax(node.children[i]).key === key ? i : i + 1;
126
+ }
127
+ return i;
128
+ }
129
+
130
+ /**
131
+ * 精确查找(始终下探到叶子取真实数据;内部分隔键只是叶子的副本,仅用于导航)
132
+ * @returns {number[]} 行号数组;未找到时返回空数组
118
133
  */
119
134
  search(key) {
120
135
  let node = this._root;
121
- while (node) {
122
- let i = 0;
123
- while (i < node.keys.length && key > node.keys[i]) i++;
124
-
125
- if (i < node.keys.length && key === node.keys[i]) {
126
- return node.values[i];
127
- }
128
-
129
- if (node.leaf) break;
130
- node = node.children[i];
131
- }
132
- return [];
136
+ while (!node.leaf) node = node.children[this._route(node, key)];
137
+ const i = node.keys.indexOf(key);
138
+ return i === -1 ? [] : node.values[i];
133
139
  }
134
140
 
135
141
  /**
@@ -209,58 +215,85 @@ class BTree {
209
215
  }
210
216
 
211
217
  _removeFromNode(node, key, rowIndex) {
212
- let i = 0;
213
- while (i < node.keys.length && key > node.keys[i]) i++;
214
-
215
218
  if (node.leaf) {
216
- if (i < node.keys.length && node.keys[i] === key) {
217
- const vals = node.values[i].filter(v => v !== rowIndex);
218
- if (vals.length === 0) {
219
- node.keys.splice(i, 1);
220
- node.values.splice(i, 1);
221
- } else {
222
- node.values[i] = vals;
223
- }
219
+ const i = node.keys.indexOf(key);
220
+ if (i === -1) return false;
221
+ if (!node.values[i].includes(rowIndex)) return false;
222
+ const vals = node.values[i].filter(v => v !== rowIndex);
223
+ if (vals.length === 0) {
224
+ node.keys.splice(i, 1);
225
+ node.values.splice(i, 1);
224
226
  return true;
225
227
  }
228
+ node.values[i] = vals;
226
229
  return false;
227
230
  }
228
231
 
229
- if (i < node.keys.length && node.keys[i] === key) {
230
- return this._removeFromInternal(node, i, rowIndex);
231
- }
232
+ let i = this._route(node, key);
232
233
 
234
+ // 下降前确保子节点有足够键,避免欠满子节点
235
+ const child = node.children[i];
236
+ if (child.keys.length <= this._minKeys) {
237
+ i = this._rebalanceChild(node, i);
238
+ }
233
239
  return this._removeFromNode(node.children[i], key, rowIndex);
234
240
  }
235
241
 
236
- _removeFromInternal(node, index, rowIndex) {
237
- const vals = node.values[index].filter(v => v !== rowIndex);
238
- if (vals.length > 0) {
239
- node.values[index] = vals;
240
- return true;
242
+ _rebalanceChild(parent, i) {
243
+ if (i > 0 && parent.children[i - 1].keys.length > this._minKeys) {
244
+ this._borrowFromLeft(parent, i);
245
+ return i;
241
246
  }
242
-
243
- const leftChild = node.children[index];
244
- if (leftChild.keys.length >= this._minDegree) {
245
- const pred = this._getMax(leftChild);
246
- node.keys[index] = pred.key;
247
- node.values[index] = pred.value;
248
- this._removeFromNode(leftChild, pred.key, rowIndex);
249
- return true;
247
+ if (i + 1 < parent.children.length && parent.children[i + 1].keys.length > this._minKeys) {
248
+ this._borrowFromRight(parent, i);
249
+ return i;
250
+ }
251
+ if (i > 0) {
252
+ this._mergeChildren(parent, i - 1);
253
+ return i - 1;
250
254
  }
255
+ this._mergeChildren(parent, i);
256
+ return i;
257
+ }
251
258
 
252
- const rightChild = node.children[index + 1];
253
- if (rightChild.keys.length >= this._minDegree) {
254
- const succ = this._getMin(rightChild);
255
- node.keys[index] = succ.key;
256
- node.values[index] = succ.value;
257
- this._removeFromNode(rightChild, succ.key, rowIndex);
258
- return true;
259
+ _borrowFromLeft(parent, i) {
260
+ const left = parent.children[i - 1];
261
+ const right = parent.children[i];
262
+ if (right.leaf) {
263
+ // 叶子节点:分隔键是叶子的副本,用被移动的键而非旧分隔键,避免重复
264
+ const k = left.keys.pop();
265
+ const v = left.values.pop();
266
+ right.keys.unshift(k);
267
+ right.values.unshift(v);
268
+ parent.keys[i - 1] = k;
269
+ parent.values[i - 1] = v;
270
+ } else {
271
+ right.keys.unshift(parent.keys[i - 1]);
272
+ right.values.unshift(parent.values[i - 1]);
273
+ parent.keys[i - 1] = left.keys.pop();
274
+ parent.values[i - 1] = left.values.pop();
275
+ right.children.unshift(left.children.pop());
259
276
  }
277
+ }
260
278
 
261
- const origKey = node.keys[index];
262
- this._mergeChildren(node, index);
263
- return this._removeFromNode(node.children[index], origKey, rowIndex);
279
+ _borrowFromRight(parent, i) {
280
+ const left = parent.children[i];
281
+ const right = parent.children[i + 1];
282
+ if (left.leaf) {
283
+ // 叶子节点:用被移动的键而非旧分隔键,避免重复
284
+ const k = right.keys.shift();
285
+ const v = right.values.shift();
286
+ left.keys.push(k);
287
+ left.values.push(v);
288
+ parent.keys[i] = k;
289
+ parent.values[i] = v;
290
+ } else {
291
+ left.keys.push(parent.keys[i]);
292
+ left.values.push(parent.values[i]);
293
+ parent.keys[i] = right.keys.shift();
294
+ parent.values[i] = right.values.shift();
295
+ left.children.push(right.children.shift());
296
+ }
264
297
  }
265
298
 
266
299
  _getMax(node) {
@@ -269,19 +302,17 @@ class BTree {
269
302
  return { key: node.keys[i], value: node.values[i] };
270
303
  }
271
304
 
272
- _getMin(node) {
273
- while (!node.leaf) node = node.children[0];
274
- return { key: node.keys[0], value: node.values[0] };
275
- }
276
-
277
305
  _mergeChildren(parent, index) {
278
306
  const left = parent.children[index];
279
307
  const right = parent.children[index + 1];
280
308
  const midKey = parent.keys[index];
281
309
  const midVal = parent.values[index];
282
310
 
283
- left.keys.push(midKey);
284
- left.values.push(midVal);
311
+ // 叶子节点:分隔键是叶子的副本(已在 left 或 right 中),不能重复压入
312
+ if (!left.leaf) {
313
+ left.keys.push(midKey);
314
+ left.values.push(midVal);
315
+ }
285
316
  left.keys.push(...right.keys);
286
317
  left.values.push(...right.values);
287
318
  if (!left.leaf) left.children.push(...right.children);
@@ -297,26 +328,19 @@ class BTree {
297
328
  // ============================================================
298
329
 
299
330
  /**
300
- * 获取所有索引条目
331
+ * 获取所有索引条目(仅叶子数据,分隔键为内部副本,不重复计入)
301
332
  */
302
333
  entries() {
303
334
  const result = [];
304
- this._traverse(this._root, result);
305
- return result;
306
- }
307
-
308
- _traverse(node, result) {
309
- if (node.leaf) {
310
- for (let i = 0; i < node.keys.length; i++) {
311
- result.push({ key: node.keys[i], value: node.values[i] });
312
- }
313
- } else {
335
+ let node = this._root;
336
+ while (node && !node.leaf) node = node.children[0];
337
+ while (node) {
314
338
  for (let i = 0; i < node.keys.length; i++) {
315
- this._traverse(node.children[i], result);
316
339
  result.push({ key: node.keys[i], value: node.values[i] });
317
340
  }
318
- this._traverse(node.children[node.children.length - 1], result);
341
+ node = node.next;
319
342
  }
343
+ return result;
320
344
  }
321
345
 
322
346
  /**
package/lib/database.js CHANGED
@@ -20,13 +20,18 @@ const JSQLFormat = require('./jsql_format');
20
20
  */
21
21
  function parseFieldShorthand(str) {
22
22
  const def = { type: str };
23
- if (/\bprimary\s+key\b/.test(str)) { def.primaryKey = true; def.type = str.replace(/\bprimary\s+key\b/g, '').trim() || def.type; }
24
- if (/\bauto_?increment\b/.test(str)) { def.autoIncrement = true; def.type = str.replace(/\bauto_?increment\b/g, '').trim() || def.type; }
25
- if (/\bnot\s+null\b/.test(str)) { def.required = true; def.type = str.replace(/\bnot\s+null\b/g, '').trim() || def.type; }
26
- if (/\bunique\b/.test(str)) { def.unique = true; def.type = str.replace(/\bunique\b/g, '').trim() || def.type; }
27
- if (/\bdefault\s+(\S+)/.test(str)) {
28
- def.default = RegExp.$1.replace(/^['"]|['"]$/g, '');
29
- def.type = str.replace(/\bdefault\s+\S+/g, '').trim() || def.type;
23
+ const strip = (re) => {
24
+ const next = def.type.replace(re, '').trim();
25
+ def.type = next || def.type;
26
+ };
27
+ if (/\bprimary\s+key\b/i.test(def.type)) { def.primaryKey = true; strip(/\bprimary\s+key\b/gi); }
28
+ if (/\bauto_?increment\b/i.test(def.type)) { def.autoIncrement = true; strip(/\bauto_?increment\b/gi); }
29
+ if (/\bnot\s+null\b/i.test(def.type)) { def.required = true; strip(/\bnot\s+null\b/gi); }
30
+ if (/\bunique\b/i.test(def.type)) { def.unique = true; strip(/\bunique\b/gi); }
31
+ if (/\bdefault\s+(\S+)/i.test(def.type)) {
32
+ const m = def.type.match(/\bdefault\s+(\S+)/i);
33
+ def.default = m[1].replace(/^['"]|['"]$/g, '');
34
+ strip(/\bdefault\s+\S+/gi);
30
35
  }
31
36
  return def;
32
37
  }
package/lib/query.js CHANGED
@@ -537,7 +537,7 @@ class Query {
537
537
  const key = fr[join.foreignField];
538
538
  const matches = localRows.filter(lr => lr[join.localField] === key);
539
539
  if (matches.length === 0) {
540
- result.push({ ...fr, ...this._nullRow(join, true) });
540
+ result.push(this._rightNullRow(localRows, fr, join));
541
541
  } else {
542
542
  for (const lr of matches) {
543
543
  result.push(this._mergeJoinRow(lr, fr, join));
@@ -582,7 +582,7 @@ class Query {
582
582
  for (const fr of foreignRows) {
583
583
  const matches = localRows.filter(lr => lr[join.localField] === fr[join.foreignField]);
584
584
  if (matches.length === 0) {
585
- result.push({ ...fr, ...this._nullRow(join, true) });
585
+ result.push(this._rightNullRow(localRows, fr, join));
586
586
  } else {
587
587
  for (const lr of matches) {
588
588
  result.push(this._mergeJoinRow(lr, fr, join));
@@ -621,6 +621,24 @@ class Query {
621
621
  return nulls;
622
622
  }
623
623
 
624
+ /**
625
+ * RIGHT JOIN 未匹配右表行:保留右表数据,本地表字段填 null。
626
+ * 返回 { localNulls, merged },其中 merged = { ...nulls, ...fr }
627
+ */
628
+ _rightNullRow(localRows, foreignRow, join) {
629
+ const localSchema = this._table._schema || {};
630
+ const localNulls = {};
631
+ for (const field of Object.keys(localSchema)) {
632
+ if (field !== '_softDelete') localNulls[field] = null;
633
+ }
634
+ const prefix = join.as ? join.as + '_' : '';
635
+ const foreignPrefixed = {};
636
+ for (const [key, value] of Object.entries(foreignRow)) {
637
+ foreignPrefixed[prefix + key] = value;
638
+ }
639
+ return { ...localNulls, ...foreignPrefixed };
640
+ }
641
+
624
642
  // ============================================================
625
643
  // 内部: 排序
626
644
  // ============================================================
package/lib/sql.js CHANGED
@@ -261,12 +261,18 @@ class Parser {
261
261
  if (this.peek().type === 'op' && this.peek().value === '.') {
262
262
  this.next();
263
263
  const t2 = this.next();
264
- if (t2.type !== 'ident') throw new Error(`Expected table name after '.', got '${t2.value}'`);
264
+ if (t2.type !== 'ident' && !(t2.type === 'keyword' && this._isSchemaView(t2.value))) {
265
+ throw new Error(`Expected table name after '.', got '${t2.value}'`);
266
+ }
265
267
  return t.value + '.' + t2.value;
266
268
  }
267
269
  return t.value;
268
270
  }
269
271
 
272
+ _isSchemaView(v) {
273
+ return ['TABLES', 'COLUMNS', 'SCHEMATA', 'STATISTICS', 'KEY_COLUMN_USAGE', 'REFERENTIAL_CONSTRAINTS', 'TABLE_CONSTRAINTS', 'VIEWS'].includes(String(v).toUpperCase());
274
+ }
275
+
270
276
  parseCreateTable() {
271
277
  this.expectKeyword('CREATE');
272
278
  this.expectKeyword('TABLE');
@@ -1870,11 +1876,12 @@ class SQLExecutor {
1870
1876
  const schema = this.engine.getTableSchema
1871
1877
  ? await this.engine.getTableSchema(statement.table)
1872
1878
  : (this.engine._schemas ? this.engine._schemas[statement.table] : null);
1879
+ const pkCols = schema ? Object.keys(schema).filter(k => schema[k].primaryKey) : [];
1873
1880
  const all = (await this.engine.find(statement.table, {}, { limit: 1e9, offset: 0 })).map(r => normalizeRow(r, schema));
1874
1881
  let count = 0;
1875
1882
  for (const row of all) {
1876
1883
  if (!statement.where || evaluateExpr(statement.where, row, this.ctx)) {
1877
- const id = row._rid !== undefined ? row._rid : row.id;
1884
+ const id = this._rowPkId(row, pkCols);
1878
1885
  if (id !== undefined) {
1879
1886
  const data = {};
1880
1887
  for (const [col, val] of statement.assignments) {
@@ -1892,11 +1899,12 @@ class SQLExecutor {
1892
1899
  const schema = this.engine.getTableSchema
1893
1900
  ? await this.engine.getTableSchema(statement.table)
1894
1901
  : (this.engine._schemas ? this.engine._schemas[statement.table] : null);
1902
+ const pkCols = schema ? Object.keys(schema).filter(k => schema[k].primaryKey) : [];
1895
1903
  const all = (await this.engine.find(statement.table, {}, { limit: 1e9, offset: 0 })).map(r => normalizeRow(r, schema));
1896
1904
  const ids = [];
1897
1905
  for (const row of all) {
1898
1906
  if (!statement.where || evaluateExpr(statement.where, row, this.ctx)) {
1899
- const id = row._rid !== undefined ? row._rid : row.id;
1907
+ const id = this._rowPkId(row, pkCols);
1900
1908
  if (id !== undefined) ids.push(id);
1901
1909
  }
1902
1910
  }
@@ -2166,6 +2174,19 @@ class SQLExecutor {
2166
2174
  return schema;
2167
2175
  }
2168
2176
 
2177
+ /**
2178
+ * 取行的行 ID:优先内部 _rid,否则用实际主键字段值(不再硬编码 id)。
2179
+ */
2180
+ _rowPkId(row, pkCols) {
2181
+ if (row && row._rid !== undefined) return row._rid;
2182
+ if (pkCols.length > 0) {
2183
+ for (const c of pkCols) {
2184
+ if (row[c] !== undefined && row[c] !== null) return row[c];
2185
+ }
2186
+ }
2187
+ return row ? row.id : undefined;
2188
+ }
2189
+
2169
2190
  async _readTable(table) {
2170
2191
  const schema = await this._getSchema(table);
2171
2192
  const rows = (await this.engine.find(table, {}, { limit: 1e9, offset: 0 })).map(r => normalizeRow(r, schema));
@@ -2366,6 +2387,19 @@ class SQLExecutor {
2366
2387
  return out;
2367
2388
  }
2368
2389
 
2390
+ /**
2391
+ * 生成对端表的前缀 null 行:仅含 `prefix.col` 键(值为 null),
2392
+ * 用于 JOIN 未匹配行补齐限定列,避免回退到未前缀副本拿错值。
2393
+ */
2394
+ _nullPrefixedRow(schema, prefix) {
2395
+ const out = {};
2396
+ for (const k of Object.keys(schema)) {
2397
+ if (k === '_softDelete') continue;
2398
+ out[prefix + '.' + k] = null;
2399
+ }
2400
+ return out;
2401
+ }
2402
+
2369
2403
  _aggValue(rows, fn, column) {
2370
2404
  if (fn === 'COUNT') return rows.length;
2371
2405
  const op = typeof column === 'string' ? { type: 'column', name: column } : column;
@@ -2445,6 +2479,7 @@ class SQLExecutor {
2445
2479
 
2446
2480
  let schema = null;
2447
2481
  let all;
2482
+ let rowsAll;
2448
2483
  if (!statement.from) {
2449
2484
  // 无 FROM:虚拟行(SELECT 1, 'a')
2450
2485
  all = [{ _virtual: true }];
@@ -2469,7 +2504,6 @@ class SQLExecutor {
2469
2504
  const rows = filtered.map(r => outCols.map(c => (c in r ? r[c] : null)));
2470
2505
  return { ok: true, type: 'select', table: firstItem.table, columns: outCols, rows, raw: filtered };
2471
2506
  }
2472
- let rowsAll;
2473
2507
  if (firstItem.subquery) {
2474
2508
  const res = await this.executeSelect(firstItem.subquery);
2475
2509
  rowsAll = { rows: this._subQueryRows(res), schema: null, columns: res.columns };
@@ -2495,6 +2529,10 @@ class SQLExecutor {
2495
2529
  }
2496
2530
  const matched = [];
2497
2531
  const unmatchedRight = new Set(rightRows.map((r, i) => i));
2532
+ // 未匹配行补对端表的前缀 null 列:限定列名(如 a.id / b.id)按前缀解析,
2533
+ // 避免回退到未前缀副本拿到错误值。
2534
+ const rightNulls = (rightRes.schema) ? this._nullPrefixedRow(rightRes.schema, rightPrefix) : null;
2535
+ const leftNulls = (rowsAll && rowsAll.schema) ? this._nullPrefixedRow(rowsAll.schema, firstPrefix) : null;
2498
2536
  rows.forEach(l => {
2499
2537
  let m = null;
2500
2538
  for (let ri = 0; ri < rightRows.length; ri++) {
@@ -2507,11 +2545,11 @@ class SQLExecutor {
2507
2545
  matched.push({ ...l, ...rightRows[m] });
2508
2546
  unmatchedRight.delete(m);
2509
2547
  } else if (j.type === 'left') {
2510
- matched.push({ ...l });
2548
+ matched.push(rightNulls ? { ...rightNulls, ...l } : { ...l });
2511
2549
  }
2512
2550
  });
2513
2551
  if (j.type === 'right') {
2514
- for (const ri of unmatchedRight) matched.push({ ...rightRows[ri] });
2552
+ for (const ri of unmatchedRight) matched.push(leftNulls ? { ...leftNulls, ...rightRows[ri] } : { ...rightRows[ri] });
2515
2553
  }
2516
2554
  rows = matched;
2517
2555
  }
package/lib/table.js CHANGED
@@ -242,6 +242,23 @@ class Table {
242
242
  const schema = this._schema;
243
243
  const checkConstraints = this._checkConstraints;
244
244
  const foreignKeys = this._foreignKeys;
245
+ // 批量预分配自增 ID 范围:先扫描显式提供的最大值,一次性推进计数器,
246
+ // 再在循环内用本地序号递增,避免批量内重复读取/写入同一计数器字段。
247
+ const baseAutoInc = this._autoIncrement;
248
+ if (autoIncField) {
249
+ let maxExplicit = 0;
250
+ for (const it of items) {
251
+ const v = it[autoIncField];
252
+ if (v !== undefined && v !== null) {
253
+ const n = Number(v);
254
+ if (!isNaN(n) && n > maxExplicit) maxExplicit = n;
255
+ }
256
+ }
257
+ if (maxExplicit > baseAutoInc) {
258
+ this._autoIncrement = maxExplicit;
259
+ }
260
+ }
261
+ let autoIncSeq = 0;
245
262
  for (let i = 0; i < N; i++) {
246
263
  let data = items[i];
247
264
  for (const [f, dv] of Object.entries(defaults)) {
@@ -279,8 +296,9 @@ class Table {
279
296
  }
280
297
  }
281
298
  if (autoIncField && data[autoIncField] === undefined) {
282
- this._autoIncrement++;
283
- data[autoIncField] = this._autoIncrement;
299
+ autoIncSeq++;
300
+ data[autoIncField] = baseAutoInc + autoIncSeq;
301
+ this._autoIncrement = data[autoIncField];
284
302
  } else if (autoIncField && data[autoIncField] > this._autoIncrement) {
285
303
  this._autoIncrement = data[autoIncField];
286
304
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsql-neo",
3
- "version": "5.1.1",
3
+ "version": "5.1.3",
4
4
  "description": "JSQL-NEO — 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",
@@ -47,6 +47,10 @@
47
47
  "scripts": {
48
48
  "postinstall": "node postinstall.js",
49
49
  "test": "node test/smoke.js",
50
+ "test:btree": "node test/btree.test.js",
51
+ "test:regress": "node test/regress-5.1.0.js",
52
+ "test:all": "node test/smoke.js && node test/coverage.js && node test/regress-5.1.0.js && node test/btree.test.js && node test/join.test.js",
53
+ "test:join": "node test/join.test.js",
50
54
  "test:coverage": "c8 --all --include='lib/**' --exclude='lib/mysql_server.js' --exclude='lib/native_client.js' --exclude='lib/wasm_client.js' --exclude='lib/mysql_compat.js' --exclude='lib/nedb_compat.js' --exclude='lib/plugin.js' --reporter=text --reporter=lcov node test/coverage.js",
51
55
  "test:orms": "node examples/orms/run-all.js"
52
56
  },
@@ -0,0 +1,128 @@
1
+ /*
2
+ * B-Tree 专项回归测试 — 覆盖 GitHub Issue #3 的三个 bug 及随机混合操作不变量。
3
+ * node test/btree.test.js
4
+ */
5
+ const BTree = require('../lib/btree');
6
+
7
+ let passed = 0, failed = 0;
8
+ function ok(name, cond, extra) {
9
+ if (cond) { passed++; }
10
+ else { failed++; console.log('[FAIL]', name, extra !== undefined ? '-> ' + JSON.stringify(extra) : ''); }
11
+ }
12
+
13
+ function validate(bt) {
14
+ const errs = [];
15
+ (function walk(node) {
16
+ if (node.leaf) {
17
+ if (node.children.length !== 0) errs.push('leaf has children');
18
+ for (let i = 1; i < node.keys.length; i++) if (node.keys[i] <= node.keys[i - 1]) errs.push('leaf unsorted');
19
+ } else {
20
+ if (node.children.length !== node.keys.length + 1) errs.push('internal children !== keys+1');
21
+ for (let i = 1; i < node.keys.length; i++) if (node.keys[i] <= node.keys[i - 1]) errs.push('internal unsorted');
22
+ node.children.forEach(walk);
23
+ }
24
+ })(bt._root);
25
+ return errs;
26
+ }
27
+
28
+ /* ---- Issue #3 Bug #1: 删除崩溃 ---- */
29
+ {
30
+ const bt = new BTree(4);
31
+ for (let i = 0; i < 20; i++) bt.insert(i, i);
32
+ const removed = bt.remove(0, 0);
33
+ ok('issue#3 Bug#1 remove(0,0) no crash', removed === true && bt.search(0).length === 0 && bt.size === 19);
34
+ }
35
+
36
+ /* ---- Issue #3 Bug #2: entries() 乱序重复 ---- */
37
+ {
38
+ const bt = new BTree(4);
39
+ for (let i = 0; i < 20; i++) bt.insert(i, i);
40
+ const e = bt.entries();
41
+ let dup = 0, disorder = 0;
42
+ for (let i = 0; i < e.length; i++) {
43
+ if (i > 0 && e[i].key <= e[i - 1].key) disorder++;
44
+ for (let j = i + 1; j < e.length; j++) if (e[i].key === e[j].key) dup++;
45
+ }
46
+ ok('issue#3 Bug#2 entries sorted & dedup', e.length === 20 && dup === 0 && disorder === 0, { len: e.length, dup, disorder });
47
+ }
48
+
49
+ /* ---- Issue #3 Bug #3: 唯一索引返回所有 values ---- */
50
+ {
51
+ const bt = new BTree(4, true);
52
+ bt.insert('a', 1); bt.insert('a', 2); bt.insert('a', 3);
53
+ ok('issue#3 Bug#3 unique search single value', JSON.stringify(bt.search('a')) === '[1]' && bt.size === 1);
54
+ const n = new BTree(4, false);
55
+ n.insert('a', 1); n.insert('a', 2); n.insert('b', 5);
56
+ ok('non-unique accumulates values', JSON.stringify(n.search('a')) === '[1,2]');
57
+ n.remove('a', 1); n.remove('a', 2); n.remove('a', 3);
58
+ ok('non-unique remove value keeps key until last', n.search('a').length === 0 && n.size === 1);
59
+ }
60
+
61
+ /* ---- 多值键删除只影响目标 rowIndex(size 对照 distinct keys) ---- */
62
+ {
63
+ const bt = new BTree(8);
64
+ bt.insert('x', 1); bt.insert('x', 2); bt.insert('x', 3);
65
+ bt.remove('x', 2);
66
+ ok('multi-value remove one keeps others', JSON.stringify(bt.search('x').sort()) === '[1,3]' && bt.size === 1);
67
+ }
68
+
69
+ /* ---- 随机顺序插入/删除压力 ---- */
70
+ for (const order of [4, 8, 16, 64]) {
71
+ for (let trial = 0; trial < 15; trial++) {
72
+ const bt = new BTree(order);
73
+ const n = 200;
74
+ const seq = [...Array(n).keys()];
75
+ for (let i = n - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [seq[i], seq[j]] = [seq[j], seq[i]]; }
76
+ for (const k of seq) bt.insert(k, k);
77
+ const e = validate(bt);
78
+ ok('o' + order + ' insert invariant t' + trial, e.length === 0, e.slice(0, 2));
79
+ for (const k of seq) bt.remove(k, k);
80
+ ok('o' + order + ' full removal t' + trial, bt.size === 0 && bt.entries().length === 0 && validate(bt).length === 0);
81
+ }
82
+ }
83
+
84
+ /* ---- 随机混合操作 vs 参考模型 ---- */
85
+ for (let t = 0; t < 25; t++) {
86
+ const order = [4, 8, 64][t % 3];
87
+ const bt = new BTree(order);
88
+ const ref = new Map(); // key -> Set(rowIndex)
89
+ let okAll = true;
90
+ for (let op = 0; op < 300; op++) {
91
+ const k = Math.floor(Math.random() * 40);
92
+ if (Math.random() < 0.55) {
93
+ const r = Math.floor(Math.random() * 5);
94
+ bt.insert(k, r);
95
+ if (!ref.has(k)) ref.set(k, new Set());
96
+ ref.get(k).add(r);
97
+ } else {
98
+ const r = Math.floor(Math.random() * 5);
99
+ bt.remove(k, r);
100
+ if (ref.has(k)) { ref.get(k).delete(r); if (ref.get(k).size === 0) ref.delete(k); }
101
+ }
102
+ if (op % 60 === 0) {
103
+ for (let x = 0; x < 40 && okAll; x++) {
104
+ const got = bt.search(x);
105
+ const exp = ref.has(x) ? [...ref.get(x)].sort((a, b) => a - b) : [];
106
+ const g = got ? [...got].sort((a, b) => a - b) : [];
107
+ if (JSON.stringify(g) !== JSON.stringify(exp)) okAll = false;
108
+ }
109
+ const es = bt.entries();
110
+ const emap = new Map(es.map(e => [e.key, [...e.value].sort((a, b) => a - b)]));
111
+ for (const [key, vals] of ref) {
112
+ const g = emap.get(key);
113
+ if (!g || JSON.stringify([...vals].sort()) !== JSON.stringify(g)) okAll = false;
114
+ }
115
+ const gtExp = [...ref.keys()].filter(k => k > 20).flatMap(k => [...ref.get(k)]).sort((a, b) => a - b);
116
+ const gt = bt.greaterThan(20).sort((a, b) => a - b);
117
+ if (JSON.stringify(gt) !== JSON.stringify(gtExp)) okAll = false;
118
+ const ltExp = [...ref.keys()].filter(k => k < 20).flatMap(k => [...ref.get(k)]).sort((a, b) => a - b);
119
+ const lt = bt.lessThan(20).sort((a, b) => a - b);
120
+ if (JSON.stringify(lt) !== JSON.stringify(ltExp)) okAll = false;
121
+ if (validate(bt).length) okAll = false;
122
+ }
123
+ }
124
+ ok('random mixed t' + t + ' vs reference', okAll && bt.size === ref.size, { size: bt.size, ref: ref.size });
125
+ }
126
+
127
+ console.log(failed === 0 ? `\nALL ${passed} BTREE TESTS PASSED` : `\n${failed} FAILURES (${passed} passed)`);
128
+ process.exit(failed === 0 ? 0 : 1);
@@ -0,0 +1,110 @@
1
+ /*
2
+ * Regression tests for SQL JOIN null-fill correctness.
3
+ * Zero runtime dependencies (uses Node builtins + in-repo libs only).
4
+ *
5
+ * node test/join.test.js
6
+ *
7
+ * Covers:
8
+ * J1 LEFT JOIN unmatched rows null-fill right-table qualified columns
9
+ * J2 RIGHT JOIN unmatched rows null-fill left-table qualified columns
10
+ * J3 INNER JOIN unaffected
11
+ * J4 unqualified column reference in JOIN
12
+ * J5 WHERE filtering on the null-filled side
13
+ * J6 self-join with table alias
14
+ * J7 chained joins
15
+ */
16
+ const path = require('path');
17
+
18
+ const ROOT = path.join(__dirname, '..');
19
+ const Database = require(path.join(ROOT, 'lib/database'));
20
+ const { executeSQL } = require(path.join(ROOT, 'lib/sql'));
21
+
22
+ let passed = 0, failed = 0;
23
+ function ok(name, cond, extra) {
24
+ if (cond) { passed++; console.log('[OK]', name); }
25
+ else { failed++; console.log('[FAIL]', name, extra !== undefined ? '-> ' + JSON.stringify(extra) : ''); }
26
+ }
27
+
28
+ async function setup(db) {
29
+ await executeSQL(db, 'CREATE TABLE a (id INT PRIMARY KEY, a_name STRING)');
30
+ await executeSQL(db, 'CREATE TABLE b (id INT PRIMARY KEY, a_id INT, b_name STRING)');
31
+ await executeSQL(db, "INSERT INTO a VALUES (1,'A1'),(2,'A2'),(3,'A3')");
32
+ await executeSQL(db, "INSERT INTO b VALUES (10,2,'B2'),(20,99,'B99')");
33
+ }
34
+
35
+ (async () => {
36
+ /* ============ J1 LEFT JOIN null-fill ============ */
37
+ {
38
+ const db = new Database(':memory:', { autoSave: false });
39
+ await setup(db);
40
+ const r = await executeSQL(db, 'SELECT a.id AS aid, a.a_name, b.id AS bid, b.b_name FROM a LEFT JOIN b ON a.id = b.a_id');
41
+ const row1 = r.rows[0]; // a=1 unmatched
42
+ ok('J1 left unmatched row', row1 && row1[0] === 1 && row1[1] === 'A1' && row1[2] === null && row1[3] === null, row1);
43
+ const row3 = r.rows[2]; // a=3 unmatched
44
+ ok('J1 second unmatched row', row3 && row3[0] === 3 && row3[2] === null, row3);
45
+ const row2 = r.rows[1]; // matched
46
+ ok('J1 matched row intact', row2 && row2[0] === 2 && row2[1] === 'A2' && row2[2] === 10 && row2[3] === 'B2', row2);
47
+ }
48
+
49
+ /* ============ J2 RIGHT JOIN null-fill ============ */
50
+ {
51
+ const db = new Database(':memory:', { autoSave: false });
52
+ await setup(db);
53
+ const r = await executeSQL(db, 'SELECT a.id AS aid, a.a_name, b.id AS bid, b.b_name FROM a RIGHT JOIN b ON a.id = b.a_id');
54
+ const b99 = r.rows[1]; // b=20 unmatched (a_id=99)
55
+ ok('J2 right unmatched row', b99 && b99[0] === null && b99[1] === null && b99[2] === 20 && b99[3] === 'B99', b99);
56
+ const b2 = r.rows[0]; // matched
57
+ ok('J2 matched row intact', b2 && b2[0] === 2 && b2[1] === 'A2' && b2[2] === 10 && b2[3] === 'B2', b2);
58
+ }
59
+
60
+ /* ============ J3 INNER JOIN ============ */
61
+ {
62
+ const db = new Database(':memory:', { autoSave: false });
63
+ await setup(db);
64
+ const r = await executeSQL(db, 'SELECT a.id AS aid, b.id AS bid FROM a INNER JOIN b ON a.id = b.a_id');
65
+ ok('J3 inner join', r.rows.length === 1 && r.rows[0][0] === 2 && r.rows[0][1] === 10, r.rows);
66
+ }
67
+
68
+ /* ============ J4 unqualified reference ============ */
69
+ {
70
+ const db = new Database(':memory:', { autoSave: false });
71
+ await setup(db);
72
+ const r = await executeSQL(db, 'SELECT a.id AS aid, b.b_name FROM a LEFT JOIN b ON a.id = b.a_id');
73
+ ok('J4 unprefixed id resolves to left table', r.rows[0][0] === 1 && r.rows[0][1] === null, r.rows[0]);
74
+ }
75
+
76
+ /* ============ J5 WHERE on null-filled side ============ */
77
+ {
78
+ const db = new Database(':memory:', { autoSave: false });
79
+ await setup(db);
80
+ const r = await executeSQL(db, "SELECT a.id AS aid, b.b_name FROM a LEFT JOIN b ON a.id = b.a_id WHERE b.id IS NULL");
81
+ ok('J5 left where b.id IS NULL', r.rows.length === 2 && r.rows.every(x => x[1] === null), r.rows);
82
+ const r2 = await executeSQL(db, "SELECT a.a_name, b.id AS bid FROM a RIGHT JOIN b ON a.id = b.a_id WHERE a.id IS NULL");
83
+ ok('J5 right where a.id IS NULL', r2.rows.length === 1 && r2.rows[0][0] === null && r2.rows[0][1] === 20, r2.rows);
84
+ }
85
+
86
+ /* ============ J6 self-join with alias ============ */
87
+ {
88
+ const db = new Database(':memory:', { autoSave: false });
89
+ await executeSQL(db, 'CREATE TABLE emp (id INT PRIMARY KEY, mgr_id INT, name STRING)');
90
+ await executeSQL(db, "INSERT INTO emp VALUES (1,NULL,'boss'),(2,1,'alice'),(3,999,'orphan')");
91
+ const r = await executeSQL(db, 'SELECT e.name AS ename, m.name AS mname FROM emp e LEFT JOIN emp m ON e.mgr_id = m.id');
92
+ ok('J6 self left join', r.rows.length === 3 && r.rows[0][0] === 'boss' && r.rows[0][1] === null && r.rows[1][1] === 'boss' && r.rows[2][1] === null, r.rows);
93
+ }
94
+
95
+ /* ============ J7 chained joins ============ */
96
+ {
97
+ const db = new Database(':memory:', { autoSave: false });
98
+ await executeSQL(db, 'CREATE TABLE a (id INT PRIMARY KEY, a_name STRING)');
99
+ await executeSQL(db, 'CREATE TABLE b (id INT PRIMARY KEY, a_id INT, b_name STRING)');
100
+ await executeSQL(db, 'CREATE TABLE c (id INT PRIMARY KEY, b_id INT, c_name STRING)');
101
+ await executeSQL(db, "INSERT INTO a VALUES (1,'A1'),(2,'A2'),(3,'A3')");
102
+ await executeSQL(db, "INSERT INTO b VALUES (10,2,'B2'),(20,99,'B99')");
103
+ await executeSQL(db, "INSERT INTO c VALUES (100,10,'C10'),(200,77,'C77')");
104
+ const r = await executeSQL(db, 'SELECT a.id AS aid, b.id AS bid, c.id AS cid, c.c_name FROM a LEFT JOIN b ON a.id = b.a_id LEFT JOIN c ON b.id = c.b_id');
105
+ ok('J7 chained left joins', r.rows.length === 3 && r.rows[1][1] === 10 && r.rows[1][2] === 100 && r.rows[1][3] === 'C10' && r.rows[0][1] === null && r.rows[0][2] === null, r.rows);
106
+ }
107
+
108
+ console.log(failed === 0 ? `\nALL ${passed} JOIN TESTS PASSED` : `\n${failed} FAILURES (${passed} passed)`);
109
+ process.exit(failed === 0 ? 0 : 1);
110
+ })().catch((e) => { console.error('FATAL', e); process.exit(1); });
@@ -0,0 +1,271 @@
1
+ /*
2
+ * Regression tests locking in the bug fixes shipped in 5.1.0 / 5.1.1.
3
+ * Zero runtime dependencies (uses Node builtins + in-repo libs only).
4
+ *
5
+ * node test/regress-5.1.0.js
6
+ *
7
+ * Covers:
8
+ * M1 $like regex-escape M2 removeById(s) index consistency
9
+ * M3 B-Tree open intervals M4 transaction deep-copy snapshot
10
+ * M5 swap-pop hash sync M6 importFromJSON shapes/overwrite
11
+ * H1/H3 parseFieldShorthand + ER_DUP_ENTRY
12
+ * S1 Redis per-socket AUTH S2/S4 WebUI CORS & authToken
13
+ * S3/S4 MySQL ACL (1044) S6 mysql_compat pool filename
14
+ * N1 CLI --version sync
15
+ */
16
+ const path = require('path');
17
+ const fs = require('fs');
18
+ const os = require('os');
19
+ const net = require('net');
20
+ const { execFileSync } = require('child_process');
21
+
22
+ const ROOT = path.join(__dirname, '..');
23
+ const Database = require(path.join(ROOT, 'lib/database'));
24
+ const BTree = require(path.join(ROOT, 'lib/btree'));
25
+ const migrate = require(path.join(ROOT, 'lib/migrate'));
26
+ const { RedisServer } = require(path.join(ROOT, 'lib/redis_server'));
27
+ const { WebUI } = require(path.join(ROOT, 'lib/web_ui'));
28
+ const { MysqlServer } = require(path.join(ROOT, 'lib/mysql_server'));
29
+ const compat = require(path.join(ROOT, 'lib/mysql_compat'));
30
+
31
+ let passed = 0, failed = 0;
32
+ function ok(name, cond, extra) {
33
+ if (cond) { passed++; console.log('[OK]', name); }
34
+ else { failed++; console.log('[FAIL]', name, extra !== undefined ? '-> ' + JSON.stringify(extra) : ''); }
35
+ }
36
+
37
+ function tmp(name) {
38
+ const p = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'jsql-r-')), name);
39
+ fs.mkdirSync(path.dirname(p), { recursive: true });
40
+ return p;
41
+ }
42
+
43
+ function freePort() {
44
+ return new Promise((res) => {
45
+ const s = net.createServer();
46
+ s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => res(p)); });
47
+ });
48
+ }
49
+
50
+ /* Minimal RESP client for integration tests. */
51
+ function respClient(port) {
52
+ return new Promise((resolve, reject) => {
53
+ const socket = net.connect(port, '127.0.0.1');
54
+ let buf = '';
55
+ const queue = [];
56
+ socket.on('connect', () => resolve({
57
+ cmd(line) {
58
+ return new Promise((r) => {
59
+ queue.push(r);
60
+ socket.write(line);
61
+ });
62
+ },
63
+ end() { socket.end(); },
64
+ }));
65
+ socket.on('data', (chunk) => {
66
+ buf += chunk.toString('utf8');
67
+ while (queue.length) {
68
+ const idx = buf.indexOf('\r\n');
69
+ if (idx < 0) break;
70
+ const line = buf.slice(0, idx);
71
+ buf = buf.slice(idx + 2);
72
+ queue.shift()(line);
73
+ }
74
+ });
75
+ socket.on('error', reject);
76
+ });
77
+ }
78
+
79
+ (async () => {
80
+ /* ============ M3: B-Tree strict open intervals ============ */
81
+ {
82
+ const t = new BTree();
83
+ for (const k of [1, 3, 5, 7, 9]) t.insert(k, k);
84
+ ok('btree.greaterThan excludes min', JSON.stringify(t.greaterThan(5)) === '[7,9]', t.greaterThan(5));
85
+ ok('btree.greaterThan(9) empty', JSON.stringify(t.greaterThan(9)) === '[]');
86
+ ok('btree.lessThan excludes max', JSON.stringify(t.lessThan(5)) === '[1,3]', t.lessThan(5));
87
+ ok('btree.lessThan(1) empty', JSON.stringify(t.lessThan(1)) === '[]');
88
+ }
89
+
90
+ /* ============ Core engine ============ */
91
+ const db = new Database(':memory:', { autoSave: false });
92
+ await db.start();
93
+ db.createTable('users', { id: 'integer primary key auto_increment', name: 'string', email: 'string unique', age: 'integer' });
94
+ for (const [n, a] of [['A', 10], ['B', 20], ['C', 30]]) db.insert('users', { name: n, email: n.toLowerCase() + '@x', age: a });
95
+
96
+ {
97
+ const gt = db.find('users', { age: { $gt: 20 } }).map(r => r.name);
98
+ ok('find $gt excludes boundary', JSON.stringify(gt) === '["C"]', gt);
99
+ const lt = db.find('users', { age: { $lt: 20 } }).map(r => r.name);
100
+ ok('find $lt excludes boundary', JSON.stringify(lt) === '["A"]', lt);
101
+ }
102
+
103
+ {
104
+ db.removeById('users', 2); // B (id=2) removed, C still id=3
105
+ ok('removeById with PK count', db.count('users') === 2, db.count('users'));
106
+ ok('removeById PK lookup null', db.findById('users', 2) === null);
107
+ ok('removeById B-Tree still consistent', db.find('users', { age: { $gt: 15 } }).length === 1);
108
+ }
109
+
110
+ {
111
+ db.createTable('np', { v: 'string', n: 'integer' });
112
+ for (let i = 0; i < 10; i++) db.insert('np', { v: 'v' + i, n: i });
113
+ db.createTable('h', { id: 'integer primary key auto_increment', grp: 'string', val: 'integer' });
114
+ for (let i = 0; i < 12; i++) db.insert('h', { grp: 'g' + (i % 4), val: i });
115
+ db._tables.h.createIndex('grp');
116
+
117
+ db.removeByIds('np', [1, 2]);
118
+ ok('removeByIds no-PK rebuilds indexes', db.find('np').length === 8, db.find('np').length);
119
+
120
+ const pre = db.find('h', { grp: 'g1' }).map(r => r.id).sort((a, b) => a - b);
121
+ db.removeById('h', 1); db.removeById('h', 5); db.removeById('h', 9); // the g0 rows
122
+ const post = db.find('h', { grp: 'g1' }).map(r => r.id).sort((a, b) => a - b);
123
+ ok('hash index survives swap-pop (g1 intact)', JSON.stringify(post) === JSON.stringify(pre), { pre, post });
124
+ ok('hash index has no stale g0 rows', db.find('h', { grp: 'g0' }).length === 0);
125
+ let stale = 0;
126
+ for (const list of db._tables.h._indexes.grp.values()) for (const i of list) if (!db._tables.h._rows[i]) stale++;
127
+ ok('hash index zero stale entries', stale === 0, stale);
128
+ }
129
+
130
+ {
131
+ db.createTable('docs', { id: 'integer primary key', data: 'string' });
132
+ db.insert('docs', { id: 1, data: JSON.stringify({ a: { b: 1 }, list: [1, 2, 3] }) });
133
+ db.begin();
134
+ db.updateById('docs', 1, { data: JSON.stringify({ a: { b: 2 }, list: [9] }) });
135
+ db.rollback();
136
+ const d = JSON.parse(db.findById('docs', 1).data);
137
+ ok('transaction snapshot deep-copies nested object', d.a.b === 1, d);
138
+ ok('transaction snapshot deep-copies arrays', JSON.stringify(d.list) === '[1,2,3]', d.list);
139
+ }
140
+
141
+ {
142
+ const pfs = Database.parseFieldShorthand;
143
+ const s1 = pfs('integer primary key auto_increment');
144
+ ok('parseFieldShorthand pk+ai', s1.primaryKey === true && s1.autoIncrement === true && s1.type === 'integer', s1);
145
+ const s2 = pfs('string unique not null');
146
+ ok('parseFieldShorthand unique+notnull', s2.unique === true && s2.required === true && s2.type === 'string', s2);
147
+ const s3 = pfs('string default x');
148
+ ok('parseFieldShorthand default', s3.default === 'x' && s3.type === 'string', s3);
149
+
150
+ let e = null;
151
+ try { db.insert('users', { name: 'X', email: 'a@x', age: 1 }); } catch (err) { e = err; }
152
+ ok('duplicate unique throws ER_DUP_ENTRY', e && (e.code === 1062 || String(e).includes('1062')), e && (e.code || e.message));
153
+ db.createTable('pk2', { a: 'integer primary key', b: 'string' });
154
+ db.insert('pk2', { a: 1, b: 'x' });
155
+ e = null;
156
+ try { db.insert('pk2', { a: 1, b: 'y' }); } catch (err) { e = err; }
157
+ ok('duplicate primary key throws ER_DUP_ENTRY', e && (e.code === 1062 || String(e).includes('1062')), e && (e.code || e.message));
158
+ }
159
+
160
+ {
161
+ const r = await migrate.importFromJSON(db, { table: 't1', schema: { id: 'integer primary key' }, rows: [{ id: 1 }, { id: 2 }] });
162
+ ok('importFromJSON single-table shape', r && db.count('t1') === 2);
163
+ let threw = null;
164
+ try { await migrate.importFromJSON(db, { table: 't1', schema: { id: 'integer primary key' }, rows: [{ id: 3 }] }); } catch (err) { threw = err; }
165
+ ok('importFromJSON refuses existing table without overwrite', !!threw, threw && (threw.message || String(threw)));
166
+ await migrate.importFromJSON(db, { table: 't1', schema: { id: 'integer primary key' }, rows: [{ id: 3 }] }, { overwrite: true });
167
+ ok('importFromJSON overwrite replaces table', db.count('t1') === 1, db.count('t1'));
168
+ }
169
+
170
+ {
171
+ db.createTable('lk', { s: 'string' });
172
+ for (const s of ['a.b', 'axb', 'aXb', 'abc']) db.insert('lk', { s });
173
+ const m = db.find('lk', { s: { $like: 'a.b' } }).map(r => r.s);
174
+ ok('$like escapes regex metacharacters', JSON.stringify(m) === '["a.b"]', m);
175
+ const m2 = db.find('lk', { s: { $like: 'a%b' } }).map(r => r.s);
176
+ ok('$like % wildcard still works', JSON.stringify(m2) === '["a.b","axb","aXb"]', m2);
177
+ }
178
+ await db.stop();
179
+
180
+ /* ============ CLI (N1) ============ */
181
+ {
182
+ const pkg = require(path.join(ROOT, 'package.json')).version;
183
+ const v = execFileSync(process.execPath, [path.join(ROOT, 'bin/jsql'), '--version']).toString().trim();
184
+ ok('cli --version reports package version', v === pkg, v);
185
+ const v2 = execFileSync(process.execPath, [path.join(ROOT, 'bin/jsql'), 'version']).toString().trim();
186
+ ok('cli version command matches', v2 === pkg, v2);
187
+ const help = execFileSync(process.execPath, [path.join(ROOT, 'bin/jsql'), 'ui', '--help']).toString();
188
+ ok('cli subcommand --help does not crash', help.includes('127.0.0.1') && help.includes('auth-token'));
189
+ }
190
+
191
+ /* ============ WebUI CORS + auth (N2 / S2) ============ */
192
+ {
193
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsql-wui-'));
194
+ fs.writeFileSync(path.join(dir, 'db.json'), JSON.stringify({ __schema__: { u: { id: { type: 'integer', primaryKey: true } } }, u: [{ id: 1 }] }));
195
+
196
+ const p1 = await freePort();
197
+ const w1 = new WebUI({ port: p1, host: undefined, dataDir: dir });
198
+ await w1.start();
199
+ ok('webui default host is 127.0.0.1', w1.host === '127.0.0.1', w1.host);
200
+ let r = await fetch('http://127.0.0.1:' + p1 + '/api/databases', { headers: { Origin: 'http://evil.example' } });
201
+ ok('webui no-token emits no ACAO', r.headers.get('access-control-allow-origin') === null, r.headers.get('access-control-allow-origin'));
202
+ r = await fetch('http://127.0.0.1:' + p1 + '/api/query', { method: 'OPTIONS', headers: { Origin: 'http://evil.example', 'Access-Control-Request-Method': 'POST' } });
203
+ ok('webui no-token preflight emits no ACAO', r.headers.get('access-control-allow-origin') === null);
204
+ await w1.stop();
205
+
206
+ const p2 = await freePort();
207
+ const w2 = new WebUI({ port: p2, host: '127.0.0.1', dataDir: dir, authToken: 'tok' });
208
+ await w2.start();
209
+ r = await fetch('http://127.0.0.1:' + p2 + '/api/databases', { headers: { Origin: 'http://evil.example' } });
210
+ ok('webui missing bearer returns 401', r.status === 401);
211
+ r = await fetch('http://127.0.0.1:' + p2 + '/api/databases', { headers: { Origin: 'http://good.example', Authorization: 'Bearer tok' } });
212
+ ok('webui auth echoes origin', r.status === 200 && r.headers.get('access-control-allow-origin') === 'http://good.example', { status: r.status, acao: r.headers.get('access-control-allow-origin') });
213
+ await w2.stop();
214
+
215
+ const p3 = await freePort();
216
+ const w3 = new WebUI({ port: p3, host: '127.0.0.1', dataDir: dir, allowOrigin: '*' });
217
+ await w3.start();
218
+ r = await fetch('http://127.0.0.1:' + p3 + '/api/databases', { headers: { Origin: 'http://any.example' } });
219
+ ok('webui explicit allowOrigin honored', r.headers.get('access-control-allow-origin') === '*');
220
+ await w3.stop();
221
+ fs.rmSync(dir, { recursive: true, force: true });
222
+ }
223
+
224
+ /* ============ Redis per-socket AUTH (S1) ============ */
225
+ {
226
+ const p = await freePort();
227
+ const rs = new RedisServer({ port: p, host: '127.0.0.1', password: 'pw' });
228
+ rs.listen();
229
+ await new Promise((res) => rs.server.once('listening', res));
230
+
231
+ const a = await respClient(p);
232
+ const b = await respClient(p);
233
+ const noauthA = await a.cmd('*1\r\n$4\r\nPING\r\n');
234
+ ok('redis unauth socket gets NOAUTH', noauthA.startsWith('-NOAUTH'), noauthA);
235
+ const authB = await b.cmd('*2\r\n$4\r\nAUTH\r\n$2\r\npw\r\n');
236
+ ok('redis AUTH pw succeeds', authB === '+OK', authB);
237
+ const pingB = await b.cmd('*1\r\n$4\r\nPING\r\n');
238
+ ok('redis authed socket works', pingB === '+PONG', pingB);
239
+ const pingA = await a.cmd('*1\r\n$4\r\nPING\r\n');
240
+ ok('redis auth is per-socket (other socket still NOAUTH)', pingA.startsWith('-NOAUTH'), pingA);
241
+ a.end(); b.end();
242
+ rs.server.close();
243
+ }
244
+
245
+ /* ============ MySQL ACL (S3 / S4) ============ */
246
+ {
247
+ const ms = new MysqlServer({ dataDir: ':memory:', auth: { alice: { password: 'pw', databases: ['app'] } } });
248
+ ok('mysql acl allows member db', ms._canAccessDb('alice', 'app') === true);
249
+ ok('mysql acl denies foreign db', ms._canAccessDb('alice', 'other') === false);
250
+ let code = null;
251
+ try { await ms._switchDb({ user: 'alice' }, 'other'); } catch (e) { code = e.code; }
252
+ ok('mysql cross-db switch denied (1044)', code === 1044, code);
253
+ }
254
+
255
+ /* ============ mysql_compat pool filename (S6) ============ */
256
+ {
257
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsql-cp-'));
258
+ const file = path.join(dir, 'pool.json');
259
+ const pool = compat.createPool({ filename: file });
260
+ await pool.query('CREATE TABLE t (id INT PRIMARY KEY, v VARCHAR(20))');
261
+ await pool.query("INSERT INTO t VALUES (1,'x')");
262
+ const [rows] = await pool.query('SELECT * FROM t');
263
+ ok('mysql_compat pool query roundtrip', rows.length === 1 && rows[0].v === 'x', rows);
264
+ ok('mysql_compat pool persists to filename', fs.existsSync(file) && fs.statSync(file).size > 0);
265
+ if (typeof pool.end === 'function') await pool.end();
266
+ fs.rmSync(dir, { recursive: true, force: true });
267
+ }
268
+
269
+ console.log(failed === 0 ? `\nALL ${passed} REGRESSION TESTS PASSED` : `\n${failed} FAILURES (${passed} passed)`);
270
+ process.exit(failed === 0 ? 0 : 1);
271
+ })().catch((e) => { console.error('FATAL', e); process.exit(1); });