tranfi 0.0.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 (151) hide show
  1. package/LICENSE +177 -21
  2. package/NOTICE +8 -0
  3. package/README.md +627 -0
  4. package/app/assets/index-6quYZ5Ap.css +5 -0
  5. package/app/assets/index-BIAIKnrp.js +160 -0
  6. package/app/assets/materialdesignicons-webfont-B7mPwVP_.ttf +0 -0
  7. package/app/assets/materialdesignicons-webfont-CSr8KVlo.eot +0 -0
  8. package/app/assets/materialdesignicons-webfont-Dp5v-WZN.woff2 +0 -0
  9. package/app/assets/materialdesignicons-webfont-PXm3-2wK.woff +0 -0
  10. package/app/index.html +13 -0
  11. package/binding.gyp +121 -0
  12. package/csrc/arena.c +93 -0
  13. package/csrc/batch.c +976 -0
  14. package/csrc/buffer.c +154 -0
  15. package/csrc/cJSON.c +3386 -0
  16. package/csrc/cJSON.h +316 -0
  17. package/csrc/codec_csv.c +1951 -0
  18. package/csrc/codec_jsonl.c +1086 -0
  19. package/csrc/codec_table.c +248 -0
  20. package/csrc/codec_text.c +447 -0
  21. package/csrc/compiler.c +130 -0
  22. package/csrc/config.h +21 -0
  23. package/csrc/date_utils.h +94 -0
  24. package/csrc/dsl.c +5417 -0
  25. package/csrc/dsl.h +22 -0
  26. package/csrc/expr.c +1553 -0
  27. package/csrc/expr.h +58 -0
  28. package/csrc/internal.h +539 -0
  29. package/csrc/ir.c +166 -0
  30. package/csrc/ir.h +208 -0
  31. package/csrc/ir_schema.c +75 -0
  32. package/csrc/ir_serialize.c +166 -0
  33. package/csrc/ir_sql.c +1822 -0
  34. package/csrc/ir_validate.c +576 -0
  35. package/csrc/json_path.c +210 -0
  36. package/csrc/main.c +1241 -0
  37. package/csrc/memory_estimate.c +477 -0
  38. package/csrc/op_acf.c +283 -0
  39. package/csrc/op_across.c +477 -0
  40. package/csrc/op_anomaly.c +255 -0
  41. package/csrc/op_assert.c +761 -0
  42. package/csrc/op_bin.c +248 -0
  43. package/csrc/op_cast.c +523 -0
  44. package/csrc/op_clip.c +99 -0
  45. package/csrc/op_date_trunc.c +355 -0
  46. package/csrc/op_datetime.c +394 -0
  47. package/csrc/op_derive.c +216 -0
  48. package/csrc/op_diff.c +250 -0
  49. package/csrc/op_ewma.c +222 -0
  50. package/csrc/op_explode.c +206 -0
  51. package/csrc/op_fill_down.c +235 -0
  52. package/csrc/op_fill_null.c +268 -0
  53. package/csrc/op_filter.c +181 -0
  54. package/csrc/op_frequency.c +721 -0
  55. package/csrc/op_grep.c +181 -0
  56. package/csrc/op_group_agg.c +1956 -0
  57. package/csrc/op_hash.c +159 -0
  58. package/csrc/op_head.c +84 -0
  59. package/csrc/op_interpolate.c +445 -0
  60. package/csrc/op_join.c +2902 -0
  61. package/csrc/op_json_extract.c +227 -0
  62. package/csrc/op_json_filter.c +384 -0
  63. package/csrc/op_json_flatten.c +293 -0
  64. package/csrc/op_json_schema.c +503 -0
  65. package/csrc/op_label_encode.c +419 -0
  66. package/csrc/op_lag.c +181 -0
  67. package/csrc/op_lead.c +242 -0
  68. package/csrc/op_normalize.c +510 -0
  69. package/csrc/op_onehot.c +457 -0
  70. package/csrc/op_pivot.c +1754 -0
  71. package/csrc/op_quarantine.c +189 -0
  72. package/csrc/op_registry.c +3044 -0
  73. package/csrc/op_rename.c +129 -0
  74. package/csrc/op_replace.c +354 -0
  75. package/csrc/op_rleid.c +297 -0
  76. package/csrc/op_rowid.c +559 -0
  77. package/csrc/op_sample.c +158 -0
  78. package/csrc/op_schema.c +1341 -0
  79. package/csrc/op_schema_infer.c +252 -0
  80. package/csrc/op_select.c +340 -0
  81. package/csrc/op_set.c +3449 -0
  82. package/csrc/op_skip.c +95 -0
  83. package/csrc/op_sort.c +819 -0
  84. package/csrc/op_source_name.c +120 -0
  85. package/csrc/op_split.c +151 -0
  86. package/csrc/op_split_data.c +119 -0
  87. package/csrc/op_stack.c +271 -0
  88. package/csrc/op_stats.c +875 -0
  89. package/csrc/op_step.c +333 -0
  90. package/csrc/op_tail.c +105 -0
  91. package/csrc/op_tee.c +338 -0
  92. package/csrc/op_top.c +357 -0
  93. package/csrc/op_trim.c +138 -0
  94. package/csrc/op_unique.c +1343 -0
  95. package/csrc/op_unpivot.c +193 -0
  96. package/csrc/op_validate.c +648 -0
  97. package/csrc/op_window.c +591 -0
  98. package/csrc/path_policy.c +85 -0
  99. package/csrc/pipeline.c +1088 -0
  100. package/csrc/recipes.c +104 -0
  101. package/csrc/recipes.h +27 -0
  102. package/csrc/report.c +506 -0
  103. package/csrc/report.h +22 -0
  104. package/csrc/selector.c +1097 -0
  105. package/csrc/size_utils.c +348 -0
  106. package/csrc/spill.c +317 -0
  107. package/csrc/spill.h +21 -0
  108. package/csrc/tranfi.h +291 -0
  109. package/csrc/transform.h +209 -0
  110. package/csrc/transform_api.c +2237 -0
  111. package/csrc/transform_categorical.c +923 -0
  112. package/csrc/transform_internal.h +472 -0
  113. package/csrc/transform_json.c +3812 -0
  114. package/csrc/transform_numeric.c +1966 -0
  115. package/csrc/transform_sha256.c +154 -0
  116. package/csrc/transform_wasm.h +162 -0
  117. package/csrc/transform_wasm_api.c +1373 -0
  118. package/csrc/wasm_api.c +218 -0
  119. package/napi_api.c +534 -0
  120. package/napi_transform.c +1648 -0
  121. package/napi_transform.h +8 -0
  122. package/package.json +64 -59
  123. package/scripts/install-native.js +76 -0
  124. package/scripts/prepack.js +64 -0
  125. package/scripts/sync-csrc.js +23 -0
  126. package/src/cli.js +190 -0
  127. package/src/engines/duckdb.js +142 -0
  128. package/src/index.js +925 -0
  129. package/src/memory_policy.js +411 -0
  130. package/src/native.js +18 -0
  131. package/src/pipeline.js +709 -0
  132. package/src/recipe_json.js +80 -0
  133. package/src/server.js +279 -0
  134. package/src/transform.js +403 -0
  135. package/src/transform_error.js +10 -0
  136. package/src/wasm.js +21 -0
  137. package/wasm/index.js +732 -0
  138. package/wasm/package.json +1 -0
  139. package/wasm/tranfi_core.js +0 -0
  140. package/wasm/transform.js +1156 -0
  141. package/wasm/worker.js +786 -0
  142. package/dist/bundle.js +0 -1
  143. package/index.html +0 -18
  144. package/src/app.css +0 -169
  145. package/src/app.js +0 -203
  146. package/src/app.vue +0 -250
  147. package/src/bulma-input.vue +0 -110
  148. package/src/common-inputs.js +0 -28
  149. package/src/main.js +0 -20
  150. package/src/transforms.js +0 -166
  151. package/webpack.config.js +0 -108
