chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ 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-bundle"
|
||||
description = "A CLI utility to build and link persecptive-viewer"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "bundle"
|
||||
path = "main.rs"
|
||||
bench = false
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.4.8", features = ["derive"] }
|
||||
wasm-bindgen-cli-support = "*"
|
||||
|
||||
# https://github.com/brson/wasm-opt-rs/issues/154
|
||||
wasm-opt = { version = "0.116.1", default-features = false }
|
||||
@@ -0,0 +1,18 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
fn main() {
|
||||
println!(
|
||||
"cargo:rustc-env=TARGET={}",
|
||||
std::env::var("TARGET").unwrap()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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::path::Path;
|
||||
use std::process::{Command, exit};
|
||||
|
||||
use clap::*;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
struct BundleArgs {
|
||||
/// Artifact name
|
||||
artifact: String,
|
||||
|
||||
/// Compile in release mode?
|
||||
#[arg(short, long)]
|
||||
release: bool,
|
||||
|
||||
/// Extra features to build with
|
||||
#[arg(long)]
|
||||
features: Option<String>,
|
||||
}
|
||||
|
||||
use wasm_bindgen_cli_support::{Bindgen, EncodeInto};
|
||||
use wasm_opt::{Feature, OptimizationOptions};
|
||||
|
||||
/// Run the packages `build` task with the appropriate flags. These can't be
|
||||
/// defined in the `/.cargo/config.toml` because they would define this build
|
||||
/// script's parameters also, and there is no way to reset e.g. the `target`
|
||||
/// field to the host platform.
|
||||
fn build(pkg: Option<&str>, is_release: bool, features: Vec<String>) {
|
||||
let features = format!("tracing/release_max_level_warn,{}", features.join(","));
|
||||
|
||||
// Build RUSTFLAGS including target-specific flags from config.toml and new
|
||||
// panic flags These are the flags from .cargo/config.toml for
|
||||
// wasm32-unknown-unknown target
|
||||
let target_flags = [
|
||||
"--cfg=getrandom_backend=\"wasm_js\"",
|
||||
"--cfg=web_sys_unstable_apis",
|
||||
"-Ctarget-feature=+bulk-memory,+simd128,+relaxed-simd,+reference-types",
|
||||
];
|
||||
|
||||
let rustflags = target_flags.join(" ");
|
||||
let mut cmd = Command::new("cargo");
|
||||
cmd.env("RUSTFLAGS", rustflags)
|
||||
.args(["build"])
|
||||
.args(["--lib"])
|
||||
.args(["--features", &features])
|
||||
.args(["--target", "wasm32-unknown-unknown"])
|
||||
.args(["-Z", "build-std=std,panic_abort"]);
|
||||
|
||||
if is_release {
|
||||
cmd.args(["--release"]);
|
||||
}
|
||||
|
||||
if let Some(pkg) = pkg {
|
||||
cmd.args(["-p", pkg]);
|
||||
}
|
||||
|
||||
cmd.execute()
|
||||
}
|
||||
|
||||
/// Generate the `wasm-bindgen` JavaScript and WASM bindings.
|
||||
fn bindgen(outdir: &Path, artifact: &str, is_release: bool) {
|
||||
let input = Path::new("../target/wasm32-unknown-unknown")
|
||||
.join(if is_release { "release" } else { "debug" })
|
||||
.join(format!("{artifact}.wasm"));
|
||||
|
||||
Bindgen::new()
|
||||
.web(true)
|
||||
.unwrap()
|
||||
.keep_debug(!is_release)
|
||||
.input_path(input)
|
||||
.encode_into(EncodeInto::Always)
|
||||
.typescript(true)
|
||||
// .reference_types(true)
|
||||
.out_name(&format!("{}.wasm", artifact.replace('_', "-")))
|
||||
.generate(outdir)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Run `wasm-opt` and output the new binary on top of the old one.
|
||||
fn opt(outpath: &Path, is_release: bool) {
|
||||
if is_release {
|
||||
OptimizationOptions::new_optimize_for_size_aggressively()
|
||||
.enable_feature(Feature::BulkMemory)
|
||||
.enable_feature(Feature::ReferenceTypes)
|
||||
.enable_feature(Feature::Simd)
|
||||
.enable_feature(Feature::RelaxedSimd)
|
||||
.enable_feature(Feature::TruncSat)
|
||||
.run(outpath, outpath)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = BundleArgs::parse();
|
||||
let outdir = Path::new("dist/wasm");
|
||||
let is_release = args.release;
|
||||
let package = args.artifact.clone().replace('_', "-");
|
||||
let outpath = &Path::new(outdir).join(format!(
|
||||
"{}.wasm",
|
||||
args.artifact.replace("-js", "").replace('_', "-")
|
||||
));
|
||||
|
||||
let features = args
|
||||
.features
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.map(|x| x.to_string())
|
||||
.collect();
|
||||
|
||||
build(Some(package.as_str()), is_release, features);
|
||||
bindgen(outdir, args.artifact.as_str(), is_release);
|
||||
opt(outpath, is_release);
|
||||
}
|
||||
|
||||
trait SimpleCommand {
|
||||
fn execute(&mut self);
|
||||
}
|
||||
|
||||
impl SimpleCommand for Command {
|
||||
fn execute(&mut self) {
|
||||
match self.status().ok().and_then(|x| x.code()) {
|
||||
Some(0) => (),
|
||||
Some(x) => exit(x),
|
||||
None => exit(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ 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-lint"
|
||||
description = "A CLI utility to lint rust code"
|
||||
version = "4.5.2"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "lint"
|
||||
path = "main.rs"
|
||||
bench = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
[dependencies]
|
||||
yew-fmt = { version = "0.6.3", artifact = "bin", optional = true }
|
||||
glob = "0.3.1"
|
||||
@@ -0,0 +1,52 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
fn check_edition(val: String, edition: &mut bool) -> String {
|
||||
if val == "--edition" {
|
||||
*edition = false;
|
||||
}
|
||||
|
||||
val
|
||||
}
|
||||
|
||||
pub fn main() {
|
||||
let mut needs_edition = true;
|
||||
let args = std::env::args()
|
||||
.skip(1)
|
||||
.map(|x| check_edition(x, &mut needs_edition))
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
let edition_args = if needs_edition {
|
||||
vec!["--edition", "2024"]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let yewfmt_args = edition_args
|
||||
.into_iter()
|
||||
.map(|x| x.into())
|
||||
.chain(args)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if let Some(cmd) = option_env!("CARGO_BIN_FILE_YEW_FMT") {
|
||||
let exit_code = std::process::Command::new(cmd)
|
||||
.args(yewfmt_args)
|
||||
.spawn()
|
||||
.expect("Could not spawn process")
|
||||
.wait()
|
||||
.expect("Process did not start");
|
||||
|
||||
std::process::exit(exit_code.code().unwrap())
|
||||
} else {
|
||||
std::process::exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
# ┃ 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 = "metadata"
|
||||
description = "A CLI utility for generating Perspective project metadata like TypeScript definitions."
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "perspective_metadata"
|
||||
path = "main.rs"
|
||||
bench = false
|
||||
|
||||
[dependencies.cargo-bundle-licenses]
|
||||
version = "4.2.0"
|
||||
artifact = ["bin"]
|
||||
|
||||
[dependencies.ts-rs]
|
||||
version = "11.0.1"
|
||||
features = ["serde-json-impl", "no-serde-warnings"]
|
||||
|
||||
[dependencies.perspective-server]
|
||||
path = "../perspective-server"
|
||||
features = ["external-cpp", "disable-cpp"]
|
||||
|
||||
[dependencies.perspective-client]
|
||||
features = ["generate-proto", "omit_metadata", "protobuf-src"]
|
||||
path = "../perspective-client"
|
||||
|
||||
[dependencies.perspective-js]
|
||||
path = "../perspective-js"
|
||||
features = ["external-cpp"]
|
||||
|
||||
[dependencies.perspective-viewer]
|
||||
path = "../perspective-viewer"
|
||||
@@ -0,0 +1,27 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import { execSync } from "child_process";
|
||||
|
||||
const INHERIT = {
|
||||
stdio: "inherit",
|
||||
stderr: "inherit",
|
||||
};
|
||||
|
||||
function get_host() {
|
||||
return /host\: (.+?)$/gm.exec(execSync(`rustc -vV`).toString())[1];
|
||||
}
|
||||
|
||||
execSync(
|
||||
`PSP_ROOT_DIR=../.. TS_RS_IMPORT_EXTENSION=js cargo run --target=${get_host()}`,
|
||||
INHERIT,
|
||||
);
|
||||
@@ -0,0 +1,109 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
#![recursion_limit = "1024"]
|
||||
|
||||
//! This module generates metadata for other crates:
|
||||
//!
|
||||
//! - `perspective-client`
|
||||
//! - Add `protobuf-src` dependency
|
||||
//! - Generate `proto.rs` protobuf client bindings.
|
||||
//! - `perspective-js`
|
||||
//! - TypeScript types
|
||||
//! - Recurisvely set external proto on `perspective-client`
|
||||
//! - `perspective-server`
|
||||
//! - Copy `cpp` and `cmake` to local root
|
||||
//!
|
||||
//! The `metadata` binary must be run for these assets to be updated in
|
||||
//! your local dev tree!
|
||||
|
||||
use std::error::Error;
|
||||
use std::fmt::Write;
|
||||
use std::fs;
|
||||
|
||||
use perspective_client::config::*;
|
||||
use perspective_client::{
|
||||
ColumnWindow, DeleteOptions, JoinOptions, OnUpdateData, OnUpdateOptions, SystemInfo,
|
||||
TableInitOptions, UpdateOptions, ViewWindow,
|
||||
};
|
||||
use perspective_js::TypedArrayWindow;
|
||||
use perspective_viewer::config::{PluginStaticConfig, ViewerConfig, ViewerConfigUpdate};
|
||||
use ts_rs::TS;
|
||||
|
||||
pub fn generate_type_bindings_viewer() -> Result<(), Box<dyn Error>> {
|
||||
let path = std::env::current_dir()?.join("../perspective-viewer/src/ts/ts-rs");
|
||||
ViewerConfigUpdate::export_all_to(&path)?;
|
||||
ViewerConfig::<String>::export_all_to(&path)?;
|
||||
PluginStaticConfig::export_all_to(&path)?;
|
||||
OnUpdateData::export_all_to(&path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_exprtk_docs() -> Result<(), Box<dyn Error>> {
|
||||
let mut txt = "<br/>\n\n# Perspective ExprTK Extensions\n\n".to_string();
|
||||
for rec in perspective_client::config::COMPLETIONS {
|
||||
writeln!(
|
||||
txt,
|
||||
"- `{}` {}",
|
||||
rec.insert_text,
|
||||
rec.documentation.replace("\n", " "),
|
||||
)?;
|
||||
}
|
||||
|
||||
fs::create_dir_all("../perspective-client/docs/")?;
|
||||
fs::write("../perspective-client/docs/expression_gen.md", txt)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn generate_type_bindings_js() -> Result<(), Box<dyn Error>> {
|
||||
let path = std::env::current_dir()?.join("../perspective-js/src/ts/ts-rs");
|
||||
ColumnType::export_all_to(&path)?;
|
||||
ColumnWindow::export_all_to(&path)?;
|
||||
DeleteOptions::export_all_to(&path)?;
|
||||
JoinOptions::export_all_to(&path)?;
|
||||
OnUpdateData::export_all_to(&path)?;
|
||||
OnUpdateOptions::export_all_to(&path)?;
|
||||
SystemInfo::<f64>::export_all_to(&path)?;
|
||||
TableInitOptions::export_all_to(&path)?;
|
||||
TypedArrayWindow::export_all_to(&path)?;
|
||||
UpdateOptions::export_all_to(&path)?;
|
||||
ViewConfig::export_all_to(&path)?;
|
||||
ViewConfigUpdate::export_all_to(&path)?;
|
||||
ViewWindow::export_all_to(&path)?;
|
||||
ViewWindow::export_all_to(&path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn generate_python_cargo_licenses() -> Result<(), Box<dyn Error>> {
|
||||
use std::fs::File;
|
||||
use std::process::{Command, Stdio};
|
||||
let python_dir = std::env::current_dir()?.join("../perspective-python");
|
||||
let bundler = env!("CARGO_BIN_FILE_CARGO_BUNDLE_LICENSES_cargo-bundle-licenses");
|
||||
let license_file = File::create(python_dir.join("LICENSE_THIRDPARTY_cargo.yml"))?;
|
||||
Command::new(bundler)
|
||||
.arg("--format=yaml")
|
||||
.current_dir(python_dir)
|
||||
.stdout(Stdio::from(license_file))
|
||||
.spawn()?
|
||||
.wait()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
generate_type_bindings_js()?;
|
||||
generate_type_bindings_viewer()?;
|
||||
generate_exprtk_docs()?;
|
||||
generate_python_cargo_licenses()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@perspective-dev/metadata",
|
||||
"version": "4.5.2",
|
||||
"description": "Benchmark utility based on perspective",
|
||||
"private": true,
|
||||
"files": [
|
||||
"src/**/*"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/perspective-dev/perspective/packages/metadata"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node build.mjs"
|
||||
},
|
||||
"author": "",
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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). ┃
|
||||
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
[package]
|
||||
name = "perspective-js"
|
||||
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 = ["experimental"]
|
||||
include = ["src/**/*", "Cargo.toml", "./package.json", "docs/**/*"]
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
rustc-args = ["--cfg", "web_sys_unstable_apis"]
|
||||
rustdoc-args = ["--html-in-header", "docs/index.html"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
path = "src/rust/lib.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
# Shoould the WASM initialization methods be exported? This should _not_ be
|
||||
# enabled if you want to import `perspective-js` as a Rust dependency, in which
|
||||
# case you'll initialize the WebAssembly yourself.
|
||||
export-init = []
|
||||
|
||||
# Should the code by built from the Perspective source tree, or the packaged
|
||||
# copy? This should only be enabled for development on the Perspective source
|
||||
# tree.
|
||||
external-cpp = [
|
||||
"perspective-client/generate-proto",
|
||||
"perspective-client/protobuf-src",
|
||||
]
|
||||
|
||||
# Should `talc` be used as a global allocator? Allows enhanced runtime metrics
|
||||
# via `Client::system_info`. Mutually exclusive to `trace-allocator`
|
||||
talc-allocator = ["perspective-client/talc-allocator"]
|
||||
|
||||
# Should global allocation be traced? Allows enhanced runtime metrics via
|
||||
# `Client::system_info`. Mutually exclusive to `talc-allocator`, which does
|
||||
# not requires a `wasm_bindgen` call to get available heap size.
|
||||
trace-allocator = []
|
||||
|
||||
[build-dependencies]
|
||||
serde_json = { version = "1.0.107", features = ["raw_value"] }
|
||||
anyhow = "1.0.66"
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3.13"
|
||||
|
||||
[dependencies]
|
||||
perspective-client = { version = "4.5.2", features = ["sendable"] }
|
||||
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 }
|
||||
bytes = "1.10.1"
|
||||
chrono = "0.4"
|
||||
derivative = "2.2.0"
|
||||
extend = "1.1.2"
|
||||
futures = "0.3.28"
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
indexmap = "2.12.1"
|
||||
js-sys = "0.3.85"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = { version = "1.0.107", features = ["raw_value"] }
|
||||
serde-wasm-bindgen = "0.6.0"
|
||||
thiserror = "2.0.17"
|
||||
tracing = { version = ">=0.1.36" }
|
||||
tracing-subscriber = "0.3.15"
|
||||
wasm-bindgen-derive = "0.3.0"
|
||||
wasm-bindgen-futures = "0.4.41"
|
||||
|
||||
[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"]
|
||||
|
||||
[dependencies.wasm-bindgen]
|
||||
version = "=0.2.108"
|
||||
features = ["enable-interning"]
|
||||
|
||||
[dependencies.web-sys]
|
||||
version = "0.3.85"
|
||||
features = [
|
||||
"console",
|
||||
"Document",
|
||||
"HtmlElement",
|
||||
"Navigator",
|
||||
"Performance",
|
||||
"PerformanceMark",
|
||||
"Window",
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
# `perspective`
|
||||
|
||||
This crate provides Rust bindings to the JavaScript Perspective API.
|
||||
@@ -0,0 +1,159 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import { execSync } from "child_process";
|
||||
import { build } from "@perspective-dev/esbuild-plugin/build.js";
|
||||
import { PerspectiveEsbuildPlugin } from "@perspective-dev/esbuild-plugin";
|
||||
import { NodeModulesExternal } from "@perspective-dev/esbuild-plugin/external.js";
|
||||
|
||||
import "zx/globals";
|
||||
import { compress } from "pro_self_extracting_wasm";
|
||||
|
||||
const IS_DEBUG =
|
||||
!!process.env.PSP_DEBUG || process.argv.indexOf("--debug") >= 0;
|
||||
|
||||
const BUILD = [
|
||||
{
|
||||
entryPoints: ["src/ts/perspective-server.worker.ts"],
|
||||
format: "esm",
|
||||
target: "es2022",
|
||||
// plugins: [PerspectiveEsbuildPlugin()],
|
||||
// loader: { ".wasm": "binary" },
|
||||
outfile: "dist/cdn/perspective-server.worker.js",
|
||||
},
|
||||
// WASM assets inlined into a single monolithic `.js` file. No special
|
||||
// loades required, this version of Perspective should be the easiest
|
||||
// to use but also the least performant at load time.
|
||||
// {
|
||||
// 'Import via `<script type="module">`': true,
|
||||
// "Requires WASM bootstrap": false,
|
||||
// "Load as binary": false,
|
||||
// "Bundler friendly": true,
|
||||
// },
|
||||
{
|
||||
entryPoints: ["src/ts/perspective.inline.ts"],
|
||||
format: "esm",
|
||||
target: "es2022",
|
||||
plugins: [PerspectiveEsbuildPlugin()],
|
||||
loader: { ".wasm": "binary" },
|
||||
outfile: "dist/esm/perspective.inline.js",
|
||||
},
|
||||
// WASM assets linked to relative path via `fetch()`. This efficiently
|
||||
// loading build is great for `<script>` tags but will give many
|
||||
// bundlers trouble.
|
||||
// {
|
||||
// 'Import via `<script type="module">`': true,
|
||||
// "Requires WASM bootstrap": false,
|
||||
// "Load as binary": true,
|
||||
// "Bundler friendly": false,
|
||||
// },
|
||||
{
|
||||
entryPoints: ["src/ts/perspective.cdn.ts"],
|
||||
format: "esm",
|
||||
target: "es2022",
|
||||
plugins: [PerspectiveEsbuildPlugin()],
|
||||
outfile: "dist/cdn/perspective.js",
|
||||
},
|
||||
// No WASM assets inlined or linked.
|
||||
// {
|
||||
// 'Import via `<script type="module">`': true, // *******
|
||||
// "Requires WASM bootstrap": true,
|
||||
// "Load as binary": true,
|
||||
// "Bundler friendly": true,
|
||||
// },
|
||||
{
|
||||
entryPoints: ["src/ts/perspective.browser.ts"],
|
||||
format: "esm",
|
||||
target: "es2022",
|
||||
plugins: [PerspectiveEsbuildPlugin()],
|
||||
outfile: "dist/esm/perspective.js",
|
||||
},
|
||||
// Node.js build
|
||||
// {
|
||||
// 'Import via `<script type="module">`': false,
|
||||
// "Requires WASM bootstrap": false,
|
||||
// "Load as binary": true,
|
||||
// "Bundler friendly": false,
|
||||
// },
|
||||
{
|
||||
entryPoints: ["src/ts/perspective.node.ts"],
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "es2022",
|
||||
minify: false,
|
||||
plugins: [PerspectiveEsbuildPlugin(), NodeModulesExternal()],
|
||||
loader: { ".wasm": "binary" },
|
||||
outdir: "dist/esm",
|
||||
},
|
||||
|
||||
{
|
||||
entryPoints: ["src/ts/virtual_servers/duckdb.ts"],
|
||||
format: "esm",
|
||||
target: "es2022",
|
||||
plugins: [PerspectiveEsbuildPlugin()],
|
||||
outfile: "dist/esm/virtual_servers/duckdb.js",
|
||||
},
|
||||
{
|
||||
entryPoints: ["src/ts/virtual_servers/clickhouse.ts"],
|
||||
format: "esm",
|
||||
target: "es2022",
|
||||
plugins: [PerspectiveEsbuildPlugin()],
|
||||
outfile: "dist/esm/virtual_servers/clickhouse.js",
|
||||
},
|
||||
];
|
||||
|
||||
const INHERIT = {
|
||||
stdio: "inherit",
|
||||
stderr: "inherit",
|
||||
};
|
||||
|
||||
function get_host() {
|
||||
return /host\: (.+?)$/gm.exec(execSync(`rustc -vV`).toString())[1];
|
||||
}
|
||||
|
||||
async function build_rust() {
|
||||
const release_flag = IS_DEBUG ? "" : "--release";
|
||||
execSync(
|
||||
`PSP_ROOT_DIR=../.. cargo bundle --target=${get_host()} -- perspective_js ${release_flag} --features="export-init,talc-allocator"`,
|
||||
INHERIT,
|
||||
);
|
||||
|
||||
await compress(
|
||||
"dist/wasm/perspective-js.wasm",
|
||||
"dist/wasm/perspective-js.wasm",
|
||||
);
|
||||
}
|
||||
|
||||
async function build_web_assets() {
|
||||
await Promise.all(BUILD.map(build)).catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
async function build_all() {
|
||||
await build_rust();
|
||||
await build_web_assets();
|
||||
|
||||
// Typecheck;
|
||||
try {
|
||||
await $`tsc --project ./tsconfig.browser.json`;
|
||||
await $`tsc --project ./tsconfig.node.json`;
|
||||
// await $`tsc --project ./tsconfig.duckdb.json`;
|
||||
} catch (e) {
|
||||
console.error(e.stdout);
|
||||
console.error(e.stderr);
|
||||
process.exit(e.exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
build_all();
|
||||
@@ -0,0 +1,17 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import * as fs from "node:fs";
|
||||
|
||||
fs.rmSync("dist", { recursive: true, force: true });
|
||||
fs.rmSync("build", { recursive: true, force: true });
|
||||
fs.rmSync("src/ts/ts-rs", { recursive: true, force: true });
|
||||
@@ -0,0 +1,17 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import "zx/globals";
|
||||
|
||||
await $`typedoc --tsconfig tsconfig.virtual_servers.json --out ../../docs/static/browser/virtual_servers`;
|
||||
await $`typedoc --tsconfig tsconfig.browser.json --out ../../docs/static/browser`;
|
||||
await $`typedoc --tsconfig tsconfig.node.json --out ../../docs/static/node`;
|
||||
@@ -0,0 +1,26 @@
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/monokai.min.css" />
|
||||
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/languages/javascript.min.js"></script>
|
||||
<script type="module">
|
||||
document.querySelectorAll("pre:not(.rust) code:not(.rust)").forEach((block) => {
|
||||
hljs.highlightElement(block);
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
code.language-python,
|
||||
code.language-bash,
|
||||
code.language-xml,
|
||||
code.language-javascript,
|
||||
pre {
|
||||
background-color: #2e2e2e;
|
||||
}
|
||||
pre code.hljs {
|
||||
padding: 0;
|
||||
}
|
||||
pre.rust,
|
||||
pre.language-python,
|
||||
div.rust,
|
||||
div.python {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"name": "@perspective-dev/client",
|
||||
"version": "4.5.2",
|
||||
"description": "Client for Perspective, a high-performance WASM engine with reactive Tables, Views, and joins.",
|
||||
"keywords": [
|
||||
"perspective",
|
||||
"data",
|
||||
"analytics",
|
||||
"streaming",
|
||||
"wasm",
|
||||
"arrow",
|
||||
"duckdb",
|
||||
"clickhouse"
|
||||
],
|
||||
"homepage": "https://perspective-dev.github.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/perspective-dev/perspective"
|
||||
},
|
||||
"type": "module",
|
||||
"license": "Apache-2.0",
|
||||
"sideEffects": false,
|
||||
"unpkg": "dist/cdn/perspective.js",
|
||||
"jsdelivr": "dist/cdn/perspective.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"node": {
|
||||
"types": "./dist/esm/perspective.node.d.ts",
|
||||
"import": "./dist/esm/perspective.node.js",
|
||||
"default": "./dist/esm/perspective.node.js"
|
||||
},
|
||||
"types": "./dist/esm/perspective.browser.d.ts",
|
||||
"import": "./dist/esm/perspective.js",
|
||||
"default": "./dist/esm/perspective.js"
|
||||
},
|
||||
"./node": {
|
||||
"types": "./dist/esm/perspective.node.d.ts",
|
||||
"import": "./dist/esm/perspective.node.js",
|
||||
"default": "./dist/esm/perspective.node.js"
|
||||
},
|
||||
"./inline": {
|
||||
"types": "./dist/esm/perspective.browser.d.ts",
|
||||
"import": "./dist/esm/perspective.inline.js",
|
||||
"default": "./dist/esm/perspective.inline.js"
|
||||
},
|
||||
"./virtual_servers/*": {
|
||||
"types": "./dist/esm/virtual_servers/*.d.ts",
|
||||
"import": "./dist/esm/virtual_servers/*.js",
|
||||
"default": "./dist/esm/virtual_servers/*.js"
|
||||
},
|
||||
"./dist/*": "./dist/*",
|
||||
"./src/*": "./src/*",
|
||||
"./test/*": "./test/*",
|
||||
"./package.json": "./package.json",
|
||||
"./tsconfig.json": "./tsconfig.json"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"src/**/*",
|
||||
"tsconfig.json"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "node ./build.mjs",
|
||||
"clean": "node ./clean.mjs",
|
||||
"docs": "node ./docs.mjs"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@perspective-dev/server": "workspace:",
|
||||
"pro_self_extracting_wasm": "catalog:",
|
||||
"stoppable": "catalog:",
|
||||
"ws": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@perspective-dev/esbuild-plugin": "workspace:",
|
||||
"@perspective-dev/metadata": "workspace:",
|
||||
"@perspective-dev/test": "workspace:",
|
||||
"@clickhouse/client-web": "catalog:",
|
||||
"@duckdb/duckdb-wasm": "catalog:",
|
||||
"@playwright/experimental-ct-react": "catalog:",
|
||||
"@playwright/test": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/ws": "catalog:",
|
||||
"@types/stoppable": "catalog:",
|
||||
"apache-arrow": "catalog:",
|
||||
"superstore-arrow": "catalog:",
|
||||
"lodash": "catalog:",
|
||||
"moment": "catalog:",
|
||||
"typedoc": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"web-worker": "catalog:",
|
||||
"zx": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
|
||||
use derivative::Derivative;
|
||||
use futures::channel::oneshot;
|
||||
use js_sys::{Function, Uint8Array};
|
||||
#[cfg(doc)]
|
||||
use perspective_client::SystemInfo;
|
||||
use perspective_client::{
|
||||
ClientError, ReconnectCallback, Session, TableData, TableInitOptions, TableRef, asyncfn,
|
||||
};
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_derive::TryFromJsValue;
|
||||
use wasm_bindgen_futures::{JsFuture, future_to_promise};
|
||||
|
||||
pub use crate::table::*;
|
||||
use crate::utils::{ApiError, ApiResult, JsValueSerdeExt, LocalPollLoop};
|
||||
use crate::{TableDataExt, apierror};
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[derive(Clone)]
|
||||
#[wasm_bindgen(typescript_type = "TableInitOptions")]
|
||||
pub type JsTableInitOptions;
|
||||
|
||||
#[derive(Clone)]
|
||||
#[wasm_bindgen(typescript_type = "JoinOptions")]
|
||||
pub type JsJoinOptions;
|
||||
}
|
||||
|
||||
async fn js_to_table_ref(val: &JsValue) -> ApiResult<TableRef> {
|
||||
if let Some(name) = val.as_string() {
|
||||
Ok(TableRef::from(name))
|
||||
} else {
|
||||
let get_name = js_sys::Reflect::get(val, &wasm_bindgen::intern("get_name").into())
|
||||
.map_err(|_| apierror!(TableRefError))?
|
||||
.dyn_into::<js_sys::Function>()
|
||||
.map_err(|_| apierror!(TableRefError))?;
|
||||
|
||||
let promise = get_name
|
||||
.call0(val)
|
||||
.map_err(|_| apierror!(TableRefError))?
|
||||
.dyn_into::<js_sys::Promise>()
|
||||
.map_err(|_| apierror!(TableRefError))?;
|
||||
|
||||
let name = wasm_bindgen_futures::JsFuture::from(promise)
|
||||
.await
|
||||
.map_err(|_| apierror!(TableRefError))?
|
||||
.as_string()
|
||||
.ok_or_else(|| apierror!(TableRefError))?;
|
||||
|
||||
Ok(TableRef::from(name))
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
#[derive(Clone)]
|
||||
pub struct ProxySession(perspective_client::ProxySession);
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl ProxySession {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(client: &Client, on_response: &Function) -> Self {
|
||||
let poll_loop = LocalPollLoop::new({
|
||||
let on_response = on_response.clone();
|
||||
move |msg: Vec<u8>| {
|
||||
let msg = Uint8Array::from(&msg[..]);
|
||||
on_response.call1(&JsValue::UNDEFINED, &JsValue::from(msg))?;
|
||||
Ok(JsValue::null())
|
||||
}
|
||||
});
|
||||
// NB: This swallows any errors raised by the inner callback
|
||||
let on_response = Box::new(move |msg: &[u8]| {
|
||||
wasm_bindgen_futures::spawn_local(poll_loop.poll(msg.to_vec()));
|
||||
Ok(())
|
||||
});
|
||||
Self(perspective_client::ProxySession::new(
|
||||
client.client.clone(),
|
||||
on_response,
|
||||
))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub async fn handle_request(&self, value: JsValue) -> ApiResult<()> {
|
||||
let uint8array = Uint8Array::new(&value);
|
||||
let slice = uint8array.to_vec();
|
||||
self.0.handle_request(&slice).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn close(self) {
|
||||
self.0.close().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// The browser and node.js libraries both support the `websocket(url)`
|
||||
/// constructor, which connects to a remote `perspective_server::Server`
|
||||
/// instance over a WebSocket transport.
|
||||
///
|
||||
/// In the browser, the `worker()` constructor creates a new Web Worker
|
||||
/// `perspective_server::Server` and returns a [`Client`] connected to it.
|
||||
///
|
||||
/// In node.js, a pre-instantied [`Client`] connected synhronously to a global
|
||||
/// singleton `perspective_server::Server` is the default module export.
|
||||
///
|
||||
/// # JavaScript Examples
|
||||
///
|
||||
/// Create a Web Worker `perspective_server::Server` in the browser and return a
|
||||
/// [`Client`] instance connected for it:
|
||||
///
|
||||
/// ```javascript
|
||||
/// import perspective from "@perspective-dev/client";
|
||||
/// const client = await perspective.worker();
|
||||
/// ```
|
||||
///
|
||||
/// Create a WebSocket connection to a remote `perspective_server::Server`:
|
||||
///
|
||||
/// ```javascript
|
||||
/// import perspective from "@perspective-dev/client";
|
||||
/// const client = await perspective.websocket("ws://locahost:8080/ws");
|
||||
/// ```
|
||||
///
|
||||
/// Access the synchronous client in node.js:
|
||||
///
|
||||
/// ```javascript
|
||||
/// import { default as client } from "@perspective-dev/client";
|
||||
/// ```
|
||||
#[wasm_bindgen]
|
||||
#[derive(TryFromJsValue, Clone)]
|
||||
pub struct Client {
|
||||
pub(crate) client: perspective_client::Client,
|
||||
pub(crate) close: Option<Function>,
|
||||
}
|
||||
|
||||
impl From<perspective_client::Client> for Client {
|
||||
fn from(client: perspective_client::Client) -> Self {
|
||||
Client {
|
||||
client,
|
||||
close: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Client {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.client.get_name() == other.client.get_name()
|
||||
}
|
||||
}
|
||||
|
||||
/// A wrapper around [`js_sys::Function`] to ease async integration for the
|
||||
/// `reconnect` argument of [`Client::on_error`] callback.
|
||||
#[derive(Derivative)]
|
||||
#[derivative(Clone(bound = ""))]
|
||||
struct JsReconnect<I>(Arc<dyn Fn(I) -> js_sys::Promise>);
|
||||
|
||||
// This type is not thread safe, but the JavaScript environment does not allow
|
||||
// threading.
|
||||
unsafe impl<I> Send for JsReconnect<I> {}
|
||||
unsafe impl<I> Sync for JsReconnect<I> {}
|
||||
|
||||
impl<I> JsReconnect<I> {
|
||||
fn run(&self, args: I) -> js_sys::Promise {
|
||||
self.0(args)
|
||||
}
|
||||
|
||||
fn run_all(
|
||||
&self,
|
||||
args: I,
|
||||
) -> impl Future<Output = Result<(), Box<dyn Error + Send + Sync + 'static>>>
|
||||
+ Send
|
||||
+ Sync
|
||||
+ 'static
|
||||
+ use<I> {
|
||||
let (sender, receiver) = oneshot::channel::<Result<(), Box<dyn Error + Send + Sync>>>();
|
||||
let p = self.0(args);
|
||||
let _ = future_to_promise(async move {
|
||||
let result = JsFuture::from(p)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|x| format!("{x:?}").into());
|
||||
|
||||
sender.send(result).unwrap();
|
||||
Ok(JsValue::UNDEFINED)
|
||||
});
|
||||
|
||||
async move { receiver.await.unwrap() }
|
||||
}
|
||||
}
|
||||
|
||||
impl<F, I> From<F> for JsReconnect<I>
|
||||
where
|
||||
F: Fn(I) -> js_sys::Promise + 'static,
|
||||
{
|
||||
fn from(value: F) -> Self {
|
||||
JsReconnect(Arc::new(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub fn get_client(&self) -> &'_ perspective_client::Client {
|
||||
&self.client
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl Client {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(send_request: Function, close: Option<Function>) -> ApiResult<Self> {
|
||||
let send_request = JsReconnect::from(move |mut v: Vec<u8>| {
|
||||
let buff2 = unsafe { js_sys::Uint8Array::view_mut_raw(v.as_mut_ptr(), v.len()) };
|
||||
send_request
|
||||
.call1(&JsValue::UNDEFINED, &buff2)
|
||||
.unwrap()
|
||||
.unchecked_into::<js_sys::Promise>()
|
||||
});
|
||||
|
||||
let client = perspective_client::Client::new_with_callback(None, move |msg| {
|
||||
send_request.run_all(msg)
|
||||
})?;
|
||||
|
||||
Ok(Client { close, client })
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn new_proxy_session(&self, on_response: &Function) -> ProxySession {
|
||||
ProxySession::new(self, on_response)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub async fn handle_response(&self, value: &JsValue) -> ApiResult<()> {
|
||||
let uint8array = Uint8Array::new(value);
|
||||
let slice = uint8array.to_vec();
|
||||
self.client.handle_response(&slice).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub async fn handle_error(&self, error: String, reconnect: Option<Function>) -> ApiResult<()> {
|
||||
self.client
|
||||
.handle_error(
|
||||
ClientError::Unknown(error),
|
||||
reconnect.map(|reconnect| {
|
||||
let reconnect =
|
||||
JsReconnect::from(move |()| match reconnect.call0(&JsValue::UNDEFINED) {
|
||||
Ok(x) => x.unchecked_into::<js_sys::Promise>(),
|
||||
Err(e) => {
|
||||
// This error may occur when _invoking_ the function
|
||||
tracing::warn!("{:?}", e);
|
||||
js_sys::Promise::reject(&format!("C {e:?}").into())
|
||||
},
|
||||
});
|
||||
|
||||
asyncfn!(reconnect, async move || {
|
||||
if let Err(e) = JsFuture::from(reconnect.run(())).await {
|
||||
if let Some(e) = e.dyn_ref::<js_sys::Object>() {
|
||||
Err(ClientError::Unknown(e.to_string().as_string().unwrap()))
|
||||
} else {
|
||||
Err(ClientError::Unknown(e.as_string().unwrap()))
|
||||
}
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub async fn on_error(&self, callback: Function) -> ApiResult<u32> {
|
||||
let callback = JsReconnect::from(
|
||||
move |(message, reconnect): (ClientError, Option<ReconnectCallback>)| {
|
||||
let cl: Closure<dyn Fn() -> js_sys::Promise> = Closure::new(move || {
|
||||
let reconnect = reconnect.clone();
|
||||
future_to_promise(async move {
|
||||
if let Some(f) = reconnect {
|
||||
(*f)().await.map_err(|e| JsValue::from(format!("A {e}")))?;
|
||||
}
|
||||
|
||||
Ok(JsValue::UNDEFINED)
|
||||
})
|
||||
});
|
||||
|
||||
if let Err(e) = callback.call2(
|
||||
&JsValue::UNDEFINED,
|
||||
&JsValue::from(apierror!(ClientError(message))),
|
||||
&cl.into_js_value(),
|
||||
) {
|
||||
tracing::warn!("{:?}", e);
|
||||
}
|
||||
|
||||
js_sys::Promise::resolve(&JsValue::UNDEFINED)
|
||||
},
|
||||
);
|
||||
|
||||
let poll_loop = LocalPollLoop::new_async(move |x| JsFuture::from(callback.run(x)));
|
||||
let id = self
|
||||
.client
|
||||
.on_error(asyncfn!(poll_loop, async move |message, reconnect| {
|
||||
poll_loop.poll((message, reconnect)).await;
|
||||
Ok(())
|
||||
}))
|
||||
.await?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// # JavaScript Examples
|
||||
///
|
||||
/// Load a CSV from a `string`:
|
||||
///
|
||||
/// ```javascript
|
||||
/// const table = await client.table("x,y\n1,2\n3,4");
|
||||
/// ```
|
||||
///
|
||||
/// Load an Arrow from an `ArrayBuffer`:
|
||||
///
|
||||
/// ```javascript
|
||||
/// import * as fs from "node:fs/promises";
|
||||
/// const table2 = await client.table(await fs.readFile("superstore.arrow"));
|
||||
/// ```
|
||||
///
|
||||
/// Load a CSV from a `UInt8Array` (the default for this type is Arrow)
|
||||
/// using a format override:
|
||||
///
|
||||
/// ```javascript
|
||||
/// const enc = new TextEncoder();
|
||||
/// const table = await client.table(enc.encode("x,y\n1,2\n3,4"), {
|
||||
/// format: "csv",
|
||||
/// });
|
||||
/// ```
|
||||
///
|
||||
/// Create a table with an `index`:
|
||||
///
|
||||
/// ```javascript
|
||||
/// const table = await client.table(data, { index: "Row ID" });
|
||||
/// ```
|
||||
#[wasm_bindgen]
|
||||
pub async fn table(
|
||||
&self,
|
||||
value: &JsTableInitData,
|
||||
options: Option<JsTableInitOptions>,
|
||||
) -> ApiResult<Table> {
|
||||
let options = options
|
||||
.into_serde_ext::<Option<TableInitOptions>>()?
|
||||
.unwrap_or_default();
|
||||
|
||||
let args = TableData::from_js_value(value, options.format)?;
|
||||
Ok(Table(self.client.table(args, options).await?))
|
||||
}
|
||||
|
||||
/// Creates a new read-only [`Table`] by performing an INNER 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 (a [`Table`] instance or a table name
|
||||
/// string).
|
||||
/// - `right` - The right source table (a [`Table`] instance or a table name
|
||||
/// string).
|
||||
/// - `on` - The column name to join on. Must exist in both tables with the
|
||||
/// same type.
|
||||
/// - `options` - Optional join configuration: `{ join_type?: "inner" |
|
||||
/// "left" | "outer", name?: string }`.
|
||||
///
|
||||
/// # JavaScript Examples
|
||||
///
|
||||
/// ```javascript
|
||||
/// const joined = await client.join(orders_table, products_table, "Product ID", { join_type: "left" });
|
||||
/// const joined = await client.join("orders", "products", "Product ID", { join_type: "left" });
|
||||
/// ```
|
||||
#[wasm_bindgen]
|
||||
pub async fn join(
|
||||
&self,
|
||||
left: JsValue,
|
||||
right: JsValue,
|
||||
on: &str,
|
||||
options: Option<JsJoinOptions>,
|
||||
) -> ApiResult<Table> {
|
||||
let options = options
|
||||
.into_serde_ext::<Option<perspective_client::JoinOptions>>()?
|
||||
.unwrap_or_default();
|
||||
|
||||
let left_ref = js_to_table_ref(&left).await?;
|
||||
let right_ref = js_to_table_ref(&right).await?;
|
||||
Ok(Table(
|
||||
self.client.join(left_ref, right_ref, on, options).await?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Terminates this [`Client`], cleaning up any [`crate::View`] handles the
|
||||
/// [`Client`] has open as well as its callbacks.
|
||||
#[wasm_bindgen]
|
||||
pub fn terminate(&self) -> ApiResult<JsValue> {
|
||||
if let Some(f) = self.close.clone() {
|
||||
Ok(f.call0(&JsValue::UNDEFINED)?)
|
||||
} else {
|
||||
Err(ApiError::new("Client type cannot be terminated"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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`].
|
||||
///
|
||||
/// # JavaScript Examples
|
||||
///
|
||||
/// Get a virtual [`Table`] named "table_one" from this [`Client`]
|
||||
///
|
||||
/// ```javascript
|
||||
/// const tables = await client.open_table("table_one");
|
||||
/// ```
|
||||
#[wasm_bindgen]
|
||||
pub async fn open_table(&self, entity_id: String) -> ApiResult<Table> {
|
||||
Ok(Table(self.client.open_table(entity_id).await?))
|
||||
}
|
||||
|
||||
/// Unsafely gets a [`View`] by raw ID, useful for JavaScript multi-threaded
|
||||
/// (via Web Worker) context where a standard `View` cannot otherwise be
|
||||
/// shared because its wrapper is not serializable.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// This method is unsafe because the lifetime of a [`View`] is bound to
|
||||
/// the [`Client`] which created it.
|
||||
///
|
||||
/// The caller must guarantee that `entity_id` corresponds to a live
|
||||
/// [`crate::View`] on the connected server (obtained from another
|
||||
/// [`Client`]'s [`crate::View::get_name`] and forwarded across the
|
||||
/// serialization boundary).
|
||||
///
|
||||
/// # JavaScript Examples
|
||||
///
|
||||
/// ```javascript
|
||||
/// const view = client.__unsafe_open_view(name_from_main_thread);
|
||||
/// const cols = await view.to_columns();
|
||||
/// ```
|
||||
#[wasm_bindgen]
|
||||
pub fn __unsafe_open_view(&self, entity_id: String) -> crate::view::View {
|
||||
crate::view::View(perspective_client::View::new(
|
||||
entity_id,
|
||||
self.client.clone(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 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`]).
|
||||
///
|
||||
/// # JavaScript Examples
|
||||
///
|
||||
/// ```javascript
|
||||
/// const tables = await client.get_hosted_table_names();
|
||||
/// ```
|
||||
#[wasm_bindgen]
|
||||
pub async fn get_hosted_table_names(&self) -> ApiResult<Vec<String>> {
|
||||
Ok(self.client.get_hosted_table_names().await?)
|
||||
}
|
||||
|
||||
/// Register a callback which is invoked whenever [`Client::table`] (on this
|
||||
/// [`Client`]) or [`Table::delete`] (on a [`Table`] belinging to this
|
||||
/// [`Client`]) are called.
|
||||
#[wasm_bindgen]
|
||||
pub async fn on_hosted_tables_update(&self, on_update_js: Function) -> ApiResult<u32> {
|
||||
let poll_loop = LocalPollLoop::new(move |_| on_update_js.call0(&JsValue::UNDEFINED));
|
||||
let on_update = Box::new(move || poll_loop.poll(()));
|
||||
let id = self.client.on_hosted_tables_update(on_update).await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Remove a callback previously registered via
|
||||
/// `Client::on_hosted_tables_update`.
|
||||
#[wasm_bindgen]
|
||||
pub async fn remove_hosted_tables_update(&self, update_id: u32) -> ApiResult<()> {
|
||||
self.client.remove_hosted_tables_update(update_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Provides the [`SystemInfo`] struct, implementation-specific metadata
|
||||
/// about the [`perspective_server::Server`] runtime such as Memory and
|
||||
/// CPU usage.
|
||||
///
|
||||
/// For WebAssembly servers, this method includes the WebAssembly heap size.
|
||||
///
|
||||
/// # JavaScript Examples
|
||||
///
|
||||
/// ```javascript
|
||||
/// const info = await client.system_info();
|
||||
/// ```
|
||||
#[wasm_bindgen]
|
||||
pub async fn system_info(&self) -> ApiResult<JsSystemInfo> {
|
||||
let mut info = self.client.system_info().await?;
|
||||
|
||||
#[cfg(feature = "trace-allocator")]
|
||||
if let perspective_client::SystemInfo {
|
||||
client_used: None,
|
||||
client_heap: None,
|
||||
..
|
||||
} = &info
|
||||
{
|
||||
let (client_used, client_heap) = crate::utils::get_used();
|
||||
info.client_used = Some(client_used as u64);
|
||||
info.client_heap = Some(client_heap as u64);
|
||||
};
|
||||
|
||||
let timestamp = web_sys::window()
|
||||
.and_then(|x| x.performance())
|
||||
.map(|x| x.now() as u64);
|
||||
|
||||
info.timestamp = timestamp;
|
||||
let record = JsValue::from_serde_ext(&info.cast::<f64>())?;
|
||||
Ok(record.unchecked_into())
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(typescript_type = "SystemInfo")]
|
||||
pub type JsSystemInfo;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
//! WASM bindings for the DuckDB SQL query builder.
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use indexmap::IndexMap;
|
||||
use js_sys::Object;
|
||||
use perspective_client::config::ViewConfig;
|
||||
use perspective_client::proto::{ColumnType, ViewPort};
|
||||
use perspective_client::virtual_server;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::utils::*;
|
||||
|
||||
/// JavaScript-facing DuckDB SQL query builder.
|
||||
///
|
||||
/// This struct wraps the Rust `DuckDBSqlBuilder` and exposes it to JavaScript
|
||||
/// via wasm_bindgen.
|
||||
#[wasm_bindgen]
|
||||
pub struct GenericSQLVirtualServerModel {
|
||||
inner: virtual_server::GenericSQLVirtualServerModel,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
pub type JsGenericSQLVirtualServerModelArgs;
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl GenericSQLVirtualServerModel {
|
||||
/// Creates a new `JsDuckDBSqlBuilder` instance.
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(args: Option<JsGenericSQLVirtualServerModelArgs>) -> Result<Self, JsValue> {
|
||||
Ok(Self {
|
||||
inner: virtual_server::GenericSQLVirtualServerModel::new(
|
||||
args.map(|x| x.into_serde_ext())
|
||||
.transpose()?
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the SQL query to list all hosted tables.
|
||||
#[wasm_bindgen(js_name = "getHostedTables")]
|
||||
pub fn get_hosted_tables(&self) -> Result<String, JsValue> {
|
||||
self.inner
|
||||
.get_hosted_tables()
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Returns the SQL query to describe a table's schema.
|
||||
#[wasm_bindgen(js_name = "tableSchema")]
|
||||
pub fn table_schema(&self, table_id: &str) -> Result<String, JsValue> {
|
||||
self.inner
|
||||
.table_schema(table_id)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Returns the SQL query to get the row count of a table.
|
||||
#[wasm_bindgen(js_name = "tableSize")]
|
||||
pub fn table_size(&self, table_id: &str) -> Result<String, JsValue> {
|
||||
self.inner
|
||||
.table_size(table_id)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Returns the SQL query to get the column count of a view.
|
||||
#[wasm_bindgen(js_name = "viewColumnSize")]
|
||||
pub fn view_column_size(&self, view_id: &str) -> Result<String, JsValue> {
|
||||
self.inner
|
||||
.view_column_size(view_id)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Returns the SQL query to validate an expression against a table.
|
||||
#[wasm_bindgen(js_name = "tableValidateExpression")]
|
||||
pub fn table_validate_expression(
|
||||
&self,
|
||||
table_id: &str,
|
||||
expression: &str,
|
||||
) -> Result<String, JsValue> {
|
||||
self.inner
|
||||
.table_validate_expression(table_id, expression)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Returns the SQL query to delete a view.
|
||||
#[wasm_bindgen(js_name = "viewDelete")]
|
||||
pub fn view_delete(&self, view_id: &str) -> Result<String, JsValue> {
|
||||
self.inner
|
||||
.view_delete(view_id)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Returns the SQL query to create a view from a table with the given
|
||||
/// configuration.
|
||||
#[wasm_bindgen(js_name = "tableMakeView")]
|
||||
pub fn table_make_view(
|
||||
&self,
|
||||
table_id: &str,
|
||||
view_id: &str,
|
||||
config: JsValue,
|
||||
) -> Result<String, JsValue> {
|
||||
let config: ViewConfig = serde_wasm_bindgen::from_value(config)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
|
||||
self.inner
|
||||
.table_make_view(table_id, view_id, &config)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Returns the SQL query to fetch data from a view with the given viewport.
|
||||
#[wasm_bindgen(js_name = "viewGetData")]
|
||||
pub fn view_get_data(
|
||||
&self,
|
||||
view_id: &str,
|
||||
config: JsValue,
|
||||
viewport: JsValue,
|
||||
schema: JsValue,
|
||||
) -> Result<String, JsValue> {
|
||||
let config: ViewConfig = serde_wasm_bindgen::from_value(config)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
|
||||
let viewport: ViewPort = serde_wasm_bindgen::from_value(viewport)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
|
||||
let schema = self.parse_schema(schema)?;
|
||||
|
||||
self.inner
|
||||
.view_get_data(view_id, &config, &viewport, &schema)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Returns the SQL query to describe a view's schema.
|
||||
#[wasm_bindgen(js_name = "viewSchema")]
|
||||
pub fn view_schema(&self, view_id: &str) -> Result<String, JsValue> {
|
||||
self.inner
|
||||
.view_schema(view_id)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Returns the SQL query to get the row count of a view.
|
||||
#[wasm_bindgen(js_name = "viewSize")]
|
||||
pub fn view_size(&self, view_id: &str) -> Result<String, JsValue> {
|
||||
self.inner
|
||||
.view_size(view_id)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Returns the SQL query to get the min and max values of a column.
|
||||
#[wasm_bindgen(js_name = "viewGetMinMax")]
|
||||
pub fn view_get_min_max(
|
||||
&self,
|
||||
view_id: &str,
|
||||
column_name: &str,
|
||||
config: JsValue,
|
||||
) -> Result<String, JsValue> {
|
||||
let config: ViewConfig = serde_wasm_bindgen::from_value(config)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
|
||||
self.inner
|
||||
.view_get_min_max(view_id, column_name, &config)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl GenericSQLVirtualServerModel {
|
||||
fn parse_schema(&self, schema: JsValue) -> Result<IndexMap<String, ColumnType>, JsValue> {
|
||||
let obj = schema.dyn_ref::<Object>().ok_or_else(|| {
|
||||
JsValue::from_str("Schema must be an object mapping column names to types")
|
||||
})?;
|
||||
|
||||
let mut result = IndexMap::new();
|
||||
let entries = Object::entries(obj);
|
||||
for i in 0..entries.length() {
|
||||
let entry = entries.get(i);
|
||||
let entry_array = entry
|
||||
.dyn_ref::<js_sys::Array>()
|
||||
.ok_or_else(|| JsValue::from_str("Invalid schema entry"))?;
|
||||
let key = entry_array
|
||||
.get(0)
|
||||
.as_string()
|
||||
.ok_or_else(|| JsValue::from_str("Column name must be a string"))?;
|
||||
let value = entry_array
|
||||
.get(1)
|
||||
.as_string()
|
||||
.ok_or_else(|| JsValue::from_str("Column type must be a string"))?;
|
||||
let column_type = ColumnType::from_str(&value)
|
||||
.map_err(|_| JsValue::from_str(&format!("Unknown column type: {}", value)))?;
|
||||
result.insert(key, column_type);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
//! <div class="warning">
|
||||
//! The examples in this module are in JavaScript. See <a href="https://docs.rs/crate/perspective/latest"><code>perspective</code></a> docs for the Rust API.
|
||||
//! </div>
|
||||
|
||||
#![warn(
|
||||
clippy::all,
|
||||
clippy::panic_in_result_fn,
|
||||
clippy::await_holding_refcell_ref,
|
||||
rustdoc::broken_intra_doc_links,
|
||||
unstable_features
|
||||
)]
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
extern crate alloc;
|
||||
|
||||
mod client;
|
||||
mod generic_sql_model;
|
||||
mod table;
|
||||
mod table_data;
|
||||
mod typed_array;
|
||||
pub mod utils;
|
||||
mod view;
|
||||
mod virtual_server;
|
||||
|
||||
#[cfg(feature = "export-init")]
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
pub use crate::client::Client;
|
||||
pub use crate::generic_sql_model::*;
|
||||
pub use crate::table::*;
|
||||
pub use crate::table_data::*;
|
||||
pub use crate::typed_array::*;
|
||||
pub use crate::virtual_server::*;
|
||||
|
||||
#[cfg(feature = "export-init")]
|
||||
#[wasm_bindgen(typescript_custom_section)]
|
||||
const TS_APPEND_CONTENT: &'static str = r#"
|
||||
export type * from "../../src/ts/ts-rs/ViewWindow.d.ts";
|
||||
export type * from "../../src/ts/ts-rs/ColumnType.d.ts";
|
||||
export type * from "../../src/ts/ts-rs/ColumnWindow.d.ts";
|
||||
export type * from "../../src/ts/ts-rs/TableInitOptions.d.ts";
|
||||
export type * from "../../src/ts/ts-rs/ViewConfigUpdate.d.ts";
|
||||
export type * from "../../src/ts/ts-rs/ViewOnUpdateResp.d.ts";
|
||||
export type * from "../../src/ts/ts-rs/OnUpdateOptions.d.ts";
|
||||
export type * from "../../src/ts/ts-rs/UpdateOptions.d.ts";
|
||||
export type * from "../../src/ts/ts-rs/DeleteOptions.d.ts";
|
||||
export type * from "../../src/ts/ts-rs/Scalar.d.ts";
|
||||
export type * from "../../src/ts/ts-rs/SystemInfo.d.ts";
|
||||
export type * from "../../src/ts/ts-rs/SortDir.d.ts";
|
||||
export type * from "../../src/ts/ts-rs/Filter.d.ts";
|
||||
export type * from "../../src/ts/ts-rs/ViewConfig.d.ts";
|
||||
export type * from "../../src/ts/ts-rs/JoinOptions.ts";
|
||||
export type * from "../../src/ts/ts-rs/JoinType.ts";
|
||||
export type * from "../../src/ts/ts-rs/TypedArrayWindow.ts";
|
||||
|
||||
import type {ColumnWindow} from "../../src/ts/ts-rs/ColumnWindow.d.ts";
|
||||
import type {ColumnType} from "../../src/ts/ts-rs/ColumnType.d.ts";
|
||||
import type {ViewWindow} from "../../src/ts/ts-rs/ViewWindow.d.ts";
|
||||
import type {TypedArrayWindow} from "../../src/ts/ts-rs/TypedArrayWindow.ts";
|
||||
import type {TableInitOptions} from "../../src/ts/ts-rs/TableInitOptions.d.ts";
|
||||
import type {JoinOptions} from "../../src/ts/ts-rs/JoinOptions.ts";
|
||||
import type {JoinType} from "../../src/ts/ts-rs/JoinType.ts";
|
||||
import type {ViewConfigUpdate} from "../../src/ts/ts-rs/ViewConfigUpdate.d.ts";
|
||||
import type * as on_update_args from "../../src/ts/ts-rs/ViewOnUpdateResp.d.ts";
|
||||
import type {OnUpdateOptions} from "../../src/ts/ts-rs/OnUpdateOptions.d.ts";
|
||||
import type {UpdateOptions} from "../../src/ts/ts-rs/UpdateOptions.d.ts";
|
||||
import type {DeleteOptions} from "../../src/ts/ts-rs/DeleteOptions.d.ts";
|
||||
import type {SystemInfo} from "../../src/ts/ts-rs/SystemInfo.d.ts";
|
||||
"#;
|
||||
|
||||
#[cfg(feature = "export-init")]
|
||||
#[wasm_bindgen]
|
||||
pub fn init() {
|
||||
// console_error_panic_hook::set_once();
|
||||
utils::set_global_logging();
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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 js_sys::Function;
|
||||
use perspective_client::config::*;
|
||||
use perspective_client::{DeleteOptions, UpdateData, UpdateOptions, assert_table_api};
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_derive::TryFromJsValue;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
use crate::Client;
|
||||
use crate::table_data::UpdateDataExt;
|
||||
use crate::utils::{ApiFuture, ApiResult, JsValueSerdeExt, LocalPollLoop};
|
||||
pub use crate::view::*;
|
||||
|
||||
#[derive(TryFromJsValue, Clone, PartialEq)]
|
||||
#[wasm_bindgen]
|
||||
pub struct Table(pub(crate) perspective_client::Table);
|
||||
|
||||
assert_table_api!(Table);
|
||||
|
||||
impl From<perspective_client::Table> for Table {
|
||||
fn from(value: perspective_client::Table) -> Self {
|
||||
Table(value)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`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`].
|
||||
impl Table {
|
||||
pub fn get_table(&self) -> &'_ perspective_client::Table {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
// TODO Fix me
|
||||
#[wasm_bindgen(typescript_type = "\
|
||||
string | ArrayBuffer | Record<string, unknown[]> | Record<string, unknown>[] | \
|
||||
Record<string, ColumnType>")]
|
||||
pub type JsTableInitData;
|
||||
|
||||
#[wasm_bindgen(typescript_type = "ViewConfigUpdate")]
|
||||
pub type JsViewConfig;
|
||||
|
||||
#[wasm_bindgen(typescript_type = "UpdateOptions")]
|
||||
pub type JsUpdateOptions;
|
||||
|
||||
#[wasm_bindgen(typescript_type = "DeleteOptions")]
|
||||
pub type JsDeleteOptions;
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl Table {
|
||||
/// Returns the name of the index column for the table.
|
||||
///
|
||||
/// # JavaScript Examples
|
||||
///
|
||||
/// ```javascript
|
||||
/// const table = await client.table("x,y\n1,2\n3,4", { index: "x" });
|
||||
/// const index = table.get_index(); // "x"
|
||||
/// ```
|
||||
#[wasm_bindgen]
|
||||
pub async fn get_index(&self) -> Option<String> {
|
||||
self.0.get_index()
|
||||
}
|
||||
|
||||
/// Get a copy of the [`Client`] this [`Table`] came from.
|
||||
#[wasm_bindgen]
|
||||
pub async fn get_client(&self) -> Client {
|
||||
Client {
|
||||
close: None,
|
||||
client: self.0.get_client(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the user-specified name for this table, or the auto-generated
|
||||
/// name if a name was not specified when the table was created.
|
||||
#[wasm_bindgen]
|
||||
pub async fn get_name(&self) -> String {
|
||||
self.0.get_name().to_owned()
|
||||
}
|
||||
|
||||
/// Returns the user-specified row limit for this table.
|
||||
#[wasm_bindgen]
|
||||
pub async fn get_limit(&self) -> Option<u32> {
|
||||
self.0.get_limit()
|
||||
}
|
||||
|
||||
/// 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`].
|
||||
#[wasm_bindgen]
|
||||
pub async fn clear(&self) -> ApiResult<()> {
|
||||
self.0.clear().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// # JavaScript Examples
|
||||
///
|
||||
/// ```javascript
|
||||
/// const table = await client.table("x,y\n1,2\n3,4");
|
||||
///
|
||||
/// // ...
|
||||
///
|
||||
/// await table.delete({ lazy: true });
|
||||
/// ```
|
||||
#[wasm_bindgen]
|
||||
pub async fn delete(self, options: Option<JsDeleteOptions>) -> ApiResult<()> {
|
||||
let options = options
|
||||
.into_serde_ext::<Option<DeleteOptions>>()?
|
||||
.unwrap_or_default();
|
||||
|
||||
self.0.delete(options).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the number of rows in a [`Table`].
|
||||
#[wasm_bindgen]
|
||||
pub async fn size(&self) -> ApiResult<f64> {
|
||||
Ok(self.0.size().await? as f64)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[wasm_bindgen(unchecked_return_type = "Record<string, ColumnType>")]
|
||||
pub async fn schema(&self) -> ApiResult<JsValue> {
|
||||
let schema = self.0.schema().await?;
|
||||
Ok(JsValue::from_serde_ext(&schema)?)
|
||||
}
|
||||
|
||||
/// Returns the column names of this [`Table`] in "natural" order (the
|
||||
/// ordering implied by the input format).
|
||||
///
|
||||
/// # JavaScript Examples
|
||||
///
|
||||
/// ```javascript
|
||||
/// const columns = await table.columns();
|
||||
/// ```
|
||||
#[wasm_bindgen]
|
||||
pub async fn columns(&self) -> ApiResult<JsValue> {
|
||||
let columns = self.0.columns().await?;
|
||||
Ok(JsValue::from_serde_ext(&columns)?)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[wasm_bindgen]
|
||||
pub async fn make_port(&self) -> ApiResult<i32> {
|
||||
Ok(self.0.make_port().await?)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[wasm_bindgen]
|
||||
pub fn on_delete(&self, on_delete: Function) -> ApiFuture<u32> {
|
||||
let table = self.clone();
|
||||
ApiFuture::new(async move {
|
||||
let emit = LocalPollLoop::new(move |()| on_delete.call0(&JsValue::UNDEFINED));
|
||||
let on_delete = Box::new(move || spawn_local(emit.poll(())));
|
||||
Ok(table.0.on_delete(on_delete).await?)
|
||||
})
|
||||
}
|
||||
|
||||
/// Removes a listener with a given ID, as returned by a previous call to
|
||||
/// [`Table::on_delete`].
|
||||
#[wasm_bindgen]
|
||||
pub fn remove_delete(&self, callback_id: u32) -> ApiFuture<()> {
|
||||
let client = self.0.clone();
|
||||
ApiFuture::new(async move {
|
||||
client.remove_delete(callback_id).await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// # JavaScript Examples
|
||||
///
|
||||
/// ```javascript
|
||||
/// await table.remove([1, 2, 3]);
|
||||
/// ```
|
||||
#[wasm_bindgen]
|
||||
pub async fn remove(&self, value: &JsValue, options: Option<JsUpdateOptions>) -> ApiResult<()> {
|
||||
let options = options
|
||||
.into_serde_ext::<Option<UpdateOptions>>()?
|
||||
.unwrap_or_default();
|
||||
|
||||
let input = UpdateData::from_js_value(value, options.format)?;
|
||||
self.0.remove(input).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace all rows in this [`Table`] with the input data, coerced to this
|
||||
/// [`Table`]'s existing [`perspective_client::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.
|
||||
///
|
||||
/// # JavaScript Examples
|
||||
///
|
||||
/// ```javascript
|
||||
/// await table.replace("x,y\n1,2");
|
||||
/// ```
|
||||
#[wasm_bindgen]
|
||||
pub async fn replace(
|
||||
&self,
|
||||
input: &JsValue,
|
||||
options: Option<JsUpdateOptions>,
|
||||
) -> ApiResult<()> {
|
||||
let options = options
|
||||
.into_serde_ext::<Option<UpdateOptions>>()?
|
||||
.unwrap_or_default();
|
||||
|
||||
let input = UpdateData::from_js_value(input, options.format)?;
|
||||
self.0.replace(input).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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`].
|
||||
///
|
||||
/// # JavaScript Examples
|
||||
///
|
||||
/// ```javascript
|
||||
/// await table.update("x,y\n1,2");
|
||||
/// ```
|
||||
#[wasm_bindgen]
|
||||
pub fn update(
|
||||
&self,
|
||||
input: JsTableInitData,
|
||||
options: Option<JsUpdateOptions>,
|
||||
) -> ApiFuture<()> {
|
||||
let table = self.clone();
|
||||
ApiFuture::new(async move {
|
||||
let options = options
|
||||
.into_serde_ext::<Option<UpdateOptions>>()?
|
||||
.unwrap_or_default();
|
||||
|
||||
let input = UpdateData::from_js_value(&input, options.format)?;
|
||||
Ok(table.0.update(input, options).await?)
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new [`View`] from this table with a specified
|
||||
/// [`ViewConfigUpdate`].
|
||||
///
|
||||
/// See [`View`] struct.
|
||||
///
|
||||
/// # JavaScript Examples
|
||||
///
|
||||
/// ```javascript
|
||||
/// const view = await table.view({
|
||||
/// columns: ["Sales"],
|
||||
/// aggregates: { Sales: "sum" },
|
||||
/// group_by: ["Region", "Country"],
|
||||
/// filter: [["Category", "in", ["Furniture", "Technology"]]],
|
||||
/// });
|
||||
/// ```
|
||||
#[wasm_bindgen]
|
||||
pub async fn view(&self, config: Option<JsViewConfig>) -> ApiResult<View> {
|
||||
let config = config
|
||||
.map(|config| js_sys::JSON::stringify(&config))
|
||||
.transpose()?
|
||||
.and_then(|x| x.as_string())
|
||||
.map(|x| serde_json::from_str(x.as_str()))
|
||||
.transpose()?;
|
||||
|
||||
let view = self.0.view(config).await?;
|
||||
Ok(View(view))
|
||||
}
|
||||
|
||||
/// Validates the given expressions.
|
||||
#[wasm_bindgen]
|
||||
pub async fn validate_expressions(&self, exprs: &JsValue) -> ApiResult<JsValue> {
|
||||
let exprs = JsValue::into_serde_ext::<Expressions>(exprs.clone())?;
|
||||
let columns = self.0.validate_expressions(exprs).await?;
|
||||
Ok(JsValue::from_serde_ext(&columns)?)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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 extend::ext;
|
||||
use js_sys::{Array, ArrayBuffer, Function, JSON, Object, Reflect, Uint8Array};
|
||||
use perspective_client::config::ColumnType;
|
||||
use perspective_client::{TableData, TableReadFormat, UpdateData};
|
||||
use wasm_bindgen::convert::TryFromJsValue;
|
||||
use wasm_bindgen::intern;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::apierror;
|
||||
use crate::utils::{ApiError, ApiResult, JsValueSerdeExt};
|
||||
pub use crate::view::*;
|
||||
|
||||
#[ext]
|
||||
impl Vec<(String, ColumnType)> {
|
||||
fn from_js_value(value: &JsValue) -> ApiResult<Vec<(String, ColumnType)>> {
|
||||
Ok(Object::keys(value.unchecked_ref())
|
||||
.iter()
|
||||
.map(|x| -> Result<_, JsValue> {
|
||||
let key = x.as_string().expect("Not string??");
|
||||
let val = Reflect::get(value, &x)?
|
||||
.as_string()
|
||||
.expect("Y no string?")
|
||||
.into_serde_ext()?;
|
||||
|
||||
Ok((key, val))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?)
|
||||
}
|
||||
}
|
||||
|
||||
#[ext]
|
||||
pub(crate) impl TableData {
|
||||
fn from_js_value(value: &JsValue, format: Option<TableReadFormat>) -> ApiResult<TableData> {
|
||||
if let Some(result) = UpdateData::from_js_value_partial(value, format)? {
|
||||
Ok(result.into())
|
||||
} else if value.is_instance_of::<Object>()
|
||||
&& Reflect::has(value, &intern("__get_model").into())?
|
||||
{
|
||||
let val = Reflect::get(value, &intern("__get_model").into())?
|
||||
.dyn_into::<Function>()?
|
||||
.call0(value)?;
|
||||
|
||||
let view = View::try_from_js_value(val)?;
|
||||
Ok(TableData::View(view.0))
|
||||
} else if value.is_instance_of::<Object>() {
|
||||
let all_strings = || {
|
||||
Object::values(value.unchecked_ref())
|
||||
.to_vec()
|
||||
.iter()
|
||||
.all(|x| x.is_string())
|
||||
};
|
||||
|
||||
let all_arrays = || {
|
||||
Object::values(value.unchecked_ref())
|
||||
.to_vec()
|
||||
.iter()
|
||||
.all(|x| x.is_instance_of::<Array>())
|
||||
};
|
||||
|
||||
if all_strings() {
|
||||
Ok(TableData::Schema(Vec::from_js_value(value)?))
|
||||
} else if all_arrays() {
|
||||
let json = JSON::stringify(value)?
|
||||
.as_string()
|
||||
.expect("STRINGIFY_ARRAY??");
|
||||
Ok(UpdateData::JsonColumns(json).into())
|
||||
} else {
|
||||
Err(apierror!(TableError(value.clone())))
|
||||
}
|
||||
} else {
|
||||
Err(apierror!(TableError(value.clone())))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[ext]
|
||||
pub(crate) impl UpdateData {
|
||||
fn from_js_value_partial(
|
||||
value: &JsValue,
|
||||
format: Option<TableReadFormat>,
|
||||
) -> ApiResult<Option<UpdateData>> {
|
||||
if value.is_undefined() {
|
||||
Err(apierror!(TableError(value.clone())))
|
||||
} else if value.is_string() {
|
||||
match format {
|
||||
None | Some(TableReadFormat::Csv) => {
|
||||
Ok(Some(UpdateData::Csv(value.as_string().expect("Csv????"))))
|
||||
},
|
||||
Some(TableReadFormat::JsonString) => Ok(Some(UpdateData::JsonRows(
|
||||
value.as_string().expect("JSON???"),
|
||||
))),
|
||||
Some(TableReadFormat::ColumnsString) => Ok(Some(UpdateData::JsonColumns(
|
||||
value.as_string().expect("ColumnString???"),
|
||||
))),
|
||||
Some(TableReadFormat::Arrow) => Ok(Some(UpdateData::Arrow(
|
||||
value.as_string().expect("Arrow???").into_bytes().into(),
|
||||
))),
|
||||
Some(TableReadFormat::Ndjson) => Ok(Some(UpdateData::Ndjson(
|
||||
value.as_string().expect("Ndjson???"),
|
||||
))),
|
||||
}
|
||||
} else if value.is_instance_of::<ArrayBuffer>() {
|
||||
let uint8array = Uint8Array::new(value);
|
||||
let slice = uint8array.to_vec();
|
||||
match format {
|
||||
Some(TableReadFormat::Csv) => Ok(Some(UpdateData::Csv(String::from_utf8(slice)?))),
|
||||
Some(TableReadFormat::JsonString) => {
|
||||
Ok(Some(UpdateData::JsonRows(String::from_utf8(slice)?)))
|
||||
},
|
||||
Some(TableReadFormat::ColumnsString) => {
|
||||
Ok(Some(UpdateData::JsonColumns(String::from_utf8(slice)?)))
|
||||
},
|
||||
Some(TableReadFormat::Ndjson) => {
|
||||
Ok(Some(UpdateData::Ndjson(String::from_utf8(slice)?)))
|
||||
},
|
||||
None | Some(TableReadFormat::Arrow) => Ok(Some(UpdateData::Arrow(slice.into()))),
|
||||
}
|
||||
} else if let Some(uint8array) = value.dyn_ref::<Uint8Array>() {
|
||||
let slice = uint8array.to_vec();
|
||||
match format {
|
||||
Some(TableReadFormat::Csv) => Ok(Some(UpdateData::Csv(String::from_utf8(slice)?))),
|
||||
Some(TableReadFormat::JsonString) => {
|
||||
Ok(Some(UpdateData::JsonRows(String::from_utf8(slice)?)))
|
||||
},
|
||||
Some(TableReadFormat::ColumnsString) => {
|
||||
Ok(Some(UpdateData::JsonColumns(String::from_utf8(slice)?)))
|
||||
},
|
||||
Some(TableReadFormat::Ndjson) => {
|
||||
Ok(Some(UpdateData::Ndjson(String::from_utf8(slice)?)))
|
||||
},
|
||||
None | Some(TableReadFormat::Arrow) => Ok(Some(UpdateData::Arrow(slice.into()))),
|
||||
}
|
||||
} else if value.is_instance_of::<Array>() {
|
||||
let rows = JSON::stringify(value)?.as_string().expect("STRINGIFY??");
|
||||
Ok(Some(UpdateData::JsonRows(rows)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn from_js_value(value: &JsValue, format: Option<TableReadFormat>) -> ApiResult<UpdateData> {
|
||||
match TableData::from_js_value(value, format)? {
|
||||
TableData::Schema(_) => Err(ApiError::new(
|
||||
"Method cannot be called with `Schema` argument",
|
||||
)),
|
||||
TableData::Update(x) => Ok(x),
|
||||
TableData::View(_) => Err(ApiError::new(
|
||||
"Method cannot be called with `Schema` argument",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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::Cursor;
|
||||
|
||||
use arrow_array::Array as _;
|
||||
use arrow_array::cast::AsArray;
|
||||
use arrow_array::types::*;
|
||||
use arrow_ipc::reader::StreamReader;
|
||||
use arrow_schema::{DataType, TimeUnit};
|
||||
use js_sys::{Array, Function, JsString, Uint8Array};
|
||||
use perspective_client::ViewWindow;
|
||||
use ts_rs::TS;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
unsafe extern "C" {
|
||||
#[wasm_bindgen(typescript_type = "TypedArrayWindow")]
|
||||
#[derive(Clone)]
|
||||
pub type JsTypedArrayWindow;
|
||||
}
|
||||
|
||||
/// Options for `with_typed_arrays`, extending `ViewWindow` with
|
||||
/// typed-array-specific options.
|
||||
#[derive(Default, serde::Deserialize, TS)]
|
||||
pub struct TypedArrayWindow {
|
||||
#[serde(flatten)]
|
||||
pub view_window: ViewWindow,
|
||||
|
||||
/// When `true`, Float64/Date32/Timestamp columns are output as
|
||||
/// `Float32Array` instead of `Float64Array`.
|
||||
#[serde(default)]
|
||||
pub float32: bool,
|
||||
}
|
||||
|
||||
impl From<TypedArrayWindow> for ViewWindow {
|
||||
fn from(w: TypedArrayWindow) -> Self {
|
||||
w.view_window
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode an Arrow IPC batch and call `callback` once with all columns.
|
||||
///
|
||||
/// Callback signature:
|
||||
/// ```js
|
||||
/// callback(names: string[], values: TypedArray[], validities: (Uint8Array|null)[], dictionaries: (string[]|null)[]) => void | Promise<void>
|
||||
/// ```
|
||||
///
|
||||
/// If the callback returns a `Promise`, it is awaited before the Arrow
|
||||
/// batch (and therefore the zero-copy typed-array views into it) is
|
||||
/// dropped. A synchronous callback returning `undefined` is supported
|
||||
/// with no promise-handling overhead.
|
||||
pub(crate) async fn decode_and_call(
|
||||
arrow: &[u8],
|
||||
float32: bool,
|
||||
callback: &Function,
|
||||
) -> Result<(), JsValue> {
|
||||
let cursor = Cursor::new(arrow);
|
||||
let reader = StreamReader::try_new(cursor, None)
|
||||
.map_err(|e| JsValue::from_str(&format!("Arrow decode error: {e}")))?;
|
||||
|
||||
let batch = reader
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| JsValue::from_str("Arrow IPC contained no record batches"))?
|
||||
.map_err(|e| JsValue::from_str(&format!("Arrow batch error: {e}")))?;
|
||||
|
||||
let schema = batch.schema();
|
||||
let num_cols = batch.num_columns();
|
||||
|
||||
let js_names = Array::new_with_length(num_cols as u32);
|
||||
let js_values = Array::new_with_length(num_cols as u32);
|
||||
let js_validities = Array::new_with_length(num_cols as u32);
|
||||
let js_dicts = Array::new_with_length(num_cols as u32);
|
||||
|
||||
// Storage for allocated conversion buffers. These MUST outlive the
|
||||
// callback because `js_sys::*Array::view()` creates zero-copy views
|
||||
// into their heap memory. Using `Box<[T]>` (rather than `Vec<T>`)
|
||||
// yields a stable pointer that won't move when the outer Vec grows.
|
||||
let mut f32_storage: Vec<Box<[f32]>> = Vec::new();
|
||||
let mut f64_storage: Vec<Box<[f64]>> = Vec::new();
|
||||
|
||||
for col_idx in 0..num_cols {
|
||||
let field = schema.field(col_idx);
|
||||
let col = batch.column(col_idx);
|
||||
let validity = col.nulls().map(|nulls| nulls.validity());
|
||||
|
||||
js_names.set(col_idx as u32, JsString::from(field.name().as_str()).into());
|
||||
|
||||
match col.data_type() {
|
||||
DataType::UInt32 => {
|
||||
let vals = col.as_primitive::<UInt32Type>().values();
|
||||
let arr = unsafe { js_sys::Uint32Array::view(vals.as_ref()) };
|
||||
js_values.set(col_idx as u32, arr.into());
|
||||
js_dicts.set(col_idx as u32, JsValue::NULL);
|
||||
},
|
||||
DataType::Int32 => {
|
||||
let vals = col.as_primitive::<Int32Type>().values();
|
||||
let arr = unsafe { js_sys::Int32Array::view(vals.as_ref()) };
|
||||
js_values.set(col_idx as u32, arr.into());
|
||||
js_dicts.set(col_idx as u32, JsValue::NULL);
|
||||
},
|
||||
DataType::Float32 => {
|
||||
let vals = col.as_primitive::<Float32Type>().values();
|
||||
let arr = unsafe { js_sys::Float32Array::view(vals.as_ref()) };
|
||||
js_values.set(col_idx as u32, arr.into());
|
||||
js_dicts.set(col_idx as u32, JsValue::NULL);
|
||||
},
|
||||
DataType::Float64 => {
|
||||
if float32 {
|
||||
let vals = col.as_primitive::<Float64Type>().values();
|
||||
f32_storage.push(vals.iter().map(|&v| v as f32).collect());
|
||||
} else {
|
||||
let vals = col.as_primitive::<Float64Type>().values();
|
||||
let arr = unsafe { js_sys::Float64Array::view(vals.as_ref()) };
|
||||
js_values.set(col_idx as u32, arr.into());
|
||||
}
|
||||
js_dicts.set(col_idx as u32, JsValue::NULL);
|
||||
},
|
||||
DataType::Date32 => {
|
||||
// Datetime values are always emitted as Float64 — narrowing
|
||||
// epoch-ms to f32 collapses ~256 ms of resolution at modern
|
||||
// timestamps, so the `float32` flag is intentionally ignored
|
||||
// for date/timestamp columns.
|
||||
let typed = col.as_primitive::<Date32Type>();
|
||||
f64_storage.push(
|
||||
typed
|
||||
.values()
|
||||
.iter()
|
||||
.map(|&v| v as f64 * 86_400_000.0)
|
||||
.collect(),
|
||||
);
|
||||
js_dicts.set(col_idx as u32, JsValue::NULL);
|
||||
},
|
||||
DataType::Timestamp(TimeUnit::Millisecond, _) => {
|
||||
let typed = col.as_primitive::<TimestampMillisecondType>();
|
||||
f64_storage.push(typed.values().iter().map(|&v| v as f64).collect());
|
||||
js_dicts.set(col_idx as u32, JsValue::NULL);
|
||||
},
|
||||
DataType::Int64 => {
|
||||
let typed = col.as_primitive::<Int64Type>();
|
||||
if float32 {
|
||||
f32_storage.push(typed.values().iter().map(|&v| v as f32).collect());
|
||||
} else {
|
||||
f64_storage.push(typed.values().iter().map(|&v| v as f64).collect());
|
||||
}
|
||||
js_dicts.set(col_idx as u32, JsValue::NULL);
|
||||
},
|
||||
DataType::Dictionary(..) => {
|
||||
let dict = col.as_dictionary::<Int32Type>();
|
||||
let keys = dict.keys();
|
||||
let arr = unsafe { js_sys::Int32Array::view(keys.values().as_ref()) };
|
||||
js_values.set(col_idx as u32, arr.into());
|
||||
|
||||
let values = dict.values().as_string::<i32>();
|
||||
let js_dict = Array::new_with_length(values.len() as u32);
|
||||
for i in 0..values.len() {
|
||||
js_dict.set(i as u32, JsValue::from_str(values.value(i)));
|
||||
}
|
||||
js_dicts.set(col_idx as u32, js_dict.into());
|
||||
},
|
||||
dt => {
|
||||
return Err(JsValue::from_str(&format!(
|
||||
"Unsupported column type for typed array: {dt}"
|
||||
)));
|
||||
},
|
||||
}
|
||||
|
||||
// SAFETY: Validity bitmap is owned by `batch` which outlives the
|
||||
// callback — safe to view zero-copy.
|
||||
let js_validity = validity.map(|v| unsafe { Uint8Array::view(v) });
|
||||
js_validities.set(
|
||||
col_idx as u32,
|
||||
js_validity
|
||||
.as_ref()
|
||||
.map(JsValue::from)
|
||||
.unwrap_or(JsValue::NULL),
|
||||
);
|
||||
}
|
||||
|
||||
// Second pass: fill in value views for columns backed by f32_storage /
|
||||
// f64_storage. The Box<[T]> buffers are heap-allocated and stable; their
|
||||
// data pointers remain valid even as the outer Vec grows.
|
||||
let mut f32_idx = 0;
|
||||
let mut f64_idx = 0;
|
||||
for col_idx in 0..num_cols {
|
||||
let col = batch.column(col_idx);
|
||||
let uses_f32_storage = matches!(
|
||||
(col.data_type(), float32),
|
||||
(DataType::Float64, true) | (DataType::Int64, true),
|
||||
);
|
||||
let uses_f64_storage = matches!(
|
||||
(col.data_type(), float32),
|
||||
(DataType::Date32, _)
|
||||
| (DataType::Timestamp(TimeUnit::Millisecond, _), _)
|
||||
| (DataType::Int64, false),
|
||||
);
|
||||
|
||||
if uses_f32_storage {
|
||||
let arr = unsafe { js_sys::Float32Array::view(&f32_storage[f32_idx]) };
|
||||
js_values.set(col_idx as u32, arr.into());
|
||||
f32_idx += 1;
|
||||
} else if uses_f64_storage {
|
||||
let arr = unsafe { js_sys::Float64Array::view(&f64_storage[f64_idx]) };
|
||||
js_values.set(col_idx as u32, arr.into());
|
||||
f64_idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let ret = callback.call4(
|
||||
&JsValue::UNDEFINED,
|
||||
&js_names.into(),
|
||||
&js_values.into(),
|
||||
&js_validities.into(),
|
||||
&js_dicts.into(),
|
||||
)?;
|
||||
|
||||
// If the callback returned a Promise, await it before releasing the
|
||||
// batch — zero-copy TypedArray views into `batch`/`f32_storage`/
|
||||
// `f64_storage` must remain valid for the full lifetime of the
|
||||
// awaited work.
|
||||
if ret.is_instance_of::<js_sys::Promise>() {
|
||||
let promise: js_sys::Promise = ret.unchecked_into();
|
||||
wasm_bindgen_futures::JsFuture::from(promise).await?;
|
||||
}
|
||||
|
||||
// Keep storage alive until after the callback (and its awaited
|
||||
// promise, if any) returns.
|
||||
drop(f32_storage);
|
||||
drop(f64_storage);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
/// Convenient short-name functions for common browser globals. These types are
|
||||
/// not `send` and cannot be made static.
|
||||
pub mod global {
|
||||
pub fn window() -> web_sys::Window {
|
||||
web_sys::window().unwrap()
|
||||
}
|
||||
|
||||
pub fn document() -> web_sys::Document {
|
||||
window().document().unwrap()
|
||||
}
|
||||
|
||||
pub fn performance() -> web_sys::Performance {
|
||||
window().performance().unwrap()
|
||||
}
|
||||
|
||||
pub fn body() -> web_sys::HtmlElement {
|
||||
document().body().unwrap()
|
||||
}
|
||||
|
||||
pub fn navigator() -> web_sys::Navigator {
|
||||
window().navigator()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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::{Debug, Write};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use tracing::Subscriber;
|
||||
use tracing::field::{Field, Visit};
|
||||
use tracing_subscriber::Layer;
|
||||
use tracing_subscriber::layer::Context;
|
||||
use tracing_subscriber::registry::LookupSpan;
|
||||
use wasm_bindgen::intern;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::utils::*;
|
||||
|
||||
/// A struct to implement the `Visit` visitor pattern trait to process
|
||||
/// `tracing::Event`s.
|
||||
#[derive(Default)]
|
||||
struct LogLineBuffer {
|
||||
value: String,
|
||||
is_tail: bool,
|
||||
}
|
||||
|
||||
impl Visit for LogLineBuffer {
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn Debug) {
|
||||
if field.name() == "message" {
|
||||
if !self.value.is_empty() {
|
||||
self.value = format!("{:?}\n{}", value, self.value)
|
||||
} else {
|
||||
self.value = format!("{value:?}")
|
||||
}
|
||||
} else {
|
||||
if self.is_tail {
|
||||
writeln!(self.value).unwrap();
|
||||
} else {
|
||||
write!(self.value, " ").unwrap();
|
||||
self.is_tail = true;
|
||||
}
|
||||
|
||||
write!(self.value, "{} = {:?};", field.name(), value).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[extend::ext]
|
||||
impl tracing::Level {
|
||||
/// Convert a `tracing::Level` to an equivalent 4-arg call to the
|
||||
/// browser console via the `web_sys::console` module.
|
||||
fn web_logger_4(&self) -> fn(&JsValue, &JsValue, &JsValue, &JsValue) {
|
||||
match *self {
|
||||
tracing::Level::TRACE => web_sys::console::trace_4,
|
||||
tracing::Level::DEBUG => web_sys::console::debug_4,
|
||||
tracing::Level::INFO => web_sys::console::info_4,
|
||||
tracing::Level::WARN => web_sys::console::warn_4,
|
||||
tracing::Level::ERROR => web_sys::console::error_4,
|
||||
}
|
||||
}
|
||||
|
||||
fn web_logger_1(&self) -> fn(&JsValue) {
|
||||
match *self {
|
||||
tracing::Level::TRACE => web_sys::console::trace_1,
|
||||
tracing::Level::DEBUG => web_sys::console::debug_1,
|
||||
tracing::Level::INFO => web_sys::console::info_1,
|
||||
tracing::Level::WARN => web_sys::console::warn_1,
|
||||
tracing::Level::ERROR => web_sys::console::error_1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a pretty color theme for a `tracing::Level`.
|
||||
fn web_log_color(&self) -> &'static str {
|
||||
match *self {
|
||||
tracing::Level::TRACE => "background: #005F73; color: #000",
|
||||
tracing::Level::DEBUG => "background: #0A9396; color: #000",
|
||||
tracing::Level::INFO => "background: #E9D8A6; color: #000",
|
||||
tracing::Level::WARN => "background: #EE9B00; color: #000",
|
||||
tracing::Level::ERROR => "background: #AE2012; color: #000",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static IS_CHROME: OnceLock<bool> = OnceLock::new();
|
||||
|
||||
fn detect_chrome() -> bool {
|
||||
if let Some(window) = web_sys::window() {
|
||||
window.get("chrome").is_some()
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[extend::ext]
|
||||
impl<'a> tracing::Metadata<'a> {
|
||||
/// Log a message in the style of `WasmLogger`.
|
||||
fn console_log(&self, msg: &str) {
|
||||
let level = self.level();
|
||||
let origin = self
|
||||
.module_path()
|
||||
.and_then(|file| self.line().map(|ln| format!("{}:{}", &file[11..], ln)))
|
||||
.unwrap_or_default();
|
||||
|
||||
if *IS_CHROME.get_or_init(detect_chrome) {
|
||||
level.web_logger_4()(
|
||||
&format!("%c {level} %c {origin}%c {msg} ").into(),
|
||||
&level.web_log_color().into(),
|
||||
&intern("color: gray; font-style: italic").into(),
|
||||
&intern("color: inherit").into(),
|
||||
);
|
||||
} else {
|
||||
level.web_logger_1()(&format!("{origin} {msg}").into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A custom logger modelled afer the `tracing_wasm` crate.
|
||||
struct WasmLogger {
|
||||
max_level: tracing::Level,
|
||||
}
|
||||
|
||||
impl Default for WasmLogger {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_level: tracing::Level::WARN,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Subscriber + for<'a> LookupSpan<'a>> Layer<S> for WasmLogger {
|
||||
fn on_event(
|
||||
&self,
|
||||
event: &tracing::Event<'_>,
|
||||
_ctx: tracing_subscriber::layer::Context<'_, S>,
|
||||
) {
|
||||
let mut recorder = LogLineBuffer::default();
|
||||
event.record(&mut recorder);
|
||||
event.metadata().console_log(&recorder.value);
|
||||
}
|
||||
|
||||
fn enabled(&self, metadata: &tracing::Metadata<'_>, _: Context<'_, S>) -> bool {
|
||||
let level = metadata.level();
|
||||
level <= &self.max_level
|
||||
}
|
||||
|
||||
fn on_new_span(
|
||||
&self,
|
||||
attrs: &tracing::span::Attributes<'_>,
|
||||
id: &tracing::Id,
|
||||
ctx: Context<'_, S>,
|
||||
) {
|
||||
let mut new_debug_record = LogLineBuffer::default();
|
||||
attrs.record(&mut new_debug_record);
|
||||
if let Some(span_ref) = ctx.span(id) {
|
||||
span_ref
|
||||
.extensions_mut()
|
||||
.insert::<LogLineBuffer>(new_debug_record);
|
||||
}
|
||||
|
||||
global::performance()
|
||||
.mark(&format!("t{:x}", id.into_u64()))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn on_record(&self, id: &tracing::Id, values: &tracing::span::Record<'_>, ctx: Context<'_, S>) {
|
||||
if let Some(span_ref) = ctx.span(id)
|
||||
&& let Some(debug_record) = span_ref.extensions_mut().get_mut::<LogLineBuffer>()
|
||||
{
|
||||
values.record(debug_record);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_close(&self, id: tracing::Id, ctx: Context<'_, S>) {
|
||||
if let Some(span_ref) = ctx.span(&id) {
|
||||
let perf = global::performance();
|
||||
let meta = span_ref.metadata();
|
||||
let mark = format!("t{:x}", id.into_u64());
|
||||
let start = perf
|
||||
.get_entries_by_name_with_entry_type(&mark, "mark")
|
||||
.at(-1)
|
||||
.unchecked_into::<web_sys::PerformanceMark>()
|
||||
.start_time();
|
||||
|
||||
meta.console_log(&format!("{:.0}ms", perf.now() - start));
|
||||
let msg = format!(
|
||||
"\"{}\" {} {}",
|
||||
meta.name(),
|
||||
meta.module_path().unwrap_or_default(),
|
||||
span_ref
|
||||
.extensions()
|
||||
.get::<LogLineBuffer>()
|
||||
.map(|x| &x.value[..])
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
|
||||
perf.measure_with_start_mark(&msg, &mark).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure `WasmLogger` as a global default for tracing.
|
||||
///
|
||||
/// This operation will conflict with any other library which sets a global
|
||||
/// default `tracing::Subscriber`, so it should not be called when `perspective`
|
||||
/// is used as a library from a larger app; in this case the app itself should
|
||||
/// configure `tracing` explicitly.
|
||||
pub fn set_global_logging() {
|
||||
static INIT_LOGGING: OnceLock<()> = OnceLock::new();
|
||||
INIT_LOGGING.get_or_init(|| {
|
||||
if !*IS_CHROME.get_or_init(detect_chrome) {
|
||||
web_sys::console::warn_1(
|
||||
&"Unknown browser detected. Some features may not work, and performance may be \
|
||||
degraded."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
let filter = tracing_subscriber::filter::filter_fn(|meta| {
|
||||
meta.module_path()
|
||||
.as_ref()
|
||||
.map(|x| x.starts_with("perspective"))
|
||||
.unwrap_or_default()
|
||||
});
|
||||
|
||||
let layer = WasmLogger::default().with_filter(filter);
|
||||
let subscriber = tracing_subscriber::Registry::default().with(layer);
|
||||
tracing::subscriber::set_global_default(subscriber).unwrap();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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::rc::Rc;
|
||||
use std::string::FromUtf8Error;
|
||||
|
||||
use perspective_client::{ClientError, ExprValidationResult};
|
||||
use thiserror::*;
|
||||
use wasm_bindgen::intern;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! apierror {
|
||||
($msg:expr) => {{
|
||||
use $crate::utils::errors::ApiErrorType::*;
|
||||
let js_err_type = $msg;
|
||||
let err = js_sys::Error::new(js_err_type.to_string().as_str());
|
||||
let js_err = $crate::utils::errors::ApiError(
|
||||
js_err_type,
|
||||
$crate::utils::errors::JsBackTrace(std::rc::Rc::new(err.clone())),
|
||||
);
|
||||
js_err
|
||||
}};
|
||||
}
|
||||
|
||||
fn format_js_error(value: &JsValue) -> String {
|
||||
if let Some(err) = value.dyn_ref::<js_sys::Error>() {
|
||||
let msg = err.message().as_string().unwrap();
|
||||
if let Ok(x) = js_sys::Reflect::get(value, &intern("stack").into()) {
|
||||
format!("{}\n{}", msg, x.as_string().unwrap())
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
} else {
|
||||
value
|
||||
.as_string()
|
||||
.unwrap_or_else(|| format!("{value:?}"))
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_valid_exprs(recs: &ExprValidationResult) -> String {
|
||||
recs.errors
|
||||
.iter()
|
||||
.map(|x| format!("\"{}\": {}", x.0, x.1.error_message))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
/// A bespoke error class for chaining a litany of error types with the `?`
|
||||
/// operator.
|
||||
#[derive(Clone, Debug, Error)]
|
||||
pub enum ApiErrorType {
|
||||
#[error("{}", format_js_error(.0))]
|
||||
JsError(JsValue),
|
||||
|
||||
#[error("{}", format_js_error(.0))]
|
||||
JsRawError(js_sys::Error),
|
||||
|
||||
#[error("Failed to construct table from {0:?}")]
|
||||
TableError(JsValue),
|
||||
|
||||
#[error("{}", format_js_error(.0))]
|
||||
ViewerPluginError(JsValue),
|
||||
|
||||
#[error("{0}")]
|
||||
ExternalError(Rc<Box<dyn std::error::Error>>),
|
||||
|
||||
#[error("{0}")]
|
||||
UnknownError(String),
|
||||
|
||||
#[error("{0}")]
|
||||
ClientError(#[from] ClientError),
|
||||
|
||||
#[error("Cancelled")]
|
||||
CancelledError(#[from] futures::channel::oneshot::Canceled),
|
||||
|
||||
#[error("{0}")]
|
||||
SerdeJsonError(Rc<serde_json::Error>),
|
||||
|
||||
#[error("{0}")]
|
||||
ProstError(#[from] prost::DecodeError),
|
||||
|
||||
#[error("Unknown column \"{1}\" in field `{0}`")]
|
||||
InvalidViewerConfigError(&'static str, String),
|
||||
|
||||
#[error("Invalid `expressions` {}", format_valid_exprs(.0))]
|
||||
InvalidViewerConfigExpressionsError(Rc<ExprValidationResult>),
|
||||
|
||||
#[error("Expected a Table or string table name")]
|
||||
TableRefError,
|
||||
|
||||
#[error("No `Table` attached")]
|
||||
NoTableError,
|
||||
|
||||
#[error(transparent)]
|
||||
SerdeWasmBindgenError(Rc<serde_wasm_bindgen::Error>),
|
||||
|
||||
#[error(transparent)]
|
||||
Utf8Error(#[from] FromUtf8Error),
|
||||
|
||||
#[error(transparent)]
|
||||
StdIoError(Rc<std::io::Error>),
|
||||
|
||||
#[error(transparent)]
|
||||
ChronoParseError(#[from] chrono::ParseError),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Error)]
|
||||
pub struct ApiError(pub ApiErrorType, pub JsBackTrace);
|
||||
|
||||
impl ApiError {
|
||||
pub fn new<T: Display>(val: T) -> Self {
|
||||
apierror!(UnknownError(format!("{val}")))
|
||||
}
|
||||
|
||||
/// The error category
|
||||
pub fn kind(&self) -> &'static str {
|
||||
match self.0 {
|
||||
ApiErrorType::JsError(..) => "[JsError]",
|
||||
ApiErrorType::TableError(_) => "[TableError]",
|
||||
ApiErrorType::ExternalError(_) => "[ExternalError]",
|
||||
ApiErrorType::UnknownError(..) => "[UnknownError]",
|
||||
ApiErrorType::ClientError(_) => "[ClientError]",
|
||||
ApiErrorType::CancelledError(_) => "[CancelledError]",
|
||||
ApiErrorType::SerdeJsonError(_) => "[SerdeJsonError]",
|
||||
ApiErrorType::ProstError(_) => "[ProstError]",
|
||||
ApiErrorType::InvalidViewerConfigError(..) => "[InvalidViewerConfigError]",
|
||||
ApiErrorType::InvalidViewerConfigExpressionsError(_) => "[InvalidViewerConfigError]",
|
||||
ApiErrorType::TableRefError => "[TableRefError]",
|
||||
ApiErrorType::NoTableError => "[NoTableError]",
|
||||
ApiErrorType::SerdeWasmBindgenError(_) => "[SerdeWasmBindgenError]",
|
||||
ApiErrorType::Utf8Error(_) => "[FromUtf8Error]",
|
||||
ApiErrorType::StdIoError(_) => "[StdIoError]",
|
||||
ApiErrorType::ChronoParseError(_) => "[ChronoParseError]",
|
||||
ApiErrorType::ViewerPluginError(_) => "[ViewerPluginError]",
|
||||
ApiErrorType::JsRawError(_) => "[JsRawError]",
|
||||
}
|
||||
}
|
||||
|
||||
/// The raw internal enum
|
||||
pub fn inner(&self) -> &'_ ApiErrorType {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// The `Display` for this error
|
||||
pub fn message(&self) -> String {
|
||||
self.0.to_string()
|
||||
}
|
||||
|
||||
/// This error's stacktrace from when it was constructed.
|
||||
pub fn stacktrace(&self) -> String {
|
||||
js_sys::Reflect::get(&self.1.0, &intern("stack").into())
|
||||
.unwrap()
|
||||
.as_string()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// This type is not thread safe, but the JavaScript environment does not allow
|
||||
// threading.
|
||||
unsafe impl Send for ApiError {}
|
||||
unsafe impl Sync for ApiError {}
|
||||
|
||||
impl std::fmt::Display for ApiError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Into<ApiErrorType>> From<T> for ApiError {
|
||||
fn from(value: T) -> Self {
|
||||
let value: ApiErrorType = value.into();
|
||||
let err = js_sys::Error::new(value.to_string().as_str());
|
||||
ApiError(value, JsBackTrace(Rc::new(err.clone())))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApiError> for JsValue {
|
||||
fn from(err: ApiError) -> Self {
|
||||
err.1.0.unchecked_ref::<JsValue>().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_wasm_bindgen::Error> for ApiError {
|
||||
fn from(value: serde_wasm_bindgen::Error) -> Self {
|
||||
ApiErrorType::SerdeWasmBindgenError(Rc::new(value)).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for ApiError {
|
||||
fn from(value: std::io::Error) -> Self {
|
||||
ApiErrorType::StdIoError(Rc::new(value)).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Box<dyn std::error::Error>> for ApiError {
|
||||
fn from(value: Box<dyn std::error::Error>) -> Self {
|
||||
ApiErrorType::ExternalError(Rc::new(value)).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for ApiError {
|
||||
fn from(value: serde_json::Error) -> Self {
|
||||
ApiErrorType::SerdeJsonError(Rc::new(value)).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<JsValue> for ApiError {
|
||||
fn from(err: JsValue) -> Self {
|
||||
if err.is_instance_of::<js_sys::Error>() {
|
||||
ApiError(
|
||||
ApiErrorType::JsRawError(err.clone().unchecked_into()),
|
||||
JsBackTrace(Rc::new(err.unchecked_into())),
|
||||
)
|
||||
} else {
|
||||
apierror!(JsError(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ApiError {
|
||||
fn from(value: String) -> Self {
|
||||
apierror!(UnknownError(value.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for ApiError {
|
||||
fn from(value: &str) -> Self {
|
||||
apierror!(UnknownError(value.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
/// `ToApiError` handles complex cases that can't be into-d
|
||||
pub trait ToApiError<T> {
|
||||
fn into_apierror(self) -> ApiResult<T>;
|
||||
}
|
||||
|
||||
impl<T> ToApiError<T> for Option<T> {
|
||||
fn into_apierror(self) -> ApiResult<T> {
|
||||
self.ok_or_else(|| intern("Unwrap on None").into())
|
||||
}
|
||||
}
|
||||
|
||||
impl ToApiError<JsValue> for Result<(), ApiResult<JsValue>> {
|
||||
fn into_apierror(self) -> ApiResult<JsValue> {
|
||||
self.map_or_else(|x| x, |()| Ok(JsValue::UNDEFINED))
|
||||
}
|
||||
}
|
||||
|
||||
/// A common Rust error handling idiom (see e.g. `anyhow::Result`)
|
||||
pub type ApiResult<T> = Result<T, ApiError>;
|
||||
|
||||
// Backtrace
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct JsBackTrace(pub Rc<js_sys::Error>);
|
||||
|
||||
impl std::fmt::Display for JsBackTrace {
|
||||
fn fmt(&self, _: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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 perspective_client::ClientError;
|
||||
// TODO This is risky to rely on, but it is currently impossible to implement
|
||||
// this trait locally due to the orphan instance restriction. Using this trait
|
||||
// removes alow of boilerplate required by `async` when casting to `Promise`.
|
||||
use wasm_bindgen::__rt::IntoJsResult;
|
||||
use wasm_bindgen::convert::{FromWasmAbi, IntoWasmAbi};
|
||||
use wasm_bindgen::describe::WasmDescribe;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::{JsFuture, future_to_promise};
|
||||
|
||||
use super::errors::*;
|
||||
|
||||
/// A newtype wrapper for a `Future` trait object which supports being
|
||||
/// marshalled to a `JsPromise`.
|
||||
///
|
||||
/// This avoids implementing an API which requires type casting to
|
||||
/// and from `JsValue` and the associated loss of type safety.
|
||||
#[must_use]
|
||||
pub struct ApiFuture<T>(Pin<Box<dyn Future<Output = ApiResult<T>>>>)
|
||||
where
|
||||
Result<T, JsValue>: IntoJsResult + 'static;
|
||||
|
||||
impl<T> ApiFuture<T>
|
||||
where
|
||||
Result<T, JsValue>: IntoJsResult + 'static,
|
||||
{
|
||||
/// Constructor for `ApiFuture`. Note that, like a regular `Future`, the
|
||||
/// `ApiFuture` created does _not_ execute without being further cast to a
|
||||
/// `Promise`, either explicitly or implcitly (when exposed via
|
||||
/// `wasm_bindgen`).
|
||||
pub fn new<U: Future<Output = ApiResult<T>> + 'static>(x: U) -> Self {
|
||||
Self(Box::pin(x))
|
||||
}
|
||||
|
||||
pub fn new_throttled<U: Future<Output = ApiResult<T>> + 'static>(x: U) -> ApiFuture<()> {
|
||||
ApiFuture::<()>(Box::pin(
|
||||
async move { x.await.ignore_view_delete().map(|_| ()) },
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> ApiFuture<T>
|
||||
where
|
||||
Result<T, JsValue>: IntoJsResult + 'static,
|
||||
{
|
||||
/// Construct an `ApiFuture` and execute it immediately. The `Promise`
|
||||
/// handle created internally is dropped, but since JavaScript `Promise`
|
||||
/// executes on construction, the async invocation persists.
|
||||
pub fn spawn<U: Future<Output = ApiResult<T>> + 'static>(x: U) {
|
||||
drop(js_sys::Promise::from(Self::new(x)))
|
||||
}
|
||||
|
||||
pub fn spawn_throttled<U: Future<Output = ApiResult<T>> + 'static>(x: U) {
|
||||
drop(js_sys::Promise::from(Self::new_throttled(x)))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Default for ApiFuture<T>
|
||||
where
|
||||
Result<T, JsValue>: IntoJsResult + 'static,
|
||||
T: Default,
|
||||
{
|
||||
fn default() -> Self {
|
||||
Self::new(async { Ok(Default::default()) })
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<ApiFuture<T>> for JsValue
|
||||
where
|
||||
Result<T, Self>: IntoJsResult + 'static,
|
||||
{
|
||||
fn from(fut: ApiFuture<T>) -> Self {
|
||||
js_sys::Promise::from(fut).unchecked_into()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<ApiFuture<T>> for js_sys::Promise
|
||||
where
|
||||
Result<T, JsValue>: IntoJsResult + 'static,
|
||||
{
|
||||
fn from(fut: ApiFuture<T>) -> Self {
|
||||
future_to_promise(async move { Ok(fut.0.await?).into_js_result() })
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> WasmDescribe for ApiFuture<T>
|
||||
where
|
||||
Result<T, JsValue>: IntoJsResult + 'static,
|
||||
{
|
||||
fn describe() {
|
||||
<js_sys::Promise as WasmDescribe>::describe()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> IntoWasmAbi for ApiFuture<T>
|
||||
where
|
||||
Result<T, JsValue>: IntoJsResult + 'static,
|
||||
{
|
||||
type Abi = <js_sys::Promise as IntoWasmAbi>::Abi;
|
||||
|
||||
#[inline]
|
||||
fn into_abi(self) -> Self::Abi {
|
||||
js_sys::Promise::from(self).into_abi()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> FromWasmAbi for ApiFuture<T>
|
||||
where
|
||||
Result<T, JsValue>: IntoJsResult + 'static,
|
||||
T: From<JsValue> + Into<JsValue>,
|
||||
{
|
||||
type Abi = <js_sys::Promise as IntoWasmAbi>::Abi;
|
||||
|
||||
#[inline]
|
||||
unsafe fn from_abi(js: Self::Abi) -> Self {
|
||||
Self::new(async move {
|
||||
let promise = unsafe { js_sys::Promise::from_abi(js) };
|
||||
Ok(JsFuture::from(promise).await?.into())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Future for ApiFuture<T>
|
||||
where
|
||||
Result<T, JsValue>: IntoJsResult + 'static,
|
||||
{
|
||||
type Output = ApiResult<T>;
|
||||
|
||||
fn poll(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<Self::Output> {
|
||||
let mut fut = unsafe { self.map_unchecked_mut(|s| &mut s.0) };
|
||||
fut.as_mut().poll(cx)
|
||||
}
|
||||
}
|
||||
|
||||
#[extend::ext]
|
||||
pub impl<T> Result<T, ApiError> {
|
||||
/// Wraps an error `JsValue` return from a caught JavaScript exception,
|
||||
/// checking for the explicit error type indicating that a
|
||||
/// `JsPerspectiveView` call has been cancelled due to it already being
|
||||
/// deleted. This is a normal mechanic of the `JsPerspectiveView` to
|
||||
/// cancel a `View` call that is no longer need be the viewer, e.g. when
|
||||
/// the user updates the UI before the previous update has finished
|
||||
/// drawing. Without using exceptions for this, we'd need to wrap every
|
||||
/// such `JsPerspectiveView` call individually.
|
||||
///
|
||||
/// When `"View method cancelled"` message is received, this call should
|
||||
/// silently be replaced with `Ok`. The message itself is returned in this
|
||||
/// case (instead of whatever the `async` returns), which is helpful for
|
||||
/// detecting this condition when debugging.
|
||||
fn ignore_view_delete(self) -> Result<Option<T>, ApiError> {
|
||||
self.map(|x| Some(x)).or_else(|err| match err.inner() {
|
||||
ApiErrorType::ClientError(ClientError::ViewNotFound) => Ok(None),
|
||||
ApiErrorType::JsRawError(..) | ApiErrorType::JsError(..)
|
||||
if format!("{err}").contains("View not found") =>
|
||||
{
|
||||
Ok(None)
|
||||
},
|
||||
_ => Err(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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 JSON generating macro which mirrors the format's classic syntax in Rust
|
||||
//! via an incremental TT munching macro. This code was stolen from the
|
||||
//! `serde-json` crate (licensed under Apache 2.0), and modified to create
|
||||
//! `js_sys::*` types directly instead of serde types. Thanks `serde-json`!
|
||||
//!
|
||||
//! License: https://github.com/serde-rs/json/blob/master/LICENSE-APACHE
|
||||
//! Repo: https://github.com/serde-rs/json
|
||||
|
||||
#[macro_export]
|
||||
#[doc(hidden)]
|
||||
macro_rules! json {
|
||||
// Hide distracting implementation details from the generated rustdoc.
|
||||
($($json:tt)+) => {
|
||||
$crate::json_internal!($($json)+)
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
#[doc(hidden)]
|
||||
macro_rules! json_internal {
|
||||
|
||||
// TT muncher for parsing the inside of an array [...]. Produces a vec![...]
|
||||
// of the elements.
|
||||
//
|
||||
// Must be invoked as: json_internal!(@array [] $($tt)*)
|
||||
|
||||
// Done with trailing comma.
|
||||
(@array [$($elems:expr,)*]) => {
|
||||
vec![$($elems,)*]
|
||||
};
|
||||
|
||||
// Done without trailing comma.
|
||||
(@array [$($elems:expr),*]) => {
|
||||
vec![$($elems),*]
|
||||
};
|
||||
|
||||
// Next element is `null`.
|
||||
(@array [$($elems:expr,)*] null $($rest:tt)*) => {
|
||||
$crate::json_internal!(@array [$($elems,)* $crate::json_internal!(null)] $($rest)*)
|
||||
};
|
||||
|
||||
// Next element is `true`.
|
||||
(@array [$($elems:expr,)*] true $($rest:tt)*) => {
|
||||
$crate::json_internal!(@array [$($elems,)* $crate::json_internal!(true)] $($rest)*)
|
||||
};
|
||||
|
||||
// Next element is `false`.
|
||||
(@array [$($elems:expr,)*] false $($rest:tt)*) => {
|
||||
$crate::json_internal!(@array [$($elems,)* $crate::json_internal!(false)] $($rest)*)
|
||||
};
|
||||
|
||||
// Next element is an array.
|
||||
(@array [$($elems:expr,)*] [$($array:tt)*] $($rest:tt)*) => {
|
||||
$crate::json_internal!(@array [$($elems,)* $crate::json_internal!([$($array)*])] $($rest)*)
|
||||
};
|
||||
|
||||
// Next element is a map.
|
||||
(@array [$($elems:expr,)*] {$($map:tt)*} $($rest:tt)*) => {
|
||||
$crate::json_internal!(@array [$($elems,)* $crate::json_internal!({$($map)*})] $($rest)*)
|
||||
};
|
||||
|
||||
// Next element is an expression followed by comma.
|
||||
(@array [$($elems:expr,)*] $next:expr, $($rest:tt)*) => {
|
||||
$crate::json_internal!(@array [$($elems,)* $crate::json_internal!($next),] $($rest)*)
|
||||
};
|
||||
|
||||
// Last element is an expression with no trailing comma.
|
||||
(@array [$($elems:expr,)*] $last:expr) => {
|
||||
$crate::json_internal!(@array [$($elems,)* $crate::json_internal!($last)])
|
||||
};
|
||||
|
||||
// Comma after the most recent element.
|
||||
(@array [$($elems:expr),*] , $($rest:tt)*) => {
|
||||
$crate::json_internal!(@array [$($elems,)*] $($rest)*)
|
||||
};
|
||||
|
||||
// Unexpected token after most recent element.
|
||||
(@array [$($elems:expr),*] $unexpected:tt $($rest:tt)*) => {
|
||||
$crate::json_unexpected!($unexpected)
|
||||
};
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// TT muncher for parsing the inside of an object {...}. Each entry is
|
||||
// inserted into the given map variable.
|
||||
//
|
||||
// Must be invoked as: json_internal!(@object $map () ($($tt)*) ($($tt)*))
|
||||
//
|
||||
// We require two copies of the input tokens so that we can match on one
|
||||
// copy and trigger errors on the other copy.
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Done.
|
||||
(@object $object:ident () () ()) => {};
|
||||
|
||||
// Insert the current entry followed by trailing comma.
|
||||
(@object $object:ident [$($key:tt)+] ($value:expr) , $($rest:tt)*) => {
|
||||
let k = JsValue::from(wasm_bindgen::intern($($key)+));
|
||||
js_sys::Reflect::set(&$object, &k, &$value).unwrap();
|
||||
$crate::json_internal!(@object $object () ($($rest)*) ($($rest)*));
|
||||
};
|
||||
|
||||
// Current entry followed by unexpected token.
|
||||
(@object $object:ident [$($key:tt)+] ($value:expr) $unexpected:tt $($rest:tt)*) => {
|
||||
$crate::json_unexpected!($unexpected);
|
||||
};
|
||||
|
||||
// Insert the last entry without trailing comma.
|
||||
(@object $object:ident [$($key:tt)+] ($value:expr)) => {
|
||||
let k = JsValue::from(wasm_bindgen::intern($($key)+));
|
||||
js_sys::Reflect::set(&$object, &k, &$value).unwrap();
|
||||
};
|
||||
|
||||
// Next value is `null`.
|
||||
(@object $object:ident ($($key:tt)+) (: null $($rest:tt)*) $copy:tt) => {
|
||||
$crate::json_internal!(@object $object [$($key)+] ($crate::json_internal!(null)) $($rest)*);
|
||||
};
|
||||
|
||||
// Next value is `true`.
|
||||
(@object $object:ident ($($key:tt)+) (: true $($rest:tt)*) $copy:tt) => {
|
||||
$crate::json_internal!(@object $object [$($key)+] ($crate::json_internal!(true)) $($rest)*);
|
||||
};
|
||||
|
||||
// Next value is `false`.
|
||||
(@object $object:ident ($($key:tt)+) (: false $($rest:tt)*) $copy:tt) => {
|
||||
$crate::json_internal!(@object $object [$($key)+] ($crate::json_internal!(false)) $($rest)*);
|
||||
};
|
||||
|
||||
// Next value is an array.
|
||||
(@object $object:ident ($($key:tt)+) (: [$($array:tt)*] $($rest:tt)*) $copy:tt) => {
|
||||
$crate::json_internal!(@object $object [$($key)+] ($crate::json_internal!([$($array)*])) $($rest)*);
|
||||
};
|
||||
|
||||
// Next value is a map.
|
||||
(@object $object:ident ($($key:tt)+) (: {$($map:tt)*} $($rest:tt)*) $copy:tt) => {
|
||||
$crate::json_internal!(@object $object [$($key)+] (JsValue::from($crate::json_internal!({$($map)*}))) $($rest)*);
|
||||
};
|
||||
|
||||
// Next value is an expression followed by comma.
|
||||
(@object $object:ident ($($key:tt)+) (: $value:expr , $($rest:tt)*) $copy:tt) => {
|
||||
$crate::json_internal!(@object $object [$($key)+] ($crate::json_internal!($value)) , $($rest)*);
|
||||
};
|
||||
|
||||
// Last value is an expression with no trailing comma.
|
||||
(@object $object:ident ($($key:tt)+) (: $value:expr) $copy:tt) => {
|
||||
$crate::json_internal!(@object $object [$($key)+] ($crate::json_internal!($value)));
|
||||
};
|
||||
|
||||
// Missing value for last entry. Trigger a reasonable error message.
|
||||
(@object $object:ident ($($key:tt)+) (:) $copy:tt) => {
|
||||
// "unexpected end of macro invocation"
|
||||
$crate::json_internal!();
|
||||
};
|
||||
|
||||
// Missing colon and value for last entry. Trigger a reasonable error
|
||||
// message.
|
||||
(@object $object:ident ($($key:tt)+) () $copy:tt) => {
|
||||
// "unexpected end of macro invocation"
|
||||
$crate::json_internal!();
|
||||
};
|
||||
|
||||
// Misplaced colon. Trigger a reasonable error message.
|
||||
(@object $object:ident () (: $($rest:tt)*) ($colon:tt $($copy:tt)*)) => {
|
||||
// Takes no arguments so "no rules expected the token `:`".
|
||||
$crate::json_unexpected!($colon);
|
||||
};
|
||||
|
||||
// Found a comma inside a key. Trigger a reasonable error message.
|
||||
(@object $object:ident ($($key:tt)*) (, $($rest:tt)*) ($comma:tt $($copy:tt)*)) => {
|
||||
// Takes no arguments so "no rules expected the token `,`".
|
||||
$crate::json_unexpected!($comma);
|
||||
};
|
||||
|
||||
// Key is fully parenthesized. This avoids clippy double_parens false
|
||||
// positives because the parenthesization may be necessary here.
|
||||
(@object $object:ident () (($key:expr) : $($rest:tt)*) $copy:tt) => {
|
||||
$crate::json_internal!(@object $object ($key) (: $($rest)*) (: $($rest)*));
|
||||
};
|
||||
|
||||
// Refuse to absorb colon token into key expression.
|
||||
(@object $object:ident ($($key:tt)*) (: $($unexpected:tt)+) $copy:tt) => {
|
||||
$crate::json_expect_expr_comma!($($unexpected)+);
|
||||
};
|
||||
|
||||
// Munch a token into the current key.
|
||||
(@object $object:ident ($($key:tt)*) ($tt:tt $($rest:tt)*) $copy:tt) => {
|
||||
$crate::json_internal!(@object $object ($($key)* $tt) ($($rest)*) ($($rest)*));
|
||||
};
|
||||
|
||||
// The main implementation.
|
||||
//
|
||||
// Must be invoked as: json_internal!($($json)+)
|
||||
|
||||
(null) => {
|
||||
$crate::Value::Null
|
||||
};
|
||||
|
||||
(true) => {
|
||||
::wasm_bindgen::JsValue::from(true)
|
||||
};
|
||||
|
||||
(false) => {
|
||||
::wasm_bindgen::JsValue::from(false)
|
||||
};
|
||||
|
||||
([]) => {
|
||||
js_sys::Array::new()
|
||||
};
|
||||
|
||||
([ $($tt:tt)+ ]) => {
|
||||
$crate::json_internal!(@array [] $($tt)+).iter().collect::<js_sys::Array>()
|
||||
};
|
||||
|
||||
({}) => {
|
||||
js_sys::Object::new()
|
||||
};
|
||||
|
||||
({ $($tt:tt)+ }) => {{
|
||||
let object = js_sys::Object::new();
|
||||
$crate::json_internal!(@object object () ($($tt)+) ($($tt)+));
|
||||
object
|
||||
}};
|
||||
|
||||
// Any Serialize type: numbers, strings, struct literals, variables etc.
|
||||
// Must be below every other rule.
|
||||
($other:expr) => {
|
||||
::wasm_bindgen::JsValue::from($other)
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
#[doc(hidden)]
|
||||
macro_rules! json_unexpected {
|
||||
() => {};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
#[doc(hidden)]
|
||||
macro_rules! json_expect_expr_comma {
|
||||
($e:expr, $($tt:tt)*) => {};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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 futures::channel::mpsc::{UnboundedSender, unbounded};
|
||||
use futures::{Future, SinkExt, StreamExt};
|
||||
use wasm_bindgen::JsValue;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
/// A useful abstraction for connecting `!Sync + !Send` callbacks (like
|
||||
/// `js_sys::Function`) to `Send + Sync` contexts (like the client loop).
|
||||
#[derive(Clone)]
|
||||
pub struct LocalPollLoop<R: Send + Sync + 'static>(UnboundedSender<R>);
|
||||
|
||||
impl<R: Send + Sync + 'static> LocalPollLoop<R> {
|
||||
/// Create a new loop which accepts a `R: Send + Sync` intermediate state
|
||||
/// argument and calls the `!Send + !Sync` callback.
|
||||
pub fn new<F: Fn(R) -> Result<JsValue, JsValue> + 'static>(send: F) -> Self {
|
||||
let (emit, mut receive) = unbounded::<R>();
|
||||
spawn_local(async move {
|
||||
while let Some(resp) = receive.next().await {
|
||||
let resp = send(resp);
|
||||
if let Err(err) = resp {
|
||||
web_sys::console::error_2(&"Failed to serialize".into(), &err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Self(emit)
|
||||
}
|
||||
|
||||
/// Create a new loop which accepts a `R: Send + Sync` intermediate state
|
||||
/// argument and calls the `!Send + !Sync` callback.
|
||||
pub fn new_async<F: Fn(R) -> FUT + 'static, FUT: Future<Output = Result<JsValue, JsValue>>>(
|
||||
send: F,
|
||||
) -> Self {
|
||||
let (emit, mut receive) = unbounded::<R>();
|
||||
spawn_local(async move {
|
||||
while let Some(resp) = receive.next().await {
|
||||
let resp = send(resp).await;
|
||||
if let Err(err) = resp {
|
||||
web_sys::console::error_2(&"Failed to serialize".into(), &err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Self(emit)
|
||||
}
|
||||
|
||||
/// Send a new `R` to the poll loop.
|
||||
pub fn poll(&self, msg: R) -> impl Future<Output = ()> + Send + Sync + 'static + use<R> {
|
||||
let mut emit = self.0.clone();
|
||||
async move { emit.send(msg).await.unwrap() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
#![doc(hidden)]
|
||||
|
||||
mod browser;
|
||||
mod console_logger;
|
||||
pub mod errors;
|
||||
mod futures;
|
||||
mod json;
|
||||
mod local_poll_loop;
|
||||
mod serde;
|
||||
#[cfg(feature = "trace-allocator")]
|
||||
mod trace_allocator;
|
||||
|
||||
pub use browser::*;
|
||||
pub use console_logger::*;
|
||||
pub use errors::*;
|
||||
pub use futures::*;
|
||||
pub use local_poll_loop::*;
|
||||
pub use serde::*;
|
||||
#[cfg(feature = "trace-allocator")]
|
||||
pub(crate) use trace_allocator::get_used;
|
||||
@@ -0,0 +1,46 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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 wasm_bindgen::JsValue;
|
||||
|
||||
pub trait JsValueSerdeExt {
|
||||
fn from_serde_ext<T>(t: &T) -> Result<JsValue, serde_wasm_bindgen::Error>
|
||||
where
|
||||
T: serde::ser::Serialize + ?Sized;
|
||||
|
||||
fn into_serde_ext<T>(self) -> Result<T, serde_wasm_bindgen::Error>
|
||||
where
|
||||
T: for<'a> serde::de::Deserialize<'a>;
|
||||
}
|
||||
|
||||
impl<U> JsValueSerdeExt for U
|
||||
where
|
||||
U: Into<JsValue>,
|
||||
{
|
||||
fn from_serde_ext<T>(t: &T) -> Result<JsValue, serde_wasm_bindgen::Error>
|
||||
where
|
||||
T: serde::ser::Serialize + ?Sized,
|
||||
{
|
||||
t.serialize(
|
||||
&serde_wasm_bindgen::Serializer::new()
|
||||
.serialize_maps_as_objects(true)
|
||||
.serialize_missing_as_null(true),
|
||||
)
|
||||
}
|
||||
|
||||
fn into_serde_ext<T>(self) -> Result<T, serde_wasm_bindgen::Error>
|
||||
where
|
||||
T: for<'a> serde::de::Deserialize<'a>,
|
||||
{
|
||||
serde_wasm_bindgen::from_value(self.into())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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 wrapper around the default allocator which counts allocations, useful for
|
||||
//! instrumenting the `Client` as an alternative to switching to `talc`.
|
||||
//!
|
||||
//! Lifted from https://stackoverflow.com/questions/78246635/how-do-i-log-wasm-heap-memory-usage-from-rust
|
||||
//! or as its popular to refer to these days, "AI".
|
||||
|
||||
use std::alloc::{GlobalAlloc, Layout, System};
|
||||
use std::sync::atomic::{AtomicIsize, Ordering};
|
||||
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::wasm_bindgen;
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
type Memory;
|
||||
#[wasm_bindgen(method, getter)]
|
||||
fn buffer(this: &Memory) -> MaybeSharedArrayBuffer;
|
||||
|
||||
type MaybeSharedArrayBuffer;
|
||||
#[wasm_bindgen(method, getter = byteLength)]
|
||||
fn byte_length(this: &MaybeSharedArrayBuffer) -> f64;
|
||||
}
|
||||
|
||||
struct CountingAllocator<A> {
|
||||
inner: A,
|
||||
allocated_now: AtomicIsize,
|
||||
}
|
||||
|
||||
impl<A> CountingAllocator<A> {
|
||||
const fn new(inner: A) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
allocated_now: AtomicIsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn allocated_now(&self) -> usize {
|
||||
self.allocated_now
|
||||
.load(Ordering::Relaxed)
|
||||
.try_into()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
unsafe impl<A: GlobalAlloc> GlobalAlloc for CountingAllocator<A> {
|
||||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||
self.allocated_now
|
||||
.fetch_add(layout.size() as isize, Ordering::Relaxed);
|
||||
unsafe { self.inner.alloc(layout) }
|
||||
}
|
||||
|
||||
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
||||
self.allocated_now
|
||||
.fetch_sub(layout.size() as isize, Ordering::Relaxed);
|
||||
unsafe { self.inner.dealloc(ptr, layout) };
|
||||
}
|
||||
|
||||
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
|
||||
self.allocated_now
|
||||
.fetch_add(layout.size() as isize, Ordering::Relaxed);
|
||||
unsafe { self.inner.alloc_zeroed(layout) }
|
||||
}
|
||||
|
||||
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
|
||||
self.allocated_now.fetch_add(
|
||||
new_size as isize - layout.size() as isize,
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
unsafe { self.inner.realloc(ptr, layout, new_size) }
|
||||
}
|
||||
}
|
||||
|
||||
#[global_allocator]
|
||||
static ALLOCATOR: CountingAllocator<System> = CountingAllocator::new(System);
|
||||
|
||||
pub(crate) fn get_used() -> (usize, usize) {
|
||||
let client_heap = wasm_bindgen::memory()
|
||||
.unchecked_into::<Memory>()
|
||||
.buffer()
|
||||
.byte_length() as u64;
|
||||
|
||||
(ALLOCATOR.allocated_now(), client_heap as usize)
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "talc-allocator", feature = "trace-allocator"))]
|
||||
compile_error!("Cannot enable both `talc-allocator` and `trace-allocator` features");
|
||||
@@ -0,0 +1,402 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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 js_sys::{Array, ArrayBuffer, Function, Object};
|
||||
use perspective_client::{
|
||||
ColumnWindow, OnUpdateData, OnUpdateOptions, ViewWindow, assert_view_api,
|
||||
};
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
#[cfg(doc)]
|
||||
use crate::table::Table;
|
||||
use crate::utils::{ApiFuture, ApiResult, JsValueSerdeExt, LocalPollLoop};
|
||||
|
||||
#[wasm_bindgen]
|
||||
unsafe extern "C" {
|
||||
#[wasm_bindgen(typescript_type = "ViewWindow")]
|
||||
#[derive(Clone)]
|
||||
pub type JsViewWindow;
|
||||
|
||||
#[wasm_bindgen(typescript_type = "ColumnWindow")]
|
||||
#[derive(Clone)]
|
||||
pub type JsColumnWindow;
|
||||
|
||||
#[wasm_bindgen(method, setter, js_name = "formatted")]
|
||||
pub fn set_formatted(this: &JsViewWindow, x: bool);
|
||||
|
||||
#[wasm_bindgen(typescript_type = "OnUpdateOptions")]
|
||||
pub type JsOnUpdateOptions;
|
||||
|
||||
}
|
||||
|
||||
impl From<ViewWindow> for JsViewWindow {
|
||||
fn from(value: ViewWindow) -> Self {
|
||||
JsViewWindow::from_serde_ext(&value)
|
||||
.unwrap()
|
||||
.unchecked_into()
|
||||
}
|
||||
}
|
||||
|
||||
fn scalar_to_jsvalue(scalar: &perspective_client::config::Scalar) -> JsValue {
|
||||
match scalar {
|
||||
perspective_client::config::Scalar::Float(x) => JsValue::from_f64(*x),
|
||||
perspective_client::config::Scalar::String(x) => JsValue::from_str(x),
|
||||
perspective_client::config::Scalar::Bool(x) => JsValue::from_bool(*x),
|
||||
perspective_client::config::Scalar::Null => JsValue::NULL,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[wasm_bindgen]
|
||||
#[derive(Clone)]
|
||||
pub struct View(pub(crate) perspective_client::View);
|
||||
|
||||
assert_view_api!(View);
|
||||
|
||||
impl From<perspective_client::View> for View {
|
||||
fn from(value: perspective_client::View) -> Self {
|
||||
View(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl View {
|
||||
#[doc(hidden)]
|
||||
pub fn __get_model(&self) -> View {
|
||||
self.clone()
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
#[doc(hidden)]
|
||||
pub fn __unsafe_get_name(&self) -> String {
|
||||
self.0.name.clone()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[wasm_bindgen]
|
||||
pub async fn column_paths(&self, window: Option<JsColumnWindow>) -> ApiResult<JsValue> {
|
||||
let window = window.into_serde_ext::<Option<ColumnWindow>>()?;
|
||||
let columns = self.0.column_paths(window.unwrap_or_default()).await?;
|
||||
Ok(JsValue::from_serde_ext(&columns)?)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[wasm_bindgen]
|
||||
pub async fn delete(self) -> ApiResult<()> {
|
||||
self.0.delete().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[wasm_bindgen]
|
||||
pub async fn dimensions(&self) -> ApiResult<JsValue> {
|
||||
let dimensions = self.0.dimensions().await?;
|
||||
Ok(JsValue::from_serde_ext(&dimensions)?)
|
||||
}
|
||||
|
||||
/// The expression schema of this [`View`], which contains only the
|
||||
/// expressions created on this [`View`]. See [`View::schema`] for
|
||||
/// details.
|
||||
#[wasm_bindgen]
|
||||
pub async fn expression_schema(&self) -> ApiResult<JsValue> {
|
||||
let schema = self.0.expression_schema().await?;
|
||||
Ok(JsValue::from_serde_ext(&schema)?)
|
||||
}
|
||||
|
||||
/// A copy of the config object passed to the [`Table::view`] method which
|
||||
/// created this [`View`].
|
||||
#[wasm_bindgen]
|
||||
pub async fn get_config(&self) -> ApiResult<JsValue> {
|
||||
let config = self.0.get_config().await?;
|
||||
Ok(JsValue::from_serde_ext(&config)?)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[wasm_bindgen]
|
||||
pub async fn get_min_max(&self, name: String) -> ApiResult<Array> {
|
||||
let result = self.0.get_min_max(name).await?;
|
||||
let arr = Array::new();
|
||||
arr.push(&scalar_to_jsvalue(&result.0));
|
||||
arr.push(&scalar_to_jsvalue(&result.1));
|
||||
Ok(arr)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[wasm_bindgen]
|
||||
pub async fn num_rows(&self) -> ApiResult<i32> {
|
||||
let size = self.0.num_rows().await?;
|
||||
Ok(size as i32)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[wasm_bindgen]
|
||||
pub async fn schema(&self) -> ApiResult<JsValue> {
|
||||
let schema = self.0.schema().await?;
|
||||
Ok(JsValue::from_serde_ext(&schema)?)
|
||||
}
|
||||
|
||||
/// Serializes a [`View`] to the Apache Arrow data format.
|
||||
#[wasm_bindgen]
|
||||
pub async fn to_arrow(&self, window: Option<JsViewWindow>) -> ApiResult<ArrayBuffer> {
|
||||
let window = window.into_serde_ext::<Option<ViewWindow>>()?;
|
||||
let arrow = self.0.to_arrow(window.unwrap_or_default()).await?;
|
||||
Ok(js_sys::Uint8Array::from(&arrow[..])
|
||||
.buffer()
|
||||
.unchecked_into())
|
||||
}
|
||||
|
||||
/// Serializes this [`View`] to a string of JSON data. Useful if you want to
|
||||
/// save additional round trip serialize/deserialize cycles.
|
||||
#[wasm_bindgen]
|
||||
pub async fn to_columns_string(&self, window: Option<JsViewWindow>) -> ApiResult<String> {
|
||||
let window = window.into_serde_ext::<Option<ViewWindow>>()?;
|
||||
let json = self.0.to_columns_string(window.unwrap_or_default()).await?;
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
/// Serializes this [`View`] to JavaScript objects in a column-oriented
|
||||
/// format.
|
||||
#[wasm_bindgen]
|
||||
pub async fn to_columns(&self, window: Option<JsViewWindow>) -> ApiResult<Object> {
|
||||
let json = self.to_columns_string(window).await?;
|
||||
Ok(js_sys::JSON::parse(&json)?.unchecked_into())
|
||||
}
|
||||
|
||||
/// Render this `View` as a JSON string.
|
||||
#[wasm_bindgen]
|
||||
pub async fn to_json_string(&self, window: Option<JsViewWindow>) -> ApiResult<String> {
|
||||
let window = window.into_serde_ext::<Option<ViewWindow>>()?;
|
||||
let json = self.0.to_json_string(window.unwrap_or_default()).await?;
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
/// Serializes this [`View`] to JavaScript objects in a row-oriented
|
||||
/// format.
|
||||
#[wasm_bindgen]
|
||||
pub async fn to_json(&self, window: Option<JsViewWindow>) -> ApiResult<Array> {
|
||||
let json = self.to_json_string(window).await?;
|
||||
Ok(js_sys::JSON::parse(&json)?.unchecked_into())
|
||||
}
|
||||
|
||||
/// Renders this [`View`] as an [NDJSON](https://github.com/ndjson/ndjson-spec)
|
||||
/// formatted [`String`].
|
||||
#[wasm_bindgen]
|
||||
pub async fn to_ndjson(&self, window: Option<JsViewWindow>) -> ApiResult<String> {
|
||||
let window = window.into_serde_ext::<Option<ViewWindow>>()?;
|
||||
let ndjson = self.0.to_ndjson(window.unwrap_or_default()).await?;
|
||||
Ok(ndjson)
|
||||
}
|
||||
|
||||
/// Serializes this [`View`] to CSV data in a standard format.
|
||||
#[wasm_bindgen]
|
||||
pub async fn to_csv(&self, window: Option<JsViewWindow>) -> ApiResult<String> {
|
||||
let window = window.into_serde_ext::<Option<ViewWindow>>()?;
|
||||
Ok(self.0.to_csv(window.unwrap_or_default()).await?)
|
||||
}
|
||||
|
||||
/// Fetches columns from the [`View`] in Arrow format, decodes them, and
|
||||
/// passes typed array views to `callback`. All arrays are only valid for
|
||||
/// the duration of the callback — if `callback` returns a `Promise`, it
|
||||
/// is awaited before the backing Arrow buffer is released, so async
|
||||
/// callbacks may use the views for the full duration of the awaited
|
||||
/// work (e.g. across an `await requestAnimationFrame`-backed promise).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// - `window` - Optional [`TypedArrayWindow`] controlling row/column
|
||||
/// windowing and output options (e.g., `float32` mode).
|
||||
/// - `callback` - A JS function called with `(names: string[], values:
|
||||
/// TypedArray[], validities: (Uint8Array|null)[], dictionaries:
|
||||
/// (string[]|null)[]) => void | Promise<void>`.
|
||||
#[wasm_bindgen]
|
||||
pub async fn with_typed_arrays(
|
||||
&self,
|
||||
window: Option<crate::typed_array::JsTypedArrayWindow>,
|
||||
callback: Function,
|
||||
) -> ApiResult<()> {
|
||||
let opts: crate::typed_array::TypedArrayWindow = window
|
||||
.into_serde_ext::<Option<crate::typed_array::TypedArrayWindow>>()?
|
||||
.unwrap_or_default();
|
||||
|
||||
let float32 = opts.float32;
|
||||
let mut view_window: ViewWindow = opts.into();
|
||||
view_window.emit_legacy_row_path_names = Some(false);
|
||||
let arrow = self.0.to_arrow(view_window).await?;
|
||||
crate::typed_array::decode_and_call(&arrow, float32, &callback).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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`].
|
||||
///
|
||||
/// # JavaScript Examples
|
||||
///
|
||||
/// ```javascript
|
||||
/// // Attach an `on_update` callback
|
||||
/// view.on_update((updated) => console.log(updated.port_id));
|
||||
/// ```
|
||||
///
|
||||
/// ```javascript
|
||||
/// // `on_update` with row deltas
|
||||
/// view.on_update((updated) => console.log(updated.delta), { mode: "row" });
|
||||
/// ```
|
||||
#[wasm_bindgen]
|
||||
pub fn on_update(
|
||||
&self,
|
||||
on_update_js: Function,
|
||||
options: Option<JsOnUpdateOptions>,
|
||||
) -> ApiFuture<u32> {
|
||||
let poll_loop = LocalPollLoop::new(move |args: OnUpdateData| {
|
||||
let js_obj = JsValue::from_serde_ext(&*args)?;
|
||||
on_update_js.call1(&JsValue::UNDEFINED, &js_obj)
|
||||
});
|
||||
|
||||
let on_update = Box::new(move |msg| poll_loop.poll(msg));
|
||||
let view = self.0.clone();
|
||||
ApiFuture::new(async move {
|
||||
let on_update_opts = options
|
||||
.into_serde_ext::<Option<OnUpdateOptions>>()?
|
||||
.unwrap_or_default();
|
||||
|
||||
let id = view.on_update(on_update, on_update_opts).await?;
|
||||
Ok(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`].
|
||||
#[wasm_bindgen]
|
||||
pub async fn remove_update(&self, callback_id: u32) -> ApiResult<()> {
|
||||
Ok(self.0.remove_update(callback_id).await?)
|
||||
}
|
||||
|
||||
/// Register a callback with this [`View`]. Whenever the [`View`] is
|
||||
/// deleted, this callback will be invoked.
|
||||
#[wasm_bindgen]
|
||||
pub fn on_delete(&self, on_delete: Function) -> ApiFuture<u32> {
|
||||
let view = self.clone();
|
||||
ApiFuture::new(async move {
|
||||
let emit = LocalPollLoop::new(move |()| on_delete.call0(&JsValue::UNDEFINED));
|
||||
let on_delete = Box::new(move || spawn_local(emit.poll(())));
|
||||
Ok(view.0.on_delete(on_delete).await?)
|
||||
})
|
||||
}
|
||||
|
||||
/// The number of aggregated columns in this [`View`]. This is affected by
|
||||
/// the "split_by" configuration parameter supplied to this view's
|
||||
/// contructor.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The number of aggregated columns.
|
||||
#[wasm_bindgen]
|
||||
pub async fn num_columns(&self) -> ApiResult<u32> {
|
||||
// TODO: This is broken because of how split by creates a
|
||||
// cartesian product of columns * unique values.
|
||||
Ok(self.0.dimensions().await?.num_view_columns)
|
||||
}
|
||||
|
||||
/// Unregister a previously registered [`View::on_delete`] callback.
|
||||
#[wasm_bindgen]
|
||||
pub fn remove_delete(&self, callback_id: u32) -> ApiFuture<()> {
|
||||
let client = self.0.clone();
|
||||
ApiFuture::new(async move {
|
||||
client.remove_delete(callback_id).await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Collapses the `group_by` row at `row_index`.
|
||||
#[wasm_bindgen]
|
||||
pub async fn collapse(&self, row_index: u32) -> ApiResult<u32> {
|
||||
Ok(self.0.collapse(row_index).await?)
|
||||
}
|
||||
|
||||
/// Expand the `group_by` row at `row_index`.
|
||||
#[wasm_bindgen]
|
||||
pub async fn expand(&self, row_index: u32) -> ApiResult<u32> {
|
||||
Ok(self.0.expand(row_index).await?)
|
||||
}
|
||||
|
||||
/// Set expansion `depth` of the `group_by` tree.
|
||||
#[wasm_bindgen]
|
||||
pub async fn set_depth(&self, depth: u32) -> ApiResult<()> {
|
||||
Ok(self.0.set_depth(depth).await?)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,817 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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::cell::UnsafeCell;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::rc::Rc;
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use indexmap::IndexMap;
|
||||
use js_sys::{Array, Date, Object, Reflect, Uint8Array};
|
||||
use perspective_client::proto::{ColumnType, HostedTable};
|
||||
use perspective_client::virtual_server;
|
||||
use perspective_client::virtual_server::{Features, ResultExt, VirtualServerHandler};
|
||||
use serde::Serialize;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use crate::JsViewConfig;
|
||||
use crate::utils::{ApiError, ApiFuture, *};
|
||||
|
||||
type HandlerFuture<T> = Pin<Box<dyn Future<Output = T>>>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct JsError(JsValue);
|
||||
|
||||
impl std::fmt::Display for JsError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:?}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for JsError {}
|
||||
|
||||
impl From<JsValue> for JsError {
|
||||
fn from(value: JsValue) -> Self {
|
||||
JsError(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<JsError> for JsValue {
|
||||
fn from(error: JsError) -> Self {
|
||||
error.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_wasm_bindgen::Error> for JsError {
|
||||
fn from(error: serde_wasm_bindgen::Error) -> Self {
|
||||
JsError(error.into())
|
||||
}
|
||||
}
|
||||
|
||||
fn jsvalue_to_scalar(val: &JsValue) -> perspective_client::config::Scalar {
|
||||
if val.is_null() || val.is_undefined() {
|
||||
perspective_client::config::Scalar::Null
|
||||
} else if let Some(b) = val.as_bool() {
|
||||
perspective_client::config::Scalar::Bool(b)
|
||||
} else if let Some(n) = val.as_f64() {
|
||||
perspective_client::config::Scalar::Float(n)
|
||||
} else if let Some(s) = val.as_string() {
|
||||
perspective_client::config::Scalar::String(s)
|
||||
} else {
|
||||
perspective_client::config::Scalar::Null
|
||||
}
|
||||
}
|
||||
|
||||
pub struct JsServerHandler(Object);
|
||||
|
||||
impl JsServerHandler {
|
||||
fn call_method_js(&self, method: &str, args: &Array) -> Result<JsValue, JsError> {
|
||||
let func = Reflect::get(&self.0, &JsValue::from_str(method))?;
|
||||
let func = func
|
||||
.dyn_ref::<js_sys::Function>()
|
||||
.ok_or_else(|| JsError(JsValue::from_str(&format!("{} is not a function", method))))?;
|
||||
Ok(func.apply(&self.0, args)?)
|
||||
}
|
||||
|
||||
async fn call_method_js_async(&self, method: &str, args: &Array) -> Result<JsValue, JsError> {
|
||||
let result = self.call_method_js(method, args)?;
|
||||
|
||||
// Check if result is a Promise
|
||||
if result.is_instance_of::<js_sys::Promise>() {
|
||||
let promise = js_sys::Promise::from(result);
|
||||
JsFuture::from(promise).await.map_err(JsError)
|
||||
} else {
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl VirtualServerHandler for JsServerHandler {
|
||||
type Error = JsError;
|
||||
|
||||
fn get_features(&self) -> HandlerFuture<Result<Features<'_>, Self::Error>> {
|
||||
let has_method = Reflect::get(&self.0, &JsValue::from_str("getFeatures"))
|
||||
.map(|val| !val.is_undefined())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !has_method {
|
||||
return Box::pin(async { Ok(Features::default()) });
|
||||
}
|
||||
|
||||
let handler = self.0.clone();
|
||||
Box::pin(async move {
|
||||
let this = JsServerHandler(handler);
|
||||
let args = Array::new();
|
||||
let result = this.call_method_js_async("getFeatures", &args).await?;
|
||||
Ok(serde_wasm_bindgen::from_value(result)?)
|
||||
})
|
||||
}
|
||||
|
||||
fn get_hosted_tables(&self) -> HandlerFuture<Result<Vec<HostedTable>, Self::Error>> {
|
||||
let handler = self.0.clone();
|
||||
Box::pin(async move {
|
||||
let this = JsServerHandler(handler);
|
||||
let args = Array::new();
|
||||
let result = this.call_method_js_async("getHostedTables", &args).await?;
|
||||
let array = result.dyn_ref::<Array>().ok_or_else(|| {
|
||||
JsError(JsValue::from_str("getHostedTables must return an array"))
|
||||
})?;
|
||||
|
||||
let mut tables = Vec::new();
|
||||
for i in 0..array.length() {
|
||||
let item = array.get(i);
|
||||
if let Some(s) = item.as_string() {
|
||||
tables.push(HostedTable {
|
||||
entity_id: s,
|
||||
index: None,
|
||||
limit: None,
|
||||
});
|
||||
} else if item.is_object() {
|
||||
let name = Reflect::get(&item, &JsValue::from_str("name"))?
|
||||
.as_string()
|
||||
.ok_or_else(|| JsError(JsValue::from_str("name must be a string")))?;
|
||||
let index = Reflect::get(&item, &JsValue::from_str("index"))
|
||||
.ok()
|
||||
.and_then(|v| v.as_string());
|
||||
let limit = Reflect::get(&item, &JsValue::from_str("limit"))
|
||||
.ok()
|
||||
.and_then(|v| v.as_f64().map(|x| x as u32));
|
||||
tables.push(HostedTable {
|
||||
entity_id: name,
|
||||
index,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(tables)
|
||||
})
|
||||
}
|
||||
|
||||
fn table_schema(
|
||||
&self,
|
||||
table_id: &str,
|
||||
) -> HandlerFuture<Result<IndexMap<String, ColumnType>, Self::Error>> {
|
||||
let handler = self.0.clone();
|
||||
let table_id = table_id.to_string();
|
||||
Box::pin(async move {
|
||||
let this = JsServerHandler(handler);
|
||||
let args = Array::new();
|
||||
args.push(&JsValue::from_str(&table_id));
|
||||
let result = this.call_method_js_async("tableSchema", &args).await?;
|
||||
let obj = result
|
||||
.dyn_ref::<Object>()
|
||||
.ok_or_else(|| JsError(JsValue::from_str("tableSchema must return an object")))?;
|
||||
|
||||
let mut schema = IndexMap::new();
|
||||
let entries = Object::entries(obj);
|
||||
for i in 0..entries.length() {
|
||||
let entry = entries.get(i);
|
||||
let entry_array = entry.dyn_ref::<Array>().unwrap();
|
||||
let key = entry_array.get(0).as_string().unwrap();
|
||||
let value = entry_array.get(1).as_string().unwrap();
|
||||
schema.insert(key, ColumnType::from_str(&value).unwrap());
|
||||
}
|
||||
Ok(schema)
|
||||
})
|
||||
}
|
||||
|
||||
fn table_size(&self, table_id: &str) -> HandlerFuture<Result<u32, Self::Error>> {
|
||||
let handler = self.0.clone();
|
||||
let table_id = table_id.to_string();
|
||||
Box::pin(async move {
|
||||
let this = JsServerHandler(handler);
|
||||
let args = Array::new();
|
||||
args.push(&JsValue::from_str(&table_id));
|
||||
let result = this.call_method_js_async("tableSize", &args).await?;
|
||||
result
|
||||
.as_f64()
|
||||
.map(|x| x as u32)
|
||||
.ok_or_else(|| JsError(JsValue::from_str("tableSize must return a number")))
|
||||
})
|
||||
}
|
||||
|
||||
fn table_column_size(&self, view_id: &str) -> HandlerFuture<Result<u32, Self::Error>> {
|
||||
let has_method = Reflect::get(&self.0, &JsValue::from_str("tableColumnsSize"))
|
||||
.map(|val| !val.is_undefined())
|
||||
.unwrap_or(false);
|
||||
|
||||
let handler = self.0.clone();
|
||||
let view_id = view_id.to_string();
|
||||
Box::pin(async move {
|
||||
let this = JsServerHandler(handler);
|
||||
let args = Array::new();
|
||||
args.push(&JsValue::from_str(&view_id));
|
||||
if has_method {
|
||||
let result = this.call_method_js_async("tableColumnsSize", &args).await?;
|
||||
result.as_f64().map(|x| x as u32).ok_or_else(|| {
|
||||
JsError(JsValue::from_str(
|
||||
"tableColumnsSize must
|
||||
return a number",
|
||||
))
|
||||
})
|
||||
} else {
|
||||
Ok(this.table_schema(view_id.as_str()).await?.len() as u32)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn table_validate_expression(
|
||||
&self,
|
||||
table_id: &str,
|
||||
expression: &str,
|
||||
) -> HandlerFuture<Result<ColumnType, Self::Error>> {
|
||||
// TODO Cache these inspection calls
|
||||
let has_method = Reflect::get(&self.0, &JsValue::from_str("tableValidateExpression"))
|
||||
.map(|val| !val.is_undefined())
|
||||
.unwrap_or(false);
|
||||
|
||||
let handler = self.0.clone();
|
||||
let table_id = table_id.to_string();
|
||||
let expression = expression.to_string();
|
||||
Box::pin(async move {
|
||||
if !has_method {
|
||||
return Err(JsError(JsValue::from_str(
|
||||
"feature `table_validate_expression` not implemented",
|
||||
)));
|
||||
}
|
||||
|
||||
let this = JsServerHandler(handler);
|
||||
let args = Array::new();
|
||||
args.push(&JsValue::from_str(&table_id));
|
||||
args.push(&JsValue::from_str(&expression));
|
||||
let result = this
|
||||
.call_method_js_async("tableValidateExpression", &args)
|
||||
.await?;
|
||||
|
||||
let type_str = result
|
||||
.as_string()
|
||||
.ok_or_else(|| JsError(JsValue::from_str("Must return a string")))?;
|
||||
|
||||
Ok(ColumnType::from_str(&type_str).unwrap())
|
||||
})
|
||||
}
|
||||
|
||||
fn table_make_view(
|
||||
&mut self,
|
||||
table_id: &str,
|
||||
view_id: &str,
|
||||
config: &mut perspective_client::config::ViewConfigUpdate,
|
||||
) -> HandlerFuture<Result<String, Self::Error>> {
|
||||
let handler = self.0.clone();
|
||||
let table_id = table_id.to_string();
|
||||
let view_id = view_id.to_string();
|
||||
let config = config.clone();
|
||||
Box::pin(async move {
|
||||
let this = JsServerHandler(handler);
|
||||
let args = Array::new();
|
||||
args.push(&JsValue::from_str(&table_id));
|
||||
args.push(&JsValue::from_str(&view_id));
|
||||
args.push(&JsValue::from_serde_ext(&config)?);
|
||||
let _ = this.call_method_js_async("tableMakeView", &args).await?;
|
||||
Ok(view_id.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn view_schema(
|
||||
&self,
|
||||
view_id: &str,
|
||||
config: &perspective_client::config::ViewConfig,
|
||||
) -> HandlerFuture<Result<IndexMap<String, ColumnType>, Self::Error>> {
|
||||
let has_view_schema = Reflect::get(&self.0, &JsValue::from_str("viewSchema"))
|
||||
.is_ok_and(|v| !v.is_undefined());
|
||||
|
||||
let handler = self.0.clone();
|
||||
let view_id = view_id.to_string();
|
||||
let config_value = JsValue::from_serde_ext(config).ok();
|
||||
|
||||
Box::pin(async move {
|
||||
let this = JsServerHandler(handler);
|
||||
let args = Array::new();
|
||||
args.push(&JsValue::from_str(&view_id));
|
||||
if let Some(cv) = config_value {
|
||||
args.push(&cv);
|
||||
}
|
||||
|
||||
let result = this
|
||||
.call_method_js_async(
|
||||
if has_view_schema {
|
||||
"viewSchema"
|
||||
} else {
|
||||
"tableSchema"
|
||||
},
|
||||
&args,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let obj = result
|
||||
.dyn_ref::<Object>()
|
||||
.ok_or_else(|| JsError(JsValue::from_str("viewSchema must return an object")))?;
|
||||
|
||||
let mut schema = IndexMap::new();
|
||||
let entries = Object::entries(obj);
|
||||
for i in 0..entries.length() {
|
||||
let entry = entries.get(i);
|
||||
let entry_array = entry.dyn_ref::<Array>().unwrap();
|
||||
let key = entry_array.get(0).as_string().unwrap();
|
||||
let value = entry_array.get(1).as_string().unwrap();
|
||||
schema.insert(key, ColumnType::from_str(&value).unwrap());
|
||||
}
|
||||
|
||||
Ok(schema)
|
||||
})
|
||||
}
|
||||
|
||||
fn view_size(&self, view_id: &str) -> HandlerFuture<Result<u32, Self::Error>> {
|
||||
let handler = self.0.clone();
|
||||
let view_id = view_id.to_string();
|
||||
let has_view_size =
|
||||
Reflect::get(&self.0, &JsValue::from_str("viewSize")).is_ok_and(|v| !v.is_undefined());
|
||||
|
||||
Box::pin(async move {
|
||||
let this = JsServerHandler(handler);
|
||||
let args = Array::new();
|
||||
args.push(&JsValue::from_str(&view_id));
|
||||
let result = this
|
||||
.call_method_js_async(
|
||||
if has_view_size {
|
||||
"viewSize"
|
||||
} else {
|
||||
"tableSize"
|
||||
},
|
||||
&args,
|
||||
)
|
||||
.await?;
|
||||
|
||||
result
|
||||
.as_f64()
|
||||
.map(|x| x as u32)
|
||||
.ok_or_else(|| JsError(JsValue::from_str("viewSize must return a number")))
|
||||
})
|
||||
}
|
||||
|
||||
fn view_column_size(
|
||||
&self,
|
||||
view_id: &str,
|
||||
config: &perspective_client::config::ViewConfig,
|
||||
) -> HandlerFuture<Result<u32, Self::Error>> {
|
||||
let has_method = Reflect::get(&self.0, &JsValue::from_str("viewColumnSize"))
|
||||
.map(|val| !val.is_undefined())
|
||||
.unwrap_or(false);
|
||||
|
||||
let handler = self.0.clone();
|
||||
let view_id = view_id.to_string();
|
||||
let config_value = JsValue::from_serde_ext(config).unwrap();
|
||||
let config = config.clone();
|
||||
Box::pin(async move {
|
||||
let this = JsServerHandler(handler);
|
||||
let args = Array::new();
|
||||
args.push(&JsValue::from_str(&view_id));
|
||||
args.push(&config_value);
|
||||
if has_method {
|
||||
let result = this.call_method_js_async("viewColumnSize", &args).await?;
|
||||
result.as_f64().map(|x| x as u32).ok_or_else(|| {
|
||||
JsError(JsValue::from_str("viewColumnSize must return a number"))
|
||||
})
|
||||
} else {
|
||||
Ok(this.view_schema(view_id.as_str(), &config).await?.len() as u32)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn view_delete(&self, view_id: &str) -> HandlerFuture<Result<(), Self::Error>> {
|
||||
let handler = self.0.clone();
|
||||
let view_id = view_id.to_string();
|
||||
Box::pin(async move {
|
||||
let this = JsServerHandler(handler);
|
||||
let args = Array::new();
|
||||
args.push(&JsValue::from_str(&view_id));
|
||||
this.call_method_js_async("viewDelete", &args).await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn table_make_port(
|
||||
&self,
|
||||
_req: &perspective_client::proto::TableMakePortReq,
|
||||
) -> HandlerFuture<Result<u32, Self::Error>> {
|
||||
let has_method = Reflect::get(&self.0, &JsValue::from_str("tableMakePort"))
|
||||
.map(|val| !val.is_undefined())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !has_method {
|
||||
return Box::pin(async { Ok(0) });
|
||||
}
|
||||
|
||||
let handler = self.0.clone();
|
||||
Box::pin(async move {
|
||||
let this = JsServerHandler(handler);
|
||||
let args = Array::new();
|
||||
let result = this.call_method_js_async("tableMakePort", &args).await?;
|
||||
result
|
||||
.as_f64()
|
||||
.map(|x| x as u32)
|
||||
.ok_or_else(|| JsError(JsValue::from_str("tableMakePort must return a number")))
|
||||
})
|
||||
}
|
||||
|
||||
fn make_table(
|
||||
&mut self,
|
||||
table_id: &str,
|
||||
data: &perspective_client::proto::MakeTableData,
|
||||
) -> HandlerFuture<Result<(), Self::Error>> {
|
||||
let has_method = Reflect::get(&self.0, &JsValue::from_str("makeTable"))
|
||||
.map(|val| !val.is_undefined())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !has_method {
|
||||
return Box::pin(async {
|
||||
Err(JsError(JsValue::from_str("makeTable not implemented")))
|
||||
});
|
||||
}
|
||||
|
||||
let handler = self.0.clone();
|
||||
let table_id = table_id.to_string();
|
||||
use perspective_client::proto::make_table_data::Data;
|
||||
let data_value = match &data.data {
|
||||
Some(Data::FromCsv(csv)) => JsValue::from_str(csv),
|
||||
Some(Data::FromArrow(arrow)) => {
|
||||
let uint8array = js_sys::Uint8Array::from(arrow.as_slice());
|
||||
JsValue::from(uint8array)
|
||||
},
|
||||
Some(Data::FromRows(rows)) => JsValue::from_str(rows),
|
||||
Some(Data::FromCols(cols)) => JsValue::from_str(cols),
|
||||
Some(Data::FromNdjson(ndjson)) => JsValue::from_str(ndjson),
|
||||
_ => JsValue::from_str(""),
|
||||
};
|
||||
|
||||
Box::pin(async move {
|
||||
let this = JsServerHandler(handler);
|
||||
let args = Array::new();
|
||||
args.push(&JsValue::from_str(&table_id));
|
||||
args.push(&data_value);
|
||||
this.call_method_js_async("makeTable", &args).await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn view_get_min_max(
|
||||
&self,
|
||||
view_id: &str,
|
||||
column_name: &str,
|
||||
config: &perspective_client::config::ViewConfig,
|
||||
) -> HandlerFuture<
|
||||
Result<
|
||||
(
|
||||
perspective_client::config::Scalar,
|
||||
perspective_client::config::Scalar,
|
||||
),
|
||||
Self::Error,
|
||||
>,
|
||||
> {
|
||||
let has_method = Reflect::get(&self.0, &JsValue::from_str("viewGetMinMax"))
|
||||
.map(|val| !val.is_undefined())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !has_method {
|
||||
return Box::pin(async {
|
||||
Err(JsError(JsValue::from_str("viewGetMinMax not implemented")))
|
||||
});
|
||||
}
|
||||
|
||||
let handler = self.0.clone();
|
||||
let view_id = view_id.to_string();
|
||||
let column_name = column_name.to_string();
|
||||
let config_js = JsValue::from_serde_ext(config).unwrap();
|
||||
Box::pin(async move {
|
||||
let this = JsServerHandler(handler);
|
||||
let args = Array::new();
|
||||
args.push(&JsValue::from_str(&view_id));
|
||||
args.push(&JsValue::from_str(&column_name));
|
||||
args.push(&config_js);
|
||||
let result = this.call_method_js_async("viewGetMinMax", &args).await?;
|
||||
let obj = result.dyn_ref::<Object>().unwrap();
|
||||
let min_val = Reflect::get(obj, &JsValue::from_str(wasm_bindgen::intern("min")))?;
|
||||
let max_val = Reflect::get(obj, &JsValue::from_str(wasm_bindgen::intern("max")))?;
|
||||
Ok((jsvalue_to_scalar(&min_val), jsvalue_to_scalar(&max_val)))
|
||||
})
|
||||
}
|
||||
|
||||
fn view_get_data(
|
||||
&self,
|
||||
view_id: &str,
|
||||
config: &perspective_client::config::ViewConfig,
|
||||
schema: &IndexMap<String, ColumnType>,
|
||||
viewport: &perspective_client::proto::ViewPort,
|
||||
) -> HandlerFuture<Result<virtual_server::VirtualDataSlice, Self::Error>> {
|
||||
let handler = self.0.clone();
|
||||
let view_id = view_id.to_string();
|
||||
let window: JsViewPort = viewport.clone().into();
|
||||
let config_value = JsValue::from_serde_ext(config).unwrap();
|
||||
let window_value = JsValue::from_serde_ext(&window).unwrap();
|
||||
let schema_value = JsValue::from_serde_ext(&schema).unwrap();
|
||||
|
||||
Box::pin(async move {
|
||||
let this = JsServerHandler(handler);
|
||||
let data = VirtualDataSlice::new(config_value.clone().unchecked_into());
|
||||
|
||||
{
|
||||
let args = Array::new();
|
||||
args.push(&JsValue::from_str(&view_id));
|
||||
args.push(&config_value);
|
||||
args.push(&schema_value);
|
||||
args.push(&window_value);
|
||||
args.push(&JsValue::from(data.clone()));
|
||||
this.call_method_js_async("viewGetData", &args).await?;
|
||||
}
|
||||
|
||||
// Lock the mutex and take ownership of the inner data
|
||||
// We can't unwrap the Arc because the JsValue might still hold a reference
|
||||
let VirtualDataSlice(_obj, arc) = data;
|
||||
let slice = std::mem::take(&mut *arc.lock().unwrap()).unwrap();
|
||||
Ok(slice)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, PartialEq)]
|
||||
pub struct JsViewPort {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub start_row: ::core::option::Option<u32>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub start_col: ::core::option::Option<u32>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub end_row: ::core::option::Option<u32>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub end_col: ::core::option::Option<u32>,
|
||||
}
|
||||
|
||||
impl From<perspective_client::proto::ViewPort> for JsViewPort {
|
||||
fn from(value: perspective_client::proto::ViewPort) -> Self {
|
||||
JsViewPort {
|
||||
start_row: value.start_row,
|
||||
start_col: value.start_col,
|
||||
end_row: value.end_row,
|
||||
end_col: value.end_col,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = "VirtualDataSlice")]
|
||||
#[derive(Clone)]
|
||||
pub struct VirtualDataSlice(Object, Arc<Mutex<Option<virtual_server::VirtualDataSlice>>>);
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl VirtualDataSlice {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(config: JsViewConfig) -> Self {
|
||||
VirtualDataSlice(
|
||||
Object::new(),
|
||||
Arc::new(Mutex::new(Some(virtual_server::VirtualDataSlice::new(
|
||||
config.into_serde_ext().unwrap(),
|
||||
)))),
|
||||
)
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = "fromArrowIpc")]
|
||||
pub fn from_arrow_ipc(&self, ipc: Uint8Array) -> Result<(), JsValue> {
|
||||
self.1
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.from_arrow_ipc(&ipc.to_vec())
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = "setCol")]
|
||||
pub fn set_col(
|
||||
&self,
|
||||
dtype: &str,
|
||||
name: &str,
|
||||
index: u32,
|
||||
val: JsValue,
|
||||
group_by_index: Option<usize>,
|
||||
) -> Result<(), JsValue> {
|
||||
match dtype {
|
||||
"string" => self.set_string_col(name, index, val, group_by_index),
|
||||
"integer" => self.set_integer_col(name, index, val, group_by_index),
|
||||
"float" => self.set_float_col(name, index, val, group_by_index),
|
||||
"date" => self.set_datetime_col(name, index, val, group_by_index),
|
||||
"datetime" => self.set_datetime_col(name, index, val, group_by_index),
|
||||
"boolean" => self.set_boolean_col(name, index, val, group_by_index),
|
||||
_ => Err(JsValue::from_str("Unknown type")),
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = "setStringCol")]
|
||||
pub fn set_string_col(
|
||||
&self,
|
||||
name: &str,
|
||||
index: u32,
|
||||
val: JsValue,
|
||||
group_by_index: Option<usize>,
|
||||
) -> Result<(), JsValue> {
|
||||
if val.is_null() || val.is_undefined() {
|
||||
self.1
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.set_col(name, group_by_index, index as usize, None as Option<String>)
|
||||
.unwrap();
|
||||
} else if let Some(s) = val.as_string() {
|
||||
self.1
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.set_col(name, group_by_index, index as usize, Some(s))
|
||||
.unwrap();
|
||||
} else {
|
||||
tracing::error!("Unhandled string value");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = "setIntegerCol")]
|
||||
pub fn set_integer_col(
|
||||
&self,
|
||||
name: &str,
|
||||
index: u32,
|
||||
val: JsValue,
|
||||
group_by_index: Option<usize>,
|
||||
) -> Result<(), JsValue> {
|
||||
if val.is_null() || val.is_undefined() {
|
||||
self.1
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.set_col(name, group_by_index, index as usize, None as Option<i32>)
|
||||
.unwrap();
|
||||
} else if let Some(n) = val.as_f64() {
|
||||
self.1
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.set_col(name, group_by_index, index as usize, Some(n as i32))
|
||||
.unwrap();
|
||||
} else {
|
||||
tracing::error!("Unhandled integer value");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = "setFloatCol")]
|
||||
pub fn set_float_col(
|
||||
&self,
|
||||
name: &str,
|
||||
index: u32,
|
||||
val: JsValue,
|
||||
group_by_index: Option<usize>,
|
||||
) -> Result<(), JsValue> {
|
||||
if val.is_null() || val.is_undefined() {
|
||||
self.1
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.set_col(name, group_by_index, index as usize, None as Option<f64>)
|
||||
.unwrap();
|
||||
} else if let Some(n) = val.as_f64() {
|
||||
self.1
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.set_col(name, group_by_index, index as usize, Some(n))
|
||||
.unwrap();
|
||||
} else {
|
||||
tracing::error!("Unhandled float value");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = "setBooleanCol")]
|
||||
pub fn set_boolean_col(
|
||||
&self,
|
||||
name: &str,
|
||||
index: u32,
|
||||
val: JsValue,
|
||||
group_by_index: Option<usize>,
|
||||
) -> Result<(), JsValue> {
|
||||
if val.is_null() || val.is_undefined() {
|
||||
self.1
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.set_col(name, group_by_index, index as usize, None as Option<bool>)
|
||||
.unwrap();
|
||||
} else if let Some(b) = val.as_bool() {
|
||||
self.1
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.set_col(name, group_by_index, index as usize, Some(b))
|
||||
.unwrap();
|
||||
} else {
|
||||
tracing::error!("Unhandled boolean value");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = "setDatetimeCol")]
|
||||
pub fn set_datetime_col(
|
||||
&self,
|
||||
name: &str,
|
||||
index: u32,
|
||||
val: JsValue,
|
||||
group_by_index: Option<usize>,
|
||||
) -> Result<(), JsValue> {
|
||||
if val.is_null() || val.is_undefined() {
|
||||
self.1
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.set_col(name, group_by_index, index as usize, None as Option<i64>)
|
||||
.unwrap();
|
||||
} else if let Some(date) = val.dyn_ref::<Date>() {
|
||||
let timestamp = date.get_time() as i64;
|
||||
self.1
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.set_col(name, group_by_index, index as usize, Some(timestamp))
|
||||
.unwrap();
|
||||
} else if let Some(n) = val.as_f64() {
|
||||
self.1
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.set_col(name, group_by_index, index as usize, Some(n as i64))
|
||||
.unwrap();
|
||||
} else {
|
||||
tracing::error!("Unhandled datetime value");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub struct VirtualServer(Rc<UnsafeCell<virtual_server::VirtualServer<JsServerHandler>>>);
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl VirtualServer {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(handler: Object) -> Result<VirtualServer, JsValue> {
|
||||
Ok(VirtualServer(Rc::new(UnsafeCell::new(
|
||||
virtual_server::VirtualServer::new(JsServerHandler(handler)),
|
||||
))))
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = "handleRequest")]
|
||||
pub fn handle_request(&self, bytes: &[u8]) -> ApiFuture<Vec<u8>> {
|
||||
let bytes = bytes.to_vec();
|
||||
let server = self.0.clone();
|
||||
|
||||
ApiFuture::new(async move {
|
||||
// SAFETY:
|
||||
// - WASM is single-threaded
|
||||
// - JS re-entrancy is allowed by design
|
||||
// - VirtualServer must tolerate re-entrant mutation
|
||||
let result = unsafe {
|
||||
(&mut *server.as_ref().get())
|
||||
.handle_request(bytes::Bytes::from(bytes))
|
||||
.await
|
||||
};
|
||||
|
||||
match result.get_internal_error() {
|
||||
Ok(x) => Ok(x.to_vec()),
|
||||
Err(Ok(x)) => Err(ApiError::from(JsValue::from(x))),
|
||||
Err(Err(x)) => Err(ApiError::from(JsValue::from_str(&x))),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import {
|
||||
PerspectiveSession,
|
||||
PerspectiveServer,
|
||||
PerspectivePollThread,
|
||||
} from "./wasm/engine.ts";
|
||||
import { compile_perspective } from "./wasm/emscripten_api.ts";
|
||||
|
||||
let GLOBAL_SERVER: PerspectiveServer;
|
||||
let POLL_THREAD: PerspectivePollThread;
|
||||
|
||||
let SESSION: PerspectiveSession | undefined;
|
||||
|
||||
async function handleMessage(this: MessagePort, msg: MessageEvent) {
|
||||
if (msg.data.cmd === "init") {
|
||||
const id = msg.data.id;
|
||||
if (!GLOBAL_SERVER) {
|
||||
const module = await compile_perspective(msg.data.args[0]);
|
||||
|
||||
GLOBAL_SERVER = new PerspectiveServer(module, {
|
||||
on_poll_request: () => POLL_THREAD.on_poll_request(),
|
||||
});
|
||||
|
||||
POLL_THREAD = new PerspectivePollThread(GLOBAL_SERVER);
|
||||
}
|
||||
|
||||
SESSION = GLOBAL_SERVER.make_session(async (resp) => {
|
||||
const f = resp.slice().buffer;
|
||||
this.postMessage(f, { transfer: [f] });
|
||||
});
|
||||
|
||||
this.postMessage({ id });
|
||||
} else {
|
||||
if (SESSION) {
|
||||
await SESSION?.handle_request(new Uint8Array(msg.data));
|
||||
} else {
|
||||
throw new Error("No session");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bindPortSharedWorker(msg: MessageEvent) {
|
||||
const port = msg.ports[0];
|
||||
port.addEventListener("message", handleMessage.bind(port));
|
||||
port.start();
|
||||
}
|
||||
|
||||
// @ts-expect-error wrong scope
|
||||
self.addEventListener("connect", bindPortSharedWorker);
|
||||
self.addEventListener(
|
||||
"message",
|
||||
handleMessage.bind(self as unknown as MessagePort),
|
||||
);
|
||||
@@ -0,0 +1,242 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
export type * from "../../dist/wasm/perspective-js.d.ts";
|
||||
import type * as psp from "../../dist/wasm/perspective-js.d.ts";
|
||||
export type * from "./virtual_server.ts";
|
||||
import * as psp_virtual from "./virtual_server.ts";
|
||||
import * as wasm_module from "../../dist/wasm/perspective-js.js";
|
||||
import * as api from "./wasm/browser.ts";
|
||||
import { load_wasm_stage_0 } from "./wasm/decompress.ts";
|
||||
|
||||
export {
|
||||
GenericSQLVirtualServerModel,
|
||||
VirtualDataSlice,
|
||||
VirtualServer,
|
||||
} from "../../dist/wasm/perspective-js.js";
|
||||
|
||||
import {
|
||||
GenericSQLVirtualServerModel,
|
||||
VirtualDataSlice,
|
||||
VirtualServer,
|
||||
} from "../../dist/wasm/perspective-js.js";
|
||||
|
||||
let GLOBAL_SERVER_WASM: Promise<ArrayBuffer | WebAssembly.Module>;
|
||||
|
||||
export async function createMessageHandler(
|
||||
handler: psp_virtual.VirtualServerHandler,
|
||||
) {
|
||||
return psp_virtual.createMessageHandler(await get_client(), handler);
|
||||
}
|
||||
|
||||
export function init_server(
|
||||
wasm:
|
||||
| Uint8Array
|
||||
| Promise<ArrayBuffer | Response | WebAssembly.Module>
|
||||
| ArrayBuffer
|
||||
| Response
|
||||
| WebAssembly.Module,
|
||||
disable_stage_0: boolean = false,
|
||||
) {
|
||||
if (wasm instanceof Uint8Array) {
|
||||
GLOBAL_SERVER_WASM = Promise.resolve(wasm.buffer);
|
||||
} else if (wasm instanceof Response) {
|
||||
GLOBAL_SERVER_WASM = Promise.resolve(wasm);
|
||||
} else if (wasm instanceof Promise) {
|
||||
GLOBAL_SERVER_WASM = wasm;
|
||||
} else {
|
||||
GLOBAL_SERVER_WASM = Promise.resolve(wasm);
|
||||
}
|
||||
|
||||
if (!disable_stage_0) {
|
||||
GLOBAL_SERVER_WASM = GLOBAL_SERVER_WASM.then((x) =>
|
||||
load_wasm_stage_0(x).then((x) => x.buffer as ArrayBuffer),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let GLOBAL_CLIENT_WASM: Promise<typeof psp>;
|
||||
let GLOBAL_CLIENT_MODULE: Promise<WebAssembly.Module> | undefined;
|
||||
|
||||
async function compile_module(wasm: any): Promise<WebAssembly.Module> {
|
||||
if (wasm instanceof WebAssembly.Module) {
|
||||
return wasm;
|
||||
}
|
||||
|
||||
if (typeof Response !== "undefined" && wasm instanceof Response) {
|
||||
return WebAssembly.compileStreaming(wasm);
|
||||
}
|
||||
|
||||
return WebAssembly.compile(wasm);
|
||||
}
|
||||
|
||||
async function compilerize(
|
||||
wasm: PerspectiveWasm,
|
||||
disable_stage_0: boolean = false,
|
||||
) {
|
||||
const wasm_buff = disable_stage_0 ? wasm : await load_wasm_stage_0(wasm);
|
||||
// Compile to a `WebAssembly.Module` once so it can be both instantiated
|
||||
// locally and forwarded to other workers via `getCompiledClientWasm()`.
|
||||
// `WebAssembly.Module` is structured-cloneable across workers, so the
|
||||
// recipient can instantiate without re-fetching or re-compiling.
|
||||
const compiled = await compile_module(wasm_buff);
|
||||
GLOBAL_CLIENT_MODULE = Promise.resolve(compiled);
|
||||
await wasm_module.default({ module_or_path: compiled });
|
||||
await wasm_module.init();
|
||||
return wasm_module;
|
||||
}
|
||||
|
||||
export type PerspectiveWasm =
|
||||
| ArrayBuffer
|
||||
| Response
|
||||
| typeof psp
|
||||
| Promise<ArrayBuffer | Response | Object>;
|
||||
|
||||
export function init_client(wasm: PerspectiveWasm, disable_stage_0 = false) {
|
||||
if (wasm instanceof Uint8Array) {
|
||||
GLOBAL_CLIENT_WASM = compilerize(
|
||||
wasm.buffer as ArrayBuffer,
|
||||
disable_stage_0,
|
||||
);
|
||||
} else if (wasm instanceof ArrayBuffer) {
|
||||
GLOBAL_CLIENT_WASM = compilerize(wasm, disable_stage_0);
|
||||
} else if (wasm instanceof Response) {
|
||||
GLOBAL_CLIENT_WASM = compilerize(wasm, disable_stage_0);
|
||||
} else if (wasm instanceof Promise) {
|
||||
GLOBAL_CLIENT_WASM = compilerize(wasm, disable_stage_0);
|
||||
} else if (wasm instanceof Object) {
|
||||
GLOBAL_CLIENT_WASM = Promise.resolve(wasm as typeof psp);
|
||||
}
|
||||
}
|
||||
|
||||
function get_client() {
|
||||
const viewer_class: any = customElements.get("perspective-viewer");
|
||||
if (viewer_class) {
|
||||
GLOBAL_CLIENT_WASM = Promise.resolve(viewer_class.__wasm_module__);
|
||||
if (
|
||||
GLOBAL_CLIENT_MODULE === undefined &&
|
||||
viewer_class.__wasm_client_module__
|
||||
) {
|
||||
GLOBAL_CLIENT_MODULE = Promise.resolve(
|
||||
viewer_class.__wasm_client_module__,
|
||||
);
|
||||
}
|
||||
} else if (GLOBAL_CLIENT_WASM === undefined) {
|
||||
throw new Error("Missing perspective-client.wasm");
|
||||
}
|
||||
|
||||
return GLOBAL_CLIENT_WASM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the compiled `WebAssembly.Module` for the perspective-js client
|
||||
* runtime. The module is structured-cloneable, so it can be sent via
|
||||
* `postMessage` to a Worker which can instantiate its own `Client` without
|
||||
* re-fetching or re-compiling the wasm binary.
|
||||
*
|
||||
* Requires that the client wasm has been initialized — typically by a prior
|
||||
* call to `init_client(...)`, or implicitly by mounting a `<perspective-viewer>`
|
||||
* element. Throws otherwise.
|
||||
*
|
||||
* # Examples
|
||||
*
|
||||
* ```javascript
|
||||
* const mod = await perspective.getCompiledClientWasm();
|
||||
* worker.postMessage({ kind: "init", clientWasm: mod }, [port]);
|
||||
* ```
|
||||
*/
|
||||
export async function getCompiledClientWasm(): Promise<WebAssembly.Module> {
|
||||
if (GLOBAL_CLIENT_MODULE !== undefined) {
|
||||
return GLOBAL_CLIENT_MODULE;
|
||||
}
|
||||
|
||||
const viewer_class: any = customElements.get("perspective-viewer");
|
||||
if (viewer_class?.__wasm_client_module__) {
|
||||
GLOBAL_CLIENT_MODULE = Promise.resolve(
|
||||
viewer_class.__wasm_client_module__,
|
||||
);
|
||||
return GLOBAL_CLIENT_MODULE;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"perspective-js client wasm has not been compiled yet — call " +
|
||||
"`init_client(...)` or `perspective.worker()` before " +
|
||||
"`getCompiledClientWasm()`.",
|
||||
);
|
||||
}
|
||||
|
||||
function get_server() {
|
||||
if (GLOBAL_SERVER_WASM === undefined) {
|
||||
throw new Error("Missing perspective-server.wasm");
|
||||
}
|
||||
|
||||
return GLOBAL_SERVER_WASM.then((x) =>
|
||||
x instanceof WebAssembly.Module ? x : x.slice(0),
|
||||
);
|
||||
}
|
||||
|
||||
let GLOBAL_WORKER: undefined | (() => Promise<Worker>) = undefined;
|
||||
|
||||
// `WorkerPlugin` resolves this import to a stub that exports
|
||||
// `getPerspectiveWorkerURL(): Promise<string>`. The URL is either a
|
||||
// Blob URL (inline mode — production builds) or a real file path
|
||||
// resolved against `import.meta.url` (file mode — debug builds).
|
||||
// Constructing the `Worker` lives here in the consumer rather than
|
||||
// inside the plugin so the same module text can also be loaded
|
||||
// in-process via dynamic `import(url)` when a future caller wants
|
||||
// it; the plugin no longer owns Worker lifecycle.
|
||||
//
|
||||
// `initialize()` constructs `new Worker(blobUrl)` and falls back to
|
||||
// running the worker source on the main thread via `new Function(...)`
|
||||
// when Worker construction is unavailable (e.g. `file://` origins where
|
||||
// module-Worker support is gated). The shim it returns is
|
||||
// MessagePort-shaped so downstream code can treat it like a real
|
||||
// Worker.
|
||||
// @ts-ignore — resolved at build time by `@perspective-dev/esbuild-plugin/worker`
|
||||
import { initialize as initializePerspectiveWorker } from "../../src/ts/perspective-server.worker.js";
|
||||
|
||||
async function get_worker(): Promise<Worker> {
|
||||
if (GLOBAL_WORKER === undefined) {
|
||||
return await initializePerspectiveWorker({
|
||||
type: "module",
|
||||
name: "perspective-server",
|
||||
});
|
||||
}
|
||||
|
||||
return GLOBAL_WORKER();
|
||||
}
|
||||
|
||||
export async function websocket(url: string | URL) {
|
||||
return await api.websocket(get_client(), url);
|
||||
}
|
||||
|
||||
export async function worker(
|
||||
worker?: Promise<SharedWorker | ServiceWorker | Worker | MessagePort>,
|
||||
) {
|
||||
if (typeof worker === "undefined") {
|
||||
worker = get_worker();
|
||||
}
|
||||
|
||||
return await api.worker(get_client(), get_server(), worker);
|
||||
}
|
||||
|
||||
export default {
|
||||
websocket,
|
||||
worker,
|
||||
init_client,
|
||||
init_server,
|
||||
createMessageHandler,
|
||||
getCompiledClientWasm,
|
||||
GenericSQLVirtualServerModel,
|
||||
VirtualDataSlice,
|
||||
VirtualServer,
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import perspective from "./perspective.browser.ts";
|
||||
export * from "./perspective.browser.ts";
|
||||
|
||||
const url = new URL(
|
||||
"../../../server/dist/wasm/perspective-server.wasm",
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
perspective.init_server(fetch(url));
|
||||
export default perspective;
|
||||
@@ -0,0 +1,27 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import perspective, { PerspectiveWasm } from "./perspective.browser.ts";
|
||||
export * from "./perspective.browser.ts";
|
||||
|
||||
// @ts-ignore;
|
||||
import server_wasm from "@perspective-dev/server/dist/wasm/perspective-server.wasm";
|
||||
|
||||
// @ts-ignore;
|
||||
import client_wasm from "../../dist/wasm/perspective-js.wasm";
|
||||
|
||||
await perspective.init_server(server_wasm);
|
||||
await perspective.init_client(client_wasm as any as PerspectiveWasm);
|
||||
|
||||
console.warn("Perspective wasn been initialized in inline mode");
|
||||
|
||||
export default perspective;
|
||||
@@ -0,0 +1,524 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
export type * from "../../dist/wasm/perspective-js.d.ts";
|
||||
export type * from "./virtual_server.ts";
|
||||
|
||||
import WebSocket, { WebSocketServer as HttpWebSocketServer } from "ws";
|
||||
import stoppable from "stoppable";
|
||||
import { promises as fs } from "node:fs";
|
||||
import {
|
||||
openSync,
|
||||
readSync,
|
||||
writeSync,
|
||||
closeSync,
|
||||
mkdirSync,
|
||||
unlinkSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import os from "node:os";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { webcrypto } from "node:crypto";
|
||||
import type * as net from "node:net";
|
||||
import * as url from "node:url";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
import * as perspective_client from "../../dist/wasm/perspective-js.js";
|
||||
import { load_wasm_stage_0 } from "./wasm/decompress.js";
|
||||
import * as engine from "./wasm/engine.ts";
|
||||
import { compile_perspective } from "./wasm/emscripten_api.ts";
|
||||
import * as psp_websocket from "./websocket.ts";
|
||||
import * as api from "./wasm/browser.ts";
|
||||
import * as virtual_server from "./virtual_server.ts";
|
||||
|
||||
export {
|
||||
GenericSQLVirtualServerModel,
|
||||
VirtualDataSlice,
|
||||
VirtualServer,
|
||||
} from "../../dist/wasm/perspective-js.js";
|
||||
|
||||
import {
|
||||
GenericSQLVirtualServerModel,
|
||||
VirtualDataSlice,
|
||||
VirtualServer,
|
||||
} from "../../dist/wasm/perspective-js.js";
|
||||
|
||||
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
|
||||
const { resolve } = createRequire(import.meta.url);
|
||||
|
||||
if (!globalThis.crypto) {
|
||||
globalThis.crypto = webcrypto as Crypto;
|
||||
}
|
||||
|
||||
const uncompressed_client_wasm = await fs
|
||||
.readFile(path.join(__dirname, "../../dist/wasm/perspective-js.wasm"))
|
||||
.then((buffer) => load_wasm_stage_0(buffer.buffer as ArrayBuffer));
|
||||
|
||||
await perspective_client.default({ module_or_path: uncompressed_client_wasm });
|
||||
|
||||
function make_node_disk_bridge({
|
||||
heap,
|
||||
toAddr,
|
||||
readCString,
|
||||
}: {
|
||||
heap: () => Uint8Array;
|
||||
toAddr: (p: number | bigint) => number;
|
||||
readCString: (p: number | bigint) => string;
|
||||
}) {
|
||||
const root = path.join(os.tmpdir(), `perspective-${process.pid}`);
|
||||
const resolve_path = (name: string) => {
|
||||
const p = path.join(root, name);
|
||||
if (!p.startsWith(root)) {
|
||||
throw new Error(`refusing disk path outside root: ${name}`);
|
||||
}
|
||||
return p;
|
||||
};
|
||||
|
||||
let cleaned = false;
|
||||
const cleanup = () => {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
try {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
/* best effort */
|
||||
}
|
||||
};
|
||||
process.on("exit", cleanup);
|
||||
process.on("SIGINT", () => {
|
||||
cleanup();
|
||||
process.exit(130);
|
||||
});
|
||||
|
||||
return {
|
||||
// node:fs is synchronous — nothing to pre-open between safepoint phases.
|
||||
async ensureOpen(_name: string) {},
|
||||
store(
|
||||
namePtr: number | bigint,
|
||||
dataPtr: number | bigint,
|
||||
len: number,
|
||||
): number {
|
||||
try {
|
||||
const p = resolve_path(readCString(namePtr));
|
||||
mkdirSync(path.dirname(p), { recursive: true });
|
||||
const fd = openSync(p, "w");
|
||||
try {
|
||||
if (len > 0) {
|
||||
const addr = toAddr(dataPtr);
|
||||
const view = heap().subarray(addr, addr + len);
|
||||
let off = 0;
|
||||
while (off < len) {
|
||||
off += writeSync(fd, view, off, len - off, off);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
return len;
|
||||
} catch (e) {
|
||||
console.error("node disk store failed", e);
|
||||
return -1;
|
||||
}
|
||||
},
|
||||
load(
|
||||
namePtr: number | bigint,
|
||||
dataPtr: number | bigint,
|
||||
len: number,
|
||||
): number {
|
||||
try {
|
||||
if (len <= 0) return 0;
|
||||
let fd: number;
|
||||
try {
|
||||
fd = openSync(resolve_path(readCString(namePtr)), "r");
|
||||
} catch (e) {
|
||||
return 0; // never-flushed file reads as zeros
|
||||
}
|
||||
try {
|
||||
const addr = toAddr(dataPtr);
|
||||
const view = heap().subarray(addr, addr + len);
|
||||
let off = 0;
|
||||
let n: number;
|
||||
while (
|
||||
off < len &&
|
||||
(n = readSync(fd, view, off, len - off, off)) > 0
|
||||
) {
|
||||
off += n;
|
||||
}
|
||||
return off;
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
remove(namePtr: number | bigint) {
|
||||
try {
|
||||
unlinkSync(resolve_path(readCString(namePtr)));
|
||||
} catch (e) {
|
||||
/* already gone */
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const SYNC_MODULE = await fs
|
||||
.readFile(
|
||||
resolve("@perspective-dev/server/dist/wasm/perspective-server.wasm"),
|
||||
)
|
||||
.then((buffer) => load_wasm_stage_0(buffer.buffer as ArrayBuffer))
|
||||
.then((buffer) =>
|
||||
compile_perspective(buffer.buffer as ArrayBuffer, {
|
||||
make_disk_bridge: make_node_disk_bridge,
|
||||
}),
|
||||
);
|
||||
|
||||
let SYNC_CLIENT: perspective_client.Client;
|
||||
|
||||
const SYNC_SERVER = new engine.PerspectiveServer(SYNC_MODULE);
|
||||
|
||||
// const SYNC_SERVER = new engine.PerspectiveServer(SYNC_MODULE, {
|
||||
// on_poll_request: () => SYNC_POLL_HANDLE.on_poll_request(),
|
||||
// });
|
||||
|
||||
// const SYNC_POLL_HANDLE: engine.PerspectivePollThread =
|
||||
// new engine.PerspectivePollThread(SYNC_SERVER);
|
||||
|
||||
const SYNC_SESSION = SYNC_SERVER.make_session(
|
||||
async (resp) => await SYNC_CLIENT.handle_response(resp),
|
||||
);
|
||||
|
||||
SYNC_CLIENT = new perspective_client.Client(async (req: Uint8Array) => {
|
||||
await SYNC_SESSION.handle_request(req);
|
||||
});
|
||||
|
||||
export class PerspectiveServer extends engine.PerspectiveServer {
|
||||
constructor(options?: engine.PerspectiveServerOptions) {
|
||||
super(SYNC_MODULE, options);
|
||||
}
|
||||
}
|
||||
|
||||
export const make_session = async (
|
||||
send_response: (buffer: Uint8Array) => Promise<void>,
|
||||
) => SYNC_SERVER.make_session(send_response);
|
||||
|
||||
// Helper function to create client emitter/receiver pairs
|
||||
export function make_client(
|
||||
send_request: (buffer: Uint8Array) => Promise<void>,
|
||||
close?: Function,
|
||||
) {
|
||||
return new perspective_client.Client(send_request, close);
|
||||
}
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
".js": "text/javascript",
|
||||
".mjs": "text/javascript",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json",
|
||||
".arrow": "arraybuffer",
|
||||
".feather": "arraybuffer",
|
||||
".wasm": "application/wasm",
|
||||
".svg": "image/svg+xml",
|
||||
};
|
||||
|
||||
/**
|
||||
* Host a Perspective server that hosts data, code files, etc.
|
||||
* Strip version numbers from the URL so we can handle CDN-like requests
|
||||
* of the form @[^~]major.minor.patch when testing local versions of
|
||||
* Perspective against Voila.
|
||||
*/
|
||||
export async function cwd_static_file_handler(
|
||||
request: http.IncomingMessage,
|
||||
response: http.ServerResponse<http.IncomingMessage>,
|
||||
assets = ["./"],
|
||||
{ debug = false } = {},
|
||||
) {
|
||||
let url =
|
||||
request.url
|
||||
?.split(/[\?\#]/)[0]
|
||||
.replace(/@[\^~]?\d+.[\d\*]*.[\d\*]*/, "") || "/";
|
||||
|
||||
if (url === "/") {
|
||||
url = "/index.html";
|
||||
}
|
||||
|
||||
let extname = path.extname(url);
|
||||
let contentType = CONTENT_TYPES[extname] || "text/html";
|
||||
try {
|
||||
for (const root of assets) {
|
||||
let filePath = root + url;
|
||||
try {
|
||||
let content = await fs.readFile(filePath);
|
||||
if (typeof content !== "undefined") {
|
||||
if (debug) {
|
||||
console.log(`200 ${url}`);
|
||||
}
|
||||
|
||||
response.writeHead(200, {
|
||||
"Content-Type": contentType,
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
});
|
||||
|
||||
if (extname === ".arrow" || extname === ".feather") {
|
||||
response.end(content, "utf8");
|
||||
} else {
|
||||
response.end(content, "utf8");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.error(`404 ${url}`);
|
||||
}
|
||||
|
||||
response.writeHead(404);
|
||||
response.end("", "utf-8");
|
||||
} catch (error) {
|
||||
if (debug) {
|
||||
console.error(`500 ${url} ${error}`);
|
||||
}
|
||||
|
||||
response.writeHead(500);
|
||||
response.end("", "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
function buffer_to_arraybuffer(
|
||||
buffer: string | Buffer | ArrayBuffer | Buffer[] | Uint8Array,
|
||||
): Uint8Array {
|
||||
if (typeof buffer === "string") {
|
||||
throw new Error("Unknown websocket message: " + buffer);
|
||||
} else if (buffer instanceof ArrayBuffer) {
|
||||
return new Uint8Array(buffer);
|
||||
} else if (buffer instanceof Array) {
|
||||
throw new Error("Unknown websocket message: " + buffer);
|
||||
} else {
|
||||
return new Uint8Array(
|
||||
buffer.buffer.slice(
|
||||
buffer.byteOffset,
|
||||
buffer.byteOffset + buffer.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple Node `http`-based WebSocket adapter that exposes a
|
||||
* `PerspectiveServer` over the wire.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* **Security.** `WebSocketServer` is a reference integration with no
|
||||
* authentication, authorization, origin enforcement, or rate limiting, and
|
||||
* is not safe to expose to untrusted networks — see
|
||||
* [`SECURITY.md`](https://github.com/perspective-dev/perspective/blob/master/SECURITY.md)
|
||||
* for the full threat model.
|
||||
*/
|
||||
export class WebSocketServer {
|
||||
_server: http.Server | any; // stoppable has no type ...
|
||||
_wss: HttpWebSocketServer;
|
||||
constructor({ port = 8080, assets = ["./"], server = undefined } = {}) {
|
||||
const perspective_server =
|
||||
typeof server === "undefined" ? SYNC_SERVER : server;
|
||||
|
||||
port = typeof port === "undefined" ? 8080 : port;
|
||||
this._server = stoppable(
|
||||
http.createServer((x, y) => cwd_static_file_handler(x, y, assets)),
|
||||
);
|
||||
|
||||
this._wss = new HttpWebSocketServer({
|
||||
noServer: true,
|
||||
perMessageDeflate: true,
|
||||
});
|
||||
|
||||
this._wss.on("connection", (ws) => {
|
||||
console.log("... Connecting websocket");
|
||||
const session = perspective_server.make_session(
|
||||
async (proto: Uint8Array) => {
|
||||
ws.send(buffer_to_arraybuffer(proto));
|
||||
},
|
||||
);
|
||||
|
||||
ws.on("message", (proto) => {
|
||||
session.handle_request(buffer_to_arraybuffer(proto));
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
session.close();
|
||||
});
|
||||
});
|
||||
|
||||
this._server.on(
|
||||
"upgrade",
|
||||
(
|
||||
request: http.IncomingMessage,
|
||||
socket: net.Socket,
|
||||
head: Buffer,
|
||||
) => {
|
||||
console.log("200 Websocket upgrade");
|
||||
this._wss.handleUpgrade(
|
||||
request,
|
||||
socket as net.Socket,
|
||||
head,
|
||||
(sock) => this._wss.emit("connection", sock, request),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
this._server.listen(port, () => {
|
||||
console.log(`Listening on ${this._server.address().port}`);
|
||||
});
|
||||
}
|
||||
|
||||
async close() {
|
||||
await new Promise((x) => this._server.stop(x));
|
||||
}
|
||||
}
|
||||
|
||||
export function get_hosted_table_names() {
|
||||
return SYNC_CLIENT.get_hosted_table_names();
|
||||
}
|
||||
|
||||
export function on_hosted_tables_update(cb: () => void) {
|
||||
return SYNC_CLIENT.on_hosted_tables_update(cb);
|
||||
}
|
||||
|
||||
export function remove_hosted_tables_update(id: number) {
|
||||
return SYNC_CLIENT.remove_hosted_tables_update(id);
|
||||
}
|
||||
|
||||
export function system_info() {
|
||||
return SYNC_CLIENT.system_info();
|
||||
}
|
||||
|
||||
export function on_error(callback: Function) {
|
||||
return SYNC_CLIENT.on_error(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a read-only table from a JOIN of two source tables.
|
||||
* @param left - The left source table (a Table instance or a table name string).
|
||||
* @param right - The right source table (a Table instance or a table name string).
|
||||
* @param on
|
||||
* @param options - Optional join configuration: { join_type?: "inner"|"left"|"outer", name?: string }
|
||||
* @returns
|
||||
*/
|
||||
export function join(
|
||||
left: perspective_client.Table | string,
|
||||
right: perspective_client.Table | string,
|
||||
on: string,
|
||||
options?: perspective_client.JoinOptions,
|
||||
) {
|
||||
return SYNC_CLIENT.join(left as any, right as any, on, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a table from the global Perspective instance.
|
||||
* @param init_data
|
||||
* @param options
|
||||
* @returns
|
||||
*/
|
||||
export function table(
|
||||
init_data:
|
||||
| string
|
||||
| ArrayBuffer
|
||||
| Record<string, any>
|
||||
| Record<string, unknown>[],
|
||||
options?: perspective_client.TableInitOptions,
|
||||
) {
|
||||
return SYNC_CLIENT.table(init_data, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new client connected via WebSocket to a server implemnting the
|
||||
* Perspective Protocol.
|
||||
* @param module
|
||||
* @param url
|
||||
* @returns
|
||||
*/
|
||||
export async function websocket(
|
||||
url: string,
|
||||
): Promise<perspective_client.Client> {
|
||||
return await psp_websocket.websocket(
|
||||
WebSocket as unknown as typeof window.WebSocket,
|
||||
perspective_client.Client,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
export async function worker(worker: Promise<MessagePort>) {
|
||||
const port = await worker;
|
||||
const client = new perspective_client.Client(
|
||||
async (proto: Uint8Array) => {
|
||||
const f = proto.slice().buffer;
|
||||
port.postMessage(f, { transfer: [f] });
|
||||
},
|
||||
async () => {
|
||||
console.debug("Closing WebWorker");
|
||||
port.close();
|
||||
},
|
||||
);
|
||||
|
||||
const { promise, resolve, reject } = Promise.withResolvers();
|
||||
port.onmessage = function listener(resp) {
|
||||
port.onmessage = null;
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
port.onmessageerror = function (...args) {
|
||||
port.onmessage = null;
|
||||
console.error(...args);
|
||||
reject(args);
|
||||
};
|
||||
|
||||
port.postMessage({ cmd: "init", args: [] });
|
||||
await promise;
|
||||
port.addEventListener("message", (json: MessageEvent<Uint8Array>) => {
|
||||
client.handle_response(json.data);
|
||||
});
|
||||
|
||||
console.log(client);
|
||||
return client;
|
||||
}
|
||||
|
||||
export function createMessageHandler(
|
||||
handler: virtual_server.VirtualServerHandler,
|
||||
) {
|
||||
return virtual_server.createMessageHandler(perspective_client, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* The initialized WASM module. Use this when you need to pass the module
|
||||
* to components that require it, such as `DuckDBHandler`.
|
||||
*/
|
||||
export { perspective_client as wasmModule };
|
||||
|
||||
export default {
|
||||
table,
|
||||
join,
|
||||
websocket,
|
||||
worker,
|
||||
get_hosted_table_names,
|
||||
on_hosted_tables_update,
|
||||
remove_hosted_tables_update,
|
||||
on_error,
|
||||
system_info,
|
||||
WebSocketServer,
|
||||
GenericSQLVirtualServerModel,
|
||||
VirtualDataSlice,
|
||||
VirtualServer,
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import { ColumnType } from "./ts-rs/ColumnType.ts";
|
||||
import { ViewConfig } from "./ts-rs/ViewConfig.ts";
|
||||
import { ViewWindow } from "./ts-rs/ViewWindow.ts";
|
||||
|
||||
import type * as perspective from "../../dist/wasm/perspective-js.js";
|
||||
|
||||
/**
|
||||
* VirtualServer API for implementing custom data sources in JavaScript/WASM.
|
||||
*
|
||||
* The VirtualServer pattern allows you to create custom data sources that
|
||||
* integrate with Perspective's protocol. This is useful for:
|
||||
* - Connecting to external databases (DuckDB, PostgreSQL, etc.)
|
||||
* - Streaming data from APIs or message queues
|
||||
* - Implementing custom aggregation or transformation logic
|
||||
* - Creating data adapters without copying data into Perspective tables
|
||||
*
|
||||
* @module virtual_server
|
||||
*/
|
||||
|
||||
export interface ServerFeatures {
|
||||
expressions?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler interface that you implement to provide custom data sources.
|
||||
*
|
||||
* All methods will be called by the VirtualServer when handling protocol
|
||||
* messages from Perspective clients. Methods can return values directly or
|
||||
* return Promises for asynchronous operations (e.g., database queries).
|
||||
*/
|
||||
export interface VirtualServerHandler {
|
||||
getHostedTables(): string[] | Promise<string[]>;
|
||||
tableSchema(
|
||||
tableId: string,
|
||||
): Record<string, ColumnType> | Promise<Record<string, ColumnType>>;
|
||||
tableSize(tableId: string): number | Promise<number>;
|
||||
tableMakeView(
|
||||
tableId: string,
|
||||
viewId: string,
|
||||
config: ViewConfig,
|
||||
): void | Promise<void>;
|
||||
viewDelete(viewId: string): void | Promise<void>;
|
||||
viewGetData(
|
||||
viewId: string,
|
||||
config: ViewConfig,
|
||||
schema: Record<string, ColumnType>,
|
||||
viewport: ViewWindow,
|
||||
dataSlice: perspective.VirtualDataSlice,
|
||||
): void | Promise<void>;
|
||||
viewSchema?(
|
||||
viewId: string,
|
||||
config?: ViewConfig,
|
||||
): Record<string, ColumnType> | Promise<Record<string, ColumnType>>;
|
||||
viewSize?(viewId: string): number | Promise<number>;
|
||||
tableValidateExpression?(
|
||||
tableId: string,
|
||||
expression: string,
|
||||
): ColumnType | Promise<ColumnType>;
|
||||
viewGetMinMax?(
|
||||
viewId: string,
|
||||
columnName: string,
|
||||
config: ViewConfig,
|
||||
): { min: any; max: any } | Promise<{ min: any; max: any }>;
|
||||
getFeatures?(): ServerFeatures | Promise<ServerFeatures>;
|
||||
makeTable?(
|
||||
tableId: string,
|
||||
data: string | Uint8Array,
|
||||
): void | Promise<void>;
|
||||
}
|
||||
|
||||
export function createMessageHandler(
|
||||
mod: typeof perspective,
|
||||
handler: VirtualServerHandler,
|
||||
) {
|
||||
let virtualServer: perspective.VirtualServer;
|
||||
async function postMessage(port: MessagePort, msg: MessageEvent) {
|
||||
if (msg.data.cmd === "init") {
|
||||
try {
|
||||
virtualServer = new mod.VirtualServer(handler);
|
||||
if (msg.data.id !== undefined) {
|
||||
port.postMessage({ id: msg.data.id });
|
||||
} else {
|
||||
port.postMessage(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error initializing worker:", error);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const requestBytes = new Uint8Array(msg.data);
|
||||
const responseBytes =
|
||||
await virtualServer.handleRequest(requestBytes);
|
||||
const buffer = responseBytes.slice().buffer;
|
||||
port.postMessage(buffer, { transfer: [buffer] });
|
||||
} catch (error) {
|
||||
console.error("Error handling request in worker:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const channel = new MessageChannel();
|
||||
channel.port1.onmessage = (message) => {
|
||||
postMessage(channel.port1, message);
|
||||
};
|
||||
|
||||
return channel.port2;
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
/**
|
||||
* An implementation of a Perspective Virtual Server for DuckDB.
|
||||
*
|
||||
* This import is optional, and so must be imported manually from either
|
||||
* `@perspective-dev/client/dist/esm/virtual_servers/duckdb.js` or
|
||||
* `@perspective-dev/client/src/ts/virtual_servers/duckdb.ts`, it is not
|
||||
* exported from the package root `@perspective-dev/client`
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type * as perspective from "@perspective-dev/client";
|
||||
import type { ColumnType } from "@perspective-dev/client/dist/esm/ts-rs/ColumnType.d.ts";
|
||||
import type { ViewConfig } from "@perspective-dev/client/dist/esm/ts-rs/ViewConfig.d.ts";
|
||||
import type { ViewWindow } from "@perspective-dev/client/dist/esm/ts-rs/ViewWindow.d.ts";
|
||||
import type * as clickhouse from "@clickhouse/client-web";
|
||||
|
||||
const NUMBER_AGGS = [
|
||||
"sum",
|
||||
"count",
|
||||
"any_value",
|
||||
"arbitrary",
|
||||
"array_agg",
|
||||
"avg",
|
||||
"bit_and",
|
||||
"bit_or",
|
||||
"bit_xor",
|
||||
"bitstring_agg",
|
||||
"bool_and",
|
||||
"bool_or",
|
||||
"countif",
|
||||
"favg",
|
||||
"fsum",
|
||||
"geomean",
|
||||
"kahan_sum",
|
||||
"last",
|
||||
"max",
|
||||
"min",
|
||||
"product",
|
||||
"string_agg",
|
||||
"sumkahan",
|
||||
];
|
||||
|
||||
const STRING_AGGS = [
|
||||
"count",
|
||||
"any_value",
|
||||
"arbitrary",
|
||||
"first",
|
||||
"countif",
|
||||
"last",
|
||||
"string_agg",
|
||||
];
|
||||
|
||||
const FILTER_OPS = [
|
||||
"==",
|
||||
"!=",
|
||||
"LIKE",
|
||||
"IS DISTINCT FROM",
|
||||
"IS NOT DISTINCT FROM",
|
||||
">=",
|
||||
"<=",
|
||||
">",
|
||||
"<",
|
||||
];
|
||||
|
||||
function duckdbTypeToPsp(name: string): ColumnType {
|
||||
if (name.startsWith("Nullable")) {
|
||||
name = name.match(/Nullable\((.+?)\)/)![1];
|
||||
}
|
||||
|
||||
if (name.startsWith("Array")) {
|
||||
return "string";
|
||||
}
|
||||
|
||||
if (name === "Int64" || name === "UInt64" || name === "Float64") {
|
||||
return "float";
|
||||
}
|
||||
|
||||
if (name === "String") {
|
||||
return "string";
|
||||
}
|
||||
|
||||
if (name === "DateTime") {
|
||||
return "datetime";
|
||||
}
|
||||
|
||||
if (name === "Date") {
|
||||
return "date";
|
||||
}
|
||||
|
||||
throw new Error(`Unknown type '${name}'`);
|
||||
}
|
||||
|
||||
function convertDecimalToNumber(value: any, dtypeString: string) {
|
||||
if (!(value instanceof Uint32Array || value instanceof Int32Array)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
let bigIntValue = BigInt(0);
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
bigIntValue |= BigInt(value[i]) << BigInt(i * 32);
|
||||
}
|
||||
|
||||
const scaleMatch = dtypeString.match(/Decimal\[\d+e(\d+)\]/);
|
||||
if (scaleMatch) {
|
||||
const scale = parseInt(scaleMatch[1]);
|
||||
return Number(bigIntValue) / Math.pow(10, scale);
|
||||
} else {
|
||||
return Number(bigIntValue);
|
||||
}
|
||||
}
|
||||
|
||||
class Lock {
|
||||
lockPromise: Promise<void>;
|
||||
constructor() {
|
||||
this.lockPromise = Promise.resolve();
|
||||
}
|
||||
|
||||
acquire() {
|
||||
let releaseLock: (value: void) => void;
|
||||
const newLockPromise: Promise<void> = new Promise((resolve) => {
|
||||
releaseLock = resolve;
|
||||
});
|
||||
|
||||
const acquirePromise = this.lockPromise.then(() => releaseLock);
|
||||
this.lockPromise = newLockPromise;
|
||||
return acquirePromise;
|
||||
}
|
||||
}
|
||||
|
||||
const LOCK = new Lock();
|
||||
|
||||
async function runQuery(
|
||||
db: clickhouse.ClickHouseClient,
|
||||
query: string,
|
||||
options: { columns?: true; execute?: boolean },
|
||||
): Promise<{
|
||||
rows: any[];
|
||||
columns: string[];
|
||||
dtypes: string[];
|
||||
}>;
|
||||
|
||||
async function runQuery(
|
||||
db: clickhouse.ClickHouseClient,
|
||||
query: string,
|
||||
options?: { columns?: false; execute?: boolean },
|
||||
): Promise<any[]>;
|
||||
|
||||
async function runQuery(
|
||||
db: clickhouse.ClickHouseClient,
|
||||
query: string,
|
||||
options: { columns?: boolean; execute?: boolean } = {},
|
||||
) {
|
||||
query = query.replace(/\s+/g, " ").trim();
|
||||
const release = await LOCK.acquire();
|
||||
try {
|
||||
const result = await db.query({ query });
|
||||
if (!options.execute) {
|
||||
const { data, meta } =
|
||||
(await result.json()) as clickhouse.ResponseJSON<unknown>;
|
||||
|
||||
if (options.columns) {
|
||||
return {
|
||||
rows: data,
|
||||
columns: meta!.map((f) => f.name),
|
||||
dtypes: meta!.map((f) => f.type),
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Query error:", error);
|
||||
console.error("Query:", query);
|
||||
throw error;
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An implementation of Perspective's Virtual Server for `@duckdb/duckdb-wasm`.
|
||||
*/
|
||||
export class ClickhouseHandler implements perspective.VirtualServerHandler {
|
||||
private db: clickhouse.ClickHouseClient;
|
||||
private sqlBuilder: perspective.GenericSQLVirtualServerModel;
|
||||
constructor(db: clickhouse.ClickHouseClient, mod?: typeof perspective) {
|
||||
if (!mod) {
|
||||
if (customElements) {
|
||||
const viewer_class: any =
|
||||
customElements.get("perspective-viewer");
|
||||
if (viewer_class) {
|
||||
mod = viewer_class.__wasm_module__;
|
||||
} else {
|
||||
throw new Error("Missing perspective-client.wasm");
|
||||
}
|
||||
} else {
|
||||
}
|
||||
}
|
||||
|
||||
this.db = db;
|
||||
this.sqlBuilder = new mod!.GenericSQLVirtualServerModel({
|
||||
create_entity: "VIEW",
|
||||
grouping_fn: "GROUPING",
|
||||
});
|
||||
}
|
||||
|
||||
getFeatures() {
|
||||
return {
|
||||
group_by: true,
|
||||
split_by: false,
|
||||
sort: true,
|
||||
expressions: true,
|
||||
group_rollup_mode: ["rollup", "flat", "total"],
|
||||
filter_ops: {
|
||||
integer: FILTER_OPS,
|
||||
float: FILTER_OPS,
|
||||
string: FILTER_OPS,
|
||||
boolean: FILTER_OPS,
|
||||
date: FILTER_OPS,
|
||||
datetime: FILTER_OPS,
|
||||
},
|
||||
aggregates: {
|
||||
integer: NUMBER_AGGS,
|
||||
float: NUMBER_AGGS,
|
||||
string: STRING_AGGS,
|
||||
boolean: STRING_AGGS,
|
||||
date: STRING_AGGS,
|
||||
datetime: STRING_AGGS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getHostedTables() {
|
||||
const query = "SHOW TABLES";
|
||||
const results = await runQuery(this.db, query);
|
||||
return results.map((row) => {
|
||||
return `${row.name}`;
|
||||
});
|
||||
}
|
||||
|
||||
async tableSchema(tableId: string, config?: ViewConfig) {
|
||||
const query = this.sqlBuilder.tableSchema(tableId);
|
||||
const results = await runQuery(this.db, query);
|
||||
const schema = {} as Record<string, ColumnType>;
|
||||
for (const result of results) {
|
||||
const colName = result.name;
|
||||
if (!colName.startsWith("__")) {
|
||||
schema[colName] = duckdbTypeToPsp(result.type) as ColumnType;
|
||||
}
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
async viewColumnSize(viewId: string, config: ViewConfig) {
|
||||
const query = `SELECT COUNT() FROM system.columns WHERE table = '${viewId}'`;
|
||||
const results = await runQuery(this.db, query);
|
||||
const gs = config.group_by?.length || 0;
|
||||
const count = Number(results[0]["COUNT()"]);
|
||||
console.log(count);
|
||||
return (
|
||||
count -
|
||||
(gs === 0 ? 0 : gs + (config.split_by?.length === 0 ? 1 : 0))
|
||||
);
|
||||
}
|
||||
|
||||
async tableSize(tableId: string) {
|
||||
const query = this.sqlBuilder.tableSize(tableId);
|
||||
const results = await runQuery(this.db, query);
|
||||
return Number(results[0]["COUNT()"]);
|
||||
}
|
||||
|
||||
async tableMakeView(tableId: string, viewId: string, config: ViewConfig) {
|
||||
const query = this.sqlBuilder.tableMakeView(tableId, viewId, config);
|
||||
await runQuery(this.db, query, { execute: true });
|
||||
}
|
||||
|
||||
async tableValidateExpression(tableId: string, expression: string) {
|
||||
const query = this.sqlBuilder.tableValidateExpression(
|
||||
tableId,
|
||||
expression,
|
||||
);
|
||||
const results = await runQuery(this.db, query);
|
||||
return duckdbTypeToPsp(results[0]["type"]) as ColumnType;
|
||||
}
|
||||
|
||||
async viewDelete(viewId: string) {
|
||||
const query = this.sqlBuilder.viewDelete(viewId);
|
||||
await runQuery(this.db, query, { execute: true });
|
||||
}
|
||||
|
||||
async viewGetData(
|
||||
viewId: string,
|
||||
config: ViewConfig,
|
||||
schema: Record<string, ColumnType>,
|
||||
viewport: ViewWindow,
|
||||
dataSlice: perspective.VirtualDataSlice,
|
||||
) {
|
||||
const query = this.sqlBuilder.viewGetData(
|
||||
viewId,
|
||||
config,
|
||||
viewport,
|
||||
schema,
|
||||
);
|
||||
|
||||
const { rows, columns, dtypes } = await runQuery(this.db, query, {
|
||||
columns: true,
|
||||
});
|
||||
|
||||
for (let cidx = 0; cidx < columns.length; cidx++) {
|
||||
const col = columns[cidx];
|
||||
const dtype = duckdbTypeToPsp(dtypes[cidx]) as ColumnType;
|
||||
|
||||
const isDecimal = dtypes[cidx].startsWith("Decimal");
|
||||
for (let ridx = 0; ridx < rows.length; ridx++) {
|
||||
const row = rows[ridx];
|
||||
const grouping_id = row["__GROUPING_ID__"];
|
||||
let value = row[col];
|
||||
if (isDecimal) {
|
||||
value = convertDecimalToNumber(value, dtypes[cidx]);
|
||||
}
|
||||
|
||||
if (typeof value === "bigint") {
|
||||
value = Number(value);
|
||||
}
|
||||
|
||||
if (dtype === "datetime" && typeof value === "string") {
|
||||
value = +new Date(value);
|
||||
}
|
||||
|
||||
if (dtype === "string" && typeof value !== "string") {
|
||||
value = `${value}`;
|
||||
}
|
||||
|
||||
dataSlice.setCol(dtype, col, ridx, value, grouping_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
/**
|
||||
* An implementation of a Perspective Virtual Server for DuckDB.
|
||||
*
|
||||
* This import is optional, and so must be imported manually from either
|
||||
* `@perspective-dev/client/dist/esm/virtual_servers/duckdb.js` or
|
||||
* `@perspective-dev/client/src/ts/virtual_servers/duckdb.ts`, it is not
|
||||
* exported from the package root `@perspective-dev/client`
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type * as perspective from "@perspective-dev/client";
|
||||
import type { ColumnType } from "@perspective-dev/client/dist/esm/ts-rs/ColumnType.d.ts";
|
||||
import type { ViewConfig } from "@perspective-dev/client/dist/esm/ts-rs/ViewConfig.d.ts";
|
||||
import type { ViewWindow } from "@perspective-dev/client/dist/esm/ts-rs/ViewWindow.d.ts";
|
||||
import type * as duckdb from "@duckdb/duckdb-wasm";
|
||||
|
||||
const NUMBER_AGGS = [
|
||||
"sum",
|
||||
"count",
|
||||
"any_value",
|
||||
"arbitrary",
|
||||
"array_agg",
|
||||
"avg",
|
||||
"bit_and",
|
||||
"bit_or",
|
||||
"bit_xor",
|
||||
"bitstring_agg",
|
||||
"bool_and",
|
||||
"bool_or",
|
||||
"countif",
|
||||
"favg",
|
||||
"fsum",
|
||||
"geomean",
|
||||
"kahan_sum",
|
||||
"last",
|
||||
"max",
|
||||
"min",
|
||||
"product",
|
||||
"string_agg",
|
||||
"sumkahan",
|
||||
];
|
||||
|
||||
const STRING_AGGS = [
|
||||
"count",
|
||||
"any_value",
|
||||
"arbitrary",
|
||||
"first",
|
||||
"countif",
|
||||
"last",
|
||||
"string_agg",
|
||||
];
|
||||
|
||||
const FILTER_OPS = [
|
||||
"==",
|
||||
"!=",
|
||||
"LIKE",
|
||||
"IS DISTINCT FROM",
|
||||
"IS NOT DISTINCT FROM",
|
||||
">=",
|
||||
"<=",
|
||||
">",
|
||||
"<",
|
||||
];
|
||||
|
||||
function duckdbTypeToPsp(name: string): ColumnType {
|
||||
name = name.toLowerCase();
|
||||
if (name === "varchar" || name == "utf8") {
|
||||
return "string";
|
||||
}
|
||||
|
||||
if (
|
||||
name === "double" ||
|
||||
name === "bigint" ||
|
||||
name === "hugeint" ||
|
||||
name === "float64" ||
|
||||
name.startsWith("decimal")
|
||||
) {
|
||||
return "float";
|
||||
}
|
||||
|
||||
if (name.startsWith("int")) {
|
||||
return "integer";
|
||||
}
|
||||
|
||||
if (name.startsWith("date")) {
|
||||
return "date";
|
||||
}
|
||||
|
||||
if (name.startsWith("bool")) {
|
||||
return "boolean";
|
||||
}
|
||||
|
||||
if (name.startsWith("timestamp")) {
|
||||
return "datetime";
|
||||
}
|
||||
|
||||
if (name.startsWith("json")) {
|
||||
return "string";
|
||||
}
|
||||
|
||||
if (name.startsWith("struct")) {
|
||||
return "string";
|
||||
}
|
||||
|
||||
if (name.startsWith("time")) {
|
||||
return "float";
|
||||
}
|
||||
|
||||
console.warn(`Unknown type '${name}'`);
|
||||
return "string";
|
||||
}
|
||||
|
||||
async function runQuery(
|
||||
db: duckdb.AsyncDuckDBConnection,
|
||||
query: string,
|
||||
options: { columns: true },
|
||||
): Promise<{
|
||||
rows: any[];
|
||||
columns: string[];
|
||||
dtypes: string[];
|
||||
}>;
|
||||
|
||||
async function runQuery(
|
||||
db: duckdb.AsyncDuckDBConnection,
|
||||
query: string,
|
||||
options?: { columns: false },
|
||||
): Promise<any[]>;
|
||||
|
||||
async function runQuery(
|
||||
db: duckdb.AsyncDuckDBConnection,
|
||||
query: string,
|
||||
options: { columns?: boolean } = {},
|
||||
) {
|
||||
query = query.replace(/\s+/g, " ").trim();
|
||||
try {
|
||||
const result = await db.query(query);
|
||||
if (options.columns) {
|
||||
return {
|
||||
rows: result.toArray(),
|
||||
columns: result.schema.fields.map((f) => f.name),
|
||||
dtypes: result.schema.fields.map((f) => f.type.toString()),
|
||||
};
|
||||
}
|
||||
|
||||
return result.toArray();
|
||||
} catch (error) {
|
||||
console.error("Query error:", error);
|
||||
console.error("Query:", query);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An implementation of Perspective's Virtual Server for `@duckdb/duckdb-wasm`.
|
||||
*/
|
||||
export class DuckDBHandler implements perspective.VirtualServerHandler {
|
||||
private db: duckdb.AsyncDuckDBConnection;
|
||||
private sqlBuilder: perspective.GenericSQLVirtualServerModel;
|
||||
constructor(db: duckdb.AsyncDuckDBConnection, mod?: typeof perspective) {
|
||||
if (!mod) {
|
||||
if (customElements) {
|
||||
const viewer_class: any =
|
||||
customElements.get("perspective-viewer");
|
||||
if (viewer_class) {
|
||||
mod = viewer_class.__wasm_module__;
|
||||
} else {
|
||||
throw new Error("Missing perspective-client.wasm");
|
||||
}
|
||||
} else {
|
||||
}
|
||||
}
|
||||
|
||||
this.db = db;
|
||||
this.sqlBuilder = new mod!.GenericSQLVirtualServerModel();
|
||||
}
|
||||
|
||||
getFeatures() {
|
||||
return {
|
||||
group_by: true,
|
||||
split_by: true,
|
||||
sort: true,
|
||||
expressions: true,
|
||||
group_rollup_mode: ["rollup", "flat", "total"],
|
||||
filter_ops: {
|
||||
integer: FILTER_OPS,
|
||||
float: FILTER_OPS,
|
||||
string: FILTER_OPS,
|
||||
boolean: FILTER_OPS,
|
||||
date: FILTER_OPS,
|
||||
datetime: FILTER_OPS,
|
||||
},
|
||||
aggregates: {
|
||||
integer: NUMBER_AGGS,
|
||||
float: NUMBER_AGGS,
|
||||
string: STRING_AGGS,
|
||||
boolean: STRING_AGGS,
|
||||
date: STRING_AGGS,
|
||||
datetime: STRING_AGGS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getHostedTables() {
|
||||
const query = this.sqlBuilder.getHostedTables();
|
||||
const results = await runQuery(this.db, query);
|
||||
return results.map((row) => {
|
||||
const json = row.toJSON();
|
||||
return `${json.database || "memory"}.${json.name}`;
|
||||
});
|
||||
}
|
||||
|
||||
async tableSchema(tableId: string, config?: ViewConfig) {
|
||||
const query = this.sqlBuilder.tableSchema(tableId);
|
||||
const results = await runQuery(this.db, query);
|
||||
const schema = {} as Record<string, ColumnType>;
|
||||
for (const result of results) {
|
||||
const res = result.toJSON();
|
||||
const colName = res.column_name;
|
||||
if (!colName.startsWith("__")) {
|
||||
schema[colName] = duckdbTypeToPsp(
|
||||
res.column_type,
|
||||
) as ColumnType;
|
||||
}
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
async viewColumnSize(viewId: string, config: ViewConfig) {
|
||||
const query = this.sqlBuilder.viewColumnSize(viewId);
|
||||
const results = await runQuery(this.db, query);
|
||||
const count = Number(Object.values(results[0].toJSON())[0]);
|
||||
const gs = config.group_by?.length || 0;
|
||||
const is_flat = config.group_rollup_mode === "flat";
|
||||
return count - (gs === 0 ? 0 : is_flat ? gs : gs + 1);
|
||||
}
|
||||
|
||||
async tableSize(tableId: string) {
|
||||
const query = this.sqlBuilder.tableSize(tableId);
|
||||
const results = await runQuery(this.db, query);
|
||||
return Number(results[0].toJSON()["count_star()"]);
|
||||
}
|
||||
|
||||
async tableMakeView(tableId: string, viewId: string, config: ViewConfig) {
|
||||
const query = this.sqlBuilder.tableMakeView(tableId, viewId, config);
|
||||
await runQuery(this.db, query);
|
||||
}
|
||||
|
||||
async tableValidateExpression(tableId: string, expression: string) {
|
||||
const query = this.sqlBuilder.tableValidateExpression(
|
||||
tableId,
|
||||
expression,
|
||||
);
|
||||
const results = await runQuery(this.db, query);
|
||||
return duckdbTypeToPsp(
|
||||
results[0].toJSON()["column_type"],
|
||||
) as ColumnType;
|
||||
}
|
||||
|
||||
async viewDelete(viewId: string) {
|
||||
const query = this.sqlBuilder.viewDelete(viewId);
|
||||
await runQuery(this.db, query);
|
||||
}
|
||||
|
||||
async viewGetMinMax(
|
||||
viewId: string,
|
||||
columnName: string,
|
||||
config: ViewConfig,
|
||||
) {
|
||||
const query = this.sqlBuilder.viewGetMinMax(viewId, columnName, config);
|
||||
const results = await runQuery(this.db, query);
|
||||
const row = results[0].toJSON();
|
||||
let [min, max] = Object.values(row);
|
||||
if (typeof min === "bigint") min = Number(min);
|
||||
if (typeof max === "bigint") max = Number(max);
|
||||
return { min: min ?? null, max: max ?? null };
|
||||
}
|
||||
|
||||
async viewGetData(
|
||||
viewId: string,
|
||||
config: ViewConfig,
|
||||
schema: Record<string, ColumnType>,
|
||||
viewport: ViewWindow,
|
||||
dataSlice: perspective.VirtualDataSlice,
|
||||
) {
|
||||
const query = this.sqlBuilder.viewGetData(
|
||||
viewId,
|
||||
config,
|
||||
viewport,
|
||||
schema,
|
||||
);
|
||||
|
||||
const ipc = await this.db.useUnsafe((bindings, conn) =>
|
||||
bindings.runQuery(conn, query),
|
||||
);
|
||||
|
||||
dataSlice.fromArrowIpc(ipc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import type * as psp from "../../../dist/wasm/perspective-js.d.ts";
|
||||
|
||||
import * as psp_websocket from "../websocket.ts";
|
||||
|
||||
function invert_promise<T>(): [
|
||||
(t: T) => void,
|
||||
Promise<T>,
|
||||
(e: string) => void,
|
||||
] {
|
||||
let sender, reject;
|
||||
let receiver: Promise<T> = new Promise((x, y) => {
|
||||
sender = x;
|
||||
reject = y;
|
||||
});
|
||||
|
||||
return [
|
||||
sender as unknown as (t: T) => void,
|
||||
receiver,
|
||||
reject as unknown as (e: string) => void,
|
||||
];
|
||||
}
|
||||
|
||||
async function _init(
|
||||
ws: MessagePort | Worker,
|
||||
wasm: WebAssembly.Module | undefined,
|
||||
) {
|
||||
const [sender, receiver] = invert_promise();
|
||||
ws.addEventListener("message", function listener(resp) {
|
||||
ws.removeEventListener("message", listener);
|
||||
sender(null);
|
||||
});
|
||||
|
||||
ws.onmessage = function listener(resp) {
|
||||
ws.onmessage = function () {};
|
||||
sender(null);
|
||||
};
|
||||
|
||||
ws.onmessageerror = console.error;
|
||||
ws.postMessage(
|
||||
{ cmd: "init", args: [wasm] },
|
||||
{
|
||||
transfer:
|
||||
wasm === undefined || wasm instanceof WebAssembly.Module
|
||||
? []
|
||||
: [wasm],
|
||||
},
|
||||
);
|
||||
|
||||
await receiver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new client connected exclusively to a new Web Worker instance of
|
||||
* the Perspective engine.
|
||||
* @param module
|
||||
* @returns
|
||||
*/
|
||||
export async function worker(
|
||||
module: Promise<typeof psp>,
|
||||
server_wasm: Promise<WebAssembly.Module> | undefined,
|
||||
perspective_wasm_worker: Promise<
|
||||
SharedWorker | ServiceWorker | Worker | MessagePort
|
||||
>,
|
||||
) {
|
||||
let [wasm, webworker]: [
|
||||
WebAssembly.Module | undefined,
|
||||
SharedWorker | ServiceWorker | Worker | MessagePort,
|
||||
] = await Promise.all([server_wasm, perspective_wasm_worker]);
|
||||
|
||||
const { Client } = await module;
|
||||
let port: MessagePort;
|
||||
if (
|
||||
typeof SharedWorker !== "undefined" &&
|
||||
webworker instanceof SharedWorker
|
||||
) {
|
||||
port = webworker.port;
|
||||
} else {
|
||||
// Assume `MessagePort`
|
||||
port = webworker as MessagePort;
|
||||
}
|
||||
|
||||
const client = new Client(
|
||||
async (proto: Uint8Array) => {
|
||||
const f = proto.slice().buffer;
|
||||
port.postMessage(f, { transfer: [f] });
|
||||
},
|
||||
async () => {
|
||||
console.debug("Closing WebWorker");
|
||||
port.close();
|
||||
if (webworker instanceof Worker) {
|
||||
webworker.terminate();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await _init(port, wasm);
|
||||
port.addEventListener("message", (json: MessageEvent<Uint8Array>) => {
|
||||
client.handle_response(json.data);
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new client connected via WebSocket to a server implemnting the
|
||||
* Perspective Protocol.
|
||||
* @param module
|
||||
* @param url
|
||||
* @returns
|
||||
*/
|
||||
export async function websocket(
|
||||
module: Promise<typeof psp>,
|
||||
url: string | URL,
|
||||
) {
|
||||
const { Client } = await module;
|
||||
return await psp_websocket.websocket(WebSocket, Client, url);
|
||||
}
|
||||
|
||||
export default { websocket, worker };
|
||||
@@ -0,0 +1,64 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
// @ts-ignore
|
||||
import { extract } from "pro_self_extracting_wasm";
|
||||
|
||||
if (typeof WebAssembly === "undefined") {
|
||||
throw new Error("WebAssembly not supported.");
|
||||
}
|
||||
|
||||
interface Module extends WebAssembly.Exports {
|
||||
size(): number;
|
||||
offset(): number;
|
||||
memory: WebAssembly.Memory;
|
||||
}
|
||||
|
||||
// Perform a silly dance to deal with the different ways webpack and esbuild
|
||||
// load binary, as this may either be an `ArrayBuffer` or `URL` depending
|
||||
// on whether `inline` option was specified to `perspective-esbuild-plugin`.
|
||||
async function compile(
|
||||
buffer: ArrayBuffer | Response | WebAssembly.Module | WebAssembly.Exports,
|
||||
): Promise<Module> {
|
||||
if (buffer instanceof Response) {
|
||||
return (await WebAssembly.instantiateStreaming(buffer)).instance
|
||||
.exports as Module;
|
||||
} else if (buffer instanceof WebAssembly.Module) {
|
||||
return (await WebAssembly.instantiate(buffer)).exports as Module;
|
||||
} else if (buffer instanceof WebAssembly.Instance) {
|
||||
return buffer.exports as Module;
|
||||
} else if (buffer instanceof ArrayBuffer) {
|
||||
return (await WebAssembly.instantiate(buffer as BufferSource)).instance
|
||||
.exports as Module;
|
||||
} else {
|
||||
return buffer as Module;
|
||||
}
|
||||
}
|
||||
|
||||
export async function load_wasm_stage_0(
|
||||
wasm:
|
||||
| ArrayBuffer
|
||||
| Response
|
||||
| WebAssembly.Module
|
||||
| (() => Promise<ArrayBuffer>),
|
||||
): Promise<Uint8Array> {
|
||||
if (wasm instanceof Function) {
|
||||
wasm = await wasm();
|
||||
}
|
||||
|
||||
try {
|
||||
return await extract(wasm as ArrayBuffer);
|
||||
} catch (e) {
|
||||
console.warn("Stage 0 wasm loading failed, skipping");
|
||||
return new Uint8Array(wasm as ArrayBuffer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import * as perspective_server from "./perspective-server.poly.ts";
|
||||
export type * from "../../../dist/wasm/perspective-js.js";
|
||||
import type * as perspective_server_t from "@perspective-dev/server/dist/wasm/perspective-server.js";
|
||||
|
||||
export type PspPtr = BigInt | number;
|
||||
export type EmscriptenServer = bigint | number;
|
||||
|
||||
export interface DiskBridgeHelpers {
|
||||
heap: () => Uint8Array;
|
||||
toAddr: (p: number | bigint) => number;
|
||||
readCString: (p: number | bigint) => string;
|
||||
}
|
||||
|
||||
export interface CompileOptions {
|
||||
make_disk_bridge?: (helpers: DiskBridgeHelpers) => {
|
||||
store(
|
||||
namePtr: number | bigint,
|
||||
dataPtr: number | bigint,
|
||||
len: number,
|
||||
): number;
|
||||
load(
|
||||
namePtr: number | bigint,
|
||||
dataPtr: number | bigint,
|
||||
len: number,
|
||||
): number;
|
||||
remove(namePtr: number | bigint): void;
|
||||
ensureOpen(name: string): Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
export async function compile_perspective(
|
||||
wasmBinary: ArrayBuffer,
|
||||
opts?: CompileOptions,
|
||||
): Promise<perspective_server_t.MainModule> {
|
||||
const module = await perspective_server.default({
|
||||
locateFile(x: any) {
|
||||
return x;
|
||||
},
|
||||
make_disk_bridge: opts?.make_disk_bridge,
|
||||
instantiateWasm: async (
|
||||
imports: any,
|
||||
receive: (_: WebAssembly.Instance) => void,
|
||||
) => {
|
||||
imports["env"] = {
|
||||
...imports["env"],
|
||||
psp_stack_trace() {
|
||||
const str = Error().stack || "";
|
||||
const textEncoder = new TextEncoder();
|
||||
const bytes = textEncoder.encode(str);
|
||||
const ptr = module._psp_alloc(
|
||||
module._psp_is_memory64()
|
||||
? (BigInt(bytes.byteLength + 1) as any as number)
|
||||
: bytes.byteLength + 1,
|
||||
);
|
||||
|
||||
module.HEAPU8.set(bytes, Number(ptr));
|
||||
module.HEAPU8[Number(ptr) + bytes.byteLength] = 0;
|
||||
return ptr;
|
||||
},
|
||||
psp_heap_size() {
|
||||
if (module._psp_is_memory64()) {
|
||||
return BigInt(module.HEAPU8.buffer.byteLength);
|
||||
} else {
|
||||
return module.HEAPU8.buffer.byteLength;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const webasm = await WebAssembly.instantiate(wasmBinary, imports);
|
||||
receive(webasm.instance);
|
||||
return webasm.instance.exports;
|
||||
},
|
||||
});
|
||||
|
||||
return module;
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import type { MainModule } from "@perspective-dev/server/dist/wasm/perspective-server.js";
|
||||
import type { EmscriptenServer, PspPtr } from "./emscripten_api.ts";
|
||||
|
||||
export type ApiResponse = {
|
||||
client_id: number;
|
||||
data: Uint8Array;
|
||||
};
|
||||
|
||||
export interface PerspectiveServerOptions {
|
||||
on_poll_request?: (x: PerspectiveServer) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A server-wide FIFO async mutex.
|
||||
*/
|
||||
class AsyncLock {
|
||||
private tail: Promise<unknown> = Promise.resolve();
|
||||
run<T>(fn: () => Promise<T> | T): Promise<T> {
|
||||
const result = this.tail.then(fn, fn);
|
||||
this.tail = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
async function residency_safepoint(
|
||||
mod: MainModule,
|
||||
lock: AsyncLock,
|
||||
server: EmscriptenServer,
|
||||
) {
|
||||
const safepoint = (mod as any).psp_residency_safepoint;
|
||||
if (safepoint) {
|
||||
await lock.run(() => safepoint(server));
|
||||
}
|
||||
}
|
||||
|
||||
export class PerspectivePollThread {
|
||||
private poll_handle?: Promise<void>;
|
||||
private server: PerspectiveServer;
|
||||
constructor(server: PerspectiveServer) {
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
private set_poll_handle() {
|
||||
this.poll_handle = new Promise((resolve, reject) =>
|
||||
setTimeout(() =>
|
||||
this.server
|
||||
.poll()
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
.finally(() => {
|
||||
this.poll_handle = undefined;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return this.poll_handle;
|
||||
}
|
||||
|
||||
async on_poll_request() {
|
||||
if (!this.poll_handle) {
|
||||
await this.set_poll_handle();
|
||||
} else {
|
||||
await this.poll_handle.then(() => {
|
||||
if (!this.poll_handle) {
|
||||
return this.set_poll_handle();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class PerspectiveServer {
|
||||
clients: Map<number, (buffer: Uint8Array) => Promise<void>>;
|
||||
server: EmscriptenServer;
|
||||
module: MainModule;
|
||||
on_poll_request?: (x: PerspectiveServer) => Promise<void>;
|
||||
lock: AsyncLock;
|
||||
|
||||
constructor(module: MainModule, options?: PerspectiveServerOptions) {
|
||||
this.clients = new Map();
|
||||
this.module = module;
|
||||
this.on_poll_request = options?.on_poll_request;
|
||||
this.lock = new AsyncLock();
|
||||
this.server = module._psp_new_server(
|
||||
!!options?.on_poll_request ? 1 : 0,
|
||||
) as EmscriptenServer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create server emitter/receiver pairs
|
||||
*/
|
||||
make_session(
|
||||
callback: (buffer: Uint8Array) => Promise<void>,
|
||||
): PerspectiveSession {
|
||||
const client_id = this.module._psp_new_session(this.server as any);
|
||||
this.clients.set(client_id, callback);
|
||||
return new PerspectiveSession(
|
||||
this.module,
|
||||
this.server,
|
||||
client_id,
|
||||
this.clients,
|
||||
this.lock,
|
||||
this.on_poll_request && (() => this.on_poll_request!(this)),
|
||||
);
|
||||
}
|
||||
|
||||
async poll() {
|
||||
const polled = await this.lock.run(() =>
|
||||
this.module._psp_poll(this.server as any),
|
||||
);
|
||||
|
||||
await decode_api_responses(
|
||||
this.module,
|
||||
polled,
|
||||
async (msg: ApiResponse) => {
|
||||
await this.clients.get(msg.client_id)!(msg.data);
|
||||
},
|
||||
);
|
||||
|
||||
await residency_safepoint(this.module, this.lock, this.server);
|
||||
}
|
||||
|
||||
delete() {
|
||||
this.module._psp_delete_server(this.server as any);
|
||||
}
|
||||
}
|
||||
|
||||
export class PerspectiveSession {
|
||||
constructor(
|
||||
private mod: MainModule,
|
||||
private server: EmscriptenServer,
|
||||
private client_id: number,
|
||||
private client_map: Map<number, (buffer: Uint8Array) => Promise<void>>,
|
||||
private lock: AsyncLock,
|
||||
private on_poll_request?: () => Promise<void>,
|
||||
) {}
|
||||
|
||||
async handle_request(view: Uint8Array) {
|
||||
const ptr = await convert_typed_array_to_pointer(
|
||||
this.mod,
|
||||
view,
|
||||
async (viewPtr) => {
|
||||
const len = this.mod._psp_is_memory64()
|
||||
? (BigInt(view.byteLength) as any as number)
|
||||
: (view.byteLength as any);
|
||||
|
||||
return this.lock.run(() =>
|
||||
this.mod._psp_handle_request(
|
||||
this.server as any,
|
||||
this.client_id,
|
||||
viewPtr as any,
|
||||
len,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await decode_api_responses(this.mod, ptr, async (msg: ApiResponse) => {
|
||||
await this.client_map.get(msg.client_id)!(msg.data);
|
||||
});
|
||||
|
||||
if (this.on_poll_request) {
|
||||
await this.on_poll_request();
|
||||
} else {
|
||||
await this.poll();
|
||||
}
|
||||
|
||||
await residency_safepoint(this.mod, this.lock, this.server);
|
||||
}
|
||||
|
||||
private async poll() {
|
||||
const polled = await this.lock.run(() =>
|
||||
this.mod._psp_poll(this.server as any),
|
||||
);
|
||||
await decode_api_responses(
|
||||
this.mod,
|
||||
polled,
|
||||
async (msg: ApiResponse) => {
|
||||
if (msg.client_id === 0) {
|
||||
await this.client_map.get(this.client_id)!(msg.data);
|
||||
} else {
|
||||
await this.client_map.get(msg.client_id)!(msg.data);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.mod._psp_close_session(this.server as any, this.client_id);
|
||||
}
|
||||
}
|
||||
|
||||
async function convert_typed_array_to_pointer(
|
||||
core: MainModule,
|
||||
array: Uint8Array,
|
||||
callback: (_: PspPtr) => Promise<PspPtr>,
|
||||
): Promise<PspPtr> {
|
||||
const ptr = core._psp_alloc(
|
||||
core._psp_is_memory64()
|
||||
? (BigInt(array.byteLength) as any as number)
|
||||
: (array.byteLength as any),
|
||||
);
|
||||
|
||||
core.HEAPU8.set(array, Number(ptr) >>> 0);
|
||||
const msg = await callback(Number(ptr) >>> 0);
|
||||
core._psp_free(Number(ptr) >>> 0);
|
||||
return msg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a pointer to WASM memory into an `ApiResponse[]`, via a custom
|
||||
* encoding.
|
||||
*
|
||||
* @param core The emscripten API
|
||||
* @param ptr A pointer to a fixed-sized struct representing a set of
|
||||
* `proto::Resp` payloads, encoded as a length-prefixed array of
|
||||
* (char* data, u32_t len, u32_t client_id) tuples:
|
||||
*
|
||||
* ```text
|
||||
* N data length client_id data length client_id
|
||||
* +-------------------------------------------------------------+
|
||||
* | 2 | 0xabc | 9 | 0 | 0xdef | 12 | 0 |
|
||||
* +-------------------------------------------------------------+
|
||||
* | |
|
||||
* | +-------------+ | +----------------+
|
||||
* +--| "Test Data" | +--| "Hello, World" |
|
||||
* +-------------+ +----------------+
|
||||
* ```
|
||||
*
|
||||
* @param callback A callback to which is passed the responses. THe responses
|
||||
* must be fully processed or copied before the callback returns, as it
|
||||
* references memory on the wasm stack.
|
||||
*/
|
||||
async function decode_api_responses(
|
||||
core: MainModule,
|
||||
ptr: PspPtr,
|
||||
callback: (_: ApiResponse) => Promise<void>,
|
||||
) {
|
||||
const is_64 = core._psp_is_memory64();
|
||||
const response = new DataView(
|
||||
core.HEAPU8.buffer,
|
||||
Number(ptr) >>> 0,
|
||||
is_64 ? 12 : 8,
|
||||
);
|
||||
|
||||
const num_msgs = response.getUint32(0, true);
|
||||
const msgs_ptr = is_64
|
||||
? response.getBigInt64(4, true)
|
||||
: response.getUint32(4, true);
|
||||
|
||||
const messages = new DataView(
|
||||
core.HEAPU8.buffer,
|
||||
Number(msgs_ptr),
|
||||
num_msgs * (is_64 ? 16 : 12),
|
||||
);
|
||||
|
||||
try {
|
||||
for (let i = 0; i < num_msgs; i++) {
|
||||
const [data_ptr, data_len, client_id] = is_64
|
||||
? [
|
||||
messages.getBigInt64(i * 16, true),
|
||||
messages.getInt32(i * 16 + 8, true),
|
||||
messages.getInt32(i * 16 + 12, true),
|
||||
]
|
||||
: [
|
||||
messages.getUint32(i * 12, true),
|
||||
messages.getUint32(i * 12 + 4, true),
|
||||
messages.getInt32(i * 12 + 8, true),
|
||||
];
|
||||
|
||||
const data = new Uint8Array(
|
||||
core.HEAPU8.buffer,
|
||||
Number(data_ptr),
|
||||
data_len,
|
||||
);
|
||||
|
||||
const resp = { client_id, data };
|
||||
await callback(resp);
|
||||
}
|
||||
} finally {
|
||||
for (let i = 0; i < num_msgs; i++) {
|
||||
const data_ptr = is_64
|
||||
? messages.getBigInt64(i * 16, true)
|
||||
: messages.getInt32(i * 12, true);
|
||||
|
||||
core._psp_free(data_ptr as any);
|
||||
}
|
||||
|
||||
core._psp_free(
|
||||
is_64
|
||||
? (BigInt(messages.byteOffset) as any as number)
|
||||
: (messages.byteOffset as any),
|
||||
);
|
||||
core._psp_free(
|
||||
is_64
|
||||
? (BigInt(response.byteOffset) as any as number)
|
||||
: (response.byteOffset as any),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import type * as perspective_server from "@perspective-dev/server/dist/wasm/perspective-server.js";
|
||||
|
||||
var out = console.log.bind(console);
|
||||
var err = console.error.bind(console);
|
||||
|
||||
var UTF8Decoder = new TextDecoder("utf8");
|
||||
var printCharBuffers: Array<Array<number> | null> = [null, [], []];
|
||||
|
||||
function UTF8ArrayToString(
|
||||
heapOrArray: Uint8Array | Array<number>,
|
||||
idx = 0,
|
||||
maxBytesToRead = NaN,
|
||||
) {
|
||||
var endIdx = idx + maxBytesToRead;
|
||||
var endPtr = idx;
|
||||
while (heapOrArray[endPtr] && !(endPtr >= endIdx)) ++endPtr;
|
||||
return UTF8Decoder.decode(
|
||||
heapOrArray instanceof Uint8Array
|
||||
? heapOrArray.subarray(idx, endPtr)
|
||||
: new Uint8Array(heapOrArray.slice(idx, endPtr)),
|
||||
);
|
||||
}
|
||||
|
||||
function printChar(stream: number, curr: number) {
|
||||
var buffer: Array<number> = printCharBuffers[stream]!;
|
||||
if (curr === 0 || curr === 10) {
|
||||
(stream === 1 ? out : err)(UTF8ArrayToString(buffer, 0));
|
||||
buffer.length = 0;
|
||||
} else {
|
||||
buffer.push(curr);
|
||||
}
|
||||
}
|
||||
|
||||
export default async function (obj: any) {
|
||||
let psp_module: perspective_server.MainModule;
|
||||
let is_memory64 = false;
|
||||
let wasm_memory: WebAssembly.Memory;
|
||||
|
||||
// A `BACKING_STORE_DISK` column's bytes live in wasm linear memory; the
|
||||
// residency manager flushes/loads them to/from OPFS through these imports.
|
||||
const heap = () => new Uint8Array(wasm_memory.buffer);
|
||||
const toAddr = (p: number | bigint) =>
|
||||
is_memory64 ? Number(p as bigint) : (p as number) >>> 0;
|
||||
|
||||
function readCString(p: number | bigint) {
|
||||
const addr = toAddr(p);
|
||||
const h = heap();
|
||||
let end = addr;
|
||||
while (h[end]) ++end;
|
||||
return UTF8Decoder.decode(h.subarray(addr, end));
|
||||
}
|
||||
|
||||
// A `DiskBridge` backs `BACKING_STORE_DISK` columns: `store`/`load` flush and
|
||||
// re-read a column buffer to/from disk synchronously (the only async step,
|
||||
// `ensureOpen`, is hoisted into the JS safepoint between engine calls). The
|
||||
// browser default is OPFS; a host can inject an alternative (e.g. `node:fs`
|
||||
// in `perspective.node.ts`) via `obj.make_disk_bridge`. `store`/`load`/
|
||||
// `remove` receive raw wasm pointers and use the heap helpers; `ensureOpen`
|
||||
// receives a resolved file name string (from `victim_fname`).
|
||||
const disk_helpers = { heap, toAddr, readCString };
|
||||
|
||||
function makeOpfsBridge() {
|
||||
async function opfsOpenFile(name: string, create: boolean) {
|
||||
const parts = name.split("/").filter((s) => s.length > 0);
|
||||
// @ts-ignore — navigator.storage.getDirectory is OPFS, Worker-only.
|
||||
let dir = await navigator.storage.getDirectory();
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
dir = await dir.getDirectoryHandle(parts[i], { create });
|
||||
}
|
||||
return await dir.getFileHandle(parts[parts.length - 1], { create });
|
||||
}
|
||||
|
||||
// One OPFS `FileSystemSyncAccessHandle` is kept open per disk-backed file
|
||||
// for its lifetime.
|
||||
const handles = new Map<string, any>();
|
||||
|
||||
return {
|
||||
async ensureOpen(name: string) {
|
||||
if (handles.has(name)) {
|
||||
return;
|
||||
}
|
||||
const fh = await opfsOpenFile(name, true);
|
||||
// @ts-ignore — createSyncAccessHandle is Worker-only OPFS.
|
||||
const ah = await fh.createSyncAccessHandle();
|
||||
handles.set(name, ah);
|
||||
},
|
||||
store(
|
||||
namePtr: number | bigint,
|
||||
dataPtr: number | bigint,
|
||||
len: number,
|
||||
): number {
|
||||
try {
|
||||
const ah = handles.get(readCString(namePtr));
|
||||
if (!ah) {
|
||||
// `commit` only evicts victims whose handle the safepoint
|
||||
// just opened, so this should be unreachable.
|
||||
return -1;
|
||||
}
|
||||
ah.truncate(len);
|
||||
if (len > 0) {
|
||||
const addr = toAddr(dataPtr);
|
||||
ah.write(heap().subarray(addr, addr + len), { at: 0 });
|
||||
}
|
||||
ah.flush();
|
||||
return len;
|
||||
} catch (e) {
|
||||
err("psp_opfs_store failed", e);
|
||||
return -1;
|
||||
}
|
||||
},
|
||||
load(
|
||||
namePtr: number | bigint,
|
||||
dataPtr: number | bigint,
|
||||
len: number,
|
||||
): number {
|
||||
try {
|
||||
if (len <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const ah = handles.get(readCString(namePtr));
|
||||
// A restore always follows an evict that left the handle open;
|
||||
// a miss (or never-flushed file) reads as zeros.
|
||||
if (!ah) {
|
||||
return 0;
|
||||
}
|
||||
const addr = toAddr(dataPtr);
|
||||
return ah.read(heap().subarray(addr, addr + len), {
|
||||
at: 0,
|
||||
});
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
remove(namePtr: number | bigint) {
|
||||
try {
|
||||
const name = readCString(namePtr);
|
||||
const ah = handles.get(name);
|
||||
if (ah) {
|
||||
try {
|
||||
ah.close();
|
||||
} catch (e) {
|
||||
/* already closed */
|
||||
}
|
||||
handles.delete(name);
|
||||
}
|
||||
// Fire-and-forget the (async) file deletion; the handle is
|
||||
// closed, so the bytes are no longer referenced.
|
||||
void (async () => {
|
||||
try {
|
||||
const parts = name
|
||||
.split("/")
|
||||
.filter((s) => s.length > 0);
|
||||
// @ts-ignore
|
||||
let dir = await navigator.storage.getDirectory();
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
dir = await dir.getDirectoryHandle(parts[i], {
|
||||
create: false,
|
||||
});
|
||||
}
|
||||
await dir.removeEntry(parts[parts.length - 1]);
|
||||
} catch (e) {
|
||||
/* already gone */
|
||||
}
|
||||
})();
|
||||
} catch (e) {
|
||||
/* already gone */
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// The host may inject a disk bridge (e.g. `node:fs`); otherwise default to
|
||||
// OPFS. Synchronous `store`/`load`/`remove` env imports — no JSPI.
|
||||
const disk = obj.make_disk_bridge
|
||||
? obj.make_disk_bridge(disk_helpers)
|
||||
: makeOpfsBridge();
|
||||
|
||||
const opfs_imports: Record<string, any> = {
|
||||
psp_opfs_store: disk.store,
|
||||
psp_opfs_load: disk.load,
|
||||
psp_opfs_remove: disk.remove,
|
||||
};
|
||||
const module = {
|
||||
HaveOffsetConverter() {
|
||||
console.error("HaveOffsetConverter");
|
||||
},
|
||||
__syscall_ftruncate64(...args: any[]) {
|
||||
console.error("__syscall_frtuncate64", args);
|
||||
},
|
||||
__syscall_getdents64(...args: any[]) {
|
||||
console.error("__syscall_frtuncate64", args);
|
||||
},
|
||||
__syscall_unlinkat(...args: any[]) {
|
||||
console.error("__syscall_frtuncate64", args);
|
||||
},
|
||||
__throw_exception_with_stack_trace(ex: number) {
|
||||
// @ts-ignore
|
||||
const e = new WebAssembly.Exception(
|
||||
(psp_module as any).__cpp_exception,
|
||||
[ex],
|
||||
{
|
||||
traceStack: true,
|
||||
},
|
||||
);
|
||||
|
||||
// @ts-ignore This is helpful for debugging
|
||||
e.message = "Unexpected internal error";
|
||||
throw e;
|
||||
},
|
||||
clock_time_get(
|
||||
clk_id: number,
|
||||
ignored_precision: bigint,
|
||||
ptime: bigint | number,
|
||||
) {
|
||||
if (is_memory64) {
|
||||
ptime = ptime as bigint;
|
||||
ptime = Number(ptime);
|
||||
if (
|
||||
!(clk_id == 0 || clk_id == 1 || clk_id == 2 || clk_id == 3)
|
||||
) {
|
||||
return 28;
|
||||
}
|
||||
var now;
|
||||
|
||||
if (clk_id === 0) {
|
||||
now = Date.now();
|
||||
} else {
|
||||
now = performance.now();
|
||||
}
|
||||
|
||||
const nsec = Math.round(now * 1000 * 1000);
|
||||
const HEAP64 = new BigInt64Array(wasm_memory.buffer);
|
||||
HEAP64[ptime / 8] = BigInt(nsec);
|
||||
return 0;
|
||||
} else {
|
||||
ptime = ptime as number;
|
||||
ptime >>>= 0;
|
||||
if (
|
||||
!(clk_id == 0 || clk_id == 1 || clk_id == 2 || clk_id == 3)
|
||||
) {
|
||||
return 28;
|
||||
}
|
||||
|
||||
var now;
|
||||
if (clk_id === 0) {
|
||||
now = Date.now();
|
||||
} else {
|
||||
now = performance.now();
|
||||
}
|
||||
|
||||
var nsec = Math.round(now * 1e6);
|
||||
const HEAP64 = new BigInt64Array(wasm_memory.buffer);
|
||||
HEAP64[ptime >>> 3] = BigInt(nsec);
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
emscripten_asm_const_int(...args: any[]) {
|
||||
return 0;
|
||||
},
|
||||
emscripten_notify_memory_growth(memoryIndex: bigint | number) {
|
||||
if (is_memory64) {
|
||||
memoryIndex = Number(memoryIndex as bigint);
|
||||
} else {
|
||||
memoryIndex = memoryIndex as number;
|
||||
memoryIndex >>>= 0;
|
||||
}
|
||||
|
||||
if (memoryIndex != 0) {
|
||||
console.error("abort");
|
||||
}
|
||||
},
|
||||
environ_get(...args: any[]) {
|
||||
return 0;
|
||||
},
|
||||
environ_sizes_get(...args: any[]) {
|
||||
return 0;
|
||||
},
|
||||
fd_close(...args: any[]) {
|
||||
console.error("fd_close", args);
|
||||
return 0;
|
||||
},
|
||||
fd_read(...args: any[]) {
|
||||
console.error("fd_read", args);
|
||||
return 0;
|
||||
},
|
||||
fd_seek(...args: any[]) {
|
||||
console.error("fs_seek", args);
|
||||
return 0;
|
||||
},
|
||||
fd_write(
|
||||
fd: number,
|
||||
iov: number | bigint,
|
||||
iovcnt: number | bigint,
|
||||
pnum: number | bigint,
|
||||
) {
|
||||
const HEAPU8 = new Uint8Array(wasm_memory.buffer);
|
||||
|
||||
if (!is_memory64) {
|
||||
iov = iov as number;
|
||||
iovcnt = iovcnt as number;
|
||||
pnum = pnum as number;
|
||||
iov >>>= 0;
|
||||
iovcnt >>>= 0;
|
||||
pnum >>>= 0;
|
||||
let num = 0;
|
||||
const HEAPU32 = new Uint32Array(wasm_memory.buffer);
|
||||
|
||||
for (let i = 0; i < iovcnt; i++) {
|
||||
let ptr = HEAPU32[(iov >>> 2) >>> 0];
|
||||
let len = HEAPU32[((iov + 4) >>> 2) >>> 0];
|
||||
iov += 8;
|
||||
for (let j = 0; j < len; j++) {
|
||||
printChar(fd, HEAPU8[(ptr + j) >>> 0]);
|
||||
}
|
||||
num += len;
|
||||
}
|
||||
HEAPU32[(pnum >>> 2) >>> 0] = num;
|
||||
return 0;
|
||||
} else {
|
||||
iov = Number(iov as bigint);
|
||||
iovcnt = Number(iovcnt as bigint);
|
||||
pnum = Number(pnum as bigint);
|
||||
let num = 0;
|
||||
const HEAPU64 = new BigUint64Array(wasm_memory.buffer);
|
||||
|
||||
for (let i = 0; i < iovcnt; i++) {
|
||||
let ptr = Number(HEAPU64[iov / 8]);
|
||||
let len = Number(HEAPU64[(iov + 8) / 8]);
|
||||
iov += 16;
|
||||
for (let j = 0; j < len; j++) {
|
||||
printChar(fd, HEAPU8[ptr + j]);
|
||||
}
|
||||
|
||||
num += len;
|
||||
}
|
||||
HEAPU64[pnum / 8] = BigInt(num);
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
proc_exit(e: number) {
|
||||
console.error("proc_exit", e);
|
||||
return 0;
|
||||
},
|
||||
...opfs_imports,
|
||||
};
|
||||
|
||||
const x = await obj.instantiateWasm(
|
||||
{ env: module, wasi_snapshot_preview1: module },
|
||||
(instance: WebAssembly.Instance) => {
|
||||
psp_module =
|
||||
instance.exports as any as perspective_server.MainModule;
|
||||
|
||||
// @ts-ignore
|
||||
is_memory64 = !!psp_module.psp_is_memory64();
|
||||
wasm_memory = instance.exports.memory as WebAssembly.Memory;
|
||||
|
||||
// @ts-ignore
|
||||
psp_module._initialize();
|
||||
},
|
||||
);
|
||||
|
||||
// No JSPI: every export is exposed verbatim (emscripten `_`-prefixed alias).
|
||||
// Restore is a synchronous import, so `psp_handle_request`/`psp_poll` stay
|
||||
// synchronous WASM calls.
|
||||
const extensions: Record<string, any> = {};
|
||||
for (const [name, func] of Object.entries(x)) {
|
||||
extensions[`_${name}`] = func;
|
||||
}
|
||||
|
||||
// JS-driven residency safepoint. The only async OPFS step — opening each
|
||||
// victim's sync access handle — happens HERE, between the two synchronous C++
|
||||
// phases: `prepare` picks the cold victims (no freeing), we open their
|
||||
// handles, then `commit` flushes + frees them (now a synchronous write). The
|
||||
// `engine.ts` server-wide lock serializes this against engine calls. A no-op
|
||||
// when nothing is over budget.
|
||||
const psp_residency_safepoint = async (server: number | bigint) => {
|
||||
const mod = psp_module as any;
|
||||
if (!mod.psp_residency_prepare) {
|
||||
return;
|
||||
}
|
||||
const n = Number(mod.psp_residency_prepare(server));
|
||||
for (let i = 0; i < n; i++) {
|
||||
const fname = readCString(
|
||||
mod.psp_residency_victim_fname(server, i),
|
||||
);
|
||||
if (fname) {
|
||||
await disk.ensureOpen(fname);
|
||||
}
|
||||
}
|
||||
mod.psp_residency_commit(server);
|
||||
};
|
||||
|
||||
return {
|
||||
...x,
|
||||
...extensions,
|
||||
psp_residency_safepoint,
|
||||
get HEAPU8() {
|
||||
// @ts-ignore
|
||||
return new Uint8Array(wasm_memory.buffer);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
||||
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
||||
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
||||
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
||||
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 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). ┃
|
||||
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
|
||||
import type * as perspective_client from "../../dist/wasm/perspective-js.js";
|
||||
|
||||
function invert_promise<T>(): [(t: T) => void, Promise<T>, (t: any) => void] {
|
||||
let sender: ((t: T) => void) | undefined = undefined,
|
||||
reject: ((t: any) => void) | undefined = undefined;
|
||||
let receiver: Promise<T> = new Promise((x, u) => {
|
||||
sender = x;
|
||||
reject = u;
|
||||
});
|
||||
|
||||
return [sender!, receiver, reject!];
|
||||
}
|
||||
|
||||
export async function websocket(
|
||||
WebSocket: typeof window.WebSocket,
|
||||
Client: typeof perspective_client.Client,
|
||||
url: string | URL,
|
||||
options?: { maxPayload: number },
|
||||
): Promise<perspective_client.Client> {
|
||||
let client: perspective_client.Client, ws: WebSocket;
|
||||
|
||||
async function connect() {
|
||||
if (
|
||||
ws?.readyState === WebSocket.CONNECTING ||
|
||||
ws?.readyState === WebSocket.OPEN
|
||||
) {
|
||||
console.warn(`Already connected ${ws.readyState}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let [sender, receiver, reject] = invert_promise();
|
||||
|
||||
// @ts-ignore
|
||||
ws = new WebSocket(url, options);
|
||||
ws.onopen = sender;
|
||||
ws.binaryType = "arraybuffer";
|
||||
ws.onerror = (event) => {
|
||||
// @ts-expect-error
|
||||
const msg = event.message || "Generic Websocket Error";
|
||||
client.handle_error(msg, connect);
|
||||
reject(msg);
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
// https://www.rfc-editor.org/rfc/rfc6455#section-7.4.1
|
||||
const msg = `WebSocket closed ${event.code}`;
|
||||
client.handle_error(msg, connect);
|
||||
reject(msg);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
client.handle_response(event.data);
|
||||
};
|
||||
|
||||
await receiver;
|
||||
}
|
||||
|
||||
async function send_message(proto: Uint8Array) {
|
||||
if (
|
||||
ws.readyState === WebSocket.CLOSING ||
|
||||
ws.readyState === WebSocket.CLOSED
|
||||
) {
|
||||
const msg = `WebSocket transport error (${ws.readyState})`;
|
||||
client.handle_error(msg, connect);
|
||||
throw new Error(msg);
|
||||
} else if (ws.readyState === WebSocket.CONNECTING) {
|
||||
const msg = `WebSocket message dropped (${ws.readyState})`;
|
||||
throw new Error(msg);
|
||||
} else {
|
||||
const buffer = proto.slice().buffer;
|
||||
ws.send(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
async function on_close() {
|
||||
console.debug("Closing WebSocket");
|
||||
ws.close();
|
||||
}
|
||||
|
||||
client = new Client(send_message, on_close);
|
||||
await connect();
|
||||
return client;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user