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