chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:25:07 +08:00
commit a26e856398
1681 changed files with 296950 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
# ┃ This file is part of the Perspective library, distributed under the terms ┃
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
[package]
name = "perspective-client"
version = "4.5.2"
authors = ["Andrew Stein <steinlink@gmail.com>"]
edition = "2024"
description = "A data visualization and analytics component, especially well-suited for large and/or streaming datasets."
repository = "https://github.com/perspective-dev/perspective"
license = "Apache-2.0"
homepage = "https://perspective-dev.github.io"
keywords = []
links = "perspective_client"
include = [
"src/**/*",
"Cargo.toml",
"package.json",
"build.rs",
"docs/**/*",
"perspective.proto",
]
[features]
default = []
# Removes the `Send` restriction from core `Future` traits, allowing easier
# integration with wasm while respecting compat with rust-analyzer and metadata.
sendable = []
# Should the project build the `proto.rs` via protoc from source or assume it
# already exists?
generate-proto = []
# Should the project build protobuf from source? If not, building this crate
# requires PROTOC be set in the environment
protobuf-src = ["dep:protobuf-src"]
# When generating metadata, we can't rely on its existence - so enable this
# to skip metadata generation. This currently only affects docs.
omit_metadata = []
# Should `talc` be used as a global allocator? Allows enhanced runtime metrics
# via `Client::system_info`.
talc-allocator = ["talc"]
[lib]
crate-type = ["rlib"]
path = "src/rust/lib.rs"
[build-dependencies]
prost-build = { version = "0.12.3" }
# https://github.com/abseil/abseil-cpp/issues/1241#issuecomment-2138616329
protobuf-src = { version = "2.1.1", optional = true }
[dependencies]
arrow-array = { version = "57.3.0", default-features = false }
arrow-ipc = { version = "57.3.0", default-features = false }
arrow-schema = { version = "57.3.0", default-features = false }
arrow-select = { version = "57.3.0", default-features = false }
async-lock = { version = "2.5.0" }
futures = { version = "0.3.28" }
indexmap = { version = "2.2.6", features = ["serde"] }
itertools = { version = "0.10.1" }
paste = { version = "1.0.12" }
prost-types = { version = "0.12.3" }
# `rand` dependency that needs features unified.
getrandom = { version = "0", features = ["wasm_js"] }
num-traits = "0.2.19"
rand = { version = ">=0.9,<1" }
serde = { version = "1.0", features = ["derive"] }
serde_bytes = { version = "0.11" }
serde_json = { version = "1.0.107", features = ["raw_value", "preserve_order"] }
talc = { version = "4.4.3", features = ["counters"], optional = true }
thiserror = { version = "1.0.55" }
tracing = { version = "0.1.36" }
[dependencies.prost]
version = "0.12.3"
default-features = false
features = ["prost-derive", "std"]
[dependencies.ts-rs]
version = "11.0.1"
features = ["serde-json-impl", "no-serde-warnings", "import-esm"]
+92
View File
@@ -0,0 +1,92 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::io::Result;
use std::path::Path;
fn prost_build() -> Result<()> {
println!(
"cargo::metadata=PROTO_PATH={}",
std::env::var("CARGO_MANIFEST_DIR").unwrap()
);
// This source file is included at `publish` time, but not `build` time
// because it is initially generated from the `perspective.proto` definition
// in the source.
if std::env::var("CARGO_FEATURE_GENERATE_PROTO").is_ok() {
println!("cargo:warning=MESSAGE Building in development mode");
// let root_dir_env = std::env::var("PSP_ROOT_DIR").expect("Must set
// PSP_ROOT_DIR"); let root_dir = Path::new(root_dir_env.as_str());
// let proto_file = Path::join(root_dir, "cpp/protos/perspective.proto");
let proto_file = Path::new("./perspective.proto");
let include_path = proto_file
.parent()
.expect("Couldn't determine parent directory of proto_file")
.to_path_buf();
println!("cargo:rerun-if-changed={}", proto_file.to_str().unwrap());
// prost_build reads PROTOC from the environment. When the `protobuf-src`
// feature is enabled, the build script sets PROTOC to the one built by
// that crate. When protobuf-src is disabled, builders must set PROTOC
// in the environment to a protocol buffer compiler.
#[cfg(feature = "protobuf-src")]
unsafe {
std::env::set_var("PROTOC", protobuf_src::protoc())
};
#[cfg(not(feature = "protobuf-src"))]
if std::env::var("PROTOC").is_err() {
panic!(
"generate-proto is enabled and protobuf-src is disabled. PROTOC must be set in \
the environment to the path of a protocol buffer compiler"
)
}
prost_build::Config::new()
// .bytes(["ViewToArrowResp.arrow", "from_arrow"])
.type_attribute("ViewOnUpdateResp", "#[derive(ts_rs::TS)]")
.field_attribute("ViewOnUpdateResp.delta", "#[ts(as = \"Vec::<u8>\")]")
.field_attribute("ViewOnUpdateResp.delta", "#[serde(with = \"serde_bytes\")]")
.type_attribute("ColumnType", "#[derive(ts_rs::TS)]")
.type_attribute(
"JoinType",
"#[derive(serde::Deserialize, ts_rs::TS)] #[serde(rename_all = \"snake_case\")]",
)
.field_attribute("ViewToArrowResp.arrow", "#[serde(skip)]")
.field_attribute("from_arrow", "#[serde(skip)]")
.type_attribute(".", "#[derive(serde::Serialize)]")
.type_attribute("ViewPort", "#[derive(serde::Deserialize)]")
.type_attribute("ViewDimensionsResp", "#[derive(serde::Deserialize)]")
.type_attribute("TableValidateExprResp", "#[derive(serde::Deserialize)]")
.type_attribute(
"ColumnType",
"#[derive(serde::Deserialize)] #[serde(rename_all = \"snake_case\")]",
)
.type_attribute("ExprValidationError", "#[derive(serde::Deserialize)]")
.compile_protos(&[proto_file], &[include_path])
.unwrap();
std::fs::copy(
std::env::var("OUT_DIR").unwrap() + "/perspective.proto.rs",
"src/rust/proto.rs",
)?;
std::fs::remove_file(std::env::var("OUT_DIR").unwrap() + "/perspective.proto.rs")?;
}
Ok(())
}
fn main() -> Result<()> {
prost_build()?;
Ok(())
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@perspective-dev/client-rust",
"version": "4.5.2",
"description": "",
"private": true,
"repository": {
"type": "git",
"url": "https://github.com/perspective-dev/perspective"
},
"type": "module",
"license": "Apache-2.0",
"scripts": {
"clean": "rm src/rust/proto.rs"
}
}
+584
View File
@@ -0,0 +1,584 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
syntax = "proto3";
import "google/protobuf/struct.proto";
package perspective.proto;
option optimize_for = LITE_RUNTIME;
////////////////////////////////////////////////////////////////////////////////
//
// Common
enum StatusCode {
SERVER_ERROR = 0;
VIEW_NOT_FOUND = 1;
TRANSPORT_ERROR = 2;
}
// Recoverable, user-readable error reporting from the engine.
message ServerError {
string message = 1;
StatusCode status_code = 2;
}
message Schema {
repeated KeyTypePair schema = 1;
message KeyTypePair {
string name = 1;
ColumnType type = 2;
}
}
// The data type constructors Perspective supports.
message MakeTableData {
oneof data {
Schema from_schema = 1;
string from_csv = 2;
bytes from_arrow = 3;
string from_rows = 4;
string from_cols = 5;
string from_view = 6;
string from_ndjson = 7;
};
}
// Filter type scalars - this is _not_ the same as a Columns scalar, as this
// value is used in the view config and must be JSON safe!
message Scalar {
oneof scalar {
bool bool = 1;
// int64 date = 2; // TODO these are the wrong type
// int64 datetime = 3;
double float = 4;
// int32 int = 5;
string string = 6;
google.protobuf.NullValue null = 7;
}
}
// View types
enum ColumnType {
STRING = 0;
DATE = 1;
DATETIME = 2;
INTEGER = 3;
FLOAT = 4;
BOOLEAN = 5;
}
// Options for requresting a slice of data, starting with the rectangular
// viewport.
message ViewPort {
optional uint32 start_row = 1;
optional uint32 start_col = 2;
optional uint32 end_row = 3;
optional uint32 end_col = 4;
optional bool emit_legacy_row_path_names = 5;
// optional bool id = 5;
// optional bool index = 3;
// optional bool formatted = 6;
// optional bool leaves_only = 7;
// optional bool compression = 3;
}
// TODO This belongs in features
enum SortOp {
SORT_NONE = 0;
SORT_ASC = 1;
SORT_DESC = 2;
SORT_COL_ASC = 3;
SORT_COL_DESC = 4;
SORT_ASC_ABS = 5;
SORT_DESC_ABS = 6;
SORT_COL_ASC_ABS = 7;
SORT_COL_DESC_ABS = 8;
}
////////////////////////////////////////////////////////////////////////////////
//
// RPC
message Request {
uint32 msg_id = 1;
string entity_id = 2;
oneof client_req {
// Minimum Virtual API (theoretical).
GetFeaturesReq get_features_req = 3;
GetHostedTablesReq get_hosted_tables_req = 4;
RemoveHostedTablesUpdateReq remove_hosted_tables_update_req = 37;
TableMakePortReq table_make_port_req = 5;
TableMakeViewReq table_make_view_req = 6;
TableSchemaReq table_schema_req = 7;
TableSizeReq table_size_req = 8;
TableValidateExprReq table_validate_expr_req = 9;
ViewColumnPathsReq view_column_paths_req = 10;
ViewDeleteReq view_delete_req = 11;
ViewDimensionsReq view_dimensions_req = 12;
ViewExpressionSchemaReq view_expression_schema_req = 13;
ViewGetConfigReq view_get_config_req = 14;
ViewSchemaReq view_schema_req = 15;
ViewToArrowReq view_to_arrow_req = 16;
// Optional (we can enable real-time/autocomplete/etc with these, but
// not required).
ServerSystemInfoReq server_system_info_req = 17;
ViewCollapseReq view_collapse_req = 18;
ViewExpandReq view_expand_req = 19;
ViewGetMinMaxReq view_get_min_max_req = 20;
ViewOnUpdateReq view_on_update_req = 21;
ViewRemoveOnUpdateReq view_remove_on_update_req = 22;
ViewSetDepthReq view_set_depth_req = 23;
ViewToColumnsStringReq view_to_columns_string_req = 24;
ViewToCSVReq view_to_csv_req = 25;
ViewToRowsStringReq view_to_rows_string_req = 26;
ViewToNdjsonStringReq view_to_ndjson_string_req = 36;
// External (we don't need these for viewer, but the developer may).
MakeTableReq make_table_req = 27;
TableDeleteReq table_delete_req = 28;
TableOnDeleteReq table_on_delete_req = 29;
TableRemoveDeleteReq table_remove_delete_req = 30;
TableRemoveReq table_remove_req = 31;
TableReplaceReq table_replace_req = 32;
TableUpdateReq table_update_req = 33;
ViewOnDeleteReq view_on_delete_req = 34;
ViewRemoveDeleteReq view_remove_delete_req = 35;
MakeJoinTableReq make_join_table_req = 38;
}
}
message Response {
uint32 msg_id = 1;
string entity_id = 2;
oneof client_resp {
GetFeaturesResp get_features_resp = 3;
GetHostedTablesResp get_hosted_tables_resp = 4;
RemoveHostedTablesUpdateResp remove_hosted_tables_update_resp = 37;
TableMakePortResp table_make_port_resp = 5;
TableMakeViewResp table_make_view_resp = 6;
TableSchemaResp table_schema_resp = 7;
TableSizeResp table_size_resp = 8;
TableValidateExprResp table_validate_expr_resp = 9;
ViewColumnPathsResp view_column_paths_resp = 10;
ViewDeleteResp view_delete_resp = 11;
ViewDimensionsResp view_dimensions_resp = 12;
ViewExpressionSchemaResp view_expression_schema_resp = 13;
ViewGetConfigResp view_get_config_resp = 14;
ViewSchemaResp view_schema_resp = 15;
ViewToArrowResp view_to_arrow_resp = 16;
ServerSystemInfoResp server_system_info_resp = 17;
ViewCollapseResp view_collapse_resp = 18;
ViewExpandResp view_expand_resp = 19;
ViewGetMinMaxResp view_get_min_max_resp = 20;
ViewOnUpdateResp view_on_update_resp = 21;
ViewRemoveOnUpdateResp view_remove_on_update_resp = 22;
ViewSetDepthResp view_set_depth_resp = 23;
ViewToColumnsStringResp view_to_columns_string_resp = 24;
ViewToCSVResp view_to_csv_resp = 25;
ViewToRowsStringResp view_to_rows_string_resp = 26;
ViewToNdjsonStringResp view_to_ndjson_string_resp = 36;
MakeTableResp make_table_resp = 27;
TableDeleteResp table_delete_resp = 28;
TableOnDeleteResp table_on_delete_resp = 29;
TableRemoveDeleteResp table_remove_delete_resp = 30;
TableRemoveResp table_remove_resp = 31;
TableReplaceResp table_replace_resp = 32;
TableUpdateResp table_update_resp = 33;
ViewOnDeleteResp view_on_delete_resp = 34;
ViewRemoveDeleteResp view_remove_delete_resp = 35;
MakeJoinTableResp make_join_table_resp = 38;
ServerError server_error = 50;
}
}
////////////////////////////////////////////////////////////////////////////////
//
// Virtual API
enum GroupRollupMode {
ROLLUP = 0;
FLAT = 1;
TOTAL = 2;
}
// Informs the client of the feature set, e.g. what to expect in the
// `ViewConfig` message.
message GetFeaturesReq {}
message GetFeaturesResp {
bool group_by = 1;
bool split_by = 2;
bool expressions = 3;
bool on_update = 4;
bool sort = 5;
map<uint32, ColumnTypeOptions> filter_ops = 6;
map<uint32, AggregateOptions> aggregates = 7;
repeated GroupRollupMode group_rollup_mode = 8;
message ColumnTypeOptions {
repeated string options = 1;
}
message AggregateOptions {
repeated AggregateArgs aggregates = 1;
}
message AggregateArgs {
string name = 1;
repeated ColumnType args = 2;
}
}
// `Client::get_hosted_tables`
message GetHostedTablesReq {
bool subscribe = 1;
}
message GetHostedTablesResp {
repeated HostedTable table_infos = 1;
}
message HostedTable {
string entity_id = 1;
optional string index = 2;
optional uint32 limit = 3;
}
message RemoveHostedTablesUpdateReq {
uint32 id = 1;
}
message RemoveHostedTablesUpdateResp {}
// `Table::size`
message TableSizeReq {}
message TableSizeResp {
uint32 size = 2;
}
// `Table::schema`
message TableSchemaReq {}
message TableSchemaResp {
Schema schema = 1;
}
// `Table::validate_expressions`
// TODO: This should be just `validate()`
message TableValidateExprReq {
map<string, string> column_to_expr = 1;
}
message TableValidateExprResp {
map<string, ColumnType> expression_schema = 1;
map<string, ExprValidationError> errors = 2;
map<string, string> expression_alias = 3;
message ExprValidationError {
string error_message = 1;
uint32 line = 2;
uint32 column = 3;
}
}
// `Table::view`
message TableMakeViewReq {
string view_id = 1;
ViewConfig config = 2;
}
message TableMakeViewResp {
string view_id = 1;
}
// `View::schema`
message ViewSchemaReq {}
message ViewSchemaResp {
map<string, ColumnType> schema = 1;
}
// `View::dimensions`
message ViewDimensionsReq {}
message ViewDimensionsResp {
uint32 num_table_rows = 1;
uint32 num_table_columns = 2;
uint32 num_view_rows = 3;
uint32 num_view_columns = 4;
}
// `View::get_config`
message ViewGetConfigReq {}
message ViewGetConfigResp {
ViewConfig config = 1;
}
////////////////////////////////////////////////////////////////////////////////
// `Client::table`.
message MakeTableReq {
MakeTableData data = 1;
optional MakeTableOptions options = 2;
message MakeTableOptions {
oneof make_table_type {
string make_index_table = 1;
uint32 make_limit_table = 2;
};
// Back this Table's canonical data with the on-disk storage backend
// (memory-mapped file on native; OPFS on WASM) instead of memory.
// Orthogonal to `make_table_type`, so it is a standalone field.
optional bool page_to_disk = 3;
}
}
message MakeTableResp {}
enum JoinType {
INNER = 0;
LEFT = 1;
OUTER = 2;
}
// `Client::join` — create a read-only table from a JOIN of two tables.
message MakeJoinTableReq {
string left_table_id = 1;
string right_table_id = 2;
string on_column = 3;
JoinType join_type = 4;
string right_on_column = 5;
}
message MakeJoinTableResp {}
// `Table::delete`
message TableDeleteReq {
bool is_immediate = 1;
}
message TableDeleteResp {}
// `Table::on_delete`
message TableOnDeleteReq {}
message TableOnDeleteResp {}
// `Table::make_port`
message TableMakePortReq {}
message TableMakePortResp {
uint32 port_id = 1;
}
// `Table::remove_delete`
message TableRemoveDeleteReq {
uint32 id = 1;
}
message TableRemoveDeleteResp {}
// `Table::update`
message TableUpdateReq {
MakeTableData data = 1;
uint32 port_id = 2;
}
message TableUpdateResp {}
// `Table::replace`
message TableReplaceReq {
MakeTableData data = 1;
}
message TableReplaceResp {}
// `Table::remove`
message TableRemoveReq {
MakeTableData data = 1;
}
message TableRemoveResp {}
message ViewOnUpdateReq {
enum Mode {
ROW = 0;
}
optional Mode mode = 1;
}
message ViewOnUpdateResp {
optional bytes delta = 1;
uint32 port_id = 2;
}
message ViewOnDeleteReq {}
message ViewOnDeleteResp {}
message ViewRemoveDeleteReq {
uint32 id = 1;
}
message ViewRemoveDeleteResp {}
message ViewToColumnsStringReq {
ViewPort viewport = 1;
optional bool id = 2;
optional bool index = 3;
optional bool formatted = 4;
}
message ViewToColumnsStringResp {
string json_string = 1;
}
message ViewToRowsStringReq {
ViewPort viewport = 1;
optional bool id = 2;
optional bool index = 3;
optional bool formatted = 4;
}
message ViewToRowsStringResp {
string json_string = 1;
}
message ViewToNdjsonStringReq {
ViewPort viewport = 1;
optional bool id = 2;
optional bool index = 3;
optional bool formatted = 4;
}
message ViewToNdjsonStringResp {
string ndjson_string = 1;
}
message ViewToArrowReq {
ViewPort viewport = 1;
optional string compression = 2;
}
message ViewToArrowResp {
bytes arrow = 1;
}
message ViewColumnPathsReq {
optional uint32 start_col = 1;
optional uint32 end_col = 2;
}
// // TODO This is a better paths representations but its not compatible with
// // the legacy API. Let's do this when we can fix the API.
// message ColumnPath {
// repeated string path = 1;
// }
message ViewColumnPathsResp {
repeated string paths = 1;
// repeated ColumnPath paths = 1;
}
message ViewDeleteReq {}
message ViewDeleteResp {}
message ViewGetMinMaxReq {
string column_name = 1;
}
message ViewGetMinMaxResp {
Scalar min = 1;
Scalar max = 2;
}
message ViewExpressionSchemaReq {}
message ViewExpressionSchemaResp {
map<string, ColumnType> schema = 1;
}
message ViewToCSVReq {
ViewPort viewport = 1;
}
message ViewToCSVResp {
string csv = 1;
}
message ViewRemoveOnUpdateReq {
uint32 id = 1;
}
message ViewRemoveOnUpdateResp {}
message ViewCollapseReq {
uint32 row_index = 1;
}
message ViewCollapseResp {
uint32 num_changed = 1;
}
message ViewExpandReq {
uint32 row_index = 1;
}
message ViewExpandResp {
uint32 num_changed = 1;
}
// `View::set_depth`
message ViewSetDepthReq {
uint32 depth = 1;
}
message ViewSetDepthResp {}
message ServerSystemInfoReq {}
message ServerSystemInfoResp {
uint64 heap_size = 1;
uint64 used_size = 2;
uint32 cpu_time = 3;
uint32 cpu_time_epoch = 4;
}
message ViewConfig {
repeated string group_by = 1;
repeated string split_by = 2;
ColumnsUpdate columns = 3;
repeated Filter filter = 4;
repeated Sort sort = 5;
map<string, string> expressions = 6;
map<string, AggList> aggregates = 7;
FilterReducer filter_op = 8;
optional uint32 group_by_depth = 9;
optional GroupRollupMode group_rollup_mode = 10;
message AggList {
repeated string aggregations = 1;
}
message Sort {
string column = 1;
SortOp op = 2;
}
message Filter {
string column = 1;
string op = 2;
repeated Scalar value = 3;
}
enum FilterReducer {
AND = 0;
OR = 1;
}
}
message ColumnsUpdate {
oneof opt_columns {
google.protobuf.NullValue default_columns = 1;
Columns columns = 2;
}
message Columns {
repeated string columns = 1;
}
}
+809
View File
@@ -0,0 +1,809 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::collections::HashMap;
use std::error::Error;
use std::ops::Deref;
use std::sync::Arc;
use async_lock::{Mutex, RwLock};
use futures::Future;
use futures::future::{BoxFuture, LocalBoxFuture, join_all};
use prost::Message;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::proto::request::ClientReq;
use crate::proto::response::ClientResp;
use crate::proto::{
ColumnType, GetFeaturesReq, GetFeaturesResp, GetHostedTablesReq, GetHostedTablesResp,
HostedTable, JoinType, MakeJoinTableReq, MakeTableReq, RemoveHostedTablesUpdateReq, Request,
Response, ServerError, ServerSystemInfoReq,
};
use crate::table::{JoinOptions, Table, TableInitOptions, TableOptions};
use crate::table_data::{TableData, UpdateData};
use crate::table_ref::TableRef;
use crate::utils::*;
use crate::view::{OnUpdateData, ViewWindow};
use crate::{OnUpdateMode, OnUpdateOptions, asyncfn, clone};
/// Metadata about the engine runtime (such as total heap utilization).
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
pub struct SystemInfo<T = u64> {
/// Total available bytes for allocation on the [`Server`].
pub heap_size: T,
/// Bytes allocated for use on the [`Server`].
pub used_size: T,
/// Wall-clock time spent processing requests on the [`Server`], in
/// milliseconds (estimated). This does not properly account for the
/// internal thread pool (which enables column-parallel processing of
/// individual requests).
pub cpu_time: u32,
/// Milliseconds since internal CPU time accumulator was reset.
pub cpu_time_epoch: u32,
/// Timestamp (POSIX) this request was made. This field may be omitted
/// for wasm due to `perspective-client` lacking a dependency on
/// `wasm_bindgen`.
pub timestamp: Option<T>,
/// Total available bytes for allocation on the [`Client`]. This is only
/// available if `trace-allocator` is enabled.
pub client_heap: Option<T>,
/// Bytes allocated for use on the [`Client`]. This is only
/// available if `trace-allocator` is enabled.
pub client_used: Option<T>,
}
impl<U: Copy + 'static> SystemInfo<U> {
/// Convert the numeric representation for `T` to something else, which is
/// useful for JavaScript where there is no `u64` native type.
pub fn cast<T: Copy + 'static>(&self) -> SystemInfo<T>
where
U: num_traits::AsPrimitive<T>,
{
SystemInfo {
heap_size: self.heap_size.as_(),
used_size: self.used_size.as_(),
cpu_time: self.cpu_time,
cpu_time_epoch: self.cpu_time_epoch,
timestamp: self.timestamp.map(|x| x.as_()),
client_heap: self.client_heap.map(|x| x.as_()),
client_used: self.client_used.map(|x| x.as_()),
}
}
}
/// Metadata about what features are supported by the `Server` to which this
/// [`Client`] connects.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Features(Arc<GetFeaturesResp>);
impl Features {
pub fn get_group_rollup_modes(&self) -> Vec<crate::config::GroupRollupMode> {
self.group_rollup_mode
.iter()
.map(|x| {
crate::config::GroupRollupMode::from(
crate::proto::GroupRollupMode::try_from(*x).unwrap(),
)
})
.collect::<Vec<_>>()
}
}
impl Deref for Features {
type Target = GetFeaturesResp;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl GetFeaturesResp {
pub fn default_op(&self, col_type: ColumnType) -> Option<&str> {
self.filter_ops
.get(&(col_type as u32))?
.options
.first()
.map(|x| x.as_str())
}
}
type BoxFn<I, O> = Box<dyn Fn(I) -> O + Send + Sync + 'static>;
type Box2Fn<I, J, O> = Box<dyn Fn(I, J) -> O + Send + Sync + 'static>;
type Subscriptions<C> = Arc<RwLock<HashMap<u32, C>>>;
type OnErrorCallback =
Box2Fn<ClientError, Option<ReconnectCallback>, BoxFuture<'static, Result<(), ClientError>>>;
type OnceCallback = Box<dyn FnOnce(Response) -> ClientResult<()> + Send + Sync + 'static>;
type SendCallback = Arc<
dyn for<'a> Fn(&'a Request) -> BoxFuture<'a, Result<(), Box<dyn Error + Send + Sync>>>
+ Send
+ Sync
+ 'static,
>;
/// The client-side representation of a connection to a `Server`.
pub trait ClientHandler: Clone + Send + Sync + 'static {
fn send_request(
&self,
msg: Vec<u8>,
) -> impl Future<Output = Result<(), Box<dyn Error + Send + Sync>>> + Send;
}
mod name_registry {
use std::collections::HashSet;
use std::sync::{Arc, LazyLock, Mutex};
use crate::ClientError;
use crate::view::ClientResult;
static CLIENT_ID_GEN: LazyLock<Arc<Mutex<u32>>> = LazyLock::new(Arc::default);
static REGISTERED_CLIENTS: LazyLock<Arc<Mutex<HashSet<String>>>> = LazyLock::new(Arc::default);
pub(crate) fn generate_name(name: Option<&str>) -> ClientResult<String> {
if let Some(name) = name {
if let Some(name) = REGISTERED_CLIENTS
.lock()
.map_err(ClientError::from)?
.get(name)
{
Err(ClientError::DuplicateNameError(name.to_owned()))
} else {
Ok(name.to_owned())
}
} else {
let mut guard = CLIENT_ID_GEN.lock()?;
*guard += 1;
Ok(format!("client-{guard}"))
}
}
}
/// The type of the `reconnect` parameter passed to [`Client::handle_error`},
/// and to the callback closure of [`Client::on_error`].
///
/// Calling this function from a [`Client::on_error`] closure should run the
/// (implementation specific) client reconnect logic, e.g. rebindign a
/// websocket.
#[derive(Clone)]
#[allow(clippy::type_complexity)]
pub struct ReconnectCallback(
Arc<dyn Fn() -> LocalBoxFuture<'static, Result<(), Box<dyn Error>>> + Send + Sync>,
);
impl Deref for ReconnectCallback {
type Target = dyn Fn() -> LocalBoxFuture<'static, Result<(), Box<dyn Error>>> + Send + Sync;
fn deref(&self) -> &Self::Target {
&*self.0
}
}
impl ReconnectCallback {
pub fn new(
f: impl Fn() -> LocalBoxFuture<'static, Result<(), Box<dyn Error>>> + Send + Sync + 'static,
) -> Self {
ReconnectCallback(Arc::new(f))
}
}
/// An instance of a [`Client`] is a connection to a single
/// `perspective_server::Server`, whether locally in-memory or remote over some
/// transport like a WebSocket.
#[derive(Clone)]
pub struct Client {
name: Arc<String>,
features: Arc<Mutex<Option<Features>>>,
send: SendCallback,
id_gen: IDGen,
subscriptions_errors: Subscriptions<OnErrorCallback>,
subscriptions_once: Subscriptions<OnceCallback>,
subscriptions: Subscriptions<BoxFn<Response, BoxFuture<'static, Result<(), ClientError>>>>,
}
impl PartialEq for Client {
fn eq(&self, other: &Self) -> bool {
self.name == other.name
}
}
impl std::fmt::Debug for Client {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Client").finish()
}
}
impl Client {
/// Create a new client instance with a closure that handles message
/// dispatch. See [`Client::new`] for details.
pub fn new_with_callback<T, U>(name: Option<&str>, send_request: T) -> ClientResult<Self>
where
T: Fn(Vec<u8>) -> U + 'static + Sync + Send,
U: Future<Output = Result<(), Box<dyn Error + Send + Sync>>> + Send + 'static,
{
let name = name_registry::generate_name(name)?;
let send_request = Arc::new(send_request);
let send: SendCallback = Arc::new(move |req| {
let mut bytes: Vec<u8> = Vec::new();
req.encode(&mut bytes).unwrap();
let send_request = send_request.clone();
Box::pin(async move { send_request(bytes).await })
});
Ok(Client {
name: Arc::new(name),
features: Arc::default(),
id_gen: IDGen::default(),
send,
subscriptions: Subscriptions::default(),
subscriptions_errors: Arc::default(),
subscriptions_once: Arc::default(),
})
}
/// Create a new [`Client`] instance with [`ClientHandler`].
pub fn new<T>(name: Option<&str>, client_handler: T) -> ClientResult<Self>
where
T: ClientHandler + 'static + Sync + Send,
{
Self::new_with_callback(
name,
asyncfn!(client_handler, async move |req| {
client_handler.send_request(req).await
}),
)
}
pub fn get_name(&self) -> &'_ str {
self.name.as_str()
}
/// Handle a message from the external message queue.
/// [`Client::handle_response`] is part of the low-level message-handling
/// API necessary to implement new transports for a [`Client`]
/// connection to a local-or-remote `perspective_server::Server`, and
/// doesn't generally need to be called directly by "users" of a
/// [`Client`] once connected.
pub async fn handle_response<'a>(&'a self, msg: &'a [u8]) -> ClientResult<bool> {
let msg = Response::decode(msg)?;
tracing::debug!("RECV {}", msg);
let mut wr = self.subscriptions_once.write().await;
if let Some(handler) = (*wr).remove(&msg.msg_id) {
drop(wr);
handler(msg)?;
return Ok(true);
} else if let Some(handler) = self.subscriptions.try_read().unwrap().get(&msg.msg_id) {
drop(wr);
handler(msg).await?;
return Ok(true);
}
if let Response {
client_resp: Some(ClientResp::ServerError(ServerError { message, .. })),
..
} = &msg
{
tracing::error!("{}", message);
} else {
tracing::debug!("Received unsolicited server response: {}", msg);
}
Ok(false)
}
/// Handle an exception from the underlying transport.
pub async fn handle_error<T, U>(
&self,
message: ClientError,
reconnect: Option<T>,
) -> ClientResult<()>
where
T: Fn() -> U + Clone + Send + Sync + 'static,
U: Future<Output = ClientResult<()>>,
{
let subs = self.subscriptions_errors.read().await;
let tasks = join_all(subs.values().map(|callback| {
callback(
message.clone(),
reconnect.clone().map(move |f| {
ReconnectCallback(Arc::new(move || {
clone!(f);
Box::pin(async move { Ok(f().await?) }) as LocalBoxFuture<'static, _>
}))
}),
)
}));
tasks.await.into_iter().collect::<Result<(), _>>()?;
self.close_and_error_subscriptions(&message).await
}
/// TODO Synthesize an error to provide to the caller, since the
/// server did not respond and the other option is to just drop the call
/// which results in a non-descript error message. It would be nice to
/// have client-side failures be a native part of the Client API.
async fn close_and_error_subscriptions(&self, message: &ClientError) -> ClientResult<()> {
let synthetic_error = |msg_id| Response {
msg_id,
entity_id: "".to_string(),
client_resp: Some(ClientResp::ServerError(ServerError {
message: format!("{message}"),
status_code: 2,
})),
};
self.subscriptions.write().await.clear();
let callbacks_once = self
.subscriptions_once
.write()
.await
.drain()
.collect::<Vec<_>>();
callbacks_once
.into_iter()
.try_for_each(|(msg_id, f)| f(synthetic_error(msg_id)))
}
pub async fn on_error<T, U, V>(&self, on_error: T) -> ClientResult<u32>
where
T: Fn(ClientError, Option<ReconnectCallback>) -> U + Clone + Send + Sync + 'static,
U: Future<Output = V> + Send + 'static,
V: Into<Result<(), ClientError>> + Sync + 'static,
{
let id = self.gen_id();
let callback = asyncfn!(on_error, async move |x, y| on_error(x, y).await.into());
self.subscriptions_errors
.write()
.await
.insert(id, Box::new(move |x, y| Box::pin(callback(x, y))));
Ok(id)
}
/// Generate a message ID unique to this client.
pub(crate) fn gen_id(&self) -> u32 {
self.id_gen.next()
}
pub(crate) async fn unsubscribe(&self, update_id: u32) -> ClientResult<()> {
let callback = self
.subscriptions
.write()
.await
.remove(&update_id)
.ok_or(ClientError::Unknown("remove_update".to_string()))?;
drop(callback);
Ok(())
}
/// Register a callback which is expected to respond exactly once.
pub(crate) async fn subscribe_once(
&self,
msg: &Request,
on_update: Box<dyn FnOnce(Response) -> ClientResult<()> + Send + Sync + 'static>,
) -> ClientResult<()> {
self.subscriptions_once
.write()
.await
.insert(msg.msg_id, on_update);
tracing::debug!("SEND {}", msg);
if let Err(e) = (self.send)(msg).await {
self.subscriptions_once.write().await.remove(&msg.msg_id);
Err(ClientError::Unknown(e.to_string()))
} else {
Ok(())
}
}
pub(crate) async fn subscribe<T, U>(&self, msg: &Request, on_update: T) -> ClientResult<()>
where
T: Fn(Response) -> U + Send + Sync + 'static,
U: Future<Output = Result<(), ClientError>> + Send + 'static,
{
self.subscriptions
.write()
.await
.insert(msg.msg_id, Box::new(move |x| Box::pin(on_update(x))));
tracing::debug!("SEND {}", msg);
if let Err(e) = (self.send)(msg).await {
self.subscriptions.write().await.remove(&msg.msg_id);
Err(ClientError::Unknown(e.to_string()))
} else {
Ok(())
}
}
/// Send a `ClientReq` and await both the successful completion of the
/// `send`, _and_ the `ClientResp` which is returned.
pub(crate) async fn oneshot(&self, req: &Request) -> ClientResult<ClientResp> {
let (sender, receiver) = futures::channel::oneshot::channel::<ClientResp>();
let on_update = Box::new(move |res: Response| {
sender.send(res.client_resp.unwrap()).map_err(|x| x.into())
});
self.subscribe_once(req, on_update).await?;
receiver
.await
.map_err(|_| ClientError::Unknown(format!("Internal error for req {req}")))
}
pub(crate) async fn get_features(&self) -> ClientResult<Features> {
let mut guard = self.features.lock().await;
let features = if let Some(features) = &*guard {
features.clone()
} else {
let msg = Request {
msg_id: self.gen_id(),
entity_id: "".to_owned(),
client_req: Some(ClientReq::GetFeaturesReq(GetFeaturesReq {})),
};
let features = Features(Arc::new(match self.oneshot(&msg).await? {
ClientResp::GetFeaturesResp(features) => Ok(features),
resp => Err(resp),
}?));
*guard = Some(features.clone());
features
};
Ok(features)
}
/// Creates a new [`Table`] from either a _schema_ or _data_.
///
/// The [`Client::table`] factory function can be initialized with either a
/// _schema_ (see [`Table::schema`]), or data in one of these formats:
///
/// - Apache Arrow
/// - CSV
/// - JSON row-oriented
/// - JSON column-oriented
/// - NDJSON
///
/// When instantiated with _data_, the schema is inferred from this data.
/// While this is convenient, inferrence is sometimes imperfect e.g.
/// when the input is empty, null or ambiguous. For these cases,
/// [`Client::table`] can first be instantiated with a explicit schema.
///
/// When instantiated with a _schema_, the resulting [`Table`] is empty but
/// with known column names and column types. When subsqeuently
/// populated with [`Table::update`], these columns will be _coerced_ to
/// the schema's type. This behavior can be useful when
/// [`Client::table`]'s column type inferences doesn't work.
///
/// The resulting [`Table`] is _virtual_, and invoking its methods
/// dispatches events to the `perspective_server::Server` this
/// [`Client`] connects to, where the data is stored and all calculation
/// occurs.
///
/// # Arguments
///
/// - `arg` - Either _schema_ or initialization _data_.
/// - `options` - Optional configuration which provides one of:
/// - `limit` - The max number of rows the resulting [`Table`] can
/// store.
/// - `index` - The column name to use as an _index_ column. If this
/// `Table` is being instantiated by _data_, this column name must be
/// present in the data.
/// - `name` - The name of the table. This will be generated if it is
/// not provided.
/// - `format` - The explicit format of the input data, can be one of
/// `"json"`, `"columns"`, `"csv"` or `"arrow"`. This overrides
/// language-specific type dispatch behavior, which allows stringified
/// and byte array alternative inputs.
///
/// # Examples
///
/// Load a CSV from a `String`:
///
/// ```no_run
/// # use perspective_client::*;
/// # async fn run(client: Client) -> Result<(), Box<dyn std::error::Error>> {
/// let opts = TableInitOptions::default();
/// let data = TableData::Update(UpdateData::Csv("x,y\n1,2\n3,4".into()));
/// let table = client.table(data, opts).await?;
/// # Ok(()) }
/// ```
pub async fn table(&self, input: TableData, options: TableInitOptions) -> ClientResult<Table> {
let entity_id = match options.name.clone() {
Some(x) => x.to_owned(),
None => randid(),
};
if let TableData::View(view) = &input {
let window = ViewWindow::default();
let arrow = view.to_arrow(window).await?;
let mut table = self
.crate_table_inner(UpdateData::Arrow(arrow).into(), options.into(), entity_id)
.await?;
let table_ = table.clone();
let callback = asyncfn!(table_, update, async move |update: OnUpdateData| {
let update = UpdateData::Arrow(update.delta.expect("Malformed message").into());
let options = crate::UpdateOptions::default();
table_.update(update, options).await.unwrap_or_log();
});
let options = OnUpdateOptions {
mode: Some(OnUpdateMode::Row),
};
let on_update_token = view.on_update(callback, options).await?;
table.view_update_token = Some(on_update_token);
Ok(table)
} else {
self.crate_table_inner(input, options.into(), entity_id)
.await
}
}
async fn crate_table_inner(
&self,
input: TableData,
options: TableOptions,
entity_id: String,
) -> ClientResult<Table> {
let msg = Request {
msg_id: self.gen_id(),
entity_id: entity_id.clone(),
client_req: Some(ClientReq::MakeTableReq(MakeTableReq {
data: Some(input.into()),
options: Some(options.clone().try_into()?),
})),
};
let client = self.clone();
match self.oneshot(&msg).await? {
ClientResp::MakeTableResp(_) => Ok(Table::new(entity_id, client, options)),
resp => Err(resp.into()),
}
}
/// Create a new read-only [`Table`] by performing a JOIN on two source
/// tables. The resulting table is reactive: when either source table is
/// updated, the join is automatically recomputed.
///
/// # Arguments
///
/// * `left` - The left source table (as a [`Table`] or name string).
/// * `right` - The right source table (as a [`Table`] or name string).
/// * `on` - The column name to join on. Must exist in both tables with the
/// same type.
/// * `options` - Join configuration (join type, table name).
pub async fn join(
&self,
left: TableRef,
right: TableRef,
on: &str,
options: JoinOptions,
) -> ClientResult<Table> {
let entity_id = options.name.unwrap_or_else(randid);
let join_type: JoinType = options.join_type.unwrap_or_default();
let right_on_column = options.right_on.unwrap_or_default();
let msg = Request {
msg_id: self.gen_id(),
entity_id: entity_id.clone(),
client_req: Some(ClientReq::MakeJoinTableReq(MakeJoinTableReq {
left_table_id: left.table_name().to_owned(),
right_table_id: right.table_name().to_owned(),
on_column: on.to_owned(),
join_type: join_type.into(),
right_on_column,
})),
};
let client = self.clone();
match self.oneshot(&msg).await? {
ClientResp::MakeJoinTableResp(_) => Ok(Table::new(entity_id, client, TableOptions {
index: Some(on.to_owned()),
limit: None,
page_to_disk: None,
})),
resp => Err(resp.into()),
}
}
async fn get_table_infos(&self) -> ClientResult<Vec<HostedTable>> {
let msg = Request {
msg_id: self.gen_id(),
entity_id: "".to_owned(),
client_req: Some(ClientReq::GetHostedTablesReq(GetHostedTablesReq {
subscribe: false,
})),
};
match self.oneshot(&msg).await? {
ClientResp::GetHostedTablesResp(GetHostedTablesResp { table_infos }) => Ok(table_infos),
resp => Err(resp.into()),
}
}
/// Opens a [`Table`] that is hosted on the `perspective_server::Server`
/// that is connected to this [`Client`].
///
/// The `name` property of [`TableInitOptions`] is used to identify each
/// [`Table`]. [`Table`] `name`s can be looked up for each [`Client`]
/// via [`Client::get_hosted_table_names`].
///
/// # Examples
///
/// ```no_run
/// # use perspective_client::Client;
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
/// # let client: Client = todo!();
/// let table = client.open_table("table_one".to_owned()).await?;
/// # Ok(()) }
/// ```
pub async fn open_table(&self, entity_id: String) -> ClientResult<Table> {
let infos = self.get_table_infos().await?;
// TODO fix this - name is repeated 2x
if let Some(info) = infos.into_iter().find(|i| i.entity_id == entity_id) {
let options = TableOptions {
index: info.index,
limit: info.limit,
// `page_to_disk` is a server-side property not surfaced in table
// info; it does not affect client-side behavior.
page_to_disk: None,
};
let client = self.clone();
Ok(Table::new(entity_id, client, options))
} else {
Err(ClientError::Unknown(format!(
"Unknown table \"{}\"",
entity_id
)))
}
}
/// Retrieves the names of all tables that this client has access to.
///
/// `name` is a string identifier unique to the [`Table`] (per [`Client`]),
/// which can be used in conjunction with [`Client::open_table`] to get
/// a [`Table`] instance without the use of [`Client::table`]
/// constructor directly (e.g., one created by another [`Client`]).
///
/// # Examples
///
/// ```no_run
/// # use perspective_client::Client;
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
/// # let client: Client = todo!();
/// let tables = client.get_hosted_table_names().await?;
/// # Ok(()) }
/// ```
pub async fn get_hosted_table_names(&self) -> ClientResult<Vec<String>> {
let msg = Request {
msg_id: self.gen_id(),
entity_id: "".to_owned(),
client_req: Some(ClientReq::GetHostedTablesReq(GetHostedTablesReq {
subscribe: false,
})),
};
match self.oneshot(&msg).await? {
ClientResp::GetHostedTablesResp(GetHostedTablesResp { table_infos }) => {
Ok(table_infos.into_iter().map(|i| i.entity_id).collect())
},
resp => Err(resp.into()),
}
}
/// Register a callback which is invoked whenever [`Client::table`] (on this
/// [`Client`]) or [`Table::delete`] (on a [`Table`] belinging to this
/// [`Client`]) are called.
pub async fn on_hosted_tables_update<T, U>(&self, on_update: T) -> ClientResult<u32>
where
T: Fn() -> U + Send + Sync + 'static,
U: Future<Output = ()> + Send + 'static,
{
let on_update = Arc::new(on_update);
let callback = asyncfn!(on_update, async move |resp: Response| {
match resp.client_resp {
Some(ClientResp::GetHostedTablesResp(_)) | None => {
on_update().await;
Ok(())
},
resp => Err(resp.into()),
}
});
let msg = Request {
msg_id: self.gen_id(),
entity_id: "".to_owned(),
client_req: Some(ClientReq::GetHostedTablesReq(GetHostedTablesReq {
subscribe: true,
})),
};
self.subscribe(&msg, callback).await?;
Ok(msg.msg_id)
}
/// Remove a callback previously registered via
/// `Client::on_hosted_tables_update`.
pub async fn remove_hosted_tables_update(&self, update_id: u32) -> ClientResult<()> {
let msg = Request {
msg_id: self.gen_id(),
entity_id: "".to_owned(),
client_req: Some(ClientReq::RemoveHostedTablesUpdateReq(
RemoveHostedTablesUpdateReq { id: update_id },
)),
};
self.unsubscribe(update_id).await?;
match self.oneshot(&msg).await? {
ClientResp::RemoveHostedTablesUpdateResp(_) => Ok(()),
resp => Err(resp.into()),
}
}
/// Provides the [`SystemInfo`] struct, implementation-specific metadata
/// about the [`perspective_server::Server`] runtime such as Memory and
/// CPU usage.
pub async fn system_info(&self) -> ClientResult<SystemInfo> {
let msg = Request {
msg_id: self.gen_id(),
entity_id: "".to_string(),
client_req: Some(ClientReq::ServerSystemInfoReq(ServerSystemInfoReq {})),
};
match self.oneshot(&msg).await? {
ClientResp::ServerSystemInfoResp(resp) => {
#[cfg(not(target_family = "wasm"))]
let timestamp = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis() as u64,
);
#[cfg(target_family = "wasm")]
let timestamp = None;
#[cfg(feature = "talc-allocator")]
let (client_used, client_heap) = {
let (client_used, client_heap) = crate::utils::get_used();
(Some(client_used as u64), Some(client_heap as u64))
};
#[cfg(not(feature = "talc-allocator"))]
let (client_used, client_heap) = (None, None);
let info = SystemInfo {
heap_size: resp.heap_size,
used_size: resp.used_size,
cpu_time: resp.cpu_time,
cpu_time_epoch: resp.cpu_time_epoch,
timestamp,
client_heap,
client_used,
};
Ok(info)
},
resp => Err(resp.into()),
}
}
}
@@ -0,0 +1,81 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::fmt::Display;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::proto::view_config;
#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize, TS)]
#[serde(untagged)]
pub enum Aggregate {
SingleAggregate(String),
MultiAggregate(String, Vec<String>),
}
impl From<&'static str> for Aggregate {
fn from(value: &'static str) -> Self {
if value.contains(" by ") {
let mut parts = value.split(" by ");
Aggregate::MultiAggregate(
parts.next().unwrap().into(),
parts.map(|x| x.into()).collect(),
)
} else {
Aggregate::SingleAggregate(value.into())
}
}
}
impl Display for Aggregate {
fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> {
match self {
Self::SingleAggregate(x) => write!(fmt, "{x}")?,
Self::MultiAggregate(agg, x) => write!(fmt, "{} by {}", agg, x.join(", "))?,
};
Ok(())
}
}
impl From<Aggregate> for view_config::AggList {
fn from(value: Aggregate) -> Self {
view_config::AggList {
aggregations: match value {
Aggregate::SingleAggregate(x) => vec![x],
Aggregate::MultiAggregate(x, y) => {
vec![x, y.join(",")]
},
},
}
}
}
impl From<view_config::AggList> for Aggregate {
fn from(value: view_config::AggList) -> Self {
if value.aggregations.len() == 1 {
Aggregate::SingleAggregate(value.aggregations.first().unwrap().clone())
} else {
Aggregate::MultiAggregate(
value.aggregations.first().unwrap().clone(),
value
.aggregations
.get(1)
.unwrap()
.split(",")
.map(|x| x.to_owned())
.collect(),
)
}
}
}
@@ -0,0 +1,74 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::fmt::Display;
use std::str::FromStr;
use crate::ClientError;
use crate::proto::ColumnType;
impl Display for ColumnType {
fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> {
write!(fmt, "{}", match self {
Self::String => "string",
Self::Integer => "integer",
Self::Float => "float",
Self::Boolean => "boolean",
Self::Date => "date",
Self::Datetime => "datetime",
})
}
}
impl FromStr for ColumnType {
type Err = ClientError;
fn from_str(val: &str) -> Result<Self, Self::Err> {
val.try_into()
}
}
impl TryFrom<&str> for ColumnType {
type Error = ClientError;
fn try_from(val: &str) -> Result<Self, Self::Error> {
if val == "string" {
Ok(Self::String)
} else if val == "integer" {
Ok(Self::Integer)
} else if val == "float" {
Ok(Self::Float)
} else if val == "boolean" {
Ok(Self::Boolean)
} else if val == "date" {
Ok(Self::Date)
} else if val == "datetime" {
Ok(Self::Datetime)
} else {
Err(ClientError::Internal(format!("Unknown type {val}")))
}
}
}
impl ColumnType {
pub fn to_capitalized(&self) -> String {
match self {
ColumnType::String => "String",
ColumnType::Datetime => "Datetime",
ColumnType::Date => "Date",
ColumnType::Integer => "Integer",
ColumnType::Float => "Float",
ColumnType::Boolean => "Boolean",
}
.into()
}
}
@@ -0,0 +1,685 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
//! Perspective supports _expression columns_, which are virtual columns
//! calculated as part of the [`crate::View`], optionally using values from its
//! underlying [`crate::Table`]'s columns. Such expression columns are defined
//! in Perspective's expression language, an extended version of
//! [ExprTK](https://github.com/ArashPartow/exprtk), which is itself quite similar
//! (in design and features) to expressions in Excel.
//!
//! ## UI
//!
//! Expression columns can be created in `<perspective-viewer>` by clicking the
//! "New Column" button at the bottom of the column list, or via the API by
//! adding the expression to the `expressions` config key when calling
//! `viewer.restore()`.
//!
//! By default, such expression columns are not "used", and will appear above
//! the `Table`'s other deselected columns in the column list, with an
//! additional set of buttons for:
//!
//! - _Editing_ the column's expression. Doing so will update the definitions of
//! _all_ usage of the expression column.
//! - _Deleting_ the column. Clicking `Reset` (or calling the `reset()` method)
//! will not delete expressions unless the `Shift` key is held (or `true`
//! parameter supplied, respectively). This button only appears if the
//! expression column i unused.
//!
//! To use the column, just drag/select the column as you would a normal column,
//! e.g. as a "Filter", "Group By", etc. Expression columns will recalculate
//! whenever their dependent columns update.
//!
//! ## Perspective Extensions to ExprTK
//!
//! ExprTK has its own
//! [excellent documentation](http://www.partow.net/programming/exprtk/) which
//! covers the core langauge in depth, which is an excellent place to start in
//! learning the basics. In addition to these features, Perspective adds a few
//! of its own custom extensions and syntax.
//!
//! #### Static Typing
//!
//! In addition to `float` values which ExprTK supports natively, Perspective's
//! expression language also supports Perspective's other types `date`,
//! `datetime`, `integer`, `boolean`; as well as rudimentary type-checking,
//! which will report an <span>error</span> when the values/columns supplied as
//! arguments cannot be resolved to the expected type, e.g. `length(x)` expects
//! an argument `x` of type `string` and is not a valid expression for an `x` of
//! another type. Perspective supplies a set of _cast_ functions for converting
//! between types where possible e.g. `string(x)` to cast a variable `x` to a
//! `string`.
//!
//! #### Expression Column Name
//!
//! Expressions are named by their key in the `ViewConfig::expressions`
//! `HashMap`. This name will be used in the `<perspective-viewer>` UI when
//! referring to the column, but will also be used in the API when specifying
//! e.g. `group_by` or `sort` fields. When creating a new column via
//! `<oerspective-viewer>`'s expression editor, new columns will get a default
//! name (which you may delete or change):
//!
//! ```html
//! // New Column 1
//! ```
//!
//! Without such a comment, an expression will show up in the
//! `<perspective-viewer>` API and UI as itself (clipped to a reasonable length
//! for the latter).
//!
//! #### Referencing [`crate::Table`] Columns
//!
//! Columns from the [`crate::Table`] can be referenced in an expression with
//! _double quotes_.
//!
//! ```text
//! // Expected Sales ("Sales" * 10) + "Profit"
//! ```
//!
//! #### String Literals
//!
//! In contrast to standard ExprTK, string literals are declared with _single
//! quotes_:
//!
//! ```text
//! // Profitable
//! if ("Profit" > 0) {
//! 'Stonks'
//! } else {
//! 'Not Stonks'
//! }
//! ```
//!
//! #### Extended Library
//!
//! Perspective adds many of its own functions in addition to `ExprTK`'s
//! standard ones, including common functions for `datetime` and `string` types
//! such as `substring()`, `bucket()`, `day_of_week()`, etc. A full list of
//! available functions is available in the
//! [Expression Columns API](../obj/perspective-viewer-exprtk).
//!
//! ## Examples
//!
//! #### Casting
//!
//! Just `2`, as an `integer` (numeric literals currently default to `float`
//! unless cast).
//!
//! ```text
//! integer(2)
//! ```
//!
//! #### Variables
//!
//! ```text
//! // My Column Name
//! var incrementedBy200 := "Sales" + 200;
//! var half := incrementedBy200 / 2;
//! half
//! ```
//!
//! ```text
//! // Complex Expression
//! var upperCustomer := upper("Customer Name");
//! var separator := concat(upperCustomer, ' | ');
//! var profitRatio := floor(percent_of("Profit", "Sales")); // Remove trailing decimal.
//! var combined := concat(separator, string(profitRatio));
//! var percentDisplay := concat(combined, '%');
//! percentDisplay
//! ```
//!
//! #### Conditionals
//!
//! ```text
//! // Conditional
//! var priceAdjustmentDate := date(2016, 6, 18);
//! var finalPrice := "Sales" - "Discount";
//! var additionalModifier := 0;
//!
//! if("Order Date" > priceAdjustmentDate) {
//! finalPrice -= 5;
//! additionalModifier -= 2;
//! }
//! else
//! finalPrice += 5;
//!
//! finalPrice + additionalModifier
//! ```
#![cfg_attr(not(feature = "omit_metadata"), doc = include_str!("../../../docs/expression_gen.md"))]
use std::borrow::Cow;
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
#[derive(Deserialize, Clone, PartialEq, Debug)]
#[serde(untagged)]
pub enum ExpressionsDeserde {
Array(Vec<String>),
Map(HashMap<String, String>),
}
#[derive(Deserialize, Serialize, Clone, PartialEq, Debug, Default, TS)]
#[serde(from = "ExpressionsDeserde")]
pub struct Expressions(pub HashMap<String, String>);
impl std::ops::Deref for Expressions {
type Target = HashMap<String, String>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl std::ops::DerefMut for Expressions {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
fn upgrade_legacy_format(expressions: &[String]) -> HashMap<String, String> {
tracing::debug!("Legacy `expressions` format: {:?}", expressions);
expressions
.iter()
.map(|s| {
if let Some((name, expression)) = s.split_once('\n') {
if !expression.is_empty() && name.starts_with("//") {
(name.split_at(2).1.trim().to_owned(), expression.to_owned())
} else {
(s.to_owned(), s.to_owned())
}
} else {
(s.to_owned(), s.to_owned())
}
})
.collect::<HashMap<_, _>>()
}
impl From<ExpressionsDeserde> for Expressions {
fn from(value: ExpressionsDeserde) -> Self {
match value {
ExpressionsDeserde::Array(arr) => Self(upgrade_legacy_format(&arr)),
ExpressionsDeserde::Map(map) => Self(map),
}
}
}
#[derive(Clone, Debug, PartialEq, TS)]
pub struct Expression<'a> {
pub name: Cow<'a, str>,
pub expression: Cow<'a, str>,
}
impl<'a> Expression<'a> {
/// If name is None, the expression is used as the name.
pub fn new(name: Option<Cow<'a, str>>, expression: Cow<'a, str>) -> Self {
Self {
name: name.unwrap_or_else(|| expression.clone()),
expression,
}
}
}
impl<'a> FromIterator<Expression<'a>> for Expressions {
fn from_iter<T: IntoIterator<Item = Expression<'a>>>(iter: T) -> Self {
Self(
iter.into_iter()
.map(|x| (x.name.as_ref().to_owned(), x.expression.as_ref().to_owned()))
.collect(),
)
}
}
impl Expressions {
pub fn insert(&mut self, expr: &Expression) {
self.0.insert(
expr.name.as_ref().to_owned(),
expr.expression.as_ref().to_owned(),
);
}
}
#[doc(hidden)]
#[derive(Serialize, Clone, Copy, PartialEq)]
pub struct CompletionItemSuggestion {
pub label: &'static str,
pub insert_text: &'static str,
pub documentation: &'static str,
}
#[doc(hidden)]
pub static COMPLETIONS: [CompletionItemSuggestion; 79] = [
CompletionItemSuggestion {
label: "var",
insert_text: "var ${1:x := 1}",
documentation: "Declare a new local variable",
},
CompletionItemSuggestion {
label: "abs",
insert_text: "abs(${1:x})",
documentation: "Absolute value of x",
},
CompletionItemSuggestion {
label: "avg",
insert_text: "avg(${1:x})",
documentation: "Average of all inputs",
},
CompletionItemSuggestion {
label: "bucket",
insert_text: "bucket(${1:x}, ${2:y})",
documentation: "Bucket x by y",
},
CompletionItemSuggestion {
label: "ceil",
insert_text: "ceil(${1:x})",
documentation: "Smallest integer >= x",
},
CompletionItemSuggestion {
label: "exp",
insert_text: "exp(${1:x})",
documentation: "Natural exponent of x (e ^ x)",
},
CompletionItemSuggestion {
label: "floor",
insert_text: "floor(${1:x})",
documentation: "Largest integer <= x",
},
CompletionItemSuggestion {
label: "frac",
insert_text: "frac(${1:x})",
documentation: "Fractional portion (after the decimal) of x",
},
CompletionItemSuggestion {
label: "iclamp",
insert_text: "iclamp(${1:x})",
documentation: "Inverse clamp x within a range",
},
CompletionItemSuggestion {
label: "inrange",
insert_text: "inrange(${1:x})",
documentation: "Returns whether x is within a range",
},
CompletionItemSuggestion {
label: "log",
insert_text: "log(${1:x})",
documentation: "Natural log of x",
},
CompletionItemSuggestion {
label: "log10",
insert_text: "log10(${1:x})",
documentation: "Base 10 log of x",
},
CompletionItemSuggestion {
label: "log1p",
insert_text: "log1p(${1:x})",
documentation: "Natural log of 1 + x where x is very small",
},
CompletionItemSuggestion {
label: "log2",
insert_text: "log2(${1:x})",
documentation: "Base 2 log of x",
},
CompletionItemSuggestion {
label: "logn",
insert_text: "logn(${1:x}, ${2:N})",
documentation: "Base N log of x where N >= 0",
},
CompletionItemSuggestion {
label: "max",
insert_text: "max(${1:x})",
documentation: "Maximum value of all inputs",
},
CompletionItemSuggestion {
label: "min",
insert_text: "min(${1:x})",
documentation: "Minimum value of all inputs",
},
CompletionItemSuggestion {
label: "mul",
insert_text: "mul(${1:x})",
documentation: "Product of all inputs",
},
CompletionItemSuggestion {
label: "percent_of",
insert_text: "percent_of(${1:x})",
documentation: "Percent y of x",
},
CompletionItemSuggestion {
label: "pow",
insert_text: "pow(${1:x}, ${2:y})",
documentation: "x to the power of y",
},
CompletionItemSuggestion {
label: "root",
insert_text: "root(${1:x}, ${2:N})",
documentation: "N-th root of x where N >= 0",
},
CompletionItemSuggestion {
label: "round",
insert_text: "round(${1:x})",
documentation: "Round x to the nearest integer",
},
CompletionItemSuggestion {
label: "sgn",
insert_text: "sgn(${1:x})",
documentation: "Sign of x: -1, 1, or 0",
},
CompletionItemSuggestion {
label: "sqrt",
insert_text: "sqrt(${1:x})",
documentation: "Square root of x",
},
CompletionItemSuggestion {
label: "sum",
insert_text: "sum(${1:x})",
documentation: "Sum of all inputs",
},
CompletionItemSuggestion {
label: "trunc",
insert_text: "trunc(${1:x})",
documentation: "Integer portion of x",
},
CompletionItemSuggestion {
label: "acos",
insert_text: "acos(${1:x})",
documentation: "Arc cosine of x in radians",
},
CompletionItemSuggestion {
label: "acosh",
insert_text: "acosh(${1:x})",
documentation: "Inverse hyperbolic cosine of x in radians",
},
CompletionItemSuggestion {
label: "asin",
insert_text: "asin(${1:x})",
documentation: "Arc sine of x in radians",
},
CompletionItemSuggestion {
label: "asinh",
insert_text: "asinh(${1:x})",
documentation: "Inverse hyperbolic sine of x in radians",
},
CompletionItemSuggestion {
label: "atan",
insert_text: "atan(${1:x})",
documentation: "Arc tangent of x in radians",
},
CompletionItemSuggestion {
label: "atanh",
insert_text: "atanh(${1:x})",
documentation: "Inverse hyperbolic tangent of x in radians",
},
CompletionItemSuggestion {
label: "cos",
insert_text: "cos(${1:x})",
documentation: "Cosine of x",
},
CompletionItemSuggestion {
label: "cosh",
insert_text: "cosh(${1:x})",
documentation: "Hyperbolic cosine of x",
},
CompletionItemSuggestion {
label: "cot",
insert_text: "cot(${1:x})",
documentation: "Cotangent of x",
},
CompletionItemSuggestion {
label: "sin",
insert_text: "sin(${1:x})",
documentation: "Sine of x",
},
CompletionItemSuggestion {
label: "sinc",
insert_text: "sinc(${1:x})",
documentation: "Sine cardinal of x",
},
CompletionItemSuggestion {
label: "sinh",
insert_text: "sinh(${1:x})",
documentation: "Hyperbolic sine of x",
},
CompletionItemSuggestion {
label: "tan",
insert_text: "tan(${1:x})",
documentation: "Tangent of x",
},
CompletionItemSuggestion {
label: "tanh",
insert_text: "tanh(${1:x})",
documentation: "Hyperbolic tangent of x",
},
CompletionItemSuggestion {
label: "deg2rad",
insert_text: "deg2rad(${1:x})",
documentation: "Convert x from degrees to radians",
},
CompletionItemSuggestion {
label: "deg2grad",
insert_text: "deg2grad(${1:x})",
documentation: "Convert x from degrees to gradians",
},
CompletionItemSuggestion {
label: "rad2deg",
insert_text: "rad2deg(${1:x})",
documentation: "Convert x from radians to degrees",
},
CompletionItemSuggestion {
label: "grad2deg",
insert_text: "grad2deg(${1:x})",
documentation: "Convert x from gradians to degrees",
},
CompletionItemSuggestion {
label: "concat",
insert_text: "concat(${1:x}, ${2:y})",
documentation: "Concatenate string columns and string literals, such \
as:\nconcat(\"State\" ', ', \"City\")",
},
CompletionItemSuggestion {
label: "order",
insert_text: "order(${1:input column}, ${2:value}, ...)",
documentation: "Generates a sort order for a string column based on the input order of \
the parameters, such as:\norder(\"State\", 'Texas', 'New York')",
},
CompletionItemSuggestion {
label: "upper",
insert_text: "upper(${1:x})",
documentation: "Uppercase of x",
},
CompletionItemSuggestion {
label: "lower",
insert_text: "lower(${1:x})",
documentation: "Lowercase of x",
},
CompletionItemSuggestion {
label: "hour_of_day",
insert_text: "hour_of_day(${1:x})",
documentation: "Return a datetime's hour of the day as a string",
},
CompletionItemSuggestion {
label: "month_of_year",
insert_text: "month_of_year(${1:x})",
documentation: "Return a datetime's month of the year as a string",
},
CompletionItemSuggestion {
label: "day_of_week",
insert_text: "day_of_week(${1:x})",
documentation: "Return a datetime's day of week as a string",
},
CompletionItemSuggestion {
label: "now",
insert_text: "now()",
documentation: "The current datetime in local time",
},
CompletionItemSuggestion {
label: "today",
insert_text: "today()",
documentation: "The current date in local time",
},
CompletionItemSuggestion {
label: "is_null",
insert_text: "is_null(${1:x})",
documentation: "Whether x is a null value",
},
CompletionItemSuggestion {
label: "is_not_null",
insert_text: "is_not_null(${1:x})",
documentation: "Whether x is not a null value",
},
CompletionItemSuggestion {
label: "coalesce",
insert_text: "coalesce(${1:x}, ${2:y})",
documentation: "Returns the first non-null argument.",
},
CompletionItemSuggestion {
label: "contains",
insert_text: "contains(${1:x}, ${2:'substr'})",
documentation: "Whether the string column or value contains the literal substring.",
},
CompletionItemSuggestion {
label: "not",
insert_text: "not(${1:x})",
documentation: "not x",
},
CompletionItemSuggestion {
label: "true",
insert_text: "true",
documentation: "Boolean value true",
},
CompletionItemSuggestion {
label: "false",
insert_text: "false",
documentation: "Boolean value false",
},
CompletionItemSuggestion {
label: "if",
insert_text: "if (${1:condition}) {} else if (${2:condition}) {} else {}",
documentation: "An if/else conditional, which evaluates a condition such as:\n if \
(\"Sales\" > 100) { true } else { false }",
},
CompletionItemSuggestion {
label: "for",
insert_text: "for (${1:expression}) {}",
documentation: "A for loop, which repeatedly evaluates an incrementing expression such \
as:\nvar x := 0; var y := 1; for (x < 10; x += 1) { y := x + y }",
},
CompletionItemSuggestion {
label: "string",
insert_text: "string(${1:x})",
documentation: "Converts the given argument to a string",
},
CompletionItemSuggestion {
label: "integer",
insert_text: "integer(${1:x})",
documentation: "Converts the given argument to a 32-bit integer. If the result \
over/under-flows, null is returned",
},
CompletionItemSuggestion {
label: "float",
insert_text: "float(${1:x})",
documentation: "Converts the argument to a float",
},
CompletionItemSuggestion {
label: "date",
insert_text: "date(${1:year}, ${1:month}, ${1:day})",
documentation: "Given a year, month (1-12) and day, create a new date",
},
CompletionItemSuggestion {
label: "datetime",
insert_text: "datetime(${1:timestamp})",
documentation: "Given a POSIX timestamp of milliseconds since epoch, create a new datetime",
},
CompletionItemSuggestion {
label: "boolean",
insert_text: "boolean(${1:x})",
documentation: "Converts the given argument to a boolean",
},
CompletionItemSuggestion {
label: "random",
insert_text: "random()",
documentation: "Returns a random float between 0 and 1, inclusive.",
},
CompletionItemSuggestion {
label: "match",
insert_text: "match(${1:string}, ${2:pattern})",
documentation: "Returns True if any part of string matches pattern, and False otherwise.",
},
CompletionItemSuggestion {
label: "match_all",
insert_text: "match_all(${1:string}, ${2:pattern})",
documentation: "Returns True if the whole string matches pattern, and False otherwise.",
},
CompletionItemSuggestion {
label: "search",
insert_text: "search(${1:string}, ${2:pattern})",
documentation: "Returns the substring that matches the first capturing group in pattern, \
or null if there are no capturing groups in the pattern or if there are \
no matches.",
},
CompletionItemSuggestion {
label: "indexof",
insert_text: "indexof(${1:string}, ${2:pattern}, ${3:output_vector})",
documentation: "Writes into index 0 and 1 of output_vector the start and end indices of \
the substring that matches the first capturing group in \
pattern.\n\nReturns true if there is a match and output was written, or \
false if there are no capturing groups in the pattern, if there are no \
matches, or if the indices are invalid.",
},
CompletionItemSuggestion {
label: "substring",
insert_text: "substring(${1:string}, ${2:start_idx}, ${3:length})",
documentation: "Returns a substring of string from start_idx with the given length. If \
length is not passed in, returns substring from start_idx to the end of \
the string. Returns null if the string or any indices are invalid.",
},
CompletionItemSuggestion {
label: "replace",
insert_text: "replace(${1:string}, ${2:pattern}, ${3:replacer})",
documentation: "Replaces the first match of pattern in string with replacer, or return \
the original string if no replaces were made.",
},
CompletionItemSuggestion {
label: "replace_all",
insert_text: "replace_all(${1:string}, ${2:pattern}, ${3:replacer})",
documentation: "Replaces all non-overlapping matches of pattern in string with replacer, \
or return the original string if no replaces were made.",
},
CompletionItemSuggestion {
label: "index",
insert_text: "index()",
documentation: "Looks up the index value of the current row",
},
CompletionItemSuggestion {
label: "col",
insert_text: "col(${1:string})",
documentation: "Looks up a column value by name",
},
CompletionItemSuggestion {
label: "vlookup",
insert_text: "vlookup(${1:string}, ${2:uint64})",
documentation: "Looks up a value in another column by index",
},
];
#[test]
fn test_completions_insert_text_matches_label() {
for comp in COMPLETIONS {
let label = comp.label;
let insert_text = comp.insert_text;
assert!(
insert_text.starts_with(label),
"insert_text for label {label} does not start with {label}:\n {insert_text}"
);
}
}
@@ -0,0 +1,202 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::fmt::Display;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::proto;
use crate::proto::scalar;
/// This type represents the ViewConfig serializable type, which must be JSON
/// safe.
#[derive(Clone, Default, Deserialize, Debug, PartialEq, Serialize, TS)]
#[serde(untagged)]
pub enum Scalar {
Float(f64),
String(String),
Bool(bool),
// DateTime(i64),
// Date(String),
// Int(i32),
#[default]
Null,
}
impl From<&str> for Scalar {
fn from(value: &str) -> Self {
Self::String(value.into())
}
}
impl Display for Scalar {
fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
match self {
Self::Float(x) => write!(fmt, "{x}"),
Self::String(x) => write!(fmt, "{x}"),
Self::Bool(x) => write!(fmt, "{x}"),
Self::Null => write!(fmt, ""),
}
}
}
#[derive(Clone, Deserialize, Debug, PartialEq, Serialize, TS)]
#[serde(untagged)]
pub enum FilterTerm {
Array(Vec<Scalar>),
Scalar(#[serde(default)] Scalar),
}
impl<'a, T> From<T> for FilterTerm
where
T: AsRef<[&'a str]>,
{
fn from(value: T) -> Self {
Self::Array(value.as_ref().iter().map(|x| (*x).into()).collect())
}
}
impl Default for FilterTerm {
fn default() -> Self {
Self::Scalar(Scalar::Null)
}
}
impl Display for FilterTerm {
fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
match self {
Self::Scalar(x) => {
write!(fmt, "{x}")?;
},
Self::Array(xs) => write!(
fmt,
"{}",
Itertools::intersperse(xs.iter().map(|x| format!("{x}")), ",".to_owned())
.collect::<String>()
)?,
}
Ok(())
}
}
impl FilterTerm {
pub fn is_null(&self) -> bool {
matches!(self, FilterTerm::Scalar(Scalar::Null))
}
}
#[derive(Clone, Deserialize, Debug, PartialEq, Serialize, TS)]
#[serde()]
pub struct Filter(String, String, #[serde(default)] FilterTerm);
impl Filter {
pub fn new<T>(column: &str, op: &str, term: T) -> Self
where
FilterTerm: From<T>,
{
Filter(column.to_string(), op.to_string(), term.into())
}
pub fn column(&self) -> &str {
self.0.as_str()
}
pub fn op(&self) -> &str {
self.1.as_str()
}
pub fn term(&self) -> &FilterTerm {
&self.2
}
pub fn column_mut(&mut self) -> &mut String {
&mut self.0
}
pub fn op_mut(&mut self) -> &mut String {
&mut self.1
}
pub fn term_mut(&mut self) -> &mut FilterTerm {
&mut self.2
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq, TS)]
pub enum FilterReducer {
#[default]
#[serde(rename = "and")]
And,
#[serde(rename = "or")]
Or,
}
impl From<Scalar> for proto::Scalar {
fn from(value: Scalar) -> Self {
match value {
Scalar::Float(x) => proto::Scalar {
scalar: Some(scalar::Scalar::Float(x)),
},
Scalar::String(x) => proto::Scalar {
scalar: Some(scalar::Scalar::String(x)),
},
Scalar::Bool(x) => proto::Scalar {
scalar: Some(scalar::Scalar::Bool(x)),
},
Scalar::Null => proto::Scalar {
scalar: Some(scalar::Scalar::Null(0)),
},
}
}
}
impl From<proto::Scalar> for Scalar {
fn from(value: proto::Scalar) -> Self {
match value.scalar {
Some(scalar::Scalar::Bool(x)) => Scalar::Bool(x),
Some(scalar::Scalar::String(x)) => Scalar::String(x),
Some(scalar::Scalar::Float(x)) => Scalar::Float(x),
Some(scalar::Scalar::Null(_)) => Scalar::Null,
None => Scalar::Null,
}
}
}
impl From<Filter> for proto::view_config::Filter {
fn from(value: Filter) -> Self {
proto::view_config::Filter {
column: value.0,
op: value.1,
value: match value.2 {
FilterTerm::Scalar(x) => vec![x.into()],
FilterTerm::Array(x) => x.into_iter().map(|x| x.into()).collect(),
},
}
}
}
impl From<proto::view_config::Filter> for Filter {
fn from(value: proto::view_config::Filter) -> Self {
Filter(
value.column,
value.op,
if value.value.len() == 1 {
FilterTerm::Scalar(value.value.into_iter().next().unwrap().into())
} else {
FilterTerm::Array(value.value.into_iter().map(|x| x.into()).collect())
},
)
}
}
@@ -0,0 +1,37 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
//! Options for [`Table::view`], and details about fields like `expressions`.
//!
//! [`ViewConfig`] for the top level argument to [`Table::view`].
//!
//! [`expressions`] module for details about the ExprTK format.
#[cfg(doc)]
use crate::Table;
mod aggregates;
mod column_type;
pub mod expressions;
mod filters;
mod plugin;
mod sort;
mod view_config;
pub use aggregates::*;
pub use expressions::*;
pub use filters::*;
pub use plugin::*;
pub use sort::*;
pub use view_config::*;
pub use crate::proto::{ColumnType, SortOp};
@@ -0,0 +1,69 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::collections::HashMap;
use std::fmt::Debug;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
pub struct Symbol {
/// the name of the symbol
pub name: String,
/// unescaped HTML string
pub html: String,
}
impl std::fmt::Display for Symbol {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.name)
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
pub struct SymbolAttributes {
/// a vec of SVGs to render
pub symbols: Vec<Symbol>,
}
/// The default style configurations per type, as retrived by
/// plugin.plugin_attributes
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct DefaultStyleAttributes {
pub string: serde_json::Value,
pub datetime: serde_json::Value,
pub date: serde_json::Value,
pub integer: serde_json::Value,
pub float: serde_json::Value,
pub bool: serde_json::Value,
}
/// The data needed to populate a column's settings.
///
/// These are typically default values, a listing of possible values, or other
/// basic configuration settings for the plugin. This is the result of calling
/// plugin.plugin_attributes
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct PluginAttributes {
pub symbol: Option<SymbolAttributes>,
pub style: Option<DefaultStyleAttributes>,
// color: ColorAttributes,
// axis: AxisAttributes,
//...
}
/// The configuration which is created as the result of calling plugin.save
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
pub struct PluginConfig {
/// Refers to the currently active columns. Maps name to configuration.
#[serde(default)]
pub columns: HashMap<String, serde_json::Value>,
}
@@ -0,0 +1,146 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::fmt::Display;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::proto;
#[derive(Clone, Deserialize, Debug, Eq, PartialEq, Serialize, TS)]
#[serde()]
pub struct Sort(pub String, pub SortDir);
#[derive(Clone, Copy, Deserialize, Debug, Eq, PartialEq, Serialize, TS)]
#[serde()]
pub enum SortDir {
#[serde(rename = "none")]
None,
#[serde(rename = "desc")]
Desc,
#[serde(rename = "asc")]
Asc,
#[serde(rename = "col desc")]
ColDesc,
#[serde(rename = "col asc")]
ColAsc,
#[serde(rename = "desc abs")]
DescAbs,
#[serde(rename = "asc abs")]
AscAbs,
#[serde(rename = "col desc abs")]
ColDescAbs,
#[serde(rename = "col asc abs")]
ColAscAbs,
}
impl Display for SortDir {
fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> {
write!(fmt, "{}", match self {
Self::None => "none",
Self::Desc => "desc",
Self::Asc => "asc",
Self::ColDesc => "col desc",
Self::ColAsc => "col asc",
Self::DescAbs => "desc abs",
Self::AscAbs => "asc abs",
Self::ColDescAbs => "col desc abs",
Self::ColAscAbs => "col asc abs",
})
}
}
impl SortDir {
/// Increment the `SortDir` in logical order, given an `abs()` modifier.
pub fn cycle(&self, split_by: bool, abs: bool) -> Self {
let order: &[Self] = match (split_by, abs) {
(false, false) => &[Self::None, Self::Asc, Self::Desc],
(false, true) => &[Self::None, Self::AscAbs, Self::DescAbs],
(true, false) => &[
Self::None,
Self::Asc,
Self::Desc,
Self::ColAsc,
Self::ColDesc,
],
(true, true) => &[
Self::None,
Self::AscAbs,
Self::DescAbs,
Self::ColAscAbs,
Self::ColDescAbs,
],
};
let index = order.iter().position(|x| x == self).unwrap_or(0);
order[(index + 1) % order.len()]
}
}
impl From<SortDir> for proto::SortOp {
fn from(value: SortDir) -> Self {
match value {
SortDir::None => proto::SortOp::SortNone,
SortDir::Desc => proto::SortOp::SortDesc,
SortDir::Asc => proto::SortOp::SortAsc,
SortDir::ColDesc => proto::SortOp::SortColDesc,
SortDir::ColAsc => proto::SortOp::SortColAsc,
SortDir::DescAbs => proto::SortOp::SortDescAbs,
SortDir::AscAbs => proto::SortOp::SortAscAbs,
SortDir::ColDescAbs => proto::SortOp::SortColDescAbs,
SortDir::ColAscAbs => proto::SortOp::SortColAscAbs,
}
}
}
impl From<proto::SortOp> for SortDir {
fn from(value: proto::SortOp) -> Self {
match value {
proto::SortOp::SortNone => SortDir::None,
proto::SortOp::SortDesc => SortDir::Desc,
proto::SortOp::SortAsc => SortDir::Asc,
proto::SortOp::SortColAsc => SortDir::ColAsc,
proto::SortOp::SortColDesc => SortDir::ColDesc,
proto::SortOp::SortAscAbs => SortDir::AscAbs,
proto::SortOp::SortDescAbs => SortDir::DescAbs,
proto::SortOp::SortColAscAbs => SortDir::ColAscAbs,
proto::SortOp::SortColDescAbs => SortDir::ColDescAbs,
}
}
}
impl From<Sort> for proto::view_config::Sort {
fn from(value: Sort) -> Self {
proto::view_config::Sort {
column: value.0,
op: proto::SortOp::from(value.1).into(),
}
}
}
impl From<proto::view_config::Sort> for Sort {
fn from(value: proto::view_config::Sort) -> Self {
Sort(
value.column,
proto::SortOp::try_from(value.op).unwrap().into(),
)
}
}
@@ -0,0 +1,479 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::collections::HashMap;
use std::fmt::Display;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use super::aggregates::*;
use super::expressions::*;
use super::filters::*;
use super::sort::*;
use crate::proto;
use crate::proto::columns_update;
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq, TS)]
pub enum GroupRollupMode {
#[default]
#[serde(rename = "rollup")]
Rollup,
#[serde(rename = "flat")]
Flat,
#[serde(rename = "total")]
Total,
}
impl Display for GroupRollupMode {
fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> {
write!(fmt, "{}", match self {
Self::Rollup => "Rollup",
Self::Flat => "Flat",
Self::Total => "Total",
})
}
}
impl From<proto::GroupRollupMode> for GroupRollupMode {
fn from(value: proto::GroupRollupMode) -> Self {
match value {
proto::GroupRollupMode::Rollup => Self::Rollup,
proto::GroupRollupMode::Flat => Self::Flat,
proto::GroupRollupMode::Total => Self::Total,
}
}
}
impl From<GroupRollupMode> for proto::GroupRollupMode {
fn from(value: GroupRollupMode) -> Self {
match value {
GroupRollupMode::Rollup => proto::GroupRollupMode::Rollup,
GroupRollupMode::Flat => proto::GroupRollupMode::Flat,
GroupRollupMode::Total => proto::GroupRollupMode::Total,
}
}
}
#[derive(Clone, Debug, Deserialize, Default, PartialEq, Serialize, TS)]
#[serde(deny_unknown_fields)]
pub struct ViewConfig {
#[serde(default)]
pub group_by: Vec<String>,
#[serde(default)]
pub split_by: Vec<String>,
#[serde(default)]
pub sort: Vec<Sort>,
#[serde(default)]
pub filter: Vec<Filter>,
// #[serde(skip_serializing_if = "is_default_value")]
#[serde(default)]
pub group_rollup_mode: GroupRollupMode,
#[serde(skip_serializing_if = "is_default_value")]
#[serde(default)]
pub filter_op: FilterReducer,
#[serde(default)]
pub expressions: Expressions,
#[serde(default)]
pub columns: Vec<Option<String>>,
#[serde(default)]
pub aggregates: HashMap<String, Aggregate>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub group_by_depth: Option<u32>,
}
fn is_default_value<A: Default + PartialEq>(value: &A) -> bool {
value == &A::default()
}
#[derive(Clone, Debug, Deserialize, Default, PartialEq, Serialize, TS)]
#[serde(deny_unknown_fields)]
pub struct ViewConfigUpdate {
/// A group by _groups_ the dataset by the unique values of each column used
/// as a group by - a close analogue in SQL to the `GROUP BY` statement.
/// The underlying dataset is aggregated to show the values belonging to
/// each group, and a total row is calculated for each group, showing
/// the currently selected aggregated value (e.g. `sum`) of the column.
/// Group by are useful for hierarchies, categorizing data and
/// attributing values, i.e. showing the number of units sold based on
/// State and City. In Perspective, group by are represented as an array
/// of string column names to pivot, are applied in the order provided;
/// For example, a group by of `["State", "City", "Postal Code"]` shows
/// the values for each Postal Code, which are grouped by City,
/// which are in turn grouped by State.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
#[ts(optional)]
pub group_by: Option<Vec<String>>,
/// A split by _splits_ the dataset by the unique values of each column used
/// as a split by. The underlying dataset is not aggregated, and a new
/// column is created for each unique value of the split by. Each newly
/// created column contains the parts of the dataset that correspond to
/// the column header, i.e. a `View` that has `["State"]` as its split
/// by will have a new column for each state. In Perspective, Split By
/// are represented as an array of string column names to pivot.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
#[ts(optional)]
pub split_by: Option<Vec<String>>,
/// The `columns` property specifies which columns should be included in the
/// [`crate::View`]'s output. This allows users to show or hide a specific
/// subset of columns, as well as control the order in which columns
/// appear to the user. This is represented in Perspective as an array
/// of string column names.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
#[ts(optional)]
pub columns: Option<Vec<Option<String>>>,
/// The `filter` property specifies columns on which the query can be
/// filtered, returning rows that pass the specified filter condition.
/// This is analogous to the `WHERE` clause in SQL. There is no limit on
/// the number of columns where `filter` is applied, but the resulting
/// dataset is one that passes all the filter conditions, i.e. the
/// filters are joined with an `AND` condition.
///
/// Perspective represents `filter` as an array of arrays, with the values
/// of each inner array being a string column name, a string filter
/// operator, and a filter operand in the type of the column.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
#[ts(optional)]
pub filter: Option<Vec<Filter>>,
/// The `sort` property specifies columns on which the query should be
/// sorted, analogous to `ORDER BY` in SQL. A column can be sorted
/// regardless of its data type, and sorts can be applied in ascending
/// or descending order. Perspective represents `sort` as an array of
/// arrays, with the values of each inner array being a string column
/// name and a string sort direction. When `column-pivots` are applied,
/// the additional sort directions `"col asc"` and `"col desc"` will
/// determine the order of pivot columns groups.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
#[ts(optional)]
pub sort: Option<Vec<Sort>>,
/// The `expressions` property specifies _new_ columns in Perspective that
/// are created using existing column values or arbitary scalar values
/// defined within the expression. In `<perspective-viewer>`,
/// expressions are added using the "New Column" button in the side
/// panel.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
#[ts(optional)]
pub expressions: Option<Expressions>,
/// Aggregates perform a calculation over an entire column, and are
/// displayed when one or more [Group By](#group-by) are applied to the
/// `View`. Aggregates can be specified by the user, or Perspective will
/// use the following sensible default aggregates based on column type:
///
/// - "sum" for `integer` and `float` columns
/// - "count" for all other columns
///
/// Perspective provides a selection of aggregate functions that can be
/// applied to columns in the `View` constructor using a dictionary of
/// column name to aggregate function name.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
#[ts(optional)]
pub aggregates: Option<HashMap<String, Aggregate>>,
#[serde(skip_serializing)]
#[serde(default)]
#[ts(optional)]
pub group_by_depth: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
#[ts(optional)]
pub filter_op: Option<FilterReducer>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
#[ts(optional)]
pub group_rollup_mode: Option<GroupRollupMode>,
}
impl From<ViewConfigUpdate> for proto::ViewConfig {
fn from(value: ViewConfigUpdate) -> Self {
proto::ViewConfig {
group_by: value.group_by.unwrap_or_default(),
split_by: value.split_by.unwrap_or_default(),
columns: value.columns.map(|x| proto::ColumnsUpdate {
opt_columns: Some(columns_update::OptColumns::Columns(
proto::columns_update::Columns {
columns: x.into_iter().flatten().collect(),
},
)),
}),
filter: value
.filter
.unwrap_or_default()
.into_iter()
.map(|x| x.into())
.collect(),
filter_op: value
.filter_op
.map(proto::view_config::FilterReducer::from)
.unwrap_or_default() as i32,
sort: value
.sort
.unwrap_or_default()
.into_iter()
.map(|x| x.into())
.collect(),
expressions: value.expressions.unwrap_or_default().0,
aggregates: value
.aggregates
.unwrap_or_default()
.into_iter()
.map(|(x, y)| (x, y.into()))
.collect(),
group_by_depth: value.group_by_depth,
group_rollup_mode: value
.group_rollup_mode
.map(|x| proto::GroupRollupMode::from(x).into()),
}
}
}
impl From<FilterReducer> for proto::view_config::FilterReducer {
fn from(value: FilterReducer) -> Self {
match value {
FilterReducer::And => proto::view_config::FilterReducer::And,
FilterReducer::Or => proto::view_config::FilterReducer::Or,
}
}
}
impl From<proto::view_config::FilterReducer> for FilterReducer {
fn from(value: proto::view_config::FilterReducer) -> Self {
match value {
proto::view_config::FilterReducer::And => FilterReducer::And,
proto::view_config::FilterReducer::Or => FilterReducer::Or,
}
}
}
impl From<ViewConfig> for ViewConfigUpdate {
fn from(value: ViewConfig) -> Self {
ViewConfigUpdate {
group_by: Some(value.group_by),
split_by: Some(value.split_by),
columns: Some(value.columns),
filter: Some(value.filter),
filter_op: Some(value.filter_op),
sort: Some(value.sort),
expressions: Some(value.expressions),
aggregates: Some(value.aggregates),
group_by_depth: value.group_by_depth,
group_rollup_mode: Some(value.group_rollup_mode),
}
}
}
impl From<proto::ViewConfig> for ViewConfig {
fn from(value: proto::ViewConfig) -> Self {
ViewConfig {
group_by: value.group_by,
split_by: value.split_by,
columns: match value.columns.unwrap_or_default().opt_columns {
Some(columns_update::OptColumns::Columns(x)) => {
x.columns.into_iter().map(Some).collect()
},
_ => {
vec![]
},
},
filter: value.filter.into_iter().map(|x| x.into()).collect(),
filter_op: proto::view_config::FilterReducer::try_from(value.filter_op)
.unwrap_or_default()
.into(),
sort: value.sort.into_iter().map(|x| x.into()).collect(),
expressions: Expressions(value.expressions),
aggregates: value
.aggregates
.into_iter()
.map(|(x, y)| (x, y.into()))
.collect(),
group_by_depth: value.group_by_depth,
group_rollup_mode: value
.group_rollup_mode
.map(proto::GroupRollupMode::try_from)
.and_then(|x| x.ok())
.map(|x| x.into())
.unwrap_or_default(),
}
}
}
impl From<ViewConfigUpdate> for ViewConfig {
fn from(value: ViewConfigUpdate) -> Self {
ViewConfig {
group_by: value.group_by.unwrap_or_default(),
split_by: value.split_by.unwrap_or_default(),
columns: value.columns.unwrap_or_default(),
filter: value.filter.unwrap_or_default(),
filter_op: value.filter_op.unwrap_or_default(),
sort: value.sort.unwrap_or_default(),
expressions: value.expressions.unwrap_or_default(),
aggregates: value.aggregates.unwrap_or_default(),
group_by_depth: value.group_by_depth,
group_rollup_mode: value.group_rollup_mode.unwrap_or_default(),
}
}
}
impl From<proto::ViewConfig> for ViewConfigUpdate {
fn from(value: proto::ViewConfig) -> Self {
ViewConfigUpdate {
group_by: Some(value.group_by),
split_by: Some(value.split_by),
columns: match value.columns.unwrap_or_default().opt_columns {
Some(columns_update::OptColumns::Columns(x)) => {
Some(x.columns.into_iter().map(Some).collect())
},
_ => None,
},
filter: Some(value.filter.into_iter().map(|x| x.into()).collect()),
filter_op: Some(
proto::view_config::FilterReducer::try_from(value.filter_op)
.unwrap_or_default()
.into(),
),
sort: Some(value.sort.into_iter().map(|x| x.into()).collect()),
expressions: Some(Expressions(value.expressions)),
aggregates: Some(
value
.aggregates
.into_iter()
.map(|(x, y)| (x, y.into()))
.collect(),
),
group_by_depth: value.group_by_depth,
group_rollup_mode: value
.group_rollup_mode
.and_then(|x| proto::GroupRollupMode::try_from(x).ok())
.map(|x| x.into()),
}
}
}
impl ViewConfig {
fn _apply<T>(field: &mut T, update: Option<T>) -> bool {
match update {
None => false,
Some(update) => {
*field = update;
true
},
}
}
pub fn reset(&mut self, reset_expressions: bool) {
let mut config = Self::default();
if !reset_expressions {
config.expressions = self.expressions.clone();
}
std::mem::swap(self, &mut config);
}
/// Apply `ViewConfigUpdate` to a `ViewConfig`, ignoring any fields in
/// `update` which were unset.
pub fn apply_update(&mut self, mut update: ViewConfigUpdate) -> bool {
let mut changed = false;
if ((self.group_rollup_mode == GroupRollupMode::Total
&& update.group_rollup_mode.is_none())
|| update.group_rollup_mode == Some(GroupRollupMode::Total))
&& update
.group_by
.as_ref()
.map(|x| !x.is_empty())
.unwrap_or_default()
{
tracing::info!("`total` incompatible with `group_by`");
changed = true;
update.group_rollup_mode = Some(GroupRollupMode::Rollup);
}
if update.group_rollup_mode == Some(GroupRollupMode::Total) && !self.group_by.is_empty() {
tracing::info!("`group_by` incompatible with `total`");
changed = true;
update.group_by = Some(vec![]);
}
changed = Self::_apply(&mut self.group_by, update.group_by) || changed;
changed = Self::_apply(&mut self.split_by, update.split_by) || changed;
changed = Self::_apply(&mut self.columns, update.columns) || changed;
changed = Self::_apply(&mut self.filter, update.filter) || changed;
changed = Self::_apply(&mut self.sort, update.sort) || changed;
changed = Self::_apply(&mut self.aggregates, update.aggregates) || changed;
changed = Self::_apply(&mut self.expressions, update.expressions) || changed;
changed = Self::_apply(&mut self.group_rollup_mode, update.group_rollup_mode) || changed;
if self.group_rollup_mode == GroupRollupMode::Total && !self.group_by.is_empty() {
tracing::info!("`total` incompatible with `group_by`");
changed = true;
self.group_by = vec![];
}
changed
}
pub fn is_aggregated(&self) -> bool {
!self.group_by.is_empty() || self.group_rollup_mode == GroupRollupMode::Total
}
pub fn is_column_expression_in_use(&self, name: &str) -> bool {
let name = name.to_owned();
self.group_by.contains(&name)
|| self.split_by.contains(&name)
|| self.sort.iter().any(|x| x.0 == name)
|| self.filter.iter().any(|x| x.column() == name)
|| self.columns.contains(&Some(name))
}
/// `ViewConfig` carries additional metadata in the form of `None` columns
/// which are filtered befor ebeing passed to the engine, but whose position
/// is a placeholder for Viewer functionality. `is_equivalent` tests
/// equivalency from the perspective of the engine.
pub fn is_equivalent(&self, other: &Self) -> bool {
let _self = self.clone();
let _self = ViewConfig {
columns: _self.columns.into_iter().filter(|x| x.is_some()).collect(),
.._self
};
let _other = other.clone();
let _other = ViewConfig {
columns: _other.columns.into_iter().filter(|x| x.is_some()).collect(),
..other.clone()
};
_self == _other
}
}
+149
View File
@@ -0,0 +1,149 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
//! `perspective_client` is the client implementation of
//! [Perspective](https://perspective-dev.github.io), designed to be used from Rust
//! directly, and as a core to `perspective-js` and `perspective-python` crates
//! which wrap language-specific bindings for this module.
//!
//! # See also
//!
//! - [`perspective-rs`](https://docs.rs/perspective/latest/) for the Rust
//! Client and Server APIs.
//! - [`perspective-js`](https://docs.rs/perspective-js/latest/) for the
//! JavaScript API.
//! - [`perspective-python`](https://docs.rs/perspective-python/latest/) for the
//! Python API.
//! - [`perspective-server`](https://docs.rs/perspective-server/latest/) for
//! Data Binding details.
//! - [`perspective-viewer`](https://docs.rs/perspective-viewer/latest/) for the
//! WebAssembly `<perspective-viewer>` Custom Element API.
#![warn(
clippy::all,
clippy::panic_in_result_fn,
clippy::await_holding_refcell_ref
)]
mod client;
mod session;
mod table;
mod table_data;
mod table_ref;
mod view;
pub mod virtual_server;
pub mod config;
#[rustfmt::skip]
#[allow(clippy::all)]
pub mod proto;
pub mod utils;
pub use crate::client::{Client, ClientHandler, Features, ReconnectCallback, SystemInfo};
use crate::proto::HostedTable;
pub use crate::proto::JoinType;
pub use crate::session::{ProxySession, Session};
pub use crate::table::{
DeleteOptions, ExprValidationResult, JoinOptions, Table, TableInitOptions, TableReadFormat,
UpdateOptions,
};
pub use crate::table_data::{TableData, UpdateData};
pub use crate::table_ref::TableRef;
pub use crate::view::{
ColumnWindow, OnUpdateData, OnUpdateMode, OnUpdateOptions, View, ViewWindow,
};
pub type ClientError = utils::ClientError;
pub type ExprValidationError = crate::proto::table_validate_expr_resp::ExprValidationError;
#[doc(hidden)]
pub mod vendor {
pub use paste;
}
impl From<&str> for HostedTable {
fn from(entity_id: &str) -> Self {
HostedTable {
entity_id: entity_id.to_string(),
index: None,
limit: None,
}
}
}
/// Assert that an implementation of domain language wrapper for [`Table`]
/// implements the expected API. As domain languages have different API needs,
/// a trait isn't useful for asserting that the entire API is implemented,
/// because the signatures will not match exactly (unless every method is
/// made heavily generic). Instead, this macro complains when a method name
/// is missing.
#[doc(hidden)]
#[macro_export]
macro_rules! assert_table_api {
($x:ty) => {
$crate::vendor::paste::paste! {
#[cfg(debug_assertions)]
fn [< _assert_table_api_ $x:lower >]() {
let _ = (
&$x::clear,
&$x::columns,
&$x::delete,
&$x::get_index,
&$x::get_limit,
&$x::get_client,
&$x::make_port,
&$x::on_delete,
&$x::remove_delete,
&$x::replace,
&$x::schema,
&$x::size,
&$x::update,
&$x::validate_expressions,
&$x::view,
);
}
}
};
}
/// Similar to [`assert_table_api`], but for [`View`]. See [`assert_table_api`].
#[doc(hidden)]
#[macro_export]
macro_rules! assert_view_api {
($x:ty) => {
$crate::vendor::paste::paste! {
#[cfg(debug_assertions)]
fn [< _assert_table_api_ $x:lower >]() {
let _ = (
&$x::column_paths,
&$x::delete,
&$x::dimensions,
&$x::expression_schema,
&$x::get_config,
&$x::get_min_max,
&$x::num_rows,
// &$x::on_update,
&$x::remove_update,
&$x::on_delete,
&$x::remove_delete,
&$x::schema,
&$x::to_arrow,
&$x::to_columns_string,
&$x::to_json_string,
&$x::to_csv,
);
}
}
};
}
+110
View File
@@ -0,0 +1,110 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::error::Error as StdError;
use std::sync::Arc;
use futures::Future;
use prost::Message;
use crate::proto::request::ClientReq;
use crate::proto::{Request, Response};
use crate::{Client, ClientError, asyncfn};
#[cfg(doc)]
use crate::{Table, View};
/// The server-side representation of a connection to a [`Client`].
///
/// For each [`Client`] that wants to connect to a `perspective_server::Server`,
/// a dedicated [`Session`] must be created. The [`Session`] handles routing
/// messages emitted by the `perspective_server::Server`ve_server::Server`, as
/// well as owning any resources the [`Client`] may request.
pub trait Session<E> {
/// Handle an incoming request from the [`Client`]. Calling
/// [`Session::handle_request`] will result in the `send_response` parameter
/// which was used to construct this [`Session`] to fire one or more times.
///
/// # Arguments
///
/// - `request` An incoming request message, generated from a
/// [`Client::new`]'s `send_request` handler (which may-or-may-not be
/// local).
fn handle_request(&self, request: &[u8]) -> impl Future<Output = Result<(), E>>;
/// Close this [`Session`], cleaning up any callbacks (e.g. arguments
/// provided to [`Session::handle_request`]) and resources (e.g. views
/// returned by a call to [`Table::view`]).
///
/// Dropping a [`Session`] outside of the context of [`Session::close`]
/// will cause a [`tracing`] error-level log to be emitted, but won't fail.
/// They will, however, leak.
fn close(self) -> impl Future<Output = ()>;
}
type ProxyCallbackError = Box<dyn StdError + Send + Sync>;
type ProxyCallback = Arc<dyn Fn(&[u8]) -> Result<(), ProxyCallbackError> + Send + Sync>;
/// A [`Session`] implementation which tunnels through another [`Client`].
/// @private
#[derive(Clone)]
pub struct ProxySession {
parent: Client,
callback: ProxyCallback,
}
impl ProxySession {
pub fn new(
client: Client,
send_response: impl Fn(&[u8]) -> Result<(), ProxyCallbackError> + Send + Sync + 'static,
) -> Self {
ProxySession {
parent: client,
callback: Arc::new(send_response),
}
}
}
fn encode(response: Response, callback: ProxyCallback) -> Result<(), ClientError> {
let mut enc = vec![];
response.encode(&mut enc)?;
callback(&enc).map_err(|x| ClientError::Unknown(x.to_string()))?;
Ok(())
}
impl Session<ClientError> for ProxySession {
async fn handle_request(&self, request: &[u8]) -> Result<(), ClientError> {
let req = Request::decode(request)?;
let callback = self.callback.clone();
match req.client_req.as_ref() {
Some(ClientReq::ViewOnUpdateReq(_)) => {
let on_update =
asyncfn!(callback, async move |response| encode(response, callback));
self.parent.subscribe(&req, on_update).await?
},
Some(_) => {
let on_update = move |response| encode(response, callback);
self.parent
.subscribe_once(&req, Box::new(on_update))
.await?
},
None => {
return Err(ClientError::Internal(
"ProxySession::handle_request: invalid request".to_string(),
));
},
};
Ok(())
}
async fn close(self) {}
}
+620
View File
@@ -0,0 +1,620 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::collections::HashMap;
use std::fmt::Display;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::assert_table_api;
use crate::client::{Client, Features};
use crate::config::{Expressions, ViewConfigUpdate};
use crate::proto::make_table_req::MakeTableOptions;
use crate::proto::make_table_req::make_table_options::MakeTableType;
use crate::proto::request::ClientReq;
use crate::proto::response::ClientResp;
use crate::proto::*;
use crate::table_data::UpdateData;
use crate::utils::*;
use crate::view::View;
pub type Schema = HashMap<String, ColumnType>;
/// The format to interpret data preovided to [`Client::table`].
///
/// When serialized, these values are `"csv"`, `"json"`, `"columns"`, `"arrow"`
/// and `"ndjson"`.
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
pub enum TableReadFormat {
#[serde(rename = "csv")]
Csv,
#[serde(rename = "json")]
JsonString,
#[serde(rename = "columns")]
ColumnsString,
#[serde(rename = "arrow")]
Arrow,
#[serde(rename = "ndjson")]
Ndjson,
}
impl TableReadFormat {
pub fn parse(value: Option<String>) -> Result<Option<Self>, String> {
Ok(match value.as_deref() {
Some("csv") => Some(TableReadFormat::Csv),
Some("json") => Some(TableReadFormat::JsonString),
Some("columns") => Some(TableReadFormat::ColumnsString),
Some("arrow") => Some(TableReadFormat::Arrow),
Some("ndjson") => Some(TableReadFormat::Ndjson),
None => None,
Some(x) => return Err(format!("Unknown format \"{x}\"")),
})
}
}
/// Options which impact the behavior of [`Client::table`], as well as
/// subsequent calls to [`Table::update`].
#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)]
pub struct TableInitOptions {
#[serde(default)]
#[ts(optional)]
pub name: Option<String>,
#[serde(default)]
#[ts(optional)]
pub format: Option<TableReadFormat>,
/// This [`Table`] should use the column named by the `index` parameter as
/// the `index`, which causes [`Table::update`] and [`Client::table`] input
/// to either insert or update existing rows based on `index` column
/// value equality.
#[serde(default)]
#[ts(optional)]
pub index: Option<String>,
/// This [`Table`] should be limited to `limit` rows, after which the
/// _earliest_ rows will be overwritten (where _earliest_ is defined as
/// relative to insertion order).
#[serde(default)]
#[ts(optional)]
pub limit: Option<u32>,
/// Back this [`Table`]'s canonical data with the on-disk storage backend
/// instead of memory. On native targets this is a memory-mapped file; on
/// WASM it is OPFS (Worker only). Defaults to in-memory.
#[serde(default)]
#[ts(optional)]
pub page_to_disk: Option<bool>,
}
impl TableInitOptions {
pub fn set_name<D: Display>(&mut self, name: D) {
self.name = Some(format!("{name}"))
}
}
impl TryFrom<TableOptions> for MakeTableOptions {
type Error = ClientError;
fn try_from(value: TableOptions) -> Result<Self, Self::Error> {
let page_to_disk = value.page_to_disk;
Ok(MakeTableOptions {
page_to_disk,
make_table_type: match value {
TableOptions {
index: Some(_),
limit: Some(_),
..
} => Err(ClientError::BadTableOptions)?,
TableOptions {
index: Some(index), ..
} => Some(MakeTableType::MakeIndexTable(index)),
TableOptions {
limit: Some(limit), ..
} => Some(MakeTableType::MakeLimitTable(limit)),
_ => None,
},
})
}
}
#[derive(Clone, Debug)]
pub(crate) struct TableOptions {
pub index: Option<String>,
pub limit: Option<u32>,
pub page_to_disk: Option<bool>,
}
impl From<TableInitOptions> for TableOptions {
fn from(value: TableInitOptions) -> Self {
TableOptions {
index: value.index,
limit: value.limit,
page_to_disk: value.page_to_disk,
}
}
}
/// Options for [`Client::join`].
#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)]
pub struct JoinOptions {
#[serde(default)]
#[ts(optional)]
pub join_type: Option<crate::proto::JoinType>,
#[serde(default)]
#[ts(optional)]
pub name: Option<String>,
#[serde(default)]
#[ts(optional)]
pub right_on: Option<String>,
}
/// Options for [`Table::delete`].
#[derive(Clone, Debug, Default, Deserialize, TS)]
pub struct DeleteOptions {
pub lazy: bool,
}
/// Options for [`Table::update`].
#[derive(Clone, Debug, Default, Deserialize, Serialize, TS)]
pub struct UpdateOptions {
pub port_id: Option<u32>,
pub format: Option<TableReadFormat>,
}
/// Result of a call to [`Table::validate_expressions`], containing a schema
/// for valid expressions and error messages for invalid ones.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ExprValidationResult {
pub expression_schema: Schema,
pub errors: HashMap<String, table_validate_expr_resp::ExprValidationError>,
pub expression_alias: HashMap<String, String>,
}
/// [`Table`] is Perspective's columnar data frame, analogous to a Pandas/Polars
/// `DataFrame` or Apache Arrow, supporting append & in-place updates, removal
/// by index, and update notifications.
///
/// A [`Table`] contains columns, each of which have a unique name, are strongly
/// and consistently typed, and contains rows of data conforming to the column's
/// type. Each column in a [`Table`] must have the same number of rows, though
/// not every row must contain data; null-values are used to indicate missing
/// values in the dataset. The schema of a [`Table`] is _immutable after
/// creation_, which means the column names and data types cannot be changed
/// after the [`Table`] has been created. Columns cannot be added or deleted
/// after creation either, but a [`View`] can be used to select an arbitrary set
/// of columns from the [`Table`].
#[derive(Clone)]
pub struct Table {
name: String,
client: Client,
options: TableOptions,
/// If this table is constructed from a View, the view's on_update callback
/// is wired into this table. So, we store the token to clean it up properly
/// on destruction.
pub(crate) view_update_token: Option<u32>,
}
assert_table_api!(Table);
impl PartialEq for Table {
fn eq(&self, other: &Self) -> bool {
self.name == other.name && self.client == other.client
}
}
impl Table {
pub(crate) fn new(name: String, client: Client, options: TableOptions) -> Self {
Table {
name,
client,
options,
view_update_token: None,
}
}
fn client_message(&self, req: ClientReq) -> Request {
Request {
msg_id: self.client.gen_id(),
entity_id: self.name.clone(),
client_req: Some(req),
}
}
/// Get a copy of the [`Client`] this [`Table`] came from.
pub fn get_client(&self) -> Client {
self.client.clone()
}
/// Get a metadata dictionary of the `perspective_server::Server`'s
/// features, which is (currently) implementation specific, but there is
/// only one implementation.
pub async fn get_features(&self) -> ClientResult<Features> {
self.client.get_features().await
}
/// Returns the name of the index column for the table.
///
/// # Examples
///
/// ```no_run
/// # use perspective_client::{Client, TableData, TableInitOptions, UpdateData};
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
/// # let client: Client = todo!();
/// let options = TableInitOptions {
/// index: Some("x".to_string()),
/// ..TableInitOptions::default()
/// };
/// let data = TableData::Update(UpdateData::Csv("x,y\n1,2\n3,4".into()));
/// let table = client.table(data, options).await?;
/// let index = table.get_index();
/// # Ok(()) }
/// ```
pub fn get_index(&self) -> Option<String> {
self.options.index.as_ref().map(|index| index.to_owned())
}
/// Returns the user-specified row limit for this table.
pub fn get_limit(&self) -> Option<u32> {
self.options.limit.as_ref().map(|limit| *limit)
}
/// Returns the user-specified name for this table, or the auto-generated
/// name if a name was not specified when the table was created.
pub fn get_name(&self) -> &str {
self.name.as_str()
}
/// Removes all the rows in the [`Table`], but preserves everything else
/// including the schema, index, and any callbacks or registered
/// [`View`] instances.
///
/// Calling [`Table::clear`], like [`Table::update`] and [`Table::remove`],
/// will trigger an update event to any registered listeners via
/// [`View::on_update`].
pub async fn clear(&self) -> ClientResult<()> {
self.replace(UpdateData::JsonRows("[]".to_owned())).await
}
/// Delete this [`Table`] and cleans up associated resources.
///
/// [`Table`]s do not stop consuming resources or processing updates when
/// they are garbage collected in their host language - you must call
/// this method to reclaim these.
///
/// # Arguments
///
/// - `options` An options dictionary.
/// - `lazy` Whether to delete this [`Table`] _lazily_. When false (the
/// default), the delete will occur immediately, assuming it has no
/// [`View`] instances registered to it (which must be deleted first,
/// otherwise this method will throw an error). When true, the
/// [`Table`] will only be marked for deltion once its [`View`]
/// dependency count reaches 0.
///
/// # Examples
///
/// ```no_run
/// # use perspective_client::{Client, DeleteOptions, TableData, TableInitOptions, UpdateData};
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
/// # let client: Client = todo!();
/// let opts = TableInitOptions::default();
/// let data = TableData::Update(UpdateData::Csv("x,y\n1,2\n3,4".into()));
/// let table = client.table(data, opts).await?;
///
/// // ...
///
/// table.delete(DeleteOptions::default()).await?;
/// # Ok(()) }
/// ```
pub async fn delete(&self, options: DeleteOptions) -> ClientResult<()> {
let msg = self.client_message(ClientReq::TableDeleteReq(TableDeleteReq {
is_immediate: !options.lazy,
}));
match self.client.oneshot(&msg).await? {
ClientResp::TableDeleteResp(_) => Ok(()),
resp => Err(resp.into()),
}
}
/// Returns the column names of this [`Table`] in "natural" order (the
/// ordering implied by the input format).
///
/// # Examples
///
/// ```no_run
/// # use perspective_client::Table;
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
/// # let table: Table = todo!();
/// let columns = table.columns().await?;
/// # Ok(()) }
/// ```
pub async fn columns(&self) -> ClientResult<Vec<String>> {
let msg = self.client_message(ClientReq::TableSchemaReq(TableSchemaReq {}));
match self.client.oneshot(&msg).await? {
ClientResp::TableSchemaResp(TableSchemaResp { schema }) => Ok(schema
.map(|x| x.schema.into_iter().map(|x| x.name.to_owned()).collect())
.unwrap()),
resp => Err(resp.into()),
}
}
/// Returns the number of rows in a [`Table`].
pub async fn size(&self) -> ClientResult<usize> {
let msg = self.client_message(ClientReq::TableSizeReq(TableSizeReq {}));
match self.client.oneshot(&msg).await? {
ClientResp::TableSizeResp(TableSizeResp { size }) => Ok(size as usize),
resp => Err(resp.into()),
}
}
/// Returns a table's [`Schema`], a mapping of column names to column types.
///
/// The mapping of a [`Table`]'s column names to data types is referred to
/// as a [`Schema`]. Each column has a unique name and a data type, one
/// of:
///
/// - `"boolean"` - A boolean type
/// - `"date"` - A timesonze-agnostic date type (month/day/year)
/// - `"datetime"` - A millisecond-precision datetime type in the UTC
/// timezone
/// - `"float"` - A 64 bit float
/// - `"integer"` - A signed 32 bit integer (the integer type supported by
/// JavaScript)
/// - `"string"` - A [`String`] data type (encoded internally as a
/// _dictionary_)
///
/// Note that all [`Table`] columns are _nullable_, regardless of the data
/// type.
pub async fn schema(&self) -> ClientResult<Schema> {
let msg = self.client_message(ClientReq::TableSchemaReq(TableSchemaReq {}));
match self.client.oneshot(&msg).await? {
ClientResp::TableSchemaResp(TableSchemaResp { schema }) => Ok(schema
.map(|x| {
x.schema
.into_iter()
.map(|x| (x.name, ColumnType::try_from(x.r#type).unwrap()))
.collect()
})
.unwrap()),
resp => Err(resp.into()),
}
}
/// Create a unique channel ID on this [`Table`], which allows
/// `View::on_update` callback calls to be associated with the
/// `Table::update` which caused them.
pub async fn make_port(&self) -> ClientResult<i32> {
let msg = self.client_message(ClientReq::TableMakePortReq(TableMakePortReq {}));
match self.client.oneshot(&msg).await? {
ClientResp::TableMakePortResp(TableMakePortResp { port_id }) => Ok(port_id as i32),
_ => Err(ClientError::Unknown("make_port".to_string())),
}
}
/// Register a callback which is called exactly once, when this [`Table`] is
/// deleted with the [`Table::delete`] method.
///
/// [`Table::on_delete`] resolves when the subscription message is sent, not
/// when the _delete_ event occurs.
pub async fn on_delete(
&self,
on_delete: Box<dyn Fn() + Send + Sync + 'static>,
) -> ClientResult<u32> {
let callback = move |resp: Response| match resp.client_resp {
Some(ClientResp::TableOnDeleteResp(_)) => {
on_delete();
Ok(())
},
resp => Err(resp.into()),
};
let msg = self.client_message(ClientReq::TableOnDeleteReq(TableOnDeleteReq {}));
self.client.subscribe_once(&msg, Box::new(callback)).await?;
Ok(msg.msg_id)
}
/// Removes a listener with a given ID, as returned by a previous call to
/// [`Table::on_delete`].
pub async fn remove_delete(&self, callback_id: u32) -> ClientResult<()> {
let msg = self.client_message(ClientReq::TableRemoveDeleteReq(TableRemoveDeleteReq {
id: callback_id,
}));
match self.client.oneshot(&msg).await? {
ClientResp::TableRemoveDeleteResp(_) => Ok(()),
resp => Err(resp.into()),
}
}
/// Removes rows from this [`Table`] with the `index` column values
/// supplied.
///
/// # Arguments
///
/// - `indices` - A list of `index` column values for rows that should be
/// removed.
///
/// # Examples
///
/// ```no_run
/// # use perspective_client::{Table, UpdateData};
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
/// # let table: Table = todo!();
/// table
/// .remove(UpdateData::Csv("index\n1\n2\n3".into()))
/// .await?;
/// # Ok(()) }
/// ```
pub async fn remove(&self, input: UpdateData) -> ClientResult<()> {
let msg = self.client_message(ClientReq::TableRemoveReq(TableRemoveReq {
data: Some(input.into()),
}));
match self.client.oneshot(&msg).await? {
ClientResp::TableRemoveResp(_) => Ok(()),
resp => Err(resp.into()),
}
}
/// Replace all rows in this [`Table`] with the input data, coerced to this
/// [`Table`]'s existing [`Schema`], notifying any derived [`View`] and
/// [`View::on_update`] callbacks.
///
/// Calling [`Table::replace`] is an easy way to replace _all_ the data in a
/// [`Table`] without losing any derived [`View`] instances or
/// [`View::on_update`] callbacks. [`Table::replace`] does _not_ infer
/// data types like [`Client::table`] does, rather it _coerces_ input
/// data to the `Schema` like [`Table::update`]. If you need a [`Table`]
/// with a different `Schema`, you must create a new one.
///
/// # Examples
///
/// ```no_run
/// # use perspective_client::{Table, UpdateData};
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
/// # let table: Table = todo!();
/// let data = UpdateData::Csv("x,y\n1,2".into());
/// table.replace(data).await?;
/// # Ok(()) }
/// ```
pub async fn replace(&self, input: UpdateData) -> ClientResult<()> {
let msg = self.client_message(ClientReq::TableReplaceReq(TableReplaceReq {
data: Some(input.into()),
}));
match self.client.oneshot(&msg).await? {
ClientResp::TableReplaceResp(_) => Ok(()),
resp => Err(resp.into()),
}
}
/// Updates the rows of this table and any derived [`View`] instances.
///
/// Calling [`Table::update`] will trigger the [`View::on_update`] callbacks
/// register to derived [`View`], and the call itself will not resolve until
/// _all_ derived [`View`]'s are notified.
///
/// When updating a [`Table`] with an `index`, [`Table::update`] supports
/// partial updates, by omitting columns from the update data.
///
/// # Arguments
///
/// - `input` - The input data for this [`Table`]. The schema of a [`Table`]
/// is immutable after creation, so this method cannot be called with a
/// schema.
/// - `options` - Options for this update step - see [`UpdateOptions`].
///
/// # Examples
///
/// ```no_run
/// # use perspective_client::{Table, UpdateData, UpdateOptions};
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
/// # let table: Table = todo!();
/// let data = UpdateData::Csv("x,y\n1,2".into());
/// let opts = UpdateOptions::default();
/// table.update(data, opts).await?;
/// # Ok(()) }
/// ```
pub async fn update(&self, input: UpdateData, options: UpdateOptions) -> ClientResult<()> {
let msg = self.client_message(ClientReq::TableUpdateReq(TableUpdateReq {
data: Some(input.into()),
port_id: options.port_id.unwrap_or(0),
}));
match self.client.oneshot(&msg).await? {
ClientResp::TableUpdateResp(_) => Ok(()),
resp => Err(resp.into()),
}
}
/// Validates the given expressions.
pub async fn validate_expressions(
&self,
expressions: Expressions,
) -> ClientResult<ExprValidationResult> {
let msg = self.client_message(ClientReq::TableValidateExprReq(TableValidateExprReq {
column_to_expr: expressions.0,
}));
match self.client.oneshot(&msg).await? {
ClientResp::TableValidateExprResp(result) => Ok(ExprValidationResult {
errors: result.errors,
expression_alias: result.expression_alias,
expression_schema: result
.expression_schema
.into_iter()
.map(|(x, y)| (x, ColumnType::try_from(y).unwrap()))
.collect(),
}),
resp => Err(resp.into()),
}
}
/// Create a new [`View`] from this table with a specified
/// [`ViewConfigUpdate`].
///
/// See [`View`] struct.
///
/// # Examples
///
/// ```no_run
/// # use std::collections::HashMap;
/// # use perspective_client::Table;
/// # use perspective_client::config::*;
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
/// # let table: Table = todo!();
/// let view = table
/// .view(Some(ViewConfigUpdate {
/// columns: Some(vec![Some("Sales".into())]),
/// aggregates: Some(HashMap::from_iter(vec![("Sales".into(), "sum".into())])),
/// group_by: Some(vec!["Region".into(), "Country".into()]),
/// filter: Some(vec![Filter::new("Category", "in", &[
/// "Furniture",
/// "Technology",
/// ])]),
/// ..ViewConfigUpdate::default()
/// }))
/// .await?;
/// # Ok(()) }
/// ```
pub async fn view(&self, config: Option<ViewConfigUpdate>) -> ClientResult<View> {
let view_name = randid();
let msg = Request {
msg_id: self.client.gen_id(),
entity_id: self.name.clone(),
client_req: ClientReq::TableMakeViewReq(TableMakeViewReq {
view_id: view_name.clone(),
config: config.map(|x| x.into()),
})
.into(),
};
match self.client.oneshot(&msg).await? {
ClientResp::TableMakeViewResp(TableMakeViewResp { view_id })
if view_id == view_name =>
{
Ok(View::new(view_name, self.client.clone()))
},
resp => Err(resp.into()),
}
}
}
@@ -0,0 +1,79 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use prost::bytes::Bytes;
use crate::proto;
use crate::proto::*;
use crate::view::View;
#[cfg(doc)]
use crate::{Client, Table};
/// The possible formats of input data which [`Client::table`] may take as an
/// argument.
#[derive(Debug)]
pub enum TableData {
Schema(Vec<(String, ColumnType)>),
Update(UpdateData),
View(View),
}
/// The possible formats of input data which [`Table::update`] may take as an
/// argument.
#[derive(Debug)]
pub enum UpdateData {
Csv(String),
Arrow(Bytes),
JsonRows(String),
JsonColumns(String),
Ndjson(String),
}
impl From<UpdateData> for TableData {
fn from(value: UpdateData) -> Self {
TableData::Update(value)
}
}
impl From<TableData> for proto::MakeTableData {
fn from(value: TableData) -> Self {
let data = match value {
TableData::Update(x) => return x.into(),
TableData::View(view) => make_table_data::Data::FromView(view.name),
TableData::Schema(x) => make_table_data::Data::FromSchema(proto::Schema {
schema: x
.into_iter()
.map(|(name, r#type)| schema::KeyTypePair {
name,
r#type: r#type as i32,
})
.collect(),
}),
};
MakeTableData { data: Some(data) }
}
}
impl From<UpdateData> for proto::MakeTableData {
fn from(value: UpdateData) -> Self {
let data = match value {
UpdateData::Csv(x) => make_table_data::Data::FromCsv(x),
UpdateData::Arrow(x) => make_table_data::Data::FromArrow(x.into()),
UpdateData::JsonRows(x) => make_table_data::Data::FromRows(x),
UpdateData::JsonColumns(x) => make_table_data::Data::FromCols(x),
UpdateData::Ndjson(x) => make_table_data::Data::FromNdjson(x),
};
MakeTableData { data: Some(data) }
}
}
@@ -0,0 +1,53 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use crate::Table;
/// A reference to a table, either by handle or by name.
#[derive(Clone)]
pub enum TableRef {
Table(Table),
Name(String),
}
impl TableRef {
pub fn table_name(&self) -> &str {
match self {
TableRef::Table(table) => table.get_name(),
TableRef::Name(name) => name,
}
}
}
impl From<&Table> for TableRef {
fn from(table: &Table) -> Self {
TableRef::Table(table.clone())
}
}
impl From<Table> for TableRef {
fn from(table: Table) -> Self {
TableRef::Table(table)
}
}
impl From<String> for TableRef {
fn from(name: String) -> Self {
TableRef::Name(name)
}
}
impl From<&str> for TableRef {
fn from(name: &str) -> Self {
TableRef::Name(name.to_owned())
}
}
@@ -0,0 +1,141 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
/// A helper to for the pattern `let x2 = x;` necessary to clone structs
/// destined for an `async` or `'static` closure stack. This is like `move || {
/// .. }` or `move async { .. }`, but for clone semantics. `clone!()` works
/// with symbols as well as properties and methods, using the last symbol name
/// in the method chain, or an alias via `x = ...` syntax.
/// ```
#[doc(hidden)]
#[macro_export]
macro_rules! clone {
(impl @bind $i:tt { $($orig:tt)* } { }) => {
let $i = $($orig)*.clone();
};
(impl @bind $i:tt { $($orig:tt)* } { @mut }) => {
let mut $i = $($orig)*.clone();
};
(impl @bind $i:tt { $($orig:tt)* } { $binder:tt }) => {
let $binder = $($orig)*.clone();
};
(impl @bind $i:tt { $($orig:tt)* } { @mut $binder:tt }) => {
let mut $binder = $($orig)*.clone();
};
(impl @expand { $($orig:tt)* } { $($binder:tt)* } $i:tt) => {
$crate::clone!(impl @bind $i { $($orig)* $i } { $($binder)* });
};
(impl @expand { $($orig:tt)* } { $($binder:tt)* } mut $i:tt) => {
$crate::clone!(impl @bind $i { $($orig)* $i } { @mut $($binder)* });
};
(impl @expand { $($orig:tt)* } { $($binder:tt)* } $i:tt ()) => {
$crate::clone!(impl @bind $i { $($orig)* $i () } { $($binder)* });
};
(impl @expand { $($orig:tt)* } { $($binder:tt)* } $i:tt . 0) => {
$crate::clone!(impl @bind $i { $($orig)* $i . 0 } { $($binder)* });
};
(impl @expand { $($orig:tt)* } { $($binder:tt)* } $i:tt . 1) => {
$crate::clone!(impl @bind $i { $($orig)* $i . 1 } { $($binder)* });
};
(impl @expand { $($orig:tt)* } { $($binder:tt)* } $i:tt . 2) => {
$crate::clone!(impl @bind $i { $($orig)* $i . 2 } { $($binder)* });
};
(impl @expand { $($orig:tt)* } { $($binder:tt)* } $i:tt . 3) => {
$crate::clone!(impl @bind $i { $($orig)* $i . 3 } { $($binder)* });
};
(impl @expand { $($orig:tt)* } { $($binder:tt)* } mut $i:tt = $($tail:tt)+) => {
$crate::clone!(impl @expand { $($orig)* } { @mut $i } $($tail)+);
};
(impl @expand { $($orig:tt)* } { $($binder:tt)* } $i:tt = $($tail:tt)+) => {
$crate::clone!(impl @expand { $($orig)* } { $i } $($tail)+);
};
(impl @expand { $($orig:tt)* } { $($binder:tt)* } $i:tt $($tail:tt)+) => {
$crate::clone!(impl @expand { $($orig)* $i } { $($binder)* } $($tail)+)
};
(impl @context { $($orig:tt)* } $tail:tt) => {
$crate::clone!(impl @expand { } { } $($orig)* $tail)
};
(impl @context { $($orig:tt)* } , $($tail:tt)+) => {
$crate::clone!(impl @expand { } { } $($orig)*);
$crate::clone!(impl @context { } $($tail)+);
};
(impl @context { $($orig:tt)* } $i:tt $($tail:tt)+) => {
$crate::clone!(impl @context { $($orig)* $i } $($tail)+)
};
($($tail:tt)+) => {
$crate::clone!(impl @context { } $($tail)+);
}
}
#[doc(hidden)]
#[macro_export]
macro_rules! asyncfn {
// Munch function args
(impl @bindings { $($args:tt)* } { $($clones:tt)* } !{ } | $($tail:tt)+) => {{
move |$($args)*| {
$($clones)*
async move {
$($tail)+
}
}
}};
(impl @bindings { $($args:tt)* } { $($clones:tt)* } !{ } $i:tt $($tail:tt)+) => {
$crate::asyncfn!(impl @bindings { $($args)* $i } { $($clones)* } !{ } $($tail)+)
};
// Munch function start
(impl @context { $($clones:tt)* } !{ } async move || $($tail:tt)+) => {{
move || {
$($clones)*
async move {
$($tail)+
}
}
}};
(impl @context { $($clones:tt)* } !{ } async move | $($tail:tt)+) => {
$crate::asyncfn!(impl @bindings { } { $($clones)* } !{ } $($tail)+)
};
// Munch clone bindings
(impl @context { $($clones:tt)* } !{ $($stack:tt)* } , $($tail:tt)+) => {{
$crate::asyncfn!(impl @context { $crate::clone!($($stack)*); $($clones)* } !{ } $($tail)+)
}};
(impl @context { $($clones:tt)* } !{ $($stack:tt)* } $i:tt $($tail:tt)+) => {
$crate::asyncfn!(impl @context { $($clones)* } !{ $($stack)* $i } $($tail)+)
};
// Root
($($tail:tt)+) => {
$crate::asyncfn!(impl @context { } !{ } $($tail)+)
}
}
@@ -0,0 +1,156 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use prost::Message;
use crate::proto::make_table_data::Data;
use crate::proto::request::ClientReq;
use crate::proto::response::ClientResp;
use crate::proto::{
MakeTableData, MakeTableReq, Request, Response, TableUpdateReq, ViewOnUpdateResp,
ViewToArrowResp, ViewToColumnsStringResp, ViewToCsvResp, ViewToNdjsonStringResp,
ViewToRowsStringResp,
};
fn replace(x: Data) -> Data {
match x {
Data::FromArrow(_) => Data::FromArrow("<< redacted >>".to_string().encode_to_vec()),
Data::FromRows(_) => Data::FromRows("<< redacted >>".to_string()),
Data::FromCols(_) => Data::FromCols("<< redacted >>".to_string()),
Data::FromCsv(_) => Data::FromCsv("<< redacted >>".to_string()),
x => x,
}
}
/// `prost` generates `Debug` implementations that includes the `data` field,
/// which makes logs output unreadable. This `Display` implementation hides
/// fields that we don't want ot display in the logs.
impl std::fmt::Display for Request {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut msg = self.clone();
msg = match msg {
Request {
client_req:
Some(ClientReq::MakeTableReq(MakeTableReq {
ref options,
data:
Some(MakeTableData {
data: Some(ref data),
}),
})),
..
} => Request {
client_req: Some(ClientReq::MakeTableReq(MakeTableReq {
options: options.clone(),
data: Some(MakeTableData {
data: Some(replace(data.clone())),
}),
})),
..msg.clone()
},
Request {
client_req:
Some(ClientReq::TableUpdateReq(TableUpdateReq {
port_id,
data:
Some(MakeTableData {
data: Some(ref data),
}),
})),
..
} => Request {
client_req: Some(ClientReq::TableUpdateReq(TableUpdateReq {
port_id,
data: Some(MakeTableData {
data: Some(replace(data.clone())),
}),
})),
..msg.clone()
},
x => x,
};
write!(
f,
"{}",
serde_json::to_string(&msg).unwrap_or("Can't deserialize".to_string())
)
}
}
impl std::fmt::Display for Response {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut msg = self.clone();
msg = match msg {
Response {
client_resp: Some(ClientResp::ViewToColumnsStringResp(_)),
..
} => Response {
client_resp: Some(ClientResp::ViewToColumnsStringResp(
ViewToColumnsStringResp {
json_string: "<< redacted >>".to_owned(),
},
)),
..msg.clone()
},
Response {
client_resp: Some(ClientResp::ViewToRowsStringResp(_)),
..
} => Response {
client_resp: Some(ClientResp::ViewToRowsStringResp(ViewToRowsStringResp {
json_string: "<< redacted >>".to_owned(),
})),
..msg.clone()
},
Response {
client_resp: Some(ClientResp::ViewToNdjsonStringResp(_)),
..
} => Response {
client_resp: Some(ClientResp::ViewToNdjsonStringResp(ViewToNdjsonStringResp {
ndjson_string: "<< redacted >>".to_owned(),
})),
..msg.clone()
},
Response {
client_resp: Some(ClientResp::ViewToArrowResp(_)),
..
} => Response {
client_resp: Some(ClientResp::ViewToArrowResp(ViewToArrowResp {
arrow: vec![],
})),
..msg.clone()
},
Response {
client_resp: Some(ClientResp::ViewToCsvResp(_)),
..
} => Response {
client_resp: Some(ClientResp::ViewToCsvResp(ViewToCsvResp {
csv: "<< redacted >>".to_owned(),
})),
..msg.clone()
},
Response {
client_resp: Some(ClientResp::ViewOnUpdateResp(ref x)),
..
} => Response {
client_resp: Some(ClientResp::ViewOnUpdateResp(ViewOnUpdateResp {
delta: x.delta.as_ref().map(|_| vec![]),
port_id: x.port_id,
})),
..msg.clone()
},
x => x,
};
write!(f, "{}", serde_json::to_string(&msg).unwrap())
}
}
@@ -0,0 +1,200 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
//! Utility functions that are common to the `perspective` crates.
mod clone;
mod logging;
mod rand_sequence;
#[cfg(feature = "talc-allocator")]
mod talc_allocator;
#[cfg(test)]
mod tests;
use std::sync::Arc;
use std::time::SystemTimeError;
use rand::prelude::*;
use rand::rngs::StdRng;
#[cfg(feature = "talc-allocator")]
pub(crate) use talc_allocator::get_used;
use thiserror::*;
use crate::proto;
use crate::utils::rand_sequence::RandomSequence;
#[derive(Clone, Error, Debug)]
pub enum ClientError {
#[error("View not found")]
ViewNotFound,
#[error("Abort(): {0}")]
Internal(String),
#[error("Transport error: {0}")]
TransportError(String),
#[error("Client not yet initialized")]
NotInitialized,
#[error("Unknown error: {0}")]
Unknown(String),
#[error("Unwrapped option")]
Option,
#[error("Bad string")]
Utf8(#[from] std::str::Utf8Error),
#[error("Undecipherable server message {0:?}")]
DecodeError(#[from] prost::DecodeError),
#[error("Response aborted")]
ResponseAborted,
#[error("Unexpected response {0:?}")]
ResponseFailed(Box<proto::response::ClientResp>),
#[error("Not yet implemented {0:?}")]
NotImplemented(&'static str),
#[error("Can't use both `limit` and `index` arguments")]
BadTableOptions,
#[error("External error: {0}")]
ExternalError(Arc<Box<dyn std::error::Error + Send + Sync>>),
#[error("Undecipherable proto message")]
ProtoError(#[from] prost::EncodeError),
#[error("Duplicate name {0}")]
DuplicateNameError(String),
#[error("{0}")]
TimeError(#[from] SystemTimeError),
}
pub type ClientResult<T> = Result<T, ClientError>;
impl From<Box<dyn std::error::Error + Send + Sync>> for ClientError {
fn from(value: Box<dyn std::error::Error + Send + Sync>) -> Self {
ClientError::ExternalError(Arc::new(value))
}
}
impl<'a, A> From<std::sync::PoisonError<std::sync::MutexGuard<'a, A>>> for ClientError {
fn from(_: std::sync::PoisonError<std::sync::MutexGuard<'a, A>>) -> Self {
ClientError::Internal("Lock Error".to_owned())
}
}
impl From<Option<proto::response::ClientResp>> for ClientError {
fn from(value: Option<proto::response::ClientResp>) -> Self {
match value {
Some(proto::response::ClientResp::ServerError(x)) => match x.status_code() {
proto::StatusCode::ServerError => ClientError::Internal(x.message),
proto::StatusCode::ViewNotFound => ClientError::ViewNotFound,
proto::StatusCode::TransportError => ClientError::TransportError(x.message),
},
Some(x) => ClientError::ResponseFailed(Box::new(x)),
None => ClientError::ResponseAborted,
}
}
}
impl From<proto::response::ClientResp> for ClientError {
fn from(value: proto::response::ClientResp) -> Self {
match value {
proto::response::ClientResp::ServerError(x) => match x.status_code() {
proto::StatusCode::ServerError => ClientError::Internal(x.message),
proto::StatusCode::ViewNotFound => ClientError::ViewNotFound,
proto::StatusCode::TransportError => ClientError::TransportError(x.message),
},
x => ClientError::ResponseFailed(Box::new(x)),
}
}
}
pub trait PerspectiveResultExt {
fn unwrap_or_log(&self);
}
impl<T, E> PerspectiveResultExt for Result<T, E>
where
E: std::error::Error,
{
fn unwrap_or_log(&self) {
if let Err(e) = self {
tracing::warn!("{}", e);
}
}
}
/// Generate a sequence of IDs
#[derive(Clone)]
pub struct IDGen(Arc<std::sync::Mutex<RandomSequence>>);
impl Default for IDGen {
fn default() -> Self {
Self(Arc::new(std::sync::Mutex::new(Self::new_seq())))
}
}
impl IDGen {
fn new_seq() -> RandomSequence {
let mut rng = rand::rngs::ThreadRng::default();
let config = RandomSequence::new(rng.next_u32(), rng.next_u32());
config.into_iter()
}
pub fn next(&self) -> u32 {
let mut idgen = self.0.lock().unwrap();
if let Some(x) = idgen.next() {
x
} else {
*idgen = Self::new_seq();
idgen.next().unwrap()
}
}
}
const SIZE: usize = 21;
const CHARACTERS: [char; 52] = [
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's',
't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L',
'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
];
/// Generate a random identifier `String`
pub fn randid() -> String {
let mask = CHARACTERS.len().next_power_of_two() - 1;
let step: usize = 8 * SIZE / 5;
let mut id = String::with_capacity(SIZE);
loop {
let mut rng = rand::rngs::ThreadRng::default();
let mut random = StdRng::from_rng(&mut rng);
let mut bytes: Vec<u8> = vec![0; step];
random.fill(&mut bytes[..]);
for &byte in &bytes {
let byte = byte as usize & mask;
if CHARACTERS.len() > byte {
id.push(CHARACTERS[byte]);
if id.len() == SIZE {
return id;
}
}
}
}
}
@@ -0,0 +1,87 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
//! This implementation liberally borrows from the excellent
//! [`rand-unique`](https://github.com/hoxxep/rand-unique) crate, MIT licensed.
//! Unlike `rand-unique`, this hacked up version only implements u32 (for wasm),
//! but is compatible with `rand>0.8` and ~20kb smaller (in wasm).
const INIT_BASE: u32 = 0x682f0161;
const INIT_OFFSET: u32 = 0x46790905;
const PRIME: u32 = 4294967291;
const INTERMEDIATE_XOR: u32 = 0x5bf03635;
#[derive(Debug, Clone)]
pub struct RandomSequence {
start_index: u32,
current_index: u32,
intermediate_offset: u32,
ended: bool,
}
impl RandomSequence {
pub fn new(seed_base: u32, seed_offset: u32) -> Self {
let start_index = permute_qpr(permute_qpr(seed_base).wrapping_add(INIT_BASE));
let intermediate_offset = permute_qpr(permute_qpr(seed_offset).wrapping_add(INIT_OFFSET));
RandomSequence {
start_index,
current_index: 0,
intermediate_offset,
ended: false,
}
}
}
impl Iterator for RandomSequence {
type Item = u32;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
let index = self.start_index.wrapping_add(self.current_index);
let inner_residue = permute_qpr(index).wrapping_add(self.intermediate_offset);
let next = permute_qpr(inner_residue ^ INTERMEDIATE_XOR);
self.current_index = match self.current_index.checked_add(1) {
Some(v) => {
self.ended = false;
v
},
None => {
if !self.ended {
self.ended = true;
self.current_index
} else {
return None;
}
},
};
Some(next)
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
(u32::MAX as usize - self.current_index as usize, None)
}
}
fn permute_qpr(x: u32) -> u32 {
if x >= PRIME {
return x;
}
let residue = ((x as u64 * x as u64) % PRIME as u64) as u32;
if x <= PRIME >> 1 {
residue
} else {
PRIME - residue
}
}
@@ -0,0 +1,25 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
#[global_allocator]
static TALC: talc::TalckWasm = unsafe { talc::TalckWasm::new_global() };
pub fn get_used() -> (usize, usize) {
return {
let counters = TALC.lock();
(
counters.get_counters().allocated_bytes,
(counters.get_counters().available_bytes + counters.get_counters().allocated_bytes)
as usize,
)
};
}
@@ -0,0 +1,83 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use crate::*;
#[derive(Clone)]
struct X {
x: i32,
}
impl X {
const fn test(&self) -> i32 {
1
}
}
#[derive(Clone)]
struct Y {
y: X,
}
#[test]
fn test_one_ident() {
let x = 0;
clone!(x);
assert_eq!(x, 0);
}
#[test]
fn test_two_idents() {
let x = 0;
let y = 1;
clone!(x, y);
assert_eq!(x, 0);
assert_eq!(y, 1);
}
#[test]
fn test_struct_field() {
let x = X { x: 0 };
clone!(x.x);
assert_eq!(x, 0);
}
#[test]
fn test_struct_struct_field() {
let x = Y { y: X { x: 0 } };
clone!(x.y.x);
assert_eq!(x, 0);
}
#[test]
fn test_tuple() {
let x = (0, 1);
clone!(x.0);
assert_eq!(x, 0);
}
#[test]
fn test_alias() {
let x = 0;
clone!(y = x);
assert_eq!(y, 0);
}
#[test]
fn test_complex() {
let x = Y { y: X { x: 0 } };
let w = (0, x.clone());
clone!(z = x.y.x, x.y.test(), w.1.y.x);
assert_eq!(z, 0);
assert_eq!(x, 0);
assert_eq!(test, 1);
}
@@ -0,0 +1,13 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
mod clone;
+638
View File
@@ -0,0 +1,638 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::collections::HashMap;
use std::ops::Deref;
use std::str::FromStr;
use std::sync::Arc;
use futures::Future;
use prost::bytes::Bytes;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use self::view_on_update_req::Mode;
use crate::assert_view_api;
use crate::client::Client;
use crate::proto::request::ClientReq;
use crate::proto::response::ClientResp;
use crate::proto::*;
#[cfg(doc)]
use crate::table::Table;
pub use crate::utils::*;
/// Options for [`View::on_update`].
#[derive(Default, Debug, Deserialize, TS)]
pub struct OnUpdateOptions {
pub mode: Option<OnUpdateMode>,
}
/// The update mode for [`View::on_update`].
///
/// `Row` mode calculates and provides the update batch new rows/columns as an
/// Apache Arrow to the callback provided to [`View::on_update`]. This allows
/// incremental updates if your callbakc can read this format, but should be
/// disabled otherwise.
#[derive(Default, Debug, Deserialize, TS)]
pub enum OnUpdateMode {
#[default]
#[serde(rename = "row")]
Row,
}
impl FromStr for OnUpdateMode {
type Err = ClientError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s == "row" {
Ok(OnUpdateMode::Row)
} else {
Err(ClientError::Option)
}
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, TS, PartialEq)]
pub struct ColumnWindow {
#[serde(skip_serializing_if = "Option::is_none")]
pub start_col: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_col: Option<f32>,
}
/// Options for serializing a window of data from a [`View`].
///
/// Some fields of [`ViewWindow`] are only applicable to specific methods of
/// [`View`].
#[derive(Clone, Debug, Default, Deserialize, Serialize, TS, PartialEq)]
pub struct ViewWindow {
#[ts(optional)]
#[serde(skip_serializing_if = "Option::is_none")]
pub start_row: Option<f64>,
#[ts(optional)]
#[serde(skip_serializing_if = "Option::is_none")]
pub start_col: Option<f64>,
#[ts(optional)]
#[serde(skip_serializing_if = "Option::is_none")]
pub end_row: Option<f64>,
#[ts(optional)]
#[serde(skip_serializing_if = "Option::is_none")]
pub end_col: Option<f64>,
#[ts(optional)]
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<bool>,
#[ts(optional)]
#[serde(skip_serializing_if = "Option::is_none")]
pub index: Option<bool>,
/// Only impacts [`View::to_csv`]
#[ts(optional)]
#[serde(skip_serializing_if = "Option::is_none")]
pub formatted: Option<bool>,
/// Only impacts [`View::to_arrow`]
#[ts(optional)]
#[serde(skip_serializing_if = "Option::is_none")]
pub compression: Option<String>,
/// When `true`, group-by columns use legacy `"colname (Group by N)"`
/// naming. When `false`, they use `__ROW_PATH_N__` naming consistent
/// with the SQL backend. Defaults to `true` for backwards compatibility.
#[ts(optional)]
#[serde(skip_serializing_if = "Option::is_none")]
pub emit_legacy_row_path_names: Option<bool>,
}
impl From<ViewWindow> for ViewPort {
fn from(window: ViewWindow) -> Self {
ViewPort {
start_row: window.start_row.map(|x| x.floor() as u32),
start_col: window.start_col.map(|x| x.floor() as u32),
end_row: window.end_row.map(|x| x.ceil() as u32),
end_col: window.end_col.map(|x| x.ceil() as u32),
emit_legacy_row_path_names: window.emit_legacy_row_path_names,
}
}
}
impl From<ViewPort> for ViewWindow {
fn from(window: ViewPort) -> Self {
ViewWindow {
start_row: window.start_row.map(|x| x as f64),
start_col: window.start_col.map(|x| x as f64),
end_row: window.end_row.map(|x| x as f64),
end_col: window.end_col.map(|x| x as f64),
emit_legacy_row_path_names: window.emit_legacy_row_path_names,
..ViewWindow::default()
}
}
}
/// Rows updated and port ID corresponding to an update batch, provided to the
/// callback argument to [`View::on_update`] with the "rows" mode.
#[derive(TS)]
pub struct OnUpdateData(crate::proto::ViewOnUpdateResp);
impl Deref for OnUpdateData {
type Target = crate::proto::ViewOnUpdateResp;
fn deref(&self) -> &Self::Target {
&self.0
}
}
/// The [`View`] struct is Perspective's query and serialization interface. It
/// represents a query on the `Table`'s dataset and is always created from an
/// existing `Table` instance via the [`Table::view`] method.
///
/// [`View`]s are immutable with respect to the arguments provided to the
/// [`Table::view`] method; to change these parameters, you must create a new
/// [`View`] on the same [`Table`]. However, each [`View`] is _live_ with
/// respect to the [`Table`]'s data, and will (within a conflation window)
/// update with the latest state as its parent [`Table`] updates, including
/// incrementally recalculating all aggregates, pivots, filters, etc. [`View`]
/// query parameters are composable, in that each parameter works independently
/// _and_ in conjunction with each other, and there is no limit to the number of
/// pivots, filters, etc. which can be applied.
///
/// To construct a [`View`], call the [`Table::view`] factory method. A
/// [`Table`] can have as many [`View`]s associated with it as you need -
/// Perspective conserves memory by relying on a single [`Table`] to power
/// multiple [`View`]s concurrently.
///
/// # Examples
///
/// ```no_run
/// # use perspective_client::{Client, TableData, TableInitOptions, UpdateData, ViewWindow};
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
/// # let client: Client = todo!();
/// let opts = TableInitOptions::default();
/// let data = TableData::Update(UpdateData::Csv("x,y\n1,2\n3,4".into()));
/// let table = client.table(data, opts).await?;
///
/// let view = table.view(None).await?;
/// let arrow = view.to_arrow(ViewWindow::default()).await?;
/// view.delete().await?;
/// # Ok(()) }
/// ```
///
/// ```no_run
/// # use std::collections::HashMap;
/// # use perspective_client::Table;
/// # use perspective_client::config::*;
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
/// # let table: Table = todo!();
/// let view = table
/// .view(Some(ViewConfigUpdate {
/// columns: Some(vec![Some("Sales".into())]),
/// aggregates: Some(HashMap::from_iter(vec![("Sales".into(), "sum".into())])),
/// group_by: Some(vec!["Region".into(), "Country".into()]),
/// filter: Some(vec![Filter::new("Category", "in", &[
/// "Furniture",
/// "Technology",
/// ])]),
/// ..ViewConfigUpdate::default()
/// }))
/// .await?;
/// # Ok(()) }
/// ```
///
/// Group By
///
/// ```no_run
/// # use perspective_client::Table;
/// # use perspective_client::config::*;
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
/// # let table: Table = todo!();
/// let view = table
/// .view(Some(ViewConfigUpdate {
/// group_by: Some(vec!["a".into(), "c".into()]),
/// ..ViewConfigUpdate::default()
/// }))
/// .await?;
/// # Ok(()) }
/// ```
///
/// Split By
///
/// ```no_run
/// # use perspective_client::Table;
/// # use perspective_client::config::*;
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
/// # let table: Table = todo!();
/// let view = table
/// .view(Some(ViewConfigUpdate {
/// split_by: Some(vec!["a".into(), "c".into()]),
/// ..ViewConfigUpdate::default()
/// }))
/// .await?;
/// # Ok(()) }
/// ```
///
/// In Javascript, a [`Table`] can be constructed on a [`Table::view`] instance,
/// which will return a new [`Table`] based on the [`Table::view`]'s dataset,
/// and all future updates that affect the [`Table::view`] will be forwarded to
/// the new [`Table`]. This is particularly useful for implementing a
/// [Client/Server Replicated](server.md#clientserver-replicated) design, by
/// serializing the `View` to an arrow and setting up an `on_update` callback.
///
/// ```no_run
/// # use perspective_client::{Client, TableData, TableInitOptions, UpdateData, UpdateOptions};
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
/// # let client: Client = todo!();
/// let opts = TableInitOptions::default();
/// let data = TableData::Update(UpdateData::Csv("x,y\n1,2\n3,4".into()));
/// let table = client.table(data, opts.clone()).await?;
/// let view = table.view(None).await?;
/// let table2 = client.table(TableData::View(view), opts).await?;
/// let more = UpdateData::Csv("x,y\n5,6".into());
/// table.update(more, UpdateOptions::default()).await?;
/// # Ok(()) }
/// ```
#[derive(Clone, Debug)]
pub struct View {
pub name: String,
client: Client,
}
assert_view_api!(View);
impl View {
pub fn new(name: String, client: Client) -> Self {
View { name, client }
}
fn client_message(&self, req: ClientReq) -> Request {
crate::proto::Request {
msg_id: self.client.gen_id(),
entity_id: self.name.clone(),
client_req: Some(req),
}
}
/// Returns an array of strings containing the column paths of the [`View`]
/// without any of the source columns.
///
/// A column path shows the columns that a given cell belongs to after
/// pivots are applied.
pub async fn column_paths(&self, window: ColumnWindow) -> ClientResult<Vec<String>> {
let msg = self.client_message(ClientReq::ViewColumnPathsReq(ViewColumnPathsReq {
start_col: window.start_col.map(|x| x as u32),
end_col: window.end_col.map(|x| x as u32),
}));
match self.client.oneshot(&msg).await? {
ClientResp::ViewColumnPathsResp(ViewColumnPathsResp { paths }) => {
// Ok(paths.into_iter().map(|x| x.path).collect())
Ok(paths)
},
resp => Err(resp.into()),
}
}
/// Returns this [`View`]'s _dimensions_, row and column count, as well as
/// those of the [`crate::Table`] from which it was derived.
///
/// - `num_table_rows` - The number of rows in the underlying
/// [`crate::Table`].
/// - `num_table_columns` - The number of columns in the underlying
/// [`crate::Table`] (including the `index` column if this
/// [`crate::Table`] was constructed with one).
/// - `num_view_rows` - The number of rows in this [`View`]. If this
/// [`View`] has a `group_by` clause, `num_view_rows` will also include
/// aggregated rows.
/// - `num_view_columns` - The number of columns in this [`View`]. If this
/// [`View`] has a `split_by` clause, `num_view_columns` will include all
/// _column paths_, e.g. the number of `columns` clause times the number
/// of `split_by` groups.
pub async fn dimensions(&self) -> ClientResult<ViewDimensionsResp> {
let msg = self.client_message(ClientReq::ViewDimensionsReq(ViewDimensionsReq {}));
match self.client.oneshot(&msg).await? {
ClientResp::ViewDimensionsResp(resp) => Ok(resp),
resp => Err(resp.into()),
}
}
/// The expression schema of this [`View`], which contains only the
/// expressions created on this [`View`]. See [`View::schema`] for
/// details.
pub async fn expression_schema(&self) -> ClientResult<HashMap<String, ColumnType>> {
if self.client.get_features().await?.expressions {
let msg = self.client_message(ClientReq::ViewExpressionSchemaReq(
ViewExpressionSchemaReq {},
));
match self.client.oneshot(&msg).await? {
ClientResp::ViewExpressionSchemaResp(ViewExpressionSchemaResp { schema }) => {
Ok(schema
.into_iter()
.map(|(x, y)| (x, ColumnType::try_from(y).unwrap()))
.collect())
},
resp => Err(resp.into()),
}
} else {
Ok([].into_iter().collect())
}
}
/// A copy of the [`ViewConfig`] object passed to the [`Table::view`] method
/// which created this [`View`].
pub async fn get_config(&self) -> ClientResult<crate::config::ViewConfig> {
let msg = self.client_message(ClientReq::ViewGetConfigReq(ViewGetConfigReq {}));
match self.client.oneshot(&msg).await? {
ClientResp::ViewGetConfigResp(ViewGetConfigResp {
config: Some(config),
}) => Ok(config.into()),
resp => Err(resp.into()),
}
}
/// The number of aggregated rows in this [`View`]. This is affected by the
/// "group_by" configuration parameter supplied to this view's contructor.
///
/// # Returns
///
/// The number of aggregated rows.
pub async fn num_rows(&self) -> ClientResult<u32> {
Ok(self.dimensions().await?.num_view_rows)
}
/// The schema of this [`View`].
///
/// The [`View`] schema differs from the `schema` returned by
/// [`Table::schema`]; it may have different column names due to
/// `expressions` or `columns` configs, or it maye have _different
/// column types_ due to the application og `group_by` and `aggregates`
/// config. You can think of [`Table::schema`] as the _input_ schema and
/// [`View::schema`] as the _output_ schema of a Perspective pipeline.
pub async fn schema(&self) -> ClientResult<HashMap<String, ColumnType>> {
let msg = self.client_message(ClientReq::ViewSchemaReq(ViewSchemaReq {}));
match self.client.oneshot(&msg).await? {
ClientResp::ViewSchemaResp(ViewSchemaResp { schema }) => Ok(schema
.into_iter()
.map(|(x, y)| (x, ColumnType::try_from(y).unwrap()))
.collect()),
resp => Err(resp.into()),
}
}
/// Serializes a [`View`] to the Apache Arrow data format.
pub async fn to_arrow(&self, window: ViewWindow) -> ClientResult<Bytes> {
let msg = self.client_message(ClientReq::ViewToArrowReq(ViewToArrowReq {
viewport: Some(window.clone().into()),
compression: window.compression,
}));
match self.client.oneshot(&msg).await? {
ClientResp::ViewToArrowResp(ViewToArrowResp { arrow }) => Ok(arrow.into()),
resp => Err(resp.into()),
}
}
/// Serializes this [`View`] to a string of JSON data. Useful if you want to
/// save additional round trip serialize/deserialize cycles.
pub async fn to_columns_string(&self, window: ViewWindow) -> ClientResult<String> {
let msg = self.client_message(ClientReq::ViewToColumnsStringReq(ViewToColumnsStringReq {
viewport: Some(window.clone().into()),
id: window.id,
index: window.index,
formatted: window.formatted,
}));
match self.client.oneshot(&msg).await? {
ClientResp::ViewToColumnsStringResp(ViewToColumnsStringResp { json_string }) => {
Ok(json_string)
},
resp => Err(resp.into()),
}
}
/// Render this `View` as a JSON string.
pub async fn to_json_string(&self, window: ViewWindow) -> ClientResult<String> {
let viewport = ViewPort::from(window.clone());
let msg = self.client_message(ClientReq::ViewToRowsStringReq(ViewToRowsStringReq {
viewport: Some(viewport),
id: window.id,
index: window.index,
formatted: window.formatted,
}));
match self.client.oneshot(&msg).await? {
ClientResp::ViewToRowsStringResp(ViewToRowsStringResp { json_string }) => {
Ok(json_string)
},
resp => Err(resp.into()),
}
}
/// Renders this [`View`] as an [NDJSON](https://github.com/ndjson/ndjson-spec)
/// formatted [`String`].
pub async fn to_ndjson(&self, window: ViewWindow) -> ClientResult<String> {
let viewport = ViewPort::from(window.clone());
let msg = self.client_message(ClientReq::ViewToNdjsonStringReq(ViewToNdjsonStringReq {
viewport: Some(viewport),
id: window.id,
index: window.index,
formatted: window.formatted,
}));
match self.client.oneshot(&msg).await? {
ClientResp::ViewToNdjsonStringResp(ViewToNdjsonStringResp { ndjson_string }) => {
Ok(ndjson_string)
},
resp => Err(resp.into()),
}
}
/// Serializes this [`View`] to CSV data in a standard format.
pub async fn to_csv(&self, window: ViewWindow) -> ClientResult<String> {
let msg = self.client_message(ClientReq::ViewToCsvReq(ViewToCsvReq {
viewport: Some(window.into()),
}));
match self.client.oneshot(&msg).await? {
ClientResp::ViewToCsvResp(ViewToCsvResp { csv }) => Ok(csv),
resp => Err(resp.into()),
}
}
/// Delete this [`View`] and clean up all resources associated with it.
/// [`View`] objects do not stop consuming resources or processing
/// updates when they are garbage collected - you must call this method
/// to reclaim these.
pub async fn delete(&self) -> ClientResult<()> {
let msg = self.client_message(ClientReq::ViewDeleteReq(ViewDeleteReq {}));
match self.client.oneshot(&msg).await? {
ClientResp::ViewDeleteResp(_) => Ok(()),
resp => Err(resp.into()),
}
}
/// Calculates the [min, max] of the leaf nodes of a column `column_name`.
///
/// # Returns
///
/// A tuple of [min, max], whose types are column and aggregate dependent.
pub async fn get_min_max(
&self,
column_name: String,
) -> ClientResult<(crate::config::Scalar, crate::config::Scalar)> {
let msg = self.client_message(ClientReq::ViewGetMinMaxReq(ViewGetMinMaxReq {
column_name,
}));
match self.client.oneshot(&msg).await? {
ClientResp::ViewGetMinMaxResp(ViewGetMinMaxResp { min, max }) => {
let min = min.map(crate::config::Scalar::from).unwrap_or_default();
let max = max.map(crate::config::Scalar::from).unwrap_or_default();
Ok((min, max))
},
resp => Err(resp.into()),
}
}
/// Register a callback with this [`View`]. Whenever the view's underlying
/// table emits an update, this callback will be invoked with an object
/// containing `port_id`, indicating which port the update fired on, and
/// optionally `delta`, which is the new data that was updated for each
/// cell or each row.
///
/// # Arguments
///
/// - `on_update` - A callback function invoked on update, which receives an
/// object with two keys: `port_id`, indicating which port the update was
/// triggered on, and `delta`, whose value is dependent on the mode
/// parameter.
/// - `options` - If this is provided as `OnUpdateOptions { mode:
/// Some(OnUpdateMode::Row) }`, then `delta` is an Arrow of the updated
/// rows. Otherwise `delta` will be [`Option::None`].
pub async fn on_update<T, U>(&self, on_update: T, options: OnUpdateOptions) -> ClientResult<u32>
where
T: Fn(OnUpdateData) -> U + Send + Sync + 'static,
U: Future<Output = ()> + Send + 'static,
{
let on_update = Arc::new(on_update);
let callback = move |resp: Response| {
let on_update = on_update.clone();
async move {
match resp.client_resp {
Some(ClientResp::ViewOnUpdateResp(resp)) => {
on_update(OnUpdateData(resp)).await;
Ok(())
},
resp => Err(resp.into()),
}
}
};
let msg = self.client_message(ClientReq::ViewOnUpdateReq(ViewOnUpdateReq {
mode: options.mode.map(|OnUpdateMode::Row| Mode::Row as i32),
}));
self.client.subscribe(&msg, callback).await?;
Ok(msg.msg_id)
}
/// Unregister a previously registered update callback with this [`View`].
///
/// # Arguments
///
/// - `id` - A callback `id` as returned by a recipricol call to
/// [`View::on_update`].
///
/// # Examples
///
/// ```no_run
/// # use perspective_client::{OnUpdateOptions, View};
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
/// # let view: View = todo!();
/// let callback = |_| async { print!("Updated!") };
/// let cid = view.on_update(callback, OnUpdateOptions::default()).await?;
/// view.remove_update(cid).await?;
/// # Ok(()) }
/// ```
pub async fn remove_update(&self, update_id: u32) -> ClientResult<()> {
let msg = self.client_message(ClientReq::ViewRemoveOnUpdateReq(ViewRemoveOnUpdateReq {
id: update_id,
}));
self.client.unsubscribe(update_id).await?;
match self.client.oneshot(&msg).await? {
ClientResp::ViewRemoveOnUpdateResp(_) => Ok(()),
resp => Err(resp.into()),
}
}
/// Register a callback with this [`View`]. Whenever the [`View`] is
/// deleted, this callback will be invoked.
pub async fn on_delete(
&self,
on_delete: Box<dyn Fn() + Send + Sync + 'static>,
) -> ClientResult<u32> {
let callback = move |resp: Response| match resp.client_resp.unwrap() {
ClientResp::ViewOnDeleteResp(_) => {
on_delete();
Ok(())
},
resp => Err(resp.into()),
};
let msg = self.client_message(ClientReq::ViewOnDeleteReq(ViewOnDeleteReq {}));
self.client.subscribe_once(&msg, Box::new(callback)).await?;
Ok(msg.msg_id)
}
/// Unregister a previously registered [`View::on_delete`] callback.
pub async fn remove_delete(&self, callback_id: u32) -> ClientResult<()> {
let msg = self.client_message(ClientReq::ViewRemoveDeleteReq(ViewRemoveDeleteReq {
id: callback_id,
}));
match self.client.oneshot(&msg).await? {
ClientResp::ViewRemoveDeleteResp(ViewRemoveDeleteResp {}) => Ok(()),
resp => Err(resp.into()),
}
}
/// Collapses the `group_by` row at `row_index`.
pub async fn collapse(&self, row_index: u32) -> ClientResult<u32> {
let msg = self.client_message(ClientReq::ViewCollapseReq(ViewCollapseReq { row_index }));
match self.client.oneshot(&msg).await? {
ClientResp::ViewCollapseResp(ViewCollapseResp { num_changed }) => Ok(num_changed),
resp => Err(resp.into()),
}
}
/// Expand the `group_by` row at `row_index`.
pub async fn expand(&self, row_index: u32) -> ClientResult<u32> {
let msg = self.client_message(ClientReq::ViewExpandReq(ViewExpandReq { row_index }));
match self.client.oneshot(&msg).await? {
ClientResp::ViewExpandResp(ViewExpandResp { num_changed }) => Ok(num_changed),
resp => Err(resp.into()),
}
}
/// Set expansion `depth` of the `group_by` tree.
pub async fn set_depth(&self, depth: u32) -> ClientResult<()> {
let msg = self.client_message(ClientReq::ViewSetDepthReq(ViewSetDepthReq { depth }));
match self.client.oneshot(&msg).await? {
ClientResp::ViewSetDepthResp(_) => Ok(()),
resp => Err(resp.into()),
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use prost::{DecodeError, EncodeError};
use thiserror::Error;
/// Error type for virtual server operations.
///
/// This enum represents the various errors that can occur when processing
/// requests through a [`VirtualServer`](super::VirtualServer).
#[derive(Clone, Error, Debug)]
pub enum VirtualServerError<T: std::fmt::Debug> {
#[error("External Error: {0:?}")]
InternalError(#[from] T),
#[error("{0}")]
DecodeError(DecodeError),
#[error("{0}")]
EncodeError(EncodeError),
#[error("View not found '{0}'")]
UnknownViewId(String),
#[error("Invalid JSON'{0}'")]
InvalidJSON(std::sync::Arc<serde_json::Error>),
#[error("{0}")]
Other(String),
}
/// Extension trait for extracting internal errors from [`VirtualServerError`]
/// results.
///
/// Provides a method to distinguish between internal handler errors and other
/// virtual server errors.
pub trait ResultExt<X, T> {
fn get_internal_error(self) -> Result<X, Result<T, String>>;
}
impl<X, T: std::fmt::Debug> ResultExt<X, T> for Result<X, VirtualServerError<T>> {
fn get_internal_error(self) -> Result<X, Result<T, String>> {
match self {
Ok(x) => Ok(x),
Err(VirtualServerError::InternalError(x)) => Err(Ok(x)),
Err(x) => Err(Err(x.to_string())),
}
}
}
@@ -0,0 +1,120 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::borrow::Cow;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use crate::config::GroupRollupMode;
use crate::proto::get_features_resp::{AggregateArgs, AggregateOptions, ColumnTypeOptions};
use crate::proto::{ColumnType, GetFeaturesResp};
/// Describes the capabilities supported by a virtual server handler.
///
/// This struct is returned by
/// [`VirtualServerHandler::get_features`](super::VirtualServerHandler::get_features)
/// to inform clients about which operations are available.
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct Features<'a> {
/// Whether group-by aggregation is supported.
#[serde(default)]
pub group_by: bool,
/// Which `group_by_rollup_mode` options are supported
#[serde(default)]
pub group_rollup_mode: Vec<GroupRollupMode>,
/// Whether split-by (pivot) operations are supported.
#[serde(default)]
pub split_by: bool,
/// Available filter operators per column type.
#[serde(default)]
pub filter_ops: IndexMap<ColumnType, Vec<Cow<'a, str>>>,
/// Available aggregate functions per column type.
#[serde(default)]
pub aggregates: IndexMap<ColumnType, Vec<AggSpec<'a>>>,
/// Whether sorting is supported.
#[serde(default)]
pub sort: bool,
/// Whether computed expressions are supported.
#[serde(default)]
pub expressions: bool,
/// Whether update callbacks are supported.
#[serde(default)]
pub on_update: bool,
}
/// Specification for an aggregate function.
///
/// Aggregates can either take no additional arguments ([`AggSpec::Single`])
/// or require column type arguments ([`AggSpec::Multiple`]).
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(untagged)]
pub enum AggSpec<'a> {
/// An aggregate function with no additional arguments.
Single(Cow<'a, str>),
/// An aggregate function that requires column type arguments.
Multiple(Cow<'a, str>, Vec<ColumnType>),
}
impl<'a> From<Features<'a>> for GetFeaturesResp {
fn from(value: Features<'a>) -> GetFeaturesResp {
GetFeaturesResp {
group_by: value.group_by,
group_rollup_mode: value
.group_rollup_mode
.iter()
.map(|x| crate::proto::GroupRollupMode::from(*x) as i32)
.collect(),
split_by: value.split_by,
expressions: value.expressions,
on_update: value.on_update,
sort: value.sort,
aggregates: value
.aggregates
.iter()
.map(|(dtype, aggs)| {
(*dtype as u32, AggregateOptions {
aggregates: aggs
.iter()
.map(|agg| match agg {
AggSpec::Single(cow) => AggregateArgs {
name: cow.to_string(),
args: vec![],
},
AggSpec::Multiple(cow, column_types) => AggregateArgs {
name: cow.to_string(),
args: column_types.iter().map(|x| *x as i32).collect(),
},
})
.collect(),
})
})
.collect(),
filter_ops: value
.filter_ops
.iter()
.map(|(ty, options)| {
(*ty as u32, ColumnTypeOptions {
options: options.iter().map(|x| (*x).to_string()).collect(),
})
})
.collect(),
}
}
}
@@ -0,0 +1,341 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
//! SQL query builder for virtual server operations.
//!
//! This module provides a stateless SQL query generator that produces
//! generic SQL strings for perspective virtual server operations.
// TODO(texodus): Missing these features
//
// - row expand/collapse in the datagrid needs datamodel support, this is likely
// a "collapsed" boolean column in the temp table we `UPDATE`.
//
// - `on_update` real-time support will be method which takes sa view name and a
// handler and calls the handler when the view needs to be recalculated.
//
// Nice to have:
//
// - Optional `view_change` method can be implemented for engine optimization,
// defaulting to just delete & recreate (as Perspective engine does now).
//
// - Would like to add a metadata API so that e.g. Viewer debug panel could show
// internal generated SQL.
mod table_make_view;
#[cfg(test)]
mod tests;
use std::fmt;
use indexmap::IndexMap;
use serde::Deserialize;
use crate::config::{FilterTerm, GroupRollupMode, Scalar, Sort, SortDir, ViewConfig};
use crate::proto::{ColumnType, ViewPort};
use crate::virtual_server::generic_sql_model::table_make_view::ViewQueryContext;
/// Error type for SQL generation operations.
#[derive(Debug, Clone)]
pub enum GenericSQLError {
/// A required column was not found in the schema.
ColumnNotFound(String),
/// An invalid configuration was provided.
InvalidConfig(String),
/// An unsupported operation was requested.
UnsupportedOperation(String),
}
impl fmt::Display for GenericSQLError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ColumnNotFound(col) => write!(f, "Column not found: {}", col),
Self::InvalidConfig(msg) => write!(f, "Invalid configuration: {}", msg),
Self::UnsupportedOperation(msg) => write!(f, "Unsupported operation: {}", msg),
}
}
}
impl std::error::Error for GenericSQLError {}
/// Result type alias for SQL operations.
pub type GenericSQLResult<T> = Result<T, GenericSQLError>;
#[derive(Clone, Debug, Deserialize, Default)]
pub struct GenericSQLVirtualServerModelArgs {
create_entity: Option<String>,
grouping_fn: Option<String>,
}
/// A stateless SQL query builder virtual server operations.
///
/// This struct generates SQL query strings without executing them, allowing
/// the caller to execute the queries against a SQL connection.
#[derive(Debug, Default, Clone)]
pub struct GenericSQLVirtualServerModel(GenericSQLVirtualServerModelArgs);
impl GenericSQLVirtualServerModel {
/// Creates a new `GenericSQLVirtualServerModel` instance.
pub fn new(args: GenericSQLVirtualServerModelArgs) -> Self {
Self(args)
}
/// Returns the SQL query to list all hosted tables.
///
/// # Returns
/// SQL: `SHOW ALL TABLES`
pub fn get_hosted_tables(&self) -> GenericSQLResult<String> {
Ok("SHOW ALL TABLES".to_string())
}
/// Returns the SQL query to describe a table's schema.
///
/// # Arguments
/// * `table_id` - The identifier of the table to describe.
///
/// # Returns
/// SQL: `DESCRIBE {table_id}`
pub fn table_schema(&self, table_id: &str) -> GenericSQLResult<String> {
Ok(format!("DESCRIBE {}", table_id))
}
/// Returns the SQL query to get the row count of a table.
///
/// # Arguments
/// * `table_id` - The identifier of the table.
///
/// # Returns
/// SQL: `SELECT COUNT(*) FROM {table_id}`
pub fn table_size(&self, table_id: &str) -> GenericSQLResult<String> {
Ok(format!("SELECT COUNT(*) FROM {}", table_id))
}
/// Returns the SQL query to get the column count of a view.
///
/// # Arguments
/// * `view_id` - The identifier of the view.
///
/// # Returns
/// SQL: `SELECT COUNT(*) FROM (DESCRIBE {view_id})`
pub fn view_column_size(&self, view_id: &str) -> GenericSQLResult<String> {
Ok(format!("SELECT COUNT(*) FROM (DESCRIBE {})", view_id))
}
/// Returns the SQL query to validate an expression against a table.
///
/// # Arguments
/// * `table_id` - The identifier of the table.
/// * `expression` - The SQL expression to validate.
///
/// # Returns
/// SQL: `DESCRIBE (SELECT {expression} FROM {table_id})`
pub fn table_validate_expression(
&self,
table_id: &str,
expression: &str,
) -> GenericSQLResult<String> {
Ok(format!(
"DESCRIBE (SELECT {} FROM {})",
expression, table_id
))
}
/// Returns the SQL query to delete a view.
///
/// # Arguments
/// * `view_id` - The identifier of the view to delete.
///
/// # Returns
/// SQL: `DROP TABLE IF EXISTS {view_id}`
pub fn view_delete(&self, view_id: &str) -> GenericSQLResult<String> {
Ok(format!("DROP TABLE IF EXISTS {}", view_id))
}
/// Returns the SQL query to create a view from a table with the given
/// configuration.
///
/// # Arguments
/// * `table_id` - The identifier of the source table.
/// * `view_id` - The identifier for the new view.
/// * `config` - The view configuration specifying columns, group_by,
/// split_by, etc.
///
/// # Returns
/// SQL: `CREATE TABLE {view_id} AS (...)`
pub fn table_make_view(
&self,
table_id: &str,
view_id: &str,
config: &ViewConfig,
) -> GenericSQLResult<String> {
let ctx = ViewQueryContext::new(self, table_id, config);
let query = ctx.build_query();
let template = self.0.create_entity.as_deref().unwrap_or("TABLE");
Ok(format!("CREATE {} {} AS ({})", template, view_id, query))
}
/// Returns the SQL query to fetch data from a view with the given viewport.
///
/// # Arguments
/// * `view_id` - The identifier of the view.
/// * `config` - The view configuration.
/// * `viewport` - The viewport specifying row/column ranges.
/// * `schema` - The schema of the view (column names to types).
///
/// # Returns
/// SQL: `SELECT ... FROM {view_id} LIMIT ... OFFSET ...`
pub fn view_get_data(
&self,
view_id: &str,
config: &ViewConfig,
viewport: &ViewPort,
schema: &IndexMap<String, ColumnType>,
) -> GenericSQLResult<String> {
let group_by = &config.group_by;
let sort = &config.sort;
let start_col = viewport.start_col.unwrap_or(0) as usize;
let end_col = viewport.end_col.map(|x| x as usize);
let start_row = viewport.start_row.unwrap_or(0);
let end_row = viewport.end_row;
let limit_clause = if let Some(end) = end_row {
format!("LIMIT {} OFFSET {}", end - start_row, start_row)
} else {
String::new()
};
let mut data_columns: Vec<&String> = schema
.keys()
.filter(|col_name| !col_name.starts_with("__"))
.collect();
let col_sort_dir = sort.iter().find_map(|Sort(_, dir)| match dir {
SortDir::ColAsc | SortDir::ColAscAbs => Some(true),
SortDir::ColDesc | SortDir::ColDescAbs => Some(false),
_ => None,
});
if let Some(ascending) = col_sort_dir {
if ascending {
data_columns.sort();
} else {
data_columns.sort_by(|a, b| b.cmp(a));
}
}
let data_columns: Vec<&String> = data_columns
.into_iter()
.skip(start_col)
.take(end_col.map(|e| e - start_col).unwrap_or(usize::MAX))
.collect();
let mut group_by_cols: Vec<String> = Vec::new();
if !group_by.is_empty() {
if config.group_rollup_mode != GroupRollupMode::Flat {
group_by_cols.push("\"__GROUPING_ID__\"".to_string());
}
for idx in 0..group_by.len() {
group_by_cols.push(format!("\"__ROW_PATH_{}__\"", idx));
}
}
let all_columns: Vec<String> = group_by_cols
.into_iter()
.chain(data_columns.iter().map(|col| format!("\"{}\"", col)))
.collect();
Ok(format!(
"SELECT {} FROM {} {}",
all_columns.join(", "),
view_id,
limit_clause
)
.trim()
.to_string())
}
/// Returns the SQL query to describe a view's schema.
///
/// # Arguments
/// * `view_id` - The identifier of the view.
///
/// # Returns
/// SQL: `DESCRIBE {view_id}`
pub fn view_schema(&self, view_id: &str) -> GenericSQLResult<String> {
Ok(format!("DESCRIBE {}", view_id))
}
/// Returns the SQL query to get the row count of a view.
///
/// # Arguments
/// * `view_id` - The identifier of the view.
///
/// # Returns
/// SQL: `SELECT COUNT(*) FROM {view_id}`
pub fn view_size(&self, view_id: &str) -> GenericSQLResult<String> {
Ok(format!("SELECT COUNT(*) FROM {}", view_id))
}
/// Returns the SQL query to get the min and max values of a column.
///
/// # Arguments
/// * `view_id` - The identifier of the view.
/// * `column_name` - The name of the column.
/// * `config` - The view configuration.
///
/// # Returns
/// SQL: `SELECT MIN("column_name"), MAX("column_name") FROM {view_id}`
/// When the view uses ROLLUP grouping (non-flat mode with group_by),
/// a `WHERE __GROUPING_ID__ = 0` clause is added to exclude non-leaf rows.
pub fn view_get_min_max(
&self,
view_id: &str,
column_name: &str,
config: &ViewConfig,
) -> GenericSQLResult<String> {
let has_grouping_id =
!config.group_by.is_empty() && config.group_rollup_mode != GroupRollupMode::Flat;
let where_clause = if has_grouping_id {
" WHERE __GROUPING_ID__ = 0"
} else {
""
};
Ok(format!(
"SELECT MIN(\"{}\"), MAX(\"{}\") FROM {}{}",
column_name, column_name, view_id, where_clause
))
}
fn filter_term_to_sql(term: &FilterTerm) -> Option<String> {
match term {
FilterTerm::Scalar(scalar) => Self::scalar_to_sql(scalar),
FilterTerm::Array(scalars) => {
let values: Vec<String> = scalars.iter().filter_map(Self::scalar_to_sql).collect();
if values.is_empty() {
None
} else {
Some(format!("({})", values.join(", ")))
}
},
}
}
fn scalar_to_sql(scalar: &Scalar) -> Option<String> {
match scalar {
Scalar::Null => None,
Scalar::Bool(b) => Some(if *b { "TRUE" } else { "FALSE" }.to_string()),
Scalar::Float(f) => Some(f.to_string()),
Scalar::String(s) => Some(format!("'{}'", s.replace('\'', "''"))),
}
}
}
@@ -0,0 +1,649 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use crate::config::{Aggregate, GroupRollupMode, Sort, SortDir, ViewConfig};
fn aggregate_to_string(agg: &Aggregate) -> String {
match agg {
Aggregate::SingleAggregate(name) => name.clone(),
Aggregate::MultiAggregate(name, _args) => name.clone(),
}
}
fn sort_dir_to_string(dir: &SortDir) -> &'static str {
match dir {
SortDir::None => "",
SortDir::Asc | SortDir::ColAsc | SortDir::AscAbs | SortDir::ColAscAbs => "ASC",
SortDir::Desc | SortDir::ColDesc | SortDir::DescAbs | SortDir::ColDescAbs => "DESC",
}
}
fn is_col_sort(dir: &SortDir) -> bool {
matches!(
dir,
SortDir::ColAsc | SortDir::ColDesc | SortDir::ColAscAbs | SortDir::ColDescAbs
)
}
enum QueryOrientation {
/// Default
Flat,
/// `group_by` set
Grouped,
/// `split_by` set
Pivoted,
/// `group_by` and `split_by` set
GroupedAndPivoted,
/// `total` set
Total,
/// `total` and `split_by`
TotalPivoted,
}
/// Precomputed context for building a SQL view query from a [`ViewConfig`].
///
/// Holds the resolved column names, grouping function, and row-path aliases
/// needed to emit the correct `SELECT`, `GROUP BY`, `PIVOT`, `ORDER BY`, and
/// `WINDOW` clauses for every combination of `group_by` / `split_by`.
pub(crate) struct ViewQueryContext<'a> {
table: &'a str,
config: &'a ViewConfig,
group_col_names: Vec<String>,
grouping_fn: &'a str,
row_path_aliases: Vec<String>,
}
impl<'a> ViewQueryContext<'a> {
/// Creates a new query context by resolving expressions, the grouping
/// function, and row-path aliases from the given model and config.
pub(crate) fn new(
model: &'a super::GenericSQLVirtualServerModel,
table: &'a str,
config: &'a ViewConfig,
) -> Self {
let expressions = &config.expressions.0;
let col_name_resolve = |col: &str| -> String {
expressions
.get(col)
.cloned()
.unwrap_or_else(|| format!("\"{}\"", col))
};
let grouping_fn = model.0.grouping_fn.as_deref().unwrap_or("GROUPING_ID");
let group_col_names: Vec<String> = config
.group_by
.iter()
.map(|c| col_name_resolve(c))
.collect();
let row_path_aliases: Vec<String> = (0..config.group_by.len())
.map(|i| format!("__ROW_PATH_{}__", i))
.collect();
Self {
table,
config,
group_col_names,
grouping_fn,
row_path_aliases,
}
}
/// Builds the inner `SELECT` query (without the outer `CREATE TABLE`
/// wrapper) for the four `group_by` x `split_by` combinations, appending
/// `WINDOW` and `ORDER BY` clauses as needed.
pub(crate) fn build_query(&self) -> String {
let where_sql = self.where_sql();
let order_by = self.order_by_clauses();
let windows = self.window_clauses();
let mut query = match self.query_orientation() {
QueryOrientation::Flat => {
let select = self.select_clauses().join(", ");
format!("SELECT {} FROM {}{}", select, self.table, where_sql)
},
QueryOrientation::Grouped => {
let mut clauses = self.select_clauses();
clauses.extend(self.row_path_select_clauses());
if self.is_flat_mode() {
format!(
"SELECT {} FROM {}{} GROUP BY {}",
clauses.join(", "),
self.table,
where_sql,
self.group_col_names.join(", ")
)
} else {
clauses.push(self.grouping_id_clause());
format!(
"SELECT {} FROM {}{} GROUP BY ROLLUP({})",
clauses.join(", "),
self.table,
where_sql,
self.group_col_names.join(", ")
)
}
},
QueryOrientation::Pivoted => {
let select = self.select_clauses();
let pivot_using: Vec<String> = self
.config
.columns
.iter()
.flatten()
.map(|col| {
let escaped = col.replace('"', "\"\"").replace('_', "-");
format!("first(\"{}\") as \"{}\"", escaped, escaped)
})
.collect();
let split_cols: String = self
.config
.split_by
.iter()
.map(|c| format!("\"{}\"", c))
.collect::<Vec<_>>()
.join(", ");
let row_num_order = self.pivot_row_num_order();
format!(
"SELECT * EXCLUDE (__ROW_NUM__) FROM (PIVOT (SELECT {}, {}, ROW_NUMBER() OVER \
(ORDER BY {}) as __ROW_NUM__ FROM {}{}) ON {} USING {} GROUP BY __ROW_NUM__)",
select.join(", "),
split_cols,
row_num_order,
self.table,
where_sql,
self.pivot_on_expr(),
pivot_using.join(", "),
)
},
QueryOrientation::GroupedAndPivoted => {
let groups_joined = self.group_col_names.join(", ");
let split_cols_joined = self.pivot_on_expr();
let mut inner_clauses = self.select_clauses();
inner_clauses.extend(self.row_path_select_clauses());
if !self.is_flat_mode() {
inner_clauses.push(self.grouping_id_clause());
}
for sb_col in &self.config.split_by {
inner_clauses.push(self.col_name(sb_col));
}
for (sidx, Sort(sort_col, sort_dir)) in self.config.sort.iter().enumerate() {
if *sort_dir != SortDir::None && !is_col_sort(sort_dir) {
let agg = self.get_aggregate(sort_col);
if self.is_flat_mode() {
inner_clauses.push(format!(
"sum({}({})) OVER (PARTITION BY {}) AS __SORT_{}__",
agg,
self.col_name(sort_col),
groups_joined,
sidx,
));
} else {
inner_clauses.push(format!(
"sum({}({})) OVER (PARTITION BY {}({}), {}) AS __SORT_{}__",
agg,
self.col_name(sort_col),
self.grouping_fn,
groups_joined,
groups_joined,
sidx,
));
}
}
}
let inner_query = if self.is_flat_mode() {
format!(
"SELECT {} FROM {}{} GROUP BY {}, {}",
inner_clauses.join(", "),
self.table,
where_sql,
groups_joined,
split_cols_joined,
)
} else {
format!(
"SELECT {} FROM {}{} GROUP BY ROLLUP({}), {}",
inner_clauses.join(", "),
self.table,
where_sql,
groups_joined,
split_cols_joined,
)
};
let pivot_using: String = self
.config
.columns
.iter()
.flatten()
.map(|col| {
let escaped = col.replace('"', "\"\"").replace('_', "-");
format!("first(\"{}\") as \"{}\"", escaped, escaped)
})
.collect::<Vec<_>>()
.join(", ");
let mut row_id_cols = self.row_path_aliases.clone();
if !self.is_flat_mode() {
row_id_cols.push("__GROUPING_ID__".to_string());
}
for (sidx, Sort(_, sort_dir)) in self.config.sort.iter().enumerate() {
if *sort_dir != SortDir::None && !is_col_sort(sort_dir) {
row_id_cols.push(format!("__SORT_{}__", sidx));
}
}
format!(
"SELECT * FROM (PIVOT ({}) ON {} USING {} GROUP BY {})",
inner_query,
self.pivot_on_expr(),
pivot_using,
row_id_cols.join(", ")
)
},
QueryOrientation::Total => {
let select = self.select_clauses().join(", ");
format!("SELECT {} FROM {}{}", select, self.table, where_sql)
},
QueryOrientation::TotalPivoted => {
let raw_cols: Vec<String> = self
.config
.columns
.iter()
.flatten()
.map(|col| self.col_name(col))
.collect();
let split_cols: String = self
.config
.split_by
.iter()
.map(|c| format!("\"{}\"", c))
.collect::<Vec<_>>()
.join(", ");
let pivot_using: Vec<String> = self
.config
.columns
.iter()
.flatten()
.map(|col| {
let agg = self.get_aggregate(col);
let escaped = col.replace('"', "\"\"").replace('_', "-");
format!("{}(\"{}\") as \"{}\"", agg, escaped, escaped)
})
.collect();
format!(
"SELECT * FROM (PIVOT (SELECT {}, {} FROM {}{}) ON {} USING {})",
raw_cols.join(", "),
split_cols,
self.table,
where_sql,
self.pivot_on_expr(),
pivot_using.join(", "),
)
},
};
if !windows.is_empty() {
query = format!("{} WINDOW {}", query, windows.join(", "));
}
if !order_by.is_empty() {
query = format!("{} ORDER BY {}", query, order_by.join(", "));
} else if self.is_flat_mode() && !self.config.group_by.is_empty() {
let default_order: Vec<String> = self
.row_path_aliases
.iter()
.map(|alias| format!("{} ASC", alias))
.collect();
query = format!("{} ORDER BY {}", query, default_order.join(", "));
} else if self.config.group_by.is_empty()
&& self.config.group_rollup_mode != GroupRollupMode::Total
{
let default_order = if self.config.split_by.is_empty() {
"rowid"
} else {
"__ROW_NUM__"
};
query = format!("{} ORDER BY {}", query, default_order);
}
query
}
fn is_flat_mode(&self) -> bool {
self.config.group_rollup_mode == GroupRollupMode::Flat
}
fn needs_aggregation(&self) -> bool {
!self.config.group_by.is_empty() || self.config.group_rollup_mode == GroupRollupMode::Total
}
fn query_orientation(&self) -> QueryOrientation {
if self.config.group_rollup_mode == GroupRollupMode::Total {
return if self.config.split_by.is_empty() {
QueryOrientation::Total
} else {
QueryOrientation::TotalPivoted
};
}
match (
self.config.group_by.is_empty(),
self.config.split_by.is_empty(),
) {
(true, true) => QueryOrientation::Flat,
(false, true) => QueryOrientation::Grouped,
(true, false) => QueryOrientation::Pivoted,
(false, false) => QueryOrientation::GroupedAndPivoted,
}
}
fn col_name(&self, col: &str) -> String {
self.config
.expressions
.0
.get(col)
.cloned()
.unwrap_or_else(|| format!("\"{}\"", col))
}
fn get_aggregate(&self, col: &str) -> String {
self.config
.aggregates
.get(col)
.map(aggregate_to_string)
.unwrap_or_else(|| "any_value".to_string())
}
fn select_clauses(&self) -> Vec<String> {
let mut clauses = Vec::new();
if self.needs_aggregation() {
for col in self.config.columns.iter().flatten() {
let agg = self.get_aggregate(col);
let escaped = col.replace('"', "\"\"").replace("_", "-");
clauses.push(format!(
"{}({}) as \"{}\"",
agg,
self.col_name(col),
escaped
));
}
} else if !self.config.columns.is_empty() {
for col in self.config.columns.iter().flatten() {
let escaped = col.replace('"', "\"\"").replace("_", "-");
clauses.push(format!("{} as \"{}\"", self.col_name(col), escaped));
}
}
clauses
}
fn where_sql(&self) -> String {
let clauses: Vec<String> = self
.config
.filter
.iter()
.filter_map(|flt| {
super::GenericSQLVirtualServerModel::filter_term_to_sql(flt.term()).map(
|term_lit| format!("{} {} {}", self.col_name(flt.column()), flt.op(), term_lit),
)
})
.collect();
if clauses.is_empty() {
String::new()
} else {
format!(" WHERE {}", clauses.join(" AND "))
}
}
/// Builds the `ORDER BY` expression for the `ROW_NUMBER()` window
/// function used inside `PIVOT` queries. Uses sort config if available,
/// otherwise falls back to `rowid`.
fn pivot_row_num_order(&self) -> String {
let sort_exprs: Vec<String> = self
.config
.sort
.iter()
.filter(|Sort(_, dir)| *dir != SortDir::None && !is_col_sort(dir))
.map(|Sort(col, dir)| format!("{} {}", self.col_name(col), sort_dir_to_string(dir)))
.collect();
if sort_exprs.is_empty() {
"rowid".to_string()
} else {
sort_exprs.join(", ")
}
}
fn pivot_on_expr(&self) -> String {
self.config
.split_by
.iter()
.map(|c| format!("\"{}\"", c))
.collect::<Vec<_>>()
.join(", ")
}
fn grouping_id_clause(&self) -> String {
format!(
"{}({}) AS __GROUPING_ID__",
self.grouping_fn,
self.group_col_names.join(", ")
)
}
fn row_path_select_clauses(&self) -> Vec<String> {
self.config
.group_by
.iter()
.enumerate()
.map(|(i, col)| format!("{} as __ROW_PATH_{}__", self.col_name(col), i))
.collect()
}
fn order_by_clauses(&self) -> Vec<String> {
let mut clauses = Vec::new();
if !self.config.group_by.is_empty() && self.is_flat_mode() {
let has_row_sort = self
.config
.sort
.iter()
.any(|Sort(_, dir)| *dir != SortDir::None && !is_col_sort(dir));
if self.config.group_by.len() > 1 && has_row_sort {
// Hierarchical flat sort — mirrors rollup logic but without GROUPING_ID
for gidx in 0..self.config.group_by.len() {
let is_leaf = gidx >= self.config.group_by.len() - 1;
for (sidx, Sort(sort_col, sort_dir)) in self.config.sort.iter().enumerate() {
if *sort_dir == SortDir::None || is_col_sort(sort_dir) {
continue;
}
let dir = sort_dir_to_string(sort_dir);
if !self.config.split_by.is_empty() {
if is_leaf {
clauses.push(format!("__SORT_{}__ {}", sidx, dir));
} else {
clauses.push(format!(
"first(__SORT_{}__) OVER __WINDOW_{}__ {}",
sidx, gidx, dir
));
}
} else {
let agg = self.get_aggregate(sort_col);
if is_leaf {
clauses.push(format!(
"{}({}) {}",
agg,
self.col_name(sort_col),
dir
));
} else {
clauses.push(format!(
"first({}({})) OVER __WINDOW_{}__ {}",
agg,
self.col_name(sort_col),
gidx,
dir
));
}
}
}
clauses.push(format!("{} ASC", self.row_path_aliases[gidx]));
}
} else {
// Single group level — simple sort, no window needed
for (sidx, Sort(sort_col, sort_dir)) in self.config.sort.iter().enumerate() {
if *sort_dir != SortDir::None && !is_col_sort(sort_dir) {
let dir = sort_dir_to_string(sort_dir);
if !self.config.split_by.is_empty() {
clauses.push(format!("__SORT_{}__ {}", sidx, dir));
} else {
let agg = self.get_aggregate(sort_col);
clauses.push(format!("{}({}) {}", agg, self.col_name(sort_col), dir));
}
}
}
}
} else if !self.config.group_by.is_empty() {
for gidx in 0..self.config.group_by.len() {
if !self.config.split_by.is_empty() {
let shift = self.config.group_by.len() - 1 - gidx;
if shift > 0 {
clauses.push(format!("(__GROUPING_ID__ >> {}) DESC", shift));
} else {
clauses.push("__GROUPING_ID__ DESC".to_string());
}
} else {
let groups_up_to = self.config.group_by[..=gidx]
.iter()
.map(|c| self.col_name(c))
.collect::<Vec<_>>()
.join(", ");
clauses.push(format!("{}({}) DESC", self.grouping_fn, groups_up_to));
}
let is_leaf = gidx >= self.config.group_by.len() - 1;
for (sidx, Sort(sort_col, sort_dir)) in self.config.sort.iter().enumerate() {
if *sort_dir == SortDir::None || is_col_sort(sort_dir) {
continue;
}
let dir = sort_dir_to_string(sort_dir);
if !self.config.split_by.is_empty() {
if is_leaf {
clauses.push(format!("__SORT_{}__ {}", sidx, dir));
} else {
clauses.push(format!(
"first(__SORT_{}__) OVER __WINDOW_{}__ {}",
sidx, gidx, dir
));
}
} else {
let agg = self.get_aggregate(sort_col);
if is_leaf {
clauses.push(format!("{}({}) {}", agg, self.col_name(sort_col), dir));
} else {
clauses.push(format!(
"first({}({})) OVER __WINDOW_{}__ {}",
agg,
self.col_name(sort_col),
gidx,
dir
));
}
}
}
clauses.push(format!("{} ASC", self.row_path_aliases[gidx]));
}
} else if self.config.split_by.is_empty() {
for Sort(sort_col, sort_dir) in &self.config.sort {
if *sort_dir != SortDir::None && !is_col_sort(sort_dir) {
let dir = sort_dir_to_string(sort_dir);
clauses.push(format!("{} {}", self.col_name(sort_col), dir));
}
}
}
clauses
}
fn window_clauses(&self) -> Vec<String> {
if self.config.sort.is_empty() || self.config.group_by.len() <= 1 {
return Vec::new();
}
let mut clauses = Vec::new();
for gidx in 0..(self.config.group_by.len() - 1) {
let partition = self.row_path_aliases[..=gidx].join(", ");
if self.is_flat_mode() {
// Flat mode: partition by row path only (no GROUPING_ID)
if !self.config.split_by.is_empty() {
let order = self.row_path_aliases.join(", ");
clauses.push(format!(
"__WINDOW_{}__ AS (PARTITION BY {} ORDER BY {})",
gidx, partition, order,
));
} else {
clauses.push(format!(
"__WINDOW_{}__ AS (PARTITION BY {} ORDER BY {})",
gidx,
partition,
self.group_col_names.join(", ")
));
}
} else if !self.config.split_by.is_empty() {
let shift = self.config.group_by.len() - 1 - gidx;
let grouping_expr = if shift > 0 {
format!("(__GROUPING_ID__ >> {})", shift)
} else {
"__GROUPING_ID__".to_string()
};
let order = self.row_path_aliases.join(", ");
clauses.push(format!(
"__WINDOW_{}__ AS (PARTITION BY {}, {} ORDER BY {})",
gidx, grouping_expr, partition, order,
));
} else {
let sub_groups = self.config.group_by[..=gidx]
.iter()
.map(|c| self.col_name(c))
.collect::<Vec<_>>()
.join(", ");
clauses.push(format!(
"__WINDOW_{}__ AS (PARTITION BY {}({}), {} ORDER BY {})",
gidx,
self.grouping_fn,
sub_groups,
partition,
self.group_col_names.join(", ")
));
}
}
clauses
}
}
@@ -0,0 +1,620 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::collections::HashMap;
use super::*;
use crate::config::{Aggregate, GroupRollupMode};
#[test]
fn test_get_hosted_tables() {
let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
assert_eq!(builder.get_hosted_tables().unwrap(), "SHOW ALL TABLES");
}
#[test]
fn test_table_schema() {
let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
assert_eq!(
builder.table_schema("my_table").unwrap(),
"DESCRIBE my_table"
);
}
#[test]
fn test_table_size() {
let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
assert_eq!(
builder.table_size("my_table").unwrap(),
"SELECT COUNT(*) FROM my_table"
);
}
#[test]
fn test_view_delete() {
let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
assert_eq!(
builder.view_delete("my_view").unwrap(),
"DROP TABLE IF EXISTS my_view"
);
}
#[test]
fn test_table_make_view_simple() {
let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
let mut config = ViewConfig::default();
config.columns = vec![Some("col1".to_string()), Some("col2".to_string())];
let sql = builder
.table_make_view("source_table", "dest_view", &config)
.unwrap();
assert!(sql.starts_with("CREATE TABLE dest_view AS"));
assert!(sql.contains("\"col1\""));
assert!(sql.contains("\"col2\""));
}
#[test]
fn test_table_make_view_with_group_by() {
let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
let mut config = ViewConfig::default();
config.columns = vec![Some("value".to_string())];
config.group_by = vec!["category".to_string()];
let sql = builder
.table_make_view("source_table", "dest_view", &config)
.unwrap();
assert!(sql.contains("GROUP BY ROLLUP"));
assert!(sql.contains("__ROW_PATH_0__"));
assert!(sql.contains("__GROUPING_ID__"));
}
#[test]
fn test_table_make_view_with_group_by_and_split_by() {
let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
let mut config = ViewConfig::default();
config.columns = vec![Some("value".to_string())];
config.group_by = vec!["category".to_string()];
config.split_by = vec!["quarter".to_string()];
let sql = builder
.table_make_view("source_table", "dest_view", &config)
.unwrap();
assert!(sql.contains("GROUP BY ROLLUP"), "expected ROLLUP: {}", sql);
assert!(sql.contains("PIVOT"), "expected PIVOT: {}", sql);
assert!(
sql.contains("__ROW_PATH_0__"),
"expected __ROW_PATH_0__: {}",
sql
);
assert!(
sql.contains("__GROUPING_ID__"),
"expected __GROUPING_ID__: {}",
sql
);
}
#[test]
fn test_table_make_view_with_sort_group_by_and_split_by() {
let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
let mut config = ViewConfig::default();
config.columns = vec![Some("value".to_string())];
config.group_by = vec!["category".to_string()];
config.split_by = vec!["quarter".to_string()];
config.sort = vec![Sort("value".to_string(), SortDir::Asc)];
config.aggregates = HashMap::from([(
"value".to_string(),
Aggregate::SingleAggregate("sum".to_string()),
)]);
let sql = builder
.table_make_view("source_table", "dest_view", &config)
.unwrap();
assert!(sql.contains("__SORT_0__"), "expected __SORT_0__: {}", sql);
assert!(
sql.contains("__GROUPING_ID__, __SORT_0__"),
"expected __SORT_0__ in GROUP BY: {}",
sql
);
assert!(
sql.contains("__SORT_0__ ASC"),
"expected __SORT_0__ ASC in ORDER BY: {}",
sql
);
assert!(
!sql.contains("sum(\"value\") ASC"),
"should not have raw aggregate in ORDER BY: {}",
sql
);
}
#[test]
fn test_table_make_view_with_sort_multi_group_by_and_split_by() {
let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
let mut config = ViewConfig::default();
config.columns = vec![Some("value".to_string())];
config.group_by = vec!["region".to_string(), "category".to_string()];
config.split_by = vec!["quarter".to_string()];
config.sort = vec![Sort("value".to_string(), SortDir::Asc)];
config.aggregates = HashMap::from([(
"value".to_string(),
Aggregate::SingleAggregate("sum".to_string()),
)]);
let sql = builder
.table_make_view("source_table", "dest_view", &config)
.unwrap();
assert!(
sql.contains("PARTITION BY (__GROUPING_ID__ >> 1)"),
"expected shifted __GROUPING_ID__ in WINDOW: {}",
sql
);
assert!(
sql.contains("first(__SORT_0__) OVER __WINDOW_0__"),
"expected first(__SORT_0__) OVER __WINDOW_0__: {}",
sql
);
assert!(
!sql.contains("GROUPING_ID(\"region\")"),
"should not have GROUPING_ID function in WINDOW: {}",
sql
);
}
#[test]
fn test_table_make_view_with_sort_and_group_by_no_split_by() {
let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
let mut config = ViewConfig::default();
config.columns = vec![Some("value".to_string())];
config.group_by = vec!["category".to_string()];
config.sort = vec![Sort("value".to_string(), SortDir::Asc)];
config.aggregates = HashMap::from([(
"value".to_string(),
Aggregate::SingleAggregate("sum".to_string()),
)]);
let sql = builder
.table_make_view("source_table", "dest_view", &config)
.unwrap();
assert!(
sql.contains("sum(\"value\") ASC"),
"expected raw aggregate in ORDER BY: {}",
sql
);
assert!(
!sql.contains("__SORT_0__"),
"should not have __SORT_0__ without split_by: {}",
sql
);
}
#[test]
fn test_table_make_view_col_sort_excludes_row_order_by() {
let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
let mut config = ViewConfig::default();
config.columns = vec![Some("value".to_string())];
config.group_by = vec!["category".to_string()];
config.split_by = vec!["quarter".to_string()];
config.sort = vec![Sort("value".to_string(), SortDir::ColAsc)];
config.aggregates = HashMap::from([(
"value".to_string(),
Aggregate::SingleAggregate("sum".to_string()),
)]);
let sql = builder
.table_make_view("source_table", "dest_view", &config)
.unwrap();
assert!(
!sql.contains("__SORT_0__"),
"col sort should not produce __SORT_0__: {}",
sql
);
assert!(
!sql.contains("sum(\"value\") ASC"),
"col sort should not produce row ORDER BY: {}",
sql
);
assert!(sql.contains("PIVOT"), "should still have PIVOT: {}", sql);
}
#[test]
fn test_table_make_view_mixed_row_and_col_sort() {
let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
let mut config = ViewConfig::default();
config.columns = vec![Some("value".to_string()), Some("qty".to_string())];
config.group_by = vec!["category".to_string()];
config.split_by = vec!["quarter".to_string()];
config.sort = vec![
Sort("value".to_string(), SortDir::ColDesc),
Sort("qty".to_string(), SortDir::Asc),
];
config.aggregates = HashMap::from([
(
"value".to_string(),
Aggregate::SingleAggregate("sum".to_string()),
),
(
"qty".to_string(),
Aggregate::SingleAggregate("sum".to_string()),
),
]);
let sql = builder
.table_make_view("source_table", "dest_view", &config)
.unwrap();
assert!(
!sql.contains("__SORT_0__"),
"col sort (idx 0) should not produce __SORT_0__: {}",
sql
);
assert!(
sql.contains("__SORT_1__"),
"row sort (idx 1) should produce __SORT_1__: {}",
sql
);
}
#[test]
fn test_table_make_view_pivoted_with_sort() {
let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
let mut config = ViewConfig::default();
config.columns = vec![Some("value".to_string())];
config.split_by = vec!["quarter".to_string()];
config.sort = vec![Sort("value".to_string(), SortDir::Desc)];
let sql = builder
.table_make_view("source_table", "dest_view", &config)
.unwrap();
assert!(sql.contains("PIVOT"), "expected PIVOT: {}", sql);
assert!(
sql.contains("ROW_NUMBER() OVER (ORDER BY \"value\" DESC)"),
"expected sort in ROW_NUMBER window: {}",
sql
);
assert!(
sql.ends_with("ORDER BY __ROW_NUM__)"),
"should end with ORDER BY __ROW_NUM__: {}",
sql
);
}
#[test]
fn test_view_get_data_col_sort_ascending() {
let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
let mut config = ViewConfig::default();
config.sort = vec![Sort("value".to_string(), SortDir::ColAsc)];
let viewport = ViewPort {
start_row: Some(0),
end_row: Some(100),
start_col: Some(0),
end_col: None,
..ViewPort::default()
};
let mut schema = IndexMap::new();
schema.insert("C_value".to_string(), ColumnType::Float);
schema.insert("A_value".to_string(), ColumnType::Float);
schema.insert("B_value".to_string(), ColumnType::Float);
let sql = builder
.view_get_data("my_view", &config, &viewport, &schema)
.unwrap();
let a_pos = sql.find("\"A_value\"").unwrap();
let b_pos = sql.find("\"B_value\"").unwrap();
let c_pos = sql.find("\"C_value\"").unwrap();
assert!(
a_pos < b_pos && b_pos < c_pos,
"col asc should order columns A < B < C: {}",
sql
);
}
#[test]
fn test_view_get_data_col_sort_descending() {
let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
let mut config = ViewConfig::default();
config.sort = vec![Sort("value".to_string(), SortDir::ColDesc)];
let viewport = ViewPort {
start_row: Some(0),
end_row: Some(100),
start_col: Some(0),
end_col: None,
..ViewPort::default()
};
let mut schema = IndexMap::new();
schema.insert("A_value".to_string(), ColumnType::Float);
schema.insert("C_value".to_string(), ColumnType::Float);
schema.insert("B_value".to_string(), ColumnType::Float);
let sql = builder
.view_get_data("my_view", &config, &viewport, &schema)
.unwrap();
let a_pos = sql.find("\"A_value\"").unwrap();
let b_pos = sql.find("\"B_value\"").unwrap();
let c_pos = sql.find("\"C_value\"").unwrap();
assert!(
c_pos < b_pos && b_pos < a_pos,
"col desc should order columns C > B > A: {}",
sql
);
}
#[test]
fn test_view_get_data() {
let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
let config = ViewConfig::default();
let viewport = ViewPort {
start_row: Some(0),
end_row: Some(100),
start_col: Some(0),
end_col: Some(5),
..ViewPort::default()
};
let mut schema = IndexMap::new();
schema.insert("col1".to_string(), ColumnType::String);
schema.insert("col2".to_string(), ColumnType::Integer);
let sql = builder
.view_get_data("my_view", &config, &viewport, &schema)
.unwrap();
assert!(sql.contains("SELECT"));
assert!(sql.contains("FROM my_view"));
assert!(sql.contains("LIMIT 100 OFFSET 0"));
}
#[test]
fn test_table_make_view_flat_group_by() {
let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
let mut config = ViewConfig::default();
config.columns = vec![Some("value".to_string())];
config.group_by = vec!["category".to_string()];
config.group_rollup_mode = GroupRollupMode::Flat;
let sql = builder
.table_make_view("source_table", "dest_view", &config)
.unwrap();
assert!(
sql.contains("GROUP BY \"category\""),
"expected plain GROUP BY: {}",
sql
);
assert!(
!sql.contains("ROLLUP"),
"should not contain ROLLUP: {}",
sql
);
assert!(
!sql.contains("__GROUPING_ID__"),
"should not contain __GROUPING_ID__: {}",
sql
);
assert!(
sql.contains("__ROW_PATH_0__"),
"should contain __ROW_PATH_0__: {}",
sql
);
}
#[test]
fn test_table_make_view_flat_group_by_with_split_by() {
let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
let mut config = ViewConfig::default();
config.columns = vec![Some("value".to_string())];
config.group_by = vec!["category".to_string()];
config.split_by = vec!["quarter".to_string()];
config.group_rollup_mode = GroupRollupMode::Flat;
let sql = builder
.table_make_view("source_table", "dest_view", &config)
.unwrap();
assert!(sql.contains("PIVOT"), "expected PIVOT: {}", sql);
assert!(
!sql.contains("ROLLUP"),
"should not contain ROLLUP: {}",
sql
);
assert!(
!sql.contains("__GROUPING_ID__"),
"should not contain __GROUPING_ID__: {}",
sql
);
assert!(
sql.contains("__ROW_PATH_0__"),
"should contain __ROW_PATH_0__: {}",
sql
);
}
#[test]
fn test_table_make_view_flat_group_by_with_sort() {
let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
let mut config = ViewConfig::default();
config.columns = vec![Some("value".to_string())];
config.group_by = vec!["category".to_string()];
config.sort = vec![Sort("value".to_string(), SortDir::Asc)];
config.aggregates = HashMap::from([(
"value".to_string(),
Aggregate::SingleAggregate("sum".to_string()),
)]);
config.group_rollup_mode = GroupRollupMode::Flat;
let sql = builder
.table_make_view("source_table", "dest_view", &config)
.unwrap();
assert!(
sql.contains("sum(\"value\") ASC"),
"expected direct aggregate in ORDER BY: {}",
sql
);
assert!(
!sql.contains("ROLLUP"),
"should not contain ROLLUP: {}",
sql
);
assert!(
!sql.contains("__WINDOW_"),
"should not contain WINDOW clauses: {}",
sql
);
}
#[test]
fn test_table_make_view_flat_group_by_with_split_by_and_sort() {
let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
let mut config = ViewConfig::default();
config.columns = vec![Some("value".to_string())];
config.group_by = vec!["category".to_string()];
config.split_by = vec!["quarter".to_string()];
config.sort = vec![Sort("value".to_string(), SortDir::Desc)];
config.aggregates = HashMap::from([(
"value".to_string(),
Aggregate::SingleAggregate("sum".to_string()),
)]);
config.group_rollup_mode = GroupRollupMode::Flat;
let sql = builder
.table_make_view("source_table", "dest_view", &config)
.unwrap();
assert!(sql.contains("PIVOT"), "expected PIVOT: {}", sql);
assert!(
!sql.contains("ROLLUP"),
"should not contain ROLLUP: {}",
sql
);
assert!(
!sql.contains("__GROUPING_ID__"),
"should not contain __GROUPING_ID__: {}",
sql
);
assert!(
sql.contains("__SORT_0__"),
"expected __SORT_0__ for flat+pivoted+sort: {}",
sql
);
assert!(
sql.contains("__SORT_0__ DESC"),
"expected __SORT_0__ DESC in ORDER BY: {}",
sql
);
assert!(
!sql.contains("sum(\"value\") DESC"),
"should not have raw aggregate in ORDER BY: {}",
sql
);
}
#[test]
fn test_view_get_data_flat_no_grouping_id() {
let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
let mut config = ViewConfig::default();
config.group_by = vec!["category".to_string()];
config.group_rollup_mode = GroupRollupMode::Flat;
let viewport = ViewPort {
start_row: Some(0),
end_row: Some(100),
start_col: Some(0),
end_col: None,
..ViewPort::default()
};
let mut schema = IndexMap::new();
schema.insert("value".to_string(), ColumnType::Float);
let sql = builder
.view_get_data("my_view", &config, &viewport, &schema)
.unwrap();
assert!(
!sql.contains("__GROUPING_ID__"),
"flat mode should not select __GROUPING_ID__: {}",
sql
);
assert!(
sql.contains("__ROW_PATH_0__"),
"flat mode should still select __ROW_PATH_0__: {}",
sql
);
}
#[test]
fn test_table_make_view_total() {
let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
let mut config = ViewConfig::default();
config.columns = vec![Some("value".to_string())];
config.group_rollup_mode = GroupRollupMode::Total;
config.aggregates = HashMap::from([(
"value".to_string(),
Aggregate::SingleAggregate("sum".to_string()),
)]);
let sql = builder
.table_make_view("source_table", "dest_view", &config)
.unwrap();
assert!(
sql.contains("sum(\"value\")"),
"expected aggregate function: {}",
sql
);
assert!(
!sql.contains("GROUP BY"),
"should not contain GROUP BY: {}",
sql
);
assert!(
!sql.contains("ORDER BY"),
"should not contain ORDER BY: {}",
sql
);
}
#[test]
fn test_table_make_view_total_with_split_by() {
let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
let mut config = ViewConfig::default();
config.columns = vec![Some("value".to_string())];
config.split_by = vec!["quarter".to_string()];
config.group_rollup_mode = GroupRollupMode::Total;
config.aggregates = HashMap::from([(
"value".to_string(),
Aggregate::SingleAggregate("sum".to_string()),
)]);
let sql = builder
.table_make_view("source_table", "dest_view", &config)
.unwrap();
assert!(sql.contains("PIVOT"), "expected PIVOT: {}", sql);
assert!(
!sql.contains("GROUP BY"),
"should not contain GROUP BY: {}",
sql
);
assert!(
!sql.contains("ROW_NUMBER"),
"should not contain ROW_NUMBER: {}",
sql
);
}
@@ -0,0 +1,173 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::future::Future;
use std::pin::Pin;
use indexmap::IndexMap;
use super::data::VirtualDataSlice;
use super::features::Features;
use crate::config::{ViewConfig, ViewConfigUpdate};
use crate::proto::{ColumnType, HostedTable, TableMakePortReq, ViewPort};
#[cfg(feature = "sendable")]
pub type VirtualServerFuture<'a, T> = Pin<Box<dyn Future<Output = T> + 'a>>;
/// A boxed future that conditionally implements `Send` based on the target
/// architecture.
///
/// This only compiles on wasm, except for `rust-analyzer` and `metadata`
/// generation, so this type exists to tryck the compiler
#[cfg(not(feature = "sendable"))]
pub type VirtualServerFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
/// Handler trait for implementing virtual server backends.
///
/// This trait defines the interface that must be implemented to provide
/// a custom data source for the Perspective virtual server. Implementors
/// handle table and view operations, translating them to their underlying
/// data store.
pub trait VirtualServerHandler {
// Required
/// The error type returned by handler methods.
#[cfg(not(feature = "sendable"))]
type Error: std::error::Error + Send + Sync + 'static;
#[cfg(feature = "sendable")]
type Error: std::error::Error + 'static;
/// Returns a list of all tables hosted by this handler.
fn get_hosted_tables(&self) -> VirtualServerFuture<'_, Result<Vec<HostedTable>, Self::Error>>;
/// Returns the schema (column names and types) for a table.
fn table_schema(
&self,
table_id: &str,
) -> VirtualServerFuture<'_, Result<IndexMap<String, ColumnType>, Self::Error>>;
/// Returns the number of rows in a table.
fn table_size(&self, table_id: &str) -> VirtualServerFuture<'_, Result<u32, Self::Error>>;
/// Creates a new view on a table with the given configuration.
///
/// The handler may modify the configuration to reflect any adjustments
/// made during view creation.
fn table_make_view(
&mut self,
view_id: &str,
view_id: &str,
config: &mut ViewConfigUpdate,
) -> VirtualServerFuture<'_, Result<String, Self::Error>>;
/// Deletes a view and releases its resources.
fn view_delete(&self, view_id: &str) -> VirtualServerFuture<'_, Result<(), Self::Error>>;
/// Retrieves data from a view within the specified viewport.
fn view_get_data(
&self,
view_id: &str,
config: &ViewConfig,
schema: &IndexMap<String, ColumnType>,
viewport: &ViewPort,
) -> VirtualServerFuture<'_, Result<VirtualDataSlice, Self::Error>>;
// Optional
/// Return the column count of a `Table`
fn table_column_size(
&self,
table_id: &str,
) -> VirtualServerFuture<'_, Result<u32, Self::Error>> {
let fut = self.table_schema(table_id);
Box::pin(async move { Ok(fut.await?.len() as u32) })
}
/// Returns the number of rows in a `View`.
fn view_size(&self, view_id: &str) -> VirtualServerFuture<'_, Result<u32, Self::Error>> {
Box::pin(self.table_size(view_id))
}
/// Return the column count of a `View`
fn view_column_size(
&self,
view_id: &str,
config: &ViewConfig,
) -> VirtualServerFuture<'_, Result<u32, Self::Error>> {
let fut = self.view_schema(view_id, config);
Box::pin(async move { Ok(fut.await?.len() as u32) })
}
/// Returns the schema of a view after applying its configuration.
fn view_schema(
&self,
view_id: &str,
_config: &ViewConfig,
) -> VirtualServerFuture<'_, Result<IndexMap<String, ColumnType>, Self::Error>> {
Box::pin(self.table_schema(view_id))
}
/// Validates an expression against a table and returns its result type.
///
/// Default implementation returns `Float` for all expressions.
fn table_validate_expression(
&self,
_table_id: &str,
_expression: &str,
) -> VirtualServerFuture<'_, Result<ColumnType, Self::Error>> {
Box::pin(async { Ok(ColumnType::Float) })
}
/// Returns the features supported by this handler.
///
/// Default implementation returns default features.
fn get_features(&self) -> VirtualServerFuture<'_, Result<Features<'_>, Self::Error>> {
Box::pin(async { Ok(Features::default()) })
}
/// Creates a new input port on a table.
///
/// Default implementation returns port ID 0.
fn table_make_port(
&self,
_req: &TableMakePortReq,
) -> VirtualServerFuture<'_, Result<u32, Self::Error>> {
Box::pin(async { Ok(0) })
}
/// Returns the min and max values of a column in a view.
///
/// Default implementation panics with "not implemented".
fn view_get_min_max(
&self,
_view_id: &str,
_column_name: &str,
_config: &crate::config::ViewConfig,
) -> VirtualServerFuture<'_, Result<(crate::config::Scalar, crate::config::Scalar), Self::Error>>
{
Box::pin(async { unimplemented!("view_get_min_max not implemented") })
}
// Unused
/// Creates a new table with the given data.
///
/// Default implementation panics with "not implemented".
fn make_table(
&mut self,
_table_id: &str,
_data: &crate::proto::MakeTableData,
) -> VirtualServerFuture<'_, Result<(), Self::Error>> {
Box::pin(async { unimplemented!("make_table not implemented") })
}
}
@@ -0,0 +1,33 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
//! Virtual server implementation for Perspective.
//!
//! This module provides a virtual server that can process Perspective protocol
//! messages and delegate operations to a custom backend handler.
mod data;
mod error;
mod features;
mod generic_sql_model;
mod handler;
mod server;
pub use data::{RowPathStyle, SetVirtualDataColumn, VirtualDataCell, VirtualDataSlice};
pub use error::{ResultExt, VirtualServerError};
pub use features::{AggSpec, Features};
pub use generic_sql_model::{
GenericSQLError, GenericSQLResult, GenericSQLVirtualServerModel,
GenericSQLVirtualServerModelArgs,
};
pub use handler::{VirtualServerFuture, VirtualServerHandler};
pub use server::VirtualServer;
@@ -0,0 +1,451 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
use std::collections::HashMap;
use indexmap::IndexMap;
use prost::Message as ProstMessage;
use prost::bytes::{Bytes, BytesMut};
use super::data::RowPathStyle;
use super::error::VirtualServerError;
use super::handler::VirtualServerHandler;
use crate::config::{ViewConfig, ViewConfigUpdate};
use crate::proto::response::ClientResp;
use crate::proto::table_validate_expr_resp::ExprValidationError;
use crate::proto::{
ColumnType, GetFeaturesResp, GetHostedTablesResp, MakeTableResp, Request, Response,
ServerError, TableMakePortResp, TableMakeViewResp, TableOnDeleteResp, TableRemoveDeleteResp,
TableSchemaResp, TableSizeResp, TableValidateExprResp, ViewColumnPathsResp, ViewDeleteResp,
ViewDimensionsResp, ViewExpressionSchemaResp, ViewGetConfigResp, ViewGetMinMaxResp,
ViewOnDeleteResp, ViewOnUpdateResp, ViewRemoveDeleteResp, ViewRemoveOnUpdateResp,
ViewSchemaResp, ViewToArrowResp, ViewToColumnsStringResp, ViewToCsvResp,
ViewToNdjsonStringResp, ViewToRowsStringResp,
};
macro_rules! respond {
($msg:ident, $name:ident { $($rest:tt)* }) => {{
let mut resp = BytesMut::new();
let resp2 = ClientResp::$name($name {
$($rest)*
});
Response {
msg_id: $msg.msg_id,
entity_id: $msg.entity_id,
client_resp: Some(resp2),
}.encode(&mut resp).map_err(VirtualServerError::EncodeError)?;
resp.freeze()
}};
}
/// A virtual server that processes Perspective protocol messages.
///
/// `VirtualServer` acts as a bridge between the Perspective protocol and a
/// custom data backend. It handles protocol decoding/encoding and delegates
/// actual data operations to the provided [`VirtualServerHandler`].
pub struct VirtualServer<T: VirtualServerHandler> {
handler: T,
view_to_table: IndexMap<String, String>,
view_configs: IndexMap<String, ViewConfig>,
view_schemas: IndexMap<String, IndexMap<String, ColumnType>>,
}
impl<T: VirtualServerHandler> VirtualServer<T> {
/// Creates a new virtual server with the given handler.
pub fn new(handler: T) -> Self {
Self {
handler,
view_configs: IndexMap::default(),
view_to_table: IndexMap::default(),
view_schemas: IndexMap::default(),
}
}
/// Processes a Perspective protocol request and returns the response.
///
/// Decodes the incoming protobuf message, dispatches to the appropriate
/// handler method, and encodes the response.
pub async fn handle_request(
&mut self,
bytes: Bytes,
) -> Result<Bytes, VirtualServerError<T::Error>> {
let msg = Request::decode(bytes).map_err(VirtualServerError::DecodeError)?;
tracing::debug!(
"Handling request: entity_id={}, req={:?}",
msg.entity_id,
msg.client_req
);
match self.internal_handle_request(msg.clone()).await {
Ok(resp) => Ok(resp),
Err(err) => {
tracing::error!("{}", err);
Ok(respond!(msg, ServerError {
message: err.to_string(),
status_code: 0
}))
},
}
}
async fn get_cached_view_schema(
&mut self,
entity_id: &str,
to_psp_format: bool,
) -> Result<IndexMap<String, ColumnType>, VirtualServerError<T::Error>> {
if !self.view_schemas.contains_key(entity_id) {
self.view_schemas.insert(
entity_id.to_string(),
self.handler
.view_schema(entity_id, self.view_configs.get(entity_id).unwrap())
.await?,
);
}
if to_psp_format {
Ok(self
.view_schemas
.get(entity_id)
.unwrap()
.iter()
.map(|(k, v)| {
(
k.split("_").collect::<Vec<_>>().last().unwrap().to_string(),
*v,
)
})
.collect())
} else {
Ok(self.view_schemas.get(entity_id).cloned().unwrap())
}
}
async fn internal_handle_request(
&mut self,
msg: Request,
) -> Result<Bytes, VirtualServerError<T::Error>> {
use crate::proto::request::ClientReq::*;
let resp = match msg.client_req.unwrap() {
GetFeaturesReq(_) => {
let features = self.handler.get_features().await?;
respond!(msg, GetFeaturesResp { ..features.into() })
},
GetHostedTablesReq(_) => {
respond!(msg, GetHostedTablesResp {
table_infos: self.handler.get_hosted_tables().await?
})
},
TableSchemaReq(_) => {
respond!(msg, TableSchemaResp {
schema: Some(crate::proto::Schema {
schema: self
.handler
.table_schema(msg.entity_id.as_str())
.await?
.iter()
.map(|x| crate::proto::schema::KeyTypePair {
name: x.0.to_string(),
r#type: *x.1 as i32,
})
.collect()
})
})
},
TableMakePortReq(req) => {
respond!(msg, TableMakePortResp {
port_id: self.handler.table_make_port(&req).await?
})
},
TableMakeViewReq(req) => {
self.view_to_table
.insert(req.view_id.clone(), msg.entity_id.clone());
let mut config: ViewConfigUpdate = req.config.clone().unwrap_or_default().into();
let bytes = respond!(msg, TableMakeViewResp {
view_id: self
.handler
.table_make_view(msg.entity_id.as_str(), req.view_id.as_str(), &mut config)
.await?
});
self.view_configs.insert(req.view_id.clone(), config.into());
bytes
},
TableSizeReq(_) => {
respond!(msg, TableSizeResp {
size: self.handler.table_size(msg.entity_id.as_str()).await?
})
},
TableValidateExprReq(req) => {
let mut expression_schema = HashMap::<String, i32>::default();
let mut expression_alias = HashMap::<String, String>::default();
let mut errors = HashMap::<String, ExprValidationError>::default();
for (name, ex) in req.column_to_expr.iter() {
let _ = expression_alias.insert(name.clone(), ex.clone());
match self
.handler
.table_validate_expression(&msg.entity_id, ex.as_str())
.await
{
Ok(dtype) => {
let _ = expression_schema.insert(name.clone(), dtype as i32);
},
Err(e) => {
let _ = errors.insert(name.clone(), ExprValidationError {
error_message: format!("{}", e),
line: 0,
column: 0,
});
},
}
}
respond!(msg, TableValidateExprResp {
expression_schema,
errors,
expression_alias,
})
},
ViewSchemaReq(_) => {
respond!(msg, ViewSchemaResp {
schema: self
.get_cached_view_schema(&msg.entity_id, true)
.await?
.into_iter()
.map(|(x, y)| (x.to_string(), y as i32))
.collect()
})
},
ViewDimensionsReq(_) => {
let view_id = &msg.entity_id;
let table_id = self
.view_to_table
.get(view_id)
.ok_or_else(|| VirtualServerError::UnknownViewId(view_id.to_string()))?;
let num_table_rows = self.handler.table_size(table_id).await?;
let num_table_columns = self.handler.table_column_size(table_id).await? as u32;
let config = self.view_configs.get(view_id).unwrap();
let num_view_columns = self.handler.view_column_size(view_id, config).await? as u32;
let num_view_rows = self.handler.view_size(view_id).await?;
let resp = ViewDimensionsResp {
num_table_columns,
num_table_rows,
num_view_columns,
num_view_rows,
};
respond!(msg, ViewDimensionsResp { ..resp })
},
ViewGetConfigReq(_) => {
respond!(msg, ViewGetConfigResp {
config: Some(
ViewConfigUpdate::from(
self.view_configs.get(&msg.entity_id).unwrap().clone()
)
.into()
)
})
},
ViewExpressionSchemaReq(_) => {
let mut schema = HashMap::<String, i32>::default();
let table_id = self.view_to_table.get(&msg.entity_id);
for (name, ex) in self
.view_configs
.get(&msg.entity_id)
.unwrap()
.expressions
.iter()
{
match self
.handler
.table_validate_expression(table_id.unwrap(), ex.as_str())
.await
{
Ok(dtype) => {
let _ = schema.insert(name.clone(), dtype as i32);
},
Err(_e) => {
// TODO: handle error
},
}
}
let resp = ViewExpressionSchemaResp { schema };
respond!(msg, ViewExpressionSchemaResp { ..resp })
},
ViewColumnPathsReq(_) => {
respond!(msg, ViewColumnPathsResp {
paths: self
.handler
.view_schema(
msg.entity_id.as_str(),
self.view_configs.get(&msg.entity_id).unwrap()
)
.await?
.keys()
.cloned()
.collect()
})
},
ViewToArrowReq(view_to_arrow_req) => {
let viewport = view_to_arrow_req.viewport.unwrap();
let schema = self.get_cached_view_schema(&msg.entity_id, false).await?;
let config = self.view_configs.get(&msg.entity_id).unwrap();
let mut cols = self
.handler
.view_get_data(msg.entity_id.as_str(), config, &schema, &viewport)
.await?;
let arrow = cols
.render_to_arrow_ipc()
.map_err(|e| VirtualServerError::Other(e.to_string()))?;
respond!(msg, ViewToArrowResp { arrow })
},
ViewToCsvReq(view_to_csv_req) => {
let viewport = view_to_csv_req.viewport.unwrap();
let schema = self.get_cached_view_schema(&msg.entity_id, false).await?;
let config = self.view_configs.get(&msg.entity_id).unwrap();
let mut cols = self
.handler
.view_get_data(msg.entity_id.as_str(), config, &schema, &viewport)
.await?;
let rows = cols.render_to_rows(RowPathStyle::PerLevel);
let mut csv = String::new();
if let Some(first_row) = rows.first() {
let headers: Vec<&str> = first_row.keys().map(|k| k.as_str()).collect();
csv.push_str(&headers.join(","));
csv.push('\n');
}
for row in &rows {
let values: Vec<String> = row
.values()
.map(|cell| serde_json::to_string(cell).unwrap_or_default())
.collect();
csv.push_str(&values.join(","));
csv.push('\n');
}
respond!(msg, ViewToCsvResp { csv })
},
ViewToNdjsonStringReq(view_to_ndjson_req) => {
let viewport = view_to_ndjson_req.viewport.unwrap();
let schema = self.get_cached_view_schema(&msg.entity_id, false).await?;
let config = self.view_configs.get(&msg.entity_id).unwrap();
let mut cols = self
.handler
.view_get_data(msg.entity_id.as_str(), config, &schema, &viewport)
.await?;
let rows = cols.render_to_rows(RowPathStyle::PerLevel);
let ndjson_string = rows
.iter()
.map(serde_json::to_string)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| VirtualServerError::InvalidJSON(std::sync::Arc::new(e)))?
.join("\n");
respond!(msg, ViewToNdjsonStringResp { ndjson_string })
},
ViewToRowsStringReq(view_to_rows_string_req) => {
let viewport = view_to_rows_string_req.viewport.unwrap();
let schema = self.get_cached_view_schema(&msg.entity_id, false).await?;
let config = self.view_configs.get(&msg.entity_id).unwrap();
let mut cols = self
.handler
.view_get_data(msg.entity_id.as_str(), config, &schema, &viewport)
.await?;
let rows = cols.render_to_rows(RowPathStyle::Sidecar);
let json_string = serde_json::to_string(&rows)
.map_err(|e| VirtualServerError::InvalidJSON(std::sync::Arc::new(e)))?;
respond!(msg, ViewToRowsStringResp { json_string })
},
ViewToColumnsStringReq(view_to_columns_string_req) => {
let viewport = view_to_columns_string_req.viewport.unwrap();
let schema = self.get_cached_view_schema(&msg.entity_id, false).await?;
let config = self.view_configs.get(&msg.entity_id).unwrap();
let mut cols = self
.handler
.view_get_data(msg.entity_id.as_str(), config, &schema, &viewport)
.await?;
let json_string = cols
.render_to_columns_json(RowPathStyle::Sidecar)
.map_err(|e| VirtualServerError::Other(e.to_string()))?;
respond!(msg, ViewToColumnsStringResp { json_string })
},
ViewDeleteReq(_) => {
self.handler.view_delete(msg.entity_id.as_str()).await?;
self.view_to_table.shift_remove(&msg.entity_id);
self.view_configs.shift_remove(&msg.entity_id);
respond!(msg, ViewDeleteResp {})
},
MakeTableReq(req) => {
self.handler
.make_table(&msg.entity_id, req.data.as_ref().unwrap())
.await?;
respond!(msg, MakeTableResp {})
},
ViewGetMinMaxReq(req) => {
let config = self.view_configs.get(&msg.entity_id).unwrap();
let (min, max) = self
.handler
.view_get_min_max(&msg.entity_id, &req.column_name, config)
.await?;
respond!(msg, ViewGetMinMaxResp {
min: Some(min.into()),
max: Some(max.into()),
})
},
// Stub implementations for callback/update requests that VirtualServer doesn't support
TableOnDeleteReq(_) => {
respond!(msg, TableOnDeleteResp {})
},
ViewOnUpdateReq(_) => {
respond!(msg, ViewOnUpdateResp {
delta: None,
port_id: 0
})
},
ViewOnDeleteReq(_) => {
respond!(msg, ViewOnDeleteResp {})
},
ViewRemoveOnUpdateReq(_) => {
respond!(msg, ViewRemoveOnUpdateResp {})
},
TableRemoveDeleteReq(_) => {
respond!(msg, TableRemoveDeleteResp {})
},
ViewRemoveDeleteReq(_) => {
respond!(msg, ViewRemoveDeleteResp {})
},
x => {
// Return an error response instead of empty bytes
return Err(VirtualServerError::Other(format!(
"Unhandled request: {:?}",
x
)));
},
};
Ok(resp)
}
}