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

nordisk/facett

facett-dbexplorer/src/lib.rs

//! **facett-dbexplorer** — the DataGrip / DBeaver pane, as a facet.
//!
//! One navigator over every configured connection, one editor, one result
//! area. A connection is a warehouse (tables → columns, SQL) or a graph
//! (labels / relationship types → properties, Cypher): a graph is just another
//! kind of database, so it sits in the same tree and its results land in the
//! same place — as a grid, or as a graph scene when the rows are edges.
//!
//! Elm (FC-1..9): the [`Model`] is the whole observable state, [`Msg`] the only
//! mutation path, [`Effect`] the work the host does (run a statement, refresh a
//! connection). The host answers with [`Explorer::set_connections`] and
//! [`Explorer::set_result`]. No I/O in here.
//!
//! Navigation: `Ctrl+Enter` runs, `↑`/`↓` in the history combobox recalls, a
//! filter box narrows the tree, double-click a table samples it. Every visible
//! count is in `state_json` (FC-3).

use std::collections::BTreeSet;
use std::sync::Arc;

use arrow_array::RecordBatch;
use facett_core::Elm;
use facett_graph::GraphView;
use facett_grid::Grid;
use serde::{Deserialize, Serialize};

/// What kind of database a connection is — decides the dialect badge, the icon,
/// the sample statement and the default result rendering.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ConnKind {
    /// Tables and columns; SQL.
    Sql,
    /// Node labels and relationship types; Cypher.
    Graph,
}

impl ConnKind {
    pub fn icon(self) -> &'static str {
        match self {
            ConnKind::Sql => "🧊",
            ConnKind::Graph => "🕸",
        }
    }
    pub fn dialect(self) -> &'static str {
        match self {
            ConnKind::Sql => "SQL",
            ConnKind::Graph => "Cypher",
        }
    }
}

/// A column (or, on a graph, a property).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ColumnNode {
    pub name: String,
    pub data_type: String,
}

/// A table (or a label / relationship type on a graph).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TableNode {
    /// Namespace / schema; empty on a graph.
    pub schema: String,
    pub name: String,
    /// Row (or node) count when the host knows it.
    pub rows: Option<u64>,
    pub columns: Vec<ColumnNode>,
    /// The statement a double-click runs. `None` ⇒ the explorer builds one
    /// (`SELECT * FROM "schema"."name" LIMIT n` for SQL; a host with a graph
    /// dialect of its own sets this).
    pub sample_query: Option<String>,
}

impl TableNode {
    pub fn fqn(&self) -> String {
        if self.schema.is_empty() { format!("\"{}\"", self.name) } else { format!("\"{}\".\"{}\"", self.schema, self.name) }
    }
}

/// A server-side plan the host can run on a connection — e.g. a knut plan that
/// maps relational tables to a graph. The explorer only names it; the host runs it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlanNode {
    pub name: String,
    /// Last known state (`idle`, `running`, `ok 3 min ago`, …), host-supplied.
    pub state: String,
}

/// One connection in the navigator.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Connection {
    /// Stable id the host keys effects on.
    pub id: String,
    /// What the tree shows.
    pub label: String,
    pub kind: ConnKind,
    pub tables: Vec<TableNode>,
    /// Runnable server-side plans on this connection (knut rel→graph plans on a
    /// warehouse row, SDPs on a Spark row). Empty ⇒ no ⚙ group is drawn.
    #[serde(default)]
    pub plans: Vec<PlanNode>,
    /// A loading / failure note under the header (`None` when healthy).
    pub note: Option<String>,
}

/// How the result area renders.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ResultMode {
    /// Grid, unless the rows are edges (`src`/`dst` Int64 columns) — then a scene.
    #[default]
    Auto,
    Grid,
    Graph,
}

/// The observable result state (the batches themselves live on the component).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ResultState {
    #[default]
    Empty,
    Running,
    Rows {
        columns: Vec<String>,
        row_count: u64,
        truncated: bool,
        elapsed_ms: u64,
        /// `true` when the result is being shown as a graph scene.
        as_graph: bool,
    },
    Error(String),
}

