merge: issue/0010-tablenode-duckdb — DuckDB foundation + render colapsado
This commit is contained in:
+3
-1
@@ -22,6 +22,7 @@ add_imgui_app(graph_explorer
|
||||
layout_store.cpp
|
||||
entity_ops.cpp
|
||||
project_manager.cpp
|
||||
tableview.cpp
|
||||
# --- viz ---
|
||||
${FN_CPP_ROOT_DIR}/functions/viz/graph_renderer.cpp
|
||||
${FN_CPP_ROOT_DIR}/functions/viz/graph_force_layout.cpp
|
||||
@@ -54,7 +55,8 @@ target_include_directories(graph_explorer PRIVATE
|
||||
${FN_CPP_ROOT_DIR}/functions
|
||||
)
|
||||
|
||||
target_link_libraries(graph_explorer PRIVATE SQLite::SQLite3)
|
||||
target_link_libraries(graph_explorer PRIVATE SQLite::SQLite3 DuckDB::DuckDB)
|
||||
duckdb_copy_runtime(graph_explorer)
|
||||
|
||||
# OpenGL: graph_renderer + graph_force_layout_gpu llaman gl* directamente.
|
||||
# fn::run_app inicializa el loader cuando AppConfig::init_gl_loader = true.
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
---
|
||||
id: 0010
|
||||
title: Nodo tabla — contenedor cuadrado con filas que son nodos del grafo
|
||||
status: pending
|
||||
priority: medium
|
||||
created: 2026-04-30
|
||||
depends_on: [0004, 0005, 0008]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Un tipo especial de nodo, **Table**, que se renderiza en el viewport como
|
||||
un rectangulo (no circulo) y agrupa visualmente N entidades del grafo. Cada
|
||||
fila de la tabla = un nodo real del grafo (con su `type_ref`, sus fields,
|
||||
sus tags). Las filas se pueden **extraer** (salen al canvas como nodos
|
||||
sueltos) y **meter** (un nodo suelto entra como fila).
|
||||
|
||||
Distinto del issue 0004 (vista tabla global por tipo): ese es una **ventana
|
||||
auxiliar** que tabula entidades existentes; este es un **nodo en el grafo**
|
||||
que existe en `entities` y posee filas via relaciones.
|
||||
|
||||
## Modelo de datos
|
||||
|
||||
- El nodo tabla es una entidad normal con `type_ref = 'Table'` y metadata:
|
||||
```json
|
||||
{
|
||||
"row_type": "Person", // tipo esperado de las filas (puede ser vacio = mixto)
|
||||
"columns": ["name","age","email"], // subset de fields del row_type a mostrar como columnas
|
||||
"expanded": true // estado de UI persistido
|
||||
}
|
||||
```
|
||||
- La pertenencia se modela con relaciones:
|
||||
- `name = "CONTAINS_ROW"`, `from_entity = <table_id>`, `to_entity = <row_entity_id>`, `order = N`.
|
||||
- Una fila puede pertenecer a varias tablas (varias relaciones `CONTAINS_ROW` apuntando al mismo nodo). Confirmado en la conversacion.
|
||||
- Las columnas pueden ser fijas (`row_type` definido → columnas = subset de los `fields` de ese tipo) o libres (definidas por el creador de la tabla en `columns`).
|
||||
|
||||
## Render en viewport
|
||||
|
||||
- Forma: `square` o `rounded_square` con tamano dependiente del numero de filas (clamp a min/max).
|
||||
- Cuando esta **colapsada**: caja con titulo + contador (`Table · 23 filas`).
|
||||
- Cuando esta **expandida**: caja crece y dibuja un grid interno (filas x columnas) con los valores principales. Las filas son arrastrables individualmente.
|
||||
- Las relaciones `CONTAINS_ROW` no se dibujan como aristas normales (serian ruido visual). En su lugar, una fila extraida muestra una arista fina punteada hacia su tabla de origen.
|
||||
- Aristas entrantes/salientes del nodo tabla se dibujan al borde del rectangulo, no al centro.
|
||||
|
||||
## Operaciones
|
||||
|
||||
- **Crear tabla**: en context menu del viewport, "New table here". Pide `row_type` opcional. Crea entidad `Table` y la posiciona donde el click.
|
||||
- **Anadir fila** (tabla expandida o seleccionada): boton "+ row" que crea una entidad nueva con `type_ref = row_type` (si esta definido) y la engancha via `CONTAINS_ROW`.
|
||||
- **Extraer fila**: borra la relacion `CONTAINS_ROW`. La fila queda como nodo libre, posicionada al lado de la tabla.
|
||||
- **Extraer multiples**: shift+click en filas dentro de la tabla expandida, "Extract selected".
|
||||
- **Meter fila**: drag de un nodo sobre el rectangulo de una tabla. Confirm dialog si su `type_ref` no coincide con `row_type` de la tabla.
|
||||
- **Editar fila**: doble-click en fila → abre Inspector con esa entidad seleccionada.
|
||||
|
||||
## Cambios en codigo
|
||||
|
||||
- `entity_ops`:
|
||||
- `bool table_create(db_path, name, row_type, columns_csv, char* out_id)`.
|
||||
- `bool table_add_row(db_path, table_id, char* out_row_id)` (crea entidad + relacion CONTAINS_ROW).
|
||||
- `bool table_extract_row(db_path, table_id, row_id)` (borra solo la relacion).
|
||||
- `bool table_attach_row(db_path, table_id, row_id, int order)`.
|
||||
- `bool table_list_rows(db_path, table_id, vector<string>* out_row_ids)`.
|
||||
- Renderer del viewport (`graph_viewport.cpp` y/o `graph_renderer`): branch para `is_table_node` (detectado por `type_ref == "Table"`) que dibuja rectangulo + grid expandido y devuelve hit-testing por filas individuales.
|
||||
- `graph_load_from_operations`: filtrar las aristas `CONTAINS_ROW` para que no entren en el layout fisico (no aplican fuerzas).
|
||||
|
||||
## Definicion de hecho
|
||||
|
||||
- Crear tabla, anadir filas, extraer y meter filas funciona round-trip via SQLite.
|
||||
- Tabla colapsada y expandida se renderizan correctamente en el viewport.
|
||||
- Doble-click en fila enfoca el Inspector con esa entidad.
|
||||
- Una fila puede pertenecer a varias tablas sin duplicarse.
|
||||
- Borrar la tabla pregunta: "borrar tabla y todas sus filas" o "extraer filas y borrar solo la tabla".
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
id: 0011
|
||||
title: Nodo tabla — UI expandida, promote/demote, ingesta CSV/Parquet
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-05-01
|
||||
depends_on: [0010]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Fase 2 del nodo tabla. Sobre el cimiento DuckDB de 0010, anade UI completa:
|
||||
expandir nodo Table en el viewport para ver paginas de filas, promover una
|
||||
fila a entidad del grafo (nodo libre + arista punteada hacia su Table) y
|
||||
demover de vuelta. Ingesta de CSV / Parquet como punto de entrada para
|
||||
materializar tablas grandes.
|
||||
|
||||
## UI expandida
|
||||
|
||||
- Click sobre nodo Table -> selecciona; doble click -> toggle `expanded` en
|
||||
metadata.
|
||||
- Cuando expanded:
|
||||
- El cuadrado overlay crece para acomodar grid de cabecera + ~20 filas
|
||||
visibles. Mas filas exigen scroll dentro del overlay.
|
||||
- Cabecera con nombres de `columns[]`. Anchura proporcional al texto, max
|
||||
cap a un % del overlay.
|
||||
- Filas paginadas via `ImGuiListClipper` + `tableview_page(offset,limit)`.
|
||||
- Indicador "promovida" (chip o color) en filas que ya estan en `entities`.
|
||||
- Doble-click en fila -> abre Inspector con esa entidad si esta promovida;
|
||||
si no, la promueve y abre Inspector.
|
||||
- Aristas entrantes/salientes del nodo Table se siguen dibujando al centro
|
||||
(mejora a "al borde" se aplaza).
|
||||
|
||||
## Promote / demote
|
||||
|
||||
- Context menu sobre fila visible (overlay expandido):
|
||||
- "Promote to graph node": crea entidad en `operations.db` con metadata de
|
||||
origen, posiciona el nodo al lado del Table y dibuja arista punteada
|
||||
hacia el Table (overlay).
|
||||
- "Demote": deletea la entidad. La fila sigue viva en DuckDB.
|
||||
- Una fila puede estar promovida una sola vez por (duckdb_path, table,
|
||||
row_id) — el helper de promocion debe checar y hacer no-op idempotente.
|
||||
|
||||
## Ingesta
|
||||
|
||||
- Boton/comando "Import dataset..." en menu o context menu del canvas.
|
||||
- Modal con:
|
||||
- Path al CSV / Parquet / JSON.
|
||||
- Nombre de la tabla DuckDB destino.
|
||||
- row_type (combo con los entity types del proyecto + "(none)").
|
||||
- Boton "Import" -> ejecuta:
|
||||
```sql
|
||||
CREATE TABLE <name> AS SELECT * FROM read_csv_auto('<path>');
|
||||
```
|
||||
sobre `tables/<slug>.duckdb` (crea el .duckdb si no existe).
|
||||
- Tras import, crea automaticamente un nodo Table en el viewport apuntando
|
||||
a la nueva tabla.
|
||||
|
||||
## Cambios en codigo
|
||||
|
||||
- `tableview.{h,cpp}`:
|
||||
- `tableview_promote_row(ops_db, duckdb_path, duck_table, row_id, row_type, out_entity_id)`.
|
||||
- `tableview_demote_row(ops_db, entity_id)`.
|
||||
- `tableview_ingest_file(duckdb_path, file_path, dest_table, *file_kind)`.
|
||||
- `views.cpp`:
|
||||
- Render expandida via overlay. Hit-testing por fila (rect intersection).
|
||||
- Modal "Import dataset...".
|
||||
- `main.cpp`:
|
||||
- Wire context menu items. Recargar grafo tras promote/demote/ingest.
|
||||
|
||||
## Definicion de hecho
|
||||
|
||||
- Toggle expanded persiste en `entities.metadata` (JSON write).
|
||||
- Tabla con 1M filas se navega con scroll fluido (paginacion 200 filas).
|
||||
- Promote: la entidad creada aparece como nodo libre adyacente al Table,
|
||||
unida por arista punteada (visual solamente — no es relacion en BD).
|
||||
- Demote: el nodo desaparece, la fila sigue contandose en `tableview_count`.
|
||||
- Ingesta de CSV de 100k filas tarda < 5 s y deja la tabla lista para mostrar.
|
||||
- Doble-click en fila no promovida la promueve y enfoca Inspector.
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
id: 0010
|
||||
title: Nodo tabla — DuckDB foundation + render colapsado
|
||||
status: completed
|
||||
priority: high
|
||||
created: 2026-04-30
|
||||
revised: 2026-05-01
|
||||
completed: 2026-05-01
|
||||
depends_on: [0004, 0005, 0008]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Tier de almacenamiento tabular para nodos `Table` que pueden contener millones
|
||||
de filas sin saturar `operations.db` ni el grafo. Las "filas" viven en un
|
||||
`.duckdb` por proyecto (o por tabla) y se promueven a entidades reales del
|
||||
grafo solo cuando se necesita interactuar con ellas individualmente
|
||||
(relaciones, edicion, etc.).
|
||||
|
||||
Esta issue cubre **fase 1** — vendoring de DuckDB, funciones `tableview_*`
|
||||
core, y render colapsado del nodo Table en el viewport. La fase 2 (UI
|
||||
expandida, paginacion, promote/demote, ingesta CSV/Parquet) va en issue
|
||||
0011.
|
||||
|
||||
## Modelo de datos
|
||||
|
||||
**Dos tiers** por proyecto:
|
||||
|
||||
```
|
||||
projects/<proj>/apps/graph_explorer/
|
||||
operations.db # SQLite — grafo (entities + relations + filas promovidas)
|
||||
tables/
|
||||
<slug>.duckdb # DuckDB — bulk tabular (millones de filas)
|
||||
```
|
||||
|
||||
El nodo Table es una entidad normal con `type_ref = 'Table'` y metadata que
|
||||
apunta a su dataset DuckDB. **No contiene filas internamente** — es una vista.
|
||||
|
||||
```json
|
||||
{
|
||||
"duckdb_path": "tables/sospechosos.duckdb",
|
||||
"table_name": "people",
|
||||
"row_type": "Person",
|
||||
"id_column": "id",
|
||||
"label_column": "name",
|
||||
"columns": ["name","age","email"],
|
||||
"filter_sql": "",
|
||||
"expanded": false
|
||||
}
|
||||
```
|
||||
|
||||
Una fila promovida es una entidad en `operations.db` con metadata de origen:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": {
|
||||
"duckdb": "tables/sospechosos.duckdb",
|
||||
"table": "people",
|
||||
"row_id": "p_42"
|
||||
},
|
||||
"name": "Ana Lopez",
|
||||
"age": 33
|
||||
}
|
||||
```
|
||||
|
||||
## Cambios en codigo
|
||||
|
||||
- **Vendor DuckDB** en `cpp/vendor/duckdb/` (amalgamation o precompiled). Add
|
||||
library en `cpp/CMakeLists.txt`.
|
||||
- Nuevo paquete `cpp/functions/duck/`:
|
||||
- `duck_open(path) -> duckdb_database` (con `duckdb_open` + `duckdb_connect`).
|
||||
- `duck_query(conn, sql, params) -> result` wrapper.
|
||||
- `entity_ops` (o `tableview.{h,cpp}` en la app) — funciones a nivel de app:
|
||||
- `tableview_create(ops_db, duckdb_path, duck_table, row_type, char* out_id)`
|
||||
crea entidad `Table` con metadata + commit en `operations.db`.
|
||||
- `tableview_count(duckdb_path, sql_filter, int64_t* out)`.
|
||||
- `tableview_page(duckdb_path, sql_filter, offset, limit, vector<TablePageRow>* out)`.
|
||||
- `TablePageRow` lleva los campos del `columns[]` resueltos a string +
|
||||
`promoted` (LEFT JOIN contra `ops.entities`).
|
||||
- `graph_load_from_operations`: filtrar relaciones `CONTAINS_ROW` (heredado
|
||||
del modelo viejo, ya no se emiten pero por si se topa con dbs antiguas).
|
||||
- `views.cpp`:
|
||||
- Detectar nodos `type_ref == "Table"` al renderizar etiquetas/contadores.
|
||||
- Overlay con `ImGui::GetForegroundDrawList()` por cada nodo Table:
|
||||
rectangulo redondeado + label "Table · N filas".
|
||||
|
||||
## Definicion de hecho
|
||||
|
||||
- DuckDB compila y linka en linux + windows (cmake target).
|
||||
- Smoke test: abrir un `.duckdb` vacio, crear tabla con 1M filas (CTAS desde
|
||||
`range`), correr `SELECT COUNT(*)` < 100 ms.
|
||||
- `tableview_create` + `tableview_count` + `tableview_page` con tests.
|
||||
- Un nodo `type_ref='Table'` en el grafo se renderiza con un cuadrado overlay
|
||||
encima del circulo GPU, con contador de filas obtenido por `tableview_count`.
|
||||
- El contador refresca al recargar el grafo o tras un INSERT en su DuckDB.
|
||||
@@ -28,6 +28,9 @@
|
||||
|
||||
#include "../../../../cpp/vendor/sqlite3/sqlite3.h"
|
||||
|
||||
#include "tableview.h"
|
||||
#include "duckdb.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
@@ -253,6 +256,16 @@ static bool load_input() {
|
||||
ge::views_reset_visibility(g_app);
|
||||
ge::views_apply_visibility(g_app);
|
||||
|
||||
// Cache de conteos de Table nodes (issue 0010).
|
||||
if (g_input.uri) {
|
||||
ge::tableview_refresh_counts(g_input.uri, &g_app.table_node_counts);
|
||||
int64_t total_rows = 0;
|
||||
for (auto& kv : g_app.table_node_counts) total_rows += kv.second;
|
||||
std::fprintf(stdout,
|
||||
"[graph_explorer] table counts refreshed: %zu tables, %lld total rows\n",
|
||||
g_app.table_node_counts.size(), (long long)total_rows);
|
||||
}
|
||||
|
||||
// Cache de la vista tabla (issue 0004) — pull bulk + neighbors desde grafo.
|
||||
{
|
||||
std::vector<ge::EntityRowSnapshot> snap;
|
||||
@@ -689,6 +702,9 @@ static void render() {
|
||||
ge::views_reset_visibility(g_app);
|
||||
ge::views_apply_visibility(g_app);
|
||||
|
||||
// Refresh Table node counts (issue 0010).
|
||||
ge::tableview_refresh_counts(g_input.uri, &g_app.table_node_counts);
|
||||
|
||||
// Refresh table cache (issue 0004).
|
||||
std::vector<ge::EntityRowSnapshot> snap;
|
||||
if (ge::entity_list_rows(g_input.uri, &snap)) {
|
||||
@@ -941,6 +957,8 @@ static void render() {
|
||||
graph::graph_labels_draw(g_graph, g_viewport, g_label_policy,
|
||||
&get_label_cb, nullptr);
|
||||
}
|
||||
// Table node overlay (issue 0010) — encima de las labels.
|
||||
ge::views_table_overlay(g_app);
|
||||
}
|
||||
ImGui::End();
|
||||
} else {
|
||||
@@ -997,7 +1015,9 @@ static void usage() {
|
||||
" graph_explorer --types <types.yaml>\n"
|
||||
" graph_explorer --layout force|grid|circular|radial|hierarchical|fixed\n"
|
||||
" graph_explorer --project <slug>\n"
|
||||
" graph_explorer --test-types-yaml <path> (load+save+reload smoke test)\n");
|
||||
" graph_explorer --test-types-yaml <path> (load+save+reload smoke test)\n"
|
||||
" graph_explorer --test-duckdb <path> (open + SELECT 42 smoke test)\n"
|
||||
" graph_explorer --test-tableview <path> (1M rows count + page test)\n");
|
||||
}
|
||||
|
||||
// Smoke test del parser+writer (issue 0005 round-trip): carga `path`,
|
||||
@@ -1091,6 +1111,59 @@ int main(int argc, char** argv) {
|
||||
project_arg = argv[++i];
|
||||
} else if (std::strcmp(a, "--test-types-yaml") == 0 && i + 1 < argc) {
|
||||
return test_types_yaml_roundtrip(argv[++i]);
|
||||
} else if (std::strcmp(a, "--test-duckdb") == 0 && i + 1 < argc) {
|
||||
const char* p = argv[++i];
|
||||
if (!ge::tableview_smoke_test(p)) {
|
||||
std::fprintf(stderr, "[duckdb] smoke test FAILED for %s\n", p);
|
||||
return 2;
|
||||
}
|
||||
std::fprintf(stdout, "[duckdb] smoke test OK (SELECT 42 -> 42) on %s\n", p);
|
||||
return 0;
|
||||
} else if (std::strcmp(a, "--test-tableview") == 0 && i + 1 < argc) {
|
||||
// Crea 1M filas en duckdb_path/people, cuenta y pagina.
|
||||
const char* p = argv[++i];
|
||||
std::remove(p); // empezar desde cero
|
||||
duckdb_database db = nullptr;
|
||||
duckdb_connection cn = nullptr;
|
||||
if (duckdb_open(p, &db) == DuckDBError) { std::fprintf(stderr, "open fail\n"); return 2; }
|
||||
duckdb_connect(db, &cn);
|
||||
duckdb_result r;
|
||||
if (duckdb_query(cn,
|
||||
"CREATE TABLE people AS "
|
||||
"SELECT range AS id, 'name_' || CAST(range AS VARCHAR) AS name, "
|
||||
" (range * 7) % 100 AS age FROM range(1000000)", &r) == DuckDBError) {
|
||||
std::fprintf(stderr, "create fail: %s\n",
|
||||
duckdb_result_error(&r) ? duckdb_result_error(&r) : "?");
|
||||
duckdb_destroy_result(&r);
|
||||
duckdb_disconnect(&cn); duckdb_close(&db); return 2;
|
||||
}
|
||||
duckdb_destroy_result(&r);
|
||||
duckdb_disconnect(&cn); duckdb_close(&db);
|
||||
|
||||
int64_t total = 0;
|
||||
if (!ge::tableview_count(p, "people", nullptr, &total) || total != 1000000) {
|
||||
std::fprintf(stderr, "[tableview_count] expected 1000000, got %lld\n",
|
||||
(long long)total);
|
||||
return 2;
|
||||
}
|
||||
std::vector<std::string> cols = { "name", "age" };
|
||||
std::vector<ge::TablePageRow> page;
|
||||
if (!ge::tableview_page(p, "people", "id", cols, nullptr,
|
||||
nullptr, nullptr, 500000, 10, &page)) {
|
||||
std::fprintf(stderr, "[tableview_page] failed\n");
|
||||
return 2;
|
||||
}
|
||||
if (page.size() != 10) {
|
||||
std::fprintf(stderr, "[tableview_page] expected 10 rows, got %zu\n",
|
||||
page.size());
|
||||
return 2;
|
||||
}
|
||||
std::fprintf(stdout,
|
||||
"[tableview] OK — count=%lld, page[0]={id=%s, name=%s, age=%s}\n",
|
||||
(long long)total, page[0].id.c_str(),
|
||||
page[0].values.size() > 0 ? page[0].values[0].c_str() : "",
|
||||
page[0].values.size() > 1 ? page[0].values[1].c_str() : "");
|
||||
return 0;
|
||||
} else if (std::strcmp(a, "--help") == 0 || std::strcmp(a, "-h") == 0) {
|
||||
usage();
|
||||
return 0;
|
||||
|
||||
+357
@@ -0,0 +1,357 @@
|
||||
#include "tableview.h"
|
||||
|
||||
#include "duckdb.h"
|
||||
#include "../../../../cpp/vendor/sqlite3/sqlite3.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace ge {
|
||||
|
||||
namespace {
|
||||
|
||||
// Escape simple para SQL identifiers — solo permite [A-Za-z0-9_]. Usado para
|
||||
// nombres de tabla / columnas que vienen de metadata. Si encuentra un char
|
||||
// invalido, lo reemplaza por '_'. NO sustituye al binding de parametros.
|
||||
std::string sanitize_ident(const char* s) {
|
||||
std::string out;
|
||||
if (!s) return out;
|
||||
for (const char* p = s; *p; ++p) {
|
||||
char c = *p;
|
||||
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
|
||||
|| (c >= '0' && c <= '9') || c == '_') out += c;
|
||||
else out += '_';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Escape para literales de string en SQL: dobla las comillas simples.
|
||||
std::string sql_escape(const char* s) {
|
||||
std::string out;
|
||||
if (!s) return out;
|
||||
for (const char* p = s; *p; ++p) {
|
||||
out += *p;
|
||||
if (*p == '\'') out += '\'';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Helper RAII alrededor de duckdb_database/connection.
|
||||
struct DuckHandle {
|
||||
duckdb_database db = nullptr;
|
||||
duckdb_connection cn = nullptr;
|
||||
bool open(const char* path) {
|
||||
if (duckdb_open(path, &db) == DuckDBError) return false;
|
||||
if (duckdb_connect(db, &cn) == DuckDBError) return false;
|
||||
return true;
|
||||
}
|
||||
~DuckHandle() {
|
||||
if (cn) duckdb_disconnect(&cn);
|
||||
if (db) duckdb_close(&db);
|
||||
}
|
||||
};
|
||||
|
||||
bool duck_query_silent(duckdb_connection cn, const char* sql) {
|
||||
duckdb_result r;
|
||||
duckdb_state st = duckdb_query(cn, sql, &r);
|
||||
duckdb_destroy_result(&r);
|
||||
return st == DuckDBSuccess;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool tableview_smoke_test(const char* duckdb_path) {
|
||||
DuckHandle h;
|
||||
if (!h.open(duckdb_path)) return false;
|
||||
duckdb_result r;
|
||||
if (duckdb_query(h.cn, "SELECT 42 AS x", &r) == DuckDBError) {
|
||||
duckdb_destroy_result(&r);
|
||||
return false;
|
||||
}
|
||||
bool ok = duckdb_row_count(&r) == 1
|
||||
&& duckdb_value_int64(&r, 0, 0) == 42;
|
||||
duckdb_destroy_result(&r);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool tableview_count(const char* duckdb_path,
|
||||
const char* duck_table,
|
||||
const char* sql_filter,
|
||||
int64_t* out)
|
||||
{
|
||||
if (!duckdb_path || !duck_table || !out) return false;
|
||||
*out = 0;
|
||||
DuckHandle h;
|
||||
if (!h.open(duckdb_path)) return false;
|
||||
std::string tname = sanitize_ident(duck_table);
|
||||
if (tname.empty()) return false;
|
||||
std::string sql = "SELECT COUNT(*) FROM " + tname;
|
||||
if (sql_filter && *sql_filter) {
|
||||
sql += " WHERE ";
|
||||
sql += sql_filter;
|
||||
}
|
||||
duckdb_result r;
|
||||
if (duckdb_query(h.cn, sql.c_str(), &r) == DuckDBError) {
|
||||
std::fprintf(stderr, "[tableview_count] %s\n",
|
||||
duckdb_result_error(&r) ? duckdb_result_error(&r) : "?");
|
||||
duckdb_destroy_result(&r);
|
||||
return false;
|
||||
}
|
||||
if (duckdb_row_count(&r) > 0) {
|
||||
*out = duckdb_value_int64(&r, 0, 0);
|
||||
}
|
||||
duckdb_destroy_result(&r);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool tableview_page(const char* duckdb_path,
|
||||
const char* duck_table,
|
||||
const char* id_column,
|
||||
const std::vector<std::string>& columns,
|
||||
const char* sql_filter,
|
||||
const char* ops_db,
|
||||
const char* row_type,
|
||||
int64_t offset, int64_t limit,
|
||||
std::vector<TablePageRow>* out)
|
||||
{
|
||||
if (!out) return false;
|
||||
out->clear();
|
||||
if (!duckdb_path || !duck_table || !id_column) return false;
|
||||
if (limit < 1) limit = 1;
|
||||
if (limit > 5000) limit = 5000;
|
||||
|
||||
DuckHandle h;
|
||||
if (!h.open(duckdb_path)) return false;
|
||||
|
||||
std::string idc = sanitize_ident(id_column);
|
||||
std::string tn = sanitize_ident(duck_table);
|
||||
if (idc.empty() || tn.empty()) return false;
|
||||
|
||||
// Si tenemos ops_db y row_type, hacemos LEFT JOIN para detectar promovidas
|
||||
// a traves de json_extract sobre entities.metadata.source.row_id.
|
||||
bool join_ops = (ops_db && *ops_db && row_type && *row_type);
|
||||
|
||||
if (join_ops) {
|
||||
// ATTACH del SQLite. Las attaches viven por conexion; idempotente
|
||||
// detectando si ya existe seria mas robusto pero este path se llama
|
||||
// por cada page() — abrimos conexion fresca cada vez asi que no.
|
||||
std::string attach = "ATTACH '" + sql_escape(ops_db) + "' AS ops (TYPE SQLITE)";
|
||||
if (!duck_query_silent(h.cn, attach.c_str())) {
|
||||
// sin fallar — sin promovidas, solo perdemos el flag.
|
||||
join_ops = false;
|
||||
}
|
||||
}
|
||||
|
||||
// SELECT: id_column + columns... + (CASE WHEN e.id NULL THEN '' ELSE e.id END).
|
||||
std::string sel = "SELECT t." + idc;
|
||||
for (const auto& c : columns) {
|
||||
std::string cc = sanitize_ident(c.c_str());
|
||||
if (!cc.empty()) sel += ", t." + cc;
|
||||
}
|
||||
if (join_ops) {
|
||||
sel += ", COALESCE(e.id, '')";
|
||||
} else {
|
||||
sel += ", ''";
|
||||
}
|
||||
sel += " FROM " + tn + " AS t";
|
||||
if (join_ops) {
|
||||
sel += " LEFT JOIN ops.entities AS e ON ";
|
||||
sel += "json_extract_string(e.metadata, '$.source.row_id') = CAST(t." + idc + " AS VARCHAR)";
|
||||
sel += " AND e.type_ref = '" + sql_escape(row_type) + "'";
|
||||
}
|
||||
if (sql_filter && *sql_filter) {
|
||||
sel += " WHERE ";
|
||||
sel += sql_filter;
|
||||
}
|
||||
sel += " ORDER BY t." + idc + " ASC";
|
||||
sel += " LIMIT " + std::to_string(limit);
|
||||
sel += " OFFSET " + std::to_string(offset);
|
||||
|
||||
duckdb_result r;
|
||||
if (duckdb_query(h.cn, sel.c_str(), &r) == DuckDBError) {
|
||||
std::fprintf(stderr, "[tableview_page] %s\n",
|
||||
duckdb_result_error(&r) ? duckdb_result_error(&r) : "?");
|
||||
duckdb_destroy_result(&r);
|
||||
return false;
|
||||
}
|
||||
idx_t rows = duckdb_row_count(&r);
|
||||
idx_t cols = duckdb_column_count(&r);
|
||||
out->reserve((size_t)rows);
|
||||
for (idx_t row = 0; row < rows; ++row) {
|
||||
TablePageRow tr;
|
||||
// col 0 = id
|
||||
if (!duckdb_value_is_null(&r, 0, row)) {
|
||||
char* v = duckdb_value_varchar(&r, 0, row);
|
||||
tr.id = v ? v : "";
|
||||
if (v) duckdb_free(v);
|
||||
}
|
||||
// cols 1..N-2 = columns[]
|
||||
idx_t expected_cols = (idx_t)columns.size();
|
||||
tr.values.reserve(expected_cols);
|
||||
for (idx_t i = 0; i < expected_cols; ++i) {
|
||||
idx_t c = 1 + i;
|
||||
if (c >= cols) { tr.values.emplace_back(""); continue; }
|
||||
if (duckdb_value_is_null(&r, c, row)) {
|
||||
tr.values.emplace_back("");
|
||||
} else {
|
||||
char* v = duckdb_value_varchar(&r, c, row);
|
||||
tr.values.emplace_back(v ? v : "");
|
||||
if (v) duckdb_free(v);
|
||||
}
|
||||
}
|
||||
// ultima col = promoted_entity_id
|
||||
idx_t prom_col = cols > 0 ? cols - 1 : 0;
|
||||
if (cols > 0 && !duckdb_value_is_null(&r, prom_col, row)) {
|
||||
char* v = duckdb_value_varchar(&r, prom_col, row);
|
||||
tr.promoted_entity_id = v ? v : "";
|
||||
if (v) duckdb_free(v);
|
||||
}
|
||||
out->push_back(std::move(tr));
|
||||
}
|
||||
duckdb_destroy_result(&r);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool tableview_create(const char* ops_db,
|
||||
const char* name,
|
||||
const char* duckdb_path,
|
||||
const char* duck_table,
|
||||
const char* row_type,
|
||||
char* out_id, std::size_t out_id_n)
|
||||
{
|
||||
if (!ops_db || !duckdb_path || !duck_table) return false;
|
||||
if (!name || !*name) name = "Table";
|
||||
|
||||
auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch()).count();
|
||||
|
||||
char id[80];
|
||||
std::snprintf(id, sizeof(id), "table_%lld", (long long)now_ms);
|
||||
if (out_id && out_id_n > 0) {
|
||||
std::snprintf(out_id, out_id_n, "%s", id);
|
||||
}
|
||||
|
||||
sqlite3* db = nullptr;
|
||||
if (sqlite3_open_v2(ops_db, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) {
|
||||
if (db) sqlite3_close(db);
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string meta = "{";
|
||||
meta += "\"duckdb_path\":\""; meta += sql_escape(duckdb_path); meta += "\",";
|
||||
meta += "\"table_name\":\""; meta += sql_escape(duck_table); meta += "\",";
|
||||
meta += "\"row_type\":\""; meta += sql_escape(row_type ? row_type : ""); meta += "\",";
|
||||
meta += "\"id_column\":\"id\",";
|
||||
meta += "\"label_column\":\"name\",";
|
||||
meta += "\"columns\":[],";
|
||||
meta += "\"filter_sql\":\"\",";
|
||||
meta += "\"expanded\":false";
|
||||
meta += "}";
|
||||
|
||||
const char* ins =
|
||||
"INSERT INTO entities(id, name, type_ref, status, tags, source, metadata, "
|
||||
" created_at, updated_at) "
|
||||
"VALUES (?, ?, 'Table', 'active', '[]', 'manual', ?, "
|
||||
" strftime('%Y-%m-%dT%H:%M:%fZ','now'), "
|
||||
" strftime('%Y-%m-%dT%H:%M:%fZ','now'))";
|
||||
sqlite3_stmt* st = nullptr;
|
||||
if (sqlite3_prepare_v2(db, ins, -1, &st, nullptr) != SQLITE_OK) {
|
||||
sqlite3_close(db);
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(st, 1, id, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(st, 3, meta.c_str(), -1, SQLITE_TRANSIENT);
|
||||
bool ok = sqlite3_step(st) == SQLITE_DONE;
|
||||
sqlite3_finalize(st);
|
||||
sqlite3_close(db);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Path resolution + counts cache
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
uint64_t fnv1a64(const char* s) {
|
||||
uint64_t h = 1469598103934665603ULL;
|
||||
for (; s && *s; ++s) {
|
||||
h ^= (uint8_t)*s;
|
||||
h *= 1099511628211ULL;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
std::string dirname_of(const char* path) {
|
||||
if (!path) return "";
|
||||
std::string s = path;
|
||||
auto pos = s.find_last_of("/\\");
|
||||
if (pos == std::string::npos) return ".";
|
||||
return s.substr(0, pos);
|
||||
}
|
||||
|
||||
bool is_absolute(const char* p) {
|
||||
if (!p || !*p) return false;
|
||||
if (p[0] == '/') return true;
|
||||
if (std::strlen(p) >= 2 && p[1] == ':' &&
|
||||
((p[0] >= 'A' && p[0] <= 'Z') || (p[0] >= 'a' && p[0] <= 'z'))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string tableview_resolve_path(const char* ops_db, const char* maybe_rel) {
|
||||
if (!maybe_rel) return "";
|
||||
if (is_absolute(maybe_rel)) return maybe_rel;
|
||||
std::string base = dirname_of(ops_db);
|
||||
if (base.empty()) base = ".";
|
||||
return base + "/" + maybe_rel;
|
||||
}
|
||||
|
||||
bool tableview_refresh_counts(const char* ops_db,
|
||||
std::unordered_map<uint64_t, int64_t>* out)
|
||||
{
|
||||
if (!ops_db || !out) return false;
|
||||
out->clear();
|
||||
sqlite3* db = nullptr;
|
||||
if (sqlite3_open_v2(ops_db, &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK) {
|
||||
if (db) sqlite3_close(db);
|
||||
return false;
|
||||
}
|
||||
const char* sql =
|
||||
"SELECT id, "
|
||||
" json_extract(metadata, '$.duckdb_path'), "
|
||||
" json_extract(metadata, '$.table_name'), "
|
||||
" json_extract(metadata, '$.filter_sql') "
|
||||
"FROM entities WHERE type_ref = 'Table'";
|
||||
sqlite3_stmt* st = nullptr;
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) != SQLITE_OK) {
|
||||
sqlite3_close(db);
|
||||
return false;
|
||||
}
|
||||
while (sqlite3_step(st) == SQLITE_ROW) {
|
||||
const unsigned char* id_p = sqlite3_column_text(st, 0);
|
||||
const unsigned char* path_p = sqlite3_column_text(st, 1);
|
||||
const unsigned char* tab_p = sqlite3_column_text(st, 2);
|
||||
const unsigned char* flt_p = sqlite3_column_text(st, 3);
|
||||
if (!id_p || !path_p || !tab_p) continue;
|
||||
std::string abs = tableview_resolve_path(ops_db, (const char*)path_p);
|
||||
int64_t total = 0;
|
||||
if (!tableview_count(abs.c_str(), (const char*)tab_p,
|
||||
flt_p ? (const char*)flt_p : nullptr,
|
||||
&total)) {
|
||||
std::fprintf(stderr,
|
||||
"[tableview_refresh_counts] count failed for id=%s\n", id_p);
|
||||
continue;
|
||||
}
|
||||
out->emplace(fnv1a64((const char*)id_p), total);
|
||||
}
|
||||
sqlite3_finalize(st);
|
||||
sqlite3_close(db);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace ge
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
// Vista tabular respaldada por DuckDB (issue 0010). Cada nodo `Table` del
|
||||
// grafo apunta via metadata a un archivo `.duckdb` y a una tabla dentro
|
||||
// de el. Las filas viven en DuckDB; el grafo solo materializa las que
|
||||
// se "promueven" a entidades (issue 0011).
|
||||
//
|
||||
// Convencion de paths: `metadata.duckdb_path` es relativo al directorio del
|
||||
// proyecto (la raiz donde vive operations.db). El caller resuelve a path
|
||||
// absoluto antes de pasar a estas funciones.
|
||||
|
||||
namespace ge {
|
||||
|
||||
struct TablePageRow {
|
||||
std::string id; // valor del id_column en duckdb (key natural)
|
||||
std::vector<std::string> values; // un valor por columna en `columns[]`
|
||||
std::string promoted_entity_id; // "" si la fila no esta promovida; sino, ops.entities.id
|
||||
};
|
||||
|
||||
// Crea o sobrescribe el nodo Table. Inserta una fila en operations.db con
|
||||
// type_ref='Table' y metadata apuntando al duckdb_path/table_name. Genera
|
||||
// un id propio. Devuelve false si SQLite falla o si los argumentos basicos
|
||||
// estan vacios.
|
||||
bool tableview_create(const char* ops_db,
|
||||
const char* name,
|
||||
const char* duckdb_path,
|
||||
const char* duck_table,
|
||||
const char* row_type,
|
||||
char* out_id, std::size_t out_id_n);
|
||||
|
||||
// Cuenta las filas de duckdb_path/duck_table aplicando opcionalmente
|
||||
// `sql_filter` (clausula WHERE sin la palabra WHERE — vacio = sin filtro).
|
||||
// Devuelve false en error de IO/parse.
|
||||
bool tableview_count(const char* duckdb_path,
|
||||
const char* duck_table,
|
||||
const char* sql_filter,
|
||||
int64_t* out);
|
||||
|
||||
// Devuelve una pagina ordenada por `id_column` ASC. Cada fila incluye los
|
||||
// valores de `columns` resueltos a string + el flag `promoted_entity_id`
|
||||
// computado via LEFT JOIN contra ops.entities (DuckDB attach a SQLite).
|
||||
// limit clampeado en [1,5000].
|
||||
bool tableview_page(const char* duckdb_path,
|
||||
const char* duck_table,
|
||||
const char* id_column,
|
||||
const std::vector<std::string>& columns,
|
||||
const char* sql_filter,
|
||||
const char* ops_db, // para LEFT JOIN de promovidas
|
||||
const char* row_type, // discriminante en ops.entities
|
||||
int64_t offset, int64_t limit,
|
||||
std::vector<TablePageRow>* out);
|
||||
|
||||
// Smoke test: abre el .duckdb, corre `SELECT 42 AS x` y verifica que
|
||||
// devuelve la fila esperada. Devuelve true si todo OK.
|
||||
bool tableview_smoke_test(const char* duckdb_path);
|
||||
|
||||
// Resuelve un path posiblemente relativo a la ubicacion de operations.db.
|
||||
// Si es absoluto (empieza por '/' o '<letra>:' en Windows), se devuelve
|
||||
// tal cual.
|
||||
std::string tableview_resolve_path(const char* ops_db, const char* maybe_rel);
|
||||
|
||||
// Refresca el cache de conteos de filas por nodo Table. Lee
|
||||
// type_ref='Table' de operations.db, extrae metadata.duckdb_path/table_name,
|
||||
// llama a tableview_count y guarda el resultado indexado por
|
||||
// fnv1a64(entity_id) — la misma key que usa graph_sources al setear
|
||||
// node.user_data, asi que el render puede mirar directo por user_data.
|
||||
// Si una tabla falla, su entrada NO se inserta y se imprime un warning.
|
||||
struct TableCounts {
|
||||
// user_data hash (fnv1a64 del entity id) -> total filas tras filter_sql.
|
||||
// -1 indica error/ausencia.
|
||||
};
|
||||
bool tableview_refresh_counts(const char* ops_db,
|
||||
std::unordered_map<uint64_t, int64_t>* out);
|
||||
|
||||
} // namespace ge
|
||||
@@ -1599,6 +1599,71 @@ void views_table(AppState& app) {
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Table node overlay (issue 0010)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
void views_table_overlay(AppState& app) {
|
||||
if (!app.graph || !app.viewport) return;
|
||||
GraphData& g = *app.graph;
|
||||
if (g.type_count == 0) return;
|
||||
|
||||
const ImVec2 wmin = ImGui::GetItemRectMin();
|
||||
const ImVec2 wmax = ImGui::GetItemRectMax();
|
||||
const float cx = (wmin.x + wmax.x) * 0.5f;
|
||||
const float cy = (wmin.y + wmax.y) * 0.5f;
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
if (!dl) return;
|
||||
ImFont* font = ImGui::GetFont();
|
||||
|
||||
for (int i = 0; i < g.node_count; ++i) {
|
||||
const GraphNode& n = g.nodes[i];
|
||||
if (!(n.flags & NF_VISIBLE)) continue;
|
||||
if (n.type_id >= (uint16_t)g.type_count) continue;
|
||||
const EntityType& t = g.types[n.type_id];
|
||||
if (!t.name || std::strcmp(t.name, "Table") != 0) continue;
|
||||
|
||||
const float vx = (n.x - app.viewport->cam_x) * app.viewport->zoom + cx;
|
||||
const float vy = (n.y - app.viewport->cam_y) * app.viewport->zoom + cy;
|
||||
if (vx < wmin.x - 200 || vx > wmax.x + 200) continue;
|
||||
if (vy < wmin.y - 100 || vy > wmax.y + 100) continue;
|
||||
|
||||
int64_t count = -1;
|
||||
auto it = app.table_node_counts.find(n.user_data);
|
||||
if (it != app.table_node_counts.end()) count = it->second;
|
||||
|
||||
char buf[96];
|
||||
if (count >= 0) std::snprintf(buf, sizeof(buf), TI_TABLE " Table %lld", (long long)count);
|
||||
else std::snprintf(buf, sizeof(buf), TI_TABLE " Table");
|
||||
|
||||
const float font_size = 13.0f;
|
||||
ImVec2 ts = font ? font->CalcTextSizeA(font_size, FLT_MAX, 0.0f, buf)
|
||||
: ImVec2(60.0f, 14.0f);
|
||||
const float pad_x = 10.0f, pad_y = 6.0f;
|
||||
const float w = std::max(96.0f, ts.x + pad_x * 2.0f);
|
||||
const float h = ts.y + pad_y * 2.0f;
|
||||
ImVec2 a(vx - w * 0.5f, vy - h * 0.5f);
|
||||
ImVec2 b(vx + w * 0.5f, vy + h * 0.5f);
|
||||
|
||||
// Sombra ligera
|
||||
dl->AddRectFilled(ImVec2(a.x + 1, a.y + 2), ImVec2(b.x + 1, b.y + 2),
|
||||
IM_COL32(0, 0, 0, 80), 6.0f);
|
||||
// Cuerpo
|
||||
dl->AddRectFilled(a, b, IM_COL32(38, 56, 92, 240), 6.0f);
|
||||
// Borde
|
||||
uint32_t border = (n.flags & NF_SELECTED)
|
||||
? IM_COL32(180, 200, 255, 255)
|
||||
: IM_COL32(120, 160, 220, 220);
|
||||
dl->AddRect(a, b, border, 6.0f, 0, (n.flags & NF_SELECTED) ? 2.0f : 1.5f);
|
||||
|
||||
if (font) {
|
||||
dl->AddText(font, font_size,
|
||||
ImVec2(vx - ts.x * 0.5f, vy - ts.y * 0.5f),
|
||||
IM_COL32(230, 240, 255, 255), buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Type Editor (issue 0007)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
#include "types_registry.h"
|
||||
#include "entity_ops.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <unordered_map>
|
||||
|
||||
struct GraphData;
|
||||
struct GraphViewportState;
|
||||
|
||||
@@ -137,6 +140,11 @@ struct AppState {
|
||||
std::vector<std::string> insp_tag_suggestions;
|
||||
std::vector<std::string> insp_type_options;
|
||||
|
||||
// ---- Table node (issue 0010) ------------------------------------------
|
||||
// Cache de conteo de filas por nodo Table indexado por user_data hash.
|
||||
// Refrescado tras load_input y tras mutaciones que afecten a Tables.
|
||||
std::unordered_map<uint64_t, int64_t> table_node_counts;
|
||||
|
||||
// ---- Table view (issue 0004) -------------------------------------------
|
||||
// Vista tabular dockeable. Tabs por type_ref del grafo activo + opcional
|
||||
// "All". Click selecciona el nodo en el viewport (mismo flujo que el
|
||||
@@ -243,6 +251,15 @@ EntityRecord views_inspector_build_record(const AppState& app);
|
||||
// al cambiar de proyecto.
|
||||
void views_inspector_clear_draft(AppState& app);
|
||||
|
||||
// ---- Table node overlay (issue 0010) ------------------------------------
|
||||
|
||||
// Dibuja un overlay rectangulo redondeado sobre cada nodo `Table` del grafo
|
||||
// con etiqueta "Table · N rows" leyendo de app.table_node_counts. Llamar
|
||||
// despues de graph_viewport(...) — usa GetItemRectMin/Max + GetWindowDrawList
|
||||
// del item viewport. No interactua con eventos; el hit-testing del nodo
|
||||
// sigue usandolo el viewport circular de fondo.
|
||||
void views_table_overlay(AppState& app);
|
||||
|
||||
// ---- Table view (issue 0004) --------------------------------------------
|
||||
|
||||
// Renderiza el panel "Table". Lee de app.table_rows; el caller ya ha hecho el
|
||||
|
||||
Reference in New Issue
Block a user