Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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"));
    }
}