jsql-neo 6.2.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/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/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,6 +2770,178 @@ 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]);
@@ -2817,7 +3149,11 @@ function evaluateExpr(expr, row, ctx) {
2817
3149
  case 'and': return evaluateExpr(expr.left, row, ctx) && evaluateExpr(expr.right, row, ctx);
2818
3150
  case 'or': return evaluateExpr(expr.left, row, ctx) || evaluateExpr(expr.right, row, ctx);
2819
3151
  case 'xor': return Boolean(evaluateExpr(expr.left, row, ctx)) !== Boolean(evaluateExpr(expr.right, row, ctx));
2820
- 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
+ }
2821
3157
  case 'compare': {
2822
3158
  const l = resolveOperand(expr.left, row, ctx);
2823
3159
  const r = resolveOperand(expr.right, row, ctx);
@@ -2828,13 +3164,23 @@ function evaluateExpr(expr, row, ctx) {
2828
3164
  if (ln || rn) return ln && rn;
2829
3165
  return l === r || String(l) === String(r);
2830
3166
  }
2831
- // SQL 标准:任何与 NULL 的比较结果为 UNKNOWN(在 WHERE/ON/HAVING 中视为 false)
2832
- 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;
2833
3170
  // 布尔与数值互通(MySQL: 1 = TRUE、0 = FALSE)
2834
3171
  const nl = normalizeCmpValue(l, r);
2835
3172
  const nr = normalizeCmpValue(r, l);
2836
- 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
+ }
2837
3182
  const fn = OPERATORS[expr.op];
3183
+ if (mixedNum) return fn(Number(nl), Number(nr));
2838
3184
  return typeof nl === 'number' && typeof nr === 'number' ? fn(nl, nr) : fn(String(nl), String(nr));
2839
3185
  }
2840
3186
  case 'isNull': {
@@ -2851,7 +3197,9 @@ function evaluateExpr(expr, row, ctx) {
2851
3197
  }
2852
3198
  case 'like': {
2853
3199
  const v = resolveOperand(expr.operand, row, ctx);
2854
- 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);
2855
3203
  return expr.not ? !r : r;
2856
3204
  }
2857
3205
  case 'quantified':
@@ -4076,8 +4424,25 @@ class SQLExecutor {
4076
4424
  }
4077
4425
  if (fn === 'SUM') return values.reduce((s, v) => s + (typeof v === 'number' ? v : Number(v) || 0), 0);
4078
4426
  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;
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
+ }
4081
4446
  if (fn === 'FIRST') return values.length ? values[0] : null;
4082
4447
  if (fn === 'LAST') return values.length ? values[values.length - 1] : null;
4083
4448
  if (fn === 'GROUP_CONCAT') {
@@ -4216,6 +4581,15 @@ class SQLExecutor {
4216
4581
  if (!sample || typeof sample !== 'object') return;
4217
4582
  const known = new Set();
4218
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);
4219
4593
  // HAVING / ORDER BY 允许引用 SELECT 的输出别名(如 HAVING cnt >= 1),
4220
4594
  // 这些名字不在表里,必须一并视为已知,否则会误报 no such column
4221
4595
  if (Array.isArray(statement.columns)) {
@@ -4231,7 +4605,11 @@ class SQLExecutor {
4231
4605
  if (node.type === 'select' || node.type === 'subquery') return;
4232
4606
  if (node.type === 'column' && typeof node.name === 'string') {
4233
4607
  const n = node.name.toLowerCase();
4234
- 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;
4235
4613
  if (!known.has(n) && !known.has(base)) {
4236
4614
  throw new Error(`no such column: ${node.name}`);
4237
4615
  }
@@ -4719,6 +5097,14 @@ class SQLExecutor {
4719
5097
  });
4720
5098
  }
4721
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
+
4722
5108
  if (statement.having) {
4723
5109
  const havingAst = JSON.parse(JSON.stringify(statement.having));
4724
5110
  rows = rows.filter(r => {
@@ -4767,6 +5153,11 @@ class SQLExecutor {
4767
5153
  // 注意:带 OVER 的聚合是窗口函数,不是“整表塌缩成一行”的聚合,必须排除
4768
5154
  if (!statement.groupBy && (statement.columns.some(c => c.aggregate && !c.window) || hasCustomAgg)) {
4769
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
+ }
4770
5161
  const valueOf = (c) => {
4771
5162
  if (c.aggregate) return this._aggValue(rows, c.aggregate || 'COUNT', c.column, c.distinct, c.separator, c.orderBy);
4772
5163
  if (c.scalar && c.scalar.type === 'func' && hasCustomAgg) {
@@ -4780,7 +5171,7 @@ class SQLExecutor {
4780
5171
  if (c.expr !== null && c.expr !== '*') return rows[0] ? resolveOperand({ type: 'column', name: c.expr }, rows[0]) : null;
4781
5172
  return null;
4782
5173
  };
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 };
5174
+ return { ok: true, type: 'select', table: tableName2, columns: cols, rows: [[...statement.columns.map(valueOf)]], aggregate: statement.aggregate };
4784
5175
  }
4785
5176
 
4786
5177
  // 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.0",
4
4
  "description": "JSQL-NEO — Rust-powered embedded database with WASM, REST API, B-Tree indexes, WAL, crash recovery",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
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;
@@ -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');