();
+ for (let fx = 0.05; fx <= 0.96; fx += step.x) {
+ for (let fy = 0.05; fy <= 0.96; fy += step.y) {
+ await page.mouse.move(box.x + box.w * fx, box.y + box.h * fy);
+ await page.mouse.click(box.x + box.w * fx, box.y + box.h * fy);
+ const cells = await readPinnedTooltip(page);
+ if (cells && cells.length > 0) {
+ seen.set(JSON.stringify(cells), cells);
+ }
+ }
+ }
+
+ return [...seen.values()];
+}
+
+export async function viewYearRange(
+ page: Page,
+ suffix: string,
+): Promise<[number, number]> {
+ return await page.evaluate(async (s) => {
+ const viewer = document.querySelector("perspective-viewer")!;
+ const view = await (viewer as any).getView({ mode: "clone" });
+ try {
+ const columns = await view.to_columns();
+ const years: number[] = [];
+ for (const [name, values] of Object.entries(columns)) {
+ if (!name.endsWith(s)) {
+ continue;
+ }
+
+ for (const value of values as (number | null)[]) {
+ if (value === null || value === undefined) {
+ continue;
+ }
+
+ years.push(new Date(Number(value)).getFullYear());
+ }
+ }
+
+ return [Math.min(...years), Math.max(...years)] as [number, number];
+ } finally {
+ await view.delete();
+ }
+ }, suffix);
+}
diff --git a/rust/perspective-viewer/test/js/superstore/inline.spec.ts b/packages/viewer-charts/test/ts/tooltip-source-values.spec.ts
similarity index 55%
rename from rust/perspective-viewer/test/js/superstore/inline.spec.ts
rename to packages/viewer-charts/test/ts/tooltip-source-values.spec.ts
index 02e209f842..4712907028 100644
--- a/rust/perspective-viewer/test/js/superstore/inline.spec.ts
+++ b/packages/viewer-charts/test/ts/tooltip-source-values.spec.ts
@@ -10,38 +10,56 @@
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
-import { test } from "../helpers.ts";
-import { run_standard_tests } from "../helpers/standard_tests.ts";
+import type { ViewerConfigUpdate } from "@perspective-dev/viewer";
+import { expect, test } from "@perspective-dev/test";
+import {
+ gotoBasic,
+ restoreChart,
+ sweepPinnedTooltips,
+ tooltipValue,
+ viewYearRange,
+} from "./helpers";
-async function get_contents(page) {
- return await page.evaluate(async () => {
- const viewer = document.querySelector(
- "perspective-viewer perspective-viewer-plugin",
- );
+const DATETIME_X: ViewerConfigUpdate = {
+ columns: ["Order Date", "Sales"],
+ group_by: [],
+ split_by: ["Region"],
+ sort: [["Order Date", "asc"]],
+} as ViewerConfigUpdate;
- // Don't format - light DOM is CSV in a tag.
- return viewer.innerHTML;
+const YEAR = /\b(\d{4})\b/;
+
+test.describe("Tooltip source values", () => {
+ test.beforeEach(async ({ page }) => {
+ await gotoBasic(page);
});
-}
-test.describe("Superstore Inline", () => {
- test.beforeEach(async function init({ page }) {
- await page.goto(
- "/node_modules/@perspective-dev/viewer/test/html/superstore-inline.html",
- );
+ for (const plugin of ["X/Y Scatter", "X/Y Line"]) {
+ test(`${plugin} pins dates and values from the source row`, async ({
+ page,
+ }) => {
+ await restoreChart(page, {
+ ...DATETIME_X,
+ plugin,
+ } as ViewerConfigUpdate);
- await page.evaluate(async () => {
- while (!window["__TEST_PERSPECTIVE_READY__"]) {
- await new Promise((x) => setTimeout(x, 10));
- }
- });
+ const [minYear, maxYear] = await viewYearRange(page, "Order Date");
+ expect(minYear).toBeGreaterThan(1970);
- await page.evaluate(async () => {
- await document.querySelector("perspective-viewer").restore({
- plugin: "Debug",
- });
- });
- });
+ const tooltips = await sweepPinnedTooltips(page);
+ expect(tooltips.length).toBeGreaterThan(4);
+
+ for (const cells of tooltips) {
+ const date = tooltipValue(cells, "Order Date");
+ const sales = tooltipValue(cells, "Sales");
+ expect(date).toBeDefined();
+ expect(sales).toBeDefined();
- run_standard_tests("superstore inline", get_contents);
+ const year = Number(YEAR.exec(date!)?.[1]);
+ expect(year).toBeGreaterThanOrEqual(minYear - 1);
+ expect(year).toBeLessThanOrEqual(maxYear + 1);
+ expect(sales!.startsWith("-")).toBe(false);
+ }
+ });
+ }
});
diff --git a/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts b/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts
index 677a662697..3e2fe499d1 100644
--- a/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts
+++ b/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts
@@ -26,7 +26,7 @@ import column_config_schema, {
import plugin_config_schema from "../plugin/plugin_config_schema.js";
import datagridStyles from "../../../dist/css/perspective-viewer-datagrid.css";
import { format_raw } from "../data_listener/format_cell.js";
-import { sourceColumn } from "@perspective-dev/viewer/src/ts/column-format.js";
+import { sourceColumn } from "@perspective-dev/viewer/column-format";
import type { View, ViewWindow } from "@perspective-dev/client";
import type {
diff --git a/packages/viewer-datagrid/src/ts/data_listener/formatter_cache.ts b/packages/viewer-datagrid/src/ts/data_listener/formatter_cache.ts
index 7983a44cb1..1633398dbe 100644
--- a/packages/viewer-datagrid/src/ts/data_listener/formatter_cache.ts
+++ b/packages/viewer-datagrid/src/ts/data_listener/formatter_cache.ts
@@ -15,7 +15,7 @@ import {
createDateFormatter,
createDatetimeFormatter,
createNumberFormatter,
-} from "@perspective-dev/viewer/src/ts/column-format.js";
+} from "@perspective-dev/viewer/column-format";
import type { ColumnConfig } from "../types.js";
export interface Formatter {
diff --git a/packages/viewer-datagrid/src/ts/types.ts b/packages/viewer-datagrid/src/ts/types.ts
index deccd878d8..07a66fdb1f 100644
--- a/packages/viewer-datagrid/src/ts/types.ts
+++ b/packages/viewer-datagrid/src/ts/types.ts
@@ -592,7 +592,7 @@ export interface PerspectiveClickDetail {
panel?: string;
}
-export { PerspectiveSelectDetail } from "@perspective-dev/viewer/src/ts/extensions.js";
+export { PerspectiveSelectDetail } from "@perspective-dev/viewer/select-detail";
// Mouse event with handled flag
export interface HandledMouseEvent extends MouseEvent {
diff --git a/rust/perspective-client/src/rust/virtual_server/generic_sql_model.rs b/rust/perspective-client/src/rust/virtual_server/generic_sql_model.rs
index bee333ba38..b068e35f33 100644
--- a/rust/perspective-client/src/rust/virtual_server/generic_sql_model.rs
+++ b/rust/perspective-client/src/rust/virtual_server/generic_sql_model.rs
@@ -417,7 +417,11 @@ impl GenericSQLVirtualServerModel {
let all_columns: Vec = group_by_cols
.into_iter()
- .chain(data_columns.iter().map(|col| format!("\"{}\"", col)))
+ .chain(
+ data_columns
+ .iter()
+ .map(|col| format!("\"{}\"", quote_ident(col))),
+ )
.collect();
Ok(format!(
@@ -477,6 +481,7 @@ impl GenericSQLVirtualServerModel {
""
};
+ let column_name = quote_ident(column_name);
Ok(format!(
"SELECT MIN(\"{}\"), MAX(\"{}\") FROM {}{}",
column_name, column_name, view_id, where_clause
diff --git a/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs
index c5702bbfcb..619a916bcf 100644
--- a/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs
+++ b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs
@@ -332,7 +332,7 @@ impl<'a> ViewQueryContext<'a> {
expressions
.get(col)
.cloned()
- .unwrap_or_else(|| format!("\"{}\"", col))
+ .unwrap_or_else(|| format!("\"{}\"", quote_ident(col)))
};
let row_id_expr = model.0.row_id_expr.as_deref().unwrap_or("rowid");
@@ -702,7 +702,7 @@ impl<'a> ViewQueryContext<'a> {
.0
.get(col)
.cloned()
- .unwrap_or_else(|| format!("\"{}\"", col))
+ .unwrap_or_else(|| format!("\"{}\"", quote_ident(col)))
}
fn get_aggregate(&self, col: &str) -> String {
@@ -991,7 +991,7 @@ impl<'a> ViewQueryContext<'a> {
self.config
.split_by
.iter()
- .map(|c| format!("\"{}\"", c))
+ .map(|c| format!("\"{}\"", quote_ident(c)))
.collect::>()
.join(", ")
}
diff --git a/rust/perspective-client/src/rust/virtual_server/generic_sql_model/tests.rs b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/tests.rs
index efcdc44ca8..4f27d88a51 100644
--- a/rust/perspective-client/src/rust/virtual_server/generic_sql_model/tests.rs
+++ b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/tests.rs
@@ -1384,3 +1384,122 @@ fn test_expression_describe_uses_template() {
"SELECT * FROM (SELECT \"x\" + 1 FROM t) AS __psp_describe__ LIMIT 0"
);
}
+
+#[test]
+fn test_view_get_data_escapes_double_quotes_in_column_paths() {
+ let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
+ let mut config = ViewConfig::default();
+ config.columns = vec![Some("amount".to_string())];
+ config.split_by = vec!["item_title".to_string()];
+ let viewport = ViewPort::default();
+
+ let mut schema = IndexMap::new();
+ schema.insert("plain|amount".to_string(), ColumnType::Integer);
+ schema.insert("say \"hi\"|amount".to_string(), ColumnType::Integer);
+ let sql = builder
+ .view_get_data("my_view", &config, &viewport, &schema)
+ .unwrap();
+
+ assert_eq!(
+ sql,
+ "SELECT \"plain|amount\", \"say \"\"hi\"\"|amount\" FROM my_view"
+ );
+}
+
+#[test]
+fn test_view_get_min_max_escapes_double_quotes_in_column_name() {
+ let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
+ let config = ViewConfig::default();
+
+ assert_eq!(
+ builder
+ .view_get_min_max("my_view", "say \"hi\"|amount", &config)
+ .unwrap(),
+ "SELECT MIN(\"say \"\"hi\"\"|amount\"), MAX(\"say \"\"hi\"\"|amount\") FROM my_view"
+ );
+}
+
+#[test]
+fn test_table_make_view_escapes_double_quotes_in_column_names() {
+ let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
+ let mut config = ViewConfig::default();
+ config.columns = vec![Some("a\"b".to_string())];
+ let sql = builder
+ .table_make_view("source_table", "dest_view", &config, &IndexMap::new())
+ .unwrap();
+
+ assert!(
+ sql.contains("\"a\"\"b\" as \"a\"\"b\""),
+ "expected escaped column identifier: {}",
+ sql
+ );
+ assert!(
+ !sql.contains("\"a\"b\""),
+ "expected no unescaped column identifier: {}",
+ sql
+ );
+}
+
+#[test]
+fn test_table_make_view_escapes_double_quotes_in_group_by() {
+ let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
+ let mut config = ViewConfig::default();
+ config.columns = vec![Some("value".to_string())];
+ config.group_by = vec!["ca\"t".to_string()];
+ config.group_rollup_mode = GroupRollupMode::Flat;
+ let sql = builder
+ .table_make_view("source_table", "dest_view", &config, &IndexMap::new())
+ .unwrap();
+
+ assert!(
+ sql.contains("GROUP BY \"ca\"\"t\""),
+ "expected escaped group_by identifier: {}",
+ sql
+ );
+}
+
+#[test]
+fn test_table_make_view_escapes_double_quotes_in_split_by() {
+ let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
+ let mut config = ViewConfig::default();
+ config.columns = vec![Some("value".to_string())];
+ config.group_by = vec!["category".to_string()];
+ config.split_by = vec!["sta\"te".to_string()];
+ let sql = builder
+ .table_make_view("source_table", "dest_view", &config, &IndexMap::new())
+ .unwrap();
+
+ assert!(
+ sql.contains("\"sta\"\"te\" || '|value'"),
+ "expected escaped split_by identifier in ON expression: {}",
+ sql
+ );
+ assert!(
+ !sql.contains("\"sta\"te\""),
+ "expected no unescaped split_by identifier: {}",
+ sql
+ );
+}
+
+#[test]
+fn test_view_get_data_orders_column_paths_containing_separator() {
+ let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
+ let mut config = ViewConfig::default();
+ config.columns = vec![Some("amount".to_string()), Some("qty".to_string())];
+ config.split_by = vec!["item_title".to_string()];
+ let viewport = ViewPort::default();
+
+ let mut schema = IndexMap::new();
+ schema.insert("b|c|qty".to_string(), ColumnType::Integer);
+ schema.insert("a|b|amount".to_string(), ColumnType::Integer);
+ schema.insert("b|c|amount".to_string(), ColumnType::Integer);
+ schema.insert("a|b|qty".to_string(), ColumnType::Integer);
+ let sql = builder
+ .view_get_data("my_view", &config, &viewport, &schema)
+ .unwrap();
+
+ assert_eq!(
+ sql,
+ "SELECT \"a|b|amount\", \"a|b|qty\", \"b|c|amount\", \"b|c|qty\" FROM my_view"
+ );
+}
diff --git a/rust/perspective-js/build.mjs b/rust/perspective-js/build.mjs
index 256285d483..369a0da7cf 100644
--- a/rust/perspective-js/build.mjs
+++ b/rust/perspective-js/build.mjs
@@ -30,23 +30,6 @@ const BUILD = [
// 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 `
-
-
-
-
-
-
-
-