Skip to content

Commit cbb0e7f

Browse files
authored
Merge pull request #3143 from perspective-dev/engine-inner-joins
`Client::join` API
2 parents 5dc4e03 + 79009b4 commit cbb0e7f

File tree

40 files changed

+2756
-14
lines changed

40 files changed

+2756
-14
lines changed

docs/md/SUMMARY.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
- [Selection and Ordering](./explanation/view/config/selection_and_ordering.md)
2323
- [`expressions`](./explanation/view/config/expressions.md)
2424
- [Advanced View Operations](./explanation/view/advanced.md)
25+
- [`Join`](./explanation/join.md)
26+
- [Join Types](./explanation/join/join_types.md)
27+
- [Join Options](./explanation/join/options.md)
28+
- [Reactivity and Constraints](./explanation/join/reactivity.md)
2529

2630
# JavaScript
2731

@@ -32,6 +36,7 @@
3236
- [Cleaning up resources](./how_to/javascript/deleting.md)
3337
- [Hosting a `WebSocketServer` in Node.js](./how_to/javascript/nodejs_server.md)
3438
- [Customizing `perspective.worker()`](./how_to/javascript/custom_worker.md)
39+
- [Joining Tables](./how_to/javascript/join.md)
3540
- [`perspective-viewer` Custom Element library](./how_to/javascript/viewer.md)
3641
- [Loading data](./how_to/javascript/loading_data.md)
3742
- [Theming](./how_to/javascript/theming.md)
@@ -52,6 +57,7 @@
5257
- [Callbacks and events](./how_to/python/callbacks.md)
5358
- [Multithreading](./how_to/python/multithreading.md)
5459
- [Hosting a WebSocket server](./how_to/python/websocket.md)
60+
- [Joining Tables](./how_to/python/join.md)
5561
- [`PerspectiveWidget` for JupyterLab](./how_to/python/jupyterlab.md)
5662
- [Virtual Servers](./how_to/python/virtual_server.md)
5763
- [DuckDB](./how_to/python/virtual_server/duckdb.md)

docs/md/explanation/join.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Join
2+
3+
`Client::join` creates a read-only `Table` by joining two source tables on a
4+
shared key column. The `left` and `right` arguments can be `Table` objects or
5+
string table names (as returned by `get_hosted_table_names()`). The resulting
6+
table is _reactive_: whenever either source table is updated, the join is
7+
automatically recomputed and any `View` derived from the joined table will
8+
update accordingly.
9+
10+
Joined tables support the full `View` API — you can apply `group_by`,
11+
`split_by`, `sort`, `filter`, `expressions`, and all other `View` operations on
12+
the result, just as you would with any other `Table`.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Join Types
2+
3+
`Client::join` supports three join types, specified via the `join_type` option.
4+
The default is `"inner"`.
5+
6+
## Inner Join (default)
7+
8+
An inner join includes only rows where the key column exists in _both_ source
9+
tables. Rows from either table that have no match in the other are excluded.
10+
11+
## Left Join
12+
13+
A left join includes all rows from the left table. For left rows that have no
14+
match in the right table, right-side columns are filled with `null`.
15+
16+
## Outer Join
17+
18+
An outer join includes all rows from both tables. Unmatched rows on either side
19+
have their missing columns filled with `null`.
20+
21+
| `join_type` | Left-only rows | Right-only rows |
22+
| ----------- | -------------- | --------------- |
23+
| `"inner"` | excluded | excluded |
24+
| `"left"` | included | excluded |
25+
| `"outer"` | included | included |
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Join Options
2+
3+
## `on` — Join Key Column
4+
5+
The `on` parameter specifies the column name used to match rows between the left
6+
and right tables. This column must exist in the left table and, by default, must
7+
also exist in the right table with the same name and compatible type.
8+
9+
The join key column becomes the index of the resulting table.
10+
11+
## `right_on` — Different Right Key Column
12+
13+
When the join key has a different name in the right table, use `right_on` to
14+
specify the right table's column name. The left table's column name (`on`) is
15+
used in the output schema; the right key column is excluded from the result.
16+
17+
The `on` and `right_on` columns must have compatible types. An error is thrown
18+
if the types do not match.
19+
20+
## `join_type` — Join Type
21+
22+
Controls which rows are included in the result. See
23+
[Join Types](./join_types.md) for details.
24+
25+
| Value | Behavior |
26+
| ----------- | ----------------------------------------------------- |
27+
| `"inner"` | Only rows with matching keys in both tables (default) |
28+
| `"left"` | All left rows; unmatched right columns are `null` |
29+
| `"outer"` | All rows from both tables; unmatched columns are `null` |
30+
31+
## `name` — Table Name
32+
33+
An optional name for the resulting joined table. If omitted, a random name is
34+
generated. This name is used to identify the table in the server's hosted table
35+
registry.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Reactivity and Constraints
2+
3+
## Reactive Updates
4+
5+
Joined tables are fully reactive. When either source table receives an
6+
`update()`, the join is automatically recomputed and any `View` created from the
7+
joined table will reflect the new data. This includes:
8+
9+
- Updates that modify existing rows in either source table.
10+
- New rows added to either source table that create new matches.
11+
- Chained joins — if a joined table is itself used as input to another join,
12+
updates propagate through the entire chain.
13+
14+
## Duplicate Keys
15+
16+
Like SQL, `join()` produces a cross-product for each matching key value. When
17+
multiple rows in the left table share the same key, each is paired with every
18+
matching row in the right table (and vice versa). The number of output rows for
19+
a given key is `left_count × right_count`.
20+
21+
This behavior depends on whether the source tables are _indexed_:
22+
23+
- **Unindexed tables** (no `index` option) — rows are appended, so duplicate
24+
keys accumulate naturally. Each `update()` appends new rows, which may
25+
introduce additional duplicates.
26+
- **Indexed tables** (`index` set to the join key) — each key appears at most
27+
once per table, so the join produces at most one row per key. Updates replace
28+
existing rows in-place rather than appending.
29+
30+
## Read-Only
31+
32+
Joined tables are read-only. Calling `update()`, `remove()`, `clear()`, or
33+
`replace()` on a joined table will throw an error. Data can only change
34+
indirectly, by updating the source tables.
35+
36+
## Column Name Conflicts
37+
38+
The left and right tables must not have overlapping column names (other than the
39+
join key). If a non-key column name appears in both tables, `join()` throws an
40+
error. Rename columns in your source data or use `View` expressions to avoid
41+
conflicts.
42+
43+
## Source Table Deletion
44+
45+
A source table cannot be deleted while a joined table depends on it. You must
46+
delete the joined table first, then delete the source tables.

