jsql-neo 6.0.2 → 6.1.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.
package/README.md CHANGED
@@ -1104,22 +1104,22 @@ ORDER BY avg_sal DESC;
1104
1104
 
1105
1105
  > **运算符方言说明。** `^` 与 `~` 在 MySQL(位运算)和 PostgreSQL(`^` 为幂、
1106
1106
  > `~` 为正则匹配)中含义冲突,本实现统一采用 **MySQL 语义**:`^` = 按位异或、
1107
- > `~` = 按位取反。需要幂运算请用 `POWER(x, n)`,需要正则请用 `REGEXP` / `~*`。
1108
- > 因此 PG 风格的 `^`(幂)、`~ ~* !~ !~*`(正则)与 JSON 操作符
1109
- > (`-> ->> #> #>> @> <@`)**暂不支持**。
1107
+ > `~` = 按位取反。需要幂运算请用 `POWER(x, n)`,需要正则请用 `REGEXP`。
1108
+ > 因此 PG 风格的 `^`(幂)与 `~ ~* !~ !~*`(正则)**暂不支持**。
1110
1109
 
1111
- **JSON (PG style)** — `->` (JSON result), `->>` (text), `#>`, `#>>` (paths), `@>` `<@`
1112
- (containment), `?` `?|` `?&` (key existence).
1110
+ **JSON (PG style)** — `->`(返回 JSON)、`->>`(返回文本),可链式
1111
+ (`meta->'addr'->>'city'`)。`#>` `#>>`(路径)、`@>` `<@`(包含)、
1112
+ `?` `?|` `?&`(键存在)**暂不支持**。
1113
1113
 
1114
1114
  ### LIKE / ILIKE / regex
1115
1115
 
1116
- - `%` any length, `_` one char, `\` escape (`ESCAPE` clause)
1117
- - `ILIKE` = LIKE, case-insensitive
1118
- - `REGEXP`/`RLIKE`/`~` use the JS RegExp engine; flags `i/m/s`
1116
+ - `%` any length, `_` one char;`ESCAPE '!'` 可自定义转义符(默认 `\`)
1117
+ - `ILIKE` = LIKE,大小写不敏感
1118
+ - `REGEXP` / `RLIKE` 使用 JS RegExp 引擎;flags `i/m/s`
1119
1119
 
1120
1120
  ```sql
1121
1121
  SELECT * FROM users
1122
- WHERE email ~* '^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$';
1122
+ WHERE email REGEXP '^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$';
1123
1123
  ```
1124
1124
 
1125
1125
  ### Indexes & constraints
@@ -1162,7 +1162,7 @@ INSERT INTO users (id, meta) VALUES
1162
1162
  (1, '{"name":"Alice","tags":["admin","ops"],"addr":{"city":"SH"}}');
1163
1163
 
1164
1164
  SELECT meta->>'name' AS name, meta->'addr'->>'city' AS city
1165
- FROM users WHERE meta @> '{"tags":["admin"]}';
1165
+ FROM users;
1166
1166
  ```
1167
1167
 
1168
1168
  JSON columns are fully interoperable with Mongo document views.
@@ -1270,16 +1270,17 @@ const result = await executeSQL(db, sql, params);
1270
1270
  ```js
1271
1271
  {
1272
1272
  columns: ['id', 'name', 'age'],
1273
- columnTypes: ['INTEGER', 'VARCHAR', 'INTEGER'],
1273
+ columnTypes: ['INT', 'VARCHAR(255)', 'INT'],
1274
1274
  rows: [[1, 'Alice', 30]],
1275
- rowCount: 2, affectedRows: 0, insertId: 1,
1276
- message: '2 rows selected', command: 'SELECT',
1275
+ rowCount: 1, affectedRows: 0,
1276
+ message: '1 row selected', command: 'SELECT',
1277
1277
  durationMs: 0.42, warnings: [],
1278
1278
  }
1279
1279
  ```
1280
1280
 
1281
- Result matrix: SELECT → `rows`; INSERT → `affectedRows` + `insertId`; UPDATE/DELETE →
1282
- `affectedRows`; DDL/txn → `message`. Params: positional `?`, named `:name`, or object maps.
1281
+ Result matrix: SELECT → `rows`(另有 `columnTypes`、`rowCount`、`command`、`message`、
1282
+ `durationMs`、`warnings`);INSERT → `affectedRows` + `insertId`(另有 `ids`);
1283
+ UPDATE/DELETE → `affectedRows`;DDL/txn → `message`。 Params: positional `?`, named `:name`, or object maps.
1283
1284
  Multi-statement supported; `SQLExecutor` class runs batched SQL from a string/stream.
1284
1285
 
1285
1286
  #### 参数绑定:两种方式
@@ -3403,7 +3404,6 @@ INTERVAL 单位:`DAY` `HOUR` `MINUTE` `SECOND` `WEEK` `MONTH` `YEAR` 及其组
3403
3404
  | `+` `-` `*` `/` | 四则 | `(a + b) * 2` |
3404
3405
  | `%` / `MOD` | 取模 | `a % 3` |
3405
3406
  | `DIV` | 整数除法(MySQL) | `7 DIV 2` → 3 |
3406
- | `^` | 幂(PG) | `2 ^ 10` → 1024 |
3407
3407
 
3408
3408
  #### 比较运算符
3409
3409
 
@@ -3445,10 +3445,10 @@ INTERVAL 单位:`DAY` `HOUR` `MINUTE` `SECOND` `WEEK` `MONTH` `YEAR` 及其组
3445
3445
  |---|---|---|
3446
3446
  | `->` | 取 JSON 字段(返回 JSON) | `meta->'name'` |
3447
3447
  | `->>` | 取 JSON 字段(返回文本) | `meta->>'name'` |
3448
- | `#>`, `#>>` | 路径访问 | `meta#>>'{a,b}'` |
3449
- | `@>` | 包含 | `meta @> '{"plan":"pro"}'` |
3450
- | `<@` | 被包含 | `'{"a":1}'::jsonb <@ meta` |
3451
- | `?` / `?|` / `?&` | 键存在 | `meta ? 'plan'` |
3448
+
3449
+ 可链式取值:`meta->'addr'->>'city'`。
3450
+
3451
+ > `#>` `#>>`(路径访问)、`@>` `<@`(包含)、`?` `?|` `?&`(键存在)**暂不支持** —— 使用时会抛出明确错误,不会静默退化成别的运算。
3452
3452
 
3453
3453
  ### LIKE / ILIKE / 正则
3454
3454
 
@@ -3466,14 +3466,12 @@ INTERVAL 单位:`DAY` `HOUR` `MINUTE` `SECOND` `WEEK` `MONTH` `YEAR` 及其组
3466
3466
 
3467
3467
  - 基于 JS RegExp 引擎
3468
3468
  - 支持 flags:`i`(忽略大小写)、`m`(多行)、`s`(点匹配换行)
