Skip to content

Commit 0109ffb

Browse files
committed
feat(linux): per-table WHERE filter dialog with full operator set
Server-side filter for the Browse tab with per-column operators, type-aware value coercion, and per-(connection, schema, table) persistence. Reachable via a Filter button on the paginator action bar and Ctrl+R shortcut. Architecture (3 layers, 3 new files): - crates/core/src/filter.rs: FilterSet / FilterRule / FilterOp / FilterValue / Combinator types + build_filter_where(driver_id, columns, set) -> Result<Option<(String, Vec<Value>)>, BuildFilterError>. Returns Ok(None) for empty rules so callers skip WHERE entirely. Identifiers via sql_dialect::quote_ident; placeholders via placeholder_for ($N for PG, ? for MySQL/SQLite). Type-aware coercion: "42" against an int column binds as Value::Int(42), not Value::Text. PG ILIKE; falls back to LIKE on MySQL/SQLite. 29 unit tests cover every operator, all three drivers' placeholder dialects, type coercion (int/float/decimal/date/datetime/timestamptz/ uuid/bool/json), error paths, and serde round-trip. - crates/app/src/services/filter_settings.rs: per-(connection_id, schema, table) persistence in $XDG_CONFIG_HOME/tablepro/filter_settings.json. Atomic temp-file rename via existing config_io::atomic_write_json. Empty FilterSet save removes the entry so the file shrinks back. Schema is in the key (column_widths.json doesn't include schema — latent bug there, not fixed in this commit). 3 unit tests. - crates/app/src/ui/filter_dialog.rs: AdwDialog with the rule editor. Match-combinator at the top (DropDown: All / Any), boxed-list of rules below, Add Rule footer row. Value widget shape adapts to operator (None for IsNull, Single for Eq/Lt, Pair for BETWEEN, List for IN). Column-type narrows the operator dropdown via per- type allowlist mirroring the core classifier. Bytes columns hidden from the column dropdown. Cancel / Clear all / Apply in the HeaderBar; default widget = Apply so Enter submits. Connection trait: new `query_params(sql, params)` with a default that delegates to `query` for empty params (legacy callers unchanged) and errors otherwise. PG / MySQL / SQLite drivers override with sqlx parametric streaming, mirroring the existing execute_params shape. ReadOnlyConnection passes through (filter SELECTs are still safe). Browse plumbing: - BrowseTab gains current_filter (FilterSet) restored from filter_settings on init. - fetch_browse_page composes SELECT * FROM ... WHERE <built> ORDER BY ... LIMIT ... OFFSET ... and routes via query_params. No-WHERE + no-ORDER BY keeps the fast-path fetch_rows call so unfiltered usage doesn't pay the parametric overhead. - fetch_browse_row_count applies the same WHERE so the "of N" total reflects filtered counts. Validation errors silently skip there (the page fetch on the same tick already toasts). - FilterApplied input persists, resets offset to 0, refreshes chrome, fires both FetchPage and FetchRowCount. - Filter button picks up `.accent` CSS class + count-suffixed tooltip when rules are active. App: - new AppMsg::ShowFilterDialog routes to on_show_filter_dialog which fetches the active tab's columns + current filter and presents the dialog with an on_apply callback that sends BrowseTabInput::FilterApplied. - Ctrl+R bound at window scope; `win.open-filter` action; entry in the Browse-table section of the shortcuts window. 92 + 103 unit tests pass (29 new filter tests + 3 filter_settings tests). Manual GUI walkthrough deferred to in-app verification.
1 parent fd198a7 commit 0109ffb

14 files changed

Lines changed: 2108 additions & 26 deletions

