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 an array of strings containing the column paths of the [`View`]
98 /// without any of the source columns.
99 ///
100 /// A column path shows the columns that a given cell belongs to after
101 /// pivots are applied.
102 #[wasm_bindgen]
103 pub async fn column_paths(&self, window: Option<JsColumnWindow>) -> ApiResult<JsValue> {
104 let window = window.into_serde_ext::<Option<ColumnWindow>>()?;
105 let columns = self.0.column_paths(window.unwrap_or_default()).await?;
106 Ok(JsValue::from_serde_ext(&columns)?)
107 }
108
109 /// Delete this [`View`] and clean up all resources associated with it.
110 /// [`View`] objects do not stop consuming resources or processing
111 /// updates when they are garbage collected - you must call this method
112 /// to reclaim these.
113 #[wasm_bindgen]
114 pub async fn delete(self) -> ApiResult<()> {
115 self.0.delete().await?;
116 Ok(())
117 }
118
119 /// Returns this [`View`]'s _dimensions_, row and column count, as well as
120 /// those of the [`crate::Table`] from which it was derived.
121 ///
122 /// - `num_table_rows` - The number of rows in the underlying
123 /// [`crate::Table`].
124 /// - `num_table_columns` - The number of columns in the underlying
125 /// [`crate::Table`] (including the `index` column if this
126 /// [`crate::Table`] was constructed with one).
127 /// - `num_view_rows` - The number of rows in this [`View`]. If this
128 /// [`View`] has a `group_by` clause, `num_view_rows` will also include
129 /// aggregated rows.
130 /// - `num_view_columns` - The number of columns in this [`View`]. If this
131 /// [`View`] has a `split_by` clause, `num_view_columns` will include all
132 /// _column paths_, e.g. the number of `columns` clause times the number
133 /// of `split_by` groups.
134 #[wasm_bindgen]
135 pub async fn dimensions(&self) -> ApiResult<JsValue> {
136 let dimensions = self.0.dimensions().await?;
137 Ok(JsValue::from_serde_ext(&dimensions)?)
138 }
139
140 /// The expression schema of this [`View`], which contains only the
141 /// expressions created on this [`View`]. See [`View::schema`] for
142 /// details.
143 #[wasm_bindgen]
144 pub async fn expression_schema(&self) -> ApiResult<JsValue> {
145 let schema = self.0.expression_schema().await?;
146 Ok(JsValue::from_serde_ext(&schema)?)
147 }
148
149 /// A copy of the config object passed to the [`Table::view`] method which
150 /// created this [`View`].
151 #[wasm_bindgen]
152 pub async fn get_config(&self) -> ApiResult<JsValue> {
153 let config = self.0.get_config().await?;
154 Ok(JsValue::from_serde_ext(&config)?)
155 }
156
157 /// Calculates the [min, max] of the leaf nodes of a column `column_name`.
158 ///
159 /// # Returns
160 ///
161 /// A tuple of [min, max], whose types are column and aggregate dependent.
162 #[wasm_bindgen]
163 pub async fn get_min_max(&self, name: String) -> ApiResult<Array> {
164 let result = self.0.get_min_max(name).await?;
165 let arr = Array::new();
166 arr.push(&scalar_to_jsvalue(&result.0));
167 arr.push(&scalar_to_jsvalue(&result.1));
168 Ok(arr)
169 }
170
171 /// The number of aggregated rows in this [`View`]. This is affected by the
172 /// "group_by" configuration parameter supplied to this view's contructor.
173 ///
174 /// # Returns
175 ///
176 /// The number of aggregated rows.
177 #[wasm_bindgen]
178 pub async fn num_rows(&self) -> ApiResult<i32> {
179 let size = self.0.num_rows().await?;
180 Ok(size as i32)
181 }
182
183 /// The schema of this [`View`].
184 ///
185 /// The [`View`] schema differs from the `schema` returned by
186 /// [`Table::schema`]; it may have different column names due to
187 /// `expressions` or `columns` configs, or it maye have _different
188 /// column types_ due to the application og `group_by` and `aggregates`
189 /// config. You can think of [`Table::schema`] as the _input_ schema and
190 /// [`View::schema`] as the _output_ schema of a Perspective pipeline.
191 #[wasm_bindgen]
192 pub async fn schema(&self) -> ApiResult<JsValue> {
193 let schema = self.0.schema().await?;
194 Ok(JsValue::from_serde_ext(&schema)?)
195 }
196
197 /// Serializes a [`View`] to the Apache Arrow data format.
198 #[wasm_bindgen]
199 pub async fn to_arrow(&self, window: Option<JsViewWindow>) -> ApiResult<ArrayBuffer> {
200 let window = window.into_serde_ext::<Option<ViewWindow>>()?;
201 let arrow = self.0.to_arrow(window.unwrap_or_default()).await?;
202 Ok(js_sys::Uint8Array::from(&arrow[..])
203 .buffer()
204 .unchecked_into())
205 }
206
207 /// Serializes this [`View`] to a string of JSON data. Useful if you want to
208 /// save additional round trip serialize/deserialize cycles.
209 #[wasm_bindgen]
210 pub async fn to_columns_string(&self, window: Option<JsViewWindow>) -> ApiResult<String> {
211 let window = window.into_serde_ext::<Option<ViewWindow>>()?;
212 let json = self.0.to_columns_string(window.unwrap_or_default()).await?;
213 Ok(json)
214 }
215
216 /// Serializes this [`View`] to JavaScript objects in a column-oriented
217 /// format.
218 #[wasm_bindgen]
219 pub async fn to_columns(&self, window: Option<JsViewWindow>) -> ApiResult<Object> {
220 let json = self.to_columns_string(window).await?;
221 Ok(js_sys::JSON::parse(&json)?.unchecked_into())
222 }
223
224 /// Render this `View` as a JSON string.
225 #[wasm_bindgen]
226 pub async fn to_json_string(&self, window: Option<JsViewWindow>) -> ApiResult<String> {
227 let window = window.into_serde_ext::<Option<ViewWindow>>()?;
228 let json = self.0.to_json_string(window.unwrap_or_default()).await?;
229 Ok(json)
230 }
231
232 /// Serializes this [`View`] to JavaScript objects in a row-oriented
233 /// format.
234 #[wasm_bindgen]
235 pub async fn to_json(&self, window: Option<JsViewWindow>) -> ApiResult<Array> {
236 let json = self.to_json_string(window).await?;
237 Ok(js_sys::JSON::parse(&json)?.unchecked_into())
238 }
239
240 /// Renders this [`View`] as an [NDJSON](https://github.com/ndjson/ndjson-spec)
241 /// formatted [`String`].
242 #[wasm_bindgen]
243 pub async fn to_ndjson(&self, window: Option<JsViewWindow>) -> ApiResult<String> {
244 let window = window.into_serde_ext::<Option<ViewWindow>>()?;
245 let ndjson = self.0.to_ndjson(window.unwrap_or_default()).await?;
246 Ok(ndjson)
247 }
248
249 /// Serializes this [`View`] to CSV data in a standard format.
250 #[wasm_bindgen]
251 pub async fn to_csv(&self, window: Option<JsViewWindow>) -> ApiResult<String> {
252 let window = window.into_serde_ext::<Option<ViewWindow>>()?;
253 Ok(self.0.to_csv(window.unwrap_or_default()).await?)
254 }
255
256 /// Fetches columns from the [`View`] in Arrow format, decodes them, and
257 /// passes typed array views to `callback`. All arrays are only valid for
258 /// the duration of the callback — if `callback` returns a `Promise`, it
259 /// is awaited before the backing Arrow buffer is released, so async
260 /// callbacks may use the views for the full duration of the awaited
261 /// work (e.g. across an `await requestAnimationFrame`-backed promise).
262 ///
263 /// # Arguments
264 ///
265 /// - `window` - Optional [`TypedArrayWindow`] controlling row/column
266 /// windowing and output options (e.g., `float32` mode).
267 /// - `callback` - A JS function called with `(names: string[], values:
268 /// TypedArray[], validities: (Uint8Array|null)[], dictionaries:
269 /// (string[]|null)[]) => void | Promise<void>`.
270 #[wasm_bindgen]
271 pub async fn with_typed_arrays(
272 &self,
273 window: Option<crate::typed_array::JsTypedArrayWindow>,
274 callback: Function,
275 ) -> ApiResult<()> {
276 let opts: crate::typed_array::TypedArrayWindow = window
277 .into_serde_ext::<Option<crate::typed_array::TypedArrayWindow>>()?
278 .unwrap_or_default();
279
280 let float32 = opts.float32;
281 let mut view_window: ViewWindow = opts.into();
282 view_window.emit_legacy_row_path_names = Some(false);
283 let arrow = self.0.to_arrow(view_window).await?;
284 crate::typed_array::decode_and_call(&arrow, float32, &callback).await?;
285 Ok(())
286 }
287
288 /// Register a callback with this [`View`]. Whenever the view's underlying
289 /// table emits an update, this callback will be invoked with an object
290 /// containing `port_id`, indicating which port the update fired on, and
291 /// optionally `delta`, which is the new data that was updated for each
292 /// cell or each row (as an Arrow).
293 ///
294 /// # Arguments
295 ///
296 /// - `on_update` - A callback function invoked on update, which receives an
297 /// object with two keys: `port_id`, indicating which port the update was
298 /// triggered on, and `delta`, whose value is dependent on the mode
299 /// parameter.
300 /// - `options` - If this is provided as `{mode: "row"}`, then `delta` is an
301 /// Arrow of the updated rows. Otherwise `delta` will be `null`.
302 ///
303 /// # JavaScript Examples
304 ///
305 /// ```javascript
306 /// view.on_update((updated) => console.log(updated.port_id));
307 /// ```
308 ///
309 /// ```javascript
310 /// view.on_update((updated) => console.log(updated.delta), { mode: "row" });
311 /// ```
312 #[wasm_bindgen]
313 pub fn on_update(
314 &self,
315 #[wasm_bindgen(unchecked_param_type = "(data: OnUpdateData) => void")]
316 on_update_js: Function,
317 options: Option<JsOnUpdateOptions>,
318 ) -> ApiFuture<u32> {
319 let poll_loop = LocalPollLoop::new(move |args: OnUpdateData| {
320 let js_obj = JsValue::from_serde_ext(&*args)?;
321 on_update_js.call1(&JsValue::UNDEFINED, &js_obj)
322 });
323
324 let on_update = Box::new(move |msg| poll_loop.poll(msg));
325 let view = self.0.clone();
326 ApiFuture::new(async move {
327 let on_update_opts = options
328 .into_serde_ext::<Option<OnUpdateOptions>>()?
329 .unwrap_or_default();
330
331 let id = view.on_update(on_update, on_update_opts).await?;
332 Ok(id)
333 })
334 }
335
336 /// Unregister a previously registered update callback with this [`View`].
337 ///
338 /// # Arguments
339 ///
340 /// - `id` - A callback `id` as returned by a recipricol call to
341 /// [`View::on_update`].
342 #[wasm_bindgen]
343 pub async fn remove_update(&self, callback_id: u32) -> ApiResult<()> {
344 Ok(self.0.remove_update(callback_id).await?)
345 }
346
347 /// Register a callback which is invoked whenever rows are removed from
348 /// this [`View`]'s [`Table`] by [`Table::remove`], with an object
349 /// containing `port_id` and `indices`, the removed `index` column
350 /// values as an Arrow of one column named after the index.
351 ///
352 /// [`Table::replace`] reports the keys it does not re-supply and
353 /// [`Table::clear`] reports every key. `on_remove` never fires for a
354 /// [`Table`] without an `index`.
355 ///
356 /// # JavaScript Examples
357 ///
358 /// ```javascript
359 /// const id = await view.on_remove(({ indices, port_id }) => {
360 /// replica.remove(indices);
361 /// });
362 /// ```
363 #[wasm_bindgen]
364 pub fn on_remove(
365 &self,
366 #[wasm_bindgen(unchecked_param_type = "(data: OnRemoveData) => void")]
367 on_remove_js: Function,
368 ) -> ApiFuture<u32> {
369 let poll_loop = LocalPollLoop::new(move |args: OnRemoveData| {
370 let js_obj = JsValue::from_serde_ext(&*args)?;
371 on_remove_js.call1(&JsValue::UNDEFINED, &js_obj)
372 });
373
374 let on_remove = Box::new(move |msg| poll_loop.poll(msg));
375 let view = self.0.clone();
376 ApiFuture::new(async move { Ok(view.on_remove(on_remove).await?) })
377 }
378
379 /// Unregister a previously registered [`View::on_remove`] callback.
380 ///
381 /// # Arguments
382 ///
383 /// - `id` - A callback `id` as returned by a reciprocal call to
384 /// [`View::on_remove`].
385 #[wasm_bindgen]
386 pub async fn remove_remove(&self, callback_id: u32) -> ApiResult<()> {
387 Ok(self.0.remove_remove(callback_id).await?)
388 }
389
390 /// Register a callback with this [`View`]. Whenever the [`View`] is
391 /// deleted, this callback will be invoked.
392 #[wasm_bindgen]
393 pub fn on_delete(&self, on_delete: Function) -> ApiFuture<u32> {
394 let view = self.clone();
395 ApiFuture::new(async move {
396 let emit = LocalPollLoop::new(move |()| on_delete.call0(&JsValue::UNDEFINED));
397 let on_delete = Box::new(move || spawn_local(emit.poll(())));
398 Ok(view.0.on_delete(on_delete).await?)
399 })
400 }
401
402 /// The number of aggregated columns in this [`View`]. This is affected by
403 /// the "split_by" configuration parameter supplied to this view's
404 /// contructor.
405 ///
406 /// # Returns
407 ///
408 /// The number of aggregated columns.
409 #[wasm_bindgen]
410 pub async fn num_columns(&self) -> ApiResult<u32> {
411 // TODO: This is broken because of how split by creates a
412 // cartesian product of columns * unique values.
413 Ok(self.0.dimensions().await?.num_view_columns)
414 }
415
416 /// Unregister a previously registered [`View::on_delete`] callback.
417 #[wasm_bindgen]
418 pub fn remove_delete(&self, callback_id: u32) -> ApiFuture<()> {
419 let client = self.0.clone();
420 ApiFuture::new(async move {
421 client.remove_delete(callback_id).await?;
422 Ok(())
423 })
424 }
425
426 /// Collapses the `group_by` row at `row_index`.
427 #[wasm_bindgen]
428 pub async fn collapse(&self, row_index: u32) -> ApiResult<u32> {
429 Ok(self.0.collapse(row_index).await?)
430 }
431
432 /// Expand the `group_by` row at `row_index`.
433 #[wasm_bindgen]
434 pub async fn expand(&self, row_index: u32) -> ApiResult<u32> {
435 Ok(self.0.expand(row_index).await?)
436 }
437
438 /// Set expansion `depth` of the `group_by` tree.
439 #[wasm_bindgen]
440 pub async fn set_depth(&self, depth: u32) -> ApiResult<()> {
441 Ok(self.0.set_depth(depth).await?)
442 }
443}