/// FC-1: everything observable.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Model {
    pub connections: Vec<Connection>,
    pub selected: Option<usize>,
    pub selected_table: Option<(usize, usize)>,
    /// Tree nodes that are open, as `conn_id` or `conn_id/schema.table`.
    pub expanded: BTreeSet<String>,
    pub filter: String,
    pub editor: String,
    pub history: Vec<String>,
    pub result: ResultState,
    pub result_mode: ResultMode,
    /// Rows a sample (double-click) asks for.
    pub sample_limit: usize,
}

#[derive(Debug, Clone)]
pub enum Msg {
    SelectConnection(usize),
    ToggleExpand(String),
    SelectTable(usize, usize),
    /// Double-click: put the sample statement in the editor and run it.
    SampleTable(usize, usize),
    Filter(String),
    EditorChanged(String),
    Run,
    Refresh,
    HistoryPick(usize),
    ClearHistory,
    SetResultMode(ResultMode),
    /// ▶ on a plan under a connection.
    RunPlan(usize, usize),
    Grid(facett_grid::Msg),
    Graph(facett_graph::Msg),
}

/// FC-8: the host's work.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Effect {
    /// Run `query` on `connection`; answer with [`Explorer::set_result`].
    Run { connection: String, kind: ConnKind, query: String },
    /// Re-list `connection`; answer with [`Explorer::set_connections`].
    Refresh { connection: String },
    /// Run the named server-side plan (knut rel→graph, an SDP) on `connection`.
    RunPlan { connection: String, plan: String },
}

const HISTORY_CAP: usize = 50;

/// The component.
pub struct Explorer {
    title: String,
    model: Model,
    grid: Option<Grid>,
    graph: Option<GraphView>,
}

impl Explorer {
    pub fn new(title: impl Into<String>) -> Self {
        Self {
            title: title.into(),
            model: Model { sample_limit: 500, ..Default::default() },
            grid: None,
            graph: None,
        }
    }

    /// Replace the navigator's connections (keeps selection by id where possible).
    pub fn set_connections(&mut self, connections: Vec<Connection>) {
        let keep = self.model.selected.and_then(|i| self.model.connections.get(i)).map(|c| c.id.clone());
        self.model.connections = connections;
        self.model.selected = match keep {
            Some(id) => self.model.connections.iter().position(|c| c.id == id),
            None => None,
        }
        .or(if self.model.connections.is_empty() { None } else { Some(0) });
        if let Some(i) = self.model.selected {
            self.model.expanded.insert(self.model.connections[i].id.clone());
        }
        self.model.selected_table = None;
        facett_core::testmatrix::emit("facett-dbexplorer", "connections_set", true, &format!("{} connection(s)", self.model.connections.len()));
    }

    /// The host's answer to [`Effect::Run`].
    pub fn set_result(&mut self, outcome: Result<QueryResult, String>) {
        self.grid = None;
        self.graph = None;
        match outcome {
            Err(e) => {
                self.model.result = ResultState::Error(e);
                facett_core::testmatrix::emit("facett-dbexplorer", "result", false, "error");
            }
            Ok(r) => {
                let columns: Vec<String> = r.schema().fields().iter().map(|f| f.name().clone()).collect();
                let row_count: usize = r.batches.iter().map(|b| b.num_rows()).sum();
                let batch = r.concat();
                let as_graph = match self.model.result_mode {
                    ResultMode::Grid => false,
                    ResultMode::Graph => true,
                    ResultMode::Auto => r.kind == ConnKind::Graph && looks_like_edges(&batch),
                };
                if as_graph && let Ok(scene) = facett_arrow::scene_from_edge_batch(&batch) {
                    self.graph = Some(GraphView::new(scene).with_title("result"));
                }
                if self.graph.is_none() {
                    self.grid = Some(Grid::from_record_batch_arc("result", Arc::new(batch)));
                }
                self.model.result = ResultState::Rows {
                    columns,
                    row_count: row_count as u64,
                    truncated: r.truncated,
                    elapsed_ms: r.elapsed_ms,
                    as_graph: self.graph.is_some(),
                };
                facett_core::testmatrix::emit("facett-dbexplorer", "result", true, &format!("{row_count} row(s)"));
            }
        }
    }

