feat(viz): graph_sources lector operations.db + streaming (issue 0049g)
- graph_load_from_operations: SQLite read-only, schema-detect (type_ref/type, from_entity/source, to_entity/target, name/type, weight, updated_at). - 16-color indigo palette por hash FNV1a32 del nombre de tipo. user_data por nodo es FNV1a64(entity.id) — deterministico entre cargas. - Label pool interno: metadata.name (JSON simple) > entities.name > id. - graph_free libera nodes/edges/types/rel_types/labels/strdup'd names via arena_map (GraphData* -> arena). - Streaming pull-based con tiebreak (updated_at, id) y crecimiento x2 de capacidad. Tipos nuevos descubiertos en stream se anaden a types. - Tests: fixture in-memory (3 entity types, 2 rel types, 10 entities, 15 relations) + smoke contra apps/script_navegador/operations.db. - Issue movido a completed/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,675 @@
|
|||||||
|
#include "graph_sources.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "../../vendor/sqlite3/sqlite3.h"
|
||||||
|
|
||||||
|
namespace graph {
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hash y palette por defecto
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static uint32_t fnv1a32(const char* s) {
|
||||||
|
uint32_t h = 2166136261u;
|
||||||
|
for (; s && *s; ++s) {
|
||||||
|
h ^= (uint8_t)*s;
|
||||||
|
h *= 16777619u;
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint64_t fnv1a64(const char* s) {
|
||||||
|
uint64_t h = 1469598103934665603ULL;
|
||||||
|
for (; s && *s; ++s) {
|
||||||
|
h ^= (uint8_t)*s;
|
||||||
|
h *= 1099511628211ULL;
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 16 colores indigo-friendly RGBA8 (R en LSB). Suficiente variedad pero
|
||||||
|
// armonia visual: hue rotando ~22 grados, saturacion media, luminancia
|
||||||
|
// estable. Si dos tipos colisionan en el palette, no es critico — el caller
|
||||||
|
// puede aplicar overrides via types.yaml.
|
||||||
|
static const uint32_t kDefaultPalette[16] = {
|
||||||
|
0xFF7C6FECu, 0xFFE0703Cu, 0xFF36C2A8u, 0xFFD96EB6u,
|
||||||
|
0xFF8FB85Eu, 0xFFE0C24Au, 0xFF5BA8E0u, 0xFFC97070u,
|
||||||
|
0xFFA67BD9u, 0xFF60B89Bu, 0xFFE08C4Au, 0xFF7995E0u,
|
||||||
|
0xFFB8607Au, 0xFF6FB4D9u, 0xFFC09A4Au, 0xFF8FA0E0u,
|
||||||
|
};
|
||||||
|
|
||||||
|
static uint32_t default_color_for(const char* type_name) {
|
||||||
|
return kDefaultPalette[fnv1a32(type_name) & 0xFu];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// LoaderArena: dueno de la memoria que cuelga del GraphData devuelto.
|
||||||
|
// Mantenemos un magic header en `nodes`/`edges` para que graph_free pueda
|
||||||
|
// localizar el arena en O(1) sin un mapa global.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct LoaderArena {
|
||||||
|
GraphNode* nodes = nullptr;
|
||||||
|
GraphEdge* edges = nullptr;
|
||||||
|
EntityType* types = nullptr;
|
||||||
|
RelationType* rel_types = nullptr;
|
||||||
|
// Nombres de tipos (strdup'd; los apunta types[i].name / rel_types[i].name).
|
||||||
|
std::vector<char*> type_names;
|
||||||
|
std::vector<char*> rel_type_names;
|
||||||
|
// String pool de labels (idx 0 reservado a "").
|
||||||
|
std::vector<char*> labels;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mapa GraphData* → LoaderArena*. Como el caller puede tener varios
|
||||||
|
// GraphData a la vez, usar un static unordered_map es la opcion mas simple
|
||||||
|
// que no contamina la struct publica (que es POD para vertex pulling).
|
||||||
|
static std::unordered_map<const GraphData*, LoaderArena*>& arena_map() {
|
||||||
|
static std::unordered_map<const GraphData*, LoaderArena*> m;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stats helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static void zero_stats(GraphLoadStats* s) {
|
||||||
|
if (!s) return;
|
||||||
|
s->nodes_loaded = 0;
|
||||||
|
s->edges_loaded = 0;
|
||||||
|
s->types_discovered = 0;
|
||||||
|
s->rel_types_discovered = 0;
|
||||||
|
s->errors = 0;
|
||||||
|
s->error_msg[0] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
static void set_err(GraphLoadStats* s, const char* msg) {
|
||||||
|
if (!s) return;
|
||||||
|
s->errors++;
|
||||||
|
std::snprintf(s->error_msg, sizeof(s->error_msg), "%s", msg ? msg : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Schema detection: `entities` y `relations` cambian de columnas entre
|
||||||
|
// versiones de operations.db. Detectamos por PRAGMA table_info.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static bool table_exists(sqlite3* db, const char* name) {
|
||||||
|
sqlite3_stmt* st = nullptr;
|
||||||
|
const char* q = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?";
|
||||||
|
if (sqlite3_prepare_v2(db, q, -1, &st, nullptr) != SQLITE_OK) return false;
|
||||||
|
sqlite3_bind_text(st, 1, name, -1, SQLITE_STATIC);
|
||||||
|
bool found = (sqlite3_step(st) == SQLITE_ROW);
|
||||||
|
sqlite3_finalize(st);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool column_exists(sqlite3* db, const char* table, const char* column) {
|
||||||
|
char sql[256];
|
||||||
|
std::snprintf(sql, sizeof(sql), "PRAGMA table_info(%s)", table);
|
||||||
|
sqlite3_stmt* st = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) != SQLITE_OK) return false;
|
||||||
|
bool found = false;
|
||||||
|
while (sqlite3_step(st) == SQLITE_ROW) {
|
||||||
|
const unsigned char* n = sqlite3_column_text(st, 1);
|
||||||
|
if (n && std::strcmp((const char*)n, column) == 0) { found = true; break; }
|
||||||
|
}
|
||||||
|
sqlite3_finalize(st);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Schema {
|
||||||
|
std::string entity_type_col; // type_ref | type
|
||||||
|
std::string entity_meta_col; // metadata (puede no existir)
|
||||||
|
std::string entity_updated; // updated_at
|
||||||
|
std::string rel_src_col; // from_entity | source
|
||||||
|
std::string rel_tgt_col; // to_entity | target
|
||||||
|
std::string rel_type_col; // name | type
|
||||||
|
std::string rel_weight_col; // weight (puede no existir)
|
||||||
|
std::string rel_updated; // updated_at
|
||||||
|
};
|
||||||
|
|
||||||
|
static bool detect_schema(sqlite3* db, Schema* s, GraphLoadStats* stats) {
|
||||||
|
if (!table_exists(db, "entities")) {
|
||||||
|
set_err(stats, "missing table: entities");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!table_exists(db, "relations")) {
|
||||||
|
set_err(stats, "missing table: relations");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
s->entity_type_col = column_exists(db, "entities", "type_ref") ? "type_ref"
|
||||||
|
: column_exists(db, "entities", "type") ? "type"
|
||||||
|
: "";
|
||||||
|
if (s->entity_type_col.empty()) {
|
||||||
|
set_err(stats, "entities: missing type_ref/type column");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
s->entity_meta_col = column_exists(db, "entities", "metadata") ? "metadata" : "";
|
||||||
|
s->entity_updated = column_exists(db, "entities", "updated_at") ? "updated_at" : "";
|
||||||
|
|
||||||
|
s->rel_src_col = column_exists(db, "relations", "from_entity") ? "from_entity"
|
||||||
|
: column_exists(db, "relations", "source") ? "source"
|
||||||
|
: "";
|
||||||
|
s->rel_tgt_col = column_exists(db, "relations", "to_entity") ? "to_entity"
|
||||||
|
: column_exists(db, "relations", "target") ? "target"
|
||||||
|
: "";
|
||||||
|
if (s->rel_src_col.empty() || s->rel_tgt_col.empty()) {
|
||||||
|
set_err(stats, "relations: missing from_entity/to_entity columns");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// El "tipo" de relacion: priorizamos `type` si existe; si no, `name`.
|
||||||
|
// En operations.db del registry no hay `type` en relations, pero `name`
|
||||||
|
// suele encodear la relacion (ej: "owns", "connects").
|
||||||
|
s->rel_type_col = column_exists(db, "relations", "type") ? "type"
|
||||||
|
: column_exists(db, "relations", "name") ? "name"
|
||||||
|
: "";
|
||||||
|
s->rel_weight_col = column_exists(db, "relations", "weight") ? "weight" : "";
|
||||||
|
s->rel_updated = column_exists(db, "relations", "updated_at") ? "updated_at" : "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// JSON metadata.name extractor (super basico, sin parser): busca "name" : "X".
|
||||||
|
// Si no encuentra, devuelve "". Sirve para entities donde metadata es JSON
|
||||||
|
// pequeno; para casos complicados, el caller debe extender.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static std::string json_get_name(const char* json) {
|
||||||
|
if (!json) return "";
|
||||||
|
const char* p = std::strstr(json, "\"name\"");
|
||||||
|
if (!p) return "";
|
||||||
|
p += 6;
|
||||||
|
while (*p == ' ' || *p == '\t' || *p == ':') ++p;
|
||||||
|
if (*p != '"') return "";
|
||||||
|
++p;
|
||||||
|
const char* end = std::strchr(p, '"');
|
||||||
|
if (!end) return "";
|
||||||
|
return std::string(p, end - p);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// String pool helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static uint32_t intern_label(LoaderArena* arena, const std::string& s) {
|
||||||
|
if (s.empty()) return 0;
|
||||||
|
char* dup = (char*)std::malloc(s.size() + 1);
|
||||||
|
std::memcpy(dup, s.c_str(), s.size() + 1);
|
||||||
|
arena->labels.push_back(dup);
|
||||||
|
// idx 0 esta reservado para "no label", asi que el pool real empieza en 1.
|
||||||
|
return (uint32_t)arena->labels.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Carga principal
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
bool graph_load_from_operations(const char* db_path, GraphData* out, GraphLoadStats* stats) {
|
||||||
|
zero_stats(stats);
|
||||||
|
if (!db_path || !out) {
|
||||||
|
set_err(stats, "null db_path or out");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*out = GraphData{};
|
||||||
|
|
||||||
|
sqlite3* db = nullptr;
|
||||||
|
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK) {
|
||||||
|
char buf[256];
|
||||||
|
std::snprintf(buf, sizeof(buf), "open: %s", sqlite3_errmsg(db));
|
||||||
|
set_err(stats, buf);
|
||||||
|
if (db) sqlite3_close(db);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema sch;
|
||||||
|
if (!detect_schema(db, &sch, stats)) {
|
||||||
|
sqlite3_close(db);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto* arena = new LoaderArena();
|
||||||
|
|
||||||
|
// --- 1) Tipos de entidad (DISTINCT entity_type) ---
|
||||||
|
std::unordered_map<std::string, uint16_t> type_idx;
|
||||||
|
{
|
||||||
|
std::string q = "SELECT DISTINCT " + sch.entity_type_col +
|
||||||
|
" FROM entities WHERE " + sch.entity_type_col + " IS NOT NULL";
|
||||||
|
sqlite3_stmt* st = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db, q.c_str(), -1, &st, nullptr) != SQLITE_OK) {
|
||||||
|
set_err(stats, sqlite3_errmsg(db));
|
||||||
|
delete arena;
|
||||||
|
sqlite3_close(db);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::vector<std::string> names;
|
||||||
|
while (sqlite3_step(st) == SQLITE_ROW) {
|
||||||
|
const unsigned char* n = sqlite3_column_text(st, 0);
|
||||||
|
if (!n) continue;
|
||||||
|
names.emplace_back((const char*)n);
|
||||||
|
}
|
||||||
|
sqlite3_finalize(st);
|
||||||
|
|
||||||
|
arena->types = (EntityType*)std::calloc(names.size() ? names.size() : 1, sizeof(EntityType));
|
||||||
|
for (size_t i = 0; i < names.size(); ++i) {
|
||||||
|
char* dup = (char*)std::malloc(names[i].size() + 1);
|
||||||
|
std::memcpy(dup, names[i].c_str(), names[i].size() + 1);
|
||||||
|
arena->type_names.push_back(dup);
|
||||||
|
arena->types[i].color = default_color_for(dup);
|
||||||
|
arena->types[i].shape = SHAPE_CIRCLE;
|
||||||
|
arena->types[i].icon_id = 0;
|
||||||
|
arena->types[i].default_size = 6.0f;
|
||||||
|
arena->types[i].name = dup;
|
||||||
|
type_idx[names[i]] = (uint16_t)i;
|
||||||
|
}
|
||||||
|
out->types = arena->types;
|
||||||
|
out->type_count = (int)names.size();
|
||||||
|
if (stats) stats->types_discovered = (int)names.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2) Tipos de relacion (DISTINCT rel_type) ---
|
||||||
|
std::unordered_map<std::string, uint16_t> rel_type_idx;
|
||||||
|
if (!sch.rel_type_col.empty()) {
|
||||||
|
std::string q = "SELECT DISTINCT " + sch.rel_type_col +
|
||||||
|
" FROM relations WHERE " + sch.rel_type_col + " IS NOT NULL";
|
||||||
|
sqlite3_stmt* st = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db, q.c_str(), -1, &st, nullptr) == SQLITE_OK) {
|
||||||
|
std::vector<std::string> names;
|
||||||
|
while (sqlite3_step(st) == SQLITE_ROW) {
|
||||||
|
const unsigned char* n = sqlite3_column_text(st, 0);
|
||||||
|
if (!n) continue;
|
||||||
|
names.emplace_back((const char*)n);
|
||||||
|
}
|
||||||
|
sqlite3_finalize(st);
|
||||||
|
|
||||||
|
arena->rel_types = (RelationType*)std::calloc(names.size() ? names.size() : 1, sizeof(RelationType));
|
||||||
|
for (size_t i = 0; i < names.size(); ++i) {
|
||||||
|
char* dup = (char*)std::malloc(names[i].size() + 1);
|
||||||
|
std::memcpy(dup, names[i].c_str(), names[i].size() + 1);
|
||||||
|
arena->rel_type_names.push_back(dup);
|
||||||
|
arena->rel_types[i].color = default_color_for(dup);
|
||||||
|
arena->rel_types[i].style = EDGE_SOLID;
|
||||||
|
arena->rel_types[i].width = 1.0f;
|
||||||
|
arena->rel_types[i].name = dup;
|
||||||
|
rel_type_idx[names[i]] = (uint16_t)i;
|
||||||
|
}
|
||||||
|
out->rel_types = arena->rel_types;
|
||||||
|
out->rel_type_count = (int)names.size();
|
||||||
|
if (stats) stats->rel_types_discovered = (int)names.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3) Entidades ---
|
||||||
|
std::unordered_map<std::string, uint32_t> id_to_idx;
|
||||||
|
{
|
||||||
|
std::string q = "SELECT id, " + sch.entity_type_col;
|
||||||
|
if (!sch.entity_meta_col.empty()) q += ", " + sch.entity_meta_col;
|
||||||
|
// Aniadir name si existe (para etiqueta sin parsear metadata)
|
||||||
|
bool has_name_col = column_exists(db, "entities", "name");
|
||||||
|
if (has_name_col) q += ", name";
|
||||||
|
q += " FROM entities";
|
||||||
|
|
||||||
|
sqlite3_stmt* st = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db, q.c_str(), -1, &st, nullptr) != SQLITE_OK) {
|
||||||
|
set_err(stats, sqlite3_errmsg(db));
|
||||||
|
sqlite3_close(db);
|
||||||
|
// arena queda con types alocados; el caller debe llamar graph_free.
|
||||||
|
arena_map()[out] = arena;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Reservamos generosamente — sera el caller quien decida si hace
|
||||||
|
// shrink despues. Para v1, alocamos exactamente lo que devuelva la
|
||||||
|
// query usando un primer pase con COUNT, pero eso obliga a 2 queries.
|
||||||
|
// Simplificamos: pasada en vector y luego copia al array final.
|
||||||
|
std::vector<GraphNode> rows;
|
||||||
|
while (sqlite3_step(st) == SQLITE_ROW) {
|
||||||
|
const unsigned char* id_c = sqlite3_column_text(st, 0);
|
||||||
|
const unsigned char* tp_c = sqlite3_column_text(st, 1);
|
||||||
|
if (!id_c || !tp_c) continue;
|
||||||
|
std::string id_s = (const char*)id_c;
|
||||||
|
std::string type_s = (const char*)tp_c;
|
||||||
|
|
||||||
|
std::string label;
|
||||||
|
int col = 2;
|
||||||
|
if (!sch.entity_meta_col.empty()) {
|
||||||
|
const unsigned char* m = sqlite3_column_text(st, col++);
|
||||||
|
if (m) label = json_get_name((const char*)m);
|
||||||
|
}
|
||||||
|
if (label.empty() && has_name_col) {
|
||||||
|
const unsigned char* nm = sqlite3_column_text(st, col);
|
||||||
|
if (nm && *nm) label = (const char*)nm;
|
||||||
|
}
|
||||||
|
if (label.empty()) label = id_s;
|
||||||
|
|
||||||
|
auto it = type_idx.find(type_s);
|
||||||
|
if (it == type_idx.end()) continue; // no deberia pasar (DISTINCT cubre)
|
||||||
|
|
||||||
|
GraphNode n = graph_node(0.0f, 0.0f, it->second);
|
||||||
|
n.user_data = fnv1a64(id_s.c_str());
|
||||||
|
n.label_idx = intern_label(arena, label);
|
||||||
|
id_to_idx[id_s] = (uint32_t)rows.size();
|
||||||
|
rows.push_back(n);
|
||||||
|
}
|
||||||
|
sqlite3_finalize(st);
|
||||||
|
|
||||||
|
if (!rows.empty()) {
|
||||||
|
arena->nodes = (GraphNode*)std::malloc(rows.size() * sizeof(GraphNode));
|
||||||
|
std::memcpy(arena->nodes, rows.data(), rows.size() * sizeof(GraphNode));
|
||||||
|
}
|
||||||
|
out->nodes = arena->nodes;
|
||||||
|
out->node_count = (int)rows.size();
|
||||||
|
out->node_capacity = (int)rows.size();
|
||||||
|
if (stats) stats->nodes_loaded = (int)rows.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4) Relaciones ---
|
||||||
|
{
|
||||||
|
std::string q = "SELECT " + sch.rel_src_col + ", " + sch.rel_tgt_col;
|
||||||
|
if (!sch.rel_type_col.empty()) q += ", " + sch.rel_type_col;
|
||||||
|
if (!sch.rel_weight_col.empty()) q += ", " + sch.rel_weight_col;
|
||||||
|
q += " FROM relations";
|
||||||
|
|
||||||
|
sqlite3_stmt* st = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db, q.c_str(), -1, &st, nullptr) != SQLITE_OK) {
|
||||||
|
set_err(stats, sqlite3_errmsg(db));
|
||||||
|
} else {
|
||||||
|
std::vector<GraphEdge> rows;
|
||||||
|
while (sqlite3_step(st) == SQLITE_ROW) {
|
||||||
|
const unsigned char* src = sqlite3_column_text(st, 0);
|
||||||
|
const unsigned char* tgt = sqlite3_column_text(st, 1);
|
||||||
|
if (!src || !tgt) {
|
||||||
|
if (stats) stats->errors++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
auto sit = id_to_idx.find((const char*)src);
|
||||||
|
auto tit = id_to_idx.find((const char*)tgt);
|
||||||
|
if (sit == id_to_idx.end() || tit == id_to_idx.end()) {
|
||||||
|
if (stats) stats->errors++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int col = 2;
|
||||||
|
uint16_t rt = 0;
|
||||||
|
if (!sch.rel_type_col.empty()) {
|
||||||
|
const unsigned char* tp = sqlite3_column_text(st, col++);
|
||||||
|
if (tp) {
|
||||||
|
auto rit = rel_type_idx.find((const char*)tp);
|
||||||
|
if (rit != rel_type_idx.end()) rt = rit->second;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
float w = 1.0f;
|
||||||
|
if (!sch.rel_weight_col.empty()) {
|
||||||
|
if (sqlite3_column_type(st, col) != SQLITE_NULL)
|
||||||
|
w = (float)sqlite3_column_double(st, col);
|
||||||
|
col++;
|
||||||
|
}
|
||||||
|
rows.push_back(graph_edge(sit->second, tit->second, w, rt));
|
||||||
|
}
|
||||||
|
sqlite3_finalize(st);
|
||||||
|
|
||||||
|
if (!rows.empty()) {
|
||||||
|
arena->edges = (GraphEdge*)std::malloc(rows.size() * sizeof(GraphEdge));
|
||||||
|
std::memcpy(arena->edges, rows.data(), rows.size() * sizeof(GraphEdge));
|
||||||
|
}
|
||||||
|
out->edges = arena->edges;
|
||||||
|
out->edge_count = (int)rows.size();
|
||||||
|
out->edge_capacity = (int)rows.size();
|
||||||
|
if (stats) stats->edges_loaded = (int)rows.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlite3_close(db);
|
||||||
|
arena_map()[out] = arena;
|
||||||
|
out->update_bounds();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// graph_free
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void graph_free(GraphData* graph) {
|
||||||
|
if (!graph) return;
|
||||||
|
auto& m = arena_map();
|
||||||
|
auto it = m.find(graph);
|
||||||
|
if (it != m.end()) {
|
||||||
|
LoaderArena* a = it->second;
|
||||||
|
std::free(a->nodes);
|
||||||
|
std::free(a->edges);
|
||||||
|
std::free(a->types);
|
||||||
|
std::free(a->rel_types);
|
||||||
|
for (char* p : a->type_names) std::free(p);
|
||||||
|
for (char* p : a->rel_type_names) std::free(p);
|
||||||
|
for (char* p : a->labels) std::free(p);
|
||||||
|
delete a;
|
||||||
|
m.erase(it);
|
||||||
|
}
|
||||||
|
*graph = GraphData{};
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* graph_label(const GraphData* graph, uint32_t label_idx) {
|
||||||
|
if (!graph || label_idx == 0) return "";
|
||||||
|
auto& m = arena_map();
|
||||||
|
auto it = m.find(graph);
|
||||||
|
if (it == m.end()) return "";
|
||||||
|
LoaderArena* a = it->second;
|
||||||
|
if (label_idx > a->labels.size()) return "";
|
||||||
|
return a->labels[label_idx - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Streaming
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct GraphStreamSource {
|
||||||
|
std::string db_path;
|
||||||
|
int poll_ms;
|
||||||
|
Schema schema;
|
||||||
|
// Tiebreak: (updated_at, id) > last_seen. Cuando no hay updated_at,
|
||||||
|
// caemos a un seek por id ordenado.
|
||||||
|
std::string last_ent_updated;
|
||||||
|
std::string last_ent_id;
|
||||||
|
std::string last_rel_updated;
|
||||||
|
std::string last_rel_id;
|
||||||
|
};
|
||||||
|
|
||||||
|
GraphStreamSource* graph_stream_operations_open(const char* db_path, int poll_ms) {
|
||||||
|
if (!db_path) return nullptr;
|
||||||
|
sqlite3* db = nullptr;
|
||||||
|
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK) {
|
||||||
|
if (db) sqlite3_close(db);
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
auto* src = new GraphStreamSource();
|
||||||
|
src->db_path = db_path;
|
||||||
|
src->poll_ms = poll_ms;
|
||||||
|
GraphLoadStats dummy{};
|
||||||
|
if (!detect_schema(db, &src->schema, &dummy)) {
|
||||||
|
sqlite3_close(db);
|
||||||
|
delete src;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
// Inicializamos last_seen al MAX actual para que el primer pull devuelva
|
||||||
|
// solo lo que llegue despues de open(). Si el caller quisiera todo lo
|
||||||
|
// existente, primero deberia hacer graph_load_from_operations.
|
||||||
|
auto fetch_max = [&](const std::string& table, const std::string& upd_col,
|
||||||
|
std::string* out_upd, std::string* out_id) {
|
||||||
|
std::string q;
|
||||||
|
if (!upd_col.empty()) {
|
||||||
|
q = "SELECT COALESCE(MAX(" + upd_col + "), ''), COALESCE(MAX(id), '') FROM " + table;
|
||||||
|
} else {
|
||||||
|
q = "SELECT '', COALESCE(MAX(id), '') FROM " + table;
|
||||||
|
}
|
||||||
|
sqlite3_stmt* st = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db, q.c_str(), -1, &st, nullptr) == SQLITE_OK) {
|
||||||
|
if (sqlite3_step(st) == SQLITE_ROW) {
|
||||||
|
const unsigned char* a = sqlite3_column_text(st, 0);
|
||||||
|
const unsigned char* b = sqlite3_column_text(st, 1);
|
||||||
|
*out_upd = a ? (const char*)a : "";
|
||||||
|
*out_id = b ? (const char*)b : "";
|
||||||
|
}
|
||||||
|
sqlite3_finalize(st);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetch_max("entities", src->schema.entity_updated,
|
||||||
|
&src->last_ent_updated, &src->last_ent_id);
|
||||||
|
fetch_max("relations", src->schema.rel_updated,
|
||||||
|
&src->last_rel_updated, &src->last_rel_id);
|
||||||
|
sqlite3_close(db);
|
||||||
|
return src;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers para crecer arrays si no hay sitio. Mantienen sincronizados arena y
|
||||||
|
// graph (que comparten el mismo puntero base).
|
||||||
|
static void ensure_node_capacity(LoaderArena* arena, GraphData* g, int needed) {
|
||||||
|
if (g->node_capacity >= needed) return;
|
||||||
|
int new_cap = g->node_capacity > 0 ? g->node_capacity * 2 : 64;
|
||||||
|
while (new_cap < needed) new_cap *= 2;
|
||||||
|
arena->nodes = (GraphNode*)std::realloc(arena->nodes, new_cap * sizeof(GraphNode));
|
||||||
|
g->nodes = arena->nodes;
|
||||||
|
g->node_capacity = new_cap;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ensure_edge_capacity(LoaderArena* arena, GraphData* g, int needed) {
|
||||||
|
if (g->edge_capacity >= needed) return;
|
||||||
|
int new_cap = g->edge_capacity > 0 ? g->edge_capacity * 2 : 64;
|
||||||
|
while (new_cap < needed) new_cap *= 2;
|
||||||
|
arena->edges = (GraphEdge*)std::realloc(arena->edges, new_cap * sizeof(GraphEdge));
|
||||||
|
g->edges = arena->edges;
|
||||||
|
g->edge_capacity = new_cap;
|
||||||
|
}
|
||||||
|
|
||||||
|
int graph_stream_pull(GraphStreamSource* src, GraphData* graph) {
|
||||||
|
if (!src || !graph) return 0;
|
||||||
|
auto it = arena_map().find(graph);
|
||||||
|
if (it == arena_map().end()) return 0; // GraphData no fue cargado por nosotros
|
||||||
|
LoaderArena* arena = it->second;
|
||||||
|
|
||||||
|
sqlite3* db = nullptr;
|
||||||
|
if (sqlite3_open_v2(src->db_path.c_str(), &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK) {
|
||||||
|
if (db) sqlite3_close(db);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int appended = 0;
|
||||||
|
auto& sch = src->schema;
|
||||||
|
|
||||||
|
// --- entidades nuevas ---
|
||||||
|
{
|
||||||
|
std::string q;
|
||||||
|
const bool has_upd = !sch.entity_updated.empty();
|
||||||
|
if (has_upd) {
|
||||||
|
q = "SELECT id, " + sch.entity_type_col + ", " + sch.entity_updated +
|
||||||
|
" FROM entities WHERE (" + sch.entity_updated + " > ?1) OR (" +
|
||||||
|
sch.entity_updated + " = ?1 AND id > ?2) ORDER BY " +
|
||||||
|
sch.entity_updated + ", id";
|
||||||
|
} else {
|
||||||
|
q = "SELECT id, " + sch.entity_type_col +
|
||||||
|
", '' FROM entities WHERE id > ?2 ORDER BY id";
|
||||||
|
}
|
||||||
|
sqlite3_stmt* st = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db, q.c_str(), -1, &st, nullptr) == SQLITE_OK) {
|
||||||
|
sqlite3_bind_text(st, 1, src->last_ent_updated.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(st, 2, src->last_ent_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
while (sqlite3_step(st) == SQLITE_ROW) {
|
||||||
|
ensure_node_capacity(arena, graph, graph->node_count + 1);
|
||||||
|
const unsigned char* id_c = sqlite3_column_text(st, 0);
|
||||||
|
const unsigned char* tp_c = sqlite3_column_text(st, 1);
|
||||||
|
const unsigned char* up_c = sqlite3_column_text(st, 2);
|
||||||
|
if (!id_c || !tp_c) continue;
|
||||||
|
// Buscamos type_id; si es nuevo, lo anadimos al final de
|
||||||
|
// arena->types con un realloc (caso poco frecuente).
|
||||||
|
uint16_t type_id = 0;
|
||||||
|
bool found_type = false;
|
||||||
|
for (int i = 0; i < graph->type_count; ++i) {
|
||||||
|
if (graph->types[i].name && std::strcmp(graph->types[i].name, (const char*)tp_c) == 0) {
|
||||||
|
type_id = (uint16_t)i;
|
||||||
|
found_type = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!found_type) {
|
||||||
|
int new_count = graph->type_count + 1;
|
||||||
|
arena->types = (EntityType*)std::realloc(arena->types, new_count * sizeof(EntityType));
|
||||||
|
graph->types = arena->types;
|
||||||
|
char* dup = (char*)std::malloc(std::strlen((const char*)tp_c) + 1);
|
||||||
|
std::strcpy(dup, (const char*)tp_c);
|
||||||
|
arena->type_names.push_back(dup);
|
||||||
|
arena->types[graph->type_count].color = default_color_for(dup);
|
||||||
|
arena->types[graph->type_count].shape = SHAPE_CIRCLE;
|
||||||
|
arena->types[graph->type_count].icon_id = 0;
|
||||||
|
arena->types[graph->type_count].default_size = 6.0f;
|
||||||
|
arena->types[graph->type_count].name = dup;
|
||||||
|
type_id = (uint16_t)graph->type_count;
|
||||||
|
graph->type_count = new_count;
|
||||||
|
}
|
||||||
|
GraphNode n = graph_node(0.0f, 0.0f, type_id);
|
||||||
|
n.user_data = fnv1a64((const char*)id_c);
|
||||||
|
n.label_idx = intern_label(arena, (const char*)id_c);
|
||||||
|
graph->nodes[graph->node_count++] = n;
|
||||||
|
|
||||||
|
if (has_upd && up_c) src->last_ent_updated = (const char*)up_c;
|
||||||
|
src->last_ent_id = (const char*)id_c;
|
||||||
|
appended++;
|
||||||
|
}
|
||||||
|
sqlite3_finalize(st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- relaciones nuevas ---
|
||||||
|
{
|
||||||
|
std::string q;
|
||||||
|
const bool has_upd = !sch.rel_updated.empty();
|
||||||
|
if (has_upd) {
|
||||||
|
q = "SELECT id, " + sch.rel_src_col + ", " + sch.rel_tgt_col +
|
||||||
|
", " + sch.rel_updated +
|
||||||
|
" FROM relations WHERE (" + sch.rel_updated + " > ?1) OR (" +
|
||||||
|
sch.rel_updated + " = ?1 AND id > ?2) ORDER BY " +
|
||||||
|
sch.rel_updated + ", id";
|
||||||
|
} else {
|
||||||
|
q = "SELECT id, " + sch.rel_src_col + ", " + sch.rel_tgt_col +
|
||||||
|
", '' FROM relations WHERE id > ?2 ORDER BY id";
|
||||||
|
}
|
||||||
|
sqlite3_stmt* st = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db, q.c_str(), -1, &st, nullptr) == SQLITE_OK) {
|
||||||
|
sqlite3_bind_text(st, 1, src->last_rel_updated.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(st, 2, src->last_rel_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
while (sqlite3_step(st) == SQLITE_ROW) {
|
||||||
|
ensure_edge_capacity(arena, graph, graph->edge_count + 1);
|
||||||
|
const unsigned char* id_c = sqlite3_column_text(st, 0);
|
||||||
|
const unsigned char* src_c = sqlite3_column_text(st, 1);
|
||||||
|
const unsigned char* tgt_c = sqlite3_column_text(st, 2);
|
||||||
|
const unsigned char* up_c = sqlite3_column_text(st, 3);
|
||||||
|
if (!id_c || !src_c || !tgt_c) continue;
|
||||||
|
uint64_t sh = fnv1a64((const char*)src_c);
|
||||||
|
uint64_t th = fnv1a64((const char*)tgt_c);
|
||||||
|
int sidx = graph->find_node_by_user_data(sh);
|
||||||
|
int tidx = graph->find_node_by_user_data(th);
|
||||||
|
if (sidx < 0 || tidx < 0) {
|
||||||
|
if (has_upd && up_c) src->last_rel_updated = (const char*)up_c;
|
||||||
|
src->last_rel_id = (const char*)id_c;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
graph->edges[graph->edge_count++] = graph_edge((uint32_t)sidx, (uint32_t)tidx);
|
||||||
|
if (has_upd && up_c) src->last_rel_updated = (const char*)up_c;
|
||||||
|
src->last_rel_id = (const char*)id_c;
|
||||||
|
appended++;
|
||||||
|
}
|
||||||
|
sqlite3_finalize(st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlite3_close(db);
|
||||||
|
return appended;
|
||||||
|
}
|
||||||
|
|
||||||
|
void graph_stream_close(GraphStreamSource* src) {
|
||||||
|
delete src;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace graph
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <cstdint>
|
||||||
|
#include "graph_types.h"
|
||||||
|
|
||||||
|
// Lectores de grafos para `GraphData` (issue 0049g). Disenado como un set de
|
||||||
|
// funciones con la misma firma `GraphLoadFn` para que anadir un backend nuevo
|
||||||
|
// (JSON, JSONL, GraphML, etc.) sea declarar otra funcion compatible — el
|
||||||
|
// resto del codigo (apps, viewport, force layout) no cambia.
|
||||||
|
//
|
||||||
|
// Esquema soportado de operations.db:
|
||||||
|
// entities (id, type_ref|type, name?, metadata?, updated_at)
|
||||||
|
// relations (id, from_entity|source, to_entity|target, name?, type?,
|
||||||
|
// weight?, updated_at)
|
||||||
|
// La funcion detecta los nombres reales via `PRAGMA table_info` y mapea:
|
||||||
|
// - entity.type_ref / entity.type → EntityType (color por hash de nombre)
|
||||||
|
// - relation.name (o type) → RelationType
|
||||||
|
// - entity.id → user_data (FNV1a 64), label
|
||||||
|
// - entity.metadata.name (json) → label si existe, else id
|
||||||
|
|
||||||
|
namespace graph {
|
||||||
|
|
||||||
|
struct GraphLoadStats {
|
||||||
|
int nodes_loaded;
|
||||||
|
int edges_loaded;
|
||||||
|
int types_discovered;
|
||||||
|
int rel_types_discovered;
|
||||||
|
int errors;
|
||||||
|
char error_msg[256];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Firma uniforme para todos los backends. `uri` es opaco — para
|
||||||
|
// `graph_load_from_operations` es el path de un fichero SQLite.
|
||||||
|
typedef bool (*GraphLoadFn)(const char* uri, GraphData* out, GraphLoadStats* stats);
|
||||||
|
|
||||||
|
// Carga sincrona de operations.db. Allocs propios en out (nodes/edges/types/
|
||||||
|
// rel_types + string pool interno). El caller libera con graph_free.
|
||||||
|
bool graph_load_from_operations(const char* db_path, GraphData* out, GraphLoadStats* stats);
|
||||||
|
|
||||||
|
// Libera toda la memoria alocada por graph_load_from_operations (incluyendo
|
||||||
|
// los nombres de tipos y el label pool). Deja la struct a cero.
|
||||||
|
void graph_free(GraphData* graph);
|
||||||
|
|
||||||
|
// Devuelve el label de un nodo. Si label_idx == 0 o no hay pool, retorna "".
|
||||||
|
const char* graph_label(const GraphData* graph, uint32_t label_idx);
|
||||||
|
|
||||||
|
// --- Streaming --------------------------------------------------------------
|
||||||
|
// Pull-based: el caller invoca pull cada N ms y la fuente devuelve cuantas
|
||||||
|
// entidades/relaciones nuevas anadio in-place al GraphData. Si no hay sitio
|
||||||
|
// (capacity), corta y devuelve cuanto pudo escribir; el caller decide si
|
||||||
|
// reallocar.
|
||||||
|
struct GraphStreamSource;
|
||||||
|
|
||||||
|
GraphStreamSource* graph_stream_operations_open(const char* db_path, int poll_ms);
|
||||||
|
int graph_stream_pull(GraphStreamSource* src, GraphData* graph);
|
||||||
|
void graph_stream_close(GraphStreamSource* src);
|
||||||
|
|
||||||
|
} // namespace graph
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
---
|
||||||
|
name: graph_sources
|
||||||
|
kind: function
|
||||||
|
lang: cpp
|
||||||
|
domain: viz
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "bool graph_load_from_operations(const char* db_path, GraphData* out, GraphLoadStats* stats)"
|
||||||
|
description: "Lectores de grafos para GraphData con firma uniforme GraphLoadFn. Primera implementacion: operations.db (entities + relations) con variante streaming pull-based"
|
||||||
|
tags: [graph, sources, sqlite, operations, streaming, loader, viz]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: ["GraphData_cpp_viz", "EntityType_cpp_viz", "RelationType_cpp_viz"]
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: []
|
||||||
|
tested: true
|
||||||
|
tests: ["test_graph_sources"]
|
||||||
|
test_file_path: "cpp/tests/test_graph_sources.cpp"
|
||||||
|
file_path: "cpp/functions/viz/graph_sources.cpp"
|
||||||
|
framework: "imgui"
|
||||||
|
params:
|
||||||
|
- name: db_path
|
||||||
|
desc: "Ruta a un fichero SQLite operations.db (schema con entities + relations). Solo se abre en modo READONLY"
|
||||||
|
- name: out
|
||||||
|
desc: "GraphData destino. La funcion aloca nodes/edges/types/rel_types y un string pool interno; el caller debe liberar con graph_free()"
|
||||||
|
- name: stats
|
||||||
|
desc: "GraphLoadStats opcional con conteos y un buffer error_msg de 256 bytes; si la carga falla, errors > 0 y error_msg describe el motivo (BD ausente, tabla faltante, columna desconocida)"
|
||||||
|
output: "true si la carga fue correcta. false con stats->errors > 0 y error_msg poblado en error. Tras un retorno true: out->nodes/edges apuntan a memoria interna; out->types/rel_types listan los tipos descubiertos con color por hash del nombre y SHAPE_CIRCLE/EDGE_SOLID por defecto. user_data por nodo es FNV1a64 del entity.id (deterministico). label_idx apunta a metadata.name si existe, else entity.name, else entity.id. Streaming: graph_stream_operations_open captura MAX(updated_at, id) actuales; graph_stream_pull devuelve cuantas filas append y crece capacity en x2 si hace falta"
|
||||||
|
---
|
||||||
|
|
||||||
|
# graph_sources
|
||||||
|
|
||||||
|
Lectores de grafos para `GraphData` (issue 0049g). Disenado como un set de funciones con la misma firma `GraphLoadFn` para que anadir un backend nuevo (JSON, JSONL, GraphML, Neo4j export, etc.) sea declarar otra funcion compatible — el resto del codigo (apps, viewport, force layout, renderer) no cambia.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
typedef bool (*GraphLoadFn)(const char* uri, GraphData* out, GraphLoadStats* stats);
|
||||||
|
|
||||||
|
bool graph_load_from_operations(const char* db_path, GraphData* out, GraphLoadStats* stats);
|
||||||
|
void graph_free(GraphData* graph);
|
||||||
|
const char* graph_label(const GraphData* graph, uint32_t label_idx);
|
||||||
|
|
||||||
|
// Streaming pull-based (poll cada N ms desde el caller).
|
||||||
|
GraphStreamSource* graph_stream_operations_open(const char* db_path, int poll_ms);
|
||||||
|
int graph_stream_pull(GraphStreamSource*, GraphData*);
|
||||||
|
void graph_stream_close(GraphStreamSource*);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mapeo operations.db → GraphData
|
||||||
|
|
||||||
|
`operations.db` es la BD de cada app del registry. La funcion detecta el schema via `PRAGMA table_info`:
|
||||||
|
|
||||||
|
| Concepto | Columna preferida | Fallback |
|
||||||
|
|----------|-------------------|----------|
|
||||||
|
| Tipo de entidad | `entities.type_ref` | `entities.type` |
|
||||||
|
| Source / target de relacion | `relations.from_entity` / `relations.to_entity` | `relations.source` / `relations.target` |
|
||||||
|
| Tipo de relacion | `relations.type` | `relations.name` |
|
||||||
|
| Etiqueta de nodo | `metadata.name` (JSON) | `entities.name` o `entities.id` |
|
||||||
|
| Tiebreak streaming | `(updated_at, id)` | `id` solo |
|
||||||
|
|
||||||
|
Cada valor distinto de `type_ref` produce un `EntityType` con color tomado de un palette de 16 (FNV1a32 sobre el nombre → `palette[h & 0xF]`), `shape = SHAPE_CIRCLE`, `default_size = 6.0`, `icon_id = 0`. La app consumidora puede sobreescribir esa apariencia via `types.yaml` (issue 0049k).
|
||||||
|
|
||||||
|
`user_data` por nodo es `FNV1a64(entity.id)` — deterministico entre cargas y util como handle estable para joins con metadata externa.
|
||||||
|
|
||||||
|
## Errores
|
||||||
|
|
||||||
|
La funcion no usa excepciones. En error:
|
||||||
|
- Retorna `false`.
|
||||||
|
- `stats->errors >= 1`.
|
||||||
|
- `stats->error_msg` contiene un texto corto (`"open: ..."`, `"missing table: entities"`, `"entities: missing type_ref/type column"`, ...).
|
||||||
|
|
||||||
|
Relaciones con `from_entity` / `to_entity` que apunten a entities inexistentes se cuentan en `stats->errors` y se descartan — el grafo resultante es siempre consistente.
|
||||||
|
|
||||||
|
## Streaming
|
||||||
|
|
||||||
|
El streaming es pull-based: el caller decide la cadencia y llama `graph_stream_pull` cuando quiere chequear cambios. La fuente guarda `(MAX(updated_at), MAX(id))` al abrir y avanza el cursor con tiebreak `(updated_at, id)`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
WHERE (updated_at > ?) OR (updated_at = ? AND id > ?)
|
||||||
|
ORDER BY updated_at, id
|
||||||
|
```
|
||||||
|
|
||||||
|
`graph_stream_pull` crece la capacidad de `nodes`/`edges` en x2 si hace falta — el caller no necesita pre-allocar. Tipos nuevos descubiertos durante el stream se anaden al final de `types`. Operaciones inversas (deletes) no se propagan — para reset, el caller debe `graph_free` y recargar.
|
||||||
|
|
||||||
|
## Memoria
|
||||||
|
|
||||||
|
`graph_load_from_operations` aloca toda la memoria que cuelga del `GraphData` devuelto (incluido el string pool de labels y los nombres de tipos via `strdup`). El caller libera todo con `graph_free(graph)`. La asociacion `GraphData* → arena` se mantiene en un mapa estatico interno; `graph_label` y `graph_stream_pull` lo consultan para acceder al pool.
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
GraphData g{};
|
||||||
|
graph::GraphLoadStats s{};
|
||||||
|
if (!graph::graph_load_from_operations("apps/script_navegador/operations.db", &g, &s)) {
|
||||||
|
fprintf(stderr, "load failed: %s\n", s.error_msg);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printf("nodes=%d edges=%d types=%d rel_types=%d\n",
|
||||||
|
s.nodes_loaded, s.edges_loaded, s.types_discovered, s.rel_types_discovered);
|
||||||
|
|
||||||
|
// Render con graph_renderer + force layout via graph_force_layout.
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// Watcher en otro hilo:
|
||||||
|
auto* src = graph::graph_stream_operations_open("apps/script_navegador/operations.db", 500);
|
||||||
|
while (running) {
|
||||||
|
sleep_ms(500);
|
||||||
|
int n = graph::graph_stream_pull(src, &g);
|
||||||
|
if (n > 0) printf("appended %d new rows\n", n);
|
||||||
|
}
|
||||||
|
graph::graph_stream_close(src);
|
||||||
|
graph::graph_free(&g);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- **v1.0** (2026-04-29, issue 0049g): primera version. Lector sincrono + streaming poll-based. Tests con fixture in-memory: 10 entities + 15 relations, 3 entity types, 2 relation types. Determinismo del `user_data` y resolucion de aristas verificados.
|
||||||
|
- La firma `GraphLoadFn` esta diseniada para futuros backends. Anadir uno (ej. `graph_load_from_jsonl`) consiste en declarar una funcion con la misma firma; nada en el resto del pipeline (renderer, layout, viewport) cambia.
|
||||||
@@ -74,6 +74,12 @@ add_fn_test(test_graph_edge_static test_graph_edge_static.cpp
|
|||||||
add_fn_test(test_graph_types test_graph_types.cpp
|
add_fn_test(test_graph_types test_graph_types.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_types.cpp)
|
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_types.cpp)
|
||||||
|
|
||||||
|
# --- Issue 0049g — graph_sources: lector de operations.db ------------------
|
||||||
|
add_fn_test(test_graph_sources test_graph_sources.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_sources.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_types.cpp)
|
||||||
|
target_link_libraries(test_graph_sources PRIVATE SQLite::SQLite3)
|
||||||
|
|
||||||
# --- Issue 0049f — atlas de iconos Tabler para graph_renderer ---------------
|
# --- Issue 0049f — atlas de iconos Tabler para graph_renderer ---------------
|
||||||
# graph_icons.cpp incluye gl_loader.h y referencia gl* — el atlas se puede
|
# graph_icons.cpp incluye gl_loader.h y referencia gl* — el atlas se puede
|
||||||
# construir sin contexto via FN_GRAPH_ICONS_SKIP_GL=1 (set por el test), pero
|
# construir sin contexto via FN_GRAPH_ICONS_SKIP_GL=1 (set por el test), pero
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
// Unit tests para graph_sources (issue 0049g).
|
||||||
|
// Genera el fixture operations.db en runtime sobre un fichero temporal
|
||||||
|
// (no se versiona binario). Cubre carga sincrona, conteos, determinismo
|
||||||
|
// del user_data, resolucion de aristas, y streaming pull-based.
|
||||||
|
|
||||||
|
#define CATCH_CONFIG_MAIN
|
||||||
|
#include "catch_amalgamated.hpp"
|
||||||
|
|
||||||
|
#include "viz/graph_sources.h"
|
||||||
|
#include "viz/graph_types.h"
|
||||||
|
|
||||||
|
#include "../vendor/sqlite3/sqlite3.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_set>
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixture: 3 entity types (Person/Email/Domain), 2 relation types
|
||||||
|
// (owns/connects), 10 entities, 15 relations. Schema = el del registry
|
||||||
|
// (type_ref, from_entity, to_entity, weight, name, updated_at).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static const char* kSchemaSQL =
|
||||||
|
"CREATE TABLE entities ("
|
||||||
|
" id TEXT PRIMARY KEY,"
|
||||||
|
" name TEXT NOT NULL DEFAULT '',"
|
||||||
|
" type_ref TEXT NOT NULL,"
|
||||||
|
" status TEXT NOT NULL DEFAULT 'active',"
|
||||||
|
" metadata TEXT NOT NULL DEFAULT '{}',"
|
||||||
|
" created_at TEXT NOT NULL DEFAULT '2026-01-01T00:00:00Z',"
|
||||||
|
" updated_at TEXT NOT NULL DEFAULT '2026-01-01T00:00:00Z'"
|
||||||
|
");"
|
||||||
|
"CREATE TABLE relations ("
|
||||||
|
" id TEXT PRIMARY KEY,"
|
||||||
|
" name TEXT NOT NULL,"
|
||||||
|
" from_entity TEXT NOT NULL,"
|
||||||
|
" to_entity TEXT NOT NULL,"
|
||||||
|
" weight REAL,"
|
||||||
|
" created_at TEXT NOT NULL DEFAULT '2026-01-01T00:00:00Z',"
|
||||||
|
" updated_at TEXT NOT NULL DEFAULT '2026-01-01T00:00:00Z'"
|
||||||
|
");";
|
||||||
|
|
||||||
|
static void exec_or_die(sqlite3* db, const char* sql) {
|
||||||
|
char* err = nullptr;
|
||||||
|
int rc = sqlite3_exec(db, sql, nullptr, nullptr, &err);
|
||||||
|
if (rc != SQLITE_OK) {
|
||||||
|
std::fprintf(stderr, "sql failed: %s\n", err ? err : "?");
|
||||||
|
sqlite3_free(err);
|
||||||
|
std::abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::string make_fixture(const char* suffix = "") {
|
||||||
|
char buf[L_tmpnam];
|
||||||
|
std::tmpnam(buf);
|
||||||
|
std::string path = std::string(buf) + suffix + ".db";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
|
||||||
|
sqlite3* db = nullptr;
|
||||||
|
REQUIRE(sqlite3_open(path.c_str(), &db) == SQLITE_OK);
|
||||||
|
exec_or_die(db, kSchemaSQL);
|
||||||
|
|
||||||
|
// 10 entities: 4 Person, 4 Email, 2 Domain
|
||||||
|
const char* entities[10][3] = {
|
||||||
|
{"p1", "Person", "Alice"},
|
||||||
|
{"p2", "Person", "Bob"},
|
||||||
|
{"p3", "Person", "Carol"},
|
||||||
|
{"p4", "Person", "Dave"},
|
||||||
|
{"e1", "Email", "alice@a.com"},
|
||||||
|
{"e2", "Email", "bob@b.com"},
|
||||||
|
{"e3", "Email", "carol@c.com"},
|
||||||
|
{"e4", "Email", "dave@d.com"},
|
||||||
|
{"d1", "Domain", "a.com"},
|
||||||
|
{"d2", "Domain", "b.com"},
|
||||||
|
};
|
||||||
|
for (auto& e : entities) {
|
||||||
|
char sql[512];
|
||||||
|
std::snprintf(sql, sizeof(sql),
|
||||||
|
"INSERT INTO entities (id, name, type_ref, metadata) VALUES "
|
||||||
|
"('%s','%s','%s','{\"name\":\"%s\"}');", e[0], e[2], e[1], e[2]);
|
||||||
|
exec_or_die(db, sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 15 relations
|
||||||
|
const char* rels[15][4] = {
|
||||||
|
{"r1", "owns", "p1", "e1"},
|
||||||
|
{"r2", "owns", "p2", "e2"},
|
||||||
|
{"r3", "owns", "p3", "e3"},
|
||||||
|
{"r4", "owns", "p4", "e4"},
|
||||||
|
{"r5", "owns", "e1", "d1"},
|
||||||
|
{"r6", "owns", "e2", "d2"},
|
||||||
|
{"r7", "connects", "p1", "p2"},
|
||||||
|
{"r8", "connects", "p2", "p3"},
|
||||||
|
{"r9", "connects", "p3", "p4"},
|
||||||
|
{"r10", "connects", "p4", "p1"},
|
||||||
|
{"r11", "connects", "e1", "e2"},
|
||||||
|
{"r12", "connects", "e3", "e4"},
|
||||||
|
{"r13", "connects", "d1", "d2"},
|
||||||
|
{"r14", "owns", "p1", "e3"},
|
||||||
|
{"r15", "owns", "p2", "e4"},
|
||||||
|
};
|
||||||
|
for (auto& r : rels) {
|
||||||
|
char sql[512];
|
||||||
|
std::snprintf(sql, sizeof(sql),
|
||||||
|
"INSERT INTO relations (id, name, from_entity, to_entity, weight) VALUES "
|
||||||
|
"('%s','%s','%s','%s', 1.0);", r[0], r[1], r[2], r[3]);
|
||||||
|
exec_or_die(db, sql);
|
||||||
|
}
|
||||||
|
sqlite3_close(db);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fase 3.1 / 3.2 — carga sincrona
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("graph_load_from_operations: conteos y tipos", "[graph_sources]") {
|
||||||
|
std::string path = make_fixture();
|
||||||
|
|
||||||
|
GraphData g{};
|
||||||
|
graph::GraphLoadStats s{};
|
||||||
|
REQUIRE(graph::graph_load_from_operations(path.c_str(), &g, &s) == true);
|
||||||
|
|
||||||
|
CHECK(s.errors == 0);
|
||||||
|
CHECK(s.nodes_loaded == 10);
|
||||||
|
CHECK(s.edges_loaded == 15);
|
||||||
|
CHECK(s.types_discovered == 3);
|
||||||
|
CHECK(s.rel_types_discovered == 2);
|
||||||
|
|
||||||
|
CHECK(g.node_count == 10);
|
||||||
|
CHECK(g.edge_count == 15);
|
||||||
|
CHECK(g.type_count == 3);
|
||||||
|
CHECK(g.rel_type_count == 2);
|
||||||
|
|
||||||
|
// Cada nodo apunta a un type_id valido.
|
||||||
|
for (int i = 0; i < g.node_count; ++i) {
|
||||||
|
CHECK(g.nodes[i].type_id < g.type_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aristas resuelven a indices validos.
|
||||||
|
for (int i = 0; i < g.edge_count; ++i) {
|
||||||
|
CHECK(g.edges[i].source < (uint32_t)g.node_count);
|
||||||
|
CHECK(g.edges[i].target < (uint32_t)g.node_count);
|
||||||
|
CHECK(g.edges[i].type_id < g.rel_type_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
graph::graph_free(&g);
|
||||||
|
CHECK(g.nodes == nullptr);
|
||||||
|
CHECK(g.node_count == 0);
|
||||||
|
std::remove(path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("graph_load_from_operations: user_data deterministico", "[graph_sources]") {
|
||||||
|
std::string path = make_fixture("_a");
|
||||||
|
|
||||||
|
GraphData g1{}; graph::GraphLoadStats s1{};
|
||||||
|
REQUIRE(graph::graph_load_from_operations(path.c_str(), &g1, &s1));
|
||||||
|
|
||||||
|
// user_data unicos y reproducibles entre cargas
|
||||||
|
std::unordered_set<uint64_t> seen;
|
||||||
|
for (int i = 0; i < g1.node_count; ++i) {
|
||||||
|
CHECK(g1.nodes[i].user_data != 0);
|
||||||
|
CHECK(seen.insert(g1.nodes[i].user_data).second);
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphData g2{}; graph::GraphLoadStats s2{};
|
||||||
|
REQUIRE(graph::graph_load_from_operations(path.c_str(), &g2, &s2));
|
||||||
|
// Mismo orden de insercion → mismo user_data en cada slot.
|
||||||
|
for (int i = 0; i < g1.node_count; ++i) {
|
||||||
|
CHECK(g1.nodes[i].user_data == g2.nodes[i].user_data);
|
||||||
|
}
|
||||||
|
|
||||||
|
graph::graph_free(&g1);
|
||||||
|
graph::graph_free(&g2);
|
||||||
|
std::remove(path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("graph_load_from_operations: error si BD no existe", "[graph_sources]") {
|
||||||
|
GraphData g{}; graph::GraphLoadStats s{};
|
||||||
|
bool ok = graph::graph_load_from_operations("/nonexistent/path/xyz.db", &g, &s);
|
||||||
|
CHECK(ok == false);
|
||||||
|
CHECK(s.errors >= 1);
|
||||||
|
CHECK(std::strlen(s.error_msg) > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("graph_load_from_operations: error si falta tabla entities", "[graph_sources]") {
|
||||||
|
char buf[L_tmpnam];
|
||||||
|
std::tmpnam(buf);
|
||||||
|
std::string path = std::string(buf) + "_empty.db";
|
||||||
|
std::remove(path.c_str());
|
||||||
|
sqlite3* db = nullptr;
|
||||||
|
REQUIRE(sqlite3_open(path.c_str(), &db) == SQLITE_OK);
|
||||||
|
sqlite3_close(db);
|
||||||
|
|
||||||
|
GraphData g{}; graph::GraphLoadStats s{};
|
||||||
|
bool ok = graph::graph_load_from_operations(path.c_str(), &g, &s);
|
||||||
|
CHECK(ok == false);
|
||||||
|
CHECK(s.errors >= 1);
|
||||||
|
std::remove(path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("graph_label devuelve nombre desde metadata.name", "[graph_sources]") {
|
||||||
|
std::string path = make_fixture("_lab");
|
||||||
|
GraphData g{}; graph::GraphLoadStats s{};
|
||||||
|
REQUIRE(graph::graph_load_from_operations(path.c_str(), &g, &s));
|
||||||
|
REQUIRE(g.node_count > 0);
|
||||||
|
const char* label = graph::graph_label(&g, g.nodes[0].label_idx);
|
||||||
|
CHECK(std::strlen(label) > 0);
|
||||||
|
// El primer entity tiene metadata.name = "Alice" (segun el insert).
|
||||||
|
CHECK(std::string(label) == "Alice");
|
||||||
|
graph::graph_free(&g);
|
||||||
|
std::remove(path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fase 3.3 — streaming
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("graph_stream: detecta filas nuevas", "[graph_sources][stream]") {
|
||||||
|
std::string path = make_fixture("_stream");
|
||||||
|
|
||||||
|
GraphData g{}; graph::GraphLoadStats s{};
|
||||||
|
REQUIRE(graph::graph_load_from_operations(path.c_str(), &g, &s));
|
||||||
|
int initial_nodes = g.node_count;
|
||||||
|
int initial_edges = g.edge_count;
|
||||||
|
|
||||||
|
auto* src = graph::graph_stream_operations_open(path.c_str(), 100);
|
||||||
|
REQUIRE(src != nullptr);
|
||||||
|
|
||||||
|
// Sin cambios, primer pull no aniade nada.
|
||||||
|
int n = graph::graph_stream_pull(src, &g);
|
||||||
|
INFO("first pull n=" << n << " node_count=" << g.node_count
|
||||||
|
<< " (initial=" << initial_nodes << ") edge_count=" << g.edge_count
|
||||||
|
<< " (initial=" << initial_edges << ")");
|
||||||
|
CHECK(n == 0);
|
||||||
|
CHECK(g.node_count == initial_nodes);
|
||||||
|
CHECK(g.edge_count == initial_edges);
|
||||||
|
|
||||||
|
// Insertar dos entities y una relacion con updated_at posterior.
|
||||||
|
sqlite3* db = nullptr;
|
||||||
|
REQUIRE(sqlite3_open(path.c_str(), &db) == SQLITE_OK);
|
||||||
|
exec_or_die(db,
|
||||||
|
"INSERT INTO entities (id, name, type_ref, updated_at) VALUES "
|
||||||
|
"('p99', 'Eve', 'Person', '2027-01-01T00:00:00Z'),"
|
||||||
|
"('e99', 'eve@x.com', 'Email', '2027-01-01T00:00:00Z');"
|
||||||
|
"INSERT INTO relations (id, name, from_entity, to_entity, weight, updated_at) VALUES "
|
||||||
|
"('r99', 'owns', 'p99', 'e99', 1.0, '2027-01-01T00:00:00Z');");
|
||||||
|
sqlite3_close(db);
|
||||||
|
|
||||||
|
n = graph::graph_stream_pull(src, &g);
|
||||||
|
CHECK(n >= 2); // 2 entities + 1 relacion
|
||||||
|
CHECK(g.node_count == initial_nodes + 2);
|
||||||
|
CHECK(g.edge_count == initial_edges + 1);
|
||||||
|
|
||||||
|
// Idempotencia: segundo pull no aniade.
|
||||||
|
n = graph::graph_stream_pull(src, &g);
|
||||||
|
CHECK(n == 0);
|
||||||
|
|
||||||
|
graph::graph_stream_close(src);
|
||||||
|
graph::graph_free(&g);
|
||||||
|
std::remove(path.c_str());
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
| [0049d](completed/0049d-graph-edges-vertex-pulling.md) | Aristas via vertex pulling con TBO | completado | alta | perf | parte de 0049 |
|
| [0049d](completed/0049d-graph-edges-vertex-pulling.md) | Aristas via vertex pulling con TBO | completado | alta | perf | parte de 0049 |
|
||||||
| [0049e](completed/0049e-graph-types-extended.md) | graph_types modelo extendido + EntityType/RelationType | completado | alta | feature | parte de 0049 |
|
| [0049e](completed/0049e-graph-types-extended.md) | graph_types modelo extendido + EntityType/RelationType | completado | alta | feature | parte de 0049 |
|
||||||
| [0049f](completed/0049f-graph-renderer-symbols.md) | Renderer extendido: shapes SDF, icon atlas, flechas, edge styles | completado | alta | feature | parte de 0049 |
|
| [0049f](completed/0049f-graph-renderer-symbols.md) | Renderer extendido: shapes SDF, icon atlas, flechas, edge styles | completado | alta | feature | parte de 0049 |
|
||||||
| [0049g](0049g-graph-source-operations.md) | graph_sources: lector operations.db + abstraccion funcional | pendiente | alta | feature | parte de 0049 |
|
| [0049g](completed/0049g-graph-source-operations.md) | graph_sources: lector operations.db + abstraccion funcional | completado | alta | feature | parte de 0049 |
|
||||||
| [0049h](0049h-graph-force-layout-gpu.md) | graph_force_layout_gpu: compute shader + spatial hash | pendiente | media-alta | feature | parte de 0049 |
|
| [0049h](0049h-graph-force-layout-gpu.md) | graph_force_layout_gpu: compute shader + spatial hash | pendiente | media-alta | feature | parte de 0049 |
|
||||||
| [0049i](0049i-graph-layouts-static.md) | graph_layouts (radial/hierarchical/fixed) + viewport multi-select | pendiente | media | feature | parte de 0049 |
|
| [0049i](0049i-graph-layouts-static.md) | graph_layouts (radial/hierarchical/fixed) + viewport multi-select | pendiente | media | feature | parte de 0049 |
|
||||||
| [0049j](0049j-graph-labels.md) | graph_labels: render etiquetas con LabelPolicy | pendiente | media | feature | parte de 0049 |
|
| [0049j](0049j-graph-labels.md) | graph_labels: render etiquetas con LabelPolicy | pendiente | media | feature | parte de 0049 |
|
||||||
|
|||||||
Reference in New Issue
Block a user