File tree

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
//! Per-table filter persistence. Mirrors `column_widths.rs` but the
2+
//! key includes the schema (so `public.users` and `audit.users` don't
3+
//! collide on a multi-schema Postgres database) and the value is a
4+
//! `FilterSet` rather than a single integer.
5+
//!
6+
//! Save path is debounced via `relm4::spawn`; rapid Apply clicks land
7+
//! one disk write each, but they don't block the GTK main loop. An
8+
//! empty `FilterSet` removes the entry from the file so it shrinks
9+
//! back when the user clears their filter.
10+
11+
use std::collections::HashMap;
12+
use std::sync::Mutex;
13+
14+
use tablepro_core::FilterSet;
15+
use uuid::Uuid;
16+
17+
use super::config_io::{atomic_write_json, xdg_config_path};
18+
19+
const FILE: &str = "filter_settings.json";
20+
21+
/// `FilterSet` keyed by `(connection_id, schema-or-empty, table)`.
22+
type Tables = HashMap<String, FilterSet>;
23+
type Schemas = HashMap<String, Tables>;
24+
type Connections = HashMap<String, Schemas>;
25+
26+
static CACHE: Mutex<Option<Connections>> = Mutex::new(None);
27+
28+
/// `None` schema is stored as the empty string so JSON keys are
29+
/// always concrete. This is the only callsite-facing translation.
30+
fn schema_key(schema: Option<&str>) -> String {
31+
schema.unwrap_or("").to_string()
32+
}
33+
34+
pub fn load(connection_id: Uuid, schema: Option<&str>, table: &str) -> FilterSet {
35+
let mut guard = match CACHE.lock() {
36+
Ok(g) => g,
37+
Err(_) => return FilterSet::default(),
38+
};
39+
let map = guard.get_or_insert_with(load_from_disk);
40+
map.get(&connection_id.to_string())
41+
.and_then(|s| s.get(&schema_key(schema)))
42+
.and_then(|t| t.get(table))
43+
.cloned()
44+
.unwrap_or_default()
45+
}
46+
47+
pub fn save(connection_id: Uuid, schema: Option<&str>, table: &str, set: FilterSet) {
48+
let mut guard = match CACHE.lock() {
49+
Ok(g) => g,
50+
Err(_) => return,
51+
};
52+
let map = guard.get_or_insert_with(load_from_disk);
53+
if set.is_empty() {
54+
// Empty FilterSet → remove the entry (and any now-empty
55+
// ancestor maps) so the file shrinks back to its previous
56+
// shape. Without this, "clear filter" would leave a
57+
// `{"rules":[]}` blob behind on disk, which loads as an
58+
// empty FilterSet anyway but bloats the file over time.
59+
if let Some(schemas) = map.get_mut(&connection_id.to_string()) {
60+
if let Some(tables) = schemas.get_mut(&schema_key(schema)) {
61+
tables.remove(table);
62+
if tables.is_empty() {
63+
schemas.remove(&schema_key(schema));
64+
}
65+
}
66+
if schemas.is_empty() {
67+
map.remove(&connection_id.to_string());
68+
}
69+
}
70+
} else {
71+
map.entry(connection_id.to_string())
72+
.or_default()
73+
.entry(schema_key(schema))
74+
.or_default()
75+
.insert(table.to_string(), set);
76+
}
77+
let snapshot = map.clone();
78+
drop(guard);
79+
relm4::spawn(async move {
80+
if let Some(path) = xdg_config_path(FILE)
81+
&& let Err(e) = atomic_write_json(&path, &snapshot)
82+
{
83+
tracing::warn!(error = %e, "filter_settings: persist failed");
84+
}
85+
});
86+
}
87+
88+
#[allow(dead_code)]
89+
pub fn clear(connection_id: Uuid, schema: Option<&str>, table: &str) {
90+
// Public API even if no current callsite uses it directly —
91+
// the dialog's "Clear all" button routes through `save` with an
92+
// empty FilterSet which is the same code path. Kept exposed for
93+
// a future "remove this filter" action elsewhere.
94+
save(connection_id, schema, table, FilterSet::default());
95+
}
96+
97+
fn load_from_disk() -> Connections {
98+
let Some(path) = xdg_config_path(FILE) else {
99+
return HashMap::new();
100+
};
101+
let Ok(bytes) = std::fs::read(path) else {
102+
return HashMap::new();
103+
};
104+
serde_json::from_slice(&bytes).unwrap_or_default()
105+
}
106+
107+
#[cfg(test)]
108+
mod tests {
109+
use super::*;
110+
use tablepro_core::{Combinator, FilterOp, FilterRule, FilterValue};
111+
112+
fn sample_set() -> FilterSet {
113+
FilterSet {
114+
combinator: Combinator::Or,
115+
rules: vec![FilterRule {
116+
column: "name".into(),
117+
op: FilterOp::Eq,
118+
value: Some(FilterValue::Single("alice".into())),
119+
}],
120+
}
121+
}
122+
123+
#[test]
124+
fn empty_set_returns_default() {
125+
// Without touching the file, the cache initialises lazily; an
126+
// entry that doesn't exist returns the FilterSet default.
127+
let id = Uuid::new_v4();
128+
let loaded = load(id, Some("public"), "users");
129+
assert!(loaded.is_empty());
130+
}
131+
132+
#[test]
133+
fn schema_none_distinct_from_some() {
134+
// An empty-string schema slot lives next to a "public" slot;
135+
// they don't collide. Verified by writing two sets with
136+
// different schema keys to the cache directly.
137+
let mut connections: Connections = HashMap::new();
138+
let id = Uuid::new_v4();
139+
connections
140+
.entry(id.to_string())
141+
.or_default()
142+
.entry(String::new())
143+
.or_default()
144+
.insert("t".into(), sample_set());
145+
connections
146+
.entry(id.to_string())
147+
.or_default()
148+
.entry("public".into())
149+
.or_default()
150+
.insert("t".into(), FilterSet::default());
151+
let none_set = connections.get(&id.to_string()).and_then(|s| s.get("")).unwrap();
152+
let public_set = connections.get(&id.to_string()).and_then(|s| s.get("public")).unwrap();
153+
assert!(!none_set.get("t").unwrap().is_empty());
154+
assert!(public_set.get("t").unwrap().is_empty());
155+
}
156+
157+
#[test]
158+
fn round_trip_serialises_and_deserialises() {
159+
// Ensure the on-disk representation round-trips a non-trivial
160+
// FilterSet — schemes / Combinator default behaviour / nested
161+
// FilterValue tagged-content serde.
162+
let original = sample_set();
163+
let json = serde_json::to_string(&original).unwrap();
164+
let parsed: FilterSet = serde_json::from_str(&json).unwrap();
165+
assert_eq!(original, parsed);
166+
}
167+
}

