jsql-neo 6.2.0 → 6.3.1

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
@@ -1056,10 +1056,15 @@ Coercion rules: numeric strings compare as numbers (`'5' = 5`), `NULL = NULL`
1056
1056
  `DAYOFWEEK`, `DAYNAME`, `MONTHNAME`, `UNIX_TIMESTAMP`, `FROM_UNIXTIME`.
1057
1057
 
1058
1058
  ```sql
1059
- SELECT DATE_ADD('2026-08-12', INTERVAL 1 DAY); -- '2026-08-13 00:00:00'
1059
+ SELECT DATE_ADD('2026-08-12', INTERVAL 1 DAY); -- '2026-08-13'(纯日期进 → 纯日期出)
1060
+ SELECT DATE_ADD('2026-08-12 10:00:00', INTERVAL 1 DAY); -- '2026-08-13 10:00:00'
1060
1061
  SELECT DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i'); -- '2026-08-12 10:30'
1061
1062
  SELECT EXTRACT(YEAR FROM '2026-08-12'); -- 2026
1062
1063
  SELECT AGE('2026-08-12', '2020-01-01'); -- '6 years 7 mons 11 days'
1064
+ SELECT DATEDIFF('2026-08-12', '2026-08-01'); -- 11
1065
+ SELECT TIMESTAMPDIFF(MONTH, '2026-01-01', '2026-08-01'); -- 7(end - start)
1066
+ SELECT TO_DATE('2026-08-12', 'YYYY-MM-DD'); -- '2026-08-12'(PG 格式串)
1067
+ SELECT DATE_TRUNC('month', '2026-08-12 10:30:00'); -- '2026-08-01 00:00:00'
1063
1068
  ```
1064
1069
 
1065
1070
  **Conditional / null** — `IF(cond, a, b)`, `IIF`, `IFNULL(a, b)`, `COALESCE(...)`, `NULLIF`,
