tranfi 0.0.2 → 0.1.2

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 (108) hide show
  1. package/README.md +395 -0
  2. package/app/assets/index-6quYZ5Ap.css +5 -0
  3. package/app/assets/index-pDFMluyz.js +160 -0
  4. package/app/assets/materialdesignicons-webfont-B7mPwVP_.ttf +0 -0
  5. package/app/assets/materialdesignicons-webfont-CSr8KVlo.eot +0 -0
  6. package/app/assets/materialdesignicons-webfont-Dp5v-WZN.woff2 +0 -0
  7. package/app/assets/materialdesignicons-webfont-PXm3-2wK.woff +0 -0
  8. package/app/index.html +13 -0
  9. package/binding.gyp +69 -0
  10. package/csrc/arena.c +91 -0
  11. package/csrc/batch.c +229 -0
  12. package/csrc/buffer.c +78 -0
  13. package/csrc/cJSON.c +3143 -0
  14. package/csrc/cJSON.h +300 -0
  15. package/csrc/codec_csv.c +1058 -0
  16. package/csrc/codec_jsonl.c +374 -0
  17. package/csrc/codec_table.c +218 -0
  18. package/csrc/codec_text.c +229 -0
  19. package/csrc/compiler.c +102 -0
  20. package/csrc/date_utils.h +94 -0
  21. package/csrc/dsl.c +1180 -0
  22. package/csrc/dsl.h +22 -0
  23. package/csrc/expr.c +1245 -0
  24. package/csrc/expr.h +56 -0
  25. package/csrc/internal.h +250 -0
  26. package/csrc/ir.c +119 -0
  27. package/csrc/ir.h +167 -0
  28. package/csrc/ir_schema.c +60 -0
  29. package/csrc/ir_serialize.c +104 -0
  30. package/csrc/ir_sql.c +1211 -0
  31. package/csrc/ir_validate.c +120 -0
  32. package/csrc/main.c +392 -0
  33. package/csrc/op_acf.c +133 -0
  34. package/csrc/op_anomaly.c +120 -0
  35. package/csrc/op_bin.c +109 -0
  36. package/csrc/op_cast.c +195 -0
  37. package/csrc/op_clip.c +88 -0
  38. package/csrc/op_date_trunc.c +181 -0
  39. package/csrc/op_datetime.c +212 -0
  40. package/csrc/op_derive.c +248 -0
  41. package/csrc/op_diff.c +134 -0
  42. package/csrc/op_ewma.c +103 -0
  43. package/csrc/op_explode.c +108 -0
  44. package/csrc/op_fill_down.c +163 -0
  45. package/csrc/op_fill_null.c +123 -0
  46. package/csrc/op_filter.c +132 -0
  47. package/csrc/op_frequency.c +193 -0
  48. package/csrc/op_grep.c +163 -0
  49. package/csrc/op_group_agg.c +285 -0
  50. package/csrc/op_hash.c +126 -0
  51. package/csrc/op_head.c +149 -0
  52. package/csrc/op_interpolate.c +239 -0
  53. package/csrc/op_join.c +384 -0
  54. package/csrc/op_label_encode.c +144 -0
  55. package/csrc/op_lead.c +190 -0
  56. package/csrc/op_normalize.c +226 -0
  57. package/csrc/op_onehot.c +185 -0
  58. package/csrc/op_pivot.c +370 -0
  59. package/csrc/op_registry.c +1148 -0
  60. package/csrc/op_rename.c +138 -0
  61. package/csrc/op_replace.c +202 -0
  62. package/csrc/op_sample.c +101 -0
  63. package/csrc/op_select.c +140 -0
  64. package/csrc/op_skip.c +152 -0
  65. package/csrc/op_sort.c +273 -0
  66. package/csrc/op_split.c +114 -0
  67. package/csrc/op_split_data.c +87 -0
  68. package/csrc/op_stack.c +315 -0
  69. package/csrc/op_stats.c +779 -0
  70. package/csrc/op_step.c +171 -0
  71. package/csrc/op_tail.c +96 -0
  72. package/csrc/op_top.c +150 -0
  73. package/csrc/op_trim.c +109 -0
  74. package/csrc/op_unique.c +300 -0
  75. package/csrc/op_unpivot.c +159 -0
  76. package/csrc/op_validate.c +71 -0
  77. package/csrc/op_window.c +150 -0
  78. package/csrc/pipeline.c +315 -0
  79. package/csrc/plan.c +206 -0
  80. package/csrc/recipes.c +102 -0
  81. package/csrc/recipes.h +27 -0
  82. package/csrc/report.c +463 -0
  83. package/csrc/report.h +22 -0
  84. package/csrc/tranfi.h +123 -0
  85. package/csrc/wasm_api.c +157 -0
  86. package/napi_api.c +326 -0
  87. package/package.json +46 -57
  88. package/src/cli.js +193 -0
  89. package/src/engines/duckdb.js +109 -0
  90. package/src/index.js +306 -0
  91. package/src/native.js +22 -0
  92. package/src/pipeline.js +286 -0
  93. package/src/server.js +277 -0
  94. package/src/wasm.js +19 -0
  95. package/wasm/index.js +244 -0
  96. package/wasm/package.json +1 -0
  97. package/wasm/tranfi_core.js +0 -0
  98. package/LICENSE +0 -21
  99. package/dist/bundle.js +0 -1
  100. package/index.html +0 -18
  101. package/src/app.css +0 -169
  102. package/src/app.js +0 -203
  103. package/src/app.vue +0 -250
  104. package/src/bulma-input.vue +0 -110
  105. package/src/common-inputs.js +0 -28
  106. package/src/main.js +0 -20
  107. package/src/transforms.js +0 -166
  108. package/webpack.config.js +0 -108
