Skip to main content

perspective_js/
view.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
13use js_sys::{Array, ArrayBuffer, Function, Object};
14use perspective_client::{
15    ColumnWindow, OnRemoveData, OnUpdateData, OnUpdateOptions, ViewWindow, assert_view_api,
16};
17use wasm_bindgen::prelude::*;
18use wasm_bindgen_futures::spawn_local;
19
20#[cfg(doc)]
21use crate::table::Table;
22use crate::utils::{ApiFuture, ApiResult, JsValueSerdeExt, LocalPollLoop};
23
24#[wasm_bindgen]
25unsafe extern "C" {
26    #[wasm_bindgen(typescript_type = "ViewWindow")]
27    #[derive(Clone)]
28    pub type JsViewWindow;
29
30    #[wasm_bindgen(typescript_type = "ColumnWindow")]
31    #[derive(Clone)]
32    pub type JsColumnWindow;
33
34    #[wasm_bindgen(method, setter, js_name = "formatted")]
35    pub fn set_formatted(this: &JsViewWindow, x: bool);
36
37    #[wasm_bindgen(typescript_type = "OnUpdateOptions")]
38    pub type JsOnUpdateOptions;
39
40}
41
42impl From<ViewWindow> for JsViewWindow {
43    fn from(value: ViewWindow) -> Self {
44        JsViewWindow::from_serde_ext(&value)
45            .unwrap()
46            .unchecked_into()
47    }
48}
49
50fn scalar_to_jsvalue(scalar: &perspective_client::config::Scalar) -> JsValue {
51    match scalar {
52        perspective_client::config::Scalar::Float(x) => JsValue::from_f64(*x),
53        perspective_client::config::Scalar::String(x) => JsValue::from_str(x),
54        perspective_client::config::Scalar::Bool(x) => JsValue::from_bool(*x),
55        perspective_client::config::Scalar::Null => JsValue::NULL,
56    }
57}
58
59/// The [`View`] struct is Perspective's query and serialization interface. It
60/// represents a query on the `Table`'s dataset and is always created from an
61/// existing `Table` instance via the [`Table::view`] method.
62///
63/// [`View`]s are immutable with respect to the arguments provided to the
64/// [`Table::view`] method; to change these parameters, you must create a new
65/// [`View`] on the same [`Table`]. However, each [`View`] is _live_ with
66/// respect to the [`Table`]'s data, and will (within a conflation window)
67/// update with the latest state as its parent [`Table`] updates, including
68/// incrementally recalculating all aggregates, pivots, filters, etc. [`View`]
69/// query parameters are composable, in that each parameter works independently
70/// _and_ in conjunction with each other, and there is no limit to the number of
71/// pivots, filters, etc. which can be applied.
72#[wasm_bindgen]
73#[derive(Clone)]
74pub struct View(pub(crate) perspective_client::View);
75
76assert_view_api!(View);
77
78impl From<perspective_client::View> for View {
79    fn from(value: perspective_client::View) -> Self {
80        View(value)
81    }
82}
83
84#[wasm_bindgen]
85impl View {
86    #[doc(hidden)]
87    pub fn __get_model(&self) -> View {
88        self.clone()
89    }
90
91    #[wasm_bindgen]
92    #[doc(hidden)]
93    pub fn __unsafe_get_name(&self) -> String {
94        self.0.name.clone()
95    }
96
97    /// Returns this [`View`]'s column header area for `window`, transposed as
98    /// `area[level][column]`.
99    ///
100    /// A column path shows the columns that a given cell belongs to after
101    /// pivots are applied. The area is rectangular with one level per
102    /// `split_by` plus one: a column's split values occupy the leading levels
103    /// and its name always occupies the last, so a subtotal or grand total
104    /// under `split_rollup_mode: "rollup"` reads `null` at the levels it does
105    /// not pivot on. Values keep their column's type - a `datetime` split value
106    /// is epoch milliseconds, not text - so formatting them is the caller's
107    /// choice.
108    ///
109    /// `window` slices the *column* axis; the number of levels does not depend
110    /// on it.
111    #[wasm_bindgen(unchecked_return_type = "(string | number | boolean | null)[][]")]
112    pub async fn column_paths(&self, window: Option<JsColumnWindow>) -> ApiResult<JsValue> {
113        let window = window.into_serde_ext::<Option<ColumnWindow>>()?;
114        let columns = self.0.column_paths(window.unwrap_or_default()).await?;
115        Ok(JsValue::from_serde_ext(&columns)?)
116    }
117
118    /// Delete this [`View`] and clean up all resources associated with it.
119    /// [`View`] objects do not stop consuming resources or processing
120    /// updates when they are garbage collected - you must call this method
121    /// to reclaim these.
122    #[wasm_bindgen]
123    pub async fn delete(self) -> ApiResult<()> {
124        self.0.delete().await?;
125        Ok(())
126    }
127
128    /// Returns this [`View`]'s _dimensions_, row and column count, as well as
129    /// those of the [`crate::Table`] from which it was derived.
130    ///
131    /// - `num_table_rows` - The number of rows in the underlying
132    ///   [`crate::Table`].
133    /// - `num_table_columns` - The number of columns in the underlying
134    ///   [`crate::Table`] (including the `index` column if this
135    ///   [`crate::Table`] was constructed with one).
136    /// - `num_view_rows` - The number of rows in this [`View`]. If this
137    ///   [`View`] has a `group_by` clause, `num_view_rows` will also include
138    ///   aggregated rows.
139    /// - `num_view_columns` - The number of columns in this [`View`]. If this
140    ///   [`View`] has a `split_by` clause, `num_view_columns` will include all
141    ///   _column paths_, e.g. the number of `columns` clause times the number
142    ///   of `split_by` groups.
143    #[wasm_bindgen]
144    pub async fn dimensions(&self) -> ApiResult<JsValue> {
145        let dimensions = self.0.dimensions().await?;
146        Ok(JsValue::from_serde_ext(&dimensions)?)
147    }
148
149    /// The expression schema of this [`View`], which contains only the
150    /// expressions created on this [`View`]. See [`View::schema`] for
151    /// details.
152    #[wasm_bindgen]
153    pub async fn expression_schema(&self) -> ApiResult<JsValue> {
154        let schema = self.0.expression_schema().await?;
155        Ok(JsValue::from_serde_ext(&schema)?)
156    }
157
158    /// A copy of the config object passed to the [`Table::view`] method which
159    /// created this [`View`].
160    #[wasm_bindgen]
161    pub async fn get_config(&self) -> ApiResult<JsValue> {
162        let config = self.0.get_config().await?;
163        Ok(JsValue::from_serde_ext(&config)?)
164    }
165
166    /// Calculates the [min, max] of the leaf nodes of a column `column_name`.
167    ///
168    /// # Returns
169    ///
170    /// A tuple of [min, max], whose types are column and aggregate dependent.
171    #[wasm_bindgen]
172    pub async fn get_min_max(&self, name: String) -> ApiResult<Array> {
173        let result = self.0.get_min_max(name).await?;
174        let arr = Array::new();
175        arr.push(&scalar_to_jsvalue(&result.0));
176        arr.push(&scalar_to_jsvalue(&result.1));
177        Ok(arr)
178    }
179
180    /// The number of aggregated rows in this [`View`]. This is affected by the
181    /// "group_by" configuration parameter supplied to this view's contructor.
182    ///
183    /// # Returns
184    ///
185    /// The number of aggregated rows.
186    #[wasm_bindgen]
187    pub async fn num_rows(&self) -> ApiResult<i32> {
188        let size = self.0.num_rows().await?;
189        Ok(size as i32)
190    }
191
192    /// The schema of this [`View`].
193    ///
194    /// The [`View`] schema differs from the `schema` returned by
195    /// [`Table::schema`]; it may have different column names due to
196    /// `expressions` or `columns` configs, or it maye have _different
197    /// column types_ due to the application og `group_by` and `aggregates`
198    /// config. You can think of [`Table::schema`] as the _input_ schema and
199    /// [`View::schema`] as the _output_ schema of a Perspective pipeline.
200    #[wasm_bindgen]
201    pub async fn schema(&self) -> ApiResult<JsValue> {
202        let schema = self.0.schema().await?;
203        Ok(JsValue::from_serde_ext(&schema)?)
204    }
205
206    /// Serializes a [`View`] to the Apache Arrow data format.
207    ///
208    /// # Arguments
209    ///
210    /// - `window` - a [`ViewWindow`]; its `compression` key selects Arrow IPC
211    ///   body compression, `"lz4"` or `"zstd"` (uncompressed when omitted).
212    ///
213    /// # JavaScript Examples
214    ///
215    /// ```javascript
216    /// const arrow = await view.to_arrow({ compression: "zstd" });
217    /// ```
218    #[wasm_bindgen]
219    pub async fn to_arrow(&self, window: Option<JsViewWindow>) -> ApiResult<ArrayBuffer> {
220        let window = window.into_serde_ext::<Option<ViewWindow>>()?;
221        let arrow = self.0.to_arrow(window.unwrap_or_default()).await?;
222        Ok(js_sys::Uint8Array::from(&arrow[..])
223            .buffer()
224            .unchecked_into())
225    }
226
227    /// Serializes this [`View`] to a string of JSON data. Useful if you want to
228    /// save additional round trip serialize/deserialize cycles.
229    #[wasm_bindgen]
230    pub async fn to_columns_string(&self, window: Option<JsViewWindow>) -> ApiResult<String> {
231        let window = window.into_serde_ext::<Option<ViewWindow>>()?;
232        let json = self.0.to_columns_string(window.unwrap_or_default()).await?;
233        Ok(json)
234    }
235
236    /// Serializes this [`View`] to JavaScript objects in a column-oriented
237    /// format.
238    #[wasm_bindgen]
239    pub async fn to_columns(&self, window: Option<JsViewWindow>) -> ApiResult<Object> {
240        let json = self.to_columns_string(window).await?;
241        Ok(js_sys::JSON::parse(&json)?.unchecked_into())
242    }
243
244    /// Render this `View` as a JSON string.
245    #[wasm_bindgen]
246    pub async fn to_json_string(&self, window: Option<JsViewWindow>) -> ApiResult<String> {
247        let window = window.into_serde_ext::<Option<ViewWindow>>()?;
248        let json = self.0.to_json_string(window.unwrap_or_default()).await?;
249        Ok(json)
250    }
251
252    /// Serializes this [`View`] to JavaScript objects in a row-oriented
253    /// format.
254    #[wasm_bindgen]
255    pub async fn to_json(&self, window: Option<JsViewWindow>) -> ApiResult<Array> {
256        let json = self.to_json_string(window).await?;
257        Ok(js_sys::JSON::parse(&json)?.unchecked_into())
258    }
259
260    /// Renders this [`View`] as an [NDJSON](https://github.com/ndjson/ndjson-spec)
261    /// formatted [`String`].
262    #[wasm_bindgen]
263    pub async fn to_ndjson(&self, window: Option<JsViewWindow>) -> ApiResult<String> {
264        let window = window.into_serde_ext::<Option<ViewWindow>>()?;
265        let ndjson = self.0.to_ndjson(window.unwrap_or_default()).await?;
266        Ok(ndjson)
267    }
268
269    /// Serializes this [`View`] to CSV data in a standard format.
270    #[wasm_bindgen]
271    pub async fn to_csv(&self, window: Option<JsViewWindow>) -> ApiResult<String> {
272        let window = window.into_serde_ext::<Option<ViewWindow>>()?;
273        Ok(self.0.to_csv(window.unwrap_or_default()).await?)
274    }
275
276    /// Fetches columns from the [`View`] in Arrow format, decodes them, and
277    /// passes typed array views to `callback`. All arrays are only valid for
278    /// the duration of the callback — if `callback` returns a `Promise`, it
279    /// is awaited before the backing Arrow buffer is released, so async
280    /// callbacks may use the views for the full duration of the awaited
281    /// work (e.g. across an `await requestAnimationFrame`-backed promise).
282    ///
283    /// # Arguments
284    ///
285    /// - `window` - Optional [`TypedArrayWindow`] controlling row/column
286    ///   windowing and output options (e.g., `float32` mode).
287    /// - `callback` - A JS function called with `(names: string[], values:
288    ///   TypedArray[], validities: (Uint8Array|null)[], dictionaries:
289    ///   (string[]|null)[]) => void | Promise<void>`.
290    #[wasm_bindgen]
291    pub async fn with_typed_arrays(
292        &self,
293        window: Option<crate::typed_array::JsTypedArrayWindow>,
294        callback: Function,
295    ) -> ApiResult<()> {
296        let opts: crate::typed_array::TypedArrayWindow = window
297            .into_serde_ext::<Option<crate::typed_array::TypedArrayWindow>>()?
298            .unwrap_or_default();
299
300        let float32 = opts.float32;
301        let mut view_window: ViewWindow = opts.into();
302        view_window.machine_column_names = Some(true);
303        let arrow = self.0.to_arrow(view_window).await?;
304        crate::typed_array::decode_and_call(&arrow, float32, &callback).await?;
305        Ok(())
306    }
307
308    /// Register a callback with this [`View`]. Whenever the view's underlying
309    /// table emits an update, this callback will be invoked with an object
310    /// containing `port_id`, indicating which port the update fired on, and
311    /// optionally `delta`, which is the new data that was updated for each
312    /// cell or each row (as an Arrow).
313    ///
314    /// # Arguments
315    ///
316    /// - `on_update` - A callback function invoked on update, which receives an
317    ///   object with two keys: `port_id`, indicating which port the update was
318    ///   triggered on, and `delta`, whose value is dependent on the mode
319    ///   parameter.
320    /// - `options` - If this is provided as `{mode: "row"}`, then `delta` is an
321    ///   Arrow of the updated rows. Otherwise `delta` will be `null`.
322    ///
323    /// # JavaScript Examples
324    ///
325    /// ```javascript
326    /// view.on_update((updated) => console.log(updated.port_id));
327    /// ```
328    ///
329    /// ```javascript
330    /// view.on_update((updated) => console.log(updated.delta), { mode: "row" });
331    /// ```
332    #[wasm_bindgen]
333    pub fn on_update(
334        &self,
335        #[wasm_bindgen(unchecked_param_type = "(data: OnUpdateData) => void")]
336        on_update_js: Function,
337        options: Option<JsOnUpdateOptions>,
338    ) -> ApiFuture<u32> {
339        let poll_loop = LocalPollLoop::new(move |args: OnUpdateData| {
340            let js_obj = JsValue::from_serde_ext(&*args)?;
341            on_update_js.call1(&JsValue::UNDEFINED, &js_obj)
342        });
343
344        let on_update = Box::new(move |msg| poll_loop.poll(msg));
345        let view = self.0.clone();
346        ApiFuture::new(async move {
347            let on_update_opts = options
348                .into_serde_ext::<Option<OnUpdateOptions>>()?
349                .unwrap_or_default();
350
351            let id = view.on_update(on_update, on_update_opts).await?;
352            Ok(id)
353        })
354    }
355
356    /// Unregister a previously registered update callback with this [`View`].
357    ///
358    /// # Arguments
359    ///
360    /// - `id` - A callback `id` as returned by a recipricol call to
361    ///   [`View::on_update`].
362    #[wasm_bindgen]
363    pub async fn remove_update(&self, callback_id: u32) -> ApiResult<()> {
364        Ok(self.0.remove_update(callback_id).await?)
365    }
366
367    /// Register a callback which is invoked whenever rows are removed from
368    /// this [`View`]'s [`Table`] by [`Table::remove`], with an object
369    /// containing `port_id` and `indices`, the removed `index` column
370    /// values as an Arrow of one column named after the index.
371    ///
372    /// [`Table::replace`] reports the keys it does not re-supply and
373    /// [`Table::clear`] reports every key. `on_remove` never fires for a
374    /// [`Table`] without an `index`.
375    ///
376    /// # JavaScript Examples
377    ///
378    /// ```javascript
379    /// const id = await view.on_remove(({ indices, port_id }) => {
380    ///     replica.remove(indices);
381    /// });
382    /// ```
383    #[wasm_bindgen]
384    pub fn on_remove(
385        &self,
386        #[wasm_bindgen(unchecked_param_type = "(data: OnRemoveData) => void")]
387        on_remove_js: Function,
388    ) -> ApiFuture<u32> {
389        let poll_loop = LocalPollLoop::new(move |args: OnRemoveData| {
390            let js_obj = JsValue::from_serde_ext(&*args)?;
391            on_remove_js.call1(&JsValue::UNDEFINED, &js_obj)
392        });
393
394        let on_remove = Box::new(move |msg| poll_loop.poll(msg));
395        let view = self.0.clone();
396        ApiFuture::new(async move { Ok(view.on_remove(on_remove).await?) })
397    }
398
399    /// Unregister a previously registered [`View::on_remove`] callback.
400    ///
401    /// # Arguments
402    ///
403    /// - `id` - A callback `id` as returned by a reciprocal call to
404    ///   [`View::on_remove`].
405    #[wasm_bindgen]
406    pub async fn remove_remove(&self, callback_id: u32) -> ApiResult<()> {
407        Ok(self.0.remove_remove(callback_id).await?)
408    }
409
410    /// Register a callback with this [`View`]. Whenever the [`View`] is
411    /// deleted, this callback will be invoked.
412    #[wasm_bindgen]
413    pub fn on_delete(&self, on_delete: Function) -> ApiFuture<u32> {
414        let view = self.clone();
415        ApiFuture::new(async move {
416            let emit = LocalPollLoop::new(move |()| on_delete.call0(&JsValue::UNDEFINED));
417            let on_delete = Box::new(move || spawn_local(emit.poll(())));
418            Ok(view.0.on_delete(on_delete).await?)
419        })
420    }
421
422    /// The number of aggregated columns in this [`View`]. This is affected by
423    /// the "split_by" configuration parameter supplied to this view's
424    /// contructor.
425    ///
426    /// # Returns
427    ///
428    /// The number of aggregated columns.
429    #[wasm_bindgen]
430    pub async fn num_columns(&self) -> ApiResult<u32> {
431        // TODO: This is broken because of how split by creates a
432        // cartesian product of columns * unique values.
433        Ok(self.0.dimensions().await?.num_view_columns)
434    }
435
436    /// Unregister a previously registered [`View::on_delete`] callback.
437    #[wasm_bindgen]
438    pub fn remove_delete(&self, callback_id: u32) -> ApiFuture<()> {
439        let client = self.0.clone();
440        ApiFuture::new(async move {
441            client.remove_delete(callback_id).await?;
442            Ok(())
443        })
444    }
445
446    /// Collapses the `group_by` row at `row_index`.
447    #[wasm_bindgen]
448    pub async fn collapse(&self, row_index: u32) -> ApiResult<u32> {
449        Ok(self.0.collapse(row_index).await?)
450    }
451
452    /// Expand the `group_by` row at `row_index`.
453    #[wasm_bindgen]
454    pub async fn expand(&self, row_index: u32) -> ApiResult<u32> {
455        Ok(self.0.expand(row_index).await?)
456    }
457
458    /// Set expansion `depth` of the `group_by` tree.
459    #[wasm_bindgen]
460    pub async fn set_depth(&self, depth: u32) -> ApiResult<()> {
461        Ok(self.0.set_depth(depth).await?)
462    }
463}