chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:25:07 +08:00
commit a26e856398
1681 changed files with 296950 additions and 0 deletions
+110
View File
@@ -0,0 +1,110 @@
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
# ┃ This file is part of the Perspective library, distributed under the terms ┃
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
[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",
]
+3
View File
@@ -0,0 +1,3 @@
# `perspective`
This crate provides Rust bindings to the JavaScript Perspective API.
+159
View File
@@ -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();
+17
View File
@@ -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 });
+17
View File
@@ -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`;
+26
View File
@@ -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>
+96
View File
@@ -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:"
}
}
+589
View File
@@ -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)
}
}
+88
View File
@@ -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();
}
+365
View File
@@ -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)?)
}
}
+164
View File
@@ -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",
)),
}
}
}
+243
View File
@@ -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),
})
}
}
+252
View File
@@ -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() }
}
}
+32
View File
@@ -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");
+402
View File
@@ -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);
}
}
+131
View File
@@ -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;
}
+316
View File
@@ -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);
},
};
}
+95
View File
@@ -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.
@@ -0,0 +1 @@
{"ENTITY_TYPE":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null]}
@@ -0,0 +1 @@
{"ENTITY_TYPE":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null]}
@@ -0,0 +1 @@
{"ENTITY_TYPE":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null]}
@@ -0,0 +1 @@
{"ENTITY_TYPE":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,"XXX","XXX",null,"XXX","XXX","YYY","XXX","YYY","ZZZ","ZZZ","YYY","YYY","ZZZ","ZZZ",null,"YYY",null,null,"XXX","YYY",null,"ZZZ","XXX","ZZZ","WWW","WWW","YYY",null,"WWW","ZZZ","YYY","YYY","XXX","XXX","ZZZ","ZZZ","ZZZ","XXX",null,null,"YYY","XXX",null,"XXX","WWW","YYY",null,null,"ZZZ",null,"YYY",null,null,"ZZZ",null,"YYY","XXX","XXX","YYY","YYY",null,"XXX","YYY","YYY",null,"YYY","YYY",null,"XXX","YYY","XXX","ZZZ",null,"XXX",null,null,"YYY","XXX","XXX","XXX","XXX","WWW","XXX","ZZZ","XXX","XXX","ZZZ","ZZZ",null,"XXX","YYY",null,"YYY","ZZZ","YYY",null,null,null,"YYY","ZZZ","XXX",null,"WWW","WWW"]}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
<!doctype html>
<html>
<head>
<script type="module">
import perspective from "/node_modules/@perspective-dev/client/dist/esm/perspective.js";
let resp = await fetch("/node_modules/@perspective-dev/test/assets/superstore.csv");
let csv = await resp.text();
const wasm = fetch("/node_modules/@perspective-dev/client/dist/wasm/perspective-js.wasm");
const wasm2 = fetch("/node_modules/@perspective-dev/server/dist/wasm/perspective-server.wasm");
perspective.init_client(wasm);
perspective.init_server(wasm2);
const registration = await navigator.serviceWorker.register("/node_modules/@perspective-dev/client/dist/cdn/perspective-server.worker.js", {
scope: "/node_modules/@perspective-dev/client/dist/cdn/",
});
const worker = await perspective.worker(registration.active);
window.table = worker.table(csv, { index: "Row ID" });
window.__TEST_WORKER__ = worker;
window.__TEST_PERSPECTIVE_READY__ = true;
</script>
</head>
</html>
@@ -0,0 +1,18 @@
<!doctype html>
<html>
<head>
<script type="module">
import perspective from "/node_modules/@perspective-dev/client/dist/esm/perspective.js";
let resp = await fetch("/node_modules/@perspective-dev/test/assets/superstore.csv");
let csv = await resp.text();
const wasm = fetch("/node_modules/@perspective-dev/client/dist/wasm/perspective-js.wasm");
const wasm2 = fetch("/node_modules/@perspective-dev/server/dist/wasm/perspective-server.wasm");
perspective.init_client(wasm);
perspective.init_server(wasm2);
const worker = await perspective.worker(new SharedWorker("/node_modules/@perspective-dev/client/dist/cdn/perspective-server.worker.js"));
window.table = worker.table(csv, { index: "Row ID" });
window.__TEST_WORKER__ = worker;
window.__TEST_PERSPECTIVE_READY__ = true;
</script>
</head>
</html>
+4
View File
@@ -0,0 +1,4 @@
<html>
<head> </head>
<body></body>
</html>
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html>
<head>
<script type="module">
import perspective from "/node_modules/@perspective-dev/client/dist/esm/perspective.js";
let resp = await fetch("/node_modules/@perspective-dev/test/assets/superstore.csv");
let csv = await resp.text();
const wasm = fetch("/node_modules/@perspective-dev/client/dist/wasm/perspective-js.wasm");
const wasm2 = fetch("/node_modules/@perspective-dev/server/dist/wasm/perspective-server.wasm");
perspective.init_client(wasm);
perspective.init_server(wasm2);
const worker = await perspective.worker(new Worker("/node_modules/@perspective-dev/client/dist/cdn/perspective-server.worker.js"));
window.table = worker.table(csv, { index: "Row ID" });
window.__TEST_WORKER__ = worker;
window.__TEST_PERSPECTIVE_READY__ = true;
</script>
</head>
</html>
File diff suppressed because it is too large Load Diff
+209
View File
@@ -0,0 +1,209 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "@perspective-dev/test";
import perspective from "./perspective_client.ts";
import type * as psp_types from "@perspective-dev/client";
((perspective) => {
test.describe("Clear", function () {
test("removes the rows from the table", async function () {
const table = await perspective.table([{ x: 1 }]);
const view = await table.view();
let json = await view.to_json();
expect(json).toHaveLength(1);
table.clear();
json = await view.to_json();
expect(json).toHaveLength(0);
view.delete();
table.delete();
});
test("to_columns output is empty", async function () {
const table = await perspective.table([{ x: 1 }]);
const view = await table.view();
let result = await view.to_columns();
expect(result).toEqual({
x: [1],
});
table.clear();
result = await view.to_columns();
expect(result).toEqual({ x: [] });
view.delete();
table.delete();
});
});
test.describe("Replace", function () {
test("replaces the rows in the table with the input data", async function () {
const table = await perspective.table([
{ x: 1, y: 2 },
{ x: 3, y: 4 },
]);
const view = await table.view();
let json = await view.to_json();
expect(json).toHaveLength(2);
expect(json).toEqual([
{ x: 1, y: 2 },
{ x: 3, y: 4 },
]);
await table.replace([{ x: 5, y: 6 }]);
json = await view.to_json();
expect(json).toHaveLength(1);
expect(json).toEqual([{ x: 5, y: 6 }]);
view.delete();
table.delete();
});
test("Replaces CSV Table with high precision datetimes", async function () {
const a = '"start"\n2024-08-14T14:06:07.826Z';
const b = '"start"\n2024-08-14T14:06:09.876667543Z';
const table = await perspective.table(a);
const view = await table.view();
const csv1 = await view.to_csv();
expect(csv1).toEqual('"start"\n2024-08-14 14:06:07.826\n');
await table.replace(b);
const csv2 = await view.to_csv();
expect(csv2).toEqual('"start"\n2024-08-14 14:06:09.876\n');
view.delete();
table.delete();
});
test("replaces the rows in the table with the input data and fires an on_update", async function () {
const table = await perspective.table([
{ x: 1, y: 2 },
{ x: 3, y: 4 },
]);
const view = await table.view();
let emit: ((x: unknown) => void) | undefined;
const callback = async function (updated: psp_types.OnUpdateArgs) {
expect(updated.port_id).toEqual(0);
const json = await view.to_json();
expect(json).toHaveLength(1);
expect(json).toEqual([{ x: 5, y: 6 }]);
view.delete();
table.delete();
emit!(undefined);
};
let result = new Promise((x) => {
emit = x;
});
view.on_update(callback);
let json = await view.to_json();
expect(json).toHaveLength(2);
expect(json).toEqual([
{ x: 1, y: 2 },
{ x: 3, y: 4 },
]);
await table.replace([{ x: 5, y: 6 }]);
await result;
});
test("replaces the rows in the table with the input data and fires an on_update with the correct delta", async function () {
const table = await perspective.table([
{ x: 1, y: 2 },
{ x: 3, y: 4 },
]);
const view = await table.view();
let emit: ((x: unknown) => void) | undefined;
const callback = async function (updated: psp_types.OnUpdateArgs) {
expect(updated.port_id).toEqual(0);
const table2 = await perspective.table(updated.delta!);
const view2 = await table2.view();
const json = await view.to_json();
expect(json).toHaveLength(1);
expect(json).toEqual([{ x: 5, y: 6 }]);
const json2 = await view2.to_json();
expect(json2).toEqual(json);
view2.delete();
table2.delete();
view.delete();
table.delete();
emit!(undefined);
};
let result = new Promise((x) => {
emit = x;
});
view.on_update(callback, { mode: "row" });
let json = await view.to_json();
expect(json).toHaveLength(2);
expect(json).toEqual([
{ x: 1, y: 2 },
{ x: 3, y: 4 },
]);
await table.replace([{ x: 5, y: 6 }]);
await result;
});
test("replace the rows in the table atomically", async function () {
const table = await perspective.table([
{ x: 1, y: 2 },
{ x: 3, y: 4 },
]);
const view = await table.view();
setTimeout(() => table.replace([{ x: 5, y: 6 }]));
let json = await view.to_json();
expect(json).toHaveLength(2);
expect(json).toEqual([
{ x: 1, y: 2 },
{ x: 3, y: 4 },
]);
await new Promise((x, y) => setTimeout(x));
json = await view.to_json();
expect(json).toHaveLength(1);
expect(json).toEqual([{ x: 5, y: 6 }]);
view.delete();
table.delete();
});
test("Preserves sort order with 2-sided pivot", async function () {
const input = [
{ x: 1, y: 7, z: "a" },
{ x: 1, y: 6, z: "b" },
{ x: 2, y: 5, z: "a" },
{ x: 2, y: 4, z: "b" },
{ x: 3, y: 3, z: "a" },
{ x: 3, y: 2, z: "b" },
];
const table = await perspective.table(input);
const view = await table.view({
group_by: ["z"],
split_by: ["x"],
sort: [["y", "asc"]],
columns: ["y"],
});
setTimeout(() => table.replace(input));
let json = await view.to_json();
await new Promise((x, y) => setTimeout(x));
let json2 = await view.to_json();
expect(json).toEqual(json2);
view.delete();
table.delete();
});
});
})(perspective);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,191 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 arrow from "apache-arrow";
import { test, expect } from "@perspective-dev/test";
import perspective from "../perspective_client";
import * as fs from "node:fs";
import * as url from "node:url";
const __dirname = url.fileURLToPath(new URL(".", import.meta.url)).slice(0, -1);
test.describe("Arrow", function () {
test.describe("Date columns", function () {
// https://github.com/perspective-dev/perspective/issues/2894
// https://github.com/jdangerx/repro-perspective-float-filter/tree/dates
test("Date columns are preserved through Arrow in and out", async function () {
const tableData = arrow.tableFromArrays({
date: arrow.vectorFromArray([20089], new arrow.Date_()),
});
const table = await perspective.table(arrow.tableToIPC(tableData));
const view = await table.view();
const json = await view.to_json();
const d = new Date(json[0].date);
expect(json[0].date).toEqual(1735689600000);
// This doesn't test anything except my math
expect(d.getUTCFullYear()).toEqual(2025);
expect(d.getUTCDate()).toEqual(1);
expect(d.getUTCMonth()).toEqual(0);
expect(d.getUTCHours()).toEqual(0);
expect(d.getUTCMinutes()).toEqual(0);
expect(d.getUTCSeconds()).toEqual(0);
expect(d.getTimezoneOffset()).toEqual(0);
await view.delete();
await table.delete();
});
test("Date columns are preserved through Arrow in and out, in a negative timezone", async function () {
process.env.TZ = `America/New_York`;
const tableData = arrow.tableFromArrays({
date: arrow.vectorFromArray([20089], new arrow.Date_()),
});
const table = await perspective.table(arrow.tableToIPC(tableData));
const view = await table.view();
const json = await view.to_json();
const d = new Date(json[0].date);
expect(json[0].date).toEqual(1735689600000);
expect(d.getUTCFullYear()).toEqual(2025);
expect(d.getUTCDate()).toEqual(1);
expect(d.getUTCMonth()).toEqual(0);
expect(d.getUTCHours()).toEqual(0);
expect(d.getUTCMinutes()).toEqual(0);
expect(d.getUTCSeconds()).toEqual(0);
// NY now ...
expect(d.getTimezoneOffset()).toEqual(300);
await view.delete();
await table.delete();
process.env.TZ = `UTC`;
});
});
test.describe("regressions", () => {
// https://github.com/perspective-dev/perspective/issues/3169
test("null values are preserved across multi-batch Arrow IPC streams", async function () {
function row(
identifier: string,
value: number | null,
date: Date | null,
) {
return arrow.tableFromArrays({
Identifier: arrow.vectorFromArray(
[identifier],
new arrow.Utf8(),
),
Value: arrow.vectorFromArray([value], new arrow.Float64()),
Date: arrow.vectorFromArray([date], new arrow.DateDay()),
});
}
const t1 = row("A", null, null);
const t2 = row("B", 5, null);
const t3 = row("C", null, new Date(Date.UTC(2025, 5, 15)));
const multiBatchTable = new arrow.Table([
...t1.batches,
...t2.batches,
...t3.batches,
]);
expect(multiBatchTable.batches.length).toEqual(3);
const ipc = arrow.tableToIPC(multiBatchTable, "stream");
const table = await perspective.table(ipc.buffer as ArrayBuffer);
const view = await table.view();
const json = await view.to_json();
await view.delete();
await table.delete();
expect(json).toStrictEqual([
{ Identifier: "A", Value: null, Date: null },
{ Identifier: "B", Value: 5, Date: null },
{ Identifier: "C", Value: null, Date: 1749945600000 },
]);
});
test("null equality works correctly in updates", async function () {
async function write_to_json(
buffer: ArrayBuffer,
filename: string,
) {
const table = await perspective.table(buffer);
const view = await table.view({
columns: ["ENTITY_TYPE"],
});
const json = await view.to_columns_string();
fs.writeFileSync(filename, json);
await view.delete();
await table.delete();
}
const file = JSON.parse(
fs.readFileSync(
`${__dirname}/../../arrow/untitled.json`,
"utf8",
),
);
const table = await perspective.table(file, {
name: "arrow_null_test",
});
const view = await table.view({ group_by: ["ENTITY_TYPE"] });
for (let i = 2; i < 6; i++) {
const file = JSON.parse(
fs.readFileSync(
`${__dirname}/../../arrow/untitled${i}.json`,
"utf8",
),
);
await table.update(file);
}
const cols = await view.to_columns({ end_row: 4 });
expect(cols).toStrictEqual({
ENTITY_TYPE: [2158, 985, 168, 311],
__ROW_PATH__: [[], [null], [""], ["AAAA"]],
});
});
test("Loads a time32 (millisecond) column without overflow", async function () {
const expected = Array.from({ length: 64 }, (_, i) => i);
const tableData = arrow.tableFromArrays({
t: arrow.vectorFromArray(expected, new arrow.TimeMillisecond()),
});
const table = await perspective.table(arrow.tableToIPC(tableData));
const view = await table.view();
expect(await table.size()).toEqual(64);
expect(await view.to_columns()).toEqual({ t: expected });
await view.delete();
await table.delete();
});
});
test.describe("Malformed input", function () {
test("Rejects an Arrow with a row count that exceeds 32 bits", async function () {
const bytes = new Uint8Array(
fs.readFileSync(`${__dirname}/../../arrow/bad_row_count.arrow`),
);
await expect(perspective.table(bytes)).rejects.toThrow(
/row count exceeds maximum supported size/,
);
});
});
});
@@ -0,0 +1,66 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "@perspective-dev/test";
import perspective from "../perspective_client";
const CSV = "x,y,z\n1,2,3\n4,5,6";
test.describe("CSV Constructors", function () {
test("Handles String format", async function () {
const table = await perspective.table(CSV);
const v = await table.view();
const json = await v.to_json();
expect(json).toEqual([
{ x: 1, y: 2, z: 3 },
{ x: 4, y: 5, z: 6 },
]);
});
test("Handles String format with explicit format option", async function () {
const table = await perspective.table(CSV, {
format: "csv",
});
const v = await table.view();
const json = await v.to_json();
expect(json).toEqual([
{ x: 1, y: 2, z: 3 },
{ x: 4, y: 5, z: 6 },
]);
});
test("Handles ArrayBuffer format", async function () {
var enc = new TextEncoder();
const table = await perspective.table(enc.encode(CSV).buffer, {
format: "csv",
});
const v = await table.view();
const json = await v.to_json();
expect(json).toEqual([
{ x: 1, y: 2, z: 3 },
{ x: 4, y: 5, z: 6 },
]);
});
test("Handles UInt8Arry format", async function () {
var enc = new TextEncoder();
const table = await perspective.table(enc.encode(CSV), {
format: "csv",
});
const v = await table.view();
const json = await v.to_json();
expect(json).toEqual([
{ x: 1, y: 2, z: 3 },
{ x: 4, y: 5, z: 6 },
]);
});
});
@@ -0,0 +1,111 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "@perspective-dev/test";
import perspective from "../perspective_client";
const JAN_1_2024_UTC = 1704067200000;
test.describe("Date timezone invariance", function () {
let SAVED_TZ: string | undefined;
test.beforeEach(() => {
SAVED_TZ = process.env.TZ;
process.env.TZ = "Europe/Amsterdam";
});
test.afterEach(() => {
process.env.TZ = SAVED_TZ;
});
test("date strings serialize to UTC midnight epoch ms", async function () {
const table = await perspective.table({ d: "date" });
await table.update({ d: ["2024-01-01"] });
const view = await table.view();
const cols = (await view.to_columns()) as { d: number[] };
expect(cols.d).toEqual([JAN_1_2024_UTC]);
await view.delete();
await table.delete();
});
test("date epoch ms input round-trips exactly", async function () {
const table = await perspective.table({ d: "date" });
await table.update({ d: [JAN_1_2024_UTC] });
const view = await table.view();
const cols = (await view.to_columns()) as { d: number[] };
expect(cols.d).toEqual([JAN_1_2024_UTC]);
await view.delete();
await table.delete();
});
test("formatted output prints the stored calendar day", async function () {
const table = await perspective.table({ d: "date" });
await table.update({ d: ["2024-01-01"] });
const view = await table.view();
expect(await view.to_columns_string({ formatted: true })).toEqual(
'{"d":["2024-01-01"]}',
);
expect(await view.to_csv()).toEqual('"d"\n2024-01-01\n');
await view.delete();
await table.delete();
});
test("day_bucket stays on the UTC calendar day at the day boundary", async function () {
const table = await perspective.table({ t: "datetime" });
// 23:59 UTC is already the next day in Europe/Amsterdam
await table.update({ t: ["2020-01-31 23:59:00"] });
const view = await table.view({
expressions: { bucket: `bucket("t", 'D')` },
});
const cols = (await view.to_columns()) as { bucket: number[] };
// 2020-01-31T00:00:00Z
expect(cols.bucket).toEqual([1580428800000]);
await view.delete();
await table.delete();
});
test("day_of_week and hour_of_day compute in UTC", async function () {
const table = await perspective.table({ t: "datetime" });
await table.update({ t: ["2020-01-31 23:59:00"] });
const view = await table.view({
expressions: {
dow: `day_of_week("t")`,
hod: `hour_of_day("t")`,
},
});
const cols = (await view.to_columns()) as {
dow: string[];
hod: number[];
};
// Friday 23:00 UTC, not Saturday 00:59 Amsterdam
expect(cols.dow).toEqual(["6 Friday"]);
expect(cols.hod).toEqual([23]);
await view.delete();
await table.delete();
});
test("JSON date output agrees with Arrow date32 day arithmetic", async function () {
const table = await perspective.table({ d: "date" });
await table.update({ d: ["2024-01-01"] });
const view = await table.view();
const cols = (await view.to_columns()) as { d: number[] };
expect(cols.d[0] % 86400000).toEqual(0);
expect(cols.d[0] / 86400000).toEqual(19723); // days since epoch
await view.delete();
await table.delete();
});
});
@@ -0,0 +1,90 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "@perspective-dev/test";
import perspective from "../perspective_client";
const TEST_JSON = { x: [1, 4], y: [2, 5], z: [3, 6] };
test.describe("JSON", function () {
test.describe("Integer columns", function () {
test("Integer columns can be updated with all JSON numeric types and cousins", async function () {
const table = await perspective.table({ x: "integer" });
await table.update([{ x: 0 }]);
await table.update([{ x: 1 }]);
await table.update([{ x: undefined }]);
await table.update([{ x: null }]);
await table.update([{ x: 7.23 }]);
await table.update([{ x: -6 }]);
await table.update([{ x: Infinity }]);
await table.update([{ x: "100.1" }]);
await table.update([{ x: "11" }]);
const v = await table.view();
const json = await v.to_json();
expect(json).toEqual([
{ x: 0 },
{ x: 1 },
{ x: null },
{ x: null },
{ x: 7 },
{ x: -6 },
{ x: null },
{ x: 100 },
{ x: 11 },
]);
});
});
test("Handles String format", async function () {
const table = await perspective.table(JSON.stringify(TEST_JSON), {
format: "columns",
});
const v = await table.view();
const json = await v.to_json();
expect(json).toEqual([
{ x: 1, y: 2, z: 3 },
{ x: 4, y: 5, z: 6 },
]);
});
test("Handles UInt8Array format", async function () {
const enc = new TextEncoder();
const table = await perspective.table(
enc.encode(JSON.stringify(TEST_JSON)),
{
format: "columns",
},
);
const v = await table.view();
const json = await v.to_json();
expect(json).toEqual([
{ x: 1, y: 2, z: 3 },
{ x: 4, y: 5, z: 6 },
]);
});
test("Handles ArrayBuffer format", async function () {
const enc = new TextEncoder();
const table = await perspective.table(
enc.encode(JSON.stringify(TEST_JSON)).buffer,
{
format: "columns",
},
);
const v = await table.view();
const json = await v.to_json();
expect(json).toEqual([
{ x: 1, y: 2, z: 3 },
{ x: 4, y: 5, z: 6 },
]);
});
});
+121
View File
@@ -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 { test, expect } from "@perspective-dev/test";
import perspective from "./perspective_client";
function it_old_behavior(name, capture) {
test(name, async function () {
let done;
let result = new Promise((x) => {
done = x;
});
await capture(done);
await result;
});
}
function mock_fn() {
let count = 0;
let fun = () => {
count += 1;
};
fun.count = () => count;
return fun;
}
((perspective) => {
test.describe("Delete", function () {
test("calls all delete callbacks registered on table", async function () {
const table = await perspective.table([{ x: 1 }]);
const cb1 = mock_fn();
const cb2 = mock_fn();
table.on_delete(cb1);
table.on_delete(cb2);
await table.delete();
expect(cb1.count()).toEqual(1);
expect(cb2.count()).toEqual(1);
});
test("remove_delete unregisters table delete callbacks", async function () {
const table = await perspective.table([{ x: 1 }]);
const cb1 = mock_fn();
const cb2 = mock_fn();
const sub1 = await table.on_delete(cb1);
table.on_delete(cb2);
await table.remove_delete(sub1);
await table.delete();
expect(cb1.count()).toEqual(0);
expect(cb2.count()).toEqual(1);
});
test("calls all delete callbacks registered on view", async function () {
const table = await perspective.table([{ x: 1 }]);
const view = await table.view();
const cb1 = mock_fn();
const cb2 = mock_fn();
view.on_delete(cb1);
view.on_delete(cb2);
await view.delete();
expect(cb1.count()).toEqual(1);
expect(cb2.count()).toEqual(1);
await table.delete();
});
test("remove_delete unregisters view delete callbacks", async function () {
const table = await perspective.table([{ x: 1 }]);
const view = await table.view();
const cb1 = mock_fn();
const cb2 = mock_fn();
const sub1 = await view.on_delete(cb1);
view.on_delete(cb2);
view.remove_delete(sub1);
await view.delete();
expect(cb1.count()).toEqual(0);
expect(cb2.count()).toEqual(1);
await table.delete();
});
it_old_behavior(
"properly removes a failed delete callback on a table",
async function (done) {
const table = await perspective.table([{ x: 1 }]);
// when a callback throws, it should delete that callback
table.on_delete(() => {
throw new Error("something went wrong!");
});
table.delete();
done();
},
);
it_old_behavior(
"properly removes a failed delete callback on a view",
async function (done) {
const table = await perspective.table([{ x: 1 }]);
const view = await table.view();
// when a callback throws, it should delete that callback
view.on_delete(() => {
throw new Error("something went wrong!");
});
view.delete();
table.delete();
done();
},
);
});
})(perspective);
File diff suppressed because it is too large Load Diff
@@ -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 { test, expect } from "@perspective-dev/test";
import { describeDuckDB } from "./setup.js";
describeDuckDB("client", (getClient) => {
test("get_hosted_table_names()", async function () {
const client = getClient();
const tables = await client.get_hosted_table_names();
expect(tables).toEqual(["memory.superstore"]);
});
});
@@ -0,0 +1,286 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "@perspective-dev/test";
import { describeDuckDB } from "./setup.js";
describeDuckDB("combined operations", (getClient) => {
test("group_by + filter + sort", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales"],
group_by: ["Category"],
filter: [["Region", "==", "West"]],
sort: [["Sales", "desc"]],
aggregates: { Sales: "sum" },
});
const json = await view.to_json();
expect(json).toEqual([
{ __ROW_PATH__: [], Sales: 725457.8245000006 },
{
__ROW_PATH__: ["Furniture"],
Sales: 252612.7435000003,
},
{
__ROW_PATH__: ["Technology"],
Sales: 251991.83199999997,
},
{
__ROW_PATH__: ["Office Supplies"],
Sales: 220853.24900000007,
},
]);
await view.delete();
});
test("split_by + group_by + filter", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales"],
group_by: ["Category"],
split_by: ["Region"],
filter: [["Quantity", ">", 3]],
aggregates: { Sales: "sum" },
});
const paths = await view.column_paths();
expect(paths).toEqual([
"Central_Sales",
"East_Sales",
"South_Sales",
"West_Sales",
]);
const numRows = await view.num_rows();
expect(numRows).toBe(4);
const json = await view.to_json();
expect(json).toEqual([
{
__ROW_PATH__: [],
"Central|Sales": 332883.0567999998,
"East|Sales": 455143.735,
"South|Sales": 274208.7699999999,
"West|Sales": 470561.28350000136,
},
{
__ROW_PATH__: ["Furniture"],
"Central|Sales": 111457.73279999988,
"East|Sales": 140376.95899999997,
"South|Sales": 80859.618,
"West|Sales": 165219.5734999998,
},
{
__ROW_PATH__: ["Office Supplies"],
"Central|Sales": 103937.78599999992,
"East|Sales": 135823.893,
"South|Sales": 84393.3579999999,
"West|Sales": 140206.93099999975,
},
{
__ROW_PATH__: ["Technology"],
"Central|Sales": 117487.53800000002,
"East|Sales": 178942.883,
"South|Sales": 108955.79400000005,
"West|Sales": 165134.77900000007,
},
]);
await view.delete();
});
test("split_by only", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales"],
split_by: ["Region"],
filter: [["Quantity", ">", 3]],
});
const paths = await view.column_paths();
expect(paths).toEqual([
"Central_Sales",
"East_Sales",
"South_Sales",
"West_Sales",
]);
const numRows = await view.num_rows();
expect(numRows).toBe(4284);
const json = await view.to_json({ start_row: 0, end_row: 1 });
expect(json).toEqual([
{
"Central|Sales": null,
"East|Sales": null,
"South|Sales": 957.5775,
"West|Sales": null,
},
]);
await view.delete();
});
test("split_by only + sort", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales"],
split_by: ["Region"],
sort: [["Sales", "desc"]],
filter: [["Quantity", ">", 3]],
});
const paths = await view.column_paths();
expect(paths).toEqual([
"Central_Sales",
"East_Sales",
"South_Sales",
"West_Sales",
]);
const numRows = await view.num_rows();
expect(numRows).toBe(4284);
const json = await view.to_json({ start_row: 0, end_row: 1 });
expect(json).toEqual([
{
"Central|Sales": null,
"East|Sales": null,
"South|Sales": 22638.48,
"West|Sales": null,
},
]);
await view.delete();
});
test("flat + multi group_by + sort", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales"],
group_by: ["Region", "Category"],
sort: [["Sales", "desc"]],
aggregates: { Sales: "sum" },
group_rollup_mode: "flat",
});
const json = await view.to_json({ start_row: 0, end_row: 5 });
expect(json).toEqual([
{
__ROW_PATH__: ["West", "Furniture"],
Sales: 252612.7435000003,
},
{
__ROW_PATH__: ["West", "Technology"],
Sales: 251991.83199999997,
},
{
__ROW_PATH__: ["West", "Office Supplies"],
Sales: 220853.24900000007,
},
{
__ROW_PATH__: ["East", "Technology"],
Sales: 264973.9810000003,
},
{
__ROW_PATH__: ["East", "Furniture"],
Sales: 208291.20400000009,
},
]);
await view.delete();
});
test("flat + multi group_by + split_by + sort", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales"],
group_by: ["Region", "Category"],
split_by: ["Ship Mode"],
sort: [["Sales", "desc"]],
aggregates: { Sales: "sum" },
group_rollup_mode: "flat",
});
const json = await view.to_json({ start_row: 0, end_row: 5 });
expect(json).toEqual([
{
__ROW_PATH__: ["West", "Furniture"],
"First Class|Sales": 40018.829499999985,
"Same Day|Sales": 14527.978000000001,
"Second Class|Sales": 54155.6555,
"Standard Class|Sales": 143910.28049999996,
},
{
__ROW_PATH__: ["West", "Technology"],
"First Class|Sales": 61107.98900000001,
"Same Day|Sales": 19218.053999999993,
"Second Class|Sales": 38610.979999999996,
"Standard Class|Sales": 133054.809,
},
{
__ROW_PATH__: ["West", "Office Supplies"],
"First Class|Sales": 28635.06999999996,
"Same Day|Sales": 9857.678000000002,
"Second Class|Sales": 52572.79199999999,
"Standard Class|Sales": 129787.70900000003,
},
{
__ROW_PATH__: ["East", "Technology"],
"First Class|Sales": 47693.312999999995,
"Same Day|Sales": 21349.464999999997,
"Second Class|Sales": 29304.490000000005,
"Standard Class|Sales": 166626.71300000005,
},
{
__ROW_PATH__: ["East", "Furniture"],
"First Class|Sales": 29410.643999999997,
"Same Day|Sales": 12852.570999999996,
"Second Class|Sales": 44035.937000000005,
"Standard Class|Sales": 121992.05199999997,
},
]);
await view.delete();
});
test("expressions + group_by + sort", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["profitmargin"],
group_by: ["Region"],
expressions: { profitmargin: '"Profit" / "Sales" * 100' },
sort: [["profitmargin", "desc"]],
aggregates: { profitmargin: "avg" },
});
const json = await view.to_json();
expect(json).toEqual([
{
__ROW_PATH__: [],
profitmargin: 12.031392972104467,
},
{
__ROW_PATH__: ["West"],
profitmargin: 21.948661793784012,
},
{
__ROW_PATH__: ["East"],
profitmargin: 16.722695960406636,
},
{
__ROW_PATH__: ["South"],
profitmargin: 16.35190329218107,
},
{
__ROW_PATH__: ["Central"],
profitmargin: -10.407293926323575,
},
]);
await view.delete();
});
});
@@ -0,0 +1,100 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "@perspective-dev/test";
import { describeDuckDB } from "./setup.js";
describeDuckDB("data types", (getClient) => {
test("integer columns", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Quantity"],
});
const json = await view.to_json({ start_row: 0, end_row: 5 });
expect(json).toEqual([
{ Quantity: 2 },
{ Quantity: 3 },
{ Quantity: 2 },
{ Quantity: 5 },
{ Quantity: 2 },
]);
await view.delete();
});
test("float columns", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales", "Profit"],
});
const json = await view.to_json({ start_row: 0, end_row: 5 });
expect(json).toEqual([
{ Sales: 261.96, Profit: 41.9136 },
{ Sales: 731.94, Profit: 219.582 },
{ Sales: 14.62, Profit: 6.8714 },
{ Sales: 957.5775, Profit: -383.031 },
{ Sales: 22.368, Profit: 2.5164 },
]);
await view.delete();
});
test("string columns", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Region", "State", "City"],
});
const json = await view.to_json({ start_row: 0, end_row: 5 });
expect(json).toEqual([
{
Region: "South",
State: "Kentucky",
City: "Henderson",
},
{
Region: "South",
State: "Kentucky",
City: "Henderson",
},
{
Region: "West",
State: "California",
City: "Los Angeles",
},
{
Region: "South",
State: "Florida",
City: "Fort Lauderdale",
},
{
Region: "South",
State: "Florida",
City: "Fort Lauderdale",
},
]);
await view.delete();
});
test("date columns", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Order Date"],
});
const json = await view.to_json({ start_row: 0, end_row: 5 });
expect(json).toEqual([
{ "Order Date": 1478563200000 },
{ "Order Date": 1478563200000 },
{ "Order Date": 1465689600000 },
{ "Order Date": 1444521600000 },
{ "Order Date": 1444521600000 },
]);
await view.delete();
});
});
@@ -0,0 +1,89 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "@perspective-dev/test";
import { describeDuckDB } from "./setup.js";
describeDuckDB("expressions", (getClient) => {
test("simple expression", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales", "doublesales"],
expressions: { doublesales: '"Sales" * 2' },
});
const json = await view.to_json({ start_row: 0, end_row: 5 });
expect(json).toEqual([
{ Sales: 261.96, doublesales: 523.92 },
{ Sales: 731.94, doublesales: 1463.88 },
{ Sales: 14.62, doublesales: 29.24 },
{ Sales: 957.5775, doublesales: 1915.155 },
{ Sales: 22.368, doublesales: 44.736 },
]);
await view.delete();
});
test("expression with multiple columns", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales", "Profit", "margin"],
expressions: { margin: '"Profit" / "Sales"' },
});
const json = await view.to_json({ start_row: 0, end_row: 5 });
expect(json).toEqual([
{
Sales: 261.96,
Profit: 41.9136,
margin: 0.16000000000000003,
},
{ Sales: 731.94, Profit: 219.582, margin: 0.3 },
{
Sales: 14.62,
Profit: 6.8714,
margin: 0.47000000000000003,
},
{ Sales: 957.5775, Profit: -383.031, margin: -0.4 },
{ Sales: 22.368, Profit: 2.5164, margin: 0.1125 },
]);
await view.delete();
});
test("expression with group_by", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["total"],
group_by: ["Region"],
expressions: { total: '"Sales" + "Profit"' },
aggregates: { total: "sum" },
});
const json = await view.to_json();
expect(json).toEqual([
{ __ROW_PATH__: [], total: 2583597.882000014 },
{
__ROW_PATH__: ["Central"],
total: 540946.2532999996,
},
{ __ROW_PATH__: ["East"], total: 770304.0199999991 },
{
__ROW_PATH__: ["South"],
total: 438471.33530000027,
},
{ __ROW_PATH__: ["West"], total: 833876.2733999988 },
]);
await view.delete();
});
});
@@ -0,0 +1,184 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "@perspective-dev/test";
import { describeDuckDB } from "./setup.js";
describeDuckDB("filter", (getClient) => {
test("filter with equals", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales", "Region"],
filter: [["Region", "==", "West"]],
});
const json = await view.to_json({ start_row: 0, end_row: 5 });
expect(json).toEqual([
{ Sales: 14.62, Region: "West" },
{ Sales: 48.86, Region: "West" },
{ Sales: 7.28, Region: "West" },
{ Sales: 907.152, Region: "West" },
{ Sales: 18.504, Region: "West" },
]);
await view.delete();
});
test("filter with not equals", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales", "Region"],
filter: [["Region", "!=", "West"]],
});
const json = await view.to_json({ start_row: 0, end_row: 5 });
expect(json).toEqual([
{ Sales: 261.96, Region: "South" },
{ Sales: 731.94, Region: "South" },
{ Sales: 957.5775, Region: "South" },
{ Sales: 22.368, Region: "South" },
{ Sales: 15.552, Region: "South" },
]);
await view.delete();
});
test("filter with greater than", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales", "Quantity"],
filter: [["Quantity", ">", 5]],
});
const json = await view.to_json({ start_row: 0, end_row: 5 });
expect(json).toEqual([
{ Sales: 48.86, Quantity: 7 },
{ Sales: 907.152, Quantity: 6 },
{ Sales: 1706.184, Quantity: 9 },
{ Sales: 665.88, Quantity: 6 },
{ Sales: 19.46, Quantity: 7 },
]);
await view.delete();
});
test("filter with less than", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales", "Quantity"],
filter: [["Quantity", "<", 3]],
});
const json = await view.to_json({ start_row: 0, end_row: 5 });
expect(json).toEqual([
{ Sales: 261.96, Quantity: 2 },
{ Sales: 14.62, Quantity: 2 },
{ Sales: 22.368, Quantity: 2 },
{ Sales: 55.5, Quantity: 2 },
{ Sales: 8.56, Quantity: 2 },
]);
await view.delete();
});
test("filter with greater than or equal", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales", "Quantity"],
filter: [["Quantity", ">=", 10]],
});
const json = await view.to_json({ start_row: 0, end_row: 5 });
expect(json).toEqual([
{ Sales: 40.096, Quantity: 14 },
{ Sales: 43.12, Quantity: 14 },
{ Sales: 384.45, Quantity: 11 },
{ Sales: 3347.37, Quantity: 13 },
{ Sales: 100.24, Quantity: 10 },
]);
await view.delete();
});
test("filter with less than or equal", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales", "Quantity"],
filter: [["Quantity", "<=", 2]],
});
const json = await view.to_json({ start_row: 0, end_row: 5 });
expect(json).toEqual([
{ Sales: 261.96, Quantity: 2 },
{ Sales: 14.62, Quantity: 2 },
{ Sales: 22.368, Quantity: 2 },
{ Sales: 55.5, Quantity: 2 },
{ Sales: 8.56, Quantity: 2 },
]);
await view.delete();
});
test("filter with LIKE", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales", "State"],
filter: [["State", "LIKE", "Cal%"]],
});
const json = await view.to_json({ start_row: 0, end_row: 5 });
expect(json).toEqual([
{ Sales: 14.62, State: "California" },
{ Sales: 48.86, State: "California" },
{ Sales: 7.28, State: "California" },
{ Sales: 907.152, State: "California" },
{ Sales: 18.504, State: "California" },
]);
await view.delete();
});
test("multiple filters", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales", "Region", "Quantity"],
filter: [
["Region", "==", "West"],
["Quantity", ">", 3],
],
});
const json = await view.to_json({ start_row: 0, end_row: 5 });
expect(json).toEqual([
{ Sales: 48.86, Region: "West", Quantity: 7 },
{ Sales: 7.28, Region: "West", Quantity: 4 },
{ Sales: 907.152, Region: "West", Quantity: 6 },
{ Sales: 114.9, Region: "West", Quantity: 5 },
{ Sales: 1706.184, Region: "West", Quantity: 9 },
]);
await view.delete();
});
test("filter with group_by", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales"],
group_by: ["Category"],
filter: [["Region", "==", "West"]],
aggregates: { Sales: "sum" },
});
const numRows = await view.num_rows();
expect(numRows).toBe(4);
const json = await view.to_json();
expect(json).toEqual([
{ __ROW_PATH__: [], Sales: 725457.8245000006 },
{
__ROW_PATH__: ["Furniture"],
Sales: 252612.7435000003,
},
{
__ROW_PATH__: ["Office Supplies"],
Sales: 220853.24900000007,
},
{
__ROW_PATH__: ["Technology"],
Sales: 251991.83199999997,
},
]);
await view.delete();
});
});
@@ -0,0 +1,194 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "@perspective-dev/test";
import { describeDuckDB } from "./setup.js";
describeDuckDB("group_by", (getClient) => {
test("single group_by", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales"],
group_by: ["Region"],
aggregates: { Sales: "sum" },
});
const numRows = await view.num_rows();
expect(numRows).toBe(5);
const json = await view.to_json();
expect(json).toEqual([
{ __ROW_PATH__: [], Sales: 2297200.860299955 },
{
__ROW_PATH__: ["Central"],
Sales: 501239.8908000005,
},
{ __ROW_PATH__: ["East"], Sales: 678781.2399999979 },
{
__ROW_PATH__: ["South"],
Sales: 391721.9050000003,
},
{ __ROW_PATH__: ["West"], Sales: 725457.8245000006 },
]);
await view.delete();
});
test("multi-level group_by", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales"],
group_by: ["Region", "Category"],
aggregates: { Sales: "sum" },
});
const json = await view.to_json();
expect(json).toEqual([
{ __ROW_PATH__: [], Sales: 2297200.860299955 },
{
__ROW_PATH__: ["Central"],
Sales: 501239.8908000005,
},
{
__ROW_PATH__: ["Central", "Furniture"],
Sales: 163797.16380000004,
},
{
__ROW_PATH__: ["Central", "Office Supplies"],
Sales: 167026.41500000027,
},
{
__ROW_PATH__: ["Central", "Technology"],
Sales: 170416.3119999999,
},
{ __ROW_PATH__: ["East"], Sales: 678781.2399999979 },
{
__ROW_PATH__: ["East", "Furniture"],
Sales: 208291.20400000009,
},
{
__ROW_PATH__: ["East", "Office Supplies"],
Sales: 205516.0549999999,
},
{
__ROW_PATH__: ["East", "Technology"],
Sales: 264973.9810000003,
},
{
__ROW_PATH__: ["South"],
Sales: 391721.9050000003,
},
{
__ROW_PATH__: ["South", "Furniture"],
Sales: 117298.6840000001,
},
{
__ROW_PATH__: ["South", "Office Supplies"],
Sales: 125651.31299999992,
},
{
__ROW_PATH__: ["South", "Technology"],
Sales: 148771.9079999999,
},
{ __ROW_PATH__: ["West"], Sales: 725457.8245000006 },
{
__ROW_PATH__: ["West", "Furniture"],
Sales: 252612.7435000003,
},
{
__ROW_PATH__: ["West", "Office Supplies"],
Sales: 220853.24900000007,
},
{
__ROW_PATH__: ["West", "Technology"],
Sales: 251991.83199999997,
},
]);
await view.delete();
});
test("group_by with count aggregate", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales"],
group_by: ["Region"],
aggregates: { Sales: "count" },
});
const json = await view.to_json();
expect(json).toEqual([
{ __ROW_PATH__: [], Sales: 9994 },
{ __ROW_PATH__: ["Central"], Sales: 2323 },
{ __ROW_PATH__: ["East"], Sales: 2848 },
{ __ROW_PATH__: ["South"], Sales: 1620 },
{ __ROW_PATH__: ["West"], Sales: 3203 },
]);
await view.delete();
});
test("group_by with avg aggregate", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales"],
group_by: ["Category"],
aggregates: { Sales: "avg" },
});
const json = await view.to_json();
expect(json).toEqual([
{ __ROW_PATH__: [], Sales: 229.8580008304938 },
{
__ROW_PATH__: ["Furniture"],
Sales: 349.83488698727007,
},
{
__ROW_PATH__: ["Office Supplies"],
Sales: 119.32410089611732,
},
{
__ROW_PATH__: ["Technology"],
Sales: 452.70927612344155,
},
]);
await view.delete();
});
test("group_by with min aggregate", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Quantity"],
group_by: ["Region"],
aggregates: { Quantity: "min" },
});
const json = await view.to_json();
expect(json).toEqual([
{ __ROW_PATH__: [], Quantity: 1 },
{ __ROW_PATH__: ["Central"], Quantity: 1 },
{ __ROW_PATH__: ["East"], Quantity: 1 },
{ __ROW_PATH__: ["South"], Quantity: 1 },
{ __ROW_PATH__: ["West"], Quantity: 1 },
]);
await view.delete();
});
test("group_by with max aggregate", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Quantity"],
group_by: ["Region"],
aggregates: { Quantity: "max" },
});
const json = await view.to_json();
expect(json).toEqual([
{ __ROW_PATH__: [], Quantity: 14 },
{ __ROW_PATH__: ["Central"], Quantity: 14 },
{ __ROW_PATH__: ["East"], Quantity: 14 },
{ __ROW_PATH__: ["South"], Quantity: 14 },
{ __ROW_PATH__: ["West"], Quantity: 14 },
]);
await view.delete();
});
});
@@ -0,0 +1,256 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "@perspective-dev/test";
import { describeDuckDB } from "./setup.js";
describeDuckDB("group_rollup_mode", (getClient) => {
test("flat mode with group_by", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales"],
group_by: ["Region"],
aggregates: { Sales: "sum" },
group_rollup_mode: "flat",
});
const numRows = await view.num_rows();
expect(numRows).toBe(4);
const json = await view.to_json();
expect(json).toEqual([
{
__ROW_PATH__: ["Central"],
Sales: 501239.8908000005,
},
{ __ROW_PATH__: ["East"], Sales: 678781.2399999979 },
{
__ROW_PATH__: ["South"],
Sales: 391721.9050000003,
},
{ __ROW_PATH__: ["West"], Sales: 725457.8245000006 },
]);
await view.delete();
});
test("flat mode with multi-level group_by", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales"],
group_by: ["Region", "Category"],
aggregates: { Sales: "sum" },
group_rollup_mode: "flat",
});
const numRows = await view.num_rows();
expect(numRows).toBe(12);
const json = await view.to_json();
expect(json).toEqual([
{
__ROW_PATH__: ["Central", "Furniture"],
Sales: 163797.16380000004,
},
{
__ROW_PATH__: ["Central", "Office Supplies"],
Sales: 167026.41500000027,
},
{
__ROW_PATH__: ["Central", "Technology"],
Sales: 170416.3119999999,
},
{
__ROW_PATH__: ["East", "Furniture"],
Sales: 208291.20400000009,
},
{
__ROW_PATH__: ["East", "Office Supplies"],
Sales: 205516.0549999999,
},
{
__ROW_PATH__: ["East", "Technology"],
Sales: 264973.9810000003,
},
{
__ROW_PATH__: ["South", "Furniture"],
Sales: 117298.6840000001,
},
{
__ROW_PATH__: ["South", "Office Supplies"],
Sales: 125651.31299999992,
},
{
__ROW_PATH__: ["South", "Technology"],
Sales: 148771.9079999999,
},
{
__ROW_PATH__: ["West", "Furniture"],
Sales: 252612.7435000003,
},
{
__ROW_PATH__: ["West", "Office Supplies"],
Sales: 220853.24900000007,
},
{
__ROW_PATH__: ["West", "Technology"],
Sales: 251991.83199999997,
},
]);
await view.delete();
});
test("flat mode with group_by and split_by", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales"],
group_by: ["Category"],
split_by: ["Region"],
aggregates: { Sales: "sum" },
group_rollup_mode: "flat",
});
const numRows = await view.num_rows();
expect(numRows).toBe(3);
const json = await view.to_json();
expect(json).toEqual([
{
__ROW_PATH__: ["Furniture"],
"Central|Sales": 163797.16380000004,
"East|Sales": 208291.20400000009,
"South|Sales": 117298.6840000001,
"West|Sales": 252612.7435000003,
},
{
__ROW_PATH__: ["Office Supplies"],
"Central|Sales": 167026.41500000027,
"East|Sales": 205516.0549999999,
"South|Sales": 125651.31299999992,
"West|Sales": 220853.24900000007,
},
{
__ROW_PATH__: ["Technology"],
"Central|Sales": 170416.3119999999,
"East|Sales": 264973.9810000003,
"South|Sales": 148771.9079999999,
"West|Sales": 251991.83199999997,
},
]);
await view.delete();
});
test("flat mode with group_by and split_by and sort", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales"],
group_by: ["Category"],
split_by: ["Region"],
sort: [["Sales", "desc"]],
aggregates: { Sales: "sum" },
group_rollup_mode: "flat",
});
const numRows = await view.num_rows();
expect(numRows).toBe(3);
const json = await view.to_json();
expect(json).toEqual([
{
__ROW_PATH__: ["Technology"],
"Central|Sales": 170416.3119999999,
"East|Sales": 264973.9810000003,
"South|Sales": 148771.9079999999,
"West|Sales": 251991.83199999997,
},
{
__ROW_PATH__: ["Furniture"],
"Central|Sales": 163797.16380000004,
"East|Sales": 208291.20400000009,
"South|Sales": 117298.6840000001,
"West|Sales": 252612.7435000003,
},
{
__ROW_PATH__: ["Office Supplies"],
"Central|Sales": 167026.41500000027,
"East|Sales": 205516.0549999999,
"South|Sales": 125651.31299999992,
"West|Sales": 220853.24900000007,
},
]);
await view.delete();
});
test("flat mode with group_by and sort", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales"],
group_by: ["Region"],
sort: [["Sales", "desc"]],
aggregates: { Sales: "sum" },
group_rollup_mode: "flat",
});
const json = await view.to_json();
expect(json).toEqual([
{ __ROW_PATH__: ["West"], Sales: 725457.8245000006 },
{ __ROW_PATH__: ["East"], Sales: 678781.2399999979 },
{
__ROW_PATH__: ["Central"],
Sales: 501239.8908000005,
},
{
__ROW_PATH__: ["South"],
Sales: 391721.9050000003,
},
]);
await view.delete();
});
test("total mode", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales"],
aggregates: { Sales: "sum" },
group_rollup_mode: "total",
});
const numRows = await view.num_rows();
expect(numRows).toBe(1);
const json = await view.to_json();
expect(json).toEqual([{ Sales: 2297200.860299955 }]);
await view.delete();
});
test("total mode with multiple columns", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales", "Quantity"],
aggregates: { Sales: "sum", Quantity: "sum" },
group_rollup_mode: "total",
});
const json = await view.to_json();
expect(json).toEqual([{ Sales: 2297200.860299955, Quantity: 37873 }]);
await view.delete();
});
test("total mode with split_by", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales"],
split_by: ["Region"],
aggregates: { Sales: "sum" },
group_rollup_mode: "total",
});
const numRows = await view.num_rows();
expect(numRows).toBe(1);
const json = await view.to_json();
expect(json).toEqual([
{
"Central|Sales": 501239.8908000005,
"East|Sales": 678781.2399999979,
"South|Sales": 391721.9050000003,
"West|Sales": 725457.8245000006,
},
]);
await view.delete();
});
});
@@ -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). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
import { test, expect } from "@perspective-dev/test";
import { describeDuckDB } from "./setup.js";
describeDuckDB("min_max", (getClient) => {
test("get_min_max() on integer column", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({ columns: ["Quantity"] });
const result = await view.get_min_max("Quantity");
expect(result[0]).toBe(1);
expect(result[1]).toBe(14);
await view.delete();
});
test("get_min_max() on float column", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({ columns: ["Sales"] });
const result = await view.get_min_max("Sales");
expect(result[0]).toBe(0.444);
expect(result[1]).toBe(22638.48);
await view.delete();
});
test("get_min_max() on string column", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({ columns: ["Category"] });
const result = await view.get_min_max("Category");
expect(result[0]).toBe("Furniture");
expect(result[1]).toBe("Technology");
await view.delete();
});
test("get_min_max() with group_by", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales"],
group_by: ["Region"],
aggregates: { Sales: "sum" },
});
const result = await view.get_min_max("Sales");
expect(result[0]).toBeGreaterThan(0);
expect(result[1]).toBeGreaterThan(0);
expect(result[1]).toBeGreaterThanOrEqual(result[0]);
await view.delete();
});
test("get_min_max() with filter", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Quantity"],
filter: [["Quantity", ">", 10]],
});
const result = await view.get_min_max("Quantity");
expect(result[0]).toBeGreaterThanOrEqual(11);
expect(result[1]).toBe(14);
await view.delete();
});
});
@@ -0,0 +1,90 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 "fs";
import * as path from "path";
import { createRequire } from "module";
import * as duckdb from "@duckdb/duckdb-wasm";
import { test } from "@perspective-dev/test";
import {
default as perspective,
createMessageHandler,
wasmModule,
} from "@perspective-dev/client";
import { DuckDBHandler } from "@perspective-dev/client/src/ts/virtual_servers/duckdb.ts";
const require = createRequire(import.meta.url);
const DUCKDB_DIST = path.dirname(require.resolve("@duckdb/duckdb-wasm"));
const Worker = require("web-worker");
async function initializeDuckDB() {
const bundle = await duckdb.selectBundle({
mvp: {
mainModule: path.resolve(DUCKDB_DIST, "./duckdb-mvp.wasm"),
mainWorker: path.resolve(
DUCKDB_DIST,
"./duckdb-node-mvp.worker.cjs",
),
},
eh: {
mainModule: path.resolve(DUCKDB_DIST, "./duckdb-eh.wasm"),
mainWorker: path.resolve(
DUCKDB_DIST,
"./duckdb-node-eh.worker.cjs",
),
},
});
const logger = new duckdb.ConsoleLogger();
const worker = new Worker(bundle.mainWorker);
const db = new duckdb.AsyncDuckDB(logger, worker);
await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
const c = await db.connect();
await c.query(`
SET default_null_order=NULLS_FIRST_ON_ASC_LAST_ON_DESC;
`);
return c;
}
async function loadSuperstoreData(db) {
const arrowPath = path.resolve(
import.meta.dirname,
"../../../node_modules/superstore-arrow/superstore.lz4.arrow",
);
const arrayBuffer = fs.readFileSync(arrowPath);
await db.insertArrowFromIPCStream(new Uint8Array(arrayBuffer), {
name: "superstore",
create: true,
});
}
export function describeDuckDB(name, fn) {
test.describe("DuckDB Virtual Server " + name, function () {
let db;
let client;
test.beforeAll(async () => {
db = await initializeDuckDB();
const server = createMessageHandler(
new DuckDBHandler(db, wasmModule),
);
client = await perspective.worker(server);
await loadSuperstoreData(db);
});
fn(() => client);
});
}
@@ -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 { test, expect } from "@perspective-dev/test";
import { describeDuckDB } from "./setup.js";
describeDuckDB("sort", (getClient) => {
test("sort ascending", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales", "Quantity"],
sort: [["Sales", "asc"]],
});
const json = await view.to_json({ start_row: 0, end_row: 5 });
expect(json).toEqual([
{ Sales: 0.444, Quantity: 1 },
{ Sales: 0.556, Quantity: 1 },
{ Sales: 0.836, Quantity: 1 },
{ Sales: 0.852, Quantity: 1 },
{ Sales: 0.876, Quantity: 1 },
]);
await view.delete();
});
test("sort descending", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales", "Quantity"],
sort: [["Sales", "desc"]],
});
const json = await view.to_json({ start_row: 0, end_row: 5 });
expect(json).toEqual([
{ Sales: 22638.48, Quantity: 6 },
{ Sales: 17499.95, Quantity: 5 },
{ Sales: 13999.96, Quantity: 4 },
{ Sales: 11199.968, Quantity: 4 },
{ Sales: 10499.97, Quantity: 3 },
]);
await view.delete();
});
test("sort with group_by", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales"],
group_by: ["Region"],
sort: [["Sales", "desc"]],
aggregates: { Sales: "sum" },
});
const json = await view.to_json();
expect(json).toEqual([
{ __ROW_PATH__: [], Sales: 2297200.860299955 },
{ __ROW_PATH__: ["West"], Sales: 725457.8245000006 },
{ __ROW_PATH__: ["East"], Sales: 678781.2399999979 },
{
__ROW_PATH__: ["Central"],
Sales: 501239.8908000005,
},
{
__ROW_PATH__: ["South"],
Sales: 391721.9050000003,
},
]);
await view.delete();
});
test("multi-column sort", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Region", "Sales", "Quantity"],
sort: [
["Region", "asc"],
["Sales", "desc"],
],
});
const json = await view.to_json({ start_row: 0, end_row: 5 });
expect(json).toEqual([
{ Region: "Central", Sales: 17499.95, Quantity: 5 },
{ Region: "Central", Sales: 9892.74, Quantity: 13 },
{ Region: "Central", Sales: 9449.95, Quantity: 5 },
{ Region: "Central", Sales: 8159.952, Quantity: 8 },
{ Region: "Central", Sales: 5443.96, Quantity: 4 },
]);
await view.delete();
});
});
@@ -0,0 +1,124 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "@perspective-dev/test";
import { describeDuckDB } from "./setup.js";
describeDuckDB("split_by", (getClient) => {
test("single split_by", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales"],
split_by: ["Region"],
group_by: ["Category"],
aggregates: { Sales: "sum" },
});
const columns = await view.column_paths();
expect(columns).toEqual([
"Central_Sales",
"East_Sales",
"South_Sales",
"West_Sales",
]);
const json = await view.to_json();
expect(json).toEqual([
{
__ROW_PATH__: [],
"Central|Sales": 501239.8908000005,
"East|Sales": 678781.2399999979,
"South|Sales": 391721.9050000003,
"West|Sales": 725457.8245000006,
},
{
__ROW_PATH__: ["Furniture"],
"Central|Sales": 163797.16380000004,
"East|Sales": 208291.20400000009,
"South|Sales": 117298.6840000001,
"West|Sales": 252612.7435000003,
},
{
__ROW_PATH__: ["Office Supplies"],
"Central|Sales": 167026.41500000027,
"East|Sales": 205516.0549999999,
"South|Sales": 125651.31299999992,
"West|Sales": 220853.24900000007,
},
{
__ROW_PATH__: ["Technology"],
"Central|Sales": 170416.3119999999,
"East|Sales": 264973.9810000003,
"South|Sales": 148771.9079999999,
"West|Sales": 251991.83199999997,
},
]);
await view.delete();
});
// https://github.com/perspective-dev/perspective/issues/3148
test("split_by with group_by and count aggregate", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Row ID"],
split_by: ["Region"],
group_by: ["Category"],
aggregates: { "Row ID": "count" },
});
const json = await view.to_json({ start_row: 0, end_row: 4 });
expect(json).toEqual([
{
__ROW_PATH__: [],
"Central|Row ID": 2323,
"East|Row ID": 2848,
"South|Row ID": 1620,
"West|Row ID": 3203,
},
{
__ROW_PATH__: ["Furniture"],
"Central|Row ID": 481,
"East|Row ID": 601,
"South|Row ID": 332,
"West|Row ID": 707,
},
{
__ROW_PATH__: ["Office Supplies"],
"Central|Row ID": 1422,
"East|Row ID": 1712,
"South|Row ID": 995,
"West|Row ID": 1897,
},
{
__ROW_PATH__: ["Technology"],
"Central|Row ID": 420,
"East|Row ID": 535,
"South|Row ID": 293,
"West|Row ID": 599,
},
]);
await view.delete();
});
test.skip("split_by without group_by", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales"],
split_by: ["Category"],
});
const paths = await view.column_paths();
expect(paths.some((c) => c.includes("Furniture"))).toBe(true);
expect(paths.some((c) => c.includes("Office Supplies"))).toBe(true);
expect(paths.some((c) => c.includes("Technology"))).toBe(true);
await view.delete();
});
});
@@ -0,0 +1,78 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "@perspective-dev/test";
import { describeDuckDB } from "./setup.js";
describeDuckDB("table", (getClient) => {
test("schema()", async function () {
const table = await getClient().open_table("memory.superstore");
const schema = await table.schema();
expect(schema).toEqual({
"Product Name": "string",
"Ship Date": "date",
City: "string",
"Row ID": "integer",
"Customer Name": "string",
Quantity: "integer",
Discount: "float",
"Sub-Category": "string",
Segment: "string",
Category: "string",
"Order Date": "date",
"Order ID": "string",
Sales: "float",
State: "string",
"Postal Code": "float",
Country: "string",
"Customer ID": "string",
"Ship Mode": "string",
Region: "string",
Profit: "float",
"Product ID": "string",
});
});
test("columns()", async function () {
const table = await getClient().open_table("memory.superstore");
const columns = await table.columns();
expect(columns).toEqual([
"Row ID",
"Order ID",
"Order Date",
"Ship Date",
"Ship Mode",
"Customer ID",
"Customer Name",
"Segment",
"Country",
"City",
"State",
"Postal Code",
"Region",
"Product ID",
"Category",
"Sub-Category",
"Product Name",
"Sales",
"Quantity",
"Discount",
"Profit",
]);
});
test("size()", async function () {
const table = await getClient().open_table("memory.superstore");
const size = await table.size();
expect(size).toBe(9994);
});
});
@@ -0,0 +1,91 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "@perspective-dev/test";
import { describeDuckDB } from "./setup.js";
describeDuckDB("view", (getClient) => {
test("num_rows()", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({ columns: ["Sales", "Profit"] });
const numRows = await view.num_rows();
expect(numRows).toBe(9994);
await view.delete();
});
test("num_columns()", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales", "Profit", "State"],
});
const numColumns = await view.num_columns();
expect(numColumns).toBe(3);
await view.delete();
});
test("schema()", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales", "Profit", "State"],
});
const schema = await view.schema();
expect(schema).toEqual({
Sales: "float",
Profit: "float",
State: "string",
});
await view.delete();
});
test("to_json()", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales", "Quantity"],
});
const json = await view.to_json({ start_row: 0, end_row: 5 });
expect(json).toEqual([
{ Sales: 261.96, Quantity: 2 },
{ Sales: 731.94, Quantity: 3 },
{ Sales: 14.62, Quantity: 2 },
{ Sales: 957.5775, Quantity: 5 },
{ Sales: 22.368, Quantity: 2 },
]);
await view.delete();
});
test("to_columns()", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales", "Quantity"],
});
const columns = await view.to_columns({
start_row: 0,
end_row: 5,
});
expect(columns).toEqual({
Sales: [261.96, 731.94, 14.62, 957.5775, 22.368],
Quantity: [2, 3, 2, 5, 2],
});
await view.delete();
});
test("column_paths()", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales", "Profit", "State"],
});
const paths = await view.column_paths();
expect(paths).toEqual(["Sales", "Profit", "State"]);
await view.delete();
});
});
@@ -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). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
import { test, expect } from "@perspective-dev/test";
import { describeDuckDB } from "./setup.js";
describeDuckDB("viewport", (getClient) => {
test("start_row and end_row", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales", "Profit"],
});
const json = await view.to_json({ start_row: 10, end_row: 15 });
expect(json).toEqual([
{ Sales: 1706.184, Profit: 85.3092 },
{ Sales: 911.424, Profit: 68.3568 },
{ Sales: 15.552, Profit: 5.4432 },
{ Sales: 407.976, Profit: 132.5922 },
{ Sales: 68.81, Profit: -123.858 },
]);
await view.delete();
});
test("start_col and end_col", async function () {
const table = await getClient().open_table("memory.superstore");
const view = await table.view({
columns: ["Sales", "Profit", "Quantity", "Discount"],
});
const json = await view.to_json({
start_row: 0,
end_row: 5,
start_col: 1,
end_col: 3,
});
expect(json).toEqual([
{ Profit: 41.9136, Quantity: 2 },
{ Profit: 219.582, Quantity: 3 },
{ Profit: 6.8714, Quantity: 2 },
{ Profit: -383.031, Quantity: 5 },
{ Profit: 2.5164, Quantity: 2 },
]);
await view.delete();
});
});
@@ -0,0 +1,163 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 arrows from "../test_arrows";
export const data = [
{ x: 1, y: "a", z: true },
{ x: 2, y: "b", z: false },
{ x: 3, y: "c", z: true },
{ x: 4, y: "d", z: false },
];
export const int_float_data = [
{ w: 1.5, x: 1, y: "a", z: true },
{ w: 2.5, x: 2, y: "b", z: false },
{ w: 3.5, x: 3, y: "c", z: true },
{ w: 4.5, x: 4, y: "d", z: false },
];
export const int_float_subtract_data = [
{ u: 2.5, v: 2, w: 1.5, x: 1, y: "a", z: true },
{ u: 3.5, v: 3, w: 2.5, x: 2, y: "b", z: false },
{ u: 4.5, v: 4, w: 3.5, x: 3, y: "c", z: true },
{ u: 5.5, v: 5, w: 4.5, x: 4, y: "d", z: false },
];
export const comparison_data = [
{ u: 0, v: 1.5, w: 1.5, x: 1, x2: 1, y: "a", z: true },
{ u: 1, v: 2.55, w: 2.5, x: 2, x2: 10, y: "b", z: false },
{ u: 0, v: 3.5, w: 3.5, x: 3, x2: 3, y: "c", z: true },
{ u: 1, v: 4.55, w: 4.5, x: 4, x2: 10, y: "d", z: false },
];
export const cols = [
"i8",
"ui8",
"i16",
"ui16",
"i32",
"ui32",
"i64",
"ui64",
"f32",
"f64",
];
export const arrow = arrows.numbers_arrow;
export const all_types_arrow = arrows.all_types_arrow;
export const all_types_multi_arrow = arrows.all_types_multi_arrow;
export const days_of_week = [
"1 Sunday",
"2 Monday",
"3 Tuesday",
"4 Wednesday",
"5 Thursday",
"6 Friday",
"7 Saturday",
];
export const months_of_year = [
"01 January",
"02 February",
"03 March",
"04 April",
"05 May",
"06 June",
"07 July",
"08 August",
"09 September",
"10 October",
"11 November",
"12 December",
];
export const second_bucket = function (val, multiplicity) {
if (multiplicity === undefined) {
multiplicity = 1;
}
const mult = 1000 * multiplicity;
return new Date(Math.floor(new Date(val).getTime() / mult) * mult);
};
export const minute_bucket = function (val, multiplicity) {
if (multiplicity === undefined) {
multiplicity = 1;
}
let date = new Date(val);
date.setSeconds(0);
date.setMilliseconds(0);
date.setMinutes(
Math.floor(date.getMinutes() / multiplicity) * multiplicity,
);
return date;
};
export const hour_bucket = function (val, multiplicity) {
if (multiplicity === undefined) {
multiplicity = 1;
}
let date = new Date(val);
date.setMinutes(0);
date.setSeconds(0);
date.setMilliseconds(0);
date.setHours(Math.floor(date.getHours() / multiplicity) * multiplicity);
return date;
};
export const day_bucket = function (val) {
let date = new Date(val);
date.setHours(0);
date.setMinutes(0);
date.setSeconds(0);
date.setMilliseconds(0);
return date;
};
export const week_bucket = function (val) {
let date = new Date(val);
let day = date.getDay();
let diff = date.getDate() - day + (day == 0 ? -6 : 1);
date.setHours(0);
date.setMinutes(0);
date.setSeconds(0);
date.setDate(diff);
return date;
};
export const month_bucket = function (val, multiplicity) {
if (multiplicity === undefined) {
multiplicity = 1;
}
let date = new Date(val);
date.setHours(0);
date.setMinutes(0);
date.setSeconds(0);
date.setDate(1);
date.setMonth(Math.floor(date.getMonth() / multiplicity) * multiplicity);
return date;
};
export const year_bucket = function (val, multiplicity) {
if (multiplicity === undefined) {
multiplicity = 1;
}
let date = new Date(val);
date.setHours(0);
date.setMinutes(0);
date.setSeconds(0);
date.setDate(1);
date.setMonth(0);
date.setFullYear(
Math.floor(date.getFullYear() / multiplicity) * multiplicity,
);
return date;
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,591 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "@perspective-dev/test";
import perspective from "../perspective_client";
const match_delta = async function (perspective, delta, expected) {
let table = await perspective.table(delta);
let view = await table.view();
let json = await view.to_json();
expect(json).toEqual(expected);
await view.delete();
await table.delete();
};
function it_old_behavior(name, capture) {
test(name, async function () {
let done;
let result = new Promise((x) => {
done = x;
});
await capture(done);
await result;
});
}
/**
* Tests the correctness of updates on Tables with expression columns created
* through `View` and deltas created through `on_update`.
*/
((perspective) => {
test.describe("Expression column update deltas", function () {
test.describe("0-sided expression column deltas", function () {
it_old_behavior(
"Returns appended rows for normal and expression columns",
async function (done) {
const table = await perspective.table({
x: [1, 2, 3, 4],
y: ["A", "B", "C", "D"],
});
const view = await table.view({
expressions: {
'lower("y")': 'lower("y")',
'-"x"': '-"x"',
},
});
view.on_update(
async function (updated) {
const expected = [
{
x: 1,
y: "HELLO",
'lower("y")': "hello",
'-"x"': -1,
},
{
x: 3,
y: "WORLD",
'lower("y")': "world",
'-"x"': -3,
},
];
await match_delta(
perspective,
updated.delta,
expected,
);
await view.delete();
await table.delete();
done();
},
{ mode: "row" },
);
await table.update({ x: [1, 3], y: ["HELLO", "WORLD"] });
},
);
it_old_behavior(
"Returns appended rows for normal and expression columns from schema",
async function (done) {
const table = await perspective.table({
x: "integer",
y: "string",
});
const view = await table.view({
expressions: { 'upper("y")': 'upper("y")' },
});
view.on_update(
async function (updated) {
const expected = [
{ x: 1, y: "a", 'upper("y")': "A" },
{ x: 2, y: "b", 'upper("y")': "B" },
{ x: 3, y: "c", 'upper("y")': "C" },
{ x: 4, y: "d", 'upper("y")': "D" },
];
await match_delta(
perspective,
updated.delta,
expected,
);
await view.delete();
await table.delete();
done();
},
{ mode: "row" },
);
await table.update({
x: [1, 2, 3, 4],
y: ["a", "b", "c", "d"],
});
},
);
it_old_behavior(
"Returns partially updated rows for normal and expression columns",
async function (done) {
const table = await perspective.table(
{
x: [1, 2, 3, 4],
y: ["A", "B", "C", "D"],
},
{ index: "x" },
);
const view = await table.view({
expressions: { 'lower("y")': 'lower("y")' },
});
view.on_update(
async function (updated) {
const full = await view.to_columns();
const expected = [
{ x: 1, y: "HELLO", 'lower("y")': "hello" },
{ x: 3, y: "WORLD", 'lower("y")': "world" },
];
expect(full).toEqual({
x: [1, 2, 3, 4],
y: ["HELLO", "B", "WORLD", "D"],
'lower("y")': ["hello", "b", "world", "d"],
});
await match_delta(
perspective,
updated.delta,
expected,
);
await view.delete();
await table.delete();
done();
},
{ mode: "row" },
);
await table.update({ x: [1, 3], y: ["HELLO", "WORLD"] });
},
);
it_old_behavior(
"Returns appended rows with missing columns for normal and expression columns",
async function (done) {
const table = await perspective.table({
x: [1, 2, 3, 4],
y: ["A", "B", "C", "D"],
});
const view = await table.view({
expressions: { 'lower("y")': 'lower("y")' },
});
view.on_update(
async function (updated) {
const full = await view.to_columns();
const expected = [
{ x: 1, y: null, 'lower("y")': null },
{ x: 3, y: null, 'lower("y")': null },
];
expect(full).toEqual({
x: [1, 2, 3, 4, 1, 3],
y: ["A", "B", "C", "D", null, null],
'lower("y")': ["a", "b", "c", "d", null, null],
});
await match_delta(
perspective,
updated.delta,
expected,
);
await view.delete();
await table.delete();
done();
},
{ mode: "row" },
);
await table.update({ x: [1, 3] });
},
);
});
test.describe("1-sided expression column deltas", function () {
it_old_behavior(
"Returns appended rows for normal and expression columns, 1-sided",
async function (done) {
const table = await perspective.table({
x: [1, 2, 3, 4],
y: ["A", "B", "C", "D"],
});
const view = await table.view({
group_by: ['lower("y")'],
expressions: { 'lower("y")': 'lower("y")' },
});
view.on_update(
async function (updated) {
const expected = [
{ x: 14, y: 6, 'lower("y")': 6 },
{ x: 1, y: 1, 'lower("y")': 1 },
{ x: 3, y: 1, 'lower("y")': 1 },
];
await match_delta(
perspective,
updated.delta,
expected,
);
await view.delete();
await table.delete();
done();
},
{ mode: "row" },
);
await table.update({ x: [1, 3], y: ["HELLO", "WORLD"] });
},
);
it_old_behavior(
"Returns appended rows for normal and expression columns, 1-sided hidden sorted",
async function (done) {
const table = await perspective.table({
x: [1, 2, 3, 4],
y: ["A", "B", "C", "D"],
});
const view = await table.view({
group_by: ['lower("y")'],
expressions: { 'lower("y")': 'lower("y")' },
sort: [['lower("y")', "desc"]],
columns: ["x"],
});
view.on_update(
async function (updated) {
const expected = [{ x: 14 }, { x: 3 }, { x: 1 }];
await match_delta(
perspective,
updated.delta,
expected,
);
await view.delete();
await table.delete();
done();
},
{ mode: "row" },
);
await table.update({ x: [1, 3], y: ["HELLO", "WORLD"] });
},
);
});
test.describe("2-sided expression column deltas", function () {
it_old_behavior(
"Returns appended rows for normal and expression columns, 2-sided",
async function (done) {
const table = await perspective.table({
x: [1, 2, 3, 4],
y: ["A", "B", "C", "D"],
});
const view = await table.view({
aggregates: {
'lower("y")': "last",
},
group_by: ['lower("y")'],
split_by: ["y"],
expressions: { 'lower("y")': 'lower("y")' },
});
view.on_update(
async function (updated) {
const expected = [
{
'A|lower("y")': "a",
"A|x": 1,
"A|y": 1,
'B|lower("y")': "b",
"B|x": 2,
"B|y": 1,
'C|lower("y")': "c",
"C|x": 3,
"C|y": 1,
'D|lower("y")': "d",
"D|x": 4,
"D|y": 1,
'HELLO|lower("y")': "hello",
"HELLO|x": 1,
"HELLO|y": 1,
'WORLD|lower("y")': "world",
"WORLD|x": 3,
"WORLD|y": 1,
},
{
'A|lower("y")': null,
"A|x": null,
"A|y": null,
'B|lower("y")': null,
"B|x": null,
"B|y": null,
'C|lower("y")': null,
"C|x": null,
"C|y": null,
'D|lower("y")': null,
"D|x": null,
"D|y": null,
'HELLO|lower("y")': "hello",
"HELLO|x": 1,
"HELLO|y": 1,
'WORLD|lower("y")': null,
"WORLD|x": null,
"WORLD|y": null,
},
{
'A|lower("y")': null,
"A|x": null,
"A|y": null,
'B|lower("y")': null,
"B|x": null,
"B|y": null,
'C|lower("y")': null,
"C|x": null,
"C|y": null,
'D|lower("y")': null,
"D|x": null,
"D|y": null,
'HELLO|lower("y")': null,
"HELLO|x": null,
"HELLO|y": null,
'WORLD|lower("y")': "world",
"WORLD|x": 3,
"WORLD|y": 1,
},
];
await match_delta(
perspective,
updated.delta,
expected,
);
await view.delete();
await table.delete();
done();
},
{ mode: "row" },
);
await table.update({ x: [1, 3], y: ["HELLO", "WORLD"] });
},
);
});
test.describe("0-sided expression column deltas with multiple views", function () {
it_old_behavior(
"`on_update` on a view with expression column should contain expression delta when columns are appended",
async function (done) {
const table = await perspective.table({
x: [1, 2, 3, 4],
y: ["A", "B", "C", "D"],
});
const view = await table.view({
expressions: { 'lower("y")': 'lower("y")' },
});
const pre_update = await view.to_columns();
expect(pre_update).toEqual({
x: [1, 2, 3, 4],
y: ["A", "B", "C", "D"],
'lower("y")': ["a", "b", "c", "d"],
});
view.on_update(
async function (updated) {
const expected = [
{ x: 1, y: null, 'lower("y")': null },
{ x: 3, y: null, 'lower("y")': null },
];
const full = await view.to_columns();
expect(full).toEqual({
x: [1, 2, 3, 4, 1, 3],
y: ["A", "B", "C", "D", null, null],
'lower("y")': ["a", "b", "c", "d", null, null],
});
await match_delta(
perspective,
updated.delta,
expected,
);
await view.delete();
await table.delete();
done();
},
{ mode: "row" },
);
await table.update({ x: [1, 3] });
},
);
it_old_behavior(
"`on_update` on a view with expression column should contain expression delta when columns are updated",
async function (done) {
const table = await perspective.table(
{
x: [1, 2, 3, 4],
y: ["A", "B", "C", "D"],
},
{ index: "x" },
);
const view = await table.view({
expressions: { 'lower("y")': 'lower("y")' },
});
const pre_update = await view.to_columns();
expect(pre_update).toEqual({
x: [1, 2, 3, 4],
y: ["A", "B", "C", "D"],
'lower("y")': ["a", "b", "c", "d"],
});
view.on_update(
async function (updated) {
const expected = [
{ x: 1, y: "ABCD", 'lower("y")': "abcd" },
{ x: 3, y: null, 'lower("y")': null },
];
const full = await view.to_columns();
expect(full).toEqual({
x: [1, 2, 3, 4],
y: ["ABCD", "B", null, "D"],
'lower("y")': ["abcd", "b", null, "d"],
});
await match_delta(
perspective,
updated.delta,
expected,
);
await view.delete();
await table.delete();
done();
},
{ mode: "row" },
);
await table.update({ x: [1, 3], y: ["ABCD", null] });
},
);
it_old_behavior(
"`on_update` on different views with different expression columns should only be notified of their columns",
async function (done) {
const table = await perspective.table(
{
x: [1, 2, 3, 4],
y: ["A", "B", "C", "D"],
},
{
index: "x",
},
);
const view = await table.view({
expressions: { 'lower("y")': 'lower("y")' },
});
const view2 = await table.view({
expressions: { '-"x"': '-"x"' },
});
view.on_update(
async function (updated) {
const expected = [
{ x: 1, y: "HELLO", 'lower("y")': "hello" },
{ x: 3, y: "WORLD", 'lower("y")': "world" },
];
const full = await view.to_columns();
expect(full).toEqual({
x: [1, 2, 3, 4],
y: ["HELLO", "B", "WORLD", "D"],
'lower("y")': ["hello", "b", "world", "d"],
});
await match_delta(
perspective,
updated.delta,
expected,
);
},
{ mode: "row" },
);
view2.on_update(
async function (updated) {
const expected = [
{ x: 1, y: "HELLO", '-"x"': -1 },
{ x: 3, y: "WORLD", '-"x"': -3 },
];
const full = await view2.to_columns();
expect(full).toEqual({
x: [1, 2, 3, 4],
y: ["HELLO", "B", "WORLD", "D"],
'-"x"': [-1, -2, -3, -4],
});
await match_delta(
perspective,
updated.delta,
expected,
);
await view.delete();
await view2.delete();
await table.delete();
done();
},
{ mode: "row" },
);
await table.update({ x: [1, 3], y: ["HELLO", "WORLD"] });
},
);
it_old_behavior(
"`on_update` on view without expression column should not be notified of expression column",
async function (done) {
const table = await perspective.table({
x: [1, 2, 3, 4],
y: ["A", "B", "C", "D"],
});
const view = await table.view();
const view2 = await table.view({
expressions: { 'lower("y")': 'lower("y")' },
});
expect(await view2.to_columns()).toEqual({
x: [1, 2, 3, 4],
y: ["A", "B", "C", "D"],
'lower("y")': ["a", "b", "c", "d"],
});
view.on_update(
async function (updated) {
const expected = [
{ x: 1, y: "abc" },
{ x: 3, y: "def" },
];
await match_delta(
perspective,
updated.delta,
expected,
);
await view2.delete();
await view.delete();
await table.delete();
done();
},
{ mode: "row" },
);
await table.update({ x: [1, 3], y: ["abc", "def"] });
},
);
});
});
})(perspective);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,385 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "@perspective-dev/test";
import perspective from "../perspective_client";
const jsc = require("jsverify");
const replicate = (n, g) => jsc.tuple(new Array(n).fill(g));
const array_equals = function (a, b) {
return JSON.stringify(a) === JSON.stringify(b);
};
const generator = function (length = 100, has_zero = true) {
const min = has_zero ? 0 : 1;
return jsc.record({
a: replicate(length, jsc.number(min, 1000000)),
b: replicate(length, jsc.number(min, 1000000)),
c: replicate(length, jsc.string),
d: replicate(length, jsc.datetime),
e: replicate(length, jsc.bool),
});
};
/**
* Uses invariant testing to assert base correctness of computed columns.
*/
((perspective) => {
test.describe("Invariant testing", function () {
test.describe("Inverse expressions should be invariant", function () {
jsc.property("(x - y) + x == y", generator(), async (data) => {
const table = await perspective.table(data);
const view = await table.view({
expressions: { '("a" - "b") + "b"': '("a" - "b") + "b"' },
});
const result = await view.to_columns();
const expected = array_equals(
result['("a" - "b") + "b"'],
data["a"],
);
view.delete();
table.delete();
return expected;
});
jsc.property("(x + y) - x - y == 0", generator(), async (data) => {
const table = await perspective.table(data);
const view = await table.view({
expressions: {
'("a" + "b") - "a" - "b"': '("a" + "b") - "a" - "b"',
},
});
const result = await view.to_columns();
const expected = array_equals(
result['("a" + "b") - "a" - "b"'],
Array(data["a"].length).fill(0),
);
view.delete();
table.delete();
return expected;
});
jsc.property(
"(x + y) - (x + y) == 0",
generator(),
async (data) => {
const table = await perspective.table(data);
const view = await table.view({
expressions: {
'("a" + "b") - ("a" + "b")':
'("a" + "b") - ("a" + "b")',
},
});
const result = await view.to_columns();
const expected = array_equals(
result['("a" + "b") - ("a" + "b")'],
Array(data["a"].length).fill(0),
);
view.delete();
table.delete();
return expected;
},
);
jsc.property("(x - x) == 0", generator(), async (data) => {
const table = await perspective.table(data);
const view = await table.view({
expressions: { '"a" - "a"': '"a" - "a"' },
});
const result = await view.to_columns();
const expected = array_equals(
result['"a" - "a"'],
Array(data["a"].length).fill(0),
);
view.delete();
table.delete();
return expected;
});
jsc.property("(x / x) == 1", generator(), async (data) => {
const table = await perspective.table(data);
const view = await table.view({
expressions: { '"a" / "a"': '"a" / "a"' },
});
const result = await view.to_columns();
const expected = array_equals(
result['"a" / "a"'],
Array(data["a"].length).fill(1),
);
view.delete();
table.delete();
return expected;
});
jsc.property("(x + x) - x - x == 0", generator(), async (data) => {
const table = await perspective.table(data);
const view = await table.view({
expressions: {
'("a" + "a") - "a" - "a"': '("a" + "a") - "a" - "a"',
},
});
const result = await view.to_columns();
const expected = array_equals(
result['("a" + "a") - "a" - "a"'],
Array(data["a"].length).fill(0),
);
view.delete();
table.delete();
return expected;
});
jsc.property(
"sqrt(x ^ 2) == x",
generator(100, false),
async (data) => {
const table = await perspective.table(data);
const view = await table.view({
expressions: {
'sqrt(pow("a", 2))': 'sqrt(pow("a", 2))',
},
});
const result = await view.to_columns();
const expected = array_equals(
result['sqrt(pow("a", 2))'],
data["a"],
);
view.delete();
table.delete();
return expected;
},
);
// jsc.property(
// "x ^ 2 == (x * x)",
// generator(100, false),
// async (data) => {
// const table = await perspective.table(data);
// const view = await table.view({
// expressions: ['pow("a", 2)', '"a" * "a"'].reduce((x, y) => Object.assign(x, {[y]: y}), {}),
// });
// const result = await view.to_columns();
// const expected = array_equals(
// result['pow("a", 2)'],
// result['"a" * "a"']
// );
// view.delete();
// table.delete();
// return expected;
// }
// );
jsc.property(
"x % x == 100",
generator(100, false),
async (data) => {
const table = await perspective.table(data);
const view = await table.view({
expressions: {
'percent_of("a", "a")': 'percent_of("a", "a")',
},
});
const result = await view.to_columns();
const expected = array_equals(
result['percent_of("a", "a")'],
Array(data["a"].length).fill(100),
);
view.delete();
table.delete();
return expected;
},
);
jsc.property(
"abs(x) ? x > 0 == x",
generator(100, false),
async (data) => {
const table = await perspective.table(data);
const view = await table.view({
expressions: { 'abs("a")': 'abs("a")' },
});
const result = await view.to_columns();
const expected = array_equals(
result['abs("a")'],
data["a"],
);
view.delete();
table.delete();
return expected;
},
);
});
test.describe("Comparison operations should be pure with the same inputs.", function () {
jsc.property(
"== should always be true with the same input column",
generator(),
async (data) => {
const table = await perspective.table(data);
const view = await table.view({
expressions: { '"a" == "a"': '"a" == "a"' },
});
const result = await view.to_columns();
const expected = array_equals(
result['"a" == "a"'],
Array(data["a"].length).fill(true),
);
view.delete();
table.delete();
return expected;
},
);
jsc.property(
"> should always be false with the same input column",
generator(),
async (data) => {
const table = await perspective.table(data);
const view = await table.view({
expressions: { '"a" > "a"': '"a" > "a"' },
});
const result = await view.to_columns();
const expected = array_equals(
result['"a" > "a"'],
Array(data["a"].length).fill(false),
);
view.delete();
table.delete();
return expected;
},
);
jsc.property(
"< should always be false with the same input column",
generator(),
async (data) => {
const table = await perspective.table(data);
const view = await table.view({
expressions: { '"a" < "a"': '"a" < "a"' },
});
const result = await view.to_columns();
const expected = array_equals(
result['"a" < "a"'],
Array(data["a"].length).fill(false),
);
view.delete();
table.delete();
return expected;
},
);
jsc.property(
"String == should always be true with the same input column",
generator(),
async (data) => {
const table = await perspective.table({
a: "float",
b: "float",
c: "string",
d: "datetime",
e: "boolean",
});
await table.update(data);
const view = await table.view({
expressions: { '"c" == "c"': '"c" == "c"' },
});
const result = await view.to_columns();
const expected = array_equals(
result['"c" == "c"'],
Array(data["c"].length).fill(true),
);
view.delete();
table.delete();
return expected;
},
);
jsc.property(
"Datetime == should always be true with the same input column",
generator(),
async (data) => {
const table = await perspective.table({
a: "float",
b: "float",
c: "string",
d: "datetime",
e: "boolean",
});
await table.update(data);
const view = await table.view({
expressions: { '"d" == "d"': '"d" == "d"' },
});
const result = await view.to_columns();
const expected = array_equals(
result['"d" == "d"'],
Array(data["d"].length).fill(true),
);
view.delete();
table.delete();
return expected;
},
);
jsc.property(
"Date == should always be true with the same input column",
generator(),
async (data) => {
const table = await perspective.table({
a: "float",
b: "float",
c: "string",
d: "date",
e: "boolean",
});
await table.update(data);
const view = await table.view({
expressions: { '"d" == "d"': '"d" == "d"' },
});
const result = await view.to_columns();
const expected = array_equals(
result['"d" == "d"'],
Array(data["d"].length).fill(true),
);
view.delete();
table.delete();
return expected;
},
);
});
});
})(perspective);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "@perspective-dev/test";
import perspective from "../perspective_client";
import * as common from "./common.js";
/**
* Tests the correctness of each datetime computation function in various
* environments and parameters - different types, nulls, undefined, etc.
*/
((perspective) => {
test.describe("bucket() function", function () {
test.describe("parses arguments separated by arbitrary whitespace", function () {
for (const [title, expression] of [
["space before", `bucket( "a",'Y')`],
["space after", `bucket("a",'Y' )`],
["space between", `bucket("a", 'Y')`],
["two spaces before", `bucket( "a",'Y')`],
["two spaces after", `bucket("a",'Y' )`],
["two spaces between", `bucket("a", 'Y')`],
["space before between and after", `bucket( "a", 'Y' )`],
[
"two spaces before between and after",
`bucket( "a", 'Y' )`,
],
]) {
test(title, async function () {
const table = await perspective.table({
a: "datetime",
});
const view = await table.view({
expressions: [expression].reduce(
(x, y) => Object.assign(x, { [y]: y }),
{},
),
});
await table.update({
a: [
new Date(2020, 0, 12),
new Date(2020, 0, 15),
new Date(2021, 11, 17),
new Date(2019, 2, 18),
new Date(2019, 2, 29),
],
});
let result = await view.to_columns();
expect(
result[expression].map((x) => (x ? new Date(x) : null)),
).toEqual(result.a.map((x) => common.year_bucket(x)));
view.delete();
table.delete();
});
}
});
});
})(perspective);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,143 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { test, expect } from "@perspective-dev/test";
import perspective from "../perspective_client";
// Concrete use cases from issue #1527 ("Better string functions in
// expressions"). The original issue body sketched these in a hypothetical
// dialect; the tests below port them to the dialect Perspective actually
// implements. Differences from the issue's pseudocode:
//
// - `find(str, substr) -> int` does not exist. The closest function is
// `indexof(col, regex, out_vec) -> bool`, which performs a *regex* search,
// writes [start, end] of the first capturing group into `out_vec`, and
// requires the regex to have at least one capturing group (else it
// returns STATUS_CLEAR). The tests therefore wrap the literal char in a
// capturing group: `' '` -> `'( )'`, `','` -> `'(,)'`, `$` -> `'([$])'`.
// - `null()` is not a function call; `null` is a literal.
// - `strlen(s)` -> `length(s)`.
// - `substring(s, start, count)` takes a *count*, not an end index, and
// returns null if `start + count > length(s)`.
// - String literals pass through ExprTK's `cleanup_escapes`, which drops
// unrecognized escape characters (`\s` -> `s`, `\.` -> `.`).
((perspective) => {
test.describe("Issue 1527 use cases", function () {
test("contains literal substring", async function () {
const table = await perspective.table({
a: ["abcdef", "xyz", "abXabY", null, "abc"],
});
const view = await table.view({
expressions: {
has_ab: "contains(\"a\", 'ab')",
},
});
const result = await view.to_columns();
const schema = await view.expression_schema();
expect(schema["has_ab"]).toEqual("boolean");
expect(result["has_ab"]).toEqual([true, false, true, null, true]);
view.delete();
table.delete();
});
// Parse "USD $1000"-style strings into Currency (string) and Value
// (float) columns, tolerant of malformed rows.
test("split currency/value string column", async function () {
const table = await perspective.table({
"Bad Column": [
"USD $1000",
"EUR $250",
"malformed",
null,
"GBP $42",
],
});
const view = await table.view({
expressions: {
Currency: `var v[2];
if (indexof("Bad Column", '( )', v)) { substring("Bad Column", 0, v[0]) } else { null }`,
Value: `var v[2];
if (indexof("Bad Column", '([$])', v)) { float(substring("Bad Column", v[0] + 1)) } else { null }`,
},
});
const result = await view.to_columns();
const schema = await view.expression_schema();
expect(schema["Currency"]).toEqual("string");
expect(schema["Value"]).toEqual("float");
expect(result["Currency"]).toEqual([
"USD",
"EUR",
null,
null,
"GBP",
]);
expect(result["Value"]).toEqual([1000, 250, null, null, 42]);
view.delete();
table.delete();
});
// Parse "(123, 456)"-style strings into Longitude and Latitude
// float columns.
test("split longitude/latitude string column", async function () {
const table = await perspective.table({
Coords: [
"(123, 456)",
"(1.5, -2.25)",
"broken",
null,
"(0, 0)",
],
});
const view = await table.view({
expressions: {
Longitude: `var v[2];
if (indexof("Coords", '(,)', v)) { float(substring("Coords", 1, v[0] - 1)) } else { null }`,
Latitude: `var v[2];
if (indexof("Coords", '(,)', v)) { float(substring("Coords", v[0] + 1, length("Coords") - v[0] - 2)) } else { null }`,
},
});
const result = await view.to_columns();
const schema = await view.expression_schema();
expect(schema["Longitude"]).toEqual("float");
expect(schema["Latitude"]).toEqual("float");
expect(result["Longitude"]).toEqual([123, 1.5, null, null, 0]);
expect(result["Latitude"]).toEqual([456, -2.25, null, null, 0]);
view.delete();
table.delete();
});
// Normalize spelling variants by stripping dots and whitespace.
test("replace_all regex strips dots/whitespace", async function () {
const table = await perspective.table({
State: ["NC", "N.C.", "N. C.", "N .C.", "VA"],
});
const view = await table.view({
expressions: {
Normalized: `replace_all("State", '[. ]', '')`,
},
});
const result = await view.to_columns();
expect(result["Normalized"]).toEqual([
"NC",
"NC",
"NC",
"NC",
"VA",
]);
view.delete();
table.delete();
});
});
})(perspective);

Some files were not shown because too many files have changed in this diff Show More