facett-icebergview/src/catalog.rs
//! **catalog** — browse **every table** in an Iceberg catalog / warehouse and
//! its info, one level up from the single-table [`IcebergView`](crate::IcebergView).
//!
//! Where `IcebergView` scrubs the snapshots of *one* table, [`CatalogView`] is the
//! **table-of-tables**: the list a user lands on when they point korp at "Spark
//! 4.1 / Iceberg 1.11 — either via Connect or straight on disk" and want to *see
//! what is in there*. Per table it shows namespace, row count, snapshot count,
//! latest-commit time, and the schema; selecting one is the host's cue to load
//! that table's frames into an [`IcebergView`].
//!
//! Like the rest of facett it is **engine-agnostic** (FC-8): it takes *data, not a
//! catalog*. The host fills a `Vec<`[`TableInfo`]`>` from whatever it has —
//! Spark Connect (`SHOW TABLES` + `.snapshots` + `DESCRIBE`) or an on-disk skade
//! warehouse — and hands it in. This crate never depends on spark/skade/iceberg,
//! so the same viewer serves both lanes with one code path.
//!
//! ## FC contract (the canonical Elm split)
//! - **[`CatalogState`]** — the complete observable, serializable state: the
//! source label, the table infos, the current name filter, and the **selected
//! table's fully-qualified name** (FC-5: a stable id, not a list index).
//! - **[`Msg`] + [`CatalogView::update`]** — the single mutation path: select a
//! table by fqn, set the filter, clear the selection.
//! - **[`CatalogView::view`]** — a pure paint (FC-9): a filter box + a selectable
//! list of tables + a schema panel for the selected one; it returns the [`Msg`]s
//! the frame produced.
//! - **[`Effect`]** — uninhabited: selecting a table produces no I/O here; the host
//! watches [`CatalogState::selected`] and does the frame load itself.
use facett_core::{FacetCaps, theme};
use serde::{Deserialize, Serialize};
/// One column of a table's schema, for the info panel + `state_json`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ColumnInfo {
/// Column name.
pub name: String,
/// Column type, as the source spells it (`bigint`, `string`, `timestamp`, …).
pub data_type: String,
}
impl ColumnInfo {
pub fn new(name: impl Into<String>, data_type: impl Into<String>) -> Self {
Self { name: name.into(), data_type: data_type.into() }
}
}
/// One table's **catalog-level** info — everything the browser shows without
/// loading a single data row. The host fills it from Spark Connect
/// (`SHOW TABLES`/`.snapshots`/`DESCRIBE`) or an on-disk skade warehouse; unknown
/// numeric fields are `0`, never faked.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TableInfo {
/// The namespace / catalog path, e.g. `lake.knut_demo`.
pub namespace: String,
/// The table name, e.g. `people`.
pub name: String,
/// Row count of the latest snapshot (0 if the host did not compute it).
pub rows: u64,
/// Number of snapshots (Iceberg history depth; 0 if unknown).
pub snapshots: u64,
/// Newest snapshot commit time, epoch milliseconds (0 if none / unknown).
pub latest_commit_ms: i64,
/// The table schema (name + type per column).
pub columns: Vec<ColumnInfo>,
}
impl TableInfo {
/// A bare info with no schema/metrics yet — chain the setters to fill it.
pub fn new(namespace: impl Into<String>, name: impl Into<String>) -> Self {
Self {
namespace: namespace.into(),
name: name.into(),
rows: 0,
snapshots: 0,
latest_commit_ms: 0,
columns: Vec::new(),
}
}
pub fn with_rows(mut self, rows: u64) -> Self {
self.rows = rows;
self
}
pub fn with_snapshots(mut self, n: u64) -> Self {
self.snapshots = n;
self
}
pub fn with_latest_commit_ms(mut self, ms: i64) -> Self {
self.latest_commit_ms = ms;
self
}
pub fn with_columns(mut self, columns: Vec<ColumnInfo>) -> Self {
self.columns = columns;
self
}
/// The fully-qualified name (`namespace.name`) — the stable id the picker and
/// [`CatalogState::selected`] key on (FC-5).
pub fn fqn(&self) -> String {
if self.namespace.is_empty() {
self.name.clone()
} else {
format!("{}.{}", self.namespace, self.name)
}
}
}
/// A robot-/CLI-addressable control message — every input [`CatalogView`]
/// accepts and the only legal way to mutate [`CatalogState`].
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Msg {
/// Select the table with this fully-qualified name (`namespace.name`). An
/// unknown fqn is a no-op (keeps the current selection).
SelectTable(String),
/// Set the case-insensitive name filter (substring over fqn); empty = all.
SetFilter(String),
/// Clear the current selection.
ClearSelection,
}
/// Side work as data (FC-8): selecting a table is not I/O here — the host reads
/// [`CatalogState::selected`] and loads that table's frames. Uninhabited on purpose.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Effect {}
/// The complete observable state (FC-1/FC-3) of a [`CatalogView`].
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CatalogState {
/// A human label for the source, e.g. `lake @ sc://localhost:15012` or
/// `/var/lib/lake (on-disk)`. Display only.
pub catalog: String,
/// The tables, in the order the host supplied them.
pub tables: Vec<TableInfo>,
/// The selected table's fqn (FC-5), or `None`.
pub selected: Option<String>,
/// The active name filter (substring over fqn, case-insensitive).
pub filter: String,
}
/// A browser over **all** tables in a catalog / warehouse.
pub struct CatalogView {
title: String,
state: CatalogState,
}
impl CatalogView {
/// Build a browser over `tables`, labelled `catalog` (the source string). No
/// selection and no filter to start.
pub fn new(
title: impl Into<String>,
catalog: impl Into<String>,
tables: Vec<TableInfo>,
) -> Self {
Self {
title: title.into(),
state: CatalogState {
catalog: catalog.into(),
tables,
selected: None,
filter: String::new(),
},
}
}
/// All observable state (FC-3).
pub fn state(&self) -> &CatalogState {
&self.state
}
/// Replace the table set (a re-list from the host). Drops a selection whose
/// table is gone; keeps it if the fqn still exists.
pub fn set_tables(&mut self, tables: Vec<TableInfo>) {
self.state.tables = tables;
let vanished = self
.state
.selected
.as_ref()
.is_some_and(|sel| !self.state.tables.iter().any(|t| &t.fqn() == sel));
if vanished {
self.state.selected = None;
}
}
/// The tables passing the current filter, in supplied order.
pub fn visible(&self) -> Vec<&TableInfo> {
let needle = self.state.filter.to_ascii_lowercase();
self.state
.tables
.iter()
.filter(|t| needle.is_empty() || t.fqn().to_ascii_lowercase().contains(&needle))
.collect()
}
/// The currently selected table's info, if any.
pub fn selected(&self) -> Option<&TableInfo> {
let sel = self.state.selected.as_ref()?;
self.state.tables.iter().find(|t| &t.fqn() == sel)
}
/// Select the table with `fqn` (host or picker). Returns whether it matched.
pub fn select(&mut self, fqn: &str) -> bool {
if self.state.tables.iter().any(|t| t.fqn() == fqn) {
self.state.selected = Some(fqn.to_string());
true
} else {
false
}
}
/// The single mutation path (FC-2).
pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
match msg {
Msg::SelectTable(fqn) => {
let _ = self.select(&fqn);
}
Msg::SetFilter(f) => {
self.state.filter = f;
}
Msg::ClearSelection => {
self.state.selected = None;
}
}
Vec::new()
}
/// FC-9 render — a pure function of `&self`: a filter box, a selectable list of
/// tables (name · rows · snapshots · latest commit), and a schema panel for the
/// selected table. Returns the [`Msg`]s the frame produced; the bridge applies
/// them through [`update`](Self::update).
pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
let mut msgs: Vec<Msg> = Vec::new();
let th = theme(ui);
ui.horizontal(|ui| {
ui.colored_label(th.text, format!("🧊 {}", self.state.catalog));
ui.separator();
ui.label(format!("{} tables", self.state.tables.len()));
});
// Filter box — a local buffer so the pure view can still emit a SetFilter.
ui.horizontal(|ui| {
ui.label("filter:");
let mut buf = self.state.filter.clone();
if ui.text_edit_singleline(&mut buf).changed() {
msgs.push(Msg::SetFilter(buf));
}
});
ui.separator();
// The table-of-tables. Each row is a selectable label; clicking it selects.
egui::ScrollArea::vertical().show(ui, |ui| {
for t in self.visible() {
let fqn = t.fqn();
let selected = self.state.selected.as_deref() == Some(fqn.as_str());
let label = format!(
"{fqn} · {} rows · {} snapshot{}",
t.rows,
t.snapshots,
if t.snapshots == 1 { "" } else { "s" },
);
if ui.selectable_label(selected, label).clicked() {
msgs.push(Msg::SelectTable(fqn));
}
}
});
// The schema panel for the selected table.
if let Some(info) = self.selected() {
ui.separator();
ui.colored_label(th.text, format!("schema · {}", info.fqn()));
for c in &info.columns {
ui.label(format!(" {} : {}", c.name, c.data_type));
}
}
msgs
}
}
impl facett_core::Elm for CatalogView {
type Model = CatalogState;
type Msg = Msg;
type Effect = Effect;
fn title(&self) -> &str {
&self.title
}
fn state(&self) -> &CatalogState {
&self.state
}
fn update(&mut self, msg: Msg) -> Vec<Effect> {
CatalogView::update(self, msg)
}
fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
CatalogView::view(self, ui)
}
}
// The bridge writes `impl Facet for CatalogView`. Form 3 (`custom_state_json`)
// because the browser publishes a richer blob than `serde(state())`: the derived
// `table_count`, the filtered `visible`, and the selected table's `info` a driver
// reads are computed here, not plain fields of the Model.
facett_core::impl_facet_via_elm!(CatalogView, custom_state_json, {
fn state_json(&self) -> serde_json::Value {
let info = |t: &TableInfo| serde_json::json!({
"fqn": t.fqn(),
"namespace": t.namespace,
"name": t.name,
"rows": t.rows,
"snapshots": t.snapshots,
"latest_commit_ms": t.latest_commit_ms,
"columns": t.columns.iter().map(|c| serde_json::json!({
"name": c.name, "type": c.data_type,
})).collect::<Vec<_>>(),
});
serde_json::json!({
"catalog": self.state.catalog,
"table_count": self.state.tables.len(),
"filter": self.state.filter,
"visible": self.visible().iter().map(|t| t.fqn()).collect::<Vec<_>>(),
"tables": self.state.tables.iter().map(&info).collect::<Vec<_>>(),
"selected": self.state.selected,
"selected_info": self.selected().map(info),
})
}
/// The robot / headless-drive seam (FC-2): parse one serde-tagged [`Msg`] and
/// apply it through the single mutation path. Malformed JSON is ignored.
fn update_json(&mut self, msg_json: &str) {
if let Ok(msg) = serde_json::from_str::<Msg>(msg_json) {
let _ = <Self as facett_core::Elm>::update(self, msg);
}
}
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.themeable().selectable().resizable()
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
});
#[cfg(test)]
mod tests {
use super::*;
use facett_core::{Facet, harness};
fn sample() -> CatalogView {
let people = TableInfo::new("lake.knut_demo", "people")
.with_rows(8)
.with_snapshots(2)
.with_latest_commit_ms(2_000)
.with_columns(vec![
ColumnInfo::new("id", "bigint"),
ColumnInfo::new("name", "string"),
ColumnInfo::new("age", "bigint"),
]);
let orders = TableInfo::new("lake.sales", "orders")
.with_rows(120)
.with_snapshots(5)
.with_latest_commit_ms(3_000);
CatalogView::new("Tables", "lake @ sc://localhost:15012", vec![people, orders])
}
#[test]
fn lists_every_table_with_its_info() {
let v = sample();
let s = v.state_json();
assert_eq!(s["table_count"], serde_json::json!(2));
assert_eq!(s["tables"][0]["fqn"], serde_json::json!("lake.knut_demo.people"));
assert_eq!(s["tables"][0]["rows"], serde_json::json!(8));
assert_eq!(s["tables"][0]["snapshots"], serde_json::json!(2));
assert_eq!(s["tables"][0]["columns"][2]["name"], serde_json::json!("age"));
assert_eq!(s["tables"][1]["fqn"], serde_json::json!("lake.sales.orders"));
}
#[test]
fn selecting_a_table_by_fqn_exposes_its_schema() {
let mut v = sample();
assert!(v.select("lake.knut_demo.people"));
let s = v.state_json();
assert_eq!(s["selected"], serde_json::json!("lake.knut_demo.people"));
assert_eq!(s["selected_info"]["rows"], serde_json::json!(8));
assert_eq!(s["selected_info"]["columns"][0]["name"], serde_json::json!("id"));
// An unknown fqn does not move the selection.
assert!(!v.select("lake.nope.ghost"));
assert_eq!(v.state().selected.as_deref(), Some("lake.knut_demo.people"));
}
#[test]
fn filter_narrows_the_visible_set_case_insensitively() {
let mut v = sample();
v.update(Msg::SetFilter("ORDER".into()));
let vis: Vec<String> = v.visible().iter().map(|t| t.fqn()).collect();
assert_eq!(vis, vec!["lake.sales.orders".to_string()]);
// state_json's `visible` mirrors it.
assert_eq!(v.state_json()["visible"], serde_json::json!(["lake.sales.orders"]));
}
/// The headless drive seam: a robot selects a table by feeding one JSON Msg
/// through the `Facet::update_json` wire seam — proving live == headless.
#[test]
fn robot_json_drive_selects_a_table() {
let mut v = sample();
Facet::update_json(&mut v, r#"{"select_table":"lake.sales.orders"}"#);
assert_eq!(v.state().selected.as_deref(), Some("lake.sales.orders"));
assert_eq!(v.state_json()["selected_info"]["snapshots"], serde_json::json!(5));
}
/// The view really paints headless (no display) — the robot-UI smoke that the
/// list + schema panel render without panicking, selection and all.
#[test]
fn paints_headlessly() {
let mut v = sample();
v.select("lake.knut_demo.people");
let r = harness::headless_render(&mut v);
assert!(r.drew(), "the catalog browser painted a frame");
}
#[test]
fn set_tables_drops_a_vanished_selection_but_keeps_a_live_one() {
let mut v = sample();
v.select("lake.sales.orders");
// Re-list without `orders` → selection drops.
v.set_tables(vec![TableInfo::new("lake.knut_demo", "people")]);
assert_eq!(v.state().selected, None);
// Re-list keeping the selected one → selection survives.
v.select("lake.knut_demo.people");
v.set_tables(vec![
TableInfo::new("lake.knut_demo", "people"),
TableInfo::new("lake.sales", "orders"),
]);
assert_eq!(v.state().selected.as_deref(), Some("lake.knut_demo.people"));
}
}