package/csrc/ir_sql.c ADDED
@@ -0,0 +1,1822 @@
1
+ /*
2
+ * ir_sql.c — IR plan to SQL transpiler.
3
+ *
4
+ * Converts a validated IR plan to a DuckDB-compatible SQL query.
5
+ * Each transform step becomes a CTE in a WITH chain.
6
+ * Expressions are translated from tranfi syntax to SQL syntax.
7
+ */
8
+
9
+ #include "internal.h"
10
+ #include "expr.h"
11
+ #include "cJSON.h"
12
+ #include <stdlib.h>
13
+ #include <string.h>
14
+ #include <stdio.h>
15
+ #include <stdarg.h>
16
+ #include <ctype.h>
17
+
18
+ /* ---- Dynamic string builder ---- */
19
+
20
+ typedef struct {
21
+ char *data;
22
+ size_t len;
23
+ size_t cap;
24
+ int failed;
25
+ } strbuf;
26
+
27
+ static void sb_free(strbuf *sb);
28
+
29
+ static void sb_init(strbuf *sb) {
30
+ sb->data = tf_mallocarray_checked(256, sizeof(char));
31
+ sb->len = 0;
32
+ sb->cap = 256;
33
+ sb->failed = 0;
34
+ if (sb->data) sb->data[0] = '\0';
35
+ else { sb->cap = 0; sb->failed = 1; }
36
+ }
37
+
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;
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;
60
+ }
61
+
62
+ static void sb_append(strbuf *sb, const char *s) {
63
+ if (!sb || sb->failed || !s) return;
64
+ size_t n = strlen(s);
65
+ if (sb_ensure(sb, n) != TF_OK) return;
66
+ memcpy(sb->data + sb->len, s, n);
67
+ sb->len += n;
68
+ sb->data[sb->len] = '\0';
69
+ }
70
+
71
+ static void sb_appendn(strbuf *sb, const char *s, size_t 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;
78
+ memcpy(sb->data + sb->len, s, n);
79
+ sb->len += n;
80
+ sb->data[sb->len] = '\0';
81
+ }
82
+
83
+ static void sb_appendf(strbuf *sb, const char *fmt, ...)
84
+ __attribute__((format(printf, 2, 3)));
85
+
86
+ static void sb_appendf(strbuf *sb, const char *fmt, ...) {
87
+ if (!sb || sb->failed || !fmt) return;
88
+ char buf[512];
89
+ va_list ap;
90
+ va_start(ap, fmt);
91
+ int n = vsnprintf(buf, sizeof(buf), fmt, ap);
92
+ va_end(ap);
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);
122
+ }
123
+
124
+ static char *sb_detach(strbuf *sb) {
125
+ if (!sb || sb->failed) {
126
+ if (sb) sb_free(sb);
127
+ return NULL;
128
+ }
129
+ char *s = sb->data;
130
+ sb->data = NULL;
131
+ sb->len = sb->cap = 0;
132
+ sb->failed = 0;
133
+ return s;
134
+ }
135
+
136
+ static void sb_free(strbuf *sb) {
137
+ if (!sb) return;
138
+ free(sb->data);
139
+ sb->data = NULL;
140
+ sb->len = sb->cap = 0;
141
+ sb->failed = 0;
142
+ }
143
+
144
+ /* ---- Expression AST to SQL ---- */
145
+
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++) {
149
+ if (*p == '"') sb_append(sb, "\"\"");
150
+ else sb_appendn(sb, p, 1);
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);
184
+ sb_append(sb, "\"");
185
+ }
186
+
187
+ /* Append a SQL string literal: 'value' */
188
+ static void sql_quote_str(strbuf *sb, const char *s) {
189
+ sb_append(sb, "'");
190
+ for (const char *p = s; *p; p++) {
191
+ if (*p == '\'') sb_append(sb, "''");
192
+ else sb_appendn(sb, p, 1);
193
+ }
194
+ sb_append(sb, "'");
195
+ }
196
+
197
+ /* Function name mapping: tranfi → SQL */
198
+ static const char *map_func_name(const char *name) {
199
+ if (strcmp(name, "len") == 0) return "length";
200
+ if (strcmp(name, "pad_left") == 0) return "lpad";
201
+ if (strcmp(name, "pad_right") == 0) return "rpad";
202
+ if (strcmp(name, "mod") == 0) return NULL; /* special: a % b */
203
+ return name; /* most map directly: upper, lower, abs, round, etc. */
204
+ }
205
+
206
+ static int expr_to_sql(const tf_expr *e, strbuf *sb);
207
+
208
+ static int expr_to_sql(const tf_expr *e, strbuf *sb) {
209
+ if (!e) return -1;
210
+
211
+ switch (e->kind) {
212
+ case EXPR_LIT_INT:
213
+ sb_appendf(sb, "%lld", (long long)e->lit_int);
214
+ return 0;
215
+
216
+ case EXPR_LIT_FLOAT:
217
+ sb_appendf(sb, TF_FLOAT64_ROUNDTRIP_FORMAT, e->lit_float);
218
+ return 0;
219
+
220
+ case EXPR_LIT_STR:
221
+ sql_quote_str(sb, e->lit_str);
222
+ return 0;
223
+
224
+ case EXPR_LIT_BOOL:
225
+ sb_append(sb, e->lit_bool ? "TRUE" : "FALSE");
226
+ return 0;
227
+
228
+ case EXPR_COL_REF:
229
+ sql_quote_ident(sb, e->col_name);
230
+ return 0;
231
+
232
+ case EXPR_CMP: {
233
+ sb_append(sb, "(");
234
+ if (expr_to_sql(e->cmp.left, sb) != 0) return -1;
235
+ switch (e->cmp.op) {
236
+ case CMP_GT: sb_append(sb, " > "); break;
237
+ case CMP_GE: sb_append(sb, " >= "); break;
238
+ case CMP_LT: sb_append(sb, " < "); break;
239
+ case CMP_LE: sb_append(sb, " <= "); break;
240
+ case CMP_EQ: sb_append(sb, " = "); break;
241
+ case CMP_NE: sb_append(sb, " <> "); break;
242
+ }
243
+ if (expr_to_sql(e->cmp.right, sb) != 0) return -1;
244
+ sb_append(sb, ")");
245
+ return 0;
246
+ }
247
+
248
+ case EXPR_AND:
249
+ sb_append(sb, "(");
250
+ if (expr_to_sql(e->binary.left, sb) != 0) return -1;
251
+ sb_append(sb, " AND ");
252
+ if (expr_to_sql(e->binary.right, sb) != 0) return -1;
253
+ sb_append(sb, ")");
254
+ return 0;
255
+
256
+ case EXPR_OR:
257
+ sb_append(sb, "(");
258
+ if (expr_to_sql(e->binary.left, sb) != 0) return -1;
259
+ sb_append(sb, " OR ");
260
+ if (expr_to_sql(e->binary.right, sb) != 0) return -1;
261
+ sb_append(sb, ")");
262
+ return 0;
263
+
264
+ case EXPR_NOT:
265
+ sb_append(sb, "(NOT ");
266
+ if (expr_to_sql(e->child, sb) != 0) return -1;
267
+ sb_append(sb, ")");
268
+ return 0;
269
+
270
+ case EXPR_NEG:
271
+ sb_append(sb, "(- ");
272
+ if (expr_to_sql(e->child, sb) != 0) return -1;
273
+ sb_append(sb, ")");
274
+ return 0;
275
+
276
+ case EXPR_ADD:
277
+ sb_append(sb, "(");
278
+ if (expr_to_sql(e->binary.left, sb) != 0) return -1;
279
+ sb_append(sb, " + ");
280
+ if (expr_to_sql(e->binary.right, sb) != 0) return -1;
281
+ sb_append(sb, ")");
282
+ return 0;
283
+
284
+ case EXPR_SUB:
285
+ sb_append(sb, "(");
286
+ if (expr_to_sql(e->binary.left, sb) != 0) return -1;
287
+ sb_append(sb, " - ");
288
+ if (expr_to_sql(e->binary.right, sb) != 0) return -1;
289
+ sb_append(sb, ")");
290
+ return 0;
291
+
292
+ case EXPR_MUL:
293
+ sb_append(sb, "(");
294
+ if (expr_to_sql(e->binary.left, sb) != 0) return -1;
295
+ sb_append(sb, " * ");
296
+ if (expr_to_sql(e->binary.right, sb) != 0) return -1;
297
+ sb_append(sb, ")");
298
+ return 0;
299
+
300
+ case EXPR_DIV:
301
+ sb_append(sb, "(");
302
+ if (expr_to_sql(e->binary.left, sb) != 0) return -1;
303
+ sb_append(sb, " / ");
304
+ if (expr_to_sql(e->binary.right, sb) != 0) return -1;
305
+ sb_append(sb, ")");
306
+ return 0;
307
+
308
+ case EXPR_FUNC_CALL: {
309
+ const char *name = e->func.name;
310
+ int n = e->func.n_args;
311
+
312
+ /* Special: if(cond, then, else) → CASE WHEN ... THEN ... ELSE ... END */
313
+ if (strcmp(name, "if") == 0 && n == 3) {
314
+ sb_append(sb, "(CASE WHEN ");
315
+ if (expr_to_sql(e->func.args[0], sb) != 0) return -1;
316
+ sb_append(sb, " THEN ");
317
+ if (expr_to_sql(e->func.args[1], sb) != 0) return -1;
318
+ sb_append(sb, " ELSE ");
319
+ if (expr_to_sql(e->func.args[2], sb) != 0) return -1;
320
+ sb_append(sb, " END)");
321
+ return 0;
322
+ }
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
+
421
+ /* Special: mod(a, b) → (a % b) */
422
+ if (strcmp(name, "mod") == 0 && n == 2) {
423
+ sb_append(sb, "(");
424
+ if (expr_to_sql(e->func.args[0], sb) != 0) return -1;
425
+ sb_append(sb, " % ");
426
+ if (expr_to_sql(e->func.args[1], sb) != 0) return -1;
427
+ sb_append(sb, ")");
428
+ return 0;
429
+ }
430
+
431
+ /* Special: slice(s, start, len) → substr(s, start+1, len)
432
+ * tranfi uses 0-based, SQL uses 1-based */
433
+ if (strcmp(name, "slice") == 0 && n >= 2) {
434
+ sb_append(sb, "substr(");
435
+ if (expr_to_sql(e->func.args[0], sb) != 0) return -1;
436
+ sb_append(sb, ", (");
437
+ if (expr_to_sql(e->func.args[1], sb) != 0) return -1;
438
+ sb_append(sb, ") + 1");
439
+ if (n >= 3) {
440
+ sb_append(sb, ", ");
441
+ if (expr_to_sql(e->func.args[2], sb) != 0) return -1;
442
+ }
443
+ sb_append(sb, ")");
444
+ return 0;
445
+ }
446
+
447
+ /* General function call with name mapping */
448
+ const char *sql_name = map_func_name(name);
449
+ if (!sql_name) sql_name = name;
450
+ sb_append(sb, sql_name);
451
+ sb_append(sb, "(");
452
+ for (int i = 0; i < n; i++) {
453
+ if (i > 0) sb_append(sb, ", ");
454
+ if (expr_to_sql(e->func.args[i], sb) != 0) return -1;
455
+ }
456
+ sb_append(sb, ")");
457
+ return 0;
458
+ }
459
+ }
460
+ return -1;
461
+ }
462
+
463
+ /* Parse an expression string and convert to SQL.
464
+ * Returns heap-allocated SQL string, or NULL on error. */
465
+ static char *translate_expr(const char *expr_str) {
466
+ tf_expr *e = tf_expr_parse(expr_str);
467
+ if (!e) return NULL;
468
+ strbuf sb;
469
+ sb_init(&sb);
470
+ int rc = expr_to_sql(e, &sb);
471
+ tf_expr_free(e);
472
+ if (rc != 0 || sb.failed) { sb_free(&sb); return NULL; }
473
+ return sb_detach(&sb);
474
+ }
475
+
476
+ /* ---- Op handlers ---- */
477
+
478
+ /* Helper: get cJSON string value */
479
+ static const char *jstr(const cJSON *obj, const char *key) {
480
+ cJSON *v = cJSON_GetObjectItemCaseSensitive(obj, key);
481
+ return (v && cJSON_IsString(v)) ? v->valuestring : NULL;
482
+ }
483
+
484
+ static int jint(const cJSON *obj, const char *key, int def) {
485
+ cJSON *v = cJSON_GetObjectItemCaseSensitive(obj, key);
486
+ return (v && cJSON_IsNumber(v)) ? v->valueint : def;
487
+ }
488
+
489
+ static int jbool(const cJSON *obj, const char *key, int def) {
490
+ cJSON *v = cJSON_GetObjectItemCaseSensitive(obj, key);
491
+ if (!v) return def;
492
+ if (cJSON_IsTrue(v)) return 1;
493
+ if (cJSON_IsFalse(v)) return 0;
494
+ return def;
495
+ }
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
+
629
+ /* Emit a CTE for a transform op. prev is the name of the previous CTE/source.
630
+ * Appends SQL like: step_N AS (SELECT ... FROM prev ...) */
631
+ static int emit_cte(strbuf *sb, const char *cte_name, const char *prev,
632
+ const char *op, const cJSON *args, char **error) {
633
+
634
+ /* ---- filter ---- */
635
+ if (strcmp(op, "filter") == 0) {
636
+ const char *expr = jstr(args, "expr");
637
+ if (!expr) { *error = strdup("filter: missing 'expr'"); return -1; }
638
+ char *sql_expr = translate_expr(expr);
639
+ if (!sql_expr) { *error = strdup("filter: failed to translate expression"); return -1; }
640
+ sb_appendf(sb, "%s AS (SELECT * FROM %s WHERE %s)", cte_name, prev, sql_expr);
641
+ free(sql_expr);
642
+ return 0;
643
+ }
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
+
679
+ /* ---- select / reorder ---- */
680
+ if (strcmp(op, "select") == 0 || strcmp(op, "reorder") == 0) {
681
+ cJSON *cols = cJSON_GetObjectItemCaseSensitive(args, "columns");
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
+ }
687
+ strbuf sel;
688
+ sb_init(&sel);
689
+ int n = cJSON_GetArraySize(cols);
690
+ for (int i = 0; i < n; i++) {
691
+ if (i > 0) sb_append(&sel, ", ");
692
+ cJSON *c = cJSON_GetArrayItem(cols, i);
693
+ if (cJSON_IsString(c)) sql_quote_ident(&sel, c->valuestring);
694
+ }
695
+ sb_appendf(sb, "%s AS (SELECT %s FROM %s)", cte_name, sel.data, prev);
696
+ sb_free(&sel);
697
+ return 0;
698
+ }
699
+
700
+ /* ---- rename ---- */
701
+ if (strcmp(op, "rename") == 0) {
702
+ cJSON *mapping = cJSON_GetObjectItemCaseSensitive(args, "mapping");
703
+ if (!mapping) { *error = strdup("rename: missing 'mapping'"); return -1; }
704
+ /* Use DuckDB RENAME extension: SELECT * RENAME (old AS new, ...) */
705
+ strbuf rn;
706
+ sb_init(&rn);
707
+ int first = 1;
708
+ cJSON *item = NULL;
709
+ cJSON_ArrayForEach(item, mapping) {
710
+ if (!cJSON_IsString(item)) continue;
711
+ if (!first) sb_append(&rn, ", ");
712
+ first = 0;
713
+ sql_quote_ident(&rn, item->string);
714
+ sb_append(&rn, " AS ");
715
+ sql_quote_ident(&rn, item->valuestring);
716
+ }
717
+ sb_appendf(sb, "%s AS (SELECT * RENAME (%s) FROM %s)", cte_name, rn.data, prev);
718
+ sb_free(&rn);
719
+ return 0;
720
+ }
721
+
722
+ /* ---- derive ---- */
723
+ if (strcmp(op, "derive") == 0) {
724
+ cJSON *columns = cJSON_GetObjectItemCaseSensitive(args, "columns");
725
+ if (!columns || !cJSON_IsArray(columns)) { *error = strdup("derive: missing 'columns'"); return -1; }
726
+ strbuf der;
727
+ sb_init(&der);
728
+ int n = cJSON_GetArraySize(columns);
729
+ for (int i = 0; i < n; i++) {
730
+ cJSON *col = cJSON_GetArrayItem(columns, i);
731
+ const char *name = jstr(col, "name");
732
+ const char *expr = jstr(col, "expr");
733
+ if (!name || !expr) continue;
734
+ char *sql_expr = translate_expr(expr);
735
+ if (!sql_expr) { sb_free(&der); *error = strdup("derive: failed to translate expression"); return -1; }
736
+ sb_append(&der, ", ");
737
+ sb_append(&der, sql_expr);
738
+ sb_append(&der, " AS ");
739
+ sql_quote_ident(&der, name);
740
+ free(sql_expr);
741
+ }
742
+ sb_appendf(sb, "%s AS (SELECT *%s FROM %s)", cte_name, der.data, prev);
743
+ sb_free(&der);
744
+ return 0;
745
+ }
746
+
747
+ /* ---- validate ---- */
748
+ if (strcmp(op, "validate") == 0) {
749
+ const char *expr = jstr(args, "expr");
750
+ if (!expr) { *error = strdup("validate: missing 'expr'"); return -1; }
751
+ char *sql_expr = translate_expr(expr);
752
+ if (!sql_expr) { *error = strdup("validate: failed to translate expression"); return -1; }
753
+ sb_appendf(sb, "%s AS (SELECT *, (%s) AS \"_valid\" FROM %s)", cte_name, sql_expr, prev);
754
+ free(sql_expr);
755
+ return 0;
756
+ }
757
+
758
+ /* ---- unique / dedup ---- */
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
+ }
772
+ cJSON *cols = cJSON_GetObjectItemCaseSensitive(args, "columns");
773
+ if (cols && cJSON_IsArray(cols) && cJSON_GetArraySize(cols) > 0) {
774
+ strbuf dcols;
775
+ sb_init(&dcols);
776
+ int n = cJSON_GetArraySize(cols);
777
+ for (int i = 0; i < n; i++) {
778
+ if (i > 0) sb_append(&dcols, ", ");
779
+ cJSON *c = cJSON_GetArrayItem(cols, i);
780
+ if (cJSON_IsString(c)) sql_quote_ident(&dcols, c->valuestring);
781
+ }
782
+ sb_appendf(sb, "%s AS (SELECT DISTINCT ON (%s) * FROM %s)", cte_name, dcols.data, prev);
783
+ sb_free(&dcols);
784
+ } else {
785
+ sb_appendf(sb, "%s AS (SELECT DISTINCT * FROM %s)", cte_name, prev);
786
+ }
787
+ return 0;
788
+ }
789
+
790
+ /* ---- sort ---- */
791
+ if (strcmp(op, "sort") == 0) {
792
+ cJSON *columns = cJSON_GetObjectItemCaseSensitive(args, "columns");
793
+ if (!columns || !cJSON_IsArray(columns)) { *error = strdup("sort: missing 'columns'"); return -1; }
794
+ strbuf ord;
795
+ sb_init(&ord);
796
+ int n = cJSON_GetArraySize(columns);
797
+ for (int i = 0; i < n; i++) {
798
+ if (i > 0) sb_append(&ord, ", ");
799
+ cJSON *c = cJSON_GetArrayItem(columns, i);
800
+ const char *name = jstr(c, "name");
801
+ int desc = jbool(c, "desc", 0);
802
+ if (name) {
803
+ sql_quote_ident(&ord, name);
804
+ sb_append(&ord, desc ? " DESC" : " ASC");
805
+ }
806
+ }
807
+ sb_appendf(sb, "%s AS (SELECT * FROM %s ORDER BY %s)", cte_name, prev, ord.data);
808
+ sb_free(&ord);
809
+ return 0;
810
+ }
811
+
812
+ /* ---- head ---- */
813
+ if (strcmp(op, "head") == 0 || strcmp(op, "slice-head") == 0) {
814
+ int n = jint(args, "n", 10);
815
+ sb_appendf(sb, "%s AS (SELECT * FROM %s LIMIT %d)", cte_name, prev, n);
816
+ return 0;
817
+ }
818
+
819
+ /* ---- skip ---- */
820
+ if (strcmp(op, "skip") == 0) {
821
+ int n = jint(args, "n", 0);
822
+ sb_appendf(sb, "%s AS (SELECT * FROM %s OFFSET %d)", cte_name, prev, n);
823
+ return 0;
824
+ }
825
+
826
+ /* ---- tail ---- */
827
+ if (strcmp(op, "tail") == 0 || strcmp(op, "slice-tail") == 0) {
828
+ int n = jint(args, "n", 10);
829
+ sb_appendf(sb, "%s AS (SELECT * FROM (SELECT *, ROW_NUMBER() OVER () AS _rn, "
830
+ "COUNT(*) OVER () AS _total FROM %s) WHERE _rn > _total - %d)",
831
+ cte_name, prev, n);
832
+ return 0;
833
+ }
834
+
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) {
839
+ int n = jint(args, "n", 10);
840
+ const char *column = jstr(args, "column");
841
+ int desc = jbool(args, "desc", 1);
842
+ if (!column) { *error = strdup("top-k: missing 'column'"); return -1; }
843
+ strbuf tmp;
844
+ sb_init(&tmp);
845
+ sql_quote_ident(&tmp, column);
846
+ sb_appendf(sb, "%s AS (SELECT * FROM %s ORDER BY %s %s LIMIT %d)",
847
+ cte_name, prev, tmp.data, desc ? "DESC" : "ASC", n);
848
+ sb_free(&tmp);
849
+ return 0;
850
+ }
851
+
852
+ /* ---- sample ---- */
853
+ if (strcmp(op, "sample") == 0) {
854
+ *error = strdup("sample: deterministic reservoir sampling cannot be lowered to SQL");
855
+ return -1;
856
+ }
857
+
858
+ /* ---- grep ---- */
859
+ if (strcmp(op, "grep") == 0) {
860
+ const char *pattern = jstr(args, "pattern");
861
+ const char *column = jstr(args, "column");
862
+ int invert = jbool(args, "invert", 0);
863
+ int regex = jbool(args, "regex", 0);
864
+ if (!pattern) { *error = strdup("grep: missing 'pattern'"); return -1; }
865
+ if (!column) column = "_line";
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;
872
+ }
873
+ sb_appendf(sb, "%s AS (SELECT * FROM %s WHERE %s%s)",
874
+ cte_name, prev, invert ? "NOT " : "", pred.data);
875
+ sb_free(&pred);
876
+ return 0;
877
+ }
878
+
879
+ /* ---- cast ---- */
880
+ if (strcmp(op, "cast") == 0) {
881
+ cJSON *mapping = cJSON_GetObjectItemCaseSensitive(args, "mapping");
882
+ if (!mapping) { *error = strdup("cast: missing 'mapping'"); return -1; }
883
+ const char *policy = cast_on_error_policy(args);
884
+ strbuf rep;
885
+ sb_init(&rep);
886
+ int first = 1;
887
+ cJSON *item = NULL;
888
+ cJSON_ArrayForEach(item, mapping) {
889
+ if (!cJSON_IsString(item)) continue;
890
+ if (!first) sb_append(&rep, ", ");
891
+ first = 0;
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 ");
898
+ sql_quote_ident(&rep, item->string);
899
+ }
900
+ sb_appendf(sb, "%s AS (SELECT * REPLACE (%s) FROM %s)", cte_name, rep.data, prev);
901
+ sb_free(&rep);
902
+ return 0;
903
+ }
904
+
905
+ /* ---- clip ---- */
906
+ if (strcmp(op, "clip") == 0) {
907
+ const char *column = jstr(args, "column");
908
+ if (!column) { *error = strdup("clip: missing 'column'"); return -1; }
909
+ cJSON *min_v = cJSON_GetObjectItemCaseSensitive(args, "min");
910
+ cJSON *max_v = cJSON_GetObjectItemCaseSensitive(args, "max");
911
+ strbuf expr;
912
+ sb_init(&expr);
913
+ if (min_v && max_v) {
914
+ strbuf tmp;
915
+ sb_init(&tmp);
916
+ sql_quote_ident(&tmp, column);
917
+ sb_appendf(&expr, "GREATEST(" TF_FLOAT64_ROUNDTRIP_FORMAT ", LEAST(" TF_FLOAT64_ROUNDTRIP_FORMAT ", %s))", min_v->valuedouble, max_v->valuedouble, tmp.data);
918
+ sb_free(&tmp);
919
+ } else if (min_v) {
920
+ strbuf tmp;
921
+ sb_init(&tmp);
922
+ sql_quote_ident(&tmp, column);
923
+ sb_appendf(&expr, "GREATEST(" TF_FLOAT64_ROUNDTRIP_FORMAT ", %s)", min_v->valuedouble, tmp.data);
924
+ sb_free(&tmp);
925
+ } else if (max_v) {
926
+ strbuf tmp;
927
+ sb_init(&tmp);
928
+ sql_quote_ident(&tmp, column);
929
+ sb_appendf(&expr, "LEAST(" TF_FLOAT64_ROUNDTRIP_FORMAT ", %s)", max_v->valuedouble, tmp.data);
930
+ sb_free(&tmp);
931
+ } else {
932
+ sql_quote_ident(&expr, column);
933
+ }
934
+ strbuf qcol;
935
+ sb_init(&qcol);
936
+ sql_quote_ident(&qcol, column);
937
+ sb_appendf(sb, "%s AS (SELECT * REPLACE (%s AS %s) FROM %s)", cte_name, expr.data, qcol.data, prev);
938
+ sb_free(&expr);
939
+ sb_free(&qcol);
940
+ return 0;
941
+ }
942
+
943
+ /* ---- replace ---- */
944
+ if (strcmp(op, "replace") == 0) {
945
+ const char *column = jstr(args, "column");
946
+ const char *pattern = jstr(args, "pattern");
947
+ const char *replacement = jstr(args, "replacement");
948
+ int regex = jbool(args, "regex", 0);
949
+ if (!column || !pattern || !replacement) { *error = strdup("replace: missing args"); return -1; }
950
+ strbuf qcol;
951
+ sb_init(&qcol);
952
+ sql_quote_ident(&qcol, column);
953
+ strbuf expr;
954
+ sb_init(&expr);
955
+ if (regex) {
956
+ sb_append(&expr, "regexp_replace(");
957
+ sb_append(&expr, qcol.data);
958
+ sb_append(&expr, ", ");
959
+ sql_quote_str(&expr, pattern);
960
+ sb_append(&expr, ", ");
961
+ sql_quote_str(&expr, replacement);
962
+ sb_append(&expr, ", 'g')");
963
+ } else {
964
+ sb_append(&expr, "replace(");
965
+ sb_append(&expr, qcol.data);
966
+ sb_append(&expr, ", ");
967
+ sql_quote_str(&expr, pattern);
968
+ sb_append(&expr, ", ");
969
+ sql_quote_str(&expr, replacement);
970
+ sb_append(&expr, ")");
971
+ }
972
+ sb_appendf(sb, "%s AS (SELECT * REPLACE (%s AS %s) FROM %s)", cte_name, expr.data, qcol.data, prev);
973
+ sb_free(&qcol);
974
+ sb_free(&expr);
975
+ return 0;
976
+ }
977
+
978
+ /* ---- trim ---- */
979
+ if (strcmp(op, "trim") == 0) {
980
+ cJSON *cols = cJSON_GetObjectItemCaseSensitive(args, "columns");
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;
994
+ }
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) {
1002
+ sb_free(&rep);
1003
+ *error = strdup("sql: out of memory");
1004
+ return -1;
1005
+ }
1006
+ sb_appendf(sb, "%s AS (SELECT * REPLACE (%s) FROM %s)", cte_name, rep.data, prev);
1007
+ sb_free(&rep);
1008
+ return 0;
1009
+ }
1010
+
1011
+ /* ---- fill-null ---- */
1012
+ if (strcmp(op, "fill-null") == 0) {
1013
+ cJSON *mapping = cJSON_GetObjectItemCaseSensitive(args, "mapping");
1014
+ if (!mapping) { *error = strdup("fill-null: missing 'mapping'"); return -1; }
1015
+ strbuf rep;
1016
+ sb_init(&rep);
1017
+ int first = 1;
1018
+ cJSON *item = NULL;
1019
+ cJSON_ArrayForEach(item, mapping) {
1020
+ if (!cJSON_IsString(item)) continue;
1021
+ if (!first) sb_append(&rep, ", ");
1022
+ first = 0;
1023
+ sb_append(&rep, "COALESCE(");
1024
+ sql_quote_ident(&rep, item->string);
1025
+ sb_append(&rep, ", ");
1026
+ sql_quote_str(&rep, item->valuestring);
1027
+ sb_append(&rep, ") AS ");
1028
+ sql_quote_ident(&rep, item->string);
1029
+ }
1030
+ sb_appendf(sb, "%s AS (SELECT * REPLACE (%s) FROM %s)", cte_name, rep.data, prev);
1031
+ sb_free(&rep);
1032
+ return 0;
1033
+ }
1034
+
1035
+ /* ---- group-agg ---- */
1036
+ if (strcmp(op, "group-agg") == 0) {
1037
+ cJSON *group_by = cJSON_GetObjectItemCaseSensitive(args, "group_by");
1038
+ cJSON *aggs = cJSON_GetObjectItemCaseSensitive(args, "aggs");
1039
+ if (!group_by || !aggs) { *error = strdup("group-agg: missing args"); return -1; }
1040
+ strbuf sel;
1041
+ sb_init(&sel);
1042
+ /* Group columns */
1043
+ int ng = cJSON_GetArraySize(group_by);
1044
+ for (int i = 0; i < ng; i++) {
1045
+ if (i > 0) sb_append(&sel, ", ");
1046
+ cJSON *c = cJSON_GetArrayItem(group_by, i);
1047
+ if (cJSON_IsString(c)) sql_quote_ident(&sel, c->valuestring);
1048
+ }
1049
+ /* Aggregate functions */
1050
+ int na = cJSON_GetArraySize(aggs);
1051
+ for (int i = 0; i < na; i++) {
1052
+ sb_append(&sel, ", ");
1053
+ cJSON *agg = cJSON_GetArrayItem(aggs, i);
1054
+ const char *col = jstr(agg, "column");
1055
+ const char *func = jstr(agg, "func");
1056
+ const char *result = jstr(agg, "name");
1057
+ if (!result) result = jstr(agg, "result");
1058
+ if (!col || !func) continue;
1059
+ /* Map agg function names */
1060
+ const char *sql_func = func;
1061
+ if (strcmp(func, "avg") == 0) sql_func = "AVG";
1062
+ else if (strcmp(func, "sum") == 0) sql_func = "SUM";
1063
+ else if (strcmp(func, "count") == 0) sql_func = "COUNT";
1064
+ else if (strcmp(func, "min") == 0) sql_func = "MIN";
1065
+ else if (strcmp(func, "max") == 0) sql_func = "MAX";
1066
+ else if (strcmp(func, "stddev") == 0) sql_func = "STDDEV_SAMP";
1067
+ else if (strcmp(func, "var") == 0) sql_func = "VAR_SAMP";
1068
+ else if (strcmp(func, "median") == 0) sql_func = "MEDIAN";
1069
+ sb_append(&sel, sql_func);
1070
+ sb_append(&sel, "(");
1071
+ if (strcmp(func, "count") == 0 && strcmp(col, "*") == 0) sb_append(&sel, "*");
1072
+ else sql_quote_ident(&sel, col);
1073
+ sb_append(&sel, ") AS ");
1074
+ if (result) sql_quote_ident(&sel, result);
1075
+ else {
1076
+ sql_quote_joined_ident(&sel, col, "_", func);
1077
+ }
1078
+ }
1079
+ /* GROUP BY clause */
1080
+ strbuf grp;
1081
+ sb_init(&grp);
1082
+ for (int i = 0; i < ng; i++) {
1083
+ if (i > 0) sb_append(&grp, ", ");
1084
+ cJSON *c = cJSON_GetArrayItem(group_by, i);
1085
+ if (cJSON_IsString(c)) sql_quote_ident(&grp, c->valuestring);
1086
+ }
1087
+ sb_appendf(sb, "%s AS (SELECT %s FROM %s GROUP BY %s)", cte_name, sel.data, prev, grp.data);
1088
+ sb_free(&sel);
1089
+ sb_free(&grp);
1090
+ return 0;
1091
+ }
1092
+
1093
+ /* ---- frequency ---- */
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
+
1111
+ cJSON *cols = cJSON_GetObjectItemCaseSensitive(args, "columns");
1112
+ if (!cols || !cJSON_IsArray(cols) || cJSON_GetArraySize(cols) <= 0) {
1113
+ *error = strdup("frequency: SQL lowering requires explicit columns");
1114
+ return -1;
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);
1128
+ return 0;
1129
+ }
1130
+
1131
+ /* ---- join ---- */
1132
+ if (strcmp(op, "join") == 0 || strcmp(op, "semi-join") == 0 || strcmp(op, "anti-join") == 0) {
1133
+ const char *file = jstr(args, "file");
1134
+ const char *on = jstr(args, "on");
1135
+ const char *how = jstr(args, "how");
1136
+ if (!file || !on) { *error = strdup("join: missing 'file' or 'on'"); return -1; }
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
+ }
1142
+ const char *join_type = "INNER";
1143
+ if (strcmp(how, "left") == 0) join_type = "LEFT";
1144
+ else if (strcmp(how, "right") == 0) join_type = "RIGHT";
1145
+ else if (strcmp(how, "outer") == 0 || strcmp(how, "full") == 0) join_type = "FULL OUTER";
1146
+
1147
+ /* Parse on: either "col" (same name both sides) or "left_col=right_col" */
1148
+ const char *eq = strchr(on, '=');
1149
+ strbuf cond;
1150
+ sb_init(&cond);
1151
+ if (eq) {
1152
+ const char *left = on;
1153
+ size_t llen = (size_t)(eq - on);
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--;
1161
+ sb_append(&cond, "a.");
1162
+ sql_quote_ident_range(&cond, left, llen);
1163
+ sb_append(&cond, " = b.");
1164
+ sql_quote_ident_range(&cond, right, rlen);
1165
+ } else {
1166
+ sb_append(&cond, "a.");
1167
+ sql_quote_ident(&cond, on);
1168
+ sb_append(&cond, " = b.");
1169
+ sql_quote_ident(&cond, on);
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
+
1180
+ sb_appendf(sb, "%s AS (SELECT a.* FROM %s a %s JOIN read_csv_auto(",
1181
+ cte_name, prev, join_type);
1182
+ sql_quote_str(sb, file);
1183
+ sb_appendf(sb, ") b ON %s)", cond.data);
1184
+ sb_free(&cond);
1185
+ return 0;
1186
+ }
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
+
1211
+ /* ---- stack ---- */
1212
+ if (strcmp(op, "stack") == 0) {
1213
+ const char *file = jstr(args, "file");
1214
+ if (!file) { *error = strdup("stack: missing 'file'"); return -1; }
1215
+ sb_appendf(sb, "%s AS (SELECT * FROM %s UNION ALL SELECT * FROM read_csv_auto(", cte_name, prev);
1216
+ sql_quote_str(sb, file);
1217
+ sb_append(sb, "))");
1218
+ return 0;
1219
+ }
1220
+
1221
+ /* ---- explode ---- */
1222
+ if (strcmp(op, "explode") == 0) {
1223
+ const char *column = jstr(args, "column");
1224
+ const char *delimiter = jstr(args, "delimiter");
1225
+ if (!column) { *error = strdup("explode: missing 'column'"); return -1; }
1226
+ if (!delimiter) delimiter = ",";
1227
+ strbuf qcol;
1228
+ sb_init(&qcol);
1229
+ sql_quote_ident(&qcol, column);
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);
1231
+ sql_quote_str(sb, delimiter);
1232
+ sb_appendf(sb, ") END) AS %s) FROM %s)", qcol.data, prev);
1233
+ sb_free(&qcol);
1234
+ return 0;
1235
+ }
1236
+
1237
+ /* ---- split ---- */
1238
+ if (strcmp(op, "split") == 0) {
1239
+ const char *column = jstr(args, "column");
1240
+ const char *delimiter = jstr(args, "delimiter");
1241
+ cJSON *names = cJSON_GetObjectItemCaseSensitive(args, "names");
1242
+ if (!column || !names) { *error = strdup("split: missing args"); return -1; }
1243
+ if (!delimiter) delimiter = " ";
1244
+ strbuf qcol;
1245
+ sb_init(&qcol);
1246
+ sql_quote_ident(&qcol, column);
1247
+ strbuf der;
1248
+ sb_init(&der);
1249
+ int n = cJSON_GetArraySize(names);
1250
+ for (int i = 0; i < n; i++) {
1251
+ cJSON *name = cJSON_GetArrayItem(names, i);
1252
+ if (!cJSON_IsString(name)) continue;
1253
+ sb_appendf(&der, ", string_split(%s, ", qcol.data);
1254
+ sql_quote_str(&der, delimiter);
1255
+ sb_appendf(&der, ")[%d] AS ", i + 1);
1256
+ sql_quote_ident(&der, name->valuestring);
1257
+ }
1258
+ sb_appendf(sb, "%s AS (SELECT *%s FROM %s)", cte_name, der.data, prev);
1259
+ sb_free(&qcol);
1260
+ sb_free(&der);
1261
+ return 0;
1262
+ }
1263
+
1264
+ /* ---- unpivot ---- */
1265
+ if (strcmp(op, "unpivot") == 0) {
1266
+ cJSON *cols = cJSON_GetObjectItemCaseSensitive(args, "columns");
1267
+ if (!cols || !cJSON_IsArray(cols)) { *error = strdup("unpivot: missing 'columns'"); return -1; }
1268
+ strbuf ucols;
1269
+ sb_init(&ucols);
1270
+ int n = cJSON_GetArraySize(cols);
1271
+ for (int i = 0; i < n; i++) {
1272
+ if (i > 0) sb_append(&ucols, ", ");
1273
+ cJSON *c = cJSON_GetArrayItem(cols, i);
1274
+ if (cJSON_IsString(c)) sql_quote_ident(&ucols, c->valuestring);
1275
+ }
1276
+ sb_appendf(sb, "%s AS (UNPIVOT %s ON %s INTO NAME \"variable\" VALUE \"value\")",
1277
+ cte_name, prev, ucols.data);
1278
+ sb_free(&ucols);
1279
+ return 0;
1280
+ }
1281
+
1282
+ /* ---- pivot ---- */
1283
+ if (strcmp(op, "pivot") == 0) {
1284
+ const char *name_col = jstr(args, "name_column");
1285
+ const char *val_col = jstr(args, "value_column");
1286
+ const char *agg = jstr(args, "agg");
1287
+ cJSON *categories = args ? cJSON_GetObjectItemCaseSensitive(args, "categories") : NULL;
1288
+ if (!name_col || !val_col) { *error = strdup("pivot: missing args"); return -1; }
1289
+ if (!agg) agg = "first";
1290
+ const char *sql_agg = "FIRST";
1291
+ if (strcmp(agg, "sum") == 0) sql_agg = "SUM";
1292
+ else if (strcmp(agg, "avg") == 0) sql_agg = "AVG";
1293
+ else if (strcmp(agg, "count") == 0) sql_agg = "COUNT";
1294
+ else if (strcmp(agg, "min") == 0) sql_agg = "MIN";
1295
+ else if (strcmp(agg, "max") == 0) sql_agg = "MAX";
1296
+ strbuf qn, qv;
1297
+ sb_init(&qn); sb_init(&qv);
1298
+ sql_quote_ident(&qn, name_col);
1299
+ sql_quote_ident(&qv, val_col);
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
+ }
1349
+ sb_free(&qn); sb_free(&qv);
1350
+ return 0;
1351
+ }
1352
+
1353
+ /* ---- bin ---- */
1354
+ if (strcmp(op, "bin") == 0) {
1355
+ const char *column = jstr(args, "column");
1356
+ cJSON *boundaries = cJSON_GetObjectItemCaseSensitive(args, "boundaries");
1357
+ if (!column || !boundaries) { *error = strdup("bin: missing args"); return -1; }
1358
+ strbuf qcol;
1359
+ sb_init(&qcol);
1360
+ sql_quote_ident(&qcol, column);
1361
+ strbuf expr;
1362
+ sb_init(&expr);
1363
+ sb_append(&expr, "CASE");
1364
+ int n = cJSON_GetArraySize(boundaries);
1365
+ for (int i = 0; i < n; i++) {
1366
+ cJSON *b = cJSON_GetArrayItem(boundaries, i);
1367
+ double val = b->valuedouble;
1368
+ if (i == 0) {
1369
+ sb_appendf(&expr, " WHEN %s < " TF_FLOAT64_ROUNDTRIP_FORMAT " THEN '<" TF_FLOAT64_ROUNDTRIP_FORMAT "'", qcol.data, val, val);
1370
+ }
1371
+ if (i > 0) {
1372
+ cJSON *prev_b = cJSON_GetArrayItem(boundaries, i - 1);
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 "'",
1374
+ qcol.data, prev_b->valuedouble, qcol.data, val,
1375
+ prev_b->valuedouble, val);
1376
+ }
1377
+ }
1378
+ if (n > 0) {
1379
+ cJSON *last = cJSON_GetArrayItem(boundaries, n - 1);
1380
+ sb_appendf(&expr, " WHEN %s >= " TF_FLOAT64_ROUNDTRIP_FORMAT " THEN '" TF_FLOAT64_ROUNDTRIP_FORMAT "+'", qcol.data, last->valuedouble, last->valuedouble);
1381
+ }
1382
+ sb_append(&expr, " END");
1383
+ strbuf bin_col;
1384
+ sb_init(&bin_col);
1385
+ sb_appendf(&bin_col, "%s_bin", column);
1386
+ strbuf qbin;
1387
+ sb_init(&qbin);
1388
+ sql_quote_ident(&qbin, bin_col.data);
1389
+ sb_appendf(sb, "%s AS (SELECT *, %s AS %s FROM %s)", cte_name, expr.data, qbin.data, prev);
1390
+ sb_free(&qcol); sb_free(&expr); sb_free(&bin_col); sb_free(&qbin);
1391
+ return 0;
1392
+ }
1393
+
1394
+ /* ---- hash ---- */
1395
+ if (strcmp(op, "hash") == 0) {
1396
+ cJSON *cols = cJSON_GetObjectItemCaseSensitive(args, "columns");
1397
+ strbuf expr;
1398
+ sb_init(&expr);
1399
+ if (cols && cJSON_IsArray(cols) && cJSON_GetArraySize(cols) > 0) {
1400
+ sb_append(&expr, "hash(");
1401
+ int n = cJSON_GetArraySize(cols);
1402
+ for (int i = 0; i < n; i++) {
1403
+ if (i > 0) sb_append(&expr, ", ");
1404
+ cJSON *c = cJSON_GetArrayItem(cols, i);
1405
+ if (cJSON_IsString(c)) sql_quote_ident(&expr, c->valuestring);
1406
+ }
1407
+ sb_append(&expr, ")");
1408
+ } else {
1409
+ sb_append(&expr, "hash(*)");
1410
+ }
1411
+ sb_appendf(sb, "%s AS (SELECT *, %s AS \"_hash\" FROM %s)", cte_name, expr.data, prev);
1412
+ sb_free(&expr);
1413
+ return 0;
1414
+ }
1415
+
1416
+ /* ---- fill-down ---- */
1417
+ if (strcmp(op, "fill-down") == 0) {
1418
+ cJSON *cols = cJSON_GetObjectItemCaseSensitive(args, "columns");
1419
+ if (cols && cJSON_IsArray(cols) && cJSON_GetArraySize(cols) > 0) {
1420
+ strbuf rep;
1421
+ sb_init(&rep);
1422
+ int n = cJSON_GetArraySize(cols);
1423
+ for (int i = 0; i < n; i++) {
1424
+ if (i > 0) sb_append(&rep, ", ");
1425
+ cJSON *c = cJSON_GetArrayItem(cols, i);
1426
+ if (!cJSON_IsString(c)) continue;
1427
+ strbuf qc;
1428
+ sb_init(&qc);
1429
+ sql_quote_ident(&qc, c->valuestring);
1430
+ sb_appendf(&rep, "LAST_VALUE(%s IGNORE NULLS) OVER (ORDER BY _rn ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS %s",
1431
+ qc.data, qc.data);
1432
+ sb_free(&qc);
1433
+ }
1434
+ sb_appendf(sb, "%s AS (SELECT * REPLACE (%s) FROM %s)", cte_name, rep.data, prev);
1435
+ sb_free(&rep);
1436
+ } else {
1437
+ sb_appendf(sb, "%s AS (SELECT * FROM %s)", cte_name, prev);
1438
+ }
1439
+ return 0;
1440
+ }
1441
+
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) {
1447
+ const char *column = jstr(args, "column");
1448
+ int size = jint(args, "size", 3);
1449
+ const char *func = jstr(args, "func");
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; }
1458
+ if (!column || !func) { *error = strdup("window: missing args"); return -1; }
1459
+
1460
+ strbuf qcol, qres;
1461
+ sb_init(&qcol); sb_init(&qres);
1462
+ sql_quote_ident(&qcol, column);
1463
+ if (result) sql_quote_ident(&qres, result);
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);
1506
+ sb_free(&qcol); sb_free(&qres);
1507
+ return 0;
1508
+ }
1509
+
1510
+ /* ---- step (running aggregate) ---- */
1511
+ if (strcmp(op, "step") == 0) {
1512
+ const char *column = jstr(args, "column");
1513
+ const char *func = jstr(args, "func");
1514
+ const char *result = jstr(args, "result");
1515
+ if (!column || !func) { *error = strdup("step: missing args"); return -1; }
1516
+ const char *sql_func = "SUM";
1517
+ if (strcmp(func, "cumsum") == 0 || strcmp(func, "running-sum") == 0) sql_func = "SUM";
1518
+ else if (strcmp(func, "cummax") == 0 || strcmp(func, "running-max") == 0) sql_func = "MAX";
1519
+ else if (strcmp(func, "cummin") == 0 || strcmp(func, "running-min") == 0) sql_func = "MIN";
1520
+ else if (strcmp(func, "cumavg") == 0 || strcmp(func, "running-avg") == 0) sql_func = "AVG";
1521
+ strbuf qcol, qres;
1522
+ sb_init(&qcol); sb_init(&qres);
1523
+ sql_quote_ident(&qcol, column);
1524
+ if (result) sql_quote_ident(&qres, result);
1525
+ else sb_appendf(&qres, "\"%s_%s\"", func, column);
1526
+ sb_appendf(sb, "%s AS (SELECT *, %s(%s) OVER (ORDER BY _rn ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS %s FROM %s)",
1527
+ cte_name, sql_func, qcol.data, qres.data, prev);
1528
+ sb_free(&qcol); sb_free(&qres);
1529
+ return 0;
1530
+ }
1531
+
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) {
1622
+ const char *column = jstr(args, "column");
1623
+ int offset = jint(args, "offset", 1);
1624
+ const char *result = jstr(args, "result");
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;
1636
+ strbuf qcol, qres;
1637
+ sb_init(&qcol); sb_init(&qres);
1638
+ sql_quote_ident(&qcol, column);
1639
+ if (result) sql_quote_ident(&qres, result);
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);
1643
+ sb_free(&qcol); sb_free(&qres);
1644
+ return 0;
1645
+ }
1646
+
1647
+ /* ---- datetime ---- */
1648
+ if (strcmp(op, "datetime") == 0) {
1649
+ const char *column = jstr(args, "column");
1650
+ cJSON *extract = cJSON_GetObjectItemCaseSensitive(args, "extract");
1651
+ if (!column) { *error = strdup("datetime: missing 'column'"); return -1; }
1652
+ strbuf qcol;
1653
+ sb_init(&qcol);
1654
+ sql_quote_ident(&qcol, column);
1655
+ strbuf der;
1656
+ sb_init(&der);
1657
+ if (extract && cJSON_IsArray(extract)) {
1658
+ int n = cJSON_GetArraySize(extract);
1659
+ for (int i = 0; i < n; i++) {
1660
+ cJSON *part = cJSON_GetArrayItem(extract, i);
1661
+ if (!cJSON_IsString(part)) continue;
1662
+ const char *p = part->valuestring;
1663
+ sb_appendf(&der, ", EXTRACT(%s FROM %s::TIMESTAMP) AS ", p, qcol.data);
1664
+ sql_quote_joined_ident(&der, column, "_", p);
1665
+ }
1666
+ }
1667
+ sb_appendf(sb, "%s AS (SELECT *%s FROM %s)", cte_name, der.data, prev);
1668
+ sb_free(&qcol); sb_free(&der);
1669
+ return 0;
1670
+ }
1671
+
1672
+ /* ---- date-trunc ---- */
1673
+ if (strcmp(op, "date-trunc") == 0) {
1674
+ const char *column = jstr(args, "column");
1675
+ const char *trunc = jstr(args, "trunc");
1676
+ const char *result = jstr(args, "result");
1677
+ if (!column || !trunc) { *error = strdup("date-trunc: missing args"); return -1; }
1678
+ strbuf qcol;
1679
+ sb_init(&qcol);
1680
+ sql_quote_ident(&qcol, column);
1681
+ strbuf qres;
1682
+ sb_init(&qres);
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);
1691
+ }
1692
+ sb_free(&qcol); sb_free(&qres);
1693
+ return 0;
1694
+ }
1695
+
1696
+ /* ---- stats ---- */
1697
+ if (strcmp(op, "stats") == 0) {
1698
+ *error = strdup("stats: native report shape cannot be lowered to SQL");
1699
+ return -1;
1700
+ }
1701
+
1702
+ /* ---- flatten ---- */
1703
+ if (strcmp(op, "flatten") == 0) {
1704
+ sb_appendf(sb, "%s AS (SELECT * FROM %s)", cte_name, prev);
1705
+ return 0;
1706
+ }
1707
+
1708
+ /* Unknown op */
1709
+ char buf[256];
1710
+ snprintf(buf, sizeof(buf), "unsupported op for SQL: '%s'", op);
1711
+ *error = strdup(buf);
1712
+ return -1;
1713
+ }
1714
+
1715
+ /* ---- Main transpiler ---- */
1716
+
1717
+ char *tf_ir_to_sql(const tf_ir_plan *plan, char **error) {
1718
+ if (!plan || plan->n_nodes == 0) {
1719
+ if (error) *error = strdup("empty plan");
1720
+ return NULL;
1721
+ }
1722
+
1723
+ if (error) *error = NULL;
1724
+
1725
+ strbuf sb;
1726
+ sb_init(&sb);
1727
+
1728
+ /* Find decoder (first node) and encoder (last node) */
1729
+ size_t first_transform = 0;
1730
+ size_t last_transform = plan->n_nodes;
1731
+ const char *input_source = "input_data";
1732
+
1733
+ /* Check first node for decoder — extract as metadata */
1734
+ const tf_ir_node *first = &plan->nodes[0];
1735
+ if (strncmp(first->op, "codec.", 6) == 0 &&
1736
+ strstr(first->op, ".decode") != NULL) {
1737
+ first_transform = 1;
1738
+ }
1739
+
1740
+ /* Check last node for encoder — skip it */
1741
+ if (plan->n_nodes > 1) {
1742
+ const tf_ir_node *last = &plan->nodes[plan->n_nodes - 1];
1743
+ if (strncmp(last->op, "codec.", 6) == 0 &&
1744
+ strstr(last->op, ".encode") != NULL) {
1745
+ last_transform = plan->n_nodes - 1;
1746
+ }
1747
+ }
1748
+
1749
+ /* No transforms — just SELECT * */
1750
+ if (first_transform >= last_transform) {
1751
+ sb_appendf(&sb, "SELECT * FROM %s", input_source);
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
+ }
1771
+ }
1772
+
1773
+ /* Build CTE chain — use two alternating name buffers so prev != current */
1774
+ int n_ctes = 0;
1775
+ char name_bufs[2][32];
1776
+ const char *prev = input_source;
1777
+ char *err = NULL;
1778
+
1779
+ sb_append(&sb, "WITH\n");
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
+
1788
+ for (size_t i = first_transform; i < last_transform; i++) {
1789
+ const tf_ir_node *node = &plan->nodes[i];
1790
+ char *cte_name = name_bufs[n_ctes % 2];
1791
+ snprintf(cte_name, 32, "step_%zu", i);
1792
+
1793
+ if (n_ctes > 0) sb_append(&sb, ",\n");
1794
+ sb_append(&sb, " ");
1795
+
1796
+ if (emit_cte(&sb, cte_name, prev, node->op, node->args, &err) != 0) {
1797
+ sb_free(&sb);
1798
+ if (error) *error = err;
1799
+ else free(err);
1800
+ return NULL;
1801
+ }
1802
+ if (sb.failed) {
1803
+ sb_free(&sb);
1804
+ if (error) *error = strdup("sql: out of memory");
1805
+ return NULL;
1806
+ }
1807
+
1808
+ prev = cte_name;
1809
+ n_ctes++;
1810
+ }
1811
+
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
+ }
1818
+
1819
+ char *sql = sb_detach(&sb);
1820
+ if (!sql && error && !*error) *error = strdup("sql: out of memory");
1821
+ return sql;
1822
+ }