Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions rust/perspective-client/perspective.proto
Original file line number Diff line number Diff line change
Expand Up @@ -462,11 +462,10 @@ message ViewGetMinMaxReq {
}

message ViewGetMinMaxResp {
string min = 1;
string max = 2;
Scalar min = 1;
Scalar max = 2;
}


message ViewExpressionSchemaReq {}
message ViewExpressionSchemaResp {
map<string, ColumnType> schema = 1;
Expand Down
6 changes: 3 additions & 3 deletions rust/perspective-client/src/rust/config/view_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -416,13 +416,13 @@ impl ViewConfig {
.map(|x| !x.is_empty())
.unwrap_or_default()
{
tracing::warn!("`total` incompatible with `group_by`");
tracing::info!("`total` incompatible with `group_by`");
changed = true;
update.group_rollup_mode = Some(GroupRollupMode::Rollup);
}

if update.group_rollup_mode == Some(GroupRollupMode::Total) && !self.group_by.is_empty() {
tracing::warn!("`group_by` incompatible with `total`");
tracing::info!("`group_by` incompatible with `total`");
changed = true;
update.group_by = Some(vec![]);
}
Expand All @@ -436,7 +436,7 @@ impl ViewConfig {
changed = Self::_apply(&mut self.expressions, update.expressions) || changed;
changed = Self::_apply(&mut self.group_rollup_mode, update.group_rollup_mode) || changed;
if self.group_rollup_mode == GroupRollupMode::Total && !self.group_by.is_empty() {
tracing::warn!("`total` incompatible with `group_by`");
tracing::info!("`total` incompatible with `group_by`");
changed = true;
self.group_by = vec![];
}
Expand Down
11 changes: 9 additions & 2 deletions rust/perspective-client/src/rust/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -455,13 +455,20 @@ impl View {
/// # Returns
///
/// A tuple of [min, max], whose types are column and aggregate dependent.
pub async fn get_min_max(&self, column_name: String) -> ClientResult<(String, String)> {
pub async fn get_min_max(
&self,
column_name: String,
) -> ClientResult<(crate::config::Scalar, crate::config::Scalar)> {
let msg = self.client_message(ClientReq::ViewGetMinMaxReq(ViewGetMinMaxReq {
column_name,
}));

match self.client.oneshot(&msg).await? {
ClientResp::ViewGetMinMaxResp(ViewGetMinMaxResp { min, max }) => Ok((min, max)),
ClientResp::ViewGetMinMaxResp(ViewGetMinMaxResp { min, max }) => {
let min = min.map(crate::config::Scalar::from).unwrap_or_default();
let max = max.map(crate::config::Scalar::from).unwrap_or_default();
Ok((min, max))
},
resp => Err(resp.into()),
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@

// TODO(texodus): Missing these features
//
// - `min_max` API for value-coloring and value-sizing.
//
// - row expand/collapse in the datagrid needs datamodel support, this is likely
// a "collapsed" boolean column in the temp table we `UPDATE`.
//
Expand Down Expand Up @@ -287,6 +285,37 @@ impl GenericSQLVirtualServerModel {
Ok(format!("SELECT COUNT(*) FROM {}", view_id))
}

/// Returns the SQL query to get the min and max values of a column.
///
/// # Arguments
/// * `view_id` - The identifier of the view.
/// * `column_name` - The name of the column.
/// * `config` - The view configuration.
///
/// # Returns
/// SQL: `SELECT MIN("column_name"), MAX("column_name") FROM {view_id}`
/// When the view uses ROLLUP grouping (non-flat mode with group_by),
/// a `WHERE __GROUPING_ID__ = 0` clause is added to exclude non-leaf rows.
pub fn view_get_min_max(
&self,
view_id: &str,
column_name: &str,
config: &ViewConfig,
) -> GenericSQLResult<String> {
let has_grouping_id =
!config.group_by.is_empty() && config.group_rollup_mode != GroupRollupMode::Flat;
let where_clause = if has_grouping_id {
" WHERE __GROUPING_ID__ = 0"
} else {
""
};

Ok(format!(
"SELECT MIN(\"{}\"), MAX(\"{}\") FROM {}{}",
column_name, column_name, view_id, where_clause
))
}

fn filter_term_to_sql(term: &FilterTerm) -> Option<String> {
match term {
FilterTerm::Scalar(scalar) => Self::scalar_to_sql(scalar),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -455,14 +455,64 @@ impl<'a> ViewQueryContext<'a> {
fn order_by_clauses(&self) -> Vec<String> {
let mut clauses = Vec::new();
if !self.config.group_by.is_empty() && self.is_flat_mode() {
for (sidx, Sort(sort_col, sort_dir)) in self.config.sort.iter().enumerate() {
if *sort_dir != SortDir::None && !is_col_sort(sort_dir) {
let dir = sort_dir_to_string(sort_dir);
if !self.config.split_by.is_empty() {
clauses.push(format!("__SORT_{}__ {}", sidx, dir));
} else {
let agg = self.get_aggregate(sort_col);
clauses.push(format!("{}({}) {}", agg, self.col_name(sort_col), dir));
let has_row_sort = self
.config
.sort
.iter()
.any(|Sort(_, dir)| *dir != SortDir::None && !is_col_sort(dir));
if self.config.group_by.len() > 1 && has_row_sort {
// Hierarchical flat sort — mirrors rollup logic but without GROUPING_ID
for gidx in 0..self.config.group_by.len() {
let is_leaf = gidx >= self.config.group_by.len() - 1;
for (sidx, Sort(sort_col, sort_dir)) in self.config.sort.iter().enumerate() {
if *sort_dir == SortDir::None || is_col_sort(sort_dir) {
continue;
}

let dir = sort_dir_to_string(sort_dir);
if !self.config.split_by.is_empty() {
if is_leaf {
clauses.push(format!("__SORT_{}__ {}", sidx, dir));
} else {
clauses.push(format!(
"first(__SORT_{}__) OVER __WINDOW_{}__ {}",
sidx, gidx, dir
));
}
} else {
let agg = self.get_aggregate(sort_col);
if is_leaf {
clauses.push(format!(
"{}({}) {}",
agg,
self.col_name(sort_col),
dir
));
} else {
clauses.push(format!(
"first({}({})) OVER __WINDOW_{}__ {}",
agg,
self.col_name(sort_col),
gidx,
dir
));
}
}
}

clauses.push(format!("{} ASC", self.row_path_aliases[gidx]));
}
} else {
// Single group level — simple sort, no window needed
for (sidx, Sort(sort_col, sort_dir)) in self.config.sort.iter().enumerate() {
if *sort_dir != SortDir::None && !is_col_sort(sort_dir) {
let dir = sort_dir_to_string(sort_dir);
if !self.config.split_by.is_empty() {
clauses.push(format!("__SORT_{}__ {}", sidx, dir));
} else {
let agg = self.get_aggregate(sort_col);
clauses.push(format!("{}({}) {}", agg, self.col_name(sort_col), dir));
}
}
}
}
Expand Down Expand Up @@ -531,14 +581,30 @@ impl<'a> ViewQueryContext<'a> {
}

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

let mut clauses = Vec::new();
for gidx in 0..(self.config.group_by.len() - 1) {
let partition = self.row_path_aliases[..=gidx].join(", ");
if !self.config.split_by.is_empty() {
if self.is_flat_mode() {
// Flat mode: partition by row path only (no GROUPING_ID)
if !self.config.split_by.is_empty() {
let order = self.row_path_aliases.join(", ");
clauses.push(format!(
"__WINDOW_{}__ AS (PARTITION BY {} ORDER BY {})",
gidx, partition, order,
));
} else {
clauses.push(format!(
"__WINDOW_{}__ AS (PARTITION BY {} ORDER BY {})",
gidx,
partition,
self.group_col_names.join(", ")
));
}
} else if !self.config.split_by.is_empty() {
let shift = self.config.group_by.len() - 1 - gidx;
let grouping_expr = if shift > 0 {
format!("(__GROUPING_ID__ >> {})", shift)
Expand Down
13 changes: 13 additions & 0 deletions rust/perspective-client/src/rust/virtual_server/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,19 @@ pub trait VirtualServerHandler {
Box::pin(async { Ok(0) })
}

/// Returns the min and max values of a column in a view.
///
/// Default implementation panics with "not implemented".
fn view_get_min_max(
&self,
_view_id: &str,
_column_name: &str,
_config: &crate::config::ViewConfig,
) -> VirtualServerFuture<'_, Result<(crate::config::Scalar, crate::config::Scalar), Self::Error>>
{
Box::pin(async { unimplemented!("view_get_min_max not implemented") })
}

// Unused

/// Creates a new table with the given data.
Expand Down
18 changes: 14 additions & 4 deletions rust/perspective-client/src/rust/virtual_server/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ use crate::proto::{
ColumnType, GetFeaturesResp, GetHostedTablesResp, MakeTableResp, Request, Response,
ServerError, TableMakePortResp, TableMakeViewResp, TableOnDeleteResp, TableRemoveDeleteResp,
TableSchemaResp, TableSizeResp, TableValidateExprResp, ViewColumnPathsResp, ViewDeleteResp,
ViewDimensionsResp, ViewExpressionSchemaResp, ViewGetConfigResp, ViewOnDeleteResp,
ViewOnUpdateResp, ViewRemoveDeleteResp, ViewRemoveOnUpdateResp, ViewSchemaResp,
ViewToColumnsStringResp, ViewToRowsStringResp,
ViewDimensionsResp, ViewExpressionSchemaResp, ViewGetConfigResp, ViewGetMinMaxResp,
ViewOnDeleteResp, ViewOnUpdateResp, ViewRemoveDeleteResp, ViewRemoveOnUpdateResp,
ViewSchemaResp, ViewToColumnsStringResp, ViewToRowsStringResp,
};

macro_rules! respond {
Expand Down Expand Up @@ -338,6 +338,17 @@ impl<T: VirtualServerHandler> VirtualServer<T> {
.await?;
respond!(msg, MakeTableResp {})
},
ViewGetMinMaxReq(req) => {
let config = self.view_configs.get(&msg.entity_id).unwrap();
let (min, max) = self
.handler
.view_get_min_max(&msg.entity_id, &req.column_name, config)
.await?;
respond!(msg, ViewGetMinMaxResp {
min: Some(min.into()),
max: Some(max.into()),
})
},

// Stub implementations for callback/update requests that VirtualServer doesn't support
TableOnDeleteReq(_) => {
Expand All @@ -361,7 +372,6 @@ impl<T: VirtualServerHandler> VirtualServer<T> {
ViewRemoveDeleteReq(_) => {
respond!(msg, ViewRemoveDeleteResp {})
},

x => {
// Return an error response instead of empty bytes
return Err(VirtualServerError::Other(format!(
Expand Down
16 changes: 16 additions & 0 deletions rust/perspective-js/src/rust/generic_sql_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,22 @@ impl GenericSQLVirtualServerModel {
.view_size(view_id)
.map_err(|e| JsValue::from_str(&e.to_string()))
}

/// Returns the SQL query to get the min and max values of a column.
#[wasm_bindgen(js_name = "viewGetMinMax")]
pub fn view_get_min_max(
&self,
view_id: &str,
column_name: &str,
config: JsValue,
) -> Result<String, JsValue> {
let config: ViewConfig = serde_wasm_bindgen::from_value(config)
.map_err(|e| JsValue::from_str(&e.to_string()))?;

self.inner
.view_get_min_max(view_id, column_name, &config)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
}

impl GenericSQLVirtualServerModel {
Expand Down
17 changes: 13 additions & 4 deletions rust/perspective-js/src/rust/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ impl From<ViewWindow> for JsViewWindow {
}
}

fn scalar_to_jsvalue(scalar: &perspective_client::config::Scalar) -> JsValue {
match scalar {
perspective_client::config::Scalar::Float(x) => JsValue::from_f64(*x),
perspective_client::config::Scalar::String(x) => JsValue::from_str(x),
perspective_client::config::Scalar::Bool(x) => JsValue::from_bool(*x),
perspective_client::config::Scalar::Null => JsValue::NULL,
}
}

/// The [`View`] struct is Perspective's query and serialization interface. It
/// represents a query on the `Table`'s dataset and is always created from an
/// existing `Table` instance via the [`Table::view`] method.
Expand Down Expand Up @@ -147,10 +156,10 @@ impl View {
#[wasm_bindgen]
pub async fn get_min_max(&self, name: String) -> ApiResult<Array> {
let result = self.0.get_min_max(name).await?;
Ok([result.0, result.1]
.iter()
.map(|x| js_sys::JSON::parse(x))
.collect::<Result<_, _>>()?)
let arr = Array::new();
arr.push(&scalar_to_jsvalue(&result.0));
arr.push(&scalar_to_jsvalue(&result.1));
Ok(arr)
}

/// The number of aggregated rows in this [`View`]. This is affected by the
Expand Down
56 changes: 56 additions & 0 deletions rust/perspective-js/src/rust/virtual_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,20 @@ impl From<serde_wasm_bindgen::Error> for JsError {
}
}

fn jsvalue_to_scalar(val: &JsValue) -> perspective_client::config::Scalar {
if val.is_null() || val.is_undefined() {
perspective_client::config::Scalar::Null
} else if let Some(b) = val.as_bool() {
perspective_client::config::Scalar::Bool(b)
} else if let Some(n) = val.as_f64() {
perspective_client::config::Scalar::Float(n)
} else if let Some(s) = val.as_string() {
perspective_client::config::Scalar::String(s)
} else {
perspective_client::config::Scalar::Null
}
}

pub struct JsServerHandler(Object);

impl JsServerHandler {
Expand Down Expand Up @@ -452,6 +466,48 @@ impl VirtualServerHandler for JsServerHandler {
})
}

fn view_get_min_max(
&self,
view_id: &str,
column_name: &str,
config: &perspective_client::config::ViewConfig,
) -> HandlerFuture<
Result<
(
perspective_client::config::Scalar,
perspective_client::config::Scalar,
),
Self::Error,
>,
> {
let has_method = Reflect::get(&self.0, &JsValue::from_str("viewGetMinMax"))
.map(|val| !val.is_undefined())
.unwrap_or(false);

if !has_method {
return Box::pin(async {
Err(JsError(JsValue::from_str("viewGetMinMax not implemented")))
});
}

let handler = self.0.clone();
let view_id = view_id.to_string();
let column_name = column_name.to_string();
let config_js = serde_wasm_bindgen::to_value(config).unwrap();
Box::pin(async move {
let this = JsServerHandler(handler);
let args = Array::new();
args.push(&JsValue::from_str(&view_id));
args.push(&JsValue::from_str(&column_name));
args.push(&config_js);
let result = this.call_method_js_async("viewGetMinMax", &args).await?;
let obj = result.dyn_ref::<Object>().unwrap();
let min_val = Reflect::get(obj, &JsValue::from_str(wasm_bindgen::intern("min")))?;
let max_val = Reflect::get(obj, &JsValue::from_str(wasm_bindgen::intern("max")))?;
Ok((jsvalue_to_scalar(&min_val), jsvalue_to_scalar(&max_val)))
})
}

fn view_get_data(
&self,
view_id: &str,
Expand Down
Loading
Loading