perspective_client/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 std::collections::HashMap;
14use std::fmt::Display;
15
16use serde::{Deserialize, Serialize};
17use ts_rs::TS;
18
19use crate::assert_table_api;
20use crate::client::{Client, Features};
21use crate::config::{Expressions, ViewConfigUpdate};
22use crate::proto::make_table_req::MakeTableOptions;
23use crate::proto::make_table_req::make_table_options::MakeTableType;
24use crate::proto::request::ClientReq;
25use crate::proto::response::ClientResp;
26use crate::proto::*;
27use crate::table_data::UpdateData;
28use crate::utils::*;
29use crate::view::View;
30
31pub type Schema = HashMap<String, ColumnType>;
32
33/// The format to interpret data preovided to [`Client::table`].
34///
35/// When serialized, these values are `"csv"`, `"json"`, `"columns"`, `"arrow"`
36/// and `"ndjson"`.
37#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
38pub enum TableReadFormat {
39 #[serde(rename = "csv")]
40 Csv,
41
42 #[serde(rename = "json")]
43 JsonString,
44
45 #[serde(rename = "columns")]
46 ColumnsString,
47
48 #[serde(rename = "arrow")]
49 Arrow,
50
51 #[serde(rename = "ndjson")]
52 Ndjson,
53}
54
55impl TableReadFormat {
56 pub fn parse(value: Option<String>) -> Result<Option<Self>, String> {
57 Ok(match value.as_deref() {
58 Some("csv") => Some(TableReadFormat::Csv),
59 Some("json") => Some(TableReadFormat::JsonString),
60 Some("columns") => Some(TableReadFormat::ColumnsString),
61 Some("arrow") => Some(TableReadFormat::Arrow),
62 Some("ndjson") => Some(TableReadFormat::Ndjson),
63 None => None,
64 Some(x) => return Err(format!("Unknown format \"{x}\"")),
65 })
66 }
67}
68
69/// Options which impact the behavior of [`Client::table`], as well as
70/// subsequent calls to [`Table::update`].
71#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)]
72pub struct TableInitOptions {
73 #[serde(default)]
74 #[ts(optional)]
75 pub name: Option<String>,
76
77 #[serde(default)]
78 #[ts(optional)]
79 pub format: Option<TableReadFormat>,
80
81 /// This [`Table`] should use the column named by the `index` parameter as
82 /// the `index`, which causes [`Table::update`] and [`Client::table`] input
83 /// to either insert or update existing rows based on `index` column
84 /// value equality.
85 #[serde(default)]
86 #[ts(optional)]
87 pub index: Option<String>,
88
89 /// This [`Table`] should be limited to `limit` rows, after which the
90 /// _earliest_ rows will be overwritten (where _earliest_ is defined as
91 /// relative to insertion order).
92 #[serde(default)]
93 #[ts(optional)]
94 pub limit: Option<u32>,
95
96 /// Back this [`Table`]'s canonical data with the on-disk storage backend
97 /// instead of memory. On native targets this is a memory-mapped file; on
98 /// WASM it is OPFS (Worker only). Defaults to in-memory.
99 #[serde(default)]
100 #[ts(optional)]
101 pub page_to_disk: Option<bool>,
102}
103
104impl TableInitOptions {
105 pub fn set_name<D: Display>(&mut self, name: D) {
106 self.name = Some(format!("{name}"))
107 }
108}
109
110impl TryFrom<TableOptions> for MakeTableOptions {
111 type Error = ClientError;
112
113 fn try_from(value: TableOptions) -> Result<Self, Self::Error> {
114 let page_to_disk = value.page_to_disk;
115 Ok(MakeTableOptions {
116 page_to_disk,
117 make_table_type: match value {
118 TableOptions {
119 index: Some(_),
120 limit: Some(_),
121 ..
122 } => Err(ClientError::BadTableOptions)?,
123 TableOptions {
124 index: Some(index), ..
125 } => Some(MakeTableType::MakeIndexTable(index)),
126 TableOptions {
127 limit: Some(limit), ..
128 } => Some(MakeTableType::MakeLimitTable(limit)),
129 _ => None,
130 },
131 })
132 }
133}
134
135#[derive(Clone, Debug)]
136pub(crate) struct TableOptions {
137 pub index: Option<String>,
138 pub limit: Option<u32>,
139 pub page_to_disk: Option<bool>,
140}
141
142impl From<TableInitOptions> for TableOptions {
143 fn from(value: TableInitOptions) -> Self {
144 TableOptions {
145 index: value.index,
146 limit: value.limit,
147 page_to_disk: value.page_to_disk,
148 }
149 }
150}
151
152/// Options for [`Client::join`].
153#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)]
154pub struct JoinOptions {
155 #[serde(default)]
156 #[ts(optional)]
157 pub join_type: Option<crate::proto::JoinType>,
158
159 #[serde(default)]
160 #[ts(optional)]
161 pub name: Option<String>,
162
163 #[serde(default)]
164 #[ts(optional)]
165 pub right_on: Option<String>,
166}
167
168/// Options for [`Table::delete`].
169#[derive(Clone, Debug, Default, Deserialize, TS)]
170pub struct DeleteOptions {
171 pub lazy: bool,
172}
173
174/// Options for [`Table::update`].
175#[derive(Clone, Debug, Default, Deserialize, Serialize, TS)]
176pub struct UpdateOptions {
177 pub port_id: Option<u32>,
178 pub format: Option<TableReadFormat>,
179}
180
181/// Result of a call to [`Table::validate_expressions`], containing a schema
182/// for valid expressions and error messages for invalid ones.
183#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
184pub struct ExprValidationResult {
185 pub expression_schema: Schema,
186 pub errors: HashMap<String, table_validate_expr_resp::ExprValidationError>,
187 pub expression_alias: HashMap<String, String>,
188}
189
190/// [`Table`] is Perspective's columnar data frame, analogous to a Pandas/Polars
191/// `DataFrame` or Apache Arrow, supporting append & in-place updates, removal
192/// by index, and update notifications.
193///
194/// A [`Table`] contains columns, each of which have a unique name, are strongly
195/// and consistently typed, and contains rows of data conforming to the column's
196/// type. Each column in a [`Table`] must have the same number of rows, though
197/// not every row must contain data; null-values are used to indicate missing
198/// values in the dataset. The schema of a [`Table`] is _immutable after
199/// creation_, which means the column names and data types cannot be changed
200/// after the [`Table`] has been created. Columns cannot be added or deleted
201/// after creation either, but a [`View`] can be used to select an arbitrary set
202/// of columns from the [`Table`].
203#[derive(Clone)]
204pub struct Table {
205 name: String,
206 client: Client,
207 options: TableOptions,
208
209 /// If this table is constructed from a View, the view's on_update callback
210 /// is wired into this table. So, we store the token to clean it up properly
211 /// on destruction.
212 pub(crate) view_update_token: Option<u32>,
213}
214
215assert_table_api!(Table);
216
217impl PartialEq for Table {
218 fn eq(&self, other: &Self) -> bool {
219 self.name == other.name && self.client == other.client
220 }
221}
222
223impl Table {
224 pub(crate) fn new(name: String, client: Client, options: TableOptions) -> Self {
225 Table {
226 name,
227 client,
228 options,
229 view_update_token: None,
230 }
231 }
232
233 fn client_message(&self, req: ClientReq) -> Request {
234 Request {
235 msg_id: self.client.gen_id(),
236 entity_id: self.name.clone(),
237 client_req: Some(req),
238 }
239 }
240
241 /// Get a copy of the [`Client`] this [`Table`] came from.
242 pub fn get_client(&self) -> Client {
243 self.client.clone()
244 }
245
246 /// Get a metadata dictionary of the `perspective_server::Server`'s
247 /// features, which is (currently) implementation specific, but there is
248 /// only one implementation.
249 pub async fn get_features(&self) -> ClientResult<Features> {
250 self.client.get_features().await
251 }
252
253 /// Returns the name of the index column for the table.
254 ///
255 /// # Examples
256 ///
257 /// ```no_run
258 /// # use perspective_client::{Client, TableData, TableInitOptions, UpdateData};
259 /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
260 /// # let client: Client = todo!();
261 /// let options = TableInitOptions {
262 /// index: Some("x".to_string()),
263 /// ..TableInitOptions::default()
264 /// };
265 /// let data = TableData::Update(UpdateData::Csv("x,y\n1,2\n3,4".into()));
266 /// let table = client.table(data, options).await?;
267 /// let index = table.get_index();
268 /// # Ok(()) }
269 /// ```
270 pub fn get_index(&self) -> Option<String> {
271 self.options.index.as_ref().map(|index| index.to_owned())
272 }
273
274 /// Returns the user-specified row limit for this table.
275 pub fn get_limit(&self) -> Option<u32> {
276 self.options.limit.as_ref().map(|limit| *limit)
277 }
278
279 /// Returns the user-specified name for this table, or the auto-generated
280 /// name if a name was not specified when the table was created.
281 pub fn get_name(&self) -> &str {
282 self.name.as_str()
283 }
284
285 /// Removes all the rows in the [`Table`], but preserves everything else
286 /// including the schema, index, and any callbacks or registered
287 /// [`View`] instances.
288 ///
289 /// Calling [`Table::clear`], like [`Table::update`] and [`Table::remove`],
290 /// will trigger an update event to any registered listeners via
291 /// [`View::on_update`].
292 pub async fn clear(&self) -> ClientResult<()> {
293 self.replace(UpdateData::JsonRows("[]".to_owned())).await
294 }
295
296 /// Delete this [`Table`] and cleans up associated resources.
297 ///
298 /// [`Table`]s do not stop consuming resources or processing updates when
299 /// they are garbage collected in their host language - you must call
300 /// this method to reclaim these.
301 ///
302 /// # Arguments
303 ///
304 /// - `options` An options dictionary.
305 /// - `lazy` Whether to delete this [`Table`] _lazily_. When false (the
306 /// default), the delete will occur immediately, assuming it has no
307 /// [`View`] instances registered to it (which must be deleted first,
308 /// otherwise this method will throw an error). When true, the
309 /// [`Table`] will only be marked for deltion once its [`View`]
310 /// dependency count reaches 0.
311 ///
312 /// # Examples
313 ///
314 /// ```no_run
315 /// # use perspective_client::{Client, DeleteOptions, TableData, TableInitOptions, UpdateData};
316 /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
317 /// # let client: Client = todo!();
318 /// let opts = TableInitOptions::default();
319 /// let data = TableData::Update(UpdateData::Csv("x,y\n1,2\n3,4".into()));
320 /// let table = client.table(data, opts).await?;
321 ///
322 /// // ...
323 ///
324 /// table.delete(DeleteOptions::default()).await?;
325 /// # Ok(()) }
326 /// ```
327 pub async fn delete(&self, options: DeleteOptions) -> ClientResult<()> {
328 let msg = self.client_message(ClientReq::TableDeleteReq(TableDeleteReq {
329 is_immediate: !options.lazy,
330 }));
331
332 match self.client.oneshot(&msg).await? {
333 ClientResp::TableDeleteResp(_) => Ok(()),
334 resp => Err(resp.into()),
335 }
336 }
337
338 /// Returns the column names of this [`Table`] in "natural" order (the
339 /// ordering implied by the input format).
340 ///
341 /// # Examples
342 ///
343 /// ```no_run
344 /// # use perspective_client::Table;
345 /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
346 /// # let table: Table = todo!();
347 /// let columns = table.columns().await?;
348 /// # Ok(()) }
349 /// ```
350 pub async fn columns(&self) -> ClientResult<Vec<String>> {
351 let msg = self.client_message(ClientReq::TableSchemaReq(TableSchemaReq {}));
352 match self.client.oneshot(&msg).await? {
353 ClientResp::TableSchemaResp(TableSchemaResp { schema }) => Ok(schema
354 .map(|x| x.schema.into_iter().map(|x| x.name.to_owned()).collect())
355 .unwrap()),
356 resp => Err(resp.into()),
357 }
358 }
359
360 /// Returns the number of rows in a [`Table`].
361 pub async fn size(&self) -> ClientResult<usize> {
362 let msg = self.client_message(ClientReq::TableSizeReq(TableSizeReq {}));
363 match self.client.oneshot(&msg).await? {
364 ClientResp::TableSizeResp(TableSizeResp { size }) => Ok(size as usize),
365 resp => Err(resp.into()),
366 }
367 }
368
369 /// Returns a table's [`Schema`], a mapping of column names to column types.
370 ///
371 /// The mapping of a [`Table`]'s column names to data types is referred to
372 /// as a [`Schema`]. Each column has a unique name and a data type, one
373 /// of:
374 ///
375 /// - `"boolean"` - A boolean type
376 /// - `"date"` - A timesonze-agnostic date type (month/day/year)
377 /// - `"datetime"` - A millisecond-precision datetime type in the UTC
378 /// timezone
379 /// - `"float"` - A 64 bit float
380 /// - `"integer"` - A signed 32 bit integer (the integer type supported by
381 /// JavaScript)
382 /// - `"string"` - A [`String`] data type (encoded internally as a
383 /// _dictionary_)
384 ///
385 /// Note that all [`Table`] columns are _nullable_, regardless of the data
386 /// type.
387 pub async fn schema(&self) -> ClientResult<Schema> {
388 let msg = self.client_message(ClientReq::TableSchemaReq(TableSchemaReq {}));
389 match self.client.oneshot(&msg).await? {
390 ClientResp::TableSchemaResp(TableSchemaResp { schema }) => Ok(schema
391 .map(|x| {
392 x.schema
393 .into_iter()
394 .map(|x| (x.name, ColumnType::try_from(x.r#type).unwrap()))
395 .collect()
396 })
397 .unwrap()),
398 resp => Err(resp.into()),
399 }
400 }
401
402 /// Create a unique channel ID on this [`Table`], which allows
403 /// `View::on_update` callback calls to be associated with the
404 /// `Table::update` which caused them.
405 pub async fn make_port(&self) -> ClientResult<i32> {
406 let msg = self.client_message(ClientReq::TableMakePortReq(TableMakePortReq {}));
407 match self.client.oneshot(&msg).await? {
408 ClientResp::TableMakePortResp(TableMakePortResp { port_id }) => Ok(port_id as i32),
409 _ => Err(ClientError::Unknown("make_port".to_string())),
410 }
411 }
412
413 /// Register a callback which is called exactly once, when this [`Table`] is
414 /// deleted with the [`Table::delete`] method.
415 ///
416 /// [`Table::on_delete`] resolves when the subscription message is sent, not
417 /// when the _delete_ event occurs.
418 pub async fn on_delete(
419 &self,
420 on_delete: Box<dyn Fn() + Send + Sync + 'static>,
421 ) -> ClientResult<u32> {
422 let callback = move |resp: Response| match resp.client_resp {
423 Some(ClientResp::TableOnDeleteResp(_)) => {
424 on_delete();
425 Ok(())
426 },
427 resp => Err(resp.into()),
428 };
429
430 let msg = self.client_message(ClientReq::TableOnDeleteReq(TableOnDeleteReq {}));
431 self.client.subscribe_once(&msg, Box::new(callback)).await?;
432 Ok(msg.msg_id)
433 }
434
435 /// Removes a listener with a given ID, as returned by a previous call to
436 /// [`Table::on_delete`].
437 pub async fn remove_delete(&self, callback_id: u32) -> ClientResult<()> {
438 let msg = self.client_message(ClientReq::TableRemoveDeleteReq(TableRemoveDeleteReq {
439 id: callback_id,
440 }));
441
442 match self.client.oneshot(&msg).await? {
443 ClientResp::TableRemoveDeleteResp(_) => Ok(()),
444 resp => Err(resp.into()),
445 }
446 }
447
448 /// Removes rows from this [`Table`] with the `index` column values
449 /// supplied.
450 ///
451 /// # Arguments
452 ///
453 /// - `indices` - A list of `index` column values for rows that should be
454 /// removed.
455 ///
456 /// # Examples
457 ///
458 /// ```no_run
459 /// # use perspective_client::{Table, UpdateData};
460 /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
461 /// # let table: Table = todo!();
462 /// table
463 /// .remove(UpdateData::Csv("index\n1\n2\n3".into()))
464 /// .await?;
465 /// # Ok(()) }
466 /// ```
467 pub async fn remove(&self, input: UpdateData) -> ClientResult<()> {
468 let msg = self.client_message(ClientReq::TableRemoveReq(TableRemoveReq {
469 data: Some(input.into()),
470 }));
471
472 match self.client.oneshot(&msg).await? {
473 ClientResp::TableRemoveResp(_) => Ok(()),
474 resp => Err(resp.into()),
475 }
476 }
477
478 /// Replace all rows in this [`Table`] with the input data, coerced to this
479 /// [`Table`]'s existing [`Schema`], notifying any derived [`View`] and
480 /// [`View::on_update`] callbacks.
481 ///
482 /// Calling [`Table::replace`] is an easy way to replace _all_ the data in a
483 /// [`Table`] without losing any derived [`View`] instances or
484 /// [`View::on_update`] callbacks. [`Table::replace`] does _not_ infer
485 /// data types like [`Client::table`] does, rather it _coerces_ input
486 /// data to the `Schema` like [`Table::update`]. If you need a [`Table`]
487 /// with a different `Schema`, you must create a new one.
488 ///
489 /// # Examples
490 ///
491 /// ```no_run
492 /// # use perspective_client::{Table, UpdateData};
493 /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
494 /// # let table: Table = todo!();
495 /// let data = UpdateData::Csv("x,y\n1,2".into());
496 /// table.replace(data).await?;
497 /// # Ok(()) }
498 /// ```
499 pub async fn replace(&self, input: UpdateData) -> ClientResult<()> {
500 let msg = self.client_message(ClientReq::TableReplaceReq(TableReplaceReq {
501 data: Some(input.into()),
502 }));
503
504 match self.client.oneshot(&msg).await? {
505 ClientResp::TableReplaceResp(_) => Ok(()),
506 resp => Err(resp.into()),
507 }
508 }
509
510 /// Updates the rows of this table and any derived [`View`] instances.
511 ///
512 /// Calling [`Table::update`] will trigger the [`View::on_update`] callbacks
513 /// register to derived [`View`], and the call itself will not resolve until
514 /// _all_ derived [`View`]'s are notified.
515 ///
516 /// When updating a [`Table`] with an `index`, [`Table::update`] supports
517 /// partial updates, by omitting columns from the update data.
518 ///
519 /// # Arguments
520 ///
521 /// - `input` - The input data for this [`Table`]. The schema of a [`Table`]
522 /// is immutable after creation, so this method cannot be called with a
523 /// schema.
524 /// - `options` - Options for this update step - see [`UpdateOptions`].
525 ///
526 /// # Examples
527 ///
528 /// ```no_run
529 /// # use perspective_client::{Table, UpdateData, UpdateOptions};
530 /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
531 /// # let table: Table = todo!();
532 /// let data = UpdateData::Csv("x,y\n1,2".into());
533 /// let opts = UpdateOptions::default();
534 /// table.update(data, opts).await?;
535 /// # Ok(()) }
536 /// ```
537 pub async fn update(&self, input: UpdateData, options: UpdateOptions) -> ClientResult<()> {
538 let msg = self.client_message(ClientReq::TableUpdateReq(TableUpdateReq {
539 data: Some(input.into()),
540 port_id: options.port_id.unwrap_or(0),
541 }));
542
543 match self.client.oneshot(&msg).await? {
544 ClientResp::TableUpdateResp(_) => Ok(()),
545 resp => Err(resp.into()),
546 }
547 }
548
549 /// Validates the given expressions.
550 pub async fn validate_expressions(
551 &self,
552 expressions: Expressions,
553 ) -> ClientResult<ExprValidationResult> {
554 let msg = self.client_message(ClientReq::TableValidateExprReq(TableValidateExprReq {
555 column_to_expr: expressions.0,
556 }));
557
558 match self.client.oneshot(&msg).await? {
559 ClientResp::TableValidateExprResp(result) => Ok(ExprValidationResult {
560 errors: result.errors,
561 expression_alias: result.expression_alias,
562 expression_schema: result
563 .expression_schema
564 .into_iter()
565 .map(|(x, y)| (x, ColumnType::try_from(y).unwrap()))
566 .collect(),
567 }),
568 resp => Err(resp.into()),
569 }
570 }
571
572 /// Create a new [`View`] from this table with a specified
573 /// [`ViewConfigUpdate`].
574 ///
575 /// See [`View`] struct.
576 ///
577 /// # Examples
578 ///
579 /// ```no_run
580 /// # use std::collections::HashMap;
581 /// # use perspective_client::Table;
582 /// # use perspective_client::config::*;
583 /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
584 /// # let table: Table = todo!();
585 /// let view = table
586 /// .view(Some(ViewConfigUpdate {
587 /// columns: Some(vec![Some("Sales".into())]),
588 /// aggregates: Some(HashMap::from_iter(vec![("Sales".into(), "sum".into())])),
589 /// group_by: Some(vec!["Region".into(), "Country".into()]),
590 /// filter: Some(vec![Filter::new("Category", "in", &[
591 /// "Furniture",
592 /// "Technology",
593 /// ])]),
594 /// ..ViewConfigUpdate::default()
595 /// }))
596 /// .await?;
597 /// # Ok(()) }
598 /// ```
599 pub async fn view(&self, config: Option<ViewConfigUpdate>) -> ClientResult<View> {
600 let view_name = randid();
601 let msg = Request {
602 msg_id: self.client.gen_id(),
603 entity_id: self.name.clone(),
604 client_req: ClientReq::TableMakeViewReq(TableMakeViewReq {
605 view_id: view_name.clone(),
606 config: config.map(|x| x.into()),
607 })
608 .into(),
609 };
610
611 match self.client.oneshot(&msg).await? {
612 ClientResp::TableMakeViewResp(TableMakeViewResp { view_id })
613 if view_id == view_name =>
614 {
615 Ok(View::new(view_name, self.client.clone()))
616 },
617 resp => Err(resp.into()),
618 }
619 }
620}