    /// The grid, when the result is one — for the host to read edits off.
    pub fn grid(&self) -> Option<&Grid> {
        self.grid.as_ref()
    }

    pub fn model(&self) -> &Model {
        &self.model
    }

    fn selected_conn(&self) -> Option<&Connection> {
        self.model.selected.and_then(|i| self.model.connections.get(i))
    }

    fn sample_statement(&self, c: &Connection, t: &TableNode) -> String {
        if let Some(q) = &t.sample_query {
            return q.clone();
        }
        match c.kind {
            ConnKind::Sql => format!("SELECT * FROM {} LIMIT {}", t.fqn(), self.model.sample_limit),
            ConnKind::Graph => format!("MATCH (n:{}) RETURN n LIMIT {}", t.name, self.model.sample_limit),
        }
    }

    fn push_history(&mut self, q: &str) {
        let q = q.trim();
        if q.is_empty() {
            return;
        }
        self.model.history.retain(|h| h != q);
        self.model.history.insert(0, q.to_string());
        self.model.history.truncate(HISTORY_CAP);
    }

    fn run_effect(&mut self) -> Vec<Effect> {
        let Some(c) = self.selected_conn() else {
            self.model.result = ResultState::Error("no connection selected".into());
            return Vec::new();
        };
        let query = self.model.editor.trim().to_string();
        if query.is_empty() {
            return Vec::new();
        }
        let (id, kind) = (c.id.clone(), c.kind);
        self.push_history(&query);
        self.model.result = ResultState::Running;
        self.grid = None;
        self.graph = None;
        vec![Effect::Run { connection: id, kind, query }]
    }
}

/// A statement's answer, as the host hands it back.
#[derive(Debug, Clone)]
pub struct QueryResult {
    pub kind: ConnKind,
    pub batches: Vec<RecordBatch>,
    /// The schema when there are zero batches (a zero-row result still has one).
    pub empty_schema: Option<arrow_schema::SchemaRef>,
    pub truncated: bool,
    pub elapsed_ms: u64,
}

impl QueryResult {
    fn schema(&self) -> arrow_schema::SchemaRef {
        self.batches.first().map(|b| b.schema()).or_else(|| self.empty_schema.clone()).unwrap_or_else(|| Arc::new(arrow_schema::Schema::empty()))
    }
    fn concat(&self) -> RecordBatch {
        let schema = self.schema();
        match self.batches.len() {
            0 => RecordBatch::new_empty(schema),
            1 => self.batches[0].clone(),
            _ => arrow_select::concat::concat_batches(&schema, &self.batches).unwrap_or_else(|_| RecordBatch::new_empty(schema)),
        }
    }
}

/// `src`/`dst` Int64 columns ⇒ an edge list.
fn looks_like_edges(b: &RecordBatch) -> bool {
    let is_i64 = |n: &str| b.column_by_name(n).map(|c| *c.data_type() == arrow_schema::DataType::Int64).unwrap_or(false);
    is_i64("src") && is_i64("dst")
}

impl Elm for Explorer {
    type Model = Model;
    type Msg = Msg;
    type Effect = Effect;

    fn title(&self) -> &str {
        &self.title
    }

    fn state(&self) -> &Model {
        &self.model
    }

