Skip to main content

perspective_js/
table.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::Function;
14use perspective_client::config::*;
15use perspective_client::{
16    DeleteOptions, DescribeVerdict, UpdateData, UpdateOptions, assert_table_api,
17};
18use wasm_bindgen::prelude::*;
19use wasm_bindgen_derive::TryFromJsValue;
20use wasm_bindgen_futures::spawn_local;
21
22use crate::Client;
23use crate::table_data::UpdateDataExt;
24use crate::utils::{ApiFuture, ApiResult, JsValueSerdeExt, LocalPollLoop};
25pub use crate::view::*;
26
27#[derive(TryFromJsValue, Clone, PartialEq)]
28#[wasm_bindgen]
29pub struct Table(pub(crate) perspective_client::Table);
30
31assert_table_api!(Table);
32
33impl From<perspective_client::Table> for Table {
34    fn from(value: perspective_client::Table) -> Self {
35        Table(value)
36    }
37}
38
39/// [`Table`] is Perspective's columnar data frame, analogous to a Pandas/Polars
40/// `DataFrame` or Apache Arrow, supporting append & in-place updates, removal
41/// by index, and update notifications.
42///
43/// A [`Table`] contains columns, each of which have a unique name, are strongly
44/// and consistently typed, and contains rows of data conforming to the column's
45/// type. Each column in a [`Table`] must have the same number of rows, though
46/// not every row must contain data; null-values are used to indicate missing
47/// values in the dataset. The schema of a [`Table`] is _immutable after
48/// creation_, which means the column names and data types cannot be changed
49/// after the [`Table`] has been created. Columns cannot be added or deleted
50/// after creation either, but a [`View`] can be used to select an arbitrary set
51/// of columns from the [`Table`].
52impl Table {
53    pub fn get_table(&self) -> &'_ perspective_client::Table {
54        &self.0
55    }
56}
57
58#[wasm_bindgen]
59extern "C" {
60    // TODO Fix me
61    #[wasm_bindgen(typescript_type = "\
62        string | ArrayBuffer | Record<string, unknown[]> | Record<string, unknown>[] | \
63                                      Record<string, ColumnType>")]
64    pub type JsTableInitData;
65
66    #[wasm_bindgen(typescript_type = "ViewConfigUpdate")]
67    pub type JsViewConfig;
68
69    #[wasm_bindgen(typescript_type = "ViewConfig")]
70    pub type JsResolvedViewConfig;
71
72    #[wasm_bindgen(typescript_type = "UpdateOptions")]
73    pub type JsUpdateOptions;
74
75    #[wasm_bindgen(typescript_type = "DeleteOptions")]
76    pub type JsDeleteOptions;
77}
78
79#[wasm_bindgen]
80impl Table {
81    /// Returns the name of the index column for the table.
82    ///
83    /// # JavaScript Examples
84    ///
85    /// ```javascript
86    /// const table = await client.table("x,y\n1,2\n3,4", { index: "x" });
87    /// const index = await table.get_index(); // "x"
88    /// const table2 = await client.table(data, { index: ["x", "y"] });
89    /// const index2 = await table2.get_index(); // ["x", "y"]
90    /// ```
91    #[wasm_bindgen(unchecked_return_type = "string | string[] | undefined")]
92    pub async fn get_index(&self) -> ApiResult<JsValue> {
93        match self.0.get_index() {
94            None => Ok(JsValue::UNDEFINED),
95            Some(index) => Ok(JsValue::from_serde_ext(&index)?),
96        }
97    }
98
99    /// Get a copy of the [`Client`] this [`Table`] came from.
100    #[wasm_bindgen]
101    pub async fn get_client(&self) -> Client {
102        Client {
103            close: None,
104            client: self.0.get_client(),
105        }
106    }
107
108    /// Returns the user-specified name for this table, or the auto-generated
109    /// name if a name was not specified when the table was created.
110    #[wasm_bindgen]
111    pub async fn get_name(&self) -> String {
112        self.0.get_name().to_owned()
113    }
114
115    /// Returns the user-specified row limit for this table.
116    #[wasm_bindgen]
117    pub async fn get_limit(&self) -> Option<u32> {
118        self.0.get_limit()
119    }
120
121    /// Removes all the rows in the [`Table`], but preserves everything else
122    /// including the schema, index, and any callbacks or registered
123    /// [`View`] instances.
124    ///
125    /// Calling [`Table::clear`], like [`Table::update`] and [`Table::remove`],
126    /// will trigger an update event to any registered listeners via
127    /// [`View::on_update`].
128    #[wasm_bindgen]
129    pub async fn clear(&self) -> ApiResult<()> {
130        self.0.clear().await?;
131        Ok(())
132    }
133
134    /// Delete this [`Table`] and cleans up associated resources.
135    ///
136    /// [`Table`]s do not stop consuming resources or processing updates when
137    /// they are garbage collected in their host language - you must call
138    /// this method to reclaim these.
139    ///
140    /// # Arguments
141    ///
142    /// - `options` An options dictionary.
143    ///     - `lazy` Whether to delete this [`Table`] _lazily_. When false (the
144    ///       default), the delete will occur immediately, assuming it has no
145    ///       [`View`] instances registered to it (which must be deleted first,
146    ///       otherwise this method will throw an error). When true, the
147    ///       [`Table`] will only be marked for deltion once its [`View`]
148    ///       dependency count reaches 0.
149    ///
150    /// # JavaScript Examples
151    ///
152    /// ```javascript
153    /// const table = await client.table("x,y\n1,2\n3,4");
154    ///
155    /// // ...
156    ///
157    /// await table.delete({ lazy: true });
158    /// ```
159    #[wasm_bindgen]
160    pub async fn delete(self, options: Option<JsDeleteOptions>) -> ApiResult<()> {
161        let options = options
162            .into_serde_ext::<Option<DeleteOptions>>()?
163            .unwrap_or_default();
164
165        self.0.delete(options).await?;
166        Ok(())
167    }
168
169    /// Returns the number of rows in a [`Table`].
170    #[wasm_bindgen]
171    pub async fn size(&self) -> ApiResult<f64> {
172        Ok(self.0.size().await? as f64)
173    }
174
175    /// Returns a table's [`Schema`], a mapping of column names to column types.
176    ///
177    /// The mapping of a [`Table`]'s column names to data types is referred to
178    /// as a [`Schema`]. Each column has a unique name and a data type, one
179    /// of:
180    ///
181    /// - `"boolean"` - A boolean type
182    /// - `"date"` - A timesonze-agnostic date type (month/day/year)
183    /// - `"datetime"` - A millisecond-precision datetime type in the UTC
184    ///   timezone
185    /// - `"float"` - A 64 bit float
186    /// - `"integer"` - A signed 32 bit integer (the integer type supported by
187    ///   JavaScript)
188    /// - `"string"` - A [`String`] data type (encoded internally as a
189    ///   _dictionary_)
190    ///
191    /// Note that all [`Table`] columns are _nullable_, regardless of the data
192    /// type.
193    #[wasm_bindgen(unchecked_return_type = "Record<string, ColumnType>")]
194    pub async fn schema(&self) -> ApiResult<JsValue> {
195        let schema = self.0.schema().await?;
196        Ok(JsValue::from_serde_ext(&schema)?)
197    }
198
199    /// Returns the features supported by the server hosting this [`Table`].
200    #[wasm_bindgen(unchecked_return_type = "Features")]
201    pub async fn get_features(&self) -> ApiResult<JsValue> {
202        let features = self.0.get_features().await?;
203        let features = perspective_client::virtual_server::Features::from(&*features);
204        Ok(JsValue::from_serde_ext(&features)?)
205    }
206
207    /// Returns the column names of this [`Table`] in "natural" order (the
208    /// ordering implied by the input format).
209    ///  
210    ///  # JavaScript Examples
211    ///
212    ///  ```javascript
213    ///  const columns = await table.columns();
214    ///  ```   
215    #[wasm_bindgen]
216    pub async fn columns(&self) -> ApiResult<JsValue> {
217        let columns = self.0.columns().await?;
218        Ok(JsValue::from_serde_ext(&columns)?)
219    }
220
221    /// Create a unique channel ID on this [`Table`], which allows
222    /// `View::on_update` callback calls to be associated with the
223    /// `Table::update` which caused them.
224    #[wasm_bindgen]
225    pub async fn make_port(&self) -> ApiResult<i32> {
226        Ok(self.0.make_port().await?)
227    }
228
229    /// Register a callback which is called exactly once, when this [`Table`] is
230    /// deleted with the [`Table::delete`] method.
231    ///
232    /// [`Table::on_delete`] resolves when the subscription message is sent, not
233    /// when the _delete_ event occurs.
234    #[wasm_bindgen]
235    pub fn on_delete(&self, on_delete: Function) -> ApiFuture<u32> {
236        let table = self.clone();
237        ApiFuture::new(async move {
238            let emit = LocalPollLoop::new(move |()| on_delete.call0(&JsValue::UNDEFINED));
239            let on_delete = Box::new(move || spawn_local(emit.poll(())));
240            Ok(table.0.on_delete(on_delete).await?)
241        })
242    }
243
244    /// Removes a listener with a given ID, as returned by a previous call to
245    /// [`Table::on_delete`].
246    #[wasm_bindgen]
247    pub fn remove_delete(&self, callback_id: u32) -> ApiFuture<()> {
248        let client = self.0.clone();
249        ApiFuture::new(async move {
250            client.remove_delete(callback_id).await?;
251            Ok(())
252        })
253    }
254
255    /// Removes rows from this [`Table`] with the `index` column values
256    /// supplied.
257    ///
258    /// # Arguments
259    ///
260    /// - `indices` - A list of `index` column values for rows that should be
261    ///   removed. For a composite `index`, each entry is an array of the index
262    ///   column values in key order.
263    ///
264    /// # JavaScript Examples
265    ///
266    /// ```javascript
267    /// await table.remove([1, 2, 3]);
268    /// await composite_table.remove([["AAPL", "Tech"], ["MSFT", "Tech"]]);
269    /// ```
270    #[wasm_bindgen]
271    pub async fn remove(&self, value: &JsValue, options: Option<JsUpdateOptions>) -> ApiResult<()> {
272        let options = options
273            .into_serde_ext::<Option<UpdateOptions>>()?
274            .unwrap_or_default();
275
276        let input = UpdateData::from_js_value(value, options.format)?;
277        self.0.remove(input).await?;
278        Ok(())
279    }
280
281    /// Replace all rows in this [`Table`] with the input data, coerced to this
282    /// [`Table`]'s existing [`perspective_client::Schema`], notifying any
283    /// derived [`View`] and [`View::on_update`] callbacks.
284    ///
285    /// Calling [`Table::replace`] is an easy way to replace _all_ the data in a
286    /// [`Table`] without losing any derived [`View`] instances or
287    /// [`View::on_update`] callbacks. [`Table::replace`] does _not_ infer
288    /// data types like [`Client::table`] does, rather it _coerces_ input
289    /// data to the `Schema` like [`Table::update`]. If you need a [`Table`]
290    /// with a different `Schema`, you must create a new one.
291    ///
292    /// # JavaScript Examples
293    ///
294    /// ```javascript
295    /// await table.replace("x,y\n1,2");
296    /// ```
297    #[wasm_bindgen]
298    pub async fn replace(
299        &self,
300        input: &JsValue,
301        options: Option<JsUpdateOptions>,
302    ) -> ApiResult<()> {
303        let options = options
304            .into_serde_ext::<Option<UpdateOptions>>()?
305            .unwrap_or_default();
306
307        let input = UpdateData::from_js_value(input, options.format)?;
308        self.0.replace(input).await?;
309        Ok(())
310    }
311
312    /// Updates the rows of this table and any derived [`View`] instances.
313    ///
314    /// Calling [`Table::update`] will trigger the [`View::on_update`] callbacks
315    /// register to derived [`View`], and the call itself will not resolve until
316    /// _all_ derived [`View`]'s are notified.
317    ///
318    /// When updating a [`Table`] with an `index`, [`Table::update`] supports
319    /// partial updates, by omitting columns from the update data.
320    ///
321    /// # Arguments
322    ///
323    /// - `input` - The input data for this [`Table`]. The schema of a [`Table`]
324    ///   is immutable after creation, so this method cannot be called with a
325    ///   schema.
326    /// - `options` - Options for this update step - see [`UpdateOptions`].
327    ///
328    /// # JavaScript Examples
329    ///
330    /// ```javascript
331    /// await table.update("x,y\n1,2");
332    /// ```
333    #[wasm_bindgen]
334    pub fn update(
335        &self,
336        input: JsTableInitData,
337        options: Option<JsUpdateOptions>,
338    ) -> ApiFuture<()> {
339        let table = self.clone();
340        ApiFuture::new(async move {
341            let options = options
342                .into_serde_ext::<Option<UpdateOptions>>()?
343                .unwrap_or_default();
344
345            let input = UpdateData::from_js_value(&input, options.format)?;
346            Ok(table.0.update(input, options).await?)
347        })
348    }
349
350    /// Create a new [`View`] from this table with a specified
351    /// [`ViewConfigUpdate`].
352    ///
353    /// See [`View`] struct.
354    ///
355    /// # JavaScript Examples
356    ///
357    /// ```javascript
358    /// const view = await table.view({
359    ///     columns: ["Sales"],
360    ///     aggregates: { Sales: "sum" },
361    ///     group_by: ["Region", "Country"],
362    ///     filter: [["Category", "in", ["Furniture", "Technology"]]],
363    /// });
364    /// ```
365    #[wasm_bindgen]
366    pub async fn view(&self, config: Option<JsViewConfig>) -> ApiResult<View> {
367        let config = config
368            .map(|config| js_sys::JSON::stringify(&config))
369            .transpose()?
370            .and_then(|x| x.as_string())
371            .map(|x| serde_json::from_str(x.as_str()))
372            .transpose()?;
373
374        let view = self.0.view(config).await?;
375        Ok(View(view))
376    }
377
378    /// Validate a complete view config against this table and report the
379    /// schema a [`View`] built from it would have, WITHOUT creating one.
380    ///
381    /// # JavaScript Examples
382    ///
383    /// ```javascript
384    /// const verdict = await table.describe({
385    ///     columns: ["Sales", "margin"],
386    ///     group_by: ["Region"],
387    ///     expressions: { margin: '"Profit" / "Sales"' },
388    /// });
389    ///
390    /// if ("view_schema" in verdict) {
391    ///     const view = await table.view(config);
392    /// }
393    /// ```
394    #[wasm_bindgen]
395    pub async fn describe(&self, config: Option<JsViewConfig>) -> ApiResult<JsValue> {
396        let config = config
397            .map(|config| js_sys::JSON::stringify(&config))
398            .transpose()?
399            .and_then(|x| x.as_string())
400            .map(|x| serde_json::from_str(x.as_str()))
401            .transpose()?
402            .unwrap_or_default();
403
404        let verdict: DescribeVerdict = self.0.describe(config).await?.into();
405        Ok(JsValue::from_serde_ext(&verdict)?)
406    }
407
408    /// Validates the given expressions.
409    #[wasm_bindgen]
410    pub async fn validate_expressions(&self, exprs: &JsValue) -> ApiResult<JsValue> {
411        let exprs = JsValue::into_serde_ext::<Expressions>(exprs.clone())?;
412        let columns = self.0.validate_expressions(exprs).await?;
413        Ok(JsValue::from_serde_ext(&columns)?)
414    }
415}