jsql-neo 6.4.0-beta.1 → 6.4.0-beta.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/README.md +1 -1
- package/index.js +1 -1
- package/lib/database.js +137 -11
- package/lib/native_client.js +44 -1
- package/lib/sql.js +32 -18
- package/lib/sqlite_compat.js +2 -1
- package/lib/wasm_client.js +44 -1
- package/lib/web_ui.js +14 -8
- package/native/jsql-neo-native.node +0 -0
- package/package.json +2 -2
- package/test/coverage.js +3 -1
- package/test/dml-constraints.test.js +16 -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.3** — 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/database.js
CHANGED
|
@@ -56,6 +56,35 @@ function parseFieldShorthand(str) {
|
|
|
56
56
|
return def;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* 深度相等比较两个行对象(用于无主键表的行匹配)。
|
|
61
|
+
* 剥离内部字段(_rid, _deleted)后逐字段比较。
|
|
62
|
+
*/
|
|
63
|
+
function _rowDeepEqual(a, b) {
|
|
64
|
+
if (a === b) return true;
|
|
65
|
+
if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false;
|
|
66
|
+
const strip = (obj) => {
|
|
67
|
+
const out = {};
|
|
68
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
69
|
+
if (k === '_rid' || k === '_deleted') continue;
|
|
70
|
+
out[k] = v;
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
};
|
|
74
|
+
const sa = strip(a), sb = strip(b);
|
|
75
|
+
const ka = Object.keys(sa), kb = Object.keys(sb);
|
|
76
|
+
if (ka.length !== kb.length) return false;
|
|
77
|
+
for (const k of ka) {
|
|
78
|
+
if (!Object.prototype.hasOwnProperty.call(sb, k)) return false;
|
|
79
|
+
const va = sa[k], vb = sb[k];
|
|
80
|
+
if (va === vb) continue;
|
|
81
|
+
if (va === null || va === undefined || vb === null || vb === undefined) return false;
|
|
82
|
+
if (typeof va === 'number' && typeof vb === 'number' && va === vb) continue;
|
|
83
|
+
if (String(va) !== String(vb)) return false;
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
59
88
|
class Database {
|
|
60
89
|
/**
|
|
61
90
|
* @param {string} filePath - 数据库文件/目录路径,传 null 或 ':memory:' 使用内存模式
|
|
@@ -113,6 +142,12 @@ class Database {
|
|
|
113
142
|
this._plugins = [];
|
|
114
143
|
this._hooks = createHookRegistry();
|
|
115
144
|
this._eventListeners = [];
|
|
145
|
+
|
|
146
|
+
// P1: {modules:true} 自动加载插件(mod.config + ~/.jsql/plugins/)
|
|
147
|
+
this._autoPlugins = options.modules !== false;
|
|
148
|
+
if (this._autoPlugins) {
|
|
149
|
+
this._loadAutoPlugins();
|
|
150
|
+
}
|
|
116
151
|
this._tableNames = new Set(Object.keys(this._tables));
|
|
117
152
|
this._migrations = [];
|
|
118
153
|
this._observers = []; // 响应式观察者
|
|
@@ -1570,6 +1605,80 @@ class Database {
|
|
|
1570
1605
|
return buildPluginContext(this, plugin);
|
|
1571
1606
|
}
|
|
1572
1607
|
|
|
1608
|
+
/**
|
|
1609
|
+
* P1 修复:{modules:true} 自动加载插件。
|
|
1610
|
+
* 1. 通过 ModuleManager 读取 ~/.config/jsql/mod.config,应用已启用的模块
|
|
1611
|
+
* 2. 扫描 ~/.jsql/plugins/ 加载 registry 安装的插件
|
|
1612
|
+
*/
|
|
1613
|
+
_loadAutoPlugins() {
|
|
1614
|
+
// 1. mod.config 中的模块
|
|
1615
|
+
try {
|
|
1616
|
+
const { ModuleManager } = require('./mod');
|
|
1617
|
+
new ModuleManager().applyTo(this);
|
|
1618
|
+
} catch (e) {
|
|
1619
|
+
if (process.env.JSQL_DEBUG) console.warn('[database] Failed to load mod.config:', e.message);
|
|
1620
|
+
}
|
|
1621
|
+
// 2. ~/.jsql/plugins/ 中的 registry 插件
|
|
1622
|
+
try {
|
|
1623
|
+
this._loadInstalledPlugins();
|
|
1624
|
+
} catch (e) {
|
|
1625
|
+
if (process.env.JSQL_DEBUG) console.warn('[database] Failed to load installed plugins:', e.message);
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/**
|
|
1630
|
+
* 扫描 ~/.jsql/plugins/ 目录,加载已安装的 registry 插件。
|
|
1631
|
+
* 兼容两种布局:根级入口文件 + plugins/ 子目录入口文件。
|
|
1632
|
+
*/
|
|
1633
|
+
_loadInstalledPlugins() {
|
|
1634
|
+
const os = require('os');
|
|
1635
|
+
const fs = require('fs');
|
|
1636
|
+
const PLUGIN_DIR = path.join(os.homedir(), '.jsql', 'plugins');
|
|
1637
|
+
if (!fs.existsSync(PLUGIN_DIR)) return;
|
|
1638
|
+
const entries = fs.readdirSync(PLUGIN_DIR, { withFileTypes: true });
|
|
1639
|
+
for (const entry of entries) {
|
|
1640
|
+
if (!entry.isDirectory()) continue;
|
|
1641
|
+
const dir = path.join(PLUGIN_DIR, entry.name);
|
|
1642
|
+
const manifestPath = path.join(dir, 'manifest.json');
|
|
1643
|
+
if (!fs.existsSync(manifestPath)) continue;
|
|
1644
|
+
let manifest;
|
|
1645
|
+
try {
|
|
1646
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
1647
|
+
} catch { continue; }
|
|
1648
|
+
if (manifest.disabled) continue;
|
|
1649
|
+
const pluginName = manifest.name || entry.name;
|
|
1650
|
+
// 查找入口文件:manifest.main || manifest.entry || 'index.js'
|
|
1651
|
+
const entryFile = manifest.main || manifest.entry || 'index.js';
|
|
1652
|
+
// 兼容两种路径:根级 + plugins/ 子目录
|
|
1653
|
+
const candidates = [
|
|
1654
|
+
path.join(dir, entryFile),
|
|
1655
|
+
path.join(dir, entryFile + '.js'),
|
|
1656
|
+
path.join(dir, 'plugins', entryFile),
|
|
1657
|
+
path.join(dir, 'plugins', entryFile + '.js'),
|
|
1658
|
+
];
|
|
1659
|
+
let entryPath = null;
|
|
1660
|
+
for (const c of candidates) {
|
|
1661
|
+
if (fs.existsSync(c) && fs.statSync(c).isFile()) {
|
|
1662
|
+
entryPath = c;
|
|
1663
|
+
break;
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
if (!entryPath) continue;
|
|
1667
|
+
try {
|
|
1668
|
+
const resolved = require.resolve(entryPath);
|
|
1669
|
+
delete require.cache[resolved];
|
|
1670
|
+
const plugin = require(resolved);
|
|
1671
|
+
if (typeof plugin === 'function') {
|
|
1672
|
+
this.use({ name: pluginName, install: plugin });
|
|
1673
|
+
} else if (plugin && typeof plugin === 'object') {
|
|
1674
|
+
if (!plugin.name) plugin.name = pluginName;
|
|
1675
|
+
this.use(plugin);
|
|
1676
|
+
}
|
|
1677
|
+
} catch (e) {
|
|
1678
|
+
if (process.env.JSQL_DEBUG) console.warn('[database] Failed to load plugin', entry.name, e.message);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
}n
|
|
1573
1682
|
on(event, fn) {
|
|
1574
1683
|
return registerHook(this, event, fn);
|
|
1575
1684
|
}
|
|
@@ -1702,7 +1811,15 @@ class Database {
|
|
|
1702
1811
|
_resolveId(table, id) {
|
|
1703
1812
|
const pk = this._pkOf(table);
|
|
1704
1813
|
if (pk) return table._rows.find(r => r[pk] === id) || null;
|
|
1705
|
-
|
|
1814
|
+
// 无主键表:id 是行对象(可能是 find() 返回的浅拷贝)时,用内容匹配代替引用比较。
|
|
1815
|
+
// 做法:逐行比对所有字段值是否相等;是数字时当 1-based 行号。
|
|
1816
|
+
if (id && typeof id === 'object') {
|
|
1817
|
+
const keys = Object.keys(id);
|
|
1818
|
+
return table._rows.find(r => keys.every(k => r[k] === id[k])) || null;
|
|
1819
|
+
}
|
|
1820
|
+
const idx = Number(id);
|
|
1821
|
+
if (!isNaN(idx)) return table._rows[idx - 1] || null;
|
|
1822
|
+
return null;
|
|
1706
1823
|
}
|
|
1707
1824
|
|
|
1708
1825
|
find(tableName, filter, opts = {}) {
|
|
@@ -1867,20 +1984,29 @@ class Database {
|
|
|
1867
1984
|
if (table._primaryKey) {
|
|
1868
1985
|
for (const id of ids) removed += table.removeById(id);
|
|
1869
1986
|
} else {
|
|
1870
|
-
// 无主键表没有稳定的行标识,
|
|
1871
|
-
//
|
|
1872
|
-
//
|
|
1873
|
-
//
|
|
1874
|
-
// 退化到行号解析;并一次性收集目标后统一移除,避免边删边移位。
|
|
1875
|
-
const targets = [];
|
|
1987
|
+
// 无主键表没有稳定的行标识,P1-11 后 find() 返回浅拷贝({...r}),
|
|
1988
|
+
// 对象引用与 table._rows 里的原对象不同,不能用 Set.has(row) 做引用匹配。
|
|
1989
|
+
// _resolveId 对无主键表做 table._rows[id-1],传入对象时 id-1=NaN → undefined。
|
|
1990
|
+
// 所以这里对对象 id 做深度相等匹配,对值 id 走 _resolveId(1-based 行号)。
|
|
1876
1991
|
const seen = new Set();
|
|
1877
|
-
const
|
|
1992
|
+
const targets = [];
|
|
1878
1993
|
for (const id of ids) {
|
|
1879
|
-
|
|
1880
|
-
|
|
1994
|
+
let rowIndex;
|
|
1995
|
+
if (id && typeof id === 'object') {
|
|
1996
|
+
// id 是行对象(find() 返回的浅拷贝),用深度相等匹配
|
|
1997
|
+
rowIndex = table._rows.findIndex(r => _rowDeepEqual(r, id));
|
|
1998
|
+
} else {
|
|
1999
|
+
// id 是值(1-based 行号),_resolveId 能处理
|
|
2000
|
+
const original = this._resolveId(table, id);
|
|
2001
|
+
if (original) rowIndex = table._rows.indexOf(original);
|
|
2002
|
+
}
|
|
2003
|
+
if (rowIndex >= 0 && !seen.has(rowIndex)) {
|
|
2004
|
+
seen.add(rowIndex);
|
|
2005
|
+
targets.push(rowIndex);
|
|
2006
|
+
}
|
|
1881
2007
|
}
|
|
1882
2008
|
if (targets.length) {
|
|
1883
|
-
table._rows = table._rows.filter(
|
|
2009
|
+
table._rows = table._rows.filter((_, idx) => !seen.has(idx));
|
|
1884
2010
|
removed = targets.length;
|
|
1885
2011
|
}
|
|
1886
2012
|
table._rebuildPKIndex();
|
package/lib/native_client.js
CHANGED
|
@@ -17,6 +17,35 @@ function safeParse(str, fallback) {
|
|
|
17
17
|
try { return JSON.parse(str); } catch (e) { return fallback; }
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* 深度相等比较两个行对象(用于无主键表行匹配)。
|
|
22
|
+
* 剥离内部字段(_rid, _deleted)后逐字段比较。
|
|
23
|
+
*/
|
|
24
|
+
function _nativeRowDeepEqual(a, b) {
|
|
25
|
+
if (a === b) return true;
|
|
26
|
+
if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false;
|
|
27
|
+
const strip = (obj) => {
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
30
|
+
if (k === '_rid' || k === '_deleted') continue;
|
|
31
|
+
out[k] = v;
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
};
|
|
35
|
+
const sa = strip(a), sb = strip(b);
|
|
36
|
+
const ka = Object.keys(sa), kb = Object.keys(sb);
|
|
37
|
+
if (ka.length !== kb.length) return false;
|
|
38
|
+
for (const k of ka) {
|
|
39
|
+
if (!Object.prototype.hasOwnProperty.call(sb, k)) return false;
|
|
40
|
+
const va = sa[k], vb = sb[k];
|
|
41
|
+
if (va === vb) continue;
|
|
42
|
+
if (va === null || va === undefined || vb === null || vb === undefined) return false;
|
|
43
|
+
if (typeof va === 'number' && typeof vb === 'number' && va === vb) continue;
|
|
44
|
+
if (String(va) !== String(vb)) return false;
|
|
45
|
+
}
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
20
49
|
/**
|
|
21
50
|
* 序列化对象/数组字段(JSON 列)并给出可读错误。直接 JSON.stringify 遇到循环引用会抛
|
|
22
51
|
* "Converting circular structure to JSON",指向不到具体列,这里补上表/列信息。
|
|
@@ -443,7 +472,21 @@ class JSQL {
|
|
|
443
472
|
if (Array.isArray(r) && r.length > 0) return r[0].id;
|
|
444
473
|
return null;
|
|
445
474
|
}
|
|
446
|
-
|
|
475
|
+
// 无主键表:id 应是 1-based 行号(数字)。
|
|
476
|
+
// 但 sql.js DELETE 对无主键表传的是行对象(find() 返回的浅拷贝),
|
|
477
|
+
// Number(obj) = NaN → JSON.stringify([NaN]) = "[null]" → 静默失效。
|
|
478
|
+
// 这里对对象 id 做深度相等匹配,找到对应行的序列号。
|
|
479
|
+
if (typeof id === 'number') return id;
|
|
480
|
+
if (id && typeof id === 'object') {
|
|
481
|
+
const all = safeParse(this._n('jsqlFind', table, '{}', 1000000, 0));
|
|
482
|
+
if (Array.isArray(all)) {
|
|
483
|
+
for (const r of all) {
|
|
484
|
+
const row = r.fields ? { ...r.fields, id: r.id } : r;
|
|
485
|
+
if (_nativeRowDeepEqual(row, id)) return r.id;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return null;
|
|
447
490
|
}
|
|
448
491
|
|
|
449
492
|
async _insertBatch(table, rows) {
|
package/lib/sql.js
CHANGED
|
@@ -613,24 +613,10 @@ class Parser {
|
|
|
613
613
|
} else if (this.isWord('FOREIGN') || this.isWord('CHECK') ||
|
|
614
614
|
t.type === 'keyword' && (t.value === 'KEY' || t.value === 'INDEX')) {
|
|
615
615
|
// 表级约束:FOREIGN KEY ... REFERENCES ... / CHECK (...) / KEY name (cols)
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
if (nextTok && (nextTok.type === 'ident' || nextTok.type === 'keyword') &&
|
|
621
|
-
['TEXT', 'INT', 'INTEGER', 'VARCHAR', 'CHAR', 'DATE', 'DATETIME', 'TIMESTAMP', 'BOOLEAN', 'BOOL', 'FLOAT', 'DOUBLE', 'DECIMAL', 'BLOB', 'JSON'].includes(String(nextTok.value).toUpperCase())) {
|
|
622
|
-
// P1-24: KEY/INDEX 是保留字,不能直接用 parseTableName(),改用 nameOfToken + next()
|
|
623
|
-
const col = nameOfToken(t);
|
|
624
|
-
this.next(); // 消费 KEY/INDEX token
|
|
625
|
-
const def = this.parseColumnDef();
|
|
626
|
-
schema[col] = def;
|
|
627
|
-
if (def.primaryKey) hasPk = true;
|
|
628
|
-
} else {
|
|
629
|
-
this._parseTableConstraint(schema);
|
|
630
|
-
}
|
|
631
|
-
} else {
|
|
632
|
-
this._parseTableConstraint(schema);
|
|
633
|
-
}
|
|
616
|
+
// KEY/INDEX 是保留字:裸写作列名(如 CREATE TABLE t (key TEXT))必须报错,
|
|
617
|
+
// 由 _parseTableConstraint 消费 KEY 后遇到类型 token 触发
|
|
618
|
+
// "Expected ',' or ')'",见 identifier-keywords 测试。
|
|
619
|
+
this._parseTableConstraint(schema);
|
|
634
620
|
} else if (t.type === 'eof' || (t.type === 'op' && t.value === ')')) {
|
|
635
621
|
break;
|
|
636
622
|
} else {
|
|
@@ -4117,6 +4103,25 @@ class SQLExecutor {
|
|
|
4117
4103
|
? await this.engine.getTableSchema(statement.name)
|
|
4118
4104
|
: (this.engine._schemas ? this.engine._schemas[statement.name] : null);
|
|
4119
4105
|
if (!schema) throw new Error(`Table '${statement.name}' does not exist`);
|
|
4106
|
+
// P2: INSERT 未知列静默接受 → 应报 Unknown column
|
|
4107
|
+
// 检查 statement.columns(INSERT INTO t (col1, col2) VALUES ...)
|
|
4108
|
+
if (statement.columns && statement.columns.length > 0) {
|
|
4109
|
+
for (const col of statement.columns) {
|
|
4110
|
+
if (!Object.prototype.hasOwnProperty.call(schema, col)) {
|
|
4111
|
+
throw new Error(`Unknown column '${col}' in '${statement.name}'`);
|
|
4112
|
+
}
|
|
4113
|
+
}
|
|
4114
|
+
}
|
|
4115
|
+
// 检查 dataRows 的键(INSERT INTO t SET col1=... 或 INSERT INTO t (col1) VALUES ...)
|
|
4116
|
+
if (dataRows) {
|
|
4117
|
+
for (const row of dataRows) {
|
|
4118
|
+
for (const col of Object.keys(row)) {
|
|
4119
|
+
if (!Object.prototype.hasOwnProperty.call(schema, col)) {
|
|
4120
|
+
throw new Error(`Unknown column '${col}' in '${statement.name}'`);
|
|
4121
|
+
}
|
|
4122
|
+
}
|
|
4123
|
+
}
|
|
4124
|
+
}
|
|
4120
4125
|
const stripDefault = (row) => {
|
|
4121
4126
|
const out = {};
|
|
4122
4127
|
for (const [k, v] of Object.entries(row)) {
|
|
@@ -4146,6 +4151,15 @@ class SQLExecutor {
|
|
|
4146
4151
|
}
|
|
4147
4152
|
if (dataRows === null && statement.values) {
|
|
4148
4153
|
const colNames = Object.keys(schema);
|
|
4154
|
+
// P2: INSERT 未知列静默接受 → 应报 Unknown column
|
|
4155
|
+
// 检查 VALUES 数量是否超过列数(无列清单时)
|
|
4156
|
+
if (!statement.columns || statement.columns.length === 0) {
|
|
4157
|
+
for (const vals of statement.values) {
|
|
4158
|
+
if (vals.length > colNames.length) {
|
|
4159
|
+
throw new Error(`Unknown column in '${statement.name}': VALUES has ${vals.length} values but table has ${colNames.length} columns`);
|
|
4160
|
+
}
|
|
4161
|
+
}
|
|
4162
|
+
}
|
|
4149
4163
|
const skipAuto = statement.values[0].length < colNames.length;
|
|
4150
4164
|
dataRows = statement.values.map(vals => {
|
|
4151
4165
|
const row = {};
|
package/lib/sqlite_compat.js
CHANGED
|
@@ -286,7 +286,8 @@ class Statement {
|
|
|
286
286
|
|
|
287
287
|
run(...params) {
|
|
288
288
|
const r = this._call('run', params.length > 0 ? params : undefined);
|
|
289
|
-
|
|
289
|
+
// worker 层返回的是 better-sqlite3 风格的 {changes, lastInsertRowid}
|
|
290
|
+
return { changes: r && typeof r.changes === 'number' ? parseInt(r.changes, 10) : 0, lastInsertRowid: r && (r.lastInsertRowid !== undefined && r.lastInsertRowid !== null) ? r.lastInsertRowid : 0, _stmt: this };
|
|
290
291
|
}
|
|
291
292
|
|
|
292
293
|
get(...params) {
|
package/lib/wasm_client.js
CHANGED
|
@@ -10,6 +10,35 @@ function safeJsonParse(str) {
|
|
|
10
10
|
try { return JSON.parse(str); } catch { return str; }
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* 深度相等比较两个行对象(用于无主键表行匹配)。
|
|
15
|
+
* 剥离内部字段(_rid, _deleted)后逐字段比较。
|
|
16
|
+
*/
|
|
17
|
+
function _wasmRowDeepEqual(a, b) {
|
|
18
|
+
if (a === b) return true;
|
|
19
|
+
if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false;
|
|
20
|
+
const strip = (obj) => {
|
|
21
|
+
const out = {};
|
|
22
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
23
|
+
if (k === '_rid' || k === '_deleted') continue;
|
|
24
|
+
out[k] = v;
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
};
|
|
28
|
+
const sa = strip(a), sb = strip(b);
|
|
29
|
+
const ka = Object.keys(sa), kb = Object.keys(sb);
|
|
30
|
+
if (ka.length !== kb.length) return false;
|
|
31
|
+
for (const k of ka) {
|
|
32
|
+
if (!Object.prototype.hasOwnProperty.call(sb, k)) return false;
|
|
33
|
+
const va = sa[k], vb = sb[k];
|
|
34
|
+
if (va === vb) continue;
|
|
35
|
+
if (va === null || va === undefined || vb === null || vb === undefined) return false;
|
|
36
|
+
if (typeof va === 'number' && typeof vb === 'number' && va === vb) continue;
|
|
37
|
+
if (String(va) !== String(vb)) return false;
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
13
42
|
/**
|
|
14
43
|
* 序列化并给出可读错误。直接 JSON.stringify 遇到循环引用会抛
|
|
15
44
|
* "Converting circular structure to JSON",指向不到具体列,这里补上表/列信息。
|
|
@@ -434,7 +463,21 @@ class JSQL {
|
|
|
434
463
|
if (Array.isArray(r) && r.length > 0) return r[0].id;
|
|
435
464
|
return null;
|
|
436
465
|
}
|
|
437
|
-
|
|
466
|
+
// 无主键表:id 应是 1-based 行号(数字)。
|
|
467
|
+
// 但 sql.js DELETE 对无主键表传的是行对象(find() 返回的浅拷贝),
|
|
468
|
+
// Number(obj) = NaN → JSON.stringify([NaN]) = "[null]" → 静默失效。
|
|
469
|
+
// 这里对对象 id 做深度相等匹配,找到对应行的序列号。
|
|
470
|
+
if (typeof id === 'number') return id;
|
|
471
|
+
if (id && typeof id === 'object') {
|
|
472
|
+
const all = safeJsonParse(wasmBindings.jsql_find(table, '{}', 1000000, 0));
|
|
473
|
+
if (Array.isArray(all)) {
|
|
474
|
+
for (const r of all) {
|
|
475
|
+
const row = r.fields ? { ...r.fields, id: r.id } : r;
|
|
476
|
+
if (_wasmRowDeepEqual(row, id)) return r.id;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return null;
|
|
438
481
|
}
|
|
439
482
|
|
|
440
483
|
async _insertBatch(table, rows) {
|
package/lib/web_ui.js
CHANGED
|
@@ -116,6 +116,7 @@ class WebUI {
|
|
|
116
116
|
this.readonly = !!opts.readonly;
|
|
117
117
|
this.host = opts.host || '127.0.0.1';
|
|
118
118
|
this.authToken = opts.authToken || null;
|
|
119
|
+
this.noAuth = !!opts.noAuth; // 显式免认证(仅本地开发/测试用)
|
|
119
120
|
this.allowOrigin = opts.allowOrigin || null;
|
|
120
121
|
this.echoOrigin = !opts.allowOrigin && !!this.authToken;
|
|
121
122
|
this.cache = new Map();
|
|
@@ -199,17 +200,22 @@ class WebUI {
|
|
|
199
200
|
// API 认证:必须设置 authToken(从环境变量或构造选项)。
|
|
200
201
|
// 仅当显式 noAuth: true 时允许无认证访问(仅本地开发用)。
|
|
201
202
|
if (url.pathname.startsWith('/api/')) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
203
|
+
const deny = (msg) => {
|
|
204
|
+
// 显式配置了 CORS(allowOrigin / echoOrigin)时,401 也要带 ACAO,
|
|
205
|
+
// 否则浏览器会把认证失败误判为 CORS 拦截,无法区分两者。
|
|
206
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
207
|
+
if (corsOrigin) {
|
|
208
|
+
headers['Access-Control-Allow-Origin'] = corsOrigin;
|
|
209
|
+
headers['Vary'] = 'Origin';
|
|
210
|
+
}
|
|
211
|
+
res.writeHead(401, headers);
|
|
212
|
+
res.end(JSON.stringify({ ok: false, error: msg }));
|
|
213
|
+
};
|
|
214
|
+
if (!this.noAuth && !this.authToken) return deny('API auth not configured');
|
|
206
215
|
if (this.authToken) {
|
|
207
216
|
const provided = req.headers.authorization || '';
|
|
208
217
|
const token = provided.startsWith('Bearer ') ? provided.slice(7) : '';
|
|
209
|
-
if (token !== this.authToken)
|
|
210
|
-
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
211
|
-
return res.end(JSON.stringify({ ok: false, error: 'unauthorized' }));
|
|
212
|
-
}
|
|
218
|
+
if (token !== this.authToken) return deny('unauthorized');
|
|
213
219
|
}
|
|
214
220
|
}
|
|
215
221
|
if (url.pathname === '/api/databases' && req.method === 'GET') {
|
|
Binary file
|
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.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",
|
|
@@ -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",
|
|
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",
|
|
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",
|
package/test/coverage.js
CHANGED
|
@@ -91,7 +91,9 @@ function tmp(name) {
|
|
|
91
91
|
__meta__: { version: 1 },
|
|
92
92
|
users: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }],
|
|
93
93
|
}));
|
|
94
|
-
|
|
94
|
+
// API 认证模型(3895dca 起):无 authToken 时 /api/* 一律 401。
|
|
95
|
+
// 本测试是功能测试,需要真实访问 API,故显式 noAuth: true。
|
|
96
|
+
const ui = new WebUI({ port: 0, dataDir: path.dirname(dbFile), noAuth: true });
|
|
95
97
|
const port = await ui.start();
|
|
96
98
|
const dbs = await (await fetch(`http://127.0.0.1:${port}/api/databases`)).json();
|
|
97
99
|
ok('webui list databases', dbs.length === 1 && dbs[0].name === 'db');
|
|
@@ -198,6 +198,22 @@ async function t(name, fn) {
|
|
|
198
198
|
return eq(await scalar(`SELECT v FROM t WHERE id = 1`), 'c');
|
|
199
199
|
});
|
|
200
200
|
|
|
201
|
+
// P2: INSERT 未知列曾静默接受 → 应报 Unknown column
|
|
202
|
+
console.log('\n--- INSERT 未知列应报错 ---');
|
|
203
|
+
await t('INSERT 未知列(指定列清单)→ 报错', async ({ exec, mustFail }) => {
|
|
204
|
+
await exec(`CREATE TABLE t (id INT, v TEXT)`);
|
|
205
|
+
return mustFail(`INSERT INTO t (id, unknown_col) VALUES (1, 'x')`);
|
|
206
|
+
});
|
|
207
|
+
await t('INSERT 未知列(VALUES 全列)→ 报错', async ({ exec, mustFail }) => {
|
|
208
|
+
await exec(`CREATE TABLE t (id INT, v TEXT)`);
|
|
209
|
+
return mustFail(`INSERT INTO t VALUES (1, 'x', 'extra')`);
|
|
210
|
+
});
|
|
211
|
+
await t('INSERT 已知列 → 正常', async ({ exec, scalar }) => {
|
|
212
|
+
await exec(`CREATE TABLE t (id INT, v TEXT)`);
|
|
213
|
+
await exec(`INSERT INTO t (id, v) VALUES (1, 'x')`);
|
|
214
|
+
return eq(await scalar(`SELECT v FROM t WHERE id = 1`), 'x');
|
|
215
|
+
});
|
|
216
|
+
|
|
201
217
|
console.log(`\n${pass} passed, ${fail} failed`);
|
|
202
218
|
process.exit(fail === 0 ? 0 : 1);
|
|
203
219
|
})();
|