package/csrc/ir_sql.c ADDED
@@ -0,0 +1,1211 @@
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
+ } strbuf;
25
+
26
+ static void sb_init(strbuf *sb) {
27
+ sb->data = malloc(256);
28
+ sb->len = 0;
29
+ sb->cap = 256;
30
+ if (sb->data) sb->data[0] = '\0';
31
+ }
32
+
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; }
39
+ }
40
+ }
41
+
42
+ static void sb_append(strbuf *sb, const char *s) {
43
+ size_t n = strlen(s);
44
+ sb_ensure(sb, n);
45
+ memcpy(sb->data + sb->len, s, n);
46
+ sb->len += n;
47
+ sb->data[sb->len] = '\0';
48
+ }
49
+
50
+ static void sb_appendn(strbuf *sb, const char *s, size_t n) {
51
+ sb_ensure(sb, n);
52
+ memcpy(sb->data + sb->len, s, n);
53
+ sb->len += n;
54
+ sb->data[sb->len] = '\0';
55
+ }
56
+
57
+ static void sb_appendf(strbuf *sb, const char *fmt, ...)
58
+ __attribute__((format(printf, 2, 3)));
59
+
60
+ static void sb_appendf(strbuf *sb, const char *fmt, ...) {
61
+ char buf[512];
62
+ va_list ap;
63
+ va_start(ap, fmt);
64
+ int n = vsnprintf(buf, sizeof(buf), fmt, ap);
65
+ va_end(ap);
66
+ if (n > 0) sb_appendn(sb, buf, (size_t)n);
67
+ }
68
+
69
+ static char *sb_detach(strbuf *sb) {
70
+ char *s = sb->data;
71
+ sb->data = NULL;
72
+ sb->len = sb->cap = 0;
73
+ return s;
74
+ }
75
+
76
+ static void sb_free(strbuf *sb) {
77
+ free(sb->data);
78
+ sb->data = NULL;
79
+ sb->len = sb->cap = 0;
80
+ }
81
+
82
+ /* ---- Expression AST to SQL ---- */
83
+
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++) {
88
+ if (*p == '"') sb_append(sb, "\"\"");
89
+ else sb_appendn(sb, p, 1);
90
+ }
91
+ sb_append(sb, "\"");
92
+ }
93
+
94
+ /* Append a SQL string literal: 'value' */
95
+ static void sql_quote_str(strbuf *sb, const char *s) {
96
+ sb_append(sb, "'");
97
+ for (const char *p = s; *p; p++) {
98
+ if (*p == '\'') sb_append(sb, "''");
99
+ else sb_appendn(sb, p, 1);
100
+ }
101
+ sb_append(sb, "'");
102
+ }
103
+
104
+ /* Function name mapping: tranfi → SQL */
105
+ static const char *map_func_name(const char *name) {
106
+ if (strcmp(name, "len") == 0) return "length";
107
+ if (strcmp(name, "pad_left") == 0) return "lpad";
108
+ if (strcmp(name, "pad_right") == 0) return "rpad";
109
+ if (strcmp(name, "mod") == 0) return NULL; /* special: a % b */
110
+ return name; /* most map directly: upper, lower, abs, round, etc. */
111
+ }
112
+
113
+ static int expr_to_sql(const tf_expr *e, strbuf *sb);
114
+
115
+ static int expr_to_sql(const tf_expr *e, strbuf *sb) {
116
+ if (!e) return -1;
117
+
118
+ switch (e->kind) {
119
+ case EXPR_LIT_INT:
120
+ sb_appendf(sb, "%lld", (long long)e->lit_int);
121
+ return 0;
122
+
123
+ case EXPR_LIT_FLOAT:
124
+ sb_appendf(sb, "%g", e->lit_float);
125
+ return 0;
126
+
127
+ case EXPR_LIT_STR:
128
+ sql_quote_str(sb, e->lit_str);
129
+ return 0;
130
+
131
+ case EXPR_COL_REF:
132
+ sql_quote_ident(sb, e->col_name);
133
+ return 0;
134
+
135
+ case EXPR_CMP: {
136
+ sb_append(sb, "(");
137
+ if (expr_to_sql(e->cmp.left, sb) != 0) return -1;
138
+ switch (e->cmp.op) {
139
+ case CMP_GT: sb_append(sb, " > "); break;
140
+ case CMP_GE: sb_append(sb, " >= "); break;
141
+ case CMP_LT: sb_append(sb, " < "); break;
142
+ case CMP_LE: sb_append(sb, " <= "); break;
143
+ case CMP_EQ: sb_append(sb, " = "); break;
144
+ case CMP_NE: sb_append(sb, " <> "); break;
145
+ }
146
+ if (expr_to_sql(e->cmp.right, sb) != 0) return -1;
147
+ sb_append(sb, ")");
148
+ return 0;
149
+ }
150
+
151
+ case EXPR_AND:
152
+ sb_append(sb, "(");
153
+ if (expr_to_sql(e->binary.left, sb) != 0) return -1;
154
+ sb_append(sb, " AND ");
155
+ if (expr_to_sql(e->binary.right, sb) != 0) return -1;
156
+ sb_append(sb, ")");
157
+ return 0;
158
+
159
+ case EXPR_OR:
160
+ sb_append(sb, "(");
161
+ if (expr_to_sql(e->binary.left, sb) != 0) return -1;
162
+ sb_append(sb, " OR ");
163
+ if (expr_to_sql(e->binary.right, sb) != 0) return -1;
164
+ sb_append(sb, ")");
165
+ return 0;
166
+
167
+ case EXPR_NOT:
168
+ sb_append(sb, "(NOT ");
169
+ if (expr_to_sql(e->child, sb) != 0) return -1;
170
+ sb_append(sb, ")");
171
+ return 0;
172
+
173
+ case EXPR_NEG:
174
+ sb_append(sb, "(- ");
175
+ if (expr_to_sql(e->child, sb) != 0) return -1;
176
+ sb_append(sb, ")");
177
+ return 0;
178
+
179
+ case EXPR_ADD:
180
+ sb_append(sb, "(");
181
+ if (expr_to_sql(e->binary.left, sb) != 0) return -1;
182
+ sb_append(sb, " + ");
183
+ if (expr_to_sql(e->binary.right, sb) != 0) return -1;
184
+ sb_append(sb, ")");
185
+ return 0;
186
+
187
+ case EXPR_SUB:
188
+ sb_append(sb, "(");
189
+ if (expr_to_sql(e->binary.left, sb) != 0) return -1;
190
+ sb_append(sb, " - ");
191
+ if (expr_to_sql(e->binary.right, sb) != 0) return -1;
192
+ sb_append(sb, ")");
193
+ return 0;
194
+
195
+ case EXPR_MUL:
196
+ sb_append(sb, "(");
197
+ if (expr_to_sql(e->binary.left, sb) != 0) return -1;
198
+ sb_append(sb, " * ");
199
+ if (expr_to_sql(e->binary.right, sb) != 0) return -1;
200
+ sb_append(sb, ")");
201
+ return 0;
202
+
203
+ case EXPR_DIV:
204
+ sb_append(sb, "(");
205
+ if (expr_to_sql(e->binary.left, sb) != 0) return -1;
206
+ sb_append(sb, " / ");
207
+ if (expr_to_sql(e->binary.right, sb) != 0) return -1;
208
+ sb_append(sb, ")");
209
+ return 0;
210
+
211
+ case EXPR_FUNC_CALL: {
212
+ const char *name = e->func.name;
213
+ int n = e->func.n_args;
214
+
215
+ /* Special: if(cond, then, else) → CASE WHEN ... THEN ... ELSE ... END */
216
+ if (strcmp(name, "if") == 0 && n == 3) {
217
+ sb_append(sb, "(CASE WHEN ");
218
+ if (expr_to_sql(e->func.args[0], sb) != 0) return -1;
219
+ sb_append(sb, " THEN ");
220
+ if (expr_to_sql(e->func.args[1], sb) != 0) return -1;
221
+ sb_append(sb, " ELSE ");
222
+ if (expr_to_sql(e->func.args[2], sb) != 0) return -1;
223
+ sb_append(sb, " END)");
224
+ return 0;
225
+ }
226
+
227
+ /* Special: mod(a, b) → (a % b) */
228
+ if (strcmp(name, "mod") == 0 && n == 2) {
229
+ sb_append(sb, "(");
230
+ if (expr_to_sql(e->func.args[0], sb) != 0) return -1;
231
+ sb_append(sb, " % ");
232
+ if (expr_to_sql(e->func.args[1], sb) != 0) return -1;
233
+ sb_append(sb, ")");
234
+ return 0;
235
+ }
236
+
237
+ /* Special: slice(s, start, len) → substr(s, start+1, len)
238
+ * tranfi uses 0-based, SQL uses 1-based */
239
+ if (strcmp(name, "slice") == 0 && n >= 2) {
240
+ sb_append(sb, "substr(");
241
+ if (expr_to_sql(e->func.args[0], sb) != 0) return -1;
242
+ sb_append(sb, ", (");
243
+ if (expr_to_sql(e->func.args[1], sb) != 0) return -1;
244
+ sb_append(sb, ") + 1");
245
+ if (n >= 3) {
246
+ sb_append(sb, ", ");
247
+ if (expr_to_sql(e->func.args[2], sb) != 0) return -1;
248
+ }
249
+ sb_append(sb, ")");
250
+ return 0;
251
+ }
252
+
253
+ /* General function call with name mapping */
254
+ const char *sql_name = map_func_name(name);
255
+ if (!sql_name) sql_name = name;
256
+ sb_append(sb, sql_name);
257
+ sb_append(sb, "(");
258
+ for (int i = 0; i < n; i++) {
259
+ if (i > 0) sb_append(sb, ", ");
260
+ if (expr_to_sql(e->func.args[i], sb) != 0) return -1;
261
+ }
262
+ sb_append(sb, ")");
263
+ return 0;
264
+ }
265
+ }
266
+ return -1;
267
+ }
268
+
269
+ /* Parse an expression string and convert to SQL.
270
+ * Returns heap-allocated SQL string, or NULL on error. */
271
+ static char *translate_expr(const char *expr_str) {
272
+ tf_expr *e = tf_expr_parse(expr_str);
273
+ if (!e) return NULL;
274
+ strbuf sb;
275
+ sb_init(&sb);
276
+ int rc = expr_to_sql(e, &sb);
277
+ tf_expr_free(e);
278
+ if (rc != 0) { sb_free(&sb); return NULL; }
279
+ return sb_detach(&sb);
280
+ }
281
+
282
+ /* ---- Op handlers ---- */
283
+
284
+ /* Helper: get cJSON string value */
285
+ static const char *jstr(const cJSON *obj, const char *key) {
286
+ cJSON *v = cJSON_GetObjectItemCaseSensitive(obj, key);
287
+ return (v && cJSON_IsString(v)) ? v->valuestring : NULL;
288
+ }
289
+
290
+ static int jint(const cJSON *obj, const char *key, int def) {
291
+ cJSON *v = cJSON_GetObjectItemCaseSensitive(obj, key);
292
+ return (v && cJSON_IsNumber(v)) ? v->valueint : def;
293
+ }
294
+
295
+ static int jbool(const cJSON *obj, const char *key, int def) {
296
+ cJSON *v = cJSON_GetObjectItemCaseSensitive(obj, key);
297
+ if (!v) return def;
298
+ if (cJSON_IsTrue(v)) return 1;
299
+ if (cJSON_IsFalse(v)) return 0;
300
+ return def;
301
+ }
302
+
303
+ /* Emit a CTE for a transform op. prev is the name of the previous CTE/source.
304
+ * Appends SQL like: step_N AS (SELECT ... FROM prev ...) */
305
+ static int emit_cte(strbuf *sb, const char *cte_name, const char *prev,
306
+ const char *op, const cJSON *args, char **error) {
307
+
308
+ /* ---- filter ---- */
309
+ if (strcmp(op, "filter") == 0) {
310
+ const char *expr = jstr(args, "expr");
311
+ if (!expr) { *error = strdup("filter: missing 'expr'"); return -1; }
312
+ char *sql_expr = translate_expr(expr);
313
+ if (!sql_expr) { *error = strdup("filter: failed to translate expression"); return -1; }
314
+ sb_appendf(sb, "%s AS (SELECT * FROM %s WHERE %s)", cte_name, prev, sql_expr);
315
+ free(sql_expr);
316
+ return 0;
317
+ }
318
+
319
+ /* ---- select / reorder ---- */
320
+ if (strcmp(op, "select") == 0 || strcmp(op, "reorder") == 0) {
321
+ cJSON *cols = cJSON_GetObjectItemCaseSensitive(args, "columns");
322
+ if (!cols || !cJSON_IsArray(cols)) { *error = strdup("select: missing 'columns'"); return -1; }
323
+ strbuf sel;
324
+ sb_init(&sel);
325
+ int n = cJSON_GetArraySize(cols);
326
+ for (int i = 0; i < n; i++) {
327
+ if (i > 0) sb_append(&sel, ", ");
328
+ cJSON *c = cJSON_GetArrayItem(cols, i);
329
+ if (cJSON_IsString(c)) sql_quote_ident(&sel, c->valuestring);
330
+ }
331
+ sb_appendf(sb, "%s AS (SELECT %s FROM %s)", cte_name, sel.data, prev);
332
+ sb_free(&sel);
333
+ return 0;
334
+ }
335
+
336
+ /* ---- rename ---- */
337
+ if (strcmp(op, "rename") == 0) {
338
+ cJSON *mapping = cJSON_GetObjectItemCaseSensitive(args, "mapping");
339
+ if (!mapping) { *error = strdup("rename: missing 'mapping'"); return -1; }
340
+ /* Use DuckDB RENAME extension: SELECT * RENAME (old AS new, ...) */
341
+ strbuf rn;
342
+ sb_init(&rn);
343
+ int first = 1;
344
+ cJSON *item = NULL;
345
+ cJSON_ArrayForEach(item, mapping) {
346
+ if (!cJSON_IsString(item)) continue;
347
+ if (!first) sb_append(&rn, ", ");
348
+ first = 0;
349
+ sql_quote_ident(&rn, item->string);
350
+ sb_append(&rn, " AS ");
351
+ sql_quote_ident(&rn, item->valuestring);
352
+ }
353
+ sb_appendf(sb, "%s AS (SELECT * RENAME (%s) FROM %s)", cte_name, rn.data, prev);
354
+ sb_free(&rn);
355
+ return 0;
356
+ }
357
+
358
+ /* ---- derive ---- */
359
+ if (strcmp(op, "derive") == 0) {
360
+ cJSON *columns = cJSON_GetObjectItemCaseSensitive(args, "columns");
361
+ if (!columns || !cJSON_IsArray(columns)) { *error = strdup("derive: missing 'columns'"); return -1; }
362
+ strbuf der;
363
+ sb_init(&der);
364
+ int n = cJSON_GetArraySize(columns);
365
+ for (int i = 0; i < n; i++) {
366
+ cJSON *col = cJSON_GetArrayItem(columns, i);
367
+ const char *name = jstr(col, "name");
368
+ const char *expr = jstr(col, "expr");
369
+ if (!name || !expr) continue;
370
+ char *sql_expr = translate_expr(expr);
371
+ if (!sql_expr) { sb_free(&der); *error = strdup("derive: failed to translate expression"); return -1; }
372
+ sb_append(&der, ", ");
373
+ sb_append(&der, sql_expr);
374
+ sb_append(&der, " AS ");
375
+ sql_quote_ident(&der, name);
376
+ free(sql_expr);
377
+ }
378
+ sb_appendf(sb, "%s AS (SELECT *%s FROM %s)", cte_name, der.data, prev);
379
+ sb_free(&der);
380
+ return 0;
381
+ }
382
+
383
+ /* ---- validate ---- */
384
+ if (strcmp(op, "validate") == 0) {
385
+ const char *expr = jstr(args, "expr");
386
+ if (!expr) { *error = strdup("validate: missing 'expr'"); return -1; }
387
+ char *sql_expr = translate_expr(expr);
388
+ if (!sql_expr) { *error = strdup("validate: failed to translate expression"); return -1; }
389
+ sb_appendf(sb, "%s AS (SELECT *, (%s) AS \"_valid\" FROM %s)", cte_name, sql_expr, prev);
390
+ free(sql_expr);
391
+ return 0;
392
+ }
393
+
394
+ /* ---- unique / dedup ---- */
395
+ if (strcmp(op, "unique") == 0 || strcmp(op, "dedup") == 0) {
396
+ cJSON *cols = cJSON_GetObjectItemCaseSensitive(args, "columns");
397
+ if (cols && cJSON_IsArray(cols) && cJSON_GetArraySize(cols) > 0) {
398
+ strbuf dcols;
399
+ sb_init(&dcols);
400
+ int n = cJSON_GetArraySize(cols);
401
+ for (int i = 0; i < n; i++) {
402
+ if (i > 0) sb_append(&dcols, ", ");
403
+ cJSON *c = cJSON_GetArrayItem(cols, i);
404
+ if (cJSON_IsString(c)) sql_quote_ident(&dcols, c->valuestring);
405
+ }
406
+ sb_appendf(sb, "%s AS (SELECT DISTINCT ON (%s) * FROM %s)", cte_name, dcols.data, prev);
407
+ sb_free(&dcols);
408
+ } else {
409
+ sb_appendf(sb, "%s AS (SELECT DISTINCT * FROM %s)", cte_name, prev);
410
+ }
411
+ return 0;
412
+ }
413
+
414
+ /* ---- sort ---- */
415
+ if (strcmp(op, "sort") == 0) {
416
+ cJSON *columns = cJSON_GetObjectItemCaseSensitive(args, "columns");
417
+ if (!columns || !cJSON_IsArray(columns)) { *error = strdup("sort: missing 'columns'"); return -1; }
418
+ strbuf ord;
419
+ sb_init(&ord);
420
+ int n = cJSON_GetArraySize(columns);
421
+ for (int i = 0; i < n; i++) {
422
+ if (i > 0) sb_append(&ord, ", ");
423
+ cJSON *c = cJSON_GetArrayItem(columns, i);
424
+ const char *name = jstr(c, "name");
425
+ int desc = jbool(c, "desc", 0);
426
+ if (name) {
427
+ sql_quote_ident(&ord, name);
428
+ sb_append(&ord, desc ? " DESC" : " ASC");
429
+ }
430
+ }
431
+ sb_appendf(sb, "%s AS (SELECT * FROM %s ORDER BY %s)", cte_name, prev, ord.data);
432
+ sb_free(&ord);
433
+ return 0;
434
+ }
435
+
436
+ /* ---- head ---- */
437
+ if (strcmp(op, "head") == 0) {
438
+ int n = jint(args, "n", 10);
439
+ sb_appendf(sb, "%s AS (SELECT * FROM %s LIMIT %d)", cte_name, prev, n);
440
+ return 0;
441
+ }
442
+
443
+ /* ---- skip ---- */
444
+ if (strcmp(op, "skip") == 0) {
445
+ int n = jint(args, "n", 0);
446
+ sb_appendf(sb, "%s AS (SELECT * FROM %s OFFSET %d)", cte_name, prev, n);
447
+ return 0;
448
+ }
449
+
450
+ /* ---- tail ---- */
451
+ if (strcmp(op, "tail") == 0) {
452
+ int n = jint(args, "n", 10);
453
+ sb_appendf(sb, "%s AS (SELECT * FROM (SELECT *, ROW_NUMBER() OVER () AS _rn, "
454
+ "COUNT(*) OVER () AS _total FROM %s) WHERE _rn > _total - %d)",
455
+ cte_name, prev, n);
456
+ return 0;
457
+ }
458
+
459
+ /* ---- top ---- */
460
+ if (strcmp(op, "top") == 0) {
461
+ int n = jint(args, "n", 10);
462
+ const char *column = jstr(args, "column");
463
+ int desc = jbool(args, "desc", 1);
464
+ if (!column) { *error = strdup("top: missing 'column'"); return -1; }
465
+ strbuf tmp;
466
+ sb_init(&tmp);
467
+ sql_quote_ident(&tmp, column);
468
+ sb_appendf(sb, "%s AS (SELECT * FROM %s ORDER BY %s %s LIMIT %d)",
469
+ cte_name, prev, tmp.data, desc ? "DESC" : "ASC", n);
470
+ sb_free(&tmp);
471
+ return 0;
472
+ }
473
+
474
+ /* ---- sample ---- */
475
+ 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;
479
+ }
480
+
481
+ /* ---- grep ---- */
482
+ if (strcmp(op, "grep") == 0) {
483
+ const char *pattern = jstr(args, "pattern");
484
+ const char *column = jstr(args, "column");
485
+ int invert = jbool(args, "invert", 0);
486
+ int regex = jbool(args, "regex", 0);
487
+ if (!pattern) { *error = strdup("grep: missing 'pattern'"); return -1; }
488
+ 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, "%')");
509
+ }
510
+ sb_free(&tmp);
511
+ return 0;
512
+ }
513
+
514
+ /* ---- cast ---- */
515
+ if (strcmp(op, "cast") == 0) {
516
+ cJSON *mapping = cJSON_GetObjectItemCaseSensitive(args, "mapping");
517
+ 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) */
539
+ strbuf rep;
540
+ sb_init(&rep);
541
+ int first = 1;
542
+ cJSON_ArrayForEach(item, mapping) {
543
+ if (!cJSON_IsString(item)) continue;
544
+ if (!first) sb_append(&rep, ", ");
545
+ 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);
557
+ sql_quote_ident(&rep, item->string);
558
+ }
559
+ sb_free(&cols);
560
+ sb_appendf(sb, "%s AS (SELECT * REPLACE (%s) FROM %s)", cte_name, rep.data, prev);
561
+ sb_free(&rep);
562
+ return 0;
563
+ }
564
+
565
+ /* ---- clip ---- */
566
+ if (strcmp(op, "clip") == 0) {
567
+ const char *column = jstr(args, "column");
568
+ if (!column) { *error = strdup("clip: missing 'column'"); return -1; }
569
+ cJSON *min_v = cJSON_GetObjectItemCaseSensitive(args, "min");
570
+ cJSON *max_v = cJSON_GetObjectItemCaseSensitive(args, "max");
571
+ strbuf expr;
572
+ sb_init(&expr);
573
+ sql_quote_ident(&expr, column);
574
+ if (min_v && max_v) {
575
+ strbuf tmp;
576
+ sb_init(&tmp);
577
+ 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);
580
+ sb_free(&tmp);
581
+ } else if (min_v) {
582
+ strbuf tmp;
583
+ sb_init(&tmp);
584
+ sql_quote_ident(&tmp, column);
585
+ sb_init(&expr);
586
+ sb_appendf(&expr, "GREATEST(%g, %s)", min_v->valuedouble, tmp.data);
587
+ sb_free(&tmp);
588
+ } else if (max_v) {
589
+ strbuf tmp;
590
+ sb_init(&tmp);
591
+ sql_quote_ident(&tmp, column);
592
+ sb_init(&expr);
593
+ sb_appendf(&expr, "LEAST(%g, %s)", max_v->valuedouble, tmp.data);
594
+ sb_free(&tmp);
595
+ }
596
+ strbuf qcol;
597
+ sb_init(&qcol);
598
+ sql_quote_ident(&qcol, column);
599
+ sb_appendf(sb, "%s AS (SELECT * REPLACE (%s AS %s) FROM %s)", cte_name, expr.data, qcol.data, prev);
600
+ sb_free(&expr);
601
+ sb_free(&qcol);
602
+ return 0;
603
+ }
604
+
605
+ /* ---- replace ---- */
606
+ if (strcmp(op, "replace") == 0) {
607
+ const char *column = jstr(args, "column");
608
+ const char *pattern = jstr(args, "pattern");
609
+ const char *replacement = jstr(args, "replacement");
610
+ int regex = jbool(args, "regex", 0);
611
+ if (!column || !pattern || !replacement) { *error = strdup("replace: missing args"); return -1; }
612
+ strbuf qcol;
613
+ sb_init(&qcol);
614
+ sql_quote_ident(&qcol, column);
615
+ strbuf expr;
616
+ sb_init(&expr);
617
+ if (regex) {
618
+ sb_append(&expr, "regexp_replace(");
619
+ sb_append(&expr, qcol.data);
620
+ sb_append(&expr, ", ");
621
+ sql_quote_str(&expr, pattern);
622
+ sb_append(&expr, ", ");
623
+ sql_quote_str(&expr, replacement);
624
+ sb_append(&expr, ", 'g')");
625
+ } else {
626
+ sb_append(&expr, "replace(");
627
+ sb_append(&expr, qcol.data);
628
+ sb_append(&expr, ", ");
629
+ sql_quote_str(&expr, pattern);
630
+ sb_append(&expr, ", ");
631
+ sql_quote_str(&expr, replacement);
632
+ sb_append(&expr, ")");
633
+ }
634
+ sb_appendf(sb, "%s AS (SELECT * REPLACE (%s AS %s) FROM %s)", cte_name, expr.data, qcol.data, prev);
635
+ sb_free(&qcol);
636
+ sb_free(&expr);
637
+ return 0;
638
+ }
639
+
640
+ /* ---- trim ---- */
641
+ if (strcmp(op, "trim") == 0) {
642
+ 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);
655
+ }
656
+ sb_appendf(sb, "%s AS (SELECT * REPLACE (%s) FROM %s)", cte_name, rep.data, prev);
657
+ 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);
663
+ }
664
+ return 0;
665
+ }
666
+
667
+ /* ---- fill-null ---- */
668
+ if (strcmp(op, "fill-null") == 0) {
669
+ cJSON *mapping = cJSON_GetObjectItemCaseSensitive(args, "mapping");
670
+ if (!mapping) { *error = strdup("fill-null: missing 'mapping'"); return -1; }
671
+ strbuf rep;
672
+ sb_init(&rep);
673
+ int first = 1;
674
+ cJSON *item = NULL;
675
+ cJSON_ArrayForEach(item, mapping) {
676
+ if (!cJSON_IsString(item)) continue;
677
+ if (!first) sb_append(&rep, ", ");
678
+ first = 0;
679
+ sb_append(&rep, "COALESCE(");
680
+ sql_quote_ident(&rep, item->string);
681
+ sb_append(&rep, ", ");
682
+ sql_quote_str(&rep, item->valuestring);
683
+ sb_append(&rep, ") AS ");
684
+ sql_quote_ident(&rep, item->string);
685
+ }
686
+ sb_appendf(sb, "%s AS (SELECT * REPLACE (%s) FROM %s)", cte_name, rep.data, prev);
687
+ sb_free(&rep);
688
+ return 0;
689
+ }
690
+
691
+ /* ---- group-agg ---- */
692
+ if (strcmp(op, "group-agg") == 0) {
693
+ cJSON *group_by = cJSON_GetObjectItemCaseSensitive(args, "group_by");
694
+ cJSON *aggs = cJSON_GetObjectItemCaseSensitive(args, "aggs");
695
+ if (!group_by || !aggs) { *error = strdup("group-agg: missing args"); return -1; }
696
+ strbuf sel;
697
+ sb_init(&sel);
698
+ /* Group columns */
699
+ int ng = cJSON_GetArraySize(group_by);
700
+ for (int i = 0; i < ng; i++) {
701
+ if (i > 0) sb_append(&sel, ", ");
702
+ cJSON *c = cJSON_GetArrayItem(group_by, i);
703
+ if (cJSON_IsString(c)) sql_quote_ident(&sel, c->valuestring);
704
+ }
705
+ /* Aggregate functions */
706
+ int na = cJSON_GetArraySize(aggs);
707
+ for (int i = 0; i < na; i++) {
708
+ sb_append(&sel, ", ");
709
+ cJSON *agg = cJSON_GetArrayItem(aggs, i);
710
+ const char *col = jstr(agg, "column");
711
+ const char *func = jstr(agg, "func");
712
+ const char *result = jstr(agg, "name");
713
+ if (!result) result = jstr(agg, "result");
714
+ if (!col || !func) continue;
715
+ /* Map agg function names */
716
+ const char *sql_func = func;
717
+ if (strcmp(func, "avg") == 0) sql_func = "AVG";
718
+ else if (strcmp(func, "sum") == 0) sql_func = "SUM";
719
+ else if (strcmp(func, "count") == 0) sql_func = "COUNT";
720
+ else if (strcmp(func, "min") == 0) sql_func = "MIN";
721
+ else if (strcmp(func, "max") == 0) sql_func = "MAX";
722
+ else if (strcmp(func, "stddev") == 0) sql_func = "STDDEV_SAMP";
723
+ else if (strcmp(func, "var") == 0) sql_func = "VAR_SAMP";
724
+ else if (strcmp(func, "median") == 0) sql_func = "MEDIAN";
725
+ sb_append(&sel, sql_func);
726
+ sb_append(&sel, "(");
727
+ sql_quote_ident(&sel, col);
728
+ sb_append(&sel, ") AS ");
729
+ if (result) sql_quote_ident(&sel, result);
730
+ else {
731
+ char buf[256];
732
+ snprintf(buf, sizeof(buf), "%s_%s", func, col);
733
+ sql_quote_ident(&sel, buf);
734
+ }
735
+ }
736
+ /* GROUP BY clause */
737
+ strbuf grp;
738
+ sb_init(&grp);
739
+ for (int i = 0; i < ng; i++) {
740
+ if (i > 0) sb_append(&grp, ", ");
741
+ cJSON *c = cJSON_GetArrayItem(group_by, i);
742
+ if (cJSON_IsString(c)) sql_quote_ident(&grp, c->valuestring);
743
+ }
744
+ sb_appendf(sb, "%s AS (SELECT %s FROM %s GROUP BY %s)", cte_name, sel.data, prev, grp.data);
745
+ sb_free(&sel);
746
+ sb_free(&grp);
747
+ return 0;
748
+ }
749
+
750
+ /* ---- frequency ---- */
751
+ if (strcmp(op, "frequency") == 0) {
752
+ 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);
775
+ }
776
+ return 0;
777
+ }
778
+
779
+ /* ---- join ---- */
780
+ if (strcmp(op, "join") == 0) {
781
+ const char *file = jstr(args, "file");
782
+ const char *on = jstr(args, "on");
783
+ const char *how = jstr(args, "how");
784
+ if (!file || !on) { *error = strdup("join: missing 'file' or 'on'"); return -1; }
785
+ if (!how) how = "inner";
786
+ const char *join_type = "INNER";
787
+ if (strcmp(how, "left") == 0) join_type = "LEFT";
788
+ else if (strcmp(how, "right") == 0) join_type = "RIGHT";
789
+ else if (strcmp(how, "outer") == 0 || strcmp(how, "full") == 0) join_type = "FULL OUTER";
790
+
791
+ /* Parse on: either "col" (same name both sides) or "left_col=right_col" */
792
+ const char *eq = strchr(on, '=');
793
+ strbuf cond;
794
+ sb_init(&cond);
795
+ if (eq) {
796
+ char left_col[128], right_col[128];
797
+ 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++;
807
+ sb_append(&cond, "a.");
808
+ sql_quote_ident(&cond, left_col);
809
+ sb_append(&cond, " = b.");
810
+ sql_quote_ident(&cond, rp);
811
+ } else {
812
+ sb_append(&cond, "a.");
813
+ sql_quote_ident(&cond, on);
814
+ sb_append(&cond, " = b.");
815
+ sql_quote_ident(&cond, on);
816
+ }
817
+ sb_appendf(sb, "%s AS (SELECT a.* FROM %s a %s JOIN read_csv_auto(",
818
+ cte_name, prev, join_type);
819
+ sql_quote_str(sb, file);
820
+ sb_appendf(sb, ") b ON %s)", cond.data);
821
+ sb_free(&cond);
822
+ return 0;
823
+ }
824
+
825
+ /* ---- stack ---- */
826
+ if (strcmp(op, "stack") == 0) {
827
+ const char *file = jstr(args, "file");
828
+ if (!file) { *error = strdup("stack: missing 'file'"); return -1; }
829
+ sb_appendf(sb, "%s AS (SELECT * FROM %s UNION ALL SELECT * FROM read_csv_auto(", cte_name, prev);
830
+ sql_quote_str(sb, file);
831
+ sb_append(sb, "))");
832
+ return 0;
833
+ }
834
+
835
+ /* ---- explode ---- */
836
+ if (strcmp(op, "explode") == 0) {
837
+ const char *column = jstr(args, "column");
838
+ const char *delimiter = jstr(args, "delimiter");
839
+ if (!column) { *error = strdup("explode: missing 'column'"); return -1; }
840
+ if (!delimiter) delimiter = ",";
841
+ strbuf qcol;
842
+ sb_init(&qcol);
843
+ sql_quote_ident(&qcol, column);
844
+ sb_appendf(sb, "%s AS (SELECT * REPLACE (unnest(string_split(%s, ", cte_name, qcol.data);
845
+ sql_quote_str(sb, delimiter);
846
+ sb_appendf(sb, ")) AS %s) FROM %s)", qcol.data, prev);
847
+ sb_free(&qcol);
848
+ return 0;
849
+ }
850
+
851
+ /* ---- split ---- */
852
+ if (strcmp(op, "split") == 0) {
853
+ const char *column = jstr(args, "column");
854
+ const char *delimiter = jstr(args, "delimiter");
855
+ cJSON *names = cJSON_GetObjectItemCaseSensitive(args, "names");
856
+ if (!column || !names) { *error = strdup("split: missing args"); return -1; }
857
+ if (!delimiter) delimiter = " ";
858
+ strbuf qcol;
859
+ sb_init(&qcol);
860
+ sql_quote_ident(&qcol, column);
861
+ strbuf der;
862
+ sb_init(&der);
863
+ int n = cJSON_GetArraySize(names);
864
+ for (int i = 0; i < n; i++) {
865
+ cJSON *name = cJSON_GetArrayItem(names, i);
866
+ if (!cJSON_IsString(name)) continue;
867
+ sb_appendf(&der, ", string_split(%s, ", qcol.data);
868
+ sql_quote_str(&der, delimiter);
869
+ sb_appendf(&der, ")[%d] AS ", i + 1);
870
+ sql_quote_ident(&der, name->valuestring);
871
+ }
872
+ sb_appendf(sb, "%s AS (SELECT *%s FROM %s)", cte_name, der.data, prev);
873
+ sb_free(&qcol);
874
+ sb_free(&der);
875
+ return 0;
876
+ }
877
+
878
+ /* ---- unpivot ---- */
879
+ if (strcmp(op, "unpivot") == 0) {
880
+ cJSON *cols = cJSON_GetObjectItemCaseSensitive(args, "columns");
881
+ if (!cols || !cJSON_IsArray(cols)) { *error = strdup("unpivot: missing 'columns'"); return -1; }
882
+ strbuf ucols;
883
+ sb_init(&ucols);
884
+ int n = cJSON_GetArraySize(cols);
885
+ for (int i = 0; i < n; i++) {
886
+ if (i > 0) sb_append(&ucols, ", ");
887
+ cJSON *c = cJSON_GetArrayItem(cols, i);
888
+ if (cJSON_IsString(c)) sql_quote_ident(&ucols, c->valuestring);
889
+ }
890
+ sb_appendf(sb, "%s AS (UNPIVOT %s ON %s INTO NAME \"variable\" VALUE \"value\")",
891
+ cte_name, prev, ucols.data);
892
+ sb_free(&ucols);
893
+ return 0;
894
+ }
895
+
896
+ /* ---- pivot ---- */
897
+ if (strcmp(op, "pivot") == 0) {
898
+ const char *name_col = jstr(args, "name_column");
899
+ const char *val_col = jstr(args, "value_column");
900
+ const char *agg = jstr(args, "agg");
901
+ if (!name_col || !val_col) { *error = strdup("pivot: missing args"); return -1; }
902
+ if (!agg) agg = "first";
903
+ const char *sql_agg = "FIRST";
904
+ if (strcmp(agg, "sum") == 0) sql_agg = "SUM";
905
+ else if (strcmp(agg, "avg") == 0) sql_agg = "AVG";
906
+ else if (strcmp(agg, "count") == 0) sql_agg = "COUNT";
907
+ else if (strcmp(agg, "min") == 0) sql_agg = "MIN";
908
+ else if (strcmp(agg, "max") == 0) sql_agg = "MAX";
909
+ strbuf qn, qv;
910
+ sb_init(&qn); sb_init(&qv);
911
+ sql_quote_ident(&qn, name_col);
912
+ 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);
915
+ sb_free(&qn); sb_free(&qv);
916
+ return 0;
917
+ }
918
+
919
+ /* ---- bin ---- */
920
+ if (strcmp(op, "bin") == 0) {
921
+ const char *column = jstr(args, "column");
922
+ cJSON *boundaries = cJSON_GetObjectItemCaseSensitive(args, "boundaries");
923
+ if (!column || !boundaries) { *error = strdup("bin: missing args"); return -1; }
924
+ strbuf qcol;
925
+ sb_init(&qcol);
926
+ sql_quote_ident(&qcol, column);
927
+ strbuf expr;
928
+ sb_init(&expr);
929
+ sb_append(&expr, "CASE");
930
+ int n = cJSON_GetArraySize(boundaries);
931
+ for (int i = 0; i < n; i++) {
932
+ cJSON *b = cJSON_GetArrayItem(boundaries, i);
933
+ double val = b->valuedouble;
934
+ if (i == 0) {
935
+ sb_appendf(&expr, " WHEN %s < %g THEN '<%g'", qcol.data, val, val);
936
+ }
937
+ if (i > 0) {
938
+ cJSON *prev_b = cJSON_GetArrayItem(boundaries, i - 1);
939
+ sb_appendf(&expr, " WHEN %s >= %g AND %s < %g THEN '%g-%g'",
940
+ qcol.data, prev_b->valuedouble, qcol.data, val,
941
+ prev_b->valuedouble, val);
942
+ }
943
+ }
944
+ if (n > 0) {
945
+ cJSON *last = cJSON_GetArrayItem(boundaries, n - 1);
946
+ sb_appendf(&expr, " WHEN %s >= %g THEN '>=%g'", qcol.data, last->valuedouble, last->valuedouble);
947
+ }
948
+ sb_append(&expr, " END");
949
+ strbuf bin_col;
950
+ sb_init(&bin_col);
951
+ sb_appendf(&bin_col, "%s_bin", column);
952
+ strbuf qbin;
953
+ sb_init(&qbin);
954
+ sql_quote_ident(&qbin, bin_col.data);
955
+ sb_appendf(sb, "%s AS (SELECT *, %s AS %s FROM %s)", cte_name, expr.data, qbin.data, prev);
956
+ sb_free(&qcol); sb_free(&expr); sb_free(&bin_col); sb_free(&qbin);
957
+ return 0;
958
+ }
959
+
960
+ /* ---- hash ---- */
961
+ if (strcmp(op, "hash") == 0) {
962
+ cJSON *cols = cJSON_GetObjectItemCaseSensitive(args, "columns");
963
+ strbuf expr;
964
+ sb_init(&expr);
965
+ if (cols && cJSON_IsArray(cols) && cJSON_GetArraySize(cols) > 0) {
966
+ sb_append(&expr, "hash(");
967
+ int n = cJSON_GetArraySize(cols);
968
+ for (int i = 0; i < n; i++) {
969
+ if (i > 0) sb_append(&expr, ", ");
970
+ cJSON *c = cJSON_GetArrayItem(cols, i);
971
+ if (cJSON_IsString(c)) sql_quote_ident(&expr, c->valuestring);
972
+ }
973
+ sb_append(&expr, ")");
974
+ } else {
975
+ sb_append(&expr, "hash(*)");
976
+ }
977
+ sb_appendf(sb, "%s AS (SELECT *, %s AS \"_hash\" FROM %s)", cte_name, expr.data, prev);
978
+ sb_free(&expr);
979
+ return 0;
980
+ }
981
+
982
+ /* ---- fill-down ---- */
983
+ if (strcmp(op, "fill-down") == 0) {
984
+ cJSON *cols = cJSON_GetObjectItemCaseSensitive(args, "columns");
985
+ if (cols && cJSON_IsArray(cols) && cJSON_GetArraySize(cols) > 0) {
986
+ strbuf rep;
987
+ sb_init(&rep);
988
+ int n = cJSON_GetArraySize(cols);
989
+ for (int i = 0; i < n; i++) {
990
+ if (i > 0) sb_append(&rep, ", ");
991
+ cJSON *c = cJSON_GetArrayItem(cols, i);
992
+ if (!cJSON_IsString(c)) continue;
993
+ strbuf qc;
994
+ sb_init(&qc);
995
+ 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",
997
+ qc.data, qc.data);
998
+ sb_free(&qc);
999
+ }
1000
+ sb_appendf(sb, "%s AS (SELECT * REPLACE (%s) FROM %s)", cte_name, rep.data, prev);
1001
+ sb_free(&rep);
1002
+ } else {
1003
+ sb_appendf(sb, "%s AS (SELECT * FROM %s)", cte_name, prev);
1004
+ }
1005
+ return 0;
1006
+ }
1007
+
1008
+ /* ---- window ---- */
1009
+ if (strcmp(op, "window") == 0) {
1010
+ const char *column = jstr(args, "column");
1011
+ int size = jint(args, "size", 3);
1012
+ const char *func = jstr(args, "func");
1013
+ const char *result = jstr(args, "result");
1014
+ 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";
1020
+ strbuf qcol, qres;
1021
+ sb_init(&qcol); sb_init(&qres);
1022
+ sql_quote_ident(&qcol, column);
1023
+ 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);
1027
+ sb_free(&qcol); sb_free(&qres);
1028
+ return 0;
1029
+ }
1030
+
1031
+ /* ---- step (running aggregate) ---- */
1032
+ if (strcmp(op, "step") == 0) {
1033
+ const char *column = jstr(args, "column");
1034
+ const char *func = jstr(args, "func");
1035
+ const char *result = jstr(args, "result");
1036
+ if (!column || !func) { *error = strdup("step: missing args"); return -1; }
1037
+ const char *sql_func = "SUM";
1038
+ if (strcmp(func, "cumsum") == 0 || strcmp(func, "running-sum") == 0) sql_func = "SUM";
1039
+ else if (strcmp(func, "cummax") == 0 || strcmp(func, "running-max") == 0) sql_func = "MAX";
1040
+ else if (strcmp(func, "cummin") == 0 || strcmp(func, "running-min") == 0) sql_func = "MIN";
1041
+ else if (strcmp(func, "cumavg") == 0 || strcmp(func, "running-avg") == 0) sql_func = "AVG";
1042
+ strbuf qcol, qres;
1043
+ sb_init(&qcol); sb_init(&qres);
1044
+ sql_quote_ident(&qcol, column);
1045
+ if (result) sql_quote_ident(&qres, result);
1046
+ 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)",
1048
+ cte_name, sql_func, qcol.data, qres.data, prev);
1049
+ sb_free(&qcol); sb_free(&qres);
1050
+ return 0;
1051
+ }
1052
+
1053
+ /* ---- lead ---- */
1054
+ if (strcmp(op, "lead") == 0) {
1055
+ const char *column = jstr(args, "column");
1056
+ int offset = jint(args, "offset", 1);
1057
+ const char *result = jstr(args, "result");
1058
+ if (!column) { *error = strdup("lead: missing 'column'"); return -1; }
1059
+ strbuf qcol, qres;
1060
+ sb_init(&qcol); sb_init(&qres);
1061
+ sql_quote_ident(&qcol, column);
1062
+ 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);
1066
+ sb_free(&qcol); sb_free(&qres);
1067
+ return 0;
1068
+ }
1069
+
1070
+ /* ---- datetime ---- */
1071
+ if (strcmp(op, "datetime") == 0) {
1072
+ const char *column = jstr(args, "column");
1073
+ cJSON *extract = cJSON_GetObjectItemCaseSensitive(args, "extract");
1074
+ if (!column) { *error = strdup("datetime: missing 'column'"); return -1; }
1075
+ strbuf qcol;
1076
+ sb_init(&qcol);
1077
+ sql_quote_ident(&qcol, column);
1078
+ strbuf der;
1079
+ sb_init(&der);
1080
+ if (extract && cJSON_IsArray(extract)) {
1081
+ int n = cJSON_GetArraySize(extract);
1082
+ for (int i = 0; i < n; i++) {
1083
+ cJSON *part = cJSON_GetArrayItem(extract, i);
1084
+ if (!cJSON_IsString(part)) continue;
1085
+ const char *p = part->valuestring;
1086
+ char result_name[128];
1087
+ snprintf(result_name, sizeof(result_name), "%s_%s", column, p);
1088
+ sb_appendf(&der, ", EXTRACT(%s FROM %s::TIMESTAMP) AS ", p, qcol.data);
1089
+ sql_quote_ident(&der, result_name);
1090
+ }
1091
+ }
1092
+ sb_appendf(sb, "%s AS (SELECT *%s FROM %s)", cte_name, der.data, prev);
1093
+ sb_free(&qcol); sb_free(&der);
1094
+ return 0;
1095
+ }
1096
+
1097
+ /* ---- date-trunc ---- */
1098
+ if (strcmp(op, "date-trunc") == 0) {
1099
+ const char *column = jstr(args, "column");
1100
+ const char *trunc = jstr(args, "trunc");
1101
+ const char *result = jstr(args, "result");
1102
+ if (!column || !trunc) { *error = strdup("date-trunc: missing args"); return -1; }
1103
+ strbuf qcol;
1104
+ sb_init(&qcol);
1105
+ sql_quote_ident(&qcol, column);
1106
+ strbuf qres;
1107
+ 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);
1113
+ }
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
+ sb_free(&qcol); sb_free(&qres);
1117
+ return 0;
1118
+ }
1119
+
1120
+ /* ---- stats ---- */
1121
+ 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;
1125
+ }
1126
+
1127
+ /* ---- flatten ---- */
1128
+ if (strcmp(op, "flatten") == 0) {
1129
+ sb_appendf(sb, "%s AS (SELECT * FROM %s)", cte_name, prev);
1130
+ return 0;
1131
+ }
1132
+
1133
+ /* Unknown op */
1134
+ char buf[256];
1135
+ snprintf(buf, sizeof(buf), "unsupported op for SQL: '%s'", op);
1136
+ *error = strdup(buf);
1137
+ return -1;
1138
+ }
1139
+
1140
+ /* ---- Main transpiler ---- */
1141
+
1142
+ char *tf_ir_to_sql(const tf_ir_plan *plan, char **error) {
1143
+ if (!plan || plan->n_nodes == 0) {
1144
+ if (error) *error = strdup("empty plan");
1145
+ return NULL;
1146
+ }
1147
+
1148
+ if (error) *error = NULL;
1149
+
1150
+ strbuf sb;
1151
+ sb_init(&sb);
1152
+
1153
+ /* Find decoder (first node) and encoder (last node) */
1154
+ size_t first_transform = 0;
1155
+ size_t last_transform = plan->n_nodes;
1156
+ const char *input_source = "input_data";
1157
+
1158
+ /* Check first node for decoder — extract as metadata */
1159
+ const tf_ir_node *first = &plan->nodes[0];
1160
+ if (strncmp(first->op, "codec.", 6) == 0 &&
1161
+ strstr(first->op, ".decode") != NULL) {
1162
+ first_transform = 1;
1163
+ }
1164
+
1165
+ /* Check last node for encoder — skip it */
1166
+ if (plan->n_nodes > 1) {
1167
+ const tf_ir_node *last = &plan->nodes[plan->n_nodes - 1];
1168
+ if (strncmp(last->op, "codec.", 6) == 0 &&
1169
+ strstr(last->op, ".encode") != NULL) {
1170
+ last_transform = plan->n_nodes - 1;
1171
+ }
1172
+ }
1173
+
1174
+ /* No transforms — just SELECT * */
1175
+ if (first_transform >= last_transform) {
1176
+ sb_appendf(&sb, "SELECT * FROM %s", input_source);
1177
+ return sb_detach(&sb);
1178
+ }
1179
+
1180
+ /* Build CTE chain — use two alternating name buffers so prev != current */
1181
+ int n_ctes = 0;
1182
+ char name_bufs[2][32];
1183
+ const char *prev = input_source;
1184
+ char *err = NULL;
1185
+
1186
+ sb_append(&sb, "WITH\n");
1187
+
1188
+ for (size_t i = first_transform; i < last_transform; i++) {
1189
+ const tf_ir_node *node = &plan->nodes[i];
1190
+ char *cte_name = name_bufs[n_ctes % 2];
1191
+ snprintf(cte_name, 32, "step_%zu", i);
1192
+
1193
+ if (n_ctes > 0) sb_append(&sb, ",\n");
1194
+ sb_append(&sb, " ");
1195
+
1196
+ if (emit_cte(&sb, cte_name, prev, node->op, node->args, &err) != 0) {
1197
+ sb_free(&sb);
1198
+ if (error) *error = err;
1199
+ else free(err);
1200
+ return NULL;
1201
+ }
1202
+
1203
+ prev = cte_name;
1204
+ n_ctes++;
1205
+ }
1206
+
1207
+ /* Final SELECT from last CTE */
1208
+ sb_appendf(&sb, "\nSELECT * FROM %s", prev);
1209
+
1210
+ return sb_detach(&sb);
1211
+ }