jsql-neo 6.4.0-beta.14 → 6.4.0-beta.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/index.js +1 -1
- package/lib/btree.js +8 -6
- package/lib/query.js +6 -0
- package/lib/sql.js +382 -5
- package/package.json +2 -2
- package/test/bug-hunt.test.js +344 -0
- package/test/json-functions.test.js +172 -0
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> **One engine to rule them all** — a Rust-powered embedded database that speaks your language:
|
|
4
4
|
> MySQL, PostgreSQL, MongoDB, Redis, SQL, TypeScript, **and the browser**.
|
|
5
5
|
|
|
6
|
-
> **v6.4.0-beta.
|
|
6
|
+
> **v6.4.0-beta.16** — official release build · [github.com/vexify-org/JSQL-neo](https://github.com/vexify-org/JSQL-neo) · [jsql.vexify.top](https://jsql.vexify.top)
|
|
7
7
|
|
|
8
8
|

|
|
9
9
|

|
package/index.js
CHANGED
package/lib/btree.js
CHANGED
|
@@ -171,9 +171,10 @@ class BTree {
|
|
|
171
171
|
for (let i = 0; i < node.keys.length; i++) {
|
|
172
172
|
const k = node.keys[i];
|
|
173
173
|
const s = k === undefined || k === null ? '' : String(k);
|
|
174
|
-
|
|
175
|
-
if (
|
|
176
|
-
|
|
174
|
+
// 不能假设键按字典序排序(数字键按数值序),必须检查所有键
|
|
175
|
+
if (s.startsWith(p)) {
|
|
176
|
+
result.push(...node.values[i]);
|
|
177
|
+
}
|
|
177
178
|
}
|
|
178
179
|
node = node.next;
|
|
179
180
|
}
|
|
@@ -326,13 +327,14 @@ class BTree {
|
|
|
326
327
|
const left = parent.children[i - 1];
|
|
327
328
|
const right = parent.children[i];
|
|
328
329
|
if (right.leaf) {
|
|
329
|
-
//
|
|
330
|
+
// 叶子节点:移动左叶最大键到右叶最小位置
|
|
330
331
|
const k = left.keys.pop();
|
|
331
332
|
const v = left.values.pop();
|
|
332
333
|
right.keys.unshift(k);
|
|
333
334
|
right.values.unshift(v);
|
|
334
|
-
|
|
335
|
-
parent.
|
|
335
|
+
// 父节点分隔键应为左叶新的最大值(pop 后的最后一个键)
|
|
336
|
+
parent.keys[i - 1] = left.keys[left.keys.length - 1];
|
|
337
|
+
parent.values[i - 1] = left.values[left.values.length - 1];
|
|
336
338
|
} else {
|
|
337
339
|
right.keys.unshift(parent.keys[i - 1]);
|
|
338
340
|
right.values.unshift(parent.values[i - 1]);
|
package/lib/query.js
CHANGED
|
@@ -675,6 +675,12 @@ class Query {
|
|
|
675
675
|
for (const { field, dir } of this._orderBy) {
|
|
676
676
|
const va = a[field];
|
|
677
677
|
const vb = b[field];
|
|
678
|
+
// MySQL 语义:NULL 在 ASC 时排在最前,DESC 时排在最后
|
|
679
|
+
const aNull = va === null || va === undefined;
|
|
680
|
+
const bNull = vb === null || vb === undefined;
|
|
681
|
+
if (aNull && bNull) continue;
|
|
682
|
+
if (aNull) return dir === 'asc' ? -1 : 1;
|
|
683
|
+
if (bNull) return dir === 'asc' ? 1 : -1;
|
|
678
684
|
if (va < vb) return dir === 'asc' ? -1 : 1;
|
|
679
685
|
if (va > vb) return dir === 'asc' ? 1 : -1;
|
|
680
686
|
}
|
package/lib/sql.js
CHANGED
|
@@ -2908,7 +2908,7 @@ function resolveOperand(operand, row, ctx) {
|
|
|
2908
2908
|
}
|
|
2909
2909
|
// P1-19: ON DUPLICATE KEY UPDATE 表达式赋值,如 count = count + 1
|
|
2910
2910
|
case 'binary': {
|
|
2911
|
-
return evaluateExpr(
|
|
2911
|
+
return evaluateExpr(operand, row, ctx);
|
|
2912
2912
|
}
|
|
2913
2913
|
case 'func':
|
|
2914
2914
|
return applyScalarFunction(operand, row, ctx);
|
|
@@ -3622,6 +3622,16 @@ function applyScalarFunction(fnNode, row, ctx) {
|
|
|
3622
3622
|
});
|
|
3623
3623
|
case 'DATABASE': case 'SCHEMA': return row && row.__db !== undefined ? row.__db : 'default';
|
|
3624
3624
|
// ===== JSON 函数(B2 修复:此前未实现,静默返回 NULL)=====
|
|
3625
|
+
// 辅助函数:获取 JSON 值的类型
|
|
3626
|
+
function getJsonType(val) {
|
|
3627
|
+
if (val === null) return 'null';
|
|
3628
|
+
if (Array.isArray(val)) return 'array';
|
|
3629
|
+
if (typeof val === 'object') return 'object';
|
|
3630
|
+
if (typeof val === 'string') return 'string';
|
|
3631
|
+
if (typeof val === 'number') return Number.isInteger(val) ? 'integer' : 'number';
|
|
3632
|
+
if (typeof val === 'boolean') return 'boolean';
|
|
3633
|
+
return 'unknown';
|
|
3634
|
+
}
|
|
3625
3635
|
case 'JSON_OBJECT': {
|
|
3626
3636
|
// JSON_OBJECT(k1, v1, k2, v2, ...) → { k1: v1, k2: v2 }
|
|
3627
3637
|
const obj = {};
|
|
@@ -3657,7 +3667,7 @@ function applyScalarFunction(fnNode, row, ctx) {
|
|
|
3657
3667
|
if (firstKey === '') return data;
|
|
3658
3668
|
let current = data;
|
|
3659
3669
|
// 逐段解析路径
|
|
3660
|
-
const segments = path.replace(
|
|
3670
|
+
const segments = path.replace(/^\$\.?/, '').split('.');
|
|
3661
3671
|
for (const seg of segments) {
|
|
3662
3672
|
if (current == null) return null;
|
|
3663
3673
|
// 处理数组索引: key[0] 或 [0]
|
|
@@ -3675,6 +3685,353 @@ function applyScalarFunction(fnNode, row, ctx) {
|
|
|
3675
3685
|
}
|
|
3676
3686
|
return current === undefined ? null : current;
|
|
3677
3687
|
}
|
|
3688
|
+
case 'JSON_KEYS': {
|
|
3689
|
+
// JSON_KEYS(json) → [key1, key2, ...]
|
|
3690
|
+
const json = args[0];
|
|
3691
|
+
if (json == null) return null;
|
|
3692
|
+
let data = json;
|
|
3693
|
+
if (typeof json === 'string') {
|
|
3694
|
+
try { data = JSON.parse(json); } catch (e) { return null; }
|
|
3695
|
+
}
|
|
3696
|
+
if (typeof data !== 'object' || data === null || Array.isArray(data)) return null;
|
|
3697
|
+
return Object.keys(data);
|
|
3698
|
+
}
|
|
3699
|
+
case 'JSON_LENGTH': {
|
|
3700
|
+
// JSON_LENGTH(json[, path]) → 元素数/长度
|
|
3701
|
+
const json = args[0];
|
|
3702
|
+
const path = args[1];
|
|
3703
|
+
if (json == null) return null;
|
|
3704
|
+
let data = json;
|
|
3705
|
+
if (typeof json === 'string') {
|
|
3706
|
+
try { data = JSON.parse(json); } catch (e) { return null; }
|
|
3707
|
+
}
|
|
3708
|
+
if (path != null) {
|
|
3709
|
+
// 按路径提取后取长度
|
|
3710
|
+
const p = String(path).replace(/^\$\.?/, '');
|
|
3711
|
+
if (p === '') return Array.isArray(data) ? data.length : (typeof data === 'object' && data !== null ? Object.keys(data).length : 0);
|
|
3712
|
+
let current = data;
|
|
3713
|
+
for (const seg of p.split('.')) {
|
|
3714
|
+
if (current == null) return null;
|
|
3715
|
+
const arrMatch = seg.match(/^([^\[]*)\[(\d+)\]$/);
|
|
3716
|
+
if (arrMatch) {
|
|
3717
|
+
const key = arrMatch[1];
|
|
3718
|
+
const idx = parseInt(arrMatch[2], 10);
|
|
3719
|
+
if (key) current = current[key];
|
|
3720
|
+
if (Array.isArray(current)) current = current[idx];
|
|
3721
|
+
else return null;
|
|
3722
|
+
} else {
|
|
3723
|
+
if (typeof current !== 'object' || current === null) return null;
|
|
3724
|
+
current = current[seg];
|
|
3725
|
+
}
|
|
3726
|
+
}
|
|
3727
|
+
if (current == null) return null;
|
|
3728
|
+
if (Array.isArray(current)) return current.length;
|
|
3729
|
+
if (typeof current === 'object') return Object.keys(current).length;
|
|
3730
|
+
if (typeof current === 'string') return current.length;
|
|
3731
|
+
return 1;
|
|
3732
|
+
}
|
|
3733
|
+
if (Array.isArray(data)) return data.length;
|
|
3734
|
+
if (typeof data === 'object' && data !== null) return Object.keys(data).length;
|
|
3735
|
+
if (typeof data === 'string') return data.length;
|
|
3736
|
+
return 1;
|
|
3737
|
+
}
|
|
3738
|
+
case 'JSON_MERGE': case 'JSON_MERGE_PRESERVE': {
|
|
3739
|
+
// JSON_MERGE_PRESERVE(json1, json2, ...) → 合并后的 JSON
|
|
3740
|
+
const items = args.filter(a => a != null);
|
|
3741
|
+
if (!items.length) return null;
|
|
3742
|
+
const parsed = items.map(item => {
|
|
3743
|
+
if (typeof item === 'string') { try { return JSON.parse(item); } catch (e) { return null; } }
|
|
3744
|
+
return item;
|
|
3745
|
+
}).filter(a => a != null);
|
|
3746
|
+
if (!parsed.length) return null;
|
|
3747
|
+
// 递归合并
|
|
3748
|
+
function mergePreserve(target, source) {
|
|
3749
|
+
if (Array.isArray(target) && Array.isArray(source)) {
|
|
3750
|
+
return [...target, ...source];
|
|
3751
|
+
}
|
|
3752
|
+
if (typeof target === 'object' && target !== null && typeof source === 'object' && source !== null && !Array.isArray(target) && !Array.isArray(source)) {
|
|
3753
|
+
const result = { ...target };
|
|
3754
|
+
for (const k of Object.keys(source)) {
|
|
3755
|
+
if (k in result) result[k] = mergePreserve(result[k], source[k]);
|
|
3756
|
+
else result[k] = source[k];
|
|
3757
|
+
}
|
|
3758
|
+
return result;
|
|
3759
|
+
}
|
|
3760
|
+
return source;
|
|
3761
|
+
}
|
|
3762
|
+
return parsed.reduce((acc, cur) => mergePreserve(acc, cur));
|
|
3763
|
+
}
|
|
3764
|
+
case 'JSON_MERGE_PATCH': {
|
|
3765
|
+
// JSON_MERGE_PATCH(json1, json2, ...) → RFC 7396 合并
|
|
3766
|
+
const items = args.filter(a => a != null);
|
|
3767
|
+
if (!items.length) return null;
|
|
3768
|
+
const parsed = items.map(item => {
|
|
3769
|
+
if (typeof item === 'string') { try { return JSON.parse(item); } catch (e) { return null; } }
|
|
3770
|
+
return item;
|
|
3771
|
+
}).filter(a => a != null);
|
|
3772
|
+
if (!parsed.length) return null;
|
|
3773
|
+
function mergePatch(target, source) {
|
|
3774
|
+
if (typeof source !== 'object' || source === null || Array.isArray(source)) return source;
|
|
3775
|
+
if (typeof target !== 'object' || target === null || Array.isArray(target)) return { ...source };
|
|
3776
|
+
const result = { ...target };
|
|
3777
|
+
for (const k of Object.keys(source)) {
|
|
3778
|
+
if (source[k] === null) delete result[k];
|
|
3779
|
+
else result[k] = mergePatch(result[k], source[k]);
|
|
3780
|
+
}
|
|
3781
|
+
return result;
|
|
3782
|
+
}
|
|
3783
|
+
return parsed.reduce((acc, cur) => mergePatch(acc, cur));
|
|
3784
|
+
}
|
|
3785
|
+
case 'JSON_OBJECTAGG': {
|
|
3786
|
+
// JSON_OBJECTAGG(key, value) → { key: value } (聚合函数)
|
|
3787
|
+
// 在 JSQL 中作为普通函数调用,接收 key 和 value
|
|
3788
|
+
const key = args[0];
|
|
3789
|
+
const value = args[1];
|
|
3790
|
+
if (key == null) return null;
|
|
3791
|
+
const obj = {};
|
|
3792
|
+
obj[String(key)] = value === undefined ? null : value;
|
|
3793
|
+
return obj;
|
|
3794
|
+
}
|
|
3795
|
+
case 'JSON_ARRAYAGG': {
|
|
3796
|
+
// JSON_ARRAYAGG(expr) → [expr] (聚合函数)
|
|
3797
|
+
// 在 JSQL 中作为普通函数调用,接收单个值
|
|
3798
|
+
const val = args[0];
|
|
3799
|
+
return [val === undefined ? null : val];
|
|
3800
|
+
}
|
|
3801
|
+
case 'JSON_SET': {
|
|
3802
|
+
// JSON_SET(json, path, val, ...) → 设置值(路径存在则更新,不存在则创建)
|
|
3803
|
+
const json = args[0];
|
|
3804
|
+
if (json == null) return null;
|
|
3805
|
+
let data = json;
|
|
3806
|
+
if (typeof json === 'string') {
|
|
3807
|
+
try { data = JSON.parse(json); } catch (e) { return null; }
|
|
3808
|
+
}
|
|
3809
|
+
// 深拷贝
|
|
3810
|
+
data = JSON.parse(JSON.stringify(data));
|
|
3811
|
+
for (let i = 1; i + 1 < args.length; i += 2) {
|
|
3812
|
+
const path = String(args[i]);
|
|
3813
|
+
const val = args[i + 1];
|
|
3814
|
+
const p = path.replace(/^\$\.?/, '');
|
|
3815
|
+
if (p === '') continue;
|
|
3816
|
+
const segments = p.split('.');
|
|
3817
|
+
let current = data;
|
|
3818
|
+
for (let j = 0; j < segments.length - 1; j++) {
|
|
3819
|
+
const seg = segments[j];
|
|
3820
|
+
const arrMatch = seg.match(/^([^\[]*)\[(\d+)\]$/);
|
|
3821
|
+
if (arrMatch) {
|
|
3822
|
+
const key = arrMatch[1];
|
|
3823
|
+
const idx = parseInt(arrMatch[2], 10);
|
|
3824
|
+
if (key) current = current[key];
|
|
3825
|
+
if (Array.isArray(current)) current = current[idx];
|
|
3826
|
+
else { current = null; break; }
|
|
3827
|
+
} else {
|
|
3828
|
+
if (typeof current !== 'object' || current === null) { current = null; break; }
|
|
3829
|
+
if (!(seg in current)) current[seg] = {};
|
|
3830
|
+
current = current[seg];
|
|
3831
|
+
}
|
|
3832
|
+
}
|
|
3833
|
+
if (current == null) continue;
|
|
3834
|
+
const last = segments[segments.length - 1];
|
|
3835
|
+
const arrMatch = last.match(/^([^\[]*)\[(\d+)\]$/);
|
|
3836
|
+
if (arrMatch) {
|
|
3837
|
+
const key = arrMatch[1];
|
|
3838
|
+
const idx = parseInt(arrMatch[2], 10);
|
|
3839
|
+
if (key) current = current[key];
|
|
3840
|
+
if (Array.isArray(current)) current[idx] = val;
|
|
3841
|
+
} else {
|
|
3842
|
+
current[last] = val;
|
|
3843
|
+
}
|
|
3844
|
+
}
|
|
3845
|
+
return data;
|
|
3846
|
+
}
|
|
3847
|
+
case 'JSON_INSERT': {
|
|
3848
|
+
// JSON_INSERT(json, path, val, ...) → 仅路径不存在时插入
|
|
3849
|
+
const json = args[0];
|
|
3850
|
+
if (json == null) return null;
|
|
3851
|
+
let data = json;
|
|
3852
|
+
if (typeof json === 'string') {
|
|
3853
|
+
try { data = JSON.parse(json); } catch (e) { return null; }
|
|
3854
|
+
}
|
|
3855
|
+
data = JSON.parse(JSON.stringify(data));
|
|
3856
|
+
for (let i = 1; i + 1 < args.length; i += 2) {
|
|
3857
|
+
const path = String(args[i]);
|
|
3858
|
+
const val = args[i + 1];
|
|
3859
|
+
const p = path.replace(/^\$\.?/, '');
|
|
3860
|
+
if (p === '') continue;
|
|
3861
|
+
const segments = p.split('.');
|
|
3862
|
+
let current = data;
|
|
3863
|
+
let exists = true;
|
|
3864
|
+
for (let j = 0; j < segments.length - 1; j++) {
|
|
3865
|
+
const seg = segments[j];
|
|
3866
|
+
const arrMatch = seg.match(/^([^\[]*)\[(\d+)\]$/);
|
|
3867
|
+
if (arrMatch) {
|
|
3868
|
+
const key = arrMatch[1];
|
|
3869
|
+
const idx = parseInt(arrMatch[2], 10);
|
|
3870
|
+
if (key) current = current[key];
|
|
3871
|
+
if (Array.isArray(current)) current = current[idx];
|
|
3872
|
+
else { exists = false; break; }
|
|
3873
|
+
} else {
|
|
3874
|
+
if (typeof current !== 'object' || current === null || !(seg in current)) { exists = false; break; }
|
|
3875
|
+
current = current[seg];
|
|
3876
|
+
}
|
|
3877
|
+
}
|
|
3878
|
+
if (!exists) continue;
|
|
3879
|
+
const last = segments[segments.length - 1];
|
|
3880
|
+
if (typeof current === 'object' && current !== null && !(last in current)) {
|
|
3881
|
+
current[last] = val;
|
|
3882
|
+
}
|
|
3883
|
+
}
|
|
3884
|
+
return data;
|
|
3885
|
+
}
|
|
3886
|
+
case 'JSON_REMOVE': {
|
|
3887
|
+
// JSON_REMOVE(json, path, ...) → 删除路径
|
|
3888
|
+
const json = args[0];
|
|
3889
|
+
if (json == null) return null;
|
|
3890
|
+
let data = json;
|
|
3891
|
+
if (typeof json === 'string') {
|
|
3892
|
+
try { data = JSON.parse(json); } catch (e) { return null; }
|
|
3893
|
+
}
|
|
3894
|
+
data = JSON.parse(JSON.stringify(data));
|
|
3895
|
+
for (let i = 1; i < args.length; i++) {
|
|
3896
|
+
const path = String(args[i]);
|
|
3897
|
+
const p = path.replace(/^\$\.?/, '');
|
|
3898
|
+
if (p === '') continue;
|
|
3899
|
+
const segments = p.split('.');
|
|
3900
|
+
let current = data;
|
|
3901
|
+
for (let j = 0; j < segments.length - 1; j++) {
|
|
3902
|
+
const seg = segments[j];
|
|
3903
|
+
const arrMatch = seg.match(/^([^\[]*)\[(\d+)\]$/);
|
|
3904
|
+
if (arrMatch) {
|
|
3905
|
+
const key = arrMatch[1];
|
|
3906
|
+
const idx = parseInt(arrMatch[2], 10);
|
|
3907
|
+
if (key) current = current[key];
|
|
3908
|
+
if (Array.isArray(current)) current = current[idx];
|
|
3909
|
+
else { current = null; break; }
|
|
3910
|
+
} else {
|
|
3911
|
+
if (typeof current !== 'object' || current === null) { current = null; break; }
|
|
3912
|
+
current = current[seg];
|
|
3913
|
+
}
|
|
3914
|
+
}
|
|
3915
|
+
if (current == null) continue;
|
|
3916
|
+
const last = segments[segments.length - 1];
|
|
3917
|
+
const arrMatch = last.match(/^([^\[]*)\[(\d+)\]$/);
|
|
3918
|
+
if (arrMatch) {
|
|
3919
|
+
const key = arrMatch[1];
|
|
3920
|
+
const idx = parseInt(arrMatch[2], 10);
|
|
3921
|
+
if (key) current = current[key];
|
|
3922
|
+
if (Array.isArray(current)) current.splice(idx, 1);
|
|
3923
|
+
} else {
|
|
3924
|
+
delete current[last];
|
|
3925
|
+
}
|
|
3926
|
+
}
|
|
3927
|
+
return data;
|
|
3928
|
+
}
|
|
3929
|
+
case 'JSON_UNQUOTE': {
|
|
3930
|
+
// JSON_UNQUOTE(json_val) → 去掉 JSON 字符串的引号
|
|
3931
|
+
const val = args[0];
|
|
3932
|
+
if (val == null) return null;
|
|
3933
|
+
if (typeof val === 'string') {
|
|
3934
|
+
// 如果是 JSON 字符串(带引号),解析它
|
|
3935
|
+
const trimmed = val.trim();
|
|
3936
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
3937
|
+
try { return JSON.parse(trimmed); } catch (e) { return trimmed.slice(1, -1); }
|
|
3938
|
+
}
|
|
3939
|
+
return val;
|
|
3940
|
+
}
|
|
3941
|
+
return val;
|
|
3942
|
+
}
|
|
3943
|
+
case 'JSON_QUOTE': {
|
|
3944
|
+
// JSON_QUOTE(str) → 给字符串加引号
|
|
3945
|
+
const val = args[0];
|
|
3946
|
+
if (val == null) return null;
|
|
3947
|
+
return JSON.stringify(String(val));
|
|
3948
|
+
}
|
|
3949
|
+
case 'JSON_TYPE': {
|
|
3950
|
+
// JSON_TYPE(json[, path]) → 返回类型
|
|
3951
|
+
const json = args[0];
|
|
3952
|
+
const path = args[1];
|
|
3953
|
+
if (json == null) return null;
|
|
3954
|
+
let data = json;
|
|
3955
|
+
if (typeof json === 'string') {
|
|
3956
|
+
try { data = JSON.parse(json); } catch (e) { return null; }
|
|
3957
|
+
}
|
|
3958
|
+
if (path != null) {
|
|
3959
|
+
const p = String(path).replace(/^\$\.?/, '');
|
|
3960
|
+
if (p === '') return getJsonType(data);
|
|
3961
|
+
let current = data;
|
|
3962
|
+
for (const seg of p.split('.')) {
|
|
3963
|
+
if (current == null) return null;
|
|
3964
|
+
const arrMatch = seg.match(/^([^\[]*)\[(\d+)\]$/);
|
|
3965
|
+
if (arrMatch) {
|
|
3966
|
+
const key = arrMatch[1];
|
|
3967
|
+
const idx = parseInt(arrMatch[2], 10);
|
|
3968
|
+
if (key) current = current[key];
|
|
3969
|
+
if (Array.isArray(current)) current = current[idx];
|
|
3970
|
+
else return null;
|
|
3971
|
+
} else {
|
|
3972
|
+
if (typeof current !== 'object' || current === null) return null;
|
|
3973
|
+
current = current[seg];
|
|
3974
|
+
}
|
|
3975
|
+
}
|
|
3976
|
+
return getJsonType(current);
|
|
3977
|
+
}
|
|
3978
|
+
return getJsonType(data);
|
|
3979
|
+
}
|
|
3980
|
+
case 'JSON_DEPTH': {
|
|
3981
|
+
// JSON_DEPTH(json) → 返回深度
|
|
3982
|
+
const json = args[0];
|
|
3983
|
+
if (json == null) return null;
|
|
3984
|
+
let data = json;
|
|
3985
|
+
if (typeof json === 'string') {
|
|
3986
|
+
try { data = JSON.parse(json); } catch (e) { return null; }
|
|
3987
|
+
}
|
|
3988
|
+
function depth(obj) {
|
|
3989
|
+
if (typeof obj !== 'object' || obj === null) return 0;
|
|
3990
|
+
if (Array.isArray(obj)) {
|
|
3991
|
+
if (obj.length === 0) return 1;
|
|
3992
|
+
return 1 + Math.max(...obj.map(depth));
|
|
3993
|
+
}
|
|
3994
|
+
const keys = Object.keys(obj);
|
|
3995
|
+
if (keys.length === 0) return 1;
|
|
3996
|
+
return 1 + Math.max(...keys.map(k => depth(obj[k])));
|
|
3997
|
+
}
|
|
3998
|
+
return depth(data);
|
|
3999
|
+
}
|
|
4000
|
+
case 'JSON_VALID': {
|
|
4001
|
+
// JSON_VALID(json) → 1 if valid JSON, 0 otherwise
|
|
4002
|
+
const val = args[0];
|
|
4003
|
+
if (val == null) return 0;
|
|
4004
|
+
if (typeof val !== 'string') return 1; // 非字符串视为有效
|
|
4005
|
+
try { JSON.parse(val); return 1; } catch (e) { return 0; }
|
|
4006
|
+
}
|
|
4007
|
+
case 'JSON_SEARCH': {
|
|
4008
|
+
// JSON_SEARCH(json, one_or_all, search_str) → 搜索路径
|
|
4009
|
+
const json = args[0];
|
|
4010
|
+
const mode = args[1];
|
|
4011
|
+
const search = args[2];
|
|
4012
|
+
if (json == null || search == null) return null;
|
|
4013
|
+
let data = json;
|
|
4014
|
+
if (typeof json === 'string') {
|
|
4015
|
+
try { data = JSON.parse(json); } catch (e) { return null; }
|
|
4016
|
+
}
|
|
4017
|
+
const searchStr = String(search).toLowerCase();
|
|
4018
|
+
const results = [];
|
|
4019
|
+
function searchObj(obj, currentPath) {
|
|
4020
|
+
if (typeof obj === 'string' && obj.toLowerCase().includes(searchStr)) {
|
|
4021
|
+
results.push(currentPath);
|
|
4022
|
+
} else if (Array.isArray(obj)) {
|
|
4023
|
+
obj.forEach((item, i) => searchObj(item, currentPath + '[' + i + ']'));
|
|
4024
|
+
} else if (typeof obj === 'object' && obj !== null) {
|
|
4025
|
+
for (const k of Object.keys(obj)) {
|
|
4026
|
+
searchObj(obj[k], currentPath ? currentPath + '.' + k : '$.' + k);
|
|
4027
|
+
}
|
|
4028
|
+
}
|
|
4029
|
+
}
|
|
4030
|
+
searchObj(data, '$');
|
|
4031
|
+
if (results.length === 0) return null;
|
|
4032
|
+
if (mode === 'one') return results[0];
|
|
4033
|
+
return results;
|
|
4034
|
+
}
|
|
3678
4035
|
case 'JSON_CONTAINS': {
|
|
3679
4036
|
// JSON_CONTAINS(json, value) → 1 or 0
|
|
3680
4037
|
const json = args[0];
|
|
@@ -3866,10 +4223,14 @@ function evaluateExpr(expr, row, ctx) {
|
|
|
3866
4223
|
}
|
|
3867
4224
|
case 'in': {
|
|
3868
4225
|
const v = resolveOperand(expr.operand, row, ctx);
|
|
4226
|
+
if (v === null || v === undefined) return null; // NULL IN (...) → NULL(三值逻辑)
|
|
3869
4227
|
if (expr.subquery && expr.subquery._values !== undefined) expr.list = expr.subquery._values;
|
|
3870
|
-
if (!expr.list) return false;
|
|
4228
|
+
if (!expr.list) return expr.not ? true : false;
|
|
4229
|
+
// 列表中出现 NULL 时:三值逻辑 → NULL IN (1,2,NULL) 为 NULL(UNKNOWN)
|
|
4230
|
+
const hasNull = expr.list.some(x => x === null || x === undefined);
|
|
4231
|
+
if (hasNull) return null;
|
|
3871
4232
|
const r = expr.list.some(x => x === v || String(x) === String(v));
|
|
3872
|
-
return expr.not ? !r : r;
|
|
4233
|
+
return expr.not ? !r : r;
|
|
3873
4234
|
}
|
|
3874
4235
|
case 'like': {
|
|
3875
4236
|
const v = resolveOperand(expr.operand, row, ctx);
|
|
@@ -6394,9 +6755,25 @@ class SQLExecutor {
|
|
|
6394
6755
|
|
|
6395
6756
|
// 分组聚合
|
|
6396
6757
|
if (statement.groupBy) {
|
|
6758
|
+
// MySQL 支持 GROUP BY alias,而 alias 指向 SELECT 列表的输出列名。
|
|
6759
|
+
// 解析时 groupBy[] 存的是原始字符串(如 "b"),必须先在 SELECT 别名里查找:
|
|
6760
|
+
// - 找到 → 取输出行里的 alias 值(因为 alias 已求出值了)
|
|
6761
|
+
// - 找不到 → 当列名取输入行里对应列的值(标准 SQL 行为)
|
|
6762
|
+
// 此前全用 resolveOperand({type:'column', name:g}) 按列名取,导致 GROUP BY b
|
|
6763
|
+
// 取 row['b'],而原始行里没有 b 列(b 是别名),全返回 undefined 并成一组。
|
|
6764
|
+
const aliases = {};
|
|
6765
|
+
if (statement.columns) {
|
|
6766
|
+
for (const c of statement.columns) {
|
|
6767
|
+
if (c.alias) aliases[c.alias.toLowerCase()] = true;
|
|
6768
|
+
}
|
|
6769
|
+
}
|
|
6397
6770
|
const groups = new Map();
|
|
6398
6771
|
for (const row of rows) {
|
|
6399
|
-
const key = JSON.stringify(statement.groupBy.map(g =>
|
|
6772
|
+
const key = JSON.stringify(statement.groupBy.map(g => {
|
|
6773
|
+
const key = String(g).toLowerCase();
|
|
6774
|
+
if (aliases[key] && row[key] !== undefined) return row[key];
|
|
6775
|
+
return resolveOperand({ type: 'column', name: g }, row, this.ctx);
|
|
6776
|
+
}));
|
|
6400
6777
|
if (!groups.has(key)) groups.set(key, []);
|
|
6401
6778
|
groups.get(key).push(row);
|
|
6402
6779
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jsql-neo",
|
|
3
|
-
"version": "6.4.0-beta.
|
|
3
|
+
"version": "6.4.0-beta.16",
|
|
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",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
],
|
|
47
47
|
"scripts": {
|
|
48
48
|
"postinstall": "node postinstall.js",
|
|
49
|
-
"test": "node test/smoke.js && node test/sql-parser.test.js && node test/readme-audit.test.js && node test/query-opt.test.js && node test/wal-recovery.test.js && node test/mysql-compat-hijack.test.js && node test/dml-constraints.test.js",
|
|
49
|
+
"test": "node test/smoke.js && node test/json-functions.test.js && node test/sql-parser.test.js && node test/readme-audit.test.js && node test/query-opt.test.js && node test/wal-recovery.test.js && node test/mysql-compat-hijack.test.js && node test/dml-constraints.test.js",
|
|
50
50
|
"test:parser": "node test/sql-parser.test.js",
|
|
51
51
|
"test:btree": "node test/btree.test.js",
|
|
52
52
|
"test:native": "node test/native.test.js",
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// © Vexify 2026 All Rights Reserved.
|
|
2
|
+
/**
|
|
3
|
+
* Bug Hunt Tests — v6.4.0-beta.16
|
|
4
|
+
* Tests for bugs found during code review:
|
|
5
|
+
* 1. BTree _borrowFromLeft wrong parent key (leaf nodes)
|
|
6
|
+
* 2. Query _applyOrderBy missing NULL handling
|
|
7
|
+
* 3. BTree prefix incorrect early return for numeric keys
|
|
8
|
+
*
|
|
9
|
+
* node test/bug-hunt.test.js
|
|
10
|
+
*/
|
|
11
|
+
const BTree = require('../lib/btree');
|
|
12
|
+
const Query = require('../lib/query');
|
|
13
|
+
|
|
14
|
+
let passed = 0, failed = 0;
|
|
15
|
+
function ok(name, cond, extra) {
|
|
16
|
+
if (cond) { passed++; }
|
|
17
|
+
else { failed++; console.log('[FAIL]', name, extra !== undefined ? '-> ' + JSON.stringify(extra) : ''); }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ============================================================
|
|
21
|
+
// Helper: Mock Table for Query tests
|
|
22
|
+
// ============================================================
|
|
23
|
+
function createMockTable(name, rows, schema = {}) {
|
|
24
|
+
const table = {
|
|
25
|
+
_name: name,
|
|
26
|
+
_rows: rows,
|
|
27
|
+
_schema: schema,
|
|
28
|
+
_btrees: {},
|
|
29
|
+
_indexes: {},
|
|
30
|
+
_applyFilterOptimized(rows, query) {
|
|
31
|
+
return rows.filter(row => {
|
|
32
|
+
for (const [field, condition] of Object.entries(query)) {
|
|
33
|
+
const value = row[field];
|
|
34
|
+
if (condition !== null && typeof condition === 'object' && !Array.isArray(condition)) {
|
|
35
|
+
for (const [op, target] of Object.entries(condition)) {
|
|
36
|
+
if (op === '$gt' && !(value > target)) return false;
|
|
37
|
+
if (op === '$gte' && !(value >= target)) return false;
|
|
38
|
+
if (op === '$lt' && !(value < target)) return false;
|
|
39
|
+
if (op === '$lte' && !(value <= target)) return false;
|
|
40
|
+
if (op === '$eq' && value !== target) return false;
|
|
41
|
+
if (op === '$ne' && value === target) return false;
|
|
42
|
+
if (op === '$in' && !target.includes(value)) return false;
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
if (value !== condition) return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
_applyFilter(rows, query) { return this._applyFilterOptimized(rows, query); },
|
|
52
|
+
_matchRow(row, query) {
|
|
53
|
+
for (const [field, condition] of Object.entries(query)) {
|
|
54
|
+
const value = row[field];
|
|
55
|
+
if (condition !== null && typeof condition === 'object' && !Array.isArray(condition)) {
|
|
56
|
+
for (const [op, target] of Object.entries(condition)) {
|
|
57
|
+
if (op === '$gt' && !(value > target)) return false;
|
|
58
|
+
if (op === '$gte' && !(value >= target)) return false;
|
|
59
|
+
if (op === '$lt' && !(value < target)) return false;
|
|
60
|
+
if (op === '$lte' && !(value <= target)) return false;
|
|
61
|
+
if (op === '$eq' && value !== target) return false;
|
|
62
|
+
if (op === '$ne' && value === target) return false;
|
|
63
|
+
if (op === '$in' && !target.includes(value)) return false;
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
if (value !== condition) return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return true;
|
|
70
|
+
},
|
|
71
|
+
_getVisibleRows() { return this._rows; },
|
|
72
|
+
_db: {
|
|
73
|
+
_logSlowQuery() {},
|
|
74
|
+
_tables: {},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
return table;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ============================================================
|
|
81
|
+
// Bug 1: BTree _borrowFromLeft wrong parent key
|
|
82
|
+
// ============================================================
|
|
83
|
+
console.log('\n=== Bug 1: BTree _borrowFromLeft parent key ===');
|
|
84
|
+
|
|
85
|
+
{
|
|
86
|
+
const tree = new BTree(4, false);
|
|
87
|
+
for (let i = 0; i < 20; i++) tree.insert(i, i);
|
|
88
|
+
|
|
89
|
+
// Delete from the right side to trigger left borrows
|
|
90
|
+
for (let i = 19; i >= 10; i--) tree.remove(i, i);
|
|
91
|
+
|
|
92
|
+
// After deletions, search for remaining keys should work
|
|
93
|
+
let allFound = true;
|
|
94
|
+
for (let i = 0; i < 10; i++) {
|
|
95
|
+
const result = tree.search(i);
|
|
96
|
+
if (result.length === 0 || !result.includes(i)) { allFound = false; break; }
|
|
97
|
+
}
|
|
98
|
+
ok('Bug1: search after left borrows', allFound);
|
|
99
|
+
|
|
100
|
+
// Range query should return correct results
|
|
101
|
+
const rangeResult = tree.range(0, 9);
|
|
102
|
+
ok('Bug1: range [0,9] returns 10 items', rangeResult.length === 10, { len: rangeResult.length });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
{
|
|
106
|
+
const tree = new BTree(4, false);
|
|
107
|
+
for (let i = 0; i < 50; i++) tree.insert(i, i);
|
|
108
|
+
|
|
109
|
+
// Delete every other key to force rebalancing
|
|
110
|
+
for (let i = 1; i < 50; i += 2) tree.remove(i, i);
|
|
111
|
+
|
|
112
|
+
// Verify all remaining keys are searchable
|
|
113
|
+
let allEvenFound = true;
|
|
114
|
+
for (let i = 0; i < 50; i += 2) {
|
|
115
|
+
const result = tree.search(i);
|
|
116
|
+
if (result.length === 0 || !result.includes(i)) { allEvenFound = false; break; }
|
|
117
|
+
}
|
|
118
|
+
ok('Bug1: even keys after heavy deletions', allEvenFound);
|
|
119
|
+
|
|
120
|
+
// Verify deleted keys are gone
|
|
121
|
+
let allOddGone = true;
|
|
122
|
+
for (let i = 1; i < 50; i += 2) {
|
|
123
|
+
const result = tree.search(i);
|
|
124
|
+
if (result.length !== 0) { allOddGone = false; break; }
|
|
125
|
+
}
|
|
126
|
+
ok('Bug1: odd keys deleted', allOddGone);
|
|
127
|
+
|
|
128
|
+
// Greater than query
|
|
129
|
+
const gtResult = tree.greaterThan(25);
|
|
130
|
+
ok('Bug1: greaterThan(25) finds keys', gtResult.length > 0);
|
|
131
|
+
|
|
132
|
+
// Less than query
|
|
133
|
+
const ltResult = tree.lessThan(25);
|
|
134
|
+
ok('Bug1: lessThan(25) finds keys', ltResult.length > 0);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
{
|
|
138
|
+
const tree = new BTree(4, true);
|
|
139
|
+
for (let i = 0; i < 30; i++) tree.insert(i * 10, i);
|
|
140
|
+
|
|
141
|
+
// Delete from the right half to trigger left borrows
|
|
142
|
+
for (let i = 29; i >= 15; i--) tree.remove(i * 10, i);
|
|
143
|
+
|
|
144
|
+
// Verify remaining keys
|
|
145
|
+
let allFound = true;
|
|
146
|
+
for (let i = 0; i < 15; i++) {
|
|
147
|
+
const result = tree.search(i * 10);
|
|
148
|
+
if (result.length !== 1 || result[0] !== i) { allFound = false; break; }
|
|
149
|
+
}
|
|
150
|
+
ok('Bug1: unique index with left borrows', allFound);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ============================================================
|
|
154
|
+
// Bug 2: Query _applyOrderBy missing NULL handling
|
|
155
|
+
// ============================================================
|
|
156
|
+
console.log('\n=== Bug 2: Query _applyOrderBy NULL handling ===');
|
|
157
|
+
|
|
158
|
+
{
|
|
159
|
+
const rows = [
|
|
160
|
+
{ id: 1, name: 'Alice', age: 30 },
|
|
161
|
+
{ id: 2, name: 'Bob', age: null },
|
|
162
|
+
{ id: 3, name: 'Charlie', age: 25 },
|
|
163
|
+
{ id: 4, name: 'Dave', age: null },
|
|
164
|
+
];
|
|
165
|
+
const table = createMockTable('users', rows);
|
|
166
|
+
const query = new Query(table);
|
|
167
|
+
query.orderBy('age', 'asc');
|
|
168
|
+
const result = query.get();
|
|
169
|
+
|
|
170
|
+
ok('Bug2: ASC nulls first (1st)', result[0].age === null);
|
|
171
|
+
ok('Bug2: ASC nulls first (2nd)', result[1].age === null);
|
|
172
|
+
ok('Bug2: ASC 25 before 30', result[2].age === 25);
|
|
173
|
+
ok('Bug2: ASC 30 last', result[3].age === 30);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
{
|
|
177
|
+
const rows = [
|
|
178
|
+
{ id: 1, name: 'Alice', age: 30 },
|
|
179
|
+
{ id: 2, name: 'Bob', age: null },
|
|
180
|
+
{ id: 3, name: 'Charlie', age: 25 },
|
|
181
|
+
{ id: 4, name: 'Dave', age: null },
|
|
182
|
+
];
|
|
183
|
+
const table = createMockTable('users', rows);
|
|
184
|
+
const query = new Query(table);
|
|
185
|
+
query.orderBy('age', 'desc');
|
|
186
|
+
const result = query.get();
|
|
187
|
+
|
|
188
|
+
ok('Bug2: DESC nulls last (1st=30)', result[0].age === 30);
|
|
189
|
+
ok('Bug2: DESC nulls last (2nd=25)', result[1].age === 25);
|
|
190
|
+
ok('Bug2: DESC nulls last (3rd=null)', result[2].age === null);
|
|
191
|
+
ok('Bug2: DESC nulls last (4th=null)', result[3].age === null);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
{
|
|
195
|
+
const rows = [
|
|
196
|
+
{ id: 1, name: 'Alice', age: null },
|
|
197
|
+
{ id: 2, name: 'Bob', age: null },
|
|
198
|
+
{ id: 3, name: 'Charlie', age: null },
|
|
199
|
+
];
|
|
200
|
+
const table = createMockTable('users', rows);
|
|
201
|
+
const query = new Query(table);
|
|
202
|
+
query.orderBy('age', 'asc');
|
|
203
|
+
const result = query.get();
|
|
204
|
+
|
|
205
|
+
ok('Bug2: all nulls ASC', result.length === 3 && result.every(r => r.age === null));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
{
|
|
209
|
+
const rows = [
|
|
210
|
+
{ id: 1, name: 'Alice', score: 85 },
|
|
211
|
+
{ id: 2, name: 'Bob' }, // score is undefined
|
|
212
|
+
{ id: 3, name: 'Charlie', score: null },
|
|
213
|
+
{ id: 4, name: 'Dave', score: 90 },
|
|
214
|
+
];
|
|
215
|
+
const table = createMockTable('users', rows);
|
|
216
|
+
const query = new Query(table);
|
|
217
|
+
query.orderBy('score', 'asc');
|
|
218
|
+
const result = query.get();
|
|
219
|
+
|
|
220
|
+
// Both null and undefined sort as null; stable sort preserves original order
|
|
221
|
+
// Bob (undefined) was at index 1, Charlie (null) at index 2
|
|
222
|
+
ok('Bug2: mixed null/undefined ASC (1st=null/undefined)', result[0].score == null);
|
|
223
|
+
ok('Bug2: mixed null/undefined ASC (2nd=null/undefined)', result[1].score == null);
|
|
224
|
+
ok('Bug2: mixed null/undefined ASC (3rd=85)', result[2].score === 85);
|
|
225
|
+
ok('Bug2: mixed null/undefined ASC (4th=90)', result[3].score === 90);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
{
|
|
229
|
+
const rows = [
|
|
230
|
+
{ id: 1, department: 'Engineering', salary: 5000 },
|
|
231
|
+
{ id: 2, department: 'Engineering', salary: null },
|
|
232
|
+
{ id: 3, department: 'Sales', salary: 4000 },
|
|
233
|
+
{ id: 4, department: 'Sales', salary: null },
|
|
234
|
+
{ id: 5, department: 'Engineering', salary: 6000 },
|
|
235
|
+
];
|
|
236
|
+
const table = createMockTable('employees', rows);
|
|
237
|
+
const query = new Query(table);
|
|
238
|
+
query.orderBy('department', 'asc').orderBy('salary', 'asc');
|
|
239
|
+
const result = query.get();
|
|
240
|
+
|
|
241
|
+
ok('Bug2: multi-field orderBy', result.length === 5);
|
|
242
|
+
ok('Bug2: Eng null salary first', result[0].department === 'Engineering' && result[0].salary === null);
|
|
243
|
+
ok('Bug2: Eng 5000 second', result[1].department === 'Engineering' && result[1].salary === 5000);
|
|
244
|
+
ok('Bug2: Eng 6000 third', result[2].department === 'Engineering' && result[2].salary === 6000);
|
|
245
|
+
ok('Bug2: Sales null salary fourth', result[3].department === 'Sales' && result[3].salary === null);
|
|
246
|
+
ok('Bug2: Sales 4000 fifth', result[4].department === 'Sales' && result[4].salary === 4000);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ============================================================
|
|
250
|
+
// Bug 3: BTree prefix incorrect early return for numeric keys
|
|
251
|
+
// ============================================================
|
|
252
|
+
console.log('\n=== Bug 3: BTree prefix with numeric keys ===');
|
|
253
|
+
|
|
254
|
+
{
|
|
255
|
+
const tree = new BTree(64, false);
|
|
256
|
+
const keys = [1, 2, 10, 20, 100, 200, 1000, 2000];
|
|
257
|
+
for (const key of keys) tree.insert(key, key);
|
|
258
|
+
|
|
259
|
+
const result = tree.prefix('1');
|
|
260
|
+
ok('Bug3: prefix "1" finds 1', result.includes(1));
|
|
261
|
+
ok('Bug3: prefix "1" finds 10', result.includes(10));
|
|
262
|
+
ok('Bug3: prefix "1" finds 100', result.includes(100));
|
|
263
|
+
ok('Bug3: prefix "1" finds 1000', result.includes(1000));
|
|
264
|
+
ok('Bug3: prefix "1" excludes 2', !result.includes(2));
|
|
265
|
+
ok('Bug3: prefix "1" excludes 20', !result.includes(20));
|
|
266
|
+
ok('Bug3: prefix "1" excludes 200', !result.includes(200));
|
|
267
|
+
ok('Bug3: prefix "1" excludes 2000', !result.includes(2000));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
{
|
|
271
|
+
const tree = new BTree(64, false);
|
|
272
|
+
const keys = [2, 20, 200, 2000, 3, 30, 300];
|
|
273
|
+
for (const key of keys) tree.insert(key, key);
|
|
274
|
+
|
|
275
|
+
const result = tree.prefix('2');
|
|
276
|
+
ok('Bug3: prefix "2" finds 2', result.includes(2));
|
|
277
|
+
ok('Bug3: prefix "2" finds 20', result.includes(20));
|
|
278
|
+
ok('Bug3: prefix "2" finds 200', result.includes(200));
|
|
279
|
+
ok('Bug3: prefix "2" finds 2000', result.includes(2000));
|
|
280
|
+
ok('Bug3: prefix "2" excludes 3', !result.includes(3));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
{
|
|
284
|
+
const tree = new BTree(64, false);
|
|
285
|
+
const keys = ['apple', 'application', 'apply', 'banana', 'cherry'];
|
|
286
|
+
for (const key of keys) tree.insert(key, key);
|
|
287
|
+
|
|
288
|
+
const result = tree.prefix('app');
|
|
289
|
+
ok('Bug3: string prefix finds apple', result.includes('apple'));
|
|
290
|
+
ok('Bug3: string prefix finds application', result.includes('application'));
|
|
291
|
+
ok('Bug3: string prefix finds apply', result.includes('apply'));
|
|
292
|
+
ok('Bug3: string prefix excludes banana', !result.includes('banana'));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
{
|
|
296
|
+
const tree = new BTree(4, false);
|
|
297
|
+
for (let i = 1; i <= 100; i++) tree.insert(i, i);
|
|
298
|
+
|
|
299
|
+
// Prefix '1' should match: 1, 10-19, 100
|
|
300
|
+
const result = tree.prefix('1');
|
|
301
|
+
ok('Bug3: multi-leaf prefix "1" finds 1', result.includes(1));
|
|
302
|
+
ok('Bug3: multi-leaf prefix "1" finds 10', result.includes(10));
|
|
303
|
+
ok('Bug3: multi-leaf prefix "1" finds 11', result.includes(11));
|
|
304
|
+
ok('Bug3: multi-leaf prefix "1" finds 19', result.includes(19));
|
|
305
|
+
ok('Bug3: multi-leaf prefix "1" finds 100', result.includes(100));
|
|
306
|
+
|
|
307
|
+
// Prefix '9' should match: 9, 90-99
|
|
308
|
+
const result9 = tree.prefix('9');
|
|
309
|
+
ok('Bug3: multi-leaf prefix "9" finds 9', result9.includes(9));
|
|
310
|
+
ok('Bug3: multi-leaf prefix "9" finds 90', result9.includes(90));
|
|
311
|
+
ok('Bug3: multi-leaf prefix "9" finds 99', result9.includes(99));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
{
|
|
315
|
+
const tree = new BTree(64, false);
|
|
316
|
+
tree.insert(1, 1);
|
|
317
|
+
tree.insert(2, 2);
|
|
318
|
+
tree.insert(3, 3);
|
|
319
|
+
|
|
320
|
+
const result = tree.prefix('9');
|
|
321
|
+
ok('Bug3: non-matching prefix returns empty', result.length === 0);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
{
|
|
325
|
+
const tree = new BTree(64, false);
|
|
326
|
+
tree.insert('a.b', 1);
|
|
327
|
+
tree.insert('a*b', 2);
|
|
328
|
+
tree.insert('a+b', 3);
|
|
329
|
+
tree.insert('abc', 4);
|
|
330
|
+
|
|
331
|
+
const result = tree.prefix('a');
|
|
332
|
+
ok('Bug3: special chars prefix finds a.b', result.includes(1));
|
|
333
|
+
ok('Bug3: special chars prefix finds a*b', result.includes(2));
|
|
334
|
+
ok('Bug3: special chars prefix finds a+b', result.includes(3));
|
|
335
|
+
ok('Bug3: special chars prefix finds abc', result.includes(4));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ============================================================
|
|
339
|
+
// Summary
|
|
340
|
+
// ============================================================
|
|
341
|
+
console.log('\n========================================');
|
|
342
|
+
console.log(`Results: ${passed} passed, ${failed} failed`);
|
|
343
|
+
if (failed > 0) process.exit(1);
|
|
344
|
+
else console.log('All tests passed!');
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* JSON function tests for JSQL-neo
|
|
3
|
+
* node test/json-functions.test.js
|
|
4
|
+
* Covers: JSON_EXTRACT path fix, JSON_KEYS, JSON_LENGTH, JSON_MERGE,
|
|
5
|
+
* JSON_MERGE_PATCH, JSON_MERGE_PRESERVE, JSON_OBJECTAGG, JSON_ARRAYAGG,
|
|
6
|
+
* JSON_SET, JSON_INSERT, JSON_REMOVE, JSON_UNQUOTE, JSON_QUOTE,
|
|
7
|
+
* JSON_TYPE, JSON_DEPTH, JSON_VALID, JSON_SEARCH
|
|
8
|
+
*/
|
|
9
|
+
const { executeSQL } = require('../lib/sql.js');
|
|
10
|
+
|
|
11
|
+
let pass = 0, fail = 0;
|
|
12
|
+
const ok = (name, cond, extra) => {
|
|
13
|
+
if (cond) { pass++; console.log('[OK]', name); }
|
|
14
|
+
else { fail++; console.log('[FAIL]', name, extra !== undefined ? '→ ' + JSON.stringify(extra) : ''); }
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const engine = {
|
|
18
|
+
hasTable: () => true,
|
|
19
|
+
truncate: async () => {},
|
|
20
|
+
flush: async () => {},
|
|
21
|
+
find: async () => [],
|
|
22
|
+
getTableSchema: async () => ({}),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Helper: execute SQL and return first row as object
|
|
26
|
+
async function query(sql) {
|
|
27
|
+
const r = await executeSQL(engine, sql);
|
|
28
|
+
if (!r.rows || r.rows.length === 0) return null;
|
|
29
|
+
const row = {};
|
|
30
|
+
r.columns.forEach((col, i) => { row[col] = r.rows[0][i]; });
|
|
31
|
+
return row;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
(async () => {
|
|
35
|
+
// ===== Bug 1: JSON_EXTRACT path parsing =====
|
|
36
|
+
{
|
|
37
|
+
const r1 = await query('SELECT JSON_EXTRACT(\'{"name":"test"}\', \'$.name\') AS val');
|
|
38
|
+
ok('JSON_EXTRACT $.name returns "test"', r1 && r1.val === 'test', r1 && r1.val);
|
|
39
|
+
|
|
40
|
+
const r2 = await query('SELECT JSON_EXTRACT(\'{"a":{"b":42}}\', \'$.a.b\') AS val');
|
|
41
|
+
ok('JSON_EXTRACT $.a.b returns 42', r2 && r2.val === 42, r2 && r2.val);
|
|
42
|
+
|
|
43
|
+
const r3 = await query('SELECT JSON_EXTRACT(\'{"items":[10,20,30]}\', \'$.items[1]\') AS val');
|
|
44
|
+
ok('JSON_EXTRACT $.items[1] returns 20', r3 && r3.val === 20, r3 && r3.val);
|
|
45
|
+
|
|
46
|
+
const r4 = await query('SELECT JSON_EXTRACT(\'{"name":"test"}\', \'$\') AS val');
|
|
47
|
+
ok('JSON_EXTRACT $ returns whole object', r4 && typeof r4.val === 'object' && r4.val.name === 'test', r4 && r4.val);
|
|
48
|
+
|
|
49
|
+
const r5 = await query('SELECT JSON_EXTRACT(\'{"name":"test"}\', \'$.nonexistent\') AS val');
|
|
50
|
+
ok('JSON_EXTRACT $.nonexistent returns null', r5 && (r5.val === null || r5.val === undefined), r5 && r5.val);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ===== JSON_KEYS =====
|
|
54
|
+
{
|
|
55
|
+
const r = await query('SELECT JSON_KEYS(\'{"a":1,"b":2,"c":3}\') AS val');
|
|
56
|
+
ok('JSON_KEYS returns all keys', r && Array.isArray(r.val) && r.val.length === 3 && r.val.includes('a') && r.val.includes('b') && r.val.includes('c'), r && r.val);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ===== JSON_LENGTH =====
|
|
60
|
+
{
|
|
61
|
+
const r1 = await query('SELECT JSON_LENGTH(\'{"a":1,"b":2,"c":3}\') AS val');
|
|
62
|
+
ok('JSON_LENGTH object returns 3', r1 && r1.val === 3, r1 && r1.val);
|
|
63
|
+
|
|
64
|
+
const r2 = await query('SELECT JSON_LENGTH(\'[1,2,3,4,5]\') AS val');
|
|
65
|
+
ok('JSON_LENGTH array returns 5', r2 && r2.val === 5, r2 && r2.val);
|
|
66
|
+
|
|
67
|
+
const r3 = await query('SELECT JSON_LENGTH(\'{"a":{"b":1,"c":2}}\', \'$.a\') AS val');
|
|
68
|
+
ok('JSON_LENGTH with path returns 2', r3 && r3.val === 2, r3 && r3.val);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ===== JSON_MERGE_PRESERVE =====
|
|
72
|
+
{
|
|
73
|
+
const r = await query('SELECT JSON_MERGE_PRESERVE(\'{"a":1}\', \'{"b":2}\') AS val');
|
|
74
|
+
ok('JSON_MERGE_PRESERVE merges objects', r && typeof r.val === 'object' && r.val.a === 1 && r.val.b === 2, r && r.val);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ===== JSON_MERGE_PATCH =====
|
|
78
|
+
{
|
|
79
|
+
const r = await query('SELECT JSON_MERGE_PATCH(\'{"a":1,"b":2}\', \'{"b":99,"c":3}\') AS val');
|
|
80
|
+
ok('JSON_MERGE_PATCH patches objects', r && typeof r.val === 'object' && r.val.a === 1 && r.val.b === 99 && r.val.c === 3, r && r.val);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ===== JSON_OBJECTAGG =====
|
|
84
|
+
{
|
|
85
|
+
const r = await query('SELECT JSON_OBJECTAGG(\'key\', \'value\') AS val');
|
|
86
|
+
ok('JSON_OBJECTAGG creates object', r && typeof r.val === 'object' && r.val.key === 'value', r && r.val);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ===== JSON_ARRAYAGG =====
|
|
90
|
+
{
|
|
91
|
+
const r = await query('SELECT JSON_ARRAYAGG(42) AS val');
|
|
92
|
+
ok('JSON_ARRAYAGG creates array', r && Array.isArray(r.val) && r.val.length === 1 && r.val[0] === 42, r && r.val);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ===== JSON_SET =====
|
|
96
|
+
{
|
|
97
|
+
const r = await query('SELECT JSON_SET(\'{"a":1}\', \'$.b\', 2) AS val');
|
|
98
|
+
ok('JSON_SET adds new key', r && typeof r.val === 'object' && r.val.a === 1 && r.val.b === 2, r && r.val);
|
|
99
|
+
|
|
100
|
+
const r2 = await query('SELECT JSON_SET(\'{"a":1}\', \'$.a\', 99) AS val');
|
|
101
|
+
ok('JSON_SET updates existing key', r2 && typeof r2.val === 'object' && r2.val.a === 99, r2 && r2.val);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ===== JSON_INSERT =====
|
|
105
|
+
{
|
|
106
|
+
const r = await query('SELECT JSON_INSERT(\'{"a":1}\', \'$.b\', 2) AS val');
|
|
107
|
+
ok('JSON_INSERT adds new key', r && typeof r.val === 'object' && r.val.a === 1 && r.val.b === 2, r && r.val);
|
|
108
|
+
|
|
109
|
+
const r2 = await query('SELECT JSON_INSERT(\'{"a":1}\', \'$.a\', 99) AS val');
|
|
110
|
+
ok('JSON_INSERT does not overwrite existing', r2 && typeof r2.val === 'object' && r2.val.a === 1, r2 && r2.val);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ===== JSON_REMOVE =====
|
|
114
|
+
{
|
|
115
|
+
const r = await query('SELECT JSON_REMOVE(\'{"a":1,"b":2}\', \'$.b\') AS val');
|
|
116
|
+
ok('JSON_REMOVE deletes key', r && typeof r.val === 'object' && r.val.a === 1 && !('b' in r.val), r && r.val);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ===== JSON_UNQUOTE =====
|
|
120
|
+
{
|
|
121
|
+
const r = await query('SELECT JSON_UNQUOTE(\'"hello"\') AS val');
|
|
122
|
+
ok('JSON_UNQUOTE removes quotes', r && r.val === 'hello', r && r.val);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ===== JSON_QUOTE =====
|
|
126
|
+
{
|
|
127
|
+
const r = await query('SELECT JSON_QUOTE(\'hello\') AS val');
|
|
128
|
+
ok('JSON_QUOTE adds quotes', r && r.val === '"hello"', r && r.val);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ===== JSON_TYPE =====
|
|
132
|
+
{
|
|
133
|
+
const r1 = await query('SELECT JSON_TYPE(\'{"a":1}\') AS val');
|
|
134
|
+
ok('JSON_TYPE object returns "object"', r1 && r1.val === 'object', r1 && r1.val);
|
|
135
|
+
|
|
136
|
+
const r2 = await query('SELECT JSON_TYPE(\'[1,2,3]\') AS val');
|
|
137
|
+
ok('JSON_TYPE array returns "array"', r2 && r2.val === 'array', r2 && r2.val);
|
|
138
|
+
|
|
139
|
+
const r3 = await query('SELECT JSON_TYPE(\'"hello"\') AS val');
|
|
140
|
+
ok('JSON_TYPE string returns "string"', r3 && r3.val === 'string', r3 && r3.val);
|
|
141
|
+
|
|
142
|
+
const r4 = await query('SELECT JSON_TYPE(\'42\') AS val');
|
|
143
|
+
ok('JSON_TYPE integer returns "integer"', r4 && r4.val === 'integer', r4 && r4.val);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ===== JSON_DEPTH =====
|
|
147
|
+
{
|
|
148
|
+
const r1 = await query('SELECT JSON_DEPTH(\'{"a":1}\') AS val');
|
|
149
|
+
ok('JSON_DEPTH flat object returns 1', r1 && r1.val === 1, r1 && r1.val);
|
|
150
|
+
|
|
151
|
+
const r2 = await query('SELECT JSON_DEPTH(\'{"a":{"b":1}}\') AS val');
|
|
152
|
+
ok('JSON_DEPTH nested object returns 2', r2 && r2.val === 2, r2 && r2.val);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ===== JSON_VALID =====
|
|
156
|
+
{
|
|
157
|
+
const r1 = await query('SELECT JSON_VALID(\'{"a":1}\') AS val');
|
|
158
|
+
ok('JSON_VALID valid JSON returns 1', r1 && r1.val === 1, r1 && r1.val);
|
|
159
|
+
|
|
160
|
+
const r2 = await query('SELECT JSON_VALID(\'not json\') AS val');
|
|
161
|
+
ok('JSON_VALID invalid JSON returns 0', r2 && r2.val === 0, r2 && r2.val);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ===== JSON_SEARCH =====
|
|
165
|
+
{
|
|
166
|
+
const r = await query('SELECT JSON_SEARCH(\'{"name":"test","desc":"hello world"}\', \'all\', \'test\') AS val');
|
|
167
|
+
ok('JSON_SEARCH finds matching value', r && Array.isArray(r.val) && r.val.length > 0, r && r.val);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
console.log(`\n${pass} passed, ${fail} failed`);
|
|
171
|
+
process.exit(fail > 0 ? 1 : 0);
|
|
172
|
+
})();
|