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::{
45    FilterTerm, GroupRollupMode, Scalar, Sort, SortDir, SplitRollupMode, ViewConfig,
46};
47use crate::proto::{ColumnType, ViewPort};
48use crate::virtual_server::generic_sql_model::table_make_view::ViewQueryContext;
49
50/// Error type for SQL generation operations.
51#[derive(Debug, Clone)]
52pub enum GenericSQLError {
53    /// A required column was not found in the schema.
54    ColumnNotFound(String),
55    /// An invalid configuration was provided.
56    InvalidConfig(String),
57    /// An unsupported operation was requested.
58    UnsupportedOperation(String),
59}
60
61impl fmt::Display for GenericSQLError {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match self {
64            Self::ColumnNotFound(col) => write!(f, "Column not found: {}", col),
65            Self::InvalidConfig(msg) => write!(f, "Invalid configuration: {}", msg),
66            Self::UnsupportedOperation(msg) => write!(f, "Unsupported operation: {}", msg),
67        }
68    }
69}
70
71impl std::error::Error for GenericSQLError {}
72
73/// Result type alias for SQL operations.
74pub type GenericSQLResult<T> = Result<T, GenericSQLError>;
75
76#[derive(Clone, Debug, Deserialize, Default)]
77pub struct GenericSQLVirtualServerModelArgs {
78    create_entity: Option<String>,
79
80    /// Entity keyword for `view_delete`'s `DROP {drop_entity} IF EXISTS`.
81    /// Must agree with `create_entity` — Postgres rejects `DROP TABLE` on a
82    /// view (`"VIEW"`), while DuckDB's temp tables take the default
83    /// (`"TABLE"`).
84    drop_entity: Option<String>,
85
86    grouping_fn: Option<String>,
87
88    /// Expression for the dialect's natural row identity, used for unsorted
89    /// view order and natural-order window frames — `rowid` (DuckDB, the
90    /// default) or `ctid` (Postgres). Dialects with no such pseudo-column
91    /// (ClickHouse) should advertise `unordered` instead.
92    row_id_expr: Option<String>,
93
94    /// Separator joining `split_by` values and the column name in pivoted
95    /// view column names, e.g. `"CA|Sales"` for separator `"|"`. Perspective's
96    /// column-path separator is `"|"`, so any other value produces views the
97    /// client will not interpret as column paths.
98    column_separator: Option<String>,
99
100    /// Escape character emitted as an `ESCAPE` clause after generated
101    /// `ILIKE` patterns (Perspective's `begins with` / `contains` /
102    /// `ends with` filter ops and their negations). Dialects with no
103    /// default `LIKE` escape character (DuckDB) must pass `"\\"`; dialects
104    /// where backslash escaping is implicit and the `ESCAPE` clause is
105    /// unsupported (ClickHouse) must omit it.
106    like_escape_clause: Option<String>,
107
108    /// Whether the dialect's string literal parser consumes C-style
109    /// backslash escapes (ClickHouse), requiring backslashes in emitted
110    /// literals to be doubled. Dialects with standard-conforming literals
111    /// (DuckDB) omit it.
112    backslash_escaped_literals: Option<bool>,
113
114    /// Name of the dialect's partial-match regex function, emitted as
115    /// `{regex_fn}("col", 'pattern')` for the `matches` / `not matches`
116    /// filter ops — `"regexp_matches"` for DuckDB, `"match"` for
117    /// ClickHouse (both RE2, matching the engine's semantics). When
118    /// omitted, regex filter clauses are dropped.
119    regex_fn: Option<String>,
120}
121
122/// Recovers the source column of a pivoted view column name — the longest
123/// `config.columns` entry that is a strict suffix of `name` — with its index
124/// in `config.columns`. Requires no separator knowledge, so it works at
125/// protocol boundaries where the SQL model's `column_separator` is unknown.
126/// Returns `None` for non-path names (e.g. flat-view columns, which equal a
127/// `config.columns` entry exactly rather than strictly containing one).
128pub(crate) fn column_path_source<'a>(
129    name: &str,
130    config: &'a ViewConfig,
131) -> Option<(usize, &'a str)> {
132    if config.split_by.is_empty() {
133        return None;
134    }
135
136    let rollup = config.split_rollup_mode == SplitRollupMode::Rollup;
137
138    let mut best: Option<(usize, &'a str)> = None;
139    for (idx, col) in config.columns.iter().flatten().enumerate() {
140        if (name.len() > col.len() || rollup)
141            && name.ends_with(col.as_str())
142            && best.is_none_or(|(_, b)| col.len() > b.len())
143        {
144            best = Some((idx, col));
145        }
146    }
147
148    best
149}
150
151/// Sorts pivoted view column names into Perspective's column-path order:
152/// `split_by` value paths ascending, then `config.columns` order within each
153/// path (e.g. `CA|price, CA|qty, NY|price, NY|qty`).
154///
155/// The per-column `PIVOT` join in [`ViewQueryContext`] emits columns grouped
156/// by source column instead, so every egress of view column names re-sorts
157/// with this. Internal `__`-prefixed columns sort first, unmatched names
158/// last, both preserving relative order.
159pub(crate) fn sort_column_paths<T: AsRef<str>>(names: &mut [T], config: &ViewConfig) {
160    names.sort_by_cached_key(|name| {
161        let name = name.as_ref();
162        if name.starts_with("__") {
163            return (0u8, String::new(), 0usize);
164        }
165
166        match column_path_source(name, config) {
167            Some((idx, col)) => (1, name[..name.len() - col.len()].to_string(), idx),
168            None => (2, String::new(), 0),
169        }
170    });
171}
172
173/// A stateless SQL query builder virtual server operations.
174///
175/// This struct generates SQL query strings without executing them, allowing
176/// the caller to execute the queries against a SQL connection.
177#[derive(Debug, Default, Clone)]
178pub struct GenericSQLVirtualServerModel(GenericSQLVirtualServerModelArgs);
179
180impl GenericSQLVirtualServerModel {
181    /// Creates a new `GenericSQLVirtualServerModel` instance.
182    pub fn new(args: GenericSQLVirtualServerModelArgs) -> Self {
183        Self(args)
184    }
185
186    /// Returns the SQL query to list all hosted tables.
187    ///
188    /// # Returns
189    /// SQL: `SHOW ALL TABLES`
190    pub fn get_hosted_tables(&self) -> GenericSQLResult<String> {
191        Ok("SHOW ALL TABLES".to_string())
192    }
193
194    /// Returns the SQL query to describe a table's schema.
195    ///
196    /// # Arguments
197    /// * `table_id` - The identifier of the table to describe.
198    ///
199    /// # Returns
200    /// SQL: `DESCRIBE {table_id}`
201    pub fn table_schema(&self, table_id: &str) -> GenericSQLResult<String> {
202        Ok(format!("DESCRIBE {}", table_id))
203    }
204
205    /// Returns the SQL query to get the row count of a table.
206    ///
207    /// # Arguments
208    /// * `table_id` - The identifier of the table.
209    ///
210    /// # Returns
211    /// SQL: `SELECT COUNT(*) FROM {table_id}`
212    pub fn table_size(&self, table_id: &str) -> GenericSQLResult<String> {
213        Ok(format!("SELECT COUNT(*) FROM {}", table_id))
214    }
215
216    /// Returns the SQL query to get the column count of a view.
217    ///
218    /// # Arguments
219    /// * `view_id` - The identifier of the view.
220    ///
221    /// # Returns
222    /// SQL: `SELECT COUNT(*) FROM (DESCRIBE {view_id})`
223    pub fn view_column_size(&self, view_id: &str) -> GenericSQLResult<String> {
224        Ok(format!("SELECT COUNT(*) FROM (DESCRIBE {})", view_id))
225    }
226
227    /// Returns the SQL query to validate an expression against a table.
228    ///
229    /// # Arguments
230    /// * `table_id` - The identifier of the table.
231    /// * `expression` - The SQL expression to validate.
232    ///
233    /// # Returns
234    /// SQL: `DESCRIBE (SELECT {expression} FROM {table_id})`
235    pub fn table_validate_expression(
236        &self,
237        table_id: &str,
238        expression: &str,
239    ) -> GenericSQLResult<String> {
240        Ok(format!(
241            "DESCRIBE (SELECT {} FROM {})",
242            expression, table_id
243        ))
244    }
245
246    /// Returns the SQL query to delete a view.
247    ///
248    /// # Arguments
249    /// * `view_id` - The identifier of the view to delete.
250    ///
251    /// # Returns
252    /// SQL: `DROP {drop_entity} IF EXISTS {view_id}`
253    pub fn view_delete(&self, view_id: &str) -> GenericSQLResult<String> {
254        let entity = self.0.drop_entity.as_deref().unwrap_or("TABLE");
255        Ok(format!("DROP {} IF EXISTS {}", entity, view_id))
256    }
257
258    /// Returns the SQL query to create a view from a table with the given
259    /// configuration.
260    ///
261    /// # Arguments
262    /// * `table_id` - The identifier of the source table.
263    /// * `view_id` - The identifier for the new view.
264    /// * `config` - The view configuration specifying columns, group_by,
265    ///   split_by, etc.
266    /// * `schema` - The schema of the source table (column names to types).
267    ///
268    /// # Returns
269    /// SQL: `CREATE TABLE {view_id} AS (...)`
270    pub fn table_make_view(
271        &self,
272        table_id: &str,
273        view_id: &str,
274        config: &ViewConfig,
275        schema: &IndexMap<String, ColumnType>,
276    ) -> GenericSQLResult<String> {
277        let ctx = ViewQueryContext::new(self, table_id, config, schema)?;
278        let query = ctx.build_query();
279        let template = self.0.create_entity.as_deref().unwrap_or("TABLE");
280        Ok(format!("CREATE {} {} AS ({})", template, view_id, query))
281    }
282
283    /// Returns the SQL query to fetch data from a view with the given viewport.
284    ///
285    /// # Arguments
286    /// * `view_id` - The identifier of the view.
287    /// * `config` - The view configuration.
288    /// * `viewport` - The viewport specifying row/column ranges.
289    /// * `schema` - The schema of the view (column names to types).
290    ///
291    /// # Returns
292    /// SQL: `SELECT ... FROM {view_id} LIMIT ... OFFSET ...`
293    pub fn view_get_data(
294        &self,
295        view_id: &str,
296        config: &ViewConfig,
297        viewport: &ViewPort,
298        schema: &IndexMap<String, ColumnType>,
299    ) -> GenericSQLResult<String> {
300        let group_by = &config.group_by;
301        let sort = &config.sort;
302        let start_col = viewport.start_col.unwrap_or(0) as usize;
303        let end_col = viewport.end_col.map(|x| x as usize);
304        let start_row = viewport.start_row.unwrap_or(0);
305        let end_row = viewport.end_row;
306        let limit_clause = if let Some(end) = end_row {
307            format!("LIMIT {} OFFSET {}", end - start_row, start_row)
308        } else {
309            String::new()
310        };
311
312        let mut data_columns: Vec<&String> = schema
313            .keys()
314            .filter(|col_name| !col_name.starts_with("__"))
315            .collect();
316
317        let col_sort_dir = sort.iter().find_map(|Sort(_, dir)| match dir {
318            SortDir::ColAsc | SortDir::ColAscAbs => Some(true),
319            SortDir::ColDesc | SortDir::ColDescAbs => Some(false),
320            _ => None,
321        });
322
323        if let Some(ascending) = col_sort_dir {
324            if ascending {
325                data_columns.sort();
326            } else {
327                data_columns.sort_by(|a, b| b.cmp(a));
328            }
329        } else if !config.split_by.is_empty() {
330            sort_column_paths(&mut data_columns, config);
331        }
332
333        let data_columns: Vec<&String> = data_columns
334            .into_iter()
335            .skip(start_col)
336            .take(end_col.map(|e| e - start_col).unwrap_or(usize::MAX))
337            .collect();
338
339        let mut group_by_cols: Vec<String> = Vec::new();
340        if !group_by.is_empty() {
341            if config.group_rollup_mode != GroupRollupMode::Flat {
342                group_by_cols.push("\"__GROUPING_ID__\"".to_string());
343            }
344            for idx in 0..group_by.len() {
345                group_by_cols.push(format!("\"__ROW_PATH_{}__\"", idx));
346            }
347        }
348
349        let all_columns: Vec<String> = group_by_cols
350            .into_iter()
351            .chain(data_columns.iter().map(|col| format!("\"{}\"", col)))
352            .collect();
353
354        Ok(format!(
355            "SELECT {} FROM {} {}",
356            all_columns.join(", "),
357            view_id,
358            limit_clause
359        )
360        .trim()
361        .to_string())
362    }
363
364    /// Returns the SQL query to describe a view's schema.
365    ///
366    /// # Arguments
367    /// * `view_id` - The identifier of the view.
368    ///
369    /// # Returns
370    /// SQL: `DESCRIBE {view_id}`
371    pub fn view_schema(&self, view_id: &str) -> GenericSQLResult<String> {
372        Ok(format!("DESCRIBE {}", view_id))
373    }
374
375    /// Returns the SQL query to get the row count of a view.
376    ///
377    /// # Arguments
378    /// * `view_id` - The identifier of the view.
379    ///
380    /// # Returns
381    /// SQL: `SELECT COUNT(*) FROM {view_id}`
382    pub fn view_size(&self, view_id: &str) -> GenericSQLResult<String> {
383        Ok(format!("SELECT COUNT(*) FROM {}", view_id))
384    }
385
386    /// Returns the SQL query to get the min and max values of a column.
387    ///
388    /// # Arguments
389    /// * `view_id` - The identifier of the view.
390    /// * `column_name` - The name of the column.
391    /// * `config` - The view configuration.
392    ///
393    /// # Returns
394    /// SQL: `SELECT MIN("column_name"), MAX("column_name") FROM {view_id}`
395    /// When the view uses ROLLUP grouping (non-flat mode with group_by),
396    /// a `WHERE __GROUPING_ID__ = 0` clause is added to exclude non-leaf rows.
397    pub fn view_get_min_max(
398        &self,
399        view_id: &str,
400        column_name: &str,
401        config: &ViewConfig,
402    ) -> GenericSQLResult<String> {
403        let has_grouping_id =
404            !config.group_by.is_empty() && config.group_rollup_mode != GroupRollupMode::Flat;
405        let where_clause = if has_grouping_id {
406            " WHERE \"__GROUPING_ID__\" = 0"
407        } else {
408            ""
409        };
410
411        Ok(format!(
412            "SELECT MIN(\"{}\"), MAX(\"{}\") FROM {}{}",
413            column_name, column_name, view_id, where_clause
414        ))
415    }
416
417    fn filter_term_to_sql(term: &FilterTerm, backslash_escaped: bool) -> Option<String> {
418        match term {
419            FilterTerm::Scalar(scalar) => Self::scalar_to_sql(scalar, backslash_escaped),
420            FilterTerm::Array(scalars) => {
421                let values: Vec<String> = scalars
422                    .iter()
423                    .filter_map(|x| Self::scalar_to_sql(x, backslash_escaped))
424                    .collect();
425                if values.is_empty() {
426                    None
427                } else {
428                    Some(format!("({})", values.join(", ")))
429                }
430            },
431        }
432    }
433
434    fn scalar_to_sql(scalar: &Scalar, backslash_escaped: bool) -> Option<String> {
435        match scalar {
436            Scalar::Null => None,
437            Scalar::Bool(b) => Some(if *b { "TRUE" } else { "FALSE" }.to_string()),
438            Scalar::Float(f) => Some(f.to_string()),
439            Scalar::String(s) => Some(table_make_view::string_literal(s, backslash_escaped)),
440        }
441    }
442}