docs/md/how_to/javascript/join.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Joining Tables
2+
3+
`perspective.join()` creates a read-only `Table` by joining two source tables on
4+
a shared key column. The result is reactive — it updates automatically when
5+
either source table changes. See [`Join`](../../explanation/join.md) for
6+
conceptual details.
7+
8+
## Basic Inner Join
9+
10+
```javascript
11+
const orders = await perspective.table([
12+
{ id: 1, product_id: 101, qty: 5 },
13+
{ id: 2, product_id: 102, qty: 3 },
14+
{ id: 3, product_id: 101, qty: 7 },
15+
]);
16+
17+
const products = await perspective.table([
18+
{ product_id: 101, name: "Widget" },
19+
{ product_id: 102, name: "Gadget" },
20+
]);
21+
22+
const joined = await perspective.join(orders, products, "product_id");
23+
const view = await joined.view();
24+
const json = await view.to_json();
25+
// [
26+
// { product_id: 101, id: 1, qty: 5, name: "Widget" },
27+
// { product_id: 101, id: 3, qty: 7, name: "Widget" },
28+
// { product_id: 102, id: 2, qty: 3, name: "Gadget" },
29+
// ]
30+
```
31+
32+
## Join Types
33+
34+
Pass `join_type` in the options to select inner, left, or outer join behavior:
35+
36+
```javascript
37+
// Left join: all left rows, nulls for unmatched right columns
38+
const left_joined = await perspective.join(left, right, "id", {
39+
join_type: "left",
40+
});
41+
42+
// Outer join: all rows from both tables
43+
const outer_joined = await perspective.join(left, right, "id", {
44+
join_type: "outer",
45+
});
46+
```
47+
48+
## Reactive Updates
49+
50+
The joined table recomputes automatically when either source table is updated:
51+
52+
```javascript
53+
const left = await perspective.table([{ id: 1, x: 10 }]);
54+
const right = await perspective.table([{ id: 2, y: "b" }]);
55+
56+
const joined = await perspective.join(left, right, "id");
57+
const view = await joined.view();
58+
59+
let json = await view.to_json();
60+
// [] — no matching keys yet
61+
62+
await right.update([{ id: 1, y: "a" }]);
63+
json = await view.to_json();
64+
// [{ id: 1, x: 10, y: "a" }] — new match detected
65+
```

docs/md/how_to/python/join.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Joining Tables
2+
3+
`perspective.join()` creates a read-only `Table` by joining two source tables on
4+
a shared key column. The result is reactive — it updates automatically when
5+
either source table changes. See [`Join`](../../explanation/join.md) for
6+
conceptual details.
7+
8+
## Basic Inner Join
9+
10+
```python
11+
orders = perspective.table([
12+
{"id": 1, "product_id": 101, "qty": 5},
13+
{"id": 2, "product_id": 102, "qty": 3},
14+
{"id": 3, "product_id": 101, "qty": 7},
15+
])
16+
17+
products = perspective.table([
18+
{"product_id": 101, "name": "Widget"},
19+
{"product_id": 102, "name": "Gadget"},
20+
])
21+
22+
joined = perspective.join(orders, products, "product_id")
23+
view = joined.view()
24+
json = view.to_json()
25+
```
26+
27+
## Join Types
28+
29+
Pass `join_type` to select inner, left, or outer join behavior:
30+
31+
```python
32+
# Left join: all left rows, nulls for unmatched right columns
33+
left_joined = perspective.join(left, right, "id", join_type="left")
34+
35+
# Outer join: all rows from both tables
36+
outer_joined = perspective.join(left, right, "id", join_type="outer")
37+
```
38+
39+
## Reactive Updates
40+
41+
The joined table recomputes automatically when either source table is updated:
42+
43+
```python
44+
left = perspective.table([{"id": 1, "x": 10}])
45+
right = perspective.table([{"id": 2, "y": "b"}])
46+
47+
joined = perspective.join(left, right, "id")
48+
view = joined.view()
49+
50+
json = view.to_json()
51+
# [] — no matching keys yet
52+
53+
right.update([{"id": 1, "y": "a"}])
54+
json = view.to_json()
55+
# [{"id": 1, "x": 10, "y": "a"}] — new match detected
56+
```
57+
58+
## Async Client
59+
60+
The async client has the same API:
61+
62+
```python
63+
joined = await client.join(orders, products, "product_id", join_type="left")
64+
```

