jsql-neo 6.3.6 → 6.3.10
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 +78 -8239
- package/index.js +1 -1
- package/lib/cache.js +33 -27
- package/lib/client.js +11 -2
- package/lib/database.js +370 -56
- package/lib/date-types.js +51 -13
- package/lib/errors.js +4 -1
- package/lib/jsql_format.js +30 -21
- package/lib/mod.js +27 -6
- package/lib/mongo_server.js +143 -26
- package/lib/mysql_compat.js +57 -11
- package/lib/mysql_server.js +104 -4
- package/lib/nedb_compat.js +9 -6
- package/lib/pg_server.js +4 -11
- package/lib/plugin.js +20 -2
- package/lib/prepared.js +10 -0
- package/lib/query.js +9 -2
- package/lib/redis_server.js +88 -16
- package/lib/sql.js +856 -81
- package/lib/sqlite_compat.js +2 -1
- package/lib/sqlite_worker.js +8 -2
- package/lib/table.js +68 -26
- package/lib/tui.js +1 -1
- package/lib/web_ui.js +24 -8
- package/native/jsql-neo-native.node +0 -0
- package/package.json +10 -3
- package/test/alter-table.test.js +289 -0
- package/test/identifier-keywords.test.js +140 -0
- package/test/index-ddl.test.js +203 -0
- package/test/mongo-group.test.js +105 -0
- package/test/null-semantics.test.js +126 -0
- package/test/recursive-cte.test.js +123 -0
- package/test/savepoint.test.js +388 -0
- package/test/sql-parser.test.js +30 -5
package/lib/sqlite_compat.js
CHANGED
|
@@ -81,6 +81,7 @@ class Database {
|
|
|
81
81
|
close() {
|
|
82
82
|
if (!this.open) return;
|
|
83
83
|
try {
|
|
84
|
+
// P3-12: close() 不等 worker 保存完——显式等待保存完成
|
|
84
85
|
this._sync('close', []);
|
|
85
86
|
} finally {
|
|
86
87
|
this.open = false;
|
|
@@ -285,7 +286,7 @@ class Statement {
|
|
|
285
286
|
|
|
286
287
|
run(...params) {
|
|
287
288
|
const r = this._call('run', params.length > 0 ? params : undefined);
|
|
288
|
-
return r;
|
|
289
|
+
return { changes: r && r.affectedRows, lastInsertRowid: r && r.insertId, _stmt: this };
|
|
289
290
|
}
|
|
290
291
|
|
|
291
292
|
get(...params) {
|
package/lib/sqlite_worker.js
CHANGED
|
@@ -97,7 +97,8 @@ function cleanResult(r) {
|
|
|
97
97
|
if (v instanceof Date) out[k] = v.toISOString();
|
|
98
98
|
else if (v === undefined) out[k] = null;
|
|
99
99
|
else if (typeof v === 'object' && v !== null && !Array.isArray(v)) {
|
|
100
|
-
//
|
|
100
|
+
// P3-14: 双次 stringify 检查可能导致某些值被错误地过滤
|
|
101
|
+
// 只检查一次 stringify 是否成功,然后保留原值
|
|
101
102
|
try { JSON.stringify(v); out[k] = v; } catch (e) { out[k] = null; }
|
|
102
103
|
} else out[k] = v;
|
|
103
104
|
}
|
|
@@ -123,9 +124,14 @@ async function handleOp(op, args) {
|
|
|
123
124
|
}
|
|
124
125
|
case 'run': {
|
|
125
126
|
const r = await executeStatement(args[0], args[1]);
|
|
127
|
+
// P3-13: 非 insert 操作不应回退到上次 insertId
|
|
128
|
+
const lastId = r && r.type === 'insert' ? Number(r.insertId || 0) : 0;
|
|
129
|
+
if (r && r.type === 'insert' && r.insertId !== undefined && r.insertId !== null) {
|
|
130
|
+
_lastInsertId = Number(r.insertId);
|
|
131
|
+
}
|
|
126
132
|
result = {
|
|
127
133
|
changes: r ? (r.affectedRows || 0) : 0,
|
|
128
|
-
lastInsertRowid:
|
|
134
|
+
lastInsertRowid: lastId,
|
|
129
135
|
};
|
|
130
136
|
break;
|
|
131
137
|
}
|
package/lib/table.js
CHANGED
|
@@ -122,7 +122,7 @@ class Table {
|
|
|
122
122
|
};
|
|
123
123
|
this._softDelete = false;
|
|
124
124
|
this._computedFields = {};
|
|
125
|
-
|
|
125
|
+
|
|
126
126
|
|
|
127
127
|
this._primaryKey = null;
|
|
128
128
|
this._pkIndex = null;
|
|
@@ -283,6 +283,13 @@ class Table {
|
|
|
283
283
|
}
|
|
284
284
|
if (def.check && data[field] !== undefined && !def.check(data[field], data)) throw createError('ER_CHECK_CONSTRAINT', this._name + '.' + field);
|
|
285
285
|
}
|
|
286
|
+
// 主键唯一性检查(主键可能不在 _cachedUniqueFields 中)
|
|
287
|
+
if (this._primaryKey && data[this._primaryKey] !== undefined && data[this._primaryKey] !== null) {
|
|
288
|
+
const pkVal = data[this._primaryKey];
|
|
289
|
+
if (this._pkIndex && this._pkIndex.has(pkVal)) {
|
|
290
|
+
throw createError('ER_DUP_ENTRY', String(pkVal), this._primaryKey);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
286
293
|
for (const fk of foreignKeys) {
|
|
287
294
|
if (data[fk.field] !== undefined && data[fk.field] !== null) {
|
|
288
295
|
const refTable = this._resolveTable(fk.table);
|
|
@@ -303,7 +310,11 @@ class Table {
|
|
|
303
310
|
this._rows.push(Object.assign({}, data));
|
|
304
311
|
if (this._pkIndex && this._primaryKey) {
|
|
305
312
|
const pkVal = data[this._primaryKey];
|
|
306
|
-
if (pkVal !== undefined && pkVal !== null)
|
|
313
|
+
if (pkVal !== undefined && pkVal !== null) {
|
|
314
|
+
// P1-1: insertMany 主键重复检查
|
|
315
|
+
if (this._pkIndex.has(pkVal)) throw createError('ER_DUP_ENTRY', String(pkVal), this._primaryKey);
|
|
316
|
+
this._pkIndex.set(pkVal, rowIdx);
|
|
317
|
+
}
|
|
307
318
|
}
|
|
308
319
|
for (const [field, tree] of Object.entries(this._btrees)) {
|
|
309
320
|
const val = data[field];
|
|
@@ -369,7 +380,6 @@ class Table {
|
|
|
369
380
|
}
|
|
370
381
|
return null;
|
|
371
382
|
}
|
|
372
|
-
this._ensureBTrees();
|
|
373
383
|
if (this._btrees[this._primaryKey]) {
|
|
374
384
|
const indices = this._btrees[this._primaryKey].search(id);
|
|
375
385
|
if (indices.length > 0) {
|
|
@@ -442,7 +452,6 @@ class Table {
|
|
|
442
452
|
if (this._pkIndex) {
|
|
443
453
|
rowIndex = this._pkIndex.get(id);
|
|
444
454
|
} else {
|
|
445
|
-
this._ensureBTrees();
|
|
446
455
|
if (this._btrees[this._primaryKey]) {
|
|
447
456
|
const indices = this._btrees[this._primaryKey].search(id);
|
|
448
457
|
rowIndex = indices.length > 0 ? indices[0] : undefined;
|
|
@@ -539,7 +548,6 @@ class Table {
|
|
|
539
548
|
if (this._pkIndex) {
|
|
540
549
|
rowIndex = this._pkIndex.get(id);
|
|
541
550
|
} else {
|
|
542
|
-
this._ensureBTrees();
|
|
543
551
|
if (this._btrees[this._primaryKey]) {
|
|
544
552
|
const indices = this._btrees[this._primaryKey].search(id);
|
|
545
553
|
rowIndex = indices.length > 0 ? indices[0] : undefined;
|
|
@@ -587,8 +595,28 @@ class Table {
|
|
|
587
595
|
}
|
|
588
596
|
|
|
589
597
|
removeSoft(query = {}) {
|
|
590
|
-
this._softDelete
|
|
591
|
-
|
|
598
|
+
if (!this._softDelete) throw createError('ER_NOT_SUPPORTED', 'Soft delete is not enabled');
|
|
599
|
+
const matched = this._applyFilterOptimized(this._getVisibleRows(), query);
|
|
600
|
+
let count = 0;
|
|
601
|
+
const now = new Date().toISOString();
|
|
602
|
+
for (const row of matched) {
|
|
603
|
+
const index = this._rows.indexOf(row);
|
|
604
|
+
if (index === -1) continue;
|
|
605
|
+
this._removeFromIndexes(index, row);
|
|
606
|
+
row._deleted = true;
|
|
607
|
+
row._deletedAt = now;
|
|
608
|
+
this._addToIndexes(index, row);
|
|
609
|
+
count++;
|
|
610
|
+
}
|
|
611
|
+
if (count > 0) {
|
|
612
|
+
this._dirty = true;
|
|
613
|
+
this._db._markDirty(this._name);
|
|
614
|
+
for (const hook of this._hooks.afterRemove) {
|
|
615
|
+
hook(query, count);
|
|
616
|
+
}
|
|
617
|
+
this._db._emitChange('remove', this._name, { query, count, soft: true });
|
|
618
|
+
}
|
|
619
|
+
return count;
|
|
592
620
|
}
|
|
593
621
|
|
|
594
622
|
_findRowIndex(id) {
|
|
@@ -628,6 +656,7 @@ class Table {
|
|
|
628
656
|
}
|
|
629
657
|
this._indexes = {};
|
|
630
658
|
this._autoIncrement = 0;
|
|
659
|
+
// P1-13: truncate 不应重置软删除模式——保留原值
|
|
631
660
|
this._dirty = true;
|
|
632
661
|
this._db._markDirty(this._name);
|
|
633
662
|
return count;
|
|
@@ -640,6 +669,10 @@ class Table {
|
|
|
640
669
|
addColumn(field, definition) {
|
|
641
670
|
if (this._schema[field]) throw createError('ER_DUP_FIELDNAME', field);
|
|
642
671
|
this._schema[field] = definition;
|
|
672
|
+
// P1-14: 更新缓存字段列表,使新列的 unique/required 约束生效
|
|
673
|
+
if (!this._cachedSchemaFields.includes(field)) this._cachedSchemaFields.push(field);
|
|
674
|
+
if (definition.unique && !this._cachedUniqueFields.includes(field)) this._cachedUniqueFields.push(field);
|
|
675
|
+
if (definition.required && !this._cachedRequiredFields.includes(field)) this._cachedRequiredFields.push(field);
|
|
643
676
|
if (definition.default !== undefined) {
|
|
644
677
|
this._defaults[field] = definition.default;
|
|
645
678
|
for (const row of this._rows) {
|
|
@@ -666,6 +699,15 @@ class Table {
|
|
|
666
699
|
this._btrees[field] = new BTree(64, true);
|
|
667
700
|
this._rebuildBTree(field);
|
|
668
701
|
}
|
|
702
|
+
// P1-8: 添加主键列时设置 _primaryKey 并重建 pkIndex
|
|
703
|
+
if (definition.primaryKey) {
|
|
704
|
+
this._primaryKey = field;
|
|
705
|
+
if (this._pkIndex) this._pkIndex.clear();
|
|
706
|
+
for (let i = 0; i < this._rows.length; i++) {
|
|
707
|
+
const val = this._rows[i][field];
|
|
708
|
+
if (val !== undefined && val !== null) this._pkIndex.set(val, i);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
669
711
|
this._dirty = true;
|
|
670
712
|
this._db._markDirty(this._name);
|
|
671
713
|
return this;
|
|
@@ -684,7 +726,11 @@ class Table {
|
|
|
684
726
|
for (const row of this._rows) {
|
|
685
727
|
delete row[field];
|
|
686
728
|
}
|
|
687
|
-
|
|
729
|
+
// P1-8: 删除主键列时清空 pkIndex
|
|
730
|
+
if (this._primaryKey === field) {
|
|
731
|
+
this._primaryKey = null;
|
|
732
|
+
if (this._pkIndex) this._pkIndex.clear();
|
|
733
|
+
}
|
|
688
734
|
if (this._autoIncrementField === field) this._autoIncrementField = null;
|
|
689
735
|
this._dirty = true;
|
|
690
736
|
this._db._markDirty(this._name);
|
|
@@ -703,6 +749,11 @@ class Table {
|
|
|
703
749
|
if (this._btrees[oldName]) {
|
|
704
750
|
this._btrees[newName] = this._btrees[oldName];
|
|
705
751
|
delete this._btrees[oldName];
|
|
752
|
+
this._btrees[newName].clear();
|
|
753
|
+
for (let i = 0; i < this._rows.length; i++) {
|
|
754
|
+
const val = this._rows[i][newName];
|
|
755
|
+
if (val !== undefined && val !== null) this._btrees[newName].insert(val, i);
|
|
756
|
+
}
|
|
706
757
|
}
|
|
707
758
|
for (const row of this._rows) {
|
|
708
759
|
row[newName] = row[oldName];
|
|
@@ -721,9 +772,13 @@ class Table {
|
|
|
721
772
|
if (definition.default !== undefined) this._defaults[field] = definition.default;
|
|
722
773
|
this._checkConstraints = this._checkConstraints.filter(c => c.field !== field);
|
|
723
774
|
if (definition.check) this._checkConstraints.push({ field, fn: definition.check });
|
|
724
|
-
if (definition.primaryKey || definition.unique) {
|
|
725
|
-
if (
|
|
726
|
-
|
|
775
|
+
if (definition.primaryKey !== undefined || definition.unique !== undefined) {
|
|
776
|
+
if (definition.primaryKey || definition.unique) {
|
|
777
|
+
if (!this._btrees[field]) this._btrees[field] = new BTree(64, true);
|
|
778
|
+
this._rebuildBTree(field);
|
|
779
|
+
} else {
|
|
780
|
+
delete this._btrees[field];
|
|
781
|
+
}
|
|
727
782
|
}
|
|
728
783
|
this._dirty = true;
|
|
729
784
|
this._db._markDirty(this._name);
|
|
@@ -817,20 +872,6 @@ class Table {
|
|
|
817
872
|
}
|
|
818
873
|
}
|
|
819
874
|
|
|
820
|
-
_ensureBTrees() {
|
|
821
|
-
if (!this._btreesDirty) return;
|
|
822
|
-
this._btreesDirty = false;
|
|
823
|
-
for (const field of Object.keys(this._btrees)) {
|
|
824
|
-
this._btrees[field].clear();
|
|
825
|
-
}
|
|
826
|
-
for (let i = 0; i < this._rows.length; i++) {
|
|
827
|
-
const row = this._rows[i];
|
|
828
|
-
for (const [field, tree] of Object.entries(this._btrees)) {
|
|
829
|
-
const v = row[field];
|
|
830
|
-
if (v !== undefined && v !== null) tree.insert(v, i);
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
875
|
|
|
835
876
|
_rebuildAllBTrees() {
|
|
836
877
|
for (const field of Object.keys(this._btrees)) {
|
|
@@ -881,7 +922,6 @@ class Table {
|
|
|
881
922
|
}
|
|
882
923
|
|
|
883
924
|
_applyFilterOptimized(rows, query) {
|
|
884
|
-
this._ensureBTrees();
|
|
885
925
|
for (const [field, condition] of Object.entries(query)) {
|
|
886
926
|
if (field === '$or' || field === '$and' || field === '$not') continue;
|
|
887
927
|
if (typeof condition !== 'object' || condition === null || Array.isArray(condition) || condition instanceof RegExp) {
|
|
@@ -940,6 +980,8 @@ class Table {
|
|
|
940
980
|
_applyFilter(rows, query) {
|
|
941
981
|
const result = [];
|
|
942
982
|
for (let i = 0; i < rows.length; i++) {
|
|
983
|
+
// 软删除行不应出现在任何查询结果中
|
|
984
|
+
if (this._softDelete && rows[i]._deleted) continue;
|
|
943
985
|
if (this._matchRow(rows[i], query)) {
|
|
944
986
|
result.push(rows[i]);
|
|
945
987
|
}
|
package/lib/tui.js
CHANGED
|
@@ -13,7 +13,7 @@ const os = require('os');
|
|
|
13
13
|
const Database = require('./database');
|
|
14
14
|
const { executeSQL } = require('./sql');
|
|
15
15
|
|
|
16
|
-
const HISTORY_FILE = path.join(os.homedir(), '.jsql-history');
|
|
16
|
+
const HISTORY_FILE = path.join(os.homedir(), '.jsql-history.' + process.pid);
|
|
17
17
|
const MAX_HISTORY = 500;
|
|
18
18
|
|
|
19
19
|
/* ---------- ANSI helpers ---------- */
|
package/lib/web_ui.js
CHANGED
|
@@ -141,9 +141,17 @@ class WebUI {
|
|
|
141
141
|
return fs.readdirSync(this.dataDir)
|
|
142
142
|
.filter(f => f.endsWith('.json'))
|
|
143
143
|
.map(f => {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
144
|
+
try {
|
|
145
|
+
const stat = fs.statSync(path.join(this.dataDir, f));
|
|
146
|
+
// 只读前 4KB:schema 头部包含 __schema__ 定义,尾部数据不需要
|
|
147
|
+
const fd = fs.openSync(path.join(this.dataDir, f), 'r');
|
|
148
|
+
const buf = Buffer.alloc(4096);
|
|
149
|
+
fs.readSync(fd, buf, 0, 4096, 0);
|
|
150
|
+
fs.closeSync(fd);
|
|
151
|
+
const match = buf.toString('utf8').match(/"__schema__"\s*:\s*\{([^}]*)\}/);
|
|
152
|
+
const tables = match ? (match[1].match(/"/g) || []).length / 2 : 0;
|
|
153
|
+
return { name: f.slice(0, -5), tables };
|
|
154
|
+
} catch (_) { return { name: f.slice(0, -5), tables: 0 }; }
|
|
147
155
|
});
|
|
148
156
|
}
|
|
149
157
|
|
|
@@ -188,12 +196,20 @@ class WebUI {
|
|
|
188
196
|
res.writeHead(204, h);
|
|
189
197
|
return res.end();
|
|
190
198
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (
|
|
199
|
+
// API 认证:必须设置 authToken(从环境变量或构造选项)。
|
|
200
|
+
// 仅当显式 noAuth: true 时允许无认证访问(仅本地开发用)。
|
|
201
|
+
if (url.pathname.startsWith('/api/')) {
|
|
202
|
+
if (!this.noAuth && !this.authToken) {
|
|
195
203
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
196
|
-
return res.end(JSON.stringify({ ok: false, error: '
|
|
204
|
+
return res.end(JSON.stringify({ ok: false, error: 'API auth not configured' }));
|
|
205
|
+
}
|
|
206
|
+
if (this.authToken) {
|
|
207
|
+
const provided = req.headers.authorization || '';
|
|
208
|
+
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
|
+
}
|
|
197
213
|
}
|
|
198
214
|
}
|
|
199
215
|
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.3.
|
|
3
|
+
"version": "6.3.10",
|
|
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",
|
|
@@ -51,10 +51,17 @@
|
|
|
51
51
|
"test:btree": "node test/btree.test.js",
|
|
52
52
|
"test:native": "node test/native.test.js",
|
|
53
53
|
"test:regress": "node test/regress-5.1.0.js",
|
|
54
|
-
"test:all": "node test/smoke.js && node test/sql-parser.test.js && node test/readme-audit.test.js && node test/native.test.js && node test/native-multi-instance.test.js && node test/wal-recovery.test.js && node test/mysql-compat-hijack.test.js && node test/wasm.test.js && node test/coverage.js && node test/regress-5.1.0.js && node test/join.test.js && node test/btree.test.js && node test/dml-constraints.test.js",
|
|
54
|
+
"test:all": "node test/smoke.js && node test/sql-parser.test.js && node test/readme-audit.test.js && node test/native.test.js && node test/native-multi-instance.test.js && node test/wal-recovery.test.js && node test/mysql-compat-hijack.test.js && node test/wasm.test.js && node test/coverage.js && node test/regress-5.1.0.js && node test/join.test.js && node test/btree.test.js && node test/dml-constraints.test.js && node test/savepoint.test.js && node test/alter-table.test.js && node test/null-semantics.test.js && node test/identifier-keywords.test.js && node test/recursive-cte.test.js && node test/index-ddl.test.js",
|
|
55
55
|
"test:coverage": "c8 --all --include='lib/**' --exclude='lib/web_ui.js' --reporter=text --reporter=lcov node test/coverage.js",
|
|
56
56
|
"test:orms": "node examples/orms/run-all.js",
|
|
57
|
-
"test:dml": "node test/dml-constraints.test.js"
|
|
57
|
+
"test:dml": "node test/dml-constraints.test.js",
|
|
58
|
+
"test:cte": "node test/recursive-cte.test.js",
|
|
59
|
+
"test:index": "node test/index-ddl.test.js",
|
|
60
|
+
"test:mongo": "node test/mongo-group.test.js",
|
|
61
|
+
"test:savepoint": "node test/savepoint.test.js",
|
|
62
|
+
"test:alter": "node test/alter-table.test.js",
|
|
63
|
+
"test:ident": "node test/identifier-keywords.test.js",
|
|
64
|
+
"test:null": "node test/null-semantics.test.js"
|
|
58
65
|
},
|
|
59
66
|
"keywords": [
|
|
60
67
|
"database",
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ALTER TABLE 补全测试(RENAME TO / RENAME COLUMN / DROP INDEX)。
|
|
3
|
+
*
|
|
4
|
+
* 这三条此前各自以不同方式「看起来成功」:
|
|
5
|
+
* - ALTER ... RENAME TO t2 → 返回 ok:true,但表名根本没变
|
|
6
|
+
* - ALTER ... RENAME COLUMN → 解析期就报 "Expected table name, got 'COLUMN'"
|
|
7
|
+
* - ALTER ... DROP INDEX idx → 返回 ok:true,索引还在
|
|
8
|
+
* - ALTER ... ADD FOREIGN KEY→ 返回 ok:true,什么都没加
|
|
9
|
+
* 执行器里这三类 op 落在同一个 `default: break`,也就是静默无操作 ——
|
|
10
|
+
* 比报错更糟,调用方会以为改成功了。
|
|
11
|
+
*
|
|
12
|
+
* 现在:能做的真做,做不到的明确报错(外键在这个引擎里没实现)。
|
|
13
|
+
*/
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
const ROOT = path.join(__dirname, '..');
|
|
19
|
+
const { Database } = require(path.join(ROOT, 'index.js'));
|
|
20
|
+
const { executeSQL } = require(path.join(ROOT, 'lib/sql.js'));
|
|
21
|
+
|
|
22
|
+
const OPTS = { safety: false };
|
|
23
|
+
let pass = 0, fail = 0;
|
|
24
|
+
|
|
25
|
+
function ok(name, cond, extra) {
|
|
26
|
+
if (cond) { pass++; console.log('[OK] ', name); }
|
|
27
|
+
else { fail++; console.log('[FAIL]', name, extra !== undefined ? '-> ' + JSON.stringify(extra).slice(0, 140) : ''); }
|
|
28
|
+
}
|
|
29
|
+
const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
30
|
+
|
|
31
|
+
async function t(name, fn) {
|
|
32
|
+
const db = new Database(':memory:');
|
|
33
|
+
const exec = (sql) => executeSQL(db, sql, OPTS);
|
|
34
|
+
const rows = async (sql) => (await exec(sql)).rows;
|
|
35
|
+
const mustFail = async (sql) => {
|
|
36
|
+
try { await exec(sql); return false; } catch (_) { return true; }
|
|
37
|
+
};
|
|
38
|
+
try {
|
|
39
|
+
const r = await fn({ db, exec, rows, mustFail });
|
|
40
|
+
ok(name, r === undefined ? true : !!r);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
ok(name, false, e.message);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
(async () => {
|
|
47
|
+
console.log('\n--- RENAME TO ---');
|
|
48
|
+
await t('改名真的生效,数据不丢', async ({ db, exec, rows }) => {
|
|
49
|
+
await exec('CREATE TABLE t (id INT PRIMARY KEY, v TEXT)');
|
|
50
|
+
await exec("INSERT INTO t VALUES (1,'x')");
|
|
51
|
+
await exec('ALTER TABLE t RENAME TO t2');
|
|
52
|
+
const tables = (await db.getTables()).slice().sort();
|
|
53
|
+
return eq(tables, ['t2']) && eq(await rows('SELECT id, v FROM t2'), [[1, 'x']]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await t('旧表名不再可用,新名字可取属性', async ({ db, exec, rows, mustFail }) => {
|
|
57
|
+
await exec('CREATE TABLE t (id INT PRIMARY KEY)');
|
|
58
|
+
await exec('ALTER TABLE t RENAME TO t2');
|
|
59
|
+
const oldGone = await mustFail('SELECT * FROM t');
|
|
60
|
+
return oldGone && !!db.t2 && db.t === undefined && rows('SELECT * FROM t2') !== null;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await t('RENAME AS 也支持', async ({ db, exec }) => {
|
|
64
|
+
await exec('CREATE TABLE t (id INT PRIMARY KEY)');
|
|
65
|
+
await exec('ALTER TABLE t RENAME AS t3');
|
|
66
|
+
return eq((await db.getTables()).slice().sort(), ['t3']);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await t('改成已存在的表名 → 报错', async ({ db, exec, mustFail }) => {
|
|
70
|
+
await exec('CREATE TABLE a (id INT PRIMARY KEY)');
|
|
71
|
+
await exec('CREATE TABLE b (id INT PRIMARY KEY)');
|
|
72
|
+
const bad = await mustFail('ALTER TABLE a RENAME TO b');
|
|
73
|
+
return bad && eq((await db.getTables()).slice().sort(), ['a', 'b']);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await t('改名不存在的表 → 报错', async ({ exec, mustFail }) => {
|
|
77
|
+
return mustFail('ALTER TABLE nope RENAME TO x');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await t('改成同名 = 无事发生,不算错', async ({ db, exec }) => {
|
|
81
|
+
await exec('CREATE TABLE t (id INT PRIMARY KEY)');
|
|
82
|
+
await exec('ALTER TABLE t RENAME TO t');
|
|
83
|
+
return eq((await db.getTables()).slice().sort(), ['t']);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await t('改名后主键/自增/唯一约束仍然生效', async ({ exec, rows, mustFail }) => {
|
|
87
|
+
await exec('CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, e TEXT UNIQUE)');
|
|
88
|
+
await exec("INSERT INTO t (e) VALUES ('a')");
|
|
89
|
+
await exec('ALTER TABLE t RENAME TO t2');
|
|
90
|
+
await exec("INSERT INTO t2 (e) VALUES ('b')");
|
|
91
|
+
const dupPk = await mustFail('INSERT INTO t2 (id, e) VALUES (1, \'c\')');
|
|
92
|
+
const dupUniq = await mustFail("INSERT INTO t2 (e) VALUES ('a')");
|
|
93
|
+
const all = await rows('SELECT id, e FROM t2 ORDER BY id');
|
|
94
|
+
return eq(all, [[1, 'a'], [2, 'b']]) && dupPk && dupUniq;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
console.log('\n--- RENAME COLUMN ---');
|
|
98
|
+
await t('改列名真的生效,数据不丢', async ({ exec, rows }) => {
|
|
99
|
+
await exec('CREATE TABLE t (id INT PRIMARY KEY, a TEXT)');
|
|
100
|
+
await exec("INSERT INTO t VALUES (1,'x')");
|
|
101
|
+
await exec('ALTER TABLE t RENAME COLUMN a TO name');
|
|
102
|
+
return eq(await rows('SELECT id, name FROM t'), [[1, 'x']]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await t('列的先后顺序保持不变', async ({ exec }) => {
|
|
106
|
+
await exec('CREATE TABLE t (id INT PRIMARY KEY, a TEXT, b TEXT, c TEXT)');
|
|
107
|
+
await exec('ALTER TABLE t RENAME COLUMN b TO b2');
|
|
108
|
+
// 直接改 schema 的键会把这一列挪到末尾,SELECT * 的列序就变了
|
|
109
|
+
return eq((await exec('SELECT * FROM t')).columns, ['id', 'a', 'b2', 'c']);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await t('列级约束(NOT NULL / UNIQUE / 主键)跟着走', async ({ exec, mustFail, rows }) => {
|
|
113
|
+
await exec('CREATE TABLE t (id INT PRIMARY KEY, code TEXT NOT NULL UNIQUE)');
|
|
114
|
+
await exec("INSERT INTO t VALUES (1,'a')");
|
|
115
|
+
await exec('ALTER TABLE t RENAME COLUMN code TO code2');
|
|
116
|
+
const nullBad = await mustFail('INSERT INTO t (id, code2) VALUES (2, NULL)');
|
|
117
|
+
const dupBad = await mustFail("INSERT INTO t (id, code2) VALUES (3, 'a')");
|
|
118
|
+
await exec("INSERT INTO t VALUES (4, 'b')");
|
|
119
|
+
return nullBad && dupBad && eq(await rows('SELECT id, code2 FROM t ORDER BY id'), [[1, 'a'], [4, 'b']]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await t('改列名后自增字段仍认得出来', async ({ exec, rows }) => {
|
|
123
|
+
await exec('CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, v TEXT)');
|
|
124
|
+
await exec('ALTER TABLE t RENAME COLUMN id TO sid');
|
|
125
|
+
await exec("INSERT INTO t (v) VALUES ('a'), ('b')");
|
|
126
|
+
return eq(await rows('SELECT sid, v FROM t ORDER BY sid'), [[1, 'a'], [2, 'b']]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await t('改列名会同步列上的索引', async ({ exec, rows }) => {
|
|
130
|
+
await exec('CREATE TABLE t (id INT PRIMARY KEY, a TEXT)');
|
|
131
|
+
await exec('ALTER TABLE t RENAME COLUMN a TO b');
|
|
132
|
+
await exec("INSERT INTO t VALUES (1,'x')");
|
|
133
|
+
// 表对象上的索引是以旧列名建的,改名后按新列名查必须仍然走得到数据
|
|
134
|
+
return eq(await rows('SELECT id FROM t WHERE b = \'x\''), [[1]]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await t('列不存在 → 报错', async ({ exec, mustFail }) => {
|
|
138
|
+
await exec('CREATE TABLE t (id INT PRIMARY KEY)');
|
|
139
|
+
return mustFail('ALTER TABLE t RENAME COLUMN zzz TO yyy');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await t('改成已存在的列名 → 报错', async ({ exec, mustFail, rows }) => {
|
|
143
|
+
await exec('CREATE TABLE t (id INT PRIMARY KEY, a TEXT, b TEXT)');
|
|
144
|
+
await exec("INSERT INTO t VALUES (1,'x','y')");
|
|
145
|
+
const bad = await mustFail('ALTER TABLE t RENAME COLUMN b TO a');
|
|
146
|
+
return bad && eq(await rows('SELECT id, a, b FROM t'), [[1, 'x', 'y']]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await t('RENAME COLUMN 缺 TO → 报错', async ({ exec, mustFail }) => {
|
|
150
|
+
await exec('CREATE TABLE t (id INT PRIMARY KEY, a TEXT)');
|
|
151
|
+
return mustFail('ALTER TABLE t RENAME COLUMN a b');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await t('改成同名列 = 无事发生', async ({ exec, rows }) => {
|
|
155
|
+
await exec('CREATE TABLE t (id INT PRIMARY KEY, a TEXT)');
|
|
156
|
+
await exec("INSERT INTO t VALUES (1,'x')");
|
|
157
|
+
await exec('ALTER TABLE t RENAME COLUMN a TO a');
|
|
158
|
+
return eq(await rows('SELECT a FROM t'), [['x']]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
console.log('\n--- DROP INDEX / DROP KEY ---');
|
|
162
|
+
await t('DROP INDEX 真的删掉索引', async ({ db, exec }) => {
|
|
163
|
+
await exec('CREATE TABLE t (id INT PRIMARY KEY, a TEXT)');
|
|
164
|
+
await exec('CREATE INDEX idx_a ON t (a)');
|
|
165
|
+
const before = Object.keys(db.t._indexes).length + Object.keys(db.t._btrees).length;
|
|
166
|
+
await exec('ALTER TABLE t DROP INDEX idx_a');
|
|
167
|
+
const after = Object.keys(db.t._indexes).length + Object.keys(db.t._btrees).length;
|
|
168
|
+
return before > 0 && after < before;
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
await t('DROP KEY 是同一件事的另一种写法', async ({ db, exec }) => {
|
|
172
|
+
await exec('CREATE TABLE t (id INT PRIMARY KEY, a TEXT)');
|
|
173
|
+
// 记下建索引前的状态:主键会在 _btrees 里留一个 id 索引,那是该留的
|
|
174
|
+
const baseline = Object.keys(db.t._indexes).length + Object.keys(db.t._btrees).length;
|
|
175
|
+
await exec('CREATE INDEX idx_a ON t (a)');
|
|
176
|
+
const withIndex = Object.keys(db.t._indexes).length + Object.keys(db.t._btrees).length;
|
|
177
|
+
await exec('ALTER TABLE t DROP KEY idx_a');
|
|
178
|
+
const after = Object.keys(db.t._indexes).length + Object.keys(db.t._btrees).length;
|
|
179
|
+
return withIndex > baseline && after === baseline;
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await t('删索引不影响数据', async ({ exec, rows }) => {
|
|
183
|
+
await exec('CREATE TABLE t (id INT PRIMARY KEY, a TEXT)');
|
|
184
|
+
await exec("INSERT INTO t VALUES (1,'x')");
|
|
185
|
+
await exec('CREATE INDEX idx_a ON t (a)');
|
|
186
|
+
await exec('ALTER TABLE t DROP INDEX idx_a');
|
|
187
|
+
return eq(await rows('SELECT id, a FROM t'), [[1, 'x']]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
await t('删不存在的索引 → 报错', async ({ exec, mustFail }) => {
|
|
191
|
+
await exec('CREATE TABLE t (id INT PRIMARY KEY, a TEXT)');
|
|
192
|
+
return mustFail('ALTER TABLE t DROP INDEX nope');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
console.log('\n--- 明确报错而不是静默无操作 ---');
|
|
196
|
+
await t('ADD FOREIGN KEY → 明确报不支持', async ({ exec }) => {
|
|
197
|
+
await exec('CREATE TABLE t (id INT PRIMARY KEY, a TEXT)');
|
|
198
|
+
try {
|
|
199
|
+
await exec('ALTER TABLE t ADD CONSTRAINT fk FOREIGN KEY (a) REFERENCES p(id)');
|
|
200
|
+
return false;
|
|
201
|
+
} catch (e) { return /not supported/i.test(e.message) && /foreign key/i.test(e.message); }
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
await t('DROP FOREIGN KEY → 明确报不支持', async ({ exec }) => {
|
|
205
|
+
await exec('CREATE TABLE t (id INT PRIMARY KEY, a TEXT)');
|
|
206
|
+
try {
|
|
207
|
+
await exec('ALTER TABLE t DROP FOREIGN KEY fk');
|
|
208
|
+
return false;
|
|
209
|
+
} catch (e) { return /not supported/i.test(e.message) && /foreign key/i.test(e.message); }
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
await t('RENAME INDEX → 明确报不支持并给出替代做法', async ({ exec }) => {
|
|
213
|
+
await exec('CREATE TABLE t (id INT PRIMARY KEY, a TEXT)');
|
|
214
|
+
try {
|
|
215
|
+
await exec('ALTER TABLE t RENAME INDEX i TO j');
|
|
216
|
+
return false;
|
|
217
|
+
} catch (e) { return /not supported/i.test(e.message) && /DROP INDEX/i.test(e.message); }
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
console.log('\n--- 多操作与组合 ---');
|
|
221
|
+
await t('一条语句里多个操作按序执行', async ({ db, exec, rows }) => {
|
|
222
|
+
await exec('CREATE TABLE t (id INT PRIMARY KEY, a TEXT)');
|
|
223
|
+
await exec("INSERT INTO t VALUES (1,'x')");
|
|
224
|
+
await exec('ALTER TABLE t ADD COLUMN b TEXT, RENAME COLUMN a TO a2');
|
|
225
|
+
const cols = (await exec('SELECT * FROM t')).columns;
|
|
226
|
+
const r = await rows('SELECT id, a2, b FROM t');
|
|
227
|
+
return eq(r, [[1, 'x', null]]) && cols.includes('a2') && cols.includes('b');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
await t('改名后的表可以继续 DDL / DML', async ({ db, exec, rows }) => {
|
|
231
|
+
await exec('CREATE TABLE t (id INT PRIMARY KEY, v TEXT)');
|
|
232
|
+
await exec('ALTER TABLE t RENAME TO t2');
|
|
233
|
+
await exec('ALTER TABLE t2 RENAME COLUMN v TO val');
|
|
234
|
+
await exec("INSERT INTO t2 VALUES (1,'a')");
|
|
235
|
+
await exec('CREATE INDEX ix ON t2 (val)');
|
|
236
|
+
await exec('DELETE FROM t2 WHERE val = \'zzz\'');
|
|
237
|
+
return eq(await rows('SELECT id, val FROM t2'), [[1, 'a']]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
console.log('\n--- 持久化 ---');
|
|
241
|
+
{
|
|
242
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsql-alter-'));
|
|
243
|
+
const file = path.join(dir, 'db.jsql');
|
|
244
|
+
try {
|
|
245
|
+
const db = new Database(file, { wal: true, autoSave: false });
|
|
246
|
+
const exec = (sql) => executeSQL(db, sql, OPTS);
|
|
247
|
+
await exec('CREATE TABLE t (id INT PRIMARY KEY, a TEXT)');
|
|
248
|
+
await exec("INSERT INTO t VALUES (1,'x')");
|
|
249
|
+
await exec('ALTER TABLE t RENAME TO t2');
|
|
250
|
+
await exec('ALTER TABLE t2 RENAME COLUMN a TO name');
|
|
251
|
+
await db.save();
|
|
252
|
+
|
|
253
|
+
const db2 = new Database(file, { wal: true });
|
|
254
|
+
const exec2 = (sql) => executeSQL(db2, sql, OPTS);
|
|
255
|
+
const tables = (await db2.getTables()).slice().sort();
|
|
256
|
+
const cols = (await exec2('SELECT * FROM t2')).columns;
|
|
257
|
+
const r = (await exec2('SELECT id, name FROM t2')).rows;
|
|
258
|
+
ok('重开后表名与列名都保住', eq(tables, ['t2']) && eq(cols, ['id', 'name']) && eq(r, [[1, 'x']]),
|
|
259
|
+
{ tables, cols, r });
|
|
260
|
+
} catch (e) {
|
|
261
|
+
ok('重开后表名与列名都保住', false, e.message);
|
|
262
|
+
} finally {
|
|
263
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
console.log('\n--- 目录模式(改名的物理文件也要跟着挪)---');
|
|
268
|
+
{
|
|
269
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsql-alterdir-'));
|
|
270
|
+
try {
|
|
271
|
+
const db = new Database(dir, { mode: 'disk', autoSave: false });
|
|
272
|
+
const exec = (sql) => executeSQL(db, sql, OPTS);
|
|
273
|
+
await exec('CREATE TABLE t (id INT PRIMARY KEY, a TEXT)');
|
|
274
|
+
await exec("INSERT INTO t VALUES (1,'x')");
|
|
275
|
+
await exec('ALTER TABLE t RENAME TO t2');
|
|
276
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.jsql'));
|
|
277
|
+
const tables = (await db.getTables()).slice().sort();
|
|
278
|
+
ok('目录模式改名后表列表正确', eq(tables, ['t2']), tables);
|
|
279
|
+
ok('物理文件名跟着改了', files.some(f => f.includes('t2')) && !files.some(f => /(^|[^2])t\.jsql$/.test(f)), files);
|
|
280
|
+
} catch (e) {
|
|
281
|
+
ok('目录模式改名', false, e.message);
|
|
282
|
+
} finally {
|
|
283
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
console.log(`\n${pass} passed, ${fail} failed`);
|
|
288
|
+
process.exit(fail === 0 ? 0 : 1);
|
|
289
|
+
})().catch(e => { console.error('💥', e); process.exit(1); });
|