merge: issue/0032 — sql_workbench

# Conflicts:
#	cpp/apps/primitives_gallery/CMakeLists.txt
#	cpp/apps/primitives_gallery/demos.h
#	cpp/apps/primitives_gallery/main.cpp
This commit is contained in:
2026-04-25 21:55:17 +02:00
8 changed files with 708 additions and 0 deletions
+400
View File
@@ -0,0 +1,400 @@
#include "core/sql_workbench.h"
#include "core/text_editor.h"
#include "core/tokens.h"
#include "core/button.h"
#include "viz/table_view.h"
#include "imgui.h"
#include <sqlite3.h>
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
namespace fn {
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
namespace {
std::string ltrim(const std::string& s) {
size_t i = 0;
while (i < s.size() && std::isspace(static_cast<unsigned char>(s[i]))) ++i;
return s.substr(i);
}
std::string upper_first_token(const std::string& sql) {
std::string t = ltrim(sql);
size_t end = 0;
while (end < t.size() && !std::isspace(static_cast<unsigned char>(t[end]))) ++end;
std::string tok = t.substr(0, end);
for (auto& c : tok) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
return tok;
}
bool is_readonly_stmt(const std::string& sql) {
const std::string head = upper_first_token(sql);
return head == "SELECT" || head == "PRAGMA" || head == "EXPLAIN" || head == "WITH";
}
TextEditorState* editor_of(SqlWorkbenchState& state) {
return reinterpret_cast<TextEditorState*>(state._editor);
}
void ensure_editor(SqlWorkbenchState& state) {
if (!state._editor) {
state._editor = text_editor_create(CodeLang::SQL);
text_editor_set_text(editor_of(state), state.query.c_str());
state._editor_synced = true;
}
}
} // namespace
// ─────────────────────────────────────────────────────────────────────────────
// Backend
// ─────────────────────────────────────────────────────────────────────────────
bool sql_workbench_run_query(sqlite3* db, const char* sql, SqlWorkbenchState& state) {
state.columns.clear();
state.rows.clear();
state.last_error.clear();
state.truncated = false;
state.last_ms = 0.0;
if (!db) {
state.last_error = "no database handle";
return false;
}
if (!sql || !*sql) {
state.last_error = "empty query";
return false;
}
if (state.readonly && !is_readonly_stmt(sql)) {
state.last_error = "readonly mode: only SELECT/PRAGMA/EXPLAIN/WITH allowed";
return false;
}
auto t0 = std::chrono::steady_clock::now();
sqlite3_stmt* stmt = nullptr;
int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr);
if (rc != SQLITE_OK) {
state.last_error = sqlite3_errmsg(db);
if (stmt) sqlite3_finalize(stmt);
return false;
}
int ncols = sqlite3_column_count(stmt);
state.columns.reserve(static_cast<size_t>(ncols));
for (int c = 0; c < ncols; ++c) {
const char* name = sqlite3_column_name(stmt, c);
state.columns.emplace_back(name ? name : "");
}
int row_cap = state.truncated_at > 0 ? state.truncated_at : 10000;
while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) {
if (static_cast<int>(state.rows.size()) >= row_cap) {
state.truncated = true;
break;
}
std::vector<std::string> row;
row.reserve(static_cast<size_t>(ncols));
for (int c = 0; c < ncols; ++c) {
int t = sqlite3_column_type(stmt, c);
if (t == SQLITE_NULL) {
row.emplace_back("NULL");
} else {
const unsigned char* txt = sqlite3_column_text(stmt, c);
row.emplace_back(txt ? reinterpret_cast<const char*>(txt) : "");
}
}
state.rows.emplace_back(std::move(row));
}
bool ok = (rc == SQLITE_ROW || rc == SQLITE_DONE);
if (!ok) {
state.last_error = sqlite3_errmsg(db);
}
sqlite3_finalize(stmt);
auto t1 = std::chrono::steady_clock::now();
state.last_ms = std::chrono::duration<double, std::milli>(t1 - t0).count();
return ok;
}
void sql_workbench_load_schema(sqlite3* db, SqlWorkbenchState& state) {
state._schema_tables.clear();
state._schema_views.clear();
state._schema_loaded = true;
if (!db) return;
const char* sql =
"SELECT name, type FROM sqlite_master "
"WHERE type IN ('table','view') "
" AND name NOT LIKE 'sqlite_%' "
"ORDER BY type, name;";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) != SQLITE_OK) return;
while (sqlite3_step(stmt) == SQLITE_ROW) {
const unsigned char* name = sqlite3_column_text(stmt, 0);
const unsigned char* type = sqlite3_column_text(stmt, 1);
if (!name || !type) continue;
std::string n = reinterpret_cast<const char*>(name);
std::string t = reinterpret_cast<const char*>(type);
if (t == "table") state._schema_tables.push_back(n);
else if (t == "view") state._schema_views.push_back(n);
}
sqlite3_finalize(stmt);
}
void sql_workbench_destroy(SqlWorkbenchState& state) {
if (state._editor) {
text_editor_destroy(editor_of(state));
state._editor = nullptr;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// UI
// ─────────────────────────────────────────────────────────────────────────────
namespace {
void run_current_query(sqlite3* db, SqlWorkbenchState& state) {
// Sincroniza desde el editor antes de ejecutar.
if (state._editor) {
const char* txt = text_editor_get_text(editor_of(state));
if (txt) state.query = txt;
}
bool ok = sql_workbench_run_query(db, state.query.c_str(), state);
if (ok) {
// Solo apear al historial si no esta duplicada con la ultima.
if (state.history.empty() || state.history.back() != state.query) {
state.history.push_back(state.query);
if (state.history.size() > 200) {
state.history.erase(state.history.begin(),
state.history.begin() + (state.history.size() - 200));
}
}
}
}
void set_query(SqlWorkbenchState& state, const std::string& q) {
state.query = q;
if (state._editor) text_editor_set_text(editor_of(state), q.c_str());
}
void draw_schema_sidebar(SqlWorkbenchState& state) {
using namespace fn_tokens;
ImGui::PushStyleColor(ImGuiCol_Text, colors::text_dim);
ImGui::TextUnformatted("SCHEMA");
ImGui::PopStyleColor();
ImGui::Separator();
if (!state._schema_loaded) {
ImGui::PushStyleColor(ImGuiCol_Text, colors::text_muted);
ImGui::TextUnformatted("(no schema)");
ImGui::PopStyleColor();
return;
}
auto draw_section = [&](const char* label, const std::vector<std::string>& items) {
if (items.empty()) return;
ImGui::PushStyleColor(ImGuiCol_Text, colors::text_muted);
ImGui::TextUnformatted(label);
ImGui::PopStyleColor();
for (const auto& name : items) {
char buf[256];
std::snprintf(buf, sizeof(buf), "%s##sw_t_%s", name.c_str(), name.c_str());
if (ImGui::Selectable(buf, false)) {
char q[512];
std::snprintf(q, sizeof(q), "SELECT * FROM %s LIMIT 100;", name.c_str());
set_query(state, q);
}
}
ImGui::Dummy(ImVec2(0, spacing::xs));
};
draw_section("tables", state._schema_tables);
draw_section("views", state._schema_views);
}
void draw_history_popup(SqlWorkbenchState& state) {
if (!state._show_history) return;
ImGui::OpenPopup("##sw_history");
ImGui::SetNextWindowSize(ImVec2(640, 420), ImGuiCond_Appearing);
if (ImGui::BeginPopupModal("##sw_history", &state._show_history,
ImGuiWindowFlags_NoSavedSettings)) {
ImGui::TextUnformatted("Query history (last 50)");
ImGui::Separator();
int total = static_cast<int>(state.history.size());
int start = std::max(0, total - 50);
for (int i = total - 1; i >= start; --i) {
char buf[64];
std::snprintf(buf, sizeof(buf), "##sw_h_%d", i);
const std::string& q = state.history[i];
// Mostrar primera linea como label.
std::string preview = q;
auto nl = preview.find('\n');
if (nl != std::string::npos) preview = preview.substr(0, nl) + " ...";
if (preview.size() > 120) preview = preview.substr(0, 117) + "...";
ImGui::PushID(i);
if (ImGui::Selectable(preview.c_str(), false)) {
set_query(state, q);
state._show_history = false;
ImGui::CloseCurrentPopup();
}
ImGui::PopID();
}
ImGui::EndPopup();
}
}
void draw_results_table(const SqlWorkbenchState& state) {
if (state.columns.empty()) {
using namespace fn_tokens;
ImGui::PushStyleColor(ImGuiCol_Text, colors::text_muted);
ImGui::TextUnformatted("(no results — run a query)");
ImGui::PopStyleColor();
return;
}
int ncols = static_cast<int>(state.columns.size());
int nrows = static_cast<int>(state.rows.size());
// Headers como const char*.
std::vector<const char*> headers;
headers.reserve(state.columns.size());
for (const auto& c : state.columns) headers.push_back(c.c_str());
// Cells flat row-major.
std::vector<const char*> cells;
cells.reserve(static_cast<size_t>(nrows) * static_cast<size_t>(ncols));
for (const auto& row : state.rows) {
for (int c = 0; c < ncols; ++c) {
if (c < static_cast<int>(row.size())) cells.push_back(row[c].c_str());
else cells.push_back("");
}
}
table_view("##sw_results", headers.data(), ncols,
cells.empty() ? nullptr : cells.data(), nrows);
}
} // namespace
void sql_workbench(const char* id, sqlite3* db, SqlWorkbenchState& state, ImVec2 size) {
using namespace fn_tokens;
ensure_editor(state);
if (db && !state._schema_loaded) {
sql_workbench_load_schema(db, state);
}
ImGui::PushID(id);
ImGui::BeginChild("##sw_root", size, ImGuiChildFlags_Borders);
if (!db) {
ImGui::PushStyleColor(ImGuiCol_Text, colors::warning);
ImGui::TextUnformatted("sql_workbench: no database handle");
ImGui::PopStyleColor();
ImGui::EndChild();
ImGui::PopID();
return;
}
// Layout: sidebar (200px) | content (editor + tabla)
if (ImGui::BeginTable("##sw_layout", 2,
ImGuiTableFlags_Resizable | ImGuiTableFlags_SizingFixedFit |
ImGuiTableFlags_BordersInnerV)) {
ImGui::TableSetupColumn("schema", ImGuiTableColumnFlags_WidthFixed, 200.0f);
ImGui::TableSetupColumn("content", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableNextRow();
// ── Sidebar ──
ImGui::TableSetColumnIndex(0);
ImGui::BeginChild("##sw_schema", ImVec2(0, 0));
draw_schema_sidebar(state);
ImGui::EndChild();
// ── Content ──
ImGui::TableSetColumnIndex(1);
ImGui::BeginChild("##sw_content", ImVec2(0, 0));
// Toolbar superior.
bool run_clicked = fn_ui::button("Run", fn_ui::ButtonVariant::Primary);
ImGui::SameLine();
if (fn_ui::button("History")) state._show_history = true;
ImGui::SameLine();
if (state.readonly) {
ImGui::PushStyleColor(ImGuiCol_Text, colors::warning);
ImGui::TextUnformatted("READONLY");
ImGui::PopStyleColor();
ImGui::SameLine();
}
ImGui::PushStyleColor(ImGuiCol_Text, colors::text_dim);
ImGui::TextUnformatted("(Ctrl+Enter)");
ImGui::PopStyleColor();
// Editor SQL.
ImVec2 avail = ImGui::GetContentRegionAvail();
float editor_h = avail.y * 0.40f;
if (editor_h < 120.0f) editor_h = 120.0f;
text_editor_render(editor_of(state), "##sw_editor", ImVec2(-1, editor_h));
// Atajo Ctrl+Enter (cuando el child tiene foco/hover).
bool shortcut = ImGui::IsWindowFocused(ImGuiFocusedFlags_ChildWindows) &&
ImGui::IsKeyDown(ImGuiKey_LeftCtrl) &&
ImGui::IsKeyPressed(ImGuiKey_Enter, false);
if (run_clicked || shortcut) {
run_current_query(db, state);
}
// Status bar.
if (!state.last_error.empty()) {
ImGui::PushStyleColor(ImGuiCol_Text, colors::error);
ImGui::TextWrapped("error: %s", state.last_error.c_str());
ImGui::PopStyleColor();
} else if (!state.columns.empty()) {
ImGui::PushStyleColor(ImGuiCol_Text, colors::text_dim);
ImGui::Text("%d rows%s in %.2f ms",
static_cast<int>(state.rows.size()),
state.truncated ? " (truncated)" : "",
state.last_ms);
ImGui::PopStyleColor();
}
// Tabla resultado.
draw_results_table(state);
ImGui::EndChild();
ImGui::EndTable();
}
draw_history_popup(state);
ImGui::EndChild();
ImGui::PopID();
}
} // namespace fn
+62
View File
@@ -0,0 +1,62 @@
#pragma once
// sql_workbench — componente ImGui que combina text_editor (SQL) + table_view
// + sidebar de schema + historial. Ejecuta queries contra una sqlite3*
// proporcionada por el caller (no se cierra desde el componente).
//
// Uso tipico:
// sqlite3* db = nullptr;
// sqlite3_open_v2("registry.db", &db, SQLITE_OPEN_READONLY, nullptr);
// fn::SqlWorkbenchState st;
// st.readonly = true;
// ...
// fn::sql_workbench("##sql", db, st, ImVec2(-1, -1));
//
// Backend (`sql_workbench_run_query`) es testeable por separado contra una DB
// en memoria.
#include "imgui.h"
#include <string>
#include <vector>
struct sqlite3;
namespace fn {
struct SqlWorkbenchState {
std::string query = "SELECT name FROM sqlite_master WHERE type='table';";
std::string last_error;
std::vector<std::string> columns;
std::vector<std::vector<std::string>> rows;
std::vector<std::string> history;
bool readonly = false;
int truncated_at = 10000;
bool truncated = false;
double last_ms = 0.0;
// Internals (PIMPL-lite — el caller no necesita tocar esto).
void* _editor = nullptr; // fn::TextEditorState*
bool _editor_synced = false;
bool _show_history = false;
std::vector<std::string> _schema_tables;
std::vector<std::string> _schema_views;
bool _schema_loaded = false;
};
// Ejecuta `sql` contra `db` y rellena state.columns/rows/last_error/last_ms.
// Devuelve true si la query se ejecuto sin error. Aplica readonly check.
// Cap de filas a state.truncated_at (state.truncated indica si se trunco).
bool sql_workbench_run_query(sqlite3* db, const char* sql, SqlWorkbenchState& state);
// Lee el schema de la DB (sqlite_master) y rellena state._schema_tables/_views.
void sql_workbench_load_schema(sqlite3* db, SqlWorkbenchState& state);
// Renderiza el componente. La DB debe existir mientras se renderiza. Si
// db == nullptr, dibuja un empty state.
void sql_workbench(const char* id, sqlite3* db, SqlWorkbenchState& state, ImVec2 size = {-1, -1});
// Libera el editor interno (llamar al cerrar la app si la state es global).
void sql_workbench_destroy(SqlWorkbenchState& state);
} // namespace fn
+108
View File
@@ -0,0 +1,108 @@
---
name: sql_workbench
kind: component
lang: cpp
domain: core
version: "1.0.0"
purity: impure
signature: "void fn::sql_workbench(const char* id, sqlite3* db, fn::SqlWorkbenchState& state, ImVec2 size); bool fn::sql_workbench_run_query(sqlite3*, const char*, fn::SqlWorkbenchState&); void fn::sql_workbench_load_schema(sqlite3*, fn::SqlWorkbenchState&); void fn::sql_workbench_destroy(fn::SqlWorkbenchState&)"
description: "Workbench SQL embebido en ImGui: editor con highlighting (text_editor + CodeLang::SQL), tabla de resultados (table_view), sidebar de schema (sqlite_master) e historial. Ejecuta queries contra una sqlite3* del caller (no abre/cierra la DB)."
tags: [imgui, sql, sqlite, editor, table, dashboard, registry, debug]
uses_functions: [text_editor_cpp_core, table_view_cpp_viz, button_cpp_core, tokens_cpp_core]
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: [imgui, sqlite3]
tested: false
tests: []
test_file_path: ""
file_path: "cpp/functions/core/sql_workbench.cpp"
framework: imgui
params:
- name: id
desc: "ImGui id estable para el contenedor (debe ser unico en el frame)"
- name: db
desc: "sqlite3* del caller. NO se cierra desde el componente. Si es nullptr, se renderiza un empty state."
- name: state
desc: "Estado mutable: query, columnas, filas, historial, last_error, readonly, truncated_at, last_ms"
- name: size
desc: "Tamano del contenedor (ImVec2). {-1,-1} = ocupar todo el espacio disponible"
output: "Renderiza el workbench dentro de un BeginChild. Modifica state.columns/rows/last_error/last_ms al ejecutar queries y state.history al apilar queries exitosas. sql_workbench_run_query devuelve true en caso de exito."
---
# sql_workbench
Componente C++/ImGui para explorar bases SQLite desde una app del registry sin alt-tab al CLI ni a DBeaver.
## Capacidades
- **Editor SQL**: `text_editor` con `CodeLang::SQL` (syntax highlighting via ImGuiColorTextEdit).
- **Run + atajo**: boton `Run` y atajo `Ctrl+Enter` cuando el editor tiene foco.
- **Schema sidebar**: lista `tables` y `views` desde `sqlite_master`. Click → `SELECT * FROM <name> LIMIT 100;`.
- **Resultados**: tabla via `table_view_cpp_viz` (sortable/scrollable/resizable).
- **Historial**: cada query exitosa se apila en `state.history`. Boton `History` abre popup con las ultimas 50.
- **Status bar**: `N rows in X.XX ms` o mensaje de error en rojo.
- **Cap de filas**: `state.truncated_at` (default 10000); marca `state.truncated = true` y muestra "(truncated)".
- **Readonly opt-in**: si `state.readonly = true`, rechaza statements que no comiencen (case-insensitive trim) por `SELECT`, `PRAGMA`, `EXPLAIN` o `WITH`.
## API
```cpp
namespace fn {
struct SqlWorkbenchState {
std::string query = "SELECT name FROM sqlite_master WHERE type='table';";
std::string last_error;
std::vector<std::string> columns;
std::vector<std::vector<std::string>> rows;
std::vector<std::string> history;
bool readonly = false;
int truncated_at = 10000;
bool truncated = false;
double last_ms = 0.0;
// ...internals (editor handle, schema cache, popup flag).
};
bool sql_workbench_run_query(sqlite3* db, const char* sql, SqlWorkbenchState&);
void sql_workbench_load_schema(sqlite3* db, SqlWorkbenchState&);
void sql_workbench(const char* id, sqlite3* db, SqlWorkbenchState&, ImVec2 size = {-1, -1});
void sql_workbench_destroy(SqlWorkbenchState&);
}
```
## Ejemplo
```cpp
sqlite3* db = nullptr;
sqlite3_open_v2("registry.db", &db, SQLITE_OPEN_READONLY, nullptr);
fn::SqlWorkbenchState st;
st.readonly = true;
fn::run_app({.title="sql"}, [&]{
fn::sql_workbench("##sql", db, st, ImVec2(-1, -1));
});
fn::sql_workbench_destroy(st);
sqlite3_close(db);
```
## Decisiones de diseno
- **Caller-owned DB**: el componente recibe `sqlite3*` y NO la cierra. Permite reusar la conexion ya abierta por la app host (ej: `registry_dashboard` con `registry.db`, app con `operations.db`).
- **Rows como strings**: `sqlite3_column_text` ya da convertibles. NULL se renderiza como literal `NULL` para distinguirlo del string vacio.
- **Backend separado de UI**: `sql_workbench_run_query` es testeable contra una DB en memoria (`:memory:`) sin frame ImGui.
- **Editor lazy**: el `TextEditorState` se crea en el primer render. `sql_workbench_destroy` lo libera.
- **Schema cache**: `sql_workbench_load_schema` se llama una vez al abrir; el caller puede forzar reload poniendo `state._schema_loaded = false`.
- **Sin sorting interno**: `table_view` muestra los indicadores pero NO reordena. Para ordenar, el usuario reescribe el `ORDER BY` en SQL — coherente con la filosofia de un workbench.
- **Sin queries async**: ejecucion sincrona en main thread. Para queries lentas, considerar wrapper con `process_runner` (deja la primitiva minima).
## Riesgos / limites
- Queries que cuelgan UI: cap de 10k filas + ejecucion sincrona. Documentado.
- Memoria: cada celda es un `std::string` propio (sin `string_view` sobre `sqlite3_column_text`, que es invalidado por `sqlite3_step`).
- Historial: cap suave a 200 entries en memoria; popup muestra las ultimas 50.
## Combinaciones sugeridas
`sql_workbench` + `app_menubar` + `dashboard_panel` = panel de exploracion en cualquier app del registry. Demo en `apps/primitives_gallery` apuntando a `registry.db` readonly.