docs/md/how_to/rust.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,64 @@ let mut options = TableInitOptions::default();
2626
options.set_name("my_data_source");
2727
client.table(data.into(), options).await?;
2828
```
29+
30+
# Joining Tables
31+
32+
`Client::join` creates a read-only `Table` by joining two source tables on a
33+
shared key column. The result is reactive — it updates automatically when
34+
either source table changes. See [`Join`](../explanation/join.md) for
35+
conceptual details.
36+
37+
```rust
38+
let orders = client.table(
39+
TableData::Update(UpdateData::JsonRows(
40+
"[{\"id\":1,\"product_id\":101,\"qty\":5},{\"id\":2,\"product_id\":102,\"qty\":3}]".into(),
41+
)),
42+
TableInitOptions::default(),
43+
).await?;
44+
45+
let products = client.table(
46+
TableData::Update(UpdateData::JsonRows(
47+
"[{\"product_id\":101,\"name\":\"Widget\"},{\"product_id\":102,\"name\":\"Gadget\"}]".into(),
48+
)),
49+
TableInitOptions::default(),
50+
).await?;
51+
52+
let joined = client.join(
53+
(&orders).into(),
54+
(&products).into(),
55+
"product_id",
56+
JoinOptions::default(),
57+
).await?;
58+
59+
let view = joined.view(None).await?;
60+
let json = view.to_json().await?;
61+
```
62+
63+
Use `JoinOptions` to configure the join type, table name, or `right_on` column:
64+
65+
```rust
66+
let options = JoinOptions {
67+
join_type: Some(JoinType::Left),
68+
name: Some("orders_with_products".into()),
69+
right_on: None,
70+
};
71+
72+
let joined = client.join(
73+
(&orders).into(),
74+
(&products).into(),
75+
"product_id",
76+
options,
77+
).await?;
78+
```
79+
80+
You can also join by table name strings instead of `Table` references:
81+
82+
```rust
83+
let joined = client.join(
84+
"orders".into(),
85+
"products".into(),
86+
"product_id",
87+
JoinOptions::default(),
88+
).await?;
89+
```

rust/metadata/main.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ use std::fs;
3232

3333
use perspective_client::config::*;
3434
use perspective_client::{
35-
ColumnWindow, DeleteOptions, OnUpdateData, OnUpdateOptions, SystemInfo, TableInitOptions,
36-
UpdateOptions, ViewWindow,
35+
ColumnWindow, DeleteOptions, JoinOptions, OnUpdateData, OnUpdateOptions, SystemInfo,
36+
TableInitOptions, UpdateOptions, ViewWindow,
3737
};
3838
use perspective_viewer::config::ViewerConfigUpdate;
3939
use ts_rs::TS;
@@ -71,6 +71,7 @@ pub fn generate_type_bindings_js() -> Result<(), Box<dyn Error>> {
7171
ViewConfigUpdate::export_all_to(&path)?;
7272
OnUpdateData::export_all_to(&path)?;
7373
OnUpdateOptions::export_all_to(&path)?;
74+
JoinOptions::export_all_to(&path)?;
7475
UpdateOptions::export_all_to(&path)?;
7576
DeleteOptions::export_all_to(&path)?;
7677
ViewWindow::export_all_to(&path)?;

rust/perspective-client/build.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ fn prost_build() -> Result<()> {
5757
.field_attribute("ViewOnUpdateResp.delta", "#[ts(as = \"Vec::<u8>\")]")
5858
.field_attribute("ViewOnUpdateResp.delta", "#[serde(with = \"serde_bytes\")]")
5959
.type_attribute("ColumnType", "#[derive(ts_rs::TS)]")
60+
.type_attribute(
61+
"JoinType",
62+
"#[derive(serde::Deserialize, ts_rs::TS)] #[serde(rename_all = \"snake_case\")]",
63+
)
6064
.field_attribute("ViewToArrowResp.arrow", "#[serde(skip)]")
6165
.field_attribute("from_arrow", "#[serde(skip)]")
6266
.type_attribute(".", "#[derive(serde::Serialize)]")

0 commit comments

Comments
 (0)