    fn update(&mut self, msg: Msg) -> Vec<Effect> {
        match msg {
            Msg::SelectConnection(i) => {
                if i < self.model.connections.len() {
                    self.model.selected = Some(i);
                    self.model.expanded.insert(self.model.connections[i].id.clone());
                }
                Vec::new()
            }
            Msg::ToggleExpand(key) => {
                if !self.model.expanded.remove(&key) {
                    self.model.expanded.insert(key);
                }
                Vec::new()
            }
            Msg::SelectTable(c, t) => {
                self.model.selected = Some(c);
                self.model.selected_table = Some((c, t));
                Vec::new()
            }
            Msg::SampleTable(ci, ti) => {
                let Some(c) = self.model.connections.get(ci).cloned() else { return Vec::new() };
                let Some(t) = c.tables.get(ti) else { return Vec::new() };
                self.model.selected = Some(ci);
                self.model.selected_table = Some((ci, ti));
                self.model.editor = self.sample_statement(&c, t);
                self.run_effect()
            }
            Msg::Filter(f) => {
                self.model.filter = f;
                Vec::new()
            }
            Msg::EditorChanged(s) => {
                self.model.editor = s;
                Vec::new()
            }
            Msg::Run => self.run_effect(),
            Msg::Refresh => match self.selected_conn() {
                Some(c) => vec![Effect::Refresh { connection: c.id.clone() }],
                None => Vec::new(),
            },
            Msg::HistoryPick(i) => {
                if let Some(h) = self.model.history.get(i) {
                    self.model.editor = h.clone();
                }
                Vec::new()
            }
            Msg::ClearHistory => {
                self.model.history.clear();
                Vec::new()
            }
            Msg::SetResultMode(m) => {
                self.model.result_mode = m;
                Vec::new()
            }
            Msg::RunPlan(ci, pi) => match self.model.connections.get(ci).and_then(|c| c.plans.get(pi).map(|p| (c.id.clone(), p.name.clone()))) {
                Some((connection, plan)) => vec![Effect::RunPlan { connection, plan }],
                None => Vec::new(),
            },
            Msg::Grid(m) => {
                if let Some(g) = &mut self.grid {
                    g.update(m);
                }
                Vec::new()
            }
            Msg::Graph(m) => {
                if let Some(g) = &mut self.graph {
                    g.update(m);
                }
                Vec::new()
            }
        }
    }

    fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        let mut out = Vec::new();
        let id = ui.id().with("dbexplorer");

