tranfi 0.1.2 → 0.2.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.
Files changed (132) hide show
  1. package/LICENSE +177 -0
  2. package/NOTICE +8 -0
  3. package/README.md +272 -40
  4. package/app/assets/{index-pDFMluyz.js → index-BIAIKnrp.js} +1 -1
  5. package/app/index.html +1 -1
  6. package/binding.gyp +55 -3
  7. package/csrc/arena.c +7 -5
  8. package/csrc/batch.c +818 -71
  9. package/csrc/buffer.c +84 -8
  10. package/csrc/cJSON.c +262 -19
  11. package/csrc/cJSON.h +17 -1
  12. package/csrc/codec_csv.c +1074 -181
  13. package/csrc/codec_jsonl.c +830 -118
  14. package/csrc/codec_table.c +108 -78
  15. package/csrc/codec_text.c +286 -68
  16. package/csrc/compiler.c +31 -3
  17. package/csrc/config.h +21 -0
  18. package/csrc/dsl.c +4722 -485
  19. package/csrc/expr.c +363 -55
  20. package/csrc/expr.h +2 -0
  21. package/csrc/internal.h +316 -27
  22. package/csrc/ir.c +65 -18
  23. package/csrc/ir.h +41 -0
  24. package/csrc/ir_schema.c +20 -5
  25. package/csrc/ir_serialize.c +68 -6
  26. package/csrc/ir_sql.c +796 -185
  27. package/csrc/ir_validate.c +462 -6
  28. package/csrc/json_path.c +210 -0
  29. package/csrc/main.c +879 -30
  30. package/csrc/memory_estimate.c +477 -0
  31. package/csrc/op_acf.c +171 -21
  32. package/csrc/op_across.c +477 -0
  33. package/csrc/op_anomaly.c +167 -32
  34. package/csrc/op_assert.c +761 -0
  35. package/csrc/op_bin.c +168 -29
  36. package/csrc/op_cast.c +383 -55
  37. package/csrc/op_clip.c +30 -19
  38. package/csrc/op_date_trunc.c +208 -34
  39. package/csrc/op_datetime.c +259 -77
  40. package/csrc/op_derive.c +65 -97
  41. package/csrc/op_diff.c +146 -30
  42. package/csrc/op_ewma.c +149 -30
  43. package/csrc/op_explode.c +124 -26
  44. package/csrc/op_fill_down.c +125 -53
  45. package/csrc/op_fill_null.c +176 -31
  46. package/csrc/op_filter.c +89 -40
  47. package/csrc/op_frequency.c +571 -43
  48. package/csrc/op_grep.c +36 -18
  49. package/csrc/op_group_agg.c +1790 -119
  50. package/csrc/op_hash.c +48 -15
  51. package/csrc/op_head.c +21 -86
  52. package/csrc/op_interpolate.c +268 -62
  53. package/csrc/op_join.c +2700 -182
  54. package/csrc/op_json_extract.c +227 -0
  55. package/csrc/op_json_filter.c +384 -0
  56. package/csrc/op_json_flatten.c +293 -0
  57. package/csrc/op_json_schema.c +503 -0
  58. package/csrc/op_label_encode.c +328 -53
  59. package/csrc/op_lag.c +181 -0
  60. package/csrc/op_lead.c +141 -89
  61. package/csrc/op_normalize.c +363 -79
  62. package/csrc/op_onehot.c +345 -73
  63. package/csrc/op_pivot.c +1546 -162
  64. package/csrc/op_quarantine.c +189 -0
  65. package/csrc/op_registry.c +2062 -166
  66. package/csrc/op_rename.c +41 -50
  67. package/csrc/op_replace.c +270 -118
  68. package/csrc/op_rleid.c +297 -0
  69. package/csrc/op_rowid.c +559 -0
  70. package/csrc/op_sample.c +80 -23
  71. package/csrc/op_schema.c +1341 -0
  72. package/csrc/op_schema_infer.c +252 -0
  73. package/csrc/op_select.c +265 -65
  74. package/csrc/op_set.c +3449 -0
  75. package/csrc/op_skip.c +30 -87
  76. package/csrc/op_sort.c +670 -124
  77. package/csrc/op_source_name.c +120 -0
  78. package/csrc/op_split.c +65 -28
  79. package/csrc/op_split_data.c +41 -9
  80. package/csrc/op_stack.c +178 -222
  81. package/csrc/op_stats.c +206 -110
  82. package/csrc/op_step.c +217 -55
  83. package/csrc/op_tail.c +21 -12
  84. package/csrc/op_tee.c +338 -0
  85. package/csrc/op_top.c +260 -53
  86. package/csrc/op_trim.c +48 -19
  87. package/csrc/op_unique.c +1193 -150
  88. package/csrc/op_unpivot.c +100 -66
  89. package/csrc/op_validate.c +601 -24
  90. package/csrc/op_window.c +492 -51
  91. package/csrc/path_policy.c +85 -0
  92. package/csrc/pipeline.c +872 -99
  93. package/csrc/recipes.c +3 -1
  94. package/csrc/report.c +73 -30
  95. package/csrc/selector.c +1097 -0
  96. package/csrc/size_utils.c +348 -0
  97. package/csrc/spill.c +317 -0
  98. package/csrc/spill.h +21 -0
  99. package/csrc/tranfi.h +169 -1
  100. package/csrc/transform.h +209 -0
  101. package/csrc/transform_api.c +2237 -0
  102. package/csrc/transform_categorical.c +923 -0
  103. package/csrc/transform_internal.h +472 -0
  104. package/csrc/transform_json.c +3812 -0
  105. package/csrc/transform_numeric.c +1966 -0
  106. package/csrc/transform_sha256.c +154 -0
  107. package/csrc/transform_wasm.h +162 -0
  108. package/csrc/transform_wasm_api.c +1373 -0
  109. package/csrc/wasm_api.c +70 -9
  110. package/napi_api.c +219 -11
  111. package/napi_transform.c +1648 -0
  112. package/napi_transform.h +8 -0
  113. package/package.json +27 -11
  114. package/scripts/install-native.js +76 -0
  115. package/scripts/prepack.js +64 -0
  116. package/scripts/sync-csrc.js +23 -0
  117. package/src/cli.js +8 -11
  118. package/src/engines/duckdb.js +45 -12
  119. package/src/index.js +661 -42
  120. package/src/memory_policy.js +411 -0
  121. package/src/native.js +1 -5
  122. package/src/pipeline.js +454 -31
  123. package/src/recipe_json.js +80 -0
  124. package/src/server.js +10 -8
  125. package/src/transform.js +403 -0
  126. package/src/transform_error.js +10 -0
  127. package/src/wasm.js +6 -4
  128. package/wasm/index.js +498 -10
  129. package/wasm/tranfi_core.js +0 -0
  130. package/wasm/transform.js +1156 -0
  131. package/wasm/worker.js +786 -0
  132. package/csrc/plan.c +0 -206
package/csrc/ir_sql.c CHANGED
@@ -21,34 +21,60 @@ typedef struct {
21
21
  char *data;
22
22
  size_t len;
23
23
  size_t cap;
24
+ int failed;
24
25
  } strbuf;
25
26
 
27
+ static void sb_free(strbuf *sb);
28
+
26
29
  static void sb_init(strbuf *sb) {
27
- sb->data = malloc(256);
30
+ sb->data = tf_mallocarray_checked(256, sizeof(char));
28
31
  sb->len = 0;
29
32
  sb->cap = 256;
33
+ sb->failed = 0;
30
34
  if (sb->data) sb->data[0] = '\0';
35
+ else { sb->cap = 0; sb->failed = 1; }
31
36
  }
32
37
 
