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 +1 -1
- package/lib/btree.js +112 -88
- package/lib/database.js +12 -7
- package/lib/query.js +20 -2
- package/lib/sql.js +44 -6
- package/lib/table.js +20 -2
- package/package.json +5 -1
- package/test/btree.test.js +128 -0
- package/test/join.test.js +110 -0
- package/test/regress-5.1.0.js +271 -0
package/index.js
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
284
|
-
left.
|
|
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.
|
|
305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (/\
|
|
28
|
-
|
|
29
|
-
|
|
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(
|
|
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(
|
|
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'
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
283
|
-
data[autoIncField] =
|
|
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.
|
|
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); });
|