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// - `on_update` real-time support will be method which takes sa view name and a
21//   handler and calls the handler when the view needs to be recalculated.
22//
23// Nice to have:
24//
25// - Optional `view_change` method can be implemented for engine optimization,
26//   defaulting to just delete & recreate (as Perspective engine does now).
27//
28// - Would like to add a metadata API so that e.g. Viewer debug panel could show
29//   internal generated SQL.
30
31mod table_make_view;
32
33#[cfg(test)]
34mod tests;
35
36use std::fmt;
37
38use indexmap::IndexMap;
39use serde::Deserialize;
40
41use crate::config::{
42    FilterTerm, GroupRollupMode, Scalar, Sort, SortDir, SplitRollupMode, ViewConfig,
43};
44use crate::proto::{ColumnType, ViewPort};
45use crate::virtual_server::generic_sql_model::table_make_view::{
46    ViewQueryContext, entity_ref, is_bare_ident, quote_ident,
47};
48
49/// Error type for SQL generation operations.
50#[derive(Debug, Clone)]
51pub enum GenericSQLError {
52    /// A required column was not found in the schema.
53    ColumnNotFound(String),
54    /// An invalid configuration was provided.
55    InvalidConfig(String),
56    /// An unsupported operation was requested.
57    UnsupportedOperation(String),
58}
59
60fn sql_type(ty: ColumnType) -> &'static str {
61    match ty {
62        ColumnType::String => "VARCHAR",
63        ColumnType::Integer => "INTEGER",
64        ColumnType::Float => "DOUBLE PRECISION",
65        ColumnType::Boolean => "BOOLEAN",
66        ColumnType::Date => "DATE",
67        ColumnType::Datetime => "TIMESTAMP",
68    }
69}
70
71impl fmt::Display for GenericSQLError {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match self {
74            Self::ColumnNotFound(col) => write!(f, "Column not found: {}", col),
75            Self::InvalidConfig(msg) => write!(f, "Invalid configuration: {}", msg),
76            Self::UnsupportedOperation(msg) => write!(f, "Unsupported operation: {}", msg),
77        }
78    }
79}
80
81impl std::error::Error for GenericSQLError {}
82
83/// Result type alias for SQL operations.
84pub type GenericSQLResult<T> = Result<T, GenericSQLError>;
85
86#[derive(Clone, Debug, Deserialize, Default)]
87pub struct GenericSQLVirtualServerModelArgs {
88    create_entity: Option<String>,
89
90    /// Entity keyword for `view_delete`'s `DROP {drop_entity} IF EXISTS`.
91    /// Must agree with `create_entity` — Postgres rejects `DROP TABLE` on a
92    /// view (`"VIEW"`), while DuckDB's temp tables take the default
93    /// (`"TABLE"`).
94    drop_entity: Option<String>,
95
96    grouping_fn: Option<String>,
97
98    /// Expression for the dialect's natural row identity, used for unsorted
99    /// view order and natural-order window frames — `rowid` (DuckDB, the
100    /// default) or `ctid` (Postgres). Dialects with no such pseudo-column
101    /// (ClickHouse) set [`Self::unordered`] instead.
102    row_id_expr: Option<String>,
103
104    /// Whether the dialect has NO natural row identity (ClickHouse), so the
105    /// builder must never reference `row_id_expr`: an unsorted view takes no
106    /// default `ORDER BY` (its order is whatever the engine scans), and a
107    /// split-only pivot numbers its rows with `ROW_NUMBER() OVER ()`. Pair
108    /// with the handler's `unordered` feature, which rejects windows without
109    /// an explicit `order_by` before they reach the builder.
110    unordered: Option<bool>,
111
112    /// Separator joining `split_by` values and the column name in pivoted
113    /// view column names, e.g. `"CA|Sales"` for separator `"|"`. Perspective's
114    /// column-path separator is `"|"`, so any other value produces views the
115    /// client will not interpret as column paths.
116    column_separator: Option<String>,
117
118    /// Escape character emitted as an `ESCAPE` clause after generated
119    /// `ILIKE` patterns (Perspective's `begins with` / `contains` /
120    /// `ends with` filter ops and their negations). Dialects with no
121    /// default `LIKE` escape character (DuckDB) must pass `"\\"`; dialects
122    /// where backslash escaping is implicit and the `ESCAPE` clause is
123    /// unsupported (ClickHouse) must omit it.
124    like_escape_clause: Option<String>,
125
126    /// Whether the dialect's string literal parser consumes C-style
127    /// backslash escapes (ClickHouse), requiring backslashes in emitted
128    /// literals to be doubled. Dialects with standard-conforming literals
129    /// (DuckDB) omit it.
130    backslash_escaped_literals: Option<bool>,
131
132    /// Name of the dialect's partial-match regex function, emitted as
133    /// `{regex_fn}("col", 'pattern')` for the `matches` / `not matches`
134    /// filter ops — `"regexp_matches"` for DuckDB, `"match"` for
135    /// ClickHouse (both RE2, matching the engine's semantics). When
136    /// omitted, regex filter clauses are dropped.
137    regex_fn: Option<String>,
138
139    /// Template wrapping a `SELECT` so the dialect PLANS it and reports its
140    /// result columns without executing it; `{}` is the query.
141    describe_template: Option<String>,
142
143    /// Whether the dialect folds unquoted identifiers to lower case
144    /// (Postgres), so [`GenericSQLVirtualServerModel::table_path`] must
145    /// quote any path part with an upper-case letter to keep it resolving
146    /// to the same relation. Case-insensitive (DuckDB) and case-preserving
147    /// (ClickHouse) dialects omit it.
148    folds_unquoted_to_lower: Option<bool>,
149
150    /// Whether views carry a `__HIDDEN__` column, `UPDATE`d in place by
151    /// [`GenericSQLVirtualServerModel::view_expand`] and friends, which
152    /// requires `create_entity` be a table whose scan order is its insertion
153    /// order (DuckDB's default).
154    expand_collapse: Option<bool>,
155}
156
157/// Recovers the source column of a pivoted view column name — the longest
158/// `config.columns` entry that is a strict suffix of `name` — with its index
159/// in `config.columns`. Requires no separator knowledge, so it works at
160/// protocol boundaries where the SQL model's `column_separator` is unknown.
161/// Returns `None` for non-path names (e.g. flat-view columns, which equal a
162/// `config.columns` entry exactly rather than strictly containing one).
163pub(crate) fn column_path_source<'a>(
164    name: &str,
165    config: &'a ViewConfig,
166) -> Option<(usize, &'a str)> {
167    if config.split_by.is_empty() {
168        return None;
169    }
170
171    let rollup = config.split_rollup_mode == SplitRollupMode::Rollup;
172
173    let mut best: Option<(usize, &'a str)> = None;
174    for (idx, col) in config.columns.iter().flatten().enumerate() {
175        if (name.len() > col.len() || rollup)
176            && name.ends_with(col.as_str())
177            && best.is_none_or(|(_, b)| col.len() > b.len())
178        {
179            best = Some((idx, col));
180        }
181    }
182
183    best
184}
185
186/// Sorts pivoted view column names into Perspective's column-path order:
187/// `split_by` value paths ascending, then `config.columns` order within each
188/// path (e.g. `CA|price, CA|qty, NY|price, NY|qty`).
189///
190/// The per-column `PIVOT` join in [`ViewQueryContext`] emits columns grouped
191/// by source column instead, so every egress of view column names re-sorts
192/// with this. Internal `__`-prefixed columns sort first, unmatched names
193/// last, both preserving relative order.
194pub(crate) fn sort_column_paths<T: AsRef<str>>(names: &mut [T], config: &ViewConfig) {
195    names.sort_by_cached_key(|name| {
196        let name = name.as_ref();
197        if name.starts_with("__") {
198            return (0u8, String::new(), 0usize);
199        }
200
201        match column_path_source(name, config) {
202            Some((idx, col)) => (1, name[..name.len() - col.len()].to_string(), idx),
203            None => (2, String::new(), 0),
204        }
205    });
206}
207
208/// A stateless SQL query builder virtual server operations.
209///
210/// This struct generates SQL query strings without executing them, allowing
211/// the caller to execute the queries against a SQL connection.
212#[derive(Debug, Default, Clone)]
213pub struct GenericSQLVirtualServerModel(GenericSQLVirtualServerModelArgs);
214
215impl GenericSQLVirtualServerModel {
216    /// Creates a new `GenericSQLVirtualServerModel` instance.
217    pub fn new(args: GenericSQLVirtualServerModelArgs) -> Self {
218        Self(args)
219    }
220
221    fn is_expand_collapse(&self) -> bool {
222        self.0.expand_collapse.unwrap_or(false)
223    }
224
225    /// The `WHERE` clause excluding collapsed rows from a view's reads.
226    fn visible_rows_sql(&self) -> &'static str {
227        if self.is_expand_collapse() {
228            " WHERE NOT \"__HIDDEN__\""
229        } else {
230            ""
231        }
232    }
233
234    /// A row's `group_by` path length, derived from its `__GROUPING_ID__`.
235    fn row_depth_sql(&self, config: &ViewConfig, alias: &str) -> String {
236        let levels = config.group_by.len();
237        let whens = (0..=levels)
238            .map(|k| format!("WHEN {} THEN {}", (1u64 << k) - 1, levels - k))
239            .collect::<Vec<_>>()
240            .join(" ");
241
242        format!("(CASE {}\"__GROUPING_ID__\" {} END)", alias, whens)
243    }
244
245    fn check_expandable(&self, config: &ViewConfig) -> GenericSQLResult<()> {
246        if !self.is_expand_collapse()
247            || config.group_by.is_empty()
248            || config.group_rollup_mode != GroupRollupMode::Rollup
249        {
250            return Err(GenericSQLError::UnsupportedOperation(
251                "expand/collapse requires `expand_collapse` and a \"rollup\" `group_by` view"
252                    .to_string(),
253            ));
254        }
255
256        Ok(())
257    }
258
259    /// A one-row relation `r` of the `row_index`-th visible row's `lo` row id,
260    /// `depth`, and the row id `hi` just past its subtree.
261    fn expansion_target_sql(
262        &self,
263        view_id: &str,
264        config: &ViewConfig,
265        row_index: u32,
266    ) -> GenericSQLResult<String> {
267        self.check_expandable(config)?;
268        let row_id = self.0.row_id_expr.as_deref().unwrap_or("rowid");
269        let view_id = entity_ref(view_id);
270        Ok(format!(
271            "(SELECT t.idx AS lo, t.depth AS depth, COALESCE(MIN(n.{row_id}), (SELECT \
272             MAX({row_id}) + 1 FROM {view_id})) AS hi FROM (SELECT {row_id} AS idx, {depth} AS \
273             depth FROM {view_id} WHERE NOT \"__HIDDEN__\" ORDER BY {row_id} LIMIT 1 OFFSET \
274             {row_index}) AS t LEFT JOIN {view_id} AS n ON n.{row_id} > t.idx AND {n_depth} <= \
275             t.depth GROUP BY t.idx, t.depth) AS r",
276            depth = self.row_depth_sql(config, ""),
277            n_depth = self.row_depth_sql(config, "n."),
278        ))
279    }
280
281    /// Returns the `UPDATE` showing the children of the `row_index`-th visible
282    /// row, each collapsed, whose affected-row count is the rows shown.
283    ///
284    /// # Returns
285    /// SQL: `UPDATE {view_id} SET "__HIDDEN__" = FALSE FROM (...) AS r WHERE
286    /// ...`
287    pub fn view_expand(
288        &self,
289        view_id: &str,
290        config: &ViewConfig,
291        row_index: u32,
292    ) -> GenericSQLResult<String> {
293        let row_id = self.0.row_id_expr.as_deref().unwrap_or("rowid");
294        Ok(format!(
295            "UPDATE {view_ref} AS v SET \"__HIDDEN__\" = FALSE FROM {target} WHERE v.{row_id} > \
296             r.lo AND v.{row_id} < r.hi AND {depth} = r.depth + 1 AND v.\"__HIDDEN__\"",
297            view_ref = entity_ref(view_id),
298            target = self.expansion_target_sql(view_id, config, row_index)?,
299            depth = self.row_depth_sql(config, "v."),
300        ))
301    }
302
303    /// Returns the `UPDATE` hiding every descendant of the `row_index`-th
304    /// visible row, whose affected-row count is the rows hidden.
305    ///
306    /// # Returns
307    /// SQL: `UPDATE {view_id} SET "__HIDDEN__" = TRUE FROM (...) AS r WHERE
308    /// ...`
309    pub fn view_collapse(
310        &self,
311        view_id: &str,
312        config: &ViewConfig,
313        row_index: u32,
314    ) -> GenericSQLResult<String> {
315        let row_id = self.0.row_id_expr.as_deref().unwrap_or("rowid");
316        Ok(format!(
317            "UPDATE {view_ref} AS v SET \"__HIDDEN__\" = TRUE FROM {target} WHERE v.{row_id} > \
318             r.lo AND v.{row_id} < r.hi AND NOT v.\"__HIDDEN__\"",
319            view_ref = entity_ref(view_id),
320            target = self.expansion_target_sql(view_id, config, row_index)?,
321        ))
322    }
323
324    /// Returns the `UPDATE` expanding exactly the rows whose path is no longer
325    /// than `depth`.
326    ///
327    /// # Returns
328    /// SQL: `UPDATE {view_id} SET "__HIDDEN__" = ({depth expr} > {depth + 1})`
329    pub fn view_set_depth(
330        &self,
331        view_id: &str,
332        config: &ViewConfig,
333        depth: u32,
334    ) -> GenericSQLResult<String> {
335        self.check_expandable(config)?;
336        Ok(format!(
337            "UPDATE {} SET \"__HIDDEN__\" = ({} > {})",
338            entity_ref(view_id),
339            self.row_depth_sql(config, ""),
340            depth as u64 + 1
341        ))
342    }
343
344    /// Renders a hosted table's relation path - e.g. `["memory", "main",
345    /// "trades"]` - as its entity id: parts joined with `.`, each quoted
346    /// only when it is not a bare identifier, so that SQL-safe names read as
347    /// they always have (`memory.trades`) and every other name is still a
348    /// relation reference the builder passes through verbatim.
349    pub fn table_path(&self, parts: &[&str]) -> GenericSQLResult<String> {
350        if parts.is_empty() || parts.iter().any(|x| x.is_empty()) {
351            return Err(GenericSQLError::InvalidConfig(format!(
352                "Invalid table path {parts:?}"
353            )));
354        }
355
356        let folds = self.0.folds_unquoted_to_lower.unwrap_or(false);
357        Ok(parts
358            .iter()
359            .map(|part| {
360                if is_bare_ident(part) && !(folds && part.chars().any(char::is_uppercase)) {
361                    (*part).to_owned()
362                } else {
363                    format!("\"{}\"", quote_ident(part))
364                }
365            })
366            .collect::<Vec<_>>()
367            .join("."))
368    }
369
370    /// Returns the SQL relation reference for an entity id - a hosted
371    /// table's path verbatim, any other name quoted whole - for handlers
372    /// that build their own queries against an entity.
373    pub fn quote_entity(&self, entity_id: &str) -> GenericSQLResult<String> {
374        Ok(entity_ref(entity_id))
375    }
376
377    /// Returns the SQL query to list all hosted tables.
378    ///
379    /// # Returns
380    /// SQL: `SHOW ALL TABLES`
381    pub fn get_hosted_tables(&self) -> GenericSQLResult<String> {
382        Ok("SHOW ALL TABLES".to_string())
383    }
384
385    /// Returns the SQL query to describe a table's schema.
386    ///
387    /// # Arguments
388    /// * `table_id` - The identifier of the table to describe.
389    ///
390    /// # Returns
391    /// SQL: `DESCRIBE {table_id}`
392    pub fn table_schema(&self, table_id: &str) -> GenericSQLResult<String> {
393        Ok(format!("DESCRIBE {}", entity_ref(table_id)))
394    }
395
396    /// Returns the SQL query to get the row count of a table.
397    ///
398    /// # Arguments
399    /// * `table_id` - The identifier of the table.
400    ///
401    /// # Returns
402    /// SQL: `SELECT COUNT(*) FROM {table_id}`
403    pub fn table_size(&self, table_id: &str) -> GenericSQLResult<String> {
404        Ok(format!("SELECT COUNT(*) FROM {}", entity_ref(table_id)))
405    }
406
407    /// Returns the SQL query to get the column count of a view.
408    ///
409    /// # Arguments
410    /// * `view_id` - The identifier of the view.
411    ///
412    /// # Returns
413    /// SQL: `SELECT COUNT(*) FROM (DESCRIBE {view_id})`
414    pub fn view_column_size(&self, view_id: &str) -> GenericSQLResult<String> {
415        Ok(format!(
416            "SELECT COUNT(*) FROM (DESCRIBE {})",
417            entity_ref(view_id)
418        ))
419    }
420
421    fn describe_wrap(&self, query: &str) -> String {
422        self.0
423            .describe_template
424            .as_deref()
425            .unwrap_or("DESCRIBE ({})")
426            .replacen("{}", query, 1)
427    }
428
429    /// Returns the SQL query that plans every expression of `config` at once
430    /// against `table_id`.
431    ///
432    /// # Returns
433    /// SQL: `DESCRIBE (SELECT {expr} AS "{alias}", ... FROM {table_id})`
434    pub fn expressions_describe(
435        &self,
436        table_id: &str,
437        config: &ViewConfig,
438    ) -> GenericSQLResult<Option<String>> {
439        if config.expressions.is_empty() {
440            return Ok(None);
441        }
442
443        let mut names = config.expressions.keys().collect::<Vec<_>>();
444        names.sort();
445        let selects = names
446            .iter()
447            .map(|name| {
448                format!(
449                    "{} AS \"{}\"",
450                    config.expressions.0[*name],
451                    quote_ident(name)
452                )
453            })
454            .collect::<Vec<_>>()
455            .join(", ");
456
457        Ok(Some(self.describe_wrap(&format!(
458            "SELECT {} FROM {}",
459            selects,
460            entity_ref(table_id)
461        ))))
462    }
463
464    /// Returns the SQL query that plans one expression against a table.
465    ///
466    /// # Returns
467    /// SQL: `DESCRIBE (SELECT {expression} FROM {table_id})`
468    pub fn expression_describe(
469        &self,
470        table_id: &str,
471        expression: &str,
472    ) -> GenericSQLResult<String> {
473        Ok(self.describe_wrap(&format!(
474            "SELECT {} FROM {}",
475            expression,
476            entity_ref(table_id)
477        )))
478    }
479
480    /// Returns the SQL query that plans the view `config` would build against
481    /// `table_id`, without materializing it.
482    ///
483    /// # Returns
484    /// SQL: `DESCRIBE (SELECT ... GROUP BY ...)`
485    pub fn table_describe(
486        &self,
487        table_id: &str,
488        config: &ViewConfig,
489        schema: &IndexMap<String, ColumnType>,
490    ) -> GenericSQLResult<Option<String>> {
491        self.table_describe_source(table_id, config, schema, false)
492    }
493
494    /// As [`Self::table_describe`], for a source with no natural row order -
495    /// a table made by [`Self::view_make_table`] in a dialect whose
496    /// `create_entity` is a view, which `row_id_expr` cannot address.
497    pub fn table_describe_unordered(
498        &self,
499        table_id: &str,
500        config: &ViewConfig,
501        schema: &IndexMap<String, ColumnType>,
502    ) -> GenericSQLResult<Option<String>> {
503        self.table_describe_source(table_id, config, schema, true)
504    }
505
506    fn table_describe_source(
507        &self,
508        table_id: &str,
509        config: &ViewConfig,
510        schema: &IndexMap<String, ColumnType>,
511        unordered: bool,
512    ) -> GenericSQLResult<Option<String>> {
513        if config.columns.iter().flatten().next().is_none() {
514            return Ok(None);
515        }
516
517        let mut folded = config.clone();
518        folded.sort.clear();
519        let split_by = std::mem::take(&mut folded.split_by);
520        if !split_by.is_empty() && folded.group_rollup_mode != GroupRollupMode::Total {
521            if folded.group_by.is_empty() {
522                folded.aggregates.clear();
523            }
524
525            folded.group_by.extend(split_by);
526        }
527
528        let ctx = ViewQueryContext::new(self, table_id, &folded, schema, unordered)?;
529        Ok(Some(self.describe_wrap(&ctx.build_query_unordered())))
530    }
531
532    /// Returns the SQL query to delete a view.
533    ///
534    /// # Arguments
535    /// * `view_id` - The identifier of the view to delete.
536    ///
537    /// # Returns
538    /// SQL: `DROP {drop_entity} IF EXISTS {view_id}`
539    pub fn view_delete(&self, view_id: &str) -> GenericSQLResult<String> {
540        let entity = self.0.drop_entity.as_deref().unwrap_or("TABLE");
541        Ok(format!("DROP {} IF EXISTS {}", entity, entity_ref(view_id)))
542    }
543
544    /// Returns the SQL query to create a view from a table with the given
545    /// configuration.
546    ///
547    /// # Arguments
548    /// * `table_id` - The identifier of the source table.
549    /// * `view_id` - The identifier for the new view.
550    /// * `config` - The view configuration specifying columns, group_by,
551    ///   split_by, etc.
552    /// * `schema` - The schema of the source table (column names to types).
553    ///
554    /// # Returns
555    /// SQL: `CREATE TABLE {view_id} AS (...)`
556    pub fn table_make_view(
557        &self,
558        table_id: &str,
559        view_id: &str,
560        config: &ViewConfig,
561        schema: &IndexMap<String, ColumnType>,
562    ) -> GenericSQLResult<String> {
563        self.table_make_view_source(table_id, view_id, config, schema, false)
564    }
565
566    /// As [`Self::table_make_view`], for a source with no natural row order -
567    /// a table made by [`Self::view_make_table`] in a dialect whose
568    /// `create_entity` is a view, which `row_id_expr` cannot address - so an
569    /// unsorted view takes the source's own order and windows never read
570    /// `row_id_expr`.
571    pub fn table_make_view_unordered(
572        &self,
573        table_id: &str,
574        view_id: &str,
575        config: &ViewConfig,
576        schema: &IndexMap<String, ColumnType>,
577    ) -> GenericSQLResult<String> {
578        self.table_make_view_source(table_id, view_id, config, schema, true)
579    }
580
581    fn table_make_view_source(
582        &self,
583        table_id: &str,
584        view_id: &str,
585        config: &ViewConfig,
586        schema: &IndexMap<String, ColumnType>,
587        unordered: bool,
588    ) -> GenericSQLResult<String> {
589        let ctx = ViewQueryContext::new(self, table_id, config, schema, unordered)?;
590        let mut query = ctx.build_query();
591        if self.is_expand_collapse() {
592            query = format!(
593                "SELECT *, FALSE AS \"__HIDDEN__\" FROM ({}) AS __PSP_EXPAND_SRC__",
594                query
595            );
596        }
597
598        let template = self.0.create_entity.as_deref().unwrap_or("TABLE");
599        Ok(format!(
600            "CREATE {} {} AS ({})",
601            template,
602            entity_ref(view_id),
603            query
604        ))
605    }
606
607    /// Returns the SQL query to create a table from a view, with the view's
608    /// row path unrolled into key columns named after their `group_by`
609    /// columns and aggregates of those columns qualified by their aggregate.
610    ///
611    /// # Arguments
612    /// * `view_id` - The identifier of the source view.
613    /// * `table_id` - The identifier for the new table.
614    /// * `config` - The configuration the view was created with.
615    /// * `view_schema` - The schema of the view's relation.
616    /// * `schema` - The columns of the new table, in place of the view's.
617    ///
618    /// # Returns
619    /// SQL: `CREATE TABLE {table_id} AS (SELECT ... FROM {view_id})`
620    pub fn view_make_table(
621        &self,
622        view_id: &str,
623        table_id: &str,
624        config: &ViewConfig,
625        view_schema: &IndexMap<String, ColumnType>,
626        schema: Option<&IndexMap<String, ColumnType>>,
627    ) -> GenericSQLResult<String> {
628        let key_types: IndexMap<&str, ColumnType> = config
629            .group_by
630            .iter()
631            .enumerate()
632            .filter_map(|(i, col)| {
633                view_schema
634                    .get(&format!("__ROW_PATH_{}__", i))
635                    .map(|ty| (col.as_str(), *ty))
636            })
637            .collect();
638
639        let mut selectable: IndexMap<String, (String, Option<ColumnType>)> = config
640            .group_by
641            .iter()
642            .enumerate()
643            .map(|(i, col)| (col.clone(), (format!("\"__ROW_PATH_{}__\"", i), None)))
644            .collect();
645
646        for (name, ty) in view_schema
647            .iter()
648            .filter(|(name, _)| !name.starts_with("__"))
649        {
650            let readable = config.readable_column_path(name, |leaf| {
651                key_types.get(leaf).copied().unwrap_or(ColumnType::String)
652            });
653
654            selectable.insert(readable, (format!("\"{}\"", name), Some(*ty)));
655        }
656
657        let select = |readable: &str, expr: &str| {
658            if expr == format!("\"{}\"", readable) {
659                expr.to_owned()
660            } else {
661                format!("{} AS \"{}\"", expr, readable)
662            }
663        };
664
665        let clauses: Vec<String> = match schema {
666            None => selectable
667                .iter()
668                .map(|(readable, (expr, _))| select(readable, expr))
669                .collect(),
670            Some(schema) => schema
671                .iter()
672                .map(|(name, ty)| match selectable.get(name) {
673                    Some((_, Some(actual))) if actual != ty => {
674                        Err(GenericSQLError::InvalidConfig(format!(
675                            "Column \"{}\" does not have the type of the View's column",
676                            name
677                        )))
678                    },
679                    Some((expr, _)) => Ok(select(name, expr)),
680                    None => Ok(format!("CAST(NULL AS {}) AS \"{}\"", sql_type(*ty), name)),
681                })
682                .collect::<GenericSQLResult<Vec<_>>>()?,
683        };
684
685        let template = self.0.create_entity.as_deref().unwrap_or("TABLE");
686        Ok(format!(
687            "CREATE {} {} AS (SELECT {} FROM {})",
688            template,
689            entity_ref(table_id),
690            clauses.join(", "),
691            entity_ref(view_id)
692        ))
693    }
694
695    /// Returns the SQL query to fetch data from a view with the given viewport.
696    ///
697    /// # Arguments
698    /// * `view_id` - The identifier of the view.
699    /// * `config` - The view configuration.
700    /// * `viewport` - The viewport specifying row/column ranges.
701    /// * `schema` - The schema of the view (column names to types).
702    ///
703    /// # Returns
704    /// SQL: `SELECT ... FROM {view_id} LIMIT ... OFFSET ...`
705    pub fn view_get_data(
706        &self,
707        view_id: &str,
708        config: &ViewConfig,
709        viewport: &ViewPort,
710        schema: &IndexMap<String, ColumnType>,
711    ) -> GenericSQLResult<String> {
712        let group_by = &config.group_by;
713        let sort = &config.sort;
714        let start_col = viewport.start_col.unwrap_or(0) as usize;
715        let end_col = viewport.end_col.map(|x| x as usize);
716        let start_row = viewport.start_row.unwrap_or(0);
717        let end_row = viewport.end_row;
718        let limit_clause = if let Some(end) = end_row {
719            format!("LIMIT {} OFFSET {}", end - start_row, start_row)
720        } else {
721            String::new()
722        };
723
724        let mut data_columns: Vec<&String> = schema
725            .keys()
726            .filter(|col_name| !col_name.starts_with("__"))
727            .collect();
728
729        let col_sort_dir = sort.iter().find_map(|Sort(_, dir)| match dir {
730            SortDir::ColAsc | SortDir::ColAscAbs => Some(true),
731            SortDir::ColDesc | SortDir::ColDescAbs => Some(false),
732            _ => None,
733        });
734
735        if let Some(ascending) = col_sort_dir {
736            if ascending {
737                data_columns.sort();
738            } else {
739                data_columns.sort_by(|a, b| b.cmp(a));
740            }
741        } else if !config.split_by.is_empty() {
742            sort_column_paths(&mut data_columns, config);
743        }
744
745        let data_columns: Vec<&String> = data_columns
746            .into_iter()
747            .skip(start_col)
748            .take(end_col.map(|e| e - start_col).unwrap_or(usize::MAX))
749            .collect();
750
751        let mut group_by_cols: Vec<String> = Vec::new();
752        if !group_by.is_empty() {
753            if config.group_rollup_mode != GroupRollupMode::Flat {
754                group_by_cols.push("\"__GROUPING_ID__\"".to_string());
755            }
756            for idx in 0..group_by.len() {
757                group_by_cols.push(format!("\"__ROW_PATH_{}__\"", idx));
758            }
759        }
760
761        let all_columns: Vec<String> = group_by_cols
762            .into_iter()
763            .chain(
764                data_columns
765                    .iter()
766                    .map(|col| format!("\"{}\"", quote_ident(col))),
767            )
768            .collect();
769
770        Ok(format!(
771            "SELECT {} FROM {}{} {}",
772            all_columns.join(", "),
773            entity_ref(view_id),
774            self.visible_rows_sql(),
775            limit_clause
776        )
777        .trim()
778        .to_string())
779    }
780
781    /// Returns the SQL query to describe a view's schema.
782    ///
783    /// # Arguments
784    /// * `view_id` - The identifier of the view.
785    ///
786    /// # Returns
787    /// SQL: `DESCRIBE {view_id}`
788    pub fn view_schema(&self, view_id: &str) -> GenericSQLResult<String> {
789        Ok(format!("DESCRIBE {}", entity_ref(view_id)))
790    }
791
792    /// Returns the SQL query to get the (visible) row count of a view.
793    ///
794    /// # Arguments
795    /// * `view_id` - The identifier of the view.
796    ///
797    /// # Returns
798    /// SQL: `SELECT COUNT(*) FROM {view_id}`
799    pub fn view_size(&self, view_id: &str) -> GenericSQLResult<String> {
800        Ok(format!(
801            "SELECT COUNT(*) FROM {}{}",
802            entity_ref(view_id),
803            self.visible_rows_sql()
804        ))
805    }
806
807    /// Returns the SQL query to get the min and max values of a column.
808    ///
809    /// # Arguments
810    /// * `view_id` - The identifier of the view.
811    /// * `column_name` - The name of the column.
812    /// * `config` - The view configuration.
813    ///
814    /// # Returns
815    /// SQL: `SELECT MIN("column_name"), MAX("column_name") FROM {view_id}`
816    /// When the view uses ROLLUP grouping (non-flat mode with group_by),
817    /// a `WHERE __GROUPING_ID__ = 0` clause is added to exclude non-leaf rows.
818    pub fn view_get_min_max(
819        &self,
820        view_id: &str,
821        column_name: &str,
822        config: &ViewConfig,
823    ) -> GenericSQLResult<String> {
824        let has_grouping_id =
825            !config.group_by.is_empty() && config.group_rollup_mode != GroupRollupMode::Flat;
826        let where_clause = if has_grouping_id {
827            " WHERE \"__GROUPING_ID__\" = 0"
828        } else {
829            ""
830        };
831
832        let column_name = quote_ident(column_name);
833        Ok(format!(
834            "SELECT MIN(\"{}\"), MAX(\"{}\") FROM {}{}",
835            column_name,
836            column_name,
837            entity_ref(view_id),
838            where_clause
839        ))
840    }
841
842    fn filter_term_to_sql(term: &FilterTerm, backslash_escaped: bool) -> Option<String> {
843        match term {
844            FilterTerm::Scalar(scalar) => Self::scalar_to_sql(scalar, backslash_escaped),
845            FilterTerm::Array(scalars) => {
846                let values: Vec<String> = scalars
847                    .iter()
848                    .filter_map(|x| Self::scalar_to_sql(x, backslash_escaped))
849                    .collect();
850                if values.is_empty() {
851                    None
852                } else {
853                    Some(format!("({})", values.join(", ")))
854                }
855            },
856        }
857    }
858
859    fn scalar_to_sql(scalar: &Scalar, backslash_escaped: bool) -> Option<String> {
860        match scalar {
861            Scalar::Null => None,
862            Scalar::Bool(b) => Some(if *b { "TRUE" } else { "FALSE" }.to_string()),
863            Scalar::Float(f) => Some(f.to_string()),
864            Scalar::String(s) => Some(table_make_view::string_literal(s, backslash_escaped)),
865        }
866    }
867}