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}