diff --git a/rust/perspective-client/perspective.proto b/rust/perspective-client/perspective.proto index 79d826d5c1..b324483e93 100644 --- a/rust/perspective-client/perspective.proto +++ b/rust/perspective-client/perspective.proto @@ -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 schema = 1; diff --git a/rust/perspective-client/src/rust/config/view_config.rs b/rust/perspective-client/src/rust/config/view_config.rs index ee2a41a17f..42f5cff2eb 100644 --- a/rust/perspective-client/src/rust/config/view_config.rs +++ b/rust/perspective-client/src/rust/config/view_config.rs @@ -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![]); } @@ -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![]; } diff --git a/rust/perspective-client/src/rust/view.rs b/rust/perspective-client/src/rust/view.rs index 91e8a6a1a4..17c9d6d479 100644 --- a/rust/perspective-client/src/rust/view.rs +++ b/rust/perspective-client/src/rust/view.rs @@ -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()), } } diff --git a/rust/perspective-client/src/rust/virtual_server/generic_sql_model.rs b/rust/perspective-client/src/rust/virtual_server/generic_sql_model.rs index caa2c0de37..4590fa62ea 100644 --- a/rust/perspective-client/src/rust/virtual_server/generic_sql_model.rs +++ b/rust/perspective-client/src/rust/virtual_server/generic_sql_model.rs @@ -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`. // @@ -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 { + 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 { match term { FilterTerm::Scalar(scalar) => Self::scalar_to_sql(scalar), diff --git a/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs index 208eb571ea..1a8d842d3c 100644 --- a/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs +++ b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs @@ -455,14 +455,64 @@ impl<'a> ViewQueryContext<'a> { fn order_by_clauses(&self) -> Vec { 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)); + } } } } @@ -531,14 +581,30 @@ impl<'a> ViewQueryContext<'a> { } fn window_clauses(&self) -> Vec { - 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) diff --git a/rust/perspective-client/src/rust/virtual_server/handler.rs b/rust/perspective-client/src/rust/virtual_server/handler.rs index f30139cb47..6b7f013500 100644 --- a/rust/perspective-client/src/rust/virtual_server/handler.rs +++ b/rust/perspective-client/src/rust/virtual_server/handler.rs @@ -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. diff --git a/rust/perspective-client/src/rust/virtual_server/server.rs b/rust/perspective-client/src/rust/virtual_server/server.rs index 17af1434f8..ea4ae41978 100644 --- a/rust/perspective-client/src/rust/virtual_server/server.rs +++ b/rust/perspective-client/src/rust/virtual_server/server.rs @@ -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 { @@ -338,6 +338,17 @@ impl VirtualServer { .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(_) => { @@ -361,7 +372,6 @@ impl VirtualServer { ViewRemoveDeleteReq(_) => { respond!(msg, ViewRemoveDeleteResp {}) }, - x => { // Return an error response instead of empty bytes return Err(VirtualServerError::Other(format!( diff --git a/rust/perspective-js/src/rust/generic_sql_model.rs b/rust/perspective-js/src/rust/generic_sql_model.rs index ee281310a7..b0d486a4e1 100644 --- a/rust/perspective-js/src/rust/generic_sql_model.rs +++ b/rust/perspective-js/src/rust/generic_sql_model.rs @@ -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 { + 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 { diff --git a/rust/perspective-js/src/rust/view.rs b/rust/perspective-js/src/rust/view.rs index 353bb621e9..0a2f063e81 100644 --- a/rust/perspective-js/src/rust/view.rs +++ b/rust/perspective-js/src/rust/view.rs @@ -47,6 +47,15 @@ impl From 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. @@ -147,10 +156,10 @@ impl View { #[wasm_bindgen] pub async fn get_min_max(&self, name: String) -> ApiResult { let result = self.0.get_min_max(name).await?; - Ok([result.0, result.1] - .iter() - .map(|x| js_sys::JSON::parse(x)) - .collect::>()?) + 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 diff --git a/rust/perspective-js/src/rust/virtual_server.rs b/rust/perspective-js/src/rust/virtual_server.rs index 3da54ef1b7..425010af44 100644 --- a/rust/perspective-js/src/rust/virtual_server.rs +++ b/rust/perspective-js/src/rust/virtual_server.rs @@ -60,6 +60,20 @@ impl From 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 { @@ -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::().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, diff --git a/rust/perspective-js/src/ts/virtual_server.ts b/rust/perspective-js/src/ts/virtual_server.ts index d6534823a0..0b91659061 100644 --- a/rust/perspective-js/src/ts/virtual_server.ts +++ b/rust/perspective-js/src/ts/virtual_server.ts @@ -68,6 +68,11 @@ export interface VirtualServerHandler { tableId: string, expression: string, ): ColumnType | Promise; + viewGetMinMax?( + viewId: string, + columnName: string, + config: ViewConfig, + ): { min: any; max: any } | Promise<{ min: any; max: any }>; getFeatures?(): ServerFeatures | Promise; makeTable?( tableId: string, diff --git a/rust/perspective-js/src/ts/virtual_servers/duckdb.ts b/rust/perspective-js/src/ts/virtual_servers/duckdb.ts index 0465b8b3df..b7c13e9b30 100644 --- a/rust/perspective-js/src/ts/virtual_servers/duckdb.ts +++ b/rust/perspective-js/src/ts/virtual_servers/duckdb.ts @@ -290,6 +290,20 @@ export class DuckDBHandler implements perspective.VirtualServerHandler { await runQuery(this.db, query); } + async viewGetMinMax( + viewId: string, + columnName: string, + config: ViewConfig, + ) { + const query = this.sqlBuilder.viewGetMinMax(viewId, columnName, config); + const results = await runQuery(this.db, query); + const row = results[0].toJSON(); + let [min, max] = Object.values(row); + if (typeof min === "bigint") min = Number(min); + if (typeof max === "bigint") max = Number(max); + return { min: min ?? null, max: max ?? null }; + } + async viewGetData( viewId: string, config: ViewConfig, diff --git a/rust/perspective-js/test/js/duckdb/combined.spec.js b/rust/perspective-js/test/js/duckdb/combined.spec.js index a66b37133b..1c8c018a1b 100644 --- a/rust/perspective-js/test/js/duckdb/combined.spec.js +++ b/rust/perspective-js/test/js/duckdb/combined.spec.js @@ -94,6 +94,7 @@ describeDuckDB("combined operations", (getClient) => { "West|Sales": 165134.77900000007, }, ]); + await view.delete(); }); @@ -124,6 +125,7 @@ describeDuckDB("combined operations", (getClient) => { "West|Sales": null, }, ]); + await view.delete(); }); @@ -155,6 +157,93 @@ describeDuckDB("combined operations", (getClient) => { "West|Sales": null, }, ]); + + await view.delete(); + }); + + test("flat + multi group_by + sort", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Region", "Category"], + sort: [["Sales", "desc"]], + aggregates: { Sales: "sum" }, + group_rollup_mode: "flat", + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { + __ROW_PATH__: ["West", "Furniture"], + Sales: 252612.7435000003, + }, + { + __ROW_PATH__: ["West", "Technology"], + Sales: 251991.83199999997, + }, + { + __ROW_PATH__: ["West", "Office Supplies"], + Sales: 220853.24900000007, + }, + { + __ROW_PATH__: ["East", "Technology"], + Sales: 264973.9810000003, + }, + { + __ROW_PATH__: ["East", "Furniture"], + Sales: 208291.20400000009, + }, + ]); + await view.delete(); + }); + + test("flat + multi group_by + split_by + sort", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Region", "Category"], + split_by: ["Ship Mode"], + sort: [["Sales", "desc"]], + aggregates: { Sales: "sum" }, + group_rollup_mode: "flat", + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { + __ROW_PATH__: ["West", "Furniture"], + "First Class|Sales": 40018.829499999985, + "Same Day|Sales": 14527.978000000001, + "Second Class|Sales": 54155.6555, + "Standard Class|Sales": 143910.28049999996, + }, + { + __ROW_PATH__: ["West", "Technology"], + "First Class|Sales": 61107.98900000001, + "Same Day|Sales": 19218.053999999993, + "Second Class|Sales": 38610.979999999996, + "Standard Class|Sales": 133054.809, + }, + { + __ROW_PATH__: ["West", "Office Supplies"], + "First Class|Sales": 28635.06999999996, + "Same Day|Sales": 9857.678000000002, + "Second Class|Sales": 52572.79199999999, + "Standard Class|Sales": 129787.70900000003, + }, + { + __ROW_PATH__: ["East", "Technology"], + "First Class|Sales": 47693.312999999995, + "Same Day|Sales": 21349.464999999997, + "Second Class|Sales": 29304.490000000005, + "Standard Class|Sales": 166626.71300000005, + }, + { + __ROW_PATH__: ["East", "Furniture"], + "First Class|Sales": 29410.643999999997, + "Same Day|Sales": 12852.570999999996, + "Second Class|Sales": 44035.937000000005, + "Standard Class|Sales": 121992.05199999997, + }, + ]); await view.delete(); }); @@ -167,6 +256,7 @@ describeDuckDB("combined operations", (getClient) => { sort: [["profitmargin", "desc"]], aggregates: { profitmargin: "avg" }, }); + const json = await view.to_json(); expect(json).toEqual([ { @@ -190,6 +280,7 @@ describeDuckDB("combined operations", (getClient) => { profitmargin: -10.407293926323575, }, ]); + await view.delete(); }); }); diff --git a/rust/perspective-js/test/js/duckdb/min_max.spec.js b/rust/perspective-js/test/js/duckdb/min_max.spec.js new file mode 100644 index 0000000000..18a8465e97 --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/min_max.spec.js @@ -0,0 +1,69 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { test, expect } from "@perspective-dev/test"; +import { describeDuckDB } from "./setup.js"; + +describeDuckDB("min_max", (getClient) => { + test("get_min_max() on integer column", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ columns: ["Quantity"] }); + const result = await view.get_min_max("Quantity"); + expect(result[0]).toBe(1); + expect(result[1]).toBe(14); + await view.delete(); + }); + + test("get_min_max() on float column", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ columns: ["Sales"] }); + const result = await view.get_min_max("Sales"); + expect(result[0]).toBe(0.444); + expect(result[1]).toBe(22638.48); + await view.delete(); + }); + + test("get_min_max() on string column", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ columns: ["Category"] }); + const result = await view.get_min_max("Category"); + expect(result[0]).toBe("Furniture"); + expect(result[1]).toBe("Technology"); + await view.delete(); + }); + + test("get_min_max() with group_by", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Region"], + aggregates: { Sales: "sum" }, + }); + const result = await view.get_min_max("Sales"); + expect(result[0]).toBeGreaterThan(0); + expect(result[1]).toBeGreaterThan(0); + expect(result[1]).toBeGreaterThanOrEqual(result[0]); + await view.delete(); + }); + + test("get_min_max() with filter", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Quantity"], + filter: [["Quantity", ">", 10]], + }); + const result = await view.get_min_max("Quantity"); + expect(result[0]).toBeGreaterThanOrEqual(11); + expect(result[1]).toBe(14); + await view.delete(); + }); +}); diff --git a/rust/perspective-python/perspective/tests/virtual_servers/test_duckdb.py b/rust/perspective-python/perspective/tests/virtual_servers/test_duckdb.py index d9917f3ebd..af85c88fb1 100644 --- a/rust/perspective-python/perspective/tests/virtual_servers/test_duckdb.py +++ b/rust/perspective-python/perspective/tests/virtual_servers/test_duckdb.py @@ -856,3 +856,53 @@ def test_expressions_group_by_sort(self, client): {"__ROW_PATH__": ["Central"], "profitmargin": -10.407293926323575}, ] view.delete() + + +class TestDuckDBMinMax: + def test_min_max_integer(self, client): + table = client.open_table("memory.superstore") + view = table.view(columns=["Quantity"]) + min_val, max_val = view.get_min_max("Quantity") + assert min_val == 1 + assert max_val == 14 + view.delete() + + def test_min_max_float(self, client): + table = client.open_table("memory.superstore") + view = table.view(columns=["Sales"]) + min_val, max_val = view.get_min_max("Sales") + assert min_val == 0.444 + assert max_val == 22638.48 + view.delete() + + def test_min_max_string(self, client): + table = client.open_table("memory.superstore") + view = table.view(columns=["Category"]) + min_val, max_val = view.get_min_max("Category") + assert min_val == "Furniture" + assert max_val == "Technology" + view.delete() + + def test_min_max_with_group_by(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Sales"], + group_by=["Region"], + aggregates={"Sales": "sum"}, + ) + min_val, max_val = view.get_min_max("Sales") + assert min_val > 0 + assert max_val > 0 + assert max_val >= min_val + view.delete() + + def test_min_max_with_filter(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Quantity"], + filter=[["Quantity", ">", 10]], + ) + min_val, max_val = view.get_min_max("Quantity") + assert min_val >= 11 + assert max_val == 14 + view.delete() diff --git a/rust/perspective-python/perspective/virtual_servers/__init__.py b/rust/perspective-python/perspective/virtual_servers/__init__.py index b50e4b62bb..550789b5e9 100644 --- a/rust/perspective-python/perspective/virtual_servers/__init__.py +++ b/rust/perspective-python/perspective/virtual_servers/__init__.py @@ -122,6 +122,14 @@ def view_delete(self, view_name): pass + def view_get_min_max(self, view_name, column_name, config): + """ + [OPTIONAL] Get the min and max values of a column in a view. + Returns a tuple of (min, max) as native Python values. + """ + + pass + def view_get_data(self, view_name, config, viewport, data): """ Serialize a rectangular slice `viewport` from temporary table diff --git a/rust/perspective-python/perspective/virtual_servers/clickhouse.py b/rust/perspective-python/perspective/virtual_servers/clickhouse.py index 41917ad8b0..7d1cb43f27 100644 --- a/rust/perspective-python/perspective/virtual_servers/clickhouse.py +++ b/rust/perspective-python/perspective/virtual_servers/clickhouse.py @@ -163,6 +163,12 @@ def view_delete(self, view_name): query = self.sql_builder.view_delete(view_name) run_query(self.db, query, execute=True) + def view_get_min_max(self, view_name, column_name, config): + query = self.sql_builder.view_get_min_max(view_name, column_name, config) + results = run_query(self.db, query) + row = results[0] + return (row[0], row[1]) + def view_get_data(self, view_name, config, schema, viewport, data): group_by = config["group_by"] split_by = config["split_by"] diff --git a/rust/perspective-python/perspective/virtual_servers/duckdb.py b/rust/perspective-python/perspective/virtual_servers/duckdb.py index 4d4ff1672c..283926ac56 100644 --- a/rust/perspective-python/perspective/virtual_servers/duckdb.py +++ b/rust/perspective-python/perspective/virtual_servers/duckdb.py @@ -172,6 +172,12 @@ def view_delete(self, view_name): query = self.sql_builder.view_delete(view_name) run_query(self.db, query, execute=True) + def view_get_min_max(self, view_name, column_name, config): + query = self.sql_builder.view_get_min_max(view_name, column_name, config) + results = run_query(self.db, query) + row = results[0] + return (row[0], row[1]) + def view_get_data(self, view_name, config, schema, viewport, data): group_by = config["group_by"] split_by = config["split_by"] diff --git a/rust/perspective-python/perspective/virtual_servers/polars.py b/rust/perspective-python/perspective/virtual_servers/polars.py index 814901f259..553c3e239d 100644 --- a/rust/perspective-python/perspective/virtual_servers/polars.py +++ b/rust/perspective-python/perspective/virtual_servers/polars.py @@ -217,6 +217,13 @@ def view_delete(self, view_name): self.views.pop(view_name, None) self.view_schemas.pop(view_name, None) + def view_get_min_max(self, view_name, column_name, config): + df = self.views[view_name] + col = df[column_name] + min_val = col.min() + max_val = col.max() + return (min_val, max_val) + def view_get_data(self, view_name, config, schema, viewport, data): df = self.views.get(view_name) if df is None: diff --git a/rust/perspective-python/src/client/client_async.rs b/rust/perspective-python/src/client/client_async.rs index 4cce54d843..4dbd80f18a 100644 --- a/rust/perspective-python/src/client/client_async.rs +++ b/rust/perspective-python/src/client/client_async.rs @@ -710,8 +710,14 @@ impl AsyncView { /// # Returns /// /// A tuple of [min, max], whose types are column and aggregate dependent. - pub async fn get_min_max(&self, name: String) -> PyResult<(String, String)> { - self.view.get_min_max(name).await.into_pyerr() + pub async fn get_min_max(&self, name: String) -> PyResult<(PyObject, PyObject)> { + let (min, max) = self.view.get_min_max(name).await.into_pyerr()?; + Python::with_gil(|py| { + Ok(( + super::client_sync::scalar_to_py(py, &min), + super::client_sync::scalar_to_py(py, &max), + )) + }) } /// The number of aggregated rows in this [`View`]. This is affected by the diff --git a/rust/perspective-python/src/client/client_sync.rs b/rust/perspective-python/src/client/client_sync.rs index c78749a1d9..23a99052ba 100644 --- a/rust/perspective-python/src/client/client_sync.rs +++ b/rust/perspective-python/src/client/client_sync.rs @@ -13,6 +13,7 @@ use std::collections::HashMap; use std::future::Future; +use perspective_client::config::Scalar; #[cfg(doc)] use perspective_client::{TableInitOptions, UpdateOptions, config::ViewConfigUpdate}; use perspective_client::{assert_table_api, assert_view_api}; @@ -24,6 +25,15 @@ use pyo3::types::*; use super::client_async::*; use crate::server::Server; +pub(crate) fn scalar_to_py(py: Python<'_>, scalar: &Scalar) -> PyObject { + match scalar { + Scalar::Float(x) => x.into_pyobject(py).unwrap().into_any().unbind(), + Scalar::String(x) => x.into_pyobject(py).unwrap().into_any().unbind(), + Scalar::Bool(x) => x.into_pyobject(py).unwrap().to_owned().into_any().unbind(), + Scalar::Null => py.None(), + } +} + pub(crate) trait PyFutureExt: Future { fn py_block_on(self, py: Python<'_>) -> Self::Output where @@ -642,7 +652,11 @@ impl View { /// # Returns /// /// A tuple of [min, max], whose types are column and aggregate dependent. - pub fn get_min_max(&self, py: Python<'_>, column_name: String) -> PyResult<(String, String)> { + pub fn get_min_max( + &self, + py: Python<'_>, + column_name: String, + ) -> PyResult<(PyObject, PyObject)> { self.0.get_min_max(column_name).py_block_on(py) } diff --git a/rust/perspective-python/src/server/generic_sql_model.rs b/rust/perspective-python/src/server/generic_sql_model.rs index 680ae5e0ab..b3d106044e 100644 --- a/rust/perspective-python/src/server/generic_sql_model.rs +++ b/rust/perspective-python/src/server/generic_sql_model.rs @@ -126,6 +126,22 @@ impl PyGenericSQLVirtualServerModel { .view_size(view_id) .map_err(|e| PyValueError::new_err(e.to_string())) } + + pub fn view_get_min_max( + &self, + view_id: &str, + column_name: &str, + config: Py, + ) -> PyResult { + let config: ViewConfig = Python::with_gil(|py| { + pythonize::depythonize(config.bind(py)) + .map_err(|e| PyValueError::new_err(e.to_string())) + })?; + + self.inner + .view_get_min_max(view_id, column_name, &config) + .map_err(|e| PyValueError::new_err(e.to_string())) + } } impl PyGenericSQLVirtualServerModel { diff --git a/rust/perspective-python/src/server/virtual_server_sync.rs b/rust/perspective-python/src/server/virtual_server_sync.rs index 55ad12aeda..4a5f6c4e15 100644 --- a/rust/perspective-python/src/server/virtual_server_sync.rs +++ b/rust/perspective-python/src/server/virtual_server_sync.rs @@ -26,6 +26,20 @@ use pyo3::types::{ use pyo3::{IntoPyObject, Py, PyAny, PyErr, PyResult, Python, pyclass, pymethods}; use serde::Serialize; +fn py_to_scalar(val: pyo3::Bound<'_, PyAny>) -> PyResult { + if val.is_none() { + Ok(perspective_client::config::Scalar::Null) + } else if let Ok(b) = val.extract::() { + Ok(perspective_client::config::Scalar::Bool(b)) + } else if let Ok(f) = val.extract::() { + Ok(perspective_client::config::Scalar::Float(f)) + } else if let Ok(s) = val.extract::() { + Ok(perspective_client::config::Scalar::String(s)) + } else { + Ok(perspective_client::config::Scalar::Null) + } +} + pub struct PyServerHandler(Py); impl VirtualServerHandler for PyServerHandler { @@ -268,6 +282,50 @@ impl VirtualServerHandler for PyServerHandler { }) } + fn view_get_min_max( + &self, + view_id: &str, + column_name: &str, + config: &perspective_client::config::ViewConfig, + ) -> VirtualServerFuture< + '_, + Result< + ( + perspective_client::config::Scalar, + perspective_client::config::Scalar, + ), + Self::Error, + >, + > { + let handler = Python::with_gil(|py| self.0.clone_ref(py)); + let view_id = view_id.to_string(); + let column_name = column_name.to_string(); + let config = config.clone(); + Box::pin(async move { + Python::with_gil(|py| { + let has_method = handler + .getattr(py, pyo3::intern!(py, "view_get_min_max")) + .is_ok(); + + if !has_method { + return Err(PyValueError::new_err("view_get_min_max not implemented")); + } + + let config_py = pythonize::pythonize(py, &config)?; + let result = handler.call_method1( + py, + pyo3::intern!(py, "view_get_min_max"), + (&view_id, &column_name, config_py), + )?; + + let tuple = result.downcast_bound::(py)?; + let min = py_to_scalar(tuple.get_item(0)?)?; + let max = py_to_scalar(tuple.get_item(1)?)?; + Ok((min, max)) + }) + }) + } + fn view_get_data( &self, view_id: &str, diff --git a/rust/perspective-server/cpp/perspective/src/cpp/server.cpp b/rust/perspective-server/cpp/perspective/src/cpp/server.cpp index 0b6ec117a2..8d9c7d9eb6 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/server.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/server.cpp @@ -47,6 +47,20 @@ #endif namespace perspective { + +static void +tscalar_to_proto(const t_tscalar& scalar, proto::Scalar* out) { + if (scalar.is_none() || !scalar.is_valid()) { + out->set_null(::google::protobuf::NullValue::NULL_VALUE); + } else if (scalar.is_str()) { + out->set_string(scalar.to_string()); + } else if (scalar.get_dtype() == DTYPE_BOOL) { + out->set_bool_(scalar.as_bool()); + } else { + out->set_float_(scalar.to_double()); + } +} + std::uint32_t server::ProtoServer::m_client_id = 1; template <> @@ -2807,14 +2821,8 @@ ProtoServer::_handle_request(std::uint32_t client_id, Request&& req) { const auto min_max = view->get_min_max(col); proto::Response resp; auto* pair = resp.mutable_view_get_min_max_resp(); - rapidjson::StringBuffer s; - rapidjson::Writer writer(s); - write_scalar(min_max.first, true, writer); - pair->set_min(s.GetString()); - rapidjson::StringBuffer s2; - rapidjson::Writer writer2(s2); - write_scalar(min_max.second, true, writer2); - pair->set_max(s2.GetString()); + tscalar_to_proto(min_max.first, pair->mutable_min()); + tscalar_to_proto(min_max.second, pair->mutable_max()); push_resp(std::move(resp)); break; } diff --git a/rust/perspective-viewer/src/rust/components/column_selector/active_column.rs b/rust/perspective-viewer/src/rust/components/column_selector/active_column.rs index d2863b7fdc..24b6377ad6 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector/active_column.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector/active_column.rs @@ -336,7 +336,6 @@ impl Component for ActiveColumn { let event_name = name.to_owned(); let dragdrop = ctx.props().dragdrop.clone(); move |event: DragEvent| { - tracing::error!("dragstart"); dragdrop.set_drag_image(&event).unwrap(); dragdrop.notify_drag_start( event_name.to_string(), diff --git a/rust/perspective-viewer/src/rust/components/number_column_style.rs b/rust/perspective-viewer/src/rust/components/number_column_style.rs index 6a2ba9b13c..f0440c9408 100644 --- a/rust/perspective-viewer/src/rust/components/number_column_style.rs +++ b/rust/perspective-viewer/src/rust/components/number_column_style.rs @@ -10,6 +10,7 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +use perspective_client::config::Scalar; use yew::prelude::*; use yew::*; @@ -72,18 +73,30 @@ impl PartialEq for NumberColumnStyleProps { } } +fn scalar_to_f64(scalar: &Scalar) -> f64 { + match scalar { + Scalar::Float(x) => *x, + Scalar::String(x) => x.parse::().unwrap_or_default(), + Scalar::Bool(x) => { + if *x { + 1.0 + } else { + 0.0 + } + }, + Scalar::Null => 0.0, + } +} + fn set_default_gradient(session: &Session, ctx: &Context) { if let Some(column_name) = ctx.props().column_name.clone() { let session = session.clone(); ctx.link().send_future(async move { let view = session.get_view().unwrap(); let min_max = view.get_min_max(column_name).await.unwrap(); - let abs_max = min_max - .0 - .parse::() - .unwrap_or_default() + let abs_max = scalar_to_f64(&min_max.0) .abs() - .max(min_max.1.parse::().unwrap_or_default().abs()); + .max(scalar_to_f64(&min_max.1).abs()); let gradient = (abs_max * 100.).round() / 100.; NumberColumnStyleMsg::DefaultGradientChanged(gradient) diff --git a/rust/perspective-viewer/src/rust/session/column_defaults_update.rs b/rust/perspective-viewer/src/rust/session/column_defaults_update.rs index 16e755d826..9388438b3b 100644 --- a/rust/perspective-viewer/src/rust/session/column_defaults_update.rs +++ b/rust/perspective-viewer/src/rust/session/column_defaults_update.rs @@ -43,7 +43,7 @@ pub impl ViewConfigUpdate { .unwrap_or(&GroupRollupMode::Rollup), ) { self.group_rollup_mode = group_rollups.first().cloned(); - tracing::error!( + tracing::debug!( "Setting plugin-advised rollup mode {:?}", self.group_rollup_mode ); diff --git a/rust/perspective-viewer/test/js/column_settings/sidebar.spec.ts b/rust/perspective-viewer/test/js/column_settings/sidebar.spec.ts index f4d2c3d9f8..2dd0eeb91f 100644 --- a/rust/perspective-viewer/test/js/column_settings/sidebar.spec.ts +++ b/rust/perspective-viewer/test/js/column_settings/sidebar.spec.ts @@ -207,14 +207,20 @@ test.describe("Column Settings Sidebar", () => { .locator(".tab-title") .getAttribute("id"); }; + expect(await selectedTab()).toBe("Attributes"); await col.activeBtn.click(); + await view.columnSettingsSidebar.container + .locator(".tab-title#Style") + .waitFor({ state: "visible" }); + await checkTab(view.columnSettingsSidebar, true, true, true); expect(await selectedTab()).toBe("Attributes"); await view.columnSettingsSidebar.attributesTab.expressionEditor.textarea.clear(); await view.columnSettingsSidebar.attributesTab.expressionEditor.textarea.type( "'new expr value'", ); + await view.columnSettingsSidebar.attributesTab.saveBtn.click(); expect(await selectedTab()).toBe("Attributes"); });