        // ── navigator ────────────────────────────────────────────────────
        egui::Panel::left(id.with("nav")).resizable(true).default_size(260.0).show(ui, |ui| {
            ui.add_space(4.0);
            ui.horizontal(|ui| {
                let mut f = self.model.filter.clone();
                let r = ui.add(egui::TextEdit::singleline(&mut f).hint_text("filter tables…").desired_width(f32::INFINITY));
                if r.changed() {
                    out.push(Msg::Filter(f));
                }
            });
            ui.add_space(2.0);
            egui::ScrollArea::vertical().id_salt(id.with("tree")).show(ui, |ui| {
                let needle = self.model.filter.trim().to_lowercase();
                for (ci, c) in self.model.connections.iter().enumerate() {
                    let open = self.model.expanded.contains(&c.id);
                    let selected = self.model.selected == Some(ci);
                    ui.horizontal(|ui| {
                        let arrow = if open { "▾" } else { "▸" };
                        if ui.add(egui::Button::new(arrow).frame(false)).clicked() {
                            out.push(Msg::ToggleExpand(c.id.clone()));
                        }
                        let head = egui::RichText::new(format!("{} {}", c.kind.icon(), c.label)).strong();
                        if ui.selectable_label(selected, head).clicked() {
                            out.push(Msg::SelectConnection(ci));
                        }
                        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                            ui.weak(egui::RichText::new(c.kind.dialect()).small());
                        });
                    });
                    if let Some(n) = &c.note {
                        ui.indent(id.with(("note", ci)), |ui| ui.weak(n));
                    }
                    if !open {
                        continue;
                    }
                    ui.indent(id.with(("conn", ci)), |ui| {
                        for (ti, t) in c.tables.iter().enumerate() {
                            if !needle.is_empty() && !t.name.to_lowercase().contains(&needle) && !t.schema.to_lowercase().contains(&needle) {
                                continue;
                            }
                            let key = format!("{}/{}.{}", c.id, t.schema, t.name);
                            let t_open = self.model.expanded.contains(&key);
                            let t_sel = self.model.selected_table == Some((ci, ti));
                            ui.horizontal(|ui| {
                                let arrow = if t_open { "▾" } else { "▸" };
                                if ui.add(egui::Button::new(egui::RichText::new(arrow).weak()).frame(false)).clicked() {
                                    out.push(Msg::ToggleExpand(key.clone()));
                                }
                                let label = if t.schema.is_empty() || needle.is_empty() {
                                    t.name.clone()
                                } else {
                                    format!("{}.{}", t.schema, t.name)
                                };
                                let r = ui.selectable_label(t_sel, label);
                                if r.double_clicked() {
                                    out.push(Msg::SampleTable(ci, ti));
                                } else if r.clicked() {
                                    out.push(Msg::SelectTable(ci, ti));
                                }
                                if let Some(n) = t.rows {
                                    ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                                        ui.weak(egui::RichText::new(facett_grid::data::group_int(n as i64)).small());
                                    });
                                }
                            });
                            if t_open {
                                ui.indent(id.with(("tbl", ci, ti)), |ui| {
                                    for col in &t.columns {
                                        ui.horizontal(|ui| {
                                            ui.label(egui::RichText::new(&col.name).monospace().small());
                                            ui.weak(egui::RichText::new(&col.data_type).small());
                                        });
                                    }
                                });
                            }
                        }
                        if !c.plans.is_empty() {
                            ui.add_space(2.0);
                            ui.weak(egui::RichText::new("⚙ plans").small());
                            for (pi, p) in c.plans.iter().enumerate() {
                                ui.horizontal(|ui| {
                                    if ui.add(egui::Button::new(egui::RichText::new("▶").small()).frame(false)).on_hover_text(format!("run `{}` on the server", p.name)).clicked() {
                                        out.push(Msg::RunPlan(ci, pi));
                                    }
                                    ui.label(egui::RichText::new(&p.name).small());
                                    if !p.state.is_empty() {
                                        ui.weak(egui::RichText::new(&p.state).small());
                                    }
                                });
                            }
                        }
                    });
                }
                if self.model.connections.is_empty() {
                    ui.weak("(no connections — configure a warehouse or graph backend)");
                }
            });
        });

        // ── editor ───────────────────────────────────────────────────────
        let dialect = self.selected_conn().map(|c| c.kind.dialect()).unwrap_or("—");
        egui::Panel::top(id.with("editor")).resizable(true).default_size(140.0).show(ui, |ui| {
            ui.add_space(4.0);
            ui.horizontal(|ui| {
                let running = matches!(self.model.result, ResultState::Running);
                let run = ui.add_enabled(!running, egui::Button::new("▶ Run").small());
                if run.clicked() {
                    out.push(Msg::Run);
                }
                ui.weak(egui::RichText::new(format!("{dialect} · Ctrl+Enter")).small());
                if ui.add(egui::Button::new("⟳").small()).on_hover_text("re-list this connection").clicked() {
                    out.push(Msg::Refresh);
                }
                ui.separator();
                let mode = self.model.result_mode;
                for (m, label) in [(ResultMode::Auto, "auto"), (ResultMode::Grid, "grid"), (ResultMode::Graph, "graph")] {
                    if ui.selectable_label(mode == m, label).clicked() {
                        out.push(Msg::SetResultMode(m));
                    }
                }
                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                    if !self.model.history.is_empty() {
                        egui::ComboBox::from_id_salt(id.with("history"))
                            .selected_text(egui::RichText::new("history").small())
                            .width(120.0)
                            .show_ui(ui, |ui| {
                                for (i, h) in self.model.history.iter().enumerate() {
                                    let short: String = h.chars().take(72).collect();
                                    if ui.selectable_label(false, egui::RichText::new(short).monospace().small()).clicked() {
                                        out.push(Msg::HistoryPick(i));
                                    }
                                }
                                ui.separator();
                                if ui.small_button("clear").clicked() {
                                    out.push(Msg::ClearHistory);
                                }
                            });
                    }
                });
            });
            let mut text = self.model.editor.clone();
            let r = ui.add_sized(
                [ui.available_width(), ui.available_height().max(60.0)],
                egui::TextEdit::multiline(&mut text).code_editor().font(egui::TextStyle::Monospace).hint_text(match dialect {
                    "Cypher" => "MATCH (n) RETURN n LIMIT 100",
                    _ => "SELECT * FROM \"main\".\"people\" LIMIT 500",
                }),
            );
            if r.changed() {
                out.push(Msg::EditorChanged(text));
            }
            let ctrl_enter = ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.command);
            if r.has_focus() && ctrl_enter {
                out.push(Msg::Run);
            }
        });

        // ── result ───────────────────────────────────────────────────────
        egui::CentralPanel::default().show(ui, |ui| {
            match &self.model.result {
                ResultState::Empty => {
                    ui.weak("(double-click a table, or write a statement and press Ctrl+Enter)");
                }
                ResultState::Running => {
                    ui.horizontal(|ui| {
                        ui.spinner();
                        ui.weak("running…");
                    });
                }
                ResultState::Error(e) => {
                    ui.colored_label(ui.visuals().error_fg_color, e);
                }
                ResultState::Rows { columns, row_count, truncated, elapsed_ms, as_graph } => {
                    ui.horizontal(|ui| {
                        ui.weak(format!(
                            "{} column(s) · {} row(s){} · {} ms{}",
                            columns.len(),
                            facett_grid::data::group_int(*row_count as i64),
                            if *truncated { " (capped)" } else { "" },
                            elapsed_ms,
                            if *as_graph { " · graph" } else { "" }
                        ));
                    });
                    if let Some(g) = &self.graph {
                        for m in g.view(ui) {
                            out.push(Msg::Graph(m));
                        }
                    } else if let Some(g) = &self.grid {
                        for m in g.view(ui) {
                            out.push(Msg::Grid(m));
                        }
                    }
                }
            }
        });
        out
    }
}

