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?;
}