package/lib/btree.js CHANGED
@@ -127,7 +127,8 @@ class BTree {
127
127
  */
128
128
  _route(node, key) {
129
129
  let i = this._lowerBound(node.keys, key);
130
- if (i < node.keys.length && node.keys[i] === key) {
130
+ // 叶子节点没有子节点可路由(children[i] 为 undefined),直接返回下界,避免崩溃。
131
+ if (i < node.keys.length && node.keys[i] === key && node.children[i]) {
131
132
  return this._getMax(node.children[i]).key === key ? i : i + 1;
132
133
  }
133
134
  return i;
package/lib/date-types.js CHANGED
@@ -33,17 +33,37 @@ function validateDateType(value, type, fieldName) {
33
33
  // DATE: YYYY-MM-DD
34
34
  // ============================================================
35
35
 
36
+ function pad2(n) { return String(n).padStart(2, '0'); }
37
+
38
+ /**
39
+ * 本地时区的 'YYYY-MM-DD'。
40
+ * 不能用 toISOString().slice(0,10) —— 那是 UTC:东八区 2023-01-01 00:00 本地
41
+ * 会是 2022-12-31T16:00Z,切出来整整差一天(2024-02-29 → 2024-02-28)。
42
+ */
43
+ function toLocalDateString(d) {
44
+ return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
45
+ }
46
+
47
+ /** 本地时区的 'YYYY-MM-DD HH:MM:SS'(同理不能用 toISOString) */
48
+ function toLocalDateTimeString(d) {
49
+ return toLocalDateString(d) + ' ' +
50
+ pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
51
+ }
52
+
36
53
  function validateDate(value, fieldName) {
37
54
  if (value instanceof Date) {
38
- return value.toISOString().slice(0, 10);
55
+ return toLocalDateString(value);
39
56
  }
40
57
  if (typeof value === 'number') {
41
58
  // Unix timestamp → date
42
- return new Date(value * 1000).toISOString().slice(0, 10);
59
+ return toLocalDateString(new Date(value * 1000));
43
60
  }
44
61
  if (typeof value === 'string') {
45
- const d = parseDateString(value);
46
- if (d) return d.toISOString().slice(0, 10);
62
+ const s = value.trim();
63
+ // 已是 YYYY-MM-DD:直接返回,避免本地→UTC 往返导致差一天
64
+ if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
65
+ const d = parseDateString(s);
66
+ if (d) return toLocalDateString(d);
47
67
  }
48
68
  throw createError('ER_INVALID_DATE', fieldName, String(value));
49
69
  }
@@ -79,26 +99,23 @@ function parseDateString(str) {
79
99
 
80
100
  function validateDateTime(value, fieldName) {
81
101
  if (value instanceof Date) {
82
- return value.toISOString().replace('T', ' ').slice(0, 19);
102
+ return toLocalDateTimeString(value);
83
103
  }
84
104
  if (typeof value === 'number') {
85
- return new Date(value * 1000).toISOString().replace('T', ' ').slice(0, 19);
105
+ return toLocalDateTimeString(new Date(value * 1000));
86
106
  }
87
107
  if (typeof value === 'string') {
88
- // ISO format: 2024-01-15T10:30:00.000Z
108
+ // ISO format: 2024-01-15T10:30:00.000Z(带时区标记,按字面取日期+时间)
89
109
  const isoMatch = value.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/);
90
110
  if (isoMatch) {
91
111
  return isoMatch[1] + ' ' + isoMatch[2];
92
112
  }
93
- // MySQL format: YYYY-MM-DD HH:MM:SS
113
+ // MySQL format: YYYY-MM-DD HH:MM:SS —— 已是目标格式,原样返回
94
114
  const myMatch = value.match(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})$/);
95
- if (myMatch) {
96
- const d = new Date(value.replace(' ', 'T') + 'Z');
97
- if (!isNaN(d.getTime())) return value;
98
- }
115
+ if (myMatch) return value;
99
116
  const d = new Date(value);
100
117
  if (!isNaN(d.getTime())) {
101
- return d.toISOString().replace('T', ' ').slice(0, 19);
118
+ return toLocalDateTimeString(d);
102
119
  }
103
120
  }
104
121
  throw createError('ER_INVALID_DATE', fieldName, String(value));
package/lib/sql.js CHANGED
@@ -1969,6 +1969,13 @@ class Parser {
1969
1969
  this.expect('op', ')');
1970
1970
  return this.parseOptionalOver({ type: 'func', name, args });
1971
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
+ }
1972
1979
  return { type: 'column', name: t.value };
1973
1980
  }
1974
1981
  if (t.type === 'number' || t.type === 'string') return { type: 'value', value: t.value };
@@ -2226,13 +2233,17 @@ class Parser {
2226
2233
 
2227
2234
  // ILIKE 刻意不加进 KEYWORDS(避免影响同名标识符),用 isWord 按上下文匹配
2228
2235
  if ((t2.type === 'keyword' && t2.value === 'LIKE') || this.isWord('ILIKE')) {
2229
- const isILike = !(t2.type === 'keyword' && t2.value === 'LIKE');
2230
2236
  this.next();
2237
+ // 可选 BINARY 关键字:LIKE BINARY '...' 才区分大小写
2238
+ let binary = false;
2239
+ if (this.isWord('BINARY')) { this.next(); binary = true; }
2231
2240
  const pattern = this.parseValue();
2232
2241
  // 可选 ESCAPE 子句:LIKE '...' ESCAPE '!'
2233
2242
  let escape = null;
2234
2243
  if (this.isWord('ESCAPE')) { this.next(); escape = this.parseValue(); }
2235
- 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 };
2236
2247
  }
2237
2248
 
2238
2249
  // PG 正则操作符(双目位置):~(大小写敏感)/ ~*(不敏感)/ !~ / !~*
@@ -2416,6 +2427,12 @@ function resolveOperand(operand, row, ctx) {
2416
2427
  case 'like':
2417
2428
  case 'between':
2418
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
+ }
2419
2436
  case 'param': {
2420
2437
  // 原生 ? 占位符。executeSQL 默认会先用 applyParams 内联替换,
2421
2438
  // 只有在显式传入 ctx.params 时才走这条路径。
@@ -2512,6 +2529,149 @@ function fmtSqlDate(dateOnly, d) {
2512
2529
  return dateOnly ? ymd : ymd + ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
2513
2530
  }
2514
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
+
2515
2675
  function applyScalarFunction(fnNode, row, ctx) {
2516
2676
  const name = (fnNode.name || '').toUpperCase();
2517
2677
  const rawArgs = fnNode.args || [];
@@ -2610,10 +2770,194 @@ function applyScalarFunction(fnNode, row, ctx) {
2610
2770
  }
2611
2771
  return fmtSqlDate(isDateOnly, d);
2612
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
+ }
2613
2945
  case 'LTRIM': return args[0] == null ? null : String(args[0]).replace(/^\s+/, '');
2614
2946
  case 'RTRIM': return args[0] == null ? null : String(args[0]).replace(/\s+$/, '');
2615
2947
  case 'ABS': return args[0] == null ? null : Math.abs(args[0]);
2616
- case 'ROUND': return args[0] == null ? null : (args[1] !== undefined ? Number(args[0].toFixed(args[1])) : Math.round(args[0]));
2948
+ case 'ROUND': {
2949
+ // MySQL 语义:四舍五入「半远离零」(Math.round 是半向 +∞,对负数会错,如 -3.5→-3)
2950
+ // 位数 D 可为负(向小数点左侧舍入);非数值/非法输入返回 null,绝不抛 TypeError/RangeError。
2951
+ if (args[0] == null) return null;
2952
+ const x = Number(args[0]);
2953
+ if (!Number.isFinite(x)) return null;
2954
+ const digits = args[1] === undefined ? 0 : (Math.trunc(Number(args[1])) || 0);
2955
+ const factor = Math.pow(10, digits);
2956
+ if (!Number.isFinite(factor)) return x; // 位数过大:值不变
2957
+ if (factor === 0) return 0; // 位数过小:舍入到 0
2958
+ const sign = x < 0 ? -1 : 1;
2959
+ return sign * Math.round(Math.abs(x) * factor) / factor;
2960
+ }
2617
2961
  case 'FLOOR': return args[0] == null ? null : Math.floor(args[0]);
2618
2962
  case 'CEIL': case 'CEILING': return args[0] == null ? null : Math.ceil(args[0]);
2619
2963
  case 'MOD': return (args[0] == null || args[1] === 0) ? null : args[0] % args[1];
@@ -2817,7 +3161,11 @@ function evaluateExpr(expr, row, ctx) {
2817
3161
  case 'and': return evaluateExpr(expr.left, row, ctx) && evaluateExpr(expr.right, row, ctx);
2818
3162
  case 'or': return evaluateExpr(expr.left, row, ctx) || evaluateExpr(expr.right, row, ctx);
2819
3163
  case 'xor': return Boolean(evaluateExpr(expr.left, row, ctx)) !== Boolean(evaluateExpr(expr.right, row, ctx));
2820
- case 'not': return !evaluateExpr(expr.expr, row, ctx);
3164
+ case 'not': {
3165
+ // 三值逻辑:NOT UNKNOWN = UNKNOWN(返回 null,在 WHERE/ON/HAVING 中视为不选中)
3166
+ const v = evaluateExpr(expr.expr, row, ctx);
3167
+ return v === null ? null : !v;
3168
+ }
2821
3169
  case 'compare': {
2822
3170
  const l = resolveOperand(expr.left, row, ctx);
2823
3171
  const r = resolveOperand(expr.right, row, ctx);
@@ -2828,13 +3176,23 @@ function evaluateExpr(expr, row, ctx) {
2828
3176
  if (ln || rn) return ln && rn;
2829
3177
  return l === r || String(l) === String(r);
2830
3178
  }
2831
- // SQL 标准:任何与 NULL 的比较结果为 UNKNOWN(在 WHERE/ON/HAVING 中视为 false)
2832
- if (l === null || r === null) return false;
3179
+ // SQL 标准:与 NULL 的比较结果是 UNKNOWN,这里用 null 表示。
3180
+ // 不能返回 false —— 否则 NOT (a > 1) 会把 a 为 NULL 的行错误纳入结果。
3181
+ if (l === null || l === undefined || r === null || r === undefined) return null;
2833
3182
  // 布尔与数值互通(MySQL: 1 = TRUE、0 = FALSE)
2834
3183
  const nl = normalizeCmpValue(l, r);
2835
3184
  const nr = normalizeCmpValue(r, l);
2836
- if (expr.op === '=') return nl === nr || String(nl) === String(nr);
3185
+ const isNum = (v) => typeof v === 'number';
3186
+ const isNumStr = (v) => typeof v === 'string' && String(v).trim() !== '' && Number.isFinite(Number(v));
3187
+ // 数值 vs 数值字符串(age < '10'、age = '09')必须按数值比,
3188
+ // 否则退化成字典序:'9' < '10' 为 false、'9' = '09' 为 false
3189
+ const mixedNum = (isNum(nl) && isNumStr(nr)) || (isNumStr(nl) && isNum(nr));
3190
+ if (expr.op === '=') {
3191
+ if (mixedNum) return Number(nl) === Number(nr);
3192
+ return nl === nr || String(nl) === String(nr);
3193
+ }
2837
3194
  const fn = OPERATORS[expr.op];
3195
+ if (mixedNum) return fn(Number(nl), Number(nr));
2838
3196
  return typeof nl === 'number' && typeof nr === 'number' ? fn(nl, nr) : fn(String(nl), String(nr));
2839
3197
  }
2840
3198
  case 'isNull': {
@@ -2851,7 +3209,9 @@ function evaluateExpr(expr, row, ctx) {
2851
3209
  }
2852
3210
  case 'like': {
2853
3211
  const v = resolveOperand(expr.operand, row, ctx);
2854
- const r = likeMatch(v, expr.pattern, !!expr.ci, expr.escape);
3212
+ // MySQL 的 LIKE 默认不区分大小写;只有显式 BINARY / ci=false 才敏感。
3213
+ // 此前写成 !!expr.ci,把 likeMatch 的 ci 默认值 true 覆盖成了 false。
3214
+ const r = likeMatch(v, expr.pattern, expr.ci !== false, expr.escape);
2855
3215
  return expr.not ? !r : r;
2856
3216
  }
2857
3217
  case 'quantified':
@@ -4076,8 +4436,25 @@ class SQLExecutor {
4076
4436
  }
4077
4437
  if (fn === 'SUM') return values.reduce((s, v) => s + (typeof v === 'number' ? v : Number(v) || 0), 0);
4078
4438
  if (fn === 'AVG') return values.length ? values.reduce((s, v) => s + (typeof v === 'number' ? v : Number(v) || 0), 0) / values.length : null;
4079
- if (fn === 'MIN') return values.length ? Math.min(...values.map(v => Number(v))) : null;
4080
- if (fn === 'MAX') return values.length ? Math.max(...values.map(v => Number(v))) : null;
4439
+ if (fn === 'MIN' || fn === 'MAX') {
4440
+ const vs = values.filter(v => v !== null && v !== undefined);
4441
+ if (!vs.length) return null;
4442
+ // 数值按数值比;字符串/日期按字典序/时间序。
4443
+ // 此前一律 Number(v) —— 字符串列会得到 NaN,MIN(name) 直接变 null。
4444
+ const numeric = (v) => typeof v === 'number' ||
4445
+ (typeof v === 'string' && String(v).trim() !== '' && Number.isFinite(Number(v)));
4446
+ if (vs.every(numeric)) {
4447
+ const nums = vs.map(v => Number(v));
4448
+ return fn === 'MIN' ? Math.min(...nums) : Math.max(...nums);
4449
+ }
4450
+ let best = vs[0];
4451
+ for (const v of vs.slice(1)) {
4452
+ const a = String(v);
4453
+ const b = String(best);
4454
+ if (fn === 'MIN' ? a < b : a > b) best = v;
4455
+ }
4456
+ return best;
4457
+ }
4081
4458
  if (fn === 'FIRST') return values.length ? values[0] : null;
4082
4459
  if (fn === 'LAST') return values.length ? values[values.length - 1] : null;
4083
4460
  if (fn === 'GROUP_CONCAT') {
@@ -4216,6 +4593,15 @@ class SQLExecutor {
4216
4593
  if (!sample || typeof sample !== 'object') return;
4217
4594
  const known = new Set();
4218
4595
  for (const k of Object.keys(sample)) known.add(String(k).toLowerCase());
4596
+ // 相关子查询:外层行的列也算已知。
4597
+ // _evalCorrelated 会把外层行挂到 ctx.__outer,子查询(`WHERE EXISTS (SELECT ... WHERE b.av = a.v)`)
4598
+ // 里的 `a.v` 属于外层作用域,不在本层 rows 里,不纳入就会误报 no such column。
4599
+ const outer = this.ctx && this.ctx.__outer;
4600
+ if (outer && typeof outer === 'object') {
4601
+ for (const k of Object.keys(outer)) known.add(String(k).toLowerCase());
4602
+ }
4603
+ // 本层 FROM 的表名/别名,用于判断限定引用是否指向外层
4604
+ const localAliases = this._outerAliases(statement);
4219
4605
  // HAVING / ORDER BY 允许引用 SELECT 的输出别名(如 HAVING cnt >= 1),
4220
4606
  // 这些名字不在表里,必须一并视为已知,否则会误报 no such column
4221
4607
  if (Array.isArray(statement.columns)) {
@@ -4231,7 +4617,11 @@ class SQLExecutor {
4231
4617
  if (node.type === 'select' || node.type === 'subquery') return;
4232
4618
  if (node.type === 'column' && typeof node.name === 'string') {
4233
4619
  const n = node.name.toLowerCase();
4234
- const base = n.indexOf('.') >= 0 ? n.slice(n.indexOf('.') + 1) : n;
4620
+ const dot = n.indexOf('.');
4621
+ const base = dot >= 0 ? n.slice(dot + 1) : n;
4622
+ // 限定列引用了本层 FROM 之外的表/别名 → 属于外层作用域(相关子查询),放行。
4623
+ // 否则 `WHERE EXISTS (SELECT 1 FROM b WHERE b.av = a.v)` 里的 a.v 会被误杀。
4624
+ if (dot >= 0 && !localAliases.has(n.slice(0, dot))) return;
4235
4625
  if (!known.has(n) && !known.has(base)) {
4236
4626
  throw new Error(`no such column: ${node.name}`);
4237
4627
  }
@@ -4719,6 +5109,14 @@ class SQLExecutor {
4719
5109
  });
4720
5110
  }
4721
5111
 
5112
+ // 无 GROUP BY 时整张表就是一个分组。必须先给每行挂上 _group=全表,
5113
+ // 否则 HAVING 里的 COUNT(*) 会被逐行算成 1(r._group || [r]),
5114
+ // 于是 HAVING COUNT(*) > 2 会把所有行滤掉、最终输出 0 而不是真实计数。
5115
+ if (statement.having && !statement.groupBy) {
5116
+ const wholeTable = rows.slice();
5117
+ rows = rows.map((r) => ({ ...r, _group: wholeTable }));
5118
+ }
5119
+
4722
5120
  if (statement.having) {
4723
5121
  const havingAst = JSON.parse(JSON.stringify(statement.having));
4724
5122
  rows = rows.filter(r => {
@@ -4767,6 +5165,11 @@ class SQLExecutor {
4767
5165
  // 注意:带 OVER 的聚合是窗口函数,不是“整表塌缩成一行”的聚合,必须排除
4768
5166
  if (!statement.groupBy && (statement.columns.some(c => c.aggregate && !c.window) || hasCustomAgg)) {
4769
5167
  const cols = statement.columns.map(c => scalarColumnName(c));
5168
+ const tableName2 = statement.from ? (statement.from.tables[0].table || null) : null;
5169
+ // HAVING 判定不通过时 rows 已为空,此时整表聚合也应为空,不能无条件补一行
5170
+ if (statement.having && rows.length === 0) {
5171
+ return { ok: true, type: 'select', table: tableName2, columns: cols, rows: [], aggregate: statement.aggregate };
5172
+ }
4770
5173
  const valueOf = (c) => {
4771
5174
  if (c.aggregate) return this._aggValue(rows, c.aggregate || 'COUNT', c.column, c.distinct, c.separator, c.orderBy);
4772
5175
  if (c.scalar && c.scalar.type === 'func' && hasCustomAgg) {
@@ -4780,7 +5183,7 @@ class SQLExecutor {
4780
5183
  if (c.expr !== null && c.expr !== '*') return rows[0] ? resolveOperand({ type: 'column', name: c.expr }, rows[0]) : null;
4781
5184
  return null;
4782
5185
  };
4783
- 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 };
5186
+ return { ok: true, type: 'select', table: tableName2, columns: cols, rows: [[...statement.columns.map(valueOf)]], aggregate: statement.aggregate };
4784
5187
  }
4785
5188
 
4786
5189
  // GROUP BY 输出:每组的列(含聚合列)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsql-neo",
3
- "version": "6.2.0",
3
+ "version": "6.3.1",
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",
package/postinstall.js CHANGED
@@ -15,25 +15,36 @@ const BP = path.join(ROOT, 'bin', BIN_NAME);
15
15
  const NATIVE_PATH = path.join(ROOT, 'native', 'jsql-neo-native.node');
16
16
 
17
17
  // GitHub 加速代理前缀;加速站的用法是把原始 URL 直接拼在后面:
18
- // https://<host>/https://github.com/owner/repo/releases/download/vX.Y.Z/asset?token=<TOKEN>
19
- // 可用环境变量覆盖 / 叠加(逗号分隔多个,按序尝试);空字符串 '' 表示直连兜底。
18
+ // https://<host>/https://github.com/owner/repo/releases/download/vX.Y.Z/asset
19
+ // 鉴权走 `Authorization: Bearer <token>` 请求头(Worker 从该头读取,不读 ?token=)。
20
+ // 前缀可用 JSQL_GHPROXY 覆盖(逗号分隔多个,按序回退;'' 表示直连兜底);
21
+ // token 可用 JSQL_GHPROXY_TOKEN 覆盖。
20
22
  // 加速站候选(按优先级):自建站优先,公共站回退,最后直连兜底。
21
23
  const DEFAULT_PROXIES = [
22
24
  'https://gh-proxyjsql-neoworkerapi.vexify.de5.net/',
23
25
  'https://gh-proxy.com/',
24
26
  ];
27
+ // 内置 token:让所有安装者都自动走自建加速站,无需配置环境变量。
28
+ // 加速站自身已限制只能代理 /releases/download/ 等文件下载路径,故该值公开无风险。
29
+ const DEFAULT_GHPROXY_TOKEN =
30
+ 'NkUbD165V_1iPvEeGRcAfNEv4cqv5WRLn1NfjgBzIMpo9gq5CBH10Rl5ZPmt7RGCh3kyaZU4Nc6pLhHHbvzc9w';
25
31
  const GHPROXY = process.env.JSQL_GHPROXY !== undefined
26
32
  ? process.env.JSQL_GHPROXY
27
33
  : DEFAULT_PROXIES[0];
28
- const GHPROXY_TOKEN = process.env.JSQL_GHPROXY_TOKEN || '';
34
+ const GHPROXY_TOKEN = process.env.JSQL_GHPROXY_TOKEN || DEFAULT_GHPROXY_TOKEN;
29
35
  const TAG = `v${pkg.version}`;
30
36
 
37
+ /** 加速站鉴权头 */
38
+ function proxyHeaders() {
39
+ return GHPROXY_TOKEN ? { Authorization: 'Bearer ' + GHPROXY_TOKEN } : undefined;
40
+ }
41
+
31
42
  /**
32
43
  * 生成候选下载地址列表(按优先级):
33
- * 环境变量 JSQL_GHPROXY 指定的代理 → 内置加速站 → 直连
44
+ * JSQL_GHPROXY 指定的代理 → 内置加速站 → 直连
34
45
  */
35
46
  function proxyCandidates(raw) {
36
- const bases = String(process.env.JSQL_GHPROXY || '')
47
+ const bases = String(GHPROXY || '')
37
48
  .split(',')
38
49
  .map((s) => s.trim())
39
50
  .filter(Boolean)
@@ -41,12 +52,9 @@ function proxyCandidates(raw) {
41
52
  .concat('');
42
53
  const list = [];
43
54
  for (const base of bases) {
44
- let url = raw;
45
- if (base) url = (base.endsWith('/') ? base : base + '/') + raw;
46
- if (base && GHPROXY_TOKEN) {
47
- url += (url.indexOf('?') === -1 ? '?' : '&') + 'token=' + encodeURIComponent(GHPROXY_TOKEN);
48
- }
49
- if (list.indexOf(url) === -1) list.push(url);
55
+ const url = base ? (base.endsWith('/') ? base : base + '/') + raw : raw;
56
+ if (list.some((x) => x.url === url)) continue;
57
+ list.push({ url, viaProxy: !!base });
50
58
  }
51
59
  return list;
52
60
  }
@@ -65,13 +73,13 @@ function nativeTargetSuffix(platform = process.platform, arch = process.arch) {
65
73
  return `${p}-${a}-msvc`;
66
74
  }
67
75
 
68
- function download(url, dest) {
76
+ function download(url, dest, headers) {
69
77
  return new Promise((resolve, reject) => {
70
78
  const mod = url.startsWith('https:') ? https : http;
71
- const req = mod.get(url, (res) => {
79
+ const req = mod.get(url, { headers: headers || {} }, (res) => {
72
80
  if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
73
81
  res.resume();
74
- download(res.headers.location, dest).then(resolve, reject);
82
+ download(res.headers.location, dest, headers).then(resolve, reject);
75
83
  return;
76
84
  }
77
85
  if (res.statusCode !== 200) {
@@ -130,10 +138,11 @@ async function downloadAndVerify() {
130
138
  const asset = `jsql-neo-native.${nativeTargetSuffix()}.node`;
131
139
  const raw = `https://github.com/${REPO}/releases/download/${TAG}/${asset}`;
132
140
  // 依次尝试:环境变量指定的代理 → 内置加速站 → 直连
133
- for (const url of proxyCandidates(raw)) {
134
- console.log(`[jsql-neo] downloading native module: ${url}`);
141
+ for (const cand of proxyCandidates(raw)) {
142
+ console.log(`[jsql-neo] downloading native module: ${cand.url}`);
135
143
  try {
136
- await download(url, NATIVE_PATH);
144
+ // 加速站鉴权走 Authorization 头,直连时不带
145
+ await download(cand.url, NATIVE_PATH, cand.viaProxy ? proxyHeaders() : undefined);
137
146
  if (tryLoadNative()) {
138
147
  console.log('[jsql-neo] native module downloaded: native/jsql-neo-native.node');
139
148
  return true;
@@ -58,6 +58,15 @@ function validate(bt) {
58
58
  ok('non-unique remove value keeps key until last', n.search('a').length === 0 && n.size === 1);
59
59
  }
60
60
 
61
+ /* ---- _route() 对叶子节点不再崩溃(单键根节点) ---- */
62
+ {
63
+ const bt = new BTree();
64
+ bt.insert(5, 0);
65
+ let threw = false;
66
+ try { bt._route(bt._root, 5); } catch (e) { threw = true; }
67
+ ok('_route() on leaf root no crash', !threw && bt._route(bt._root, 5) === 0);
68
+ }
69
+
61
70
  /* ---- 二分查找 / IN / 前缀 ---- */
62
71
  {
63
72
  const bt = new BTree(16);
@@ -202,6 +202,48 @@ async function main() {
202
202
  ok("nm ~* '^a' 不敏感", JSON.stringify(await rowsOf("SELECT id FROM tbl WHERE nm ~* '^a'")) === '[[1]]');
203
203
  ok("nm !~ '^A' 取反", JSON.stringify(await rowsOf("SELECT id FROM tbl WHERE nm !~ '^A'")) === '[[2],[3]]');
204
204
  ok('REGEXP 仍可用', JSON.stringify(await rowsOf("SELECT id FROM tbl WHERE nm REGEXP '^A'")) === '[[1]]');
205
+ // —— 日期/时间函数(README 承诺但此前大量缺失、静默返回 null)——
206
+ ok('YEAR()', JSON.stringify(await rowsOf("SELECT YEAR('2024-03-15')")) === '[[2024]]');
207
+ ok('MONTH()', JSON.stringify(await rowsOf("SELECT MONTH('2024-03-15')")) === '[[3]]');
208
+ ok('DAY()', JSON.stringify(await rowsOf("SELECT DAY('2024-03-15')")) === '[[15]]');
209
+ ok('HOUR()', JSON.stringify(await rowsOf("SELECT HOUR('2024-03-15 10:20:30')")) === '[[10]]');
210
+ ok('MINUTE()', JSON.stringify(await rowsOf("SELECT MINUTE('2024-03-15 10:20:30')")) === '[[20]]');
211
+ ok('SECOND()', JSON.stringify(await rowsOf("SELECT SECOND('2024-03-15 10:20:30')")) === '[[30]]');
212
+ ok('QUARTER()', JSON.stringify(await rowsOf("SELECT QUARTER('2024-08-15')")) === '[[3]]');
213
+ ok('DAYOFWEEK()', JSON.stringify(await rowsOf("SELECT DAYOFWEEK('2024-03-15')")) === '[[6]]');
214
+ ok('DATE()', JSON.stringify(await rowsOf("SELECT DATE('2024-03-15 10:20:30')")) === '[["2024-03-15"]]');
215
+ ok('TIME()', JSON.stringify(await rowsOf("SELECT TIME('2024-03-15 10:20:30')")) === '[["10:20:30"]]');
216
+ ok('DATEDIFF', JSON.stringify(await rowsOf("SELECT DATEDIFF('2026-08-12','2026-08-01')")) === '[[11]]');
217
+ ok('TIMESTAMPDIFF 裸 unit',
218
+ JSON.stringify(await rowsOf("SELECT TIMESTAMPDIFF(MONTH,'2024-01-01','2024-03-01')")) === '[[2]]');
219
+ ok('DATE_FORMAT',
220
+ JSON.stringify(await rowsOf("SELECT DATE_FORMAT('2026-08-12 10:30:00','%Y-%m-%d %H:%i')")) === '[["2026-08-12 10:30"]]');
221
+ ok('STR_TO_DATE',
222
+ JSON.stringify(await rowsOf("SELECT STR_TO_DATE('2024-03-15','%Y-%m-%d')")) === '[["2024-03-15"]]');
223
+ ok('TO_DATE(PG 格式)',
224
+ JSON.stringify(await rowsOf("SELECT TO_DATE('2024-03-15','YYYY-MM-DD')")) === '[["2024-03-15"]]');
225
+ ok('TO_CHAR(PG 格式)',
226
+ JSON.stringify(await rowsOf("SELECT TO_CHAR('2024-03-15','YYYY-MM-DD')")) === '[["2024-03-15"]]');
227
+ ok('DATE_PART', JSON.stringify(await rowsOf("SELECT DATE_PART('year','2024-03-15')")) === '[[2024]]');
228
+ ok('AGE', JSON.stringify(await rowsOf("SELECT AGE('2026-08-12','2020-01-01')")) === '[["6 years 7 mons 11 days"]]');
229
+ ok('LAST_DAY 闰年', JSON.stringify(await rowsOf("SELECT LAST_DAY('2024-02-05')")) === '[["2024-02-29"]]');
230
+ ok('DAYNAME', JSON.stringify(await rowsOf("SELECT DAYNAME('2024-03-15')")) === '[["Friday"]]');
231
+ ok('MONTHNAME', JSON.stringify(await rowsOf("SELECT MONTHNAME('2024-03-15')")) === '[["March"]]');
232
+ ok('PERIOD_DIFF', JSON.stringify(await rowsOf('SELECT PERIOD_DIFF(202403,202401)')) === '[[2]]');
233
+ ok('DATE_TRUNC',
234
+ JSON.stringify(await rowsOf("SELECT DATE_TRUNC('month','2024-03-15 10:20:30')")) === '[["2024-03-01 00:00:00"]]');
235
+ ok('WEEK()', JSON.stringify(await rowsOf("SELECT WEEK('2024-01-14')")) === '[[2]]');
236
+
237
+ // —— 数值比较 / 聚合 / LIKE:曾"不报错但结果错"——
238
+ ok('数值列 vs 数值字符串 <', JSON.stringify(await rowsOf("SELECT id FROM tbl WHERE n < '9'")) === '[]');
239
+ ok('数值列 vs 数值字符串 =', JSON.stringify(await rowsOf("SELECT id FROM tbl WHERE n = '10'")) === '[[1]]');
240
+ ok('HAVING 无 GROUP BY', JSON.stringify(await rowsOf('SELECT COUNT(*) AS c FROM tbl HAVING COUNT(*) > 2')) === '[[3]]');
241
+ ok('HAVING 不成立应为空', JSON.stringify(await rowsOf('SELECT COUNT(*) AS c FROM tbl HAVING COUNT(*) > 100')) === '[]');
242
+ ok('MIN 字符串列', JSON.stringify(await rowsOf('SELECT MIN(nm) FROM tbl')) === '[["Alice"]]');
243
+ ok('MAX 字符串列', JSON.stringify(await rowsOf('SELECT MAX(nm) FROM tbl')) === '[["Cara"]]');
244
+ ok('LIKE 默认不区分大小写', JSON.stringify(await rowsOf("SELECT id FROM tbl WHERE nm LIKE 'alice'")) === '[[1]]');
245
+ ok('LIKE BINARY 区分大小写', JSON.stringify(await rowsOf("SELECT id FROM tbl WHERE nm LIKE BINARY 'alice'")) === '[]');
246
+
205
247
  // —— 6.2.0 回归项(这些曾不报错但结果错)——
206
248
  ok('NOT IN 取反', JSON.stringify(await rowsOf('SELECT id FROM tbl WHERE id NOT IN (1,2)')) === '[[3]]');
207
249
  const dStar = await rowsOf('SELECT DISTINCT * FROM tbl');
@@ -427,6 +469,44 @@ async function main() {
427
469
  }
428
470
  }
429
471
 
472
+ console.log('\n--- NULL 三值逻辑 / 相关子查询 / DATE 列时区 ---');
473
+ {
474
+ let JSQL, db;
475
+ try {
476
+ ({ JSQL } = require('../lib/native_client.js'));
477
+ db = new JSQL();
478
+ await db.start();
479
+ } catch (e) {
480
+ console.log('[SKIP] native 不可用:' + e.message.slice(0, 60));
481
+ }
482
+ if (db) {
483
+ const run = (sql) => executeSQL(db, sql, OPTS);
484
+ // NOT 作用于含 NULL 的比较:UNKNOWN 取反仍是 UNKNOWN,该行不应入选
485
+ await run('CREATE TABLE nl (id INT PRIMARY KEY, a INT)');
486
+ await run('INSERT INTO nl (id,a) VALUES (1,1),(2,2),(3,NULL)');
487
+ ok('NOT (a > 1) 排除 NULL 行', JSON.stringify((await run('SELECT id FROM nl WHERE NOT (a > 1)')).rows) === '[[1]]');
488
+
489
+ // 相关子查询引用外层别名
490
+ await run('CREATE TABLE ta (id INT PRIMARY KEY, v INT)');
491
+ await run('CREATE TABLE tb (id INT PRIMARY KEY, av INT)');
492
+ await run('INSERT INTO ta (id,v) VALUES (1,10),(2,20)');
493
+ await run('INSERT INTO tb (id,av) VALUES (1,10)');
494
+ ok('EXISTS 相关子查询',
495
+ JSON.stringify((await run('SELECT id FROM ta WHERE EXISTS (SELECT 1 FROM tb WHERE tb.av = ta.v)')).rows) === '[[1]]');
496
+ ok('NOT EXISTS 相关子查询',
497
+ JSON.stringify((await run('SELECT id FROM ta WHERE NOT EXISTS (SELECT 1 FROM tb WHERE tb.av = ta.v)')).rows) === '[[2]]');
498
+
499
+ // DATE 列序列化:不能用 toISOString(UTC),否则东八区差一天
500
+ const { validateDateType } = require('../lib/date-types.js');
501
+ ok('DATE 字符串不回退一天', validateDateType('2023-01-01', 'date', 'f') === '2023-01-01');
502
+ ok('DATE 闰年边界(2024-02-29)', validateDateType('2024-02-29', 'date', 'f') === '2024-02-29');
503
+ ok('DATE 对象按本地时区', validateDateType(new Date(2023, 0, 1), 'date', 'f') === '2023-01-01');
504
+ ok('DATETIME 对象按本地时区',
505
+ validateDateType(new Date(2023, 0, 1, 8, 30), 'datetime', 'f') === '2023-01-01 08:30:00');
506
+ await db.stop();
507
+ }
508
+ }
509
+
430
510
  console.log('\n--- Redis:RESP2 帧解析(曾 inline 命令恒为 unknown command)---');
431
511
  {
432
512
  const { RedisServer } = require('../lib/redis_server.js');
@@ -268,6 +268,20 @@ const rowsOf = (r) => (Array.isArray(r) ? r[0] : r).rows;
268
268
  ok('未绑定参数不应在解析期报错', threw === null);
269
269
  }
270
270
 
271
+ /* ================= BUG 修复: ROUND 非数值/负数位数不再崩溃 ================= */
272
+ console.log('\n--- BUG 修复: ROUND MySQL 语义 ---');
273
+ {
274
+ const roundOf = async (expr) => rowsOf(await executeSQL(engine, `SELECT ${expr} AS v`, OPTS))[0][0];
275
+ ok("ROUND('abc', 0) 返回 null 而非 TypeError", await roundOf("ROUND('abc', 0)") === null);
276
+ ok("ROUND('abc') 返回 null", await roundOf("ROUND('abc')") === null);
277
+ ok('ROUND(3.14159, 2) = 3.14', await roundOf('ROUND(3.14159, 2)') === 3.14);
278
+ ok('ROUND(3.5) = 4', await roundOf('ROUND(3.5)') === 4);
279
+ ok('ROUND(-3.5) = -4(半远离零,与 MySQL 一致)', await roundOf('ROUND(-3.5)') === -4);
280
+ ok('ROUND(-2.5) = -3', await roundOf('ROUND(-2.5)') === -3);
281
+ ok('ROUND(123.456, -1) = 120(负数位数)', await roundOf('ROUND(123.456, -1)') === 120);
282
+ ok('ROUND(1.5, 101) 不再抛 RangeError', await roundOf('ROUND(1.5, 101)') === 1.5);
283
+ }
284
+
271
285
  /* ================= 5.6.0: 相关子查询(曾静默返回 null) ================= */
272
286
  console.log('\n--- 5.6.0: 相关子查询 ---');
273
287
  {
@@ -616,3 +616,76 @@ let wasm;
616
616
  export function __wbg_set_wasm(val) {
617
617
  wasm = val;
618
618
  }
619
+
620
+ // ============================================================
621
+ // WASM 初始化(原构建产物缺失这一段,导致 browser.mjs 无法加载:
622
+ // 它 `import init from './browser_bg.mjs'`,但本文件没有 default export)
623
+ // ============================================================
624
+
625
+ function __wbg_get_imports() {
626
+ const import0 = {
627
+ __proto__: null,
628
+ __wbg___wbindgen_string_get_b0ca35b86a603356: function(arg0, arg1) {
629
+ const obj = getObject(arg1);
630
+ const ret = typeof(obj) === 'string' ? obj : undefined;
631
+ var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_export, wasm.__wbindgen_export2);
632
+ var len1 = WASM_VECTOR_LEN;
633
+ getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
634
+ getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
635
+ },
636
+ __wbg___wbindgen_throw_344f42d3211c4765: function(arg0, arg1) {
637
+ throw new Error(getStringFromWasm0(arg0, arg1));
638
+ },
639
+ __wbg_new_cc984128914cfc6f: function(arg0) {
640
+ const ret = new Date(getObject(arg0));
641
+ return addHeapObject(ret);
642
+ },
643
+ __wbg_now_86c0d4ba3fa605b8: function() {
644
+ const ret = Date.now();
645
+ return ret;
646
+ },
647
+ __wbg_toISOString_706fbe321055ee58: function(arg0) {
648
+ const ret = getObject(arg0).toISOString();
649
+ return addHeapObject(ret);
650
+ },
651
+ __wbindgen_cast_0000000000000001: function(arg0) {
652
+ // Cast intrinsic for `F64 -> Externref`.
653
+ const ret = arg0;
654
+ return addHeapObject(ret);
655
+ },
656
+ __wbindgen_object_drop_ref: function(arg0) {
657
+ takeObject(arg0);
658
+ },
659
+ };
660
+ return { './jsql_neo_wasm_bg.js': import0 };
661
+ }
662
+
663
+ /**
664
+ * 初始化 WASM 模块(default export,供 browser.mjs 的 `await init()` 调用)。
665
+ * @param {string|URL|Request|Response|ArrayBuffer|WebAssembly.Module} [input]
666
+ * 默认加载同目录的 jsql_neo_wasm_bg.wasm
667
+ */
668
+ async function __wbg_init(input) {
669
+ if (wasm !== undefined) return wasm;
670
+ const imports = __wbg_get_imports();
671
+ const path = input === undefined
672
+ ? new URL('./jsql_neo_wasm_bg.wasm', import.meta.url)
673
+ : input;
674
+
675
+ let instance;
676
+ if (path instanceof WebAssembly.Module) {
677
+ instance = (await WebAssembly.instantiate(path, imports)).instance;
678
+ } else if (typeof Response !== 'undefined' && path instanceof Response) {
679
+ instance = (await WebAssembly.instantiate(await path.arrayBuffer(), imports)).instance;
680
+ } else if (path instanceof ArrayBuffer || ArrayBuffer.isView(path)) {
681
+ instance = (await WebAssembly.instantiate(path, imports)).instance;
682
+ } else {
683
+ // 用 instantiate 而非 instantiateStreaming:某些 CDN 不返回 application/wasm MIME
684
+ const bytes = await (await fetch(path)).arrayBuffer();
685
+ instance = (await WebAssembly.instantiate(bytes, imports)).instance;
686
+ }
687
+ __wbg_set_wasm(instance.exports);
688
+ return instance.exports;
689
+ }
690
+
691
+ export default __wbg_init;