diff --git a/rust/perspective-client/src/rust/virtual_server/data.rs b/rust/perspective-client/src/rust/virtual_server/data.rs index 2e74c945ec..459cb4ae0a 100644 --- a/rust/perspective-client/src/rust/virtual_server/data.rs +++ b/rust/perspective-client/src/rust/virtual_server/data.rs @@ -601,11 +601,10 @@ impl VirtualDataSlice { /// native `perspective-server`'s `to_arrow` output when /// `emit_legacy_row_path_names: false`. /// - /// When `split_by` is active, renames data columns by replacing `_` - /// with `|` (the DuckDB PIVOT separator). - /// /// Also coerces non-standard Arrow types (e.g. `Decimal128`, `Int64`) - /// to Perspective-compatible types. + /// to Perspective-compatible types. Data column names are passed + /// through verbatim — pivoted views already name columns with + /// Perspective's column-path separator. pub fn from_arrow_ipc(&mut self, ipc: &[u8]) -> Result<(), Box> { let cursor = std::io::Cursor::new(ipc); let batches: Vec = if &ipc[0..6] == "ARROW1".as_bytes() { @@ -702,14 +701,7 @@ impl VirtualDataSlice { continue; } - let new_name = if has_split_by && !name.starts_with("__") { - name.replace('_', "|") - } else { - name.clone() - }; - - let (coerced_field, coerced_array) = - coerce_column(&new_name, field, batch.column(col_idx))?; + let (coerced_field, coerced_array) = coerce_column(name, field, batch.column(col_idx))?; new_fields.push(coerced_field); new_arrays.push(coerced_array); } @@ -1108,20 +1100,14 @@ impl VirtualDataSlice { Ok(()) } else { - let col_name = if !self.config.split_by.is_empty() && !name.starts_with("__") { - name.replace('_', "|") - } else { - name.to_owned() - }; - - if !self.builders.contains_key(&col_name) { - self.builders.insert(col_name.clone(), T::new_builder()); + if !self.builders.contains_key(name) { + self.builders.insert(name.to_owned(), T::new_builder()); } let col = self .builders - .get_mut(&col_name) - .ok_or_else(|| format!("Column '{}' not found after insertion", col_name))?; + .get_mut(name) + .ok_or_else(|| format!("Column '{}' not found after insertion", name))?; Ok(value.write_to(col)?) } 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 4590fa62ea..d0f90f95f5 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 @@ -75,6 +75,57 @@ pub type GenericSQLResult = Result; pub struct GenericSQLVirtualServerModelArgs { create_entity: Option, grouping_fn: Option, + + /// Separator joining `split_by` values and the column name in pivoted + /// view column names, e.g. `"CA|Sales"` for separator `"|"`. Perspective's + /// column-path separator is `"|"`, so any other value produces views the + /// client will not interpret as column paths. + column_separator: Option, +} + +/// Recovers the source column of a pivoted view column name — the longest +/// `config.columns` entry that is a strict suffix of `name` — with its index +/// in `config.columns`. Requires no separator knowledge, so it works at +/// protocol boundaries where the SQL model's `column_separator` is unknown. +/// Returns `None` for non-path names (e.g. flat-view columns, which equal a +/// `config.columns` entry exactly rather than strictly containing one). +pub(crate) fn column_path_source<'a>( + name: &str, + config: &'a ViewConfig, +) -> Option<(usize, &'a str)> { + let mut best: Option<(usize, &'a str)> = None; + for (idx, col) in config.columns.iter().flatten().enumerate() { + if name.len() > col.len() + && name.ends_with(col.as_str()) + && best.is_none_or(|(_, b)| col.len() > b.len()) + { + best = Some((idx, col)); + } + } + + best +} + +/// Sorts pivoted view column names into Perspective's column-path order: +/// `split_by` value paths ascending, then `config.columns` order within each +/// path (e.g. `CA|price, CA|qty, NY|price, NY|qty`). +/// +/// The per-column `PIVOT` join in [`ViewQueryContext`] emits columns grouped +/// by source column instead, so every egress of view column names re-sorts +/// with this. Internal `__`-prefixed columns sort first, unmatched names +/// last, both preserving relative order. +pub(crate) fn sort_column_paths>(names: &mut [T], config: &ViewConfig) { + names.sort_by_cached_key(|name| { + let name = name.as_ref(); + if name.starts_with("__") { + return (0u8, String::new(), 0usize); + } + + match column_path_source(name, config) { + Some((idx, col)) => (1, name[..name.len() - col.len()].to_string(), idx), + None => (2, String::new(), 0), + } + }); } /// A stateless SQL query builder virtual server operations. @@ -230,6 +281,8 @@ impl GenericSQLVirtualServerModel { } else { data_columns.sort_by(|a, b| b.cmp(a)); } + } else if !config.split_by.is_empty() { + sort_column_paths(&mut data_columns, config); } let data_columns: Vec<&String> = data_columns 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 7e7149e11c..1b0e24af88 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 @@ -54,6 +54,14 @@ enum QueryOrientation { TotalPivoted, } +fn quote_ident(name: &str) -> String { + name.replace('"', "\"\"") +} + +fn quote_literal(value: &str) -> String { + value.replace('\'', "''") +} + /// Precomputed context for building a SQL view query from a [`ViewConfig`]. /// /// Holds the resolved column names, grouping function, and row-path aliases @@ -64,6 +72,7 @@ pub(crate) struct ViewQueryContext<'a> { config: &'a ViewConfig, group_col_names: Vec, grouping_fn: &'a str, + column_separator: &'a str, row_path_aliases: Vec, } @@ -84,6 +93,7 @@ impl<'a> ViewQueryContext<'a> { }; let grouping_fn = model.0.grouping_fn.as_deref().unwrap_or("GROUPING_ID"); + let column_separator = model.0.column_separator.as_deref().unwrap_or("|"); let group_col_names: Vec = config .group_by .iter() @@ -99,6 +109,7 @@ impl<'a> ViewQueryContext<'a> { config, group_col_names, grouping_fn, + column_separator, row_path_aliases, } } @@ -138,37 +149,30 @@ impl<'a> ViewQueryContext<'a> { } }, 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 mut src_clauses = self.select_clauses(); + src_clauses.extend(self.split_select_clauses()); + src_clauses.push(format!( + "ROW_NUMBER() OVER (ORDER BY {}) as __ROW_NUM__", + self.pivot_row_num_order() + )); - let split_cols: String = self - .config - .split_by - .iter() - .map(|c| format!("\"{}\"", c)) - .collect::>() - .join(", "); + let src = format!( + "SELECT {} FROM {}{}", + src_clauses.join(", "), + self.table, + where_sql + ); + + let cols: Vec<&String> = self.config.columns.iter().flatten().collect(); + let from = if cols.is_empty() { + "__PSP_PIVOT_SRC__".to_string() + } else { + self.pivot_join(&cols, &["__ROW_NUM__".to_string()]) + }; - let row_num_order = self.pivot_row_num_order(); format!( - "SELECT * EXCLUDE (__ROW_NUM__) FROM (PIVOT (SELECT {}, {}, ROW_NUMBER() OVER \ - (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(), - pivot_using.join(", "), + "WITH __PSP_PIVOT_SRC__ AS ({}) SELECT * EXCLUDE (__ROW_NUM__) FROM {}", + src, from ) }, QueryOrientation::GroupedAndPivoted => { @@ -179,9 +183,7 @@ impl<'a> ViewQueryContext<'a> { 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)); - } + inner_clauses.extend(self.split_select_clauses()); for (sidx, Sort(sort_col, sort_dir)) in self.config.sort.iter().enumerate() { if *sort_dir != SortDir::None && !is_col_sort(sort_dir) { @@ -228,17 +230,6 @@ impl<'a> ViewQueryContext<'a> { ) }; - let pivot_using: String = self - .config - .columns - .iter() - .flatten() - .map(|col| { - let escaped = col.replace('"', "\"\"").replace('_', "-"); - format!("first(\"{}\") as \"{}\"", escaped, escaped) - }) - .collect::>() - .join(", "); let mut row_id_cols = self.row_path_aliases.clone(); if !self.is_flat_mode() { row_id_cols.push("__GROUPING_ID__".to_string()); @@ -249,12 +240,16 @@ impl<'a> ViewQueryContext<'a> { } } + let cols: Vec<&String> = self.config.columns.iter().flatten().collect(); + let from = if cols.is_empty() { + "__PSP_PIVOT_SRC__".to_string() + } else { + self.pivot_join(&cols, &row_id_cols) + }; + format!( - "SELECT * FROM (PIVOT ({}) ON {} USING {} GROUP BY {})", - inner_query, - self.pivot_on_expr(), - pivot_using, - row_id_cols.join(", ") + "WITH __PSP_PIVOT_SRC__ AS ({}) SELECT * FROM {}", + inner_query, from ) }, QueryOrientation::Total => { @@ -262,43 +257,51 @@ impl<'a> ViewQueryContext<'a> { format!("SELECT {} FROM {}{}", select, self.table, where_sql) }, QueryOrientation::TotalPivoted => { - let raw_cols: Vec = self + let mut src_clauses: Vec = self .config .columns .iter() .flatten() - .map(|col| self.col_name(col)) + .map(|col| format!("{} as \"{}\"", self.col_name(col), quote_ident(col))) .collect(); - let split_cols: String = self - .config - .split_by - .iter() - .map(|c| format!("\"{}\"", c)) - .collect::>() - .join(", "); + src_clauses.extend(self.split_select_clauses()); + let src = format!( + "SELECT {} FROM {}{}", + src_clauses.join(", "), + self.table, + where_sql + ); - 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(); + let cols: Vec<&String> = self.config.columns.iter().flatten().collect(); + let from = if cols.is_empty() { + "__PSP_PIVOT_SRC__".to_string() + } else { + // Without a `GROUP BY`, `PIVOT` implicitly groups by every + // non-pivoted source column, so each per-column pivot must + // project only its own column and the `split_by` columns. + cols.iter() + .map(|col| { + let mut proj = vec![format!("\"{}\"", quote_ident(col))]; + for c in &self.config.split_by { + if c != *col { + proj.push(format!("\"{}\"", quote_ident(c))); + } + } - format!( - "SELECT * FROM (PIVOT (SELECT {}, {} FROM {}{}) ON {} USING {})", - raw_cols.join(", "), - split_cols, - self.table, - where_sql, - self.pivot_on_expr(), - pivot_using.join(", "), - ) + format!( + "(PIVOT (SELECT {} FROM __PSP_PIVOT_SRC__) ON {} USING {}(\"{}\"))", + proj.join(", "), + self.pivot_on_expr_for(col), + self.get_aggregate(col), + quote_ident(col), + ) + }) + .collect::>() + .join(" CROSS JOIN ") + }; + + format!("WITH __PSP_PIVOT_SRC__ AS ({}) SELECT * FROM {}", src, from) }, }; @@ -380,24 +383,97 @@ impl<'a> ViewQueryContext<'a> { if self.needs_aggregation() { 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 + quote_ident(col) )); } } 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.push(format!( + "{} as \"{}\"", + self.col_name(col), + quote_ident(col) + )); } } clauses } + /// `SELECT` clauses aliasing each `split_by` column for a pivot source + /// query, skipping any that already appear in `config.columns` (whose + /// alias would collide). + fn split_select_clauses(&self) -> Vec { + self.config + .split_by + .iter() + .filter(|c| !self.config.columns.iter().flatten().any(|x| &x == c)) + .map(|c| format!("{} as \"{}\"", self.col_name(c), quote_ident(c))) + .collect() + } + + /// The `PIVOT ... ON` expression for one data column: the `split_by` + /// columns and the column name literal joined with `column_separator`, + /// so DuckDB names each output column `valuevaluecolumn` + /// verbatim. `||` concatenation propagates NULL split values, which + /// DuckDB's `PIVOT` then drops (matching its native `ON` behavior). + fn pivot_on_expr_for(&self, col: &str) -> String { + let sep = quote_literal(self.column_separator); + let splits = self + .config + .split_by + .iter() + .map(|c| format!("\"{}\"", quote_ident(c))) + .collect::>() + .join(&format!(" || '{}' || ", sep)); + + format!("{} || '{}{}'", splits, sep, quote_literal(col)) + } + + /// Builds a `FROM` expression pivoting `__PSP_PIVOT_SRC__` once per data + /// column and joining the results on `keys`. Each pivot uses a single + /// unaliased `USING` aggregate — the only form for which DuckDB names + /// output columns by the `ON` value alone, with no `_`-joined alias + /// suffix. Joins compare with `IS NOT DISTINCT FROM` because rollup rows + /// carry NULL `__ROW_PATH_N__` keys. + fn pivot_join(&self, cols: &[&String], keys: &[String]) -> String { + let keys_joined = keys.join(", "); + let pivots: Vec = cols + .iter() + .map(|col| { + format!( + "(PIVOT __PSP_PIVOT_SRC__ ON {} USING first(\"{}\") GROUP BY {})", + self.pivot_on_expr_for(col), + quote_ident(col), + keys_joined + ) + }) + .collect(); + + if pivots.len() == 1 { + return pivots.into_iter().next().unwrap(); + } + + let mut select_terms = vec!["__PSP_PIVOT_0__.*".to_string()]; + let mut from = format!("{} __PSP_PIVOT_0__", pivots[0]); + for (i, pivot) in pivots.iter().enumerate().skip(1) { + let alias = format!("__PSP_PIVOT_{}__", i); + select_terms.push(format!("{}.* EXCLUDE ({})", alias, keys_joined)); + let on = keys + .iter() + .map(|k| format!("__PSP_PIVOT_0__.{} IS NOT DISTINCT FROM {}.{}", k, alias, k)) + .collect::>() + .join(" AND "); + + from.push_str(&format!(" JOIN {} {} ON {}", pivot, alias, on)); + } + + format!("(SELECT {} FROM {})", select_terms.join(", "), from) + } + fn where_sql(&self) -> String { let clauses: Vec = self .config 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 fa4a7ee272..99a1049922 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 @@ -591,6 +591,248 @@ fn test_table_make_view_total() { ); } +#[test] +fn test_table_make_view_flat_preserves_underscores() { + // https://github.com/perspective-dev/perspective/issues/3187 + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("account_number".to_string())]; + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!( + sql.contains("\"account_number\" as \"account_number\""), + "underscore names should pass through verbatim: {}", + sql + ); + assert!( + !sql.contains("account-number"), + "underscores should not be mangled to hyphens: {}", + sql + ); +} + +#[test] +fn test_table_make_view_pivoted_column_paths() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![ + Some("account_number".to_string()), + Some("other_val".to_string()), + ]; + config.split_by = vec!["state".to_string()]; + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!( + sql.contains("ON \"state\" || '|account_number' USING first(\"account_number\")"), + "expected per-column ON expression: {}", + sql + ); + assert!( + sql.contains("ON \"state\" || '|other_val' USING first(\"other_val\")"), + "expected per-column ON expression: {}", + sql + ); + assert!( + !sql.contains("account-number"), + "underscores should not be mangled to hyphens: {}", + sql + ); + assert!( + sql.contains("IS NOT DISTINCT FROM"), + "multi-column pivots should be NULL-safe joined: {}", + sql + ); +} + +#[test] +fn test_table_make_view_pivoted_custom_separator() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs { + create_entity: None, + grouping_fn: None, + column_separator: Some("::".to_string()), + }); + + let mut config = ViewConfig::default(); + config.columns = vec![Some("account_number".to_string())]; + config.split_by = vec!["state".to_string()]; + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!( + sql.contains("ON \"state\" || '::account_number'"), + "expected custom separator in ON expression: {}", + sql + ); +} + +#[test] +fn test_table_make_view_multi_split_by_separator() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("value".to_string())]; + config.split_by = vec!["region".to_string(), "state".to_string()]; + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!( + sql.contains("ON \"region\" || '|' || \"state\" || '|value'"), + "expected separator-joined multi-level ON expression: {}", + sql + ); +} + +#[test] +fn test_table_make_view_grouped_pivoted_null_safe_join() { + 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()]; + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!( + sql.contains("GROUP BY __ROW_PATH_0__, __GROUPING_ID__"), + "expected pivot GROUP BY on row keys: {}", + sql + ); + assert!( + sql.contains( + "__PSP_PIVOT_0__.__ROW_PATH_0__ IS NOT DISTINCT FROM __PSP_PIVOT_1__.__ROW_PATH_0__" + ), + "rollup rows have NULL row-path keys, join must be NULL-safe: {}", + sql + ); +} + +#[test] +fn test_table_make_view_total_pivoted_aggregate() { + 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("ON \"quarter\" || '|value' USING sum(\"value\")"), + "expected unaliased aggregate in USING: {}", + sql + ); +} + +#[test] +fn test_column_path_source() { + let mut config = ViewConfig::default(); + config.columns = vec![ + Some("price".to_string()), + Some("total_price".to_string()), + Some("account_number".to_string()), + ]; + + // Path names resolve to their source column, longest suffix winning. + assert_eq!(column_path_source("CA|price", &config), Some((0, "price"))); + assert_eq!( + column_path_source("CA|total_price", &config), + Some((1, "total_price")) + ); + assert_eq!( + column_path_source("us_east|account_number", &config), + Some((2, "account_number")) + ); + + // Flat-view names equal a config column exactly — not a path. + assert_eq!(column_path_source("price", &config), None); + assert_eq!(column_path_source("__ROW_PATH_0__", &config), None); +} + +#[test] +fn test_sort_column_paths_value_major() { + let mut config = ViewConfig::default(); + config.columns = vec![Some("price".to_string()), Some("qty".to_string())]; + config.split_by = vec!["state".to_string()]; + let mut names = vec![ + "CA|price".to_string(), + "NY|price".to_string(), + "CA|qty".to_string(), + "NY|qty".to_string(), + ]; + + sort_column_paths(&mut names, &config); + assert_eq!(names, vec!["CA|price", "CA|qty", "NY|price", "NY|qty"]); +} + +#[test] +fn test_sort_column_paths_longest_suffix_wins() { + let mut config = ViewConfig::default(); + config.columns = vec![Some("price".to_string()), Some("total_price".to_string())]; + config.split_by = vec!["state".to_string()]; + let mut names = vec![ + "NY|total_price".to_string(), + "CA|total_price".to_string(), + "CA|price".to_string(), + "__ROW_PATH_0__".to_string(), + ]; + + sort_column_paths(&mut names, &config); + assert_eq!(names, vec![ + "__ROW_PATH_0__", + "CA|price", + "CA|total_price", + "NY|total_price" + ]); +} + +#[test] +fn test_view_get_data_split_by_value_major_order() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("price".to_string()), Some("qty".to_string())]; + config.split_by = vec!["state".to_string()]; + let viewport = ViewPort { + start_row: Some(0), + end_row: Some(100), + start_col: Some(0), + end_col: None, + ..ViewPort::default() + }; + + // The per-column pivot join emits column-major order; the data query + // must restore value-major order. + let mut schema = IndexMap::new(); + schema.insert("CA|price".to_string(), ColumnType::Float); + schema.insert("NY|price".to_string(), ColumnType::Float); + schema.insert("CA|qty".to_string(), ColumnType::Float); + schema.insert("NY|qty".to_string(), ColumnType::Float); + let sql = builder + .view_get_data("my_view", &config, &viewport, &schema) + .unwrap(); + + let positions: Vec = ["\"CA|price\"", "\"CA|qty\"", "\"NY|price\"", "\"NY|qty\""] + .iter() + .map(|c| sql.find(c).unwrap()) + .collect(); + + assert!( + positions.windows(2).all(|w| w[0] < w[1]), + "expected value-major column order: {}", + sql + ); +} + #[test] fn test_table_make_view_total_with_split_by() { let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); diff --git a/rust/perspective-client/src/rust/virtual_server/server.rs b/rust/perspective-client/src/rust/virtual_server/server.rs index e1c557f022..a593faaaa6 100644 --- a/rust/perspective-client/src/rust/virtual_server/server.rs +++ b/rust/perspective-client/src/rust/virtual_server/server.rs @@ -18,6 +18,7 @@ use prost::bytes::{Bytes, BytesMut}; use super::data::RowPathStyle; use super::error::VirtualServerError; +use super::generic_sql_model::{column_path_source, sort_column_paths}; use super::handler::VirtualServerHandler; use crate::config::{ViewConfig, ViewConfigUpdate}; use crate::proto::response::ClientResp; @@ -114,16 +115,21 @@ impl VirtualServer { } if to_psp_format { + // `view.schema()` is keyed by *source* column name, matching the + // native engine, while the cached schema is keyed by the view's + // actual (possibly pivoted-path) SQL column names. + let config = self.view_configs.get(entity_id).unwrap(); Ok(self .view_schemas .get(entity_id) .unwrap() .iter() .map(|(k, v)| { - ( - k.split("_").collect::>().last().unwrap().to_string(), - *v, - ) + let name = column_path_source(k, config) + .map(|(_, col)| col.to_string()) + .unwrap_or_else(|| k.clone()); + + (name, *v) }) .collect()) } else { @@ -286,18 +292,20 @@ impl VirtualServer { respond!(msg, ViewExpressionSchemaResp { ..resp }) }, ViewColumnPathsReq(_) => { - respond!(msg, ViewColumnPathsResp { - paths: self - .handler - .view_schema( - msg.entity_id.as_str(), - self.view_configs.get(&msg.entity_id).unwrap() - ) - .await? - .keys() - .cloned() - .collect() - }) + let config = self.view_configs.get(&msg.entity_id).unwrap(); + let mut paths: Vec = self + .handler + .view_schema(msg.entity_id.as_str(), config) + .await? + .keys() + .cloned() + .collect(); + + if !config.split_by.is_empty() { + sort_column_paths(&mut paths, config); + } + + respond!(msg, ViewColumnPathsResp { paths }) }, ViewToArrowReq(view_to_arrow_req) => { let viewport = view_to_arrow_req.viewport.unwrap(); diff --git a/rust/perspective-js/src/ts/virtual_servers/clickhouse.ts b/rust/perspective-js/src/ts/virtual_servers/clickhouse.ts index fb9c472814..1dd15a2526 100644 --- a/rust/perspective-js/src/ts/virtual_servers/clickhouse.ts +++ b/rust/perspective-js/src/ts/virtual_servers/clickhouse.ts @@ -214,6 +214,7 @@ export class ClickhouseHandler implements perspective.VirtualServerHandler { this.sqlBuilder = new mod!.GenericSQLVirtualServerModel({ create_entity: "VIEW", grouping_fn: "GROUPING", + column_separator: "|", }); } diff --git a/rust/perspective-js/src/ts/virtual_servers/duckdb.ts b/rust/perspective-js/src/ts/virtual_servers/duckdb.ts index a8a762aaac..a8a9599983 100644 --- a/rust/perspective-js/src/ts/virtual_servers/duckdb.ts +++ b/rust/perspective-js/src/ts/virtual_servers/duckdb.ts @@ -184,7 +184,9 @@ export class DuckDBHandler implements perspective.VirtualServerHandler { } this.db = db; - this.sqlBuilder = new mod!.GenericSQLVirtualServerModel(); + this.sqlBuilder = new mod!.GenericSQLVirtualServerModel({ + column_separator: "|", + }); } getFeatures() { diff --git a/rust/perspective-js/test/js/duckdb/client.spec.js b/rust/perspective-js/test/js/duckdb/client.spec.js index 34e193f54f..d1ae2eff8a 100644 --- a/rust/perspective-js/test/js/duckdb/client.spec.js +++ b/rust/perspective-js/test/js/duckdb/client.spec.js @@ -17,6 +17,6 @@ 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"]); + expect(tables).toEqual(["memory.superstore", "memory.underscore_test"]); }); }); diff --git a/rust/perspective-js/test/js/duckdb/combined.spec.js b/rust/perspective-js/test/js/duckdb/combined.spec.js index 1c8c018a1b..1373afe3bb 100644 --- a/rust/perspective-js/test/js/duckdb/combined.spec.js +++ b/rust/perspective-js/test/js/duckdb/combined.spec.js @@ -54,10 +54,10 @@ describeDuckDB("combined operations", (getClient) => { const paths = await view.column_paths(); expect(paths).toEqual([ - "Central_Sales", - "East_Sales", - "South_Sales", - "West_Sales", + "Central|Sales", + "East|Sales", + "South|Sales", + "West|Sales", ]); const numRows = await view.num_rows(); @@ -108,10 +108,10 @@ describeDuckDB("combined operations", (getClient) => { const paths = await view.column_paths(); expect(paths).toEqual([ - "Central_Sales", - "East_Sales", - "South_Sales", - "West_Sales", + "Central|Sales", + "East|Sales", + "South|Sales", + "West|Sales", ]); const numRows = await view.num_rows(); @@ -140,10 +140,10 @@ describeDuckDB("combined operations", (getClient) => { const paths = await view.column_paths(); expect(paths).toEqual([ - "Central_Sales", - "East_Sales", - "South_Sales", - "West_Sales", + "Central|Sales", + "East|Sales", + "South|Sales", + "West|Sales", ]); const numRows = await view.num_rows(); diff --git a/rust/perspective-js/test/js/duckdb/setup.js b/rust/perspective-js/test/js/duckdb/setup.js index a8a16b3f81..1e06eeed03 100644 --- a/rust/perspective-js/test/js/duckdb/setup.js +++ b/rust/perspective-js/test/js/duckdb/setup.js @@ -71,6 +71,27 @@ async function loadSuperstoreData(db) { }); } +// Column names AND values contain underscores, which DuckDB's `PIVOT` uses +// as its own output-name separator. https://github.com/perspective-dev/perspective/issues/3187 +async function loadUnderscoreData(db) { + await db.query(` + CREATE TABLE underscore_test ( + region_name VARCHAR, + sub_region VARCHAR, + account_number INTEGER, + total_sales DOUBLE + ); + `); + + await db.query(` + INSERT INTO underscore_test VALUES + ('east_coast', 'new_york', 1, 100.5), + ('east_coast', 'new_jersey', 2, 200.25), + ('west_coast', 'bay_area', 3, 300.75), + ('west_coast', 'la_metro', 4, 400.0); + `); +} + export function describeDuckDB(name, fn) { test.describe("DuckDB Virtual Server " + name, function () { let db; @@ -83,6 +104,7 @@ export function describeDuckDB(name, fn) { ); client = await perspective.worker(server); await loadSuperstoreData(db); + await loadUnderscoreData(db); }); fn(() => client); diff --git a/rust/perspective-js/test/js/duckdb/split_by.spec.js b/rust/perspective-js/test/js/duckdb/split_by.spec.js index 53820f2068..a80b6736a8 100644 --- a/rust/perspective-js/test/js/duckdb/split_by.spec.js +++ b/rust/perspective-js/test/js/duckdb/split_by.spec.js @@ -25,10 +25,10 @@ describeDuckDB("split_by", (getClient) => { const columns = await view.column_paths(); expect(columns).toEqual([ - "Central_Sales", - "East_Sales", - "South_Sales", - "West_Sales", + "Central|Sales", + "East|Sales", + "South|Sales", + "West|Sales", ]); const json = await view.to_json(); diff --git a/rust/perspective-js/test/js/duckdb/underscore_columns.spec.js b/rust/perspective-js/test/js/duckdb/underscore_columns.spec.js new file mode 100644 index 0000000000..53c412254b --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/underscore_columns.spec.js @@ -0,0 +1,315 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// Column names and values containing "_" must pass through the SQL model +// unmangled, including through `PIVOT`, which internally joins output names +// with "_". https://github.com/perspective-dev/perspective/issues/3187 + +import { test, expect } from "@perspective-dev/test"; +import { describeDuckDB } from "./setup.js"; + +describeDuckDB("underscore columns", (getClient) => { + test("table schema() preserves underscore names", async function () { + const table = await getClient().open_table("memory.underscore_test"); + const schema = await table.schema(); + expect(schema).toEqual({ + region_name: "string", + sub_region: "string", + account_number: "integer", + total_sales: "float", + }); + }); + + test("flat view passes underscore names through", async function () { + const table = await getClient().open_table("memory.underscore_test"); + const view = await table.view({ + columns: ["account_number", "total_sales"], + }); + + const paths = await view.column_paths(); + expect(paths).toEqual(["account_number", "total_sales"]); + + const schema = await view.schema(); + expect(schema).toEqual({ + account_number: "integer", + total_sales: "float", + }); + + const columns = await view.to_columns(); + expect(columns).toEqual({ + account_number: [1, 2, 3, 4], + total_sales: [100.5, 200.25, 300.75, 400.0], + }); + + await view.delete(); + }); + + test("sort on underscore column", async function () { + const table = await getClient().open_table("memory.underscore_test"); + const view = await table.view({ + columns: ["account_number"], + sort: [["account_number", "desc"]], + }); + + const columns = await view.to_columns(); + expect(columns).toEqual({ + account_number: [4, 3, 2, 1], + }); + + await view.delete(); + }); + + test("filter on underscore column", async function () { + const table = await getClient().open_table("memory.underscore_test"); + const view = await table.view({ + columns: ["account_number"], + filter: [["account_number", ">", 2]], + }); + + const columns = await view.to_columns(); + expect(columns).toEqual({ + account_number: [3, 4], + }); + + await view.delete(); + }); + + test("group_by underscore column with underscore values", async function () { + const table = await getClient().open_table("memory.underscore_test"); + const view = await table.view({ + columns: ["total_sales"], + group_by: ["region_name"], + aggregates: { total_sales: "sum" }, + }); + + const json = await view.to_json(); + expect(json).toEqual([ + { __ROW_PATH__: [], total_sales: 1001.5 }, + { __ROW_PATH__: ["east_coast"], total_sales: 300.75 }, + { __ROW_PATH__: ["west_coast"], total_sales: 700.75 }, + ]); + + await view.delete(); + }); + + test("split_by underscore column with underscore values", async function () { + const table = await getClient().open_table("memory.underscore_test"); + const view = await table.view({ + columns: ["total_sales"], + split_by: ["region_name"], + }); + + const paths = await view.column_paths(); + expect(paths).toEqual([ + "east_coast|total_sales", + "west_coast|total_sales", + ]); + + const json = await view.to_json(); + expect(json).toEqual([ + { "east_coast|total_sales": 100.5, "west_coast|total_sales": null }, + { + "east_coast|total_sales": 200.25, + "west_coast|total_sales": null, + }, + { + "east_coast|total_sales": null, + "west_coast|total_sales": 300.75, + }, + { "east_coast|total_sales": null, "west_coast|total_sales": 400.0 }, + ]); + + await view.delete(); + }); + + test("split_by + group_by, multiple underscore columns", async function () { + const table = await getClient().open_table("memory.underscore_test"); + const view = await table.view({ + columns: ["account_number", "total_sales"], + group_by: ["sub_region"], + split_by: ["region_name"], + aggregates: { account_number: "sum", total_sales: "sum" }, + }); + + const paths = await view.column_paths(); + expect(paths).toEqual([ + "east_coast|account_number", + "east_coast|total_sales", + "west_coast|account_number", + "west_coast|total_sales", + ]); + + // Like the native engine, `view.schema()` is keyed by source column + // name, not by column path — the datagrid resolves types with it. + const schema = await view.schema(); + expect(schema).toEqual({ + account_number: "float", + total_sales: "float", + }); + + const json = await view.to_json(); + expect(json).toEqual([ + { + __ROW_PATH__: [], + "east_coast|account_number": 3, + "east_coast|total_sales": 300.75, + "west_coast|account_number": 7, + "west_coast|total_sales": 700.75, + }, + { + __ROW_PATH__: ["bay_area"], + "east_coast|account_number": null, + "east_coast|total_sales": null, + "west_coast|account_number": 3, + "west_coast|total_sales": 300.75, + }, + { + __ROW_PATH__: ["la_metro"], + "east_coast|account_number": null, + "east_coast|total_sales": null, + "west_coast|account_number": 4, + "west_coast|total_sales": 400.0, + }, + { + __ROW_PATH__: ["new_jersey"], + "east_coast|account_number": 2, + "east_coast|total_sales": 200.25, + "west_coast|account_number": null, + "west_coast|total_sales": null, + }, + { + __ROW_PATH__: ["new_york"], + "east_coast|account_number": 1, + "east_coast|total_sales": 100.5, + "west_coast|account_number": null, + "west_coast|total_sales": null, + }, + ]); + + await view.delete(); + }); + + test("sort with split_by on underscore columns", async function () { + const table = await getClient().open_table("memory.underscore_test"); + const view = await table.view({ + columns: ["total_sales"], + group_by: ["sub_region"], + split_by: ["region_name"], + aggregates: { total_sales: "sum" }, + sort: [["total_sales", "desc"]], + }); + + const json = await view.to_json(); + expect(json).toEqual([ + { + __ROW_PATH__: [], + "east_coast|total_sales": 300.75, + "west_coast|total_sales": 700.75, + }, + { + __ROW_PATH__: ["la_metro"], + "east_coast|total_sales": null, + "west_coast|total_sales": 400.0, + }, + { + __ROW_PATH__: ["bay_area"], + "east_coast|total_sales": null, + "west_coast|total_sales": 300.75, + }, + { + __ROW_PATH__: ["new_jersey"], + "east_coast|total_sales": 200.25, + "west_coast|total_sales": null, + }, + { + __ROW_PATH__: ["new_york"], + "east_coast|total_sales": 100.5, + "west_coast|total_sales": null, + }, + ]); + + await view.delete(); + }); + + test("multi-level split_by on underscore columns", async function () { + const table = await getClient().open_table("memory.underscore_test"); + const view = await table.view({ + columns: ["total_sales"], + split_by: ["region_name", "sub_region"], + }); + + const paths = await view.column_paths(); + expect(paths).toEqual([ + "east_coast|new_jersey|total_sales", + "east_coast|new_york|total_sales", + "west_coast|bay_area|total_sales", + "west_coast|la_metro|total_sales", + ]); + + const json = await view.to_json({ start_row: 0, end_row: 1 }); + expect(json).toEqual([ + { + "east_coast|new_jersey|total_sales": null, + "east_coast|new_york|total_sales": 100.5, + "west_coast|bay_area|total_sales": null, + "west_coast|la_metro|total_sales": null, + }, + ]); + + await view.delete(); + }); + + test("expression with underscore alias and underscore inputs", async function () { + const table = await getClient().open_table("memory.underscore_test"); + const view = await table.view({ + columns: ["total_sales", "double_sales"], + expressions: { double_sales: '"total_sales" * 2' }, + }); + + const columns = await view.to_columns(); + expect(columns).toEqual({ + total_sales: [100.5, 200.25, 300.75, 400.0], + double_sales: [201.0, 400.5, 601.5, 800.0], + }); + + await view.delete(); + }); + + test("expression with underscore alias under split_by", async function () { + const table = await getClient().open_table("memory.underscore_test"); + const view = await table.view({ + columns: ["double_sales"], + split_by: ["region_name"], + expressions: { double_sales: '"total_sales" * 2' }, + }); + + const paths = await view.column_paths(); + expect(paths).toEqual([ + "east_coast|double_sales", + "west_coast|double_sales", + ]); + + const json = await view.to_json({ start_row: 0, end_row: 2 }); + expect(json).toEqual([ + { + "east_coast|double_sales": 201.0, + "west_coast|double_sales": null, + }, + { + "east_coast|double_sales": 400.5, + "west_coast|double_sales": null, + }, + ]); + + await view.delete(); + }); +}); diff --git a/rust/perspective-python/perspective/tests/virtual_servers/test_duckdb.py b/rust/perspective-python/perspective/tests/virtual_servers/test_duckdb.py index af85c88fb1..c97697909b 100644 --- a/rust/perspective-python/perspective/tests/virtual_servers/test_duckdb.py +++ b/rust/perspective-python/perspective/tests/virtual_servers/test_duckdb.py @@ -324,10 +324,10 @@ def test_single_split_by(self, client): column_paths = view.column_paths() assert column_paths == [ - "Central_Sales", - "East_Sales", - "South_Sales", - "West_Sales", + "Central|Sales", + "East|Sales", + "South|Sales", + "West|Sales", ] json = view.to_json() @@ -766,10 +766,10 @@ def test_split_by_group_by_filter(self, client): paths = view.column_paths() assert paths == [ - "Central_Sales", - "East_Sales", - "South_Sales", - "West_Sales", + "Central|Sales", + "East|Sales", + "South|Sales", + "West|Sales", ] num_rows = view.num_rows() @@ -818,10 +818,10 @@ def test_split_by_only(self, client): paths = view.column_paths() assert paths == [ - "Central_Sales", - "East_Sales", - "South_Sales", - "West_Sales", + "Central|Sales", + "East|Sales", + "South|Sales", + "West|Sales", ] num_rows = view.num_rows() diff --git a/rust/perspective-server/cmake/Boost.txt.in b/rust/perspective-server/cmake/Boost.txt.in index 139c9c28f0..322dfc48f0 100644 --- a/rust/perspective-server/cmake/Boost.txt.in +++ b/rust/perspective-server/cmake/Boost.txt.in @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.7.2) +cmake_minimum_required(VERSION 3.18.2) project(Boost-download NONE) diff --git a/rust/perspective-server/cmake/Pybind.txt.in b/rust/perspective-server/cmake/Pybind.txt.in index 512b1d9402..e47de3b1af 100644 --- a/rust/perspective-server/cmake/Pybind.txt.in +++ b/rust/perspective-server/cmake/Pybind.txt.in @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.7.2) +cmake_minimum_required(VERSION 3.18.2) project(pybind11-download NONE) diff --git a/rust/perspective-server/cmake/arrow.txt.in b/rust/perspective-server/cmake/arrow.txt.in index 4dd39e7b40..174edc419d 100644 --- a/rust/perspective-server/cmake/arrow.txt.in +++ b/rust/perspective-server/cmake/arrow.txt.in @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.7.2) +cmake_minimum_required(VERSION 3.18.2) project(arrow-download NONE) diff --git a/rust/perspective-server/cmake/date.txt.in b/rust/perspective-server/cmake/date.txt.in index c0123ea580..ca1037509d 100644 --- a/rust/perspective-server/cmake/date.txt.in +++ b/rust/perspective-server/cmake/date.txt.in @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.7.2) +cmake_minimum_required(VERSION 3.18.2) project(date-download NONE) diff --git a/rust/perspective-server/cmake/exprtk.txt.in b/rust/perspective-server/cmake/exprtk.txt.in index d1a3942f5e..4f5126677e 100644 --- a/rust/perspective-server/cmake/exprtk.txt.in +++ b/rust/perspective-server/cmake/exprtk.txt.in @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.7.2) +cmake_minimum_required(VERSION 3.18.2) project(exprtk-download NONE) diff --git a/rust/perspective-server/cmake/hopscotch.txt.in b/rust/perspective-server/cmake/hopscotch.txt.in index c0094035ae..96bc8eed98 100644 --- a/rust/perspective-server/cmake/hopscotch.txt.in +++ b/rust/perspective-server/cmake/hopscotch.txt.in @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.7.2) +cmake_minimum_required(VERSION 3.18.2) project(hopscotch-download NONE) diff --git a/rust/perspective-server/cmake/modules/FindColor.cmake b/rust/perspective-server/cmake/modules/FindColor.cmake index d868433b00..5dd4cef78b 100644 --- a/rust/perspective-server/cmake/modules/FindColor.cmake +++ b/rust/perspective-server/cmake/modules/FindColor.cmake @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.7.2) +cmake_minimum_required(VERSION 3.18.2) if(NOT WIN32) string(ASCII 27 Esc) diff --git a/rust/perspective-server/cmake/modules/FindExprTk.cmake b/rust/perspective-server/cmake/modules/FindExprTk.cmake index 86f7e07889..8ff7606c0a 100644 --- a/rust/perspective-server/cmake/modules/FindExprTk.cmake +++ b/rust/perspective-server/cmake/modules/FindExprTk.cmake @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.7.2) +cmake_minimum_required(VERSION 3.18.2) # Program: Visualization Toolkit # Module: Copyright.txt diff --git a/rust/perspective-server/cmake/modules/FindInstallDependency.cmake b/rust/perspective-server/cmake/modules/FindInstallDependency.cmake index 7378a9d618..a9dabbc635 100644 --- a/rust/perspective-server/cmake/modules/FindInstallDependency.cmake +++ b/rust/perspective-server/cmake/modules/FindInstallDependency.cmake @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.7.2) +cmake_minimum_required(VERSION 3.18.2) # Workaround for: https://gitlab.kitware.com/cmake/cmake/-/issues/22740 set(D "$") diff --git a/rust/perspective-server/cmake/modules/FindRe2.cmake b/rust/perspective-server/cmake/modules/FindRe2.cmake index 78763a42db..e23706dc4d 100644 --- a/rust/perspective-server/cmake/modules/FindRe2.cmake +++ b/rust/perspective-server/cmake/modules/FindRe2.cmake @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.7.2) +cmake_minimum_required(VERSION 3.18.2) # Copyright 2017 gRPC authors. # diff --git a/rust/perspective-server/cmake/ordered-map.txt.in b/rust/perspective-server/cmake/ordered-map.txt.in index 04c918b2b7..832552606c 100644 --- a/rust/perspective-server/cmake/ordered-map.txt.in +++ b/rust/perspective-server/cmake/ordered-map.txt.in @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.7.2) +cmake_minimum_required(VERSION 3.18.2) project(ordered-map-download NONE) diff --git a/rust/perspective-server/cmake/protobuf.txt.in b/rust/perspective-server/cmake/protobuf.txt.in index 35504307a8..895b8ced12 100644 --- a/rust/perspective-server/cmake/protobuf.txt.in +++ b/rust/perspective-server/cmake/protobuf.txt.in @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.7.2) +cmake_minimum_required(VERSION 3.18.2) project(protobuf-download NONE) diff --git a/rust/perspective-server/cmake/rapidjson.txt.in b/rust/perspective-server/cmake/rapidjson.txt.in index 18ef8c7ed1..b01912174a 100644 --- a/rust/perspective-server/cmake/rapidjson.txt.in +++ b/rust/perspective-server/cmake/rapidjson.txt.in @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.7.2) +cmake_minimum_required(VERSION 3.18.2) project(rapidjson-download NONE) diff --git a/rust/perspective-server/cmake/re2.txt.in b/rust/perspective-server/cmake/re2.txt.in index 65b4899548..8d80103af4 100644 --- a/rust/perspective-server/cmake/re2.txt.in +++ b/rust/perspective-server/cmake/re2.txt.in @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.7.2) +cmake_minimum_required(VERSION 3.18.2) project(re2-download NONE)