data_table: Phase 2 — Button + events + tooltip + RightClick + TQL persist column_specs (issue 0081-O)
- CellRenderer::Button=5: renders SmallButton per cell; emits TableEvent::ButtonClick on click - TableEventKind enum (ButtonClick/RowDoubleClick/RowRightClick/CellEdit) + TableEvent struct - render() extended overload: adds events_out parameter (nullptr = back-compat, no events) - RowDoubleClick and RowRightClick detection in raw table loop (stage 0) - RowRightClick also detected in aggregated stage table (stage 1+) - Tooltip per cell: tooltip_on_hover + tooltip fields on ColumnSpec; "auto" = show cell value - State::aux_column_specs: TQL-persisted column specs sidecar per table - tql_emit: serializes aux_column_specs[0] as column_specs block (badge/progress/duration/icon/button/tooltip) - tql_apply: parses column_specs block back into state.aux_column_specs[0] - render() merges aux_column_specs into TableInput when caller passes empty column_specs - test_column_specs: 5->8 tests (Button struct, tooltip fields, both render() signatures link) - tql_emit_test: 3 new tests (column_specs badge/button/tooltip emit) — 52 passed - tql_apply_test: 3 new tests (column_specs badge/button/tooltip roundtrip) — 106 passed - Back-compat: existing apps (graph_explorer, registry_dashboard) unchanged - Version bump: data_table v1.1.0 -> v1.2.0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -129,6 +129,7 @@ enum class JoinStrategy { Left, Inner, Right, Full };
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// CellRenderer: declarative rendering mode per column (issue 0081-N, v1.1.0).
|
||||
// Phase 2 (issue 0081-O, v1.2.0): Button=5 added.
|
||||
// ----------------------------------------------------------------------------
|
||||
enum class CellRenderer : uint8_t {
|
||||
Text = 0, // default — current behavior
|
||||
@@ -136,7 +137,29 @@ enum class CellRenderer : uint8_t {
|
||||
Progress = 2, // progress bar (0..1 or 0..100)
|
||||
Duration = 3, // milliseconds with color gradient
|
||||
Icon = 4, // icon lookup by value string
|
||||
// Future (Phase 2-3): Button=5, TextInput=6, Custom=7. IDs reserved.
|
||||
Button = 5, // clickable button; emits TableEvent::ButtonClick
|
||||
// Future (Phase 3): TextInput=6, Custom=7. IDs reserved.
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// TableEventKind: kinds of events emitted by render() via events_out.
|
||||
// Issue 0081-O, v1.2.0.
|
||||
// ----------------------------------------------------------------------------
|
||||
enum class TableEventKind : uint8_t {
|
||||
ButtonClick = 1, // CellRenderer::Button was clicked
|
||||
RowDoubleClick = 2, // row was double-clicked (left button)
|
||||
RowRightClick = 3, // row right-clicked (open context menu)
|
||||
CellEdit = 4, // reserved for Phase 3 (TextInput)
|
||||
};
|
||||
|
||||
// TableEvent: carries the context of a single UI interaction.
|
||||
struct TableEvent {
|
||||
TableEventKind kind;
|
||||
int row = -1; // index in TableInput (not StageOutput)
|
||||
int col = -1; // column index in TableInput
|
||||
std::string column_id; // ColumnSpec.id of the column
|
||||
std::string action_id; // for ButtonClick: ColumnSpec.button_action
|
||||
std::string value; // cell value at the click point
|
||||
};
|
||||
|
||||
// BadgeRule: maps a cell value to a colored badge label.
|
||||
@@ -155,7 +178,7 @@ struct IconMapEntry {
|
||||
|
||||
// ColumnSpec: rendering spec for one column. Indexed by column position.
|
||||
struct ColumnSpec {
|
||||
std::string id; // stable id, used in TQL (future)
|
||||
std::string id; // stable id, used in TQL
|
||||
CellRenderer renderer = CellRenderer::Text;
|
||||
|
||||
// Badge
|
||||
@@ -171,6 +194,15 @@ struct ColumnSpec {
|
||||
|
||||
// Icon
|
||||
std::vector<IconMapEntry> icon_map;
|
||||
|
||||
// Button (Phase 2, v1.2.0): CellRenderer::Button
|
||||
std::string button_action; // semantic id the app processes
|
||||
std::string button_label; // button text; "" -> use cell value
|
||||
std::string button_color_hex; // optional button color; "" -> default
|
||||
|
||||
// Tooltip (Phase 2, v1.2.0): per-cell hover tooltip
|
||||
std::string tooltip; // text; "auto" -> show cell value
|
||||
bool tooltip_on_hover = false; // if true, show on hover
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -241,6 +273,13 @@ struct State {
|
||||
std::vector<bool> col_visible;
|
||||
std::vector<int> col_order;
|
||||
|
||||
// aux_column_specs (Phase 2, v1.2.0): TQL-persisted column specs sidecar.
|
||||
// Parallel to tables[]; aux_column_specs[0] corresponds to tables[0], etc.
|
||||
// Empty = no TQL-persisted specs. When non-empty, render() merges these
|
||||
// into TableInput.column_specs if the caller passed an empty column_specs.
|
||||
// Caller-provided column_specs take precedence over aux_column_specs.
|
||||
std::vector<std::vector<ColumnSpec>> aux_column_specs;
|
||||
|
||||
// Helpers (definidos en compute_stage.cpp).
|
||||
Stage& raw();
|
||||
const Stage& raw() const;
|
||||
|
||||
@@ -0,0 +1,672 @@
|
||||
// tql_apply.cpp — TQL parser using Lua 5.4 C API directly (no lua_engine).
|
||||
// See tql_apply.h for documentation.
|
||||
// Promoted from primitives_gallery/playground/tables/tql.cpp (apply()).
|
||||
|
||||
#include "core/tql_apply.h"
|
||||
#include "core/tql_helpers.h"
|
||||
|
||||
extern "C" {
|
||||
#include "lua.h"
|
||||
#include "lualib.h"
|
||||
#include "lauxlib.h"
|
||||
}
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
namespace tql {
|
||||
|
||||
using namespace data_table;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
namespace {
|
||||
|
||||
// Find col index in a header list; returns -1 if not found.
|
||||
int find_col(const std::vector<std::string>& headers, const std::string& name) {
|
||||
for (size_t i = 0; i < headers.size(); ++i)
|
||||
if (headers[i] == name) return (int)i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Safe lua_tostring wrapper that never returns null.
|
||||
std::string lua_to_str(lua_State* L, int idx) {
|
||||
if (lua_isnil(L, idx)) return {};
|
||||
if (lua_isboolean(L, idx)) return lua_toboolean(L, idx) ? "true" : "false";
|
||||
size_t n = 0;
|
||||
const char* s = luaL_tolstring(L, idx, &n);
|
||||
std::string out(s, n);
|
||||
lua_pop(L, 1);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // anon
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// apply
|
||||
// ---------------------------------------------------------------------------
|
||||
ApplyResult apply(const std::string& lua_text,
|
||||
const std::vector<std::string>& available_headers)
|
||||
{
|
||||
ApplyResult res;
|
||||
State& state = res.state;
|
||||
|
||||
auto warn = [&](std::string msg) {
|
||||
res.warnings.push_back(std::move(msg));
|
||||
};
|
||||
|
||||
// Create a fresh Lua state for parsing only (no engine libs needed).
|
||||
lua_State* L = luaL_newstate();
|
||||
if (!L) {
|
||||
res.error = "luaL_newstate failed";
|
||||
return res;
|
||||
}
|
||||
luaL_openlibs(L); // needed for string coercions (luaL_tolstring)
|
||||
|
||||
// Load and execute the TQL chunk.
|
||||
if (luaL_loadbufferx(L, lua_text.data(), lua_text.size(), "tql", "t") != LUA_OK) {
|
||||
res.error = lua_tostring(L, -1) ? lua_tostring(L, -1) : "load error";
|
||||
lua_close(L);
|
||||
return res;
|
||||
}
|
||||
if (lua_pcall(L, 0, 1, 0) != LUA_OK) {
|
||||
res.error = lua_tostring(L, -1) ? lua_tostring(L, -1) : "exec error";
|
||||
lua_close(L);
|
||||
return res;
|
||||
}
|
||||
if (!lua_istable(L, -1)) {
|
||||
res.error = "TQL root must be a table";
|
||||
lua_close(L);
|
||||
return res;
|
||||
}
|
||||
|
||||
// version check
|
||||
lua_getfield(L, -1, "version");
|
||||
if (lua_isnil(L, -1)) {
|
||||
warn("version missing (assuming 1)");
|
||||
} else if (!lua_isnumber(L, -1)) {
|
||||
res.error = "version must be a number";
|
||||
lua_close(L);
|
||||
return res;
|
||||
} else {
|
||||
int v = (int)lua_tointeger(L, -1);
|
||||
if (v != 1) {
|
||||
char buf[64];
|
||||
std::snprintf(buf, sizeof(buf),
|
||||
"unsupported TQL version %d (expected 1)", v);
|
||||
res.error = buf;
|
||||
lua_close(L);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
|
||||
// main_source
|
||||
lua_getfield(L, -1, "main_source");
|
||||
if (lua_isstring(L, -1)) state.main_source = lua_tostring(L, -1);
|
||||
else state.main_source.clear();
|
||||
lua_pop(L, 1);
|
||||
|
||||
// display
|
||||
lua_getfield(L, -1, "display");
|
||||
if (lua_isstring(L, -1)) {
|
||||
std::string d = lua_tostring(L, -1);
|
||||
ViewMode m = view_mode_from_token(d.c_str());
|
||||
state.display = m;
|
||||
if (view_mode_token(m) != d) {
|
||||
warn("unknown display \"" + d + "\" (defaulting to table)");
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
|
||||
// Reset mutable state.
|
||||
state.stages.clear();
|
||||
state.active_stage = 0;
|
||||
state.color_rules.clear();
|
||||
state.joins.clear();
|
||||
|
||||
// ---- joins ----
|
||||
lua_getfield(L, -1, "joins");
|
||||
if (lua_istable(L, -1)) {
|
||||
int nj = (int)lua_rawlen(L, -1);
|
||||
for (int i = 1; i <= nj; ++i) {
|
||||
lua_rawgeti(L, -1, i);
|
||||
if (!lua_istable(L, -1)) { lua_pop(L, 1); continue; }
|
||||
Join jn;
|
||||
|
||||
lua_getfield(L, -1, "alias");
|
||||
if (lua_isstring(L, -1)) jn.alias = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
lua_getfield(L, -1, "source");
|
||||
if (lua_isstring(L, -1)) jn.source = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
lua_getfield(L, -1, "strategy");
|
||||
if (lua_isstring(L, -1))
|
||||
jn.strategy = join_strategy_from_token(lua_tostring(L, -1));
|
||||
lua_pop(L, 1);
|
||||
|
||||
lua_getfield(L, -1, "on");
|
||||
if (lua_istable(L, -1)) {
|
||||
int on_n = (int)lua_rawlen(L, -1);
|
||||
for (int k = 1; k <= on_n; ++k) {
|
||||
lua_rawgeti(L, -1, k);
|
||||
if (lua_istable(L, -1) && lua_rawlen(L, -1) >= 2) {
|
||||
lua_rawgeti(L, -1, 1); std::string a = lua_to_str(L, -1); lua_pop(L, 1);
|
||||
lua_rawgeti(L, -1, 2); std::string b = lua_to_str(L, -1); lua_pop(L, 1);
|
||||
jn.on.push_back({a, b});
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
|
||||
lua_getfield(L, -1, "fields");
|
||||
if (lua_istable(L, -1)) {
|
||||
int fn_n = (int)lua_rawlen(L, -1);
|
||||
for (int k = 1; k <= fn_n; ++k) {
|
||||
lua_rawgeti(L, -1, k);
|
||||
if (lua_isstring(L, -1)) jn.fields.emplace_back(lua_tostring(L, -1));
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
|
||||
state.joins.push_back(std::move(jn));
|
||||
lua_pop(L, 1); // pop join entry
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1); // joins
|
||||
|
||||
// ---- stages ----
|
||||
lua_getfield(L, -1, "stages");
|
||||
if (lua_istable(L, -1)) {
|
||||
int n_stages = (int)lua_rawlen(L, -1);
|
||||
// cur_headers: tracks effective headers stage by stage for filter col lookup.
|
||||
std::vector<std::string> cur_headers = available_headers;
|
||||
|
||||
for (int si = 1; si <= n_stages; ++si) {
|
||||
lua_rawgeti(L, -1, si);
|
||||
if (!lua_istable(L, -1)) { lua_pop(L, 1); continue; }
|
||||
|
||||
Stage stg;
|
||||
|
||||
// expressions (stage 0 only in practice; accepted in any stage for symmetry)
|
||||
lua_getfield(L, -1, "expressions");
|
||||
if (lua_istable(L, -1)) {
|
||||
lua_pushnil(L);
|
||||
while (lua_next(L, -2) != 0) {
|
||||
if (lua_isstring(L, -2) && lua_isstring(L, -1)) {
|
||||
DerivedColumn d;
|
||||
d.source_col = -1;
|
||||
d.name = lua_tostring(L, -2);
|
||||
d.formula = lua_tostring(L, -1);
|
||||
d.lua_id = -1; // caller must compile
|
||||
d.type = ColumnType::String;
|
||||
stg.derived.push_back(std::move(d));
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1); // expressions
|
||||
|
||||
// filter
|
||||
lua_getfield(L, -1, "filter");
|
||||
if (lua_istable(L, -1)) {
|
||||
int n = (int)lua_rawlen(L, -1);
|
||||
for (int i = 1; i <= n; ++i) {
|
||||
lua_rawgeti(L, -1, i);
|
||||
if (lua_istable(L, -1) && lua_rawlen(L, -1) >= 3) {
|
||||
lua_rawgeti(L, -1, 1); std::string op = lua_to_str(L, -1); lua_pop(L, 1);
|
||||
lua_rawgeti(L, -1, 2); std::string col = lua_to_str(L, -1); lua_pop(L, 1);
|
||||
lua_rawgeti(L, -1, 3); std::string val = lua_to_str(L, -1); lua_pop(L, 1);
|
||||
int ci = find_col(cur_headers, col);
|
||||
if (ci >= 0) {
|
||||
stg.filters.push_back({ci, op_from_label(op.c_str()), val});
|
||||
} else if (!cur_headers.empty()) {
|
||||
// Only warn when headers were provided (validation mode).
|
||||
warn("stage " + std::to_string(si - 1) +
|
||||
": filter col \"" + col + "\" not found");
|
||||
} else {
|
||||
// No headers: store filter with col=-1 as placeholder.
|
||||
stg.filters.push_back({-1, op_from_label(op.c_str()), val});
|
||||
}
|
||||
// Validate op token.
|
||||
if (op != "=" && op != "!=" && op != ">" && op != ">=" &&
|
||||
op != "<" && op != "<=" && op != "contains" &&
|
||||
op != "!contains" && op != "starts" && op != "ends") {
|
||||
warn("stage " + std::to_string(si - 1) +
|
||||
": unknown filter op \"" + op + "\" (defaulting to =)");
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1); // filter
|
||||
|
||||
// breakout
|
||||
lua_getfield(L, -1, "breakout");
|
||||
if (lua_istable(L, -1)) {
|
||||
int n = (int)lua_rawlen(L, -1);
|
||||
for (int i = 1; i <= n; ++i) {
|
||||
lua_rawgeti(L, -1, i);
|
||||
if (lua_isstring(L, -1)) {
|
||||
std::string bn = lua_tostring(L, -1);
|
||||
if (!cur_headers.empty()) {
|
||||
std::string clean;
|
||||
parse_breakout_granularity(bn, clean);
|
||||
if (find_col(cur_headers, clean) < 0) {
|
||||
warn("stage " + std::to_string(si - 1) +
|
||||
": breakout col \"" + clean +
|
||||
"\" not in input headers");
|
||||
}
|
||||
}
|
||||
stg.breakouts.emplace_back(std::move(bn));
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1); // breakout
|
||||
|
||||
// aggregation
|
||||
lua_getfield(L, -1, "aggregation");
|
||||
if (lua_istable(L, -1)) {
|
||||
int n = (int)lua_rawlen(L, -1);
|
||||
for (int i = 1; i <= n; ++i) {
|
||||
lua_rawgeti(L, -1, i);
|
||||
if (lua_istable(L, -1) && lua_rawlen(L, -1) >= 1) {
|
||||
Aggregation a;
|
||||
lua_rawgeti(L, -1, 1);
|
||||
std::string fn_name = lua_to_str(L, -1);
|
||||
lua_pop(L, 1);
|
||||
const bool known = (fn_name == "count" || fn_name == "sum" ||
|
||||
fn_name == "avg" || fn_name == "min" ||
|
||||
fn_name == "max" || fn_name == "distinct" ||
|
||||
fn_name == "stddev" || fn_name == "median" ||
|
||||
fn_name == "p25" || fn_name == "p75" ||
|
||||
fn_name == "p90" || fn_name == "p99" ||
|
||||
fn_name == "percentile");
|
||||
if (!known) {
|
||||
warn("stage " + std::to_string(si - 1) +
|
||||
": unknown aggregation fn \"" + fn_name +
|
||||
"\" (defaulting to count)");
|
||||
}
|
||||
a.fn = agg_fn_from_string(fn_name);
|
||||
if ((int)lua_rawlen(L, -1) >= 2) {
|
||||
lua_rawgeti(L, -1, 2);
|
||||
a.col = lua_to_str(L, -1);
|
||||
lua_pop(L, 1);
|
||||
if (a.fn != AggFn::Count && !cur_headers.empty() &&
|
||||
find_col(cur_headers, a.col) < 0) {
|
||||
warn("stage " + std::to_string(si - 1) +
|
||||
": aggregation col \"" + a.col +
|
||||
"\" not in input headers");
|
||||
}
|
||||
} else if (a.fn != AggFn::Count) {
|
||||
warn("stage " + std::to_string(si - 1) +
|
||||
": aggregation \"" + fn_name + "\" requires a column");
|
||||
}
|
||||
if ((int)lua_rawlen(L, -1) >= 3) {
|
||||
lua_rawgeti(L, -1, 3);
|
||||
if (lua_isnumber(L, -1)) a.arg = lua_tonumber(L, -1);
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
stg.aggregations.push_back(std::move(a));
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1); // aggregation
|
||||
|
||||
// sort
|
||||
lua_getfield(L, -1, "sort");
|
||||
if (lua_istable(L, -1)) {
|
||||
int n = (int)lua_rawlen(L, -1);
|
||||
for (int i = 1; i <= n; ++i) {
|
||||
lua_rawgeti(L, -1, i);
|
||||
if (lua_istable(L, -1) && lua_rawlen(L, -1) >= 2) {
|
||||
lua_rawgeti(L, -1, 1); std::string dir = lua_to_str(L, -1); lua_pop(L, 1);
|
||||
lua_rawgeti(L, -1, 2); std::string col = lua_to_str(L, -1); lua_pop(L, 1);
|
||||
if (dir != "asc" && dir != "desc") {
|
||||
warn("stage " + std::to_string(si - 1) +
|
||||
": unknown sort dir \"" + dir + "\" (defaulting to asc)");
|
||||
}
|
||||
stg.sorts.push_back({col, dir == "desc"});
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1); // sort
|
||||
|
||||
state.stages.push_back(std::move(stg));
|
||||
|
||||
// Advance cur_headers for next stage col resolution.
|
||||
const Stage& last = state.stages.back();
|
||||
if (si == 1) {
|
||||
// Stage 0: cur_headers = orig + derived names.
|
||||
for (const auto& d : last.derived) cur_headers.push_back(d.name);
|
||||
} else {
|
||||
if (!last.breakouts.empty() || !last.aggregations.empty()) {
|
||||
std::vector<std::string> next;
|
||||
for (const auto& b : last.breakouts) next.push_back(b);
|
||||
for (const auto& a : last.aggregations) next.push_back(aggregation_alias(a));
|
||||
cur_headers = std::move(next);
|
||||
}
|
||||
}
|
||||
|
||||
lua_pop(L, 1); // pop stage entry
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1); // stages
|
||||
|
||||
// ensure_stage0 equivalent: at least one stage.
|
||||
if (state.stages.empty()) state.stages.push_back(Stage{});
|
||||
if (state.active_stage < 0) state.active_stage = 0;
|
||||
if (state.active_stage >= (int)state.stages.size())
|
||||
state.active_stage = (int)state.stages.size() - 1;
|
||||
|
||||
// ---- columns (per-col render config) ----
|
||||
int orig_cols = (int)available_headers.size();
|
||||
int eff_cols = orig_cols + (int)state.stages[0].derived.size();
|
||||
|
||||
lua_getfield(L, -1, "columns");
|
||||
if (lua_istable(L, -1) && eff_cols > 0) {
|
||||
state.col_visible.assign(eff_cols, true);
|
||||
std::vector<std::pair<int,int>> order_pairs;
|
||||
std::vector<bool> seen(eff_cols, false);
|
||||
|
||||
int n = (int)lua_rawlen(L, -1);
|
||||
for (int i = 1; i <= n; ++i) {
|
||||
lua_rawgeti(L, -1, i);
|
||||
if (!lua_istable(L, -1)) { lua_pop(L, 1); continue; }
|
||||
|
||||
lua_getfield(L, -1, "name");
|
||||
std::string nm = lua_to_str(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
int col_idx = find_col(available_headers, nm);
|
||||
if (col_idx < 0) {
|
||||
// Check derived.
|
||||
const auto& der = state.stages[0].derived;
|
||||
for (int di = 0; di < (int)der.size(); ++di) {
|
||||
if (der[di].name == nm) { col_idx = orig_cols + di; break; }
|
||||
}
|
||||
}
|
||||
if (col_idx < 0 || col_idx >= eff_cols) { lua_pop(L, 1); continue; }
|
||||
seen[col_idx] = true;
|
||||
|
||||
lua_getfield(L, -1, "visible");
|
||||
if (lua_isboolean(L, -1))
|
||||
state.col_visible[col_idx] = (bool)lua_toboolean(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
lua_getfield(L, -1, "order");
|
||||
int order_val = lua_isnumber(L, -1)
|
||||
? (int)lua_tointeger(L, -1) : (col_idx + 1);
|
||||
lua_pop(L, 1);
|
||||
order_pairs.emplace_back(order_val, col_idx);
|
||||
|
||||
// type: only mutable for derived cols.
|
||||
lua_getfield(L, -1, "type");
|
||||
if (lua_isstring(L, -1)) {
|
||||
ColumnType t = column_type_from_string(lua_tostring(L, -1));
|
||||
if (col_idx >= orig_cols && col_idx - orig_cols < (int)state.stages[0].derived.size())
|
||||
state.stages[0].derived[col_idx - orig_cols].type = t;
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
|
||||
// color_rules
|
||||
lua_getfield(L, -1, "color_rules");
|
||||
if (lua_istable(L, -1)) {
|
||||
int rn = (int)lua_rawlen(L, -1);
|
||||
for (int j = 1; j <= rn; ++j) {
|
||||
lua_rawgeti(L, -1, j);
|
||||
if (lua_istable(L, -1)) {
|
||||
lua_getfield(L, -1, "equals");
|
||||
std::string eq = lua_to_str(L, -1);
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, -1, "color");
|
||||
std::string hx = lua_to_str(L, -1);
|
||||
lua_pop(L, 1);
|
||||
state.color_rules.push_back({col_idx, eq, hex_to_color(hx)});
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1); // color_rules
|
||||
|
||||
lua_pop(L, 1); // col entry
|
||||
}
|
||||
|
||||
std::sort(order_pairs.begin(), order_pairs.end());
|
||||
state.col_order.clear();
|
||||
for (auto& p : order_pairs) state.col_order.push_back(p.second);
|
||||
for (int c = 0; c < eff_cols; ++c)
|
||||
if (!seen[c]) state.col_order.push_back(c);
|
||||
}
|
||||
lua_pop(L, 1); // columns
|
||||
|
||||
// ---- views (viz panels) ----
|
||||
state.extra_panels.clear();
|
||||
lua_getfield(L, -1, "views");
|
||||
if (lua_istable(L, -1)) {
|
||||
int n = (int)lua_rawlen(L, -1);
|
||||
for (int i = 1; i <= n; ++i) {
|
||||
lua_rawgeti(L, -1, i);
|
||||
if (!lua_istable(L, -1)) { lua_pop(L, 1); continue; }
|
||||
|
||||
VizPanel p;
|
||||
lua_getfield(L, -1, "display");
|
||||
if (lua_isstring(L, -1))
|
||||
p.display = view_mode_from_token(lua_tostring(L, -1));
|
||||
lua_pop(L, 1);
|
||||
|
||||
auto read_str = [&](const char* key, std::string& out_s) {
|
||||
lua_getfield(L, -1, key);
|
||||
if (lua_isstring(L, -1)) out_s = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
};
|
||||
read_str("x_col", p.config.x_col);
|
||||
read_str("cat_col", p.config.cat_col);
|
||||
read_str("size_col", p.config.size_col);
|
||||
|
||||
lua_getfield(L, -1, "y_cols");
|
||||
if (lua_istable(L, -1)) {
|
||||
int yn = (int)lua_rawlen(L, -1);
|
||||
for (int j = 1; j <= yn; ++j) {
|
||||
lua_rawgeti(L, -1, j);
|
||||
if (lua_isstring(L, -1)) p.config.y_cols.emplace_back(lua_tostring(L, -1));
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
|
||||
lua_getfield(L, -1, "color");
|
||||
if (lua_isstring(L, -1)) p.config.primary_color = hex_to_color(lua_tostring(L, -1));
|
||||
lua_pop(L, 1);
|
||||
|
||||
lua_getfield(L, -1, "hist_bins");
|
||||
if (lua_isnumber(L, -1)) p.config.hist_bins = (int)lua_tointeger(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
lua_getfield(L, -1, "pie_radius");
|
||||
if (lua_isnumber(L, -1)) p.config.pie_radius = (float)lua_tonumber(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
lua_getfield(L, -1, "show_legend");
|
||||
if (lua_isboolean(L, -1)) p.config.show_legend = (bool)lua_toboolean(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
lua_getfield(L, -1, "show_markers");
|
||||
if (lua_isboolean(L, -1)) p.config.show_markers = (bool)lua_toboolean(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
lua_getfield(L, -1, "locked");
|
||||
if (lua_isboolean(L, -1)) p.config.locked = (bool)lua_toboolean(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
// Panel 0 = main viz (state.display + state.viz_config).
|
||||
if (i == 1) {
|
||||
state.display = p.display;
|
||||
state.viz_config = p.config;
|
||||
} else {
|
||||
state.extra_panels.push_back(std::move(p));
|
||||
}
|
||||
lua_pop(L, 1); // panel entry
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1); // views
|
||||
|
||||
// ---- column_specs (Phase 2, v1.2.0) ----
|
||||
// Populate state.aux_column_specs[0] from optional "column_specs" block.
|
||||
state.aux_column_specs.clear();
|
||||
lua_getfield(L, -1, "column_specs");
|
||||
if (lua_istable(L, -1)) {
|
||||
std::vector<data_table::ColumnSpec> specs;
|
||||
int n_cs = (int)lua_rawlen(L, -1);
|
||||
for (int i = 1; i <= n_cs; ++i) {
|
||||
lua_rawgeti(L, -1, i);
|
||||
if (!lua_istable(L, -1)) { lua_pop(L, 1); continue; }
|
||||
|
||||
data_table::ColumnSpec cs;
|
||||
|
||||
lua_getfield(L, -1, "id");
|
||||
if (lua_isstring(L, -1)) cs.id = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
lua_getfield(L, -1, "renderer");
|
||||
if (lua_isstring(L, -1)) {
|
||||
std::string rn = lua_tostring(L, -1);
|
||||
if (rn == "badge") cs.renderer = data_table::CellRenderer::Badge;
|
||||
else if (rn == "progress") cs.renderer = data_table::CellRenderer::Progress;
|
||||
else if (rn == "duration") cs.renderer = data_table::CellRenderer::Duration;
|
||||
else if (rn == "icon") cs.renderer = data_table::CellRenderer::Icon;
|
||||
else if (rn == "button") cs.renderer = data_table::CellRenderer::Button;
|
||||
else cs.renderer = data_table::CellRenderer::Text;
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
|
||||
// Badge rules
|
||||
lua_getfield(L, -1, "badges");
|
||||
if (lua_istable(L, -1)) {
|
||||
int nb = (int)lua_rawlen(L, -1);
|
||||
for (int j = 1; j <= nb; ++j) {
|
||||
lua_rawgeti(L, -1, j);
|
||||
if (lua_istable(L, -1)) {
|
||||
data_table::BadgeRule br;
|
||||
lua_getfield(L, -1, "value");
|
||||
if (lua_isstring(L, -1)) br.value = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, -1, "color");
|
||||
if (lua_isstring(L, -1)) br.color_hex = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, -1, "label");
|
||||
if (lua_isstring(L, -1)) br.label = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
cs.badges.push_back(std::move(br));
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1); // badges
|
||||
|
||||
// Progress
|
||||
lua_getfield(L, -1, "progress_scale_100");
|
||||
if (lua_isboolean(L, -1)) cs.progress_scale_100 = (bool)lua_toboolean(L, -1);
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, -1, "progress_color");
|
||||
if (lua_isstring(L, -1)) cs.progress_color_hex = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
// Duration
|
||||
lua_getfield(L, -1, "warn_ms");
|
||||
if (lua_isnumber(L, -1)) cs.duration_warn_ms = (float)lua_tonumber(L, -1);
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, -1, "error_ms");
|
||||
if (lua_isnumber(L, -1)) cs.duration_error_ms = (float)lua_tonumber(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
// Icon map
|
||||
lua_getfield(L, -1, "icon_map");
|
||||
if (lua_istable(L, -1)) {
|
||||
int ni = (int)lua_rawlen(L, -1);
|
||||
for (int j = 1; j <= ni; ++j) {
|
||||
lua_rawgeti(L, -1, j);
|
||||
if (lua_istable(L, -1)) {
|
||||
data_table::IconMapEntry ie;
|
||||
lua_getfield(L, -1, "value");
|
||||
if (lua_isstring(L, -1)) ie.value = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, -1, "icon");
|
||||
if (lua_isstring(L, -1)) ie.icon_name = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, -1, "color");
|
||||
if (lua_isstring(L, -1)) ie.color_hex = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
cs.icon_map.push_back(std::move(ie));
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1); // icon_map
|
||||
|
||||
// Button
|
||||
lua_getfield(L, -1, "button_action");
|
||||
if (lua_isstring(L, -1)) cs.button_action = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, -1, "button_label");
|
||||
if (lua_isstring(L, -1)) cs.button_label = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, -1, "button_color");
|
||||
if (lua_isstring(L, -1)) cs.button_color_hex = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
// Tooltip
|
||||
lua_getfield(L, -1, "tooltip");
|
||||
if (lua_isstring(L, -1)) cs.tooltip = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
lua_getfield(L, -1, "tooltip_on_hover");
|
||||
if (lua_isboolean(L, -1)) cs.tooltip_on_hover = (bool)lua_toboolean(L, -1);
|
||||
lua_pop(L, 1);
|
||||
|
||||
specs.push_back(std::move(cs));
|
||||
lua_pop(L, 1); // spec entry
|
||||
}
|
||||
if (!specs.empty()) {
|
||||
state.aux_column_specs.push_back(std::move(specs));
|
||||
}
|
||||
}
|
||||
lua_pop(L, 1); // column_specs
|
||||
|
||||
lua_pop(L, 1); // root
|
||||
lua_close(L);
|
||||
|
||||
res.ok = true;
|
||||
return res;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// apply (extended overload) — playground-compatible bool-returning wrapper.
|
||||
// ---------------------------------------------------------------------------
|
||||
bool apply(const std::string& lua_text,
|
||||
data_table::State& st,
|
||||
const std::vector<std::string>& orig_headers,
|
||||
const std::vector<data_table::ColumnType>& /*orig_types*/,
|
||||
const char* const* /*cells*/,
|
||||
int /*rows*/,
|
||||
int /*orig_cols*/,
|
||||
std::string* err)
|
||||
{
|
||||
ApplyResult res = apply(lua_text, orig_headers);
|
||||
if (res.ok) {
|
||||
st = std::move(res.state);
|
||||
} else {
|
||||
if (err) *err = res.error;
|
||||
}
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
} // namespace tql
|
||||
@@ -0,0 +1,546 @@
|
||||
// tql_apply_test.cpp — Tests for tql_apply (TQL Lua parser).
|
||||
// Run with: cmake --build cpp/build --target tql_apply_test && cpp/build/tql_apply_test
|
||||
// Or via: ./fn run tql_apply_cpp_core
|
||||
|
||||
#include "core/tql_apply.h"
|
||||
#include "core/tql_emit.h"
|
||||
#include "core/tql_helpers.h"
|
||||
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace data_table;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal assertions
|
||||
// ---------------------------------------------------------------------------
|
||||
static int g_pass = 0, g_fail = 0;
|
||||
|
||||
static void check(bool cond, const char* label) {
|
||||
if (cond) {
|
||||
std::printf("PASS: %s\n", label);
|
||||
++g_pass;
|
||||
} else {
|
||||
std::printf("FAIL: %s\n", label);
|
||||
++g_fail;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: parse minimal TQL chunk
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_parse_minimal() {
|
||||
const char* tql_text = R"(
|
||||
return {
|
||||
version = 1,
|
||||
display = "table",
|
||||
stages = { {} },
|
||||
columns = {},
|
||||
views = {},
|
||||
visualization_settings = {},
|
||||
}
|
||||
)";
|
||||
auto res = tql::apply(tql_text, {});
|
||||
|
||||
check(res.ok, "parse minimal: ok=true");
|
||||
check(res.error.empty(), "parse minimal: no error");
|
||||
check(res.state.display == ViewMode::Table, "parse minimal: display=Table");
|
||||
check(!res.state.stages.empty(), "parse minimal: stages not empty");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: parse display=bar
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_parse_display() {
|
||||
const char* tql_text = R"(
|
||||
return {
|
||||
version = 1,
|
||||
display = "bar",
|
||||
stages = { {} },
|
||||
columns = {},
|
||||
views = {},
|
||||
visualization_settings = {},
|
||||
}
|
||||
)";
|
||||
auto res = tql::apply(tql_text, {});
|
||||
check(res.ok, "parse display: ok");
|
||||
check(res.state.display == ViewMode::Bar, "parse display: display=Bar");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: filter parsing with known header
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_parse_filter_known_col() {
|
||||
const char* tql_text = R"(
|
||||
return {
|
||||
version = 1,
|
||||
display = "table",
|
||||
stages = {
|
||||
{
|
||||
filter = {
|
||||
{"=", "name", "Alice"},
|
||||
},
|
||||
},
|
||||
},
|
||||
columns = {},
|
||||
views = {},
|
||||
visualization_settings = {},
|
||||
}
|
||||
)";
|
||||
std::vector<std::string> headers = {"name", "age"};
|
||||
auto res = tql::apply(tql_text, headers);
|
||||
|
||||
check(res.ok, "filter known col: ok");
|
||||
check(res.warnings.empty(), "filter known col: no warnings");
|
||||
check(!res.state.stages.empty(), "filter known col: has stages");
|
||||
check(res.state.stages[0].filters.size() == 1, "filter known col: 1 filter");
|
||||
check(res.state.stages[0].filters[0].col == 0, "filter known col: col=0");
|
||||
check(res.state.stages[0].filters[0].op == Op::Eq,"filter known col: op=Eq");
|
||||
check(res.state.stages[0].filters[0].value == "Alice", "filter known col: value=Alice");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: unknown column generates warning
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_parse_filter_unknown_col() {
|
||||
const char* tql_text = R"(
|
||||
return {
|
||||
version = 1,
|
||||
display = "table",
|
||||
stages = {
|
||||
{
|
||||
filter = {
|
||||
{"=", "nonexistent_col", "x"},
|
||||
},
|
||||
},
|
||||
},
|
||||
columns = {},
|
||||
views = {},
|
||||
visualization_settings = {},
|
||||
}
|
||||
)";
|
||||
std::vector<std::string> headers = {"name", "age"};
|
||||
auto res = tql::apply(tql_text, headers);
|
||||
|
||||
check(res.ok, "unknown col: ok (warning not error)");
|
||||
check(!res.warnings.empty(), "unknown col: warning generated");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: sort parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_parse_sort() {
|
||||
const char* tql_text = R"(
|
||||
return {
|
||||
version = 1,
|
||||
display = "table",
|
||||
stages = {
|
||||
{
|
||||
sort = {
|
||||
{"desc", "age"},
|
||||
},
|
||||
},
|
||||
},
|
||||
columns = {},
|
||||
views = {},
|
||||
visualization_settings = {},
|
||||
}
|
||||
)";
|
||||
auto res = tql::apply(tql_text, {});
|
||||
|
||||
check(res.ok, "sort: ok");
|
||||
check(res.state.stages[0].sorts.size() == 1, "sort: 1 sort clause");
|
||||
check(res.state.stages[0].sorts[0].col == "age", "sort: col=age");
|
||||
check(res.state.stages[0].sorts[0].desc == true, "sort: desc=true");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: aggregation stage parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_parse_aggregation() {
|
||||
const char* tql_text = R"(
|
||||
return {
|
||||
version = 1,
|
||||
display = "table",
|
||||
stages = {
|
||||
{},
|
||||
{
|
||||
breakout = {"dept"},
|
||||
aggregation = {
|
||||
{"sum", "salary"},
|
||||
{"count"},
|
||||
},
|
||||
sort = {
|
||||
{"asc", "dept"},
|
||||
},
|
||||
},
|
||||
},
|
||||
columns = {},
|
||||
views = {},
|
||||
visualization_settings = {},
|
||||
}
|
||||
)";
|
||||
auto res = tql::apply(tql_text, {});
|
||||
|
||||
check(res.ok, "aggregation: ok");
|
||||
check(res.state.stages.size() == 2, "aggregation: 2 stages");
|
||||
const Stage& s1 = res.state.stages[1];
|
||||
check(s1.breakouts.size() == 1, "aggregation: 1 breakout");
|
||||
check(s1.breakouts[0] == "dept", "aggregation: breakout=dept");
|
||||
check(s1.aggregations.size() == 2, "aggregation: 2 aggs");
|
||||
check(s1.aggregations[0].fn == AggFn::Sum, "aggregation: [0] fn=Sum");
|
||||
check(s1.aggregations[0].col == "salary", "aggregation: [0] col=salary");
|
||||
check(s1.aggregations[1].fn == AggFn::Count, "aggregation: [1] fn=Count");
|
||||
check(s1.sorts.size() == 1, "aggregation: 1 sort");
|
||||
check(s1.sorts[0].col == "dept", "aggregation: sort col=dept");
|
||||
check(s1.sorts[0].desc == false, "aggregation: sort asc");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: expression (formula) stored verbatim, lua_id=-1
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_parse_expressions() {
|
||||
const char* tql_text = R"(
|
||||
return {
|
||||
version = 1,
|
||||
display = "table",
|
||||
stages = {
|
||||
{
|
||||
expressions = {
|
||||
["total"] = "return [price] * [qty]",
|
||||
},
|
||||
},
|
||||
},
|
||||
columns = {},
|
||||
views = {},
|
||||
visualization_settings = {},
|
||||
}
|
||||
)";
|
||||
auto res = tql::apply(tql_text, {});
|
||||
|
||||
check(res.ok, "expr: ok");
|
||||
check(res.state.stages[0].derived.size() == 1, "expr: 1 derived col");
|
||||
check(res.state.stages[0].derived[0].name == "total", "expr: name=total");
|
||||
check(res.state.stages[0].derived[0].formula == "return [price] * [qty]",
|
||||
"expr: formula stored verbatim");
|
||||
check(res.state.stages[0].derived[0].lua_id == -1, "expr: lua_id=-1 (not compiled)");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: views parsing (extra viz panel)
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_parse_views() {
|
||||
const char* tql_text = R"(
|
||||
return {
|
||||
version = 1,
|
||||
display = "table",
|
||||
stages = { {} },
|
||||
columns = {},
|
||||
views = {
|
||||
{display = "bar", x_col = "month", y_cols = {"revenue", "cost"}},
|
||||
{display = "line", x_col = "date"},
|
||||
},
|
||||
visualization_settings = {},
|
||||
}
|
||||
)";
|
||||
auto res = tql::apply(tql_text, {});
|
||||
|
||||
check(res.ok, "views: ok");
|
||||
// Panel 0 sets state.display + state.viz_config.
|
||||
check(res.state.display == ViewMode::Bar, "views: display=Bar (from panel 0)");
|
||||
check(res.state.viz_config.x_col == "month", "views: x_col=month");
|
||||
check(res.state.viz_config.y_cols.size() == 2, "views: y_cols has 2 entries");
|
||||
check(res.state.viz_config.y_cols[0] == "revenue", "views: y_cols[0]=revenue");
|
||||
// Panel 1 goes into extra_panels.
|
||||
check(res.state.extra_panels.size() == 1, "views: 1 extra panel");
|
||||
check(res.state.extra_panels[0].display == ViewMode::Line, "views: extra=Line");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: join parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_parse_join() {
|
||||
const char* tql_text = R"(
|
||||
return {
|
||||
version = 1,
|
||||
display = "table",
|
||||
joins = {
|
||||
{alias = "d", source = "departments", strategy = "inner",
|
||||
on = {{"dept_id", "id"}}, fields = {"dept_name"}},
|
||||
},
|
||||
stages = { {} },
|
||||
columns = {},
|
||||
views = {},
|
||||
visualization_settings = {},
|
||||
}
|
||||
)";
|
||||
auto res = tql::apply(tql_text, {});
|
||||
|
||||
check(res.ok, "join: ok");
|
||||
check(res.state.joins.size() == 1, "join: 1 join");
|
||||
check(res.state.joins[0].alias == "d", "join: alias=d");
|
||||
check(res.state.joins[0].source == "departments", "join: source=departments");
|
||||
check(res.state.joins[0].strategy == JoinStrategy::Inner, "join: strategy=Inner");
|
||||
check(res.state.joins[0].on.size() == 1, "join: 1 on clause");
|
||||
check(res.state.joins[0].on[0].first == "dept_id", "join: on left=dept_id");
|
||||
check(res.state.joins[0].on[0].second == "id", "join: on right=id");
|
||||
check(res.state.joins[0].fields.size() == 1, "join: 1 field");
|
||||
check(res.state.joins[0].fields[0] == "dept_name", "join: field=dept_name");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: version mismatch returns error
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_parse_version_mismatch() {
|
||||
const char* tql_text = R"(
|
||||
return { version = 99, display = "table", stages = {{}}, columns = {}, views = {} }
|
||||
)";
|
||||
auto res = tql::apply(tql_text, {});
|
||||
|
||||
check(!res.ok, "version mismatch: ok=false");
|
||||
check(!res.error.empty(), "version mismatch: error set");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: invalid Lua syntax returns error
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_parse_invalid_lua() {
|
||||
const char* bad = "this is not lua %%%";
|
||||
auto res = tql::apply(bad, {});
|
||||
|
||||
check(!res.ok, "invalid lua: ok=false");
|
||||
check(!res.error.empty(), "invalid lua: error set");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: roundtrip emit -> apply -> emit produces same display/stages
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_roundtrip() {
|
||||
// Build a state with a filter and a grouped stage.
|
||||
State st;
|
||||
|
||||
Stage s0;
|
||||
s0.filters.push_back({3, Op::Neq, "inactive"}); // status is index 3
|
||||
s0.sorts.push_back({"name", false});
|
||||
st.stages.push_back(s0);
|
||||
|
||||
Stage s1;
|
||||
s1.breakouts.push_back("dept");
|
||||
Aggregation a;
|
||||
a.fn = AggFn::Avg;
|
||||
a.col = "salary";
|
||||
s1.aggregations.push_back(a);
|
||||
st.stages.push_back(s1);
|
||||
|
||||
st.display = ViewMode::Bar;
|
||||
st.viz_config.x_col = "dept";
|
||||
st.viz_config.y_cols = {"avg_salary"};
|
||||
|
||||
std::vector<std::string> headers = {"name", "dept", "salary", "status"};
|
||||
std::vector<ColumnType> types = {
|
||||
ColumnType::String, ColumnType::String, ColumnType::Float, ColumnType::String
|
||||
};
|
||||
|
||||
// Emit.
|
||||
std::string tql_text = tql::emit(st, headers, types);
|
||||
check(!tql_text.empty(), "roundtrip: emit produced text");
|
||||
|
||||
// Apply with the same headers.
|
||||
auto res = tql::apply(tql_text, headers);
|
||||
check(res.ok, "roundtrip: apply ok");
|
||||
check(res.warnings.empty(), "roundtrip: no warnings");
|
||||
|
||||
const State& st2 = res.state;
|
||||
|
||||
check(st2.display == ViewMode::Bar, "roundtrip: display=Bar");
|
||||
check(st2.stages.size() == 2, "roundtrip: 2 stages");
|
||||
|
||||
// Stage 0 filter.
|
||||
check(st2.stages[0].filters.size() == 1, "roundtrip: s0 1 filter");
|
||||
check(st2.stages[0].filters[0].col == 3, "roundtrip: s0 filter col=3 (status)");
|
||||
check(st2.stages[0].filters[0].op == Op::Neq, "roundtrip: s0 filter op=Neq");
|
||||
check(st2.stages[0].filters[0].value == "inactive", "roundtrip: s0 filter value=inactive");
|
||||
|
||||
// Stage 0 sort.
|
||||
check(st2.stages[0].sorts.size() == 1, "roundtrip: s0 1 sort");
|
||||
check(st2.stages[0].sorts[0].col == "name", "roundtrip: s0 sort col=name");
|
||||
check(st2.stages[0].sorts[0].desc == false, "roundtrip: s0 sort asc");
|
||||
|
||||
// Stage 1 breakout.
|
||||
check(st2.stages[1].breakouts.size() == 1, "roundtrip: s1 breakout");
|
||||
check(st2.stages[1].breakouts[0] == "dept", "roundtrip: s1 breakout=dept");
|
||||
|
||||
// Stage 1 aggregation.
|
||||
check(st2.stages[1].aggregations.size() == 1, "roundtrip: s1 1 agg");
|
||||
check(st2.stages[1].aggregations[0].fn == AggFn::Avg, "roundtrip: s1 agg fn=Avg");
|
||||
check(st2.stages[1].aggregations[0].col == "salary", "roundtrip: s1 agg col=salary");
|
||||
|
||||
// viz_config.
|
||||
check(st2.viz_config.x_col == "dept", "roundtrip: viz x_col=dept");
|
||||
check(st2.viz_config.y_cols.size() == 1, "roundtrip: viz 1 y_col");
|
||||
check(st2.viz_config.y_cols[0] == "avg_salary", "roundtrip: viz y_col=avg_salary");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: column visibility and order preserved through roundtrip
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_roundtrip_col_order() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{});
|
||||
st.col_visible = {true, false, true};
|
||||
st.col_order = {2, 0, 1}; // custom order
|
||||
|
||||
std::vector<std::string> headers = {"a", "b", "c"};
|
||||
std::vector<ColumnType> types = {ColumnType::String, ColumnType::Int, ColumnType::Float};
|
||||
|
||||
std::string tql_text = tql::emit(st, headers, types);
|
||||
auto res = tql::apply(tql_text, headers);
|
||||
|
||||
check(res.ok, "col order roundtrip: ok");
|
||||
check(res.state.col_visible.size() == 3, "col order roundtrip: 3 visible entries");
|
||||
check(res.state.col_visible[1] == false, "col order roundtrip: col b invisible");
|
||||
// col_order after roundtrip should match the original (sorted by order value).
|
||||
check(res.state.col_order.size() == 3, "col order roundtrip: 3 order entries");
|
||||
check(res.state.col_order[0] == 2, "col order roundtrip: first=c (idx 2)");
|
||||
check(res.state.col_order[1] == 0, "col order roundtrip: second=a (idx 0)");
|
||||
check(res.state.col_order[2] == 1, "col order roundtrip: third=b (idx 1)");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: color rule roundtrip
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_roundtrip_color_rules() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{});
|
||||
ColorRule cr{0, "Alice", 0xFF2244FFu};
|
||||
st.color_rules.push_back(cr);
|
||||
|
||||
std::vector<std::string> headers = {"name"};
|
||||
std::vector<ColumnType> types = {ColumnType::String};
|
||||
|
||||
std::string tql_text = tql::emit(st, headers, types);
|
||||
auto res = tql::apply(tql_text, headers);
|
||||
|
||||
check(res.ok, "color rule roundtrip: ok");
|
||||
check(res.state.color_rules.size() == 1, "color rule roundtrip: 1 rule");
|
||||
check(res.state.color_rules[0].col == 0, "color rule roundtrip: col=0");
|
||||
check(res.state.color_rules[0].equals == "Alice", "color rule roundtrip: equals=Alice");
|
||||
check(res.state.color_rules[0].color == cr.color, "color rule roundtrip: color matches");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: column_specs roundtrip — Badge
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_roundtrip_column_specs_badge() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{});
|
||||
|
||||
ColumnSpec cs;
|
||||
cs.id = "status";
|
||||
cs.renderer = CellRenderer::Badge;
|
||||
cs.badges = { BadgeRule{"ok", "#22c55e", "OK"}, BadgeRule{"error", "#ef4444", ""} };
|
||||
st.aux_column_specs.push_back({cs});
|
||||
|
||||
std::vector<std::string> headers = {"status"};
|
||||
std::vector<ColumnType> types = {ColumnType::String};
|
||||
|
||||
std::string tql_text = tql::emit(st, headers, types);
|
||||
auto res = tql::apply(tql_text, headers);
|
||||
|
||||
check(res.ok, "cs badge roundtrip: ok");
|
||||
check(!res.state.aux_column_specs.empty(), "cs badge roundtrip: aux_column_specs non-empty");
|
||||
check(!res.state.aux_column_specs[0].empty(), "cs badge roundtrip: specs[0] non-empty");
|
||||
check(res.state.aux_column_specs[0][0].renderer == CellRenderer::Badge,
|
||||
"cs badge roundtrip: renderer=Badge");
|
||||
check(res.state.aux_column_specs[0][0].id == "status", "cs badge roundtrip: id=status");
|
||||
check(res.state.aux_column_specs[0][0].badges.size() == 2, "cs badge roundtrip: 2 badge rules");
|
||||
check(res.state.aux_column_specs[0][0].badges[0].value == "ok", "cs badge roundtrip: badge[0].value=ok");
|
||||
check(res.state.aux_column_specs[0][0].badges[0].label == "OK", "cs badge roundtrip: badge[0].label=OK");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: column_specs roundtrip — Button
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_roundtrip_column_specs_button() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{});
|
||||
|
||||
ColumnSpec cs;
|
||||
cs.id = "actions";
|
||||
cs.renderer = CellRenderer::Button;
|
||||
cs.button_action = "cancel";
|
||||
cs.button_label = "Cancel";
|
||||
cs.button_color_hex = "#ef4444";
|
||||
st.aux_column_specs.push_back({cs});
|
||||
|
||||
std::vector<std::string> headers = {"name", "actions"};
|
||||
std::vector<ColumnType> types = {ColumnType::String, ColumnType::String};
|
||||
|
||||
std::string tql_text = tql::emit(st, headers, types);
|
||||
auto res = tql::apply(tql_text, headers);
|
||||
|
||||
check(res.ok, "cs button roundtrip: ok");
|
||||
check(!res.state.aux_column_specs.empty() &&
|
||||
!res.state.aux_column_specs[0].empty(), "cs button roundtrip: specs present");
|
||||
const auto& cs2 = res.state.aux_column_specs[0][0];
|
||||
check(cs2.renderer == CellRenderer::Button, "cs button roundtrip: renderer=Button");
|
||||
check(cs2.button_action == "cancel", "cs button roundtrip: button_action=cancel");
|
||||
check(cs2.button_label == "Cancel", "cs button roundtrip: button_label=Cancel");
|
||||
check(cs2.button_color_hex == "#ef4444", "cs button roundtrip: button_color");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: column_specs roundtrip — Tooltip
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_roundtrip_column_specs_tooltip() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{});
|
||||
|
||||
ColumnSpec cs;
|
||||
cs.id = "name";
|
||||
cs.renderer = CellRenderer::Text;
|
||||
cs.tooltip = "auto";
|
||||
cs.tooltip_on_hover = true;
|
||||
st.aux_column_specs.push_back({cs});
|
||||
|
||||
std::vector<std::string> headers = {"name"};
|
||||
std::vector<ColumnType> types = {ColumnType::String};
|
||||
|
||||
std::string tql_text = tql::emit(st, headers, types);
|
||||
auto res = tql::apply(tql_text, headers);
|
||||
|
||||
check(res.ok, "cs tooltip roundtrip: ok");
|
||||
check(!res.state.aux_column_specs.empty() &&
|
||||
!res.state.aux_column_specs[0].empty(), "cs tooltip roundtrip: specs present");
|
||||
const auto& cs2 = res.state.aux_column_specs[0][0];
|
||||
check(cs2.tooltip == "auto", "cs tooltip roundtrip: tooltip=auto");
|
||||
check(cs2.tooltip_on_hover == true, "cs tooltip roundtrip: tooltip_on_hover=true");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// main
|
||||
// ---------------------------------------------------------------------------
|
||||
int main() {
|
||||
test_parse_minimal();
|
||||
test_parse_display();
|
||||
test_parse_filter_known_col();
|
||||
test_parse_filter_unknown_col();
|
||||
test_parse_sort();
|
||||
test_parse_aggregation();
|
||||
test_parse_expressions();
|
||||
test_parse_views();
|
||||
test_parse_join();
|
||||
test_parse_version_mismatch();
|
||||
test_parse_invalid_lua();
|
||||
test_roundtrip();
|
||||
test_roundtrip_col_order();
|
||||
test_roundtrip_color_rules();
|
||||
test_roundtrip_column_specs_badge();
|
||||
test_roundtrip_column_specs_button();
|
||||
test_roundtrip_column_specs_tooltip();
|
||||
|
||||
std::printf("---\nResults: %d passed, %d failed\n", g_pass, g_fail);
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
// tql_emit.cpp — Pure TQL serialization. See tql_emit.h.
|
||||
// Promoted from primitives_gallery/playground/tables/tql.cpp (emit()).
|
||||
|
||||
#include "core/tql_emit.h"
|
||||
#include "core/tql_helpers.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace tql {
|
||||
|
||||
using namespace data_table;
|
||||
|
||||
std::string emit(const State& state,
|
||||
const std::vector<std::string>& headers,
|
||||
const std::vector<ColumnType>& types)
|
||||
{
|
||||
int orig_cols = (int)headers.size();
|
||||
const Stage& raw = state.stages.empty() ? Stage{} : state.stages[0];
|
||||
int eff_cols = orig_cols + (int)raw.derived.size();
|
||||
|
||||
// Build effective headers + types (same indexing as col_visible/order).
|
||||
std::vector<std::string> eff_headers(eff_cols);
|
||||
std::vector<ColumnType> eff_types(eff_cols);
|
||||
for (int c = 0; c < orig_cols; ++c) {
|
||||
eff_headers[c] = headers[c];
|
||||
eff_types[c] = (c < (int)types.size()) ? types[c] : ColumnType::Auto;
|
||||
}
|
||||
for (int k = 0; k < (int)raw.derived.size(); ++k) {
|
||||
eff_headers[orig_cols + k] = raw.derived[k].name;
|
||||
eff_types[orig_cols + k] = raw.derived[k].type;
|
||||
}
|
||||
|
||||
// Build order positions: col_idx -> visual order (1-based).
|
||||
std::unordered_map<int, int> order_pos;
|
||||
for (size_t i = 0; i < state.col_order.size(); ++i)
|
||||
order_pos[(int)state.col_order[i]] = (int)i + 1;
|
||||
|
||||
// ---- Lambda helpers ----
|
||||
auto emit_filter_block = [&](const std::vector<Filter>& filters,
|
||||
const std::vector<std::string>& stage_headers,
|
||||
const char* indent) -> std::string {
|
||||
if (filters.empty()) return {};
|
||||
std::string s;
|
||||
s += indent; s += "filter = {\n";
|
||||
for (const auto& f : filters) {
|
||||
std::string col_name = (f.col >= 0 && f.col < (int)stage_headers.size())
|
||||
? stage_headers[f.col] : "";
|
||||
s += indent; s += " {";
|
||||
s += lua_string_literal(op_label(f.op));
|
||||
s += ", ";
|
||||
s += lua_string_literal(col_name);
|
||||
s += ", ";
|
||||
s += lua_string_literal(f.value);
|
||||
s += "},\n";
|
||||
}
|
||||
s += indent; s += "},\n";
|
||||
return s;
|
||||
};
|
||||
|
||||
auto emit_sort_block = [&](const std::vector<SortClause>& sorts,
|
||||
const char* indent) -> std::string {
|
||||
if (sorts.empty()) return {};
|
||||
std::string s;
|
||||
s += indent; s += "sort = {\n";
|
||||
for (const auto& sc : sorts) {
|
||||
s += indent; s += " {";
|
||||
s += lua_string_literal(sc.desc ? "desc" : "asc");
|
||||
s += ", ";
|
||||
s += lua_string_literal(sc.col);
|
||||
s += "},\n";
|
||||
}
|
||||
s += indent; s += "},\n";
|
||||
return s;
|
||||
};
|
||||
|
||||
auto emit_view = [&](const VizPanel& p) -> std::string {
|
||||
std::string s = " {";
|
||||
s += "display = " + lua_string_literal(view_mode_token(p.display));
|
||||
if (!p.config.x_col.empty())
|
||||
s += ", x_col = " + lua_string_literal(p.config.x_col);
|
||||
if (!p.config.cat_col.empty())
|
||||
s += ", cat_col = " + lua_string_literal(p.config.cat_col);
|
||||
if (!p.config.size_col.empty())
|
||||
s += ", size_col = "+ lua_string_literal(p.config.size_col);
|
||||
if (!p.config.y_cols.empty()) {
|
||||
s += ", y_cols = {";
|
||||
for (size_t i = 0; i < p.config.y_cols.size(); ++i) {
|
||||
if (i) s += ", ";
|
||||
s += lua_string_literal(p.config.y_cols[i]);
|
||||
}
|
||||
s += "}";
|
||||
}
|
||||
if (p.config.primary_color != 0)
|
||||
s += ", color = " + lua_string_literal(color_to_hex(p.config.primary_color));
|
||||
if (p.config.hist_bins > 0)
|
||||
s += ", hist_bins = " + std::to_string(p.config.hist_bins);
|
||||
if (p.config.pie_radius > 0.0f)
|
||||
s += ", pie_radius = " + std::to_string(p.config.pie_radius);
|
||||
if (!p.config.show_legend) s += ", show_legend = false";
|
||||
if (p.config.show_markers) s += ", show_markers = true";
|
||||
if (p.config.locked) s += ", locked = true";
|
||||
s += "},\n";
|
||||
return s;
|
||||
};
|
||||
|
||||
// ---- Build output ----
|
||||
std::string out;
|
||||
out += "-- TQL v1 (Table Query Language). Round-trip de State <-> Lua.\n";
|
||||
out += "-- Schema:\n";
|
||||
out += "-- version = 1 -- bump si breaking change\n";
|
||||
out += "-- display = \"table\" -- table|bar|line|pie (futuro)\n";
|
||||
out += "-- stages = { stage0, stage1, ... } -- pipeline; stage 0 = Raw\n";
|
||||
out += "-- columns = { {name,type,visible,order,color_rules}, ... }\n";
|
||||
out += "--\n";
|
||||
out += "-- Stage 0 (Raw): filter + expressions + sort\n";
|
||||
out += "-- Stage N (Grouped): filter + breakout + aggregation + sort\n";
|
||||
out += "--\n";
|
||||
out += "-- filter: {{op, col, val}, ...} op in =,!=,>,>=,<,<=,contains,!contains,starts,ends\n";
|
||||
out += "-- expressions: {[name] = \"lua_body\"} ej: [\"total\"] = \"return [a] + [b]\"\n";
|
||||
out += "-- breakout: {\"col1\", \"col2\"} group by\n";
|
||||
out += "-- aggregation: {{fn, col, arg?}, ...} fn in count,sum,avg,min,max,distinct,stddev,median,p25,p75,p90,p99,percentile\n";
|
||||
out += "-- sort: {{dir, col}, ...} dir in asc,desc\n";
|
||||
out += "return {\n";
|
||||
out += " version = 1,\n";
|
||||
out += " display = ";
|
||||
out += lua_string_literal(view_mode_token(state.display));
|
||||
out += ",\n";
|
||||
if (!state.main_source.empty()) {
|
||||
out += " main_source = ";
|
||||
out += lua_string_literal(state.main_source);
|
||||
out += ",\n";
|
||||
}
|
||||
|
||||
// joins
|
||||
if (!state.joins.empty()) {
|
||||
out += " joins = {\n";
|
||||
for (const auto& jn : state.joins) {
|
||||
out += " {alias = " + lua_string_literal(jn.alias);
|
||||
out += ", source = " + lua_string_literal(jn.source);
|
||||
out += ", strategy = " + lua_string_literal(join_strategy_token(jn.strategy));
|
||||
out += ", on = {";
|
||||
for (size_t i = 0; i < jn.on.size(); ++i) {
|
||||
if (i) out += ", ";
|
||||
out += "{" + lua_string_literal(jn.on[i].first) + ", "
|
||||
+ lua_string_literal(jn.on[i].second) + "}";
|
||||
}
|
||||
out += "}";
|
||||
if (!jn.fields.empty()) {
|
||||
out += ", fields = {";
|
||||
for (size_t i = 0; i < jn.fields.size(); ++i) {
|
||||
if (i) out += ", ";
|
||||
out += lua_string_literal(jn.fields[i]);
|
||||
}
|
||||
out += "}";
|
||||
}
|
||||
out += "},\n";
|
||||
}
|
||||
out += " },\n";
|
||||
}
|
||||
|
||||
out += " stages = {\n";
|
||||
|
||||
// Cur_headers tracks the output headers of each stage for filter/sort col name resolution.
|
||||
std::vector<std::string> cur_headers = headers;
|
||||
|
||||
for (int si = 0; si < (int)state.stages.size(); ++si) {
|
||||
const Stage& stg = state.stages[si];
|
||||
out += " {\n";
|
||||
|
||||
if (si == 0) {
|
||||
// Stage 0 (Raw): filter + expressions + sort.
|
||||
std::vector<std::string> s0_headers = headers;
|
||||
out += emit_filter_block(stg.filters, s0_headers, " ");
|
||||
|
||||
if (!stg.derived.empty()) {
|
||||
bool any_formula = false;
|
||||
for (const auto& d : stg.derived) if (!d.formula.empty()) { any_formula = true; break; }
|
||||
if (any_formula) {
|
||||
out += " expressions = {\n";
|
||||
for (const auto& d : stg.derived) {
|
||||
if (d.formula.empty()) continue;
|
||||
out += " [";
|
||||
out += lua_string_literal(d.name);
|
||||
out += "] = ";
|
||||
out += lua_string_literal(d.formula);
|
||||
out += ",\n";
|
||||
}
|
||||
out += " },\n";
|
||||
}
|
||||
}
|
||||
|
||||
out += emit_sort_block(stg.sorts, " ");
|
||||
|
||||
// Advance cur_headers: orig + derived.
|
||||
for (const auto& d : stg.derived) cur_headers.push_back(d.name);
|
||||
|
||||
} else {
|
||||
// Stage 1+ (Grouped): filter + breakout + aggregation + sort.
|
||||
out += emit_filter_block(stg.filters, cur_headers, " ");
|
||||
|
||||
if (!stg.breakouts.empty()) {
|
||||
out += " breakout = {";
|
||||
for (size_t i = 0; i < stg.breakouts.size(); ++i) {
|
||||
if (i > 0) out += ", ";
|
||||
out += lua_string_literal(stg.breakouts[i]);
|
||||
}
|
||||
out += "},\n";
|
||||
}
|
||||
|
||||
if (!stg.aggregations.empty()) {
|
||||
out += " aggregation = {\n";
|
||||
for (const auto& a : stg.aggregations) {
|
||||
out += " {";
|
||||
out += lua_string_literal(agg_fn_token(a.fn));
|
||||
if (a.fn != AggFn::Count) {
|
||||
out += ", ";
|
||||
out += lua_string_literal(a.col);
|
||||
}
|
||||
if (a.fn == AggFn::Percentile) {
|
||||
char buf[32];
|
||||
std::snprintf(buf, sizeof(buf), "%g", a.arg);
|
||||
out += ", "; out += buf;
|
||||
}
|
||||
out += "},\n";
|
||||
}
|
||||
out += " },\n";
|
||||
}
|
||||
|
||||
out += emit_sort_block(stg.sorts, " ");
|
||||
|
||||
// Advance cur_headers: breakouts + agg aliases.
|
||||
std::vector<std::string> next;
|
||||
for (const auto& b : stg.breakouts) next.push_back(b);
|
||||
for (const auto& a : stg.aggregations) next.push_back(aggregation_alias(a));
|
||||
cur_headers = std::move(next);
|
||||
}
|
||||
|
||||
out += " },\n";
|
||||
}
|
||||
out += " },\n";
|
||||
|
||||
// columns — per-col render config (effective cols of stage 0).
|
||||
out += " columns = {\n";
|
||||
for (int c = 0; c < eff_cols; ++c) {
|
||||
out += " {";
|
||||
out += "name = " + lua_string_literal(eff_headers[c]);
|
||||
out += ", type = " + lua_string_literal(column_type_name(eff_types[c]));
|
||||
bool vis = (c < (int)state.col_visible.size()) ? state.col_visible[c] : true;
|
||||
out += std::string(", visible = ") + (vis ? "true" : "false");
|
||||
int order = order_pos.count(c) ? order_pos.at(c) : c + 1;
|
||||
out += ", order = " + std::to_string(order);
|
||||
// color_rules for this col
|
||||
bool first = true;
|
||||
for (const auto& cr : state.color_rules) {
|
||||
if (cr.col != c) continue;
|
||||
if (first) { out += ", color_rules = {"; first = false; }
|
||||
else { out += ", "; }
|
||||
out += "{equals = " + lua_string_literal(cr.equals);
|
||||
out += ", color = " + lua_string_literal(color_to_hex(cr.color)) + "}";
|
||||
}
|
||||
if (!first) out += "}";
|
||||
out += "},\n";
|
||||
}
|
||||
out += " },\n";
|
||||
|
||||
// views — main viz panel (index 0) + extra_panels.
|
||||
out += " views = {\n";
|
||||
VizPanel main_p;
|
||||
main_p.display = state.display;
|
||||
main_p.config = state.viz_config;
|
||||
out += emit_view(main_p);
|
||||
for (const auto& p : state.extra_panels) out += emit_view(p);
|
||||
out += " },\n";
|
||||
|
||||
out += " visualization_settings = {},\n";
|
||||
|
||||
// column_specs (Phase 2, v1.2.0): TQL-persisted declarative renderer specs.
|
||||
// Only emitted when state.aux_column_specs is non-empty.
|
||||
// Format: column_specs = { { id="col_id", renderer="badge|progress|...", ... }, ... }
|
||||
if (!state.aux_column_specs.empty() && !state.aux_column_specs[0].empty()) {
|
||||
const auto& specs = state.aux_column_specs[0];
|
||||
// Emit the block only if at least one spec has a non-default renderer OR tooltip.
|
||||
bool any_renderable = false;
|
||||
for (const auto& cs : specs) {
|
||||
if (cs.renderer != data_table::CellRenderer::Text || cs.tooltip_on_hover) {
|
||||
any_renderable = true; break;
|
||||
}
|
||||
}
|
||||
if (any_renderable) {
|
||||
out += " column_specs = {\n";
|
||||
for (const auto& cs : specs) {
|
||||
if (cs.renderer == data_table::CellRenderer::Text && !cs.tooltip_on_hover)
|
||||
continue; // skip pure-text cols without extras
|
||||
out += " { id = " + lua_string_literal(cs.id);
|
||||
// renderer
|
||||
const char* rname = "text";
|
||||
switch (cs.renderer) {
|
||||
case data_table::CellRenderer::Badge: rname = "badge"; break;
|
||||
case data_table::CellRenderer::Progress: rname = "progress"; break;
|
||||
case data_table::CellRenderer::Duration: rname = "duration"; break;
|
||||
case data_table::CellRenderer::Icon: rname = "icon"; break;
|
||||
case data_table::CellRenderer::Button: rname = "button"; break;
|
||||
default: break;
|
||||
}
|
||||
out += ", renderer = " + lua_string_literal(rname);
|
||||
// Badge rules
|
||||
if (!cs.badges.empty()) {
|
||||
out += ", badges = {\n";
|
||||
for (const auto& br : cs.badges) {
|
||||
out += " { value = " + lua_string_literal(br.value);
|
||||
out += ", color = " + lua_string_literal(br.color_hex);
|
||||
if (!br.label.empty())
|
||||
out += ", label = " + lua_string_literal(br.label);
|
||||
out += " },\n";
|
||||
}
|
||||
out += " }";
|
||||
}
|
||||
// Progress
|
||||
if (cs.renderer == data_table::CellRenderer::Progress) {
|
||||
if (cs.progress_scale_100)
|
||||
out += ", progress_scale_100 = true";
|
||||
if (!cs.progress_color_hex.empty())
|
||||
out += ", progress_color = " + lua_string_literal(cs.progress_color_hex);
|
||||
}
|
||||
// Duration
|
||||
if (cs.renderer == data_table::CellRenderer::Duration) {
|
||||
char buf[64];
|
||||
std::snprintf(buf, sizeof(buf), "%g", (double)cs.duration_warn_ms);
|
||||
out += std::string(", warn_ms = ") + buf;
|
||||
std::snprintf(buf, sizeof(buf), "%g", (double)cs.duration_error_ms);
|
||||
out += std::string(", error_ms = ") + buf;
|
||||
}
|
||||
// Icon map
|
||||
if (!cs.icon_map.empty()) {
|
||||
out += ", icon_map = {\n";
|
||||
for (const auto& ie : cs.icon_map) {
|
||||
out += " { value = " + lua_string_literal(ie.value);
|
||||
out += ", icon = " + lua_string_literal(ie.icon_name);
|
||||
if (!ie.color_hex.empty())
|
||||
out += ", color = " + lua_string_literal(ie.color_hex);
|
||||
out += " },\n";
|
||||
}
|
||||
out += " }";
|
||||
}
|
||||
// Button
|
||||
if (cs.renderer == data_table::CellRenderer::Button) {
|
||||
if (!cs.button_action.empty())
|
||||
out += ", button_action = " + lua_string_literal(cs.button_action);
|
||||
if (!cs.button_label.empty())
|
||||
out += ", button_label = " + lua_string_literal(cs.button_label);
|
||||
if (!cs.button_color_hex.empty())
|
||||
out += ", button_color = " + lua_string_literal(cs.button_color_hex);
|
||||
}
|
||||
// Tooltip
|
||||
if (cs.tooltip_on_hover) {
|
||||
out += ", tooltip = " + lua_string_literal(cs.tooltip.empty() ? "auto" : cs.tooltip);
|
||||
out += ", tooltip_on_hover = true";
|
||||
}
|
||||
out += " },\n";
|
||||
}
|
||||
out += " },\n";
|
||||
}
|
||||
}
|
||||
|
||||
out += "}\n";
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace tql
|
||||
@@ -0,0 +1,329 @@
|
||||
// tql_emit_test.cpp — Tests for tql_emit (pure TQL serialization).
|
||||
// Run with: cmake --build cpp/build --target tql_emit_test && cpp/build/tql_emit_test
|
||||
// Or via: ./fn run tql_emit_cpp_core
|
||||
|
||||
#include "core/tql_emit.h"
|
||||
#include "core/tql_helpers.h"
|
||||
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace data_table;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal assertions
|
||||
// ---------------------------------------------------------------------------
|
||||
static int g_pass = 0, g_fail = 0;
|
||||
|
||||
static void check(bool cond, const char* label) {
|
||||
if (cond) {
|
||||
std::printf("PASS: %s\n", label);
|
||||
++g_pass;
|
||||
} else {
|
||||
std::printf("FAIL: %s\n", label);
|
||||
++g_fail;
|
||||
}
|
||||
}
|
||||
|
||||
static bool contains(const std::string& haystack, const char* needle) {
|
||||
return haystack.find(needle) != std::string::npos;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: emit empty state produces valid header
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_emit_empty_state() {
|
||||
State st;
|
||||
std::vector<std::string> headers;
|
||||
std::vector<ColumnType> types;
|
||||
std::string out = tql::emit(st, headers, types);
|
||||
|
||||
check(contains(out, "version = 1"), "emit empty state: version=1");
|
||||
check(contains(out, "display = \"table\""), "emit empty state: display=table");
|
||||
check(contains(out, "stages = {"), "emit empty state: stages block");
|
||||
check(contains(out, "columns = {"), "emit empty state: columns block");
|
||||
check(contains(out, "views = {"), "emit empty state: views block");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: emit state with one column
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_emit_single_column() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{});
|
||||
st.col_visible = {true};
|
||||
st.col_order = {0};
|
||||
|
||||
std::vector<std::string> headers = {"name"};
|
||||
std::vector<ColumnType> types = {ColumnType::String};
|
||||
|
||||
std::string out = tql::emit(st, headers, types);
|
||||
|
||||
check(contains(out, "name = \"name\""), "emit single col: name field");
|
||||
check(contains(out, "type = \"string\""), "emit single col: type=string");
|
||||
check(contains(out, "visible = true"), "emit single col: visible=true");
|
||||
check(contains(out, "order = 1"), "emit single col: order=1");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: emit state with filter in stage 0
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_emit_filter() {
|
||||
State st;
|
||||
Stage s0;
|
||||
s0.filters.push_back({0, Op::Eq, "Alice"});
|
||||
st.stages.push_back(s0);
|
||||
|
||||
std::vector<std::string> headers = {"name", "age"};
|
||||
std::vector<ColumnType> types = {ColumnType::String, ColumnType::Int};
|
||||
|
||||
std::string out = tql::emit(st, headers, types);
|
||||
|
||||
check(contains(out, "filter = {"), "emit filter: filter block");
|
||||
check(contains(out, "\"=\""), "emit filter: op=");
|
||||
check(contains(out, "\"name\""), "emit filter: col name");
|
||||
check(contains(out, "\"Alice\""), "emit filter: value");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: emit state with sort
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_emit_sort() {
|
||||
State st;
|
||||
Stage s0;
|
||||
s0.sorts.push_back({"age", true}); // desc
|
||||
st.stages.push_back(s0);
|
||||
|
||||
std::vector<std::string> headers = {"name", "age"};
|
||||
std::vector<ColumnType> types = {ColumnType::String, ColumnType::Int};
|
||||
|
||||
std::string out = tql::emit(st, headers, types);
|
||||
|
||||
check(contains(out, "sort = {"), "emit sort: sort block");
|
||||
check(contains(out, "\"desc\""), "emit sort: direction desc");
|
||||
check(contains(out, "\"age\""), "emit sort: col name age");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: emit stage 1 with breakout + aggregation
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_emit_grouped_stage() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{}); // stage 0 empty
|
||||
|
||||
Stage s1;
|
||||
s1.breakouts.push_back("dept");
|
||||
Aggregation a;
|
||||
a.fn = AggFn::Sum;
|
||||
a.col = "salary";
|
||||
s1.aggregations.push_back(a);
|
||||
st.stages.push_back(s1);
|
||||
|
||||
std::vector<std::string> headers = {"dept", "salary"};
|
||||
std::vector<ColumnType> types = {ColumnType::String, ColumnType::Float};
|
||||
|
||||
std::string out = tql::emit(st, headers, types);
|
||||
|
||||
check(contains(out, "breakout = {"), "emit grouped: breakout block");
|
||||
check(contains(out, "\"dept\""), "emit grouped: breakout col");
|
||||
check(contains(out, "aggregation = {"), "emit grouped: aggregation block");
|
||||
check(contains(out, "\"sum\""), "emit grouped: agg fn sum");
|
||||
check(contains(out, "\"salary\""), "emit grouped: agg col salary");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: emit with color rule
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_emit_color_rule() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{});
|
||||
ColorRule cr;
|
||||
cr.col = 0;
|
||||
cr.equals = "Alice";
|
||||
cr.color = 0xFF0000FF; // red, full alpha: ABGR -> r=0xFF g=0x00 b=0x00 a=0xFF
|
||||
st.color_rules.push_back(cr);
|
||||
|
||||
std::vector<std::string> headers = {"name"};
|
||||
std::vector<ColumnType> types = {ColumnType::String};
|
||||
|
||||
std::string out = tql::emit(st, headers, types);
|
||||
|
||||
check(contains(out, "color_rules = {"), "emit color rule: block");
|
||||
check(contains(out, "equals = \"Alice\""), "emit color rule: equals");
|
||||
check(contains(out, "color = \"#"), "emit color rule: hex color");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: emit with viz panel (extra panel)
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_emit_viz_panel() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{});
|
||||
st.display = ViewMode::Bar;
|
||||
st.viz_config.x_col = "month";
|
||||
st.viz_config.y_cols = {"revenue", "cost"};
|
||||
|
||||
std::vector<std::string> headers = {"month", "revenue", "cost"};
|
||||
std::vector<ColumnType> types = {ColumnType::String, ColumnType::Float, ColumnType::Float};
|
||||
|
||||
std::string out = tql::emit(st, headers, types);
|
||||
|
||||
check(contains(out, "display = \"bar\""), "emit viz panel: display=bar");
|
||||
check(contains(out, "x_col = \"month\""), "emit viz panel: x_col");
|
||||
check(contains(out, "y_cols = {"), "emit viz panel: y_cols block");
|
||||
check(contains(out, "\"revenue\""), "emit viz panel: y_col revenue");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: emit join
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_emit_join() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{});
|
||||
Join jn;
|
||||
jn.alias = "dept_table";
|
||||
jn.source = "departments";
|
||||
jn.strategy = JoinStrategy::Left;
|
||||
jn.on.push_back({"dept_id", "id"});
|
||||
st.joins.push_back(jn);
|
||||
|
||||
std::vector<std::string> headers = {"dept_id", "name"};
|
||||
std::vector<ColumnType> types = {ColumnType::Int, ColumnType::String};
|
||||
|
||||
std::string out = tql::emit(st, headers, types);
|
||||
|
||||
check(contains(out, "joins = {"), "emit join: joins block");
|
||||
check(contains(out, "alias = \"dept_table\""), "emit join: alias");
|
||||
check(contains(out, "source = \"departments\""), "emit join: source");
|
||||
check(contains(out, "strategy = \"left\""), "emit join: strategy left");
|
||||
check(contains(out, "\"dept_id\""), "emit join: on left col");
|
||||
check(contains(out, "\"id\""), "emit join: on right col");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: lua_string_literal escaping
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_lua_string_literal() {
|
||||
check(lua_string_literal("hello") == "\"hello\"", "literal: plain string");
|
||||
check(lua_string_literal("a\"b") == "\"a\\\"b\"", "literal: embedded quote");
|
||||
check(lua_string_literal("a\\b") == "\"a\\\\b\"", "literal: backslash");
|
||||
check(lua_string_literal("a\nb") == "\"a\\nb\"", "literal: newline");
|
||||
check(lua_string_literal("") == "\"\"", "literal: empty");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: color_to_hex / hex_to_color roundtrip
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_color_roundtrip() {
|
||||
// Opaque red in ABGR (0xAA BB GG RR): r=0xFF g=0x00 b=0x00 a=0xFF
|
||||
unsigned int c = 0xFF0000FFu;
|
||||
std::string hex = color_to_hex(c);
|
||||
unsigned int back = hex_to_color(hex);
|
||||
check(back == c, "color roundtrip: opaque red");
|
||||
|
||||
// Semi-transparent green
|
||||
unsigned int c2 = (0x80u << 24) | (0xFFu << 8); // a=0x80 g=0xFF
|
||||
std::string hex2 = color_to_hex(c2);
|
||||
unsigned int back2 = hex_to_color(hex2);
|
||||
check(back2 == c2, "color roundtrip: semi-transparent green");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: emit with Badge column_spec in aux_column_specs
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_emit_column_specs_badge() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{});
|
||||
|
||||
// Populate aux_column_specs[0] with a Badge spec.
|
||||
ColumnSpec cs;
|
||||
cs.id = "status";
|
||||
cs.renderer = CellRenderer::Badge;
|
||||
cs.badges = { BadgeRule{"ok", "#22c55e", "OK"}, BadgeRule{"error", "#ef4444", ""} };
|
||||
st.aux_column_specs.push_back({cs});
|
||||
|
||||
std::vector<std::string> headers = {"status", "value"};
|
||||
std::vector<ColumnType> types = {ColumnType::String, ColumnType::Float};
|
||||
|
||||
std::string out = tql::emit(st, headers, types);
|
||||
|
||||
check(contains(out, "column_specs = {"), "emit column_specs badge: block");
|
||||
check(contains(out, "renderer = \"badge\""), "emit column_specs badge: renderer");
|
||||
check(contains(out, "id = \"status\""), "emit column_specs badge: id");
|
||||
check(contains(out, "\"#22c55e\""), "emit column_specs badge: color ok");
|
||||
check(contains(out, "\"error\""), "emit column_specs badge: value error");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: emit with Button column_spec in aux_column_specs
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_emit_column_specs_button() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{});
|
||||
|
||||
ColumnSpec cs;
|
||||
cs.id = "actions";
|
||||
cs.renderer = CellRenderer::Button;
|
||||
cs.button_action = "cancel";
|
||||
cs.button_label = "Cancel";
|
||||
cs.button_color_hex = "#ef4444";
|
||||
st.aux_column_specs.push_back({cs});
|
||||
|
||||
std::vector<std::string> headers = {"name", "actions"};
|
||||
std::vector<ColumnType> types = {ColumnType::String, ColumnType::String};
|
||||
|
||||
std::string out = tql::emit(st, headers, types);
|
||||
|
||||
check(contains(out, "renderer = \"button\""), "emit column_specs button: renderer");
|
||||
check(contains(out, "button_action = \"cancel\""), "emit column_specs button: action");
|
||||
check(contains(out, "button_label = \"Cancel\""), "emit column_specs button: label");
|
||||
check(contains(out, "button_color = \"#ef4444\""), "emit column_specs button: color");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: emit with Tooltip column_spec in aux_column_specs
|
||||
// ---------------------------------------------------------------------------
|
||||
static void test_emit_column_specs_tooltip() {
|
||||
State st;
|
||||
st.stages.push_back(Stage{});
|
||||
|
||||
ColumnSpec cs;
|
||||
cs.id = "name";
|
||||
cs.renderer = CellRenderer::Text;
|
||||
cs.tooltip = "auto";
|
||||
cs.tooltip_on_hover = true;
|
||||
st.aux_column_specs.push_back({cs});
|
||||
|
||||
std::vector<std::string> headers = {"name"};
|
||||
std::vector<ColumnType> types = {ColumnType::String};
|
||||
|
||||
std::string out = tql::emit(st, headers, types);
|
||||
|
||||
check(contains(out, "tooltip = \"auto\""), "emit column_specs tooltip: auto");
|
||||
check(contains(out, "tooltip_on_hover = true"), "emit column_specs tooltip: on_hover");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// main
|
||||
// ---------------------------------------------------------------------------
|
||||
int main() {
|
||||
test_emit_empty_state();
|
||||
test_emit_single_column();
|
||||
test_emit_filter();
|
||||
test_emit_sort();
|
||||
test_emit_grouped_stage();
|
||||
test_emit_color_rule();
|
||||
test_emit_viz_panel();
|
||||
test_emit_join();
|
||||
test_lua_string_literal();
|
||||
test_color_roundtrip();
|
||||
test_emit_column_specs_badge();
|
||||
test_emit_column_specs_button();
|
||||
test_emit_column_specs_tooltip();
|
||||
|
||||
std::printf("---\nResults: %d passed, %d failed\n", g_pass, g_fail);
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
Reference in New Issue
Block a user