diff --git a/issues/0011-tablenode-expanded-promote.md b/issues/completed/0011-tablenode-expanded-promote.md similarity index 100% rename from issues/0011-tablenode-expanded-promote.md rename to issues/completed/0011-tablenode-expanded-promote.md diff --git a/tableview.cpp b/tableview.cpp index 2ebcdfd..35723f3 100644 --- a/tableview.cpp +++ b/tableview.cpp @@ -354,4 +354,418 @@ bool tableview_refresh_counts(const char* ops_db, return true; } +// ---------------------------------------------------------------------------- +// Issue 0011 — UI fase 2 helpers +// ---------------------------------------------------------------------------- + +namespace { + +std::string sanitize_id_part(const char* s) { + std::string out; + if (!s) return out; + for (const char* p = s; *p && out.size() < 60; ++p) { + char c = *p; + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') + || (c >= '0' && c <= '9') || c == '_') out += c; + else out += '_'; + } + return out; +} + +// Parser minimo para extraer un array de strings de un JSON tipo +// "[\"a\",\"b\",\"c\"]". No es un JSON parser general — se usa solo sobre +// el campo `columns` que escribe esta misma capa, asi que el formato esta +// controlado. +std::vector parse_json_string_array(const char* s) { + std::vector out; + if (!s) return out; + const char* p = s; + while (*p && *p != '[') ++p; + if (*p != '[') return out; + ++p; + while (*p) { + while (*p && (*p == ' ' || *p == ',' || *p == '\t' || *p == '\n')) ++p; + if (*p == ']' || !*p) break; + if (*p != '"') { ++p; continue; } + ++p; + std::string v; + while (*p && *p != '"') { + if (*p == '\\' && p[1]) { v += p[1]; p += 2; } + else { v += *p++; } + } + if (*p == '"') ++p; + out.push_back(std::move(v)); + } + return out; +} + +} // namespace + +bool tableview_list_columns(const char* duckdb_path, + const char* duck_table, + std::vector* out) +{ + if (!duckdb_path || !duck_table || !out) return false; + out->clear(); + DuckHandle h; + if (!h.open(duckdb_path)) return false; + std::string sql = "SELECT * FROM " + sanitize_ident(duck_table) + " LIMIT 0"; + duckdb_result r; + if (duckdb_query(h.cn, sql.c_str(), &r) == DuckDBError) { + duckdb_destroy_result(&r); + return false; + } + idx_t cn = duckdb_column_count(&r); + for (idx_t i = 0; i < cn; ++i) { + const char* name = duckdb_column_name(&r, i); + if (name) out->emplace_back(name); + } + duckdb_destroy_result(&r); + return true; +} + +bool tableview_get_metadata(const char* ops_db, const char* entity_id, + TableMetadata* out) +{ + if (!ops_db || !entity_id || !out) return false; + *out = TableMetadata{}; + out->entity_id = entity_id; + 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 name, " + " json_extract(metadata, '$.duckdb_path'), " + " json_extract(metadata, '$.table_name'), " + " json_extract(metadata, '$.row_type'), " + " json_extract(metadata, '$.id_column'), " + " json_extract(metadata, '$.label_column'), " + " json_extract(metadata, '$.columns'), " + " json_extract(metadata, '$.filter_sql'), " + " json_extract(metadata, '$.expanded') " + "FROM entities WHERE id = ? AND type_ref = 'Table'"; + 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, entity_id, -1, SQLITE_TRANSIENT); + bool ok = false; + if (sqlite3_step(st) == SQLITE_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(); + }; + out->name = col_text(0); + out->duckdb_path = col_text(1); + out->table_name = col_text(2); + out->row_type = col_text(3); + out->id_column = col_text(4); + out->label_column = col_text(5); + std::string cols_json = col_text(6); + out->filter_sql = col_text(7); + std::string exp_json = col_text(8); + + if (out->id_column.empty()) out->id_column = "id"; + if (out->label_column.empty()) out->label_column = "name"; + out->columns = parse_json_string_array(cols_json.c_str()); + out->expanded = (exp_json == "true" || exp_json == "1"); + out->duckdb_path_abs = tableview_resolve_path(ops_db, out->duckdb_path.c_str()); + ok = true; + } + sqlite3_finalize(st); + sqlite3_close(db); + return ok; +} + +namespace { + +bool exec_metadata_patch(const char* ops_db, const char* entity_id, + const char* json_set_clause) +{ + sqlite3* db = nullptr; + if (sqlite3_open_v2(ops_db, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return false; + } + std::string sql = "UPDATE entities SET metadata = "; + sql += json_set_clause; + sql += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"; + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, sql.c_str(), -1, &st, nullptr) != SQLITE_OK) { + sqlite3_close(db); + return false; + } + sqlite3_bind_text(st, 1, entity_id, -1, SQLITE_TRANSIENT); + bool ok = sqlite3_step(st) == SQLITE_DONE; + sqlite3_finalize(st); + sqlite3_close(db); + return ok; +} + +} // namespace + +bool tableview_set_expanded(const char* ops_db, const char* entity_id, + bool expanded) +{ + if (!ops_db || !entity_id) return false; + std::string clause = "json_set(COALESCE(metadata,'{}'), '$.expanded', json('"; + clause += expanded ? "true" : "false"; + clause += "'))"; + return exec_metadata_patch(ops_db, entity_id, clause.c_str()); +} + +bool tableview_set_columns(const char* ops_db, const char* entity_id, + const std::vector& columns) +{ + if (!ops_db || !entity_id) return false; + std::string arr = "["; + for (size_t i = 0; i < columns.size(); ++i) { + if (i) arr += ","; + arr += "\""; + arr += sql_escape(columns[i].c_str()); + arr += "\""; + } + arr += "]"; + std::string clause = "json_set(COALESCE(metadata,'{}'), '$.columns', json('"; + clause += sql_escape(arr.c_str()); + clause += "'))"; + return exec_metadata_patch(ops_db, entity_id, clause.c_str()); +} + +namespace { + +// Busca si ya existe una entidad promovida por (duckdb_path, row_id). +bool find_existing_promotion(const char* ops_db, const char* duckdb_path, + const char* row_id, std::string* out_id) +{ + if (!ops_db || !duckdb_path || !row_id || !out_id) return false; + out_id->clear(); + sqlite3* db = nullptr; + if (sqlite3_open_v2(ops_db, &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return false; + } + const char* sql = + "SELECT id FROM entities " + "WHERE json_extract(metadata, '$.source.duckdb') = ? " + " AND json_extract(metadata, '$.source.row_id') = ? " + "LIMIT 1"; + 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, duckdb_path, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 2, row_id, -1, SQLITE_TRANSIENT); + bool found = false; + if (sqlite3_step(st) == SQLITE_ROW) { + const unsigned char* p = sqlite3_column_text(st, 0); + if (p) { *out_id = (const char*)p; found = true; } + } + sqlite3_finalize(st); + sqlite3_close(db); + return found; +} + +} // namespace + +bool tableview_promote_row(const char* ops_db, + const char* duckdb_path, + const char* duck_table, + const char* row_id, + const char* row_type, + const char* label_column, + char* out_entity_id, std::size_t out_id_n) +{ + if (!ops_db || !duckdb_path || !duck_table || !row_id) return false; + if (!row_type || !*row_type) row_type = "Row"; + if (!label_column || !*label_column) label_column = "name"; + + // Idempotencia: si ya esta promovida, devuelve el id existente. + { + std::string existing; + if (find_existing_promotion(ops_db, duckdb_path, row_id, &existing)) { + if (out_entity_id && out_id_n > 0) { + std::snprintf(out_entity_id, out_id_n, "%s", existing.c_str()); + } + return true; + } + } + + // Lee la fila + label desde DuckDB. + DuckHandle h; + if (!h.open(duckdb_path)) return false; + std::string tn = sanitize_ident(duck_table); + std::string idc = "id"; // por convencion. Si la tabla usa otro PK, el caller lo sabe. + { + // Best-effort: si label_column existe en la tabla, lo seleccionamos. + std::string sel = "SELECT * FROM " + tn + " WHERE CAST(" + idc + " AS VARCHAR) = '" + + sql_escape(row_id) + "' LIMIT 1"; + duckdb_result r; + if (duckdb_query(h.cn, sel.c_str(), &r) == DuckDBError) { + std::fprintf(stderr, "[promote] query fail: %s\n", + duckdb_result_error(&r) ? duckdb_result_error(&r) : "?"); + duckdb_destroy_result(&r); + return false; + } + if (duckdb_row_count(&r) == 0) { + duckdb_destroy_result(&r); + return false; + } + // Construir name = valor del label_column si existe; fallback row_id. + std::string display_name = row_id; + idx_t ncols = duckdb_column_count(&r); + for (idx_t i = 0; i < ncols; ++i) { + const char* cname = duckdb_column_name(&r, i); + if (cname && std::strcmp(cname, label_column) == 0) { + if (!duckdb_value_is_null(&r, i, 0)) { + char* v = duckdb_value_varchar(&r, i, 0); + if (v) { display_name = v; duckdb_free(v); } + } + break; + } + } + + // Construir metadata JSON con source + columnas. + std::string meta = "{\"source\":{"; + meta += "\"duckdb\":\""; meta += sql_escape(duckdb_path); meta += "\","; + meta += "\"table\":\""; meta += sql_escape(duck_table); meta += "\","; + meta += "\"row_id\":\""; meta += sql_escape(row_id); meta += "\""; + meta += "}"; + for (idx_t i = 0; i < ncols; ++i) { + const char* cname = duckdb_column_name(&r, i); + if (!cname || !*cname) continue; + meta += ",\""; + meta += sql_escape(cname); + meta += "\":"; + if (duckdb_value_is_null(&r, i, 0)) { + meta += "null"; + } else { + char* v = duckdb_value_varchar(&r, i, 0); + meta += "\""; + if (v) { meta += sql_escape(v); duckdb_free(v); } + meta += "\""; + } + } + meta += "}"; + duckdb_destroy_result(&r); + + // Genera id estable. + std::string entity_id = "prom_"; + entity_id += sanitize_id_part(row_type); + entity_id += "_"; + entity_id += sanitize_id_part(row_id); + + sqlite3* db = nullptr; + if (sqlite3_open_v2(ops_db, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return false; + } + const char* ins = + "INSERT OR IGNORE INTO entities(" + " id, name, type_ref, status, tags, source, metadata, " + " created_at, updated_at) " + "VALUES (?, ?, ?, 'active', '[]', 'tableview', ?, " + " strftime('%Y-%m-%dT%H:%M:%fZ','now'), " + " strftime('%Y-%m-%dT%H:%M:%fZ','now'))"; + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, ins, -1, &st, nullptr) != SQLITE_OK) { + sqlite3_close(db); + return false; + } + sqlite3_bind_text(st, 1, entity_id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 2, display_name.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, row_type, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, meta.c_str(), -1, SQLITE_TRANSIENT); + bool ok = sqlite3_step(st) == SQLITE_DONE; + sqlite3_finalize(st); + sqlite3_close(db); + if (ok && out_entity_id && out_id_n > 0) { + std::snprintf(out_entity_id, out_id_n, "%s", entity_id.c_str()); + } + return ok; + } +} + +bool tableview_demote_row(const char* ops_db, const char* entity_id) { + if (!ops_db || !entity_id) return false; + sqlite3* db = nullptr; + if (sqlite3_open_v2(ops_db, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return false; + } + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, "DELETE FROM entities WHERE id = ?", + -1, &st, nullptr) != SQLITE_OK) { + sqlite3_close(db); + return false; + } + sqlite3_bind_text(st, 1, entity_id, -1, SQLITE_TRANSIENT); + bool ok = sqlite3_step(st) == SQLITE_DONE; + sqlite3_finalize(st); + sqlite3_close(db); + return ok; +} + +namespace { + +IngestKind detect_ingest_kind(const char* path) { + if (!path) return INGEST_CSV; + const char* dot = std::strrchr(path, '.'); + if (!dot) return INGEST_CSV; + if (std::strcmp(dot, ".csv") == 0) return INGEST_CSV; + if (std::strcmp(dot, ".tsv") == 0) return INGEST_CSV; + if (std::strcmp(dot, ".parquet") == 0) return INGEST_PARQUET; + if (std::strcmp(dot, ".pq") == 0) return INGEST_PARQUET; + if (std::strcmp(dot, ".json") == 0) return INGEST_JSON; + if (std::strcmp(dot, ".ndjson") == 0) return INGEST_JSON; + return INGEST_CSV; +} + +const char* ingest_func_name(IngestKind k) { + switch (k) { + case INGEST_PARQUET: return "read_parquet"; + case INGEST_JSON: return "read_json_auto"; + default: return "read_csv_auto"; + } +} + +} // namespace + +bool tableview_ingest_file(const char* duckdb_path, + const char* file_path, + const char* dest_table, + IngestKind kind, + std::string* out_error) +{ + if (!duckdb_path || !file_path || !dest_table) { + if (out_error) *out_error = "args nulos"; + return false; + } + if (kind == INGEST_AUTO) kind = detect_ingest_kind(file_path); + + DuckHandle h; + if (!h.open(duckdb_path)) { + if (out_error) *out_error = "no se pudo abrir el .duckdb"; + return false; + } + std::string tn = sanitize_ident(dest_table); + std::string sql = "CREATE TABLE " + tn + " AS SELECT * FROM " + + ingest_func_name(kind) + "('" + sql_escape(file_path) + "')"; + duckdb_result r; + if (duckdb_query(h.cn, sql.c_str(), &r) == DuckDBError) { + if (out_error) { + const char* err = duckdb_result_error(&r); + *out_error = err ? err : "ingest fail"; + } + duckdb_destroy_result(&r); + return false; + } + duckdb_destroy_result(&r); + return true; +} + } // namespace ge diff --git a/tableview.h b/tableview.h index d81a79b..2b3fa03 100644 --- a/tableview.h +++ b/tableview.h @@ -76,4 +76,82 @@ struct TableCounts { bool tableview_refresh_counts(const char* ops_db, std::unordered_map* out); +// ---------------------------------------------------------------------------- +// Issue 0011 — UI fase 2 helpers +// ---------------------------------------------------------------------------- + +// Snapshot de la metadata relevante de un nodo Table. Caller-owned strings. +struct TableMetadata { + std::string entity_id; + std::string name; + std::string duckdb_path; // tal como aparece en metadata (relativo o absoluto) + std::string duckdb_path_abs; // resuelto con tableview_resolve_path + std::string table_name; + std::string row_type; + std::string id_column; + std::string label_column; + std::vector columns; + std::string filter_sql; + bool expanded = false; +}; + +// Lee la metadata del nodo Table (entidad type_ref='Table' con id=`entity_id`). +// Devuelve false si no existe o falla el JSON. Aplica defaults razonables a +// los campos faltantes (id_column='id', label_column='name'). +bool tableview_get_metadata(const char* ops_db, const char* entity_id, + TableMetadata* out); + +// Persiste el flag `expanded` en la metadata. Idempotente. Devuelve false +// en error de IO/SQL. +bool tableview_set_expanded(const char* ops_db, const char* entity_id, + bool expanded); + +// Sobrescribe el array `columns` en la metadata. Llamar tras editar columnas +// desde la UI o tras tableview_create cuando se descubren columnas. +bool tableview_set_columns(const char* ops_db, const char* entity_id, + const std::vector& columns); + +// Promueve una fila de DuckDB a entidad del grafo. Idempotente: si ya existe +// una entidad con metadata.source.row_id == row_id Y metadata.source.duckdb +// == duckdb_path, devuelve esa misma id en out_id sin tocar nada. +// +// Si no existe, lee la fila de DuckDB con SELECT *, construye un id estable +// "prom__", e inserta en ops.entities +// con type_ref=row_type, name = valor del label_column (o row_id si vacio), +// metadata = { source: {duckdb, table, row_id}, }. +bool tableview_promote_row(const char* ops_db, + const char* duckdb_path, + const char* duck_table, + const char* row_id, + const char* row_type, + const char* label_column, + char* out_entity_id, std::size_t out_id_n); + +// Borra la entidad. La fila DuckDB sigue intacta. Devuelve true si la fila +// no existia (no-op idempotente). +bool tableview_demote_row(const char* ops_db, const char* entity_id); + +// Tipos de fichero soportados por tableview_ingest_file. +enum IngestKind { + INGEST_AUTO = 0, // detecta por extension + INGEST_CSV = 1, + INGEST_PARQUET = 2, + INGEST_JSON = 3, +}; + +// Importa un fichero CSV/Parquet/JSON al `.duckdb`. Crea el .duckdb si no +// existe. Si la tabla destino existe, falla (no sobrescribe — explicit fail). +// Por defecto INGEST_AUTO inspecciona la extension del path. +bool tableview_ingest_file(const char* duckdb_path, + const char* file_path, + const char* dest_table, + IngestKind kind, + std::string* out_error); + +// Lista los nombres de columna de la tabla DuckDB. Para popular la lista +// `columns` por defecto en tableview_create. +bool tableview_list_columns(const char* duckdb_path, + const char* duck_table, + std::vector* out); + } // namespace ge