33
- static void sb_ensure(strbuf *sb, size_t extra) {
34
- if (sb->len + extra + 1 > sb->cap) {
35
- size_t newcap = sb->cap * 2;
36
- if (newcap < sb->len + extra + 1) newcap = sb->len + extra + 1;
37
- char *nd = realloc(sb->data, newcap);
38
- if (nd) { sb->data = nd; sb->cap = newcap; }
38
+ static int sb_ensure(strbuf *sb, size_t extra) {
39
+ if (!sb || sb->failed) return TF_ERROR;
40
+ size_t need = 0;
41
+ if (tf_size_add(sb->len, extra, &need) != TF_OK ||
42
+ tf_size_add(need, 1, &need) != TF_OK) {
43
+ sb->failed = 1;
44
+ return TF_ERROR;
45
+ }
46
+ if (need <= sb->cap) return TF_OK;
47
+ size_t newcap = 0;
48
+ if (tf_size_grow_pow2(sb->cap, need, 256, &newcap) != TF_OK) {
49
+ sb->failed = 1;
50
+ return TF_ERROR;
39
51
  }
52
+ char *nd = tf_reallocarray_checked(sb->data, newcap, sizeof(char));
53
+ if (!nd) {
54
+ sb->failed = 1;
55
+ return TF_ERROR;
56
+ }
57
+ sb->data = nd;
58
+ sb->cap = newcap;
59
+ return TF_OK;
40
60
  }
41
61
 
42
62
  static void sb_append(strbuf *sb, const char *s) {
63
+ if (!sb || sb->failed || !s) return;
43
64
  size_t n = strlen(s);
44
- sb_ensure(sb, n);
65
+ if (sb_ensure(sb, n) != TF_OK) return;
45
66
  memcpy(sb->data + sb->len, s, n);
46
67
  sb->len += n;
47
68
  sb->data[sb->len] = '\0';
48
69
  }
49
70
 
50
71
  static void sb_appendn(strbuf *sb, const char *s, size_t n) {
51
- sb_ensure(sb, n);
72
+ if (!sb || sb->failed || (!s && n > 0)) {
73
+ if (sb) sb->failed = 1;
74
+ return;
75
+ }
76
+ if (sb_ensure(sb, n) != TF_OK) return;
77
+ if (n == 0) return;
52
78
  memcpy(sb->data + sb->len, s, n);
53
79
  sb->len += n;
54
80
  sb->data[sb->len] = '\0';
@@ -58,36 +84,103 @@ static void sb_appendf(strbuf *sb, const char *fmt, ...)
58
84
  __attribute__((format(printf, 2, 3)));
59
85
 
60
86
  static void sb_appendf(strbuf *sb, const char *fmt, ...) {
87
+ if (!sb || sb->failed || !fmt) return;
61
88
  char buf[512];
62
89
  va_list ap;
63
90
  va_start(ap, fmt);
64
91
  int n = vsnprintf(buf, sizeof(buf), fmt, ap);
65
92
  va_end(ap);
66
- if (n > 0) sb_appendn(sb, buf, (size_t)n);
93
+ if (n == 0) return;
94
+ if (n < 0) {
95
+ sb->failed = 1;
96
+ return;
97
+ }
98
+ if ((size_t)n < sizeof(buf)) {
99
+ sb_appendn(sb, buf, (size_t)n);
100
+ return;
101
+ }
102
+ size_t dyn_size = 0;
103
+ if (tf_size_add((size_t)n, 1, &dyn_size) != TF_OK) {
104
+ sb->failed = 1;
105
+ return;
106
+ }
107
+ char *dyn = tf_mallocarray_checked(dyn_size, sizeof(char));
108
+ if (!dyn) {
109
+ sb->failed = 1;
110
+ return;
111
+ }
112
+ va_start(ap, fmt);
113
+ int n2 = vsnprintf(dyn, dyn_size, fmt, ap);
114
+ va_end(ap);
115
+ if (n2 < 0 || (size_t)n2 >= dyn_size) {
116
+ free(dyn);
117
+ sb->failed = 1;
118
+ return;
119
+ }
120
+ sb_appendn(sb, dyn, (size_t)n2);
121
+ free(dyn);
67
122
  }
68
123
 
69
124
  static char *sb_detach(strbuf *sb) {
125
+ if (!sb || sb->failed) {
126
+ if (sb) sb_free(sb);
127
+ return NULL;
128
+ }
70
129
  char *s = sb->data;
71
130
  sb->data = NULL;
72
131
  sb->len = sb->cap = 0;
132
+ sb->failed = 0;
73
133
  return s;
74
134
  }
75
135
 
76
136
  static void sb_free(strbuf *sb) {
137
+ if (!sb) return;
77
138
  free(sb->data);
78
139
  sb->data = NULL;
79
140
  sb->len = sb->cap = 0;
141
+ sb->failed = 0;
80
142
  }
81
143
 
82
144
  /* ---- Expression AST to SQL ---- */
83
145
 
84
- /* Append a SQL-quoted identifier: "name" */
85
- static void sql_quote_ident(strbuf *sb, const char *name) {
86
- sb_append(sb, "\"");
87
- for (const char *p = name; *p; p++) {
146
+ static void sql_quote_ident_part(strbuf *sb, const char *name) {
147
+ const char *safe = name ? name : "";
148
+ for (const char *p = safe; *p; p++) {
88
149
  if (*p == '"') sb_append(sb, "\"\"");
89
150
  else sb_appendn(sb, p, 1);
90
151
  }
152
+ }
153
+
154
+ static void sql_quote_ident_part_range(strbuf *sb, const char *name, size_t len) {
155
+ if (!name && len > 0) {
156
+ if (sb) sb->failed = 1;
157
+ return;
158
+ }
159
+ for (size_t i = 0; i < len; i++) {
160
+ if (name[i] == '"') sb_append(sb, "\"\"");
161
+ else sb_appendn(sb, name + i, 1);
162
+ }
163
+ }
164
+
165
+ /* Append a SQL-quoted identifier: "name" */
166
+ static void sql_quote_ident(strbuf *sb, const char *name) {
167
+ sb_append(sb, "\"");
168
+ sql_quote_ident_part(sb, name);
169
+ sb_append(sb, "\"");
170
+ }
171
+
172
+ static void sql_quote_ident_range(strbuf *sb, const char *name, size_t len) {
173
+ sb_append(sb, "\"");
174
+ sql_quote_ident_part_range(sb, name, len);
175
+ sb_append(sb, "\"");
176
+ }
177
+
178
+ static void sql_quote_joined_ident(strbuf *sb, const char *prefix,
179
+ const char *middle, const char *suffix) {
180
+ sb_append(sb, "\"");
181
+ sql_quote_ident_part(sb, prefix);
182
+ sql_quote_ident_part(sb, middle);
183
+ sql_quote_ident_part(sb, suffix);
91
184
  sb_append(sb, "\"");
92
185
  }
93
186
 
@@ -121,13 +214,17 @@ static int expr_to_sql(const tf_expr *e, strbuf *sb) {
121
214
  return 0;
122
215
 
123
216
  case EXPR_LIT_FLOAT:
124
- sb_appendf(sb, "%g", e->lit_float);
217
+ sb_appendf(sb, TF_FLOAT64_ROUNDTRIP_FORMAT, e->lit_float);
125
218
  return 0;
126
219
 
127
220
  case EXPR_LIT_STR:
128
221
  sql_quote_str(sb, e->lit_str);
129
222
  return 0;
130
223
 
224
+ case EXPR_LIT_BOOL:
225
+ sb_append(sb, e->lit_bool ? "TRUE" : "FALSE");
226
+ return 0;
227
+
131
228
  case EXPR_COL_REF:
132
229
  sql_quote_ident(sb, e->col_name);
133
230
  return 0;
@@ -224,6 +321,103 @@ static int expr_to_sql(const tf_expr *e, strbuf *sb) {
224
321
  return 0;
225
322
  }
226
323
 
324
+ /* Special: case_when(cond, value, ..., default) → searched CASE */
325
+ if (strcmp(name, "case_when") == 0 && n >= 2) {
326
+ int pair_count = n / 2;
327
+ int default_idx = (n % 2 == 1) ? n - 1 : -1;
328
+ sb_append(sb, "(CASE");
329
+ for (int i = 0; i < pair_count; i++) {
330
+ sb_append(sb, " WHEN ");
331
+ if (expr_to_sql(e->func.args[i * 2], sb) != 0) return -1;
332
+ sb_append(sb, " THEN ");
333
+ if (expr_to_sql(e->func.args[i * 2 + 1], sb) != 0) return -1;
334
+ }
335
+ sb_append(sb, " ELSE ");
336
+ if (default_idx >= 0) {
337
+ if (expr_to_sql(e->func.args[default_idx], sb) != 0) return -1;
338
+ } else {
339
+ sb_append(sb, "NULL");
340
+ }
341
+ sb_append(sb, " END)");
342
+ return 0;
343
+ }
344
+
345
+ /* Special: case_match(value, key, result, ..., default) → simple CASE */
346
+ if (strcmp(name, "case_match") == 0 && n >= 3) {
347
+ int remaining = n - 1;
348
+ int pair_count = remaining / 2;
349
+ int default_idx = (remaining % 2 == 1) ? n - 1 : -1;
350
+ sb_append(sb, "(CASE ");
351
+ if (expr_to_sql(e->func.args[0], sb) != 0) return -1;
352
+ for (int i = 0; i < pair_count; i++) {
353
+ sb_append(sb, " WHEN ");
354
+ if (expr_to_sql(e->func.args[1 + i * 2], sb) != 0) return -1;
355
+ sb_append(sb, " THEN ");
356
+ if (expr_to_sql(e->func.args[2 + i * 2], sb) != 0) return -1;
357
+ }
358
+ sb_append(sb, " ELSE ");
359
+ if (default_idx >= 0) {
360
+ if (expr_to_sql(e->func.args[default_idx], sb) != 0) return -1;
361
+ } else {
362
+ sb_append(sb, "NULL");
363
+ }
364
+ sb_append(sb, " END)");
365
+ return 0;
366
+ }
367
+
368
+ /* Special: if_any(pred, ...) / if_all(pred, ...) → OR/AND reduction */
369
+ if (strcmp(name, "if_any") == 0 || strcmp(name, "if_all") == 0) {
370
+ int is_any = strcmp(name, "if_any") == 0;
371
+ if (n == 0) {
372
+ sb_append(sb, is_any ? "(FALSE)" : "(TRUE)");
373
+ return 0;
374
+ }
375
+ sb_append(sb, "(");
376
+ for (int i = 0; i < n; i++) {
377
+ if (i > 0) sb_append(sb, is_any ? " OR " : " AND ");
378
+ if (expr_to_sql(e->func.args[i], sb) != 0) return -1;
379
+ }
380
+ sb_append(sb, ")");
381
+ return 0;
382
+ }
383
+
384
+ /* Special: between(x, left, right) / inrange(x, left, right) → inclusive SQL range */
385
+ if ((strcmp(name, "between") == 0 || strcmp(name, "inrange") == 0) && n == 3) {
386
+ sb_append(sb, "(");
387
+ if (expr_to_sql(e->func.args[0], sb) != 0) return -1;
388
+ sb_append(sb, " BETWEEN ");
389
+ if (expr_to_sql(e->func.args[1], sb) != 0) return -1;
390
+ sb_append(sb, " AND ");
391
+ if (expr_to_sql(e->func.args[2], sb) != 0) return -1;
392
+ sb_append(sb, ")");
393
+ return 0;
394
+ }
395
+
396
+ /* Special: date/time component extraction */
397
+ if ((strcmp(name, "year") == 0 || strcmp(name, "month") == 0 ||
398
+ strcmp(name, "day") == 0 || strcmp(name, "hour") == 0 ||
399
+ strcmp(name, "minute") == 0 || strcmp(name, "second") == 0 ||
400
+ strcmp(name, "weekday") == 0 || strcmp(name, "epoch") == 0) && n == 1) {
401
+ const char *part = name;
402
+ if (strcmp(name, "weekday") == 0) part = "dow";
403
+ sb_append(sb, "EXTRACT(");
404
+ sb_append(sb, part);
405
+ sb_append(sb, " FROM ");
406
+ if (expr_to_sql(e->func.args[0], sb) != 0) return -1;
407
+ sb_append(sb, ")");
408
+ return 0;
409
+ }
410
+
411
+ /* Special: date_trunc(value, 'unit') → SQL date_trunc('unit', value) */
412
+ if (strcmp(name, "date_trunc") == 0 && n == 2 && e->func.args[1]->kind == EXPR_LIT_STR) {
413
+ sb_append(sb, "date_trunc(");
414
+ sql_quote_str(sb, e->func.args[1]->lit_str);
415
+ sb_append(sb, ", ");
416
+ if (expr_to_sql(e->func.args[0], sb) != 0) return -1;
417
+ sb_append(sb, ")");
418
+ return 0;
419
+ }
420
+
227
421
  /* Special: mod(a, b) → (a % b) */
228
422
  if (strcmp(name, "mod") == 0 && n == 2) {
229
423
  sb_append(sb, "(");
@@ -275,7 +469,7 @@ static char *translate_expr(const char *expr_str) {
275
469
  sb_init(&sb);
276
470
  int rc = expr_to_sql(e, &sb);
277
471
  tf_expr_free(e);
278
- if (rc != 0) { sb_free(&sb); return NULL; }
472
+ if (rc != 0 || sb.failed) { sb_free(&sb); return NULL; }
279
473
  return sb_detach(&sb);
280
474
  }
281
475
 
@@ -300,6 +494,138 @@ static int jbool(const cJSON *obj, const char *key, int def) {
300
494
  return def;
301
495
  }
302
496
 
497
+ static const char *sql_type_for_cast(const char *tf_type) {
498
+ if (!tf_type) return "VARCHAR";
499
+ if (strcmp(tf_type, "int") == 0 || strcmp(tf_type, "int64") == 0) return "BIGINT";
500
+ if (strcmp(tf_type, "float") == 0 || strcmp(tf_type, "float64") == 0) return "DOUBLE";
501
+ if (strcmp(tf_type, "bool") == 0 || strcmp(tf_type, "boolean") == 0) return "BOOLEAN";
502
+ if (strcmp(tf_type, "string") == 0 || strcmp(tf_type, "str") == 0) return "VARCHAR";
503
+ if (strcmp(tf_type, "date") == 0) return "DATE";
504
+ if (strcmp(tf_type, "timestamp") == 0 || strcmp(tf_type, "datetime") == 0) return "TIMESTAMP";
505
+ return "VARCHAR";
506
+ }
507
+
508
+ static const char *cast_on_error_policy(const cJSON *args) {
509
+ const cJSON *j = args ? cJSON_GetObjectItemCaseSensitive(args, "on_error") : NULL;
510
+ if (!j) j = args ? cJSON_GetObjectItemCaseSensitive(args, "onError") : NULL;
511
+ if (!j || !cJSON_IsString(j) || !j->valuestring) return "coerce";
512
+ if (strcmp(j->valuestring, "null") == 0 || strcmp(j->valuestring, "nulling") == 0) return "null";
513
+ if (strcmp(j->valuestring, "fail") == 0 || strcmp(j->valuestring, "error") == 0 ||
514
+ strcmp(j->valuestring, "strict") == 0) return "fail";
515
+ return "coerce";
516
+ }
517
+
518
+ static int append_cast_expr(strbuf *sb, const char *column,
519
+ const char *tf_type, const char *policy) {
520
+ if (!sb || !column) return TF_ERROR;
521
+ const char *sql_type = sql_type_for_cast(tf_type);
522
+ strbuf qcol;
523
+ sb_init(&qcol);
524
+ sql_quote_ident(&qcol, column);
525
+ if (qcol.failed) {
526
+ sb_free(&qcol);
527
+ return TF_ERROR;
528
+ }
529
+
530
+ if (strcmp(policy, "null") == 0) {
531
+ sb_append(sb, "TRY_CAST(");
532
+ sb_append(sb, qcol.data);
533
+ sb_appendf(sb, " AS %s)", sql_type);
534
+ sb_free(&qcol);
535
+ return sb->failed ? TF_ERROR : TF_OK;
536
+ }
537
+
538
+ if (strcmp(policy, "fail") == 0) {
539
+ sb_append(sb, "CAST(");
540
+ sb_append(sb, qcol.data);
541
+ sb_appendf(sb, " AS %s)", sql_type);
542
+ sb_free(&qcol);
543
+ return sb->failed ? TF_ERROR : TF_OK;
544
+ }
545
+
546
+ if (strcmp(sql_type, "BIGINT") == 0) {
547
+ sb_append(sb, "COALESCE(TRY_CAST(regexp_extract(CAST(");
548
+ sb_append(sb, qcol.data);
549
+ sb_append(sb, " AS VARCHAR), ");
550
+ sql_quote_str(sb, "^[[:space:]]*[+-]?[0-9]+");
551
+ sb_append(sb, ") AS BIGINT), 0)");
552
+ } else if (strcmp(sql_type, "DOUBLE") == 0) {
553
+ sb_append(sb, "COALESCE(TRY_CAST(regexp_extract(CAST(");
554
+ sb_append(sb, qcol.data);
555
+ sb_append(sb, " AS VARCHAR), ");
556
+ sql_quote_str(sb, "^[[:space:]]*[+-]?([0-9]+(\\.[0-9]*)?|\\.[0-9]+)([eE][+-]?[0-9]+)?");
557
+ sb_append(sb, ") AS DOUBLE), 0.0)");
558
+ } else if (strcmp(sql_type, "BOOLEAN") == 0) {
559
+ sb_append(sb, "(CASE WHEN ");
560
+ sb_append(sb, qcol.data);
561
+ sb_append(sb, " IS NULL THEN NULL WHEN CAST(");
562
+ sb_append(sb, qcol.data);
563
+ sb_append(sb, " AS VARCHAR) = '' OR CAST(");
564
+ sb_append(sb, qcol.data);
565
+ sb_append(sb, " AS VARCHAR) = 'false' THEN FALSE ELSE TRUE END)");
566
+ } else if (strcmp(sql_type, "DATE") == 0) {
567
+ sb_append(sb, "COALESCE(TRY_CAST(");
568
+ sb_append(sb, qcol.data);
569
+ sb_append(sb, " AS DATE), DATE '1970-01-01')");
570
+ } else if (strcmp(sql_type, "TIMESTAMP") == 0) {
571
+ sb_append(sb, "COALESCE(TRY_CAST(");
572
+ sb_append(sb, qcol.data);
573
+ sb_append(sb, " AS TIMESTAMP), TIMESTAMP '1970-01-01 00:00:00')");
574
+ } else {
575
+ sb_append(sb, "CAST(");
576
+ sb_append(sb, qcol.data);
577
+ sb_appendf(sb, " AS %s)", sql_type);
578
+ }
579
+ sb_free(&qcol);
580
+ return sb->failed ? TF_ERROR : TF_OK;
581
+ }
582
+
583
+ static int append_frequency_key_expr(strbuf *sb, cJSON *cols) {
584
+ if (!sb || !cJSON_IsArray(cols) || cJSON_GetArraySize(cols) <= 0) return TF_ERROR;
585
+ int n = cJSON_GetArraySize(cols);
586
+ for (int i = 0; i < n; i++) {
587
+ cJSON *c = cJSON_GetArrayItem(cols, i);
588
+ if (!cJSON_IsString(c) || !c->valuestring || c->valuestring[0] == '\0') {
589
+ return TF_ERROR;
590
+ }
591
+ if (i > 0) sb_append(sb, " || chr(1) || ");
592
+ sb_append(sb, "COALESCE(CAST(");
593
+ sql_quote_ident(sb, c->valuestring);
594
+ sb_append(sb, " AS VARCHAR), ");
595
+ sql_quote_str(sb, "\\N");
596
+ sb_append(sb, ")");
597
+ if (sb->failed) return TF_ERROR;
598
+ }
599
+ return TF_OK;
600
+ }
601
+
602
+ static int append_grep_match_expr(strbuf *sb, const char *column,
603
+ const char *pattern, int regex) {
604
+ if (!sb || !column || !pattern) return TF_ERROR;
605
+ strbuf qcol;
606
+ sb_init(&qcol);
607
+ sql_quote_ident(&qcol, column);
608
+ if (qcol.failed) {
609
+ sb_free(&qcol);
610
+ return TF_ERROR;
611
+ }
612
+
613
+ sb_append(sb, "(CASE WHEN typeof(");
614
+ sb_append(sb, qcol.data);
615
+ sb_append(sb, ") = 'VARCHAR' THEN COALESCE(");
616
+ if (regex) {
617
+ sb_append(sb, "regexp_matches(CAST(");
618
+ } else {
619
+ sb_append(sb, "contains(CAST(");
620
+ }
621
+ sb_append(sb, qcol.data);
622
+ sb_append(sb, " AS VARCHAR), ");
623
+ sql_quote_str(sb, pattern);
624
+ sb_append(sb, "), FALSE) ELSE FALSE END)");
625
+ sb_free(&qcol);
626
+ return sb->failed ? TF_ERROR : TF_OK;
627
+ }
628
+
303
629
  /* Emit a CTE for a transform op. prev is the name of the previous CTE/source.
304
630
  * Appends SQL like: step_N AS (SELECT ... FROM prev ...) */
305
631
  static int emit_cte(strbuf *sb, const char *cte_name, const char *prev,
@@ -316,10 +642,48 @@ static int emit_cte(strbuf *sb, const char *cte_name, const char *prev,
316
642
  return 0;
317
643
  }
318
644
 
645
+ /* ---- relocate ---- */
646
+ if (strcmp(op, "relocate") == 0) {
647
+ cJSON *before = cJSON_GetObjectItemCaseSensitive(args, "before");
648
+ cJSON *after = cJSON_GetObjectItemCaseSensitive(args, "after");
649
+ if (before || after) {
650
+ *error = strdup("relocate: SQL lowering for before/after requires known schema; default-front relocate is supported");
651
+ return -1;
652
+ }
653
+ cJSON *cols = cJSON_GetObjectItemCaseSensitive(args, "columns");
654
+ if (!cols || !cJSON_IsArray(cols)) { *error = strdup("relocate: missing 'columns'"); return -1; }
655
+ if (tf_column_selectors_have_syntax_json(cols)) {
656
+ *error = strdup("relocate: selector helpers require known schema for SQL lowering");
657
+ return -1;
658
+ }
659
+ strbuf moved;
660
+ sb_init(&moved);
661
+ int n = cJSON_GetArraySize(cols);
662
+ for (int i = 0; i < n; i++) {
663
+ cJSON *c = cJSON_GetArrayItem(cols, i);
664
+ if (!cJSON_IsString(c)) continue;
665
+ if (moved.len > 0) sb_append(&moved, ", ");
666
+ sql_quote_ident(&moved, c->valuestring);
667
+ }
668
+ if (moved.len == 0) {
669
+ sb_free(&moved);
670
+ *error = strdup("relocate: missing 'columns'");
671
+ return -1;
672
+ }
673
+ sb_appendf(sb, "%s AS (SELECT %s, * EXCLUDE (%s) FROM %s)",
674
+ cte_name, moved.data, moved.data, prev);
675
+ sb_free(&moved);
676
+ return 0;
677
+ }
678
+
319
679
  /* ---- select / reorder ---- */
320
680
  if (strcmp(op, "select") == 0 || strcmp(op, "reorder") == 0) {
321
681
  cJSON *cols = cJSON_GetObjectItemCaseSensitive(args, "columns");
322
682
  if (!cols || !cJSON_IsArray(cols)) { *error = strdup("select: missing 'columns'"); return -1; }
683
+ if (tf_column_selectors_have_syntax_json(cols)) {
684
+ *error = strdup("select: selector helpers require known schema for SQL lowering");
685
+ return -1;
686
+ }
323
687
  strbuf sel;
324
688
  sb_init(&sel);
325
689
  int n = cJSON_GetArraySize(cols);
@@ -393,6 +757,18 @@ static int emit_cte(strbuf *sb, const char *cte_name, const char *prev,
393
757
 
394
758
  /* ---- unique / dedup ---- */
395
759
  if (strcmp(op, "unique") == 0 || strcmp(op, "dedup") == 0) {
760
+ cJSON *sorted = cJSON_GetObjectItemCaseSensitive(args, "sorted");
761
+ if (cJSON_IsBool(sorted) && cJSON_IsTrue(sorted)) {
762
+ *error = strdup("unique: sorted=true adjacent-run mode cannot be lowered to SQL");
763
+ return -1;
764
+ }
765
+ cJSON *mode = cJSON_GetObjectItemCaseSensitive(args, "mode");
766
+ cJSON *approx = cJSON_GetObjectItemCaseSensitive(args, "approx");
767
+ if ((cJSON_IsString(mode) && mode->valuestring && strcmp(mode->valuestring, "approx") == 0) ||
768
+ (cJSON_IsBool(approx) && cJSON_IsTrue(approx))) {
769
+ *error = strdup("unique: approximate mode cannot be lowered to SQL");
770
+ return -1;
771
+ }
396
772
  cJSON *cols = cJSON_GetObjectItemCaseSensitive(args, "columns");
397
773
  if (cols && cJSON_IsArray(cols) && cJSON_GetArraySize(cols) > 0) {
398
774
  strbuf dcols;
@@ -434,7 +810,7 @@ static int emit_cte(strbuf *sb, const char *cte_name, const char *prev,
434
810
  }
435
811
 
436
812
  /* ---- head ---- */
437
- if (strcmp(op, "head") == 0) {
813
+ if (strcmp(op, "head") == 0 || strcmp(op, "slice-head") == 0) {
438
814
  int n = jint(args, "n", 10);
439
815
  sb_appendf(sb, "%s AS (SELECT * FROM %s LIMIT %d)", cte_name, prev, n);
440
816
  return 0;
@@ -448,7 +824,7 @@ static int emit_cte(strbuf *sb, const char *cte_name, const char *prev,
448
824
  }
449
825
 
450
826
  /* ---- tail ---- */
451
- if (strcmp(op, "tail") == 0) {
827
+ if (strcmp(op, "tail") == 0 || strcmp(op, "slice-tail") == 0) {
452
828
  int n = jint(args, "n", 10);
453
829
  sb_appendf(sb, "%s AS (SELECT * FROM (SELECT *, ROW_NUMBER() OVER () AS _rn, "
454
830
  "COUNT(*) OVER () AS _total FROM %s) WHERE _rn > _total - %d)",
@@ -456,12 +832,14 @@ static int emit_cte(strbuf *sb, const char *cte_name, const char *prev,
456
832
  return 0;
457
833
  }
458
834
 
459
- /* ---- top ---- */
460
- if (strcmp(op, "top") == 0) {
835
+ /* ---- bounded top-k / bottom-k ---- */
836
+ if (strcmp(op, "top") == 0 || strcmp(op, "top-k") == 0 ||
837
+ strcmp(op, "bottom-k") == 0 || strcmp(op, "slice-min") == 0 ||
838
+ strcmp(op, "slice-max") == 0) {
461
839
  int n = jint(args, "n", 10);
462
840
  const char *column = jstr(args, "column");
463
841
  int desc = jbool(args, "desc", 1);
464
- if (!column) { *error = strdup("top: missing 'column'"); return -1; }
842
+ if (!column) { *error = strdup("top-k: missing 'column'"); return -1; }
465
843
  strbuf tmp;
466
844
  sb_init(&tmp);
467
845
  sql_quote_ident(&tmp, column);
@@ -473,9 +851,8 @@ static int emit_cte(strbuf *sb, const char *cte_name, const char *prev,
473
851
 
474
852
  /* ---- sample ---- */
475
853
  if (strcmp(op, "sample") == 0) {
476
- int n = jint(args, "n", 100);
477
- sb_appendf(sb, "%s AS (SELECT * FROM %s USING SAMPLE %d)", cte_name, prev, n);
478
- return 0;
854
+ *error = strdup("sample: deterministic reservoir sampling cannot be lowered to SQL");
855
+ return -1;
479
856
  }
480
857
 
481
858
  /* ---- grep ---- */
@@ -486,28 +863,16 @@ static int emit_cte(strbuf *sb, const char *cte_name, const char *prev,
486
863
  int regex = jbool(args, "regex", 0);
487
864
  if (!pattern) { *error = strdup("grep: missing 'pattern'"); return -1; }
488
865
  if (!column) column = "_line";
489
- strbuf tmp;
490
- sb_init(&tmp);
491
- sql_quote_ident(&tmp, column);
492
- if (regex) {
493
- sb_appendf(sb, "%s AS (SELECT * FROM %s WHERE %sregexp_matches(%s, ",
494
- cte_name, prev, invert ? "NOT " : "", tmp.data);
495
- sql_quote_str(sb, pattern);
496
- sb_append(sb, "))");
497
- } else {
498
- sb_appendf(sb, "%s AS (SELECT * FROM %s WHERE ", cte_name, prev);
499
- if (invert) sb_append(sb, "NOT ");
500
- sb_append(sb, tmp.data);
501
- sb_append(sb, " LIKE '%");
502
- /* Escape pattern for LIKE */
503
- for (const char *p = pattern; *p; p++) {
504
- if (*p == '%' || *p == '_' || *p == '\\') sb_appendn(sb, "\\", 1);
505
- if (*p == '\'') sb_append(sb, "''");
506
- else sb_appendn(sb, p, 1);
507
- }
508
- sb_append(sb, "%')");
866
+ strbuf pred;
867
+ sb_init(&pred);
868
+ if (append_grep_match_expr(&pred, column, pattern, regex) != TF_OK) {
869
+ sb_free(&pred);
870
+ *error = strdup("sql: out of memory");
871
+ return -1;
509
872
  }
510
- sb_free(&tmp);
873
+ sb_appendf(sb, "%s AS (SELECT * FROM %s WHERE %s%s)",
874
+ cte_name, prev, invert ? "NOT " : "", pred.data);
875
+ sb_free(&pred);
511
876
  return 0;
512
877
  }
513
878
 
@@ -515,48 +880,23 @@ static int emit_cte(strbuf *sb, const char *cte_name, const char *prev,
515
880
  if (strcmp(op, "cast") == 0) {
516
881
  cJSON *mapping = cJSON_GetObjectItemCaseSensitive(args, "mapping");
517
882
  if (!mapping) { *error = strdup("cast: missing 'mapping'"); return -1; }
518
- strbuf cols;
519
- sb_init(&cols);
520
- cJSON *item = NULL;
521
- cJSON_ArrayForEach(item, mapping) {
522
- if (!cJSON_IsString(item)) continue;
523
- sb_append(&cols, ", CAST(");
524
- sql_quote_ident(&cols, item->string);
525
- /* Map tranfi types to SQL types */
526
- const char *tf_type = item->valuestring;
527
- const char *sql_type = "VARCHAR";
528
- if (strcmp(tf_type, "int") == 0 || strcmp(tf_type, "int64") == 0) sql_type = "BIGINT";
529
- else if (strcmp(tf_type, "float") == 0 || strcmp(tf_type, "float64") == 0) sql_type = "DOUBLE";
530
- else if (strcmp(tf_type, "bool") == 0) sql_type = "BOOLEAN";
531
- else if (strcmp(tf_type, "string") == 0) sql_type = "VARCHAR";
532
- else if (strcmp(tf_type, "date") == 0) sql_type = "DATE";
533
- else if (strcmp(tf_type, "timestamp") == 0) sql_type = "TIMESTAMP";
534
- sb_appendf(&cols, " AS %s) AS ", sql_type);
535
- sql_quote_ident(&cols, item->string);
536
- }
537
- /* Use COLUMNS(*) EXCLUDE + explicit casts — simpler: use REPLACE */
538
- /* DuckDB REPLACE: SELECT * REPLACE (CAST(col AS type) AS col) */
883
+ const char *policy = cast_on_error_policy(args);
539
884
  strbuf rep;
540
885
  sb_init(&rep);
541
886
  int first = 1;
887
+ cJSON *item = NULL;
542
888
  cJSON_ArrayForEach(item, mapping) {
543
889
  if (!cJSON_IsString(item)) continue;
544
890
  if (!first) sb_append(&rep, ", ");
545
891
  first = 0;
546
- const char *tf_type = item->valuestring;
547
- const char *sql_type = "VARCHAR";
548
- if (strcmp(tf_type, "int") == 0 || strcmp(tf_type, "int64") == 0) sql_type = "BIGINT";
549
- else if (strcmp(tf_type, "float") == 0 || strcmp(tf_type, "float64") == 0) sql_type = "DOUBLE";
550
- else if (strcmp(tf_type, "bool") == 0) sql_type = "BOOLEAN";
551
- else if (strcmp(tf_type, "string") == 0) sql_type = "VARCHAR";
552
- else if (strcmp(tf_type, "date") == 0) sql_type = "DATE";
553
- else if (strcmp(tf_type, "timestamp") == 0) sql_type = "TIMESTAMP";
554
- sb_append(&rep, "CAST(");
555
- sql_quote_ident(&rep, item->string);
556
- sb_appendf(&rep, " AS %s) AS ", sql_type);
892
+ if (append_cast_expr(&rep, item->string, item->valuestring, policy) != TF_OK) {
893
+ sb_free(&rep);
894
+ *error = strdup("sql: out of memory");
895
+ return -1;
896
+ }
897
+ sb_append(&rep, " AS ");
557
898
  sql_quote_ident(&rep, item->string);
558
899
  }
559
- sb_free(&cols);
560
900
  sb_appendf(sb, "%s AS (SELECT * REPLACE (%s) FROM %s)", cte_name, rep.data, prev);
561
901
  sb_free(&rep);
562
902
  return 0;
@@ -570,28 +910,26 @@ static int emit_cte(strbuf *sb, const char *cte_name, const char *prev,
570
910
  cJSON *max_v = cJSON_GetObjectItemCaseSensitive(args, "max");
571
911
  strbuf expr;
572
912
  sb_init(&expr);
573
- sql_quote_ident(&expr, column);
574
913
  if (min_v && max_v) {
575
914
  strbuf tmp;
576
915
  sb_init(&tmp);
577
916
  sql_quote_ident(&tmp, column);
578
- sb_init(&expr);
579
- sb_appendf(&expr, "GREATEST(%g, LEAST(%g, %s))", min_v->valuedouble, max_v->valuedouble, tmp.data);
917
+ sb_appendf(&expr, "GREATEST(" TF_FLOAT64_ROUNDTRIP_FORMAT ", LEAST(" TF_FLOAT64_ROUNDTRIP_FORMAT ", %s))", min_v->valuedouble, max_v->valuedouble, tmp.data);
580
918
  sb_free(&tmp);
581
919
  } else if (min_v) {
582
920
  strbuf tmp;
583
921
  sb_init(&tmp);
584
922
  sql_quote_ident(&tmp, column);
585
- sb_init(&expr);
586
- sb_appendf(&expr, "GREATEST(%g, %s)", min_v->valuedouble, tmp.data);
923
+ sb_appendf(&expr, "GREATEST(" TF_FLOAT64_ROUNDTRIP_FORMAT ", %s)", min_v->valuedouble, tmp.data);
587
924
  sb_free(&tmp);
588
925
  } else if (max_v) {
589
926
  strbuf tmp;
590
927
  sb_init(&tmp);
591
928
  sql_quote_ident(&tmp, column);
592
- sb_init(&expr);
593
- sb_appendf(&expr, "LEAST(%g, %s)", max_v->valuedouble, tmp.data);
929
+ sb_appendf(&expr, "LEAST(" TF_FLOAT64_ROUNDTRIP_FORMAT ", %s)", max_v->valuedouble, tmp.data);
594
930
  sb_free(&tmp);
931
+ } else {
932
+ sql_quote_ident(&expr, column);
595
933
  }
596
934
  strbuf qcol;
597
935
  sb_init(&qcol);
@@ -640,27 +978,33 @@ static int emit_cte(strbuf *sb, const char *cte_name, const char *prev,
640
978
  /* ---- trim ---- */
641
979
  if (strcmp(op, "trim") == 0) {
642
980
  cJSON *cols = cJSON_GetObjectItemCaseSensitive(args, "columns");
643
- if (cols && cJSON_IsArray(cols) && cJSON_GetArraySize(cols) > 0) {
644
- strbuf rep;
645
- sb_init(&rep);
646
- int n = cJSON_GetArraySize(cols);
647
- for (int i = 0; i < n; i++) {
648
- if (i > 0) sb_append(&rep, ", ");
649
- cJSON *c = cJSON_GetArrayItem(cols, i);
650
- if (!cJSON_IsString(c)) continue;
651
- sb_append(&rep, "trim(");
652
- sql_quote_ident(&rep, c->valuestring);
653
- sb_append(&rep, ") AS ");
654
- sql_quote_ident(&rep, c->valuestring);
981
+ if (!cols || !cJSON_IsArray(cols) || cJSON_GetArraySize(cols) <= 0) {
982
+ *error = strdup("trim: SQL lowering requires explicit columns");
983
+ return -1;
984
+ }
985
+ strbuf rep;
986
+ sb_init(&rep);
987
+ int n = cJSON_GetArraySize(cols);
988
+ for (int i = 0; i < n; i++) {
989
+ cJSON *c = cJSON_GetArrayItem(cols, i);
990
+ if (!cJSON_IsString(c) || !c->valuestring || c->valuestring[0] == '\0') {
991
+ sb_free(&rep);
992
+ *error = strdup("trim: invalid columns");
993
+ return -1;
655
994
  }
656
- sb_appendf(sb, "%s AS (SELECT * REPLACE (%s) FROM %s)", cte_name, rep.data, prev);
995
+ if (i > 0) sb_append(&rep, ", ");
996
+ sb_append(&rep, "trim(");
997
+ sql_quote_ident(&rep, c->valuestring);
998
+ sb_append(&rep, ") AS ");
999
+ sql_quote_ident(&rep, c->valuestring);
1000
+ }
1001
+ if (rep.failed) {
657
1002
  sb_free(&rep);
658
- } else {
659
- /* Trim all string columns — without schema we just pass through.
660
- * DuckDB doesn't have a "trim all" so we'd need column names.
661
- * Fallback: SELECT * (no trim) with a comment. */
662
- sb_appendf(sb, "%s AS (SELECT * FROM %s)", cte_name, prev);
1003
+ *error = strdup("sql: out of memory");
1004
+ return -1;
663
1005
  }
1006
+ sb_appendf(sb, "%s AS (SELECT * REPLACE (%s) FROM %s)", cte_name, rep.data, prev);
1007
+ sb_free(&rep);
664
1008
  return 0;
665
1009
  }
666
1010
 
@@ -724,13 +1068,12 @@ static int emit_cte(strbuf *sb, const char *cte_name, const char *prev,
724
1068
  else if (strcmp(func, "median") == 0) sql_func = "MEDIAN";
725
1069
  sb_append(&sel, sql_func);
726
1070
  sb_append(&sel, "(");
727
- sql_quote_ident(&sel, col);
1071
+ if (strcmp(func, "count") == 0 && strcmp(col, "*") == 0) sb_append(&sel, "*");
1072
+ else sql_quote_ident(&sel, col);
728
1073
  sb_append(&sel, ") AS ");
729
1074
  if (result) sql_quote_ident(&sel, result);
730
1075
  else {
731
- char buf[256];
732
- snprintf(buf, sizeof(buf), "%s_%s", func, col);
733
- sql_quote_ident(&sel, buf);
1076
+ sql_quote_joined_ident(&sel, col, "_", func);
734
1077
  }
735
1078
  }
736
1079
  /* GROUP BY clause */
@@ -749,40 +1092,53 @@ static int emit_cte(strbuf *sb, const char *cte_name, const char *prev,
749
1092
 
750
1093
  /* ---- frequency ---- */
751
1094
  if (strcmp(op, "frequency") == 0) {
1095
+ cJSON *mode = args ? cJSON_GetObjectItemCaseSensitive(args, "mode") : NULL;
1096
+ if (cJSON_IsString(mode) && strcmp(mode->valuestring, "approx") == 0) {
1097
+ *error = strdup("frequency: mode=approx is not supported by SQL lowering");
1098
+ return -1;
1099
+ }
1100
+ cJSON *approx = args ? cJSON_GetObjectItemCaseSensitive(args, "approx") : NULL;
1101
+ if (cJSON_IsTrue(approx)) {
1102
+ *error = strdup("frequency: mode=approx is not supported by SQL lowering");
1103
+ return -1;
1104
+ }
1105
+ cJSON *overflow = args ? cJSON_GetObjectItemCaseSensitive(args, "overflow") : NULL;
1106
+ if (cJSON_IsString(overflow) && strcmp(overflow->valuestring, "other") == 0) {
1107
+ *error = strdup("frequency: overflow=other is not supported by SQL lowering");
1108
+ return -1;
1109
+ }
1110
+
752
1111
  cJSON *cols = cJSON_GetObjectItemCaseSensitive(args, "columns");
753
- if (cols && cJSON_IsArray(cols) && cJSON_GetArraySize(cols) > 0) {
754
- strbuf sel;
755
- sb_init(&sel);
756
- strbuf grp;
757
- sb_init(&grp);
758
- int n = cJSON_GetArraySize(cols);
759
- for (int i = 0; i < n; i++) {
760
- if (i > 0) { sb_append(&sel, ", "); sb_append(&grp, ", "); }
761
- cJSON *c = cJSON_GetArrayItem(cols, i);
762
- if (cJSON_IsString(c)) {
763
- sql_quote_ident(&sel, c->valuestring);
764
- sql_quote_ident(&grp, c->valuestring);
765
- }
766
- }
767
- sb_appendf(sb, "%s AS (SELECT %s, COUNT(*) AS \"count\" FROM %s GROUP BY %s ORDER BY \"count\" DESC)",
768
- cte_name, sel.data, prev, grp.data);
769
- sb_free(&sel);
770
- sb_free(&grp);
771
- } else {
772
- /* No columns specified — frequency of all columns not meaningful,
773
- * fall back to counting rows */
774
- sb_appendf(sb, "%s AS (SELECT COUNT(*) AS \"count\" FROM %s)", cte_name, prev);
1112
+ if (!cols || !cJSON_IsArray(cols) || cJSON_GetArraySize(cols) <= 0) {
1113
+ *error = strdup("frequency: SQL lowering requires explicit columns");
1114
+ return -1;
775
1115
  }
1116
+ strbuf key;
1117
+ sb_init(&key);
1118
+ if (append_frequency_key_expr(&key, cols) != TF_OK) {
1119
+ sb_free(&key);
1120
+ *error = strdup("frequency: invalid columns");
1121
+ return -1;
1122
+ }
1123
+ sb_appendf(sb, "%s AS (SELECT \"value\", COUNT(*) AS \"count\" FROM "
1124
+ "(SELECT %s AS \"value\" FROM %s) __tf_frequency "
1125
+ "GROUP BY \"value\" ORDER BY \"count\" DESC, \"value\" ASC)",
1126
+ cte_name, key.data, prev);
1127
+ sb_free(&key);
776
1128
  return 0;
777
1129
  }
778
1130
 
779
1131
  /* ---- join ---- */
780
- if (strcmp(op, "join") == 0) {
1132
+ if (strcmp(op, "join") == 0 || strcmp(op, "semi-join") == 0 || strcmp(op, "anti-join") == 0) {
781
1133
  const char *file = jstr(args, "file");
782
1134
  const char *on = jstr(args, "on");
783
1135
  const char *how = jstr(args, "how");
784
1136
  if (!file || !on) { *error = strdup("join: missing 'file' or 'on'"); return -1; }
785
- if (!how) how = "inner";
1137
+ if (!how) {
1138
+ if (strcmp(op, "semi-join") == 0) how = "semi";
1139
+ else if (strcmp(op, "anti-join") == 0) how = "anti";
1140
+ else how = "inner";
1141
+ }
786
1142
  const char *join_type = "INNER";
787
1143
  if (strcmp(how, "left") == 0) join_type = "LEFT";
788
1144
  else if (strcmp(how, "right") == 0) join_type = "RIGHT";
@@ -793,27 +1149,34 @@ static int emit_cte(strbuf *sb, const char *cte_name, const char *prev,
793
1149
  strbuf cond;
794
1150
  sb_init(&cond);
795
1151
  if (eq) {
796
- char left_col[128], right_col[128];
1152
+ const char *left = on;
797
1153
  size_t llen = (size_t)(eq - on);
798
- if (llen >= sizeof(left_col)) llen = sizeof(left_col) - 1;
799
- memcpy(left_col, on, llen);
800
- left_col[llen] = '\0';
801
- strncpy(right_col, eq + 1, sizeof(right_col) - 1);
802
- right_col[sizeof(right_col) - 1] = '\0';
803
- /* Trim whitespace */
804
- while (llen > 0 && left_col[llen - 1] == ' ') left_col[--llen] = '\0';
805
- char *rp = right_col;
806
- while (*rp == ' ') rp++;
1154
+ while (llen > 0 && *left == ' ') { left++; llen--; }
1155
+ while (llen > 0 && left[llen - 1] == ' ') llen--;
1156
+
1157
+ const char *right = eq + 1;
1158
+ while (*right == ' ') right++;
1159
+ size_t rlen = strlen(right);
1160
+ while (rlen > 0 && right[rlen - 1] == ' ') rlen--;
807
1161
  sb_append(&cond, "a.");
808
- sql_quote_ident(&cond, left_col);
1162
+ sql_quote_ident_range(&cond, left, llen);
809
1163
  sb_append(&cond, " = b.");
810
- sql_quote_ident(&cond, rp);
1164
+ sql_quote_ident_range(&cond, right, rlen);
811
1165
  } else {
812
1166
  sb_append(&cond, "a.");
813
1167
  sql_quote_ident(&cond, on);
814
1168
  sb_append(&cond, " = b.");
815
1169
  sql_quote_ident(&cond, on);
816
1170
  }
1171
+ if (strcmp(how, "semi") == 0 || strcmp(how, "anti") == 0) {
1172
+ sb_appendf(sb, "%s AS (SELECT a.* FROM %s a WHERE %sEXISTS (SELECT 1 FROM read_csv_auto(",
1173
+ cte_name, prev, strcmp(how, "anti") == 0 ? "NOT " : "");
1174
+ sql_quote_str(sb, file);
1175
+ sb_appendf(sb, ") b WHERE %s))", cond.data);
1176
+ sb_free(&cond);
1177
+ return 0;
1178
+ }
1179
+
817
1180
  sb_appendf(sb, "%s AS (SELECT a.* FROM %s a %s JOIN read_csv_auto(",
818
1181
  cte_name, prev, join_type);
819
1182
  sql_quote_str(sb, file);
@@ -822,6 +1185,29 @@ static int emit_cte(strbuf *sb, const char *cte_name, const char *prev,
822
1185
  return 0;
823
1186
  }
824
1187
 
1188
+ /* ---- set ops ---- */
1189
+ if (strcmp(op, "intersect") == 0 || strcmp(op, "setdiff") == 0 ||
1190
+ strcmp(op, "intersect-all") == 0 || strcmp(op, "setdiff-all") == 0 ||
1191
+ strcmp(op, "union") == 0 || strcmp(op, "union-all") == 0) {
1192
+ const char *file = jstr(args, "file");
1193
+ if (!file) { *error = strdup("set op: missing 'file'"); return -1; }
1194
+ cJSON *cols = args ? cJSON_GetObjectItemCaseSensitive(args, "columns") : NULL;
1195
+ if (cols && cJSON_IsArray(cols) && cJSON_GetArraySize(cols) > 0) {
1196
+ *error = strdup("set op SQL lowering only supports all-column set operations");
1197
+ return -1;
1198
+ }
1199
+ const char *sql_op = "INTERSECT";
1200
+ if (strcmp(op, "setdiff") == 0) sql_op = "EXCEPT";
1201
+ else if (strcmp(op, "intersect-all") == 0) sql_op = "INTERSECT ALL";
1202
+ else if (strcmp(op, "setdiff-all") == 0) sql_op = "EXCEPT ALL";
1203
+ else if (strcmp(op, "union") == 0) sql_op = "UNION";
1204
+ else if (strcmp(op, "union-all") == 0) sql_op = "UNION ALL";
1205
+ sb_appendf(sb, "%s AS (SELECT * FROM %s %s SELECT * FROM read_csv_auto(", cte_name, prev, sql_op);
1206
+ sql_quote_str(sb, file);
1207
+ sb_append(sb, "))");
1208
+ return 0;
1209
+ }
1210
+
825
1211
  /* ---- stack ---- */
826
1212
  if (strcmp(op, "stack") == 0) {
827
1213
  const char *file = jstr(args, "file");
@@ -841,9 +1227,9 @@ static int emit_cte(strbuf *sb, const char *cte_name, const char *prev,
841
1227
  strbuf qcol;
842
1228
  sb_init(&qcol);
843
1229
  sql_quote_ident(&qcol, column);
844
- sb_appendf(sb, "%s AS (SELECT * REPLACE (unnest(string_split(%s, ", cte_name, qcol.data);
1230
+ sb_appendf(sb, "%s AS (SELECT * REPLACE (unnest(CASE WHEN %s IS NULL THEN list_value(NULL::VARCHAR) ELSE string_split(%s, ", cte_name, qcol.data, qcol.data);
845
1231
  sql_quote_str(sb, delimiter);
846
- sb_appendf(sb, ")) AS %s) FROM %s)", qcol.data, prev);
1232
+ sb_appendf(sb, ") END) AS %s) FROM %s)", qcol.data, prev);
847
1233
  sb_free(&qcol);
848
1234
  return 0;
849
1235
  }
@@ -898,6 +1284,7 @@ static int emit_cte(strbuf *sb, const char *cte_name, const char *prev,
898
1284
  const char *name_col = jstr(args, "name_column");
899
1285
  const char *val_col = jstr(args, "value_column");
900
1286
  const char *agg = jstr(args, "agg");
1287
+ cJSON *categories = args ? cJSON_GetObjectItemCaseSensitive(args, "categories") : NULL;
901
1288
  if (!name_col || !val_col) { *error = strdup("pivot: missing args"); return -1; }
902
1289
  if (!agg) agg = "first";
903
1290
  const char *sql_agg = "FIRST";
@@ -910,8 +1297,55 @@ static int emit_cte(strbuf *sb, const char *cte_name, const char *prev,
910
1297
  sb_init(&qn); sb_init(&qv);
911
1298
  sql_quote_ident(&qn, name_col);
912
1299
  sql_quote_ident(&qv, val_col);
913
- sb_appendf(sb, "%s AS (PIVOT %s ON %s USING %s(%s))",
914
- cte_name, prev, qn.data, sql_agg, qv.data);
1300
+ if (qn.failed || qv.failed) {
1301
+ sb_free(&qn); sb_free(&qv);
1302
+ *error = strdup("sql: out of memory");
1303
+ return -1;
1304
+ }
1305
+ if (categories) {
1306
+ if (!cJSON_IsArray(categories) || cJSON_GetArraySize(categories) <= 0) {
1307
+ sb_free(&qn); sb_free(&qv);
1308
+ *error = strdup("pivot: SQL categories must be a non-empty array");
1309
+ return -1;
1310
+ }
1311
+ strbuf cats;
1312
+ sb_init(&cats);
1313
+ int n = cJSON_GetArraySize(categories);
1314
+ for (int i = 0; i < n; i++) {
1315
+ cJSON *cat = cJSON_GetArrayItem(categories, i);
1316
+ if (!cJSON_IsString(cat)) {
1317
+ sb_free(&cats); sb_free(&qn); sb_free(&qv);
1318
+ *error = strdup("pivot: SQL categories must be strings");
1319
+ return -1;
1320
+ }
1321
+ if (i > 0) sb_append(&cats, ", ");
1322
+ sql_quote_str(&cats, cat->valuestring);
1323
+ if (cats.failed) {
1324
+ sb_free(&cats); sb_free(&qn); sb_free(&qv);
1325
+ *error = strdup("sql: out of memory");
1326
+ return -1;
1327
+ }
1328
+ }
1329
+ sb_appendf(sb, "%s AS (PIVOT %s ON %s IN (%s) USING %s(%s))",
1330
+ cte_name, prev, qn.data, cats.data, sql_agg, qv.data);
1331
+ sb_free(&cats);
1332
+ } else {
1333
+ if (strcmp(prev, "input_data") != 0) {
1334
+ sb_free(&qn); sb_free(&qv);
1335
+ *error = strdup("pivot: dynamic SQL pivot after upstream transforms requires categories=...");
1336
+ return -1;
1337
+ }
1338
+ sb_appendf(sb,
1339
+ "%s AS (PIVOT %s ON %s IN (SELECT %s FROM "
1340
+ "(SELECT %s, row_number() OVER () AS \"__tf_pivot_category_ordinal\" FROM %s) __tf_pivot_cats "
1341
+ "GROUP BY %s ORDER BY min(\"__tf_pivot_category_ordinal\")) USING %s(%s))",
1342
+ cte_name, prev, qn.data, qn.data, qn.data, prev, qn.data, sql_agg, qv.data);
1343
+ }
1344
+ if (qn.failed || qv.failed || sb->failed) {
1345
+ sb_free(&qn); sb_free(&qv);
1346
+ *error = strdup("sql: out of memory");
1347
+ return -1;
1348
+ }
915
1349
  sb_free(&qn); sb_free(&qv);
916
1350
  return 0;
917
1351
  }
@@ -932,18 +1366,18 @@ static int emit_cte(strbuf *sb, const char *cte_name, const char *prev,
932
1366
  cJSON *b = cJSON_GetArrayItem(boundaries, i);
933
1367
  double val = b->valuedouble;
934
1368
  if (i == 0) {
935
- sb_appendf(&expr, " WHEN %s < %g THEN '<%g'", qcol.data, val, val);
1369
+ sb_appendf(&expr, " WHEN %s < " TF_FLOAT64_ROUNDTRIP_FORMAT " THEN '<" TF_FLOAT64_ROUNDTRIP_FORMAT "'", qcol.data, val, val);
936
1370
  }
937
1371
  if (i > 0) {
938
1372
  cJSON *prev_b = cJSON_GetArrayItem(boundaries, i - 1);
939
- sb_appendf(&expr, " WHEN %s >= %g AND %s < %g THEN '%g-%g'",
1373
+ sb_appendf(&expr, " WHEN %s >= " TF_FLOAT64_ROUNDTRIP_FORMAT " AND %s < " TF_FLOAT64_ROUNDTRIP_FORMAT " THEN '" TF_FLOAT64_ROUNDTRIP_FORMAT "-" TF_FLOAT64_ROUNDTRIP_FORMAT "'",
940
1374
  qcol.data, prev_b->valuedouble, qcol.data, val,
941
1375
  prev_b->valuedouble, val);
942
1376
  }
943
1377
  }
944
1378
  if (n > 0) {
945
1379
  cJSON *last = cJSON_GetArrayItem(boundaries, n - 1);
946
- sb_appendf(&expr, " WHEN %s >= %g THEN '>=%g'", qcol.data, last->valuedouble, last->valuedouble);
1380
+ sb_appendf(&expr, " WHEN %s >= " TF_FLOAT64_ROUNDTRIP_FORMAT " THEN '" TF_FLOAT64_ROUNDTRIP_FORMAT "+'", qcol.data, last->valuedouble, last->valuedouble);
947
1381
  }
948
1382
  sb_append(&expr, " END");
949
1383
  strbuf bin_col;
@@ -993,7 +1427,7 @@ static int emit_cte(strbuf *sb, const char *cte_name, const char *prev,
993
1427
  strbuf qc;
994
1428
  sb_init(&qc);
995
1429
  sql_quote_ident(&qc, c->valuestring);
996
- sb_appendf(&rep, "LAST_VALUE(%s IGNORE NULLS) OVER (ORDER BY rowid() ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS %s",
1430
+ sb_appendf(&rep, "LAST_VALUE(%s IGNORE NULLS) OVER (ORDER BY _rn ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS %s",
997
1431
  qc.data, qc.data);
998
1432
  sb_free(&qc);
999
1433
  }
@@ -1005,25 +1439,70 @@ static int emit_cte(strbuf *sb, const char *cte_name, const char *prev,
1005
1439
  return 0;
1006
1440
  }
1007
1441
 
1008
- /* ---- window ---- */
1009
- if (strcmp(op, "window") == 0) {
1442
+ /* ---- window / rolling ---- */
1443
+ if (strcmp(op, "window") == 0 || strcmp(op, "rolling-sum") == 0 ||
1444
+ strcmp(op, "rolling-mean") == 0 || strcmp(op, "rolling-min") == 0 ||
1445
+ strcmp(op, "rolling-max") == 0 || strcmp(op, "rolling-any") == 0 ||
1446
+ strcmp(op, "rolling-all") == 0) {
1010
1447
  const char *column = jstr(args, "column");
1011
1448
  int size = jint(args, "size", 3);
1012
1449
  const char *func = jstr(args, "func");
1013
1450
  const char *result = jstr(args, "result");
1451
+ int bool_roll = 0;
1452
+ if (strcmp(op, "rolling-sum") == 0) func = "sum";
1453
+ else if (strcmp(op, "rolling-mean") == 0) func = "avg";
1454
+ else if (strcmp(op, "rolling-min") == 0) func = "min";
1455
+ else if (strcmp(op, "rolling-max") == 0) func = "max";
1456
+ else if (strcmp(op, "rolling-any") == 0) { func = "any"; bool_roll = 1; }
1457
+ else if (strcmp(op, "rolling-all") == 0) { func = "all"; bool_roll = 1; }
1014
1458
  if (!column || !func) { *error = strdup("window: missing args"); return -1; }
1015
- const char *sql_func = "AVG";
1016
- if (strcmp(func, "sum") == 0) sql_func = "SUM";
1017
- else if (strcmp(func, "min") == 0) sql_func = "MIN";
1018
- else if (strcmp(func, "max") == 0) sql_func = "MAX";
1019
- else if (strcmp(func, "avg") == 0) sql_func = "AVG";
1459
+
1020
1460
  strbuf qcol, qres;
1021
1461
  sb_init(&qcol); sb_init(&qres);
1022
1462
  sql_quote_ident(&qcol, column);
1023
1463
  if (result) sql_quote_ident(&qres, result);
1024
- else sb_appendf(&qres, "\"%s_%s_%d\"", func, column, size);
1025
- sb_appendf(sb, "%s AS (SELECT *, %s(%s) OVER (ORDER BY rowid() ROWS BETWEEN %d PRECEDING AND CURRENT ROW) AS %s FROM %s)",
1026
- cte_name, sql_func, qcol.data, size - 1, qres.data, prev);
1464
+ else sb_appendf(&qres, "\"%s_%s%d\"", column, func, size);
1465
+
1466
+ int preceding = size > 0 ? size - 1 : 0;
1467
+ if (bool_roll) {
1468
+ const char *nulls = jstr(args, "nulls");
1469
+ if (!nulls) nulls = "ignore";
1470
+ if (strcmp(nulls, "ignore") != 0 && strcmp(nulls, "false") != 0 &&
1471
+ strcmp(nulls, "true") != 0 && strcmp(nulls, "propagate") != 0) {
1472
+ sb_free(&qcol); sb_free(&qres);
1473
+ *error = strdup("rolling-any/all: nulls must be ignore, false, true, or propagate");
1474
+ return -1;
1475
+ }
1476
+ const char *sql_func = strcmp(func, "any") == 0 ? "BOOL_OR" : "BOOL_AND";
1477
+ if (strcmp(nulls, "propagate") == 0) {
1478
+ sb_appendf(sb, "%s AS (SELECT *, CASE WHEN COUNT(*) OVER (ORDER BY _rn ROWS BETWEEN %d PRECEDING AND CURRENT ROW) > COUNT(%s) OVER (ORDER BY _rn ROWS BETWEEN %d PRECEDING AND CURRENT ROW) THEN NULL ELSE %s(%s) OVER (ORDER BY _rn ROWS BETWEEN %d PRECEDING AND CURRENT ROW) END AS %s FROM %s)",
1479
+ cte_name, preceding, qcol.data, preceding, sql_func, qcol.data, preceding, qres.data, prev);
1480
+ } else if (strcmp(nulls, "false") == 0 || strcmp(nulls, "true") == 0) {
1481
+ const char *fill = strcmp(nulls, "true") == 0 ? "TRUE" : "FALSE";
1482
+ sb_appendf(sb, "%s AS (SELECT *, %s(COALESCE(%s, %s)) OVER (ORDER BY _rn ROWS BETWEEN %d PRECEDING AND CURRENT ROW) AS %s FROM %s)",
1483
+ cte_name, sql_func, qcol.data, fill, preceding, qres.data, prev);
1484
+ } else {
1485
+ sb_appendf(sb, "%s AS (SELECT *, %s(%s) OVER (ORDER BY _rn ROWS BETWEEN %d PRECEDING AND CURRENT ROW) AS %s FROM %s)",
1486
+ cte_name, sql_func, qcol.data, preceding, qres.data, prev);
1487
+ }
1488
+ sb_free(&qcol); sb_free(&qres);
1489
+ return 0;
1490
+ }
1491
+
1492
+ const char *sql_func = "AVG";
1493
+ if (strcmp(func, "sum") == 0) sql_func = "SUM";
1494
+ else if (strcmp(func, "min") == 0) sql_func = "MIN";
1495
+ else if (strcmp(func, "max") == 0) sql_func = "MAX";
1496
+ else if (strcmp(func, "count") == 0) sql_func = "COUNT";
1497
+ else if (strcmp(func, "avg") == 0 || strcmp(func, "mean") == 0) sql_func = "AVG";
1498
+ sb_appendf(sb, "%s AS (SELECT * EXCLUDE (__tf_window_nn), "
1499
+ "CASE WHEN %s IS NULL THEN NULL ELSE %s(%s) OVER "
1500
+ "(ORDER BY __tf_window_nn RANGE BETWEEN %d PRECEDING AND CURRENT ROW) END AS %s "
1501
+ "FROM (SELECT *, COUNT(%s) OVER "
1502
+ "(ORDER BY _rn ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) "
1503
+ "AS __tf_window_nn FROM %s) __tf_window_src)",
1504
+ cte_name, qcol.data, sql_func, qcol.data, preceding,
1505
+ qres.data, qcol.data, prev);
1027
1506
  sb_free(&qcol); sb_free(&qres);
1028
1507
  return 0;
1029
1508
  }
@@ -1044,25 +1523,123 @@ static int emit_cte(strbuf *sb, const char *cte_name, const char *prev,
1044
1523
  sql_quote_ident(&qcol, column);
1045
1524
  if (result) sql_quote_ident(&qres, result);
1046
1525
  else sb_appendf(&qres, "\"%s_%s\"", func, column);
1047
- sb_appendf(sb, "%s AS (SELECT *, %s(%s) OVER (ORDER BY rowid() ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS %s FROM %s)",
1526
+ sb_appendf(sb, "%s AS (SELECT *, %s(%s) OVER (ORDER BY _rn ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS %s FROM %s)",
1048
1527
  cte_name, sql_func, qcol.data, qres.data, prev);
1049
1528
  sb_free(&qcol); sb_free(&qres);
1050
1529
  return 0;
1051
1530
  }
1052
1531
 
1053
- /* ---- lead ---- */
1054
- if (strcmp(op, "lead") == 0) {
1532
+ /* ---- rowid ---- */
1533
+ if (strcmp(op, "rowid") == 0) {
1534
+ cJSON *cols = cJSON_GetObjectItemCaseSensitive(args, "columns");
1535
+ const char *result = jstr(args, "result");
1536
+ if (!result) result = "_rowid";
1537
+
1538
+ strbuf qres;
1539
+ sb_init(&qres);
1540
+ sql_quote_ident(&qres, result);
1541
+
1542
+ strbuf partition;
1543
+ sb_init(&partition);
1544
+ if (cJSON_IsArray(cols) && cJSON_GetArraySize(cols) > 0) {
1545
+ int n_cols = cJSON_GetArraySize(cols);
1546
+ for (int i = 0; i < n_cols; i++) {
1547
+ cJSON *item = cJSON_GetArrayItem(cols, i);
1548
+ if (!cJSON_IsString(item)) {
1549
+ sb_free(&qres);
1550
+ sb_free(&partition);
1551
+ *error = strdup("rowid: columns must be strings");
1552
+ return -1;
1553
+ }
1554
+ strbuf qcol;
1555
+ sb_init(&qcol);
1556
+ sql_quote_ident(&qcol, item->valuestring);
1557
+ if (i > 0) sb_append(&partition, ", ");
1558
+ sb_append(&partition, qcol.data);
1559
+ sb_free(&qcol);
1560
+ }
1561
+ }
1562
+
1563
+ if (partition.len > 0) {
1564
+ sb_appendf(sb, "%s AS (SELECT *, ROW_NUMBER() OVER (PARTITION BY %s ORDER BY _rn) AS %s FROM %s)",
1565
+ cte_name, partition.data, qres.data, prev);
1566
+ } else {
1567
+ sb_appendf(sb, "%s AS (SELECT *, ROW_NUMBER() OVER (ORDER BY _rn) AS %s FROM %s)",
1568
+ cte_name, qres.data, prev);
1569
+ }
1570
+ sb_free(&qres);
1571
+ sb_free(&partition);
1572
+ return 0;
1573
+ }
1574
+
1575
+ /* ---- rleid ---- */
1576
+ if (strcmp(op, "rleid") == 0) {
1577
+ cJSON *cols = cJSON_GetObjectItemCaseSensitive(args, "columns");
1578
+ const char *result = jstr(args, "result");
1579
+ if (!result) result = "_rleid";
1580
+ if (!cJSON_IsArray(cols) || cJSON_GetArraySize(cols) <= 0) {
1581
+ *error = strdup("rleid: missing 'columns'");
1582
+ return -1;
1583
+ }
1584
+
1585
+ strbuf qres;
1586
+ sb_init(&qres);
1587
+ sql_quote_ident(&qres, result);
1588
+
1589
+ strbuf change;
1590
+ sb_init(&change);
1591
+ int n_cols = cJSON_GetArraySize(cols);
1592
+ for (int i = 0; i < n_cols; i++) {
1593
+ cJSON *item = cJSON_GetArrayItem(cols, i);
1594
+ if (!cJSON_IsString(item)) {
1595
+ sb_free(&qres);
1596
+ sb_free(&change);
1597
+ *error = strdup("rleid: columns must be strings");
1598
+ return -1;
1599
+ }
1600
+ strbuf qcol;
1601
+ sb_init(&qcol);
1602
+ sql_quote_ident(&qcol, item->valuestring);
1603
+ if (i > 0) sb_append(&change, " OR ");
1604
+ sb_appendf(&change, "%s IS DISTINCT FROM LAG(%s) OVER (ORDER BY _rn)",
1605
+ qcol.data, qcol.data);
1606
+ sb_free(&qcol);
1607
+ }
1608
+
1609
+ sb_appendf(sb, "%s AS (SELECT * EXCLUDE (__tf_rleid_changed, __tf_rleid_ord), "
1610
+ "SUM(__tf_rleid_changed) OVER (ORDER BY __tf_rleid_ord ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS %s "
1611
+ "FROM (SELECT *, _rn AS __tf_rleid_ord, "
1612
+ "CASE WHEN ROW_NUMBER() OVER (ORDER BY _rn) = 1 OR %s THEN 1 ELSE 0 END AS __tf_rleid_changed "
1613
+ "FROM %s) __tf_rleid_src)",
1614
+ cte_name, qres.data, change.data, prev);
1615
+ sb_free(&qres);
1616
+ sb_free(&change);
1617
+ return 0;
1618
+ }
1619
+
1620
+ /* ---- lead / lag / shift ---- */
1621
+ if (strcmp(op, "lead") == 0 || strcmp(op, "lag") == 0 || strcmp(op, "shift") == 0) {
1055
1622
  const char *column = jstr(args, "column");
1056
1623
  int offset = jint(args, "offset", 1);
1057
1624
  const char *result = jstr(args, "result");
1058
- if (!column) { *error = strdup("lead: missing 'column'"); return -1; }
1625
+ const char *type = jstr(args, "type");
1626
+ const char *fn = "LAG";
1627
+ const char *suffix = "lag";
1628
+ if (strcmp(op, "lead") == 0 || (strcmp(op, "shift") == 0 && type && strcmp(type, "lead") == 0)) {
1629
+ fn = "LEAD";
1630
+ suffix = "lead";
1631
+ } else if (strcmp(op, "shift") == 0) {
1632
+ suffix = "shift";
1633
+ }
1634
+ if (!column) { *error = strdup("shift: missing 'column'"); return -1; }
1635
+ if (offset <= 0) offset = 1;
1059
1636
  strbuf qcol, qres;
1060
1637
  sb_init(&qcol); sb_init(&qres);
1061
1638
  sql_quote_ident(&qcol, column);
1062
1639
  if (result) sql_quote_ident(&qres, result);
1063
- else sb_appendf(&qres, "\"%s_lead_%d\"", column, offset);
1064
- sb_appendf(sb, "%s AS (SELECT *, LEAD(%s, %d) OVER (ORDER BY rowid()) AS %s FROM %s)",
1065
- cte_name, qcol.data, offset, qres.data, prev);
1640
+ else sb_appendf(&qres, "\"%s_%s\"", column, suffix);
1641
+ sb_appendf(sb, "%s AS (SELECT *, %s(%s, %d) OVER (ORDER BY _rn) AS %s FROM %s)",
1642
+ cte_name, fn, qcol.data, offset, qres.data, prev);
1066
1643
  sb_free(&qcol); sb_free(&qres);
1067
1644
  return 0;
1068
1645
  }
@@ -1083,10 +1660,8 @@ static int emit_cte(strbuf *sb, const char *cte_name, const char *prev,
1083
1660
  cJSON *part = cJSON_GetArrayItem(extract, i);
1084
1661
  if (!cJSON_IsString(part)) continue;
1085
1662
  const char *p = part->valuestring;
1086
- char result_name[128];
1087
- snprintf(result_name, sizeof(result_name), "%s_%s", column, p);
1088
1663
  sb_appendf(&der, ", EXTRACT(%s FROM %s::TIMESTAMP) AS ", p, qcol.data);
1089
- sql_quote_ident(&der, result_name);
1664
+ sql_quote_joined_ident(&der, column, "_", p);
1090
1665
  }
1091
1666
  }
1092
1667
  sb_appendf(sb, "%s AS (SELECT *%s FROM %s)", cte_name, der.data, prev);
@@ -1105,23 +1680,23 @@ static int emit_cte(strbuf *sb, const char *cte_name, const char *prev,
1105
1680
  sql_quote_ident(&qcol, column);
1106
1681
  strbuf qres;
1107
1682
  sb_init(&qres);
1108
- if (result) sql_quote_ident(&qres, result);
1109
- else {
1110
- char buf[128];
1111
- snprintf(buf, sizeof(buf), "%s_%s", column, trunc);
1112
- sql_quote_ident(&qres, buf);
1683
+ int in_place = result == NULL;
1684
+ sql_quote_ident(&qres, result ? result : column);
1685
+ if (in_place) {
1686
+ sb_appendf(sb, "%s AS (SELECT * REPLACE (date_trunc('%s', %s::TIMESTAMP) AS %s) FROM %s)",
1687
+ cte_name, trunc, qcol.data, qres.data, prev);
1688
+ } else {
1689
+ sb_appendf(sb, "%s AS (SELECT *, date_trunc('%s', %s::TIMESTAMP) AS %s FROM %s)",
1690
+ cte_name, trunc, qcol.data, qres.data, prev);
1113
1691
  }
1114
- sb_appendf(sb, "%s AS (SELECT *, date_trunc('%s', %s::TIMESTAMP) AS %s FROM %s)",
1115
- cte_name, trunc, qcol.data, qres.data, prev);
1116
1692
  sb_free(&qcol); sb_free(&qres);
1117
1693
  return 0;
1118
1694
  }
1119
1695
 
1120
1696
  /* ---- stats ---- */
1121
1697
  if (strcmp(op, "stats") == 0) {
1122
- /* Stats uses DuckDB's SUMMARIZE — works without knowing column names */
1123
- sb_appendf(sb, "%s AS (SELECT * FROM (SUMMARIZE SELECT * FROM %s))", cte_name, prev);
1124
- return 0;
1698
+ *error = strdup("stats: native report shape cannot be lowered to SQL");
1699
+ return -1;
1125
1700
  }
1126
1701
 
1127
1702
  /* ---- flatten ---- */
@@ -1174,7 +1749,25 @@ char *tf_ir_to_sql(const tf_ir_plan *plan, char **error) {
1174
1749
  /* No transforms — just SELECT * */
1175
1750
  if (first_transform >= last_transform) {
1176
1751
  sb_appendf(&sb, "SELECT * FROM %s", input_source);
1177
- return sb_detach(&sb);
1752
+ char *sql = sb_detach(&sb);
1753
+ if (!sql && error && !*error) *error = strdup("sql: out of memory");
1754
+ return sql;
1755
+ }
1756
+
1757
+ /* Check if any operator needs row ordering (_rn column) */
1758
+ int needs_rn = 0;
1759
+ for (size_t i = first_transform; i < last_transform; i++) {
1760
+ const char *op = plan->nodes[i].op;
1761
+ if (strcmp(op, "window") == 0 || strcmp(op, "rolling-sum") == 0 ||
1762
+ strcmp(op, "rolling-mean") == 0 || strcmp(op, "rolling-min") == 0 ||
1763
+ strcmp(op, "rolling-max") == 0 || strcmp(op, "rolling-any") == 0 ||
1764
+ strcmp(op, "rolling-all") == 0 || strcmp(op, "step") == 0 ||
1765
+ strcmp(op, "lead") == 0 || strcmp(op, "lag") == 0 ||
1766
+ strcmp(op, "shift") == 0 || strcmp(op, "rowid") == 0 ||
1767
+ strcmp(op, "rleid") == 0 || strcmp(op, "fill-down") == 0) {
1768
+ needs_rn = 1;
1769
+ break;
1770
+ }
1178
1771
  }
1179
1772
 
1180
1773
  /* Build CTE chain — use two alternating name buffers so prev != current */
@@ -1185,6 +1778,13 @@ char *tf_ir_to_sql(const tf_ir_plan *plan, char **error) {
1185
1778
 
1186
1779
  sb_append(&sb, "WITH\n");
1187
1780
 
1781
+ /* If row ordering is needed, inject a _rn column from the source */
1782
+ if (needs_rn) {
1783
+ sb_appendf(&sb, " _numbered AS (SELECT *, ROW_NUMBER() OVER () AS _rn FROM %s)", input_source);
1784
+ prev = "_numbered";
1785
+ n_ctes++;
1786
+ }
1787
+
1188
1788
  for (size_t i = first_transform; i < last_transform; i++) {
1189
1789
  const tf_ir_node *node = &plan->nodes[i];
1190
1790
  char *cte_name = name_bufs[n_ctes % 2];
@@ -1199,13 +1799,24 @@ char *tf_ir_to_sql(const tf_ir_plan *plan, char **error) {
1199
1799
  else free(err);
1200
1800
  return NULL;
1201
1801
  }
1802
+ if (sb.failed) {
1803
+ sb_free(&sb);
1804
+ if (error) *error = strdup("sql: out of memory");
1805
+ return NULL;
1806
+ }
1202
1807
 
1203
1808
  prev = cte_name;
1204
1809
  n_ctes++;
1205
1810
  }
1206
1811
 
1207
- /* Final SELECT from last CTE */
1208
- sb_appendf(&sb, "\nSELECT * FROM %s", prev);
1812
+ /* Final SELECT from last CTE — exclude internal _rn column */
1813
+ if (needs_rn) {
1814
+ sb_appendf(&sb, "\nSELECT * EXCLUDE (_rn) FROM %s", prev);
1815
+ } else {
1816
+ sb_appendf(&sb, "\nSELECT * FROM %s", prev);
1817
+ }
1209
1818
 
1210
- return sb_detach(&sb);
1819
+ char *sql = sb_detach(&sb);
1820
+ if (!sql && error && !*error) *error = strdup("sql: out of memory");
1821
+ return sql;
1211
1822
  }