diff --git a/README.md b/README.md index 1653dcce8a..6df4065c61 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ WebAssembly, Python, and Rust. ## Examples -
editablefileduckdb
fractalmarketraycasting
evictionsnypdstreaming
covidwebcammovies
superstorecitibikeolympics
dataset
+
editablefileduckdb
fractalmarketraycasting
evictionsnypdstreaming
covidwebcammovies
superstoreolympicsdataset
## Media diff --git a/examples/blocks/examples.js b/examples/blocks/examples.js index 7ab7b2bfb1..791f365920 100644 --- a/examples/blocks/examples.js +++ b/examples/blocks/examples.js @@ -24,7 +24,6 @@ const LOCAL_EXAMPLES = [ "webcam", "movies", "superstore", - "citibike", "olympics", "dataset", ]; diff --git a/examples/blocks/server.mjs b/examples/blocks/server.mjs index 63fb9f02fe..03b11f3d24 100644 --- a/examples/blocks/server.mjs +++ b/examples/blocks/server.mjs @@ -51,6 +51,7 @@ const dev_template = (name) => ` `; const gists = [ + "duckdb", "fractal", "raycasting", "evictions", @@ -58,7 +59,6 @@ const gists = [ "covid", "movies", "superstore", - "citibike", "olympics", "editable", "file", diff --git a/examples/blocks/src/citibike/.block b/examples/blocks/src/citibike/.block deleted file mode 100644 index ad08ee86b5..0000000000 --- a/examples/blocks/src/citibike/.block +++ /dev/null @@ -1 +0,0 @@ -license: apache-2.0 \ No newline at end of file diff --git a/examples/blocks/src/citibike/README.md b/examples/blocks/src/citibike/README.md deleted file mode 100644 index bcfa9f0782..0000000000 --- a/examples/blocks/src/citibike/README.md +++ /dev/null @@ -1,13 +0,0 @@ -Demo of [Perspective](https://github.com/perspective-dev/perspective). - -A real-time map of NYC Citi Bike stations, colored by the number of bikes -available at each station, updating once per second. This example uses a -perspective `Table` with partial updates (`update()` calls with only some -columns present) to affect a _join_ of the real-time updates of bike -availability, to the immutable station reference data which is only ever udpated -once - similar to a market price monitor. - -This example connects directly to -[https://citibikenyc.com](https://citibikenyc.com) public APIs from the browser -itself, for both real-time and reference (station) data, and does not have a -server component. diff --git a/examples/blocks/src/citibike/citibike.js b/examples/blocks/src/citibike/citibike.js deleted file mode 100644 index 0b8a236c67..0000000000 --- a/examples/blocks/src/citibike/citibike.js +++ /dev/null @@ -1,95 +0,0 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ 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 "/node_modules/@perspective-dev/viewer/dist/cdn/perspective-viewer.js"; -import "/node_modules/@perspective-dev/workspace/dist/cdn/perspective-workspace.js"; -import "/node_modules/@perspective-dev/viewer-datagrid/dist/cdn/perspective-viewer-datagrid.js"; -import "/node_modules/@perspective-dev/viewer-d3fc/dist/cdn/perspective-viewer-d3fc.js"; -import "/node_modules/@perspective-dev/viewer-openlayers/dist/cdn/perspective-viewer-openlayers.js"; - -import perspective from "/node_modules/@perspective-dev/client/dist/cdn/perspective.js"; - -// Quick wrapper function for making a GET call. -function get(url) { - return new Promise((resolve) => { - const xhr = new XMLHttpRequest(); - xhr.open("GET", url, true); - xhr.responseType = "json"; - xhr.onload = () => resolve(xhr.response); - xhr.send(null); - }); -} - -// Fetch feed data from NYC Citibike, if a callback is provided do it again every 1s asynchronously. -async function get_feed(feedname, callback) { - const url = `https://gbfs.citibikenyc.com/gbfs/en/${feedname}.json`; - const { - data: { stations }, - ttl, - } = await get(url); - if (typeof callback === "function") { - callback(stations); - setTimeout(() => get_feed(feedname, callback), ttl * 1000); - } else { - return stations; - } -} - -// Create a new Perspective WebWorker instance. -const worker = await perspective.worker(); - -// Use Perspective WebWorker's table to infer the feed's schema. -async function get_schema(feed) { - const feed2 = feed.slice(0, 1); - delete feed2[0]["rental_methods"]; - delete feed2[0]["rental_uris"]; - delete feed2[0]["eightd_station_services"]; - const table = await worker.table(feed2); - const schema = await table.schema(); - await table.delete(); - return schema; -} - -// Create a superset of the schemas defined by the feeds. -async function merge_schemas(feeds) { - const schemas = await Promise.all(feeds.map(get_schema)); - return Object.assign({}, ...schemas); -} - -async function get_layout() { - const req = await fetch("layout.json"); - const json = await req.json(); - return json; -} - -async function main() { - const feednames = ["station_status", "station_information"]; - const feeds = await Promise.all(feednames.map(get_feed)); - const schema = await merge_schemas(feeds); - - // Creating a table by joining feeds with an index - const table = await worker.table(schema, { index: "station_id" }); - - // Load the `table` in the `` DOM reference with the initial `feeds`. - for (let feed of feeds) { - table.update(feed); - } - - // Start a recurring asyn call to `get_feed` and update the `table` with the response. - get_feed("station_status", table.update.bind(table)); - - window.workspace.tables.set("citibike", Promise.resolve(table)); - const layout = await get_layout(); - window.workspace.restore(layout); -} - -main(); diff --git a/examples/blocks/src/citibike/index.html b/examples/blocks/src/citibike/index.html deleted file mode 100644 index fd09d5fc4b..0000000000 --- a/examples/blocks/src/citibike/index.html +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - diff --git a/examples/blocks/src/citibike/layout.json b/examples/blocks/src/citibike/layout.json deleted file mode 100644 index 1efb9884f5..0000000000 --- a/examples/blocks/src/citibike/layout.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "sizes": [1], - "detail": { - "main": { - "type": "split-area", - "orientation": "horizontal", - "children": [ - { - "type": "tab-area", - "widgets": ["PERSPECTIVE_GENERATED_ID_0"], - "currentIndex": 0 - }, - { - "type": "tab-area", - "widgets": ["PERSPECTIVE_GENERATED_ID_1"], - "currentIndex": 0 - } - ], - "sizes": [0.8, 0.2] - } - }, - "mode": "globalFilters", - "viewers": { - "PERSPECTIVE_GENERATED_ID_0": { - "plugin": "Map Scatter", - "plugin_config": { - "center": [-8235869.593541528, 4976703.012901576], - "zoom": 10 - }, - "table": "citibike", - "settings": false, - "group_by": [], - "split_by": [], - "columns": ["lon", "lat", "num_bikes_available", null, null], - "filter": [["lon", "!=", 0.0]], - "sort": [["num_bikes_available", "asc"]], - "expressions": {}, - "aggregates": {} - }, - "PERSPECTIVE_GENERATED_ID_1": { - "plugin": "Datagrid", - "table": "citibike", - "plugin_config": { - "columns": { - "\"num_bikes_available\" / \"capacity\"": { - "fg_gradient": 1, - "number_fg_mode": "bar" - }, - "Available (%)": { - "fg_gradient": 1, - "number_fg_mode": "bar" - }, - "num_bikes_available": { - "fg_gradient": 97, - "number_fg_mode": "bar" - } - }, - "editable": false, - "scroll_lock": true - }, - "settings": false, - "theme": "Pro Light", - "group_by": [], - "split_by": [], - "columns": ["Available (%)", "name"], - "filter": [], - "sort": [["last_reported", "desc"]], - "expressions": { - "Available (%)": "\"num_bikes_available\" / \"capacity\"" - }, - "aggregates": {} - } - } -} diff --git a/examples/blocks/src/citibike/preview.png b/examples/blocks/src/citibike/preview.png deleted file mode 100644 index 4b0944cdf1..0000000000 Binary files a/examples/blocks/src/citibike/preview.png and /dev/null differ diff --git a/examples/blocks/src/citibike/thumbnail.png b/examples/blocks/src/citibike/thumbnail.png deleted file mode 100644 index 15c3b5c339..0000000000 Binary files a/examples/blocks/src/citibike/thumbnail.png and /dev/null differ diff --git a/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts b/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts index 6b4a219fc5..4a1e0913f1 100644 --- a/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts +++ b/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts @@ -134,7 +134,7 @@ export class HTMLPerspectiveViewerDatagridPluginElement } get group_rollups(): string[] { - return ["rollup", "flat"]; + return ["rollup", "flat", "total"]; } /** diff --git a/rust/perspective-client/perspective.proto b/rust/perspective-client/perspective.proto index fbdf8a1b39..79d826d5c1 100644 --- a/rust/perspective-client/perspective.proto +++ b/rust/perspective-client/perspective.proto @@ -207,6 +207,13 @@ message Response { // // Virtual API + +enum GroupRollupMode { + ROLLUP = 0; + FLAT = 1; + TOTAL = 2; +} + // Informs the client of the feature set, e.g. what to expect in the // `ViewConfig` message. message GetFeaturesReq {} @@ -218,6 +225,7 @@ message GetFeaturesResp { bool sort = 5; map filter_ops = 6; map aggregates = 7; + repeated GroupRollupMode group_rollup_mode = 8; message ColumnTypeOptions { repeated string options = 1; @@ -233,7 +241,6 @@ message GetFeaturesResp { } } - // `Client::get_hosted_tables` message GetHostedTablesReq { bool subscribe = 1; @@ -540,12 +547,6 @@ message ViewConfig { AND = 0; OR = 1; } - - enum GroupRollupMode { - ROLLUP = 0; - FLAT = 1; - // TOTAL = 2; - } } message ColumnsUpdate { diff --git a/rust/perspective-client/src/rust/client.rs b/rust/perspective-client/src/rust/client.rs index 706fcbf6c3..53a443ffa1 100644 --- a/rust/perspective-client/src/rust/client.rs +++ b/rust/perspective-client/src/rust/client.rs @@ -91,6 +91,19 @@ impl SystemInfo { #[derive(Clone, Debug, Default)] pub struct Features(Arc); +impl Features { + pub fn get_group_rollup_modes(&self) -> Vec { + self.group_rollup_mode + .iter() + .map(|x| { + crate::config::GroupRollupMode::from( + crate::proto::GroupRollupMode::try_from(*x).unwrap(), + ) + }) + .collect::>() + } +} + impl Deref for Features { type Target = GetFeaturesResp; diff --git a/rust/perspective-client/src/rust/config/view_config.rs b/rust/perspective-client/src/rust/config/view_config.rs index 43a2d9c391..ee2a41a17f 100644 --- a/rust/perspective-client/src/rust/config/view_config.rs +++ b/rust/perspective-client/src/rust/config/view_config.rs @@ -31,8 +31,9 @@ pub enum GroupRollupMode { #[serde(rename = "flat")] Flat, - // #[serde(rename = "total")] - // Total, + + #[serde(rename = "total")] + Total, } impl Display for GroupRollupMode { @@ -40,27 +41,27 @@ impl Display for GroupRollupMode { write!(fmt, "{}", match self { Self::Rollup => "Rollup", Self::Flat => "Flat", - // Self::Total => "Total", + Self::Total => "Total", }) } } -impl From for GroupRollupMode { - fn from(value: proto::view_config::GroupRollupMode) -> Self { +impl From for GroupRollupMode { + fn from(value: proto::GroupRollupMode) -> Self { match value { - proto::view_config::GroupRollupMode::Rollup => Self::Rollup, - proto::view_config::GroupRollupMode::Flat => Self::Flat, - // proto::view_config::GroupRollupMode::Total => Self::Total, + proto::GroupRollupMode::Rollup => Self::Rollup, + proto::GroupRollupMode::Flat => Self::Flat, + proto::GroupRollupMode::Total => Self::Total, } } } -impl From for proto::view_config::GroupRollupMode { +impl From for proto::GroupRollupMode { fn from(value: GroupRollupMode) -> Self { match value { - GroupRollupMode::Rollup => proto::view_config::GroupRollupMode::Rollup, - GroupRollupMode::Flat => proto::view_config::GroupRollupMode::Flat, - // GroupRollupMode::Total => proto::view_config::GroupRollupMode::Total, + GroupRollupMode::Rollup => proto::GroupRollupMode::Rollup, + GroupRollupMode::Flat => proto::GroupRollupMode::Flat, + GroupRollupMode::Total => proto::GroupRollupMode::Total, } } } @@ -256,7 +257,7 @@ impl From for proto::ViewConfig { group_by_depth: value.group_by_depth, group_rollup_mode: value .group_rollup_mode - .map(|x| proto::view_config::GroupRollupMode::from(x).into()), + .map(|x| proto::GroupRollupMode::from(x).into()), } } } @@ -323,7 +324,7 @@ impl From for ViewConfig { group_by_depth: value.group_by_depth, group_rollup_mode: value .group_rollup_mode - .map(proto::view_config::GroupRollupMode::try_from) + .map(proto::GroupRollupMode::try_from) .and_then(|x| x.ok()) .map(|x| x.into()) .unwrap_or_default(), @@ -377,7 +378,7 @@ impl From for ViewConfigUpdate { group_by_depth: value.group_by_depth, group_rollup_mode: value .group_rollup_mode - .and_then(|x| proto::view_config::GroupRollupMode::try_from(x).ok()) + .and_then(|x| proto::GroupRollupMode::try_from(x).ok()) .map(|x| x.into()), } } @@ -404,8 +405,28 @@ impl ViewConfig { /// Apply `ViewConfigUpdate` to a `ViewConfig`, ignoring any fields in /// `update` which were unset. - pub fn apply_update(&mut self, update: ViewConfigUpdate) -> bool { + pub fn apply_update(&mut self, mut update: ViewConfigUpdate) -> bool { let mut changed = false; + if ((self.group_rollup_mode == GroupRollupMode::Total + && update.group_rollup_mode.is_none()) + || update.group_rollup_mode == Some(GroupRollupMode::Total)) + && update + .group_by + .as_ref() + .map(|x| !x.is_empty()) + .unwrap_or_default() + { + tracing::warn!("`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`"); + changed = true; + update.group_by = Some(vec![]); + } + changed = Self::_apply(&mut self.group_by, update.group_by) || changed; changed = Self::_apply(&mut self.split_by, update.split_by) || changed; changed = Self::_apply(&mut self.columns, update.columns) || changed; @@ -414,11 +435,17 @@ impl ViewConfig { changed = Self::_apply(&mut self.aggregates, update.aggregates) || changed; 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`"); + changed = true; + self.group_by = vec![]; + } + changed } pub fn is_aggregated(&self) -> bool { - !self.group_by.is_empty() + !self.group_by.is_empty() || self.group_rollup_mode == GroupRollupMode::Total } pub fn is_column_expression_in_use(&self, name: &str) -> bool { diff --git a/rust/perspective-client/src/rust/virtual_server/data.rs b/rust/perspective-client/src/rust/virtual_server/data.rs index 45620b9683..770ef88f60 100644 --- a/rust/perspective-client/src/rust/virtual_server/data.rs +++ b/rust/perspective-client/src/rust/virtual_server/data.rs @@ -204,7 +204,7 @@ impl VirtualDataSlice { if name.starts_with("__ROW_PATH_") { let group_by_index: u32 = name[11..name.len() - 2].parse()?; let max_grouping_id = 2_i32.pow((self.0.group_by.len() as u32) - group_by_index) - 1; - if grouping_id.map(|x| x as i32).unwrap_or(i32::MAX) < max_grouping_id { + if grouping_id.map(|x| x as i32).unwrap_or(0) < max_grouping_id { if !self.contains_key("__ROW_PATH__") { self.insert( "__ROW_PATH__".to_owned(), diff --git a/rust/perspective-client/src/rust/virtual_server/features.rs b/rust/perspective-client/src/rust/virtual_server/features.rs index e17141299e..97e1e952ad 100644 --- a/rust/perspective-client/src/rust/virtual_server/features.rs +++ b/rust/perspective-client/src/rust/virtual_server/features.rs @@ -15,6 +15,7 @@ use std::borrow::Cow; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; +use crate::config::GroupRollupMode; use crate::proto::get_features_resp::{AggregateArgs, AggregateOptions, ColumnTypeOptions}; use crate::proto::{ColumnType, GetFeaturesResp}; @@ -29,6 +30,10 @@ pub struct Features<'a> { #[serde(default)] pub group_by: bool, + /// Which `group_by_rollup_mode` options are supported + #[serde(default)] + pub group_rollup_mode: Vec, + /// Whether split-by (pivot) operations are supported. #[serde(default)] pub split_by: bool, @@ -71,6 +76,11 @@ impl<'a> From> for GetFeaturesResp { fn from(value: Features<'a>) -> GetFeaturesResp { GetFeaturesResp { group_by: value.group_by, + group_rollup_mode: value + .group_rollup_mode + .iter() + .map(|x| crate::proto::GroupRollupMode::from(*x) as i32) + .collect(), split_by: value.split_by, expressions: value.expressions, on_update: value.on_update, 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 286df3075b..caa2c0de37 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 @@ -43,7 +43,7 @@ use std::fmt; use indexmap::IndexMap; use serde::Deserialize; -use crate::config::{FilterTerm, Scalar, Sort, SortDir, ViewConfig}; +use crate::config::{FilterTerm, GroupRollupMode, Scalar, Sort, SortDir, ViewConfig}; use crate::proto::{ColumnType, ViewPort}; use crate::virtual_server::generic_sql_model::table_make_view::ViewQueryContext; @@ -242,7 +242,9 @@ impl GenericSQLVirtualServerModel { let mut group_by_cols: Vec = Vec::new(); if !group_by.is_empty() { - group_by_cols.push("\"__GROUPING_ID__\"".to_string()); + if config.group_rollup_mode != GroupRollupMode::Flat { + group_by_cols.push("\"__GROUPING_ID__\"".to_string()); + } for idx in 0..group_by.len() { group_by_cols.push(format!("\"__ROW_PATH_{}__\"", idx)); } 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 6a416ffe3c..208eb571ea 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 @@ -10,7 +10,7 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -use crate::config::{Aggregate, Sort, SortDir, ViewConfig}; +use crate::config::{Aggregate, GroupRollupMode, Sort, SortDir, ViewConfig}; fn aggregate_to_string(agg: &Aggregate) -> String { match agg { @@ -35,10 +35,23 @@ fn is_col_sort(dir: &SortDir) -> bool { } enum QueryOrientation { + /// Default Flat, + + /// `group_by` set Grouped, + + /// `split_by` set Pivoted, + + /// `group_by` and `split_by` set GroupedAndPivoted, + + /// `total` set + Total, + + /// `total` and `split_by` + TotalPivoted, } /// Precomputed context for building a SQL view query from a [`ViewConfig`]. @@ -105,14 +118,24 @@ impl<'a> ViewQueryContext<'a> { QueryOrientation::Grouped => { let mut clauses = self.select_clauses(); clauses.extend(self.row_path_select_clauses()); - clauses.push(self.grouping_id_clause()); - format!( - "SELECT {} FROM {}{} GROUP BY ROLLUP({})", - clauses.join(", "), - self.table, - where_sql, - self.group_col_names.join(", ") - ) + if self.is_flat_mode() { + format!( + "SELECT {} FROM {}{} GROUP BY {}", + clauses.join(", "), + self.table, + where_sql, + self.group_col_names.join(", ") + ) + } else { + clauses.push(self.grouping_id_clause()); + format!( + "SELECT {} FROM {}{} GROUP BY ROLLUP({})", + clauses.join(", "), + self.table, + where_sql, + self.group_col_names.join(", ") + ) + } }, QueryOrientation::Pivoted => { let select = self.select_clauses(); @@ -135,12 +158,13 @@ impl<'a> ViewQueryContext<'a> { .collect::>() .join(", "); + let row_num_order = self.pivot_row_num_order(); format!( "SELECT * EXCLUDE (__ROW_NUM__) FROM (PIVOT (SELECT {}, {}, ROW_NUMBER() OVER \ - (ORDER BY rowid) as __ROW_NUM__ FROM {}{}) ON {} USING {} GROUP BY \ - __ROW_NUM__)", + (ORDER BY {}) as __ROW_NUM__ FROM {}{}) ON {} USING {} GROUP BY __ROW_NUM__)", select.join(", "), split_cols, + row_num_order, self.table, where_sql, self.pivot_on_expr(), @@ -152,7 +176,9 @@ impl<'a> ViewQueryContext<'a> { let split_cols_joined = self.pivot_on_expr(); let mut inner_clauses = self.select_clauses(); inner_clauses.extend(self.row_path_select_clauses()); - inner_clauses.push(self.grouping_id_clause()); + if !self.is_flat_mode() { + inner_clauses.push(self.grouping_id_clause()); + } for sb_col in &self.config.split_by { inner_clauses.push(self.col_name(sb_col)); } @@ -160,30 +186,53 @@ impl<'a> ViewQueryContext<'a> { for (sidx, Sort(sort_col, sort_dir)) in self.config.sort.iter().enumerate() { if *sort_dir != SortDir::None && !is_col_sort(sort_dir) { let agg = self.get_aggregate(sort_col); - inner_clauses.push(format!( - "sum({}({})) OVER (PARTITION BY {}({}), {}) AS __SORT_{}__", - agg, - self.col_name(sort_col), - self.grouping_fn, - groups_joined, - groups_joined, - sidx, - )); + if self.is_flat_mode() { + inner_clauses.push(format!( + "sum({}({})) OVER (PARTITION BY {}) AS __SORT_{}__", + agg, + self.col_name(sort_col), + groups_joined, + sidx, + )); + } else { + inner_clauses.push(format!( + "sum({}({})) OVER (PARTITION BY {}({}), {}) AS __SORT_{}__", + agg, + self.col_name(sort_col), + self.grouping_fn, + groups_joined, + groups_joined, + sidx, + )); + } } } - let inner_query = format!( - "SELECT {} FROM {}{} GROUP BY ROLLUP({}), {}", - inner_clauses.join(", "), - self.table, - where_sql, - groups_joined, - split_cols_joined, - ); + let inner_query = if self.is_flat_mode() { + format!( + "SELECT {} FROM {}{} GROUP BY {}, {}", + inner_clauses.join(", "), + self.table, + where_sql, + groups_joined, + split_cols_joined, + ) + } else { + format!( + "SELECT {} FROM {}{} GROUP BY ROLLUP({}), {}", + inner_clauses.join(", "), + self.table, + where_sql, + groups_joined, + split_cols_joined, + ) + }; let pivot_using = self.select_clauses().join(", "); let mut row_id_cols = self.row_path_aliases.clone(); - row_id_cols.push("__GROUPING_ID__".to_string()); + if !self.is_flat_mode() { + row_id_cols.push("__GROUPING_ID__".to_string()); + } for (sidx, Sort(_, sort_dir)) in self.config.sort.iter().enumerate() { if *sort_dir != SortDir::None && !is_col_sort(sort_dir) { row_id_cols.push(format!("__SORT_{}__", sidx)); @@ -198,6 +247,49 @@ impl<'a> ViewQueryContext<'a> { row_id_cols.join(", ") ) }, + QueryOrientation::Total => { + let select = self.select_clauses().join(", "); + format!("SELECT {} FROM {}{}", select, self.table, where_sql) + }, + QueryOrientation::TotalPivoted => { + let raw_cols: Vec = self + .config + .columns + .iter() + .flatten() + .map(|col| self.col_name(col)) + .collect(); + + let split_cols: String = self + .config + .split_by + .iter() + .map(|c| format!("\"{}\"", c)) + .collect::>() + .join(", "); + + let pivot_using: Vec = self + .config + .columns + .iter() + .flatten() + .map(|col| { + let agg = self.get_aggregate(col); + let escaped = col.replace('"', "\"\"").replace('_', "-"); + format!("{}(\"{}\") as \"{}\"", agg, escaped, escaped) + }) + .collect(); + + format!( + "SELECT * FROM (PIVOT (SELECT {}, {} FROM {}{}) ON {} USING {})", + raw_cols.join(", "), + split_cols, + self.table, + where_sql, + self.pivot_on_expr(), + pivot_using.join(", "), + ) + }, }; if !windows.is_empty() { @@ -206,7 +298,16 @@ impl<'a> ViewQueryContext<'a> { if !order_by.is_empty() { query = format!("{} ORDER BY {}", query, order_by.join(", ")); - } else if self.config.group_by.is_empty() { + } else if self.is_flat_mode() && !self.config.group_by.is_empty() { + let default_order: Vec = self + .row_path_aliases + .iter() + .map(|alias| format!("{} ASC", alias)) + .collect(); + query = format!("{} ORDER BY {}", query, default_order.join(", ")); + } else if self.config.group_by.is_empty() + && self.config.group_rollup_mode != GroupRollupMode::Total + { let default_order = if self.config.split_by.is_empty() { "rowid" } else { @@ -219,7 +320,23 @@ impl<'a> ViewQueryContext<'a> { query } + fn is_flat_mode(&self) -> bool { + self.config.group_rollup_mode == GroupRollupMode::Flat + } + + fn needs_aggregation(&self) -> bool { + !self.config.group_by.is_empty() || self.config.group_rollup_mode == GroupRollupMode::Total + } + fn query_orientation(&self) -> QueryOrientation { + if self.config.group_rollup_mode == GroupRollupMode::Total { + return if self.config.split_by.is_empty() { + QueryOrientation::Total + } else { + QueryOrientation::TotalPivoted + }; + } + match ( self.config.group_by.is_empty(), self.config.split_by.is_empty(), @@ -250,7 +367,7 @@ impl<'a> ViewQueryContext<'a> { fn select_clauses(&self) -> Vec { let mut clauses = Vec::new(); - if !self.config.group_by.is_empty() { + if self.needs_aggregation() { for col in self.config.columns.iter().flatten() { let agg = self.get_aggregate(col); let escaped = col.replace('"', "\"\"").replace("_", "-"); @@ -290,6 +407,25 @@ impl<'a> ViewQueryContext<'a> { } } + /// Builds the `ORDER BY` expression for the `ROW_NUMBER()` window + /// function used inside `PIVOT` queries. Uses sort config if available, + /// otherwise falls back to `rowid`. + fn pivot_row_num_order(&self) -> String { + let sort_exprs: Vec = self + .config + .sort + .iter() + .filter(|Sort(_, dir)| *dir != SortDir::None && !is_col_sort(dir)) + .map(|Sort(col, dir)| format!("{} {}", self.col_name(col), sort_dir_to_string(dir))) + .collect(); + + if sort_exprs.is_empty() { + "rowid".to_string() + } else { + sort_exprs.join(", ") + } + } + fn pivot_on_expr(&self) -> String { self.config .split_by @@ -318,7 +454,19 @@ impl<'a> ViewQueryContext<'a> { fn order_by_clauses(&self) -> Vec { let mut clauses = Vec::new(); - if !self.config.group_by.is_empty() { + 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)); + } + } + } + } else if !self.config.group_by.is_empty() { for gidx in 0..self.config.group_by.len() { if !self.config.split_by.is_empty() { let shift = self.config.group_by.len() - 1 - gidx; @@ -370,7 +518,7 @@ impl<'a> ViewQueryContext<'a> { clauses.push(format!("{} ASC", self.row_path_aliases[gidx])); } - } else { + } else if self.config.split_by.is_empty() { for Sort(sort_col, sort_dir) in &self.config.sort { if *sort_dir != SortDir::None && !is_col_sort(sort_dir) { let dir = sort_dir_to_string(sort_dir); @@ -383,7 +531,7 @@ impl<'a> ViewQueryContext<'a> { } fn window_clauses(&self) -> Vec { - if self.config.sort.is_empty() || self.config.group_by.len() <= 1 { + if self.is_flat_mode() || self.config.sort.is_empty() || self.config.group_by.len() <= 1 { return Vec::new(); } diff --git a/rust/perspective-client/src/rust/virtual_server/generic_sql_model/tests.rs b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/tests.rs index 8184396f0e..e5c2057a8e 100644 --- a/rust/perspective-client/src/rust/virtual_server/generic_sql_model/tests.rs +++ b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/tests.rs @@ -13,7 +13,7 @@ use std::collections::HashMap; use super::*; -use crate::config::Aggregate; +use crate::config::{Aggregate, GroupRollupMode}; #[test] fn test_get_hosted_tables() { @@ -277,6 +277,30 @@ fn test_table_make_view_mixed_row_and_col_sort() { ); } +#[test] +fn test_table_make_view_pivoted_with_sort() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("value".to_string())]; + config.split_by = vec!["quarter".to_string()]; + config.sort = vec![Sort("value".to_string(), SortDir::Desc)]; + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!(sql.contains("PIVOT"), "expected PIVOT: {}", sql); + assert!( + sql.contains("ROW_NUMBER() OVER (ORDER BY \"value\" DESC)"), + "expected sort in ROW_NUMBER window: {}", + sql + ); + assert!( + sql.ends_with("ORDER BY __ROW_NUM__)"), + "should end with ORDER BY __ROW_NUM__: {}", + sql + ); +} + #[test] fn test_view_get_data_col_sort_ascending() { let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); @@ -359,3 +383,234 @@ fn test_view_get_data() { assert!(sql.contains("FROM my_view")); assert!(sql.contains("LIMIT 100 OFFSET 0")); } + +#[test] +fn test_table_make_view_flat_group_by() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("value".to_string())]; + config.group_by = vec!["category".to_string()]; + config.group_rollup_mode = GroupRollupMode::Flat; + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!( + sql.contains("GROUP BY \"category\""), + "expected plain GROUP BY: {}", + sql + ); + assert!( + !sql.contains("ROLLUP"), + "should not contain ROLLUP: {}", + sql + ); + assert!( + !sql.contains("__GROUPING_ID__"), + "should not contain __GROUPING_ID__: {}", + sql + ); + assert!( + sql.contains("__ROW_PATH_0__"), + "should contain __ROW_PATH_0__: {}", + sql + ); +} + +#[test] +fn test_table_make_view_flat_group_by_with_split_by() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("value".to_string())]; + config.group_by = vec!["category".to_string()]; + config.split_by = vec!["quarter".to_string()]; + config.group_rollup_mode = GroupRollupMode::Flat; + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!(sql.contains("PIVOT"), "expected PIVOT: {}", sql); + assert!( + !sql.contains("ROLLUP"), + "should not contain ROLLUP: {}", + sql + ); + assert!( + !sql.contains("__GROUPING_ID__"), + "should not contain __GROUPING_ID__: {}", + sql + ); + assert!( + sql.contains("__ROW_PATH_0__"), + "should contain __ROW_PATH_0__: {}", + sql + ); +} + +#[test] +fn test_table_make_view_flat_group_by_with_sort() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("value".to_string())]; + config.group_by = vec!["category".to_string()]; + config.sort = vec![Sort("value".to_string(), SortDir::Asc)]; + config.aggregates = HashMap::from([( + "value".to_string(), + Aggregate::SingleAggregate("sum".to_string()), + )]); + config.group_rollup_mode = GroupRollupMode::Flat; + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!( + sql.contains("sum(\"value\") ASC"), + "expected direct aggregate in ORDER BY: {}", + sql + ); + assert!( + !sql.contains("ROLLUP"), + "should not contain ROLLUP: {}", + sql + ); + assert!( + !sql.contains("__WINDOW_"), + "should not contain WINDOW clauses: {}", + sql + ); +} + +#[test] +fn test_table_make_view_flat_group_by_with_split_by_and_sort() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("value".to_string())]; + config.group_by = vec!["category".to_string()]; + config.split_by = vec!["quarter".to_string()]; + config.sort = vec![Sort("value".to_string(), SortDir::Desc)]; + config.aggregates = HashMap::from([( + "value".to_string(), + Aggregate::SingleAggregate("sum".to_string()), + )]); + config.group_rollup_mode = GroupRollupMode::Flat; + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!(sql.contains("PIVOT"), "expected PIVOT: {}", sql); + assert!( + !sql.contains("ROLLUP"), + "should not contain ROLLUP: {}", + sql + ); + assert!( + !sql.contains("__GROUPING_ID__"), + "should not contain __GROUPING_ID__: {}", + sql + ); + assert!( + sql.contains("__SORT_0__"), + "expected __SORT_0__ for flat+pivoted+sort: {}", + sql + ); + assert!( + sql.contains("__SORT_0__ DESC"), + "expected __SORT_0__ DESC in ORDER BY: {}", + sql + ); + assert!( + !sql.contains("sum(\"value\") DESC"), + "should not have raw aggregate in ORDER BY: {}", + sql + ); +} + +#[test] +fn test_view_get_data_flat_no_grouping_id() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.group_by = vec!["category".to_string()]; + config.group_rollup_mode = GroupRollupMode::Flat; + let viewport = ViewPort { + start_row: Some(0), + end_row: Some(100), + start_col: Some(0), + end_col: None, + }; + + let mut schema = IndexMap::new(); + schema.insert("value".to_string(), ColumnType::Float); + let sql = builder + .view_get_data("my_view", &config, &viewport, &schema) + .unwrap(); + + assert!( + !sql.contains("__GROUPING_ID__"), + "flat mode should not select __GROUPING_ID__: {}", + sql + ); + assert!( + sql.contains("__ROW_PATH_0__"), + "flat mode should still select __ROW_PATH_0__: {}", + sql + ); +} + +#[test] +fn test_table_make_view_total() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("value".to_string())]; + config.group_rollup_mode = GroupRollupMode::Total; + config.aggregates = HashMap::from([( + "value".to_string(), + Aggregate::SingleAggregate("sum".to_string()), + )]); + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!( + sql.contains("sum(\"value\")"), + "expected aggregate function: {}", + sql + ); + assert!( + !sql.contains("GROUP BY"), + "should not contain GROUP BY: {}", + sql + ); + assert!( + !sql.contains("ORDER BY"), + "should not contain ORDER BY: {}", + sql + ); +} + +#[test] +fn test_table_make_view_total_with_split_by() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("value".to_string())]; + config.split_by = vec!["quarter".to_string()]; + config.group_rollup_mode = GroupRollupMode::Total; + config.aggregates = HashMap::from([( + "value".to_string(), + Aggregate::SingleAggregate("sum".to_string()), + )]); + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!(sql.contains("PIVOT"), "expected PIVOT: {}", sql); + assert!( + !sql.contains("GROUP BY"), + "should not contain GROUP BY: {}", + sql + ); + assert!( + !sql.contains("ROW_NUMBER"), + "should not contain ROW_NUMBER: {}", + sql + ); +} diff --git a/rust/perspective-js/src/ts/virtual_servers/clickhouse.ts b/rust/perspective-js/src/ts/virtual_servers/clickhouse.ts index 57ebc0f368..244da475bc 100644 --- a/rust/perspective-js/src/ts/virtual_servers/clickhouse.ts +++ b/rust/perspective-js/src/ts/virtual_servers/clickhouse.ts @@ -223,6 +223,7 @@ export class ClickhouseHandler implements perspective.VirtualServerHandler { split_by: false, sort: true, expressions: true, + group_rollup_mode: ["rollup", "flat", "total"], filter_ops: { integer: FILTER_OPS, float: FILTER_OPS, diff --git a/rust/perspective-js/src/ts/virtual_servers/duckdb.ts b/rust/perspective-js/src/ts/virtual_servers/duckdb.ts index 877c892037..0465b8b3df 100644 --- a/rust/perspective-js/src/ts/virtual_servers/duckdb.ts +++ b/rust/perspective-js/src/ts/virtual_servers/duckdb.ts @@ -208,6 +208,7 @@ export class DuckDBHandler implements perspective.VirtualServerHandler { split_by: true, sort: true, expressions: true, + group_rollup_mode: ["rollup", "flat", "total"], filter_ops: { integer: FILTER_OPS, float: FILTER_OPS, @@ -258,7 +259,8 @@ export class DuckDBHandler implements perspective.VirtualServerHandler { const results = await runQuery(this.db, query); const count = Number(Object.values(results[0].toJSON())[0]); const gs = config.group_by?.length || 0; - return count - (gs === 0 ? 0 : gs + 1); + const is_flat = config.group_rollup_mode === "flat"; + return count - (gs === 0 ? 0 : is_flat ? gs : gs + 1); } async tableSize(tableId: string) { @@ -297,6 +299,8 @@ export class DuckDBHandler implements perspective.VirtualServerHandler { ) { const is_group_by = config.group_by?.length > 0; const is_split_by = config.split_by?.length > 0; + const is_flat = config.group_rollup_mode === "flat"; + const has_grouping_id = is_group_by && !is_flat; const query = this.sqlBuilder.viewGetData( viewId, config, @@ -309,7 +313,7 @@ export class DuckDBHandler implements perspective.VirtualServerHandler { }); for (let cidx = 0; cidx < columns.length; cidx++) { - if (cidx === 0 && is_group_by) { + if (cidx === 0 && has_grouping_id) { // This is the grouping_id column, skip it continue; } @@ -323,7 +327,9 @@ export class DuckDBHandler implements perspective.VirtualServerHandler { const isDecimal = dtypes[cidx].startsWith("Decimal"); for (let ridx = 0; ridx < rows.length; ridx++) { const rowArray = rows[ridx].toArray(); - const grouping_id = Number(rowArray[0]); + const grouping_id = has_grouping_id + ? Number(rowArray[0]) + : undefined; let value = rowArray[cidx]; if (isDecimal) { value = convertDecimalToNumber(value, dtypes[cidx]); diff --git a/rust/perspective-js/test/js/duckdb.spec.js b/rust/perspective-js/test/js/duckdb.spec.js deleted file mode 100644 index f8bec2edcb..0000000000 --- a/rust/perspective-js/test/js/duckdb.spec.js +++ /dev/null @@ -1,1079 +0,0 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ 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 * as fs from "fs"; -import * as path from "path"; -import { createRequire } from "module"; - -import * as duckdb from "@duckdb/duckdb-wasm"; - -import { test, expect } from "@perspective-dev/test"; -import { - default as perspective, - createMessageHandler, - wasmModule, -} from "@perspective-dev/client"; -import { DuckDBHandler } from "@perspective-dev/client/src/ts/virtual_servers/duckdb.ts"; - -const require = createRequire(import.meta.url); -const DUCKDB_DIST = path.dirname(require.resolve("@duckdb/duckdb-wasm")); -const Worker = require("web-worker"); - -async function initializeDuckDB() { - const bundle = await duckdb.selectBundle({ - mvp: { - mainModule: path.resolve(DUCKDB_DIST, "./duckdb-mvp.wasm"), - mainWorker: path.resolve( - DUCKDB_DIST, - "./duckdb-node-mvp.worker.cjs", - ), - }, - eh: { - mainModule: path.resolve(DUCKDB_DIST, "./duckdb-eh.wasm"), - mainWorker: path.resolve( - DUCKDB_DIST, - "./duckdb-node-eh.worker.cjs", - ), - }, - }); - - const logger = new duckdb.ConsoleLogger(); - const worker = new Worker(bundle.mainWorker); - const db = new duckdb.AsyncDuckDB(logger, worker); - await db.instantiate(bundle.mainModule, bundle.pthreadWorker); - const c = await db.connect(); - await c.query(` - SET default_null_order=NULLS_FIRST_ON_ASC_LAST_ON_DESC; - `); - - return c; -} - -async function loadSuperstoreData(db) { - const arrowPath = path.resolve( - import.meta.dirname, - "../../node_modules/superstore-arrow/superstore.lz4.arrow", - ); - - const arrayBuffer = fs.readFileSync(arrowPath); - await db.insertArrowFromIPCStream(new Uint8Array(arrayBuffer), { - name: "superstore", - create: true, - }); -} - -test.describe("DuckDB Virtual Server", function () { - let db; - let client; - - test.beforeAll(async () => { - db = await initializeDuckDB(); - const server = createMessageHandler(new DuckDBHandler(db, wasmModule)); - client = await perspective.worker(server); - await loadSuperstoreData(db); - }); - - test.describe("client", () => { - test("get_hosted_table_names()", async function () { - const tables = await client.get_hosted_table_names(); - expect(tables).toEqual(["memory.superstore"]); - }); - }); - - test.describe("table", () => { - test("schema()", async function () { - const table = await client.open_table("memory.superstore"); - const schema = await table.schema(); - expect(schema).toEqual({ - "Product Name": "string", - "Ship Date": "date", - City: "string", - "Row ID": "integer", - "Customer Name": "string", - Quantity: "integer", - Discount: "float", - "Sub-Category": "string", - Segment: "string", - Category: "string", - "Order Date": "date", - "Order ID": "string", - Sales: "float", - State: "string", - "Postal Code": "float", - Country: "string", - "Customer ID": "string", - "Ship Mode": "string", - Region: "string", - Profit: "float", - "Product ID": "string", - }); - }); - - test("columns()", async function () { - const table = await client.open_table("memory.superstore"); - const columns = await table.columns(); - expect(columns).toEqual([ - "Row ID", - "Order ID", - "Order Date", - "Ship Date", - "Ship Mode", - "Customer ID", - "Customer Name", - "Segment", - "Country", - "City", - "State", - "Postal Code", - "Region", - "Product ID", - "Category", - "Sub-Category", - "Product Name", - "Sales", - "Quantity", - "Discount", - "Profit", - ]); - }); - - test("size()", async function () { - const table = await client.open_table("memory.superstore"); - const size = await table.size(); - expect(size).toBe(9994); - }); - }); - - test.describe("view", () => { - test("num_rows()", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ columns: ["Sales", "Profit"] }); - const numRows = await view.num_rows(); - expect(numRows).toBe(9994); - await view.delete(); - }); - - test("num_columns()", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales", "Profit", "State"], - }); - - const numColumns = await view.num_columns(); - expect(numColumns).toBe(3); - await view.delete(); - }); - - test("schema()", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales", "Profit", "State"], - }); - const schema = await view.schema(); - expect(schema).toEqual({ - Sales: "float", - Profit: "float", - State: "string", - }); - await view.delete(); - }); - - test("to_json()", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales", "Quantity"], - }); - const json = await view.to_json({ start_row: 0, end_row: 5 }); - expect(json).toEqual([ - { Sales: 261.96, Quantity: 2 }, - { Sales: 731.94, Quantity: 3 }, - { Sales: 14.62, Quantity: 2 }, - { Sales: 957.5775, Quantity: 5 }, - { Sales: 22.368, Quantity: 2 }, - ]); - await view.delete(); - }); - - test("to_columns()", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales", "Quantity"], - }); - const columns = await view.to_columns({ - start_row: 0, - end_row: 5, - }); - expect(columns).toEqual({ - Sales: [261.96, 731.94, 14.62, 957.5775, 22.368], - Quantity: [2, 3, 2, 5, 2], - }); - await view.delete(); - }); - - test("column_paths()", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales", "Profit", "State"], - }); - const paths = await view.column_paths(); - expect(paths).toEqual(["Sales", "Profit", "State"]); - await view.delete(); - }); - }); - - test.describe("group_by", () => { - test("single group_by", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales"], - group_by: ["Region"], - aggregates: { Sales: "sum" }, - }); - const numRows = await view.num_rows(); - expect(numRows).toBe(5); - const json = await view.to_json(); - expect(json).toEqual([ - { __ROW_PATH__: [], Sales: 2297200.860299955 }, - { - __ROW_PATH__: ["Central"], - Sales: 501239.8908000005, - }, - { __ROW_PATH__: ["East"], Sales: 678781.2399999979 }, - { - __ROW_PATH__: ["South"], - Sales: 391721.9050000003, - }, - { __ROW_PATH__: ["West"], Sales: 725457.8245000006 }, - ]); - await view.delete(); - }); - - test("multi-level group_by", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales"], - group_by: ["Region", "Category"], - aggregates: { Sales: "sum" }, - }); - const json = await view.to_json(); - expect(json).toEqual([ - { __ROW_PATH__: [], Sales: 2297200.860299955 }, - { - __ROW_PATH__: ["Central"], - Sales: 501239.8908000005, - }, - { - __ROW_PATH__: ["Central", "Furniture"], - Sales: 163797.16380000004, - }, - { - __ROW_PATH__: ["Central", "Office Supplies"], - Sales: 167026.41500000027, - }, - { - __ROW_PATH__: ["Central", "Technology"], - Sales: 170416.3119999999, - }, - { __ROW_PATH__: ["East"], Sales: 678781.2399999979 }, - { - __ROW_PATH__: ["East", "Furniture"], - Sales: 208291.20400000009, - }, - { - __ROW_PATH__: ["East", "Office Supplies"], - Sales: 205516.0549999999, - }, - { - __ROW_PATH__: ["East", "Technology"], - Sales: 264973.9810000003, - }, - { - __ROW_PATH__: ["South"], - Sales: 391721.9050000003, - }, - { - __ROW_PATH__: ["South", "Furniture"], - Sales: 117298.6840000001, - }, - { - __ROW_PATH__: ["South", "Office Supplies"], - Sales: 125651.31299999992, - }, - { - __ROW_PATH__: ["South", "Technology"], - Sales: 148771.9079999999, - }, - { __ROW_PATH__: ["West"], Sales: 725457.8245000006 }, - { - __ROW_PATH__: ["West", "Furniture"], - Sales: 252612.7435000003, - }, - { - __ROW_PATH__: ["West", "Office Supplies"], - Sales: 220853.24900000007, - }, - { - __ROW_PATH__: ["West", "Technology"], - Sales: 251991.83199999997, - }, - ]); - await view.delete(); - }); - - test("group_by with count aggregate", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales"], - group_by: ["Region"], - aggregates: { Sales: "count" }, - }); - const json = await view.to_json(); - expect(json).toEqual([ - { __ROW_PATH__: [], Sales: 9994 }, - { __ROW_PATH__: ["Central"], Sales: 2323 }, - { __ROW_PATH__: ["East"], Sales: 2848 }, - { __ROW_PATH__: ["South"], Sales: 1620 }, - { __ROW_PATH__: ["West"], Sales: 3203 }, - ]); - await view.delete(); - }); - - test("group_by with avg aggregate", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales"], - group_by: ["Category"], - aggregates: { Sales: "avg" }, - }); - const json = await view.to_json(); - expect(json).toEqual([ - { __ROW_PATH__: [], Sales: 229.8580008304938 }, - { - __ROW_PATH__: ["Furniture"], - Sales: 349.83488698727007, - }, - { - __ROW_PATH__: ["Office Supplies"], - Sales: 119.32410089611732, - }, - { - __ROW_PATH__: ["Technology"], - Sales: 452.70927612344155, - }, - ]); - await view.delete(); - }); - - test("group_by with min aggregate", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Quantity"], - group_by: ["Region"], - aggregates: { Quantity: "min" }, - }); - const json = await view.to_json(); - expect(json).toEqual([ - { __ROW_PATH__: [], Quantity: 1 }, - { __ROW_PATH__: ["Central"], Quantity: 1 }, - { __ROW_PATH__: ["East"], Quantity: 1 }, - { __ROW_PATH__: ["South"], Quantity: 1 }, - { __ROW_PATH__: ["West"], Quantity: 1 }, - ]); - await view.delete(); - }); - - test("group_by with max aggregate", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Quantity"], - group_by: ["Region"], - aggregates: { Quantity: "max" }, - }); - const json = await view.to_json(); - expect(json).toEqual([ - { __ROW_PATH__: [], Quantity: 14 }, - { __ROW_PATH__: ["Central"], Quantity: 14 }, - { __ROW_PATH__: ["East"], Quantity: 14 }, - { __ROW_PATH__: ["South"], Quantity: 14 }, - { __ROW_PATH__: ["West"], Quantity: 14 }, - ]); - await view.delete(); - }); - }); - - test.describe("split_by", () => { - test("single split_by", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales"], - split_by: ["Region"], - group_by: ["Category"], - aggregates: { Sales: "sum" }, - }); - - const columns = await view.column_paths(); - expect(columns).toEqual([ - "Central_Sales", - "East_Sales", - "South_Sales", - "West_Sales", - ]); - - const json = await view.to_json(); - expect(json).toEqual([ - { - __ROW_PATH__: [], - "Central|Sales": 501239.8908000005, - "East|Sales": 678781.2399999979, - "South|Sales": 391721.9050000003, - "West|Sales": 725457.8245000006, - }, - { - __ROW_PATH__: ["Furniture"], - "Central|Sales": 163797.16380000004, - "East|Sales": 208291.20400000009, - "South|Sales": 117298.6840000001, - "West|Sales": 252612.7435000003, - }, - { - __ROW_PATH__: ["Office Supplies"], - "Central|Sales": 167026.41500000027, - "East|Sales": 205516.0549999999, - "South|Sales": 125651.31299999992, - "West|Sales": 220853.24900000007, - }, - { - __ROW_PATH__: ["Technology"], - "Central|Sales": 170416.3119999999, - "East|Sales": 264973.9810000003, - "South|Sales": 148771.9079999999, - "West|Sales": 251991.83199999997, - }, - ]); - await view.delete(); - }); - - test.skip("split_by without group_by", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales"], - split_by: ["Category"], - }); - const paths = await view.column_paths(); - expect(paths.some((c) => c.includes("Furniture"))).toBe(true); - expect(paths.some((c) => c.includes("Office Supplies"))).toBe(true); - expect(paths.some((c) => c.includes("Technology"))).toBe(true); - await view.delete(); - }); - }); - - test.describe("filter", () => { - test("filter with equals", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales", "Region"], - filter: [["Region", "==", "West"]], - }); - const json = await view.to_json({ start_row: 0, end_row: 5 }); - expect(json).toEqual([ - { Sales: 14.62, Region: "West" }, - { Sales: 48.86, Region: "West" }, - { Sales: 7.28, Region: "West" }, - { Sales: 907.152, Region: "West" }, - { Sales: 18.504, Region: "West" }, - ]); - await view.delete(); - }); - - test("filter with not equals", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales", "Region"], - filter: [["Region", "!=", "West"]], - }); - const json = await view.to_json({ start_row: 0, end_row: 5 }); - expect(json).toEqual([ - { Sales: 261.96, Region: "South" }, - { Sales: 731.94, Region: "South" }, - { Sales: 957.5775, Region: "South" }, - { Sales: 22.368, Region: "South" }, - { Sales: 15.552, Region: "South" }, - ]); - await view.delete(); - }); - - test("filter with greater than", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales", "Quantity"], - filter: [["Quantity", ">", 5]], - }); - const json = await view.to_json({ start_row: 0, end_row: 5 }); - expect(json).toEqual([ - { Sales: 48.86, Quantity: 7 }, - { Sales: 907.152, Quantity: 6 }, - { Sales: 1706.184, Quantity: 9 }, - { Sales: 665.88, Quantity: 6 }, - { Sales: 19.46, Quantity: 7 }, - ]); - await view.delete(); - }); - - test("filter with less than", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales", "Quantity"], - filter: [["Quantity", "<", 3]], - }); - const json = await view.to_json({ start_row: 0, end_row: 5 }); - expect(json).toEqual([ - { Sales: 261.96, Quantity: 2 }, - { Sales: 14.62, Quantity: 2 }, - { Sales: 22.368, Quantity: 2 }, - { Sales: 55.5, Quantity: 2 }, - { Sales: 8.56, Quantity: 2 }, - ]); - await view.delete(); - }); - - test("filter with greater than or equal", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales", "Quantity"], - filter: [["Quantity", ">=", 10]], - }); - const json = await view.to_json({ start_row: 0, end_row: 5 }); - expect(json).toEqual([ - { Sales: 40.096, Quantity: 14 }, - { Sales: 43.12, Quantity: 14 }, - { Sales: 384.45, Quantity: 11 }, - { Sales: 3347.37, Quantity: 13 }, - { Sales: 100.24, Quantity: 10 }, - ]); - await view.delete(); - }); - - test("filter with less than or equal", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales", "Quantity"], - filter: [["Quantity", "<=", 2]], - }); - const json = await view.to_json({ start_row: 0, end_row: 5 }); - expect(json).toEqual([ - { Sales: 261.96, Quantity: 2 }, - { Sales: 14.62, Quantity: 2 }, - { Sales: 22.368, Quantity: 2 }, - { Sales: 55.5, Quantity: 2 }, - { Sales: 8.56, Quantity: 2 }, - ]); - await view.delete(); - }); - - test("filter with LIKE", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales", "State"], - filter: [["State", "LIKE", "Cal%"]], - }); - const json = await view.to_json({ start_row: 0, end_row: 5 }); - expect(json).toEqual([ - { Sales: 14.62, State: "California" }, - { Sales: 48.86, State: "California" }, - { Sales: 7.28, State: "California" }, - { Sales: 907.152, State: "California" }, - { Sales: 18.504, State: "California" }, - ]); - await view.delete(); - }); - - test("multiple filters", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales", "Region", "Quantity"], - filter: [ - ["Region", "==", "West"], - ["Quantity", ">", 3], - ], - }); - const json = await view.to_json({ start_row: 0, end_row: 5 }); - expect(json).toEqual([ - { Sales: 48.86, Region: "West", Quantity: 7 }, - { Sales: 7.28, Region: "West", Quantity: 4 }, - { Sales: 907.152, Region: "West", Quantity: 6 }, - { Sales: 114.9, Region: "West", Quantity: 5 }, - { Sales: 1706.184, Region: "West", Quantity: 9 }, - ]); - await view.delete(); - }); - - test("filter with group_by", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales"], - group_by: ["Category"], - filter: [["Region", "==", "West"]], - aggregates: { Sales: "sum" }, - }); - const numRows = await view.num_rows(); - expect(numRows).toBe(4); - const json = await view.to_json(); - expect(json).toEqual([ - { __ROW_PATH__: [], Sales: 725457.8245000006 }, - { - __ROW_PATH__: ["Furniture"], - Sales: 252612.7435000003, - }, - { - __ROW_PATH__: ["Office Supplies"], - Sales: 220853.24900000007, - }, - { - __ROW_PATH__: ["Technology"], - Sales: 251991.83199999997, - }, - ]); - await view.delete(); - }); - }); - - test.describe("sort", () => { - test("sort ascending", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales", "Quantity"], - sort: [["Sales", "asc"]], - }); - const json = await view.to_json({ start_row: 0, end_row: 5 }); - expect(json).toEqual([ - { Sales: 0.444, Quantity: 1 }, - { Sales: 0.556, Quantity: 1 }, - { Sales: 0.836, Quantity: 1 }, - { Sales: 0.852, Quantity: 1 }, - { Sales: 0.876, Quantity: 1 }, - ]); - await view.delete(); - }); - - test("sort descending", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales", "Quantity"], - sort: [["Sales", "desc"]], - }); - const json = await view.to_json({ start_row: 0, end_row: 5 }); - expect(json).toEqual([ - { Sales: 22638.48, Quantity: 6 }, - { Sales: 17499.95, Quantity: 5 }, - { Sales: 13999.96, Quantity: 4 }, - { Sales: 11199.968, Quantity: 4 }, - { Sales: 10499.97, Quantity: 3 }, - ]); - await view.delete(); - }); - - test("sort with group_by", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales"], - group_by: ["Region"], - sort: [["Sales", "desc"]], - aggregates: { Sales: "sum" }, - }); - const json = await view.to_json(); - expect(json).toEqual([ - { __ROW_PATH__: [], Sales: 2297200.860299955 }, - { __ROW_PATH__: ["West"], Sales: 725457.8245000006 }, - { __ROW_PATH__: ["East"], Sales: 678781.2399999979 }, - { - __ROW_PATH__: ["Central"], - Sales: 501239.8908000005, - }, - { - __ROW_PATH__: ["South"], - Sales: 391721.9050000003, - }, - ]); - await view.delete(); - }); - - test("multi-column sort", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Region", "Sales", "Quantity"], - sort: [ - ["Region", "asc"], - ["Sales", "desc"], - ], - }); - const json = await view.to_json({ start_row: 0, end_row: 5 }); - expect(json).toEqual([ - { Region: "Central", Sales: 17499.95, Quantity: 5 }, - { Region: "Central", Sales: 9892.74, Quantity: 13 }, - { Region: "Central", Sales: 9449.95, Quantity: 5 }, - { Region: "Central", Sales: 8159.952, Quantity: 8 }, - { Region: "Central", Sales: 5443.96, Quantity: 4 }, - ]); - await view.delete(); - }); - }); - - test.describe("expressions", () => { - test("simple expression", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales", "doublesales"], - expressions: { doublesales: '"Sales" * 2' }, - }); - - const json = await view.to_json({ start_row: 0, end_row: 5 }); - expect(json).toEqual([ - { Sales: 261.96, doublesales: 523.92 }, - { Sales: 731.94, doublesales: 1463.88 }, - { Sales: 14.62, doublesales: 29.24 }, - { Sales: 957.5775, doublesales: 1915.155 }, - { Sales: 22.368, doublesales: 44.736 }, - ]); - - await view.delete(); - }); - - test("expression with multiple columns", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales", "Profit", "margin"], - expressions: { margin: '"Profit" / "Sales"' }, - }); - - const json = await view.to_json({ start_row: 0, end_row: 5 }); - expect(json).toEqual([ - { - Sales: 261.96, - Profit: 41.9136, - margin: 0.16000000000000003, - }, - { Sales: 731.94, Profit: 219.582, margin: 0.3 }, - { - Sales: 14.62, - Profit: 6.8714, - margin: 0.47000000000000003, - }, - { Sales: 957.5775, Profit: -383.031, margin: -0.4 }, - { Sales: 22.368, Profit: 2.5164, margin: 0.1125 }, - ]); - - await view.delete(); - }); - - test("expression with group_by", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["total"], - group_by: ["Region"], - expressions: { total: '"Sales" + "Profit"' }, - aggregates: { total: "sum" }, - }); - - const json = await view.to_json(); - expect(json).toEqual([ - { __ROW_PATH__: [], total: 2583597.882000014 }, - { - __ROW_PATH__: ["Central"], - total: 540946.2532999996, - }, - { __ROW_PATH__: ["East"], total: 770304.0199999991 }, - { - __ROW_PATH__: ["South"], - total: 438471.33530000027, - }, - { __ROW_PATH__: ["West"], total: 833876.2733999988 }, - ]); - - await view.delete(); - }); - }); - - test.describe("viewport", () => { - test("start_row and end_row", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales", "Profit"], - }); - const json = await view.to_json({ start_row: 10, end_row: 15 }); - expect(json).toEqual([ - { Sales: 1706.184, Profit: 85.3092 }, - { Sales: 911.424, Profit: 68.3568 }, - { Sales: 15.552, Profit: 5.4432 }, - { Sales: 407.976, Profit: 132.5922 }, - { Sales: 68.81, Profit: -123.858 }, - ]); - await view.delete(); - }); - - test("start_col and end_col", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales", "Profit", "Quantity", "Discount"], - }); - const json = await view.to_json({ - start_row: 0, - end_row: 5, - start_col: 1, - end_col: 3, - }); - expect(json).toEqual([ - { Profit: 41.9136, Quantity: 2 }, - { Profit: 219.582, Quantity: 3 }, - { Profit: 6.8714, Quantity: 2 }, - { Profit: -383.031, Quantity: 5 }, - { Profit: 2.5164, Quantity: 2 }, - ]); - await view.delete(); - }); - }); - - test.describe("data types", () => { - test("integer columns", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Quantity"], - }); - const json = await view.to_json({ start_row: 0, end_row: 5 }); - expect(json).toEqual([ - { Quantity: 2 }, - { Quantity: 3 }, - { Quantity: 2 }, - { Quantity: 5 }, - { Quantity: 2 }, - ]); - await view.delete(); - }); - - test("float columns", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales", "Profit"], - }); - const json = await view.to_json({ start_row: 0, end_row: 5 }); - expect(json).toEqual([ - { Sales: 261.96, Profit: 41.9136 }, - { Sales: 731.94, Profit: 219.582 }, - { Sales: 14.62, Profit: 6.8714 }, - { Sales: 957.5775, Profit: -383.031 }, - { Sales: 22.368, Profit: 2.5164 }, - ]); - await view.delete(); - }); - - test("string columns", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Region", "State", "City"], - }); - const json = await view.to_json({ start_row: 0, end_row: 5 }); - expect(json).toEqual([ - { - Region: "South", - State: "Kentucky", - City: "Henderson", - }, - { - Region: "South", - State: "Kentucky", - City: "Henderson", - }, - { - Region: "West", - State: "California", - City: "Los Angeles", - }, - { - Region: "South", - State: "Florida", - City: "Fort Lauderdale", - }, - { - Region: "South", - State: "Florida", - City: "Fort Lauderdale", - }, - ]); - await view.delete(); - }); - - test("date columns", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Order Date"], - }); - const json = await view.to_json({ start_row: 0, end_row: 5 }); - expect(json).toEqual([ - { "Order Date": 1478563200000 }, - { "Order Date": 1478563200000 }, - { "Order Date": 1465689600000 }, - { "Order Date": 1444521600000 }, - { "Order Date": 1444521600000 }, - ]); - await view.delete(); - }); - }); - - test.describe("combined operations", () => { - test("group_by + filter + sort", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales"], - group_by: ["Category"], - filter: [["Region", "==", "West"]], - sort: [["Sales", "desc"]], - aggregates: { Sales: "sum" }, - }); - const json = await view.to_json(); - expect(json).toEqual([ - { __ROW_PATH__: [], Sales: 725457.8245000006 }, - { - __ROW_PATH__: ["Furniture"], - Sales: 252612.7435000003, - }, - { - __ROW_PATH__: ["Technology"], - Sales: 251991.83199999997, - }, - { - __ROW_PATH__: ["Office Supplies"], - Sales: 220853.24900000007, - }, - ]); - await view.delete(); - }); - - test("split_by + group_by + filter", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales"], - group_by: ["Category"], - split_by: ["Region"], - filter: [["Quantity", ">", 3]], - aggregates: { Sales: "sum" }, - }); - - const paths = await view.column_paths(); - expect(paths).toEqual([ - "Central_Sales", - "East_Sales", - "South_Sales", - "West_Sales", - ]); - - const numRows = await view.num_rows(); - expect(numRows).toBe(4); - - const json = await view.to_json(); - expect(json).toEqual([ - { - __ROW_PATH__: [], - "Central|Sales": 332883.0567999998, - "East|Sales": 455143.735, - "South|Sales": 274208.7699999999, - "West|Sales": 470561.28350000136, - }, - { - __ROW_PATH__: ["Furniture"], - "Central|Sales": 111457.73279999988, - "East|Sales": 140376.95899999997, - "South|Sales": 80859.618, - "West|Sales": 165219.5734999998, - }, - { - __ROW_PATH__: ["Office Supplies"], - "Central|Sales": 103937.78599999992, - "East|Sales": 135823.893, - "South|Sales": 84393.3579999999, - "West|Sales": 140206.93099999975, - }, - { - __ROW_PATH__: ["Technology"], - "Central|Sales": 117487.53800000002, - "East|Sales": 178942.883, - "South|Sales": 108955.79400000005, - "West|Sales": 165134.77900000007, - }, - ]); - await view.delete(); - }); - - test("split_by only", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales"], - split_by: ["Region"], - filter: [["Quantity", ">", 3]], - }); - - const paths = await view.column_paths(); - expect(paths).toEqual([ - "Central_Sales", - "East_Sales", - "South_Sales", - "West_Sales", - ]); - - const numRows = await view.num_rows(); - expect(numRows).toBe(4284); - const json = await view.to_json({ start_row: 0, end_row: 1 }); - expect(json).toEqual([ - { - "Central|Sales": null, - "East|Sales": null, - "South|Sales": 957.5775, - "West|Sales": null, - }, - ]); - await view.delete(); - }); - - test("expressions + group_by + sort", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["profitmargin"], - group_by: ["Region"], - expressions: { profitmargin: '"Profit" / "Sales" * 100' }, - sort: [["profitmargin", "desc"]], - aggregates: { profitmargin: "avg" }, - }); - const json = await view.to_json(); - expect(json).toEqual([ - { - __ROW_PATH__: [], - profitmargin: 12.031392972104467, - }, - { - __ROW_PATH__: ["West"], - profitmargin: 21.948661793784012, - }, - { - __ROW_PATH__: ["East"], - profitmargin: 16.722695960406636, - }, - { - __ROW_PATH__: ["South"], - profitmargin: 16.35190329218107, - }, - { - __ROW_PATH__: ["Central"], - profitmargin: -10.407293926323575, - }, - ]); - await view.delete(); - }); - }); -}); diff --git a/rust/perspective-js/test/js/duckdb/client.spec.js b/rust/perspective-js/test/js/duckdb/client.spec.js new file mode 100644 index 0000000000..34e193f54f --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/client.spec.js @@ -0,0 +1,22 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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("client", (getClient) => { + test("get_hosted_table_names()", async function () { + const client = getClient(); + const tables = await client.get_hosted_table_names(); + expect(tables).toEqual(["memory.superstore"]); + }); +}); diff --git a/rust/perspective-js/test/js/duckdb/combined.spec.js b/rust/perspective-js/test/js/duckdb/combined.spec.js new file mode 100644 index 0000000000..a66b37133b --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/combined.spec.js @@ -0,0 +1,195 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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("combined operations", (getClient) => { + test("group_by + filter + sort", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Category"], + filter: [["Region", "==", "West"]], + sort: [["Sales", "desc"]], + aggregates: { Sales: "sum" }, + }); + const json = await view.to_json(); + expect(json).toEqual([ + { __ROW_PATH__: [], Sales: 725457.8245000006 }, + { + __ROW_PATH__: ["Furniture"], + Sales: 252612.7435000003, + }, + { + __ROW_PATH__: ["Technology"], + Sales: 251991.83199999997, + }, + { + __ROW_PATH__: ["Office Supplies"], + Sales: 220853.24900000007, + }, + ]); + await view.delete(); + }); + + test("split_by + group_by + filter", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Category"], + split_by: ["Region"], + filter: [["Quantity", ">", 3]], + aggregates: { Sales: "sum" }, + }); + + const paths = await view.column_paths(); + expect(paths).toEqual([ + "Central_Sales", + "East_Sales", + "South_Sales", + "West_Sales", + ]); + + const numRows = await view.num_rows(); + expect(numRows).toBe(4); + + const json = await view.to_json(); + expect(json).toEqual([ + { + __ROW_PATH__: [], + "Central|Sales": 332883.0567999998, + "East|Sales": 455143.735, + "South|Sales": 274208.7699999999, + "West|Sales": 470561.28350000136, + }, + { + __ROW_PATH__: ["Furniture"], + "Central|Sales": 111457.73279999988, + "East|Sales": 140376.95899999997, + "South|Sales": 80859.618, + "West|Sales": 165219.5734999998, + }, + { + __ROW_PATH__: ["Office Supplies"], + "Central|Sales": 103937.78599999992, + "East|Sales": 135823.893, + "South|Sales": 84393.3579999999, + "West|Sales": 140206.93099999975, + }, + { + __ROW_PATH__: ["Technology"], + "Central|Sales": 117487.53800000002, + "East|Sales": 178942.883, + "South|Sales": 108955.79400000005, + "West|Sales": 165134.77900000007, + }, + ]); + await view.delete(); + }); + + test("split_by only", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + split_by: ["Region"], + filter: [["Quantity", ">", 3]], + }); + + const paths = await view.column_paths(); + expect(paths).toEqual([ + "Central_Sales", + "East_Sales", + "South_Sales", + "West_Sales", + ]); + + const numRows = await view.num_rows(); + expect(numRows).toBe(4284); + const json = await view.to_json({ start_row: 0, end_row: 1 }); + expect(json).toEqual([ + { + "Central|Sales": null, + "East|Sales": null, + "South|Sales": 957.5775, + "West|Sales": null, + }, + ]); + await view.delete(); + }); + + test("split_by only + sort", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + split_by: ["Region"], + sort: [["Sales", "desc"]], + filter: [["Quantity", ">", 3]], + }); + + const paths = await view.column_paths(); + expect(paths).toEqual([ + "Central_Sales", + "East_Sales", + "South_Sales", + "West_Sales", + ]); + + const numRows = await view.num_rows(); + expect(numRows).toBe(4284); + const json = await view.to_json({ start_row: 0, end_row: 1 }); + expect(json).toEqual([ + { + "Central|Sales": null, + "East|Sales": null, + "South|Sales": 22638.48, + "West|Sales": null, + }, + ]); + await view.delete(); + }); + + test("expressions + group_by + sort", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["profitmargin"], + group_by: ["Region"], + expressions: { profitmargin: '"Profit" / "Sales" * 100' }, + sort: [["profitmargin", "desc"]], + aggregates: { profitmargin: "avg" }, + }); + const json = await view.to_json(); + expect(json).toEqual([ + { + __ROW_PATH__: [], + profitmargin: 12.031392972104467, + }, + { + __ROW_PATH__: ["West"], + profitmargin: 21.948661793784012, + }, + { + __ROW_PATH__: ["East"], + profitmargin: 16.722695960406636, + }, + { + __ROW_PATH__: ["South"], + profitmargin: 16.35190329218107, + }, + { + __ROW_PATH__: ["Central"], + profitmargin: -10.407293926323575, + }, + ]); + await view.delete(); + }); +}); diff --git a/rust/perspective-js/test/js/duckdb/data_types.spec.js b/rust/perspective-js/test/js/duckdb/data_types.spec.js new file mode 100644 index 0000000000..617ca6c2a9 --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/data_types.spec.js @@ -0,0 +1,100 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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("data types", (getClient) => { + test("integer columns", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Quantity"], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Quantity: 2 }, + { Quantity: 3 }, + { Quantity: 2 }, + { Quantity: 5 }, + { Quantity: 2 }, + ]); + await view.delete(); + }); + + test("float columns", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Profit"], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Sales: 261.96, Profit: 41.9136 }, + { Sales: 731.94, Profit: 219.582 }, + { Sales: 14.62, Profit: 6.8714 }, + { Sales: 957.5775, Profit: -383.031 }, + { Sales: 22.368, Profit: 2.5164 }, + ]); + await view.delete(); + }); + + test("string columns", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Region", "State", "City"], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { + Region: "South", + State: "Kentucky", + City: "Henderson", + }, + { + Region: "South", + State: "Kentucky", + City: "Henderson", + }, + { + Region: "West", + State: "California", + City: "Los Angeles", + }, + { + Region: "South", + State: "Florida", + City: "Fort Lauderdale", + }, + { + Region: "South", + State: "Florida", + City: "Fort Lauderdale", + }, + ]); + await view.delete(); + }); + + test("date columns", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Order Date"], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { "Order Date": 1478563200000 }, + { "Order Date": 1478563200000 }, + { "Order Date": 1465689600000 }, + { "Order Date": 1444521600000 }, + { "Order Date": 1444521600000 }, + ]); + await view.delete(); + }); +}); diff --git a/rust/perspective-js/test/js/duckdb/expressions.spec.js b/rust/perspective-js/test/js/duckdb/expressions.spec.js new file mode 100644 index 0000000000..c0b2036a85 --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/expressions.spec.js @@ -0,0 +1,89 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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("expressions", (getClient) => { + test("simple expression", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "doublesales"], + expressions: { doublesales: '"Sales" * 2' }, + }); + + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Sales: 261.96, doublesales: 523.92 }, + { Sales: 731.94, doublesales: 1463.88 }, + { Sales: 14.62, doublesales: 29.24 }, + { Sales: 957.5775, doublesales: 1915.155 }, + { Sales: 22.368, doublesales: 44.736 }, + ]); + + await view.delete(); + }); + + test("expression with multiple columns", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Profit", "margin"], + expressions: { margin: '"Profit" / "Sales"' }, + }); + + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { + Sales: 261.96, + Profit: 41.9136, + margin: 0.16000000000000003, + }, + { Sales: 731.94, Profit: 219.582, margin: 0.3 }, + { + Sales: 14.62, + Profit: 6.8714, + margin: 0.47000000000000003, + }, + { Sales: 957.5775, Profit: -383.031, margin: -0.4 }, + { Sales: 22.368, Profit: 2.5164, margin: 0.1125 }, + ]); + + await view.delete(); + }); + + test("expression with group_by", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["total"], + group_by: ["Region"], + expressions: { total: '"Sales" + "Profit"' }, + aggregates: { total: "sum" }, + }); + + const json = await view.to_json(); + expect(json).toEqual([ + { __ROW_PATH__: [], total: 2583597.882000014 }, + { + __ROW_PATH__: ["Central"], + total: 540946.2532999996, + }, + { __ROW_PATH__: ["East"], total: 770304.0199999991 }, + { + __ROW_PATH__: ["South"], + total: 438471.33530000027, + }, + { __ROW_PATH__: ["West"], total: 833876.2733999988 }, + ]); + + await view.delete(); + }); +}); diff --git a/rust/perspective-js/test/js/duckdb/filter.spec.js b/rust/perspective-js/test/js/duckdb/filter.spec.js new file mode 100644 index 0000000000..902c436e72 --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/filter.spec.js @@ -0,0 +1,184 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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("filter", (getClient) => { + test("filter with equals", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Region"], + filter: [["Region", "==", "West"]], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Sales: 14.62, Region: "West" }, + { Sales: 48.86, Region: "West" }, + { Sales: 7.28, Region: "West" }, + { Sales: 907.152, Region: "West" }, + { Sales: 18.504, Region: "West" }, + ]); + await view.delete(); + }); + + test("filter with not equals", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Region"], + filter: [["Region", "!=", "West"]], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Sales: 261.96, Region: "South" }, + { Sales: 731.94, Region: "South" }, + { Sales: 957.5775, Region: "South" }, + { Sales: 22.368, Region: "South" }, + { Sales: 15.552, Region: "South" }, + ]); + await view.delete(); + }); + + test("filter with greater than", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Quantity"], + filter: [["Quantity", ">", 5]], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Sales: 48.86, Quantity: 7 }, + { Sales: 907.152, Quantity: 6 }, + { Sales: 1706.184, Quantity: 9 }, + { Sales: 665.88, Quantity: 6 }, + { Sales: 19.46, Quantity: 7 }, + ]); + await view.delete(); + }); + + test("filter with less than", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Quantity"], + filter: [["Quantity", "<", 3]], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Sales: 261.96, Quantity: 2 }, + { Sales: 14.62, Quantity: 2 }, + { Sales: 22.368, Quantity: 2 }, + { Sales: 55.5, Quantity: 2 }, + { Sales: 8.56, Quantity: 2 }, + ]); + await view.delete(); + }); + + test("filter with greater than or equal", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Quantity"], + filter: [["Quantity", ">=", 10]], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Sales: 40.096, Quantity: 14 }, + { Sales: 43.12, Quantity: 14 }, + { Sales: 384.45, Quantity: 11 }, + { Sales: 3347.37, Quantity: 13 }, + { Sales: 100.24, Quantity: 10 }, + ]); + await view.delete(); + }); + + test("filter with less than or equal", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Quantity"], + filter: [["Quantity", "<=", 2]], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Sales: 261.96, Quantity: 2 }, + { Sales: 14.62, Quantity: 2 }, + { Sales: 22.368, Quantity: 2 }, + { Sales: 55.5, Quantity: 2 }, + { Sales: 8.56, Quantity: 2 }, + ]); + await view.delete(); + }); + + test("filter with LIKE", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "State"], + filter: [["State", "LIKE", "Cal%"]], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Sales: 14.62, State: "California" }, + { Sales: 48.86, State: "California" }, + { Sales: 7.28, State: "California" }, + { Sales: 907.152, State: "California" }, + { Sales: 18.504, State: "California" }, + ]); + await view.delete(); + }); + + test("multiple filters", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Region", "Quantity"], + filter: [ + ["Region", "==", "West"], + ["Quantity", ">", 3], + ], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Sales: 48.86, Region: "West", Quantity: 7 }, + { Sales: 7.28, Region: "West", Quantity: 4 }, + { Sales: 907.152, Region: "West", Quantity: 6 }, + { Sales: 114.9, Region: "West", Quantity: 5 }, + { Sales: 1706.184, Region: "West", Quantity: 9 }, + ]); + await view.delete(); + }); + + test("filter with group_by", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Category"], + filter: [["Region", "==", "West"]], + aggregates: { Sales: "sum" }, + }); + const numRows = await view.num_rows(); + expect(numRows).toBe(4); + const json = await view.to_json(); + expect(json).toEqual([ + { __ROW_PATH__: [], Sales: 725457.8245000006 }, + { + __ROW_PATH__: ["Furniture"], + Sales: 252612.7435000003, + }, + { + __ROW_PATH__: ["Office Supplies"], + Sales: 220853.24900000007, + }, + { + __ROW_PATH__: ["Technology"], + Sales: 251991.83199999997, + }, + ]); + await view.delete(); + }); +}); diff --git a/rust/perspective-js/test/js/duckdb/group_by.spec.js b/rust/perspective-js/test/js/duckdb/group_by.spec.js new file mode 100644 index 0000000000..1e0d09ef6e --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/group_by.spec.js @@ -0,0 +1,194 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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("group_by", (getClient) => { + test("single 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 numRows = await view.num_rows(); + expect(numRows).toBe(5); + const json = await view.to_json(); + expect(json).toEqual([ + { __ROW_PATH__: [], Sales: 2297200.860299955 }, + { + __ROW_PATH__: ["Central"], + Sales: 501239.8908000005, + }, + { __ROW_PATH__: ["East"], Sales: 678781.2399999979 }, + { + __ROW_PATH__: ["South"], + Sales: 391721.9050000003, + }, + { __ROW_PATH__: ["West"], Sales: 725457.8245000006 }, + ]); + await view.delete(); + }); + + test("multi-level group_by", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Region", "Category"], + aggregates: { Sales: "sum" }, + }); + const json = await view.to_json(); + expect(json).toEqual([ + { __ROW_PATH__: [], Sales: 2297200.860299955 }, + { + __ROW_PATH__: ["Central"], + Sales: 501239.8908000005, + }, + { + __ROW_PATH__: ["Central", "Furniture"], + Sales: 163797.16380000004, + }, + { + __ROW_PATH__: ["Central", "Office Supplies"], + Sales: 167026.41500000027, + }, + { + __ROW_PATH__: ["Central", "Technology"], + Sales: 170416.3119999999, + }, + { __ROW_PATH__: ["East"], Sales: 678781.2399999979 }, + { + __ROW_PATH__: ["East", "Furniture"], + Sales: 208291.20400000009, + }, + { + __ROW_PATH__: ["East", "Office Supplies"], + Sales: 205516.0549999999, + }, + { + __ROW_PATH__: ["East", "Technology"], + Sales: 264973.9810000003, + }, + { + __ROW_PATH__: ["South"], + Sales: 391721.9050000003, + }, + { + __ROW_PATH__: ["South", "Furniture"], + Sales: 117298.6840000001, + }, + { + __ROW_PATH__: ["South", "Office Supplies"], + Sales: 125651.31299999992, + }, + { + __ROW_PATH__: ["South", "Technology"], + Sales: 148771.9079999999, + }, + { __ROW_PATH__: ["West"], Sales: 725457.8245000006 }, + { + __ROW_PATH__: ["West", "Furniture"], + Sales: 252612.7435000003, + }, + { + __ROW_PATH__: ["West", "Office Supplies"], + Sales: 220853.24900000007, + }, + { + __ROW_PATH__: ["West", "Technology"], + Sales: 251991.83199999997, + }, + ]); + await view.delete(); + }); + + test("group_by with count aggregate", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Region"], + aggregates: { Sales: "count" }, + }); + const json = await view.to_json(); + expect(json).toEqual([ + { __ROW_PATH__: [], Sales: 9994 }, + { __ROW_PATH__: ["Central"], Sales: 2323 }, + { __ROW_PATH__: ["East"], Sales: 2848 }, + { __ROW_PATH__: ["South"], Sales: 1620 }, + { __ROW_PATH__: ["West"], Sales: 3203 }, + ]); + await view.delete(); + }); + + test("group_by with avg aggregate", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Category"], + aggregates: { Sales: "avg" }, + }); + const json = await view.to_json(); + expect(json).toEqual([ + { __ROW_PATH__: [], Sales: 229.8580008304938 }, + { + __ROW_PATH__: ["Furniture"], + Sales: 349.83488698727007, + }, + { + __ROW_PATH__: ["Office Supplies"], + Sales: 119.32410089611732, + }, + { + __ROW_PATH__: ["Technology"], + Sales: 452.70927612344155, + }, + ]); + await view.delete(); + }); + + test("group_by with min aggregate", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Quantity"], + group_by: ["Region"], + aggregates: { Quantity: "min" }, + }); + const json = await view.to_json(); + expect(json).toEqual([ + { __ROW_PATH__: [], Quantity: 1 }, + { __ROW_PATH__: ["Central"], Quantity: 1 }, + { __ROW_PATH__: ["East"], Quantity: 1 }, + { __ROW_PATH__: ["South"], Quantity: 1 }, + { __ROW_PATH__: ["West"], Quantity: 1 }, + ]); + await view.delete(); + }); + + test("group_by with max aggregate", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Quantity"], + group_by: ["Region"], + aggregates: { Quantity: "max" }, + }); + const json = await view.to_json(); + expect(json).toEqual([ + { __ROW_PATH__: [], Quantity: 14 }, + { __ROW_PATH__: ["Central"], Quantity: 14 }, + { __ROW_PATH__: ["East"], Quantity: 14 }, + { __ROW_PATH__: ["South"], Quantity: 14 }, + { __ROW_PATH__: ["West"], Quantity: 14 }, + ]); + await view.delete(); + }); +}); diff --git a/rust/perspective-js/test/js/duckdb/group_rollup_mode.spec.js b/rust/perspective-js/test/js/duckdb/group_rollup_mode.spec.js new file mode 100644 index 0000000000..bb86a40d51 --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/group_rollup_mode.spec.js @@ -0,0 +1,256 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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("group_rollup_mode", (getClient) => { + test("flat mode 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" }, + group_rollup_mode: "flat", + }); + const numRows = await view.num_rows(); + expect(numRows).toBe(4); + const json = await view.to_json(); + expect(json).toEqual([ + { + __ROW_PATH__: ["Central"], + Sales: 501239.8908000005, + }, + { __ROW_PATH__: ["East"], Sales: 678781.2399999979 }, + { + __ROW_PATH__: ["South"], + Sales: 391721.9050000003, + }, + { __ROW_PATH__: ["West"], Sales: 725457.8245000006 }, + ]); + await view.delete(); + }); + + test("flat mode with multi-level group_by", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Region", "Category"], + aggregates: { Sales: "sum" }, + group_rollup_mode: "flat", + }); + const numRows = await view.num_rows(); + expect(numRows).toBe(12); + const json = await view.to_json(); + expect(json).toEqual([ + { + __ROW_PATH__: ["Central", "Furniture"], + Sales: 163797.16380000004, + }, + { + __ROW_PATH__: ["Central", "Office Supplies"], + Sales: 167026.41500000027, + }, + { + __ROW_PATH__: ["Central", "Technology"], + Sales: 170416.3119999999, + }, + { + __ROW_PATH__: ["East", "Furniture"], + Sales: 208291.20400000009, + }, + { + __ROW_PATH__: ["East", "Office Supplies"], + Sales: 205516.0549999999, + }, + { + __ROW_PATH__: ["East", "Technology"], + Sales: 264973.9810000003, + }, + { + __ROW_PATH__: ["South", "Furniture"], + Sales: 117298.6840000001, + }, + { + __ROW_PATH__: ["South", "Office Supplies"], + Sales: 125651.31299999992, + }, + { + __ROW_PATH__: ["South", "Technology"], + Sales: 148771.9079999999, + }, + { + __ROW_PATH__: ["West", "Furniture"], + Sales: 252612.7435000003, + }, + { + __ROW_PATH__: ["West", "Office Supplies"], + Sales: 220853.24900000007, + }, + { + __ROW_PATH__: ["West", "Technology"], + Sales: 251991.83199999997, + }, + ]); + await view.delete(); + }); + + test("flat mode with group_by and split_by", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Category"], + split_by: ["Region"], + aggregates: { Sales: "sum" }, + group_rollup_mode: "flat", + }); + const numRows = await view.num_rows(); + expect(numRows).toBe(3); + const json = await view.to_json(); + expect(json).toEqual([ + { + __ROW_PATH__: ["Furniture"], + "Central|Sales": 163797.16380000004, + "East|Sales": 208291.20400000009, + "South|Sales": 117298.6840000001, + "West|Sales": 252612.7435000003, + }, + { + __ROW_PATH__: ["Office Supplies"], + "Central|Sales": 167026.41500000027, + "East|Sales": 205516.0549999999, + "South|Sales": 125651.31299999992, + "West|Sales": 220853.24900000007, + }, + { + __ROW_PATH__: ["Technology"], + "Central|Sales": 170416.3119999999, + "East|Sales": 264973.9810000003, + "South|Sales": 148771.9079999999, + "West|Sales": 251991.83199999997, + }, + ]); + await view.delete(); + }); + + test("flat mode with group_by and split_by and sort", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Category"], + split_by: ["Region"], + sort: [["Sales", "desc"]], + aggregates: { Sales: "sum" }, + group_rollup_mode: "flat", + }); + const numRows = await view.num_rows(); + expect(numRows).toBe(3); + const json = await view.to_json(); + expect(json).toEqual([ + { + __ROW_PATH__: ["Technology"], + "Central|Sales": 170416.3119999999, + "East|Sales": 264973.9810000003, + "South|Sales": 148771.9079999999, + "West|Sales": 251991.83199999997, + }, + { + __ROW_PATH__: ["Furniture"], + "Central|Sales": 163797.16380000004, + "East|Sales": 208291.20400000009, + "South|Sales": 117298.6840000001, + "West|Sales": 252612.7435000003, + }, + { + __ROW_PATH__: ["Office Supplies"], + "Central|Sales": 167026.41500000027, + "East|Sales": 205516.0549999999, + "South|Sales": 125651.31299999992, + "West|Sales": 220853.24900000007, + }, + ]); + await view.delete(); + }); + + test("flat mode with group_by and sort", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Region"], + sort: [["Sales", "desc"]], + aggregates: { Sales: "sum" }, + group_rollup_mode: "flat", + }); + const json = await view.to_json(); + expect(json).toEqual([ + { __ROW_PATH__: ["West"], Sales: 725457.8245000006 }, + { __ROW_PATH__: ["East"], Sales: 678781.2399999979 }, + { + __ROW_PATH__: ["Central"], + Sales: 501239.8908000005, + }, + { + __ROW_PATH__: ["South"], + Sales: 391721.9050000003, + }, + ]); + await view.delete(); + }); + + test("total mode", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + aggregates: { Sales: "sum" }, + group_rollup_mode: "total", + }); + const numRows = await view.num_rows(); + expect(numRows).toBe(1); + const json = await view.to_json(); + expect(json).toEqual([{ Sales: 2297200.860299955 }]); + await view.delete(); + }); + + test("total mode with multiple columns", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Quantity"], + aggregates: { Sales: "sum", Quantity: "sum" }, + group_rollup_mode: "total", + }); + const json = await view.to_json(); + expect(json).toEqual([{ Sales: 2297200.860299955, Quantity: 37873 }]); + await view.delete(); + }); + + test("total mode with split_by", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + split_by: ["Region"], + aggregates: { Sales: "sum" }, + group_rollup_mode: "total", + }); + const numRows = await view.num_rows(); + expect(numRows).toBe(1); + const json = await view.to_json(); + expect(json).toEqual([ + { + "Central|Sales": 501239.8908000005, + "East|Sales": 678781.2399999979, + "South|Sales": 391721.9050000003, + "West|Sales": 725457.8245000006, + }, + ]); + await view.delete(); + }); +}); diff --git a/rust/perspective-js/test/js/duckdb/setup.js b/rust/perspective-js/test/js/duckdb/setup.js new file mode 100644 index 0000000000..a8a16b3f81 --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/setup.js @@ -0,0 +1,90 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 * as fs from "fs"; +import * as path from "path"; +import { createRequire } from "module"; + +import * as duckdb from "@duckdb/duckdb-wasm"; + +import { test } from "@perspective-dev/test"; +import { + default as perspective, + createMessageHandler, + wasmModule, +} from "@perspective-dev/client"; +import { DuckDBHandler } from "@perspective-dev/client/src/ts/virtual_servers/duckdb.ts"; + +const require = createRequire(import.meta.url); +const DUCKDB_DIST = path.dirname(require.resolve("@duckdb/duckdb-wasm")); +const Worker = require("web-worker"); + +async function initializeDuckDB() { + const bundle = await duckdb.selectBundle({ + mvp: { + mainModule: path.resolve(DUCKDB_DIST, "./duckdb-mvp.wasm"), + mainWorker: path.resolve( + DUCKDB_DIST, + "./duckdb-node-mvp.worker.cjs", + ), + }, + eh: { + mainModule: path.resolve(DUCKDB_DIST, "./duckdb-eh.wasm"), + mainWorker: path.resolve( + DUCKDB_DIST, + "./duckdb-node-eh.worker.cjs", + ), + }, + }); + + const logger = new duckdb.ConsoleLogger(); + const worker = new Worker(bundle.mainWorker); + const db = new duckdb.AsyncDuckDB(logger, worker); + await db.instantiate(bundle.mainModule, bundle.pthreadWorker); + const c = await db.connect(); + await c.query(` + SET default_null_order=NULLS_FIRST_ON_ASC_LAST_ON_DESC; + `); + + return c; +} + +async function loadSuperstoreData(db) { + const arrowPath = path.resolve( + import.meta.dirname, + "../../../node_modules/superstore-arrow/superstore.lz4.arrow", + ); + + const arrayBuffer = fs.readFileSync(arrowPath); + await db.insertArrowFromIPCStream(new Uint8Array(arrayBuffer), { + name: "superstore", + create: true, + }); +} + +export function describeDuckDB(name, fn) { + test.describe("DuckDB Virtual Server " + name, function () { + let db; + let client; + + test.beforeAll(async () => { + db = await initializeDuckDB(); + const server = createMessageHandler( + new DuckDBHandler(db, wasmModule), + ); + client = await perspective.worker(server); + await loadSuperstoreData(db); + }); + + fn(() => client); + }); +} diff --git a/rust/perspective-js/test/js/duckdb/sort.spec.js b/rust/perspective-js/test/js/duckdb/sort.spec.js new file mode 100644 index 0000000000..06fd1db358 --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/sort.spec.js @@ -0,0 +1,95 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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("sort", (getClient) => { + test("sort ascending", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Quantity"], + sort: [["Sales", "asc"]], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Sales: 0.444, Quantity: 1 }, + { Sales: 0.556, Quantity: 1 }, + { Sales: 0.836, Quantity: 1 }, + { Sales: 0.852, Quantity: 1 }, + { Sales: 0.876, Quantity: 1 }, + ]); + await view.delete(); + }); + + test("sort descending", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Quantity"], + sort: [["Sales", "desc"]], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Sales: 22638.48, Quantity: 6 }, + { Sales: 17499.95, Quantity: 5 }, + { Sales: 13999.96, Quantity: 4 }, + { Sales: 11199.968, Quantity: 4 }, + { Sales: 10499.97, Quantity: 3 }, + ]); + await view.delete(); + }); + + test("sort with group_by", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Region"], + sort: [["Sales", "desc"]], + aggregates: { Sales: "sum" }, + }); + const json = await view.to_json(); + expect(json).toEqual([ + { __ROW_PATH__: [], Sales: 2297200.860299955 }, + { __ROW_PATH__: ["West"], Sales: 725457.8245000006 }, + { __ROW_PATH__: ["East"], Sales: 678781.2399999979 }, + { + __ROW_PATH__: ["Central"], + Sales: 501239.8908000005, + }, + { + __ROW_PATH__: ["South"], + Sales: 391721.9050000003, + }, + ]); + await view.delete(); + }); + + test("multi-column sort", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Region", "Sales", "Quantity"], + sort: [ + ["Region", "asc"], + ["Sales", "desc"], + ], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Region: "Central", Sales: 17499.95, Quantity: 5 }, + { Region: "Central", Sales: 9892.74, Quantity: 13 }, + { Region: "Central", Sales: 9449.95, Quantity: 5 }, + { Region: "Central", Sales: 8159.952, Quantity: 8 }, + { Region: "Central", Sales: 5443.96, Quantity: 4 }, + ]); + await view.delete(); + }); +}); diff --git a/rust/perspective-js/test/js/duckdb/split_by.spec.js b/rust/perspective-js/test/js/duckdb/split_by.spec.js new file mode 100644 index 0000000000..5410125ac3 --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/split_by.spec.js @@ -0,0 +1,80 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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("split_by", (getClient) => { + test("single split_by", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + split_by: ["Region"], + group_by: ["Category"], + aggregates: { Sales: "sum" }, + }); + + const columns = await view.column_paths(); + expect(columns).toEqual([ + "Central_Sales", + "East_Sales", + "South_Sales", + "West_Sales", + ]); + + const json = await view.to_json(); + expect(json).toEqual([ + { + __ROW_PATH__: [], + "Central|Sales": 501239.8908000005, + "East|Sales": 678781.2399999979, + "South|Sales": 391721.9050000003, + "West|Sales": 725457.8245000006, + }, + { + __ROW_PATH__: ["Furniture"], + "Central|Sales": 163797.16380000004, + "East|Sales": 208291.20400000009, + "South|Sales": 117298.6840000001, + "West|Sales": 252612.7435000003, + }, + { + __ROW_PATH__: ["Office Supplies"], + "Central|Sales": 167026.41500000027, + "East|Sales": 205516.0549999999, + "South|Sales": 125651.31299999992, + "West|Sales": 220853.24900000007, + }, + { + __ROW_PATH__: ["Technology"], + "Central|Sales": 170416.3119999999, + "East|Sales": 264973.9810000003, + "South|Sales": 148771.9079999999, + "West|Sales": 251991.83199999997, + }, + ]); + await view.delete(); + }); + + test.skip("split_by without group_by", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + split_by: ["Category"], + }); + const paths = await view.column_paths(); + expect(paths.some((c) => c.includes("Furniture"))).toBe(true); + expect(paths.some((c) => c.includes("Office Supplies"))).toBe(true); + expect(paths.some((c) => c.includes("Technology"))).toBe(true); + await view.delete(); + }); +}); diff --git a/rust/perspective-js/test/js/duckdb/table.spec.js b/rust/perspective-js/test/js/duckdb/table.spec.js new file mode 100644 index 0000000000..f7984dfcc7 --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/table.spec.js @@ -0,0 +1,78 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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("table", (getClient) => { + test("schema()", async function () { + const table = await getClient().open_table("memory.superstore"); + const schema = await table.schema(); + expect(schema).toEqual({ + "Product Name": "string", + "Ship Date": "date", + City: "string", + "Row ID": "integer", + "Customer Name": "string", + Quantity: "integer", + Discount: "float", + "Sub-Category": "string", + Segment: "string", + Category: "string", + "Order Date": "date", + "Order ID": "string", + Sales: "float", + State: "string", + "Postal Code": "float", + Country: "string", + "Customer ID": "string", + "Ship Mode": "string", + Region: "string", + Profit: "float", + "Product ID": "string", + }); + }); + + test("columns()", async function () { + const table = await getClient().open_table("memory.superstore"); + const columns = await table.columns(); + expect(columns).toEqual([ + "Row ID", + "Order ID", + "Order Date", + "Ship Date", + "Ship Mode", + "Customer ID", + "Customer Name", + "Segment", + "Country", + "City", + "State", + "Postal Code", + "Region", + "Product ID", + "Category", + "Sub-Category", + "Product Name", + "Sales", + "Quantity", + "Discount", + "Profit", + ]); + }); + + test("size()", async function () { + const table = await getClient().open_table("memory.superstore"); + const size = await table.size(); + expect(size).toBe(9994); + }); +}); diff --git a/rust/perspective-js/test/js/duckdb/view.spec.js b/rust/perspective-js/test/js/duckdb/view.spec.js new file mode 100644 index 0000000000..aa21716c73 --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/view.spec.js @@ -0,0 +1,91 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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("view", (getClient) => { + test("num_rows()", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ columns: ["Sales", "Profit"] }); + const numRows = await view.num_rows(); + expect(numRows).toBe(9994); + await view.delete(); + }); + + test("num_columns()", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Profit", "State"], + }); + + const numColumns = await view.num_columns(); + expect(numColumns).toBe(3); + await view.delete(); + }); + + test("schema()", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Profit", "State"], + }); + const schema = await view.schema(); + expect(schema).toEqual({ + Sales: "float", + Profit: "float", + State: "string", + }); + await view.delete(); + }); + + test("to_json()", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Quantity"], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Sales: 261.96, Quantity: 2 }, + { Sales: 731.94, Quantity: 3 }, + { Sales: 14.62, Quantity: 2 }, + { Sales: 957.5775, Quantity: 5 }, + { Sales: 22.368, Quantity: 2 }, + ]); + await view.delete(); + }); + + test("to_columns()", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Quantity"], + }); + const columns = await view.to_columns({ + start_row: 0, + end_row: 5, + }); + expect(columns).toEqual({ + Sales: [261.96, 731.94, 14.62, 957.5775, 22.368], + Quantity: [2, 3, 2, 5, 2], + }); + await view.delete(); + }); + + test("column_paths()", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Profit", "State"], + }); + const paths = await view.column_paths(); + expect(paths).toEqual(["Sales", "Profit", "State"]); + await view.delete(); + }); +}); diff --git a/rust/perspective-js/test/js/duckdb/viewport.spec.js b/rust/perspective-js/test/js/duckdb/viewport.spec.js new file mode 100644 index 0000000000..9b46ee26c3 --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/viewport.spec.js @@ -0,0 +1,53 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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("viewport", (getClient) => { + test("start_row and end_row", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Profit"], + }); + const json = await view.to_json({ start_row: 10, end_row: 15 }); + expect(json).toEqual([ + { Sales: 1706.184, Profit: 85.3092 }, + { Sales: 911.424, Profit: 68.3568 }, + { Sales: 15.552, Profit: 5.4432 }, + { Sales: 407.976, Profit: 132.5922 }, + { Sales: 68.81, Profit: -123.858 }, + ]); + await view.delete(); + }); + + test("start_col and end_col", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Profit", "Quantity", "Discount"], + }); + const json = await view.to_json({ + start_row: 0, + end_row: 5, + start_col: 1, + end_col: 3, + }); + expect(json).toEqual([ + { Profit: 41.9136, Quantity: 2 }, + { Profit: 219.582, Quantity: 3 }, + { Profit: 6.8714, Quantity: 2 }, + { Profit: -383.031, Quantity: 5 }, + { Profit: 2.5164, Quantity: 2 }, + ]); + await view.delete(); + }); +}); diff --git a/rust/perspective-js/test/js/group_rollup_mode.spec.js b/rust/perspective-js/test/js/group_rollup_mode.spec.js index 2a99f8f6be..98ea661e74 100644 --- a/rust/perspective-js/test/js/group_rollup_mode.spec.js +++ b/rust/perspective-js/test/js/group_rollup_mode.spec.js @@ -483,5 +483,125 @@ const data = { table.delete(); }); }); + + test.describe("total", function () { + test.skip("returns only grand total with group_by", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_by: ["y"], + group_rollup_mode: "total", + }); + const json = await view.to_json(); + expect(json).toStrictEqual([ + { __ROW_PATH__: [], w: 40, x: 20, y: 8, z: 4 }, + ]); + view.delete(); + table.delete(); + }); + + test("returns only grand total schema", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_rollup_mode: "total", + }); + + const json = await view.schema(); + expect(json).toStrictEqual({ + w: "float", + x: "integer", + y: "integer", + z: "integer", + }); + + view.delete(); + table.delete(); + }); + + test("returns only grand total", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_rollup_mode: "total", + }); + + const json = await view.to_json(); + expect(json).toStrictEqual([ + { __ROW_PATH__: [], w: 40, x: 20, y: 8, z: 8 }, + ]); + + view.delete(); + table.delete(); + }); + + test("num_rows returns 1 without group_by", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_rollup_mode: "total", + }); + + const num_rows = await view.num_rows(); + expect(num_rows).toEqual(1); + view.delete(); + table.delete(); + }); + + test("to_columns works", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_rollup_mode: "total", + }); + + const cols = await view.to_columns(); + expect(cols).toStrictEqual({ + w: [40], + x: [20], + y: [8], + z: [8], + }); + + view.delete(); + table.delete(); + }); + + test("with split_by", async function () { + const table = await perspective.table(data); + const view = await table.view({ + split_by: ["z"], + group_rollup_mode: "total", + }); + const json = await view.to_json(); + expect(json).toStrictEqual([ + { + "false|w": 22, + "false|x": 10, + "false|y": 4, + "false|z": 4, + "true|w": 18, + "true|x": 10, + "true|y": 4, + "true|z": 4, + }, + ]); + view.delete(); + table.delete(); + }); + + test("updates after table.update()", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_rollup_mode: "total", + }); + const before = await view.to_json(); + expect(before).toStrictEqual([ + { __ROW_PATH__: [], w: 40, x: 20, y: 8, z: 8 }, + ]); + table.update([{ w: 10, x: 5, y: "e", z: true }]); + const after = await view.to_json(); + expect(after).toStrictEqual([ + { __ROW_PATH__: [], w: 50, x: 25, y: 9, z: 9 }, + ]); + view.delete(); + table.delete(); + }); + }); }); })(perspective); diff --git a/rust/perspective-python/perspective/tests/virtual_servers/test_polars.py b/rust/perspective-python/perspective/tests/virtual_servers/test_polars.py index a5b98b9bc9..ded5e8a839 100644 --- a/rust/perspective-python/perspective/tests/virtual_servers/test_polars.py +++ b/rust/perspective-python/perspective/tests/virtual_servers/test_polars.py @@ -673,6 +673,218 @@ def test_date_columns(self, client): view.delete() +class TestPolarsGroupRollupMode: + def test_flat_mode_with_group_by(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales"], + group_by=["Region"], + aggregates={"Sales": "sum"}, + group_rollup_mode="flat", + ) + num_rows = view.num_rows() + assert num_rows == 4 + json = view.to_json() + assert json == approx_json( + [ + {"__ROW_PATH__": ["Central"], "Sales": 501239.8908000005}, + {"__ROW_PATH__": ["East"], "Sales": 678781.2399999979}, + {"__ROW_PATH__": ["South"], "Sales": 391721.9050000003}, + {"__ROW_PATH__": ["West"], "Sales": 725457.8245000006}, + ] + ) + view.delete() + + def test_flat_mode_with_multi_level_group_by(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales"], + group_by=["Region", "Category"], + aggregates={"Sales": "sum"}, + group_rollup_mode="flat", + ) + num_rows = view.num_rows() + assert num_rows == 12 + json = view.to_json() + assert json == approx_json( + [ + {"__ROW_PATH__": ["Central", "Furniture"], "Sales": 163797.16380000004}, + { + "__ROW_PATH__": ["Central", "Office Supplies"], + "Sales": 167026.41500000027, + }, + {"__ROW_PATH__": ["Central", "Technology"], "Sales": 170416.3119999999}, + {"__ROW_PATH__": ["East", "Furniture"], "Sales": 208291.20400000009}, + { + "__ROW_PATH__": ["East", "Office Supplies"], + "Sales": 205516.0549999999, + }, + {"__ROW_PATH__": ["East", "Technology"], "Sales": 264973.9810000003}, + {"__ROW_PATH__": ["South", "Furniture"], "Sales": 117298.6840000001}, + { + "__ROW_PATH__": ["South", "Office Supplies"], + "Sales": 125651.31299999992, + }, + {"__ROW_PATH__": ["South", "Technology"], "Sales": 148771.9079999999}, + {"__ROW_PATH__": ["West", "Furniture"], "Sales": 252612.7435000003}, + { + "__ROW_PATH__": ["West", "Office Supplies"], + "Sales": 220853.24900000007, + }, + {"__ROW_PATH__": ["West", "Technology"], "Sales": 251991.83199999997}, + ] + ) + view.delete() + + def test_flat_mode_with_group_by_and_split_by(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales"], + group_by=["Category"], + split_by=["Region"], + aggregates={"Sales": "sum"}, + group_rollup_mode="flat", + ) + num_rows = view.num_rows() + assert num_rows == 3 + json = view.to_json() + assert json == approx_json( + [ + { + "__ROW_PATH__": ["Furniture"], + "Central|Sales": 163797.16380000004, + "East|Sales": 208291.20400000009, + "South|Sales": 117298.6840000001, + "West|Sales": 252612.7435000003, + }, + { + "__ROW_PATH__": ["Office Supplies"], + "Central|Sales": 167026.41500000027, + "East|Sales": 205516.0549999999, + "South|Sales": 125651.31299999992, + "West|Sales": 220853.24900000007, + }, + { + "__ROW_PATH__": ["Technology"], + "Central|Sales": 170416.3119999999, + "East|Sales": 264973.9810000003, + "South|Sales": 148771.9079999999, + "West|Sales": 251991.83199999997, + }, + ] + ) + view.delete() + + def test_flat_mode_with_group_by_and_split_by_and_sort(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales"], + group_by=["Category"], + split_by=["Region"], + sort=[["Sales", "desc"]], + aggregates={"Sales": "sum"}, + group_rollup_mode="flat", + ) + num_rows = view.num_rows() + assert num_rows == 3 + json = view.to_json() + assert json == approx_json( + [ + { + "__ROW_PATH__": ["Technology"], + "Central|Sales": 170416.3119999999, + "East|Sales": 264973.9810000003, + "South|Sales": 148771.9079999999, + "West|Sales": 251991.83199999997, + }, + { + "__ROW_PATH__": ["Furniture"], + "Central|Sales": 163797.16380000004, + "East|Sales": 208291.20400000009, + "South|Sales": 117298.6840000001, + "West|Sales": 252612.7435000003, + }, + { + "__ROW_PATH__": ["Office Supplies"], + "Central|Sales": 167026.41500000027, + "East|Sales": 205516.0549999999, + "South|Sales": 125651.31299999992, + "West|Sales": 220853.24900000007, + }, + ] + ) + view.delete() + + def test_flat_mode_with_group_by_and_sort(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales"], + group_by=["Region"], + sort=[["Sales", "desc"]], + aggregates={"Sales": "sum"}, + group_rollup_mode="flat", + ) + json = view.to_json() + assert json == approx_json( + [ + {"__ROW_PATH__": ["West"], "Sales": 725457.8245000006}, + {"__ROW_PATH__": ["East"], "Sales": 678781.2399999979}, + {"__ROW_PATH__": ["Central"], "Sales": 501239.8908000005}, + {"__ROW_PATH__": ["South"], "Sales": 391721.9050000003}, + ] + ) + view.delete() + + def test_total_mode(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales"], + aggregates={"Sales": "sum"}, + group_rollup_mode="total", + ) + num_rows = view.num_rows() + assert num_rows == 1 + json = view.to_json() + assert json == approx_json([{"Sales": 2297200.860299955}]) + view.delete() + + def test_total_mode_with_multiple_columns(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales", "Quantity"], + aggregates={"Sales": "sum", "Quantity": "sum"}, + group_rollup_mode="total", + ) + json = view.to_json() + assert json == approx_json( + [{"Sales": 2297200.860299955, "Quantity": 37873}] + ) + view.delete() + + def test_total_mode_with_split_by(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales"], + split_by=["Region"], + aggregates={"Sales": "sum"}, + group_rollup_mode="total", + ) + num_rows = view.num_rows() + assert num_rows == 1 + json = view.to_json() + assert json == approx_json( + [ + { + "Central|Sales": 501239.8908000005, + "East|Sales": 678781.2399999979, + "South|Sales": 391721.9050000003, + "West|Sales": 725457.8245000006, + } + ] + ) + view.delete() + + class TestPolarsCombinedOperations: def test_group_by_filter_sort(self, client): table = client.open_table("superstore") diff --git a/rust/perspective-python/perspective/virtual_servers/clickhouse.py b/rust/perspective-python/perspective/virtual_servers/clickhouse.py index 8961e48c9d..41917ad8b0 100644 --- a/rust/perspective-python/perspective/virtual_servers/clickhouse.py +++ b/rust/perspective-python/perspective/virtual_servers/clickhouse.py @@ -102,6 +102,7 @@ def get_features(self): "split_by": False, "sort": True, "expressions": True, + "group_rollup_mode": ["rollup", "flat", "total"], "filter_ops": { "integer": FILTER_OPS, "float": FILTER_OPS, diff --git a/rust/perspective-python/perspective/virtual_servers/duckdb.py b/rust/perspective-python/perspective/virtual_servers/duckdb.py index 77e122e816..4d4ff1672c 100644 --- a/rust/perspective-python/perspective/virtual_servers/duckdb.py +++ b/rust/perspective-python/perspective/virtual_servers/duckdb.py @@ -111,6 +111,7 @@ def get_features(self): "split_by": True, "sort": True, "expressions": True, + "group_rollup_mode": ["rollup", "flat", "total"], "filter_ops": { "integer": FILTER_OPS, "float": FILTER_OPS, diff --git a/rust/perspective-python/perspective/virtual_servers/polars.py b/rust/perspective-python/perspective/virtual_servers/polars.py index 891a524620..814901f259 100644 --- a/rust/perspective-python/perspective/virtual_servers/polars.py +++ b/rust/perspective-python/perspective/virtual_servers/polars.py @@ -96,6 +96,7 @@ def get_features(self): "split_by": True, "sort": True, "expressions": True, + "group_rollup_mode": ["rollup", "flat", "total"], "filter_ops": { "integer": FILTER_OPS, "float": FILTER_OPS, @@ -156,6 +157,7 @@ def table_make_view(self, table_name, view_name, config): filters = config.get("filter", []) split_by = config.get("split_by", []) expressions = config.get("expressions", {}) + group_rollup_mode = config.get("group_rollup_mode", "rollup") if expressions: for expr_name, expr_str in expressions.items(): @@ -165,18 +167,41 @@ def table_make_view(self, table_name, view_name, config): df = apply_filters(df, filters) col_alias = lambda c: c.replace("_", "-") + is_flat = group_rollup_mode == "flat" + is_total = group_rollup_mode == "total" - if split_by and group_by: - result = build_split_by_grouped( - df, group_by, split_by, columns, aggregates, col_alias - ) - result = apply_sort_grouped(result, sort, group_by, col_alias) + if is_total: + if split_by: + result = build_split_by_total( + df, split_by, columns, aggregates, col_alias + ) + else: + result = build_total(df, columns, aggregates, col_alias) + elif split_by and group_by: + if is_flat: + result = build_split_by_grouped_flat( + df, group_by, split_by, columns, aggregates, col_alias + ) + result = apply_sort_split_by_flat( + result, sort, columns, group_by, split_by + ) + else: + result = build_split_by_grouped( + df, group_by, split_by, columns, aggregates, col_alias + ) + result = apply_sort_grouped(result, sort, group_by, col_alias) elif split_by: result = build_split_by_flat(df, split_by, columns, col_alias) result = apply_sort_flat(result, sort, col_alias) elif group_by: - result = build_rollup(df, group_by, columns, aggregates, col_alias) - result = apply_sort_grouped(result, sort, group_by, col_alias) + if is_flat: + result = build_flat_group_by( + df, group_by, columns, aggregates, col_alias + ) + result = apply_sort_flat(result, sort, col_alias) + else: + result = build_rollup(df, group_by, columns, aggregates, col_alias) + result = apply_sort_grouped(result, sort, group_by, col_alias) else: select_exprs = [pl.col(c).alias(col_alias(c)) for c in columns] result = df.select(select_exprs) @@ -199,7 +224,9 @@ def view_get_data(self, view_name, config, schema, viewport, data): group_by = config.get("group_by", []) split_by = config.get("split_by", []) + group_rollup_mode = config.get("group_rollup_mode", "rollup") is_split_by = len(split_by) > 0 + is_flat = group_rollup_mode == "flat" start_row = viewport.get("start_row", 0) or 0 end_row = viewport.get("end_row") or df.height @@ -218,20 +245,21 @@ def view_get_data(self, view_name, config, schema, viewport, data): data_columns = data_columns[start_col:] has_group_by = len(group_by) > 0 + has_grouping_id = has_group_by and not is_flat all_cols = [] - if has_group_by: + if has_grouping_id: all_cols.append("__GROUPING_ID__") for idx in range(len(group_by)): all_cols.append(f"__ROW_PATH_{idx}__") all_cols.extend(data_columns) grouping_ids = None - if has_group_by: + if has_grouping_id: grouping_ids = df_slice["__GROUPING_ID__"].to_list() for cidx, col in enumerate(all_cols): - if cidx == 0 and has_group_by: + if cidx == 0 and has_grouping_id: continue series = df_slice[col] @@ -383,6 +411,109 @@ def build_rollup(df, group_by, columns, aggregates, col_alias): return result +def build_flat_group_by(df, group_by, columns, aggregates, col_alias): + """Build a simple GROUP BY (no rollup) - only leaf-level rows.""" + n = len(group_by) + data_columns = [c for c in columns if c not in group_by] + + agg_exprs = [] + for col in data_columns: + agg_name = aggregates.get(col, default_aggregate(col, df)) + agg_exprs.append(get_polars_agg_expr(col, agg_name).alias(col_alias(col))) + + grouped = df.group_by(group_by, maintain_order=True).agg(agg_exprs) + + for idx in range(n): + grouped = grouped.with_columns( + pl.col(group_by[idx]).alias(f"__ROW_PATH_{idx}__") + ) + + for gb_col in group_by: + if gb_col in grouped.columns: + grouped = grouped.drop(gb_col) + + path_cols = [f"__ROW_PATH_{i}__" for i in range(n)] + data_col_aliases = [col_alias(c) for c in data_columns] + final_order = path_cols + data_col_aliases + result = grouped.select([c for c in final_order if c in grouped.columns]) + return result.sort(path_cols) + + +def build_total(df, columns, aggregates, col_alias): + """Build a single total row aggregating the entire dataset.""" + agg_exprs = [] + for col in columns: + agg_name = aggregates.get(col, default_aggregate(col, df)) + agg_exprs.append(get_polars_agg_expr(col, agg_name).alias(col_alias(col))) + return df.select(agg_exprs) + + +def build_split_by_total(df, split_by, columns, aggregates, col_alias): + """Build a single total row with split_by (pivot) columns.""" + split_col = split_by[0] + data_columns = [c for c in columns if c not in split_by] + split_values = sorted(df[split_col].unique().to_list()) + + agg_exprs = [] + for sv in split_values: + filter_expr = pl.col(split_col) == sv + for dc in data_columns: + agg_name = aggregates.get(dc, default_aggregate(dc, df)) + col_name = f"{sv}_{col_alias(dc)}" + agg_exprs.append( + get_polars_agg_expr(dc, agg_name, filter_expr=filter_expr).alias( + col_name + ) + ) + + return df.select(agg_exprs) + + +def build_split_by_grouped_flat(df, group_by, split_by, columns, aggregates, col_alias): + """Build a flat grouped view with split_by (pivot) columns - no rollup rows.""" + n = len(group_by) + split_col = split_by[0] + data_columns = [c for c in columns if c not in group_by and c not in split_by] + split_values = sorted(df[split_col].unique().to_list()) + + agg_exprs = [] + for sv in split_values: + filter_expr = pl.col(split_col) == sv + for dc in data_columns: + agg_name = aggregates.get(dc, default_aggregate(dc, df)) + col_name = f"{sv}_{col_alias(dc)}" + agg_exprs.append( + get_polars_agg_expr(dc, agg_name, filter_expr=filter_expr).alias( + col_name + ) + ) + + for dc in data_columns: + agg_name = aggregates.get(dc, default_aggregate(dc, df)) + agg_exprs.append(get_polars_agg_expr(dc, agg_name).alias(f"__SORT_{dc}__")) + + grouped = df.group_by(group_by, maintain_order=True).agg(agg_exprs) + + for idx in range(n): + grouped = grouped.with_columns( + pl.col(group_by[idx]).alias(f"__ROW_PATH_{idx}__") + ) + + for gb_col in group_by: + if gb_col in grouped.columns: + grouped = grouped.drop(gb_col) + + path_cols = [f"__ROW_PATH_{i}__" for i in range(n)] + data_col_names = [] + for sv in split_values: + for dc in data_columns: + data_col_names.append(f"{sv}_{col_alias(dc)}") + sort_col_names = [f"__SORT_{dc}__" for dc in data_columns] + final_order = path_cols + data_col_names + sort_col_names + result = grouped.select([c for c in final_order if c in grouped.columns]) + return result.sort(path_cols) + + def apply_sort_grouped(df, sort_config, group_by, col_alias): """Apply sort to a ROLLUP result DataFrame.""" n = len(group_by) @@ -414,6 +545,31 @@ def apply_sort_grouped(df, sort_config, group_by, col_alias): return pl.concat([total_row, rest]) +def apply_sort_split_by_flat(df, sort_config, columns, group_by, split_by): + """Apply sort to a flat split_by grouped DataFrame using __SORT__ columns.""" + data_columns = [c for c in columns if c not in group_by and c not in split_by] + sort_cols = [] + sort_desc = [] + for entry in sort_config: + col = entry[0] + direction = entry[1] + if direction != "none": + sort_name = f"__SORT_{col}__" + if sort_name in df.columns: + sort_cols.append(sort_name) + sort_desc.append(direction in ("desc", "col desc")) + + if sort_cols: + df = df.sort(sort_cols, descending=sort_desc) + + drop_cols = [ + f"__SORT_{dc}__" for dc in data_columns if f"__SORT_{dc}__" in df.columns + ] + if drop_cols: + df = df.drop(drop_cols) + return df + + def apply_sort_flat(df, sort_config, col_alias): """Apply sort to a flat (non-grouped) DataFrame.""" if not sort_config: diff --git a/rust/perspective-server/cpp/perspective/src/cpp/context_one.cpp b/rust/perspective-server/cpp/perspective/src/cpp/context_one.cpp index 7d4f7a3939..6f4e26fcb7 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/context_one.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/context_one.cpp @@ -346,7 +346,9 @@ void t_ctx1::step_end() { PSP_TRACE_SENTINEL(); PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); - if (m_leaves_only) { + if (m_total_only) { + m_traversal->rebuild_for_total(); + } else if (m_leaves_only) { m_traversal->rebuild_from_leaves(m_sortby); } else { sort_by(m_sortby); @@ -436,6 +438,14 @@ t_ctx1::set_leaves_only(bool enabled) { m_traversal->set_leaves_only(enabled, m_config.get_num_rpivots()); } +void +t_ctx1::set_total_only(bool enabled) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + m_total_only = enabled; + m_traversal->set_total_only(enabled); +} + std::vector t_ctx1::get_pkeys(const std::vector>& cells ) const { diff --git a/rust/perspective-server/cpp/perspective/src/cpp/context_two.cpp b/rust/perspective-server/cpp/perspective/src/cpp/context_two.cpp index d4c5980500..28c8be68a3 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/context_two.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/context_two.cpp @@ -110,7 +110,9 @@ t_ctx2::step_begin() { void t_ctx2::step_end() { - if (m_leaves_only) { + if (m_total_only) { + m_rtraversal->rebuild_for_total(); + } else if (m_leaves_only) { m_rtraversal->rebuild_from_leaves(m_sortby); } else { if (m_row_depth_set) { @@ -945,6 +947,12 @@ t_ctx2::set_leaves_only(bool enabled) { m_rtraversal->set_leaves_only(enabled, m_config.get_num_rpivots()); } +void +t_ctx2::set_total_only(bool enabled) { + m_total_only = enabled; + m_rtraversal->set_total_only(enabled); +} + std::vector t_ctx2::get_pkeys(const std::vector>& cells ) const { diff --git a/rust/perspective-server/cpp/perspective/src/cpp/server.cpp b/rust/perspective-server/cpp/perspective/src/cpp/server.cpp index 0680f4d6a9..0b6ec117a2 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/server.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/server.cpp @@ -130,7 +130,9 @@ make_context( auto pool = table->get_pool(); auto gnode = table->get_gnode(); - if (view_config->is_leaves_only()) { + if (view_config->is_total_only()) { + ctx1->set_total_only(true); + } else if (view_config->is_leaves_only()) { ctx1->set_leaves_only(true); } else if (row_pivot_depth > -1) { ctx1->set_depth(row_pivot_depth - 1); @@ -195,7 +197,9 @@ make_context( ctx2->column_sort_by(col_sortspec); } - if (view_config->is_leaves_only() && !column_only) { + if (view_config->is_total_only()) { + ctx2->set_total_only(true); + } else if (view_config->is_leaves_only() && !column_only) { ctx2->set_leaves_only(true); } else if (row_pivot_depth > -1) { ctx2->set_depth(t_header::HEADER_ROW, row_pivot_depth - 1); @@ -1353,6 +1357,9 @@ ProtoServer::_handle_request(std::uint32_t client_id, Request&& req) { features->set_sort(true); features->set_on_update(true); features->set_expressions(true); + features->add_group_rollup_mode(proto::GroupRollupMode::ROLLUP); + features->add_group_rollup_mode(proto::GroupRollupMode::FLAT); + features->add_group_rollup_mode(proto::GroupRollupMode::TOTAL); proto::GetFeaturesResp_ColumnTypeOptions opts; opts.add_options("=="); opts.add_options("!="); @@ -1883,6 +1890,8 @@ ProtoServer::_handle_request(std::uint32_t client_id, Request&& req) { } bool column_only = false; + bool is_total = + cfg.has_group_rollup_mode() ? cfg.group_rollup_mode() == 2 : false; // make sure that primary keys are created for column-only views if (row_pivots.empty() && !column_pivots.empty()) { @@ -2097,6 +2106,8 @@ ProtoServer::_handle_request(std::uint32_t client_id, Request&& req) { bool leaves_only = cfg.has_group_rollup_mode() ? cfg.group_rollup_mode() == 1 : false; + bool total_only = + cfg.has_group_rollup_mode() ? cfg.group_rollup_mode() == 2 : false; auto config = std::make_shared( vocab, @@ -2109,7 +2120,8 @@ ProtoServer::_handle_request(std::uint32_t client_id, Request&& req) { expressions, filter_op, column_only, - leaves_only + leaves_only, + total_only ); config->init(schema); @@ -2125,6 +2137,8 @@ ProtoServer::_handle_request(std::uint32_t client_id, Request&& req) { } else { sides = 1; } + } else if (total_only) { + sides = 1; } else { sides = 0; } @@ -2414,11 +2428,14 @@ ProtoServer::_handle_request(std::uint32_t client_id, Request&& req) { ); } - if (view_config->is_leaves_only()) { - const auto mode = proto::ViewConfig_GroupRollupMode::ViewConfig_GroupRollupMode_FLAT; + if (view_config->is_total_only()) { + const auto mode = proto::GroupRollupMode::TOTAL; + view_config_proto->set_group_rollup_mode(mode); + } else if (view_config->is_leaves_only()) { + const auto mode = proto::GroupRollupMode::FLAT; view_config_proto->set_group_rollup_mode(mode); } else { - const auto mode = proto::ViewConfig_GroupRollupMode::ViewConfig_GroupRollupMode_ROLLUP; + const auto mode = proto::GroupRollupMode::ROLLUP; view_config_proto->set_group_rollup_mode(mode); } @@ -2580,7 +2597,7 @@ ProtoServer::_handle_request(std::uint32_t client_id, Request&& req) { r.index(), r.id(), view->sides(), - view->sides() > 0 && !config->is_column_only(), + view->sides() > 0 && !config->is_column_only() && !config->get_row_pivots().empty(), nidx, config->get_columns().size(), config->get_row_pivots().size() diff --git a/rust/perspective-server/cpp/perspective/src/cpp/traversal.cpp b/rust/perspective-server/cpp/perspective/src/cpp/traversal.cpp index 71a492d6f3..7838b1472b 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/traversal.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/traversal.cpp @@ -794,4 +794,30 @@ t_traversal::rebuild_from_leaves(const std::vector& sortby) { collect_leaves(0, 0, sortby, sortby_agg_indices, sort_orders); } +void +t_traversal::set_total_only(bool enabled) { + m_total_only = enabled; + if (m_total_only) { + rebuild_for_total(); + } +} + +bool +t_traversal::is_total_only() const { + return m_total_only; +} + +void +t_traversal::rebuild_for_total() { + m_nodes = std::make_shared>(); + t_tvnode node; + node.m_expanded = false; + node.m_depth = 0; + node.m_rel_pidx = 0; + node.m_ndesc = 0; + node.m_nchild = 0; + node.m_tnid = 0; + m_nodes->push_back(node); +} + } // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/tree_context_common.cpp b/rust/perspective-server/cpp/perspective/src/cpp/tree_context_common.cpp index 59863f93b6..eaba09e5dd 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/tree_context_common.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/tree_context_common.cpp @@ -72,8 +72,10 @@ notify_sparse_tree_common( bool is_leaves_only = process_traversal && traversal != nullptr && traversal->is_leaves_only(); + bool is_total_only = + process_traversal && traversal != nullptr && traversal->is_total_only(); - if (process_traversal && !is_leaves_only) { + if (process_traversal && !is_leaves_only && !is_total_only) { t_uindex t_osize = traversal->size(); traversal->drop_tree_indices(zero_strands); t_uindex t_nsize = traversal->size(); @@ -91,7 +93,9 @@ notify_sparse_tree_common( tree->update_aggs_from_static(dctx, gstate, expression_master_table); - if (is_leaves_only) { + if (is_total_only) { + traversal->rebuild_for_total(); + } else if (is_leaves_only) { traversal->rebuild_from_leaves(ctx_sortby); } else { std::set visited; diff --git a/rust/perspective-server/cpp/perspective/src/cpp/view.cpp b/rust/perspective-server/cpp/perspective/src/cpp/view.cpp index 545fad44e3..cd8c0b6a6f 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/view.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/view.cpp @@ -74,7 +74,10 @@ View::View( } // configure data window for `get_data` and `row_delta` - is_column_only() ? m_row_offset = 1 : m_row_offset = 0; + // Column-only views skip the grand total row (offset=1), but + // total_only mode needs to return exactly that row. + is_column_only() && !m_view_config->is_total_only() ? m_row_offset = 1 + : m_row_offset = 0; // TODO: make sure is 0 for column only - right now get_data returns row // path for everything @@ -127,7 +130,7 @@ View::sides() const { template std::int32_t View::num_rows() const { - if (is_column_only()) { + if (is_column_only() && !m_view_config->is_total_only()) { return m_ctx->get_row_count() - 1; } return m_ctx->get_row_count(); @@ -458,7 +461,7 @@ View::schema() const { std::string type_string = dtype_to_str(types[agg_name]); new_schema[agg_name] = type_string; - if (!m_row_pivots.empty() && !is_column_only()) { + if ((!m_row_pivots.empty() || m_view_config->is_total_only()) && !is_column_only()) { new_schema[agg_name] = _map_aggregate_types(agg_name, new_schema[agg_name]); } @@ -537,7 +540,7 @@ View::expression_schema() const { const std::string& expression_alias = expr->get_expression_alias(); new_schema[expression_alias] = dtype_to_str(expr->get_dtype()); - if (!m_row_pivots.empty() && !is_column_only()) { + if ((!m_row_pivots.empty() || m_view_config->is_total_only()) && !is_column_only()) { new_schema[expression_alias] = _map_aggregate_types( expression_alias, new_schema[expression_alias] ); @@ -2572,7 +2575,7 @@ View::to_columns( rapidjson::StringBuffer s; rapidjson::Writer writer(s); writer.StartObject(); - write_row_path(start_row, end_row, true, is_formatted, writer); + write_row_path(start_row, end_row, has_row_path, is_formatted, writer); if (get_ids) { writer.Key("__ID__"); writer.StartArray(); diff --git a/rust/perspective-server/cpp/perspective/src/cpp/view_config.cpp b/rust/perspective-server/cpp/perspective/src/cpp/view_config.cpp index be50f1a5ed..e06c981d47 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/view_config.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/view_config.cpp @@ -29,7 +29,8 @@ t_view_config::t_view_config( const std::vector>& expressions, std::string filter_op, bool column_only, - bool leaves_only + bool leaves_only, + bool total_only ) : m_init(false), m_vocab(std::move(vocab)), @@ -44,7 +45,8 @@ t_view_config::t_view_config( m_column_pivot_depth(-1), m_filter_op(std::move(filter_op)), m_column_only(column_only), - m_leaves_only(leaves_only) {} + m_leaves_only(leaves_only), + m_total_only(total_only) {} void t_view_config::init(const std::shared_ptr& schema) { @@ -272,6 +274,11 @@ t_view_config::is_leaves_only() const { return m_leaves_only; } +bool +t_view_config::is_total_only() const { + return m_total_only; +} + std::int32_t t_view_config::get_row_pivot_depth() const { PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); @@ -309,8 +316,9 @@ t_view_config::fill_aggspecs(const std::shared_ptr& schema) { // Generate a default aggregate based on the column type. std::vector dependencies{t_dep(column, DEPTYPE_COLUMN)}; t_aggtype agg_type; - m_column_only ? agg_type = AGGTYPE_ANY - : agg_type = _get_default_aggregate(dtype); + (m_column_only && !m_total_only) + ? agg_type = AGGTYPE_ANY + : agg_type = _get_default_aggregate(dtype); m_aggspecs.emplace_back(column, agg_type, dependencies); m_aggregate_names.push_back(column); } @@ -333,7 +341,8 @@ t_view_config::fill_aggspecs(const std::shared_ptr& schema) { m_column_pivots.begin(), m_column_pivots.end(), column ) != m_column_pivots.end(); - bool is_column_only = m_row_pivots.empty() || m_column_only; + bool is_column_only = + (m_row_pivots.empty() || m_column_only) && !m_total_only; bool is_row_sort = sort[1].rfind("col", 0) != 0; std::vector dependencies{t_dep(column, DEPTYPE_COLUMN)}; @@ -435,7 +444,7 @@ t_view_config::make_aggspec( std::vector dependencies{t_dep(column, DEPTYPE_COLUMN)}; dependencies.reserve(2); - if (m_column_only) { + if (m_column_only && !m_total_only) { agg_type = t_aggtype::AGGTYPE_ANY; } else { if (aggregate.at(0) == "weighted mean") { diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/context_one.h b/rust/perspective-server/cpp/perspective/src/include/perspective/context_one.h index 46c496928f..6adf0807ca 100644 --- a/rust/perspective-server/cpp/perspective/src/include/perspective/context_one.h +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/context_one.h @@ -44,6 +44,7 @@ class PERSPECTIVE_EXPORT t_ctx1 : public t_ctxbase { std::vector get_row_path(t_index idx) const; void set_depth(t_depth depth); void set_leaves_only(bool enabled); + void set_total_only(bool enabled); t_index get_row_idx(const std::vector& path) const; @@ -62,6 +63,7 @@ class PERSPECTIVE_EXPORT t_ctx1 : public t_ctxbase { t_depth m_depth; bool m_depth_set; bool m_leaves_only = false; + bool m_total_only = false; }; } // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/context_two.h b/rust/perspective-server/cpp/perspective/src/include/perspective/context_two.h index 0a5eb6c988..1d5942b5fb 100644 --- a/rust/perspective-server/cpp/perspective/src/include/perspective/context_two.h +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/context_two.h @@ -56,6 +56,7 @@ class PERSPECTIVE_EXPORT t_ctx2 : public t_ctxbase { void set_depth(t_header header, t_depth depth); void set_leaves_only(bool enabled); + void set_total_only(bool enabled); std::pair get_min_max(const std::string& colname ) const; @@ -95,6 +96,7 @@ class PERSPECTIVE_EXPORT t_ctx2 : public t_ctxbase { bool m_column_depth_set; std::shared_ptr m_expression_tables; bool m_leaves_only = false; + bool m_total_only = false; }; } // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/traversal.h b/rust/perspective-server/cpp/perspective/src/include/perspective/traversal.h index 00fb379cc9..2a9bdb0810 100644 --- a/rust/perspective-server/cpp/perspective/src/include/perspective/traversal.h +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/traversal.h @@ -136,6 +136,10 @@ class t_traversal { bool is_leaves_only() const; void rebuild_from_leaves(const std::vector& sortby); + void set_total_only(bool enabled); + bool is_total_only() const; + void rebuild_for_total(); + private: void collect_leaves( t_uindex tnid, @@ -149,6 +153,7 @@ class t_traversal { std::shared_ptr> m_nodes; bool m_leaves_only = false; t_uindex m_leaf_depth = 0; + bool m_total_only = false; }; /** diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/view_config.h b/rust/perspective-server/cpp/perspective/src/include/perspective/view_config.h index 8c0bc35695..5a334b5def 100644 --- a/rust/perspective-server/cpp/perspective/src/include/perspective/view_config.h +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/view_config.h @@ -58,7 +58,8 @@ class PERSPECTIVE_EXPORT t_view_config { const std::vector>& expressions, std::string filter_op, bool column_only, - bool leaves_only = false + bool leaves_only = false, + bool total_only = false ); /** @@ -121,6 +122,7 @@ class PERSPECTIVE_EXPORT t_view_config { bool is_column_only() const; bool is_leaves_only() const; + bool is_total_only() const; std::int32_t get_row_pivot_depth() const; std::int32_t get_column_pivot_depth() const; @@ -242,5 +244,6 @@ class PERSPECTIVE_EXPORT t_view_config { */ bool m_column_only; bool m_leaves_only; + bool m_total_only; }; } // end namespace perspective \ No newline at end of file diff --git a/rust/perspective-viewer/build.mjs b/rust/perspective-viewer/build.mjs index a403361841..45736c34d9 100644 --- a/rust/perspective-viewer/build.mjs +++ b/rust/perspective-viewer/build.mjs @@ -127,6 +127,7 @@ async function build_all() { add(builder, "icons.less"); add(builder, "pro.less"); add(builder, "pro-dark.less"); + add(builder, "botanical.less"); add(builder, "monokai.less"); add(builder, "solarized.less"); add(builder, "solarized-dark.less"); diff --git a/rust/perspective-viewer/src/less/column-selector.less b/rust/perspective-viewer/src/less/column-selector.less index 5a041378e7..e0d14c523a 100644 --- a/rust/perspective-viewer/src/less/column-selector.less +++ b/rust/perspective-viewer/src/less/column-selector.less @@ -317,11 +317,11 @@ @include scrollbar; } - #sub-columns:before { + #sub-columns .scroll-panel-container:before { font-size: var(--label--font-size, 0.75em); padding: var(--column_type--padding, 0px 0px 0px 0px); position: absolute; - margin-top: 14px; + margin-top: -13px; top: 0; content: var(--all-columns-label--content, "All Columns"); } diff --git a/rust/perspective-viewer/src/less/config-selector.less b/rust/perspective-viewer/src/less/config-selector.less index 08d6dfd54f..7d1b07048b 100644 --- a/rust/perspective-viewer/src/less/config-selector.less +++ b/rust/perspective-viewer/src/less/config-selector.less @@ -30,6 +30,44 @@ } } + #top_panel.group-rollup-mode-total { + #group_by { + width: 100%; + // height: 26px; + .pivot-column { + .pivot-column-total { + min-height: 24px; + margin-bottom: 4px; + &:before { + background-color: var(--plugin--background); + } + } + + .column_name { + color: var(--inactive--color); + } + + .type-icon { + background-color: var(--inactive--color); + } + + &:hover .pivot-column-border { + border-color: var(--inactive--color, #ababab); + } + + // input { + // background-color: var(--plugin--background); + // pointer-events: none; + // border: 1px solid var(--inactive--color); + // color: var(--inactive--color) !important; + // // &:placeholder { + + // // } + // } + } + } + } + #top_panel { display: flex; flex-direction: column; diff --git a/rust/perspective-viewer/src/rust/components/column_selector.rs b/rust/perspective-viewer/src/rust/components/column_selector.rs index d590cf8168..6911442bff 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector.rs @@ -28,6 +28,7 @@ use std::rc::Rc; pub use empty_column::*; pub use invalid_column::*; use perspective_js::utils::ApiFuture; +pub use pivot_column::*; use web_sys::*; use yew::prelude::*; @@ -77,6 +78,7 @@ pub enum ColumnSelectorMsg { TableLoaded, ViewCreated, HoverActiveIndex(Option), + SetWidth(f64), Drag(DragEffect), DragEnd, Drop((String, DragTarget, DragEffect, usize)), @@ -91,6 +93,7 @@ pub struct ColumnSelector { named_row_count: usize, drag_container: DragDropContainer, column_dropdown: ColumnDropDownElement, + viewport_width: f64, on_reset: Rc>, } @@ -147,6 +150,7 @@ impl Component for ColumnSelector { Self { _subscriptions: [table_sub, view_sub, drop_sub, drag_sub, dragend_sub], named_row_count, + viewport_width: 0f64, drag_container, column_dropdown, on_reset: Default::default(), @@ -157,6 +161,10 @@ impl Component for ColumnSelector { match msg { Drag(DragEffect::Move(DragTarget::Active)) => false, Drag(_) | DragEnd | TableLoaded => true, + SetWidth(w) => { + self.viewport_width = w; + false + }, ViewCreated => { let named = maybe! { let plugin = @@ -385,8 +393,8 @@ impl Component for ColumnSelector { inactive_children.insert(0, add_column); } - let selected_columns = html! { -
+ let mut selected_columns = vec![html! { +
>()} />
- }; + }]; + + if !inactive_children.is_empty() { + selected_columns.push(html! { + + }) + } html! { <> @@ -411,15 +433,7 @@ impl Component for ColumnSelector { skip_empty=true orientation={Orientation::Vertical} > - { selected_columns } - if !inactive_children.is_empty() { - - } + { for selected_columns } } diff --git a/rust/perspective-viewer/src/rust/components/column_selector/config_selector.rs b/rust/perspective-viewer/src/rust/components/column_selector/config_selector.rs index 4e1e4b282e..654fcc7211 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector/config_selector.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector/config_selector.rs @@ -64,6 +64,7 @@ pub enum ConfigSelectorMsg { TransposePivots, ViewCreated, New(DragTarget, InPlaceColumn), + UpdateGroupRollupMode(GroupRollupMode), } #[derive(Clone)] @@ -136,11 +137,9 @@ impl Component for ConfigSelector { ctx.props().onselect.emit(()); false }, - ConfigSelectorMsg::Close(index, DragTarget::GroupBy) => { - let mut group_by = ctx.props().session.get_view_config().group_by.clone(); - group_by.remove(index); + ConfigSelectorMsg::UpdateGroupRollupMode(mode) => { let config = ViewConfigUpdate { - group_by: Some(group_by), + group_rollup_mode: Some(mode), ..ViewConfigUpdate::default() }; @@ -149,9 +148,45 @@ impl Component for ConfigSelector { .map(ApiFuture::spawn) .unwrap_or_log(); - ctx.props().onselect.emit(()); false }, + ConfigSelectorMsg::Close(index, DragTarget::GroupBy) => { + if ctx.props().session.get_view_config().group_rollup_mode == GroupRollupMode::Total + { + let requirements = ctx.props().renderer.metadata(); + + let rollup_features = ctx + .props() + .session + .metadata() + .get_features() + .map(|x| x.get_group_rollup_modes()) + .unwrap(); + + let group_rollups = requirements.get_group_rollups(&rollup_features); + + ctx.link() + .send_message(ConfigSelectorMsg::UpdateGroupRollupMode( + group_rollups.first().cloned().unwrap(), + )); + false + } else { + let mut group_by = ctx.props().session.get_view_config().group_by.clone(); + group_by.remove(index); + let config = ViewConfigUpdate { + group_by: Some(group_by), + ..ViewConfigUpdate::default() + }; + + ctx.props() + .update_and_render(config) + .map(ApiFuture::spawn) + .unwrap_or_log(); + + ctx.props().onselect.emit(()); + false + } + }, ConfigSelectorMsg::Close(index, DragTarget::SplitBy) => { let mut split_by = ctx.props().session.get_view_config().split_by.clone(); split_by.remove(index); @@ -228,6 +263,7 @@ impl Component for ConfigSelector { ctx.props().onselect.emit(()); false }, + ConfigSelectorMsg::SetFilterValue(index, input) => { let mut filter = ctx.props().session.get_view_config().filter.clone(); @@ -438,11 +474,15 @@ impl Component for ConfigSelector { let config = session.get_view_config(); let transpose = ctx.link().callback(|_| ConfigSelectorMsg::TransposePivots); let column_dropdown = self.column_dropdown.clone(); - let class = if dragdrop.get_drag_column().is_some() { - "dragdrop-highlight" - } else { - "" - }; + let mut class = classes!(); + + if dragdrop.get_drag_column().is_some() { + class.push("dragdrop-highlight"); + } + + if config.group_rollup_mode == GroupRollupMode::Total { + class.push("group-rollup-mode-total"); + } let dragend = Callback::from({ let dragdrop = dragdrop.clone(); @@ -452,57 +492,51 @@ impl Component for ConfigSelector { let metadata = session.metadata(); let features = metadata.get_features().unwrap(); let requirements = renderer.metadata(); + let on_group_rollup_mode = ctx + .link() + .callback(ConfigSelectorMsg::UpdateGroupRollupMode); - let on_group_rollup_mode = Callback::from({ - let props = ctx.props().clone(); - move |x| { - let config = ViewConfigUpdate { - group_rollup_mode: Some(x), - ..ViewConfigUpdate::default() - }; + let rollup_features = ctx + .props() + .session + .metadata() + .get_features() + .map(|x| x.get_group_rollup_modes()) + .unwrap(); - props - .update_and_render(config) - .map(ApiFuture::spawn) - .unwrap_or_log(); - } - }); + let group_rollups = requirements.get_group_rollups(&rollup_features); html! {
- if !config.group_by.is_empty() { - if requirements.group_rollups.as_ref().map(|x| x.len()).unwrap_or_default() > 1 { - - id="group_rollup_mode_selector" - wrapper_class="group_rollup_wrapper" - values={Rc::new( - requirements - .group_rollups - .as_ref() - .unwrap() + if group_rollups.len() > 1 { + + id="group_rollup_mode_selector" + wrapper_class="group_rollup_wrapper" + values={Rc::new( + group_rollups .iter() .map(|x| SelectItem::Option(*x)) .collect(), )} - selected={config.group_rollup_mode} - on_select={on_group_rollup_mode} - /> - } - if config.split_by.is_empty() { - - } + selected={config.group_rollup_mode} + on_select={on_group_rollup_mode} + /> + } + if !config.group_by.is_empty() && config.split_by.is_empty() { + }
if features.group_by { >()} @@ -515,7 +549,7 @@ impl Component for ConfigSelector { action={DragTarget::GroupBy} column={group_by.clone()} {dragdrop} - {session} + opt_session={session} > } @@ -546,9 +580,8 @@ impl Component for ConfigSelector { + opt_session={session}> } }) } diff --git a/rust/perspective-viewer/src/rust/components/column_selector/pivot_column.rs b/rust/perspective-viewer/src/rust/components/column_selector/pivot_column.rs index 5812f875eb..71ecedab51 100644 --- a/rust/perspective-viewer/src/rust/components/column_selector/pivot_column.rs +++ b/rust/perspective-viewer/src/rust/components/column_selector/pivot_column.rs @@ -26,11 +26,15 @@ pub struct PivotColumnProps { /// Column name. pub column: String, + #[prop_or_default] + pub column_type: Option, + /// The drag starte of this column, if applicable. pub action: DragTarget, // State - pub session: Session, + #[prop_or_default] + pub opt_session: Option, pub dragdrop: DragDrop, } @@ -74,12 +78,13 @@ impl Component for PivotColumn { move |_event| dragdrop.notify_drag_end() }); - let col_type = ctx - .props() - .session - .metadata() - .get_column_table_type(&ctx.props().column) - .unwrap_or(ColumnType::Integer); + let col_type = ctx.props().column_type.unwrap_or_else(|| { + ctx.props() + .opt_session + .as_ref() + .and_then(|x| x.metadata().get_column_table_type(&ctx.props().column)) + .unwrap_or(ColumnType::Integer) + }); html! {
::Properties: DragDropListItemProps, { pub parent: Scope, + pub dragdrop: DragDrop, pub name: &'static str, pub column_dropdown: ColumnDropDownElement, pub exclude: HashSet, pub children: ChildrenWithProps, + #[prop_or_default] + pub disabled: bool, + #[prop_or_default] pub is_dragover: Option<( usize, @@ -59,6 +65,7 @@ where && self.children == other.children && self.allow_duplicates == other.allow_duplicates && self.is_dragover == other.is_dragover + && self.disabled == other.disabled } } @@ -283,20 +290,34 @@ where let column_dropdown = ctx.props().column_dropdown.clone(); let exclude = ctx.props().exclude.clone(); let on_select = ctx.props().parent.callback(V::create); + let class = classes!("rrow"); + let is_enabled = true; + html! { -
+
    { columns_html } - if ctx.props().is_dragover.is_none() | (!invalid_drag && valid_duplicate_drag) { + if ctx.props().disabled && ctx.props().is_dragover.is_none() { +
    +
    + + { "TOTAL" } +
    + +
    + } else if ctx.props().is_dragover.is_none() | (!invalid_drag && valid_duplicate_drag) { } else if invalid_drag { diff --git a/rust/perspective-viewer/src/rust/components/containers/scroll_panel.rs b/rust/perspective-viewer/src/rust/components/containers/scroll_panel.rs index cff0c99fb5..b9f29e6069 100644 --- a/rust/perspective-viewer/src/rust/components/containers/scroll_panel.rs +++ b/rust/perspective-viewer/src/rust/components/containers/scroll_panel.rs @@ -34,6 +34,9 @@ pub struct ScrollPanelProps { #[prop_or_default] pub viewport_ref: Option, + #[prop_or_default] + pub initial_width: Option, + #[prop_or_default] pub class: Classes, @@ -52,6 +55,9 @@ pub struct ScrollPanelProps { #[prop_or_default] pub on_resize: Option>>, + #[prop_or_default] + pub on_auto_width: Callback, + #[prop_or_default] pub on_dimensions_reset: Option>>, @@ -125,7 +131,7 @@ impl Component for ScrollPanel { Self { viewport_ref: Default::default(), viewport_height: 0f64, - viewport_width: 0f64, + viewport_width: ctx.props().initial_width.unwrap_or_default(), content_window: None, needs_rerender: true, total_height, @@ -150,6 +156,7 @@ impl Component for ScrollPanel { self.viewport_height = rect.height() - 8.0; self.viewport_width = self.viewport_width.max(rect.width() - 6.0); + ctx.props().on_auto_width.emit(self.viewport_width); re_render }, ScrollPanelMsg::CalculateWindowContent => self.calculate_window_content(ctx), diff --git a/rust/perspective-viewer/src/rust/components/containers/split_panel.rs b/rust/perspective-viewer/src/rust/components/containers/split_panel.rs index d504135a2b..2fe4b4b709 100644 --- a/rust/perspective-viewer/src/rust/components/containers/split_panel.rs +++ b/rust/perspective-viewer/src/rust/components/containers/split_panel.rs @@ -201,11 +201,11 @@ impl Component for SplitPanel { let count = iter.len(); let contents = html! { <> - + for (i, x) in iter { if i == 0 { if count == 1 { - + {x} } else { diff --git a/rust/perspective-viewer/src/rust/components/plugin_selector.rs b/rust/perspective-viewer/src/rust/components/plugin_selector.rs index 219bdad458..cb44d0d2b5 100644 --- a/rust/perspective-viewer/src/rust/components/plugin_selector.rs +++ b/rust/perspective-viewer/src/rust/components/plugin_selector.rs @@ -85,12 +85,15 @@ impl Component for PluginSelector { let prev_metadata = renderer.metadata(); let requirements = metadata.as_ref().unwrap_or(&*prev_metadata); + let rollup_features = session + .metadata() + .get_features() + .map(|x| x.get_group_rollup_modes()) + .unwrap(); + + let group_rollups = requirements.get_group_rollups(&rollup_features); let mut update = ViewConfigUpdate { - group_rollup_mode: requirements - .group_rollups - .as_ref() - .and_then(|x| x.first()) - .cloned(), + group_rollup_mode: group_rollups.first().cloned(), ..ViewConfigUpdate::default() }; diff --git a/rust/perspective-viewer/src/rust/js/plugin.rs b/rust/perspective-viewer/src/rust/js/plugin.rs index 973e553998..991fe40654 100644 --- a/rust/perspective-viewer/src/rust/js/plugin.rs +++ b/rust/perspective-viewer/src/rust/js/plugin.rs @@ -150,7 +150,7 @@ pub struct ViewConfigRequirements { pub max_cells: Option, pub name: String, pub render_warning: bool, - pub group_rollups: Option>, + group_rollups: Option>, } impl ViewConfigRequirements { @@ -160,6 +160,17 @@ impl ViewConfigRequirements { .map(|x| index < x.len() - 1) .unwrap_or(false) } + + pub fn get_group_rollups(&self, rollup_features: &[GroupRollupMode]) -> Vec { + self.group_rollups + .clone() + .map(|x| { + x.into_iter() + .filter(|y| rollup_features.is_empty() || rollup_features.contains(y)) + .collect() + }) + .unwrap_or_default() + } } impl JsPerspectiveViewerPlugin { 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 74ee6b45d7..16e755d826 100644 --- a/rust/perspective-viewer/src/rust/session/column_defaults_update.rs +++ b/rust/perspective-viewer/src/rust/session/column_defaults_update.rs @@ -31,24 +31,18 @@ pub impl ViewConfigUpdate { columns: &[Option], requirements: &ViewConfigRequirements, ) { - if requirements - .group_rollups - .as_ref() - .map(|x| { - !x.contains( - self.group_rollup_mode - .as_ref() - .unwrap_or(&GroupRollupMode::Rollup), - ) - }) - .unwrap_or_default() - { - self.group_rollup_mode = requirements - .group_rollups - .as_ref() - .and_then(|x| x.first()) - .cloned(); + let rollup_features = metadata + .get_features() + .map(|x| x.get_group_rollup_modes()) + .unwrap_or_default(); + let group_rollups = requirements.get_group_rollups(&rollup_features); + if !group_rollups.contains( + self.group_rollup_mode + .as_ref() + .unwrap_or(&GroupRollupMode::Rollup), + ) { + self.group_rollup_mode = group_rollups.first().cloned(); tracing::error!( "Setting plugin-advised rollup mode {:?}", self.group_rollup_mode diff --git a/rust/perspective-viewer/src/themes/botanical.less b/rust/perspective-viewer/src/themes/botanical.less new file mode 100644 index 0000000000..74b986d1a7 --- /dev/null +++ b/rust/perspective-viewer/src/themes/botanical.less @@ -0,0 +1,142 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 "icons.less"; +@import "intl.less"; + +@import url("ref://pro.less"); + +// Register theme for auto-detection +perspective-viewer, +perspective-viewer[theme="Botanical"] { + --theme-name: "Botanical"; +} + +perspective-viewer[theme="Botanical"] { + @include perspective-viewer-botanical; +} + +perspective-copy-menu[theme="Botanical"], +perspective-export-menu[theme="Botanical"], +perspective-dropdown[theme="Botanical"], +perspective-date-column-style[theme="Botanical"], +perspective-datetime-column-style[theme="Botanical"], +perspective-number-column-style[theme="Botanical"], +perspective-string-column-style[theme="Botanical"] { + @include perspective-modal-botanical; +} + +@mixin perspective-viewer-botanical { + @include perspective-viewer-pro; + @include perspective-viewer-botanical--colors; + @include perspective-viewer-botanical--datagrid; + @include perspective-viewer-botanical--d3fc; + @include perspective-viewer-botanical--openlayers; +} + +@mixin perspective-modal-botanical { + @include perspective-modal-pro; + @include perspective-viewer-botanical--colors; + + background-color: #1a2e1a; + border: 1px solid #3d5c3d; +} + +@mixin perspective-viewer-botanical--colors { + background-color: #1a2e1a; + color: #e0ead8; + --icon--color: #e0ead8; + --active--color: #5a9e4b; + --error--color: #e8836a; + --inactive--color: #526b4a; + --inactive--border-color: #3d5c3d; + --plugin--background: #1a2e1a; + --modal-target--background: rgba(224, 234, 216, 0.05); + --active--background: rgba(90, 158, 75, 0.5); + --expression--operator-color: #b8c9ad; + --expression--function-color: #7bc96f; + --expression--error-color: rgb(232, 131, 106); + --calendar--filter: invert(1); + --warning--color: #1a2e1a; + --warning--background: var(--icon--color); + + --select-arrow--background-image: var( + --select-arrow-light--background-image + ); + + --select-arrow--hover--background-image: var( + --select-arrow-dark--background-image + ); + + // Syntax + --code-editor-symbol--color: #e0ead8; + --code-editor-literal--color: #a8d8a0; + --code-editor-operator--color: rgb(206, 176, 104); + --code-editor-comment--color: rgb(120, 160, 100); + --code-editor-column--color: #c9a0d8; +} + +@mixin perspective-viewer-botanical--openlayers { + --map-tile-url: "http://{a-c}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"; + --map-attribution--filter: invert(1) hue-rotate(180deg); + --map-element-background: #1e3420; + --map-category-1: rgb(90, 158, 75); + --map-category-2: rgb(206, 176, 104); + --map-category-3: rgb(160, 110, 180); + --map-category-4: rgb(80, 170, 150); + --map-category-5: rgb(140, 170, 90); + --map-category-6: rgb(200, 120, 140); + --map-category-7: rgb(100, 150, 190); + --map-category-8: rgb(210, 140, 80); + --map-category-9: rgb(130, 120, 190); + --map-category-10: rgb(170, 200, 110); + --map-gradient: linear-gradient(#e8836a 0%, #1a2e1a 50%, #5a9e4b 100%); +} + +@mixin perspective-viewer-botanical--datagrid { + --rt-pos-cell--color: #7bc96f; + --rt-neg-cell--color: #ebac21; +} + +@mixin perspective-viewer-botanical--d3fc { + --d3fc-legend--text: #b8c9ad; + --d3fc-treedata--labels: #e0ead8; + --d3fc-treedata--hover-highlight: #e0ead8; + --d3fc-tooltip--color: #e0ead8; + --d3fc-axis-ticks--color: #b8c9ad; + --d3fc-axis--lines: #526b4a; + --d3fc-gridline--color: #2a4228; + --d3fc-tooltip--background: rgba(30, 52, 32, 1); + --d3fc-tooltip--border-color: #1a2e1a; + --d3fc-legend--background: var(--plugin--background); + + --d3fc-series: rgb(90, 158, 75); + --d3fc-series-1: rgb(90, 158, 75); + --d3fc-series-2: rgb(206, 176, 104); + --d3fc-series-3: rgb(160, 110, 180); + --d3fc-series-4: rgb(80, 170, 150); + --d3fc-series-5: rgb(140, 170, 90); + --d3fc-series-6: rgb(200, 120, 140); + --d3fc-series-7: rgb(100, 150, 190); + --d3fc-series-8: rgb(210, 140, 80); + --d3fc-series-9: rgb(130, 120, 190); + --d3fc-series-10: rgb(170, 200, 110); + + --d3fc-full--gradient: linear-gradient( + #e8836a 0%, + #1a2e1a 50%, + #5a9e4b 100% + ); + + --d3fc-positive--gradient: linear-gradient(#1a2e1a 0%, #5a9e4b 100%); + --d3fc-negative--gradient: linear-gradient(#e8836a 0%, #1a2e1a 100%); +} diff --git a/rust/perspective-viewer/src/themes/themes.less b/rust/perspective-viewer/src/themes/themes.less index ef70b6015d..13cb32aa5a 100644 --- a/rust/perspective-viewer/src/themes/themes.less +++ b/rust/perspective-viewer/src/themes/themes.less @@ -18,4 +18,5 @@ @import "vaporwave.less"; @import "gruvbox.less"; @import "gruvbox-dark.less"; -@import "dracula.less"; \ No newline at end of file +@import "dracula.less"; +@import "botanical.less"; \ No newline at end of file diff --git a/tools/test/results.tar.gz b/tools/test/results.tar.gz index 31f2865855..564106469f 100644 Binary files a/tools/test/results.tar.gz and b/tools/test/results.tar.gz differ