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>
This commit is contained in:
+323
@@ -329,6 +329,329 @@ bool entity_set_notes(const char* db_path, const char* id, const char* notes) {
|
||||
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
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user