/// [`Facet`](facett_core::Facet) bridge so a deck can mount it directly.
impl facett_core::Facet for Explorer {
    fn title(&self) -> &str {
        &self.title
    }
    fn ui(&mut self, ui: &mut egui::Ui) {
        for m in Elm::view(self, ui) {
            Elm::update(self, m);
        }
    }
    fn state_json(&self) -> serde_json::Value {
        serde_json::to_value(&self.model).unwrap_or(serde_json::Value::Null)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use arrow_array::{Int64Array, StringArray};
    use arrow_schema::{DataType, Field, Schema};

    fn conns() -> Vec<Connection> {
        vec![
            Connection {
                id: "wh".into(),
                label: "skade (remote)".into(),
                kind: ConnKind::Sql,
                tables: vec![TableNode {
                    schema: "main".into(),
                    name: "people".into(),
                    rows: Some(5),
                    columns: vec![ColumnNode { name: "id".into(), data_type: "Int64".into() }],
                    sample_query: None,
                }],
                plans: vec![PlanNode { name: "nordisk_ingest".into(), state: "idle".into() }],
                note: None,
            },
            Connection {
                id: "g".into(),
                label: "falkor".into(),
                kind: ConnKind::Graph,
                tables: vec![TableNode { schema: String::new(), name: "Person".into(), rows: None, columns: vec![], sample_query: Some("MATCH (n:Person) RETURN n._gar_id AS _gar_id LIMIT 10".into()) }],
                plans: vec![],
                note: None,
            },
        ]
    }

    #[test]
    fn sample_builds_statement_and_emits_run() {
        let mut e = Explorer::new("explore");
        e.set_connections(conns());
        let fx = e.update(Msg::SampleTable(0, 0));
        assert_eq!(fx, vec![Effect::Run { connection: "wh".into(), kind: ConnKind::Sql, query: "SELECT * FROM \"main\".\"people\" LIMIT 500".into() }]);
        assert_eq!(e.model().history.len(), 1);
        assert!(matches!(e.model().result, ResultState::Running));
        let fx = e.update(Msg::SampleTable(1, 0));
        assert!(matches!(fx.as_slice(), [Effect::Run { kind: ConnKind::Graph, query, .. }] if query.starts_with("MATCH")));
    }

    #[test]
    fn result_becomes_grid_or_graph() {
        let mut e = Explorer::new("explore");
        e.set_connections(conns());
        let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false), Field::new("name", DataType::Utf8, true)]));
        let b = RecordBatch::try_new(schema.clone(), vec![Arc::new(Int64Array::from(vec![1, 2])), Arc::new(StringArray::from(vec!["a", "b"]))]).unwrap();
        e.set_result(Ok(QueryResult { kind: ConnKind::Sql, batches: vec![b], empty_schema: None, truncated: false, elapsed_ms: 3 }));
        assert!(e.grid().is_some());
        assert!(matches!(e.model().result, ResultState::Rows { row_count: 2, as_graph: false, .. }));

        let es = Arc::new(Schema::new(vec![Field::new("src", DataType::Int64, false), Field::new("dst", DataType::Int64, false)]));
        let eb = RecordBatch::try_new(es, vec![Arc::new(Int64Array::from(vec![1, 2])), Arc::new(Int64Array::from(vec![2, 3]))]).unwrap();
        e.set_result(Ok(QueryResult { kind: ConnKind::Graph, batches: vec![eb], empty_schema: None, truncated: false, elapsed_ms: 1 }));
        assert!(matches!(e.model().result, ResultState::Rows { as_graph: true, .. }));

        e.set_result(Err("boom".into()));
        assert!(matches!(e.model().result, ResultState::Error(_)));
        assert!(e.grid().is_none());
    }

    #[test]
    fn zero_rows_keeps_schema_columns() {
        let mut e = Explorer::new("explore");
        e.set_connections(conns());
        let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));
        e.set_result(Ok(QueryResult { kind: ConnKind::Sql, batches: vec![], empty_schema: Some(schema), truncated: false, elapsed_ms: 0 }));
        assert!(matches!(&e.model().result, ResultState::Rows { columns, row_count: 0, .. } if columns == &vec!["id".to_string()]));
    }

    #[test]
    fn history_dedups_and_caps() {
        let mut e = Explorer::new("explore");
        e.set_connections(conns());
        for i in 0..60 {
            e.update(Msg::EditorChanged(format!("SELECT {i}")));
            e.update(Msg::Run);
        }
        e.update(Msg::EditorChanged("SELECT 59".into()));
        e.update(Msg::Run);
        assert_eq!(e.model().history.len(), HISTORY_CAP);
        assert_eq!(e.model().history[0], "SELECT 59");
        assert_eq!(e.model().history.iter().filter(|h| *h == "SELECT 59").count(), 1);
    }
}

#[cfg(test)]
mod plan_tests {
    use super::*;
    #[test]
    fn run_plan_emits_effect_by_name() {
        let mut e = Explorer::new("x");
        e.set_connections(vec![Connection {
            id: "wh".into(),
            label: "wh".into(),
            kind: ConnKind::Sql,
            tables: vec![],
            plans: vec![PlanNode { name: "nordisk_ingest".into(), state: String::new() }],
            note: None,
        }]);
        assert_eq!(e.update(Msg::RunPlan(0, 0)), vec![Effect::RunPlan { connection: "wh".into(), plan: "nordisk_ingest".into() }]);
        assert!(e.update(Msg::RunPlan(0, 7)).is_empty());
    }
}