feat(0036b): NodeGroups admite kind=Group + loader entities
NodeGroupsWindowState gana un discriminador `kind` (Table | Group) y
un flag `focus_request` (lo consumira 0036c). Por defecto Table, asi
que el flujo historico (DuckDB rows tras expand de un nodo Table) no
cambia.
kind=Group lee directamente operations.db consultando
`entities WHERE group_id = container_id` con columnas fijas
(id, name, type_ref, status, updated_at) ordenadas por updated_at DESC.
Los nuevos loaders viven en node_groups.cpp:
- node_groups_count_for_group -> SELECT count(*) ...
- node_groups_page_for_group -> SELECT id,name,type_ref,status,
updated_at ... LIMIT ? OFFSET ?
Para columnas, opcion (A) del issue: pre-popular meta.columns con la
lista fija al abrir kind=Group, asi el render se mantiene generico.
NodeGroupsRow.values guarda los 5 campos en ese orden y row.id es la
key natural (= entity_id de la fila — al ser ya entidad, no hace falta
promocionarla).
Render en views.cpp ramifica por kind:
- Table: layout original [id_col + columns + promoted] con doble
click -> promote/focus.
- Group: layout [columns fijas] sin promoted. Doble click sobre la
fila ya pone want_focus_entity = id (los flujos posteriores 0036c-e
afinan UX). Right click ofrece "Focus in Inspector".
main.cpp dispatcha por kind al refrescar paginas y, al cerrar via X,
solo llama a node_groups_set_expanded para kind=Table (Group no usa
ese flag).
views_node_groups_windows_sync se hace kind-aware: solo reconcilia
entries kind=Table contra el set de Tables expandidas; no toca las
entries kind=Group (las gestiona views_node_groups_open).
Nueva API publica:
views_node_groups_open(app, container_id, kind, ops_db)
Crea o reusa la entry, setea focus_request=true y para kind=Group
pre-popula meta.columns + intenta leer `name` del Group para el
titulo. Sin caller todavia — la consume 0036c.
Tests:
- tests/test_node_groups_loader.py (6 tests) verifica el contrato
SQL via gx-cli. Nuevo subcomando `gx-cli group page <id>` espejea
el loader C++ exactamente (mismo SQL); tambien expuesto como tool
MCP `group_page` para que Echo pueda inspeccionar Groups.
Resultado:
- WSL: 89 -> 95 passed
- Windows: 78+11 -> 84+11 passed
- Build C++ Windows limpio, sin warnings nuevos.
- Regresion kind=Table: comportamiento identico (mismo render,
mismo loader DuckDB).
Refs: issues/0036b-kind-discriminator-and-group-loader.md
This commit is contained in:
@@ -560,6 +560,38 @@ def cmd_table_page(args) -> None:
|
||||
"offset": args.offset, "limit": args.limit, "rows": rows})
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# group ops (issue 0036b) — espejo Python del loader C++ kind=Group
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
def cmd_group_page(args) -> None:
|
||||
"""Lista entidades hijas de un Group (entities.group_id = ?).
|
||||
|
||||
Espejea exactamente la query del loader C++
|
||||
`node_groups_page_for_group` para que los tests pytest verifiquen
|
||||
el contrato SQL (mismo orden de filas, mismas columnas) sin depender
|
||||
del binario. Util tambien como tool MCP para que el agente Echo
|
||||
inspeccione el contenido de un Group sin abrir la app.
|
||||
"""
|
||||
cn = _connect(_ops_db(), readonly=True)
|
||||
total = cn.execute(
|
||||
"SELECT count(*) FROM entities WHERE group_id = ?",
|
||||
(args.container_id,),
|
||||
).fetchone()[0]
|
||||
limit = max(1, min(int(args.limit), 5000))
|
||||
offset = max(0, int(args.offset))
|
||||
cur = cn.execute(
|
||||
"SELECT id, name, type_ref, status, updated_at "
|
||||
"FROM entities WHERE group_id = ? "
|
||||
"ORDER BY updated_at DESC LIMIT ? OFFSET ?",
|
||||
(args.container_id, limit, offset),
|
||||
)
|
||||
rows = [dict(r) for r in cur.fetchall()]
|
||||
cn.close()
|
||||
_emit({"ok": True, "container": args.container_id, "total": total,
|
||||
"offset": offset, "limit": limit, "rows": rows})
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# enricher ops
|
||||
# ----------------------------------------------------------------------------
|
||||
@@ -841,6 +873,13 @@ MCP_TOOLS = [
|
||||
"description": "Borra la entidad promovida. La fila DuckDB queda intacta.",
|
||||
"inputSchema": {"type": "object", "properties": {
|
||||
"id": {"type": "string"}}, "required": ["id"]}},
|
||||
{"name": "group_page",
|
||||
"description": "Lista entidades hijas de un Group (entities.group_id = container_id). Espejea el loader C++ de NodeGroups kind=Group.",
|
||||
"inputSchema": {"type": "object", "properties": {
|
||||
"container_id": {"type": "string"},
|
||||
"offset": {"type": "integer", "default": 0, "minimum": 0},
|
||||
"limit": {"type": "integer", "default": 200, "minimum": 1, "maximum": 5000}},
|
||||
"required": ["container_id"]}},
|
||||
{"name": "enricher_list",
|
||||
"description": "Lista enrichers cargados. Si se pasa type, filtra por applies_to.",
|
||||
"inputSchema": {"type": "object", "properties": {
|
||||
@@ -902,6 +941,7 @@ MCP_DISPATCH = {
|
||||
"table_page": (cmd_table_page, {"offset": 0, "limit": 50}),
|
||||
"table_promote": (cmd_table_promote, {}),
|
||||
"table_demote": (cmd_table_demote, {}),
|
||||
"group_page": (cmd_group_page, {"offset": 0, "limit": 200}),
|
||||
"enricher_list": (cmd_enricher_list, {"type": None}),
|
||||
"enricher_run": (cmd_enricher_run, {"node": None, "params": None}),
|
||||
"query": (cmd_query, {"limit": 100}),
|
||||
@@ -1093,6 +1133,15 @@ def main() -> None:
|
||||
sp.add_argument("--limit", type=int, default=50)
|
||||
sp.set_defaults(fn=cmd_table_page)
|
||||
|
||||
# group (issue 0036b)
|
||||
g = sub.add_parser("group").add_subparsers(dest="op", required=True)
|
||||
sp = g.add_parser("page",
|
||||
help="Lista entidades hijas de un Group (group_id=?)")
|
||||
sp.add_argument("container_id")
|
||||
sp.add_argument("--offset", type=int, default=0)
|
||||
sp.add_argument("--limit", type=int, default=200)
|
||||
sp.set_defaults(fn=cmd_group_page)
|
||||
|
||||
# enricher
|
||||
e = sub.add_parser("enricher").add_subparsers(dest="op", required=True)
|
||||
sp = e.add_parser("list")
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
id: 0036b
|
||||
title: NodeGroups window con kind (Table | Group) y loader para Groups
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-05-04
|
||||
parent: 0036
|
||||
depends_on: [0036a]
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Que la NodeGroups window admita dos kinds: `Table` (DuckDB-backed,
|
||||
comportamiento actual) y `Group` (entidades hijas con `group_id` set).
|
||||
La window se elige por el `type_ref` del contenedor; el loader y las
|
||||
columnas mostradas se ramifican por kind.
|
||||
|
||||
## Cambios
|
||||
|
||||
### `NodeGroupsWindowState` extendido
|
||||
|
||||
Anyadir campo:
|
||||
|
||||
```cpp
|
||||
enum class NodeGroupsKind { Table, Group };
|
||||
NodeGroupsKind kind = NodeGroupsKind::Table;
|
||||
```
|
||||
|
||||
### Loaders por kind (en `node_groups.cpp`)
|
||||
|
||||
- **kind = Table**: comportamiento actual (`node_groups_load_metadata`
|
||||
+ `node_groups_page_rows` sobre DuckDB).
|
||||
- **kind = Group**: nuevo loader que hace
|
||||
`SELECT id, name, type_ref, status, updated_at FROM entities
|
||||
WHERE group_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?`
|
||||
+ `SELECT count(*)` para `total_rows`.
|
||||
Las columnas que se renderizan son fijas:
|
||||
`id, name, type_ref, status, updated_at`.
|
||||
|
||||
Convertir el dispatch en un metodo o switch dentro de
|
||||
`node_groups_load_metadata` y `node_groups_page_rows` que mire `kind`.
|
||||
|
||||
### Columnas dinamicas en el render
|
||||
|
||||
Hoy `views.cpp` (en la pintura de la window) asume las columnas
|
||||
DuckDB. Adaptar para que cuando `kind == Group` use las columnas
|
||||
fijas listadas arriba.
|
||||
|
||||
### Apertura programatica
|
||||
|
||||
Para que 0036c pueda abrir una window de Group, exponer una API
|
||||
limpia tipo:
|
||||
|
||||
```cpp
|
||||
NodeGroupsWindowState* node_groups_open(AppState& app,
|
||||
const std::string& container_id,
|
||||
NodeGroupsKind kind);
|
||||
```
|
||||
|
||||
Que crea la entrada en `app.node_groups_windows[container_id]` si no
|
||||
existe, le pone el kind, y retorna puntero. El caller puede setear
|
||||
`focus_request = true` antes de devolver el control al render.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Tests pytest siguen verdes.
|
||||
- Manual: insertar via SQL un nodo `Group` con 5 entidades hijas
|
||||
(`group_id = <ese group>`), luego en la app llamar a
|
||||
`node_groups_open(app, "<group_id>", Group)` (o disparar via test
|
||||
unitario en C++ si se incluye), recargar render → la window
|
||||
muestra las 5 entidades con columnas id/name/type_ref/status/
|
||||
updated_at correctas.
|
||||
- Apertura de un Table existente (kind=Table) sigue funcionando
|
||||
identico (regresion).
|
||||
|
||||
## TBD
|
||||
|
||||
Branch `issue/0036b-kind-and-group-loader`, merge `--no-ff` a master.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Disparar drill-in desde doble click sobre Group (es 0036c).
|
||||
- Promote / row click (0036d-e).
|
||||
@@ -1697,11 +1697,15 @@ static void render() {
|
||||
g_app.want_toggle_nodegroups = false;
|
||||
g_app.toggle_nodegroups_id.clear();
|
||||
}
|
||||
// Cierre via X de la ventana -> bajar expanded en BD.
|
||||
// Cierre via X de la ventana -> bajar expanded en BD (solo kind=Table).
|
||||
// En kind=Group no hay metadata `expanded`; basta con borrar la entry.
|
||||
for (auto it = g_app.node_groups_windows.begin(); it != g_app.node_groups_windows.end(); ) {
|
||||
if (!it->second.open && !g_input_path.empty()) {
|
||||
ge::node_groups_set_expanded(g_input_path.c_str(),
|
||||
it->first.c_str(), false);
|
||||
if (!it->second.open) {
|
||||
if (it->second.kind == ge::NodeGroupsKind::Table
|
||||
&& !g_input_path.empty()) {
|
||||
ge::node_groups_set_expanded(g_input_path.c_str(),
|
||||
it->first.c_str(), false);
|
||||
}
|
||||
it = g_app.node_groups_windows.erase(it);
|
||||
} else ++it;
|
||||
}
|
||||
@@ -1711,6 +1715,36 @@ static void render() {
|
||||
if (!w.page_dirty) continue;
|
||||
const auto& m = w.meta;
|
||||
w.last_error.clear();
|
||||
|
||||
if (w.kind == ge::NodeGroupsKind::Group) {
|
||||
// kind=Group: contar y paginar entidades hijas via group_id.
|
||||
bool ok_count = ge::node_groups_count_for_group(
|
||||
g_input_path.c_str(),
|
||||
m.entity_id.c_str(), &w.total_rows);
|
||||
if (!ok_count) {
|
||||
char buf[256];
|
||||
std::snprintf(buf, sizeof(buf),
|
||||
"group count failed | container=%s", m.entity_id.c_str());
|
||||
w.last_error = buf;
|
||||
std::fprintf(stderr, "[graph_explorer] %s\n", buf);
|
||||
}
|
||||
bool ok_page = ge::node_groups_page_for_group(
|
||||
g_input_path.c_str(),
|
||||
m.entity_id.c_str(),
|
||||
w.offset, 200, &w.page);
|
||||
if (!ok_page && w.last_error.empty()) {
|
||||
char buf[256];
|
||||
std::snprintf(buf, sizeof(buf),
|
||||
"group page query failed | offset=%lld limit=200",
|
||||
(long long)w.offset);
|
||||
w.last_error = buf;
|
||||
std::fprintf(stderr, "[graph_explorer] %s\n", buf);
|
||||
}
|
||||
w.page_dirty = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// kind=Table: comportamiento original (DuckDB).
|
||||
bool ok_count = ge::node_groups_count(m.duckdb_path_abs.c_str(),
|
||||
m.table_name.c_str(),
|
||||
m.filter_sql.empty() ? nullptr : m.filter_sql.c_str(),
|
||||
|
||||
@@ -807,4 +807,93 @@ bool node_groups_ingest_file(const char* duckdb_path,
|
||||
return true;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Issue 0036b — kind=Group loaders sobre operations.db
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
bool node_groups_count_for_group(const char* ops_db,
|
||||
const char* container_id,
|
||||
int64_t* out_total)
|
||||
{
|
||||
if (!ops_db || !container_id || !out_total) return false;
|
||||
*out_total = 0;
|
||||
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 count(*) FROM entities WHERE group_id = ?";
|
||||
sqlite3_stmt* st = nullptr;
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) != SQLITE_OK) {
|
||||
sqlite3_close(db);
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(st, 1, container_id, -1, SQLITE_TRANSIENT);
|
||||
bool ok = false;
|
||||
if (sqlite3_step(st) == SQLITE_ROW) {
|
||||
*out_total = sqlite3_column_int64(st, 0);
|
||||
ok = true;
|
||||
}
|
||||
sqlite3_finalize(st);
|
||||
sqlite3_close(db);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool node_groups_page_for_group(const char* ops_db,
|
||||
const char* container_id,
|
||||
int64_t offset, int64_t limit,
|
||||
std::vector<NodeGroupsRow>* out_rows)
|
||||
{
|
||||
if (!ops_db || !container_id || !out_rows) return false;
|
||||
out_rows->clear();
|
||||
if (limit < 1) limit = 1;
|
||||
if (limit > 5000) limit = 5000;
|
||||
|
||||
sqlite3* db = nullptr;
|
||||
if (sqlite3_open_v2(ops_db, &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK) {
|
||||
if (db) sqlite3_close(db);
|
||||
return false;
|
||||
}
|
||||
// Columnas fijas: id, name, type_ref, status, updated_at — el orden
|
||||
// espejea la lista que pre-pobla views_node_groups_open() en meta.columns
|
||||
// para que el render se mantenga generico.
|
||||
const char* sql =
|
||||
"SELECT id, name, type_ref, status, updated_at "
|
||||
"FROM entities WHERE group_id = ? "
|
||||
"ORDER BY updated_at DESC "
|
||||
"LIMIT ? OFFSET ?";
|
||||
sqlite3_stmt* st = nullptr;
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) != SQLITE_OK) {
|
||||
sqlite3_close(db);
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text (st, 1, container_id, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int64(st, 2, limit);
|
||||
sqlite3_bind_int64(st, 3, offset);
|
||||
while (sqlite3_step(st) == SQLITE_ROW) {
|
||||
NodeGroupsRow row;
|
||||
auto col_text = [&](int i) -> std::string {
|
||||
const unsigned char* p = sqlite3_column_text(st, i);
|
||||
return p ? std::string((const char*)p) : std::string();
|
||||
};
|
||||
// id va en NodeGroupsRow.id (key natural) y tambien en values[0]
|
||||
// para que el render pinte la columna "id" igual que las demas.
|
||||
row.id = col_text(0);
|
||||
row.values.reserve(5);
|
||||
row.values.push_back(row.id); // id
|
||||
row.values.push_back(col_text(1)); // name
|
||||
row.values.push_back(col_text(2)); // type_ref
|
||||
row.values.push_back(col_text(3)); // status
|
||||
row.values.push_back(col_text(4)); // updated_at
|
||||
// promoted_entity_id no aplica en kind=Group — la fila YA es una
|
||||
// entidad real del grafo, asi que la dejamos vacia (el render
|
||||
// mostrara "-" en la columna 'promoted', que ocultamos para Group
|
||||
// mas abajo en views.cpp).
|
||||
out_rows->push_back(std::move(row));
|
||||
}
|
||||
sqlite3_finalize(st);
|
||||
sqlite3_close(db);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace ge
|
||||
|
||||
@@ -159,4 +159,21 @@ bool node_groups_list_columns(const char* duckdb_path,
|
||||
const char* duck_table,
|
||||
std::vector<std::string>* out);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Issue 0036b — kind discriminator + Group loader
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Loaders especificos para kind=Group. Operan sobre operations.db
|
||||
// consultando `entities` filtrando por `group_id`. Las columnas del
|
||||
// result son fijas (id, name, type_ref, status, updated_at) y se mapean
|
||||
// a NodeGroupsRow.values en ese orden. Devuelven false si la query falla.
|
||||
bool node_groups_count_for_group(const char* ops_db,
|
||||
const char* container_id,
|
||||
int64_t* out_total);
|
||||
|
||||
bool node_groups_page_for_group(const char* ops_db,
|
||||
const char* container_id,
|
||||
int64_t offset, int64_t limit,
|
||||
std::vector<NodeGroupsRow>* out_rows);
|
||||
|
||||
} // namespace ge
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Tests del loader NodeGroups kind=Group (issue 0036b).
|
||||
|
||||
El binario C++ implementa `node_groups_count_for_group` y
|
||||
`node_groups_page_for_group` sobre operations.db. El subcomando
|
||||
`gx-cli group page <container_id>` espejea exactamente esa query, asi
|
||||
estos tests verifican el contrato SQL: count + columnas fijas
|
||||
(id, name, type_ref, status, updated_at) + ORDER BY updated_at DESC.
|
||||
|
||||
Reusa la fixture `env_dirs` del modulo de tests del CLI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from test_gx_cli import OPS_SCHEMA, APP_SCHEMA, env_dirs, run_gx # noqa: F401
|
||||
|
||||
|
||||
def _seed_group_with_children(ops_db, group_id: str, n_children: int):
|
||||
"""Inserta un Group + n_children entidades hijas con group_id seteado.
|
||||
|
||||
Las hijas se crean con updated_at distintos (separados un milisegundo)
|
||||
para que el ORDER BY updated_at DESC tenga orden estable y verificable.
|
||||
"""
|
||||
cn = sqlite3.connect(ops_db)
|
||||
try:
|
||||
# El Group contenedor — type_ref='Group' (definicion en 0035a).
|
||||
cn.execute(
|
||||
"INSERT INTO entities(id, name, type_ref, status, source, "
|
||||
" metadata, created_at, updated_at) "
|
||||
"VALUES (?, ?, 'Group', 'active', 'manual', '{}', "
|
||||
" '2026-05-04T10:00:00.000Z', '2026-05-04T10:00:00.000Z')",
|
||||
(group_id, "test-group"),
|
||||
)
|
||||
# Hijas: Word entities con group_id apuntando al Group. Generamos
|
||||
# updated_at descendente para que el orden ORDER BY DESC sea
|
||||
# determinista (la primera insertada queda al final del ranking).
|
||||
for i in range(n_children):
|
||||
child_id = f"word_{i:02d}"
|
||||
ts = f"2026-05-04T11:{i:02d}:00.000Z"
|
||||
cn.execute(
|
||||
"INSERT INTO entities(id, name, type_ref, status, source, "
|
||||
" metadata, group_id, "
|
||||
" created_at, updated_at) "
|
||||
"VALUES (?, ?, 'Word', 'active', 'manual', '{}', ?, ?, ?)",
|
||||
(child_id, f"word-{i}", group_id, ts, ts),
|
||||
)
|
||||
# Una entidad sin group_id para verificar que NO aparece en el page.
|
||||
cn.execute(
|
||||
"INSERT INTO entities(id, name, type_ref, status, source, "
|
||||
" metadata, created_at, updated_at) "
|
||||
"VALUES ('orphan_01', 'orphan', 'Word', 'active', 'manual', '{}', "
|
||||
" '2026-05-04T09:00:00.000Z', '2026-05-04T09:00:00.000Z')"
|
||||
)
|
||||
cn.commit()
|
||||
finally:
|
||||
cn.close()
|
||||
|
||||
|
||||
class TestGroupPage:
|
||||
def test_count_matches_children(self, env_dirs):
|
||||
_seed_group_with_children(env_dirs["ops"], "grp_alpha", n_children=5)
|
||||
out = run_gx(env_dirs, "group", "page", "grp_alpha")
|
||||
assert out["total"] == 5, out
|
||||
assert len(out["rows"]) == 5
|
||||
|
||||
def test_columns_are_fixed_set(self, env_dirs):
|
||||
_seed_group_with_children(env_dirs["ops"], "grp_alpha", n_children=3)
|
||||
out = run_gx(env_dirs, "group", "page", "grp_alpha")
|
||||
assert out["rows"], out
|
||||
first = out["rows"][0]
|
||||
assert set(first.keys()) == {"id", "name", "type_ref",
|
||||
"status", "updated_at"}
|
||||
|
||||
def test_orphan_not_listed(self, env_dirs):
|
||||
_seed_group_with_children(env_dirs["ops"], "grp_alpha", n_children=3)
|
||||
out = run_gx(env_dirs, "group", "page", "grp_alpha")
|
||||
ids = {r["id"] for r in out["rows"]}
|
||||
assert "orphan_01" not in ids
|
||||
assert ids == {"word_00", "word_01", "word_02"}
|
||||
|
||||
def test_order_by_updated_at_desc(self, env_dirs):
|
||||
_seed_group_with_children(env_dirs["ops"], "grp_alpha", n_children=4)
|
||||
out = run_gx(env_dirs, "group", "page", "grp_alpha")
|
||||
ids_in_order = [r["id"] for r in out["rows"]]
|
||||
# word_03 tiene el updated_at mas reciente (11:03), word_00 el mas
|
||||
# antiguo (11:00). El ORDER BY ... DESC los pone en ese orden.
|
||||
assert ids_in_order == ["word_03", "word_02", "word_01", "word_00"]
|
||||
|
||||
def test_pagination_offset_limit(self, env_dirs):
|
||||
_seed_group_with_children(env_dirs["ops"], "grp_alpha", n_children=10)
|
||||
page1 = run_gx(env_dirs, "group", "page", "grp_alpha",
|
||||
"--limit", "3", "--offset", "0")
|
||||
page2 = run_gx(env_dirs, "group", "page", "grp_alpha",
|
||||
"--limit", "3", "--offset", "3")
|
||||
assert page1["total"] == 10
|
||||
assert page2["total"] == 10
|
||||
ids1 = [r["id"] for r in page1["rows"]]
|
||||
ids2 = [r["id"] for r in page2["rows"]]
|
||||
# No deben solaparse y el orden global sigue DESC por updated_at.
|
||||
assert set(ids1).isdisjoint(set(ids2))
|
||||
assert ids1 == ["word_09", "word_08", "word_07"]
|
||||
assert ids2 == ["word_06", "word_05", "word_04"]
|
||||
|
||||
def test_unknown_container_returns_empty(self, env_dirs):
|
||||
# Sin seed — la BD esta vacia.
|
||||
out = run_gx(env_dirs, "group", "page", "no_such_group")
|
||||
assert out["total"] == 0
|
||||
assert out["rows"] == []
|
||||
@@ -1873,6 +1873,78 @@ void views_table(AppState& app) {
|
||||
// Table node UI fase 2 (issue 0011) — ventana expandida + import
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
AppState::NodeGroupsWindowState*
|
||||
views_node_groups_open(AppState& app,
|
||||
const std::string& container_id,
|
||||
NodeGroupsKind kind,
|
||||
const char* ops_db)
|
||||
{
|
||||
if (container_id.empty()) return nullptr;
|
||||
|
||||
auto it = app.node_groups_windows.find(container_id);
|
||||
if (it != app.node_groups_windows.end()) {
|
||||
// Ya existe — no recargar metadata, solo pedir focus. El kind se
|
||||
// respeta tal como estaba (mover entre kinds para el mismo id no
|
||||
// tiene sentido en la UI actual).
|
||||
it->second.open = true;
|
||||
it->second.focus_request = true;
|
||||
return &it->second;
|
||||
}
|
||||
|
||||
auto& w = app.node_groups_windows[container_id];
|
||||
w.kind = kind;
|
||||
w.open = true;
|
||||
w.focus_request = true;
|
||||
w.page_dirty = true;
|
||||
w.offset = 0;
|
||||
w.page.clear();
|
||||
w.total_rows = 0;
|
||||
w.last_error.clear();
|
||||
|
||||
// Pre-popular meta segun el kind. Para kind=Group, las columnas son
|
||||
// fijas y conocidas — no hace falta tocar BD para descubrirlas, y
|
||||
// tampoco hay un nodo type='Table' que leer.
|
||||
w.meta = NodeGroupsMeta{};
|
||||
w.meta.entity_id = container_id;
|
||||
|
||||
if (kind == NodeGroupsKind::Group) {
|
||||
w.meta.columns = {"id", "name", "type_ref", "status", "updated_at"};
|
||||
w.meta.id_column = "id";
|
||||
w.meta.label_column = "name";
|
||||
// Best effort: leer el name del Group desde operations.db para que
|
||||
// el titulo de la ventana sea informativo. Si falla no bloquea.
|
||||
if (ops_db && *ops_db) {
|
||||
sqlite3* db = nullptr;
|
||||
if (sqlite3_open_v2(ops_db, &db, SQLITE_OPEN_READONLY, nullptr) == SQLITE_OK) {
|
||||
sqlite3_stmt* st = nullptr;
|
||||
if (sqlite3_prepare_v2(db,
|
||||
"SELECT name FROM entities WHERE id = ?",
|
||||
-1, &st, nullptr) == SQLITE_OK) {
|
||||
sqlite3_bind_text(st, 1, container_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (sqlite3_step(st) == SQLITE_ROW) {
|
||||
const unsigned char* p = sqlite3_column_text(st, 0);
|
||||
if (p) w.meta.name = (const char*)p;
|
||||
}
|
||||
sqlite3_finalize(st);
|
||||
}
|
||||
sqlite3_close(db);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// kind=Table: cargar metadata real del nodo Table-typed. El path
|
||||
// tipico para entries creadas por views_node_groups_windows_sync
|
||||
// ya hace esto, pero si llaman a views_node_groups_open directo
|
||||
// queremos comportamiento equivalente.
|
||||
if (ops_db && *ops_db) {
|
||||
NodeGroupsMeta meta;
|
||||
if (node_groups_get_metadata(ops_db, container_id.c_str(), &meta)) {
|
||||
w.meta = std::move(meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
return &w;
|
||||
}
|
||||
|
||||
void views_node_groups_windows_sync(AppState& app, const char* ops_db) {
|
||||
if (!app.graph || !ops_db) return;
|
||||
GraphData& g = *app.graph;
|
||||
@@ -1914,8 +1986,11 @@ void views_node_groups_windows_sync(AppState& app, const char* ops_db) {
|
||||
sqlite3_finalize(st);
|
||||
sqlite3_close(db);
|
||||
|
||||
// Quitar las que ya no estan expanded.
|
||||
// Quitar las que ya no estan expanded — pero solo las kind=Table.
|
||||
// Las kind=Group viven en operations.db con su propia condicion de
|
||||
// existencia (entity con type_ref='Group') y no deben tocarse aqui.
|
||||
for (auto it = app.node_groups_windows.begin(); it != app.node_groups_windows.end(); ) {
|
||||
if (it->second.kind == NodeGroupsKind::Group) { ++it; continue; }
|
||||
if (live.find(it->first) == live.end()) it = app.node_groups_windows.erase(it);
|
||||
else ++it;
|
||||
}
|
||||
@@ -1927,6 +2002,7 @@ void views_node_groups_windows_sync(AppState& app, const char* ops_db) {
|
||||
for (auto& kv : live) {
|
||||
auto& w = app.node_groups_windows[kv.first];
|
||||
bool was_present = !w.meta.entity_id.empty();
|
||||
w.kind = NodeGroupsKind::Table; // expanded -> siempre Table
|
||||
w.meta = std::move(kv.second);
|
||||
w.open = true;
|
||||
w.page_dirty = true;
|
||||
@@ -1947,101 +2023,160 @@ void views_node_groups_window(AppState& app) {
|
||||
NodeGroupsMeta& m = kv.second.meta;
|
||||
AppState::NodeGroupsWindowState& w = kv.second;
|
||||
|
||||
const bool is_group = (w.kind == NodeGroupsKind::Group);
|
||||
|
||||
char title[160];
|
||||
std::snprintf(title, sizeof(title), TI_TABLE " NodeGroups: %s##te_%s",
|
||||
m.name.empty() ? "(unnamed)" : m.name.c_str(),
|
||||
m.entity_id.c_str());
|
||||
if (is_group) {
|
||||
std::snprintf(title, sizeof(title), TI_TABLE " Group: %s##te_%s",
|
||||
m.name.empty() ? "(unnamed)" : m.name.c_str(),
|
||||
m.entity_id.c_str());
|
||||
} else {
|
||||
std::snprintf(title, sizeof(title), TI_TABLE " NodeGroups: %s##te_%s",
|
||||
m.name.empty() ? "(unnamed)" : m.name.c_str(),
|
||||
m.entity_id.c_str());
|
||||
}
|
||||
ImGui::SetNextWindowSize(ImVec2(640, 460), ImGuiCond_FirstUseEver);
|
||||
if (w.focus_request) {
|
||||
// El render lo consume 0036c — por ahora simplemente lo limpiamos
|
||||
// tras un frame para que no se quede pegado. Cuando 0036c llegue
|
||||
// anadira ImGui::SetNextWindowFocus() aqui.
|
||||
w.focus_request = false;
|
||||
}
|
||||
if (!ImGui::Begin(title, &w.open)) { ImGui::End(); continue; }
|
||||
|
||||
// Header de info
|
||||
ImGui::TextDisabled("%s · %s · %lld rows",
|
||||
m.duckdb_path.c_str(), m.table_name.c_str(),
|
||||
(long long)w.total_rows);
|
||||
// Header de info (varia por kind)
|
||||
if (is_group) {
|
||||
ImGui::TextDisabled("group_id=%s · %lld rows",
|
||||
m.entity_id.c_str(),
|
||||
(long long)w.total_rows);
|
||||
} else {
|
||||
ImGui::TextDisabled("%s · %s · %lld rows",
|
||||
m.duckdb_path.c_str(), m.table_name.c_str(),
|
||||
(long long)w.total_rows);
|
||||
}
|
||||
if (!w.last_error.empty()) {
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f),
|
||||
"ERROR: %s", w.last_error.c_str());
|
||||
}
|
||||
ImGui::Separator();
|
||||
|
||||
// Tabla
|
||||
const int col_count = (int)m.columns.size() + 2; // id + columns... + promoted
|
||||
// Tabla — layout depende del kind:
|
||||
// Table: [id_column] + columns[] + [promoted] (col_count = N+2)
|
||||
// Group: columns[] (id, name, type_ref, status, updated_at)
|
||||
// (col_count = N)
|
||||
const int col_count = is_group
|
||||
? (int)m.columns.size()
|
||||
: (int)m.columns.size() + 2;
|
||||
ImGuiTableFlags tflags =
|
||||
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
|
||||
ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable |
|
||||
ImGuiTableFlags_SizingStretchProp;
|
||||
if (ImGui::BeginTable("##te_rows", col_count, tflags,
|
||||
if (col_count > 0 && ImGui::BeginTable("##te_rows", col_count, tflags,
|
||||
ImVec2(0, -ImGui::GetFrameHeightWithSpacing()))) {
|
||||
ImGui::TableSetupScrollFreeze(0, 1);
|
||||
ImGui::TableSetupColumn(m.id_column.empty() ? "id" : m.id_column.c_str(),
|
||||
ImGuiTableColumnFlags_WidthFixed, 100.0f);
|
||||
for (const auto& c : m.columns) {
|
||||
ImGui::TableSetupColumn(c.c_str(), ImGuiTableColumnFlags_WidthStretch);
|
||||
if (is_group) {
|
||||
for (size_t i = 0; i < m.columns.size(); ++i) {
|
||||
bool is_id = (i == 0);
|
||||
ImGui::TableSetupColumn(m.columns[i].c_str(),
|
||||
is_id ? ImGuiTableColumnFlags_WidthFixed
|
||||
: ImGuiTableColumnFlags_WidthStretch,
|
||||
is_id ? 160.0f : 0.0f);
|
||||
}
|
||||
} else {
|
||||
ImGui::TableSetupColumn(m.id_column.empty() ? "id" : m.id_column.c_str(),
|
||||
ImGuiTableColumnFlags_WidthFixed, 100.0f);
|
||||
for (const auto& c : m.columns) {
|
||||
ImGui::TableSetupColumn(c.c_str(), ImGuiTableColumnFlags_WidthStretch);
|
||||
}
|
||||
ImGui::TableSetupColumn("promoted",
|
||||
ImGuiTableColumnFlags_WidthFixed, 80.0f);
|
||||
}
|
||||
ImGui::TableSetupColumn("promoted",
|
||||
ImGuiTableColumnFlags_WidthFixed, 80.0f);
|
||||
ImGui::TableHeadersRow();
|
||||
|
||||
// Decidir paginacion por scroll: pedimos siempre 200 filas a
|
||||
// partir de offset; si el usuario llega cerca del final,
|
||||
// avanzamos offset.
|
||||
const int64_t page_size = 200;
|
||||
for (int64_t i = 0; i < (int64_t)w.page.size(); ++i) {
|
||||
const NodeGroupsRow& row = w.page[i];
|
||||
ImGui::TableNextRow();
|
||||
ImGui::PushID((int)(w.offset + i));
|
||||
|
||||
ImGui::TableSetColumnIndex(0);
|
||||
bool is_promoted = !row.promoted_entity_id.empty();
|
||||
|
||||
// Selectable spanning para que el doble-click y el right-click
|
||||
// funcionen sobre toda la fila, no solo el texto del id.
|
||||
// funcionen sobre toda la fila, no solo el texto.
|
||||
ImGuiSelectableFlags sf = ImGuiSelectableFlags_SpanAllColumns
|
||||
| ImGuiSelectableFlags_AllowDoubleClick;
|
||||
ImGui::Selectable(row.id.c_str(), false, sf);
|
||||
if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) {
|
||||
if (is_promoted) {
|
||||
|
||||
if (is_group) {
|
||||
// En kind=Group la fila YA es una entidad real del grafo.
|
||||
// Doble click → focus inspector. Right click → focus.
|
||||
// (El boton "Promote" no aplica — 0036d hace eso para
|
||||
// contextos donde tenga sentido.)
|
||||
if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) {
|
||||
app.want_focus_entity = true;
|
||||
app.focus_entity_id = row.promoted_entity_id;
|
||||
} else {
|
||||
app.want_promote_row = true;
|
||||
app.promote_table_id = m.entity_id;
|
||||
app.promote_row_id = row.id;
|
||||
app.focus_entity_id = row.id;
|
||||
}
|
||||
}
|
||||
if (ImGui::BeginPopupContextItem()) {
|
||||
if (is_promoted) {
|
||||
if (ImGui::BeginPopupContextItem()) {
|
||||
if (ImGui::MenuItem(TI_FOCUS " Focus in Inspector")) {
|
||||
app.want_focus_entity = true;
|
||||
app.focus_entity_id = row.id;
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
// Render de las columnas (la 0 ya tiene el Selectable;
|
||||
// el texto del id se ve en el propio Selectable).
|
||||
for (size_t c = 1; c < m.columns.size(); ++c) {
|
||||
ImGui::TableSetColumnIndex((int)c);
|
||||
if (c < row.values.size())
|
||||
ImGui::TextUnformatted(row.values[c].c_str());
|
||||
}
|
||||
} else {
|
||||
// kind=Table — comportamiento original (DuckDB-backed).
|
||||
bool is_promoted = !row.promoted_entity_id.empty();
|
||||
if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) {
|
||||
if (is_promoted) {
|
||||
app.want_focus_entity = true;
|
||||
app.focus_entity_id = row.promoted_entity_id;
|
||||
}
|
||||
if (ImGui::MenuItem(TI_X " Demote (delete entity)")) {
|
||||
app.want_demote_entity = true;
|
||||
app.demote_entity_id = row.promoted_entity_id;
|
||||
}
|
||||
} else {
|
||||
if (ImGui::MenuItem(TI_PLUS " Promote to graph node")) {
|
||||
} else {
|
||||
app.want_promote_row = true;
|
||||
app.promote_table_id = m.entity_id;
|
||||
app.promote_row_id = row.id;
|
||||
}
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
if (ImGui::BeginPopupContextItem()) {
|
||||
if (is_promoted) {
|
||||
if (ImGui::MenuItem(TI_FOCUS " Focus in Inspector")) {
|
||||
app.want_focus_entity = true;
|
||||
app.focus_entity_id = row.promoted_entity_id;
|
||||
}
|
||||
if (ImGui::MenuItem(TI_X " Demote (delete entity)")) {
|
||||
app.want_demote_entity = true;
|
||||
app.demote_entity_id = row.promoted_entity_id;
|
||||
}
|
||||
} else {
|
||||
if (ImGui::MenuItem(TI_PLUS " Promote to graph node")) {
|
||||
app.want_promote_row = true;
|
||||
app.promote_table_id = m.entity_id;
|
||||
app.promote_row_id = row.id;
|
||||
}
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
for (size_t c = 0; c < m.columns.size(); ++c) {
|
||||
ImGui::TableSetColumnIndex(1 + (int)c);
|
||||
if (c < row.values.size())
|
||||
ImGui::TextUnformatted(row.values[c].c_str());
|
||||
}
|
||||
ImGui::TableSetColumnIndex(col_count - 1);
|
||||
if (is_promoted) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text,
|
||||
ImVec4(0.6f, 0.95f, 0.6f, 1.0f));
|
||||
ImGui::TextUnformatted("yes");
|
||||
ImGui::PopStyleColor();
|
||||
} else {
|
||||
ImGui::TextDisabled("-");
|
||||
for (size_t c = 0; c < m.columns.size(); ++c) {
|
||||
ImGui::TableSetColumnIndex(1 + (int)c);
|
||||
if (c < row.values.size())
|
||||
ImGui::TextUnformatted(row.values[c].c_str());
|
||||
}
|
||||
ImGui::TableSetColumnIndex(col_count - 1);
|
||||
if (is_promoted) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text,
|
||||
ImVec4(0.6f, 0.95f, 0.6f, 1.0f));
|
||||
ImGui::TextUnformatted("yes");
|
||||
ImGui::PopStyleColor();
|
||||
} else {
|
||||
ImGui::TextDisabled("-");
|
||||
}
|
||||
}
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@ struct GraphViewportState;
|
||||
|
||||
namespace ge {
|
||||
|
||||
// Discriminador de la NodeGroups window (issue 0036b). Una window puede
|
||||
// estar respaldada por una tabla DuckDB (kind=Table) o por una agrupacion
|
||||
// de entidades en operations.db via `entities.group_id` (kind=Group).
|
||||
enum class NodeGroupsKind { Table, Group };
|
||||
|
||||
// Estado compartido entre las vistas y el bucle render. Pasado por puntero
|
||||
// desde main.cpp.
|
||||
struct AppState {
|
||||
@@ -160,19 +165,26 @@ struct AppState {
|
||||
// Refrescado tras load_input y tras mutaciones que afecten a Tables.
|
||||
std::unordered_map<uint64_t, int64_t> node_groups_counts;
|
||||
|
||||
// ---- NodeGroups window (issue 0011, renombrado en 0036a) --------------
|
||||
// Estado runtime por ventana de NodeGroups (un Table-typed expandido).
|
||||
// Una entrada por entity_id de Table que el usuario haya expandido. La
|
||||
// ventana se cierra cuando set_expanded(false) — ya sea desde context
|
||||
// menu o cerrando la ImGui window (que pone el flag a false
|
||||
// automaticamente).
|
||||
// ---- NodeGroups window (issue 0011, renombrado en 0036a, kind en 0036b) -
|
||||
// Estado runtime por ventana de NodeGroups. Hay dos kinds (issue 0036b):
|
||||
// - Table: respaldada por DuckDB (el comportamiento original — un nodo
|
||||
// `Table` del grafo que apunta a un .duckdb + table_name).
|
||||
// - Group: respaldada por la propia operations.db. Lista las entidades
|
||||
// hijas (`entities.group_id = container_id`) con columnas fijas
|
||||
// id/name/type_ref/status/updated_at.
|
||||
//
|
||||
// Una entrada por container_id (entity_id del nodo contenedor — Table o
|
||||
// Group). La ventana se cierra al pulsar la X de ImGui o, en kind=Table,
|
||||
// al hacer set_expanded(false) desde el menu contextual.
|
||||
struct NodeGroupsWindowState {
|
||||
NodeGroupsKind kind = NodeGroupsKind::Table; // default compat 0036a
|
||||
NodeGroupsMeta meta; // refrescada cada vez que entity cambia
|
||||
int64_t total_rows = 0;
|
||||
int64_t offset = 0;
|
||||
std::vector<NodeGroupsRow> page;
|
||||
bool page_dirty = true;
|
||||
bool open = true; // bound a ImGui::Begin
|
||||
bool focus_request = false; // 0036c: pedir SetWindowFocus
|
||||
std::string last_error; // ultimo error de query (vacio = OK)
|
||||
};
|
||||
std::unordered_map<std::string, NodeGroupsWindowState> node_groups_windows;
|
||||
@@ -356,6 +368,25 @@ bool views_import_dataset_modal(AppState& app);
|
||||
// entradas para nuevos expanded y borra las que ya no aplican.
|
||||
void views_node_groups_windows_sync(AppState& app, const char* ops_db);
|
||||
|
||||
// Crea o reusa una entrada en `app.node_groups_windows[container_id]` y la
|
||||
// marca con el `kind` indicado. Setea `focus_request = true` para que el
|
||||
// render pueda llamar a ImGui::SetWindowFocus (lo consume 0036c). Si la
|
||||
// entry ya existe se respeta su kind anterior y solo se setea
|
||||
// focus_request — no recarga ni resetea offset. Si es nueva, llama a
|
||||
// `node_groups_load_metadata` para popular `meta` (en kind=Group eso pre-
|
||||
// puebla las columnas fijas; en kind=Table lee la metadata del Table-typed
|
||||
// node de operations.db).
|
||||
//
|
||||
// `ops_db` es el path de operations.db. Si esta vacio, no se carga
|
||||
// metadata pero la entry se crea de todos modos (caller puede rellenar
|
||||
// despues). Devuelve puntero a la entry — nunca nullptr salvo
|
||||
// container_id vacio.
|
||||
AppState::NodeGroupsWindowState*
|
||||
views_node_groups_open(AppState& app,
|
||||
const std::string& container_id,
|
||||
NodeGroupsKind kind,
|
||||
const char* ops_db);
|
||||
|
||||
// ---- Table node overlay (issue 0010) ------------------------------------
|
||||
|
||||
// Dibuja un overlay rectangulo redondeado sobre cada nodo `Table` del grafo
|
||||
|
||||
Reference in New Issue
Block a user