Skip to main content

perspective_client/virtual_server/
generic_sql_model.rs

1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
5// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13//! SQL query builder for virtual server operations.
14//!
15//! This module provides a stateless SQL query generator that produces
16//! generic SQL strings for perspective virtual server operations.
17
18// TODO(texodus): Missing these features
19//
20// - row expand/collapse in the datagrid needs datamodel support, this is likely
21//   a "collapsed" boolean column in the temp table we `UPDATE`.
22//
23// - `on_update` real-time support will be method which takes sa view name and a
24//   handler and calls the handler when the view needs to be recalculated.
25//
26// Nice to have:
27//
28// - Optional `view_change` method can be implemented for engine optimization,
29//   defaulting to just delete & recreate (as Perspective engine does now).
30//
31// - Would like to add a metadata API so that e.g. Viewer debug panel could show
32//   internal generated SQL.
33
34mod table_make_view;
35
36#[cfg(test)]
37mod tests;
38
39use std::fmt;
40
41use indexmap::IndexMap;
42use serde::Deserialize;
43
44use crate::config::{FilterTerm, GroupRollupMode, Scalar, Sort, SortDir, ViewConfig};
45use crate::proto::{ColumnType, ViewPort};
46use crate::virtual_server::generic_sql_model::table_make_view::ViewQueryContext;
47
48/// Error type for SQL generation operations.
49#[derive(Debug, Clone)]
50pub enum GenericSQLError {
51    /// A required column was not found in the schema.
52    ColumnNotFound(String),
53    /// An invalid configuration was provided.
54    InvalidConfig(String),
55    /// An unsupported operation was requested.
56    UnsupportedOperation(String),
57}
58
59impl fmt::Display for GenericSQLError {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        match self {
62            Self::ColumnNotFound(col) => write!(f, "Column not found: {}", col),
63            Self::InvalidConfig(msg) => write!(f, "Invalid configuration: {}", msg),
64            Self::UnsupportedOperation(msg) => write!(f, "Unsupported operation: {}", msg),
65        }
66    }
67}
68
69impl std::error::Error for GenericSQLError {}
70
71/// Result type alias for SQL operations.
72pub type GenericSQLResult<T> = Result<T, GenericSQLError>;
73
74#[derive(Clone, Debug, Deserialize, Default)]
75pub struct GenericSQLVirtualServerModelArgs {
76    create_entity: Option<String>,
77    grouping_fn: Option<String>,
78
79    /// Separator joining `split_by` values and the column name in pivoted
80    /// view column names, e.g. `"CA|Sales"` for separator `"|"`. Perspective's
81    /// column-path separator is `"|"`, so any other value produces views the
82    /// client will not interpret as column paths.
83    column_separator: Option<String>,
84}
85
86/// Recovers the source column of a pivoted view column name — the longest
87/// `config.columns` entry that is a strict suffix of `name` — with its index
88/// in `config.columns`. Requires no separator knowledge, so it works at
89/// protocol boundaries where the SQL model's `column_separator` is unknown.
90/// Returns `None` for non-path names (e.g. flat-view columns, which equal a
91/// `config.columns` entry exactly rather than strictly containing one).
92pub(crate) fn column_path_source<'a>(
93    name: &str,
94    config: &'a ViewConfig,
95) -> Option<(usize, &'a str)> {
96    let mut best: Option<(usize, &'a str)> = None;
97    for (idx, col) in config.columns.iter().flatten().enumerate() {
98        if name.len() > col.len()
99            && name.ends_with(col.as_str())
100            && best.is_none_or(|(_, b)| col.len() > b.len())
101        {
102            best = Some((idx, col));
103        }
104    }
105
106    best
107}
108
109/// Sorts pivoted view column names into Perspective's column-path order:
110/// `split_by` value paths ascending, then `config.columns` order within each
111/// path (e.g. `CA|price, CA|qty, NY|price, NY|qty`).
112///
113/// The per-column `PIVOT` join in [`ViewQueryContext`] emits columns grouped
114/// by source column instead, so every egress of view column names re-sorts
115/// with this. Internal `__`-prefixed columns sort first, unmatched names
116/// last, both preserving relative order.
117pub(crate) fn sort_column_paths<T: AsRef<str>>(names: &mut [T], config: &ViewConfig) {
118    names.sort_by_cached_key(|name| {
119        let name = name.as_ref();
120        if name.starts_with("__") {
121            return (0u8, String::new(), 0usize);
122        }
123
124        match column_path_source(name, config) {
125            Some((idx, col)) => (1, name[..name.len() - col.len()].to_string(), idx),
126            None => (2, String::new(), 0),
127        }
128    });
129}
130
131/// A stateless SQL query builder virtual server operations.
132///
133/// This struct generates SQL query strings without executing them, allowing
134/// the caller to execute the queries against a SQL connection.
135#[derive(Debug, Default, Clone)]
136pub struct GenericSQLVirtualServerModel(GenericSQLVirtualServerModelArgs);
137
138impl GenericSQLVirtualServerModel {
139    /// Creates a new `GenericSQLVirtualServerModel` instance.
140    pub fn new(args: GenericSQLVirtualServerModelArgs) -> Self {
141        Self(args)
142    }
143
144    /// Returns the SQL query to list all hosted tables.
145    ///
146    /// # Returns
147    /// SQL: `SHOW ALL TABLES`
148    pub fn get_hosted_tables(&self) -> GenericSQLResult<String> {
149        Ok("SHOW ALL TABLES".to_string())
150    }
151
152    /// Returns the SQL query to describe a table's schema.
153    ///
154    /// # Arguments
155    /// * `table_id` - The identifier of the table to describe.
156    ///
157    /// # Returns
158    /// SQL: `DESCRIBE {table_id}`
159    pub fn table_schema(&self, table_id: &str) -> GenericSQLResult<String> {
160        Ok(format!("DESCRIBE {}", table_id))
161    }
162
163    /// Returns the SQL query to get the row count of a table.
164    ///
165    /// # Arguments
166    /// * `table_id` - The identifier of the table.
167    ///
168    /// # Returns
169    /// SQL: `SELECT COUNT(*) FROM {table_id}`
170    pub fn table_size(&self, table_id: &str) -> GenericSQLResult<String> {
171        Ok(format!("SELECT COUNT(*) FROM {}", table_id))
172    }
173
174    /// Returns the SQL query to get the column count of a view.
175    ///
176    /// # Arguments
177    /// * `view_id` - The identifier of the view.
178    ///
179    /// # Returns
180    /// SQL: `SELECT COUNT(*) FROM (DESCRIBE {view_id})`
181    pub fn view_column_size(&self, view_id: &str) -> GenericSQLResult<String> {
182        Ok(format!("SELECT COUNT(*) FROM (DESCRIBE {})", view_id))
183    }
184
185    /// Returns the SQL query to validate an expression against a table.
186    ///
187    /// # Arguments
188    /// * `table_id` - The identifier of the table.
189    /// * `expression` - The SQL expression to validate.
190    ///
191    /// # Returns
192    /// SQL: `DESCRIBE (SELECT {expression} FROM {table_id})`
193    pub fn table_validate_expression(
194        &self,
195        table_id: &str,
196        expression: &str,
197    ) -> GenericSQLResult<String> {
198        Ok(format!(
199            "DESCRIBE (SELECT {} FROM {})",
200            expression, table_id
201        ))
202    }
203
204    /// Returns the SQL query to delete a view.
205    ///
206    /// # Arguments
207    /// * `view_id` - The identifier of the view to delete.
208    ///
209    /// # Returns
210    /// SQL: `DROP TABLE IF EXISTS {view_id}`
211    pub fn view_delete(&self, view_id: &str) -> GenericSQLResult<String> {
212        Ok(format!("DROP TABLE IF EXISTS {}", view_id))
213    }
214
215    /// Returns the SQL query to create a view from a table with the given
216    /// configuration.
217    ///
218    /// # Arguments
219    /// * `table_id` - The identifier of the source table.
220    /// * `view_id` - The identifier for the new view.
221    /// * `config` - The view configuration specifying columns, group_by,
222    ///   split_by, etc.
223    ///
224    /// # Returns
225    /// SQL: `CREATE TABLE {view_id} AS (...)`
226    pub fn table_make_view(
227        &self,
228        table_id: &str,
229        view_id: &str,
230        config: &ViewConfig,
231    ) -> GenericSQLResult<String> {
232        let ctx = ViewQueryContext::new(self, table_id, config);
233        let query = ctx.build_query();
234        let template = self.0.create_entity.as_deref().unwrap_or("TABLE");
235        Ok(format!("CREATE {} {} AS ({})", template, view_id, query))
236    }
237
238    /// Returns the SQL query to fetch data from a view with the given viewport.
239    ///
240    /// # Arguments
241    /// * `view_id` - The identifier of the view.
242    /// * `config` - The view configuration.
243    /// * `viewport` - The viewport specifying row/column ranges.
244    /// * `schema` - The schema of the view (column names to types).
245    ///
246    /// # Returns
247    /// SQL: `SELECT ... FROM {view_id} LIMIT ... OFFSET ...`
248    pub fn view_get_data(
249        &self,
250        view_id: &str,
251        config: &ViewConfig,
252        viewport: &ViewPort,
253        schema: &IndexMap<String, ColumnType>,
254    ) -> GenericSQLResult<String> {
255        let group_by = &config.group_by;
256        let sort = &config.sort;
257        let start_col = viewport.start_col.unwrap_or(0) as usize;
258        let end_col = viewport.end_col.map(|x| x as usize);
259        let start_row = viewport.start_row.unwrap_or(0);
260        let end_row = viewport.end_row;
261        let limit_clause = if let Some(end) = end_row {
262            format!("LIMIT {} OFFSET {}", end - start_row, start_row)
263        } else {
264            String::new()
265        };
266
267        let mut data_columns: Vec<&String> = schema
268            .keys()
269            .filter(|col_name| !col_name.starts_with("__"))
270            .collect();
271
272        let col_sort_dir = sort.iter().find_map(|Sort(_, dir)| match dir {
273            SortDir::ColAsc | SortDir::ColAscAbs => Some(true),
274            SortDir::ColDesc | SortDir::ColDescAbs => Some(false),
275            _ => None,
276        });
277
278        if let Some(ascending) = col_sort_dir {
279            if ascending {
280                data_columns.sort();
281            } else {
282                data_columns.sort_by(|a, b| b.cmp(a));
283            }
284        } else if !config.split_by.is_empty() {
285            sort_column_paths(&mut data_columns, config);
286        }
287
288        let data_columns: Vec<&String> = data_columns
289            .into_iter()
290            .skip(start_col)
291            .take(end_col.map(|e| e - start_col).unwrap_or(usize::MAX))
292            .collect();
293
294        let mut group_by_cols: Vec<String> = Vec::new();
295        if !group_by.is_empty() {
296            if config.group_rollup_mode != GroupRollupMode::Flat {
297                group_by_cols.push("\"__GROUPING_ID__\"".to_string());
298            }
299            for idx in 0..group_by.len() {
300                group_by_cols.push(format!("\"__ROW_PATH_{}__\"", idx));
301            }
302        }
303
304        let all_columns: Vec<String> = group_by_cols
305            .into_iter()
306            .chain(data_columns.iter().map(|col| format!("\"{}\"", col)))
307            .collect();
308
309        Ok(format!(
310            "SELECT {} FROM {} {}",
311            all_columns.join(", "),
312            view_id,
313            limit_clause
314        )
315        .trim()
316        .to_string())
317    }
318
319    /// Returns the SQL query to describe a view's schema.
320    ///
321    /// # Arguments
322    /// * `view_id` - The identifier of the view.
323    ///
324    /// # Returns
325    /// SQL: `DESCRIBE {view_id}`
326    pub fn view_schema(&self, view_id: &str) -> GenericSQLResult<String> {
327        Ok(format!("DESCRIBE {}", view_id))
328    }
329
330    /// Returns the SQL query to get the row count of a view.
331    ///
332    /// # Arguments
333    /// * `view_id` - The identifier of the view.
334    ///
335    /// # Returns
336    /// SQL: `SELECT COUNT(*) FROM {view_id}`
337    pub fn view_size(&self, view_id: &str) -> GenericSQLResult<String> {
338        Ok(format!("SELECT COUNT(*) FROM {}", view_id))
339    }
340
341    /// Returns the SQL query to get the min and max values of a column.
342    ///
343    /// # Arguments
344    /// * `view_id` - The identifier of the view.
345    /// * `column_name` - The name of the column.
346    /// * `config` - The view configuration.
347    ///
348    /// # Returns
349    /// SQL: `SELECT MIN("column_name"), MAX("column_name") FROM {view_id}`
350    /// When the view uses ROLLUP grouping (non-flat mode with group_by),
351    /// a `WHERE __GROUPING_ID__ = 0` clause is added to exclude non-leaf rows.
352    pub fn view_get_min_max(
353        &self,
354        view_id: &str,
355        column_name: &str,
356        config: &ViewConfig,
357    ) -> GenericSQLResult<String> {
358        let has_grouping_id =
359            !config.group_by.is_empty() && config.group_rollup_mode != GroupRollupMode::Flat;
360        let where_clause = if has_grouping_id {
361            " WHERE __GROUPING_ID__ = 0"
362        } else {
363            ""
364        };
365
366        Ok(format!(
367            "SELECT MIN(\"{}\"), MAX(\"{}\") FROM {}{}",
368            column_name, column_name, view_id, where_clause
369        ))
370    }
371
372    fn filter_term_to_sql(term: &FilterTerm) -> Option<String> {
373        match term {
374            FilterTerm::Scalar(scalar) => Self::scalar_to_sql(scalar),
375            FilterTerm::Array(scalars) => {
376                let values: Vec<String> = scalars.iter().filter_map(Self::scalar_to_sql).collect();
377                if values.is_empty() {
378                    None
379                } else {
380                    Some(format!("({})", values.join(", ")))
381                }
382            },
383        }
384    }
385
386    fn scalar_to_sql(scalar: &Scalar) -> Option<String> {
387        match scalar {
388            Scalar::Null => None,
389            Scalar::Bool(b) => Some(if *b { "TRUE" } else { "FALSE" }.to_string()),
390            Scalar::Float(f) => Some(f.to_string()),
391            Scalar::String(s) => Some(format!("'{}'", s.replace('\'', "''"))),
392        }
393    }
394}