Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Advanced View Operations

Beyond the standard query configuration, View provides additional methods for interacting with hierarchical results and introspecting data.

Tree Hierarchy Operations

When a View has group_by applied, the results form a tree hierarchy. Perspective provides methods to control which levels of the tree are expanded or collapsed:

const view = await table.view({ group_by: ["Region", "Country", "City"] });

// Collapse the tree at row index 5
await view.collapse(5);

// Expand the tree at row index 5
await view.expand(5);

// Set the expansion depth (0 = fully collapsed, 1 = first level, etc.)
await view.set_depth(1);

Using the sync API

view = table.view(group_by=["Region", "Country", "City"])

view.collapse(5)
view.expand(5)
view.set_depth(1)
#![allow(unused)]
fn main() {
let view = table.view(Some(ViewConfigUpdate {
    group_by: Some(vec!["Region".into(), "Country".into(), "City".into()]),
    ..ViewConfigUpdate::default()
})).await?;

view.collapse(5).await?;
view.expand(5).await?;
view.set_depth(1).await?;
}

Perspective’s built-in engine is lazy — aggregates for collapsed rows are not recalculated when the underlying Table is updated. Updates are only computed for rows that are currently visible (expanded). When a collapsed row is later expanded, its aggregates are calculated at that point.

Column Range Queries

View::get_min_max returns the minimum and maximum values for a given column, which is useful for setting up scales in custom visualizations:

const [min, max] = await view.get_min_max("Sales");
min_val, max_val = view.get_min_max("Sales")

Describing a View Config

Table::describe validates a complete view config against a table and reports the schema a View built from it would have - without creating one. describe reports a view_schema if and only if Table::view with the same config would succeed, and the two schemas are equal. It costs no engine resources, so it is the right way to check a config before applying it.

const verdict = await table.describe({
    columns: ["Sales", "margin"],
    group_by: ["Region"],
    expressions: { margin: '"Profit" / "Sales"' },
    aggregates: { margin: "avg" },
});

if ("view_schema" in verdict) {
    // { expression_schema: { margin: "float" },
    //   view_schema: { Sales: "float", margin: "float" } }
} else if ("expression_errors" in verdict) {
    // { expression_schema: {...}, expression_errors: { margin: { error_message, line, column } } }
} else {
    // { config_error: "Invalid column 'Sales' found in View columns." }
}
verdict = table.describe(
    columns=["Sales", "margin"],
    group_by=["Region"],
    expressions={"margin": '"Profit" / "Sales"'},
    aggregates={"margin": "avg"},
)

if "view_schema" in verdict:
    ...
elif "expression_errors" in verdict:
    ...
else:
    verdict["config_error"]

Expression Validation

Table::validate_expressions is a specialization of Table::describe over a config that selects no columns, so only the expressions are checked. It returns which expressions are valid and their inferred types, plus an expression_alias map echoing the request:

const result = await table.validate_expressions({
    expr1: '"Sales" + "Profit"',
    expr2: "invalid_column + 1",
});
// result.expression_schema contains valid expressions and their types
// result.errors contains invalid expressions and error messages
result = table.validate_expressions(['"Sales" + "Profit"', 'invalid + 1'])

View Dimensions

View::dimensions returns the number of rows and columns in the current view, including information about group-by header rows:

const dims = await view.dimensions();
// { num_view_rows, num_view_columns, num_table_rows, num_table_columns, ... }
dims = view.dimensions()

View Configuration Introspection

View::get_config returns the full configuration used to create the view:

const config = await view.get_config();
// { group_by: [...], split_by: [...], sort: [...], filter: [...], ... }
config = view.get_config()

Update Callbacks

Register a callback to be notified whenever the underlying Table is updated and the View has been recalculated:

view.on_update(
    (updated) => {
        console.log("View updated", updated.port_id);
    },
    { mode: "row" },
);

// Later, remove the callback
view.remove_update(callback);
def on_update(port_id, delta):
    print("View updated", port_id)

view.on_update(on_update, mode="row")
view.remove_update(on_update)

When mode is set to "row", the callback receives a delta of only the rows that changed (as Apache Arrow), which is useful for efficiently synchronizing tables across clients.

Remove Callbacks

Register a callback to be notified whenever rows are removed from the underlying Table by remove(), which requires an index. The callback receives the port_id and the removed index column values as an Apache Arrow of a single column named after the index. It fires once per update step, only for rows which existed before that step; replace() reports the keys it does not re-supply, and clear() reports every key:

const callback = await view.on_remove(({ indices, port_id }) => {
    replica.remove(indices);
});

// Later, remove the callback
await view.remove_remove(callback);
def on_remove(port_id, indices):
    replica.remove(indices)

callback = view.on_remove(on_remove)
view.remove_remove(callback)

Flattening a View into a Table

A [Table] can be constructed on a [Table::view] instance, which will return a new [Table] based on the [Table::view]’s dataset, and all future updates that affect the [Table::view] will be forwarded to the new [Table]. This is particularly useful for implementing a Client/Server Replicated design, as it handles the View serialization and on_update forwarding for you. This pattern is available in JavaScript, Python and Rust.

When the source Table has an index, and the View is unpivoted and includes the index column, the new Table inherits that index and subscribes to the source’s on_remove(), so in-place updates and remove() calls on the source are mirrored rather than appended. A pivoted View, or one which omits the index column, produces an unindexed, append-only Table. A limit is inherited the same way. replace() and clear() on the source are mirrored too.

const worker = await perspective.worker();
const table = await worker.table(data);
const view = await table.view({ filter: [["State", "==", "Texas"]] });
const table2 = await worker.table(view);
table.update([{ State: "Texas", City: "Austin" }]);
table = client.table(data)
view = table.view(filter=[["State", "==", "Texas"]])
table2 = client.table(view)
table.update([{"State": "Texas", "City": "Austin"}])
#![allow(unused)]
fn main() {
let opts = TableInitOptions::default();
let data = TableData::Update(UpdateData::Csv("x,y\n1,2\n3,4".into()));
let table = client.table(data, opts).await?;
let view = table.view(None).await?;
let table2 = client.table(TableData::View(view)).await?;
table.update(data).await?;
}