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