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