jsql-neo 6.1.0 → 6.3.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/lib/sql.js CHANGED
@@ -11,6 +11,10 @@ const DANGEROUS_SQL = [
11
11
  { re: /^SYSEXEC$|^SYS_EXEC$/, name: 'sys_exec' },
12
12
  ];
13
13
 
14
+ // SQL 文本体积上限(默认 64 MiB):防止超大、无分号的输入造成无界内存消耗
15
+ // (CWE-770)。可在 executeSQL 通过 opts.maxSqlLength 放宽/关闭(传 Infinity)。
16
+ const DEFAULT_MAX_SQL_LENGTH = 64 * 1024 * 1024;
17
+
14
18
  function findDangerousSQL(tokens) {
15
19
  for (let i = 0; i < tokens.length; i++) {
16
20
  const t = tokens[i];
@@ -79,10 +83,12 @@ function tokenize(sql) {
79
83
  while (i < n && sql[i] !== '\n') i++;
80
84
  continue;
81
85
  }
82
- // PG 的 JSON 路径操作符 #> / #>> 尚未实现。
83
- // 若不在这里拦截,'#' 会被当成行注释吃掉,导致 SQL 后半段被静默丢弃。
86
+ // PG 的 JSON 路径操作符 #> / #>>(必须在 '#' 行注释之前识别)
84
87
  if (c === '#' && sql[i + 1] === '>') {
85
- throw new Error("JSON 路径操作符 '#>' / '#>>' 暂不支持,请改用 '->' / '->>'");
88
+ const op = sql[i + 2] === '>' ? '#>>' : '#>';
89
+ tokens.push(new SQLToken('op', op, i));
90
+ i += op.length;
91
+ continue;
86
92
  }
87
93
  if (c === '#' || (c === '/' && sql[i + 1] === '*')) {
88
94
  if (c === '#') { while (i < n && sql[i] !== '\n') i++; continue; }
@@ -106,12 +112,15 @@ function tokenize(sql) {
106
112
  else str += esc;
107
113
  j += 2;
108
114
  } else if (sql[j] === quote) {
115
+ // SQL 标准转义:字符串内两个连续引号表示一个字面量引号('O''Brien' → O'Brien)
116
+ if (sql[j + 1] === quote) { str += quote; j += 2; continue; }
109
117
  break;
110
118
  } else {
111
119
  str += sql[j];
112
120
  j++;
113
121
  }
114
122
  }
123
+ if (j >= n) throw new Error(`Unterminated ${quote === '"' ? 'double' : 'single'}-quoted string at position ${i}`);
115
124
  tokens.push(new SQLToken('string', str, i));
116
125
  i = j + 1;
117
126
  continue;
@@ -153,15 +162,17 @@ function tokenize(sql) {
153
162
  if (c === '`') {
154
163
  let j = i + 1;
155
164
  while (j < n && sql[j] !== '`') j++;
165
+ if (j >= n) throw new Error(`Unterminated backtick-quoted identifier at position ${i}`);
156
166
  tokens.push(new SQLToken('ident', sql.slice(i + 1, j), i));
157
167
  i = j + 1;
158
168
  continue;
159
169
  }
160
170
 
161
- // PG 的 JSON 包含操作符 @> 尚未实现。
162
- // 若不拦截,'@' 会被 sysvar 分支静默吞掉、'>' 退化成普通大于号,产生错误结果。
171
+ // PG 的 JSON 包含操作符 @>
163
172
  if (c === '@' && sql[i + 1] === '>') {
164
- throw new Error("JSON 包含操作符 '@>' 暂不支持");
173
+ tokens.push(new SQLToken('op', '@>', i));
174
+ i += 2;
175
+ continue;
165
176
  }
166
177
 
167
178
  if (c === '@') {
@@ -181,6 +192,12 @@ function tokenize(sql) {
181
192
  }
182
193
 
183
194
  // 参数占位符:? (顺序)、?N (编号)、?? (标识符占位)
195
+ // 注意:?| / ?& 是 PG 的 JSON 键存在操作符,必须优先识别
196
+ if (c === '?' && (sql[i + 1] === '|' || sql[i + 1] === '&')) {
197
+ tokens.push(new SQLToken('op', '?' + sql[i + 1], i));
198
+ i += 2;
199
+ continue;
200
+ }
184
201
  if (c === '?') {
185
202
  if (sql[i + 1] === '?') { tokens.push(new SQLToken('param', '??', i)); i += 2; continue; }
186
203
  let j = i + 1;
@@ -193,9 +210,11 @@ function tokenize(sql) {
193
210
 
194
211
  const three = sql.slice(i, i + 3);
195
212
  if (sql.slice(i, i + 2) === '<@') {
196
- throw new Error("JSON 包含操作符 '<@' 暂不支持");
213
+ tokens.push(new SQLToken('op', '<@', i));
214
+ i += 2;
215
+ continue;
197
216
  }
198
- if (three === '->>' || three === '<=>') {
217
+ if (three === '->>' || three === '<=>' || three === '!~*') {
199
218
  tokens.push(new SQLToken('op', three, i));
200
219
  i += 3;
201
220
  continue;
@@ -204,7 +223,7 @@ function tokenize(sql) {
204
223
  const two = sql.slice(i, i + 2);
205
224
  if (two === '<=' || two === '>=' || two === '!=' || two === '<>' || two === '==' ||
206
225
  two === '<<' || two === '>>' || two === '->' || two === '::' ||
207
- two === '&&' || two === '||') {
226
+ two === '&&' || two === '||' || two === '~*' || two === '!~') {
208
227
  tokens.push(new SQLToken('op', two, i));
209
228
  i += 2;
210
229
  continue;
@@ -1838,6 +1857,13 @@ class Parser {
1838
1857
  this.expect('op', ')');
1839
1858
  return { type: 'cast', expr, dataType };
1840
1859
  }
1860
+ // 零参时间函数:CURRENT_DATE / CURRENT_TIME / CURRENT_TIMESTAMP(可省略括号)。
1861
+ // 此前不在 KEYWORDS 里,被当成列名解析 → "no such column: CURRENT_DATE"
1862
+ if (['CURRENT_DATE', 'CURRENT_TIME', 'CURRENT_TIMESTAMP'].includes(String(t.value).toUpperCase())) {
1863
+ const name = String(t.value).toUpperCase();
1864
+ if (this.peek().type === 'op' && this.peek().value === '(') { this.next(); this.expect('op', ')'); }
1865
+ return { type: 'func', name, args: [] };
1866
+ }
1841
1867
  // EXTRACT(YEAR FROM expr) —— 标准 SQL 的 FROM 形式
1842
1868
  if (String(t.value).toUpperCase() === 'EXTRACT' && this.peek().type === 'op' && this.peek().value === '(') {
1843
1869
  this.next();
@@ -1943,6 +1969,13 @@ class Parser {
1943
1969
  this.expect('op', ')');
1944
1970
  return this.parseOptionalOver({ type: 'func', name, args });
1945
1971
  }
1972
+ // 时间单位裸关键字(TIMESTAMPDIFF(MONTH, a, b) 里的 MONTH):
1973
+ // 解析成 unit 节点,取值时优先当列、取不到才当单位名,
1974
+ // 这样既支持裸写法,也不影响真正名为 month/year 的列。
1975
+ if (TIME_UNITS.has(String(t.value).toUpperCase()) &&
1976
+ !(this.peek().type === 'op' && (this.peek().value === '(' || this.peek().value === '.'))) {
1977
+ return { type: 'unit', name: String(t.value).toUpperCase() };
1978
+ }
1946
1979
  return { type: 'column', name: t.value };
1947
1980
  }
1948
1981
  if (t.type === 'number' || t.type === 'string') return { type: 'value', value: t.value };
@@ -2101,6 +2134,13 @@ class Parser {
2101
2134
  node = { type: 'jsonAccess', operand: node, key, asText: t.value === '->>' };
2102
2135
  continue;
2103
2136
  }
2137
+ // PG 路径取值:meta#>'{a,b}' / meta#>>'{a,b}'
2138
+ if (t.type === 'op' && (t.value === '#>' || t.value === '#>>')) {
2139
+ this.next();
2140
+ const key = this.parseValue();
2141
+ node = { type: 'jsonAccess', operand: node, key, asText: t.value === '#>>', isPath: true };
2142
+ continue;
2143
+ }
2104
2144
  break;
2105
2145
  }
2106
2146
  return node;
@@ -2193,13 +2233,38 @@ class Parser {
2193
2233
 
2194
2234
  // ILIKE 刻意不加进 KEYWORDS(避免影响同名标识符),用 isWord 按上下文匹配
2195
2235
  if ((t2.type === 'keyword' && t2.value === 'LIKE') || this.isWord('ILIKE')) {
2196
- const isILike = !(t2.type === 'keyword' && t2.value === 'LIKE');
2197
2236
  this.next();
2237
+ // 可选 BINARY 关键字:LIKE BINARY '...' 才区分大小写
2238
+ let binary = false;
2239
+ if (this.isWord('BINARY')) { this.next(); binary = true; }
2198
2240
  const pattern = this.parseValue();
2199
2241
  // 可选 ESCAPE 子句:LIKE '...' ESCAPE '!'
2200
2242
  let escape = null;
2201
2243
  if (this.isWord('ESCAPE')) { this.next(); escape = this.parseValue(); }
2202
- return { type: 'like', operand: left, pattern, not, ci: isILike, escape };
2244
+ // MySQL 的 LIKE 默认不区分大小写(默认 collation 为 CI),
2245
+ // 此前写成 ci: isILike,导致 LIKE 恒为大小写敏感。
2246
+ return { type: 'like', operand: left, pattern, not, ci: !binary, escape };
2247
+ }
2248
+
2249
+ // PG 正则操作符(双目位置):~(大小写敏感)/ ~*(不敏感)/ !~ / !~*
2250
+ if (t2.type === 'op' && (t2.value === '~' || t2.value === '~*' || t2.value === '!~' || t2.value === '!~*')) {
2251
+ this.next();
2252
+ const pattern = this.parseValue();
2253
+ return {
2254
+ type: 'regexp', operand: left, pattern,
2255
+ not: t2.value.startsWith('!') || not,
2256
+ ci: t2.value.endsWith('*'),
2257
+ };
2258
+ }
2259
+
2260
+ // PG JSON 键存在:? / ?| / ?&
2261
+ // 裸 `?` 在此处处于"运算符位置",与值位置上的 `?` 参数占位符不冲突。
2262
+ if ((t2.type === 'op' && (t2.value === '?|' || t2.value === '?&')) ||
2263
+ (t2.type === 'param' && t2.value === null)) {
2264
+ const mode = t2.type === 'param' ? '?' : t2.value;
2265
+ this.next();
2266
+ const keys = this.parseValue();
2267
+ return { type: 'jsonKeyExists', operand: left, keys, mode, not };
2203
2268
  }
2204
2269
 
2205
2270
  // MySQL 中 RLIKE 是 REGEXP 的同义词
@@ -2209,6 +2274,13 @@ class Parser {
2209
2274
  return { type: 'regexp', operand: left, pattern, not };
2210
2275
  }
2211
2276
 
2277
+ // PG JSON 包含操作符
2278
+ if (t2.type === 'op' && (t2.value === '@>' || t2.value === '<@')) {
2279
+ this.next();
2280
+ const right = this.parseBitwise();
2281
+ return { type: 'jsonContains', op: t2.value, left, right, not };
2282
+ }
2283
+
2212
2284
  if (t2.type === 'op' && ['=', '!=', '<>', '<', '<=', '>', '>=', '<=>'].includes(t2.value)) {
2213
2285
  this.next();
2214
2286
  // = ANY (SELECT ...) / > ALL (SELECT ...)
@@ -2355,6 +2427,12 @@ function resolveOperand(operand, row, ctx) {
2355
2427
  case 'like':
2356
2428
  case 'between':
2357
2429
  return evaluateExpr(operand, row, ctx);
2430
+ case 'unit': {
2431
+ // 裸时间单位(MONTH / DAY …):优先当列取,取不到就返回单位名本身
2432
+ const lower = String(operand.name).toLowerCase();
2433
+ const v = row ? row[lower] : undefined;
2434
+ return v !== undefined ? v : String(operand.name).toUpperCase();
2435
+ }
2358
2436
  case 'param': {
2359
2437
  // 原生 ? 占位符。executeSQL 默认会先用 applyParams 内联替换,
2360
2438
  // 只有在显式传入 ctx.params 时才走这条路径。
@@ -2377,15 +2455,18 @@ function resolveOperand(operand, row, ctx) {
2377
2455
  if (typeof base === 'string') {
2378
2456
  try { base = JSON.parse(base); } catch (e) { return null; }
2379
2457
  }
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;
2458
+ const keys = operand.isPath ? parsePgPath(operand.key) : [operand.key];
2459
+ let v = base;
2460
+ for (const key of keys) {
2461
+ if (v === null || v === undefined) return null;
2462
+ if (Array.isArray(v)) {
2463
+ const idx = Number(key);
2464
+ v = Number.isInteger(idx) ? (idx < 0 ? v[v.length + idx] : v[idx]) : undefined;
2465
+ } else if (typeof v === 'object') {
2466
+ v = v[key];
2467
+ } else {
2468
+ return null;
2469
+ }
2389
2470
  }
2390
2471
  if (v === undefined) return null;
2391
2472
  if (operand.asText) {
@@ -2394,6 +2475,17 @@ function resolveOperand(operand, row, ctx) {
2394
2475
  }
2395
2476
  return v;
2396
2477
  }
2478
+ case 'jsonContains': {
2479
+ const l = resolveOperand(operand.left, row, ctx);
2480
+ const r = resolveOperand(operand.right, row, ctx);
2481
+ const res = jsonContains(l, r, operand.op === '<@');
2482
+ return operand.not ? !res : res;
2483
+ }
2484
+ case 'jsonKeyExists': {
2485
+ const base = resolveOperand(operand.operand, row, ctx);
2486
+ const res = jsonKeyExists(base, operand.keys, operand.mode);
2487
+ return operand.not ? !res : res;
2488
+ }
2397
2489
  case 'quantified': {
2398
2490
  // = ANY / > ALL (SELECT ...) —— 需先由执行层物化成 operand._list
2399
2491
  const v = resolveOperand(operand.operand, row, ctx);
@@ -2425,12 +2517,161 @@ function parseSqlDate(v) {
2425
2517
 
2426
2518
  function pad2(n) { return String(n).padStart(2, '0'); }
2427
2519
 
2520
+ /** 本地时区的 'YYYY-MM-DD HH:mm:ss'(MySQL 的 NOW() 语义;区别于 UTC_TIMESTAMP) */
2521
+ function sqlLocalDateTime(d = new Date()) {
2522
+ return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) + ' ' +
2523
+ pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
2524
+ }
2525
+
2428
2526
  /** 按输入形态回写日期:纯日期返回 YYYY-MM-DD,带时间返回 YYYY-MM-DD HH:MM:SS */
2429
2527
  function fmtSqlDate(dateOnly, d) {
2430
2528
  const ymd = d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
2431
2529
  return dateOnly ? ymd : ymd + ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
2432
2530
  }
2433
2531
 
2532
+ const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
2533
+ const MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June',
2534
+ 'July', 'August', 'September', 'October', 'November', 'December'];
2535
+
2536
+ /** 可作为裸关键字出现的时间单位(TIMESTAMPDIFF(MONTH, a, b) / DATE_ADD 的 INTERVAL n unit) */
2537
+ const TIME_UNITS = new Set([
2538
+ 'MICROSECOND', 'SECOND', 'MINUTE', 'HOUR', 'DAY', 'WEEK', 'MONTH', 'QUARTER', 'YEAR',
2539
+ 'SECONDS', 'MINUTES', 'HOURS', 'DAYS', 'WEEKS', 'MONTHS', 'YEARS',
2540
+ ]);
2541
+
2542
+ /** MySQL DATE_FORMAT 的 % 说明符 */
2543
+ function strftime(d, fmt) {
2544
+ const h12 = ((d.getHours() % 12) || 12);
2545
+ const map = {
2546
+ '%Y': String(d.getFullYear()), '%y': pad2(d.getFullYear() % 100),
2547
+ '%m': pad2(d.getMonth() + 1), '%c': String(d.getMonth() + 1),
2548
+ '%d': pad2(d.getDate()), '%e': String(d.getDate()),
2549
+ '%H': pad2(d.getHours()), '%k': String(d.getHours()),
2550
+ '%h': pad2(h12), '%I': pad2(h12), '%l': String(h12),
2551
+ '%i': pad2(d.getMinutes()), '%S': pad2(d.getSeconds()), '%s': pad2(d.getSeconds()),
2552
+ '%p': d.getHours() < 12 ? 'AM' : 'PM',
2553
+ '%M': MONTH_NAMES[d.getMonth()], '%b': MONTH_NAMES[d.getMonth()].slice(0, 3),
2554
+ '%W': DAY_NAMES[d.getDay()], '%a': DAY_NAMES[d.getDay()].slice(0, 3),
2555
+ '%w': String(d.getDay()),
2556
+ '%j': String(Math.round((d - new Date(d.getFullYear(), 0, 0)) / 86400000)),
2557
+ '%%': '%',
2558
+ };
2559
+ return String(fmt).replace(/%[A-Za-z%]/g, (m) => (m in map ? map[m] : m));
2560
+ }
2561
+
2562
+ /** PG 日期格式串(YYYY-MM-DD / HH24:MI:SS)→ MySQL 的 % 说明符 */
2563
+ function pgFmtToMysql(fmt) {
2564
+ const s = String(fmt);
2565
+ if (s.indexOf('%') !== -1) return s;
2566
+ // 长模式先替换成占位符,避免被短模式(MM 吃掉 MMMM)误匹配
2567
+ const rules = [
2568
+ [/YYYY/g, '\u0001'], [/YY/g, '\u0002'],
2569
+ [/MMMM/g, '\u0003'], [/MMM/g, '\u0004'], [/MM/g, '\u0005'],
2570
+ [/DDDD/g, '\u0006'], [/DDD/g, '\u0007'], [/DD/g, '\u0008'],
2571
+ [/HH24/g, '\u0009'], [/HH12/g, '\u0010'], [/HH/g, '\u0011'],
2572
+ [/MI/g, '\u0012'], [/SS/g, '\u0013'],
2573
+ [/AM/g, '\u0014'], [/PM/g, '\u0014'],
2574
+ ];
2575
+ let out = s;
2576
+ for (const [re, ch] of rules) out = out.replace(re, ch);
2577
+ const back = { '\u0001': '%Y', '\u0002': '%y', '\u0003': '%M', '\u0004': '%b', '\u0005': '%m',
2578
+ '\u0006': '%W', '\u0007': '%a', '\u0008': '%d', '\u0009': '%H', '\u0010': '%h', '\u0011': '%H',
2579
+ '\u0012': '%i', '\u0013': '%S', '\u0014': '%p' };
2580
+ return out.replace(/[\u0001-\u0014]/g, (c) => back[c] || c);
2581
+ }
2582
+
2583
+ /** STR_TO_DATE / TO_DATE:按格式串反解出 Date */
2584
+ function strToDate(s, fmt) {
2585
+ const spec = {
2586
+ Y: '(\\d{4})', y: '(\\d{2})', m: '(\\d{1,2})', c: '(\\d{1,2})',
2587
+ d: '(\\d{1,2})', e: '(\\d{1,2})',
2588
+ H: '(\\d{1,2})', k: '(\\d{1,2})', h: '(\\d{1,2})', I: '(\\d{1,2})', l: '(\\d{1,2})',
2589
+ i: '(\\d{1,2})', S: '(\\d{1,2})', s: '(\\d{1,2})',
2590
+ M: '([A-Za-z]+)', b: '([A-Za-z]+)', p: '(?:AM|PM|am|pm)?',
2591
+ };
2592
+ let pattern = '';
2593
+ const order = [];
2594
+ for (let i = 0; i < String(fmt).length;) {
2595
+ if (String(fmt)[i] === '%' && i + 1 < String(fmt).length) {
2596
+ const c = String(fmt)[i + 1];
2597
+ if (c === '%') { pattern += '%'; i += 2; continue; }
2598
+ if (spec[c]) { pattern += spec[c]; order.push(c); i += 2; continue; }
2599
+ pattern += c; i += 2; continue;
2600
+ }
2601
+ pattern += String(fmt)[i].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2602
+ i++;
2603
+ }
2604
+ const m = new RegExp('^' + pattern + '$').exec(String(s));
2605
+ if (!m) return null;
2606
+ let year = 1970, month = 0, day = 1, hour = 0, min = 0, sec = 0;
2607
+ order.forEach((c, idx) => {
2608
+ const v = m[idx + 1];
2609
+ if (v === undefined) return;
2610
+ switch (c) {
2611
+ case 'Y': year = Number(v); break;
2612
+ case 'y': year = Number(v) < 70 ? 2000 + Number(v) : 1900 + Number(v); break;
2613
+ case 'm': case 'c': month = Number(v) - 1; break;
2614
+ case 'M': case 'b': {
2615
+ const mi = MONTH_NAMES.findIndex((x) => x.toLowerCase().startsWith(String(v).toLowerCase().slice(0, 3)));
2616
+ if (mi >= 0) month = mi;
2617
+ break;
2618
+ }
2619
+ case 'd': case 'e': day = Number(v); break;
2620
+ case 'H': case 'k': hour = Number(v); break;
2621
+ case 'h': case 'I': case 'l': {
2622
+ const hh = Number(v) % 12;
2623
+ hour = /pm/i.test(String(s)) ? hh + 12 : hh;
2624
+ break;
2625
+ }
2626
+ case 'i': min = Number(v); break;
2627
+ case 'S': case 's': sec = Number(v); break;
2628
+ default: break;
2629
+ }
2630
+ });
2631
+ const d = new Date(year, month, day, hour, min, sec);
2632
+ return Number.isNaN(d.getTime()) ? null : d;
2633
+ }
2634
+
2635
+ /** PG DATE_PART / EXTRACT 的部件取值 */
2636
+ function pgDatePart(part, d) {
2637
+ switch (String(part).toLowerCase()) {
2638
+ case 'year': case 'y': return d.getFullYear();
2639
+ case 'month': case 'mon': case 'mm': return d.getMonth() + 1;
2640
+ case 'day': case 'dd': case 'd': return d.getDate();
2641
+ case 'hour': case 'hh': return d.getHours();
2642
+ case 'minute': case 'mi': return d.getMinutes();
2643
+ case 'second': case 'ss': return d.getSeconds();
2644
+ case 'dow': return d.getDay();
2645
+ case 'doy': return Math.round((d - new Date(d.getFullYear(), 0, 0)) / 86400000);
2646
+ case 'quarter': return Math.floor(d.getMonth() / 3) + 1;
2647
+ case 'epoch': return Math.floor(d.getTime() / 1000);
2648
+ case 'week': {
2649
+ // ISO 8601 周序号
2650
+ const t = new Date(d.getFullYear(), d.getMonth(), d.getDate());
2651
+ const dayNr = (t.getDay() + 6) % 7;
2652
+ t.setDate(t.getDate() - dayNr + 3);
2653
+ const firstThursday = new Date(t.getFullYear(), 0, 4);
2654
+ const fdayNr = (firstThursday.getDay() + 6) % 7;
2655
+ firstThursday.setDate(firstThursday.getDate() - fdayNr + 3);
2656
+ return 1 + Math.round((t - firstThursday) / (7 * 86400000));
2657
+ }
2658
+ default: return null;
2659
+ }
2660
+ }
2661
+
2662
+ /** PG AGE(from, to) 的间隔文本:'6 years 7 mons 11 days' */
2663
+ function pgAge(from, to) {
2664
+ let years = to.getFullYear() - from.getFullYear();
2665
+ let months = to.getMonth() - from.getMonth();
2666
+ let days = to.getDate() - from.getDate();
2667
+ if (days < 0) {
2668
+ months -= 1;
2669
+ days += new Date(to.getFullYear(), to.getMonth(), 0).getDate();
2670
+ }
2671
+ if (months < 0) { years -= 1; months += 12; }
2672
+ return `${years} years ${months} mons ${days} days`;
2673
+ }
2674
+
2434
2675
  function applyScalarFunction(fnNode, row, ctx) {
2435
2676
  const name = (fnNode.name || '').toUpperCase();
2436
2677
  const rawArgs = fnNode.args || [];
@@ -2456,9 +2697,11 @@ function applyScalarFunction(fnNode, row, ctx) {
2456
2697
  case 'FOUND_ROWS': return session && session.foundRows !== undefined ? session.foundRows : 0;
2457
2698
  case 'CONNECTION_ID': return session && session.connectionId !== undefined ? session.connectionId : 0;
2458
2699
  case 'DATABASE': case 'SCHEMA': return session && session.currentDb ? session.currentDb : 'default';
2459
- case 'NOW': case 'CURRENT_TIMESTAMP': return new Date().toISOString().slice(0, 19).replace('T', ' ');
2460
- case 'CURDATE': case 'CURRENT_DATE': return new Date().toISOString().slice(0, 10);
2461
- case 'CURTIME': return new Date().toISOString().slice(11, 19);
2700
+ // NOW() / CURDATE() / CURTIME() 返回会话时区(本地)时间,不能用 toISOString()(UTC)——
2701
+ // 否则东八区凌晨会整整差一天(2024-02-29 被存成 2024-02-28)。
2702
+ case 'NOW': case 'CURRENT_TIMESTAMP': return sqlLocalDateTime();
2703
+ case 'CURDATE': case 'CURRENT_DATE': return sqlLocalDateTime().slice(0, 10);
2704
+ case 'CURTIME': return sqlLocalDateTime().slice(11, 19);
2462
2705
  case 'UTC_TIMESTAMP': return new Date().toISOString().slice(0, 19).replace('T', ' ') + ' UTC';
2463
2706
  case 'CONCAT': return args.map(a => a === null || a === undefined ? '' : String(a)).join('');
2464
2707
  case 'CONCAT_WS': {
@@ -2527,6 +2770,178 @@ function applyScalarFunction(fnNode, row, ctx) {
2527
2770
  }
2528
2771
  return fmtSqlDate(isDateOnly, d);
2529
2772
  }
2773
+ // ===== 日期/时间(补齐 README 契约:此前这些全部缺失,静默返回 null)=====
2774
+ case 'YEAR': {
2775
+ const d = parseSqlDate(args[0]);
2776
+ return d ? d.getFullYear() : null;
2777
+ }
2778
+ case 'MONTH': {
2779
+ const d = parseSqlDate(args[0]);
2780
+ return d ? d.getMonth() + 1 : null;
2781
+ }
2782
+ case 'DAY': case 'DAYOFMONTH': {
2783
+ const d = parseSqlDate(args[0]);
2784
+ return d ? d.getDate() : null;
2785
+ }
2786
+ case 'HOUR': {
2787
+ const d = parseSqlDate(args[0]);
2788
+ return d ? d.getHours() : null;
2789
+ }
2790
+ case 'MINUTE': {
2791
+ const d = parseSqlDate(args[0]);
2792
+ return d ? d.getMinutes() : null;
2793
+ }
2794
+ case 'SECOND': {
2795
+ const d = parseSqlDate(args[0]);
2796
+ return d ? d.getSeconds() : null;
2797
+ }
2798
+ case 'QUARTER': {
2799
+ const d = parseSqlDate(args[0]);
2800
+ return d ? Math.floor(d.getMonth() / 3) + 1 : null;
2801
+ }
2802
+ case 'WEEK': {
2803
+ const d = parseSqlDate(args[0]);
2804
+ if (!d) return null;
2805
+ const mode = args[1] != null ? Number(args[1]) : 0;
2806
+ // mode 1/3(周一为一周之始且首周需 ≥4 天)走 ISO 周序;其余按 MySQL 默认 mode 0
2807
+ if (mode === 1 || mode === 3) return pgDatePart('week', d);
2808
+ // mode 0:周日为一周之始,第 1 周是本年第一个含周日的周(之前为第 0 周)
2809
+ const jan1Dow = new Date(d.getFullYear(), 0, 1).getDay(); // 0=周日
2810
+ const firstSunday = jan1Dow === 0 ? 1 : 8 - jan1Dow;
2811
+ const doy = Math.round((d - new Date(d.getFullYear(), 0, 0)) / 86400000);
2812
+ if (doy < firstSunday) return 0;
2813
+ return Math.floor((doy - firstSunday) / 7) + 1;
2814
+ }
2815
+ case 'DAYOFWEEK': { // MySQL:1=周日 … 7=周六
2816
+ const d = parseSqlDate(args[0]);
2817
+ return d ? d.getDay() + 1 : null;
2818
+ }
2819
+ case 'DATE': {
2820
+ const d = parseSqlDate(args[0]);
2821
+ return d ? fmtSqlDate(true, d) : null;
2822
+ }
2823
+ case 'TIME': {
2824
+ const d = parseSqlDate(args[0]);
2825
+ return d ? pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds()) : null;
2826
+ }
2827
+ case 'DATEDIFF': {
2828
+ const a = parseSqlDate(args[0]);
2829
+ const b = parseSqlDate(args[1]);
2830
+ if (!a || !b) return null;
2831
+ // MySQL 语义:按日期相减(忽略时间部分)
2832
+ const ua = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());
2833
+ const ub = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());
2834
+ return Math.round((ua - ub) / 86400000);
2835
+ }
2836
+ case 'TIMESTAMPDIFF': {
2837
+ // unit 允许写成裸关键字(TIMESTAMPDIFF(MONTH, a, b)),此时会被解析成列名而取不到值,
2838
+ // 回退用列名文本;不影响真正名为 month/year 的列。
2839
+ const raw0 = rawArgs[0];
2840
+ const unit = String(args[0] != null ? args[0]
2841
+ : (raw0 && raw0.type === 'column' ? raw0.name : '') || '').toUpperCase();
2842
+ // MySQL 语义:TIMESTAMPDIFF(unit, start, end) = end - start
2843
+ const a = parseSqlDate(args[2]);
2844
+ const b = parseSqlDate(args[1]);
2845
+ if (!a || !b) return null;
2846
+ const ms = a.getTime() - b.getTime();
2847
+ const months = (a.getFullYear() - b.getFullYear()) * 12 + (a.getMonth() - b.getMonth());
2848
+ switch (unit) {
2849
+ case 'MICROSECOND': return Math.floor(ms * 1000);
2850
+ case 'SECOND': return Math.floor(ms / 1000);
2851
+ case 'MINUTE': return Math.floor(ms / 60000);
2852
+ case 'HOUR': return Math.floor(ms / 3600000);
2853
+ case 'DAY': return Math.floor(ms / 86400000);
2854
+ case 'WEEK': return Math.floor(ms / (86400000 * 7));
2855
+ case 'MONTH': return months;
2856
+ case 'QUARTER': return Math.floor(months / 3);
2857
+ case 'YEAR': return a.getFullYear() - b.getFullYear();
2858
+ default: return null;
2859
+ }
2860
+ }
2861
+ case 'DATE_FORMAT': {
2862
+ const d = parseSqlDate(args[0]);
2863
+ return d ? strftime(d, String(args[1] || '%Y-%m-%d')) : null;
2864
+ }
2865
+ case 'STR_TO_DATE': {
2866
+ const d = strToDate(String(args[0] == null ? '' : args[0]), String(args[1] || ''));
2867
+ return d ? fmtSqlDate(!/%[HhIiklsS]/.test(String(args[1] || '')), d) : null;
2868
+ }
2869
+ case 'TO_CHAR': { // PG:TO_CHAR(d, fmt) —— 格式串用 PG 风格(YYYY-MM-DD)
2870
+ const d = parseSqlDate(args[0]);
2871
+ return d ? strftime(d, pgFmtToMysql(args[1] || '%Y-%m-%d')) : null;
2872
+ }
2873
+ case 'TO_DATE': { // PG:TO_DATE(s, fmt)
2874
+ const d = strToDate(String(args[0] == null ? '' : args[0]), pgFmtToMysql(String(args[1] || '')));
2875
+ return d ? fmtSqlDate(true, d) : null;
2876
+ }
2877
+ case 'DATE_PART': { // PG:DATE_PART('year', d)
2878
+ const d = parseSqlDate(args[1]);
2879
+ return d ? pgDatePart(args[0], d) : null;
2880
+ }
2881
+ case 'AGE': { // PG:AGE(a, b) —— a 相对 b 的间隔
2882
+ const a = parseSqlDate(args[0]);
2883
+ const b = args[1] !== undefined && args[1] !== null ? parseSqlDate(args[1]) : new Date();
2884
+ return (a && b) ? pgAge(b, a) : null;
2885
+ }
2886
+ case 'FROM_UNIXTIME': {
2887
+ const ts = Number(args[0]);
2888
+ if (!Number.isFinite(ts)) return null;
2889
+ const d = new Date(ts * 1000);
2890
+ return args[1] !== undefined && args[1] !== null ? strftime(d, String(args[1])) : fmtSqlDate(false, d);
2891
+ }
2892
+ case 'LAST_DAY': {
2893
+ const d = parseSqlDate(args[0]);
2894
+ return d ? fmtSqlDate(true, new Date(d.getFullYear(), d.getMonth() + 1, 0)) : null;
2895
+ }
2896
+ case 'WEEKDAY': { // MySQL:0=周一 … 6=周日
2897
+ const d = parseSqlDate(args[0]);
2898
+ return d ? (d.getDay() + 6) % 7 : null;
2899
+ }
2900
+ case 'DAYOFYEAR': {
2901
+ const d = parseSqlDate(args[0]);
2902
+ return d ? Math.round((d - new Date(d.getFullYear(), 0, 0)) / 86400000) : null;
2903
+ }
2904
+ case 'DAYNAME': {
2905
+ const d = parseSqlDate(args[0]);
2906
+ return d ? DAY_NAMES[d.getDay()] : null;
2907
+ }
2908
+ case 'MONTHNAME': {
2909
+ const d = parseSqlDate(args[0]);
2910
+ return d ? MONTH_NAMES[d.getMonth()] : null;
2911
+ }
2912
+ case 'MAKEDATE': {
2913
+ const y = Number(args[0]);
2914
+ const doy = Number(args[1]);
2915
+ if (!Number.isFinite(y) || !Number.isFinite(doy)) return null;
2916
+ const d = new Date(y, 0, 1);
2917
+ d.setDate(doy);
2918
+ return fmtSqlDate(true, d);
2919
+ }
2920
+ case 'PERIOD_DIFF': {
2921
+ const p1 = String(args[0] == null ? '' : args[0]);
2922
+ const p2 = String(args[1] == null ? '' : args[1]);
2923
+ if (!/^\d{6}$/.test(p1) || !/^\d{6}$/.test(p2)) return null;
2924
+ return (Number(p1.slice(0, 4)) * 12 + Number(p1.slice(4))) -
2925
+ (Number(p2.slice(0, 4)) * 12 + Number(p2.slice(4)));
2926
+ }
2927
+ case 'DATE_TRUNC': { // PG:DATE_TRUNC('month', d)
2928
+ const part = String(args[0] || '').toLowerCase();
2929
+ const d = parseSqlDate(args[1]);
2930
+ if (!d) return null;
2931
+ let nd;
2932
+ switch (part) {
2933
+ case 'year': nd = new Date(d.getFullYear(), 0, 1); break;
2934
+ case 'quarter': nd = new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3, 1); break;
2935
+ case 'month': nd = new Date(d.getFullYear(), d.getMonth(), 1); break;
2936
+ case 'week': { const wd = (d.getDay() + 6) % 7; nd = new Date(d.getFullYear(), d.getMonth(), d.getDate() - wd); break; }
2937
+ case 'day': nd = new Date(d.getFullYear(), d.getMonth(), d.getDate()); break;
2938
+ case 'hour': nd = new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()); break;
2939
+ case 'minute': nd = new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours(), d.getMinutes()); break;
2940
+ case 'second': nd = new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours(), d.getMinutes(), d.getSeconds()); break;
2941
+ default: return null;
2942
+ }
2943
+ return fmtSqlDate(false, nd);
2944
+ }
2530
2945
  case 'LTRIM': return args[0] == null ? null : String(args[0]).replace(/^\s+/, '');
2531
2946
  case 'RTRIM': return args[0] == null ? null : String(args[0]).replace(/\s+$/, '');
2532
2947
  case 'ABS': return args[0] == null ? null : Math.abs(args[0]);
@@ -2633,6 +3048,57 @@ function likeMatch(value, pattern, ci = true, escape = '\\') {
2633
3048
  return new RegExp('^' + out + '$', ci ? 'i' : '').test(value);
2634
3049
  }
2635
3050
 
3051
+ /** 解析 PG 的路径字面量:'{a,b}' 或数组 → ['a','b'] */
3052
+ function parsePgPath(key) {
3053
+ if (Array.isArray(key)) return key.map(String);
3054
+ if (typeof key === 'string') {
3055
+ const s = key.trim();
3056
+ if (s.startsWith('{') && s.endsWith('}')) {
3057
+ return s.slice(1, -1).split(',').map(x => x.trim().replace(/^"(.*)"$/, '$1')).filter(x => x !== '');
3058
+ }
3059
+ return [s];
3060
+ }
3061
+ return [String(key)];
3062
+ }
3063
+
3064
+ /** 把可能是 JSON 字符串的值归一为 JS 值 */
3065
+ function toJsonValue(v) {
3066
+ if (typeof v === 'string') { try { return JSON.parse(v); } catch (e) { return v; } }
3067
+ return v;
3068
+ }
3069
+
3070
+ /** JSON 包含语义(PG 的 @> / <@)。swap=true 时判断 target 是否包含 container。 */
3071
+ function jsonContains(a, b, swap) {
3072
+ const container = toJsonValue(swap ? b : a);
3073
+ const target = toJsonValue(swap ? a : b);
3074
+ return jsonContainsValue(container, target);
3075
+ }
3076
+
3077
+ function jsonContainsValue(container, target) {
3078
+ if (target === null || target === undefined) return true;
3079
+ if (Array.isArray(target)) {
3080
+ if (!Array.isArray(container)) return false;
3081
+ return target.every(t => container.some(c => jsonContainsValue(c, t)));
3082
+ }
3083
+ if (typeof target === 'object') {
3084
+ if (container === null || typeof container !== 'object' || Array.isArray(container)) return false;
3085
+ return Object.keys(target).every(k => Object.prototype.hasOwnProperty.call(container, k) && jsonContainsValue(container[k], target[k]));
3086
+ }
3087
+ if (Array.isArray(container)) return container.some(c => c === target || String(c) === String(target));
3088
+ if (container !== null && typeof container === 'object') return false;
3089
+ return container === target || String(container) === String(target);
3090
+ }
3091
+
3092
+ /** JSON 键存在(PG 的 ? / ?| / ?&)。keys 为单个键名或 '{a,b}' 形式的键列表。 */
3093
+ function jsonKeyExists(base, keys, mode) {
3094
+ const v = toJsonValue(base);
3095
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) return false;
3096
+ const has = (k) => Object.prototype.hasOwnProperty.call(v, k);
3097
+ if (mode === '?|') return parsePgPath(keys).some(has);
3098
+ if (mode === '?&') return parsePgPath(keys).every(has);
3099
+ return has(String(keys));
3100
+ }
3101
+
2636
3102
  function extractEqualPushdown(expr, schema) {
2637
3103
  if (!expr || !schema) return null;
2638
3104
  const filter = {};
@@ -2683,7 +3149,11 @@ function evaluateExpr(expr, row, ctx) {
2683
3149
  case 'and': return evaluateExpr(expr.left, row, ctx) && evaluateExpr(expr.right, row, ctx);
2684
3150
  case 'or': return evaluateExpr(expr.left, row, ctx) || evaluateExpr(expr.right, row, ctx);
2685
3151
  case 'xor': return Boolean(evaluateExpr(expr.left, row, ctx)) !== Boolean(evaluateExpr(expr.right, row, ctx));
2686
- case 'not': return !evaluateExpr(expr.expr, row, ctx);
3152
+ case 'not': {
3153
+ // 三值逻辑:NOT UNKNOWN = UNKNOWN(返回 null,在 WHERE/ON/HAVING 中视为不选中)
3154
+ const v = evaluateExpr(expr.expr, row, ctx);
3155
+ return v === null ? null : !v;
3156
+ }
2687
3157
  case 'compare': {
2688
3158
  const l = resolveOperand(expr.left, row, ctx);
2689
3159
  const r = resolveOperand(expr.right, row, ctx);
@@ -2694,13 +3164,23 @@ function evaluateExpr(expr, row, ctx) {
2694
3164
  if (ln || rn) return ln && rn;
2695
3165
  return l === r || String(l) === String(r);
2696
3166
  }
2697
- // SQL 标准:任何与 NULL 的比较结果为 UNKNOWN(在 WHERE/ON/HAVING 中视为 false)
2698
- if (l === null || r === null) return false;
3167
+ // SQL 标准:与 NULL 的比较结果是 UNKNOWN,这里用 null 表示。
3168
+ // 不能返回 false —— 否则 NOT (a > 1) 会把 a 为 NULL 的行错误纳入结果。
3169
+ if (l === null || l === undefined || r === null || r === undefined) return null;
2699
3170
  // 布尔与数值互通(MySQL: 1 = TRUE、0 = FALSE)
2700
3171
  const nl = normalizeCmpValue(l, r);
2701
3172
  const nr = normalizeCmpValue(r, l);
2702
- if (expr.op === '=') return nl === nr || String(nl) === String(nr);
3173
+ const isNum = (v) => typeof v === 'number';
3174
+ const isNumStr = (v) => typeof v === 'string' && String(v).trim() !== '' && Number.isFinite(Number(v));
3175
+ // 数值 vs 数值字符串(age < '10'、age = '09')必须按数值比,
3176
+ // 否则退化成字典序:'9' < '10' 为 false、'9' = '09' 为 false
3177
+ const mixedNum = (isNum(nl) && isNumStr(nr)) || (isNumStr(nl) && isNum(nr));
3178
+ if (expr.op === '=') {
3179
+ if (mixedNum) return Number(nl) === Number(nr);
3180
+ return nl === nr || String(nl) === String(nr);
3181
+ }
2703
3182
  const fn = OPERATORS[expr.op];
3183
+ if (mixedNum) return fn(Number(nl), Number(nr));
2704
3184
  return typeof nl === 'number' && typeof nr === 'number' ? fn(nl, nr) : fn(String(nl), String(nr));
2705
3185
  }
2706
3186
  case 'isNull': {
@@ -2712,22 +3192,30 @@ function evaluateExpr(expr, row, ctx) {
2712
3192
  const v = resolveOperand(expr.operand, row, ctx);
2713
3193
  if (expr.subquery && expr.subquery._values !== undefined) expr.list = expr.subquery._values;
2714
3194
  if (!expr.list) return false;
2715
- return expr.list.some(x => x === v || String(x) === String(v));
3195
+ const r = expr.list.some(x => x === v || String(x) === String(v));
3196
+ return expr.not ? !r : r; // NOT IN:此前漏了取反,导致 NOT IN 与 IN 结果相同
2716
3197
  }
2717
3198
  case 'like': {
2718
3199
  const v = resolveOperand(expr.operand, row, ctx);
2719
- const r = likeMatch(v, expr.pattern, !!expr.ci, expr.escape);
3200
+ // MySQL 的 LIKE 默认不区分大小写;只有显式 BINARY / ci=false 才敏感。
3201
+ // 此前写成 !!expr.ci,把 likeMatch 的 ci 默认值 true 覆盖成了 false。
3202
+ const r = likeMatch(v, expr.pattern, expr.ci !== false, expr.escape);
2720
3203
  return expr.not ? !r : r;
2721
3204
  }
2722
3205
  case 'quantified':
2723
3206
  return !!resolveOperand(expr, row, ctx);
3207
+ case 'jsonContains':
3208
+ return !!resolveOperand(expr, row, ctx);
2724
3209
  case 'regexp': {
2725
3210
  const v = resolveOperand(expr.operand, row, ctx);
2726
3211
  if (v === null || v === undefined) return false;
2727
- const re = new RegExp(String(expr.pattern), 'i');
3212
+ // REGEXP / RLIKE 历史上就是大小写不敏感的;`~` 敏感、`~*` 不敏感
3213
+ const re = new RegExp(String(expr.pattern), expr.ci === false ? '' : 'i');
2728
3214
  const r = re.test(String(v));
2729
3215
  return expr.not ? !r : r;
2730
3216
  }
3217
+ case 'jsonKeyExists':
3218
+ return !!resolveOperand(expr, row, ctx);
2731
3219
  case 'isTruth': {
2732
3220
  const v = resolveOperand(expr.operand, row, ctx);
2733
3221
  const isTrue = v === true || v === 1 || v === '1' || v === 'true' || v === 'TRUE' || v === 't' || (typeof v === 'number' && v !== 0);
@@ -3013,9 +3501,30 @@ class SQLExecutor {
3013
3501
  dataRows = dataRows.map(stripDefault);
3014
3502
  }
3015
3503
  const pkCols = schema ? Object.keys(schema).filter(k => schema[k].primaryKey) : [];
3504
+ const uniqueCols = schema ? Object.keys(schema).filter(k => schema[k].unique && !schema[k].primaryKey) : [];
3016
3505
  let toInsert = dataRows;
3017
3506
  let updated = 0;
3018
3507
  let skipped = 0;
3508
+ // 非主键 UNIQUE 约束:默认动作(无 ON CONFLICT / IGNORE / REPLACE)下违反即 ER_DUP_ENTRY。
3509
+ // 主键冲突由下面的冲突逻辑处理;这里补唯一列(含批内重复)。
3510
+ if (uniqueCols.length > 0 && this._conflictAction(statement) === 'throw') {
3511
+ const existing = (await this.engine.find(statement.name, {}, { limit: 1e9, offset: 0 }))
3512
+ .map(r => normalizeRow(r, schema));
3513
+ for (const col of uniqueCols) {
3514
+ const used = new Set();
3515
+ for (const r of existing) {
3516
+ const v = r[col];
3517
+ if (v !== undefined && v !== null) used.add(String(v));
3518
+ }
3519
+ for (const d of dataRows) {
3520
+ const v = d[col];
3521
+ if (v === undefined || v === null) continue; // UNIQUE 允许多个 NULL
3522
+ const s = String(v);
3523
+ if (used.has(s)) throw new Error(`ER_DUP_ENTRY: Duplicate entry '${s}' for unique column '${col}'`);
3524
+ used.add(s);
3525
+ }
3526
+ }
3527
+ }
3019
3528
  if (pkCols.length > 0) {
3020
3529
  const keyOf = (row) => pkCols.map(c => (row[c] !== undefined && row[c] !== null ? String(row[c]) : '')).join('|');
3021
3530
  const hasExplicitPk = (row) => pkCols.some(c => row[c] !== undefined && row[c] !== null);
@@ -3028,11 +3537,8 @@ class SQLExecutor {
3028
3537
  : null;
3029
3538
  const extraIdx = [];
3030
3539
  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
- }
3540
+ // 唯一列始终参与冲突判定(PG 的 ON CONFLICT 与 MySQL 的 ON DUPLICATE 都包含唯一键)
3541
+ for (const c of uniqueCols) extraIdx.push([c]);
3036
3542
  const hasAllOf = (cols) => (row) => cols.every(c => row[c] !== undefined && row[c] !== null);
3037
3543
 
3038
3544
  const explicit = dataRows.filter((row) => hasExplicitPk(row) || extraIdx.some((cols) => hasAllOf(cols)(row)));
@@ -3918,8 +4424,25 @@ class SQLExecutor {
3918
4424
  }
3919
4425
  if (fn === 'SUM') return values.reduce((s, v) => s + (typeof v === 'number' ? v : Number(v) || 0), 0);
3920
4426
  if (fn === 'AVG') return values.length ? values.reduce((s, v) => s + (typeof v === 'number' ? v : Number(v) || 0), 0) / values.length : null;
3921
- if (fn === 'MIN') return values.length ? Math.min(...values.map(v => Number(v))) : null;
3922
- if (fn === 'MAX') return values.length ? Math.max(...values.map(v => Number(v))) : null;
4427
+ if (fn === 'MIN' || fn === 'MAX') {
4428
+ const vs = values.filter(v => v !== null && v !== undefined);
4429
+ if (!vs.length) return null;
4430
+ // 数值按数值比;字符串/日期按字典序/时间序。
4431
+ // 此前一律 Number(v) —— 字符串列会得到 NaN,MIN(name) 直接变 null。
4432
+ const numeric = (v) => typeof v === 'number' ||
4433
+ (typeof v === 'string' && String(v).trim() !== '' && Number.isFinite(Number(v)));
4434
+ if (vs.every(numeric)) {
4435
+ const nums = vs.map(v => Number(v));
4436
+ return fn === 'MIN' ? Math.min(...nums) : Math.max(...nums);
4437
+ }
4438
+ let best = vs[0];
4439
+ for (const v of vs.slice(1)) {
4440
+ const a = String(v);
4441
+ const b = String(best);
4442
+ if (fn === 'MIN' ? a < b : a > b) best = v;
4443
+ }
4444
+ return best;
4445
+ }
3923
4446
  if (fn === 'FIRST') return values.length ? values[0] : null;
3924
4447
  if (fn === 'LAST') return values.length ? values[values.length - 1] : null;
3925
4448
  if (fn === 'GROUP_CONCAT') {
@@ -4058,6 +4581,15 @@ class SQLExecutor {
4058
4581
  if (!sample || typeof sample !== 'object') return;
4059
4582
  const known = new Set();
4060
4583
  for (const k of Object.keys(sample)) known.add(String(k).toLowerCase());
4584
+ // 相关子查询:外层行的列也算已知。
4585
+ // _evalCorrelated 会把外层行挂到 ctx.__outer,子查询(`WHERE EXISTS (SELECT ... WHERE b.av = a.v)`)
4586
+ // 里的 `a.v` 属于外层作用域,不在本层 rows 里,不纳入就会误报 no such column。
4587
+ const outer = this.ctx && this.ctx.__outer;
4588
+ if (outer && typeof outer === 'object') {
4589
+ for (const k of Object.keys(outer)) known.add(String(k).toLowerCase());
4590
+ }
4591
+ // 本层 FROM 的表名/别名,用于判断限定引用是否指向外层
4592
+ const localAliases = this._outerAliases(statement);
4061
4593
  // HAVING / ORDER BY 允许引用 SELECT 的输出别名(如 HAVING cnt >= 1),
4062
4594
  // 这些名字不在表里,必须一并视为已知,否则会误报 no such column
4063
4595
  if (Array.isArray(statement.columns)) {
@@ -4073,7 +4605,11 @@ class SQLExecutor {
4073
4605
  if (node.type === 'select' || node.type === 'subquery') return;
4074
4606
  if (node.type === 'column' && typeof node.name === 'string') {
4075
4607
  const n = node.name.toLowerCase();
4076
- const base = n.indexOf('.') >= 0 ? n.slice(n.indexOf('.') + 1) : n;
4608
+ const dot = n.indexOf('.');
4609
+ const base = dot >= 0 ? n.slice(dot + 1) : n;
4610
+ // 限定列引用了本层 FROM 之外的表/别名 → 属于外层作用域(相关子查询),放行。
4611
+ // 否则 `WHERE EXISTS (SELECT 1 FROM b WHERE b.av = a.v)` 里的 a.v 会被误杀。
4612
+ if (dot >= 0 && !localAliases.has(n.slice(0, dot))) return;
4077
4613
  if (!known.has(n) && !known.has(base)) {
4078
4614
  throw new Error(`no such column: ${node.name}`);
4079
4615
  }
@@ -4504,16 +5040,16 @@ class SQLExecutor {
4504
5040
  const rightNulls = (rightRes.schema) ? this._nullPrefixedRow(rightRes.schema, rightPrefix) : null;
4505
5041
  const leftNulls = (rowsAll && rowsAll.schema) ? this._nullPrefixedRow(rowsAll.schema, firstPrefix) : null;
4506
5042
  rows.forEach(l => {
4507
- let m = null;
5043
+ // 一对多:一个左行可能匹配多个右行,必须全部收进来(此前只取第一个)
5044
+ const hits = [];
4508
5045
  for (let ri = 0; ri < rightRows.length; ri++) {
4509
- if (evaluateExpr(j.on, { ...l, ...rightRows[ri] })) {
4510
- m = ri;
4511
- break;
4512
- }
5046
+ if (evaluateExpr(j.on, { ...l, ...rightRows[ri] })) hits.push(ri);
4513
5047
  }
4514
- if (m !== null) {
4515
- matched.push({ ...l, ...rightRows[m] });
4516
- unmatchedRight.delete(m);
5048
+ if (hits.length > 0) {
5049
+ for (const ri of hits) {
5050
+ matched.push({ ...l, ...rightRows[ri] });
5051
+ unmatchedRight.delete(ri);
5052
+ }
4517
5053
  } else if (j.type === 'left' || j.type === 'full') {
4518
5054
  matched.push(rightNulls ? { ...rightNulls, ...l } : { ...l });
4519
5055
  }
@@ -4561,6 +5097,14 @@ class SQLExecutor {
4561
5097
  });
4562
5098
  }
4563
5099
 
5100
+ // 无 GROUP BY 时整张表就是一个分组。必须先给每行挂上 _group=全表,
5101
+ // 否则 HAVING 里的 COUNT(*) 会被逐行算成 1(r._group || [r]),
5102
+ // 于是 HAVING COUNT(*) > 2 会把所有行滤掉、最终输出 0 而不是真实计数。
5103
+ if (statement.having && !statement.groupBy) {
5104
+ const wholeTable = rows.slice();
5105
+ rows = rows.map((r) => ({ ...r, _group: wholeTable }));
5106
+ }
5107
+
4564
5108
  if (statement.having) {
4565
5109
  const havingAst = JSON.parse(JSON.stringify(statement.having));
4566
5110
  rows = rows.filter(r => {
@@ -4589,6 +5133,10 @@ class SQLExecutor {
4589
5133
  const seen = new Set();
4590
5134
  rows = rows.filter(r => {
4591
5135
  const key = JSON.stringify(statement.columns.map(c => {
5136
+ // SELECT DISTINCT *:按整行去重(此前把 '*' 当列名解析成 null,导致所有行同 key 只剩 1 行)
5137
+ if (c.expr === '*') {
5138
+ return Object.keys(r).filter(k => !String(k).startsWith('_')).sort().map(k => r[k]);
5139
+ }
4592
5140
  if (c.scalar) return resolveOperand(c.scalar, r, this.ctx);
4593
5141
  if (c.caseExpr) return evaluateCaseVal(c.caseExpr, r);
4594
5142
  if (c.aggregate) return this._aggValue(r._group || [r], c.aggregate || 'COUNT', c.column, c.distinct, c.separator, c.orderBy);
@@ -4605,6 +5153,11 @@ class SQLExecutor {
4605
5153
  // 注意:带 OVER 的聚合是窗口函数,不是“整表塌缩成一行”的聚合,必须排除
4606
5154
  if (!statement.groupBy && (statement.columns.some(c => c.aggregate && !c.window) || hasCustomAgg)) {
4607
5155
  const cols = statement.columns.map(c => scalarColumnName(c));
5156
+ const tableName2 = statement.from ? (statement.from.tables[0].table || null) : null;
5157
+ // HAVING 判定不通过时 rows 已为空,此时整表聚合也应为空,不能无条件补一行
5158
+ if (statement.having && rows.length === 0) {
5159
+ return { ok: true, type: 'select', table: tableName2, columns: cols, rows: [], aggregate: statement.aggregate };
5160
+ }
4608
5161
  const valueOf = (c) => {
4609
5162
  if (c.aggregate) return this._aggValue(rows, c.aggregate || 'COUNT', c.column, c.distinct, c.separator, c.orderBy);
4610
5163
  if (c.scalar && c.scalar.type === 'func' && hasCustomAgg) {
@@ -4618,7 +5171,7 @@ class SQLExecutor {
4618
5171
  if (c.expr !== null && c.expr !== '*') return rows[0] ? resolveOperand({ type: 'column', name: c.expr }, rows[0]) : null;
4619
5172
  return null;
4620
5173
  };
4621
- return { ok: true, type: 'select', table: statement.from ? (statement.from.tables[0].table || null) : null, columns: cols, rows: [[...statement.columns.map(valueOf)]], aggregate: statement.aggregate };
5174
+ return { ok: true, type: 'select', table: tableName2, columns: cols, rows: [[...statement.columns.map(valueOf)]], aggregate: statement.aggregate };
4622
5175
  }
4623
5176
 
4624
5177
  // GROUP BY 输出:每组的列(含聚合列)
@@ -4636,10 +5189,20 @@ class SQLExecutor {
4636
5189
  }
4637
5190
 
4638
5191
  if (statement.orderBy) {
4639
- // ORDER BY 项可能是列名字符串,也可能是表达式节点
4640
- const ordVal = (row, o) => (typeof o.column === 'string'
4641
- ? resolveOperand({ type: 'column', name: o.column }, row, this.ctx)
4642
- : resolveOperand(o.column, row, this.ctx));
5192
+ // ORDER BY 项可能是列名字符串、表达式节点,或列的位置序号(ORDER BY 1,1-based)
5193
+ const ordNames = statement.columns.map(c => scalarColumnName(c));
5194
+ const ordVal = (row, o) => {
5195
+ const pos = (o.column && typeof o.column === 'object' && o.column.type === 'value' && typeof o.column.value === 'number')
5196
+ ? o.column.value
5197
+ : (typeof o.column === 'number' ? o.column : null);
5198
+ if (pos !== null && pos !== undefined) {
5199
+ const name = ordNames[pos - 1];
5200
+ if (name !== undefined && name !== null) return resolveOperand({ type: 'column', name }, row, this.ctx);
5201
+ }
5202
+ return typeof o.column === 'string'
5203
+ ? resolveOperand({ type: 'column', name: o.column }, row, this.ctx)
5204
+ : resolveOperand(o.column, row, this.ctx);
5205
+ };
4643
5206
  const cmp = (a, b) => {
4644
5207
  for (const o of statement.orderBy) {
4645
5208
  const av = ordVal(a, o);
@@ -4690,53 +5253,88 @@ class SQLExecutor {
4690
5253
  }
4691
5254
  }
4692
5255
 
4693
- function splitStatements(sql) {
4694
- const statements = [];
4695
- let current = '';
4696
- let inStr = null; // "'" | '"' | '`'
4697
- let i = 0;
4698
- while (i < sql.length) {
5256
+ /** 跳过一个引号串(含转义/反引号成对),返回闭合引号之后的下标 */
5257
+ function skipQuoted(sql, i, q) {
5258
+ const n = sql.length;
5259
+ i++; // 跳过起始引号
5260
+ while (i < n) {
4699
5261
  const c = sql[i];
4700
- if (inStr) {
4701
- current += c;
4702
- if (inStr === '`') {
4703
- // 反引号标识符内部反斜杠不是转义符;两个连续反引号才表示一个字面反引号。
4704
- if (c === '`' && sql[i + 1] === '`') { current += sql[i + 1]; i += 2; continue; }
4705
- if (c === '`') inStr = null;
4706
- i++;
4707
- continue;
5262
+ if (q === '`') {
5263
+ // 反引号标识符内部反斜杠不是转义符;两个连续反引号表示一个字面反引号。
5264
+ if (c === '`') {
5265
+ if (sql[i + 1] === '`') { i += 2; continue; }
5266
+ return i + 1;
4708
5267
  }
4709
- if (c === '\\' && i + 1 < sql.length) { current += sql[i + 1]; i += 2; continue; }
4710
- if (c === inStr) inStr = null;
4711
5268
  i++;
4712
5269
  continue;
4713
5270
  }
4714
- if (c === "'" || c === '"' || c === '`') { inStr = c; current += c; i++; continue; }
5271
+ if (c === '\\') { i += 2; continue; }
5272
+ if (c === q) return i + 1;
5273
+ i++;
5274
+ }
5275
+ return n; // 未闭合:到结尾
5276
+ }
5277
+
5278
+ function splitStatements(sql, opts = {}) {
5279
+ if (typeof sql !== 'string') {
5280
+ throw new Error('splitStatements: sql must be a string');
5281
+ }
5282
+ const maxLength = opts.maxLength == null ? DEFAULT_MAX_SQL_LENGTH : opts.maxLength;
5283
+ if (typeof maxLength === 'number' && Number.isFinite(maxLength) && sql.length > maxLength) {
5284
+ throw new Error(
5285
+ `SQL text too large: ${sql.length} chars exceeds limit of ${maxLength} ` +
5286
+ `(raise opts.maxSqlLength to allow larger input)`
5287
+ );
5288
+ }
5289
+ const statements = [];
5290
+ // 用「区间切片」收集,而不是逐字符拼接:既避免 `current += c` 的 O(n²) 拷贝放大,
5291
+ // 也避免为每个字符建一个小字符串对象(20MB 无分号输入下两者都会打爆默认堆)。
5292
+ let parts = [];
5293
+ const take = end => { parts.push(sql.slice(segStart, end)); };
5294
+ const flush = () => {
5295
+ if (parts.length === 0) return;
5296
+ const stmt = parts.join('').trim();
5297
+ parts = [];
5298
+ if (stmt) statements.push(stmt);
5299
+ };
5300
+
5301
+ let segStart = 0;
5302
+ let i = 0;
5303
+ const n = sql.length;
5304
+ while (i < n) {
5305
+ const c = sql[i];
5306
+ if (c === "'" || c === '"' || c === '`') { i = skipQuoted(sql, i, c); continue; }
4715
5307
  if (c === '-' && sql[i + 1] === '-') {
4716
- while (i < sql.length && sql[i] !== '\n') i++;
5308
+ // 行注释:丢弃注释文本本身(与原实现一致),保留其后的换行
5309
+ take(i);
5310
+ while (i < n && sql[i] !== '\n') i++;
5311
+ segStart = i;
4717
5312
  continue;
4718
5313
  }
4719
- if (c === '#') {
4720
- while (i < sql.length && sql[i] !== '\n') i++;
5314
+ if (c === '#' && sql[i + 1] !== '>') {
5315
+ take(i);
5316
+ while (i < n && sql[i] !== '\n') i++;
5317
+ segStart = i;
4721
5318
  continue;
4722
5319
  }
4723
5320
  if (c === '/' && sql[i + 1] === '*') {
4724
- current += '/*';
5321
+ // 块注释:原实现会保留 `/* */` 原文,这里通过切片一并保留
4725
5322
  i += 2;
4726
- while (i + 1 < sql.length && !(sql[i] === '*' && sql[i + 1] === '/')) { current += sql[i]; i++; }
4727
- if (i + 1 < sql.length) { current += '*/'; i += 2; }
5323
+ while (i + 1 < n && !(sql[i] === '*' && sql[i + 1] === '/')) i++;
5324
+ i = i + 1 < n ? i + 2 : n;
4728
5325
  continue;
4729
5326
  }
4730
5327
  if (c === ';') {
4731
- if (current.trim()) statements.push(current.trim());
4732
- current = '';
5328
+ take(i);
5329
+ flush();
4733
5330
  i++;
5331
+ segStart = i;
4734
5332
  continue;
4735
5333
  }
4736
- current += c;
4737
5334
  i++;
4738
5335
  }
4739
- if (current.trim()) statements.push(current.trim());
5336
+ take(n);
5337
+ flush();
4740
5338
  return statements;
4741
5339
  }
4742
5340
 
@@ -4763,7 +5361,7 @@ function hasComments(sql) {
4763
5361
  if (c === "'" || c === '"') { inStr = c; i++; continue; }
4764
5362
  if (c === '`') { i++; while (i < sql.length && sql[i] !== '`') i++; i++; continue; }
4765
5363
  if (c === '-' && sql[i + 1] === '-') return true;
4766
- if (c === '#') return true;
5364
+ if (c === '#' && sql[i + 1] !== '>') return true; // '#>' / '#>>' 是 JSON 操作符,不是注释
4767
5365
  if (c === '/' && sql[i + 1] === '*') return true;
4768
5366
  i++;
4769
5367
  }
@@ -4812,16 +5410,34 @@ function applyParams(sql, values) {
4812
5410
  if (args && typeof args === 'object' && !Array.isArray(args)) {
4813
5411
  hasNamed = true;
4814
5412
  }
4815
- while (i < sql.length) {
5413
+ const n = sql.length;
5414
+ while (i < n) {
4816
5415
  const c = sql[i];
4817
5416
  if (inStr) {
4818
5417
  out += c;
4819
- if (c === '\\' && i + 1 < sql.length) { out += sql[i + 1]; i += 2; continue; }
5418
+ if (c === '\\' && i + 1 < n) { out += sql[i + 1]; i += 2; continue; }
4820
5419
  if (c === inStr) inStr = null;
4821
5420
  i++;
4822
5421
  continue;
4823
5422
  }
4824
5423
  if (c === "'" || c === '"' || c === '`') { inStr = c; out += c; i++; continue; }
5424
+ // 跳过注释:`--` 和 `#`(但 `#>` / `#>>` 是 JSON 操作符,不是注释)
5425
+ if (c === '-' && sql[i + 1] === '-') {
5426
+ // 行注释:整段跳过,保留换行(不在注释里的字符不进入 out)
5427
+ while (i < n && sql[i] !== '\n') i++;
5428
+ continue;
5429
+ }
5430
+ if (c === '#' && sql[i + 1] !== '>') {
5431
+ while (i < n && sql[i] !== '\n') i++;
5432
+ continue;
5433
+ }
5434
+ if (c === '/' && sql[i + 1] === '*') {
5435
+ // 块注释:整段跳过
5436
+ i += 2;
5437
+ while (i + 1 < n && !(sql[i] === '*' && sql[i + 1] === '/')) i++;
5438
+ if (i + 1 < n) i += 2; else i = n;
5439
+ continue;
5440
+ }
4825
5441
  if (c === '?' && sql[i + 1] === '?') {
4826
5442
  if (!hasNamed && idx >= args.length) throw new Error('Not enough parameters for SQL: expected ' + (count + 1));
4827
5443
  out += escapeId(hasNamed ? args['@@'] : args[idx++]);
@@ -4854,6 +5470,24 @@ function applyParams(sql, values) {
4854
5470
  i++;
4855
5471
  continue;
4856
5472
  }
5473
+ // $N 编号占位符(PG 风格):$1 / $2 ...
5474
+ if (c === '$' && i + 1 < sql.length && sql[i + 1] >= '0' && sql[i + 1] <= '9') {
5475
+ let num = '';
5476
+ let j = i + 1;
5477
+ while (j < sql.length && sql[j] >= '0' && sql[j] <= '9') { num += sql[j]; j++; }
5478
+ const n = parseInt(num, 10);
5479
+ if (hasNamed) {
5480
+ if (!(n in args)) throw new Error(`No value for parameter $${n}`);
5481
+ out += escapeValue(args[n]);
5482
+ } else {
5483
+ if (n - 1 >= args.length) throw new Error(`Not enough parameters for SQL: expected $${n}`);
5484
+ out += escapeValue(args[n - 1]);
5485
+ if (n > idx) idx = n;
5486
+ }
5487
+ count++;
5488
+ i = j;
5489
+ continue;
5490
+ }
4857
5491
  if ((c === ':' || c === '@' || c === '$') && i + 1 < sql.length && /[A-Za-z_]/.test(sql[i + 1])) {
4858
5492
  // 命名占位符 :name @name $name
4859
5493
  let name = '';
@@ -4936,14 +5570,19 @@ async function executeSQL(engine, sql, paramsOrOpts, opts = {}) {
4936
5570
  if (Array.isArray(paramsOrOpts)) {
4937
5571
  sql = applyParams(sql, paramsOrOpts);
4938
5572
  } else if (paramsOrOpts && typeof paramsOrOpts === 'object') {
4939
- opts = paramsOrOpts;
5573
+ // 第三参为对象时:含已知 opts 键 → 视为 opts;否则视为命名参数({id: 1} → :id / $id)
5574
+ const OPTS_KEYS = ['safety', 'session', 'params', 'functions', 'aggregates',
5575
+ 'allowComments', 'maxStatements', 'maxSqlLength', 'context', 'timeout', 'dialect'];
5576
+ const looksLikeOpts = Object.keys(paramsOrOpts).some(k => OPTS_KEYS.includes(k));
5577
+ if (looksLikeOpts) opts = paramsOrOpts;
5578
+ else sql = applyParams(sql, paramsOrOpts);
4940
5579
  }
4941
5580
  if (opts.safety !== false) {
4942
5581
  if (!opts.allowComments && hasComments(sql)) {
4943
5582
  throw new Error('SQL comments are disabled for security (--, #, /* */)');
4944
5583
  }
4945
5584
  }
4946
- let statements = splitStatements(sql);
5585
+ let statements = splitStatements(sql, { maxLength: opts.maxSqlLength });
4947
5586
  if (opts.maxStatements != null && statements.length > opts.maxStatements) {
4948
5587
  throw new Error(`too many statements (${statements.length} > ${opts.maxStatements})`);
4949
5588
  }