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, quote_ident};
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
61fn sql_type(ty: ColumnType) -> &'static str {
62    match ty {
63        ColumnType::String => "VARCHAR",
64        ColumnType::Integer => "INTEGER",
65        ColumnType::Float => "DOUBLE PRECISION",
66        ColumnType::Boolean => "BOOLEAN",
67        ColumnType::Date => "DATE",
68        ColumnType::Datetime => "TIMESTAMP",
69    }
70}
71
72impl fmt::Display for GenericSQLError {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        match self {
75            Self::ColumnNotFound(col) => write!(f, "Column not found: {}", col),
76            Self::InvalidConfig(msg) => write!(f, "Invalid configuration: {}", msg),
77            Self::UnsupportedOperation(msg) => write!(f, "Unsupported operation: {}", msg),
78        }
79    }
80}
81
82impl std::error::Error for GenericSQLError {}
83
84/// Result type alias for SQL operations.
85pub type GenericSQLResult<T> = Result<T, GenericSQLError>;
86
87#[derive(Clone, Debug, Deserialize, Default)]
88pub struct GenericSQLVirtualServerModelArgs {
89    create_entity: Option<String>,
90
91    /// Entity keyword for `view_delete`'s `DROP {drop_entity} IF EXISTS`.
92    /// Must agree with `create_entity` — Postgres rejects `DROP TABLE` on a
93    /// view (`"VIEW"`), while DuckDB's temp tables take the default
94    /// (`"TABLE"`).
95    drop_entity: Option<String>,
96
97    grouping_fn: Option<String>,
98
99    /// Expression for the dialect's natural row identity, used for unsorted
100    /// view order and natural-order window frames — `rowid` (DuckDB, the
101    /// default) or `ctid` (Postgres). Dialects with no such pseudo-column
102    /// (ClickHouse) should advertise `unordered` instead.
103    row_id_expr: Option<String>,
104
105    /// Separator joining `split_by` values and the column name in pivoted
106    /// view column names, e.g. `"CA|Sales"` for separator `"|"`. Perspective's
107    /// column-path separator is `"|"`, so any other value produces views the
108    /// client will not interpret as column paths.
109    column_separator: Option<String>,
110
111    /// Escape character emitted as an `ESCAPE` clause after generated
112    /// `ILIKE` patterns (Perspective's `begins with` / `contains` /
113    /// `ends with` filter ops and their negations). Dialects with no
114    /// default `LIKE` escape character (DuckDB) must pass `"\\"`; dialects
115    /// where backslash escaping is implicit and the `ESCAPE` clause is
116    /// unsupported (ClickHouse) must omit it.
117    like_escape_clause: Option<String>,
118
119    /// Whether the dialect's string literal parser consumes C-style
120    /// backslash escapes (ClickHouse), requiring backslashes in emitted
121    /// literals to be doubled. Dialects with standard-conforming literals
122    /// (DuckDB) omit it.
123    backslash_escaped_literals: Option<bool>,
124
125    /// Name of the dialect's partial-match regex function, emitted as
126    /// `{regex_fn}("col", 'pattern')` for the `matches` / `not matches`
127    /// filter ops — `"regexp_matches"` for DuckDB, `"match"` for
128    /// ClickHouse (both RE2, matching the engine's semantics). When
129    /// omitted, regex filter clauses are dropped.
130    regex_fn: Option<String>,
131
132    /// Template wrapping a `SELECT` so the dialect PLANS it and reports its
133    /// result columns without executing it; `{}` is the query.
134    describe_template: Option<String>,
135}
136
137/// Recovers the source column of a pivoted view column name — the longest
138/// `config.columns` entry that is a strict suffix of `name` — with its index
139/// in `config.columns`. Requires no separator knowledge, so it works at
140/// protocol boundaries where the SQL model's `column_separator` is unknown.
141/// Returns `None` for non-path names (e.g. flat-view columns, which equal a
142/// `config.columns` entry exactly rather than strictly containing one).
143pub(crate) fn column_path_source<'a>(
144    name: &str,
145    config: &'a ViewConfig,
146) -> Option<(usize, &'a str)> {
147    if config.split_by.is_empty() {
148        return None;
149    }
150
151    let rollup = config.split_rollup_mode == SplitRollupMode::Rollup;
152
153    let mut best: Option<(usize, &'a str)> = None;
154    for (idx, col) in config.columns.iter().flatten().enumerate() {
155        if (name.len() > col.len() || rollup)
156            && name.ends_with(col.as_str())
157            && best.is_none_or(|(_, b)| col.len() > b.len())
158        {
159            best = Some((idx, col));
160        }
161    }
162
163    best
164}
165
166/// Sorts pivoted view column names into Perspective's column-path order:
167/// `split_by` value paths ascending, then `config.columns` order within each
168/// path (e.g. `CA|price, CA|qty, NY|price, NY|qty`).
169///
170/// The per-column `PIVOT` join in [`ViewQueryContext`] emits columns grouped
171/// by source column instead, so every egress of view column names re-sorts
172/// with this. Internal `__`-prefixed columns sort first, unmatched names
173/// last, both preserving relative order.
174pub(crate) fn sort_column_paths<T: AsRef<str>>(names: &mut [T], config: &ViewConfig) {
175    names.sort_by_cached_key(|name| {
176        let name = name.as_ref();
177        if name.starts_with("__") {
178            return (0u8, String::new(), 0usize);
179        }
180
181        match column_path_source(name, config) {
182            Some((idx, col)) => (1, name[..name.len() - col.len()].to_string(), idx),
183            None => (2, String::new(), 0),
184        }
185    });
186}
187
188/// A stateless SQL query builder virtual server operations.
189///
190/// This struct generates SQL query strings without executing them, allowing
191/// the caller to execute the queries against a SQL connection.
192#[derive(Debug, Default, Clone)]
193pub struct GenericSQLVirtualServerModel(GenericSQLVirtualServerModelArgs);
194
195impl GenericSQLVirtualServerModel {
196    /// Creates a new `GenericSQLVirtualServerModel` instance.
197    pub fn new(args: GenericSQLVirtualServerModelArgs) -> Self {
198        Self(args)
199    }
200
201    /// Returns the SQL query to list all hosted tables.
202    ///
203    /// # Returns
204    /// SQL: `SHOW ALL TABLES`
205    pub fn get_hosted_tables(&self) -> GenericSQLResult<String> {
206        Ok("SHOW ALL TABLES".to_string())
207    }
208
209    /// Returns the SQL query to describe a table's schema.
210    ///
211    /// # Arguments
212    /// * `table_id` - The identifier of the table to describe.
213    ///
214    /// # Returns
215    /// SQL: `DESCRIBE {table_id}`
216    pub fn table_schema(&self, table_id: &str) -> GenericSQLResult<String> {
217        Ok(format!("DESCRIBE {}", table_id))
218    }
219
220    /// Returns the SQL query to get the row count of a table.
221    ///
222    /// # Arguments
223    /// * `table_id` - The identifier of the table.
224    ///
225    /// # Returns
226    /// SQL: `SELECT COUNT(*) FROM {table_id}`
227    pub fn table_size(&self, table_id: &str) -> GenericSQLResult<String> {
228        Ok(format!("SELECT COUNT(*) FROM {}", table_id))
229    }
230
231    /// Returns the SQL query to get the column count of a view.
232    ///
233    /// # Arguments
234    /// * `view_id` - The identifier of the view.
235    ///
236    /// # Returns
237    /// SQL: `SELECT COUNT(*) FROM (DESCRIBE {view_id})`
238    pub fn view_column_size(&self, view_id: &str) -> GenericSQLResult<String> {
239        Ok(format!("SELECT COUNT(*) FROM (DESCRIBE {})", view_id))
240    }
241
242    fn describe_wrap(&self, query: &str) -> String {
243        self.0
244            .describe_template
245            .as_deref()
246            .unwrap_or("DESCRIBE ({})")
247            .replacen("{}", query, 1)
248    }
249
250    /// Returns the SQL query that plans every expression of `config` at once
251    /// against `table_id`.
252    ///
253    /// # Returns
254    /// SQL: `DESCRIBE (SELECT {expr} AS "{alias}", ... FROM {table_id})`
255    pub fn expressions_describe(
256        &self,
257        table_id: &str,
258        config: &ViewConfig,
259    ) -> GenericSQLResult<Option<String>> {
260        if config.expressions.is_empty() {
261            return Ok(None);
262        }
263
264        let mut names = config.expressions.keys().collect::<Vec<_>>();
265        names.sort();
266        let selects = names
267            .iter()
268            .map(|name| {
269                format!(
270                    "{} AS \"{}\"",
271                    config.expressions.0[*name],
272                    quote_ident(name)
273                )
274            })
275            .collect::<Vec<_>>()
276            .join(", ");
277
278        Ok(Some(self.describe_wrap(&format!(
279            "SELECT {} FROM {}",
280            selects, table_id
281        ))))
282    }
283
284    /// Returns the SQL query that plans one expression against a table.
285    ///
286    /// # Returns
287    /// SQL: `DESCRIBE (SELECT {expression} FROM {table_id})`
288    pub fn expression_describe(
289        &self,
290        table_id: &str,
291        expression: &str,
292    ) -> GenericSQLResult<String> {
293        Ok(self.describe_wrap(&format!("SELECT {} FROM {}", expression, table_id)))
294    }
295
296    /// Returns the SQL query that plans the view `config` would build against
297    /// `table_id`, without materializing it.
298    ///
299    /// # Returns
300    /// SQL: `DESCRIBE (SELECT ... GROUP BY ...)`
301    pub fn table_describe(
302        &self,
303        table_id: &str,
304        config: &ViewConfig,
305        schema: &IndexMap<String, ColumnType>,
306    ) -> GenericSQLResult<Option<String>> {
307        if config.columns.iter().flatten().next().is_none() {
308            return Ok(None);
309        }
310
311        let mut folded = config.clone();
312        folded.sort.clear();
313        let split_by = std::mem::take(&mut folded.split_by);
314        if !split_by.is_empty() && folded.group_rollup_mode != GroupRollupMode::Total {
315            if folded.group_by.is_empty() {
316                folded.aggregates.clear();
317            }
318
319            folded.group_by.extend(split_by);
320        }
321
322        let ctx = ViewQueryContext::new(self, table_id, &folded, schema)?;
323        Ok(Some(self.describe_wrap(&ctx.build_query())))
324    }
325
326    /// Returns the SQL query to delete a view.
327    ///
328    /// # Arguments
329    /// * `view_id` - The identifier of the view to delete.
330    ///
331    /// # Returns
332    /// SQL: `DROP {drop_entity} IF EXISTS {view_id}`
333    pub fn view_delete(&self, view_id: &str) -> GenericSQLResult<String> {
334        let entity = self.0.drop_entity.as_deref().unwrap_or("TABLE");
335        Ok(format!("DROP {} IF EXISTS {}", entity, view_id))
336    }
337
338    /// Returns the SQL query to create a view from a table with the given
339    /// configuration.
340    ///
341    /// # Arguments
342    /// * `table_id` - The identifier of the source table.
343    /// * `view_id` - The identifier for the new view.
344    /// * `config` - The view configuration specifying columns, group_by,
345    ///   split_by, etc.
346    /// * `schema` - The schema of the source table (column names to types).
347    ///
348    /// # Returns
349    /// SQL: `CREATE TABLE {view_id} AS (...)`
350    pub fn table_make_view(
351        &self,
352        table_id: &str,
353        view_id: &str,
354        config: &ViewConfig,
355        schema: &IndexMap<String, ColumnType>,
356    ) -> GenericSQLResult<String> {
357        let ctx = ViewQueryContext::new(self, table_id, config, schema)?;
358        let query = ctx.build_query();
359        let template = self.0.create_entity.as_deref().unwrap_or("TABLE");
360        Ok(format!("CREATE {} {} AS ({})", template, view_id, query))
361    }
362
363    /// Returns the SQL query to create a table from a view, with the view's
364    /// row path unrolled into key columns named after their `group_by`
365    /// columns and aggregates of those columns qualified by their aggregate.
366    ///
367    /// # Arguments
368    /// * `view_id` - The identifier of the source view.
369    /// * `table_id` - The identifier for the new table.
370    /// * `config` - The configuration the view was created with.
371    /// * `view_schema` - The schema of the view's relation.
372    /// * `schema` - The columns of the new table, in place of the view's.
373    ///
374    /// # Returns
375    /// SQL: `CREATE TABLE {table_id} AS (SELECT ... FROM {view_id})`
376    pub fn view_make_table(
377        &self,
378        view_id: &str,
379        table_id: &str,
380        config: &ViewConfig,
381        view_schema: &IndexMap<String, ColumnType>,
382        schema: Option<&IndexMap<String, ColumnType>>,
383    ) -> GenericSQLResult<String> {
384        let key_types: IndexMap<&str, ColumnType> = config
385            .group_by
386            .iter()
387            .enumerate()
388            .filter_map(|(i, col)| {
389                view_schema
390                    .get(&format!("__ROW_PATH_{}__", i))
391                    .map(|ty| (col.as_str(), *ty))
392            })
393            .collect();
394
395        let mut selectable: IndexMap<String, (String, Option<ColumnType>)> = config
396            .group_by
397            .iter()
398            .enumerate()
399            .map(|(i, col)| (col.clone(), (format!("\"__ROW_PATH_{}__\"", i), None)))
400            .collect();
401
402        for (name, ty) in view_schema
403            .iter()
404            .filter(|(name, _)| !name.starts_with("__"))
405        {
406            let readable = config.readable_column_path(name, |leaf| {
407                key_types.get(leaf).copied().unwrap_or(ColumnType::String)
408            });
409
410            selectable.insert(readable, (format!("\"{}\"", name), Some(*ty)));
411        }
412
413        let select = |readable: &str, expr: &str| {
414            if expr == format!("\"{}\"", readable) {
415                expr.to_owned()
416            } else {
417                format!("{} AS \"{}\"", expr, readable)
418            }
419        };
420
421        let clauses: Vec<String> = match schema {
422            None => selectable
423                .iter()
424                .map(|(readable, (expr, _))| select(readable, expr))
425                .collect(),
426            Some(schema) => schema
427                .iter()
428                .map(|(name, ty)| match selectable.get(name) {
429                    Some((_, Some(actual))) if actual != ty => {
430                        Err(GenericSQLError::InvalidConfig(format!(
431                            "Column \"{}\" does not have the type of the View's column",
432                            name
433                        )))
434                    },
435                    Some((expr, _)) => Ok(select(name, expr)),
436                    None => Ok(format!("CAST(NULL AS {}) AS \"{}\"", sql_type(*ty), name)),
437                })
438                .collect::<GenericSQLResult<Vec<_>>>()?,
439        };
440
441        let template = self.0.create_entity.as_deref().unwrap_or("TABLE");
442        Ok(format!(
443            "CREATE {} {} AS (SELECT {} FROM {})",
444            template,
445            table_id,
446            clauses.join(", "),
447            view_id
448        ))
449    }
450
451    /// Returns the SQL query to fetch data from a view with the given viewport.
452    ///
453    /// # Arguments
454    /// * `view_id` - The identifier of the view.
455    /// * `config` - The view configuration.
456    /// * `viewport` - The viewport specifying row/column ranges.
457    /// * `schema` - The schema of the view (column names to types).
458    ///
459    /// # Returns
460    /// SQL: `SELECT ... FROM {view_id} LIMIT ... OFFSET ...`
461    pub fn view_get_data(
462        &self,
463        view_id: &str,
464        config: &ViewConfig,
465        viewport: &ViewPort,
466        schema: &IndexMap<String, ColumnType>,
467    ) -> GenericSQLResult<String> {
468        let group_by = &config.group_by;
469        let sort = &config.sort;
470        let start_col = viewport.start_col.unwrap_or(0) as usize;
471        let end_col = viewport.end_col.map(|x| x as usize);
472        let start_row = viewport.start_row.unwrap_or(0);
473        let end_row = viewport.end_row;
474        let limit_clause = if let Some(end) = end_row {
475            format!("LIMIT {} OFFSET {}", end - start_row, start_row)
476        } else {
477            String::new()
478        };
479
480        let mut data_columns: Vec<&String> = schema
481            .keys()
482            .filter(|col_name| !col_name.starts_with("__"))
483            .collect();
484
485        let col_sort_dir = sort.iter().find_map(|Sort(_, dir)| match dir {
486            SortDir::ColAsc | SortDir::ColAscAbs => Some(true),
487            SortDir::ColDesc | SortDir::ColDescAbs => Some(false),
488            _ => None,
489        });
490
491        if let Some(ascending) = col_sort_dir {
492            if ascending {
493                data_columns.sort();
494            } else {
495                data_columns.sort_by(|a, b| b.cmp(a));
496            }
497        } else if !config.split_by.is_empty() {
498            sort_column_paths(&mut data_columns, config);
499        }
500
501        let data_columns: Vec<&String> = data_columns
502            .into_iter()
503            .skip(start_col)
504            .take(end_col.map(|e| e - start_col).unwrap_or(usize::MAX))
505            .collect();
506
507        let mut group_by_cols: Vec<String> = Vec::new();
508        if !group_by.is_empty() {
509            if config.group_rollup_mode != GroupRollupMode::Flat {
510                group_by_cols.push("\"__GROUPING_ID__\"".to_string());
511            }
512            for idx in 0..group_by.len() {
513                group_by_cols.push(format!("\"__ROW_PATH_{}__\"", idx));
514            }
515        }
516
517        let all_columns: Vec<String> = group_by_cols
518            .into_iter()
519            .chain(
520                data_columns
521                    .iter()
522                    .map(|col| format!("\"{}\"", quote_ident(col))),
523            )
524            .collect();
525
526        Ok(format!(
527            "SELECT {} FROM {} {}",
528            all_columns.join(", "),
529            view_id,
530            limit_clause
531        )
532        .trim()
533        .to_string())
534    }
535
536    /// Returns the SQL query to describe a view's schema.
537    ///
538    /// # Arguments
539    /// * `view_id` - The identifier of the view.
540    ///
541    /// # Returns
542    /// SQL: `DESCRIBE {view_id}`
543    pub fn view_schema(&self, view_id: &str) -> GenericSQLResult<String> {
544        Ok(format!("DESCRIBE {}", view_id))
545    }
546
547    /// Returns the SQL query to get the row count of a view.
548    ///
549    /// # Arguments
550    /// * `view_id` - The identifier of the view.
551    ///
552    /// # Returns
553    /// SQL: `SELECT COUNT(*) FROM {view_id}`
554    pub fn view_size(&self, view_id: &str) -> GenericSQLResult<String> {
555        Ok(format!("SELECT COUNT(*) FROM {}", view_id))
556    }
557
558    /// Returns the SQL query to get the min and max values of a column.
559    ///
560    /// # Arguments
561    /// * `view_id` - The identifier of the view.
562    /// * `column_name` - The name of the column.
563    /// * `config` - The view configuration.
564    ///
565    /// # Returns
566    /// SQL: `SELECT MIN("column_name"), MAX("column_name") FROM {view_id}`
567    /// When the view uses ROLLUP grouping (non-flat mode with group_by),
568    /// a `WHERE __GROUPING_ID__ = 0` clause is added to exclude non-leaf rows.
569    pub fn view_get_min_max(
570        &self,
571        view_id: &str,
572        column_name: &str,
573        config: &ViewConfig,
574    ) -> GenericSQLResult<String> {
575        let has_grouping_id =
576            !config.group_by.is_empty() && config.group_rollup_mode != GroupRollupMode::Flat;
577        let where_clause = if has_grouping_id {
578            " WHERE \"__GROUPING_ID__\" = 0"
579        } else {
580            ""
581        };
582
583        let column_name = quote_ident(column_name);
584        Ok(format!(
585            "SELECT MIN(\"{}\"), MAX(\"{}\") FROM {}{}",
586            column_name, column_name, view_id, where_clause
587        ))
588    }
589
590    fn filter_term_to_sql(term: &FilterTerm, backslash_escaped: bool) -> Option<String> {
591        match term {
592            FilterTerm::Scalar(scalar) => Self::scalar_to_sql(scalar, backslash_escaped),
593            FilterTerm::Array(scalars) => {
594                let values: Vec<String> = scalars
595                    .iter()
596                    .filter_map(|x| Self::scalar_to_sql(x, backslash_escaped))
597                    .collect();
598                if values.is_empty() {
599                    None
600                } else {
601                    Some(format!("({})", values.join(", ")))
602                }
603            },
604        }
605    }
606
607    fn scalar_to_sql(scalar: &Scalar, backslash_escaped: bool) -> Option<String> {
608        match scalar {
609            Scalar::Null => None,
610            Scalar::Bool(b) => Some(if *b { "TRUE" } else { "FALSE" }.to_string()),
611            Scalar::Float(f) => Some(f.to_string()),
612            Scalar::String(s) => Some(table_make_view::string_literal(s, backslash_escaped)),
613        }
614    }
615}