Files
graph_explorer/entity_ops.cpp
T
egutierrez 6ed00fcdce feat(entity_ops): EntityRecord + JSON helpers + load/update/list_tags
Issue 0008 — capa de datos para el Inspector editable:
- struct MetadataField {key, value_str, is_string} — pares de la
  columna metadata. is_string distingue '"foo"' de literal (number,
  bool). EntityRecord agrupa los campos editables (id, name, type_ref,
  description, status, tags[], metadata[]).
- entity_load_full: SELECT name/type/desc/status/tags/metadata, parsea
  JSON plano con un parser propio (evita arrastrar libs). Soporta
  escapes basicos (\n \t \" \\\\ etc.; \uXXXX → '?').
- entity_update: un solo UPDATE con tags+metadata serializados a JSON.
  Toca updated_at.
- entity_list_distinct_tags: usa json_each (SQLITE_ENABLE_JSON1) para
  enumerar tags distintas — autocomplete del Inspector.
- Parser JSON plano: parse_string_array, parse_flat_object. Solo
  objetos planos (sin nested objects/arrays excepto consumirlos como
  literal). Suficiente para el caso del Inspector.
- Writer JSON: build_string_array, build_flat_object con escape
  apropiado. Si is_string=false pero el valor no es literal valido,
  se re-emite como string para no producir JSON invalido.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 00:12:44 +02:00

689 lines
24 KiB
C++

#include "entity_ops.h"
#include "../../../../cpp/vendor/sqlite3/sqlite3.h"
#include <chrono>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <cctype>
namespace ge {
// ----------------------------------------------------------------------------
// FNV1a-64 — debe coincidir con graph_sources.cpp
// ----------------------------------------------------------------------------
static uint64_t fnv1a64(const char* s) {
uint64_t h = 1469598103934665603ULL;
for (; s && *s; ++s) {
h ^= (uint8_t)*s;
h *= 1099511628211ULL;
}
return h;
}
// ----------------------------------------------------------------------------
// Heuristicas — sin <regex> para evitar peso. Inspeccion lineal del string.
// ----------------------------------------------------------------------------
static bool is_email(const char* s) {
if (!s || !*s) return false;
const char* at = std::strchr(s, '@');
if (!at || at == s) return false;
if (std::strchr(at + 1, '@')) return false; // dos @
const char* dot = std::strchr(at + 1, '.');
if (!dot || dot[1] == 0) return false;
if (std::strchr(s, ' ')) return false;
return true;
}
static bool is_ipv4(const char* s) {
if (!s || !*s) return false;
int parts = 0, digits = 0, n = 0;
for (const char* p = s; ; ++p) {
if (*p >= '0' && *p <= '9') {
n = n * 10 + (*p - '0');
if (++digits > 3 || n > 255) return false;
} else if (*p == '.' || *p == 0) {
if (digits == 0) return false;
++parts; digits = 0; n = 0;
if (*p == 0) break;
} else {
return false;
}
}
return parts == 4;
}
static bool is_url(const char* s) {
if (!s) return false;
return std::strncmp(s, "http://", 7) == 0 || std::strncmp(s, "https://", 8) == 0;
}
static bool is_domain(const char* s) {
if (!s || !*s) return false;
if (std::strchr(s, ' ') || std::strchr(s, '@') || std::strchr(s, '/')) return false;
const char* dot = std::strchr(s, '.');
if (!dot || dot == s || dot[1] == 0) return false;
// El TLD debe ser al menos 2 caracteres alfabeticos
int tld = 0;
for (const char* p = std::strrchr(s, '.') + 1; *p; ++p) {
if (!std::isalpha((unsigned char)*p)) return false;
++tld;
}
return tld >= 2;
}
static bool is_phone(const char* s) {
if (!s || !*s) return false;
int digits = 0;
for (const char* p = s; *p; ++p) {
if (*p >= '0' && *p <= '9') ++digits;
else if (*p == '+' || *p == ' ' || *p == '-' || *p == '(' || *p == ')' || *p == '.') {}
else return false;
}
return digits >= 7 && digits <= 15;
}
DetectedType detect_type(const char* text) {
if (!text || !*text) return DT_TEXT;
if (is_email(text)) return DT_EMAIL;
if (is_ipv4(text)) return DT_IP_ADDRESS;
if (is_url(text)) return DT_URL;
if (is_phone(text)) return DT_PHONE; // antes que domain (numeros con puntos)
if (is_domain(text)) return DT_DOMAIN;
return DT_TEXT;
}
const char* detected_type_name(DetectedType dt) {
switch (dt) {
case DT_EMAIL: return "email";
case DT_IP_ADDRESS: return "ip_address";
case DT_URL: return "url";
case DT_DOMAIN: return "domain";
case DT_PHONE: return "phone";
case DT_TEXT:
default: return "text";
}
}
// ----------------------------------------------------------------------------
// SQLite helpers
// ----------------------------------------------------------------------------
static bool exec_one(sqlite3* db, const char* sql,
const char** params, int n_params)
{
sqlite3_stmt* st = nullptr;
if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) != SQLITE_OK) return false;
for (int i = 0; i < n_params; ++i) {
sqlite3_bind_text(st, i + 1, params[i] ? params[i] : "", -1, SQLITE_TRANSIENT);
}
int rc = sqlite3_step(st);
sqlite3_finalize(st);
return rc == SQLITE_DONE;
}
static std::string now_iso() {
using namespace std::chrono;
auto t = system_clock::to_time_t(system_clock::now());
char buf[32];
std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S", std::gmtime(&t));
return std::string(buf) + "Z";
}
static long long now_ms() {
using namespace std::chrono;
return duration_cast<milliseconds>(system_clock::now().time_since_epoch()).count();
}
// ----------------------------------------------------------------------------
// CRUD
// ----------------------------------------------------------------------------
bool entity_insert(const char* db_path, const char* name, const char* type_ref,
char* out_id, size_t out_id_n)
{
if (!db_path || !name || !*name || !out_id || out_id_n < 32) return false;
std::string tref;
if (type_ref && *type_ref) {
tref = type_ref;
} else {
tref = detected_type_name(detect_type(name));
}
std::snprintf(out_id, out_id_n, "%s_%lld", tref.c_str(), now_ms());
std::string ts = now_iso();
sqlite3* db = nullptr;
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) {
if (db) sqlite3_close(db);
return false;
}
const char* sql =
"INSERT INTO entities (id, name, type_ref, source, created_at, updated_at) "
"VALUES (?, ?, ?, 'manual', ?, ?)";
const char* params[5] = { out_id, name, tref.c_str(), ts.c_str(), ts.c_str() };
bool ok = exec_one(db, sql, params, 5);
sqlite3_close(db);
return ok;
}
bool entity_delete(const char* db_path, const char* id) {
if (!db_path || !id) return false;
sqlite3* db = nullptr;
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) {
if (db) sqlite3_close(db);
return false;
}
const char* p_rel[2] = { id, id };
const char* p_ent[1] = { id };
exec_one(db, "DELETE FROM relations WHERE from_entity = ? OR to_entity = ?", p_rel, 2);
bool ok = exec_one(db, "DELETE FROM entities WHERE id = ?", p_ent, 1);
sqlite3_close(db);
return ok;
}
bool entity_update_type(const char* db_path, const char* id, const char* new_type) {
if (!db_path || !id || !new_type) return false;
sqlite3* db = nullptr;
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) {
if (db) sqlite3_close(db);
return false;
}
std::string ts = now_iso();
const char* p[3] = { new_type, ts.c_str(), id };
bool ok = exec_one(db, "UPDATE entities SET type_ref = ?, updated_at = ? WHERE id = ?", p, 3);
sqlite3_close(db);
return ok;
}
bool entity_duplicate(const char* db_path, const char* id,
char* out_id, size_t out_id_n)
{
if (!db_path || !id || !out_id || out_id_n < 32) return false;
sqlite3* db = nullptr;
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) {
if (db) sqlite3_close(db);
return false;
}
sqlite3_stmt* st = nullptr;
const char* sel = "SELECT name, type_ref, description, domain, tags, source, metadata, notes "
"FROM entities WHERE id = ?";
if (sqlite3_prepare_v2(db, sel, -1, &st, nullptr) != SQLITE_OK) {
sqlite3_close(db);
return false;
}
sqlite3_bind_text(st, 1, id, -1, SQLITE_TRANSIENT);
if (sqlite3_step(st) != SQLITE_ROW) {
sqlite3_finalize(st);
sqlite3_close(db);
return false;
}
auto col = [&](int i) {
const unsigned char* p = sqlite3_column_text(st, i);
return std::string(p ? (const char*)p : "");
};
std::string name = col(0) + " (copia)";
std::string tref = col(1);
std::string desc = col(2);
std::string dom = col(3);
std::string tags = col(4);
std::string src = col(5);
std::string meta = col(6);
std::string notes = col(7);
sqlite3_finalize(st);
std::snprintf(out_id, out_id_n, "%s_%lld", tref.c_str(), now_ms());
std::string ts = now_iso();
const char* ins =
"INSERT INTO entities (id, name, type_ref, description, domain, tags, source, "
"metadata, notes, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
sqlite3_stmt* ist = nullptr;
if (sqlite3_prepare_v2(db, ins, -1, &ist, nullptr) != SQLITE_OK) {
sqlite3_close(db);
return false;
}
sqlite3_bind_text(ist, 1, out_id, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(ist, 2, name.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(ist, 3, tref.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(ist, 4, desc.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(ist, 5, dom.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(ist, 6, tags.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(ist, 7, src.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(ist, 8, meta.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(ist, 9, notes.c_str(),-1, SQLITE_TRANSIENT);
sqlite3_bind_text(ist, 10, ts.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(ist, 11, ts.c_str(), -1, SQLITE_TRANSIENT);
bool ok = sqlite3_step(ist) == SQLITE_DONE;
sqlite3_finalize(ist);
sqlite3_close(db);
return ok;
}
bool relation_insert(const char* db_path, const char* from_id, const char* to_id,
const char* name)
{
if (!db_path || !from_id || !to_id) return false;
const char* rel = (name && *name) ? name : k_default_relation_name;
sqlite3* db = nullptr;
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) {
if (db) sqlite3_close(db);
return false;
}
char id[64];
std::snprintf(id, sizeof(id), "rel_%lld", now_ms());
std::string ts = now_iso();
const char* sql =
"INSERT INTO relations (id, name, from_entity, to_entity, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?)";
const char* params[6] = { id, rel, from_id, to_id, ts.c_str(), ts.c_str() };
bool ok = exec_one(db, sql, params, 6);
sqlite3_close(db);
return ok;
}
// ----------------------------------------------------------------------------
// Notes (markdown)
// ----------------------------------------------------------------------------
bool entity_get_notes(const char* db_path, const char* id, std::string* out) {
if (!db_path || !id || !out) return false;
out->clear();
sqlite3* db = nullptr;
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK) {
if (db) sqlite3_close(db);
return false;
}
sqlite3_stmt* st = nullptr;
if (sqlite3_prepare_v2(db, "SELECT notes FROM entities WHERE id = ?", -1, &st, nullptr) != SQLITE_OK) {
sqlite3_close(db);
return false;
}
sqlite3_bind_text(st, 1, id, -1, SQLITE_TRANSIENT);
bool ok = false;
if (sqlite3_step(st) == SQLITE_ROW) {
const unsigned char* p = sqlite3_column_text(st, 0);
if (p) *out = (const char*)p;
ok = true;
}
sqlite3_finalize(st);
sqlite3_close(db);
return ok;
}
bool entity_set_notes(const char* db_path, const char* id, const char* notes) {
if (!db_path || !id) return false;
sqlite3* db = nullptr;
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) {
if (db) sqlite3_close(db);
return false;
}
std::string ts = now_iso();
const char* p[3] = { notes ? notes : "", ts.c_str(), id };
bool ok = exec_one(db, "UPDATE entities SET notes = ?, updated_at = ? WHERE id = ?", p, 3);
sqlite3_close(db);
return ok;
}
// ----------------------------------------------------------------------------
// JSON helpers — parser + writer minimal para objetos planos y arrays de
// strings. NO es un parser JSON general (no soporta objetos anidados, ni
// arrays anidados, ni escapes Unicode \uXXXX). Suficiente para tags JSON
// y metadata flat del Inspector.
// ----------------------------------------------------------------------------
namespace {
void skip_ws(const char*& p, const char* end) {
while (p < end) {
char c = *p;
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { ++p; continue; }
break;
}
}
// Parse un string JSON: comillas + contenido con escapes basicos. Devuelve
// el contenido decodificado en `out`. Avanza `p`. Retorna false si malformado.
bool parse_json_string(const char*& p, const char* end, std::string* out) {
if (p >= end || *p != '"') return false;
++p;
out->clear();
while (p < end) {
char c = *p++;
if (c == '"') return true;
if (c == '\\' && p < end) {
char e = *p++;
switch (e) {
case 'n': out->push_back('\n'); break;
case 't': out->push_back('\t'); break;
case 'r': out->push_back('\r'); break;
case '"': out->push_back('"'); break;
case '\\': out->push_back('\\'); break;
case '/': out->push_back('/'); break;
case 'b': out->push_back('\b'); break;
case 'f': out->push_back('\f'); break;
case 'u':
// Skip 4 hex chars; emit '?' (no soportamos Unicode escapes).
for (int i = 0; i < 4 && p < end; ++i) ++p;
out->push_back('?');
break;
default:
out->push_back(e);
break;
}
} else {
out->push_back(c);
}
}
return false;
}
// Parse un literal: numero, true, false, null. Avanza `p`. Devuelve raw
// (lo que aparece literalmente en el JSON, sin comillas).
bool parse_json_literal(const char*& p, const char* end, std::string* out) {
out->clear();
while (p < end) {
char c = *p;
if (c == ',' || c == '}' || c == ']' || c == ' ' || c == '\t' ||
c == '\n' || c == '\r')
break;
out->push_back(c);
++p;
}
return !out->empty();
}
void escape_json_string(const std::string& s, std::string* out) {
out->push_back('"');
for (char c : s) {
switch (c) {
case '"': *out += "\\\""; break;
case '\\': *out += "\\\\"; break;
case '\n': *out += "\\n"; break;
case '\r': *out += "\\r"; break;
case '\t': *out += "\\t"; break;
default:
if ((unsigned char)c < 0x20) {
char buf[8];
std::snprintf(buf, sizeof(buf), "\\u%04x", (unsigned)(unsigned char)c);
*out += buf;
} else {
out->push_back(c);
}
}
}
out->push_back('"');
}
// Parsea `["a","b",...]` (array plano de strings). Devuelve true si OK.
// Tolerante: si no es un array, devuelve false con `out` vacio.
bool parse_string_array(const std::string& src, std::vector<std::string>* out) {
out->clear();
if (src.empty()) return true;
const char* p = src.c_str();
const char* end = p + src.size();
skip_ws(p, end);
if (p >= end || *p != '[') return false;
++p;
skip_ws(p, end);
if (p < end && *p == ']') return true; // []
while (p < end) {
skip_ws(p, end);
std::string s;
if (*p == '"') {
if (!parse_json_string(p, end, &s)) return false;
} else {
if (!parse_json_literal(p, end, &s)) return false;
}
out->push_back(s);
skip_ws(p, end);
if (p >= end) return false;
if (*p == ',') { ++p; continue; }
if (*p == ']') { ++p; return true; }
return false;
}
return false;
}
// Parsea `{"k":"v","k2":42,"k3":true}` (objeto plano). Devuelve los pares
// en orden, con `is_string=true` si el valor venia entre comillas.
bool parse_flat_object(const std::string& src, std::vector<MetadataField>* out) {
out->clear();
if (src.empty()) return true;
const char* p = src.c_str();
const char* end = p + src.size();
skip_ws(p, end);
if (p >= end || *p != '{') return false;
++p;
skip_ws(p, end);
if (p < end && *p == '}') return true; // {}
while (p < end) {
skip_ws(p, end);
std::string key;
if (*p != '"' || !parse_json_string(p, end, &key)) return false;
skip_ws(p, end);
if (p >= end || *p != ':') return false;
++p;
skip_ws(p, end);
if (p >= end) return false;
MetadataField f;
f.key = std::move(key);
if (*p == '"') {
f.is_string = true;
if (!parse_json_string(p, end, &f.value_str)) return false;
} else if (*p == '{' || *p == '[') {
// Objeto/array anidado: consumir como literal hasta el cierre balanceado.
int depth = 0;
while (p < end) {
char c = *p++;
f.value_str.push_back(c);
if (c == '{' || c == '[') ++depth;
else if (c == '}' || c == ']') {
--depth;
if (depth == 0) break;
}
}
f.is_string = false;
} else {
f.is_string = false;
if (!parse_json_literal(p, end, &f.value_str)) return false;
}
out->push_back(std::move(f));
skip_ws(p, end);
if (p >= end) return false;
if (*p == ',') { ++p; continue; }
if (*p == '}') { ++p; return true; }
return false;
}
return false;
}
void build_string_array(const std::vector<std::string>& xs, std::string* out) {
out->push_back('[');
for (size_t i = 0; i < xs.size(); ++i) {
if (i) out->push_back(',');
escape_json_string(xs[i], out);
}
out->push_back(']');
}
void build_flat_object(const std::vector<MetadataField>& fs, std::string* out) {
out->push_back('{');
bool first = true;
for (const auto& f : fs) {
if (f.key.empty()) continue;
// Saltar fields con valor vacio para no ensuciar el JSON con
// claves "" / null. La excepcion es bool/string vacio explicito —
// pero en v1 las claves vacias se omiten.
if (f.is_string && f.value_str.empty()) continue;
if (!f.is_string && f.value_str.empty()) continue;
if (!first) out->push_back(',');
first = false;
escape_json_string(f.key, out);
out->push_back(':');
if (f.is_string) {
escape_json_string(f.value_str, out);
} else {
// Validar literal minimamente: si no es number/bool/null/array/object,
// re-emitir como string para no producir JSON invalido.
const std::string& v = f.value_str;
char first_c = v[0];
bool ok_literal =
(first_c == '-' || (first_c >= '0' && first_c <= '9')) ||
v == "true" || v == "false" || v == "null" ||
first_c == '{' || first_c == '[';
if (ok_literal) {
*out += v;
} else {
escape_json_string(v, out);
}
}
}
out->push_back('}');
}
} // namespace
bool entity_load_full(const char* db_path, const char* id, EntityRecord* out) {
if (!db_path || !id || !out) return false;
*out = EntityRecord{};
out->id = id;
sqlite3* db = nullptr;
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK) {
if (db) sqlite3_close(db);
return false;
}
sqlite3_stmt* st = nullptr;
const char* sql =
"SELECT name, type_ref, description, status, tags, metadata "
"FROM entities WHERE id = ?";
if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) != SQLITE_OK) {
sqlite3_close(db);
return false;
}
sqlite3_bind_text(st, 1, id, -1, SQLITE_TRANSIENT);
bool ok = false;
if (sqlite3_step(st) == SQLITE_ROW) {
auto col = [&](int i) {
const unsigned char* p = sqlite3_column_text(st, i);
return std::string(p ? (const char*)p : "");
};
out->name = col(0);
out->type_ref = col(1);
out->description = col(2);
out->status = col(3);
std::string tags_json = col(4);
std::string meta_json = col(5);
parse_string_array(tags_json, &out->tags);
parse_flat_object(meta_json, &out->metadata);
ok = true;
}
sqlite3_finalize(st);
sqlite3_close(db);
return ok;
}
bool entity_update(const char* db_path, const EntityRecord& rec) {
if (!db_path || rec.id.empty()) return false;
sqlite3* db = nullptr;
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) {
if (db) sqlite3_close(db);
return false;
}
std::string tags_json;
std::string meta_json;
build_string_array(rec.tags, &tags_json);
build_flat_object(rec.metadata, &meta_json);
std::string ts = now_iso();
std::string status = rec.status.empty() ? std::string("active") : rec.status;
const char* sql =
"UPDATE entities SET name = ?, type_ref = ?, description = ?, "
"status = ?, tags = ?, metadata = ?, updated_at = ? WHERE 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, rec.name.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 2, rec.type_ref.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 3, rec.description.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 4, status.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 5, tags_json.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 6, meta_json.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 7, ts.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 8, rec.id.c_str(), -1, SQLITE_TRANSIENT);
bool ok = sqlite3_step(st) == SQLITE_DONE;
sqlite3_finalize(st);
sqlite3_close(db);
return ok;
}
bool entity_list_distinct_tags(const char* db_path,
std::vector<std::string>* out)
{
if (!db_path || !out) return false;
out->clear();
sqlite3* db = nullptr;
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK) {
if (db) sqlite3_close(db);
return false;
}
// json_each esta disponible (SQLITE_ENABLE_JSON1 — ver CMakeLists del
// amalgamation). Solo strings como tag (kind = 'text').
const char* sql =
"SELECT DISTINCT json_each.value FROM entities, json_each(entities.tags) "
"WHERE json_each.type = 'text' ORDER BY json_each.value";
sqlite3_stmt* st = nullptr;
if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) != SQLITE_OK) {
sqlite3_close(db);
return false;
}
while (sqlite3_step(st) == SQLITE_ROW) {
const unsigned char* p = sqlite3_column_text(st, 0);
if (p && *p) out->emplace_back((const char*)p);
}
sqlite3_finalize(st);
sqlite3_close(db);
return true;
}
// ----------------------------------------------------------------------------
// Index user_data -> sql id
// ----------------------------------------------------------------------------
bool entity_index_build(const char* db_path, EntityIndex* idx) {
if (!db_path || !idx) return false;
idx->by_hash.clear();
sqlite3* db = nullptr;
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK) {
if (db) sqlite3_close(db);
return false;
}
sqlite3_stmt* st = nullptr;
if (sqlite3_prepare_v2(db, "SELECT id FROM entities", -1, &st, nullptr) != SQLITE_OK) {
sqlite3_close(db);
return false;
}
while (sqlite3_step(st) == SQLITE_ROW) {
const unsigned char* p = sqlite3_column_text(st, 0);
if (!p) continue;
const char* id = (const char*)p;
idx->by_hash.emplace(fnv1a64(id), std::string(id));
}
sqlite3_finalize(st);
sqlite3_close(db);
return true;
}
const char* entity_index_lookup(const EntityIndex& idx, uint64_t user_data) {
auto it = idx.by_hash.find(user_data);
return (it == idx.by_hash.end()) ? nullptr : it->second.c_str();
}
} // namespace ge