jsql-neo 5.3.1 → 5.4.0

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.
@@ -20,7 +20,7 @@ function startEngine(filename, options) {
20
20
  const opts = options || {};
21
21
  const isMemory = filename === ':memory:' || !filename;
22
22
  if (opts.engine === 'native') {
23
- const { NativeJSQL } = require('./native_client');
23
+ const { JSQL: NativeJSQL } = require('./native_client');
24
24
  const engine = new NativeJSQL({ mode: 'memory' });
25
25
  db = {
26
26
  start: async () => { await engine.start(); },
@@ -30,10 +30,14 @@ function startEngine(filename, options) {
30
30
  dropTable: n => engine.dropTable(n),
31
31
  insert: (n, rows) => engine.insert(n, rows),
32
32
  find: (n, f, o) => engine.find(n, f, o),
33
+ updateById: (n, id, data) => engine.updateById(n, id, data),
33
34
  updateByIds: (n, e) => engine.updateByIds(n, e),
34
35
  removeByIds: (n, ids) => engine.removeByIds(n, ids),
35
36
  count: (n, f) => engine.count ? engine.count(n, f) : engine.find(n, f || {}, {}).then(rs => rs.length),
36
37
  getTableSchema: n => engine._schemas[n] || null,
38
+ beginTx: () => engine.beginTx(),
39
+ commitTx: id => engine.commitTx(id),
40
+ rollbackTx: id => engine.rollbackTx(id),
37
41
  truncate: async () => { throw new Error('truncate not supported on native engine'); },
38
42
  flush: async () => { if (engine.flush) await engine.flush(); },
39
43
  listTables: () => Array.from(engine._tableNames || []),
@@ -23,14 +23,52 @@ const NATIVE_TYPE_MAP = {
23
23
  int: 'integer',
24
24
  };
25
25
 
26
+ function parseShorthandSchema(str) {
27
+ const def = { type: str };
28
+ const strip = (re) => {
29
+ const next = def.type.replace(re, '').trim();
30
+ def.type = next || def.type;
31
+ };
32
+ if (/\bprimary\s+key\b/i.test(def.type)) { def.primaryKey = true; strip(/\bprimary\s+key\b/gi); }
33
+ if (/\bauto_?increment\b/i.test(def.type)) { def.autoIncrement = true; strip(/\bauto_?increment\b/gi); }
34
+ if (/\bnot\s+null\b/i.test(def.type)) { def.required = true; strip(/\bnot\s+null\b/gi); }
35
+ if (/\bunique\b/i.test(def.type)) { def.unique = true; strip(/\bunique\b/gi); }
36
+ if (/\bdefault\s+(\S+)/i.test(def.type)) {
37
+ const m = def.type.match(/\bdefault\s+(\S+)/i);
38
+ def.default = m[1].replace(/^['"]|['"]$/g, '');
39
+ strip(/\bdefault\s+\S+/gi);
40
+ }
41
+ return def;
42
+ }
43
+
44
+ function parseSchemaString(str) {
45
+ const out = {};
46
+ for (const seg of str.split(',')) {
47
+ const trimmed = seg.trim();
48
+ if (!trimmed) continue;
49
+ const parts = trimmed.split(/\s+/);
50
+ const name = parts.shift();
51
+ const def = parseShorthandSchema(parts.join(' ').toLowerCase().trim());
52
+ if (def.type.length === 0) def.type = 'string';
53
+ out[name] = def;
54
+ }
55
+ return out;
56
+ }
57
+
26
58
  function mapNativeSchema(schema) {
59
+ if (typeof schema === 'string') schema = parseSchemaString(schema);
27
60
  const mapped = {};
28
61
  for (const [field, def] of Object.entries(schema || {})) {
29
62
  if (typeof def === 'string') {
30
- mapped[field] = { type: NATIVE_TYPE_MAP[def.toLowerCase()] || def.toLowerCase() };
63
+ const parsed = parseShorthandSchema(def.toLowerCase().trim());
64
+ if (parsed.type.length === 0) parsed.type = 'string';
65
+ mapped[field] = { ...parsed, type: NATIVE_TYPE_MAP[parsed.type] || parsed.type };
31
66
  } else if (def && typeof def === 'object') {
32
67
  const t = (def.type || 'string').toLowerCase();
33
- mapped[field] = { ...def, type: NATIVE_TYPE_MAP[t] || t };
68
+ const out = { ...def, type: NATIVE_TYPE_MAP[t] || t };
69
+ if (out.primary_key !== undefined && out.primaryKey === undefined) { out.primaryKey = out.primary_key; delete out.primary_key; }
70
+ if (out.auto_increment !== undefined && out.autoIncrement === undefined) { out.autoIncrement = out.auto_increment; delete out.auto_increment; }
71
+ mapped[field] = out;
34
72
  } else {
35
73
  mapped[field] = { type: 'string' };
36
74
  }
@@ -39,25 +77,46 @@ function mapNativeSchema(schema) {
39
77
  }
40
78
 
41
79
  function restoreRow(row, schema) {
42
- if (!schema || typeof row !== 'object' || row === null) return row;
43
- const out = {};
44
- for (const [field, def] of Object.entries(row)) {
45
- if (field === 'fields' && typeof row[field] === 'object' && row[field] !== null) {
46
- out[field] = restoreRow(row[field], schema);
47
- continue;
80
+ if (typeof row !== 'object' || row === null) return row;
81
+ let src = row;
82
+ if (row.fields && typeof row.fields === 'object' && row.fields !== null) {
83
+ src = { ...row.fields };
84
+ if (row.id !== undefined) {
85
+ const pkCols = [];
86
+ if (schema) {
87
+ for (const [k, def] of Object.entries(schema)) {
88
+ if (def && typeof def === 'object' && def.autoIncrement) continue;
89
+ const isPk = typeof def === 'string'
90
+ ? /\bprimary\s+key\b/i.test(def)
91
+ : !!(def && (def.primaryKey || def.primary_key));
92
+ if (isPk) pkCols.push(k);
93
+ }
94
+ } else if (src.id === undefined) {
95
+ pkCols.push('id');
96
+ }
97
+ for (const c of pkCols) {
98
+ const v = src[c];
99
+ if (v === undefined || v === null || v === 0 || v === '' || v === false) src[c] = row.id;
100
+ }
48
101
  }
102
+ if (row.created_at !== undefined) src.created_at = row.created_at;
103
+ if (row.updated_at !== undefined) src.updated_at = row.updated_at;
104
+ }
105
+ if (!schema) return src;
106
+ const out = {};
107
+ for (const [field, value] of Object.entries(src)) {
49
108
  const colDef = schema[field];
50
109
  let t = null;
51
110
  if (typeof colDef === 'string') t = colDef.toLowerCase();
52
111
  else if (colDef && typeof colDef === 'object' && colDef.type) t = String(colDef.type).toLowerCase();
53
- if (['json', 'array', 'object'].includes(t) && typeof row[field] === 'string') {
54
- try { out[field] = JSON.parse(row[field]); } catch (e) { out[field] = row[field]; }
55
- } else if ((t === 'integer' || t === 'int' || t === 'bigint') && typeof row[field] === 'string' && /^-?\d+$/.test(row[field])) {
56
- out[field] = Number(row[field]);
57
- } else if ((t === 'float' || t === 'double' || t === 'number') && typeof row[field] === 'string' && !Number.isNaN(Number(row[field]))) {
58
- out[field] = Number(row[field]);
112
+ if (['json', 'array', 'object'].includes(t) && typeof value === 'string') {
113
+ try { out[field] = JSON.parse(value); } catch (e) { out[field] = value; }
114
+ } else if ((t === 'integer' || t === 'int' || t === 'bigint') && typeof value === 'string' && /^-?\d+$/.test(value)) {
115
+ out[field] = Number(value);
116
+ } else if ((t === 'float' || t === 'double' || t === 'number') && typeof value === 'string' && !Number.isNaN(Number(value))) {
117
+ out[field] = Number(value);
59
118
  } else {
60
- out[field] = row[field];
119
+ out[field] = value;
61
120
  }
62
121
  }
63
122
  return out;
@@ -230,6 +289,7 @@ class JSQL {
230
289
  this._bufferSize = 0;
231
290
  this._tableNames = new Set();
232
291
  this._schemas = {};
292
+ this._txId = undefined;
233
293
  this._autoPlugins = opts.modules !== false;
234
294
 
235
295
  this._plugins = [];
@@ -324,6 +384,62 @@ class JSQL {
324
384
  this._emit('start', {});
325
385
  }
326
386
 
387
+ _pkOf(table) {
388
+ const schema = this._schemas[table];
389
+ if (!schema) return null;
390
+ for (const [k, def] of Object.entries(schema)) {
391
+ const isPk = (typeof def === 'string' && /\bprimary\s+key\b/i.test(def))
392
+ || !!(def && typeof def === 'object' && (def.primaryKey || def.primary_key));
393
+ if (isPk) return k;
394
+ }
395
+ return null;
396
+ }
397
+
398
+ _pkAutoIncrement(table) {
399
+ const schema = this._schemas[table];
400
+ const pk = this._pkOf(table);
401
+ if (!pk || !schema) return false;
402
+ const def = schema[pk];
403
+ if (typeof def === 'string') return /\bauto_?increment\b/i.test(def);
404
+ return !!(def && def.autoIncrement);
405
+ }
406
+
407
+ _coercePkId(table, id) {
408
+ const schema = this._schemas[table];
409
+ const pk = this._pkOf(table);
410
+ if (!schema || !pk) return id;
411
+ const def = schema[pk];
412
+ let t = null;
413
+ if (typeof def === 'string') t = def.toLowerCase();
414
+ else if (def && typeof def === 'object' && def.type) t = String(def.type).toLowerCase();
415
+ if (['integer', 'int', 'bigint'].includes(t)) return Number(id);
416
+ return id;
417
+ }
418
+
419
+ _findRawById(table, id) {
420
+ const pk = this._pkOf(table);
421
+ if (pk) {
422
+ const filter = {};
423
+ filter[pk] = this._coercePkId(table, id);
424
+ const r = safeJsonParse(wasmBindings.jsql_find(table, JSON.stringify(filter), 1, 0));
425
+ if (r && r.error) return r;
426
+ return Array.isArray(r) && r.length > 0 ? r[0] : null;
427
+ }
428
+ return safeJsonParse(wasmBindings.jsql_find_by_id(table, BigInt(id)));
429
+ }
430
+
431
+ _seqIdByPk(table, id) {
432
+ const pk = this._pkOf(table);
433
+ if (pk) {
434
+ const filter = {};
435
+ filter[pk] = this._coercePkId(table, id);
436
+ const r = safeJsonParse(wasmBindings.jsql_find(table, JSON.stringify(filter), 1, 0));
437
+ if (Array.isArray(r) && r.length > 0) return r[0].id;
438
+ return null;
439
+ }
440
+ return Number(id);
441
+ }
442
+
327
443
  async _insertBatch(table, rows) {
328
444
  const schema = this._schemas[table];
329
445
  if (schema) {
@@ -347,6 +463,15 @@ class JSQL {
347
463
  }
348
464
  const r = safeJsonParse(wasmBindings.jsql_insert_json(table, JSON.stringify(rows)));
349
465
  if (r && r.error) throw new Error(r.error);
466
+ if (r && Array.isArray(r) && !this._pkAutoIncrement(table)) {
467
+ const pk = this._pkOf(table);
468
+ if (pk) {
469
+ return r.map((seq, i) => {
470
+ const row = rows[i];
471
+ return (row && row[pk] !== undefined && row[pk] !== null) ? row[pk] : seq;
472
+ });
473
+ }
474
+ }
350
475
  return r;
351
476
  }
352
477
 
@@ -401,7 +526,7 @@ class JSQL {
401
526
  const r = safeJsonParse(wasmBindings.jsql_create_table(name, JSON.stringify(mapNativeSchema(schema))));
402
527
  if (r && r.ok === false) throw new Error(r.error || 'create table failed');
403
528
  this._tableNames.add(name);
404
- this._schemas[name] = schema;
529
+ this._schemas[name] = typeof schema === 'string' ? mapNativeSchema(schema) : schema;
405
530
  this._emit('createTable', { name, schema });
406
531
  this._runHooks('afterCreateTable', [name, schema]);
407
532
  return r;
@@ -422,7 +547,7 @@ class JSQL {
422
547
  async findById(table, id) {
423
548
  await this._flush();
424
549
  if (!this._runHooks('beforeFind', [table, { id }])) return null;
425
- const r = safeJsonParse(wasmBindings.jsql_find_by_id(table, BigInt(id)));
550
+ const r = this._findRawById(table, id);
426
551
  if (r && r.error) throw new Error(r.error);
427
552
  this._runHooks('afterFind', [table, { id }, r]);
428
553
  const schema = this._schemas[table];
@@ -453,7 +578,11 @@ class JSQL {
453
578
  }
454
579
 
455
580
  async updateByIds(table, entries) {
456
- const pairs = entries.map(([id, data]) => [Number(id), data]);
581
+ const pairs = [];
582
+ for (const [id, data] of entries) {
583
+ const seq = this._seqIdByPk(table, id);
584
+ if (seq !== null) pairs.push([seq, data]);
585
+ }
457
586
  if (!this._runHooks('beforeUpdate', [table, pairs])) return;
458
587
  await this._flush();
459
588
  const r = safeJsonParse(wasmBindings.jsql_update_by_ids(table, JSON.stringify(pairs)));
@@ -466,9 +595,10 @@ class JSQL {
466
595
  async removeByIds(table, ids) {
467
596
  if (!this._runHooks('beforeDelete', [table, ids])) return;
468
597
  await this._flush();
469
- const r = safeJsonParse(wasmBindings.jsql_remove_by_ids(table, JSON.stringify(ids)));
598
+ const seqs = ids.map(id => this._seqIdByPk(table, id)).filter(s => s !== null);
599
+ const r = safeJsonParse(wasmBindings.jsql_remove_by_ids(table, JSON.stringify(seqs)));
470
600
  if (r && r.error) throw new Error(r.error);
471
- this._emit('delete', { table, ids, result: r });
601
+ this._emit('delete', { table, ids: seqs, result: r });
472
602
  this._runHooks('afterDelete', [table, ids, r]);
473
603
  return r;
474
604
  }
@@ -476,7 +606,12 @@ class JSQL {
476
606
  async findByIds(table, ids) {
477
607
  await this._flush();
478
608
  if (!this._runHooks('beforeFind', [table, { ids }])) return null;
479
- const r = safeJsonParse(wasmBindings.jsql_find_by_ids(table, JSON.stringify(ids)));
609
+ let r;
610
+ if (this._pkOf(table)) {
611
+ r = ids.map(id => this._findRawById(table, id)).filter(x => x !== null && !(x && x.error));
612
+ } else {
613
+ r = safeJsonParse(wasmBindings.jsql_find_by_ids(table, JSON.stringify(ids)));
614
+ }
480
615
  if (r && r.error) throw new Error(r.error);
481
616
  this._runHooks('afterFind', [table, { ids }, r]);
482
617
  const schema = this._schemas[table];
@@ -491,7 +626,9 @@ class JSQL {
491
626
  async updateById(table, id, data) {
492
627
  if (!this._runHooks('beforeUpdate', [table, id, data])) return;
493
628
  await this._flush();
494
- const r = safeJsonParse(wasmBindings.jsql_update_by_id(table, BigInt(id), JSON.stringify(data)));
629
+ const seq = this._seqIdByPk(table, id);
630
+ if (seq === null) return null;
631
+ const r = safeJsonParse(wasmBindings.jsql_update_by_id(table, BigInt(seq), JSON.stringify(data)));
495
632
  if (r && r.ok === false) throw new Error(r.error || 'update failed');
496
633
  this._emit('update', { table, id, data });
497
634
  this._runHooks('afterUpdate', [table, id, data, r]);
@@ -501,13 +638,53 @@ class JSQL {
501
638
  async removeById(table, id) {
502
639
  if (!this._runHooks('beforeDelete', [table, id])) return;
503
640
  await this._flush();
504
- const r = safeJsonParse(wasmBindings.jsql_remove_by_id(table, BigInt(id)));
641
+ const seq = this._seqIdByPk(table, id);
642
+ if (seq === null) return null;
643
+ const r = safeJsonParse(wasmBindings.jsql_remove_by_id(table, BigInt(seq)));
505
644
  if (r && r.ok === false) throw new Error(r.error || 'remove failed');
506
645
  this._emit('delete', { table, id });
507
646
  this._runHooks('afterDelete', [table, id, r]);
508
647
  return r;
509
648
  }
510
649
 
650
+ async beginTx() {
651
+ await this._flush();
652
+ const r = safeJsonParse(wasmBindings.jsql_begin_tx());
653
+ if (r && r.ok === false) throw new Error(r.error || 'begin transaction failed');
654
+ this._txId = r.txId;
655
+ return r.txId;
656
+ }
657
+
658
+ async commitTx(txId) {
659
+ const r = safeJsonParse(wasmBindings.jsql_commit_tx(String(txId)));
660
+ if (r && r.ok === false) throw new Error(r.error || 'commit transaction failed');
661
+ return true;
662
+ }
663
+
664
+ async rollbackTx(txId) {
665
+ const r = safeJsonParse(wasmBindings.jsql_rollback_tx(String(txId)));
666
+ if (r && r.ok === false) throw new Error(r.error || 'rollback transaction failed');
667
+ return true;
668
+ }
669
+
670
+ async beginTransaction() {
671
+ return this.beginTx();
672
+ }
673
+
674
+ async commit() {
675
+ if (this._txId === undefined) return true;
676
+ const r = await this.commitTx(this._txId);
677
+ this._txId = undefined;
678
+ return r;
679
+ }
680
+
681
+ async rollback() {
682
+ if (this._txId === undefined) return true;
683
+ const r = await this.rollbackTx(this._txId);
684
+ this._txId = undefined;
685
+ return r;
686
+ }
687
+
511
688
  async hasTable(name) {
512
689
  return this._tableNames.has(name);
513
690
  }
package/lib/web_ui.js CHANGED
@@ -111,7 +111,7 @@ load();
111
111
 
112
112
  class WebUI {
113
113
  constructor(opts = {}) {
114
- this.port = opts.port || 8080;
114
+ this.port = opts.port !== undefined ? opts.port : 8080;
115
115
  this.dataDir = opts.dataDir || '.';
116
116
  this.readonly = !!opts.readonly;
117
117
  this.host = opts.host || '127.0.0.1';
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "jsql-neo-core"
3
- version = "0.1.0"
3
+ version = "5.4.0"
4
4
  edition = "2021"
5
5
  description = "Embedded database engine — core library (supports WASM)"
6
6
 
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "jsql-neo-native"
3
- version = "0.1.0"
3
+ version = "5.4.0"
4
4
  edition = "2021"
5
5
 
6
6
  [lib]
@@ -171,7 +171,7 @@ dependencies = [
171
171
 
172
172
  [[package]]
173
173
  name = "jsql-neo-core"
174
- version = "0.1.0"
174
+ version = "5.4.0"
175
175
  dependencies = [
176
176
  "bincode",
177
177
  "chrono",
@@ -187,7 +187,7 @@ dependencies = [
187
187
 
188
188
  [[package]]
189
189
  name = "jsql-neo-wasm"
190
- version = "0.1.0"
190
+ version = "5.4.0"
191
191
  dependencies = [
192
192
  "js-sys",
193
193
  "jsql-neo-core",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "jsql-neo-wasm"
3
- version = "0.1.0"
3
+ version = "5.4.0"
4
4
  edition = "2021"
5
5
 
6
6
  [lib]
@@ -421,3 +421,27 @@ pub fn jsql_find_by_ids(table: &str, ids_json: &str) -> String {
421
421
  }
422
422
  })
423
423
  }
424
+
425
+ #[wasm_bindgen]
426
+ pub fn jsql_begin_tx() -> String {
427
+ ENGINE.with(|eng| match eng.borrow_mut().begin_tx() {
428
+ Ok(tx_id) => serde_json::json!({ "ok": true, "txId": tx_id }).to_string(),
429
+ Err(e) => format!(r#"{{"ok":false,"error":"{}"}}"#, e),
430
+ })
431
+ }
432
+
433
+ #[wasm_bindgen]
434
+ pub fn jsql_commit_tx(tx_id: &str) -> String {
435
+ ENGINE.with(|eng| match eng.borrow_mut().commit_tx(tx_id) {
436
+ Ok(()) => r#"{"ok":true}"#.to_string(),
437
+ Err(e) => format!(r#"{{"ok":false,"error":"{}"}}"#, e),
438
+ })
439
+ }
440
+
441
+ #[wasm_bindgen]
442
+ pub fn jsql_rollback_tx(tx_id: &str) -> String {
443
+ ENGINE.with(|eng| match eng.borrow_mut().rollback_tx(tx_id) {
444
+ Ok(()) => r#"{"ok":true}"#.to_string(),
445
+ Err(e) => format!(r#"{{"ok":false,"error":"{}"}}"#, e),
446
+ })
447
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsql-neo",
3
- "version": "5.3.1",
3
+ "version": "5.4.0",
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",
@@ -19,7 +19,6 @@
19
19
  "types": "./wasm/browser.d.ts",
20
20
  "default": "./wasm/browser.mjs"
21
21
  },
22
- "./wasm/browser_bg.mjs": "./wasm/browser_bg.mjs",
23
22
  "./wasm/browser.d.ts": "./wasm/browser.d.ts",
24
23
  "./wasm/*": "./wasm/*",
25
24
  "./lib/*.js": "./lib/*.js",
@@ -48,8 +47,9 @@
48
47
  "postinstall": "node postinstall.js",
49
48
  "test": "node test/smoke.js",
50
49
  "test:btree": "node test/btree.test.js",
50
+ "test:native": "node test/native.test.js",
51
51
  "test:regress": "node test/regress-5.1.0.js",
52
- "test:all": "node test/smoke.js && node test/coverage.js && node test/regress-5.1.0.js && node test/btree.test.js",
52
+ "test:all": "node test/smoke.js && node test/native.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",
53
53
  "test:coverage": "c8 --all --include='lib/**' --exclude='lib/mysql_server.js' --exclude='lib/native_client.js' --exclude='lib/wasm_client.js' --exclude='lib/mysql_compat.js' --exclude='lib/nedb_compat.js' --exclude='lib/plugin.js' --reporter=text --reporter=lcov node test/coverage.js",
54
54
  "test:orms": "node examples/orms/run-all.js"
55
55
  },
@@ -0,0 +1,110 @@
1
+ /*
2
+ * Regression tests for SQL JOIN null-fill correctness.
3
+ * Zero runtime dependencies (uses Node builtins + in-repo libs only).
4
+ *
5
+ * node test/join.test.js
6
+ *
7
+ * Covers:
8
+ * J1 LEFT JOIN unmatched rows null-fill right-table qualified columns
9
+ * J2 RIGHT JOIN unmatched rows null-fill left-table qualified columns
10
+ * J3 INNER JOIN unaffected
11
+ * J4 unqualified column reference in JOIN
12
+ * J5 WHERE filtering on the null-filled side
13
+ * J6 self-join with table alias
14
+ * J7 chained joins
15
+ */
16
+ const path = require('path');
17
+
18
+ const ROOT = path.join(__dirname, '..');
19
+ const Database = require(path.join(ROOT, 'lib/database'));
20
+ const { executeSQL } = require(path.join(ROOT, 'lib/sql'));
21
+
22
+ let passed = 0, failed = 0;
23
+ function ok(name, cond, extra) {
24
+ if (cond) { passed++; console.log('[OK]', name); }
25
+ else { failed++; console.log('[FAIL]', name, extra !== undefined ? '-> ' + JSON.stringify(extra) : ''); }
26
+ }
27
+
28
+ async function setup(db) {
29
+ await executeSQL(db, 'CREATE TABLE a (id INT PRIMARY KEY, a_name STRING)');
30
+ await executeSQL(db, 'CREATE TABLE b (id INT PRIMARY KEY, a_id INT, b_name STRING)');
31
+ await executeSQL(db, "INSERT INTO a VALUES (1,'A1'),(2,'A2'),(3,'A3')");
32
+ await executeSQL(db, "INSERT INTO b VALUES (10,2,'B2'),(20,99,'B99')");
33
+ }
34
+
35
+ (async () => {
36
+ /* ============ J1 LEFT JOIN null-fill ============ */
37
+ {
38
+ const db = new Database(':memory:', { autoSave: false });
39
+ await setup(db);
40
+ const r = await executeSQL(db, 'SELECT a.id AS aid, a.a_name, b.id AS bid, b.b_name FROM a LEFT JOIN b ON a.id = b.a_id');
41
+ const row1 = r.rows[0]; // a=1 unmatched
42
+ ok('J1 left unmatched row', row1 && row1[0] === 1 && row1[1] === 'A1' && row1[2] === null && row1[3] === null, row1);
43
+ const row3 = r.rows[2]; // a=3 unmatched
44
+ ok('J1 second unmatched row', row3 && row3[0] === 3 && row3[2] === null, row3);
45
+ const row2 = r.rows[1]; // matched
46
+ ok('J1 matched row intact', row2 && row2[0] === 2 && row2[1] === 'A2' && row2[2] === 10 && row2[3] === 'B2', row2);
47
+ }
48
+
49
+ /* ============ J2 RIGHT JOIN null-fill ============ */
50
+ {
51
+ const db = new Database(':memory:', { autoSave: false });
52
+ await setup(db);
53
+ const r = await executeSQL(db, 'SELECT a.id AS aid, a.a_name, b.id AS bid, b.b_name FROM a RIGHT JOIN b ON a.id = b.a_id');
54
+ const b99 = r.rows[1]; // b=20 unmatched (a_id=99)
55
+ ok('J2 right unmatched row', b99 && b99[0] === null && b99[1] === null && b99[2] === 20 && b99[3] === 'B99', b99);
56
+ const b2 = r.rows[0]; // matched
57
+ ok('J2 matched row intact', b2 && b2[0] === 2 && b2[1] === 'A2' && b2[2] === 10 && b2[3] === 'B2', b2);
58
+ }
59
+
60
+ /* ============ J3 INNER JOIN ============ */
61
+ {
62
+ const db = new Database(':memory:', { autoSave: false });
63
+ await setup(db);
64
+ const r = await executeSQL(db, 'SELECT a.id AS aid, b.id AS bid FROM a INNER JOIN b ON a.id = b.a_id');
65
+ ok('J3 inner join', r.rows.length === 1 && r.rows[0][0] === 2 && r.rows[0][1] === 10, r.rows);
66
+ }
67
+
68
+ /* ============ J4 unqualified reference ============ */
69
+ {
70
+ const db = new Database(':memory:', { autoSave: false });
71
+ await setup(db);
72
+ const r = await executeSQL(db, 'SELECT a.id AS aid, b.b_name FROM a LEFT JOIN b ON a.id = b.a_id');
73
+ ok('J4 unprefixed id resolves to left table', r.rows[0][0] === 1 && r.rows[0][1] === null, r.rows[0]);
74
+ }
75
+
76
+ /* ============ J5 WHERE on null-filled side ============ */
77
+ {
78
+ const db = new Database(':memory:', { autoSave: false });
79
+ await setup(db);
80
+ const r = await executeSQL(db, "SELECT a.id AS aid, b.b_name FROM a LEFT JOIN b ON a.id = b.a_id WHERE b.id IS NULL");
81
+ ok('J5 left where b.id IS NULL', r.rows.length === 2 && r.rows.every(x => x[1] === null), r.rows);
82
+ const r2 = await executeSQL(db, "SELECT a.a_name, b.id AS bid FROM a RIGHT JOIN b ON a.id = b.a_id WHERE a.id IS NULL");
83
+ ok('J5 right where a.id IS NULL', r2.rows.length === 1 && r2.rows[0][0] === null && r2.rows[0][1] === 20, r2.rows);
84
+ }
85
+
86
+ /* ============ J6 self-join with alias ============ */
87
+ {
88
+ const db = new Database(':memory:', { autoSave: false });
89
+ await executeSQL(db, 'CREATE TABLE emp (id INT PRIMARY KEY, mgr_id INT, name STRING)');
90
+ await executeSQL(db, "INSERT INTO emp VALUES (1,NULL,'boss'),(2,1,'alice'),(3,999,'orphan')");
91
+ const r = await executeSQL(db, 'SELECT e.name AS ename, m.name AS mname FROM emp e LEFT JOIN emp m ON e.mgr_id = m.id');
92
+ ok('J6 self left join', r.rows.length === 3 && r.rows[0][0] === 'boss' && r.rows[0][1] === null && r.rows[1][1] === 'boss' && r.rows[2][1] === null, r.rows);
93
+ }
94
+
95
+ /* ============ J7 chained joins ============ */
96
+ {
97
+ const db = new Database(':memory:', { autoSave: false });
98
+ await executeSQL(db, 'CREATE TABLE a (id INT PRIMARY KEY, a_name STRING)');
99
+ await executeSQL(db, 'CREATE TABLE b (id INT PRIMARY KEY, a_id INT, b_name STRING)');
100
+ await executeSQL(db, 'CREATE TABLE c (id INT PRIMARY KEY, b_id INT, c_name STRING)');
101
+ await executeSQL(db, "INSERT INTO a VALUES (1,'A1'),(2,'A2'),(3,'A3')");
102
+ await executeSQL(db, "INSERT INTO b VALUES (10,2,'B2'),(20,99,'B99')");
103
+ await executeSQL(db, "INSERT INTO c VALUES (100,10,'C10'),(200,77,'C77')");
104
+ const r = await executeSQL(db, 'SELECT a.id AS aid, b.id AS bid, c.id AS cid, c.c_name FROM a LEFT JOIN b ON a.id = b.a_id LEFT JOIN c ON b.id = c.b_id');
105
+ ok('J7 chained left joins', r.rows.length === 3 && r.rows[1][1] === 10 && r.rows[1][2] === 100 && r.rows[1][3] === 'C10' && r.rows[0][1] === null && r.rows[0][2] === null, r.rows);
106
+ }
107
+
108
+ console.log(failed === 0 ? `\nALL ${passed} JOIN TESTS PASSED` : `\n${failed} FAILURES (${passed} passed)`);
109
+ process.exit(failed === 0 ? 0 : 1);
110
+ })().catch((e) => { console.error('FATAL', e); process.exit(1); });