Skip to main content

perspective_js/
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//! WASM bindings for the DuckDB SQL query builder.
14
15use std::str::FromStr;
16
17use indexmap::IndexMap;
18use js_sys::Object;
19use perspective_client::config::ViewConfig;
20use perspective_client::proto::{ColumnType, ViewPort};
21use perspective_client::virtual_server;
22use wasm_bindgen::prelude::*;
23
24use crate::utils::*;
25
26/// JavaScript-facing DuckDB SQL query builder.
27///
28/// This struct wraps the Rust `DuckDBSqlBuilder` and exposes it to JavaScript
29/// via wasm_bindgen.
30#[wasm_bindgen]
31pub struct GenericSQLVirtualServerModel {
32    inner: virtual_server::GenericSQLVirtualServerModel,
33}
34
35#[wasm_bindgen]
36extern "C" {
37    pub type JsGenericSQLVirtualServerModelArgs;
38}
39
40#[wasm_bindgen]
41impl GenericSQLVirtualServerModel {
42    /// Creates a new `JsDuckDBSqlBuilder` instance.
43    #[wasm_bindgen(constructor)]
44    pub fn new(args: Option<JsGenericSQLVirtualServerModelArgs>) -> Result<Self, JsValue> {
45        Ok(Self {
46            inner: virtual_server::GenericSQLVirtualServerModel::new(
47                args.map(|x| x.into_serde_ext())
48                    .transpose()?
49                    .unwrap_or_default(),
50            ),
51        })
52    }
53
54    /// Returns the entity id of the hosted table at relation path `parts`,
55    /// each part quoted only when it is not a bare identifier.
56    #[wasm_bindgen(js_name = "tablePath")]
57    pub fn table_path(&self, parts: Vec<String>) -> Result<String, JsValue> {
58        let parts = parts.iter().map(String::as_str).collect::<Vec<_>>();
59        self.inner
60            .table_path(&parts)
61            .map_err(|e| JsValue::from_str(&e.to_string()))
62    }
63
64    /// Returns the SQL relation reference for an entity id.
65    #[wasm_bindgen(js_name = "quoteEntity")]
66    pub fn quote_entity(&self, entity_id: &str) -> Result<String, JsValue> {
67        self.inner
68            .quote_entity(entity_id)
69            .map_err(|e| JsValue::from_str(&e.to_string()))
70    }
71
72    /// Returns the SQL query to list all hosted tables.
73    #[wasm_bindgen(js_name = "getHostedTables")]
74    pub fn get_hosted_tables(&self) -> Result<String, JsValue> {
75        self.inner
76            .get_hosted_tables()
77            .map_err(|e| JsValue::from_str(&e.to_string()))
78    }
79
80    /// Returns the SQL query to describe a table's schema.
81    #[wasm_bindgen(js_name = "tableSchema")]
82    pub fn table_schema(&self, table_id: &str) -> Result<String, JsValue> {
83        self.inner
84            .table_schema(table_id)
85            .map_err(|e| JsValue::from_str(&e.to_string()))
86    }
87
88    /// Returns the SQL query to get the row count of a table.
89    #[wasm_bindgen(js_name = "tableSize")]
90    pub fn table_size(&self, table_id: &str) -> Result<String, JsValue> {
91        self.inner
92            .table_size(table_id)
93            .map_err(|e| JsValue::from_str(&e.to_string()))
94    }
95
96    /// Returns the SQL query to get the column count of a view.
97    #[wasm_bindgen(js_name = "viewColumnSize")]
98    pub fn view_column_size(&self, view_id: &str) -> Result<String, JsValue> {
99        self.inner
100            .view_column_size(view_id)
101            .map_err(|e| JsValue::from_str(&e.to_string()))
102    }
103
104    /// Returns the SQL query that plans every expression of `config` at once
105    /// against a table, one result column per expression alias, or `undefined`
106    /// when the config has no expressions.
107    #[wasm_bindgen(js_name = "expressionsDescribe")]
108    pub fn expressions_describe(
109        &self,
110        table_id: &str,
111        config: JsValue,
112    ) -> Result<Option<String>, JsValue> {
113        let config: ViewConfig = serde_wasm_bindgen::from_value(config)
114            .map_err(|e| JsValue::from_str(&e.to_string()))?;
115
116        self.inner
117            .expressions_describe(table_id, &config)
118            .map_err(|e| JsValue::from_str(&e.to_string()))
119    }
120
121    /// Returns the SQL query that plans ONE expression against a table - for
122    /// attributing a failed `expressionsDescribe` to the expressions at fault.
123    #[wasm_bindgen(js_name = "expressionDescribe")]
124    pub fn expression_describe(&self, table_id: &str, expression: &str) -> Result<String, JsValue> {
125        self.inner
126            .expression_describe(table_id, expression)
127            .map_err(|e| JsValue::from_str(&e.to_string()))
128    }
129
130    /// Returns the SQL query that plans the view `config` would build and
131    /// reports its result columns without materializing it (`split_by` folded
132    /// into `group_by`, no `PIVOT`), or `undefined` when the config selects no
133    /// columns.
134    #[wasm_bindgen(js_name = "tableDescribe")]
135    pub fn table_describe(
136        &self,
137        table_id: &str,
138        config: JsValue,
139        schema: JsValue,
140        unordered: Option<bool>,
141    ) -> Result<Option<String>, JsValue> {
142        let config: ViewConfig = serde_wasm_bindgen::from_value(config)
143            .map_err(|e| JsValue::from_str(&e.to_string()))?;
144
145        let schema = if schema.is_undefined() || schema.is_null() {
146            IndexMap::new()
147        } else {
148            self.parse_schema(schema)?
149        };
150
151        let result = if unordered.unwrap_or(false) {
152            self.inner
153                .table_describe_unordered(table_id, &config, &schema)
154        } else {
155            self.inner.table_describe(table_id, &config, &schema)
156        };
157
158        result.map_err(|e| JsValue::from_str(&e.to_string()))
159    }
160
161    /// Returns the SQL query to delete a view.
162    #[wasm_bindgen(js_name = "viewDelete")]
163    pub fn view_delete(&self, view_id: &str) -> Result<String, JsValue> {
164        self.inner
165            .view_delete(view_id)
166            .map_err(|e| JsValue::from_str(&e.to_string()))
167    }
168
169    /// Returns the SQL query to create a view from a table with the given
170    /// configuration.
171    #[wasm_bindgen(js_name = "tableMakeView")]
172    pub fn table_make_view(
173        &self,
174        table_id: &str,
175        view_id: &str,
176        config: JsValue,
177        schema: JsValue,
178        unordered: Option<bool>,
179    ) -> Result<String, JsValue> {
180        let config: ViewConfig = serde_wasm_bindgen::from_value(config)
181            .map_err(|e| JsValue::from_str(&e.to_string()))?;
182
183        let schema = if schema.is_undefined() || schema.is_null() {
184            IndexMap::new()
185        } else {
186            self.parse_schema(schema)?
187        };
188
189        let result = if unordered.unwrap_or(false) {
190            self.inner
191                .table_make_view_unordered(table_id, view_id, &config, &schema)
192        } else {
193            self.inner
194                .table_make_view(table_id, view_id, &config, &schema)
195        };
196
197        result.map_err(|e| JsValue::from_str(&e.to_string()))
198    }
199
200    /// Returns the SQL query to create a table from a view.
201    #[wasm_bindgen(js_name = "viewMakeTable")]
202    pub fn view_make_table(
203        &self,
204        view_id: &str,
205        table_id: &str,
206        config: JsValue,
207        view_schema: JsValue,
208        schema: JsValue,
209    ) -> Result<String, JsValue> {
210        let config: ViewConfig = serde_wasm_bindgen::from_value(config)
211            .map_err(|e| JsValue::from_str(&e.to_string()))?;
212
213        let view_schema = self.parse_schema(view_schema)?;
214        let schema = if schema.is_undefined() || schema.is_null() {
215            None
216        } else {
217            Some(self.parse_schema(schema)?)
218        };
219
220        self.inner
221            .view_make_table(view_id, table_id, &config, &view_schema, schema.as_ref())
222            .map_err(|e| JsValue::from_str(&e.to_string()))
223    }
224
225    /// Returns the SQL query to fetch data from a view with the given viewport.
226    #[wasm_bindgen(js_name = "viewGetData")]
227    pub fn view_get_data(
228        &self,
229        view_id: &str,
230        config: JsValue,
231        viewport: JsValue,
232        schema: JsValue,
233    ) -> Result<String, JsValue> {
234        let config: ViewConfig = serde_wasm_bindgen::from_value(config)
235            .map_err(|e| JsValue::from_str(&e.to_string()))?;
236
237        let viewport: ViewPort = serde_wasm_bindgen::from_value(viewport)
238            .map_err(|e| JsValue::from_str(&e.to_string()))?;
239
240        let schema = self.parse_schema(schema)?;
241
242        self.inner
243            .view_get_data(view_id, &config, &viewport, &schema)
244            .map_err(|e| JsValue::from_str(&e.to_string()))
245    }
246
247    /// Returns the SQL query to describe a view's schema.
248    #[wasm_bindgen(js_name = "viewSchema")]
249    pub fn view_schema(&self, view_id: &str) -> Result<String, JsValue> {
250        self.inner
251            .view_schema(view_id)
252            .map_err(|e| JsValue::from_str(&e.to_string()))
253    }
254
255    /// Returns the SQL query to get the row count of a view.
256    #[wasm_bindgen(js_name = "viewSize")]
257    pub fn view_size(&self, view_id: &str) -> Result<String, JsValue> {
258        self.inner
259            .view_size(view_id)
260            .map_err(|e| JsValue::from_str(&e.to_string()))
261    }
262
263    /// Returns the SQL query to get the min and max values of a column.
264    #[wasm_bindgen(js_name = "viewGetMinMax")]
265    pub fn view_get_min_max(
266        &self,
267        view_id: &str,
268        column_name: &str,
269        config: JsValue,
270    ) -> Result<String, JsValue> {
271        let config: ViewConfig = serde_wasm_bindgen::from_value(config)
272            .map_err(|e| JsValue::from_str(&e.to_string()))?;
273
274        self.inner
275            .view_get_min_max(view_id, column_name, &config)
276            .map_err(|e| JsValue::from_str(&e.to_string()))
277    }
278
279    /// Returns the `UPDATE` expanding the `row_index`-th visible row, whose
280    /// affected-row count is the number of rows shown.
281    #[wasm_bindgen(js_name = "viewExpand")]
282    pub fn view_expand(
283        &self,
284        view_id: &str,
285        config: JsValue,
286        row_index: u32,
287    ) -> Result<String, JsValue> {
288        let config: ViewConfig = serde_wasm_bindgen::from_value(config)
289            .map_err(|e| JsValue::from_str(&e.to_string()))?;
290
291        self.inner
292            .view_expand(view_id, &config, row_index)
293            .map_err(|e| JsValue::from_str(&e.to_string()))
294    }
295
296    /// Returns the `UPDATE` collapsing the `row_index`-th visible row, whose
297    /// affected-row count is the number of rows hidden.
298    #[wasm_bindgen(js_name = "viewCollapse")]
299    pub fn view_collapse(
300        &self,
301        view_id: &str,
302        config: JsValue,
303        row_index: u32,
304    ) -> Result<String, JsValue> {
305        let config: ViewConfig = serde_wasm_bindgen::from_value(config)
306            .map_err(|e| JsValue::from_str(&e.to_string()))?;
307
308        self.inner
309            .view_collapse(view_id, &config, row_index)
310            .map_err(|e| JsValue::from_str(&e.to_string()))
311    }
312
313    /// Returns the `UPDATE` expanding exactly the rows whose path is no longer
314    /// than `depth`.
315    #[wasm_bindgen(js_name = "viewSetDepth")]
316    pub fn view_set_depth(
317        &self,
318        view_id: &str,
319        config: JsValue,
320        depth: u32,
321    ) -> Result<String, JsValue> {
322        let config: ViewConfig = serde_wasm_bindgen::from_value(config)
323            .map_err(|e| JsValue::from_str(&e.to_string()))?;
324
325        self.inner
326            .view_set_depth(view_id, &config, depth)
327            .map_err(|e| JsValue::from_str(&e.to_string()))
328    }
329}
330
331impl GenericSQLVirtualServerModel {
332    fn parse_schema(&self, schema: JsValue) -> Result<IndexMap<String, ColumnType>, JsValue> {
333        let obj = schema.dyn_ref::<Object>().ok_or_else(|| {
334            JsValue::from_str("Schema must be an object mapping column names to types")
335        })?;
336
337        let mut result = IndexMap::new();
338        let entries = Object::entries(obj);
339        for i in 0..entries.length() {
340            let entry = entries.get(i);
341            let entry_array = entry
342                .dyn_ref::<js_sys::Array>()
343                .ok_or_else(|| JsValue::from_str("Invalid schema entry"))?;
344            let key = entry_array
345                .get(0)
346                .as_string()
347                .ok_or_else(|| JsValue::from_str("Column name must be a string"))?;
348            let value = entry_array
349                .get(1)
350                .as_string()
351                .ok_or_else(|| JsValue::from_str("Column type must be a string"))?;
352            let column_type = ColumnType::from_str(&value)
353                .map_err(|_| JsValue::from_str(&format!("Unknown column type: {}", value)))?;
354            result.insert(key, column_type);
355        }
356        Ok(result)
357    }
358}