feat(core): sql_workbench component (issue 0032)
ImGui SQL workbench: editor (text_editor + CodeLang::SQL), schema sidebar desde sqlite_master, tabla resultado (table_view), historial. Backend sql_workbench_run_query separado de UI; readonly opt-in (rechaza non-SELECT/ PRAGMA/EXPLAIN/WITH); cap de filas (truncated_at, default 10000); last_ms medido con steady_clock. API: sql_workbench(id, sqlite3*, SqlWorkbenchState&, size). DB caller-owned. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user