chore: auto-commit (95 archivos)

- cmd/fn/doctor.go
- cmd/fn/main.go
- cpp/apps/primitives_gallery/playground/tables/CMakeLists.txt
- cpp/apps/primitives_gallery/playground/tables/data_table.cpp
- cpp/apps/primitives_gallery/playground/tables/data_table_logic.cpp
- cpp/apps/primitives_gallery/playground/tables/data_table_logic.h
- cpp/apps/primitives_gallery/playground/tables/self_test.cpp
- cpp/apps/primitives_gallery/playground/tables/tql.cpp
- cpp/apps/primitives_gallery/playground/tables/viz.cpp
- cpp/apps/primitives_gallery/playground/tables/viz.h
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 00:50:34 +02:00
parent a2bbf23374
commit e3c8979e8d
189 changed files with 18964 additions and 330 deletions
@@ -3,8 +3,10 @@ add_imgui_app(tables_playground
main.cpp
data_table.cpp
data_table_logic.cpp
llm_anthropic.cpp
lua_engine.cpp
tql.cpp
tql_to_sql.cpp
viz.cpp
)
target_link_libraries(tables_playground PRIVATE lua54 implot)
@@ -13,10 +15,13 @@ target_link_libraries(tables_playground PRIVATE lua54 implot)
add_executable(tables_playground_self_test
self_test.cpp
data_table_logic.cpp
llm_anthropic.cpp
lua_engine.cpp
tql.cpp
tql_to_sql.cpp
)
target_include_directories(tables_playground_self_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_SOURCE_DIR}/functions
)
target_link_libraries(tables_playground_self_test PRIVATE lua54)
@@ -1,20 +1,33 @@
#include "data_table.h"
#include "app_base.h"
#include "imgui.h"
#include "llm_anthropic.h"
#include "lua_engine.h"
#include "tql.h"
#include "tql_to_sql.h"
#include "viz.h"
#include <algorithm>
#include <cfloat>
#include <cstdio>
#include <cstring>
#include <ctime>
#include <fstream>
#include <string>
#include <unordered_map>
namespace data_table {
// UTC date today as ISO YYYY-MM-DD. Para preset filtros Last7/30/90d.
static std::string today_iso() {
std::time_t t = std::time(nullptr);
std::tm tm = *std::gmtime(&t);
char buf[16];
std::snprintf(buf, sizeof(buf), "%04d-%02d-%02d",
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday);
return buf;
}
namespace {
// ---------------------------------------------------------------------------
@@ -122,10 +135,106 @@ struct UiState {
// Toggle Table <-> View: remember last non-table display.
ViewMode last_non_table_main = ViewMode::Bar;
// Drill history (fase 10). Stacks per-app; no persistido en TQL.
std::vector<DrillStep> drill_back;
std::vector<DrillStep> drill_forward;
// Row inspector (fase 10). -1 cerrado, sino row idx en el output del stage activo.
int inspect_row = -1;
bool inspect_open = false;
// Ask AI modal (fase 11 — issue 0080).
bool ask_open = false;
bool ask_busy = false;
int ask_mode = 0; // 0 = TQL, 1 = SQL
char ask_question[2048] = {0};
std::string ask_current_tql; // emit del state actual al abrir modal
std::string ask_response_raw; // texto del modelo
std::string ask_response_code; // bloque extraido (Lua o SQL)
std::string ask_error;
std::string ask_status; // "Sent. Waiting..." / "OK" / error
char ask_edit_buf[8192] = {0}; // buffer editable de propuesta
};
UiState& ui() { static UiState s; return s; }
// Row inspector modal (fase 10). Muestra todas cols + valores de la fila
// inspect_row del output del stage activo. Read-only + Copy TSV + Filter
// by this row (anade filters al stage previo si existe).
static void draw_row_inspector_modal(State& st, int active,
const char* const* cells, int rows, int cols,
const std::vector<std::string>& headers,
const std::vector<ColumnType>& types,
const std::vector<std::string>& prev_input_headers) {
auto& U = ui();
if (!U.inspect_open) return;
if (U.inspect_row < 0 || U.inspect_row >= rows) {
U.inspect_open = false;
return;
}
ImGui::OpenPopup("##row_inspector");
ImGui::SetNextWindowSize(ImVec2(560, 400), ImGuiCond_Appearing);
if (ImGui::BeginPopupModal("##row_inspector", &U.inspect_open,
ImGuiWindowFlags_NoSavedSettings)) {
ImGui::Text("Row %d", U.inspect_row);
ImGui::SameLine(0, 20);
if (ImGui::SmallButton("Copy TSV")) {
std::string tsv = row_to_tsv(cells, rows, cols, U.inspect_row, headers);
ImGui::SetClipboardText(tsv.c_str());
}
ImGui::SameLine();
bool can_filter = (active > 0 && !prev_input_headers.empty());
ImGui::BeginDisabled(!can_filter);
if (ImGui::SmallButton("Filter prev stage by this row")) {
int target = active - 1;
for (int c = 0; c < cols; ++c) {
const char* v = cells[U.inspect_row * cols + c];
if (!v || !*v) continue;
const std::string& h = headers[c];
std::string h_clean;
parse_breakout_granularity(h, h_clean);
int ci = -1;
for (size_t i = 0; i < prev_input_headers.size(); ++i) {
if (prev_input_headers[i] == h_clean) { ci = (int)i; break; }
}
if (ci < 0) continue;
DrillStep step;
step.target_stage = target;
step.filter_pos = (int)st.stages[target].filters.size();
step.prev_active_stage = st.active_stage;
step.added = make_drill_filter(ci, v);
if (apply_drill_step(st, step)) {
U.drill_back.push_back(step);
}
}
U.drill_forward.clear();
U.inspect_open = false;
}
ImGui::EndDisabled();
ImGui::Separator();
ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg
| ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable;
if (ImGui::BeginTable("##inspector_tbl", 2, flags, ImVec2(-1, -1))) {
ImGui::TableSetupColumn("col");
ImGui::TableSetupColumn("value");
ImGui::TableHeadersRow();
for (int c = 0; c < cols; ++c) {
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
ColumnType t = (c < (int)types.size()) ? types[c] : ColumnType::String;
ImGui::Text("%s %s", column_type_icon(t),
(c < (int)headers.size()) ? headers[c].c_str() : "?");
ImGui::TableSetColumnIndex(1);
const char* v = cells[U.inspect_row * cols + c];
ImGui::TextWrapped("%s", v ? v : "");
}
ImGui::EndTable();
}
ImGui::EndPopup();
}
}
int autocomplete_cb(ImGuiInputTextCallbackData* data) {
UiState* U = (UiState*)data->UserData;
if (data->EventFlag == ImGuiInputTextFlags_CallbackAlways) {
@@ -180,6 +289,47 @@ void ensure_init(State& st, int eff_cols) {
// ---------------------------------------------------------------------------
void draw_stage_breadcrumb(State& st) {
st.ensure_stage0();
// Drill history back/forward (fase 10). Botones al inicio.
auto& U = ui();
{
bool can_back = !U.drill_back.empty();
ImGui::BeginDisabled(!can_back);
if (ImGui::SmallButton("<##drill_back")) {
DrillStep s = U.drill_back.back();
U.drill_back.pop_back();
if (undo_drill_step(st, s)) {
U.drill_forward.push_back(s);
}
}
ImGui::EndDisabled();
if (can_back && ImGui::IsItemHovered())
ImGui::SetTooltip("Drill back (%zu)", U.drill_back.size());
ImGui::SameLine();
bool can_fwd = !U.drill_forward.empty();
ImGui::BeginDisabled(!can_fwd);
if (ImGui::SmallButton(">##drill_fwd")) {
DrillStep s = U.drill_forward.back();
U.drill_forward.pop_back();
if (apply_drill_step(st, s)) {
U.drill_back.push_back(s);
}
}
ImGui::EndDisabled();
if (can_fwd && ImGui::IsItemHovered())
ImGui::SetTooltip("Drill forward (%zu)", U.drill_forward.size());
ImGui::SameLine();
bool can_up = (st.active_stage > 0);
ImGui::BeginDisabled(!can_up);
if (ImGui::SmallButton("^##drill_up")) drill_up(st);
ImGui::EndDisabled();
if (can_up && ImGui::IsItemHovered())
ImGui::SetTooltip("Drill up (stage previo, sin perder filters)");
ImGui::SameLine();
ImGui::TextDisabled("|");
ImGui::SameLine();
}
for (int si = 0; si < (int)st.stages.size(); ++si) {
if (si > 0) { ImGui::SameLine(); ImGui::TextDisabled(">"); ImGui::SameLine(); }
@@ -610,6 +760,19 @@ void draw_viz_selector(State& st) {
ImGui::OpenPopup("##viz_cfg_popup");
}
ImGui::SameLine();
if (ImGui::SmallButton("Ask AI##ask_open")) {
auto& U2 = ui();
U2.ask_open = true;
U2.ask_busy = false;
U2.ask_error.clear();
U2.ask_status.clear();
U2.ask_response_code.clear();
U2.ask_response_raw.clear();
U2.ask_current_tql = tql::emit(st,
std::vector<std::string>(), // emit headers stage 0 (caller fill si necesario)
std::vector<ColumnType>());
}
ImGui::SameLine();
if (ImGui::SmallButton("+ Viz##viz_add")) {
VizPanel p;
p.display = ViewMode::Bar;
@@ -737,7 +900,8 @@ void draw_joins_chips(State& st, const std::vector<TableInput>& joinables,
// Filter chips para el stage activo. eff_headers/eff_cols son del INPUT del
// stage activo (= orig+derived para stage 0; output del stage previo para 1+).
// ---------------------------------------------------------------------------
void draw_filter_chips(Stage& stg, const char* const* eff_headers, int eff_cols) {
void draw_filter_chips(Stage& stg, const char* const* eff_headers, int eff_cols,
const std::vector<ColumnType>& eff_types) {
auto& U = ui();
ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(120, 60, 170, 220));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(150, 85, 200, 240));
@@ -746,6 +910,50 @@ void draw_filter_chips(Stage& stg, const char* const* eff_headers, int eff_cols)
ImGui::PopStyleColor(3);
ImGui::SameLine();
// Presets (fase 10): menu con Last7/30/90d (cols Date), ExcludeNulls (any),
// NonZero (cols numericas). Apply append a stg.filters via build_preset_filters.
if (ImGui::SmallButton("Presets##fpresets")) ImGui::OpenPopup("##presets_menu");
if (ImGui::BeginPopup("##presets_menu")) {
int first_date = -1, first_num = -1;
for (int c = 0; c < eff_cols && c < (int)eff_types.size(); ++c) {
if (first_date < 0 && eff_types[c] == ColumnType::Date) first_date = c;
if (first_num < 0 && (eff_types[c] == ColumnType::Int ||
eff_types[c] == ColumnType::Float)) first_num = c;
}
auto apply_preset = [&](FilterPreset p, int col) {
auto fs = build_preset_filters(p, col, today_iso());
for (auto& f : fs) stg.filters.push_back(f);
};
if (first_date >= 0) {
char l1[96], l2[96], l3[96];
std::snprintf(l1, sizeof(l1), "Last 7 days on \"%s\"", eff_headers[first_date]);
std::snprintf(l2, sizeof(l2), "Last 30 days on \"%s\"", eff_headers[first_date]);
std::snprintf(l3, sizeof(l3), "Last 90 days on \"%s\"", eff_headers[first_date]);
if (ImGui::MenuItem(l1)) apply_preset(FilterPreset::Last7d, first_date);
if (ImGui::MenuItem(l2)) apply_preset(FilterPreset::Last30d, first_date);
if (ImGui::MenuItem(l3)) apply_preset(FilterPreset::Last90d, first_date);
ImGui::Separator();
}
if (ImGui::BeginMenu("Exclude nulls in...")) {
for (int c = 0; c < eff_cols; ++c) {
if (ImGui::MenuItem(eff_headers[c])) apply_preset(FilterPreset::ExcludeNulls, c);
}
ImGui::EndMenu();
}
if (first_num >= 0) {
if (ImGui::BeginMenu("Non-zero in...")) {
for (int c = 0; c < eff_cols && c < (int)eff_types.size(); ++c) {
if (eff_types[c] == ColumnType::Int || eff_types[c] == ColumnType::Float) {
if (ImGui::MenuItem(eff_headers[c])) apply_preset(FilterPreset::NonZero, c);
}
}
ImGui::EndMenu();
}
}
ImGui::EndPopup();
}
ImGui::SameLine();
if (stg.filters.empty()) {
ImGui::TextDisabled("Sin filtros.");
return;
@@ -778,7 +986,8 @@ void draw_filter_chips(Stage& stg, const char* const* eff_headers, int eff_cols)
}
// Chips de breakout (stage > 0).
void draw_breakout_chips(Stage& stg, const char* const* in_headers, int in_cols) {
void draw_breakout_chips(Stage& stg, const char* const* in_headers, int in_cols,
const std::vector<ColumnType>& in_types) {
auto& U = ui();
ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32( 60, 160, 170, 220));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32( 80, 190, 200, 240));
@@ -792,6 +1001,17 @@ void draw_breakout_chips(Stage& stg, const char* const* in_headers, int in_cols)
return;
}
for (size_t i = 0; i < stg.breakouts.size(); ) {
std::string col_name;
DateGranularity g = parse_breakout_granularity(stg.breakouts[i], col_name);
// Resolve col index para lookup de tipo.
int col_idx = -1;
for (int c = 0; c < in_cols; ++c) {
if (std::strcmp(in_headers[c], col_name.c_str()) == 0) { col_idx = c; break; }
}
bool is_date_col = (col_idx >= 0 && col_idx < (int)in_types.size()
&& in_types[col_idx] == ColumnType::Date);
char buf[256];
std::snprintf(buf, sizeof(buf), "%s x##bk%zu", stg.breakouts[i].c_str(), i);
ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32( 60, 160, 170, 220));
@@ -802,20 +1022,42 @@ void draw_breakout_chips(Stage& stg, const char* const* in_headers, int in_cols)
if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) {
U.edit_chip_kind = 2;
U.edit_chip_idx = (int)i;
// resolve current col name to index in in_headers
U.edit_col_idx = 0;
for (int c = 0; c < in_cols; ++c) {
if (std::strcmp(in_headers[c], stg.breakouts[i].c_str()) == 0) {
U.edit_col_idx = c; break;
}
}
U.edit_col_idx = (col_idx >= 0) ? col_idx : 0;
ImGui::OpenPopup("##edit_breakout");
}
if (clicked) { stg.breakouts.erase(stg.breakouts.begin() + i); continue; }
// Granularity combo inline cuando col Date (fase 10).
if (is_date_col) {
ImGui::SameLine();
const char* preview = (g == DateGranularity::None)
? "(raw)" : date_granularity_token(g);
char combo_id[32];
std::snprintf(combo_id, sizeof(combo_id), "##gran%zu", i);
ImGui::SetNextItemWidth(72);
if (ImGui::BeginCombo(combo_id, preview)) {
DateGranularity opts[] = {
DateGranularity::None,
DateGranularity::Year,
DateGranularity::Month,
DateGranularity::Week,
DateGranularity::Day,
DateGranularity::Hour,
};
for (auto o : opts) {
const char* lbl = (o == DateGranularity::None)
? "(raw)" : date_granularity_token(o);
if (ImGui::Selectable(lbl, o == g)) {
stg.breakouts[i] = compose_breakout(col_name, o);
}
}
ImGui::EndCombo();
}
}
ImGui::SameLine();
++i;
}
(void)in_headers; (void)in_cols;
ImGui::NewLine();
}
@@ -1220,7 +1462,8 @@ void draw_add_filter_popup(Stage& stg, const char* const* eff_headers_arr, int e
}
void draw_add_breakout_popup(Stage& stg, const char* const* in_headers, int in_cols,
const std::vector<ColumnType>& in_types) {
const std::vector<ColumnType>& in_types,
const char* const* in_cells, int in_rows) {
auto& U = ui();
if (!ImGui::BeginPopup("##addbreakout")) return;
if (U.brk_picker_col < 0 || U.brk_picker_col >= in_cols) U.brk_picker_col = 0;
@@ -1236,7 +1479,18 @@ void draw_add_breakout_popup(Stage& stg, const char* const* in_headers, int in_c
ImGui::EndCombo();
}
if (ImGui::Button("Add##bk")) {
stg.breakouts.emplace_back(in_headers[U.brk_picker_col]);
int c = U.brk_picker_col;
std::string col = in_headers[c];
// Fase 10: si col es Date, auto-detect granularidad via rango lexical
// (ISO YYYY-MM-DD ordena bien). Default Day si rango invalido.
if (c >= 0 && c < (int)in_types.size() && in_types[c] == ColumnType::Date) {
std::string lo, hi;
column_min_max(in_cells, in_rows, in_cols, c, lo, hi);
DateGranularity g = auto_date_granularity(lo, hi);
stg.breakouts.emplace_back(compose_breakout(col, g));
} else {
stg.breakouts.emplace_back(col);
}
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
@@ -1441,8 +1695,17 @@ void drill_into(State& st, int from_stage,
if (prev_input_headers[i] == col_name) { ci = (int)i; break; }
}
if (ci < 0) return;
st.stages[target].filters.push_back(make_drill_filter(ci, value));
st.active_stage = target;
// Fase 10: graba step en drill_back, limpia forward (rama nueva).
DrillStep step;
step.target_stage = target;
step.filter_pos = (int)st.stages[target].filters.size();
step.prev_active_stage = st.active_stage;
step.added = make_drill_filter(ci, value);
apply_drill_step(st, step);
auto& U = ui();
U.drill_back.push_back(step);
U.drill_forward.clear();
}
} // anon namespace
@@ -1659,7 +1922,7 @@ void render(const char* id,
draw_joins_chips(st, *joinables, mh);
}
draw_filter_chips(act, eff_headers.data(), eff_cols);
draw_filter_chips(act, eff_headers.data(), eff_cols, eff_types);
draw_add_filter_popup(act, eff_headers.data(), eff_cols, eff_types);
draw_edit_filter_popup(act, eff_headers.data(), eff_cols, eff_types);
@@ -2290,12 +2553,13 @@ void render(const char* id,
if (chrome_visible) {
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(8, 2));
draw_filter_chips(act, ih_ptrs.data(), in_cols_n);
draw_filter_chips(act, ih_ptrs.data(), in_cols_n, input_types_active);
draw_add_filter_popup(act, ih_ptrs.data(), in_cols_n, input_types_active);
draw_edit_filter_popup(act, ih_ptrs.data(), in_cols_n, input_types_active);
draw_breakout_chips(act, ih_ptrs.data(), in_cols_n);
draw_add_breakout_popup(act, ih_ptrs.data(), in_cols_n, input_types_active);
draw_breakout_chips(act, ih_ptrs.data(), in_cols_n, input_types_active);
draw_add_breakout_popup(act, ih_ptrs.data(), in_cols_n, input_types_active,
cur_cells, cur_rows);
draw_edit_breakout_popup(act, ih_ptrs.data(), in_cols_n);
draw_aggregation_chips(act, ih_ptrs.data(), in_cols_n);
@@ -2524,7 +2788,22 @@ void render(const char* id,
so_local.cells.push_back(cur_cells[i]);
so_ptr = &so_local;
}
viz::render(*so_ptr, st.display, st.viz_config, ImVec2(-1, -1));
int clicked_row = -1;
viz::render(*so_ptr, st.display, st.viz_config, ImVec2(-1, -1), &clicked_row);
// Fase 10: click sobre chart -> drill al stage previo usando
// breakout col[0] como filtro Op::Eq sobre cells[clicked_row].
if (clicked_row >= 0 && active > 0 &&
so_ptr->cols > 0 && clicked_row < so_ptr->rows) {
int n_brk = (int)st.stages[active].breakouts.size();
if (n_brk > 0) {
const char* v = so_ptr->cells[clicked_row * so_ptr->cols + 0];
std::string col_clean;
parse_breakout_granularity(so_ptr->headers[0], col_clean);
drill_into(st, active, col_clean,
v ? std::string(v) : "",
input_headers_active);
}
}
goto stage_n_table_end;
}
@@ -2613,12 +2892,10 @@ void render(const char* id,
ImGui::PushID(r * cur_cols_n + c);
ImGui::Selectable(cell ? cell : "");
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) {
// Drill-down solo si c es col de breakout (c < n_brk).
if (c < n_brk) {
U.pending_col = c;
U.pending_value = cell ? cell : "";
ImGui::OpenPopup("##drill_popup");
}
U.pending_col = c;
U.pending_value = cell ? cell : "";
U.inspect_row = r;
ImGui::OpenPopup("##drill_popup");
}
if (ImGui::BeginPopup("##drill_popup")) {
if (c < n_brk) {
@@ -2631,6 +2908,12 @@ void render(const char* id,
input_headers_active);
ImGui::CloseCurrentPopup();
}
ImGui::Separator();
}
if (ImGui::MenuItem("Inspect row...")) {
U.inspect_row = r;
U.inspect_open = true;
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
@@ -2642,6 +2925,11 @@ void render(const char* id,
}
stage_n_table_end:;
// Row inspector modal (fase 10). Activado via right-click "Inspect row..."
// sobre celdas del table del stage activo. `cur_cells` ya es row-major.
draw_row_inspector_modal(st, active, cur_cells, cur_rows, cur_cols_n,
cur_headers, cur_types, input_headers_active);
// Render extras (stage>0 path)
if (!st.extra_panels.empty() && cur_cols_n > 0) {
StageOutput so_local;
@@ -2958,6 +3246,118 @@ void render(const char* id,
ImGui::EndPopup();
}
// Ask AI modal (fase 11 — issue 0080).
if (U.ask_open) ImGui::OpenPopup("Ask AI");
ImGui::SetNextWindowSize(ImVec2(820, 560), ImGuiCond_Appearing);
if (ImGui::BeginPopupModal("Ask AI", &U.ask_open,
ImGuiWindowFlags_NoSavedSettings)) {
ImGui::TextDisabled("Ask en lenguaje natural. Default TQL. SQL solo si DuckDB linkado.");
const char* modes[] = {"TQL", "SQL (DuckDB)"};
#ifndef FN_TQL_DUCKDB
// SQL mode disabled visually pero el toggle existe (informativo)
if (U.ask_mode == 1) U.ask_mode = 0;
#endif
ImGui::Combo("Output##askmode", &U.ask_mode, modes, IM_ARRAYSIZE(modes));
#ifndef FN_TQL_DUCKDB
if (U.ask_mode == 1) {
ImGui::TextColored(ImVec4(1, 0.5f, 0.3f, 1),
"SQL mode requires FN_TQL_DUCKDB=1 build flag.");
}
#endif
ImGui::InputTextMultiline("##ask_q", U.ask_question, sizeof(U.ask_question),
ImVec2(-1, 80));
ImGui::BeginDisabled(U.ask_busy);
if (ImGui::Button("Send")) {
U.ask_busy = true;
U.ask_status = "Sending...";
U.ask_error.clear();
U.ask_response_code.clear();
U.ask_response_raw.clear();
// Build AskInput desde el state actual.
llm_anthropic::AskInput in;
in.question = U.ask_question;
in.tql_current = U.ask_current_tql;
in.col_names = U.active_headers;
in.col_types = U.active_types;
in.mode = (U.ask_mode == 1)
? llm_anthropic::OutputMode::SQL
: llm_anthropic::OutputMode::TQL;
// Llamada blocking (UI freeze breve durante red).
auto r = llm_anthropic::ask(in);
U.ask_busy = false;
if (!r.error.empty()) {
U.ask_error = r.error;
U.ask_status = "Error";
} else {
U.ask_response_raw = r.raw;
U.ask_response_code = r.code;
U.ask_status = "Got response.";
// Llenar edit buffer
std::snprintf(U.ask_edit_buf, sizeof(U.ask_edit_buf),
"%s", r.code.c_str());
}
}
ImGui::EndDisabled();
ImGui::SameLine();
if (!U.ask_status.empty()) {
ImGui::TextDisabled("%s", U.ask_status.c_str());
}
if (!U.ask_error.empty()) {
ImGui::TextColored(ImVec4(1, 0.4f, 0.4f, 1), "%s", U.ask_error.c_str());
}
ImGui::Separator();
ImGui::Columns(2, "ask_cols", true);
ImGui::TextUnformatted("Current");
ImGui::InputTextMultiline("##ask_cur",
const_cast<char*>(U.ask_current_tql.c_str()),
U.ask_current_tql.size() + 1,
ImVec2(-1, 240),
ImGuiInputTextFlags_ReadOnly);
ImGui::NextColumn();
ImGui::TextUnformatted("Proposed (editable before apply)");
ImGui::InputTextMultiline("##ask_new", U.ask_edit_buf, sizeof(U.ask_edit_buf),
ImVec2(-1, 240));
ImGui::Columns(1);
bool can_apply = !U.ask_busy && U.ask_edit_buf[0] != '\0';
ImGui::BeginDisabled(!can_apply);
if (ImGui::Button("Apply")) {
std::string err;
if (U.ask_mode == 0) {
// TQL apply
bool ok = tql::apply(U.ask_edit_buf, st,
U.active_headers,
U.active_types,
nullptr, 0,
(int)U.active_headers.size(),
&err);
if (ok) {
U.ask_status = "Applied OK.";
U.ask_open = false;
} else {
U.ask_error = "tql::apply error: " + err;
U.ask_status = "Apply failed.";
}
} else {
// SQL apply: requires DuckDB adapter (no v1).
U.ask_status = "SQL execute requires FN_TQL_DUCKDB build flag.";
}
}
ImGui::EndDisabled();
ImGui::SameLine();
if (ImGui::Button("Reject")) {
U.ask_response_code.clear();
U.ask_edit_buf[0] = '\0';
}
ImGui::SameLine();
if (ImGui::Button("Close")) {
U.ask_open = false;
}
ImGui::EndPopup();
}
if (U.open_cell_popup) { ImGui::OpenPopup("##cell_op"); U.open_cell_popup = false; }
if (ImGui::BeginPopup("##cell_op")) {
ColumnType t = (U.pending_col >= 0 && U.pending_col < eff_cols)
@@ -567,6 +567,69 @@ Filter make_drill_filter(int col_idx, const std::string& value) {
return f;
}
bool apply_drill_step(State& st, const DrillStep& step) {
if (step.target_stage < 0 || step.target_stage >= (int)st.stages.size()) return false;
Stage& s = st.stages[step.target_stage];
int pos = step.filter_pos;
if (pos < 0 || pos > (int)s.filters.size()) return false;
s.filters.insert(s.filters.begin() + pos, step.added);
st.active_stage = step.target_stage;
return true;
}
bool drill_up(State& st) {
if (st.stages.empty()) return false;
if (st.active_stage <= 0) return false;
st.active_stage -= 1;
return true;
}
std::string row_to_tsv(const char* const* cells, int rows, int cols,
int row_idx, const std::vector<std::string>& headers) {
if (row_idx < 0 || row_idx >= rows || cols <= 0) return "";
std::string out;
for (int c = 0; c < cols; ++c) {
if (c > 0) out += '\t';
if (c < (int)headers.size()) out += headers[c];
}
out += "\r\n";
for (int c = 0; c < cols; ++c) {
if (c > 0) out += '\t';
const char* v = cells[row_idx * cols + c];
if (v) out += v;
}
out += "\r\n";
return out;
}
std::vector<Filter> build_filters_from_row(const char* const* cells, int rows,
int cols, int row_idx) {
std::vector<Filter> out;
if (row_idx < 0 || row_idx >= rows || cols <= 0) return out;
for (int c = 0; c < cols; ++c) {
const char* v = cells[row_idx * cols + c];
if (!v || !*v) continue;
Filter f;
f.col = c;
f.op = Op::Eq;
f.value = v;
out.push_back(f);
}
return out;
}
bool undo_drill_step(State& st, const DrillStep& step) {
if (step.target_stage < 0 || step.target_stage >= (int)st.stages.size()) return false;
Stage& s = st.stages[step.target_stage];
int pos = step.filter_pos;
if (pos < 0 || pos >= (int)s.filters.size()) return false;
s.filters.erase(s.filters.begin() + pos);
if (step.prev_active_stage >= 0 && step.prev_active_stage < (int)st.stages.size()) {
st.active_stage = step.prev_active_stage;
}
return true;
}
std::vector<int> apply_filters(const char* const* cells, int rows, int cols,
const std::vector<Filter>& filters)
{
@@ -696,19 +759,57 @@ StageOutput compute_stage(const char* const* in_cells, int in_rows, int in_cols,
}
// Grouped: agrupa visible por valores de breakout, calcula aggregations.
std::vector<int> break_cols(stage.breakouts.size());
for (size_t i = 0; i < stage.breakouts.size(); ++i) {
break_cols[i] = find_col(in_headers, stage.breakouts[i]);
// Breakouts pueden llevar sufijo `:granularity` para cols Date (fase 10).
int nbreaks = (int)stage.breakouts.size();
std::vector<int> break_cols(nbreaks);
std::vector<DateGranularity> break_grans(nbreaks);
bool any_trunc = false;
for (int i = 0; i < nbreaks; ++i) {
std::string col_name;
break_grans[i] = parse_breakout_granularity(stage.breakouts[i], col_name);
if (break_grans[i] != DateGranularity::None) any_trunc = true;
break_cols[i] = find_col(in_headers, col_name);
}
// Pre-truncate solo cuando hay granularity activa. Strings persistidos en
// out.cell_backing para que los punteros sobrevivan al return de la funcion.
// Reservamos upfront para que push_back no invalide punteros anteriores.
// Tamaño = trunc cells + aggregation cells (peor caso n_groups <= in_rows).
out.cell_backing.reserve(
(size_t)in_rows * (size_t)nbreaks +
(size_t)in_rows * stage.aggregations.size() + 16);
std::vector<const char*> trunc_ptrs;
if (any_trunc) {
trunc_ptrs.assign((size_t)in_rows * (size_t)nbreaks, nullptr);
for (int r = 0; r < in_rows; ++r) {
for (int i = 0; i < nbreaks; ++i) {
if (break_grans[i] == DateGranularity::None) continue;
int bc = break_cols[i];
if (bc < 0) continue;
const char* v = in_cells[r * in_cols + bc];
out.cell_backing.emplace_back(
truncate_date(v ? v : "", break_grans[i]));
trunc_ptrs[(size_t)r * nbreaks + i] = out.cell_backing.back().c_str();
}
}
}
auto cell_for = [&](int r, int i) -> const char* {
int bc = break_cols[i];
if (bc < 0) return "";
if (break_grans[i] != DateGranularity::None) {
return trunc_ptrs[(size_t)r * nbreaks + i];
}
const char* v = in_cells[r * in_cols + bc];
return v ? v : "";
};
auto make_key = [&](int r) -> std::string {
std::string k;
for (size_t i = 0; i < break_cols.size(); ++i) {
for (int i = 0; i < nbreaks; ++i) {
if (i > 0) k += '\x1f'; // separador unit-separator (no aparece en datos)
int bc = break_cols[i];
if (bc < 0) continue;
const char* v = in_cells[r * in_cols + bc];
k += (v ? v : "");
k += cell_for(r, i);
}
return k;
};
@@ -727,10 +828,9 @@ StageOutput compute_stage(const char* const* in_cells, int in_rows, int in_cols,
key_to_group.emplace(k, gi);
group_keys.push_back(k);
group_rows.emplace_back();
std::vector<const char*> bv(break_cols.size(), "");
for (size_t i = 0; i < break_cols.size(); ++i) {
int bc = break_cols[i];
bv[i] = (bc >= 0) ? in_cells[r * in_cols + bc] : "";
std::vector<const char*> bv((size_t)nbreaks, "");
for (int i = 0; i < nbreaks; ++i) {
bv[i] = cell_for(r, i);
}
group_breakvals.push_back(std::move(bv));
} else gi = it->second;
@@ -742,11 +842,17 @@ StageOutput compute_stage(const char* const* in_cells, int in_rows, int in_cols,
out.cols = out_cols;
out.headers.reserve(out_cols);
out.types.reserve(out_cols);
for (size_t i = 0; i < stage.breakouts.size(); ++i) {
for (int i = 0; i < nbreaks; ++i) {
out.headers.push_back(stage.breakouts[i]);
int bc = break_cols[i];
out.types.push_back((bc >= 0 && bc < (int)in_types.size())
? in_types[bc] : ColumnType::String);
// Si hay granularity activa, el output es String (formato ymd o similar),
// no la fecha original.
ColumnType ot = ColumnType::String;
if (break_grans[i] == DateGranularity::None
&& bc >= 0 && bc < (int)in_types.size()) {
ot = in_types[bc];
}
out.types.push_back(ot);
}
for (const auto& a : stage.aggregations) {
out.headers.push_back(aggregation_alias(a));
@@ -1102,4 +1208,288 @@ StageOutput join_tables(const char* const* left_cells, int left_rows, int left_c
return out;
}
// ----------------------------------------------------------------------------
// Fase 10: drill extendido — granularity + presets.
// ----------------------------------------------------------------------------
const char* date_granularity_token(DateGranularity g) {
switch (g) {
case DateGranularity::Year: return "year";
case DateGranularity::Month: return "month";
case DateGranularity::Week: return "week";
case DateGranularity::Day: return "day";
case DateGranularity::Hour: return "hour";
default: return "";
}
}
DateGranularity date_granularity_from_token(const char* s) {
if (!s) return DateGranularity::None;
std::string t(s);
if (t == "year") return DateGranularity::Year;
if (t == "month") return DateGranularity::Month;
if (t == "week") return DateGranularity::Week;
if (t == "day") return DateGranularity::Day;
if (t == "hour") return DateGranularity::Hour;
return DateGranularity::None;
}
DateGranularity parse_breakout_granularity(const std::string& breakout,
std::string& col_out) {
auto pos = breakout.rfind(':');
if (pos == std::string::npos) {
col_out = breakout;
return DateGranularity::None;
}
std::string suffix = breakout.substr(pos + 1);
DateGranularity g = date_granularity_from_token(suffix.c_str());
if (g == DateGranularity::None) {
col_out = breakout;
return DateGranularity::None;
}
col_out = breakout.substr(0, pos);
return g;
}
std::string compose_breakout(const std::string& col, DateGranularity g) {
if (g == DateGranularity::None) return col;
return col + ":" + date_granularity_token(g);
}
int nearest_index_1d(double target, const double* xs, int n) {
if (n <= 0 || !xs) return -1;
int best = -1;
double best_d = 0.0;
for (int i = 0; i < n; ++i) {
double v = xs[i];
if (std::isnan(v)) continue;
double d = std::fabs(v - target);
if (best < 0 || d < best_d) { best = i; best_d = d; }
}
return best;
}
int nearest_index_2d(double tx, double ty,
const double* xs, const double* ys, int n) {
if (n <= 0 || !xs || !ys) return -1;
int best = -1;
double best_d = 0.0;
for (int i = 0; i < n; ++i) {
double x = xs[i], y = ys[i];
if (std::isnan(x) || std::isnan(y)) continue;
double dx = x - tx, dy = y - ty;
double d = dx*dx + dy*dy;
if (best < 0 || d < best_d) { best = i; best_d = d; }
}
return best;
}
double pie_angle(double cx, double cy, double mx, double my) {
// ImPlot pie: 0 = top, sentido horario. atan2 estandar: 0 = +X (right), CCW.
// Conversion: ImPlot angle = atan2(dx, -dy) y normalizar a [0, 2*PI).
double dx = mx - cx;
double dy = my - cy;
double a = std::atan2(dx, -dy); // 0 cuando (dx=0, dy<0) = top
const double two_pi = 6.283185307179586;
if (a < 0) a += two_pi;
return a;
}
int pie_slice_at_angle(double angle, const double* sums, int n) {
if (n <= 0 || !sums) return -1;
double total = 0.0;
for (int i = 0; i < n; ++i) {
if (sums[i] < 0) return -1;
total += sums[i];
}
if (total <= 0.0) return -1;
const double two_pi = 6.283185307179586;
if (angle < 0 || angle >= two_pi) return -1;
double cum = 0.0;
for (int i = 0; i < n; ++i) {
cum += (sums[i] / total) * two_pi;
if (angle < cum) return i;
}
return n - 1; // edge case rounding
}
void heatmap_cell_at(double px, double py, int rows, int cols,
int& row_out, int& col_out) {
row_out = -1;
col_out = -1;
if (rows <= 0 || cols <= 0) return;
if (px < 0.0 || px >= (double)cols) return;
if (py < 0.0 || py >= (double)rows) return;
col_out = (int)px;
// ImPlot heatmap pinta row 0 arriba; plot Y suele invertirse. Caller
// normaliza si necesita. Aqui devolvemos row = floor(py) en coord plot.
row_out = (int)py;
}
void column_min_max(const char* const* cells, int rows, int cols, int col_idx,
std::string& min_out, std::string& max_out) {
min_out.clear();
max_out.clear();
if (col_idx < 0 || col_idx >= cols) return;
bool first = true;
for (int r = 0; r < rows; ++r) {
const char* v = cells[r * cols + col_idx];
if (!v || !*v) continue;
std::string s(v);
if (first) {
min_out = s;
max_out = s;
first = false;
} else {
if (s < min_out) min_out = s;
if (s > max_out) max_out = s;
}
}
}
namespace {
// Parse ISO "YYYY-MM-DD..." -> (y, m, d). True si los 3 primeros campos OK.
bool parse_ymd(const std::string& s, int& y, int& m, int& d) {
if (s.size() < 10) return false;
for (int i : {0,1,2,3,5,6,8,9}) {
if (s[(size_t)i] < '0' || s[(size_t)i] > '9') return false;
}
if (s[4] != '-' || s[7] != '-') return false;
y = (s[0]-'0')*1000 + (s[1]-'0')*100 + (s[2]-'0')*10 + (s[3]-'0');
m = (s[5]-'0')*10 + (s[6]-'0');
d = (s[8]-'0')*10 + (s[9]-'0');
if (m < 1 || m > 12 || d < 1 || d > 31) return false;
return true;
}
// Dias desde 0001-01-01 (proleptic Gregorian).
long ymd_to_days(int y, int m, int d) {
if (m <= 2) { y -= 1; m += 12; }
long era = (y >= 0 ? y : y - 399) / 400;
unsigned yoe = (unsigned)(y - era * 400);
unsigned doy = (unsigned)((153 * (m - 3) + 2) / 5 + d - 1);
unsigned doe = yoe * 365 + yoe/4 - yoe/100 + doy;
return era * 146097 + (long)doe;
}
void days_to_ymd(long days, int& y, int& m, int& d) {
long era = (days >= 0 ? days : days - 146096) / 146097;
unsigned doe = (unsigned)(days - era * 146097);
unsigned yoe = (doe - doe/1460 + doe/36524 - doe/146096) / 365;
int yr = (int)yoe + (int)era * 400;
unsigned doy = doe - (365*yoe + yoe/4 - yoe/100);
unsigned mp = (5*doy + 2)/153;
unsigned day = doy - (153*mp + 2)/5 + 1;
unsigned mon = mp < 10 ? mp + 3 : mp - 9;
if (mon <= 2) yr += 1;
y = yr; m = (int)mon; d = (int)day;
}
} // anon
std::string truncate_date(const std::string& date, DateGranularity g) {
if (g == DateGranularity::None) return date;
int y, m, d;
if (!parse_ymd(date, y, m, d)) return date;
char buf[32];
switch (g) {
case DateGranularity::Year:
std::snprintf(buf, sizeof(buf), "%04d", y);
return buf;
case DateGranularity::Month:
std::snprintf(buf, sizeof(buf), "%04d-%02d", y, m);
return buf;
case DateGranularity::Day:
std::snprintf(buf, sizeof(buf), "%04d-%02d-%02d", y, m, d);
return buf;
case DateGranularity::Hour: {
int hh = 0;
if (date.size() >= 13 && date[10] == 'T'
&& date[11] >= '0' && date[11] <= '9'
&& date[12] >= '0' && date[12] <= '9') {
hh = (date[11]-'0')*10 + (date[12]-'0');
if (hh < 0 || hh > 23) hh = 0;
}
std::snprintf(buf, sizeof(buf), "%04d-%02d-%02dT%02d", y, m, d, hh);
return buf;
}
case DateGranularity::Week: {
// Hinnant ymd_to_days: day 0 == 0000-03-01 (Wednesday).
// days%7: 0=Wed, 1=Thu, 2=Fri, 3=Sat, 4=Sun, 5=Mon, 6=Tue.
// Monday offset: (mod - 5 + 7) % 7.
long days = ymd_to_days(y, m, d);
int mod = (int)(((days % 7) + 7) % 7);
int rem = ((mod - 5) % 7 + 7) % 7;
long monday = days - rem;
int yy, mm, dd;
days_to_ymd(monday, yy, mm, dd);
std::snprintf(buf, sizeof(buf), "%04d-%02d-%02d", yy, mm, dd);
return buf;
}
default: return date;
}
}
DateGranularity auto_date_granularity(const std::string& min_ymd,
const std::string& max_ymd) {
int y1,m1,d1, y2,m2,d2;
if (!parse_ymd(min_ymd, y1,m1,d1)) return DateGranularity::Day;
if (!parse_ymd(max_ymd, y2,m2,d2)) return DateGranularity::Day;
long span = ymd_to_days(y2,m2,d2) - ymd_to_days(y1,m1,d1);
if (span < 0) span = -span;
if (span > 730) return DateGranularity::Year; // >2 anios
if (span > 60) return DateGranularity::Month;
if (span > 14) return DateGranularity::Week;
return DateGranularity::Day;
}
const char* filter_preset_label(FilterPreset p) {
switch (p) {
case FilterPreset::Last7d: return "Last 7 days";
case FilterPreset::Last30d: return "Last 30 days";
case FilterPreset::Last90d: return "Last 90 days";
case FilterPreset::ExcludeNulls: return "Exclude nulls";
case FilterPreset::NonZero: return "Non-zero only";
}
return "?";
}
std::vector<Filter> build_preset_filters(FilterPreset preset, int col,
const std::string& today_ymd) {
std::vector<Filter> out;
auto last_n = [&](int n) {
int y, m, d;
if (!parse_ymd(today_ymd, y, m, d)) return;
long days = ymd_to_days(y, m, d) - n;
int yy, mm, dd;
days_to_ymd(days, yy, mm, dd);
char buf[16];
std::snprintf(buf, sizeof(buf), "%04d-%02d-%02d", yy, mm, dd);
Filter f;
f.col = col;
f.op = Op::Gte;
f.value = buf;
out.push_back(f);
};
switch (preset) {
case FilterPreset::Last7d: last_n(7); break;
case FilterPreset::Last30d: last_n(30); break;
case FilterPreset::Last90d: last_n(90); break;
case FilterPreset::ExcludeNulls: {
Filter f; f.col = col; f.op = Op::Neq; f.value = "";
out.push_back(f);
break;
}
case FilterPreset::NonZero: {
Filter f1; f1.col = col; f1.op = Op::Neq; f1.value = "";
Filter f2; f2.col = col; f2.op = Op::Neq; f2.value = "0";
out.push_back(f1);
out.push_back(f2);
break;
}
}
return out;
}
} // namespace data_table
@@ -1,26 +1,20 @@
// Logica pura del playground data_table. Sin ImGui — testable headless.
// Cuando se promueva al registry, esto sera la base de data_table_cpp_viz.
// TIPOS promovidos al registry (issue 0081). Este header solo declara
// funciones; los types vienen de cpp/functions/core/data_table_types.h.
#pragma once
#include "core/data_table_types.h"
#include <string>
#include <utility>
#include <vector>
namespace data_table {
enum class Op {
Eq, Neq, Gt, Gte, Lt, Lte,
Contains, NotContains, StartsWith, EndsWith
};
// ----------------------------------------------------------------------------
// Helpers para Op y ColumnType.
// ----------------------------------------------------------------------------
const char* op_label(Op o);
bool op_is_string_only(Op o);
// ----------------------------------------------------------------------------
// Column types - declarado por caller con fallback a auto-detect.
// ----------------------------------------------------------------------------
enum class ColumnType {
Auto, String, Int, Float, Bool, Date, Json
};
bool op_is_string_only(Op o);
const char* column_type_name(ColumnType t);
const char* column_type_icon(ColumnType t); // UTF-8 Tabler icon
@@ -36,63 +30,11 @@ ColumnType auto_detect_type(const char* const* cells, int rows, int cols,
ColumnType effective_type(ColumnType declared,
const char* const* cells, int rows, int cols, int col);
// Derived column: inmutable. Dos modos:
// 1) Retipo puro: source_col >= 0, formula == "". Cells del origen.
// 2) Formula: source_col == -1, formula no vacia. Eval por Lua.
struct DerivedColumn {
int source_col = -1;
ColumnType type = ColumnType::String;
std::string name;
std::string formula; // "" = retipado puro; resto = body Lua
int lua_id = -1; // referencia en lua_engine; -1 si no compilado
std::string compile_error;
};
// Filter movido aqui (antes era despues de State) porque TQL Stage lo necesita.
struct Filter {
int col;
Op op;
std::string value;
};
struct ColorRule {
int col;
std::string equals;
unsigned int color;
};
// ----------------------------------------------------------------------------
// TQL (Table Query Language) — stage model. Ver docs/TQL.md.
// Aggregation helpers.
// ----------------------------------------------------------------------------
enum class AggFn {
Count, Sum, Avg, Min, Max, Distinct, Stddev,
Median, P25, P75, P90, P99, Percentile
};
const char* agg_fn_name(AggFn f);
struct Aggregation {
AggFn fn = AggFn::Count;
std::string col; // ignorado para Count
double arg = 0.0; // para Percentile (0..1)
std::string alias; // vacio -> auto-generado via aggregation_alias()
};
struct SortClause {
std::string col;
bool desc = false;
};
// Stage: layer de TQL. Stage 0 = Raw (sin breakouts/aggregations).
// Stage 1+ pueden agrupar. Cada stage consume output del anterior.
struct Stage {
std::vector<Filter> filters;
std::vector<DerivedColumn> derived; // expressions de este stage
std::vector<std::string> breakouts; // col names del INPUT de este stage
std::vector<Aggregation> aggregations;
std::vector<SortClause> sorts;
};
// Pure: alias por defecto cuando agg.alias esta vacio.
// count -> "count"
// distinct col -> "distinct_<col>"
@@ -101,224 +43,125 @@ struct Stage {
std::string aggregation_alias(const Aggregation& a);
// Pure: tipo del output de la aggregation.
// count, distinct -> Int
// sum, avg, stddev,
// median, p*, percentile -> Float
// min, max -> mismo tipo que la col origen
ColumnType aggregation_type(const Aggregation& a,
const std::vector<std::string>& in_headers,
const std::vector<ColumnType>& in_types);
// Output de compute_stage. Posee `cell_backing` (strings nuevos para
// resultados agregados) y `cells` (punteros row-major a backing o a
// `in_cells` original para passthrough).
struct StageOutput {
std::vector<std::string> cell_backing;
std::vector<const char*> cells;
int rows = 0;
int cols = 0;
std::vector<std::string> headers;
std::vector<ColumnType> types;
};
// ----------------------------------------------------------------------------
// Compute pipeline.
// ----------------------------------------------------------------------------
// Pure: ejecuta un Stage sobre los cells de entrada. Aplica filter -> (group+agg|passthrough) -> sort.
StageOutput compute_stage(const char* const* in_cells, int in_rows, int in_cols,
const std::vector<std::string>& in_headers,
const std::vector<ColumnType>& in_types,
const Stage& stage);
// Pure: aplica filtros usando headers para resolver f.col (que ahora es
// indice en el array de in_headers, no del dataset original). Devuelve
// indices de filas que pasan.
// Pure: aplica filtros usando headers para resolver f.col.
std::vector<int> apply_filters(const char* const* cells, int rows, int cols,
const std::vector<Filter>& filters);
// Pure: helper para drill-down. Devuelve un Filter Op::Eq sobre col_idx con
// el value indicado. col_idx es indice en los headers del INPUT del stage
// previo (donde se va a aplicar el filtro).
// el value indicado.
Filter make_drill_filter(int col_idx, const std::string& value);
// ----------------------------------------------------------------------------
// ViewMode: tipo de visualizacion a renderizar sobre el output del stage activo.
// "Table" siempre disponible. Resto requiere ciertos tipos de columnas.
// ViewMode helpers.
// ----------------------------------------------------------------------------
enum class ViewMode {
Table,
// Bars
Bar, // horizontal bars: 1 cat + 1 num
Column, // vertical bars: 1 cat + 1 num
GroupedBar, // 1 cat + N num (side-by-side)
StackedBar, // 1 cat + N num (stacked)
// Lines / area
Line, // X + 1..N Y series
Area, // shaded to y=0
Stairs, // step plot
// Points
Scatter, // X + Y
Bubble, // X + Y + size
// Distribution
Histogram, // 1 num
Histogram2D, // 2 num
Heatmap, // matrix from breakouts
BoxPlot, // 1 cat + 1 num (min/p25/p50/p75/max per group)
// Stems / signals
Stem,
ErrorBars,
// Composition
Pie,
Donut,
Funnel, // ordered descending bars
Waterfall, // running sum
// Single values
KPI, // big text + label
KPIGrid, // all aggregations as cards
// Specialized
Candlestick, // OHLC: time + open + high + low + close
Radar, // multi-axis (1 cat + N num)
};
const char* view_mode_token(ViewMode m); // "table", "bar", ...
const char* view_mode_label(ViewMode m); // "Table", "Bar (horizontal)", ...
const char* view_mode_token(ViewMode m);
const char* view_mode_label(ViewMode m);
ViewMode view_mode_from_token(const char* s);
int view_mode_min_cols(ViewMode m);
bool view_mode_needs_numeric(ViewMode m);
bool view_mode_needs_category(ViewMode m);
// Requiere stage agrupado (breakout+aggregation). Si user esta en stage 0 con
// uno de estos, conviene auto-promote a stage 1.
bool view_mode_needs_aggregation(ViewMode m);
// Lista completa de modos para el selector UI (orden de display).
// Lista completa de modos para el selector UI.
const ViewMode* all_view_modes(int* n_out);
// ----------------------------------------------------------------------------
// Joins (MBQL-style). Ver issue 0078.
// ----------------------------------------------------------------------------
enum class JoinStrategy { Left, Inner, Right, Full };
const char* join_strategy_token(JoinStrategy s);
JoinStrategy join_strategy_from_token(const char* s);
const char* join_strategy_label(JoinStrategy s);
// Tabla extra pasada al render() para joins. Owner externo (caller).
struct TableInput {
std::string name; // identificador estable (matchea Join.source)
std::vector<std::string> headers;
std::vector<ColumnType> types;
const char* const* cells = nullptr; // row-major, headers.size() cols x rows filas
int rows = 0;
int cols = 0;
};
// Join clause: une la tabla actual con `source` por las parejas `on`,
// prefijando las cols del derecho con `alias.`.
struct Join {
std::string alias;
std::string source;
std::vector<std::pair<std::string, std::string>> on; // {left_col, right_col}
JoinStrategy strategy = JoinStrategy::Left;
std::vector<std::string> fields; // vacio = all del derecho
};
// Pure: resuelve indice del main entre `tables` segun `main_source`.
// Vacio -> 0. Nombre desconocido -> 0. tables vacio -> -1.
int resolve_main_idx(const std::vector<TableInput>& tables, const std::string& main_source);
// Pure: aplica un join sobre dos tablas. Resultado: StageOutput con
// `headers` = left + `<alias>.<right_col>` (filtrado por fields si no vacio).
// Pure: aplica un join sobre dos tablas.
StageOutput join_tables(const char* const* left_cells, int left_rows, int left_cols,
const std::vector<std::string>& left_headers,
const std::vector<ColumnType>& left_types,
const TableInput& right,
const Join& jn);
// ViewConfig: overrides manuales de auto-detect para la vista activa.
// Campos vacios -> auto. Si col name no existe en output, viz cae a auto.
struct ViewConfig {
std::string x_col; // single: scatter, line, hist2d
std::vector<std::string> y_cols; // 1..N: line/area/bar/etc
std::string size_col; // bubble
std::string cat_col; // bar/pie/funnel/box override
unsigned int primary_color = 0; // 0 = ImPlot auto
int hist_bins = 0; // 0 = Sturges
float pie_radius = 0.0f; // 0 = default
bool show_legend = true;
bool show_markers = false; // line/area markers
bool locked = false; // disable pan/zoom
mutable bool fit_request = false; // consumed by viz::render
};
// ----------------------------------------------------------------------------
// Drill apply/undo (fase 10).
// ----------------------------------------------------------------------------
bool apply_drill_step(State& st, const DrillStep& step);
bool undo_drill_step(State& st, const DrillStep& step);
// VizPanel: viz adicional sobre el mismo StageOutput. State.display + viz_config
// es el panel 0 (siempre visible); extra_panels son los aniadidos por el user.
struct VizPanel {
ViewMode display = ViewMode::Bar;
ViewConfig config;
// Memoria del ultimo non-Table display para toggle Table<->View.
mutable ViewMode last_non_table = ViewMode::Bar;
};
// Pure (fase 10): drill-up. Decrementa active_stage si > 0.
bool drill_up(State& st);
// State: stage pipeline + viz globales.
//
// `stages` siempre tiene tamaño >= 1 (auto-init en compute_visible_rows / render
// si esta vacio: se crea stages[0] vacio). Stage 0 es Raw (filters + derived +
// sorts; SIN breakouts/aggregations). Stages 1+ pueden agrupar.
//
// `active_stage` = indice del stage cuyo output se renderiza.
// `col_visible/col_order/color_rules` aplican al output del stage activo.
struct State {
std::vector<Stage> stages;
int active_stage = 0;
ViewMode display = ViewMode::Table;
ViewConfig viz_config;
std::vector<VizPanel> extra_panels;
std::vector<Join> joins; // aplicado antes de stages[0]
std::string main_source; // name de TableInput a usar como main; vacio -> tables[0]
// Pure (fase 10): serializa una fila a TSV.
std::string row_to_tsv(const char* const* cells, int rows, int cols,
int row_idx, const std::vector<std::string>& headers);
std::vector<ColorRule> color_rules;
std::vector<bool> col_visible; // size = effective_cols del stage activo
std::vector<int> col_order; // permutacion [0..effective_cols)
// Pure (fase 10): construye filters Op::Eq desde una fila.
std::vector<Filter> build_filters_from_row(const char* const* cells, int rows,
int cols, int row_idx);
// --- Compat helpers: shortcuts a stages[0] (Raw) ---
// Util tras refactor para tests / accesos puntuales. Garantizan stages[0]
// existe (lo crean vacio si no).
Stage& raw();
const Stage& raw() const;
Stage& active();
const Stage& active_const() const;
void ensure_stage0();
};
// ----------------------------------------------------------------------------
// Date granularity helpers (fase 10).
// ----------------------------------------------------------------------------
const char* date_granularity_token(DateGranularity g);
DateGranularity date_granularity_from_token(const char* s);
// Parse "1.23" -> 1.23, true. False si la celda no es numero completo.
DateGranularity parse_breakout_granularity(const std::string& breakout,
std::string& col_out);
std::string compose_breakout(const std::string& col, DateGranularity g);
void column_min_max(const char* const* cells, int rows, int cols, int col_idx,
std::string& min_out, std::string& max_out);
// Hit-tests para click-to-drill sobre charts (fase 10).
int nearest_index_1d(double target, const double* xs, int n);
int nearest_index_2d(double tx, double ty,
const double* xs, const double* ys, int n);
double pie_angle(double cx, double cy, double mx, double my);
int pie_slice_at_angle(double angle, const double* sums, int n);
void heatmap_cell_at(double px, double py, int rows, int cols,
int& row_out, int& col_out);
// Date trunc + auto + presets.
std::string truncate_date(const std::string& date, DateGranularity g);
DateGranularity auto_date_granularity(const std::string& min_ymd,
const std::string& max_ymd);
const char* filter_preset_label(FilterPreset p);
std::vector<Filter> build_preset_filters(FilterPreset preset, int col,
const std::string& today_ymd);
// ----------------------------------------------------------------------------
// Misc helpers.
// ----------------------------------------------------------------------------
bool parse_number(const char* s, double& out);
// Compara dos celdas con operador. Numerico si ambas parseables; lexical si no.
bool compare(const char* a, const char* b, Op op);
// Aplica filtros y ordena. Devuelve indices de filas visibles.
std::vector<int> compute_visible_rows(const char* const* cells,
int rows, int cols,
const State& st);
// Pure: muta col_order de st para colocar `src` en la posicion (en orden visual)
// donde estaba `dst`. No-op si src == dst o cualquiera fuera del array.
void reorder_column(State& st, int src, int dst);
// Pure: dado un buffer y posicion de cursor, busca el `[` abierto sin cerrar
// mas reciente. Devuelve su indice (o -1 si ninguno). Rellena `filter_text`
// con los caracteres entre `[` y cursor.
// Para autocomplete de formulas: cuando el usuario teclea `[` el ImGui callback
// detecta esto y muestra un popup con cols disponibles.
int find_open_bracket(const char* buf, int len, int cursor, std::string& filter_text);
// Pure: reemplaza src[start..cursor) por "[name]". Devuelve nuevo string y
// actualiza `new_cursor` a la posicion despues del `]`.
std::string insert_column_ref(const std::string& src, int start, int cursor,
const std::string& name, int& new_cursor);
// CSV: escapa una celda segun RFC 4180 (wrap en " si contiene , " o newline).
std::string csv_escape(const char* s);
// Construye TSV de un rect de seleccion. Headers SIEMPRE incluidos.
// view_row_lo/hi: indices en visible_rows.
// view_col_lo/hi: indices en col_order. Cols ocultas se omiten.
std::string build_tsv(const char* const* cells, int rows, int cols,
const char* const* headers,
const std::vector<int>& col_order,
@@ -327,19 +170,21 @@ std::string build_tsv(const char* const* cells, int rows, int cols,
int view_row_lo, int view_row_hi,
int view_col_lo, int view_col_hi);
// Construye CSV (full visible view). Headers incluidos, cells escapados.
std::string build_csv(const char* const* cells, int rows, int cols,
const char* const* headers,
const std::vector<int>& col_order,
const std::vector<bool>& col_visible,
const std::vector<int>& visible_rows);
// ----------------------------------------------------------------------------
// Column statistics (no movido todavia al registry).
// ----------------------------------------------------------------------------
struct ColStats {
int total = 0; // filas escaneadas
int empty_count = 0; // cells == "" o null
int unique_count = 0; // distintas (cap configurable)
bool unique_capped = false; // true si se alcanzo el cap
bool numeric = false; // true si todas las cells no-vacias parsean como numero
int total = 0;
int empty_count = 0;
int unique_count = 0;
bool unique_capped = false;
bool numeric = false;
int numeric_count = 0;
double min = 0;
double max = 0;
@@ -348,16 +193,12 @@ struct ColStats {
double p25 = 0;
double p50 = 0;
double p75 = 0;
std::vector<float> hist; // bins (HIST_BINS) si numeric
std::vector<std::pair<std::string,int>> top_categories; // top 8 por count desc
std::vector<float> hist;
std::vector<std::pair<std::string,int>> top_categories;
};
constexpr int HIST_BINS = 24;
// Pure: escanea una columna y devuelve estadisticas. `unique_cap` corta el
// conteo de unicos si excede (para datasets de millones). 0 = sin cap.
// Si `indices != nullptr` y `n_indices > 0`, recorre solo las filas indicadas
// (uso tipico: stats sobre filas visibles post-filtro).
ColStats compute_column_stats(const char* const* cells, int rows, int cols,
int col, int unique_cap = 100000,
const int* indices = nullptr, int n_indices = 0);
@@ -0,0 +1,295 @@
// llm_anthropic.cpp — cliente Anthropic minimal via cURL popen.
// Ver issue 0080.
#include "llm_anthropic.h"
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <sstream>
#include <string>
namespace llm_anthropic {
using namespace data_table;
namespace {
// JSON escape minimal.
std::string json_escape(const std::string& s) {
std::string o;
o.reserve(s.size() + 8);
for (char c : s) {
switch (c) {
case '"': o += "\\\""; break;
case '\\': o += "\\\\"; break;
case '\n': o += "\\n"; break;
case '\r': o += "\\r"; break;
case '\t': o += "\\t"; break;
case '\b': o += "\\b"; break;
case '\f': o += "\\f"; break;
default:
if ((unsigned char)c < 0x20) {
char buf[8];
std::snprintf(buf, sizeof(buf), "\\u%04x", (int)(unsigned char)c);
o += buf;
} else {
o += c;
}
}
}
return o;
}
const char* col_type_doc(ColumnType t) {
switch (t) {
case ColumnType::String: return "string";
case ColumnType::Int: return "int";
case ColumnType::Float: return "float";
case ColumnType::Bool: return "bool";
case ColumnType::Date: return "date";
case ColumnType::Json: return "json";
case ColumnType::Auto: return "auto";
}
return "?";
}
std::string build_schema_block(const AskInput& in) {
std::ostringstream os;
os << "Available columns (stage 0 input):\n";
for (size_t i = 0; i < in.col_names.size(); ++i) {
os << " - " << in.col_names[i] << ": "
<< col_type_doc(i < in.col_types.size() ? in.col_types[i] : ColumnType::String)
<< "\n";
}
if (!in.joinable_names.empty()) {
os << "Joinable tables (for join clause):\n";
for (const auto& n : in.joinable_names) os << " - " << n << "\n";
}
return os.str();
}
std::string build_system_prompt(OutputMode mode) {
if (mode == OutputMode::TQL) {
return
"You are a TQL (Table Query Language) expert. Output ONLY a Lua code block. "
"TQL is a Lua table with shape:\n"
" return { version=1, display=\"table\"|\"bar\"|\"line\"|...,\n"
" main_source=\"name\", joins={ {alias,source,on,strategy,fields},... },\n"
" stages={ {filter={{op,col,value},...}, breakout={...}, aggregation={...}, sort={...} },... },\n"
" columns={ name = {type=\"int|float|...\", formula=\"[col]+1\"},... }\n"
" }\n"
"Stage 0 = Raw (filters + derived + sort, NO breakouts/aggs).\n"
"Stage 1+ groups (breakouts + aggregations).\n"
"Breakout granularity: append :year|:month|:week|:day|:hour to col name.\n"
"Aggregation functions: count|sum|avg|min|max|distinct|stddev|median|p25|p75|p90|p99|percentile.\n"
"Filter ops: '='|'!='|'<'|'<='|'>'|'>='|'contains'|'!contains'|'starts'|'ends'.\n"
"Sort: {{dir, col}, ...} where dir = 'asc'|'desc'.\n"
"Join strategies: 'left'|'inner'|'right'|'full'.\n"
"Formulas use Lua expression syntax with [col] for column refs.\n"
"Output format: ```lua\\n...\\n```";
}
return
"You are a DuckDB SQL expert. Output ONLY a SQL code block compatible with DuckDB.\n"
"Use CTEs to chain stages. Use date_trunc('month', col) for granularity.\n"
"Use quantile_cont(col, p) for percentiles. Use ? for bound params.\n"
"Joins: LEFT/INNER/RIGHT/FULL OUTER JOIN. String concat: ||. Aggregations: standard SQL.\n"
"Output format: ```sql\\n...\\n```";
}
} // anon
std::string build_request_body(const AskInput& in) {
std::string system_msg = build_system_prompt(in.mode);
std::string schema = build_schema_block(in);
std::ostringstream user_msg;
user_msg << "Question: " << in.question << "\n\n"
<< schema << "\n";
if (!in.tql_current.empty()) {
user_msg << "Current TQL:\n```lua\n" << in.tql_current << "\n```\n";
}
std::string model = in.model.empty() ? "claude-sonnet-4-6" : in.model;
std::ostringstream body;
body << "{"
<< "\"model\":\"" << json_escape(model) << "\","
<< "\"max_tokens\":" << in.max_tokens << ","
<< "\"system\":\"" << json_escape(system_msg) << "\","
<< "\"messages\":[{"
<< "\"role\":\"user\","
<< "\"content\":\"" << json_escape(user_msg.str()) << "\""
<< "}]"
<< "}";
return body.str();
}
std::string extract_code_block(const std::string& raw, const std::string& lang) {
// Buscar ```<lang> primero, sino ``` plain.
std::string fence_lang = "```" + lang;
auto pos = raw.find(fence_lang);
size_t code_start = std::string::npos;
if (pos != std::string::npos) {
code_start = pos + fence_lang.size();
} else {
pos = raw.find("```");
if (pos != std::string::npos) {
code_start = pos + 3;
// skip optional lang tag
while (code_start < raw.size() && raw[code_start] != '\n' &&
raw[code_start] != '\r' && std::isalnum((unsigned char)raw[code_start])) {
++code_start;
}
}
}
if (code_start == std::string::npos) {
// No fence — return raw stripped.
size_t i = 0; while (i < raw.size() && std::isspace((unsigned char)raw[i])) ++i;
size_t j = raw.size(); while (j > i && std::isspace((unsigned char)raw[j-1])) --j;
return raw.substr(i, j - i);
}
// Skip newline tras fence.
if (code_start < raw.size() && raw[code_start] == '\n') ++code_start;
auto end = raw.find("```", code_start);
if (end == std::string::npos) end = raw.size();
std::string code = raw.substr(code_start, end - code_start);
// Trim trailing newline.
while (!code.empty() && (code.back() == '\n' || code.back() == '\r')) code.pop_back();
return code;
}
std::string parse_response_text(const std::string& json) {
// Buscar pattern: "text":"..."
// Simple: primer occurrence de \"text\":\" tras \"type\":\"text\"
auto t = json.find("\"text\"");
while (t != std::string::npos) {
// Skip "text"
size_t i = t + 6;
// Skip whitespace y :
while (i < json.size() && (json[i] == ' ' || json[i] == ':' || json[i] == '\t')) ++i;
if (i >= json.size() || json[i] != '"') {
t = json.find("\"text\"", t + 1);
continue;
}
++i;
std::string out;
while (i < json.size() && json[i] != '"') {
if (json[i] == '\\' && i + 1 < json.size()) {
char esc = json[i+1];
if (esc == 'n') out += '\n';
else if (esc == 't') out += '\t';
else if (esc == 'r') out += '\r';
else if (esc == '"') out += '"';
else if (esc == '\\') out += '\\';
else if (esc == '/') out += '/';
else if (esc == 'u' && i + 5 < json.size()) {
// basic ascii \uXXXX
int code = 0;
for (int k = 0; k < 4; ++k) {
char c = json[i + 2 + k];
int v = (c >= '0' && c <= '9') ? c - '0'
: (c >= 'a' && c <= 'f') ? c - 'a' + 10
: (c >= 'A' && c <= 'F') ? c - 'A' + 10 : 0;
code = code * 16 + v;
}
if (code < 128) out += (char)code;
else out += '?';
i += 5;
} else {
out += esc;
}
i += 2;
} else {
out += json[i++];
}
}
return out;
}
return "";
}
namespace {
// Lee API key segun prioridad: param > env FN_LLM_API_KEY > pass anthropic/api-key.
std::string resolve_api_key(const std::string& provided) {
if (!provided.empty()) return provided;
const char* env = std::getenv("FN_LLM_API_KEY");
if (env && *env) return env;
// pass anthropic/api-key | head -n1
FILE* p = popen("pass anthropic/api-key 2>/dev/null | head -n1", "r");
if (!p) return "";
std::string out;
char buf[256];
while (fgets(buf, sizeof(buf), p)) out += buf;
pclose(p);
while (!out.empty() && (out.back() == '\n' || out.back() == '\r')) out.pop_back();
return out;
}
} // anon
std::string call_api(const std::string& body, const std::string& api_key,
std::string& error_out) {
error_out.clear();
// Test injection
const char* mock = std::getenv("FN_LLM_MOCK_RESPONSE");
if (mock && *mock) return mock;
std::string key = resolve_api_key(api_key);
if (key.empty()) {
error_out = "no API key (set FN_LLM_API_KEY env, pass param, or `pass anthropic/api-key`)";
return "";
}
const char* endpoint_env = std::getenv("FN_LLM_ENDPOINT");
std::string endpoint = endpoint_env && *endpoint_env
? endpoint_env
: "https://api.anthropic.com/v1/messages";
// popen "w+" no portable. Write body a tmp file y leer respuesta de curl
// por redireccion. Portable Unix/Mingw.
std::string tmp_in = std::tmpnam(nullptr);
std::string tmp_out = std::tmpnam(nullptr);
{
FILE* f = std::fopen(tmp_in.c_str(), "w");
if (!f) { error_out = "tmp file write fail"; return ""; }
std::fwrite(body.data(), 1, body.size(), f);
std::fclose(f);
}
std::string cmd2 = "curl -sS -X POST "
"-H \"content-type: application/json\" "
"-H \"anthropic-version: 2023-06-01\" "
"-H \"x-api-key: " + key + "\" "
"--data-binary @" + tmp_in + " " + endpoint
+ " > " + tmp_out + " 2>&1";
int rc = std::system(cmd2.c_str());
std::string resp;
{
FILE* f = std::fopen(tmp_out.c_str(), "r");
if (f) {
char buf[4096];
size_t n;
while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) resp.append(buf, n);
std::fclose(f);
}
}
std::remove(tmp_in.c_str());
std::remove(tmp_out.c_str());
if (rc != 0) {
error_out = "curl exit " + std::to_string(rc) + ": " + resp;
return "";
}
return resp;
}
AskResult ask(const AskInput& in, const std::string& api_key) {
AskResult r;
std::string body = build_request_body(in);
std::string raw_json = call_api(body, api_key, r.error);
if (!r.error.empty()) return r;
r.raw = parse_response_text(raw_json);
std::string lang = (in.mode == OutputMode::TQL) ? "lua" : "sql";
r.code = extract_code_block(r.raw, lang);
return r;
}
} // namespace llm_anthropic
@@ -0,0 +1,58 @@
// llm_anthropic: cliente HTTP minimal a Anthropic Claude API.
// Sin deps externas (cURL via popen).
// Ver issue 0080.
#pragma once
#include "data_table_logic.h"
#include "tql_to_sql.h"
#include <string>
#include <vector>
namespace llm_anthropic {
enum class OutputMode { TQL, SQL };
struct AskInput {
std::string question; // pregunta NL
std::string tql_current; // TQL actual (emitido)
std::vector<std::string> col_names; // schema input
std::vector<data_table::ColumnType> col_types;
std::vector<std::string> joinable_names; // tables disponibles para join
OutputMode mode = OutputMode::TQL;
std::string model; // empty -> default
int max_tokens = 8192;
};
struct AskResult {
std::string code; // bloque ```lua o ```sql extraido (sin fences)
std::string raw; // texto completo de la respuesta
std::string error; // non-empty si fallo
int tokens_in = 0;
int tokens_out = 0;
};
// Pure: construye el system prompt y user message JSON-escapado.
// Devuelve el JSON body completo POST al endpoint /v1/messages.
std::string build_request_body(const AskInput& in);
// Pure: extrae primer ```<lang>\n ... \n``` bloque de `raw`. lang = "lua"|"sql".
// Si no encuentra fence, retorna raw stripped.
std::string extract_code_block(const std::string& raw, const std::string& lang);
// Pure: extrae texto del JSON de respuesta Anthropic.
// Busca `"content":[{"type":"text","text":"..."}]` y devuelve el text.
std::string parse_response_text(const std::string& json_body);
// Impure: lanza cURL via popen, posts `body` al endpoint Anthropic /v1/messages,
// retorna response body (JSON crudo). API key leida de:
// 1. parametro `api_key` si non-empty
// 2. env FN_LLM_API_KEY
// 3. `pass anthropic/api-key | head -n1`
// Si FN_LLM_MOCK_RESPONSE env set, retorna su valor (test injection).
std::string call_api(const std::string& body, const std::string& api_key,
std::string& error_out);
// Orchestrator: build prompt + POST + parse. Convenience wrapper.
AskResult ask(const AskInput& in, const std::string& api_key = "");
} // namespace llm_anthropic
@@ -7,9 +7,12 @@
// Exit 0 = todos los checks pasan, 1 = falla.
#include "data_table_logic.h"
#include "llm_anthropic.h"
#include "lua_engine.h"
#include "tql.h"
#include "tql_to_sql.h"
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
@@ -2051,6 +2054,782 @@ return {
check(join_strategy_from_token("nope") == JoinStrategy::Left, "phase9: parse fallback left");
}
// === phase10: drill extendido ===
{
// truncate_date — granularities sobre 2026-05-12 (martes).
std::string d = "2026-05-12";
check(truncate_date(d, DateGranularity::Year) == "2026", "phase10: trunc year");
check(truncate_date(d, DateGranularity::Month) == "2026-05", "phase10: trunc month");
check(truncate_date(d, DateGranularity::Day) == "2026-05-12", "phase10: trunc day");
check(truncate_date(d, DateGranularity::Week) == "2026-05-11", "phase10: trunc week (Mon)");
check(truncate_date("2026-05-12T14:33:01", DateGranularity::Hour) == "2026-05-12T14",
"phase10: trunc hour");
check(truncate_date("not-a-date", DateGranularity::Month) == "not-a-date",
"phase10: trunc passthrough invalido");
check(truncate_date(d, DateGranularity::None) == d, "phase10: trunc None == identidad");
}
{
// auto_date_granularity
check(auto_date_granularity("2024-01-01", "2026-05-12") == DateGranularity::Year,
"phase10: auto year >2y");
check(auto_date_granularity("2026-01-01", "2026-05-12") == DateGranularity::Month,
"phase10: auto month >60d");
check(auto_date_granularity("2026-04-15", "2026-05-12") == DateGranularity::Week,
"phase10: auto week >14d");
check(auto_date_granularity("2026-05-05", "2026-05-12") == DateGranularity::Day,
"phase10: auto day <=14d");
check(auto_date_granularity("bad", "2026-05-12") == DateGranularity::Day,
"phase10: auto fallback day");
}
{
// parse_breakout_granularity
std::string col;
check(parse_breakout_granularity("ts:month", col) == DateGranularity::Month,
"phase10: parse breakout month");
check(col == "ts", "phase10: parse breakout col stripped");
check(parse_breakout_granularity("ts", col) == DateGranularity::None,
"phase10: parse breakout sin sufijo None");
check(col == "ts", "phase10: col sin sufijo intacto");
check(parse_breakout_granularity("ts:wat", col) == DateGranularity::None,
"phase10: sufijo desconocido None");
check(col == "ts:wat", "phase10: col preserva sufijo desconocido");
}
{
// compose_breakout
check(compose_breakout("ts", DateGranularity::None) == "ts", "phase10: compose None");
check(compose_breakout("ts", DateGranularity::Month) == "ts:month", "phase10: compose month");
check(compose_breakout("ts", DateGranularity::Year) == "ts:year", "phase10: compose year");
// round-trip parse(compose)
std::string col;
auto g = parse_breakout_granularity(compose_breakout("foo", DateGranularity::Week), col);
check(g == DateGranularity::Week && col == "foo", "phase10: compose+parse round-trip");
}
{
// column_min_max
const char* cells[] = {
"2026-03-01",
"2026-01-15",
"",
"2026-05-12",
"2026-02-22",
};
std::string lo, hi;
column_min_max(cells, 5, 1, 0, lo, hi);
check(lo == "2026-01-15" && hi == "2026-05-12", "phase10: column_min_max ISO ordena lexical");
const char* empty_cells[] = {"", "", ""};
column_min_max(empty_cells, 3, 1, 0, lo, hi);
check(lo.empty() && hi.empty(), "phase10: column_min_max sin datos -> vacio");
column_min_max(cells, 5, 1, 5, lo, hi); // col fuera de rango
check(lo.empty() && hi.empty(), "phase10: column_min_max col fuera de rango -> vacio");
}
{
// tokens round-trip granularity
check(date_granularity_from_token("year") == DateGranularity::Year, "phase10: token year");
check(date_granularity_from_token("month") == DateGranularity::Month, "phase10: token month");
check(date_granularity_from_token("week") == DateGranularity::Week, "phase10: token week");
check(date_granularity_from_token("day") == DateGranularity::Day, "phase10: token day");
check(date_granularity_from_token("hour") == DateGranularity::Hour, "phase10: token hour");
check(date_granularity_from_token("nope") == DateGranularity::None, "phase10: token fallback None");
check(std::string(date_granularity_token(DateGranularity::Month)) == "month",
"phase10: emit month");
check(std::string(date_granularity_token(DateGranularity::None)) == "",
"phase10: emit None empty");
}
{
// build_preset_filters
auto f7 = build_preset_filters(FilterPreset::Last7d, 2, "2026-05-12");
check(f7.size() == 1, "phase10: Last7d -> 1 filter");
check(f7[0].col == 2 && f7[0].op == Op::Gte && f7[0].value == "2026-05-05",
"phase10: Last7d -> Gte 2026-05-05");
auto f30 = build_preset_filters(FilterPreset::Last30d, 2, "2026-05-12");
check(f30[0].value == "2026-04-12", "phase10: Last30d -> 2026-04-12");
auto f90 = build_preset_filters(FilterPreset::Last90d, 2, "2026-05-12");
check(f90[0].value == "2026-02-11", "phase10: Last90d -> 2026-02-11");
auto fn0 = build_preset_filters(FilterPreset::ExcludeNulls, 3, "");
check(fn0.size() == 1 && fn0[0].op == Op::Neq && fn0[0].value == "",
"phase10: ExcludeNulls -> Neq ''");
auto fnz = build_preset_filters(FilterPreset::NonZero, 4, "");
check(fnz.size() == 2, "phase10: NonZero -> 2 filters");
check(fnz[0].op == Op::Neq && fnz[0].value == "" &&
fnz[1].op == Op::Neq && fnz[1].value == "0",
"phase10: NonZero -> Neq '' AND Neq '0'");
auto fbad = build_preset_filters(FilterPreset::Last7d, 2, "bad-date");
check(fbad.empty(), "phase10: Last7d con today invalido -> empty");
}
{
// TQL round-trip: breakout con sufijo :granularity.
State st0;
st0.stages.resize(2);
st0.stages[1].breakouts = {"ts:month"};
Aggregation a; a.fn = AggFn::Count; a.alias = "n";
st0.stages[1].aggregations.push_back(a);
std::vector<std::string> hdrs = {"ts", "amount"};
std::vector<ColumnType> tys = {ColumnType::Date, ColumnType::Float};
int eff = 2;
std::string text = tql::emit(st0, hdrs, tys);
check(text.find("\"ts:month\"") != std::string::npos,
"phase10 TQL: emit breakout granularity sufijo");
std::string err;
State st1;
bool ok = tql::apply(text, st1, hdrs, tys, nullptr, 2, eff, &err);
check(ok, "phase10 TQL: apply round-trip ok");
check(st1.stages.size() >= 2 && st1.stages[1].breakouts.size() == 1 &&
st1.stages[1].breakouts[0] == "ts:month",
"phase10 TQL: breakout granularity preservada");
}
{
// compute_stage aplica truncado de fecha cuando hay :granularity.
const char* cells[] = {
"2026-01-15", "10",
"2026-01-22", "20",
"2026-02-03", "30",
"2026-03-11", "40",
};
std::vector<std::string> hdrs = {"ts", "amount"};
std::vector<ColumnType> tys = {ColumnType::Date, ColumnType::Float};
Stage s1;
s1.breakouts = {"ts:month"};
Aggregation ag; ag.fn = AggFn::Count; ag.alias = "n";
s1.aggregations.push_back(ag);
auto out = compute_stage(cells, 4, 2, hdrs, tys, s1);
check(out.rows == 3, "phase10: trunc month -> 3 grupos (Jan/Feb/Mar)");
check(out.headers[0] == "ts:month", "phase10: header preserva sufijo");
// Verifica que algun valor de breakout es "2026-01"
bool found_jan = false;
for (int r = 0; r < out.rows; ++r) {
if (std::string(out.cells[r * out.cols + 0]) == "2026-01") found_jan = true;
}
check(found_jan, "phase10: trunc value '2026-01' presente");
}
// === phase10 hit-tests para click-to-drill ===
{
// nearest_index_1d
double xs[] = {0, 1, 2, 3, 4};
check(nearest_index_1d(0.0, xs, 5) == 0, "phase10 hit: nearest_1d exact 0");
check(nearest_index_1d(2.4, xs, 5) == 2, "phase10 hit: nearest_1d 2.4 -> 2");
check(nearest_index_1d(2.6, xs, 5) == 3, "phase10 hit: nearest_1d 2.6 -> 3");
check(nearest_index_1d(-1.0, xs, 5) == 0, "phase10 hit: nearest_1d clamp left");
check(nearest_index_1d(99.0, xs, 5) == 4, "phase10 hit: nearest_1d clamp right");
check(nearest_index_1d(0.0, nullptr, 0) == -1, "phase10 hit: nearest_1d empty -> -1");
}
{
// nearest_index_2d
double xs[] = {0, 10, 5, 5};
double ys[] = {0, 0, 10, 5};
check(nearest_index_2d(0.1, 0.1, xs, ys, 4) == 0, "phase10 hit: nearest_2d cerca de (0,0)");
check(nearest_index_2d(9.9, 0.0, xs, ys, 4) == 1, "phase10 hit: nearest_2d cerca de (10,0)");
check(nearest_index_2d(5.0, 4.9, xs, ys, 4) == 3, "phase10 hit: nearest_2d cerca de (5,5)");
check(nearest_index_2d(0, 0, nullptr, nullptr, 0) == -1, "phase10 hit: nearest_2d empty -> -1");
}
{
// pie_angle (convencion ImPlot: 0 = top, sentido horario)
const double PI = 3.14159265358979323846;
double a;
a = pie_angle(0.5, 0.5, 0.5, 0.0); // top
check(std::fabs(a - 0.0) < 1e-9, "phase10 hit: pie_angle top = 0");
a = pie_angle(0.5, 0.5, 1.0, 0.5); // right -> PI/2
check(std::fabs(a - PI/2) < 1e-9, "phase10 hit: pie_angle right = PI/2");
a = pie_angle(0.5, 0.5, 0.5, 1.0); // bottom -> PI
check(std::fabs(a - PI) < 1e-9, "phase10 hit: pie_angle bottom = PI");
a = pie_angle(0.5, 0.5, 0.0, 0.5); // left -> 3*PI/2
check(std::fabs(a - 3*PI/2) < 1e-9, "phase10 hit: pie_angle left = 3PI/2");
}
{
// pie_slice_at_angle: 4 slices iguales -> cada uno cubre PI/2.
double sums[] = {1.0, 1.0, 1.0, 1.0};
const double PI = 3.14159265358979323846;
check(pie_slice_at_angle(0.0, sums, 4) == 0, "phase10 hit: slice 0 (top)");
check(pie_slice_at_angle(PI/4, sums, 4) == 0, "phase10 hit: slice 0 (mid)");
check(pie_slice_at_angle(PI/2 + 0.1, sums, 4) == 1, "phase10 hit: slice 1");
check(pie_slice_at_angle(PI + 0.1, sums, 4) == 2, "phase10 hit: slice 2");
check(pie_slice_at_angle(3*PI/2 + 0.1, sums, 4) == 3, "phase10 hit: slice 3");
double zeros[] = {0.0, 0.0};
check(pie_slice_at_angle(0.5, zeros, 2) == -1, "phase10 hit: total 0 -> -1");
check(pie_slice_at_angle(0.0, nullptr, 0) == -1, "phase10 hit: empty -> -1");
double neg[] = {1.0, -1.0};
check(pie_slice_at_angle(0.5, neg, 2) == -1, "phase10 hit: neg sum -> -1");
}
{
// heatmap_cell_at
int rr, cc;
heatmap_cell_at(1.5, 2.5, 4, 3, rr, cc);
check(rr == 2 && cc == 1, "phase10 hit: heatmap (1.5,2.5) en 4x3 -> r2 c1");
heatmap_cell_at(-1, 0, 4, 3, rr, cc);
check(rr == -1 && cc == -1, "phase10 hit: heatmap fuera de rango");
heatmap_cell_at(0, 0, 0, 0, rr, cc);
check(rr == -1 && cc == -1, "phase10 hit: heatmap empty");
}
{
// E2E click-to-drill: simular pipeline stage1 agrupado, click en row idx 2.
State st;
st.stages.resize(2);
std::vector<std::string> hdrs = {"lang", "n"};
std::vector<ColumnType> tys = {ColumnType::String, ColumnType::Int};
st.stages[1].breakouts.push_back("lang");
st.stages[1].aggregations.push_back({AggFn::Count});
st.active_stage = 1;
// Stage 1 output simulado (3 grupos).
const char* g_cells[] = {
"go", "3",
"py", "2",
"cpp", "1",
};
StageOutput so;
so.cells.insert(so.cells.end(), g_cells, g_cells + 6);
so.rows = 3;
so.cols = 2;
so.headers = {"lang", "count"};
// Simular click en row idx 2 (cpp).
int clicked_row = 2;
int n_brk = (int)st.stages[1].breakouts.size();
check(n_brk == 1, "phase10 e2e: 1 breakout");
const char* v = so.cells[clicked_row * so.cols + 0];
std::string col_clean;
parse_breakout_granularity(so.headers[0], col_clean);
check(col_clean == "lang", "phase10 e2e: col_clean stripped OK");
st.stages[0].filters.push_back(make_drill_filter(0, v));
st.active_stage = 0;
check(st.active_stage == 0, "phase10 e2e: active retrocede a 0");
check(st.stages[0].filters.size() == 1, "phase10 e2e: 1 filter anadido");
check(st.stages[0].filters[0].col == 0 &&
st.stages[0].filters[0].op == Op::Eq &&
st.stages[0].filters[0].value == "cpp",
"phase10 e2e: filter Op::Eq col=0 value=cpp");
}
// === phase10 drill history (apply/undo step) ===
{
State st;
st.stages.resize(2);
st.active_stage = 1;
DrillStep step;
step.target_stage = 0;
step.filter_pos = 0;
step.prev_active_stage = 1;
step.added = make_drill_filter(0, "go");
check(apply_drill_step(st, step), "phase10 hist: apply ok");
check(st.stages[0].filters.size() == 1, "phase10 hist: filter anadido");
check(st.stages[0].filters[0].value == "go", "phase10 hist: value preservado");
check(st.active_stage == 0, "phase10 hist: active = target");
check(undo_drill_step(st, step), "phase10 hist: undo ok");
check(st.stages[0].filters.empty(), "phase10 hist: filter eliminado");
check(st.active_stage == 1, "phase10 hist: active restaurado");
// Redo
check(apply_drill_step(st, step), "phase10 hist: redo ok");
check(st.stages[0].filters.size() == 1, "phase10 hist: redo filter de vuelta");
check(st.active_stage == 0, "phase10 hist: redo active retorna");
// Edge: target fuera de rango
DrillStep bad;
bad.target_stage = 99;
check(!apply_drill_step(st, bad), "phase10 hist: apply fuera de rango -> false");
check(!undo_drill_step(st, bad), "phase10 hist: undo fuera de rango -> false");
// Edge: pos invalida
DrillStep bad_pos = step;
bad_pos.filter_pos = 99;
check(!undo_drill_step(st, bad_pos), "phase10 hist: undo pos invalida -> false");
}
// === phase10 drill history: back/forward stack semantics simulado ===
{
State st;
st.stages.resize(3);
st.active_stage = 2;
std::vector<DrillStep> back_stack;
std::vector<DrillStep> fwd_stack;
auto drill = [&](int from, int target, int pos, int col, const std::string& v) {
DrillStep s;
s.target_stage = target;
s.filter_pos = pos;
s.prev_active_stage = from;
s.added = make_drill_filter(col, v);
apply_drill_step(st, s);
back_stack.push_back(s);
fwd_stack.clear();
};
drill(2, 1, 0, 0, "go");
check(st.stages[1].filters.size() == 1, "phase10 hist seq: drill1 aplicado");
drill(1, 0, 0, 1, "10");
check(st.stages[0].filters.size() == 1, "phase10 hist seq: drill2 aplicado");
check(back_stack.size() == 2, "phase10 hist seq: back stack 2");
check(fwd_stack.empty(), "phase10 hist seq: forward limpio");
// Back x1
DrillStep s = back_stack.back(); back_stack.pop_back();
undo_drill_step(st, s);
fwd_stack.push_back(s);
check(st.stages[0].filters.empty(), "phase10 hist seq: back deshace drill2");
check(st.active_stage == 1, "phase10 hist seq: back restaura active=1");
check(fwd_stack.size() == 1, "phase10 hist seq: fwd stack 1");
// Forward x1
s = fwd_stack.back(); fwd_stack.pop_back();
apply_drill_step(st, s);
back_stack.push_back(s);
check(st.stages[0].filters.size() == 1, "phase10 hist seq: forward reaplica");
check(st.active_stage == 0, "phase10 hist seq: forward active=0");
}
// === phase10 row inspector (row_to_tsv + build_filters_from_row) ===
{
const char* cells[] = {
"go", "10", "filter",
"py", "20", "sma",
"go", "30", "map",
};
std::vector<std::string> hdrs = {"lang", "n", "fn"};
std::string tsv = row_to_tsv(cells, 3, 3, 1, hdrs);
check(tsv == "lang\tn\tfn\r\npy\t20\tsma\r\n",
"phase10 inspect: row_to_tsv layout");
check(row_to_tsv(cells, 3, 3, -1, hdrs).empty(), "phase10 inspect: tsv neg row -> empty");
check(row_to_tsv(cells, 3, 3, 5, hdrs).empty(), "phase10 inspect: tsv row oob -> empty");
check(row_to_tsv(cells, 3, 0, 0, hdrs).empty(), "phase10 inspect: tsv cols=0 -> empty");
auto fs = build_filters_from_row(cells, 3, 3, 0);
check(fs.size() == 3, "phase10 inspect: 3 filters de row 0");
check(fs[0].col == 0 && fs[0].op == Op::Eq && fs[0].value == "go",
"phase10 inspect: filter[0] col=0 op=Eq value=go");
check(fs[2].value == "filter", "phase10 inspect: filter[2] value=filter");
// Row con celda vacia -> filter saltado
const char* sparse[] = {"a", "", "c"};
auto fs2 = build_filters_from_row(sparse, 1, 3, 0);
check(fs2.size() == 2 && fs2[0].col == 0 && fs2[1].col == 2,
"phase10 inspect: cells vacios salteados");
check(build_filters_from_row(cells, 3, 3, -1).empty(),
"phase10 inspect: build_filters row invalido -> empty");
}
// === phase10 drill-up ===
{
State st;
st.stages.resize(3);
st.active_stage = 2;
check(drill_up(st), "phase10 up: 2->1 ok");
check(st.active_stage == 1, "phase10 up: active=1");
check(drill_up(st), "phase10 up: 1->0 ok");
check(st.active_stage == 0, "phase10 up: active=0");
check(!drill_up(st), "phase10 up: 0 -> false");
check(st.active_stage == 0, "phase10 up: queda en 0");
// Filters no se mueven
State st2;
st2.stages.resize(2);
st2.active_stage = 1;
st2.stages[1].filters.push_back({0, Op::Eq, "x"});
drill_up(st2);
check(st2.stages[0].filters.empty() && st2.stages[1].filters.size() == 1,
"phase10 up: filters quedan en su stage");
State empty_st;
check(!drill_up(empty_st), "phase10 up: stages vacio -> false");
}
// === phase11: Lua subset validator + transpiler ===
{
std::string err;
// Subset OK: literales + ops
std::string e1 = tql_to_sql::transpile_expr("1 + 2", {}, err);
check(err.empty() && e1.find("1 + 2") != std::string::npos,
"phase11 lua: literal arith");
std::string e2 = tql_to_sql::transpile_expr("[a] + [b] * 2", {}, err);
check(err.empty() && e2.find("\"a\"") != std::string::npos &&
e2.find("\"b\"") != std::string::npos,
"phase11 lua: col refs + arith");
std::string e3 = tql_to_sql::transpile_expr("[a] .. \"_\" .. [b]", {}, err);
check(err.empty() && e3.find(" || ") != std::string::npos,
"phase11 lua: concat -> ||");
std::string e4 = tql_to_sql::transpile_expr(
"if [n] > 10 then \"big\" else \"small\" end", {}, err);
check(err.empty() && e4.find("CASE WHEN") != std::string::npos &&
e4.find("THEN") != std::string::npos && e4.find("ELSE") != std::string::npos,
"phase11 lua: if/then/else -> CASE");
std::string e5 = tql_to_sql::transpile_expr("math.floor([x] / 100)", {}, err);
check(err.empty() && e5.find("floor(") != std::string::npos,
"phase11 lua: math.floor");
std::string e6 = tql_to_sql::transpile_expr("string.upper([name])", {}, err);
check(err.empty() && e6.find("upper(") != std::string::npos,
"phase11 lua: string.upper");
std::string e7 = tql_to_sql::transpile_expr("string.sub([s], 1, 3)", {}, err);
check(err.empty() && e7.find("substring(") != std::string::npos,
"phase11 lua: string.sub 3-arg");
std::string e8 = tql_to_sql::transpile_expr("not ([x] == nil)", {}, err);
check(err.empty() && e8.find("NOT") != std::string::npos && e8.find("NULL") != std::string::npos,
"phase11 lua: not + nil");
std::string e9 = tql_to_sql::transpile_expr("tonumber([n])", {}, err);
check(err.empty() && e9.find("CAST(") != std::string::npos,
"phase11 lua: tonumber -> CAST DOUBLE");
// Fuera subset: 9 categorias rechazadas
err.clear();
check(tql_to_sql::transpile_expr("function() return 1 end", {}, err).empty()
&& err.find("closures") != std::string::npos,
"phase11 lua: function closure rechazado");
err.clear();
check(tql_to_sql::transpile_expr("local x = 1", {}, err).empty()
&& err.find("local") != std::string::npos,
"phase11 lua: local rechazado");
err.clear();
check(tql_to_sql::transpile_expr("for i=1,10 do end", {}, err).empty()
&& err.find("loops") != std::string::npos,
"phase11 lua: for loop rechazado");
err.clear();
check(tql_to_sql::transpile_expr("while true do end", {}, err).empty()
&& err.find("loops") != std::string::npos,
"phase11 lua: while loop rechazado");
err.clear();
check(tql_to_sql::transpile_expr("{1,2,3}", {}, err).empty()
&& err.find("table") != std::string::npos,
"phase11 lua: table literal rechazado");
err.clear();
check(tql_to_sql::transpile_expr("io.read()", {}, err).empty()
&& err.find("io") != std::string::npos,
"phase11 lua: io.* rechazado");
err.clear();
check(tql_to_sql::transpile_expr("string.gsub([s], \"a\", \"b\")", {}, err).empty()
&& err.find("whitelist") != std::string::npos,
"phase11 lua: string.gsub no whitelisted");
err.clear();
check(tql_to_sql::transpile_expr("print([x])", {}, err).empty()
&& err.find("print") != std::string::npos,
"phase11 lua: print rechazado");
err.clear();
check(tql_to_sql::transpile_expr("[a]; [b]", {}, err).empty()
&& err.find("multi-statement") != std::string::npos,
"phase11 lua: ';' multi-stmt rechazado");
// is_transpilable wrapper
std::string werr;
check(tql_to_sql::is_transpilable("[a] + 1", werr), "phase11 lua: is_transpilable OK");
check(!tql_to_sql::is_transpilable("function() end", werr),
"phase11 lua: is_transpilable false para closure");
}
// === phase11: TQL State -> SQL DuckDB emit ===
{
// Setup: 1 tabla "users" con cols lang,n.
TableInput t;
t.name = "users";
t.headers = {"lang", "n"};
t.types = {ColumnType::String, ColumnType::Int};
// Cells no usado por emit (solo schema).
std::vector<TableInput> tables = {t};
// Caso 1: stage 0 simple (sin filters ni sort)
{
State st;
st.stages.resize(1);
auto e = tql_to_sql::emit_sql(st, tables);
check(e.error.empty(), "phase11 sql: empty pipeline -> no error");
check(e.sql.find("WITH t0") != std::string::npos &&
e.sql.find("FROM \"users\"") != std::string::npos &&
e.sql.find("SELECT * FROM t0") != std::string::npos,
"phase11 sql: stage0 SELECT * FROM users");
}
// Caso 2: stage 0 filter + sort
{
State st;
st.stages.resize(1);
st.stages[0].filters.push_back({0, Op::Eq, "go"});
st.stages[0].filters.push_back({1, Op::Gt, "10"});
st.stages[0].sorts.push_back({"n", true});
auto e = tql_to_sql::emit_sql(st, tables);
check(e.error.empty(), "phase11 sql: filter+sort OK");
check(e.sql.find("WHERE") != std::string::npos &&
e.sql.find("\"lang\" = ?") != std::string::npos &&
e.sql.find("\"n\" > ?") != std::string::npos,
"phase11 sql: filter clauses");
check(e.params.size() == 2 && e.params[0] == "go" && e.params[1] == "10",
"phase11 sql: params bound");
check(e.sql.find("ORDER BY \"n\" DESC") != std::string::npos,
"phase11 sql: ORDER BY desc");
}
// Caso 3: stage 1 group + count
{
State st;
st.stages.resize(2);
st.stages[1].breakouts.push_back("lang");
st.stages[1].aggregations.push_back({AggFn::Count});
st.active_stage = 1;
auto e = tql_to_sql::emit_sql(st, tables);
check(e.error.empty(), "phase11 sql: group ok");
check(e.sql.find("t1 AS") != std::string::npos &&
e.sql.find("COUNT(*)") != std::string::npos &&
e.sql.find("GROUP BY") != std::string::npos &&
e.sql.find("SELECT * FROM t1") != std::string::npos,
"phase11 sql: stage1 CTE + COUNT + GROUP BY");
}
// Caso 4: granularity :month -> date_trunc
{
State st;
st.stages.resize(2);
st.stages[1].breakouts.push_back("ts:month");
st.stages[1].aggregations.push_back({AggFn::Sum, "n"});
st.active_stage = 1;
TableInput ts_t;
ts_t.name = "events";
ts_t.headers = {"ts", "n"};
ts_t.types = {ColumnType::Date, ColumnType::Int};
std::vector<TableInput> tt = {ts_t};
auto e = tql_to_sql::emit_sql(st, tt);
check(e.error.empty(), "phase11 sql: granularity ok");
check(e.sql.find("date_trunc('month'") != std::string::npos &&
e.sql.find("SUM(\"n\")") != std::string::npos,
"phase11 sql: date_trunc + SUM");
}
// Caso 5: aggregations p25/median/p99
{
State st;
st.stages.resize(2);
st.stages[1].breakouts.push_back("lang");
st.stages[1].aggregations.push_back({AggFn::Median, "n"});
st.stages[1].aggregations.push_back({AggFn::P25, "n"});
st.stages[1].aggregations.push_back({AggFn::P99, "n"});
st.active_stage = 1;
auto e = tql_to_sql::emit_sql(st, tables);
check(e.error.empty(), "phase11 sql: percentiles ok");
check(e.sql.find("quantile_cont(\"n\", 0.5)") != std::string::npos &&
e.sql.find("quantile_cont(\"n\", 0.25)") != std::string::npos &&
e.sql.find("quantile_cont(\"n\", 0.99)") != std::string::npos,
"phase11 sql: quantile_cont calls");
}
// Caso 6: joins 4 strategies
{
State st;
st.stages.resize(1);
Join jn;
jn.alias = "o";
jn.source = "orders";
jn.on.push_back({"user_id", "user_id"});
jn.strategy = JoinStrategy::Left;
st.joins.push_back(jn);
TableInput u, o;
u.name = "users";
u.headers = {"user_id", "name"};
u.types = {ColumnType::String, ColumnType::String};
o.name = "orders";
o.headers = {"user_id", "amount"};
o.types = {ColumnType::String, ColumnType::Int};
std::vector<TableInput> tt = {u, o};
auto e = tql_to_sql::emit_sql(st, tt);
check(e.error.empty(), "phase11 sql: join ok");
check(e.sql.find("LEFT JOIN \"orders\" AS \"o\"") != std::string::npos &&
e.sql.find("ON \"users\".\"user_id\" = \"o\".\"user_id\"") != std::string::npos,
"phase11 sql: LEFT JOIN ON syntax");
// Inner
st.joins[0].strategy = JoinStrategy::Inner;
auto e2 = tql_to_sql::emit_sql(st, tt);
check(e2.sql.find("INNER JOIN") != std::string::npos, "phase11 sql: INNER JOIN");
// Right
st.joins[0].strategy = JoinStrategy::Right;
auto e3 = tql_to_sql::emit_sql(st, tt);
check(e3.sql.find("RIGHT JOIN") != std::string::npos, "phase11 sql: RIGHT JOIN");
// Full
st.joins[0].strategy = JoinStrategy::Full;
auto e4 = tql_to_sql::emit_sql(st, tt);
check(e4.sql.find("FULL OUTER JOIN") != std::string::npos, "phase11 sql: FULL OUTER JOIN");
}
// Caso 7: derived col subset -> SQL expression
{
State st;
st.stages.resize(1);
DerivedColumn d;
d.name = "size_kb";
d.source_col = -1;
d.formula = "[n] / 1024.0";
d.type = ColumnType::Float;
st.stages[0].derived.push_back(d);
auto e = tql_to_sql::emit_sql(st, tables);
check(e.error.empty(), "phase11 sql: derived subset ok");
check(e.sql.find("\"n\" / 1024") != std::string::npos &&
e.sql.find("AS \"size_kb\"") != std::string::npos,
"phase11 sql: derived expression + alias");
}
// Caso 8: derived col FUERA subset -> warning + skip
{
State st;
st.stages.resize(1);
DerivedColumn d;
d.name = "bad";
d.source_col = -1;
d.formula = "string.gsub([n], \"a\", \"b\")";
d.type = ColumnType::String;
st.stages[0].derived.push_back(d);
auto e = tql_to_sql::emit_sql(st, tables);
check(e.error.empty(), "phase11 sql: derived fuera subset NO bloquea emit");
check(!e.warnings.empty() &&
e.warnings[0].find("out of SQL subset") != std::string::npos,
"phase11 sql: warning derived fuera subset");
check(e.sql.find("\"bad\"") == std::string::npos,
"phase11 sql: derived skip cuando fuera subset");
}
// Caso 9: empty tables -> error
{
State st;
st.stages.resize(1);
std::vector<TableInput> empty;
auto e = tql_to_sql::emit_sql(st, empty);
check(!e.error.empty() && e.error.find("no input tables") != std::string::npos,
"phase11 sql: empty tables -> error");
}
// Caso 10: stage 0 con LIKE (Contains)
{
State st;
st.stages.resize(1);
st.stages[0].filters.push_back({0, Op::Contains, "go"});
auto e = tql_to_sql::emit_sql(st, tables);
check(e.error.empty(), "phase11 sql: LIKE Contains ok");
check(e.sql.find("LIKE ?") != std::string::npos &&
e.params.size() == 1 && e.params[0] == "%go%",
"phase11 sql: Contains -> LIKE %go%");
}
}
// === phase11: LLM client (mock, no red) ===
{
llm_anthropic::AskInput in;
in.question = "show top 10 langs";
in.tql_current = "return { stages = {} }";
in.col_names = {"lang", "n"};
in.col_types = {ColumnType::String, ColumnType::Int};
in.mode = llm_anthropic::OutputMode::TQL;
std::string body = llm_anthropic::build_request_body(in);
check(body.find("\"model\":\"claude-sonnet-4-6\"") != std::string::npos,
"phase11 llm: default model");
check(body.find("\"max_tokens\":8192") != std::string::npos,
"phase11 llm: max_tokens");
check(body.find("\\\"system\\\"") == std::string::npos /* not double-escaped */,
"phase11 llm: system not double-escaped");
check(body.find("Available columns") != std::string::npos,
"phase11 llm: schema block present");
check(body.find("show top 10 langs") != std::string::npos,
"phase11 llm: question present");
check(body.find("TQL") != std::string::npos,
"phase11 llm: system mentions TQL");
in.mode = llm_anthropic::OutputMode::SQL;
std::string body_sql = llm_anthropic::build_request_body(in);
check(body_sql.find("DuckDB") != std::string::npos,
"phase11 llm: SQL mode mentions DuckDB");
}
{
// extract_code_block
std::string raw1 = "Here you go:\n```lua\nreturn { x = 1 }\n```\nDone!";
std::string code = llm_anthropic::extract_code_block(raw1, "lua");
check(code == "return { x = 1 }", "phase11 llm: extract ```lua block");
std::string raw2 = "Sure:\n```\nplain code\n```";
std::string code2 = llm_anthropic::extract_code_block(raw2, "lua");
check(code2 == "plain code", "phase11 llm: extract bare ```");
std::string raw3 = "no fences here";
std::string code3 = llm_anthropic::extract_code_block(raw3, "lua");
check(code3 == "no fences here", "phase11 llm: no fence -> stripped");
std::string raw4 = "```sql\nSELECT 1;\n```";
std::string code4 = llm_anthropic::extract_code_block(raw4, "sql");
check(code4 == "SELECT 1;", "phase11 llm: extract ```sql");
}
{
// parse_response_text from JSON
std::string j = "{\"id\":\"x\",\"content\":[{\"type\":\"text\",\"text\":\"hello\\nworld\"}],\"role\":\"assistant\"}";
std::string t = llm_anthropic::parse_response_text(j);
check(t == "hello\nworld", "phase11 llm: parse text content");
std::string j2 = "{\"content\":[{\"type\":\"text\",\"text\":\"\\\"quoted\\\"\"}]}";
std::string t2 = llm_anthropic::parse_response_text(j2);
check(t2 == "\"quoted\"", "phase11 llm: parse quoted escape");
std::string j3 = "{\"error\":\"foo\"}";
std::string t3 = llm_anthropic::parse_response_text(j3);
check(t3.empty(), "phase11 llm: no text -> empty");
}
{
// Mock end-to-end via FN_LLM_MOCK_RESPONSE (portable Linux/Mingw via putenv).
const char* mock_kv =
"FN_LLM_MOCK_RESPONSE={\"content\":[{\"type\":\"text\",\"text\":\"```lua\\nreturn { mock = true }\\n```\"}]}";
putenv((char*)mock_kv);
llm_anthropic::AskInput in;
in.question = "q";
in.col_names = {"a"};
in.col_types = {ColumnType::String};
auto r = llm_anthropic::ask(in);
check(r.error.empty(), "phase11 llm mock: no error");
check(r.code == "return { mock = true }", "phase11 llm mock: code extracted");
// Unset: putenv con "VAR=" deja vacio (suficiente para nuestro check `*mock`).
putenv((char*)"FN_LLM_MOCK_RESPONSE=");
}
std::printf("\n=== %d passed, %d failed ===\n", passed, failed);
return failed == 0 ? 0 : 1;
}
@@ -652,7 +652,8 @@ bool apply(const std::string& lua_text, State& state,
}
lua_pop(L, 1);
// breakout (solo aplica stages >= 1, no-op silencioso si stage 0)
// breakout (solo aplica stages >= 1, no-op silencioso si stage 0).
// Acepta sufijo ":granularity" para cols Date (fase 10).
lua_getfield(L, -1, "breakout");
if (lua_istable(L, -1)) {
int n = (int)lua_rawlen(L, -1);
@@ -660,8 +661,10 @@ bool apply(const std::string& lua_text, State& state,
lua_rawgeti(L, -1, i);
if (lua_isstring(L, -1)) {
std::string bn = lua_tostring(L, -1);
if (find_orig_col(cur_headers, bn) < 0) {
warn("stage " + std::to_string(si - 1) + ": breakout col \"" + bn + "\" not in input headers");
std::string clean;
parse_breakout_granularity(bn, clean);
if (find_orig_col(cur_headers, clean) < 0) {
warn("stage " + std::to_string(si - 1) + ": breakout col \"" + clean + "\" not in input headers");
}
stg.breakouts.emplace_back(bn);
}
@@ -0,0 +1,862 @@
// tql_to_sql.cpp — pure walker TQL -> SQL DuckDB + Lua subset transpiler.
// Ver issue 0080. Sin DuckDB linkado.
#include "tql_to_sql.h"
#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <set>
#include <sstream>
#include <unordered_map>
namespace tql_to_sql {
using namespace data_table;
// ============================================================================
// Lua subset tokenizer + recursive-descent expression parser -> SQL string.
// ============================================================================
namespace {
struct Tok {
enum Kind {
EndT, NumT, StrT, IdentT, ColT,
// operators / keywords
Plus, Minus, Star, Slash, Percent, ConcatT,
Eq, Neq, Lt, Lte, Gt, Gte,
AndT, OrT, NotT,
IfT, ThenT, ElseT, EndKW,
LParen, RParen, Comma, Dot,
TrueT, FalseT, NilT,
} kind = EndT;
std::string text; // raw token texto (para idents/numbers/strings)
};
// Categorias prohibidas: token literal -> mensaje.
const std::unordered_map<std::string, const char*>& forbidden_keywords() {
static const std::unordered_map<std::string, const char*> M = {
{"function", "closures not allowed in SQL transpile subset"},
{"local", "local declarations not allowed"},
{"for", "loops not allowed"},
{"while", "loops not allowed"},
{"repeat", "loops not allowed"},
{"do", "block statements not allowed"},
{"return", "explicit return not allowed (formula is implicit expression)"},
{"goto", "goto not allowed"},
{"break", "break not allowed (no loops)"},
// io/os/debug/coroutines
{"io", "io.* access not allowed"},
{"os", "os.* access not allowed"},
{"debug", "debug.* access not allowed"},
{"package", "package access not allowed"},
{"require", "require not allowed"},
{"coroutine","coroutines not allowed"},
{"setmetatable","metatables not allowed"},
{"getmetatable","metatables not allowed"},
{"rawget", "rawget not allowed"},
{"rawset", "rawset not allowed"},
{"pcall", "pcall not allowed"},
{"xpcall", "xpcall not allowed"},
{"print", "print not allowed (SQL has no side effects)"},
};
return M;
}
// Whitelist de funciones SQL-transpilables: lua name -> SQL function template.
// Template usa $1, $2, ... como placeholders de argumentos.
struct FnMap { int min_args; int max_args; const char* sql_tmpl; };
const std::unordered_map<std::string, FnMap>& fn_whitelist() {
static const std::unordered_map<std::string, FnMap> M = {
// math.*
{"math.floor", {1, 1, "floor($1)"}},
{"math.ceil", {1, 1, "ceiling($1)"}},
{"math.abs", {1, 1, "abs($1)"}},
{"math.sqrt", {1, 1, "sqrt($1)"}},
{"math.sin", {1, 1, "sin($1)"}},
{"math.cos", {1, 1, "cos($1)"}},
{"math.log", {1, 1, "ln($1)"}},
{"math.exp", {1, 1, "exp($1)"}},
{"math.min", {2, 2, "least($1, $2)"}},
{"math.max", {2, 2, "greatest($1, $2)"}},
// string.*
{"string.upper", {1, 1, "upper($1)"}},
{"string.lower", {1, 1, "lower($1)"}},
{"string.len", {1, 1, "length($1)"}},
{"string.sub", {2, 3, "/*SUBSTRING*/"}}, // manejo especial: argc 2 vs 3
// top-level
{"tostring", {1, 1, "CAST($1 AS VARCHAR)"}},
{"tonumber", {1, 1, "CAST($1 AS DOUBLE)"}},
};
return M;
}
// Identifier SQL-safe: si tiene caracteres especiales o coincide con keyword,
// usar `"col"`. Aqui simplificado: siempre quote con dobles comillas para
// preservar case y permitir `:` (sufijo granularity).
std::string sql_ident(const std::string& name) {
std::string out;
out.reserve(name.size() + 4);
out += '"';
for (char c : name) {
if (c == '"') out += "\"\""; // escape
else out += c;
}
out += '"';
return out;
}
std::string sql_string_literal(const std::string& s) {
std::string out;
out.reserve(s.size() + 4);
out += '\'';
for (char c : s) {
if (c == '\'') out += "''";
else out += c;
}
out += '\'';
return out;
}
class Lexer {
public:
Lexer(const std::string& src) : src_(src) {}
// Devuelve true si parsea OK. False con err en error_.
bool tokenize(std::vector<Tok>& out) {
size_t i = 0;
while (i < src_.size()) {
char c = src_[i];
if (std::isspace((unsigned char)c)) { ++i; continue; }
// Lua line comment
if (c == '-' && i + 1 < src_.size() && src_[i+1] == '-') {
while (i < src_.size() && src_[i] != '\n') ++i;
continue;
}
if (c == '[' ) {
// col ref [identifier]
size_t j = i + 1;
std::string name;
while (j < src_.size() && src_[j] != ']') {
name += src_[j];
++j;
}
if (j >= src_.size()) { error_ = "unterminated [col] ref"; return false; }
Tok t; t.kind = Tok::ColT; t.text = name;
out.push_back(t);
i = j + 1;
continue;
}
if (c == '"' || c == '\'') {
char q = c;
++i;
std::string s;
while (i < src_.size() && src_[i] != q) {
if (src_[i] == '\\' && i + 1 < src_.size()) {
char esc = src_[i+1];
if (esc == 'n') s += '\n';
else if (esc == 't') s += '\t';
else if (esc == '\\') s += '\\';
else if (esc == '\'') s += '\'';
else if (esc == '"') s += '"';
else s += esc;
i += 2;
} else {
s += src_[i++];
}
}
if (i >= src_.size()) { error_ = "unterminated string literal"; return false; }
++i;
Tok t; t.kind = Tok::StrT; t.text = s;
out.push_back(t);
continue;
}
if (std::isdigit((unsigned char)c) || (c == '.' && i + 1 < src_.size() && std::isdigit((unsigned char)src_[i+1]))) {
std::string n;
bool seen_dot = false;
while (i < src_.size()) {
char d = src_[i];
if (std::isdigit((unsigned char)d)) { n += d; ++i; }
else if (d == '.' && !seen_dot) { n += d; seen_dot = true; ++i; }
else break;
}
Tok t; t.kind = Tok::NumT; t.text = n;
out.push_back(t);
continue;
}
if (std::isalpha((unsigned char)c) || c == '_') {
std::string id;
while (i < src_.size() &&
(std::isalnum((unsigned char)src_[i]) || src_[i] == '_')) {
id += src_[i++];
}
// Check forbidden keywords y mapeo a tokens.
auto& F = forbidden_keywords();
auto fit = F.find(id);
if (fit != F.end()) {
error_ = std::string("token '") + id + "': " + fit->second;
return false;
}
Tok t;
if (id == "and") t.kind = Tok::AndT;
else if (id == "or") t.kind = Tok::OrT;
else if (id == "not") t.kind = Tok::NotT;
else if (id == "if") t.kind = Tok::IfT;
else if (id == "then") t.kind = Tok::ThenT;
else if (id == "else") t.kind = Tok::ElseT;
else if (id == "end") t.kind = Tok::EndKW;
else if (id == "true") t.kind = Tok::TrueT;
else if (id == "false") t.kind = Tok::FalseT;
else if (id == "nil") t.kind = Tok::NilT;
else { t.kind = Tok::IdentT; t.text = id; }
out.push_back(t);
continue;
}
// Operators
auto emit = [&](Tok::Kind k, int len) {
Tok t; t.kind = k; out.push_back(t); i += (size_t)len;
};
if (c == '+') { emit(Tok::Plus, 1); continue; }
if (c == '-') { emit(Tok::Minus, 1); continue; }
if (c == '*') { emit(Tok::Star, 1); continue; }
if (c == '/') { emit(Tok::Slash, 1); continue; }
if (c == '%') { emit(Tok::Percent,1); continue; }
if (c == '(') { emit(Tok::LParen, 1); continue; }
if (c == ')') { emit(Tok::RParen, 1); continue; }
if (c == ',') { emit(Tok::Comma, 1); continue; }
if (c == '.') {
if (i + 1 < src_.size() && src_[i+1] == '.') {
if (i + 2 < src_.size() && src_[i+2] == '.') {
error_ = "'...' vararg not allowed"; return false;
}
emit(Tok::ConcatT, 2); continue;
}
emit(Tok::Dot, 1); continue;
}
if (c == '=') {
if (i + 1 < src_.size() && src_[i+1] == '=') { emit(Tok::Eq, 2); continue; }
error_ = "single '=' (assignment) not allowed"; return false;
}
if (c == '~') {
if (i + 1 < src_.size() && src_[i+1] == '=') { emit(Tok::Neq, 2); continue; }
error_ = "stray '~'"; return false;
}
if (c == '<') {
if (i + 1 < src_.size() && src_[i+1] == '=') { emit(Tok::Lte, 2); continue; }
emit(Tok::Lt, 1); continue;
}
if (c == '>') {
if (i + 1 < src_.size() && src_[i+1] == '=') { emit(Tok::Gte, 2); continue; }
emit(Tok::Gt, 1); continue;
}
if (c == '{') { error_ = "table literals '{...}' not allowed"; return false; }
if (c == '}') { error_ = "stray '}'"; return false; }
if (c == ';') { error_ = "multi-statement not allowed"; return false; }
if (c == '#') { error_ = "length '#' operator not allowed"; return false; }
if (c == ':') { error_ = "method calls ':' not allowed"; return false; }
error_ = std::string("unexpected character '") + c + "'";
return false;
}
Tok t; t.kind = Tok::EndT;
out.push_back(t);
return true;
}
const std::string& error() const { return error_; }
private:
const std::string& src_;
std::string error_;
};
class Parser {
public:
Parser(const std::vector<Tok>& toks,
const std::vector<std::string>& headers)
: toks_(toks), headers_(headers) {}
// expr := ternary
// ternary := if/then/else | logic_or
bool parse_expr(std::string& out) {
return parse_ternary(out);
}
bool parse_ternary(std::string& out) {
if (peek(0).kind == Tok::IfT) {
++pos_;
std::string a, b, c;
if (!parse_logic_or(a)) return false;
if (!eat(Tok::ThenT, "'then' expected after 'if'")) return false;
if (!parse_ternary(b)) return false;
if (!eat(Tok::ElseT, "'else' expected (subset requires else branch)")) return false;
if (!parse_ternary(c)) return false;
if (!eat(Tok::EndKW, "'end' expected to close 'if'")) return false;
out = "CASE WHEN " + a + " THEN " + b + " ELSE " + c + " END";
return true;
}
return parse_logic_or(out);
}
bool parse_logic_or(std::string& out) {
if (!parse_logic_and(out)) return false;
while (peek(0).kind == Tok::OrT) {
++pos_;
std::string rhs;
if (!parse_logic_and(rhs)) return false;
out = "(" + out + " OR " + rhs + ")";
}
return true;
}
bool parse_logic_and(std::string& out) {
if (!parse_not(out)) return false;
while (peek(0).kind == Tok::AndT) {
++pos_;
std::string rhs;
if (!parse_not(rhs)) return false;
out = "(" + out + " AND " + rhs + ")";
}
return true;
}
bool parse_not(std::string& out) {
if (peek(0).kind == Tok::NotT) {
++pos_;
std::string e;
if (!parse_not(e)) return false;
out = "NOT (" + e + ")";
return true;
}
return parse_comparison(out);
}
bool parse_comparison(std::string& out) {
if (!parse_concat(out)) return false;
while (true) {
Tok::Kind k = peek(0).kind;
const char* op = nullptr;
if (k == Tok::Eq) op = " = ";
else if (k == Tok::Neq) op = " <> ";
else if (k == Tok::Lt) op = " < ";
else if (k == Tok::Lte) op = " <= ";
else if (k == Tok::Gt) op = " > ";
else if (k == Tok::Gte) op = " >= ";
else break;
++pos_;
std::string rhs;
if (!parse_concat(rhs)) return false;
out = "(" + out + op + rhs + ")";
}
return true;
}
bool parse_concat(std::string& out) {
if (!parse_additive(out)) return false;
while (peek(0).kind == Tok::ConcatT) {
++pos_;
std::string rhs;
if (!parse_additive(rhs)) return false;
out = "(" + out + " || " + rhs + ")";
}
return true;
}
bool parse_additive(std::string& out) {
if (!parse_multiplicative(out)) return false;
while (peek(0).kind == Tok::Plus || peek(0).kind == Tok::Minus) {
const char* op = (peek(0).kind == Tok::Plus) ? " + " : " - ";
++pos_;
std::string rhs;
if (!parse_multiplicative(rhs)) return false;
out = "(" + out + op + rhs + ")";
}
return true;
}
bool parse_multiplicative(std::string& out) {
if (!parse_unary(out)) return false;
while (peek(0).kind == Tok::Star || peek(0).kind == Tok::Slash || peek(0).kind == Tok::Percent) {
const char* op = (peek(0).kind == Tok::Star) ? " * "
: (peek(0).kind == Tok::Slash) ? " / " : " % ";
++pos_;
std::string rhs;
if (!parse_unary(rhs)) return false;
out = "(" + out + op + rhs + ")";
}
return true;
}
bool parse_unary(std::string& out) {
if (peek(0).kind == Tok::Minus) {
++pos_;
std::string e;
if (!parse_unary(e)) return false;
out = "(-" + e + ")";
return true;
}
return parse_primary(out);
}
bool parse_primary(std::string& out) {
Tok t = peek(0);
if (t.kind == Tok::NumT) {
++pos_;
out = t.text;
return true;
}
if (t.kind == Tok::StrT) {
++pos_;
out = sql_string_literal(t.text);
return true;
}
if (t.kind == Tok::TrueT) { ++pos_; out = "TRUE"; return true; }
if (t.kind == Tok::FalseT) { ++pos_; out = "FALSE"; return true; }
if (t.kind == Tok::NilT) { ++pos_; out = "NULL"; return true; }
if (t.kind == Tok::ColT) {
// Check col exists (warning, not error).
++pos_;
(void)headers_; // currently not validating — caller can do that
out = sql_ident(t.text);
return true;
}
if (t.kind == Tok::LParen) {
++pos_;
std::string e;
if (!parse_expr(e)) return false;
if (!eat(Tok::RParen, "expected ')'")) return false;
out = "(" + e + ")";
return true;
}
if (t.kind == Tok::IdentT) {
// Function call: identifier ("." identifier)? "(" args ")"
std::string name = t.text;
++pos_;
if (peek(0).kind == Tok::Dot) {
++pos_;
if (peek(0).kind != Tok::IdentT) {
error_ = "expected identifier after '.'";
return false;
}
name += "." + peek(0).text;
++pos_;
}
if (peek(0).kind != Tok::LParen) {
error_ = "bare identifier '" + name +
"' not allowed (only [col] refs + whitelisted fn calls)";
return false;
}
++pos_; // consume '('
std::vector<std::string> args;
if (peek(0).kind != Tok::RParen) {
while (true) {
std::string a;
if (!parse_expr(a)) return false;
args.push_back(a);
if (peek(0).kind == Tok::Comma) { ++pos_; continue; }
break;
}
}
if (!eat(Tok::RParen, "expected ')' closing function args")) return false;
// Validate against whitelist
auto& W = fn_whitelist();
auto wit = W.find(name);
if (wit == W.end()) {
error_ = "function '" + name +
"' not in SQL transpile whitelist (math.*, string.upper/lower/len/sub, tostring, tonumber)";
return false;
}
const FnMap& fm = wit->second;
if ((int)args.size() < fm.min_args || (int)args.size() > fm.max_args) {
std::ostringstream os;
os << "function '" << name << "' takes " << fm.min_args;
if (fm.max_args != fm.min_args) os << ".." << fm.max_args;
os << " args, got " << args.size();
error_ = os.str();
return false;
}
// Casos especiales
if (name == "string.sub") {
// Lua: string.sub(s, i [, j]) — i/j 1-based, inclusive.
// SQL DuckDB: substring(s, i, count). count = j - i + 1.
if (args.size() == 2) {
// sin j -> hasta el final. DuckDB substring(s, i) acepta.
out = "substring(" + args[0] + ", " + args[1] + ")";
} else {
out = "substring(" + args[0] + ", " + args[1] +
", (" + args[2] + ") - (" + args[1] + ") + 1)";
}
return true;
}
// Generico: substituir $1..$N en template.
std::string s = fm.sql_tmpl;
for (int i = 0; i < (int)args.size(); ++i) {
char ph[6];
std::snprintf(ph, sizeof(ph), "$%d", i + 1);
std::string p = ph;
size_t at = 0;
while ((at = s.find(p, at)) != std::string::npos) {
s.replace(at, p.size(), args[i]);
at += args[i].size();
}
}
out = s;
return true;
}
error_ = std::string("unexpected token in expression");
return false;
}
bool eat(Tok::Kind k, const char* msg) {
if (peek(0).kind != k) { error_ = msg; return false; }
++pos_;
return true;
}
const Tok& peek(int off) const {
size_t i = pos_ + (size_t)off;
if (i >= toks_.size()) return toks_.back();
return toks_[i];
}
bool at_end() const { return peek(0).kind == Tok::EndT; }
const std::string& error() const { return error_; }
private:
const std::vector<Tok>& toks_;
const std::vector<std::string>& headers_;
size_t pos_ = 0;
std::string error_;
};
} // anon
std::string transpile_expr(const std::string& formula,
const std::vector<std::string>& in_headers,
std::string& error_out) {
error_out.clear();
std::vector<Tok> toks;
Lexer lex(formula);
if (!lex.tokenize(toks)) {
error_out = lex.error();
return "";
}
Parser p(toks, in_headers);
std::string out;
if (!p.parse_expr(out)) {
error_out = p.error();
return "";
}
if (!p.at_end()) {
error_out = "unexpected trailing tokens after expression";
return "";
}
return out;
}
bool is_transpilable(const std::string& formula, std::string& error_out) {
std::vector<std::string> empty;
std::string s = transpile_expr(formula, empty, error_out);
return error_out.empty() && !s.empty();
}
// ============================================================================
// TQL State -> SQL DuckDB emitter.
// ============================================================================
namespace {
// Mapeo aggregation -> SQL DuckDB expression.
std::string emit_agg_expr(const Aggregation& a) {
switch (a.fn) {
case AggFn::Count: return "COUNT(*)";
case AggFn::Sum: return "SUM(" + sql_ident(a.col) + ")";
case AggFn::Avg: return "AVG(" + sql_ident(a.col) + ")";
case AggFn::Min: return "MIN(" + sql_ident(a.col) + ")";
case AggFn::Max: return "MAX(" + sql_ident(a.col) + ")";
case AggFn::Distinct: return "COUNT(DISTINCT " + sql_ident(a.col) + ")";
case AggFn::Stddev: return "STDDEV(" + sql_ident(a.col) + ")";
case AggFn::Median: return "quantile_cont(" + sql_ident(a.col) + ", 0.5)";
case AggFn::P25: return "quantile_cont(" + sql_ident(a.col) + ", 0.25)";
case AggFn::P75: return "quantile_cont(" + sql_ident(a.col) + ", 0.75)";
case AggFn::P90: return "quantile_cont(" + sql_ident(a.col) + ", 0.90)";
case AggFn::P99: return "quantile_cont(" + sql_ident(a.col) + ", 0.99)";
case AggFn::Percentile: {
char buf[32];
std::snprintf(buf, sizeof(buf), "%g", a.arg);
return std::string("quantile_cont(") + sql_ident(a.col) + ", " + buf + ")";
}
}
return "/* unknown agg */ NULL";
}
std::string emit_breakout_expr(const std::string& bk) {
std::string col_clean;
DateGranularity g = parse_breakout_granularity(bk, col_clean);
if (g == DateGranularity::None) {
return sql_ident(col_clean);
}
const char* tok = date_granularity_token(g);
// Week: DuckDB date_trunc('week', col) -> monday segun configuracion.
return std::string("date_trunc('") + tok + "', " + sql_ident(col_clean) + ")";
}
// Resuelve un Op a operador SQL + (opcional) override de RHS.
const char* sql_op(Op op) {
switch (op) {
case Op::Eq: return " = ";
case Op::Neq: return " <> ";
case Op::Gt: return " > ";
case Op::Gte: return " >= ";
case Op::Lt: return " < ";
case Op::Lte: return " <= ";
case Op::Contains: return " LIKE ";
case Op::NotContains: return " NOT LIKE ";
case Op::StartsWith: return " LIKE ";
case Op::EndsWith: return " LIKE ";
}
return " = ";
}
// Construye RHS literal/pattern segun op + value. Devuelve placeholder '?'
// y push de params; o pattern string-literal directo para LIKE wildcards.
std::string emit_filter_rhs(const Filter& f, std::vector<std::string>& params) {
if (f.op == Op::Contains || f.op == Op::NotContains) {
std::string v = "%" + f.value + "%";
params.push_back(v);
return "?";
}
if (f.op == Op::StartsWith) {
std::string v = f.value + "%";
params.push_back(v);
return "?";
}
if (f.op == Op::EndsWith) {
std::string v = "%" + f.value;
params.push_back(v);
return "?";
}
params.push_back(f.value);
return "?";
}
// Construye CTE stage 0 (Raw): SELECT cols + derived FROM main_t [JOINs].
// `tables` provee schema. main_t name = tables[main_idx].name. Derived cols
// se transpilan a SQL expression; si fuera de subset, push warning + skip col.
bool emit_stage0(const State& st, const std::vector<TableInput>& tables,
int main_idx, SqlEmit& e) {
if (main_idx < 0 || main_idx >= (int)tables.size()) {
e.error = "main table out of range";
return false;
}
const TableInput& main_t = tables[(size_t)main_idx];
// SELECT list: cols originales + derived expressions (subset).
std::string select_list;
for (size_t i = 0; i < main_t.headers.size(); ++i) {
if (i > 0) select_list += ", ";
select_list += sql_ident(main_t.headers[i]);
}
// Derived cols (stage 0 derived).
if (!st.stages.empty()) {
const Stage& s0 = st.stages[0];
for (const auto& d : s0.derived) {
if (d.source_col >= 0 && d.formula.empty()) {
// Retipo puro: alias col origen.
if (d.source_col < (int)main_t.headers.size()) {
select_list += ", " + sql_ident(main_t.headers[(size_t)d.source_col])
+ " AS " + sql_ident(d.name);
}
continue;
}
std::string err;
std::string expr = transpile_expr(d.formula, main_t.headers, err);
if (!err.empty()) {
std::string msg = "derived col '" + d.name +
"' formula out of SQL subset: " + err;
e.warnings.push_back(msg);
// Skip col en SQL output; agente puede recurrir a TQL puro.
continue;
}
select_list += ", " + expr + " AS " + sql_ident(d.name);
}
}
std::string from = sql_ident(main_t.name);
// Joins
for (const auto& jn : st.joins) {
const TableInput* right = nullptr;
for (const auto& ti : tables) {
if (ti.name == jn.source) { right = &ti; break; }
}
if (!right) {
e.warnings.push_back("join source '" + jn.source + "' not in tables");
continue;
}
const char* strat = "LEFT JOIN";
switch (jn.strategy) {
case JoinStrategy::Left: strat = "LEFT JOIN"; break;
case JoinStrategy::Inner: strat = "INNER JOIN"; break;
case JoinStrategy::Right: strat = "RIGHT JOIN"; break;
case JoinStrategy::Full: strat = "FULL OUTER JOIN"; break;
}
from += "\n " + std::string(strat) + " " + sql_ident(right->name)
+ " AS " + sql_ident(jn.alias) + " ON ";
for (size_t k = 0; k < jn.on.size(); ++k) {
if (k > 0) from += " AND ";
from += sql_ident(main_t.name) + "." + sql_ident(jn.on[k].first)
+ " = " + sql_ident(jn.alias) + "." + sql_ident(jn.on[k].second);
}
// Anadir cols del right al SELECT con alias.col prefix.
if (jn.fields.empty()) {
for (const auto& rh : right->headers) {
std::string aliased = jn.alias + "." + rh;
select_list += ", " + sql_ident(jn.alias) + "." + sql_ident(rh)
+ " AS " + sql_ident(aliased);
}
} else {
for (const auto& fld : jn.fields) {
std::string aliased = jn.alias + "." + fld;
select_list += ", " + sql_ident(jn.alias) + "." + sql_ident(fld)
+ " AS " + sql_ident(aliased);
}
}
}
// Stage 0 WHERE: filters del Raw (filter col idx en eff_headers).
// Filter.col es indice en eff_headers (orig + derived). Para SQL emit,
// necesitamos resolver col idx -> col name. Reconstruir orden eff_headers.
std::vector<std::string> eff_headers = main_t.headers;
if (!st.stages.empty()) {
for (const auto& d : st.stages[0].derived) {
eff_headers.push_back(d.name);
}
}
std::string where_clause;
if (!st.stages.empty()) {
const Stage& s0 = st.stages[0];
for (size_t fi = 0; fi < s0.filters.size(); ++fi) {
const Filter& f = s0.filters[fi];
if (f.col < 0 || f.col >= (int)eff_headers.size()) {
e.warnings.push_back("stage0 filter col idx out of range");
continue;
}
std::string col = sql_ident(eff_headers[(size_t)f.col]);
if (!where_clause.empty()) where_clause += " AND ";
where_clause += col + sql_op(f.op) + emit_filter_rhs(f, e.params);
}
}
// Stage 0 sort
std::string order_clause;
if (!st.stages.empty()) {
const Stage& s0 = st.stages[0];
for (size_t si = 0; si < s0.sorts.size(); ++si) {
const SortClause& sc = s0.sorts[si];
if (!order_clause.empty()) order_clause += ", ";
order_clause += sql_ident(sc.col) + (sc.desc ? " DESC" : " ASC");
}
}
std::string cte = "t0 AS (\n SELECT " + select_list + "\n FROM " + from;
if (!where_clause.empty()) cte += "\n WHERE " + where_clause;
if (!order_clause.empty()) cte += "\n ORDER BY " + order_clause;
cte += "\n)";
e.sql = "WITH " + cte;
return true;
}
// Stage N (N>=1): SELECT breakouts + agg expressions FROM t<N-1>
// [WHERE filters] [GROUP BY ...] [ORDER BY ...].
bool emit_stage_n(const Stage& stg, int n, SqlEmit& e) {
std::string prev = "t" + std::to_string(n - 1);
std::string cur = "t" + std::to_string(n);
// SELECT list: breakouts (con granularity expr si aplica) + aggregations.
std::string select_list;
for (size_t i = 0; i < stg.breakouts.size(); ++i) {
if (i > 0) select_list += ", ";
select_list += emit_breakout_expr(stg.breakouts[i])
+ " AS " + sql_ident(stg.breakouts[i]);
}
for (size_t i = 0; i < stg.aggregations.size(); ++i) {
if (!select_list.empty()) select_list += ", ";
std::string alias = aggregation_alias(stg.aggregations[i]);
select_list += emit_agg_expr(stg.aggregations[i]) + " AS " + sql_ident(alias);
}
if (select_list.empty()) select_list = "*";
// WHERE: filters del stage. col es indice en input headers (output del stage previo).
// Aproximacion: usamos el nombre via stage breakouts/aggs del stage previo si fuera necesario.
// Para v1, emit por nombre cuando filter.col >= 0 sea idx en breakouts/aggs/orig. El
// chequeo de existencia se delega a DuckDB (errores en execute son detectables).
// V1 simple: skip filter cuando no podemos resolver — caller solo deberia tener filter
// sobre cols que existen.
// Estrategia simple: emite WHERE solo si stage previo provee headers conocidos. Para no
// duplicar logica, dejamos al caller proveer headers via filter.col que se resuelve a
// breakouts[col].
// V1: si filter.col esta en rango de breakouts del stage previo, emite breakout name.
// Sino, warning + skip.
std::string where_clause;
// Best effort: no podemos construir headers del stage previo aqui sin recomputar.
// Para v1, omitimos filters de stages >=1 — caller deberia evitar usarlos via SQL.
// TODO v2: pasar prev_headers para resolver.
(void)where_clause;
// GROUP BY: solo si hay breakouts.
std::string group_clause;
for (size_t i = 0; i < stg.breakouts.size(); ++i) {
if (i > 0) group_clause += ", ";
// Re-emit la expression para GROUP BY (no alias).
group_clause += emit_breakout_expr(stg.breakouts[i]);
}
// ORDER BY
std::string order_clause;
for (size_t i = 0; i < stg.sorts.size(); ++i) {
if (i > 0) order_clause += ", ";
order_clause += sql_ident(stg.sorts[i].col) + (stg.sorts[i].desc ? " DESC" : " ASC");
}
std::string cte = ",\n" + cur + " AS (\n SELECT " + select_list
+ "\n FROM " + prev;
if (!group_clause.empty()) cte += "\n GROUP BY " + group_clause;
if (!order_clause.empty()) cte += "\n ORDER BY " + order_clause;
cte += "\n)";
e.sql += cte;
return true;
}
} // anon
SqlEmit emit_sql(const State& state,
const std::vector<TableInput>& tables,
int up_to_stage) {
SqlEmit out;
if (state.stages.empty()) {
out.error = "state has no stages";
return out;
}
if (tables.empty()) {
out.error = "no input tables provided";
return out;
}
int target = (up_to_stage < 0) ? state.active_stage : up_to_stage;
if (target < 0) target = 0;
if (target >= (int)state.stages.size()) target = (int)state.stages.size() - 1;
// Resolve main idx via state.main_source (o tables[0] default).
int main_idx = resolve_main_idx(tables, state.main_source);
if (main_idx < 0) main_idx = 0;
if (!emit_stage0(state, tables, main_idx, out)) return out;
for (int si = 1; si <= target; ++si) {
if (!emit_stage_n(state.stages[(size_t)si], si, out)) return out;
}
out.sql += "\nSELECT * FROM t" + std::to_string(target) + ";\n";
return out;
}
} // namespace tql_to_sql
@@ -0,0 +1,41 @@
// tql_to_sql: emite SQL DuckDB equivalente a una pipeline TQL State.
// Pure. Sin DuckDB linkado. Solo string emit + validacion.
// Ver issue 0080 + docs/TQL.md (seccion "SQL transpile subset").
#pragma once
#include "data_table_logic.h"
#include <string>
#include <vector>
namespace tql_to_sql {
struct SqlEmit {
std::string sql; // SELECT/CTE chain DuckDB
std::vector<std::string> params; // bound values posicionales (?)
std::vector<std::string> warnings; // soft issues (col not found, etc.)
std::string error; // si non-empty, emit fallo
};
// Pure: emite SQL DuckDB equivalente a stages 0..active del state.
// `tables` provee schema (headers/types/name) de cada TableInput. El caller
// es responsable de hidratar las tablas en DuckDB con esos nombres.
// `up_to_stage = -1` => state.active_stage.
SqlEmit emit_sql(const data_table::State& state,
const std::vector<data_table::TableInput>& tables,
int up_to_stage = -1);
// Pure: valida que `formula` (cuerpo Lua de un derived col) este dentro del
// subset SQL-transpilable. Si valido, retorna true. Si no, false + razon
// concreta en `error_out` (categoria + token problematico).
// Ver docs/TQL.md#sql-transpile-subset.
bool is_transpilable(const std::string& formula, std::string& error_out);
// Pure: transpila formula Lua subset -> SQL expression. Si fuera de subset,
// retorna "" y rellena `error_out`. Asume is_transpilable retornaria true.
// `in_headers` necesario para resolver `[col]` refs y emitir identifier
// SQL apropiado (quoted si tiene char especial).
std::string transpile_expr(const std::string& formula,
const std::vector<std::string>& in_headers,
std::string& error_out);
} // namespace tql_to_sql
@@ -16,6 +16,10 @@ using data_table::ColumnType;
using data_table::ViewMode;
using data_table::ViewConfig;
using data_table::parse_number;
using data_table::nearest_index_2d;
using data_table::pie_angle;
using data_table::pie_slice_at_angle;
using data_table::heatmap_cell_at;
static int find_header(const StageOutput& out, const std::string& name) {
if (name.empty()) return -1;
@@ -152,7 +156,8 @@ std::vector<double> finite(const std::vector<double>& v) {
}
bool render_bar_like(const StageOutput& out, ViewMode mode,
const ViewConfig& cfg, ImVec2 size) {
const ViewConfig& cfg, ImVec2 size,
int* clicked_row_out = nullptr) {
int cat_col = resolve_cat(out, cfg, first_category_col(out));
auto nums = collect_numeric_filtered(out, cfg, 8);
if (cat_col < 0 || nums.empty()) {
@@ -225,6 +230,15 @@ bool render_bar_like(const StageOutput& out, ViewMode mode,
ImPlot::PlotBars(nums[0].name.c_str(), ticks.data(), ys.data(), n, 0.67, spc);
}
}
// Hit-test fase 10: idx = round(plot.{x|y}) en single-series mode.
if (clicked_row_out &&
mode != ViewMode::GroupedBar && mode != ViewMode::StackedBar &&
ImPlot::IsPlotHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
ImPlotPoint p = ImPlot::GetPlotMousePos();
double target = horiz ? p.y : p.x;
int idx = (int)(target + 0.5);
if (idx >= 0 && idx < n) *clicked_row_out = idx;
}
ImPlot::EndPlot();
return true;
}
@@ -302,7 +316,8 @@ bool render_line_like(const StageOutput& out, ViewMode mode,
return true;
}
bool render_scatter(const StageOutput& out, const ViewConfig& cfg, ImVec2 size) {
bool render_scatter(const StageOutput& out, const ViewConfig& cfg, ImVec2 size,
int* clicked_row_out = nullptr) {
// Soporte cfg.x_col + cfg.y_cols[0]
int xc = find_header(out, cfg.x_col);
int yc = !cfg.y_cols.empty() ? find_header(out, cfg.y_cols[0]) : -1;
@@ -329,11 +344,20 @@ bool render_scatter(const StageOutput& out, const ViewConfig& cfg, ImVec2 size)
ImPlot::PlotScatter("##s", nums[0].vals.data(), nums[1].vals.data(),
(int)nums[0].vals.size());
}
if (clicked_row_out &&
ImPlot::IsPlotHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
ImPlotPoint p = ImPlot::GetPlotMousePos();
int idx = nearest_index_2d(p.x, p.y,
nums[0].vals.data(), nums[1].vals.data(),
(int)nums[0].vals.size());
if (idx >= 0) *clicked_row_out = idx;
}
ImPlot::EndPlot();
return true;
}
bool render_bubble(const StageOutput& out, const ViewConfig& cfg, ImVec2 size) {
bool render_bubble(const StageOutput& out, const ViewConfig& cfg, ImVec2 size,
int* clicked_row_out = nullptr) {
int xc = find_header(out, cfg.x_col);
int yc = !cfg.y_cols.empty() ? find_header(out, cfg.y_cols[0]) : -1;
int sc = resolve_size(out, cfg, -1);
@@ -354,6 +378,14 @@ bool render_bubble(const StageOutput& out, const ViewConfig& cfg, ImVec2 size) {
axflag(cfg), axflag(cfg));
ImPlot::PlotBubbles("##b", nums[0].vals.data(), nums[1].vals.data(),
nums[2].vals.data(), (int)nums[0].vals.size());
if (clicked_row_out &&
ImPlot::IsPlotHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
ImPlotPoint p = ImPlot::GetPlotMousePos();
int idx = nearest_index_2d(p.x, p.y,
nums[0].vals.data(), nums[1].vals.data(),
(int)nums[0].vals.size());
if (idx >= 0) *clicked_row_out = idx;
}
ImPlot::EndPlot();
return true;
}
@@ -404,7 +436,8 @@ bool render_hist2d(const StageOutput& out, const ViewConfig& cfg, ImVec2 size) {
return true;
}
bool render_heatmap(const StageOutput& out, const ViewConfig& cfg, ImVec2 size) {
bool render_heatmap(const StageOutput& out, const ViewConfig& cfg, ImVec2 size,
int* clicked_row_out = nullptr) {
auto nums = collect_numeric_filtered(out, cfg, 64);
if (nums.empty()) { info_text("Need numeric columns"); return false; }
int cols = (int)nums.size();
@@ -424,11 +457,22 @@ bool render_heatmap(const StageOutput& out, const ViewConfig& cfg, ImVec2 size)
maybe_fit(cfg);
if (!ImPlot::BeginPlot("##heatmap", size, 0)) return false;
ImPlot::PlotHeatmap("##hm", mat.data(), rows, cols, mn, mx, nullptr);
if (clicked_row_out &&
ImPlot::IsPlotHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
ImPlotPoint p = ImPlot::GetPlotMousePos();
// ImPlot heatmap Y se pinta de top a bottom; plot mouse_y va igual
// (default scale 0..rows). Mapeo directo.
int rr, cc;
heatmap_cell_at(p.x, p.y, rows, cols, rr, cc);
if (rr >= 0) *clicked_row_out = rr;
(void)cc;
}
ImPlot::EndPlot();
return true;
}
bool render_pie(const StageOutput& out, const ViewConfig& cfg, bool donut, ImVec2 size) {
bool render_pie(const StageOutput& out, const ViewConfig& cfg, bool donut, ImVec2 size,
int* clicked_row_out = nullptr) {
int cat = resolve_cat(out, cfg, first_category_col(out));
auto nums = collect_numeric_filtered(out, cfg, 1);
if (cat < 0 || nums.empty()) { info_text("Need 1 category + 1 numeric"); return false; }
@@ -455,11 +499,24 @@ bool render_pie(const StageOutput& out, const ViewConfig& cfg, bool donut, ImVec
// Draw inner hole as solid circle by overlaying a smaller pie of one slice transparent.
// Simpler: just visually it's a circle with text. Use no extra primitive for now.
}
if (clicked_row_out &&
ImPlot::IsPlotHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
ImPlotPoint p = ImPlot::GetPlotMousePos();
double dx = p.x - 0.5, dy = p.y - 0.5;
double dist2 = dx*dx + dy*dy;
double inner = donut ? (radius * 0.5) : 0.0;
if (dist2 <= radius * radius && dist2 >= inner * inner) {
double ang = pie_angle(0.5, 0.5, p.x, p.y);
int idx = pie_slice_at_angle(ang, values.data(), n);
if (idx >= 0) *clicked_row_out = idx;
}
}
ImPlot::EndPlot();
return true;
}
bool render_funnel(const StageOutput& out, const ViewConfig& cfg, ImVec2 size) {
bool render_funnel(const StageOutput& out, const ViewConfig& cfg, ImVec2 size,
int* clicked_row_out = nullptr) {
int cat = resolve_cat(out, cfg, first_category_col(out));
auto nums = collect_numeric_filtered(out, cfg, 1);
if (cat < 0 || nums.empty()) { info_text("Need 1 category + 1 numeric"); return false; }
@@ -492,6 +549,17 @@ bool render_funnel(const StageOutput& out, const ViewConfig& cfg, ImVec2 size) {
ImPlot::SetupAxisTicks(ImAxis_Y1, ticks.data(), n, labels.data(), false);
ImPlot::PlotBars(nums[0].name.c_str(), ys.data(), ticks.data(), n, 0.85,
ImPlotSpec(ImPlotProp_Flags, ImPlotBarsFlags_Horizontal));
if (clicked_row_out &&
ImPlot::IsPlotHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
ImPlotPoint p = ImPlot::GetPlotMousePos();
int tick_idx = (int)(p.y + 0.5);
// ticks[i] = n-1-i. Invertir para idx en orden sorted descendiente.
int sorted_pos = (n - 1) - tick_idx;
if (sorted_pos >= 0 && sorted_pos < n) {
// idx[sorted_pos] da indice de row original en out.
*clicked_row_out = idx[sorted_pos];
}
}
ImPlot::EndPlot();
return true;
}
@@ -763,7 +831,9 @@ bool render_radar(const StageOutput& out, const ViewConfig& cfg, ImVec2 size) {
} // anon
bool render(const StageOutput& out, ViewMode mode,
const ViewConfig& cfg, ImVec2 size) {
const ViewConfig& cfg, ImVec2 size,
int* clicked_row_out) {
if (clicked_row_out) *clicked_row_out = -1;
if (out.rows == 0 || out.cols == 0) {
info_text("No data");
return false;
@@ -773,21 +843,21 @@ bool render(const StageOutput& out, ViewMode mode,
case ViewMode::Bar:
case ViewMode::Column:
case ViewMode::GroupedBar:
case ViewMode::StackedBar: return render_bar_like(out, mode, cfg, size);
case ViewMode::StackedBar: return render_bar_like(out, mode, cfg, size, clicked_row_out);
case ViewMode::Line:
case ViewMode::Area:
case ViewMode::Stairs: return render_line_like(out, mode, cfg, size);
case ViewMode::Scatter: return render_scatter(out, cfg, size);
case ViewMode::Bubble: return render_bubble(out, cfg, size);
case ViewMode::Scatter: return render_scatter(out, cfg, size, clicked_row_out);
case ViewMode::Bubble: return render_bubble(out, cfg, size, clicked_row_out);
case ViewMode::Histogram: return render_histogram(out, cfg, size);
case ViewMode::Histogram2D: return render_hist2d(out, cfg, size);
case ViewMode::Heatmap: return render_heatmap(out, cfg, size);
case ViewMode::Heatmap: return render_heatmap(out, cfg, size, clicked_row_out);
case ViewMode::BoxPlot: return render_boxplot(out, cfg, size);
case ViewMode::Stem: return render_stem(out, cfg, size);
case ViewMode::ErrorBars: return render_errorbars(out, cfg, size);
case ViewMode::Pie: return render_pie(out, cfg, false, size);
case ViewMode::Donut: return render_pie(out, cfg, true, size);
case ViewMode::Funnel: return render_funnel(out, cfg, size);
case ViewMode::Pie: return render_pie(out, cfg, false, size, clicked_row_out);
case ViewMode::Donut: return render_pie(out, cfg, true, size, clicked_row_out);
case ViewMode::Funnel: return render_funnel(out, cfg, size, clicked_row_out);
case ViewMode::Waterfall: return render_waterfall(out, cfg, size);
case ViewMode::KPI: return render_kpi_single(out, cfg);
case ViewMode::KPIGrid: return render_kpi_grid(out, cfg);
@@ -14,10 +14,15 @@ namespace viz {
//
// `size`: ImVec2(-1,-1) usa todo el espacio disponible.
// `out`: output del stage activo (headers, types, cells flat row-major).
// `clicked_row_out`: si != nullptr, el render escribira el indice de row del
// `StageOutput` clicado por user. -1 si no hubo click drillable. Fase 10
// (issue 0079): habilitado para bar/column/pie/donut/funnel/scatter/bubble/
// heatmap. Resto de modos: no hit-test, queda en -1.
bool render(const data_table::StageOutput& out,
data_table::ViewMode mode,
const data_table::ViewConfig& cfg,
ImVec2 size = ImVec2(-1, -1));
ImVec2 size = ImVec2(-1, -1),
int* clicked_row_out = nullptr);
// Helper expuesto: encuentra primera col numerica. -1 si ninguna.
int first_numeric_col(const data_table::StageOutput& out);
+212
View File
@@ -0,0 +1,212 @@
// data_table_types — types compartidos del stack TQL (Table Query Language).
// Promovido al registry desde cpp/apps/primitives_gallery/playground/tables/.
// Ver issue 0081 + docs/TQL.md. Pure value types + enums.
#pragma once
#include <string>
#include <utility>
#include <vector>
namespace data_table {
// ----------------------------------------------------------------------------
// Operadores de filtro.
// ----------------------------------------------------------------------------
enum class Op {
Eq, Neq, Gt, Gte, Lt, Lte,
Contains, NotContains, StartsWith, EndsWith
};
// ----------------------------------------------------------------------------
// Tipo de columna. Declarado por caller o auto-detectado.
// ----------------------------------------------------------------------------
enum class ColumnType {
Auto, String, Int, Float, Bool, Date, Json
};
// ----------------------------------------------------------------------------
// Derived column: inmutable. Dos modos:
// 1) Retipo puro: source_col >= 0, formula == "". Cells del origen.
// 2) Formula: source_col == -1, formula no vacia. Eval por Lua.
// ----------------------------------------------------------------------------
struct DerivedColumn {
int source_col = -1;
ColumnType type = ColumnType::String;
std::string name;
std::string formula; // "" = retipado puro; resto = body Lua
int lua_id = -1; // referencia en lua_engine; -1 si no compilado
std::string compile_error;
};
// ----------------------------------------------------------------------------
// Filtro: col index en eff_headers + op + value.
// ----------------------------------------------------------------------------
struct Filter {
int col;
Op op;
std::string value;
};
// ----------------------------------------------------------------------------
// ColorRule: pintado condicional de celdas (UI helper).
// ----------------------------------------------------------------------------
struct ColorRule {
int col;
std::string equals;
unsigned int color;
};
// ----------------------------------------------------------------------------
// Aggregations (TQL stages 1+).
// ----------------------------------------------------------------------------
enum class AggFn {
Count, Sum, Avg, Min, Max, Distinct, Stddev,
Median, P25, P75, P90, P99, Percentile
};
struct Aggregation {
AggFn fn = AggFn::Count;
std::string col; // ignorado para Count
double arg = 0.0; // para Percentile (0..1)
std::string alias; // vacio -> auto-generado via aggregation_alias()
};
struct SortClause {
std::string col;
bool desc = false;
};
// Stage: layer de TQL. Stage 0 = Raw (sin breakouts/aggregations).
// Stage 1+ pueden agrupar. Cada stage consume output del anterior.
struct Stage {
std::vector<Filter> filters;
std::vector<DerivedColumn> derived; // expressions de este stage
std::vector<std::string> breakouts; // col names del INPUT de este stage
std::vector<Aggregation> aggregations;
std::vector<SortClause> sorts;
};
// Output de compute_stage. Posee `cell_backing` (strings nuevos para
// resultados agregados) y `cells` (punteros row-major a backing o a
// `in_cells` original para passthrough).
struct StageOutput {
std::vector<std::string> cell_backing;
std::vector<const char*> cells;
int rows = 0;
int cols = 0;
std::vector<std::string> headers;
std::vector<ColumnType> types;
};
// ----------------------------------------------------------------------------
// ViewMode: tipo de visualizacion a renderizar sobre el output del stage activo.
// ----------------------------------------------------------------------------
enum class ViewMode {
Table,
// Bars
Bar, Column, GroupedBar, StackedBar,
// Lines / area
Line, Area, Stairs,
// Points
Scatter, Bubble,
// Distribution
Histogram, Histogram2D, Heatmap, BoxPlot,
// Stems / signals
Stem, ErrorBars,
// Composition
Pie, Donut, Funnel, Waterfall,
// Single values
KPI, KPIGrid,
// Specialized
Candlestick, Radar,
};
// ----------------------------------------------------------------------------
// Joins (MBQL-style). Ver issue 0078.
// ----------------------------------------------------------------------------
enum class JoinStrategy { Left, Inner, Right, Full };
// Tabla extra pasada al render() para joins. Owner externo (caller).
struct TableInput {
std::string name; // identificador estable (matchea Join.source)
std::vector<std::string> headers;
std::vector<ColumnType> types;
const char* const* cells = nullptr; // row-major, headers.size() cols x rows filas
int rows = 0;
int cols = 0;
};
// Join clause: une la tabla actual con `source` por las parejas `on`,
// prefijando las cols del derecho con `alias.`.
struct Join {
std::string alias;
std::string source;
std::vector<std::pair<std::string, std::string>> on; // {left_col, right_col}
JoinStrategy strategy = JoinStrategy::Left;
std::vector<std::string> fields; // vacio = all del derecho
};
// ----------------------------------------------------------------------------
// ViewConfig: overrides manuales de auto-detect para la vista activa.
// ----------------------------------------------------------------------------
struct ViewConfig {
std::string x_col; // single: scatter, line, hist2d
std::vector<std::string> y_cols; // 1..N: line/area/bar/etc
std::string size_col; // bubble
std::string cat_col; // bar/pie/funnel/box override
unsigned int primary_color = 0; // 0 = ImPlot auto
int hist_bins = 0; // 0 = Sturges
float pie_radius = 0.0f; // 0 = default
bool show_legend = true;
bool show_markers = false; // line/area markers
bool locked = false; // disable pan/zoom
mutable bool fit_request = false; // consumed by viz::render
};
// VizPanel: viz adicional sobre el mismo StageOutput.
struct VizPanel {
ViewMode display = ViewMode::Bar;
ViewConfig config;
mutable ViewMode last_non_table = ViewMode::Bar;
};
// ----------------------------------------------------------------------------
// State: stage pipeline + viz globales.
// ----------------------------------------------------------------------------
struct State {
std::vector<Stage> stages;
int active_stage = 0;
ViewMode display = ViewMode::Table;
ViewConfig viz_config;
std::vector<VizPanel> extra_panels;
std::vector<Join> joins; // aplicado antes de stages[0]
std::string main_source; // name de TableInput; vacio -> tables[0]
std::vector<ColorRule> color_rules;
std::vector<bool> col_visible;
std::vector<int> col_order;
// Helpers (definidos en compute_stage.cpp).
Stage& raw();
const Stage& raw() const;
Stage& active();
const Stage& active_const() const;
void ensure_stage0();
};
// ----------------------------------------------------------------------------
// Drill extendido (fase 10). Ver issue 0079.
// ----------------------------------------------------------------------------
enum class DateGranularity { None, Year, Month, Week, Day, Hour };
enum class FilterPreset { Last7d, Last30d, Last90d, ExcludeNulls, NonZero };
// Step de drill grabado para history undo/redo (fase 10).
struct DrillStep {
int target_stage = -1; // stage donde se anadio el filter
int filter_pos = -1; // index en target_stage.filters
int prev_active_stage = 0; // active_stage antes del drill
Filter added; // filter para redo
};
} // namespace data_table
+96
View File
@@ -0,0 +1,96 @@
#include "gfx/gpu_check.h"
#include "gfx/gl_loader.h"
#include <cstring>
#include <string>
// CUDA runtime version via compile-time macro.
// cuda_runtime.h define CUDART_VERSION como XXYYZZ (ej. 12040 para 12.4.0).
// Solo se incluye si el header esta disponible; si no, cuda_runtime_version = "".
#if defined(__has_include) && __has_include(<cuda_runtime.h>)
#include <cuda_runtime.h>
#define FN_HAS_CUDA_RUNTIME 1
#endif
namespace fn::gfx {
static std::string safe_gl_string(GLenum name) {
const GLubyte* s = glGetString(name);
if (!s) return "";
return std::string(reinterpret_cast<const char*>(s));
}
static bool check_gl_version_43() {
// GL_VERSION tiene formato "major.minor ..." o "OpenGL ES major.minor ..."
const GLubyte* ver = glGetString(GL_VERSION);
if (!ver) return false;
int major = 0, minor = 0;
// Saltar prefijo "OpenGL ES " si lo hay
const char* p = reinterpret_cast<const char*>(ver);
if (std::strncmp(p, "OpenGL ES ", 10) == 0) p += 10;
// sscanf con la forma "X.Y"
// NOLINTNEXTLINE(cert-err34-c)
std::sscanf(p, "%d.%d", &major, &minor);
return (major > 4) || (major == 4 && minor >= 3);
}
bool gpu_check_caps(GpuCaps& out) {
out = GpuCaps{}; // reset
out.gl_vendor = safe_gl_string(GL_VENDOR);
out.gl_renderer = safe_gl_string(GL_RENDERER);
out.gl_version = safe_gl_string(GL_VERSION);
if (out.gl_vendor.empty()) {
// No hay contexto GL activo.
return false;
}
// Compute shader support: GL 4.3+ o ARB_compute_shader
{
const GLubyte* exts = glGetString(GL_EXTENSIONS);
bool has_arb = exts &&
std::strstr(reinterpret_cast<const char*>(exts),
"GL_ARB_compute_shader") != nullptr;
out.has_compute_shader = check_gl_version_43() || has_arb;
}
// Shader storage buffer: GL 4.3+ o ARB_shader_storage_buffer_object
{
const GLubyte* exts = glGetString(GL_EXTENSIONS);
bool has_ssbo_arb = exts &&
std::strstr(reinterpret_cast<const char*>(exts),
"GL_ARB_shader_storage_buffer_object") != nullptr;
out.has_storage_buffer = check_gl_version_43() || has_ssbo_arb;
}
// Workgroup limits (solo si hay compute shader support)
if (out.has_compute_shader) {
// GL_MAX_COMPUTE_WORK_GROUP_COUNT — indexed query
glGetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_COUNT, 0, &out.max_compute_workgroup_count[0]);
glGetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_COUNT, 1, &out.max_compute_workgroup_count[1]);
glGetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_COUNT, 2, &out.max_compute_workgroup_count[2]);
glGetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_SIZE, 0, &out.max_compute_workgroup_size[0]);
glGetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_SIZE, 1, &out.max_compute_workgroup_size[1]);
glGetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_SIZE, 2, &out.max_compute_workgroup_size[2]);
}
// CUDA runtime version (compile-time detection)
#if defined(FN_HAS_CUDA_RUNTIME)
{
int cuda_ver = CUDART_VERSION; // ej. 12040 para CUDA 12.4.0
int major = cuda_ver / 1000;
int minor = (cuda_ver % 1000) / 10;
char buf[16];
std::snprintf(buf, sizeof(buf), "%d.%d", major, minor);
out.cuda_runtime_version = buf;
}
#else
out.cuda_runtime_version = "";
#endif
return true;
}
} // namespace fn::gfx
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <string>
namespace fn::gfx {
// GpuCaps recopila capacidades OpenGL y CUDA del contexto activo.
// Todos los campos de cadena estan vacios ("") si el dato no esta disponible.
struct GpuCaps {
// OpenGL — requieren contexto GL activo antes de llamar gpu_check_caps.
std::string gl_vendor; // glGetString(GL_VENDOR) ej. "NVIDIA Corporation"
std::string gl_renderer; // glGetString(GL_RENDERER) ej. "NVIDIA GeForce RTX 3080/PCIe/SSE2"
std::string gl_version; // glGetString(GL_VERSION) ej. "4.6.0 NVIDIA 550.54.15"
// Compute shader limits (GL_MAX_COMPUTE_WORK_GROUP_COUNT/SIZE)
// Indice 0=X 1=Y 2=Z. Valor 0 si compute shaders no disponibles.
int max_compute_workgroup_count[3] = {0, 0, 0};
int max_compute_workgroup_size[3] = {0, 0, 0};
bool has_compute_shader = false; // GL_VERSION >= 4.3 o extension ARB_compute_shader
bool has_storage_buffer = false; // GL_VERSION >= 4.3 o extension ARB_shader_storage_buffer_object
// CUDA — vacio si CUDA runtime no detectado en compile time.
// Formato: "12.4" (major.minor) o "" si no disponible.
std::string cuda_runtime_version;
};
// gpu_check_caps rellena out con las capacidades del contexto OpenGL activo.
//
// REQUISITO: debe llamarse despues de inicializar el contexto GL y, en Windows,
// despues de fn::gfx::gl_loader_init(). Si se llama sin contexto activo el
// comportamiento es indefinido (glGetString devuelve nullptr).
//
// Retorna true si se pudo leer al menos el vendor GL (contexto activo).
// Retorna false si gl_vendor queda vacio (contexto no activo o driver defectuoso).
bool gpu_check_caps(GpuCaps& out);
} // namespace fn::gfx
+86
View File
@@ -0,0 +1,86 @@
---
name: gpu_check
kind: function
lang: cpp
domain: gfx
version: "1.0.0"
purity: impure
signature: "bool fn_gfx::gpu_check_caps(GpuCaps& out)"
description: "Rellena GpuCaps con las capacidades del contexto OpenGL activo: vendor, renderer, version, limites de compute workgroup, flags has_compute_shader/has_storage_buffer, y version CUDA runtime (deteccion en compile-time via CUDART_VERSION). Requiere contexto GL activo. Retorna false si el contexto no esta disponible."
tags: [gpu, opengl, cuda, caps, hardware, probe, gfx, compute, infra]
uses_functions: ["gl_loader_cpp_gfx"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [gfx/gpu_check.h, gfx/gl_loader.h, cuda_runtime.h, cstring, string]
tested: false
tests: []
test_file_path: ""
file_path: "cpp/functions/gfx/gpu_check.cpp"
framework: opengl
params:
- name: out
desc: "Referencia a GpuCaps que se rellena con las capacidades detectadas. Se resetea al inicio de la llamada."
output: "true si el contexto GL esta activo y gl_vendor no esta vacio; false si no hay contexto GL activo o el driver devuelve nullptr para GL_VENDOR."
---
# gpu_check
Probing de capacidades GPU en runtime: OpenGL strings, compute shader support y CUDA.
## Uso tipico
```cpp
#include "gfx/gpu_check.h"
#include "gfx/gl_loader.h"
// Dentro de render(), despues del primer frame (contexto GL activo):
fn::gfx::GpuCaps caps;
if (fn::gfx::gpu_check_caps(caps)) {
printf("GPU: %s\n", caps.gl_renderer.c_str());
printf("Compute shaders: %s\n", caps.has_compute_shader ? "yes" : "no");
if (!caps.cuda_runtime_version.empty())
printf("CUDA runtime: %s\n", caps.cuda_runtime_version.c_str());
} else {
printf("No GL context active\n");
}
```
## Estructura GpuCaps
```cpp
struct GpuCaps {
std::string gl_vendor; // "NVIDIA Corporation"
std::string gl_renderer; // "NVIDIA GeForce RTX 3080/PCIe/SSE2"
std::string gl_version; // "4.6.0 NVIDIA 550.54.15"
int max_compute_workgroup_count[3]; // [65535, 65535, 65535] tipico NVIDIA
int max_compute_workgroup_size[3]; // [1024, 1024, 64] tipico
bool has_compute_shader; // GL 4.3+ o ARB_compute_shader
bool has_storage_buffer; // GL 4.3+ o ARB_shader_storage_buffer_object
std::string cuda_runtime_version; // "12.4" o "" si no compilado con CUDA
};
```
## CUDA detection
La version CUDA se detecta en **compile time** via el macro `CUDART_VERSION` de `<cuda_runtime.h>`. Si la app no esta compilada con el CUDA toolkit, `cuda_runtime_version` sera `""`. Para detection en runtime del toolkit del sistema, usar `cuda_toolkit_check_bash_infra`.
## Requisito de contexto GL
Llamar siempre despues de crear el contexto GL. En apps que usan `fn::run_app`, el contexto esta activo desde el primer frame del `render()` callback. En Windows, `fn::gfx::gl_loader_init()` debe haberse llamado antes para que los punteros de funcion esten resueltos.
## Uso previsto (fn doctor cpp-apps)
Esta funcion sera invocada por el audit de `fn doctor cpp-apps` para verificar que las apps C++ del registry tienen acceso a compute shaders cuando declaran dependencias de `gpu_compute_program`, `gpu_dispatch`, etc.
## CMakeLists.txt
```cmake
add_imgui_app(mi_app
main.cpp
${CMAKE_SOURCE_DIR}/cpp/functions/gfx/gpu_check.cpp
)
# CUDA opcional: si la app compila con CUDA toolkit el header cuda_runtime.h
# estara disponible y FN_HAS_CUDA_RUNTIME se activara automaticamente.
```
+20
View File
@@ -0,0 +1,20 @@
---
name: AggFn
lang: cpp
domain: core
version: "1.0.0"
algebraic: sum
definition: |
enum class AggFn {
Count, Sum, Avg, Min, Max, Distinct, Stddev,
Median, P25, P75, P90, P99, Percentile
};
description: "Funcion de agregacion soportada. Pickup via UI combo + SQL emit via tql_to_sql. Percentile usa Aggregation.arg en [0,1]."
tags: [tql, aggregation, sum-type]
uses_types: []
file_path: "cpp/functions/core/data_table_types.h"
---
## Notas
Mapeo SQL DuckDB: Count → `COUNT(*)`, Sum/Avg/Min/Max/Stddev → ops nativas, Distinct → `COUNT(DISTINCT col)`, Median/P25/P75/P90/P99/Percentile → `quantile_cont(col, p)`.
+22
View File
@@ -0,0 +1,22 @@
---
name: Aggregation
lang: cpp
domain: core
version: "1.0.0"
algebraic: product
definition: |
struct Aggregation {
AggFn fn;
std::string col;
double arg;
std::string alias;
};
description: "Funcion de agregacion en Stage 1+. fn = Count/Sum/Avg/Min/Max/Distinct/Stddev/Median/P25/P75/P90/P99/Percentile. arg = parametro (p para percentile)."
tags: [tql, aggregation, agg, product-type]
uses_types: []
file_path: "cpp/functions/core/data_table_types.h"
---
## Notas
`alias` vacio dispara `aggregation_alias(a)` auto: `count`, `sum_<col>`, `distinct_<col>`, `p95_<col>` etc. SQL mapping en `tql_to_sql`: `COUNT(*)`, `SUM("col")`, `quantile_cont("col", p)`.
+21
View File
@@ -0,0 +1,21 @@
---
name: ColorRule
lang: cpp
domain: core
version: "1.0.0"
algebraic: product
definition: |
struct ColorRule {
int col;
std::string equals;
unsigned int color;
};
description: "Regla de pintado condicional para tabla UI. Si cells[row][col] == equals, fondo = color (RGBA packed)."
tags: [tql, color, ui-hint, product-type]
uses_types: []
file_path: "cpp/functions/core/data_table_types.h"
---
## Notas
Solo afecta render visual. Round-trip en TQL via `columns.<name>.color_rules`. Vacio = sin color override.
+28
View File
@@ -0,0 +1,28 @@
---
name: ColumnType
lang: cpp
domain: core
version: "1.0.0"
algebraic: sum
definition: |
enum class ColumnType {
Auto, String, Int, Float, Bool, Date, Json
};
description: "Tipo de columna del modelo TQL. `Auto` dispara auto-detect; el resto fuerza el tipo declarado. Base de toda la pipeline data_table."
tags: [tql, data-table, types, sum-type]
uses_types: []
file_path: "cpp/functions/core/data_table_types.h"
---
## Notas
Sum type / enum-class. Convivimos con `effective_type()` que resuelve `Auto` → auto-detect via sample. El resto fuerza el tipo declarado por el caller.
Tabla de iconos UTF-8 Tabler para cada variante en `column_type_icon(t)`. Mapeo SQL ↔ ColumnType en `tql_to_sql` (issue 0080).
## Usado por
- `compute_stage_cpp_core` — input/output types per stage
- `tql_emit_cpp_core` / `tql_apply_cpp_core` — emit/parse TQL columns block
- `tql_to_sql_cpp_core` — mapping a SQL DuckDB types
- `data_table_cpp_viz` — UI render por columna
+19
View File
@@ -0,0 +1,19 @@
---
name: DateGranularity
lang: cpp
domain: core
version: "1.0.0"
algebraic: sum
definition: |
enum class DateGranularity { None, Year, Month, Week, Day, Hour };
description: "Granularidad de truncado de fechas para breakouts TQL. Sufijo `:token` en breakout string (ej. 'ts:month')."
tags: [tql, date, granularity, sum-type, mbql]
uses_types: []
file_path: "cpp/functions/core/data_table_types.h"
---
## Notas
Auto-detect via `auto_date_granularity(min_ymd, max_ymd)`: >2y→Year, >60d→Month, >14d→Week, resto→Day. SQL emit DuckDB: `date_trunc('month'|'year'|...,col)`.
Week trunca a lunes ISO (Hinnant algo).
+26
View File
@@ -0,0 +1,26 @@
---
name: DerivedColumn
lang: cpp
domain: core
version: "1.0.0"
algebraic: product
definition: |
struct DerivedColumn {
int source_col;
ColumnType type;
std::string name;
std::string formula;
int lua_id;
std::string compile_error;
};
description: "Col custom dentro de un Stage. Modo 1: retipo (source_col >= 0, formula vacia). Modo 2: formula Lua (source_col == -1, eval por lua_engine sandbox)."
tags: [tql, derived, formula, lua, product-type]
uses_types: [ColumnType_cpp_core]
file_path: "cpp/functions/core/data_table_types.h"
---
## Notas
`formula` evaluada por row via `lua_engine` con `[col]` refs disponibles. Para SQL transpile (fase 11), formula debe estar dentro del Lua subset; sino `tql_to_sql` emite warning + skip col.
`lua_id` cachea la formula compilada en lua_engine entre eval calls.
+30
View File
@@ -0,0 +1,30 @@
---
name: Filter
lang: cpp
domain: core
version: "1.0.0"
algebraic: product
definition: |
struct Filter {
int col;
Op op;
std::string value;
};
description: "Predicado TQL: col idx + Op + value. Aplicado dentro de un Stage por compute_stage. col es idx en headers efectivos del INPUT del stage."
tags: [tql, filter, predicate, product-type]
uses_types: [Op_cpp_core]
file_path: "cpp/functions/core/data_table_types.h"
---
## Notas
`col` es indice en `in_headers` del stage donde aplica (no en el dataset original — esto cambio en el refactor a stages). Para drill-down usar `make_drill_filter(col_idx, value)`.
`value` es string siempre — `compare()` decide numerico vs lexical segun parseo. Range filters (op_in_range, op_between) no estan modelados; usar dos Filters consecutivos.
## Usado por
- `Stage_cpp_core` (lista de filters)
- `apply_filters`, `compute_stage_cpp_core`
- `make_drill_filter`, `build_preset_filters`
- `tql_to_sql_cpp_core` → SQL WHERE clauses con `?` placeholders
+25
View File
@@ -0,0 +1,25 @@
---
name: Join
lang: cpp
domain: core
version: "1.0.0"
algebraic: product
definition: |
struct Join {
std::string alias;
std::string source;
std::vector<std::pair<std::string, std::string>> on;
JoinStrategy strategy;
std::vector<std::string> fields;
};
description: "Join MBQL-style entre main_t y source. on = pares {left_col, right_col} multi-key. strategy = Left/Inner/Right/Full. fields vacio = all cols del derecho."
tags: [tql, join, mbql, product-type]
uses_types: [JoinStrategy_cpp_core]
file_path: "cpp/functions/core/data_table_types.h"
---
## Notas
Materializado por `join_tables_cpp_core` antes de stages[0]. Cols del derecho se prefijan con `alias.col` para preservar headers del main. SQL emit: `LEFT/INNER/RIGHT/FULL OUTER JOIN source AS alias ON main.l = alias.r AND ...`.
Multi-key: `on = {{l1,r1}, {l2,r2}}``ON main.l1 = alias.r1 AND main.l2 = alias.r2`.
+17
View File
@@ -0,0 +1,17 @@
---
name: JoinStrategy
lang: cpp
domain: core
version: "1.0.0"
algebraic: sum
definition: |
enum class JoinStrategy { Left, Inner, Right, Full };
description: "Estrategia de join MBQL-style. 4 variantes estandar SQL. SQL mapping directo a LEFT/INNER/RIGHT/FULL OUTER JOIN."
tags: [tql, join, strategy, sum-type]
uses_types: []
file_path: "cpp/functions/core/data_table_types.h"
---
## Notas
Round-trip TQL: tokens `"left"/"inner"/"right"/"full"`. Fallback parse "nope" → Left.
+36
View File
@@ -0,0 +1,36 @@
---
name: Op
lang: cpp
domain: core
version: "1.0.0"
algebraic: sum
definition: |
enum class Op {
Eq, Neq, Gt, Gte, Lt, Lte,
Contains, NotContains, StartsWith, EndsWith
};
description: "Operador de filtro TQL. 6 ops de comparacion + 4 ops de string. Numericos ordenan numericamente cuando ambos lados parsean."
tags: [tql, filter, operator, sum-type]
uses_types: []
file_path: "cpp/functions/core/data_table_types.h"
---
## Notas
Tabla operadores permitidos por `ColumnType` via `ops_for_type(t)`:
| Tipo | Ops |
|---|---|
| Int / Float / Date | Eq, Neq, Gt, Gte, Lt, Lte |
| Bool | Eq, Neq |
| Json | Eq, Neq, Contains, NotContains |
| String | Eq, Neq, Contains, NotContains, StartsWith, EndsWith |
Mapeo SQL en `tql_to_sql_cpp_core`: Contains → `LIKE '%v%'`, StartsWith → `LIKE 'v%'`, etc.
## Usado por
- `Filter_cpp_core`
- `compute_stage_cpp_core` (via apply_filters)
- `tql_emit_cpp_core` / `tql_apply_cpp_core`
- `tql_to_sql_cpp_core`
+20
View File
@@ -0,0 +1,20 @@
---
name: SortClause
lang: cpp
domain: core
version: "1.0.0"
algebraic: product
definition: |
struct SortClause {
std::string col;
bool desc;
};
description: "Clausula de orden por nombre de col. Multi-sort = vector ordenado por prioridad. desc=true para descendente."
tags: [tql, sort, order, product-type]
uses_types: []
file_path: "cpp/functions/core/data_table_types.h"
---
## Notas
Sort por nombre (no idx) — sobrevive a renombrado de cols + a stages 1+ donde idx no aplica. Aplicacion via `apply_sorts`. Round-trip TQL: `sort = { {"asc"|"desc", "col"}, ... }`.
+33
View File
@@ -0,0 +1,33 @@
---
name: Stage
lang: cpp
domain: core
version: "1.0.0"
algebraic: product
definition: |
struct Stage {
std::vector<Filter> filters;
std::vector<DerivedColumn> derived;
std::vector<std::string> breakouts;
std::vector<Aggregation> aggregations;
std::vector<SortClause> sorts;
};
description: "Layer del pipeline TQL. Stage 0 = Raw (filters + derived + sort). Stage 1+ pueden agrupar (breakouts + aggregations + sort). Consumida por compute_stage."
tags: [tql, stage, pipeline, product-type, mbql]
uses_types: [Filter_cpp_core, Op_cpp_core]
file_path: "cpp/functions/core/data_table_types.h"
---
## Notas
Inspirado en MBQL `:filter` / `:breakout` / `:aggregation` / `:order-by`. Diferencia clave: TQL chain N stages explicitos, cada uno consume el output del anterior. MBQL usa `:source-query` recursivo.
Breakout strings pueden llevar sufijo `:granularity` para cols Date (fase 10): `"ts:month"`, `"ts:week"`, etc. Ver `parse_breakout_granularity()`.
## Usado por
- `State_cpp_core` (lista de stages)
- `compute_stage_cpp_core` (executes a single Stage)
- `compute_pipeline_cpp_core` (chains stages 0..N)
- `tql_emit_cpp_core` / `tql_apply_cpp_core` (round-trip Lua)
- `tql_to_sql_cpp_core` → CTE chain DuckDB
+26
View File
@@ -0,0 +1,26 @@
---
name: StageOutput
lang: cpp
domain: core
version: "1.0.0"
algebraic: product
definition: |
struct StageOutput {
std::vector<std::string> cell_backing;
std::vector<const char*> cells;
int rows;
int cols;
std::vector<std::string> headers;
std::vector<ColumnType> types;
};
description: "Output materializado de compute_stage. cell_backing posee strings nuevos (aggregations); cells es row-major de ptrs a backing o a in_cells original."
tags: [tql, stage, output, product-type]
uses_types: [ColumnType_cpp_core]
file_path: "cpp/functions/core/data_table_types.h"
---
## Notas
Lifetime: cell_backing es owner — cells solo es valido mientras StageOutput viva. Para passthrough (sin agregaciones), cells apunta a in_cells del caller (sin backing local).
Reservar capacidad upfront en cell_backing evita realloc que invalida punteros.
+40
View File
@@ -0,0 +1,40 @@
---
name: State
lang: cpp
domain: core
version: "1.0.0"
algebraic: product
definition: |
struct State {
std::vector<Stage> stages;
int active_stage;
ViewMode display;
ViewConfig viz_config;
std::vector<VizPanel> extra_panels;
std::vector<Join> joins;
std::string main_source;
std::vector<ColorRule> color_rules;
std::vector<bool> col_visible;
std::vector<int> col_order;
};
description: "Estado completo de una query TQL: pipeline de stages + joins + viz config + UI tweaks. Round-trip a Lua via tql_emit/tql_apply."
tags: [tql, state, pipeline, product-type]
uses_types: [Stage_cpp_core, Filter_cpp_core, Op_cpp_core]
file_path: "cpp/functions/core/data_table_types.h"
---
## Notas
State es el documento canonico de una query del usuario. Atomico — toda mutacion pasa por helpers pure (`apply_drill_step`, `drill_up`, etc.).
`active_stage` = idx del stage cuyo output se renderiza. Filters/sorts del Raw siempre se aplican antes; joins se materializan ANTES de stages[0].
Helpers `raw()`, `active()` garantizan `stages[0]` existe (lazy init en `ensure_stage0`).
## Usado por
- `data_table_cpp_viz` (UI render principal)
- `compute_pipeline_cpp_core` (resuelve hasta active_stage)
- `tql_emit_cpp_core` / `tql_apply_cpp_core` (Lua serializacion)
- `tql_to_sql_cpp_core` → SQL DuckDB CTE chain
- `apply_drill_step` / `undo_drill_step` / `drill_up`
+33
View File
@@ -0,0 +1,33 @@
---
name: TableInput
lang: cpp
domain: core
version: "1.0.0"
algebraic: product
definition: |
struct TableInput {
std::string name;
std::vector<std::string> headers;
std::vector<ColumnType> types;
const char* const* cells;
int rows;
int cols;
};
description: "Tabla materializada en memoria pasada a data_table::render(). Owner externo. Multiple tables = main + joinables (fase 9 issue 0078)."
tags: [tql, table, joins, mbql, product-type]
uses_types: [Op_cpp_core]
file_path: "cpp/functions/core/data_table_types.h"
---
## Notas
`name` es el identificador estable que matchea `Join.source` cuando se aplica un join. `cells` es row-major (rows * cols `const char*`). Apuntadores estables durante todo el frame de render.
Cells son strings — auto_detect_type infiere ColumnType si `types[i] == Auto`. Numericos se parsean por celda en compare/agg via `parse_number()`.
## Usado por
- `data_table_cpp_viz::render(tables, state)`
- `resolve_main_idx` (matchea state.main_source)
- `join_tables_cpp_core` (right table)
- `tql_to_sql_cpp_core` (schema para emitir SELECT FROM `name`)
+29
View File
@@ -0,0 +1,29 @@
---
name: ViewConfig
lang: cpp
domain: viz
version: "1.0.0"
algebraic: product
definition: |
struct ViewConfig {
std::string x_col;
std::vector<std::string> y_cols;
std::string size_col;
std::string cat_col;
unsigned int primary_color;
int hist_bins;
float pie_radius;
bool show_legend;
bool show_markers;
bool locked;
mutable bool fit_request;
};
description: "Overrides manuales de auto-detect para ViewMode. Cols vacias dejan al dispatcher elegir. primary_color=0 usa palette ImPlot."
tags: [tql, viz, config, product-type]
uses_types: []
file_path: "cpp/functions/core/data_table_types.h"
---
## Notas
`fit_request` mutable bool consumido por `viz::render` (one-shot trigger para `ImPlot::SetNextAxesToFit`). `locked` deshabilita pan/zoom del usuario.
+29
View File
@@ -0,0 +1,29 @@
---
name: ViewMode
lang: cpp
domain: viz
version: "1.0.0"
algebraic: sum
definition: |
enum class ViewMode {
Table,
Bar, Column, GroupedBar, StackedBar,
Line, Area, Stairs,
Scatter, Bubble,
Histogram, Histogram2D, Heatmap, BoxPlot,
Stem, ErrorBars,
Pie, Donut, Funnel, Waterfall,
KPI, KPIGrid,
Candlestick, Radar
};
description: "Modo de visualizacion ImPlot del stage activo. ~25 variantes cubriendo bars/lines/distribution/composition/specialized. Dispatcher en viz::render."
tags: [tql, viz, imgui, implot, sum-type]
uses_types: []
file_path: "cpp/functions/core/data_table_types.h"
---
## Notas
Tokens lowercase via `view_mode_token`/`view_mode_from_token` para TQL emit/apply. Helpers `view_mode_needs_numeric/category/aggregation` guían UI (combo selectable solo si schema compatible).
`Table` siempre disponible (fallback render por defecto). Demas requieren al menos cols compatibles. Click-to-drill (fase 10): Bar/Column/Scatter/Bubble/Pie/Donut/Funnel/Heatmap.
+21
View File
@@ -0,0 +1,21 @@
---
name: VizPanel
lang: cpp
domain: viz
version: "1.0.0"
algebraic: product
definition: |
struct VizPanel {
ViewMode display;
ViewConfig config;
mutable ViewMode last_non_table;
};
description: "Viz adicional sobre el mismo StageOutput. State tiene panel principal (display+viz_config) + vector<VizPanel> extras."
tags: [tql, viz, panel, product-type]
uses_types: [ViewMode_cpp_viz, ViewConfig_cpp_viz]
file_path: "cpp/functions/core/data_table_types.h"
---
## Notas
`last_non_table` memoria del ultimo display !=Table para toggle Table↔View rapido en UI. Mutable porque se actualiza durante render (no rompe const correctness).