From 20d8bbf360a5001dae3fc2b8dceda49842b5fb60 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 1 May 2026 01:14:52 +0200 Subject: [PATCH 1/2] docs(issues): rewrite 0010 con DuckDB + new 0011 (UI fase 2) 0010 cambia de modelo SQLite CONTAINS_ROW a tier DuckDB: operations.db sigue con grafo + filas promovidas, tablas grandes viven en projects//apps/graph_explorer/tables/.duckdb. 0011 separa la fase 2 (UI expandida + promote/demote + ingesta CSV). --- issues/0010-table-node.md | 119 +++++++++++++--------- issues/0011-tablenode-expanded-promote.md | 79 ++++++++++++++ 2 files changed, 150 insertions(+), 48 deletions(-) create mode 100644 issues/0011-tablenode-expanded-promote.md diff --git a/issues/0010-table-node.md b/issues/0010-table-node.md index 1883a25..b3f7b7f 100644 --- a/issues/0010-table-node.md +++ b/issues/0010-table-node.md @@ -1,71 +1,94 @@ --- id: 0010 -title: Nodo tabla — contenedor cuadrado con filas que son nodos del grafo +title: Nodo tabla — DuckDB foundation + render colapsado status: pending -priority: medium +priority: high created: 2026-04-30 +revised: 2026-05-01 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). +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.). -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. +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 -- 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 = `, `to_entity = `, `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`). +**Dos tiers** por proyecto: -## Render en viewport +``` +projects//apps/graph_explorer/ + operations.db # SQLite — grafo (entities + relations + filas promovidas) + tables/ + .duckdb # DuckDB — bulk tabular (millones de filas) +``` -- 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. +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. -## Operaciones +```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 +} +``` -- **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. +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 -- `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* 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). +- **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* 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 -- 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". +- 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. diff --git a/issues/0011-tablenode-expanded-promote.md b/issues/0011-tablenode-expanded-promote.md new file mode 100644 index 0000000..9341540 --- /dev/null +++ b/issues/0011-tablenode-expanded-promote.md @@ -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 AS SELECT * FROM read_csv_auto(''); + ``` + sobre `tables/.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. From 082008bc007d87761e87ea922959c8a1d0975c99 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 1 May 2026 01:24:25 +0200 Subject: [PATCH 2/2] feat(table-node): DuckDB foundation + render colapsado (issue 0010) - tableview.{h,cpp}: capa C sobre DuckDB v1.1.3. * tableview_smoke_test (SELECT 42). * tableview_count (con sql_filter opcional). * tableview_page (LEFT JOIN sobre ops.entities via ATTACH para flag promoted). * tableview_create (inserta entidad type_ref='Table' con metadata pointer). * tableview_refresh_counts (lee Table entities, count cada DuckDB y cachea por user_data hash). * tableview_resolve_path (rel a dirname(ops_db) o absoluto). - AppState::table_node_counts cache, refrescado tras load_input y mutaciones. - views_table_overlay: rectangulo redondeado overlay ("Table N") encima de cada nodo type_ref='Table'. Sigue camara via cam_x/cam_y/zoom. - main.cpp: * --test-duckdb smoke (SELECT 42). * --test-tableview bulk test (1M rows count + page offset). * Refresh de counts tras load + reload_after_mutation. * Llamada a views_table_overlay despues de graph_labels_draw. - CMakeLists.txt: link DuckDB::DuckDB + duckdb_copy_runtime. Smoke tests: - 1M rows count + page(offset=500k, limit=10) en 0.65 s end-to-end. - Operations.db con un nodo Table apuntando a duckdb 1M filas: refresh reporta correctamente "1 tables, 1000000 total rows". --- CMakeLists.txt | 4 +- issues/{ => completed}/0010-table-node.md | 3 +- main.cpp | 75 ++++- tableview.cpp | 357 ++++++++++++++++++++++ tableview.h | 79 +++++ views.cpp | 65 ++++ views.h | 17 ++ 7 files changed, 597 insertions(+), 3 deletions(-) rename issues/{ => completed}/0010-table-node.md (98%) create mode 100644 tableview.cpp create mode 100644 tableview.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 9674d8a..9be7863 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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. diff --git a/issues/0010-table-node.md b/issues/completed/0010-table-node.md similarity index 98% rename from issues/0010-table-node.md rename to issues/completed/0010-table-node.md index b3f7b7f..b19df73 100644 --- a/issues/0010-table-node.md +++ b/issues/completed/0010-table-node.md @@ -1,10 +1,11 @@ --- id: 0010 title: Nodo tabla — DuckDB foundation + render colapsado -status: pending +status: completed priority: high created: 2026-04-30 revised: 2026-05-01 +completed: 2026-05-01 depends_on: [0004, 0005, 0008] --- diff --git a/main.cpp b/main.cpp index dc0a249..6c264d5 100644 --- a/main.cpp +++ b/main.cpp @@ -28,6 +28,9 @@ #include "../../../../cpp/vendor/sqlite3/sqlite3.h" +#include "tableview.h" +#include "duckdb.h" + #include #include #include @@ -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 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 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 \n" " graph_explorer --layout force|grid|circular|radial|hierarchical|fixed\n" " graph_explorer --project \n" - " graph_explorer --test-types-yaml (load+save+reload smoke test)\n"); + " graph_explorer --test-types-yaml (load+save+reload smoke test)\n" + " graph_explorer --test-duckdb (open + SELECT 42 smoke test)\n" + " graph_explorer --test-tableview (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 cols = { "name", "age" }; + std::vector 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; diff --git a/tableview.cpp b/tableview.cpp new file mode 100644 index 0000000..2ebcdfd --- /dev/null +++ b/tableview.cpp @@ -0,0 +1,357 @@ +#include "tableview.h" + +#include "duckdb.h" +#include "../../../../cpp/vendor/sqlite3/sqlite3.h" + +#include +#include +#include +#include + +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& columns, + const char* sql_filter, + const char* ops_db, + const char* row_type, + int64_t offset, int64_t limit, + std::vector* 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::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* 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 diff --git a/tableview.h b/tableview.h new file mode 100644 index 0000000..d81a79b --- /dev/null +++ b/tableview.h @@ -0,0 +1,79 @@ +#pragma once +#include +#include +#include +#include + +// 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 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& 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* 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 ':' 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* out); + +} // namespace ge diff --git a/views.cpp b/views.cpp index bc6f0a8..7aedb7b 100644 --- a/views.cpp +++ b/views.cpp @@ -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) // ---------------------------------------------------------------------------- diff --git a/views.h b/views.h index 0b50d69..ea8b06b 100644 --- a/views.h +++ b/views.h @@ -6,6 +6,9 @@ #include "types_registry.h" #include "entity_ops.h" +#include +#include + struct GraphData; struct GraphViewportState; @@ -137,6 +140,11 @@ struct AppState { std::vector insp_tag_suggestions; std::vector 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 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