3469
- - PG 风格的 `~` / `~*` / `!~` / `!~*` 运算符也支持:
3470
- - `name ~ '^A'`(大小写敏感匹配)
3471
- - `name ~* '^a'`(不敏感)
3472
- - `name !~ '^X'`(不匹配)
3469
+ - PG 风格的 `~` / `~*` / `!~` / `!~*` 运算符**暂不支持**(`~` 已被用作按位取反)。
3470
+ 正则匹配请用 `REGEXP` / `RLIKE`。
3473
3471
 
3474
3472
  ```sql
3475
3473
  SELECT * FROM users
3476
- WHERE email ~* '^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$';
3474
+ WHERE email REGEXP '^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$';
3477
3475
  ```
3478
3476
 
3479
3477
  ### 事务 Transactions
@@ -3572,7 +3570,7 @@ INSERT INTO users (id, meta) VALUES
3572
3570
  -- 查询路径
3573
3571
  SELECT meta->>'name' AS name,
3574
3572
  meta->'addr'->>'city' AS city
3575
- FROM users WHERE meta @> '{"tags":["admin"]}';
3573
+ FROM users;
3576
3574
  ```
3577
3575
 
3578
3576
  JSON 函数见 [系统与杂项函数](#系统与杂项函数)。Mongo 客户端视角下,JSON 列与 BSON 文档完全互通。
@@ -5800,7 +5798,6 @@ expr := literal
5800
5798
  | expr [NOT] LIKE pattern [ESCAPE char]
5801
5799
  | expr [NOT] ILIKE pattern
5802
5800
  | expr [NOT] RLIKE pattern | expr [NOT] REGEXP pattern
5803
- | expr [NOT] ~ pattern | expr [NOT] ~* pattern | expr !~ pattern
5804
5801
  | expr IS [NOT] NULL | expr IS [NOT] TRUE | expr IS [NOT] FALSE
5805
5802
  | expr [NOT] IN ( ... )
5806
5803
  | expr [NOT] ANY '(' select_statement ')'
@@ -5846,16 +5843,17 @@ function_call := func_name '(' [DISTINCT] args ')' -- 普通/聚合
5846
5843
  ### 运算符优先级(从高到低)
5847
5844
 
5848
5845
  ```
5849
- 1. () . [] -> ->> #> #>>
5850
- 2. :: CAST 一元 + - ~
5846
+ 1. () . [] -> ->> ::
5847
+ 2. CAST 一元 + - ~
5851
5848
  3. ^ * / % DIV MOD
5852
5849
  4. + -
5853
5850
  5. << >> & | ^(位)
5854
- 6. = <> != < <= > >= <=> BETWEEN IN LIKE ILIKE RLIKE REGEXP ~ ~* @> <@ ?
5851
+ 6. = <> != < <= > >= <=> BETWEEN IN LIKE ILIKE RLIKE REGEXP
5855
5852
  7. NOT
5856
- 8. AND
5857
- 9. OR XOR
5858
- 10. 三元(IF / CASE 解析为函数)
5853
+ 8. AND &&
5854
+ 9. XOR
5855
+ 10. OR ||
5856
+ 11. 三元(IF / CASE 解析为函数)
5859
5857
  ```
5860
5858
 
5861
5859
  ### 关键词语法表
@@ -7447,12 +7445,12 @@ jsql import ./data app.sql
7447
7445
  | 冲突处理 | `ON DUPLICATE KEY UPDATE` | `ON CONFLICT ... DO ...` | 两者都支持 |
7448
7446
  | 占位符 | `?` | `$1, $2` | 两者(分协议) |
7449
7447
  | 模糊匹配 | `LIKE`(大小写敏感) | `LIKE` + `ILIKE` | 两者 |
7450
- | 正则 | `REGEXP` | `~` `~*` | 两者 + `RLIKE` |
7448
+ | 正则 | `REGEXP` | `REGEXP` | 两者 + `RLIKE` |
7451
7449
  | 标识符引用 | 反引号 `` ` `` | 双引号 `"` | 两者 |
7452
7450
  | 字符串字面量 | 单引号 | 单引号 | 单引号(双引号按标识符,PG 语义) |
7453
7451
  | 布尔 | `TRUE/FALSE`(1/0) | `t/f`(三值逻辑) | 兼容两者 |
7454
7452
  | 分页 | `LIMIT off, n` | `LIMIT n OFFSET off` | 两者 |
7455
- | JSON 访问 | `JSON_EXTRACT` | `->` `->>` | 两者 + `#>>` |
7453
+ | JSON 访问 | `JSON_EXTRACT` | `->` `->>` | 两者 |
7456
7454
  | 类型转换 | `CAST`/`CONVERT` | `::` | 三者 |
7457
7455
  | 条件分支 | `IF()` | `CASE` | `IF` + `CASE` + `IIF` |
7458
7456
  | 空值回退 | `IFNULL()` | `COALESCE` | 两者 |
@@ -17,6 +17,33 @@ function safeParse(str, fallback) {
17
17
  try { return JSON.parse(str); } catch (e) { return fallback; }
18
18
  }
19
19
 
