diff --git a/playground/tables/CMakeLists.txt b/playground/tables/CMakeLists.txt index 903dd22..a0091e3 100644 --- a/playground/tables/CMakeLists.txt +++ b/playground/tables/CMakeLists.txt @@ -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) diff --git a/playground/tables/data_table.cpp b/playground/tables/data_table.cpp index 14879d7..56dbde9 100644 --- a/playground/tables/data_table.cpp +++ b/playground/tables/data_table.cpp @@ -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 #include #include #include +#include #include #include #include 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 drill_back; + std::vector 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& headers, + const std::vector& types, + const std::vector& 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(), // emit headers stage 0 (caller fill si necesario) + std::vector()); + } + 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& 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& 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& 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& in_types) { + const std::vector& 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(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) diff --git a/playground/tables/data_table_logic.cpp b/playground/tables/data_table_logic.cpp index 51065e0..f81e09e 100644 --- a/playground/tables/data_table_logic.cpp +++ b/playground/tables/data_table_logic.cpp @@ -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& 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 build_filters_from_row(const char* const* cells, int rows, + int cols, int row_idx) { + std::vector 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 apply_filters(const char* const* cells, int rows, int cols, const std::vector& 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 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 break_cols(nbreaks); + std::vector 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 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 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 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 build_preset_filters(FilterPreset preset, int col, + const std::string& today_ymd) { + std::vector 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 diff --git a/playground/tables/data_table_logic.h b/playground/tables/data_table_logic.h index 9c5c290..8ab4b06 100644 --- a/playground/tables/data_table_logic.h +++ b/playground/tables/data_table_logic.h @@ -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 #include #include 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 filters; - std::vector derived; // expressions de este stage - std::vector breakouts; // col names del INPUT de este stage - std::vector aggregations; - std::vector sorts; -}; - // Pure: alias por defecto cuando agg.alias esta vacio. // count -> "count" // distinct col -> "distinct_" @@ -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& in_headers, const std::vector& 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 cell_backing; - std::vector cells; - int rows = 0; - int cols = 0; - std::vector headers; - std::vector 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& in_headers, const std::vector& 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 apply_filters(const char* const* cells, int rows, int cols, const std::vector& 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 headers; - std::vector 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> on; // {left_col, right_col} - JoinStrategy strategy = JoinStrategy::Left; - std::vector 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& tables, const std::string& main_source); -// Pure: aplica un join sobre dos tablas. Resultado: StageOutput con -// `headers` = left + `.` (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& left_headers, const std::vector& 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 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 stages; - int active_stage = 0; - ViewMode display = ViewMode::Table; - ViewConfig viz_config; - std::vector extra_panels; - std::vector 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& headers); - std::vector color_rules; - std::vector col_visible; // size = effective_cols del stage activo - std::vector col_order; // permutacion [0..effective_cols) +// Pure (fase 10): construye filters Op::Eq desde una fila. +std::vector 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 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 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& 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& col_order, const std::vector& col_visible, const std::vector& 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 hist; // bins (HIST_BINS) si numeric - std::vector> top_categories; // top 8 por count desc + std::vector hist; + std::vector> 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); diff --git a/playground/tables/llm_anthropic.cpp b/playground/tables/llm_anthropic.cpp new file mode 100644 index 0000000..abfed04 --- /dev/null +++ b/playground/tables/llm_anthropic.cpp @@ -0,0 +1,295 @@ +// llm_anthropic.cpp — cliente Anthropic minimal via cURL popen. +// Ver issue 0080. +#include "llm_anthropic.h" + +#include +#include +#include +#include +#include + +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 ``` 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 diff --git a/playground/tables/llm_anthropic.h b/playground/tables/llm_anthropic.h new file mode 100644 index 0000000..bbc35c0 --- /dev/null +++ b/playground/tables/llm_anthropic.h @@ -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 +#include + +namespace llm_anthropic { + +enum class OutputMode { TQL, SQL }; + +struct AskInput { + std::string question; // pregunta NL + std::string tql_current; // TQL actual (emitido) + std::vector col_names; // schema input + std::vector col_types; + std::vector 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 ```\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 diff --git a/playground/tables/self_test.cpp b/playground/tables/self_test.cpp index 08d3927..6a2283f 100644 --- a/playground/tables/self_test.cpp +++ b/playground/tables/self_test.cpp @@ -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 #include #include #include @@ -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 hdrs = {"ts", "amount"}; + std::vector 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 hdrs = {"ts", "amount"}; + std::vector 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 hdrs = {"lang", "n"}; + std::vector 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 back_stack; + std::vector 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 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 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 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 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 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; } diff --git a/playground/tables/tql.cpp b/playground/tables/tql.cpp index 3bcc3ce..ab80f4c 100644 --- a/playground/tables/tql.cpp +++ b/playground/tables/tql.cpp @@ -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); } diff --git a/playground/tables/tql_to_sql.cpp b/playground/tables/tql_to_sql.cpp new file mode 100644 index 0000000..2dba82d --- /dev/null +++ b/playground/tables/tql_to_sql.cpp @@ -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 +#include +#include +#include +#include +#include +#include + +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& forbidden_keywords() { + static const std::unordered_map 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& fn_whitelist() { + static const std::unordered_map 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& 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& toks, + const std::vector& 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 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& toks_; + const std::vector& headers_; + size_t pos_ = 0; + std::string error_; +}; + +} // anon + +std::string transpile_expr(const std::string& formula, + const std::vector& in_headers, + std::string& error_out) { + error_out.clear(); + std::vector 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 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& 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& 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 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 +// [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& 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 diff --git a/playground/tables/tql_to_sql.h b/playground/tables/tql_to_sql.h new file mode 100644 index 0000000..2968310 --- /dev/null +++ b/playground/tables/tql_to_sql.h @@ -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 +#include + +namespace tql_to_sql { + +struct SqlEmit { + std::string sql; // SELECT/CTE chain DuckDB + std::vector params; // bound values posicionales (?) + std::vector 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& 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& in_headers, + std::string& error_out); + +} // namespace tql_to_sql diff --git a/playground/tables/viz.cpp b/playground/tables/viz.cpp index 2f71704..5d75ea5 100644 --- a/playground/tables/viz.cpp +++ b/playground/tables/viz.cpp @@ -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 finite(const std::vector& 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); diff --git a/playground/tables/viz.h b/playground/tables/viz.h index 96b364c..ff358fb 100644 --- a/playground/tables/viz.h +++ b/playground/tables/viz.h @@ -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);