linux/crates/app/src/services/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pub mod config_io;
44
pub mod connection_monitor;
55
pub mod connection_service;
66
pub mod database_service;
7+
pub mod filter_settings;
78
pub mod preferences;
89
pub mod single_instance;
910
pub mod structure_tracker;

linux/crates/app/src/ui/app/browse.rs

Lines changed: 105 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@ impl App {
2323
}
2424

2525
/// Fire the SELECT * query for a specific browse tab. Result goes to
26-
/// the same tab via `AppMsg::RowsLoaded(tab_id, ...)`.
26+
/// the same tab via `AppMsg::RowsLoaded(tab_id, ...)`. Composes the
27+
/// SELECT from the tab's current sort + filter + pagination state.
28+
/// Filter and sort are server-side; pagination uses LIMIT/OFFSET.
2729
pub(super) fn fetch_browse_page(&self, tab_id: Uuid, sender: ComponentSender<Self>) {
28-
let (schema, table, offset, limit, sort, driver_id) = {
30+
let (schema, table, offset, limit, sort, filter, columns, driver_id) = {
2931
let tabs = self.workspace_tabs.borrow();
3032
let Some(controller) = tabs.get(&tab_id).and_then(|t| t.browse_controller()) else {
3133
return;
@@ -37,6 +39,8 @@ impl App {
3739
model.current_offset(),
3840
model.page_size(),
3941
model.current_sort(),
42+
model.current_filter().clone(),
43+
model.columns().to_vec(),
4044
model.driver_id().to_string(),
4145
)
4246
};
@@ -46,37 +50,56 @@ impl App {
4650
return;
4751
};
4852
let order_by = sort.and_then(|(idx, asc)| {
49-
let tabs = self.workspace_tabs.borrow();
50-
let cols = tabs
51-
.get(&tab_id)
52-
.and_then(|t| t.browse_controller())
53-
.map(|c| c.model().columns().to_vec())
54-
.unwrap_or_default();
55-
cols.get(idx).map(|c| {
53+
columns.get(idx).map(|c| {
5654
let name = tablepro_core::sql_dialect::quote_ident(&driver_id, &c.name);
5755
let dir = if asc { "ASC" } else { "DESC" };
5856
format!("{name} {dir}")
5957
})
6058
});
59+
60+
// Build WHERE up front so a filter validation error short-
61+
// circuits to a toast without spawning the async command.
62+
// Build returns None when the filter is empty; that path
63+
// matches today's no-filter behaviour exactly.
64+
let where_result = tablepro_core::build_filter_where(&driver_id, &columns, &filter);
65+
let (where_sql, params) = match where_result {
66+
Ok(Some((sql, p))) => (Some(sql), p),
67+
Ok(None) => (None, Vec::new()),
68+
Err(e) => {
69+
sender.input(AppMsg::ShowToast(format!("{e}")));
70+
return;
71+
}
72+
};
73+
6174
let sender_clone = sender.clone();
6275
sender.command(move |_, shutdown| {
6376
shutdown
6477
.register(async move {
65-
let result = match &order_by {
66-
Some(order) => {
67-
let qualified = match &schema {
68-
Some(s) => format!(
69-
"{}.{}",
70-
tablepro_core::sql_dialect::quote_ident(&driver_id, s),
71-
tablepro_core::sql_dialect::quote_ident(&driver_id, &table)
72-
),
73-
None => tablepro_core::sql_dialect::quote_ident(&driver_id, &table),
74-
};
75-
let sql =
76-
format!("SELECT * FROM {qualified} ORDER BY {order} LIMIT {limit} OFFSET {offset}");
77-
conn.query(&sql).await
78+
// No WHERE + no ORDER BY: keep the existing
79+
// fetch_rows fast-path so unchanged callers don't
80+
// pay the parametric overhead.
81+
let result = if where_sql.is_none() && order_by.is_none() {
82+
conn.fetch_rows(schema.as_deref(), &table, offset, limit).await
83+
} else {
84+
let qualified = match &schema {
85+
Some(s) => format!(
86+
"{}.{}",
87+
tablepro_core::sql_dialect::quote_ident(&driver_id, s),
88+
tablepro_core::sql_dialect::quote_ident(&driver_id, &table)
89+
),
90+
None => tablepro_core::sql_dialect::quote_ident(&driver_id, &table),
91+
};
92+
let mut sql = format!("SELECT * FROM {qualified}");
93+
if let Some(w) = &where_sql {
94+
sql.push_str(" WHERE ");
95+
sql.push_str(w);
7896
}
79-
None => conn.fetch_rows(schema.as_deref(), &table, offset, limit).await,
97+
if let Some(order) = &order_by {
98+
sql.push_str(" ORDER BY ");
99+
sql.push_str(order);
100+
}
101+
sql.push_str(&format!(" LIMIT {limit} OFFSET {offset}"));
102+
conn.query_params(&sql, &params).await
80103
};
81104
match result {
82105
Ok(query_result) => sender_clone.input(AppMsg::RowsLoaded(tab_id, offset, query_result)),
@@ -116,7 +139,7 @@ impl App {
116139
}
117140

118141
pub(super) fn fetch_browse_row_count(&self, tab_id: Uuid, sender: ComponentSender<Self>) {
119-
let (schema, table, driver_id) = {
142+
let (schema, table, filter, columns, driver_id) = {
120143
let tabs = self.workspace_tabs.borrow();
121144
let Some(controller) = tabs.get(&tab_id).and_then(|t| t.browse_controller()) else {
122145
return;
@@ -125,13 +148,25 @@ impl App {
125148
(
126149
model.schema().map(str::to_owned),
127150
model.table().to_string(),
151+
model.current_filter().clone(),
152+
model.columns().to_vec(),
128153
model.driver_id().to_string(),
129154
)
130155
};
131156

132157
let Some(conn) = database_service::instance().active() else {
133158
return;
134159
};
160+
161+
// Same WHERE the page fetch uses, so the "of N" total matches
162+
// the filtered result set. Validation errors are silently
163+
// suppressed here — fetch_browse_page surfaces the toast for
164+
// the same filter on the same tick, no need to double-toast.
165+
let (where_sql, params) = match tablepro_core::build_filter_where(&driver_id, &columns, &filter) {
166+
Ok(Some((sql, p))) => (Some(sql), p),
167+
_ => (None, Vec::new()),
168+
};
169+
135170
let sender_clone = sender.clone();
136171
sender.command(move |_, shutdown| {
137172
shutdown
@@ -144,8 +179,17 @@ impl App {
144179
),
145180
None => tablepro_core::sql_dialect::quote_ident(&driver_id, &table),
146181
};
147-
let sql = format!("SELECT COUNT(*) FROM {qualified}");
148-
if let Ok(qr) = conn.query(&sql).await
182+
let mut sql = format!("SELECT COUNT(*) FROM {qualified}");
183+
if let Some(w) = &where_sql {
184+
sql.push_str(" WHERE ");
185+
sql.push_str(w);
186+
}
187+
let qr_result = if where_sql.is_some() {
188+
conn.query_params(&sql, &params).await
189+
} else {
190+
conn.query(&sql).await
191+
};
192+
if let Ok(qr) = qr_result
149193
&& let Some(row) = qr.rows.first()
150194
&& let Some(value) = row.first()
151195
{
@@ -283,6 +327,41 @@ impl App {
283327
}
284328
}
285329

330+
/// Ctrl+R / Filter button — present the filter editor for the
331+
/// active Browse tab. Hands the dialog the tab's columns +
332+
/// current FilterSet; the dialog calls back via `on_apply` with
333+
/// either the new set or `FilterSet::default()` for "Clear all".
334+
/// Either way it routes through `BrowseTabInput::FilterApplied`
335+
/// so persistence + chrome update + refetch all run in one place.
336+
pub(super) fn on_show_filter_dialog(&self) {
337+
let Some(id) = self.selected_browse_tab_id() else {
338+
self.show_toast(&crate::tr!("Open a table to filter rows."));
339+
return;
340+
};
341+
let (columns, current_filter) = {
342+
let tabs = self.workspace_tabs.borrow();
343+
let Some(controller) = tabs.get(&id).and_then(|t| t.browse_controller()) else {
344+
return;
345+
};
346+
let model = controller.model();
347+
(model.columns().to_vec(), model.current_filter().clone())
348+
};
349+
if columns.is_empty() {
350+
// ColumnsLoaded hasn't fired yet — opening the dialog with
351+
// no columns would just show an empty Add Rule list.
352+
self.show_toast(&crate::tr!("Loading columns… try again in a moment."));
353+
return;
354+
}
355+
let tab_id = id;
356+
let workspace_tabs = self.workspace_tabs.clone();
357+
let on_apply: std::rc::Rc<dyn Fn(tablepro_core::FilterSet)> = std::rc::Rc::new(move |set| {
358+
if let Some(controller) = workspace_tabs.borrow().get(&tab_id).and_then(|t| t.browse_controller()) {
359+
let _ = controller.sender().send(BrowseTabInput::FilterApplied(set));
360+
}
361+
});
362+
crate::ui::filter_dialog::present(&self.window, columns, current_filter, on_apply);
363+
}
364+
286365
pub(super) fn on_refresh_active_tab(&self) {
287366
let Some(id) = self.selected_browse_tab_id() else {
288367
return;

0 commit comments

Comments
 (0)