20
+ /**
21
+ * 序列化对象/数组字段(JSON 列)并给出可读错误。直接 JSON.stringify 遇到循环引用会抛
22
+ * "Converting circular structure to JSON",指向不到具体列,这里补上表/列信息。
23
+ * 与 lib/wasm_client.js 的 safeStringify 保持一致。
24
+ */
25
+ function safeStringify(value, table, column) {
26
+ try {
27
+ return JSON.stringify(value);
28
+ } catch (e) {
29
+ const msg = (e && e.message) ? e.message : String(e);
30
+ throw new Error(`Cannot serialize column '${column}' of table '${table}': ${msg}`);
31
+ }
32
+ }
33
+
34
+ /** 插入行必须是普通对象;null/undefined/数组/基本类型在这里就被拦下(与 lib/wasm_client.js 一致) */
35
+ function assertRowObject(row, index, isBatch) {
36
+ const ok = row !== null && row !== undefined && typeof row === 'object' &&
37
+ !Array.isArray(row) && !(row instanceof Date);
38
+ if (ok) return;
39
+ const got = row === null ? 'null'
40
+ : Array.isArray(row) ? 'array'
41
+ : row instanceof Date ? 'Date'
42
+ : typeof row;
43
+ const where = isBatch ? ` at index ${index}` : '';
44
+ throw new TypeError(`insert(): expected a row object${where}, got ${got}`);
45
+ }
46
+
20
47
  const NATIVE_TYPE_MAP = {
21
48
  text: 'string',
22
49
  varchar: 'string',
@@ -399,8 +426,17 @@ class JSQL {
399
426
  rows = rows.map(row => {
400
427
  const out = {};
401
428
  for (const [k, v] of Object.entries(row)) {
402
- if (v instanceof Date) out[k] = v.toISOString();
403
- else out[k] = v;
429
+ const def = schema[k];
430
+ let t = null;
431
+ if (typeof def === 'string') t = def.toLowerCase();
432
+ else if (def && typeof def === 'object' && def.type) t = String(def.type).toLowerCase();
433
+ if (v !== null && v !== undefined && ['json', 'array', 'object'].includes(t) && typeof v !== 'string') {
434
+ out[k] = safeStringify(v, table, k);
435
+ } else if (v instanceof Date) {
436
+ out[k] = v.toISOString();
437
+ } else {
438
+ out[k] = v;
439
+ }
404
440
  }
405
441
  return out;
406
442
  });
@@ -450,18 +486,39 @@ class JSQL {
450
486
  async _flush() {
451
487
  this._flushOpsNow();
452
488
  if (!this._runHooks('beforeFlush', [])) return null;
453
- const flushed = {};
454
- for (const [table, rows] of Object.entries(this._buffer)) {
455
- if (rows.length === 0) continue;
456
- const r = await this._insertBatch(table, rows);
457
- flushed[table] = r;
458
- }
489
+ // 先把整个 buffer 原子取出再写入。
490
+ // 旧写法是遍历 this._buffer、await 之后才清空 —— 期间其他协程继续往同一个数组
491
+ // push,同一批行会被多个 flush 重复写入(并发 insert 行数暴涨/丢行)。
492
+ // 与 lib/wasm_client.js 的修法保持一致。
493
+ const pending = this._buffer;
459
494
  this._buffer = {};
460
495
  this._bufferSize = 0;
496
+ const flushed = {};
497
+ try {
498
+ for (const [table, rows] of Object.entries(pending)) {
499
+ if (rows.length === 0) continue;
500
+ flushed[table] = await this._insertBatch(table, rows);
501
+ }
502
+ } catch (e) {
503
+ // 坏行不回写 buffer:否则 stop() 二次 flush 会再抛同一个错导致进程 exit 1
504
+ if (!this._flushErrors) this._flushErrors = [];
505
+ this._flushErrors.push({
506
+ at: Date.now(),
507
+ message: (e && e.message) ? e.message : String(e),
508
+ tables: Object.keys(pending),
509
+ rowCounts: Object.fromEntries(Object.entries(pending).map(([t, r]) => [t, r.length])),
510
+ });
511
+ throw e;
512
+ }
461
513
  this._runHooks('afterFlush', []);
462
514
  return flushed;
463
515
  }
464
516
 
517
+ /** 返回历次 flush 失败的记录(坏行不会回写 buffer,只能从这里查) */
518
+ lastFlushErrors() {
519
+ return (this._flushErrors || []).slice();
520
+ }
521
+
465
522
  _flushOpsNow() {
466
523
  if (this._opTimer) {
467
524
  clearTimeout(this._opTimer);
@@ -479,7 +536,11 @@ class JSQL {
479
536
  }
480
537
 
481
538
  async insert(table, data) {
539
+ // 入参校验:null / undefined / 非对象会在下游 Object.entries() 处炸成
540
+ // 难以定位的 "Cannot convert undefined or null to object",这里直接给明确 TypeError
541
+ // (与 lib/wasm_client.js 的 assertRowObject 保持一致)
482
542
  const arr = Array.isArray(data) ? data : [data];
543
+ arr.forEach((row, i) => assertRowObject(row, i, arr.length > 1 || Array.isArray(data)));
483
544
  var filtered = arr;
484
545
  if (!this._runHooks('beforeInsert', [table, filtered])) return [];
485
546
  if (arr.length > 1) {
package/lib/sql.js CHANGED
@@ -79,6 +79,11 @@ function tokenize(sql) {
79
79
  while (i < n && sql[i] !== '\n') i++;
80
80
  continue;
81
81
  }
82
+ // PG 的 JSON 路径操作符 #> / #>> 尚未实现。
83
+ // 若不在这里拦截,'#' 会被当成行注释吃掉,导致 SQL 后半段被静默丢弃。
84
+ if (c === '#' && sql[i + 1] === '>') {
85
+ throw new Error("JSON 路径操作符 '#>' / '#>>' 暂不支持,请改用 '->' / '->>'");
86
+ }
82
87
  if (c === '#' || (c === '/' && sql[i + 1] === '*')) {
83
88
  if (c === '#') { while (i < n && sql[i] !== '\n') i++; continue; }
84
89
  i += 2;
@@ -95,7 +100,10 @@ function tokenize(sql) {
95
100
  if (sql[j] === '\\' && j + 1 < n) {
96
101
  const esc = sql[j + 1];
97
102
  const map = { n: '\n', t: '\t', r: '\r', '0': '\0', "'": "'", '"': '"', '\\': '\\', b: '\b', Z: '\x1a', a: '\a' };
98
- str += map[esc] !== undefined ? map[esc] : esc;
103
+ // MySQL 语义:\% 与 \_ 保留反斜杠(供 LIKE 匹配字面量通配符),其余未知转义忽略反斜杠
104
+ if (map[esc] !== undefined) str += map[esc];
105
+ else if (esc === '%' || esc === '_') str += '\\' + esc;
106
+ else str += esc;
99
107
  j += 2;
100
108
  } else if (sql[j] === quote) {
101
109
  break;
@@ -150,6 +158,12 @@ function tokenize(sql) {
150
158
  continue;
151
159
  }
152
160
 
161
+ // PG 的 JSON 包含操作符 @> 尚未实现。
162
+ // 若不拦截,'@' 会被 sysvar 分支静默吞掉、'>' 退化成普通大于号,产生错误结果。
163
+ if (c === '@' && sql[i + 1] === '>') {
164
+ throw new Error("JSON 包含操作符 '@>' 暂不支持");
165
+ }
166
+
153
167
  if (c === '@') {
154
168
  let j = i;
155
169
  while (j < n && sql[j] === '@') j++;
@@ -177,9 +191,20 @@ function tokenize(sql) {
177
191
  continue;
178
192
  }
179
193
 
194
+ const three = sql.slice(i, i + 3);
195
+ if (sql.slice(i, i + 2) === '<@') {
196
+ throw new Error("JSON 包含操作符 '<@' 暂不支持");
197
+ }
198
+ if (three === '->>' || three === '<=>') {
199
+ tokens.push(new SQLToken('op', three, i));
200
+ i += 3;
201
+ continue;
202
+ }
203
+
180
204
  const two = sql.slice(i, i + 2);
181
205
  if (two === '<=' || two === '>=' || two === '!=' || two === '<>' || two === '==' ||
182
- two === '<<' || two === '>>') {
206
+ two === '<<' || two === '>>' || two === '->' || two === '::' ||
207
+ two === '&&' || two === '||') {
183
208
  tokens.push(new SQLToken('op', two, i));
184
209
  i += 2;
185
210
  continue;
@@ -523,19 +548,27 @@ class Parser {
523
548
  parseColumnDef() {
524
549
  const def = {};
525
550
  const typeTok = this.next();
526
- if (typeTok.type !== 'keyword') throw new Error(`Expected column type, got '${typeTok.value}'`);
527
- const type = typeTok.value.toLowerCase();
551
+ const typeRaw = String(typeTok.value);
552
+ // 这些类型不在 KEYWORDS 里(避免影响同名标识符),按上下文识别
553
+ const IDENT_TYPES = /^(BIG)?SERIAL$|^JSONB$|^TIMESTAMPTZ$|^BYTEA$|^UUID$/i;
554
+ const isIdentType = IDENT_TYPES.test(typeRaw);
555
+ if (typeTok.type !== 'keyword' && !isIdentType) throw new Error(`Expected column type, got '${typeTok.value}'`);
556
+ const type = typeRaw.toLowerCase();
528
557
  const typeMap = {
529
558
  integer: 'integer', int: 'integer', bigint: 'integer', tinyint: 'integer', smallint: 'integer',
530
559
  string: 'string', text: 'string', varchar: 'string', char: 'string',
531
560
  float: 'number', double: 'number', real: 'number', numeric: 'number', decimal: 'number',
532
561
  boolean: 'boolean', bool: 'boolean',
533
- date: 'date', datetime: 'datetime', timestamp: 'timestamp',
534
- any: 'any', object: 'object', json: 'object', array: 'array'
562
+ date: 'date', datetime: 'datetime', timestamp: 'timestamp', timestamptz: 'timestamp',
563
+ any: 'any', object: 'object', json: 'object', jsonb: 'object', array: 'array',
564
+ bytea: 'string', uuid: 'string',
565
+ // PG 自增序列类型:等价于整型 + 自增 + NOT NULL
566
+ serial: 'integer', bigserial: 'integer'
535
567
  };
536
568
  const mapped = typeMap[type];
537
569
  if (!mapped) throw new Error(`Unsupported column type: ${typeTok.value}`);
538
570
  def.type = mapped;
571
+ if (/^(BIG)?SERIAL$/i.test(typeRaw)) { def.autoIncrement = true; def.required = true; }
539
572
 
540
573
  // 长度限制: TEXT(255) / VARCHAR(100) / INTEGER(11) ...
541
574
  if (this.peek().type === 'op' && this.peek().value === '(') {
@@ -598,6 +631,11 @@ class Parser {
598
631
  def.defaultExpr = true;
599
632
  } else {
600
633
  def.default = this.parseValue();
634
+ // 允许字面量后带 :: 类型转换:DEFAULT '{}'::jsonb
635
+ if (this.peek().type === 'op' && this.peek().value === '::') {
636
+ this.next();
637
+ if (this.peek().type === 'keyword' || this.peek().type === 'ident') this.next();
638
+ }
601
639
  }
602
640
  break;
603
641
  case 'COLLATE':
@@ -772,7 +810,7 @@ class Parser {
772
810
  for (;;) {
773
811
  const col = this.parseColumnRef();
774
812
  this.expect('op', '=');
775
- const val = this.parseValue();
813
+ const val = this.parseConflictValue();
776
814
  sets.push([col, val]);
777
815
  if (this.peek().value === ',') { this.next(); continue; }
778
816
  break;
@@ -852,6 +890,21 @@ class Parser {
852
890
  throw new Error(`Expected value, got '${t.value}'`);
853
891
  }
854
892
 
893
+ /**
894
+ * ON CONFLICT DO UPDATE 赋值右侧:
895
+ * EXCLUDED.col —— PG:引用"本次本应插入的值"
896
+ * VALUES(col) —— MySQL 同义(在 parseValue 中处理)
897
+ * 其他字面量
898
+ */
899
+ parseConflictValue() {
900
+ if (this.isWord('EXCLUDED') && this.peek(1).type === 'op' && this.peek(1).value === '.') {
901
+ this.next(); this.next();
902
+ const col = this.parseColumnRef();
903
+ return { _excludedOf: col };
904
+ }
905
+ return this.parseValue();
906
+ }
907
+
855
908
  /**
856
909
  * WITH [RECURSIVE] name [(col, ...)] AS (SELECT ...), ... <statement>
857
910
  * CTE 在语法层解析为 { type:'with', ctes:[{name, columns, select}], statement }。
@@ -1628,18 +1681,33 @@ class Parser {
1628
1681
  }
1629
1682
 
1630
1683
  parseOr() {
1684
+ let left = this.parseXor();
1685
+ for (;;) {
1686
+ const isOr = this.isKeyword('OR') || (this.peek().type === 'op' && this.peek().value === '||');
1687
+ if (!isOr) break;
1688
+ this.next();
1689
+ const right = this.parseXor();
1690
+ left = { type: 'or', left, right };
1691
+ }
1692
+ return left;
1693
+ }
1694
+
1695
+ // 逻辑异或 XOR(MySQL 优先级:OR < XOR < AND)
1696
+ parseXor() {
1631
1697
  let left = this.parseAnd();
1632
- while (this.isKeyword('OR')) {
1698
+ while (this.isWord('XOR')) {
1633
1699
  this.next();
1634
1700
  const right = this.parseAnd();
1635
- left = { type: 'or', left, right };
1701
+ left = { type: 'xor', left, right };
1636
1702
  }
1637
1703
  return left;
1638
1704
  }
1639
1705
 
1640
1706
  parseAnd() {
1641
1707
  let left = this.parseNot();
1642
- while (this.isKeyword('AND')) {
1708
+ for (;;) {
1709
+ const isAnd = this.isKeyword('AND') || (this.peek().type === 'op' && this.peek().value === '&&');
1710
+ if (!isAnd) break;
1643
1711
  this.next();
1644
1712
  const right = this.parseNot();
1645
1713
  left = { type: 'and', left, right };
@@ -1981,15 +2049,58 @@ class Parser {
1981
2049
  }
1982
2050
 
1983
2051
  parseTerm() {
1984
- let node = this.parseOperand();
2052
+ let node = this.parsePostfix(this.parseOperand());
1985
2053
  for (;;) {
1986
2054
  const t = this.peek();
1987
2055
  if (t.type === 'op' && (t.value === '*' || t.value === '/' || t.value === '%')) {
1988
2056
  this.next();
1989
- const right = this.parseOperand();
2057
+ const right = this.parsePostfix(this.parseOperand());
1990
2058
  node = { type: 'arith', op: t.value, left: node, right };
1991
2059
  continue;
1992
2060
  }
2061
+ // MySQL 整数除法:7 DIV 2 → 3
2062
+ if (this.isWord('DIV')) {
2063
+ this.next();
2064
+ const right = this.parsePostfix(this.parseOperand());
2065
+ node = { type: 'arith', op: 'DIV', left: node, right };
2066
+ continue;
2067
+ }
2068
+ break;
2069
+ }
2070
+ return node;
2071
+ }
2072
+
2073
+ /**
2074
+ * 后缀运算符(绑定最紧):PG 风格 `::` 类型转换、`->` / `->>` JSON 取值。
2075
+ * 例:'5'::INT、meta->>'name'、meta->'addr'->>'city'。
2076
+ */
2077
+ parsePostfix(node) {
2078
+ for (;;) {
2079
+ const t = this.peek();
2080
+ if (t.type === 'op' && t.value === '::') {
2081
+ this.next();
2082
+ const typeTok = this.next();
2083
+ let dataType = String(typeTok.value).toUpperCase();
2084
+ if (this.peek().type === 'op' && this.peek().value === '(') {
2085
+ this.next();
2086
+ let depth = 1, spec = '';
2087
+ while (depth > 0 && this.peek().type !== 'eof') {
2088
+ const tk = this.next();
2089
+ if (tk.type === 'op' && tk.value === '(') depth++;
2090
+ else if (tk.type === 'op' && tk.value === ')') { depth--; if (depth === 0) break; }
2091
+ spec += tk.value;
2092
+ }
2093
+ dataType += '(' + spec + ')';
2094
+ }
2095
+ node = { type: 'cast', expr: node, dataType };
2096
+ continue;
2097
+ }
2098
+ if (t.type === 'op' && (t.value === '->' || t.value === '->>')) {
2099
+ this.next();
2100
+ const key = this.parseValue();
2101
+ node = { type: 'jsonAccess', operand: node, key, asText: t.value === '->>' };
2102
+ continue;
2103
+ }
1993
2104
  break;
1994
2105
  }
1995
2106
  return node;
@@ -2085,7 +2196,10 @@ class Parser {
2085
2196
  const isILike = !(t2.type === 'keyword' && t2.value === 'LIKE');
2086
2197
  this.next();
2087
2198
  const pattern = this.parseValue();
2088
- return { type: 'like', operand: left, pattern, not, ci: isILike };
2199
+ // 可选 ESCAPE 子句:LIKE '...' ESCAPE '!'
2200
+ let escape = null;
2201
+ if (this.isWord('ESCAPE')) { this.next(); escape = this.parseValue(); }
2202
+ return { type: 'like', operand: left, pattern, not, ci: isILike, escape };
2089
2203
  }
2090
2204
 
2091
2205
  // MySQL 中 RLIKE 是 REGEXP 的同义词
@@ -2095,7 +2209,7 @@ class Parser {
2095
2209
  return { type: 'regexp', operand: left, pattern, not };
2096
2210
  }
2097
2211
 
2098
- if (t2.type === 'op' && ['=', '!=', '<>', '<', '<=', '>', '>='].includes(t2.value)) {
2212
+ if (t2.type === 'op' && ['=', '!=', '<>', '<', '<=', '>', '>=', '<=>'].includes(t2.value)) {
2099
2213
  this.next();
2100
2214
  // = ANY (SELECT ...) / > ALL (SELECT ...)
2101
2215
  const anyAll = this.isWord('ANY') || this.isWord('ALL') || this.isWord('SOME');
@@ -2209,6 +2323,8 @@ function resolveOperand(operand, row, ctx) {
2209
2323
  case '*': return l * r;
2210
2324
  case '/': return r === 0 ? null : l / r;
2211
2325
  case '%': return r === 0 ? null : l % r;
2326
+ // MySQL 整数除法 DIV(结果向零取整)
2327
+ case 'DIV': return r === 0 ? null : Math.trunc(Number(l) / Number(r));
2212
2328
  // 位运算:先按整数归一,与 MySQL 的整数位运算语义一致
2213
2329
  case '&': return (Number(l) | 0) & (Number(r) | 0);
2214
2330
  case '|': return (Number(l) | 0) | (Number(r) | 0);
@@ -2229,6 +2345,7 @@ function resolveOperand(operand, row, ctx) {
2229
2345
  case 'compare':
2230
2346
  case 'and':
2231
2347
  case 'or':
2348
+ case 'xor':
2232
2349
  case 'not':
2233
2350
  case 'in':
2234
2351
  case 'isNull':
@@ -2253,6 +2370,30 @@ function resolveOperand(operand, row, ctx) {
2253
2370
  const v = resolveOperand(operand.expr, row, ctx);
2254
2371
  return applyCast(v, operand.dataType);
2255
2372
  }
2373
+ case 'jsonAccess': {
2374
+ let base = resolveOperand(operand.operand, row, ctx);
2375
+ if (base === null || base === undefined) return null;
2376
+ // JSON 列可能以字符串形式存储,先解析
2377
+ if (typeof base === 'string') {
2378
+ try { base = JSON.parse(base); } catch (e) { return null; }
2379
+ }
2380
+ const key = operand.key;
2381
+ let v;
2382
+ if (Array.isArray(base)) {
2383
+ const idx = Number(key);
2384
+ v = Number.isInteger(idx) ? (idx < 0 ? base[base.length + idx] : base[idx]) : undefined;
2385
+ } else if (base && typeof base === 'object') {
2386
+ v = base[key];
2387
+ } else {
2388
+ return null;
2389
+ }
2390
+ if (v === undefined) return null;
2391
+ if (operand.asText) {
2392
+ if (v === null) return null;
2393
+ return typeof v === 'object' ? JSON.stringify(v) : String(v);
2394
+ }
2395
+ return v;
2396
+ }
2256
2397
  case 'quantified': {
2257
2398
  // = ANY / > ALL (SELECT ...) —— 需先由执行层物化成 operand._list
2258
2399
  const v = resolveOperand(operand.operand, row, ctx);
@@ -2471,13 +2612,25 @@ function seedRand(seed) {
2471
2612
  }
2472
2613
 
2473
2614
  // ci 默认 true:保持 LIKE 既有的大小写不敏感行为不变;ILIKE 同为不敏感。
2474
- function likeMatch(value, pattern, ci = true) {
2615
+ // escape:可选转义符(ESCAPE 子句),默认反斜杠;转义符后跟 % _ 或转义符本身按字面量匹配。
2616
+ function likeMatch(value, pattern, ci = true, escape = '\\') {
2475
2617
  if (typeof value !== 'string') return false;
2476
- const regex = pattern
2477
- .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
2478
- .replace(/%/g, '.*')
2479
- .replace(/_/g, '.');
2480
- return new RegExp('^' + regex + '$', ci ? 'i' : '').test(value);
2618
+ const p = String(pattern);
2619
+ const e = (escape === null || escape === undefined || escape === '') ? '\\' : String(escape);
2620
+ const reEsc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2621
+ let out = '';
2622
+ for (let i = 0; i < p.length; i++) {
2623
+ const ch = p[i];
2624
+ if (ch === e && i + 1 < p.length && (p[i + 1] === '%' || p[i + 1] === '_' || p[i + 1] === e)) {
2625
+ out += reEsc(p[i + 1]);
2626
+ i++;
2627
+ continue;
2628
+ }
2629
+ if (ch === '%') out += '.*';
2630
+ else if (ch === '_') out += '.';
2631
+ else out += reEsc(ch);
2632
+ }
2633
+ return new RegExp('^' + out + '$', ci ? 'i' : '').test(value);
2481
2634
  }
2482
2635
 
2483
2636
  function extractEqualPushdown(expr, schema) {
@@ -2529,10 +2682,18 @@ function evaluateExpr(expr, row, ctx) {
2529
2682
  switch (expr.type) {
2530
2683
  case 'and': return evaluateExpr(expr.left, row, ctx) && evaluateExpr(expr.right, row, ctx);
2531
2684
  case 'or': return evaluateExpr(expr.left, row, ctx) || evaluateExpr(expr.right, row, ctx);
2685
+ case 'xor': return Boolean(evaluateExpr(expr.left, row, ctx)) !== Boolean(evaluateExpr(expr.right, row, ctx));
2532
2686
  case 'not': return !evaluateExpr(expr.expr, row, ctx);
2533
2687
  case 'compare': {
2534
2688
  const l = resolveOperand(expr.left, row, ctx);
2535
2689
  const r = resolveOperand(expr.right, row, ctx);
2690
+ // MySQL NULL 安全相等:NULL <=> NULL 为真;NULL <=> 值 为假
2691
+ if (expr.op === '<=>') {
2692
+ const ln = l === null || l === undefined;
2693
+ const rn = r === null || r === undefined;
2694
+ if (ln || rn) return ln && rn;
2695
+ return l === r || String(l) === String(r);
2696
+ }
2536
2697
  // SQL 标准:任何与 NULL 的比较结果为 UNKNOWN(在 WHERE/ON/HAVING 中视为 false)
2537
2698
  if (l === null || r === null) return false;
2538
2699
  // 布尔与数值互通(MySQL: 1 = TRUE、0 = FALSE)
@@ -2555,7 +2716,7 @@ function evaluateExpr(expr, row, ctx) {
2555
2716
  }
2556
2717
  case 'like': {
2557
2718
  const v = resolveOperand(expr.operand, row, ctx);
2558
- const r = likeMatch(v, expr.pattern, !!expr.ci);
2719
+ const r = likeMatch(v, expr.pattern, !!expr.ci, expr.escape);
2559
2720
  return expr.not ? !r : r;
2560
2721
  }
2561
2722
  case 'quantified':
@@ -2858,7 +3019,23 @@ class SQLExecutor {
2858
3019
  if (pkCols.length > 0) {
2859
3020
  const keyOf = (row) => pkCols.map(c => (row[c] !== undefined && row[c] !== null ? String(row[c]) : '')).join('|');
2860
3021
  const hasExplicitPk = (row) => pkCols.some(c => row[c] !== undefined && row[c] !== null);
2861
- const explicit = dataRows.filter(hasExplicitPk);
3022
+ // 冲突判定的额外列组:
3023
+ // ON CONFLICT (col) DO UPDATE → 指定列(PG)
3024
+ // ON DUPLICATE KEY UPDATE → 各唯一列(MySQL:任一唯一键冲突都触发)
3025
+ // 此前只认主键,导致这两者在唯一列冲突时会插入重复行而不是更新。
3026
+ const ocTarget = (statement.onConflict && Array.isArray(statement.onConflict.target))
3027
+ ? statement.onConflict.target.map(c => (c && c.name !== undefined ? c.name : c)).filter(Boolean)
3028
+ : null;
3029
+ const extraIdx = [];
3030
+ if (ocTarget && ocTarget.length) extraIdx.push(ocTarget);
3031
+ else if (statement.onDuplicate && schema) {
3032
+ for (const [c, def] of Object.entries(schema)) {
3033
+ if (def && def.unique && !def.primaryKey) extraIdx.push([c]);
3034
+ }
3035
+ }
3036
+ const hasAllOf = (cols) => (row) => cols.every(c => row[c] !== undefined && row[c] !== null);
3037
+
3038
+ const explicit = dataRows.filter((row) => hasExplicitPk(row) || extraIdx.some((cols) => hasAllOf(cols)(row)));
2862
3039
  if (explicit.length > 0) {
2863
3040
  const all = (await this.engine.find(statement.name, {}, { limit: 1e9, offset: 0 })).map(r => normalizeRow(r, schema));
2864
3041
  const pkMap = new Map();
@@ -2866,15 +3043,31 @@ class SQLExecutor {
2866
3043
  const keys = pkCols.map(c => row[c]).filter(v => v !== undefined && v !== null);
2867
3044
  if (keys.length === pkCols.length) pkMap.set(keyOf(row), keys);
2868
3045
  }
3046
+ const pkValsOf = (row) => pkCols.map(c => row[c]).filter(v => v !== undefined && v !== null);
3047
+ const extraMaps = extraIdx.map((cols) => {
3048
+ const m = new Map();
3049
+ const pick = hasAllOf(cols);
3050
+ const key = (row) => cols.map(c => String(row[c])).join('|');
3051
+ for (const row of all) {
3052
+ if (!pick(row)) continue;
3053
+ m.set(key(row), pkValsOf(row));
3054
+ }
3055
+ return { pick, key, m };
3056
+ });
2869
3057
  const conflicts = [];
2870
3058
  const fresh = [];
2871
3059
  for (const d of dataRows) {
2872
- const key = keyOf(d);
2873
- if (hasExplicitPk(d) && pkMap.has(key)) {
2874
- conflicts.push({ d, existingId: pkMap.get(key) });
2875
- } else {
2876
- fresh.push(d);
3060
+ let existingId = null;
3061
+ if (hasExplicitPk(d) && pkMap.has(keyOf(d))) existingId = pkMap.get(keyOf(d));
3062
+ if (!existingId) {
3063
+ for (const { pick, key, m } of extraMaps) {
3064
+ if (!pick(d)) continue;
3065
+ const k = key(d);
3066
+ if (m.has(k)) { existingId = m.get(k); break; }
3067
+ }
2877
3068
  }
3069
+ if (existingId) conflicts.push({ d, existingId });
3070
+ else fresh.push(d);
2878
3071
  }
2879
3072
  const conflictAction = this._conflictAction(statement);
2880
3073
  const conflictSets = this._conflictSets(statement);
@@ -2902,10 +3095,14 @@ class SQLExecutor {
2902
3095
  const data = {};
2903
3096
  if (conflictSets) {
2904
3097
  for (const [col, val] of conflictSets) {
2905
- // VALUES(col) 引用本次本应插入的值
2906
- data[col] = (val && typeof val === 'object' && val._valuesOf !== undefined)
2907
- ? (d[val._valuesOf] !== undefined ? d[val._valuesOf] : null)
2908
- : val;
3098
+ // VALUES(col) 引用本次本应插入的值;EXCLUDED.col 为 PG 同义写法
3099
+ if (val && typeof val === 'object' && val._valuesOf !== undefined) {
3100
+ data[col] = d[val._valuesOf] !== undefined ? d[val._valuesOf] : null;
3101
+ } else if (val && typeof val === 'object' && val._excludedOf !== undefined) {
3102
+ data[col] = d[val._excludedOf] !== undefined ? d[val._excludedOf] : null;
3103
+ } else {
3104
+ data[col] = val;
3105
+ }
2909
3106
  }
2910
3107
  } else {
2911
3108
  for (const k of Object.keys(d)) if (!pkCols.includes(k)) data[k] = d[k];
@@ -4678,6 +4875,44 @@ function applyParams(sql, values) {
4678
4875
  return out;
4679
4876
  }
4680
4877
 
4878
+ // 把执行器结果补齐为 README 承诺的结果信封
4879
+ // (columnTypes / rowCount / message / command / durationMs / warnings)
4880
+ function buildResultEnvelope(stmt, r, durationMs) {
4881
+ if (r === null || typeof r !== 'object' || Array.isArray(r)) return r;
4882
+ const out = { ...r };
4883
+ const t = stmt && stmt.type;
4884
+ if (out.command === undefined) out.command = String(t || '').toUpperCase();
4885
+ if (out.affectedRows === undefined) out.affectedRows = 0;
4886
+ if (out.rowCount === undefined) {
4887
+ out.rowCount = Array.isArray(out.rows) ? out.rows.length
4888
+ : (typeof out.affectedRows === 'number' ? out.affectedRows : 0);
4889
+ }
4890
+ if (out.warnings === undefined) out.warnings = [];
4891
+ if (out.durationMs === undefined) out.durationMs = durationMs;
4892
+ if (out.message === undefined) {
4893
+ out.message = t === 'select'
4894
+ ? `${out.rowCount} row${out.rowCount === 1 ? '' : 's'} selected`
4895
+ : `${out.affectedRows} row${out.affectedRows === 1 ? '' : 's'} affected`;
4896
+ }
4897
+ return out;
4898
+ }
4899
+
4900
+ // SELECT 结果的列类型:单表查询按 schema 推断;表达式/多表查询该列为 null
4901
+ async function inferColumnTypes(engine, stmt, r) {
4902
+ if (!r || !Array.isArray(r.columns)) return undefined;
4903
+ let schema = null;
4904
+ try {
4905
+ const t = stmt && stmt.type === 'select' && stmt.from &&
4906
+ Array.isArray(stmt.from.tables) && stmt.from.tables.length === 1
4907
+ ? stmt.from.tables[0].table : null;
4908
+ if (t && engine && typeof engine.getTableSchema === 'function') schema = await engine.getTableSchema(t);
4909
+ } catch (e) { schema = null; }
4910
+ return r.columns.map((name) => {
4911
+ const def = schema && schema[name];
4912
+ return def && def.type ? sqlTypeName(def.type).toUpperCase() : null;
4913
+ });
4914
+ }
4915
+
4681
4916
  async function executeSQL(engine, sql, paramsOrOpts, opts = {}) {
4682
4917
  // 参数守卫:engine 必须是引擎对象,sql 必须是字符串。误用时报清晰错误,
4683
4918
  // 而不是在后续像 `reading 'length'` 那样抛出令人费解的 TypeError。
@@ -4727,7 +4962,14 @@ async function executeSQL(engine, sql, paramsOrOpts, opts = {}) {
4727
4962
  const results = [];
4728
4963
  for (const stmtSql of statements) {
4729
4964
  const stmt = parseSQL(stmtSql);
4730
- results.push(await executor.execute(stmt));
4965
+ const t0 = process.hrtime.bigint();
4966
+ let r = await executor.execute(stmt);
4967
+ const durationMs = Number(process.hrtime.bigint() - t0) / 1e6;
4968
+ r = buildResultEnvelope(stmt, r, Math.round(durationMs * 1000) / 1000);
4969
+ if (r && r.columns && r.columnTypes === undefined) {
4970
+ r.columnTypes = await inferColumnTypes(engine, stmt, r);
4971
+ }
4972
+ results.push(r);
4731
4973
  }
4732
4974
  return results.length === 1 ? results[0] : results;
4733
4975
  }
Binary file
@@ -205,7 +205,7 @@ dependencies = [
205
205
 
206
206
  [[package]]
207
207
  name = "jsql-neo-core"
208
- version = "5.6.0"
208
+ version = "6.0.0"
209
209
  dependencies = [
210
210
  "bincode",
211
211
  "chrono",
@@ -220,7 +220,7 @@ dependencies = [
220
220
 
221
221
  [[package]]
222
222
  name = "jsql-neo-native"
223
- version = "5.6.0"
223
+ version = "6.0.0"
224
224
  dependencies = [
225
225
  "jsql-neo-core",
226
226
  "napi",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsql-neo",
3
- "version": "6.0.2",
3
+ "version": "6.1.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",
@@ -139,6 +139,51 @@ async function main() {
139
139
  JSON.stringify(await rowsOf('SELECT name FROM (SELECT name, sal FROM emp WHERE sal > 150)')) === '[["b"],["c"],["d"]]');
140
140
  }
141
141
 
142
+ console.log('\n--- 方言特性:DIV / XOR / && / || / <=> / :: / -> ->> / LIKE ESCAPE ---');
143
+ {
144
+ const data = [
145
+ { id: 1, n: 10, flag: 1, s: '100%', meta: '{"name":"Alice","addr":{"city":"SH"},"tags":["x","y"]}' },
146
+ { id: 2, n: 20, flag: 0, s: '1000', meta: '{"name":"Bob","addr":{"city":"BJ"},"tags":[]}' },
147
+ { id: 3, n: 30, flag: 1, s: '100', meta: null },
148
+ ];
149
+ const engine = {
150
+ hasTable: () => true, truncate: async () => {}, flush: async () => {},
151
+ find: async () => data.map(r => ({ ...r })),
152
+ getTableSchema: async () => ({
153
+ id: { type: 'integer', primaryKey: true }, n: { type: 'integer' }, flag: { type: 'integer' },
154
+ s: { type: 'string' }, meta: { type: 'json' },
155
+ }),
156
+ };
157
+ const rowsOf = async (sql) => {
158
+ const r = await executeSQL(engine, sql, OPTS);
159
+ return (Array.isArray(r) ? r[0] : r).rows;
160
+ };
161
+
162
+ ok('DIV 整数除法', JSON.stringify(await rowsOf('SELECT 7 DIV 2 AS r')) === '[[3]]');
163
+ ok('DIV 负数向零取整', JSON.stringify(await rowsOf('SELECT -7 DIV 2 AS r')) === '[[-3]]');
164
+ ok("'5'::INT 类型转换", JSON.stringify(await rowsOf("SELECT '5'::INT AS r")) === '[[5]]');
165
+ ok('XOR 逻辑异或(WHERE)',
166
+ JSON.stringify(await rowsOf('SELECT id FROM tbl WHERE flag = 1 XOR n = 20')) === '[[1],[2],[3]]');
167
+ ok('&& 是 AND 别名',
168
+ JSON.stringify(await rowsOf('SELECT id FROM tbl WHERE flag = 1 && n > 10')) === '[[3]]');
169
+ ok('|| 是 OR 别名',
170
+ JSON.stringify(await rowsOf('SELECT id FROM tbl WHERE flag = 0 || n > 20')) === '[[2],[3]]');
171
+ ok('<=> 非空相等', JSON.stringify(await rowsOf('SELECT id FROM tbl WHERE n <=> 20')) === '[[2]]');
172
+ ok('<=> NULL 安全(NULL<=>NULL 为真)',
173
+ JSON.stringify(await rowsOf('SELECT id FROM tbl WHERE meta <=> NULL')) === '[[3]]');
174
+ ok("->>'name'", JSON.stringify(await rowsOf("SELECT meta->>'name' AS x FROM tbl WHERE id = 1")) === '[["Alice"]]');
175
+ ok("->'addr'->>'city' 链式",
176
+ JSON.stringify(await rowsOf("SELECT meta->'addr'->>'city' AS x FROM tbl WHERE id = 1")) === '[["SH"]]');
177
+ ok("->'tags'->>0 数组下标",
178
+ JSON.stringify(await rowsOf("SELECT meta->'tags'->>0 AS x FROM tbl WHERE id = 1")) === '[["x"]]');
179
+ ok("LIKE ... ESCAPE '!' 匹配字面量 %",
180
+ JSON.stringify(await rowsOf("SELECT id FROM tbl WHERE s LIKE '100!%' ESCAPE '!'")) === '[[1]]');
181
+ ok('LIKE 反斜杠转义匹配字面量 %',
182
+ JSON.stringify(await rowsOf("SELECT id FROM tbl WHERE s LIKE '100\\%'")) === '[[1]]');
183
+ ok('LIKE 普通 % 仍为通配',
184
+ JSON.stringify(await rowsOf("SELECT id FROM tbl WHERE s LIKE '100%'")) === '[[1],[2],[3]]');
185
+ }
186
+
142
187
  console.log('\n--- DDL / 解析器节点覆盖 ---');
143
188
  {
144
189
  ok('CREATE INDEX 可解析', parseSQL('CREATE INDEX i ON t (a)').type === 'createIndex');
@@ -156,6 +201,30 @@ async function main() {
156
201
  const st = parseSQL('CREATE TABLE t (id INT PRIMARY KEY, c DATETIME DEFAULT CURRENT_TIMESTAMP)');
157
202
  return st.schema.c.default === 'CURRENT_TIMESTAMP';
158
203
  })());
204
+ ok('SERIAL 折叠为整型+自增', (() => {
205
+ const st = parseSQL('CREATE TABLE t (id SERIAL PRIMARY KEY, n TEXT)');
206
+ return st.schema.id.type === 'integer' && st.schema.id.autoIncrement === true;
207
+ })());
208
+ ok('BIGSERIAL 折叠为整型+自增', (() => {
209
+ const st = parseSQL('CREATE TABLE t (id BIGSERIAL PRIMARY KEY)');
210
+ return st.schema.id.type === 'integer' && st.schema.id.autoIncrement === true;
211
+ })());
212
+ }
213
+
214
+ console.log('\n--- executeSQL 结果信封(README 承诺的字段)---');
215
+ {
216
+ const engine = {
217
+ hasTable: () => true, truncate: async () => {}, flush: async () => {},
218
+ find: async () => [{ id: 1, name: 'a' }, { id: 2, name: 'b' }],
219
+ getTableSchema: async () => ({ id: { type: 'integer', primaryKey: true }, name: { type: 'string' } }),
220
+ };
221
+ const r = await executeSQL(engine, 'SELECT * FROM t', OPTS);
222
+ for (const f of ['columns', 'columnTypes', 'rows', 'rowCount', 'affectedRows', 'message', 'command', 'durationMs', 'warnings']) {
223
+ ok(`信封含 ${f}`, r[f] !== undefined, Object.keys(r));
224
+ }
225
+ ok('rowCount = 2', r.rowCount === 2);
226
+ ok('command = SELECT', r.command === 'SELECT');
227
+ ok('columnTypes 可推断(id → INT)', Array.isArray(r.columnTypes) && r.columnTypes[0] === 'INT');
159
228
  }
160
229
 
161
230
  console.log('\n--- 顶层工厂导出不得为 undefined ---');
@@ -214,29 +283,32 @@ async function main() {
214
283
 
215
284
  console.log('\n--- 并发 insert 不得重复写(曾行数暴涨 / 丢失)---');
216
285
  {
217
- const { JSQL } = require('../lib/wasm_client.js');
218
- let db;
219
- try {
220
- db = new JSQL();
221
- await db.start();
222
- } catch (e) {
223
- console.log('[SKIP] WASM 客户端不可用:' + e.message.slice(0, 60));
224
- }
225
- if (db) {
286
+ // 两个引擎都要验收:曾只测 wasm,导致 native(默认引擎)的同款竞态长期漏网。
287
+ for (const engineName of ['wasm_client', 'native_client']) {
288
+ let JSQL, db;
289
+ try {
290
+ ({ JSQL } = require('../lib/' + engineName + '.js'));
291
+ db = new JSQL();
292
+ await db.start();
293
+ } catch (e) {
294
+ console.log(`[SKIP] ${engineName} 不可用:` + e.message.slice(0, 60));
295
+ continue;
296
+ }
297
+ const tag = `[${engineName}]`;
226
298
  let id = 0;
227
299
  await db.createTable('conc', { id: { type: 'INT', primaryKey: true }, b: { type: 'INT' } });
228
300
  await Promise.all(Array.from({ length: 200 }, () => db.insert('conc', { id: ++id, b: 5 })));
229
301
  const cnt = await db.count('conc');
230
- ok(`并发 insert 200 行 → count 恰为 200(实际 ${cnt})`, cnt === 200);
302
+ ok(`${tag} 并发 insert 200 行 → count 恰为 200(实际 ${cnt})`, cnt === 200);
231
303
 
232
304
  // flush 失败不得把坏行留在 buffer 里二次爆炸
233
- ok('insert(null) 抛 TypeError', await (async () => {
305
+ ok(`${tag} insert(null) 抛 TypeError`, await (async () => {
234
306
  try { await db.insert('conc', null); return false; } catch (e) { return e instanceof TypeError; }
235
307
  })());
236
- ok('insert(undefined) 抛 TypeError', await (async () => {
308
+ ok(`${tag} insert(undefined) 抛 TypeError`, await (async () => {
237
309
  try { await db.insert('conc', undefined); return false; } catch (e) { return e instanceof TypeError; }
238
310
  })());
239
- ok('insert(非对象) 抛 TypeError', await (async () => {
311
+ ok(`${tag} insert(非对象) 抛 TypeError`, await (async () => {
240
312
  try { await db.insert('conc', 42); return false; } catch (e) { return e instanceof TypeError; }
241
313
  })());
242
314
 
@@ -246,7 +318,7 @@ async function main() {
246
318
  cyc.self = cyc;
247
319
  let cycErr = null;
248
320
  try { await db.insert('cyc', { id: 1, meta: cyc }); } catch (e) { cycErr = e; }
249
- ok('环形对象给出带表/列名的错误',
321
+ ok(`${tag} 环形对象给出带表/列名的错误`,
250
322
  !!cycErr && /Cannot serialize column 'meta'/.test(cycErr.message),
251
323
  cycErr && cycErr.message);
252
324
  await db.stop();