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