Skip to content

Commit e96763c

Browse files
authored
Merge pull request #3140 from perspective-dev/vs-get-min-max
Implement `ViewGetMinMaxReq` API for Virtual Servers
2 parents d6e1b56 + 33bcd7b commit e96763c

File tree

28 files changed

+627
-46
lines changed

28 files changed

+627
-46
lines changed

rust/perspective-client/perspective.proto

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -462,11 +462,10 @@ message ViewGetMinMaxReq {
462462
}
463463

464464
message ViewGetMinMaxResp {
465-
string min = 1;
466-
string max = 2;
465+
Scalar min = 1;
466+
Scalar max = 2;
467467
}
468468

469-
470469
message ViewExpressionSchemaReq {}
471470
message ViewExpressionSchemaResp {
472471
map<string, ColumnType> schema = 1;

rust/perspective-client/src/rust/config/view_config.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -416,13 +416,13 @@ impl ViewConfig {
416416
.map(|x| !x.is_empty())
417417
.unwrap_or_default()
418418
{
419-
tracing::warn!("`total` incompatible with `group_by`");
419+
tracing::info!("`total` incompatible with `group_by`");
420420
changed = true;
421421
update.group_rollup_mode = Some(GroupRollupMode::Rollup);
422422
}
423423

424424
if update.group_rollup_mode == Some(GroupRollupMode::Total) && !self.group_by.is_empty() {
425-
tracing::warn!("`group_by` incompatible with `total`");
425+
tracing::info!("`group_by` incompatible with `total`");
426426
changed = true;
427427
update.group_by = Some(vec![]);
428428
}
@@ -436,7 +436,7 @@ impl ViewConfig {
436436
changed = Self::_apply(&mut self.expressions, update.expressions) || changed;
437437
changed = Self::_apply(&mut self.group_rollup_mode, update.group_rollup_mode) || changed;
438438
if self.group_rollup_mode == GroupRollupMode::Total && !self.group_by.is_empty() {
439-
tracing::warn!("`total` incompatible with `group_by`");
439+
tracing::info!("`total` incompatible with `group_by`");
440440
changed = true;
441441
self.group_by = vec![];
442442
}

rust/perspective-client/src/rust/view.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -455,13 +455,20 @@ impl View {
455455
/// # Returns
456456
///
457457
/// A tuple of [min, max], whose types are column and aggregate dependent.
458-
pub async fn get_min_max(&self, column_name: String) -> ClientResult<(String, String)> {
458+
pub async fn get_min_max(
459+
&self,
460+
column_name: String,
461+
) -> ClientResult<(crate::config::Scalar, crate::config::Scalar)> {
459462
let msg = self.client_message(ClientReq::ViewGetMinMaxReq(ViewGetMinMaxReq {
460463
column_name,
461464
}));
462465

463466
match self.client.oneshot(&msg).await? {
464-
ClientResp::ViewGetMinMaxResp(ViewGetMinMaxResp { min, max }) => Ok((min, max)),
467+
ClientResp::ViewGetMinMaxResp(ViewGetMinMaxResp { min, max }) => {
468+
let min = min.map(crate::config::Scalar::from).unwrap_or_default();
469+
let max = max.map(crate::config::Scalar::from).unwrap_or_default();
470+
Ok((min, max))
471+
},
465472
resp => Err(resp.into()),
466473
}
467474
}

rust/perspective-client/src/rust/virtual_server/generic_sql_model.rs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@
1717
1818
// TODO(texodus): Missing these features
1919
//
20-
// - `min_max` API for value-coloring and value-sizing.
21-
//
2220
// - row expand/collapse in the datagrid needs datamodel support, this is likely
2321
// a "collapsed" boolean column in the temp table we `UPDATE`.
2422
//
@@ -287,6 +285,37 @@ impl GenericSQLVirtualServerModel {
287285
Ok(format!("SELECT COUNT(*) FROM {}", view_id))
288286
}
289287

288+
/// Returns the SQL query to get the min and max values of a column.
289+
///
290+
/// # Arguments
291+
/// * `view_id` - The identifier of the view.
292+
/// * `column_name` - The name of the column.
293+
/// * `config` - The view configuration.
294+
///
295+
/// # Returns
296+
/// SQL: `SELECT MIN("column_name"), MAX("column_name") FROM {view_id}`
297+
/// When the view uses ROLLUP grouping (non-flat mode with group_by),
298+
/// a `WHERE __GROUPING_ID__ = 0` clause is added to exclude non-leaf rows.
299+
pub fn view_get_min_max(
300+
&self,
301+
view_id: &str,
302+
column_name: &str,
303+
config: &ViewConfig,
304+
) -> GenericSQLResult<String> {
305+
let has_grouping_id =
306+
!config.group_by.is_empty() && config.group_rollup_mode != GroupRollupMode::Flat;
307+
let where_clause = if has_grouping_id {
308+
" WHERE __GROUPING_ID__ = 0"
309+
} else {
310+
""
311+
};
312+
313+
Ok(format!(
314+
"SELECT MIN(\"{}\"), MAX(\"{}\") FROM {}{}",
315+
column_name, column_name, view_id, where_clause
316+
))
317+
}
318+
290319
fn filter_term_to_sql(term: &FilterTerm) -> Option<String> {
291320
match term {
292321
FilterTerm::Scalar(scalar) => Self::scalar_to_sql(scalar),

rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -455,14 +455,64 @@ impl<'a> ViewQueryContext<'a> {
455455
fn order_by_clauses(&self) -> Vec<String> {
456456
let mut clauses = Vec::new();
457457
if !self.config.group_by.is_empty() && self.is_flat_mode() {
458-
for (sidx, Sort(sort_col, sort_dir)) in self.config.sort.iter().enumerate() {
459-
if *sort_dir != SortDir::None && !is_col_sort(sort_dir) {
460-
let dir = sort_dir_to_string(sort_dir);
461-
if !self.config.split_by.is_empty() {
462-
clauses.push(format!("__SORT_{}__ {}", sidx, dir));
463-
} else {
464-
let agg = self.get_aggregate(sort_col);
465-
clauses.push(format!("{}({}) {}", agg, self.col_name(sort_col), dir));
458+
let has_row_sort = self
459+
.config
460+
.sort
461+
.iter()
462+
.any(|Sort(_, dir)| *dir != SortDir::None && !is_col_sort(dir));
463+
if self.config.group_by.len() > 1 && has_row_sort {
464+
// Hierarchical flat sort — mirrors rollup logic but without GROUPING_ID
465+
for gidx in 0..self.config.group_by.len() {
466+
let is_leaf = gidx >= self.config.group_by.len() - 1;
467+
for (sidx, Sort(sort_col, sort_dir)) in self.config.sort.iter().enumerate() {
468+
if *sort_dir == SortDir::None || is_col_sort(sort_dir) {
469+
continue;
470+
}
471+
472+
let dir = sort_dir_to_string(sort_dir);
473+
if !self.config.split_by.is_empty() {
474+
if is_leaf {
475+
clauses.push(format!("__SORT_{}__ {}", sidx, dir));
476+
} else {
477+
clauses.push(format!(
478+
"first(__SORT_{}__) OVER __WINDOW_{}__ {}",
479+
sidx, gidx, dir
480+
));
481+
}
482+
} else {
483+
let agg = self.get_aggregate(sort_col);
484+
if is_leaf {
485+
clauses.push(format!(
486+
"{}({}) {}",
487+
agg,
488+
self.col_name(sort_col),
489+
dir
490+
));
491+
} else {
492+
clauses.push(format!(
493+
"first({}({})) OVER __WINDOW_{}__ {}",
494+
agg,
495+
self.col_name(sort_col),
496+
gidx,
497+
dir
498+
));
499+
}
500+
}
501+
}
502+
503+
clauses.push(format!("{} ASC", self.row_path_aliases[gidx]));
504+
}
505+
} else {
506+
// Single group level — simple sort, no window needed
507+
for (sidx, Sort(sort_col, sort_dir)) in self.config.sort.iter().enumerate() {
508+
if *sort_dir != SortDir::None && !is_col_sort(sort_dir) {
509+
let dir = sort_dir_to_string(sort_dir);
510+
if !self.config.split_by.is_empty() {
511+
clauses.push(format!("__SORT_{}__ {}", sidx, dir));
512+
} else {
513+
let agg = self.get_aggregate(sort_col);
514+
clauses.push(format!("{}({}) {}", agg, self.col_name(sort_col), dir));
515+
}
466516
}
467517
}
468518
}
@@ -531,14 +581,30 @@ impl<'a> ViewQueryContext<'a> {
531581
}
532582

533583
fn window_clauses(&self) -> Vec<String> {
534-
if self.is_flat_mode() || self.config.sort.is_empty() || self.config.group_by.len() <= 1 {
584+
if self.config.sort.is_empty() || self.config.group_by.len() <= 1 {
535585
return Vec::new();
536586
}
537587

538588
let mut clauses = Vec::new();
539589
for gidx in 0..(self.config.group_by.len() - 1) {
540590
let partition = self.row_path_aliases[..=gidx].join(", ");
541-
if !self.config.split_by.is_empty() {
591+
if self.is_flat_mode() {
592+
// Flat mode: partition by row path only (no GROUPING_ID)
593+
if !self.config.split_by.is_empty() {
594+
let order = self.row_path_aliases.join(", ");
595+
clauses.push(format!(
596+
"__WINDOW_{}__ AS (PARTITION BY {} ORDER BY {})",
597+
gidx, partition, order,
598+
));
599+
} else {
600+
clauses.push(format!(
601+
"__WINDOW_{}__ AS (PARTITION BY {} ORDER BY {})",
602+
gidx,
603+
partition,
604+
self.group_col_names.join(", ")
605+
));
606+
}
607+
} else if !self.config.split_by.is_empty() {
542608
let shift = self.config.group_by.len() - 1 - gidx;
543609
let grouping_expr = if shift > 0 {
544610
format!("(__GROUPING_ID__ >> {})", shift)

rust/perspective-client/src/rust/virtual_server/handler.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,19 @@ pub trait VirtualServerHandler {
145145
Box::pin(async { Ok(0) })
146146
}
147147

148+
/// Returns the min and max values of a column in a view.
149+
///
150+
/// Default implementation panics with "not implemented".
151+
fn view_get_min_max(
152+
&self,
153+
_view_id: &str,
154+
_column_name: &str,
155+
_config: &crate::config::ViewConfig,
156+
) -> VirtualServerFuture<'_, Result<(crate::config::Scalar, crate::config::Scalar), Self::Error>>
157+
{
158+
Box::pin(async { unimplemented!("view_get_min_max not implemented") })
159+
}
160+
148161
// Unused
149162

150163
/// Creates a new table with the given data.

rust/perspective-client/src/rust/virtual_server/server.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@ use crate::proto::{
2525
ColumnType, GetFeaturesResp, GetHostedTablesResp, MakeTableResp, Request, Response,
2626
ServerError, TableMakePortResp, TableMakeViewResp, TableOnDeleteResp, TableRemoveDeleteResp,
2727
TableSchemaResp, TableSizeResp, TableValidateExprResp, ViewColumnPathsResp, ViewDeleteResp,
28-
ViewDimensionsResp, ViewExpressionSchemaResp, ViewGetConfigResp, ViewOnDeleteResp,
29-
ViewOnUpdateResp, ViewRemoveDeleteResp, ViewRemoveOnUpdateResp, ViewSchemaResp,
30-
ViewToColumnsStringResp, ViewToRowsStringResp,
28+
ViewDimensionsResp, ViewExpressionSchemaResp, ViewGetConfigResp, ViewGetMinMaxResp,
29+
ViewOnDeleteResp, ViewOnUpdateResp, ViewRemoveDeleteResp, ViewRemoveOnUpdateResp,
30+
ViewSchemaResp, ViewToColumnsStringResp, ViewToRowsStringResp,
3131
};
3232

3333
macro_rules! respond {
@@ -338,6 +338,17 @@ impl<T: VirtualServerHandler> VirtualServer<T> {
338338
.await?;
339339
respond!(msg, MakeTableResp {})
340340
},
341+
ViewGetMinMaxReq(req) => {
342+
let config = self.view_configs.get(&msg.entity_id).unwrap();
343+
let (min, max) = self
344+
.handler
345+
.view_get_min_max(&msg.entity_id, &req.column_name, config)
346+
.await?;
347+
respond!(msg, ViewGetMinMaxResp {
348+
min: Some(min.into()),
349+
max: Some(max.into()),
350+
})
351+
},
341352

342353
// Stub implementations for callback/update requests that VirtualServer doesn't support
343354
TableOnDeleteReq(_) => {
@@ -361,7 +372,6 @@ impl<T: VirtualServerHandler> VirtualServer<T> {
361372
ViewRemoveDeleteReq(_) => {
362373
respond!(msg, ViewRemoveDeleteResp {})
363374
},
364-
365375
x => {
366376
// Return an error response instead of empty bytes
367377
return Err(VirtualServerError::Other(format!(

rust/perspective-js/src/rust/generic_sql_model.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,22 @@ impl GenericSQLVirtualServerModel {
157157
.view_size(view_id)
158158
.map_err(|e| JsValue::from_str(&e.to_string()))
159159
}
160+
161+
/// Returns the SQL query to get the min and max values of a column.
162+
#[wasm_bindgen(js_name = "viewGetMinMax")]
163+
pub fn view_get_min_max(
164+
&self,
165+
view_id: &str,
166+
column_name: &str,
167+
config: JsValue,
168+
) -> Result<String, JsValue> {
169+
let config: ViewConfig = serde_wasm_bindgen::from_value(config)
170+
.map_err(|e| JsValue::from_str(&e.to_string()))?;
171+
172+
self.inner
173+
.view_get_min_max(view_id, column_name, &config)
174+
.map_err(|e| JsValue::from_str(&e.to_string()))
175+
}
160176
}
161177

162178
impl GenericSQLVirtualServerModel {

rust/perspective-js/src/rust/view.rs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,15 @@ impl From<ViewWindow> for JsViewWindow {
4747
}
4848
}
4949

50+
fn scalar_to_jsvalue(scalar: &perspective_client::config::Scalar) -> JsValue {
51+
match scalar {
52+
perspective_client::config::Scalar::Float(x) => JsValue::from_f64(*x),
53+
perspective_client::config::Scalar::String(x) => JsValue::from_str(x),
54+
perspective_client::config::Scalar::Bool(x) => JsValue::from_bool(*x),
55+
perspective_client::config::Scalar::Null => JsValue::NULL,
56+
}
57+
}
58+
5059
/// The [`View`] struct is Perspective's query and serialization interface. It
5160
/// represents a query on the `Table`'s dataset and is always created from an
5261
/// existing `Table` instance via the [`Table::view`] method.
@@ -147,10 +156,10 @@ impl View {
147156
#[wasm_bindgen]
148157
pub async fn get_min_max(&self, name: String) -> ApiResult<Array> {
149158
let result = self.0.get_min_max(name).await?;
150-
Ok([result.0, result.1]
151-
.iter()
152-
.map(|x| js_sys::JSON::parse(x))
153-
.collect::<Result<_, _>>()?)
159+
let arr = Array::new();
160+
arr.push(&scalar_to_jsvalue(&result.0));
161+
arr.push(&scalar_to_jsvalue(&result.1));
162+
Ok(arr)
154163
}
155164

156165
/// The number of aggregated rows in this [`View`]. This is affected by the

rust/perspective-js/src/rust/virtual_server.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,20 @@ impl From<serde_wasm_bindgen::Error> for JsError {
6060
}
6161
}
6262

63+
fn jsvalue_to_scalar(val: &JsValue) -> perspective_client::config::Scalar {
64+
if val.is_null() || val.is_undefined() {
65+
perspective_client::config::Scalar::Null
66+
} else if let Some(b) = val.as_bool() {
67+
perspective_client::config::Scalar::Bool(b)
68+
} else if let Some(n) = val.as_f64() {
69+
perspective_client::config::Scalar::Float(n)
70+
} else if let Some(s) = val.as_string() {
71+
perspective_client::config::Scalar::String(s)
72+
} else {
73+
perspective_client::config::Scalar::Null
74+
}
75+
}
76+
6377
pub struct JsServerHandler(Object);
6478

6579
impl JsServerHandler {
@@ -452,6 +466,48 @@ impl VirtualServerHandler for JsServerHandler {
452466
})
453467
}
454468

469+
fn view_get_min_max(
470+
&self,
471+
view_id: &str,
472+
column_name: &str,
473+
config: &perspective_client::config::ViewConfig,
474+
) -> HandlerFuture<
475+
Result<
476+
(
477+
perspective_client::config::Scalar,
478+
perspective_client::config::Scalar,
479+
),
480+
Self::Error,
481+
>,
482+
> {
483+
let has_method = Reflect::get(&self.0, &JsValue::from_str("viewGetMinMax"))
484+
.map(|val| !val.is_undefined())
485+
.unwrap_or(false);
486+
487+
if !has_method {
488+
return Box::pin(async {
489+
Err(JsError(JsValue::from_str("viewGetMinMax not implemented")))
490+
});
491+
}
492+
493+
let handler = self.0.clone();
494+
let view_id = view_id.to_string();
495+
let column_name = column_name.to_string();
496+
let config_js = serde_wasm_bindgen::to_value(config).unwrap();
497+
Box::pin(async move {
498+
let this = JsServerHandler(handler);
499+
let args = Array::new();
500+
args.push(&JsValue::from_str(&view_id));
501+
args.push(&JsValue::from_str(&column_name));
502+
args.push(&config_js);
503+
let result = this.call_method_js_async("viewGetMinMax", &args).await?;
504+
let obj = result.dyn_ref::<Object>().unwrap();
505+
let min_val = Reflect::get(obj, &JsValue::from_str(wasm_bindgen::intern("min")))?;
506+
let max_val = Reflect::get(obj, &JsValue::from_str(wasm_bindgen::intern("max")))?;
507+
Ok((jsvalue_to_scalar(&min_val), jsvalue_to_scalar(&max_val)))
508+
})
509+
}
510+
455511
fn view_get_data(
456512
&self,
457513
view_id: &str,

0 commit comments

Comments
 (0)