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}