diff --git a/rust/perspective-client/src/rust/virtual_server/data.rs b/rust/perspective-client/src/rust/virtual_server/data.rs index 1d80c4f6c1..45620b9683 100644 --- a/rust/perspective-client/src/rust/virtual_server/data.rs +++ b/rust/perspective-client/src/rust/virtual_server/data.rs @@ -204,9 +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 - || !self.0.split_by.is_empty() - { + if grouping_id.map(|x| x as i32).unwrap_or(i32::MAX) < 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/generic_sql_model.rs b/rust/perspective-client/src/rust/virtual_server/generic_sql_model.rs index f7103b6e67..286df3075b 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 @@ -33,13 +33,19 @@ // - Would like to add a metadata API so that e.g. Viewer debug panel could show // internal generated SQL. +mod table_make_view; + +#[cfg(test)] +mod tests; + use std::fmt; use indexmap::IndexMap; use serde::Deserialize; -use crate::config::{Aggregate, FilterTerm, Scalar, Sort, SortDir, ViewConfig}; +use crate::config::{FilterTerm, Scalar, Sort, SortDir, ViewConfig}; use crate::proto::{ColumnType, ViewPort}; +use crate::virtual_server::generic_sql_model::table_make_view::ViewQueryContext; /// Error type for SQL generation operations. #[derive(Debug, Clone)] @@ -83,7 +89,6 @@ pub struct GenericSQLVirtualServerModel(GenericSQLVirtualServerModelArgs); impl GenericSQLVirtualServerModel { /// Creates a new `GenericSQLVirtualServerModel` instance. pub fn new(args: GenericSQLVirtualServerModelArgs) -> Self { - tracing::error!("{:?}", args); Self(args) } @@ -175,201 +180,8 @@ impl GenericSQLVirtualServerModel { view_id: &str, config: &ViewConfig, ) -> GenericSQLResult { - let columns = &config.columns; - let group_by = &config.group_by; - let split_by = &config.split_by; - let aggregates = &config.aggregates; - let sort = &config.sort; - let expressions = &config.expressions.0; - let filter = &config.filter; - - let col_name = |col: &str| -> String { - expressions - .get(col) - .cloned() - .unwrap_or_else(|| format!("\"{}\"", col)) - }; - - let get_aggregate = |col: &str| -> Option<&Aggregate> { aggregates.get(col) }; - let generate_select_clauses = || -> Vec { - let mut clauses = Vec::new(); - - if !group_by.is_empty() { - for col in columns.iter().flatten() { - let agg = get_aggregate(col) - .map(Self::aggregate_to_string) - .unwrap_or_else(|| "any_value".to_string()); - clauses.push(format!( - "{}({}) as \"{}\"", - agg, - col_name(col), - col.replace('"', "\"\"").replace("_", "-") - )); - } - - if split_by.is_empty() { - for (idx, gb_col) in group_by.iter().enumerate() { - clauses.push(format!("{} as __ROW_PATH_{}__", col_name(gb_col), idx)); - } - - let groups = group_by.iter().map(|c| col_name(c)).collect::>(); - let grouping_fn = self.0.grouping_fn.as_deref().unwrap_or("GROUPING_ID"); - clauses.push(format!( - "{}({}) AS __GROUPING_ID__", - grouping_fn, - groups.join(", ") - )); - } - } else if !columns.is_empty() { - for col in columns.iter().flatten() { - let escaped_col = col.replace('"', "\"\"").replace("_", "-"); - clauses.push(format!("{} as \"{}\"", col_name(col), escaped_col)); - } - } - - clauses - }; - - let mut order_by_clauses: Vec = Vec::new(); - let mut window_clauses: Vec = Vec::new(); - let mut where_clauses: Vec = Vec::new(); - - if !group_by.is_empty() { - for gidx in 0..group_by.len() { - let groups = group_by[..=gidx] - .iter() - .map(|c| col_name(c)) - .collect::>() - .join(", "); - - if split_by.is_empty() { - let grouping_fn = self.0.grouping_fn.as_deref().unwrap_or("GROUPING_ID"); - order_by_clauses.push(format!("{}({}) DESC", grouping_fn, groups)); - } - - for Sort(sort_col, sort_dir) in sort { - if *sort_dir != SortDir::None { - let agg = get_aggregate(sort_col) - .map(Self::aggregate_to_string) - .unwrap_or_else(|| "any_value".to_string()); - let dir_str = Self::sort_dir_to_string(sort_dir); - - if gidx >= group_by.len() - 1 { - order_by_clauses.push(format!( - "{}({}) {}", - agg, - col_name(sort_col), - dir_str - )); - } else { - order_by_clauses.push(format!( - "first({}({})) OVER __WINDOW_{}__ {}", - agg, - col_name(sort_col), - gidx, - dir_str - )); - } - } - } - - order_by_clauses.push(format!("__ROW_PATH_{}__ ASC", gidx)); - } - } else { - for Sort(sort_col, sort_dir) in sort { - if *sort_dir != SortDir::None { - let dir_str = Self::sort_dir_to_string(sort_dir); - order_by_clauses.push(format!("{} {}", col_name(sort_col), dir_str)); - } - } - } - - if !sort.is_empty() && group_by.len() > 1 { - for gidx in 0..(group_by.len() - 1) { - let partition = (0..=gidx) - .map(|i| format!("__ROW_PATH_{}__", i)) - .collect::>() - .join(", "); - - let sub_groups = group_by[..=gidx] - .iter() - .map(|c| col_name(c)) - .collect::>() - .join(", "); - - let groups = group_by.iter().map(|c| col_name(c)).collect::>(); - let grouping_fn = self.0.grouping_fn.as_deref().unwrap_or("GROUPING_ID"); - window_clauses.push(format!( - "__WINDOW_{}__ AS (PARTITION BY {}({}), {} ORDER BY {})", - gidx, - grouping_fn, - sub_groups, - partition, - groups.join(", ") - )); - } - } - - for flt in filter { - let term = Self::filter_term_to_sql(flt.term()); - if let Some(term_lit) = term { - where_clauses.push(format!( - "{} {} {}", - col_name(flt.column()), - flt.op(), - term_lit - )); - } - } - - let mut query = if !split_by.is_empty() { - format!("SELECT * FROM {}", table_id) - } else { - let select_clauses = generate_select_clauses(); - format!("SELECT {} FROM {}", select_clauses.join(", "), table_id) - }; - - if !where_clauses.is_empty() { - query = format!("{} WHERE {}", query, where_clauses.join(" AND ")); - } - - if !split_by.is_empty() { - let groups = group_by.iter().map(|c| col_name(c)).collect::>(); - let group_aliases = group_by - .iter() - .enumerate() - .map(|(i, c)| format!("{} AS __ROW_PATH_{}__", col_name(c), i)) - .collect::>() - .join(", "); - let pivot_on = split_by - .iter() - .map(|c| format!("\"{}\"", c)) - .collect::>() - .join(", "); - let pivot_using = generate_select_clauses().join(", "); - - query = format!( - "SELECT * EXCLUDE ({}) , {} FROM (PIVOT ({}) ON {} USING {} GROUP BY {})", - groups.join(", "), - group_aliases, - query, - pivot_on, - pivot_using, - groups.join(", ") - ); - } else if !group_by.is_empty() { - let groups = group_by.iter().map(|c| col_name(c)).collect::>(); - query = format!("{} GROUP BY ROLLUP({})", query, groups.join(", ")); - } - - if !window_clauses.is_empty() { - query = format!("{} WINDOW {}", query, window_clauses.join(", ")); - } - - if !order_by_clauses.is_empty() { - query = format!("{} ORDER BY {}", query, order_by_clauses.join(", ")); - } - + let ctx = ViewQueryContext::new(self, table_id, config); + let query = ctx.build_query(); let template = self.0.create_entity.as_deref().unwrap_or("TABLE"); Ok(format!("CREATE {} {} AS ({})", template, view_id, query)) } @@ -392,7 +204,7 @@ impl GenericSQLVirtualServerModel { schema: &IndexMap, ) -> GenericSQLResult { let group_by = &config.group_by; - let split_by = &config.split_by; + let sort = &config.sort; let start_col = viewport.start_col.unwrap_or(0) as usize; let end_col = viewport.end_col.map(|x| x as usize); let start_row = viewport.start_row.unwrap_or(0); @@ -403,19 +215,34 @@ impl GenericSQLVirtualServerModel { String::new() }; - let data_columns: Vec<&String> = schema + let mut data_columns: Vec<&String> = schema .keys() .filter(|col_name| !col_name.starts_with("__")) + .collect(); + + let col_sort_dir = sort.iter().find_map(|Sort(_, dir)| match dir { + SortDir::ColAsc | SortDir::ColAscAbs => Some(true), + SortDir::ColDesc | SortDir::ColDescAbs => Some(false), + _ => None, + }); + + if let Some(ascending) = col_sort_dir { + if ascending { + data_columns.sort(); + } else { + data_columns.sort_by(|a, b| b.cmp(a)); + } + } + + let data_columns: Vec<&String> = data_columns + .into_iter() .skip(start_col) .take(end_col.map(|e| e - start_col).unwrap_or(usize::MAX)) .collect(); let mut group_by_cols: Vec = Vec::new(); if !group_by.is_empty() { - if split_by.is_empty() { - group_by_cols.push("\"__GROUPING_ID__\"".to_string()); - } - + group_by_cols.push("\"__GROUPING_ID__\"".to_string()); for idx in 0..group_by.len() { group_by_cols.push(format!("\"__ROW_PATH_{}__\"", idx)); } @@ -458,21 +285,6 @@ impl GenericSQLVirtualServerModel { Ok(format!("SELECT COUNT(*) FROM {}", view_id)) } - fn aggregate_to_string(agg: &Aggregate) -> String { - match agg { - Aggregate::SingleAggregate(name) => name.clone(), - Aggregate::MultiAggregate(name, _args) => name.clone(), - } - } - - fn sort_dir_to_string(dir: &SortDir) -> &'static str { - match dir { - SortDir::None => "", - SortDir::Asc | SortDir::ColAsc | SortDir::AscAbs | SortDir::ColAscAbs => "ASC", - SortDir::Desc | SortDir::ColDesc | SortDir::DescAbs | SortDir::ColDescAbs => "DESC", - } - } - fn filter_term_to_sql(term: &FilterTerm) -> Option { match term { FilterTerm::Scalar(scalar) => Self::scalar_to_sql(scalar), @@ -496,100 +308,3 @@ impl GenericSQLVirtualServerModel { } } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_get_hosted_tables() { - let builder = - GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); - assert_eq!(builder.get_hosted_tables().unwrap(), "SHOW ALL TABLES"); - } - - #[test] - fn test_table_schema() { - let builder = - GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); - assert_eq!( - builder.table_schema("my_table").unwrap(), - "DESCRIBE my_table" - ); - } - - #[test] - fn test_table_size() { - let builder = - GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); - assert_eq!( - builder.table_size("my_table").unwrap(), - "SELECT COUNT(*) FROM my_table" - ); - } - - #[test] - fn test_view_delete() { - let builder = - GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); - assert_eq!( - builder.view_delete("my_view").unwrap(), - "DROP TABLE IF EXISTS my_view" - ); - } - - #[test] - fn test_table_make_view_simple() { - let builder = - GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); - let mut config = ViewConfig::default(); - config.columns = vec![Some("col1".to_string()), Some("col2".to_string())]; - let sql = builder - .table_make_view("source_table", "dest_view", &config) - .unwrap(); - - assert!(sql.starts_with("CREATE TABLE dest_view AS")); - assert!(sql.contains("\"col1\"")); - assert!(sql.contains("\"col2\"")); - } - - #[test] - fn test_table_make_view_with_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()]; - let sql = builder - .table_make_view("source_table", "dest_view", &config) - .unwrap(); - - assert!(sql.contains("GROUP BY ROLLUP")); - assert!(sql.contains("__ROW_PATH_0__")); - assert!(sql.contains("__GROUPING_ID__")); - } - - #[test] - fn test_view_get_data() { - let builder = - GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); - let config = ViewConfig::default(); - let viewport = ViewPort { - start_row: Some(0), - end_row: Some(100), - start_col: Some(0), - end_col: Some(5), - }; - - let mut schema = IndexMap::new(); - schema.insert("col1".to_string(), ColumnType::String); - schema.insert("col2".to_string(), ColumnType::Integer); - let sql = builder - .view_get_data("my_view", &config, &viewport, &schema) - .unwrap(); - - assert!(sql.contains("SELECT")); - assert!(sql.contains("FROM my_view")); - assert!(sql.contains("LIMIT 100 OFFSET 0")); - } -} 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 new file mode 100644 index 0000000000..aea5cd2f21 --- /dev/null +++ b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs @@ -0,0 +1,416 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +use crate::config::{Aggregate, Sort, SortDir, ViewConfig}; + +fn aggregate_to_string(agg: &Aggregate) -> String { + match agg { + Aggregate::SingleAggregate(name) => name.clone(), + Aggregate::MultiAggregate(name, _args) => name.clone(), + } +} + +fn sort_dir_to_string(dir: &SortDir) -> &'static str { + match dir { + SortDir::None => "", + SortDir::Asc | SortDir::ColAsc | SortDir::AscAbs | SortDir::ColAscAbs => "ASC", + SortDir::Desc | SortDir::ColDesc | SortDir::DescAbs | SortDir::ColDescAbs => "DESC", + } +} + +fn is_col_sort(dir: &SortDir) -> bool { + matches!( + dir, + SortDir::ColAsc | SortDir::ColDesc | SortDir::ColAscAbs | SortDir::ColDescAbs + ) +} + +enum QueryOrientation { + Flat, + Grouped, + Pivoted, + GroupedAndPivoted, +} + +/// Precomputed context for building a SQL view query from a [`ViewConfig`]. +/// +/// Holds the resolved column names, grouping function, and row-path aliases +/// needed to emit the correct `SELECT`, `GROUP BY`, `PIVOT`, `ORDER BY`, and +/// `WINDOW` clauses for every combination of `group_by` / `split_by`. +pub(crate) struct ViewQueryContext<'a> { + table: &'a str, + config: &'a ViewConfig, + group_col_names: Vec, + grouping_fn: &'a str, + row_path_aliases: Vec, +} + +impl<'a> ViewQueryContext<'a> { + /// Creates a new query context by resolving expressions, the grouping + /// function, and row-path aliases from the given model and config. + pub(crate) fn new( + model: &'a super::GenericSQLVirtualServerModel, + table: &'a str, + config: &'a ViewConfig, + ) -> Self { + let expressions = &config.expressions.0; + let col_name_resolve = |col: &str| -> String { + expressions + .get(col) + .cloned() + .unwrap_or_else(|| format!("\"{}\"", col)) + }; + + let grouping_fn = model.0.grouping_fn.as_deref().unwrap_or("GROUPING_ID"); + let group_col_names: Vec = config + .group_by + .iter() + .map(|c| col_name_resolve(c)) + .collect(); + + let row_path_aliases: Vec = (0..config.group_by.len()) + .map(|i| format!("__ROW_PATH_{}__", i)) + .collect(); + + Self { + table, + config, + group_col_names, + grouping_fn, + row_path_aliases, + } + } + + /// Builds the inner `SELECT` query (without the outer `CREATE TABLE` + /// wrapper) for the four `group_by` x `split_by` combinations, appending + /// `WINDOW` and `ORDER BY` clauses as needed. + pub(crate) fn build_query(&self) -> String { + let where_sql = self.where_sql(); + let order_by = self.order_by_clauses(); + let windows = self.window_clauses(); + let mut query = match self.query_orientation() { + QueryOrientation::Flat => { + let select = self.select_clauses().join(", "); + format!("SELECT {} FROM {}{}", select, self.table, where_sql) + }, + 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(", ") + ) + }, + QueryOrientation::Pivoted => { + let select = self.select_clauses(); + let pivot_using: Vec = self + .config + .columns + .iter() + .flatten() + .map(|col| { + let escaped = col.replace('"', "\"\"").replace('_', "-"); + format!("first(\"{}\") as \"{}\"", escaped, escaped) + }) + .collect(); + + let split_cols: String = self + .config + .split_by + .iter() + .map(|c| format!("\"{}\"", c)) + .collect::>() + .join(", "); + + format!( + "SELECT * EXCLUDE (__ROW_NUM__) FROM (PIVOT (SELECT {}, {}, ROW_NUMBER() OVER \ + () as __ROW_NUM__ FROM {}{}) ON {} USING {} GROUP BY __ROW_NUM__)", + select.join(", "), + split_cols, + self.table, + where_sql, + self.pivot_on_expr(), + pivot_using.join(", "), + ) + }, + QueryOrientation::GroupedAndPivoted => { + let groups_joined = self.group_col_names.join(", "); + 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()); + for sb_col in &self.config.split_by { + inner_clauses.push(self.col_name(sb_col)); + } + + 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, + )); + } + } + + let inner_query = 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()); + 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)); + } + } + + format!( + "SELECT * FROM (PIVOT ({}) ON {} USING {} GROUP BY {})", + inner_query, + self.pivot_on_expr(), + pivot_using, + row_id_cols.join(", ") + ) + }, + }; + + if !windows.is_empty() { + query = format!("{} WINDOW {}", query, windows.join(", ")); + } + + if !order_by.is_empty() { + query = format!("{} ORDER BY {}", query, order_by.join(", ")); + } + + query + } + + fn query_orientation(&self) -> QueryOrientation { + match ( + self.config.group_by.is_empty(), + self.config.split_by.is_empty(), + ) { + (true, true) => QueryOrientation::Flat, + (false, true) => QueryOrientation::Grouped, + (true, false) => QueryOrientation::Pivoted, + (false, false) => QueryOrientation::GroupedAndPivoted, + } + } + + fn col_name(&self, col: &str) -> String { + self.config + .expressions + .0 + .get(col) + .cloned() + .unwrap_or_else(|| format!("\"{}\"", col)) + } + + fn get_aggregate(&self, col: &str) -> String { + self.config + .aggregates + .get(col) + .map(aggregate_to_string) + .unwrap_or_else(|| "any_value".to_string()) + } + + fn select_clauses(&self) -> Vec { + let mut clauses = Vec::new(); + if !self.config.group_by.is_empty() { + for col in self.config.columns.iter().flatten() { + let agg = self.get_aggregate(col); + let escaped = col.replace('"', "\"\"").replace("_", "-"); + clauses.push(format!( + "{}({}) as \"{}\"", + agg, + self.col_name(col), + escaped + )); + } + } else if !self.config.columns.is_empty() { + for col in self.config.columns.iter().flatten() { + let escaped = col.replace('"', "\"\"").replace("_", "-"); + clauses.push(format!("{} as \"{}\"", self.col_name(col), escaped)); + } + } + + clauses + } + + fn where_sql(&self) -> String { + let clauses: Vec = self + .config + .filter + .iter() + .filter_map(|flt| { + super::GenericSQLVirtualServerModel::filter_term_to_sql(flt.term()).map( + |term_lit| format!("{} {} {}", self.col_name(flt.column()), flt.op(), term_lit), + ) + }) + .collect(); + + if clauses.is_empty() { + String::new() + } else { + format!(" WHERE {}", clauses.join(" AND ")) + } + } + + fn pivot_on_expr(&self) -> String { + self.config + .split_by + .iter() + .map(|c| format!("\"{}\"", c)) + .collect::>() + .join(", ") + } + + fn grouping_id_clause(&self) -> String { + format!( + "{}({}) AS __GROUPING_ID__", + self.grouping_fn, + self.group_col_names.join(", ") + ) + } + + fn row_path_select_clauses(&self) -> Vec { + self.config + .group_by + .iter() + .enumerate() + .map(|(i, col)| format!("{} as __ROW_PATH_{}__", self.col_name(col), i)) + .collect() + } + + fn order_by_clauses(&self) -> Vec { + let mut clauses = Vec::new(); + 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; + if shift > 0 { + clauses.push(format!("(__GROUPING_ID__ >> {}) DESC", shift)); + } else { + clauses.push("__GROUPING_ID__ DESC".to_string()); + } + } else { + let groups_up_to = self.config.group_by[..=gidx] + .iter() + .map(|c| self.col_name(c)) + .collect::>() + .join(", "); + clauses.push(format!("{}({}) DESC", self.grouping_fn, groups_up_to)); + } + + let is_leaf = gidx >= self.config.group_by.len() - 1; + for (sidx, Sort(sort_col, sort_dir)) in self.config.sort.iter().enumerate() { + if *sort_dir == SortDir::None || is_col_sort(sort_dir) { + continue; + } + + let dir = sort_dir_to_string(sort_dir); + if !self.config.split_by.is_empty() { + if is_leaf { + clauses.push(format!("__SORT_{}__ {}", sidx, dir)); + } else { + clauses.push(format!( + "first(__SORT_{}__) OVER __WINDOW_{}__ {}", + sidx, gidx, dir + )); + } + } else { + let agg = self.get_aggregate(sort_col); + if is_leaf { + clauses.push(format!("{}({}) {}", agg, self.col_name(sort_col), dir)); + } else { + clauses.push(format!( + "first({}({})) OVER __WINDOW_{}__ {}", + agg, + self.col_name(sort_col), + gidx, + dir + )); + } + } + } + + clauses.push(format!("{} ASC", self.row_path_aliases[gidx])); + } + } else { + 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); + clauses.push(format!("{} {}", self.col_name(sort_col), dir)); + } + } + } + + clauses + } + + fn window_clauses(&self) -> Vec { + if self.config.sort.is_empty() || self.config.group_by.len() <= 1 { + return Vec::new(); + } + + let mut clauses = Vec::new(); + for gidx in 0..(self.config.group_by.len() - 1) { + let partition = self.row_path_aliases[..=gidx].join(", "); + if !self.config.split_by.is_empty() { + let shift = self.config.group_by.len() - 1 - gidx; + let grouping_expr = if shift > 0 { + format!("(__GROUPING_ID__ >> {})", shift) + } else { + "__GROUPING_ID__".to_string() + }; + + let order = self.row_path_aliases.join(", "); + clauses.push(format!( + "__WINDOW_{}__ AS (PARTITION BY {}, {} ORDER BY {})", + gidx, grouping_expr, partition, order, + )); + } else { + let sub_groups = self.config.group_by[..=gidx] + .iter() + .map(|c| self.col_name(c)) + .collect::>() + .join(", "); + clauses.push(format!( + "__WINDOW_{}__ AS (PARTITION BY {}({}), {} ORDER BY {})", + gidx, + self.grouping_fn, + sub_groups, + partition, + self.group_col_names.join(", ") + )); + } + } + + clauses + } +} 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 new file mode 100644 index 0000000000..8184396f0e --- /dev/null +++ b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/tests.rs @@ -0,0 +1,361 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +use std::collections::HashMap; + +use super::*; +use crate::config::Aggregate; + +#[test] +fn test_get_hosted_tables() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + assert_eq!(builder.get_hosted_tables().unwrap(), "SHOW ALL TABLES"); +} + +#[test] +fn test_table_schema() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + assert_eq!( + builder.table_schema("my_table").unwrap(), + "DESCRIBE my_table" + ); +} + +#[test] +fn test_table_size() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + assert_eq!( + builder.table_size("my_table").unwrap(), + "SELECT COUNT(*) FROM my_table" + ); +} + +#[test] +fn test_view_delete() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + assert_eq!( + builder.view_delete("my_view").unwrap(), + "DROP TABLE IF EXISTS my_view" + ); +} + +#[test] +fn test_table_make_view_simple() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("col1".to_string()), Some("col2".to_string())]; + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!(sql.starts_with("CREATE TABLE dest_view AS")); + assert!(sql.contains("\"col1\"")); + assert!(sql.contains("\"col2\"")); +} + +#[test] +fn test_table_make_view_with_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()]; + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!(sql.contains("GROUP BY ROLLUP")); + assert!(sql.contains("__ROW_PATH_0__")); + assert!(sql.contains("__GROUPING_ID__")); +} + +#[test] +fn test_table_make_view_with_group_by_and_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()]; + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!(sql.contains("GROUP BY ROLLUP"), "expected ROLLUP: {}", sql); + assert!(sql.contains("PIVOT"), "expected PIVOT: {}", sql); + assert!( + sql.contains("__ROW_PATH_0__"), + "expected __ROW_PATH_0__: {}", + sql + ); + + assert!( + sql.contains("__GROUPING_ID__"), + "expected __GROUPING_ID__: {}", + sql + ); +} + +#[test] +fn test_table_make_view_with_sort_group_by_and_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.sort = vec![Sort("value".to_string(), SortDir::Asc)]; + 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("__SORT_0__"), "expected __SORT_0__: {}", sql); + assert!( + sql.contains("__GROUPING_ID__, __SORT_0__"), + "expected __SORT_0__ in GROUP BY: {}", + sql + ); + + assert!( + sql.contains("__SORT_0__ ASC"), + "expected __SORT_0__ ASC in ORDER BY: {}", + sql + ); + + assert!( + !sql.contains("sum(\"value\") ASC"), + "should not have raw aggregate in ORDER BY: {}", + sql + ); +} + +#[test] +fn test_table_make_view_with_sort_multi_group_by_and_split_by() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("value".to_string())]; + config.group_by = vec!["region".to_string(), "category".to_string()]; + config.split_by = vec!["quarter".to_string()]; + config.sort = vec![Sort("value".to_string(), SortDir::Asc)]; + 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("PARTITION BY (__GROUPING_ID__ >> 1)"), + "expected shifted __GROUPING_ID__ in WINDOW: {}", + sql + ); + + assert!( + sql.contains("first(__SORT_0__) OVER __WINDOW_0__"), + "expected first(__SORT_0__) OVER __WINDOW_0__: {}", + sql + ); + + assert!( + !sql.contains("GROUPING_ID(\"region\")"), + "should not have GROUPING_ID function in WINDOW: {}", + sql + ); +} + +#[test] +fn test_table_make_view_with_sort_and_group_by_no_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.sort = vec![Sort("value".to_string(), SortDir::Asc)]; + 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\") ASC"), + "expected raw aggregate in ORDER BY: {}", + sql + ); + + assert!( + !sql.contains("__SORT_0__"), + "should not have __SORT_0__ without split_by: {}", + sql + ); +} + +#[test] +fn test_table_make_view_col_sort_excludes_row_order_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.sort = vec![Sort("value".to_string(), SortDir::ColAsc)]; + 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("__SORT_0__"), + "col sort should not produce __SORT_0__: {}", + sql + ); + + assert!( + !sql.contains("sum(\"value\") ASC"), + "col sort should not produce row ORDER BY: {}", + sql + ); + + assert!(sql.contains("PIVOT"), "should still have PIVOT: {}", sql); +} + +#[test] +fn test_table_make_view_mixed_row_and_col_sort() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("value".to_string()), Some("qty".to_string())]; + config.group_by = vec!["category".to_string()]; + config.split_by = vec!["quarter".to_string()]; + config.sort = vec![ + Sort("value".to_string(), SortDir::ColDesc), + Sort("qty".to_string(), SortDir::Asc), + ]; + + config.aggregates = HashMap::from([ + ( + "value".to_string(), + Aggregate::SingleAggregate("sum".to_string()), + ), + ( + "qty".to_string(), + Aggregate::SingleAggregate("sum".to_string()), + ), + ]); + + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!( + !sql.contains("__SORT_0__"), + "col sort (idx 0) should not produce __SORT_0__: {}", + sql + ); + + assert!( + sql.contains("__SORT_1__"), + "row sort (idx 1) should produce __SORT_1__: {}", + sql + ); +} + +#[test] +fn test_view_get_data_col_sort_ascending() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.sort = vec![Sort("value".to_string(), SortDir::ColAsc)]; + 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("C_value".to_string(), ColumnType::Float); + schema.insert("A_value".to_string(), ColumnType::Float); + schema.insert("B_value".to_string(), ColumnType::Float); + let sql = builder + .view_get_data("my_view", &config, &viewport, &schema) + .unwrap(); + + let a_pos = sql.find("\"A_value\"").unwrap(); + let b_pos = sql.find("\"B_value\"").unwrap(); + let c_pos = sql.find("\"C_value\"").unwrap(); + assert!( + a_pos < b_pos && b_pos < c_pos, + "col asc should order columns A < B < C: {}", + sql + ); +} + +#[test] +fn test_view_get_data_col_sort_descending() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.sort = vec![Sort("value".to_string(), SortDir::ColDesc)]; + 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("A_value".to_string(), ColumnType::Float); + schema.insert("C_value".to_string(), ColumnType::Float); + schema.insert("B_value".to_string(), ColumnType::Float); + let sql = builder + .view_get_data("my_view", &config, &viewport, &schema) + .unwrap(); + + let a_pos = sql.find("\"A_value\"").unwrap(); + let b_pos = sql.find("\"B_value\"").unwrap(); + let c_pos = sql.find("\"C_value\"").unwrap(); + assert!( + c_pos < b_pos && b_pos < a_pos, + "col desc should order columns C > B > A: {}", + sql + ); +} + +#[test] +fn test_view_get_data() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let config = ViewConfig::default(); + let viewport = ViewPort { + start_row: Some(0), + end_row: Some(100), + start_col: Some(0), + end_col: Some(5), + }; + + let mut schema = IndexMap::new(); + schema.insert("col1".to_string(), ColumnType::String); + schema.insert("col2".to_string(), ColumnType::Integer); + let sql = builder + .view_get_data("my_view", &config, &viewport, &schema) + .unwrap(); + + assert!(sql.contains("SELECT")); + assert!(sql.contains("FROM my_view")); + assert!(sql.contains("LIMIT 100 OFFSET 0")); +} diff --git a/rust/perspective-client/src/rust/virtual_server/server.rs b/rust/perspective-client/src/rust/virtual_server/server.rs index 0429be6976..17af1434f8 100644 --- a/rust/perspective-client/src/rust/virtual_server/server.rs +++ b/rust/perspective-client/src/rust/virtual_server/server.rs @@ -91,7 +91,7 @@ impl VirtualServer { tracing::error!("{}", err); Ok(respond!(msg, ServerError { message: err.to_string(), - status_code: 1 + status_code: 0 })) }, } diff --git a/rust/perspective-js/src/rust/utils/futures.rs b/rust/perspective-js/src/rust/utils/futures.rs index b8dd86951a..7b8beed3c6 100644 --- a/rust/perspective-js/src/rust/utils/futures.rs +++ b/rust/perspective-js/src/rust/utils/futures.rs @@ -90,12 +90,7 @@ where Result: IntoJsResult + 'static, { fn from(fut: ApiFuture) -> Self { - future_to_promise(async move { - match fut.0.await.ignore_view_delete()? { - Some(x) => Ok(x).into_js_result(), - None => Err("View not found".into()).into_js_result(), - } - }) + future_to_promise(async move { Ok(fut.0.await?).into_js_result() }) } } diff --git a/rust/perspective-js/src/ts/virtual_servers/duckdb.ts b/rust/perspective-js/src/ts/virtual_servers/duckdb.ts index ae16abe66b..877c892037 100644 --- a/rust/perspective-js/src/ts/virtual_servers/duckdb.ts +++ b/rust/perspective-js/src/ts/virtual_servers/duckdb.ts @@ -76,41 +76,47 @@ const FILTER_OPS = [ ]; function duckdbTypeToPsp(name: string): ColumnType { - if (name === "VARCHAR" || name == "Utf8") { + name = name.toLowerCase(); + if (name === "varchar" || name == "utf8") { return "string"; } if ( - name === "DOUBLE" || - name === "BIGINT" || - name === "HUGEINT" || - name === "Float64" || - name.startsWith("Decimal") + name === "double" || + name === "bigint" || + name === "hugeint" || + name === "float64" || + name.startsWith("decimal") ) { return "float"; } - if (name.startsWith("Int") || name == "INTEGER") { + if (name.startsWith("int")) { return "integer"; } - if (name === "INTEGER") { - return "integer"; - } - - if (name === "DATE" || name.startsWith("Date")) { + if (name.startsWith("date")) { return "date"; } - if (name === "BOOLEAN" || name === "Bool") { + if (name.startsWith("bool")) { return "boolean"; } - if (name === "TIMESTAMP" || name.startsWith("Timestamp")) { + if (name.startsWith("timestamp")) { return "datetime"; } - throw new Error(`Unknown type '${name}'`); + if (name.startsWith("json")) { + return "string"; + } + + if (name.startsWith("struct")) { + return "string"; + } + + console.warn(`Unknown type '${name}'`); + return "string"; } function convertDecimalToNumber(value: any, dtypeString: string) { @@ -250,12 +256,9 @@ export class DuckDBHandler implements perspective.VirtualServerHandler { async viewColumnSize(viewId: string, config: ViewConfig) { const query = this.sqlBuilder.viewColumnSize(viewId); const results = await runQuery(this.db, query); - const gs = config.group_by?.length || 0; const count = Number(Object.values(results[0].toJSON())[0]); - return ( - count - - (gs === 0 ? 0 : gs + (config.split_by?.length === 0 ? 1 : 0)) - ); + const gs = config.group_by?.length || 0; + return count - (gs === 0 ? 0 : gs + 1); } async tableSize(tableId: string) { @@ -306,18 +309,17 @@ export class DuckDBHandler implements perspective.VirtualServerHandler { }); for (let cidx = 0; cidx < columns.length; cidx++) { - if (cidx === 0 && is_group_by && !is_split_by) { + if (cidx === 0 && is_group_by) { // This is the grouping_id column, skip it continue; } let col = columns[cidx]; - if (is_split_by && !col.startsWith("__ROW_PATH_")) { + if (is_split_by && !col.startsWith("__")) { col = col.replaceAll("_", "|"); } const dtype = duckdbTypeToPsp(dtypes[cidx]) as ColumnType; - const isDecimal = dtypes[cidx].startsWith("Decimal"); for (let ridx = 0; ridx < rows.length; ridx++) { const rowArray = rows[ridx].toArray(); @@ -331,6 +333,14 @@ export class DuckDBHandler implements perspective.VirtualServerHandler { value = Number(value); } + if (typeof value !== "string" && dtype === "string") { + try { + value = JSON.stringify(value); + } catch (e) { + value = `${value}`; + } + } + dataSlice.setCol(dtype, col, ridx, value, grouping_id); } } diff --git a/rust/perspective-js/test/js/duckdb.spec.js b/rust/perspective-js/test/js/duckdb.spec.js index 77a0875b81..f8bec2edcb 100644 --- a/rust/perspective-js/test/js/duckdb.spec.js +++ b/rust/perspective-js/test/js/duckdb.spec.js @@ -85,7 +85,7 @@ test.describe("DuckDB Virtual Server", function () { test.describe("client", () => { test("get_hosted_table_names()", async function () { const tables = await client.get_hosted_table_names(); - expect(tables).toContain("memory.superstore"); + expect(tables).toEqual(["memory.superstore"]); }); }); @@ -93,31 +93,57 @@ test.describe("DuckDB Virtual Server", function () { test("schema()", async function () { const table = await client.open_table("memory.superstore"); const schema = await table.schema(); - expect(schema).toHaveProperty("Sales"); - expect(schema).toHaveProperty("Profit"); - expect(schema).toHaveProperty("State"); - expect(schema).toHaveProperty("Quantity"); - expect(schema).toHaveProperty("Discount"); - }); - - test("schema() returns correct types", async function () { - const table = await client.open_table("memory.superstore"); - const schema = await table.schema(); - expect(schema["Sales"]).toBe("float"); - expect(schema["Profit"]).toBe("float"); - expect(schema["Quantity"]).toBe("integer"); - expect(schema["State"]).toBe("string"); - expect(schema["Order Date"]).toBe("date"); + 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).toContain("Sales"); - expect(columns).toContain("Profit"); - expect(columns).toContain("State"); - expect(columns).toContain("Region"); - expect(columns).toContain("Category"); + 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 () { @@ -166,10 +192,14 @@ test.describe("DuckDB Virtual Server", function () { const view = await table.view({ columns: ["Sales", "Quantity"], }); - const json = await view.to_json({ start_row: 0, end_row: 3 }); - expect(json.length).toBe(3); - expect(json[0]).toHaveProperty("Sales"); - expect(json[0]).toHaveProperty("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(); }); @@ -180,12 +210,12 @@ test.describe("DuckDB Virtual Server", function () { }); const columns = await view.to_columns({ start_row: 0, - end_row: 3, + end_row: 5, + }); + expect(columns).toEqual({ + Sales: [261.96, 731.94, 14.62, 957.5775, 22.368], + Quantity: [2, 3, 2, 5, 2], }); - expect(columns).toHaveProperty("Sales"); - expect(columns).toHaveProperty("Quantity"); - expect(columns["Sales"].length).toBe(3); - expect(columns["Quantity"].length).toBe(3); await view.delete(); }); @@ -209,10 +239,21 @@ test.describe("DuckDB Virtual Server", function () { aggregates: { Sales: "sum" }, }); const numRows = await view.num_rows(); - expect(numRows).toBe(5); // 4 regions + 1 total row + expect(numRows).toBe(5); const json = await view.to_json(); - expect(json[0]).toHaveProperty("__ROW_PATH__"); - expect(json[0]["__ROW_PATH__"]).toEqual([]); + 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(); }); @@ -224,13 +265,67 @@ test.describe("DuckDB Virtual Server", function () { aggregates: { Sales: "sum" }, }); const json = await view.to_json(); - // First row should be total - expect(json[0]["__ROW_PATH__"]).toEqual([]); - // Should have region-level rows and region+category rows - const regionRows = json.filter( - (row) => row["__ROW_PATH__"].length === 1, - ); - expect(regionRows.length).toBe(4); // 4 regions + 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(); }); @@ -242,8 +337,13 @@ test.describe("DuckDB Virtual Server", function () { aggregates: { Sales: "count" }, }); const json = await view.to_json(); - // Total count should be 9994 - expect(json[0]["Sales"]).toBe(9994); + 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(); }); @@ -255,11 +355,21 @@ test.describe("DuckDB Virtual Server", function () { aggregates: { Sales: "avg" }, }); const json = await view.to_json(); - expect(json.length).toBe(4); // 3 categories + total - // Each row should have an average value - for (const row of json) { - expect(typeof row["Sales"]).toBe("number"); - } + 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(); }); @@ -271,9 +381,13 @@ test.describe("DuckDB Virtual Server", function () { aggregates: { Quantity: "min" }, }); const json = await view.to_json(); - for (const row of json) { - expect(typeof row["Quantity"]).toBe("number"); - } + 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(); }); @@ -285,9 +399,13 @@ test.describe("DuckDB Virtual Server", function () { aggregates: { Quantity: "max" }, }); const json = await view.to_json(); - for (const row of json) { - expect(typeof row["Quantity"]).toBe("number"); - } + 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(); }); }); @@ -303,11 +421,44 @@ test.describe("DuckDB Virtual Server", function () { }); const columns = await view.column_paths(); - // Should have columns for each region - expect(columns.some((c) => c.includes("Central"))).toBe(true); - expect(columns.some((c) => c.includes("East"))).toBe(true); - expect(columns.some((c) => c.includes("South"))).toBe(true); - expect(columns.some((c) => c.includes("West"))).toBe(true); + 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(); }); @@ -332,10 +483,14 @@ test.describe("DuckDB Virtual Server", function () { columns: ["Sales", "Region"], filter: [["Region", "==", "West"]], }); - const json = await view.to_json(); - for (const row of json) { - expect(row["Region"]).toBe("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(); }); @@ -345,10 +500,14 @@ test.describe("DuckDB Virtual Server", function () { columns: ["Sales", "Region"], filter: [["Region", "!=", "West"]], }); - const json = await view.to_json({ start_row: 0, end_row: 100 }); - for (const row of json) { - expect(row["Region"]).not.toBe("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(); }); @@ -358,10 +517,14 @@ test.describe("DuckDB Virtual Server", function () { columns: ["Sales", "Quantity"], filter: [["Quantity", ">", 5]], }); - const json = await view.to_json({ start_row: 0, end_row: 100 }); - for (const row of json) { - expect(row["Quantity"]).toBeGreaterThan(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(); }); @@ -371,10 +534,14 @@ test.describe("DuckDB Virtual Server", function () { columns: ["Sales", "Quantity"], filter: [["Quantity", "<", 3]], }); - const json = await view.to_json({ start_row: 0, end_row: 100 }); - for (const row of json) { - expect(row["Quantity"]).toBeLessThan(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(); }); @@ -384,10 +551,14 @@ test.describe("DuckDB Virtual Server", function () { columns: ["Sales", "Quantity"], filter: [["Quantity", ">=", 10]], }); - const json = await view.to_json({ start_row: 0, end_row: 100 }); - for (const row of json) { - expect(row["Quantity"]).toBeGreaterThanOrEqual(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(); }); @@ -397,10 +568,14 @@ test.describe("DuckDB Virtual Server", function () { columns: ["Sales", "Quantity"], filter: [["Quantity", "<=", 2]], }); - const json = await view.to_json({ start_row: 0, end_row: 100 }); - for (const row of json) { - expect(row["Quantity"]).toBeLessThanOrEqual(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(); }); @@ -410,10 +585,14 @@ test.describe("DuckDB Virtual Server", function () { columns: ["Sales", "State"], filter: [["State", "LIKE", "Cal%"]], }); - const json = await view.to_json({ start_row: 0, end_row: 100 }); - for (const row of json) { - expect(row["State"].startsWith("Cal")).toBe(true); - } + 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(); }); @@ -426,11 +605,14 @@ test.describe("DuckDB Virtual Server", function () { ["Quantity", ">", 3], ], }); - const json = await view.to_json({ start_row: 0, end_row: 100 }); - for (const row of json) { - expect(row["Region"]).toBe("West"); - expect(row["Quantity"]).toBeGreaterThan(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(); }); @@ -443,7 +625,23 @@ test.describe("DuckDB Virtual Server", function () { aggregates: { Sales: "sum" }, }); const numRows = await view.num_rows(); - expect(numRows).toBe(4); // 3 categories + total + 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(); }); }); @@ -455,12 +653,14 @@ test.describe("DuckDB Virtual Server", function () { columns: ["Sales", "Quantity"], sort: [["Sales", "asc"]], }); - const json = await view.to_json({ start_row: 0, end_row: 10 }); - for (let i = 1; i < json.length; i++) { - expect(json[i]["Sales"]).toBeGreaterThanOrEqual( - json[i - 1]["Sales"], - ); - } + 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(); }); @@ -470,12 +670,14 @@ test.describe("DuckDB Virtual Server", function () { columns: ["Sales", "Quantity"], sort: [["Sales", "desc"]], }); - const json = await view.to_json({ start_row: 0, end_row: 10 }); - for (let i = 1; i < json.length; i++) { - expect(json[i]["Sales"]).toBeLessThanOrEqual( - json[i - 1]["Sales"], - ); - } + 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(); }); @@ -488,13 +690,19 @@ test.describe("DuckDB Virtual Server", function () { aggregates: { Sales: "sum" }, }); const json = await view.to_json(); - // Skip the first row (total) and verify sorting - const regionRows = json.slice(1); - for (let i = 1; i < regionRows.length; i++) { - expect(regionRows[i]["Sales"]).toBeLessThanOrEqual( - regionRows[i - 1]["Sales"], - ); - } + 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(); }); @@ -507,18 +715,14 @@ test.describe("DuckDB Virtual Server", function () { ["Sales", "desc"], ], }); - const json = await view.to_json({ start_row: 0, end_row: 100 }); - // Check that Region is sorted first - let lastRegion = ""; - let lastSales = Infinity; - for (const row of json) { - if (row["Region"] !== lastRegion) { - lastRegion = row["Region"]; - lastSales = Infinity; - } - expect(row["Sales"]).toBeLessThanOrEqual(lastSales); - lastSales = row["Sales"]; - } + 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(); }); }); @@ -531,11 +735,14 @@ test.describe("DuckDB Virtual Server", function () { expressions: { doublesales: '"Sales" * 2' }, }); - const json = await view.to_json({ start_row: 0, end_row: 10 }); - for (const row of json) { - console.log(row); - expect(row["doublesales"]).toBeCloseTo(row["Sales"] * 2, 5); - } + 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(); }); @@ -547,15 +754,22 @@ test.describe("DuckDB Virtual Server", function () { expressions: { margin: '"Profit" / "Sales"' }, }); - const json = await view.to_json({ start_row: 0, end_row: 10 }); - for (const row of json) { - if (row["Sales"] !== 0) { - expect(row["margin"]).toBeCloseTo( - row["Profit"] / row["Sales"], - 5, - ); - } - } + 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(); }); @@ -570,10 +784,19 @@ test.describe("DuckDB Virtual Server", function () { }); const json = await view.to_json(); - expect(json.length).toBe(5); // 4 regions + total - for (const row of json) { - expect(typeof row["total"]).toBe("number"); - } + 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(); }); @@ -585,8 +808,14 @@ test.describe("DuckDB Virtual Server", function () { const view = await table.view({ columns: ["Sales", "Profit"], }); - const json = await view.to_json({ start_row: 10, end_row: 20 }); - expect(json.length).toBe(10); + 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(); }); @@ -601,21 +830,13 @@ test.describe("DuckDB Virtual Server", function () { start_col: 1, end_col: 3, }); - expect(json.length).toBe(5); - // Should only have Profit and Quantity (columns 1 and 2) - expect(Object.keys(json[0]).sort()).toEqual( - ["Profit", "Quantity"].sort(), - ); - await view.delete(); - }); - - test("large viewport", async function () { - const table = await client.open_table("memory.superstore"); - const view = await table.view({ - columns: ["Sales"], - }); - const json = await view.to_json({ start_row: 0, end_row: 1000 }); - expect(json.length).toBe(1000); + 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(); }); }); @@ -626,10 +847,14 @@ test.describe("DuckDB Virtual Server", function () { const view = await table.view({ columns: ["Quantity"], }); - const json = await view.to_json({ start_row: 0, end_row: 10 }); - for (const row of json) { - expect(Number.isInteger(row["Quantity"])).toBe(true); - } + 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(); }); @@ -638,11 +863,14 @@ test.describe("DuckDB Virtual Server", function () { const view = await table.view({ columns: ["Sales", "Profit"], }); - const json = await view.to_json({ start_row: 0, end_row: 10 }); - for (const row of json) { - expect(typeof row["Sales"]).toBe("number"); - expect(typeof row["Profit"]).toBe("number"); - } + 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(); }); @@ -651,12 +879,34 @@ test.describe("DuckDB Virtual Server", function () { const view = await table.view({ columns: ["Region", "State", "City"], }); - const json = await view.to_json({ start_row: 0, end_row: 10 }); - for (const row of json) { - expect(typeof row["Region"]).toBe("string"); - expect(typeof row["State"]).toBe("string"); - expect(typeof row["City"]).toBe("string"); - } + 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(); }); @@ -665,11 +915,14 @@ test.describe("DuckDB Virtual Server", function () { const view = await table.view({ columns: ["Order Date"], }); - const json = await view.to_json({ start_row: 0, end_row: 10 }); - for (const row of json) { - // Dates come as timestamps - expect(typeof row["Order Date"]).toBe("number"); - } + 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(); }); }); @@ -685,14 +938,21 @@ test.describe("DuckDB Virtual Server", function () { aggregates: { Sales: "sum" }, }); const json = await view.to_json(); - expect(json.length).toBe(4); // 3 categories + total - // Skip total row and verify sorting - const categoryRows = json.slice(1); - for (let i = 1; i < categoryRows.length; i++) { - expect(categoryRows[i]["Sales"]).toBeLessThanOrEqual( - categoryRows[i - 1]["Sales"], - ); - } + 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(); }); @@ -705,10 +965,79 @@ test.describe("DuckDB Virtual Server", function () { filter: [["Quantity", ">", 3]], aggregates: { Sales: "sum" }, }); + const paths = await view.column_paths(); - expect(paths.length).toBeGreaterThan(0); + 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(3); // 3 categories + total + 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(); }); @@ -722,14 +1051,28 @@ test.describe("DuckDB Virtual Server", function () { aggregates: { profitmargin: "avg" }, }); const json = await view.to_json(); - expect(json.length).toBe(5); // 4 regions + total - // Verify sorting on region rows - const regionRows = json.slice(1); - for (let i = 1; i < regionRows.length; i++) { - expect(regionRows[i]["profitmargin"]).toBeLessThanOrEqual( - regionRows[i - 1]["profitmargin"], - ); - } + 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-viewer/src/rust/components/status_indicator.rs b/rust/perspective-viewer/src/rust/components/status_indicator.rs index 9a420b4596..ff60c8c4e1 100644 --- a/rust/perspective-viewer/src/rust/components/status_indicator.rs +++ b/rust/perspective-viewer/src/rust/components/status_indicator.rs @@ -164,6 +164,9 @@ impl Reducible for StatusIconState { StatusIconStateAction::Increment | StatusIconStateAction::Decrement, ) => StatusIconState::Loading, (_, StatusIconStateAction::Increment) => Self::Updating(1), + (StatusIconState::Errored(x, y, z), _) => { + StatusIconState::Errored(x.clone(), y.clone(), z) + }, (_, StatusIconStateAction::Decrement) => StatusIconState::Normal, };