Skip to content

Commit dc2fcd9

Browse files
committed
Client::join API
Signed-off-by: Andrew Stein <steinlink@gmail.com> # Conflicts: # tools/test/results.tar.gz
1 parent 5dc4e03 commit dc2fcd9

File tree

13 files changed

+1068
-2
lines changed

13 files changed

+1068
-2
lines changed

rust/perspective-client/perspective.proto

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ message Request {
157157
TableUpdateReq table_update_req = 33;
158158
ViewOnDeleteReq view_on_delete_req = 34;
159159
ViewRemoveDeleteReq view_remove_delete_req = 35;
160+
MakeJoinTableReq make_join_table_req = 38;
160161
}
161162
}
162163

@@ -199,6 +200,7 @@ message Response {
199200
TableUpdateResp table_update_resp = 33;
200201
ViewOnDeleteResp view_on_delete_resp = 34;
201202
ViewRemoveDeleteResp view_remove_delete_resp = 35;
203+
MakeJoinTableResp make_join_table_resp = 38;
202204
ServerError server_error = 50;
203205
}
204206
}
@@ -335,6 +337,14 @@ message MakeTableReq {
335337
}
336338
message MakeTableResp {}
337339

340+
// `Client::join` — create a read-only table from an INNER JOIN of two tables.
341+
message MakeJoinTableReq {
342+
string left_table_id = 1;
343+
string right_table_id = 2;
344+
string on_column = 3;
345+
}
346+
message MakeJoinTableResp {}
347+
338348
// `Table::delete`
339349
message TableDeleteReq {
340350
bool is_immediate = 1;

rust/perspective-client/src/rust/client.rs

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ use crate::proto::request::ClientReq;
2626
use crate::proto::response::ClientResp;
2727
use crate::proto::{
2828
ColumnType, GetFeaturesReq, GetFeaturesResp, GetHostedTablesReq, GetHostedTablesResp,
29-
HostedTable, MakeTableReq, RemoveHostedTablesUpdateReq, Request, Response, ServerError,
30-
ServerSystemInfoReq,
29+
HostedTable, MakeJoinTableReq, MakeTableReq, RemoveHostedTablesUpdateReq, Request, Response,
30+
ServerError, ServerSystemInfoReq,
3131
};
3232
use crate::table::{Table, TableInitOptions, TableOptions};
3333
use crate::table_data::{TableData, UpdateData};
@@ -589,6 +589,45 @@ impl Client {
589589
}
590590
}
591591

592+
/// Create a new read-only [`Table`] by performing an INNER JOIN on two
593+
/// source tables. The resulting table is reactive: when either source
594+
/// table is updated, the join is automatically recomputed.
595+
///
596+
/// # Arguments
597+
///
598+
/// * `left` - The left source table.
599+
/// * `right` - The right source table.
600+
/// * `on` - The column name to join on. Must exist in both tables with the
601+
/// same type.
602+
/// * `name` - Optional name for the resulting table.
603+
pub async fn join(
604+
&self,
605+
left: &Table,
606+
right: &Table,
607+
on: &str,
608+
name: Option<String>,
609+
) -> ClientResult<Table> {
610+
let entity_id = name.unwrap_or_else(randid);
611+
let msg = Request {
612+
msg_id: self.gen_id(),
613+
entity_id: entity_id.clone(),
614+
client_req: Some(ClientReq::MakeJoinTableReq(MakeJoinTableReq {
615+
left_table_id: left.get_name().to_owned(),
616+
right_table_id: right.get_name().to_owned(),
617+
on_column: on.to_owned(),
618+
})),
619+
};
620+
621+
let client = self.clone();
622+
match self.oneshot(&msg).await? {
623+
ClientResp::MakeJoinTableResp(_) => Ok(Table::new(entity_id, client, TableOptions {
624+
index: Some(on.to_owned()),
625+
limit: None,
626+
})),
627+
resp => Err(resp.into()),
628+
}
629+
}
630+
592631
async fn get_table_infos(&self) -> ClientResult<Vec<HostedTable>> {
593632
let msg = Request {
594633
msg_id: self.gen_id(),

rust/perspective-js/src/rust/client.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,34 @@ impl Client {
382382
Ok(Table(self.client.table(args, options).await?))
383383
}
384384

385+
/// Creates a new read-only [`Table`] by performing an INNER JOIN on two
386+
/// source tables. The resulting table is reactive: when either source
387+
/// table is updated, the join is automatically recomputed.
388+
///
389+
/// # Arguments
390+
///
391+
/// - `left` - The left source table.
392+
/// - `right` - The right source table.
393+
/// - `on` - The column name to join on. Must exist in both tables with the
394+
/// same type.
395+
/// - `name` - Optional name for the resulting table.
396+
///
397+
/// # JavaScript Examples
398+
///
399+
/// ```javascript
400+
/// const joined = await client.join(orders_table, products_table, "Product ID");
401+
/// ```
402+
#[wasm_bindgen]
403+
pub async fn join(
404+
&self,
405+
left: &Table,
406+
right: &Table,
407+
on: &str,
408+
name: Option<String>,
409+
) -> ApiResult<Table> {
410+
Ok(Table(self.client.join(&left.0, &right.0, on, name).await?))
411+
}
412+
385413
/// Terminates this [`Client`], cleaning up any [`crate::View`] handles the
386414
/// [`Client`] has open as well as its callbacks.
387415
#[wasm_bindgen]

rust/perspective-js/src/ts/perspective.node.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,23 @@ export function on_error(callback: Function) {
273273
return SYNC_CLIENT.on_error(callback);
274274
}
275275

276+
/**
277+
* Create a read-only table from an INNER JOIN of two source tables.
278+
* @param left
279+
* @param right
280+
* @param on
281+
* @param name
282+
* @returns
283+
*/
284+
export function join(
285+
left: perspective_client.Table,
286+
right: perspective_client.Table,
287+
on: string,
288+
name?: string,
289+
) {
290+
return SYNC_CLIENT.join(left, right, on, name);
291+
}
292+
276293
/**
277294
* Create a table from the global Perspective instance.
278295
* @param init_data
@@ -356,6 +373,7 @@ export { perspective_client as wasmModule };
356373

357374
export default {
358375
table,
376+
join,
359377
websocket,
360378
worker,
361379
get_hosted_table_names,
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2+
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3+
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4+
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5+
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6+
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7+
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
8+
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9+
// ┃ This file is part of the Perspective library, distributed under the terms ┃
10+
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11+
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12+
13+
import { test, expect } from "@perspective-dev/test";
14+
import perspective from "../perspective_client.ts";
15+
16+
((perspective) => {
17+
test.describe("Inner joins", function () {
18+
test("inner joins two tables on a shared key", async function () {
19+
const left = await perspective.table([
20+
{ id: 1, x: 10 },
21+
{ id: 2, x: 20 },
22+
{ id: 3, x: 30 },
23+
]);
24+
25+
const right = await perspective.table([
26+
{ id: 1, y: "a" },
27+
{ id: 2, y: "b" },
28+
{ id: 4, y: "d" },
29+
]);
30+
31+
const joined = await perspective.join(left, right, "id");
32+
const view = await joined.view();
33+
const json = await view.to_json();
34+
35+
expect(json).toHaveLength(2);
36+
37+
view.delete();
38+
joined.delete();
39+
right.delete();
40+
left.delete();
41+
});
42+
43+
test("joined table has correct schema", async function () {
44+
const left = await perspective.table({ id: "integer", x: "float" });
45+
46+
const right = await perspective.table({
47+
id: "integer",
48+
y: "string",
49+
});
50+
51+
const joined = await perspective.join(left, right, "id");
52+
const schema = await joined.schema();
53+
54+
expect(schema).toEqual({
55+
id: "integer",
56+
x: "float",
57+
y: "string",
58+
});
59+
60+
joined.delete();
61+
right.delete();
62+
left.delete();
63+
});
64+
65+
test("joined table reacts to left table updates", async function () {
66+
const left = await perspective.table([
67+
{ id: 1, x: 10 },
68+
{ id: 2, x: 20 },
69+
]);
70+
71+
const right = await perspective.table([
72+
{ id: 1, y: "a" },
73+
{ id: 2, y: "b" },
74+
]);
75+
76+
const joined = await perspective.join(left, right, "id");
77+
const view = await joined.view();
78+
79+
let json = await view.to_json();
80+
expect(json).toHaveLength(2);
81+
82+
await left.update([{ id: 1, x: 99 }]);
83+
json = await view.to_json();
84+
85+
expect(json).toEqual([
86+
{ id: 1, x: 10, y: "a" },
87+
{ id: 2, x: 20, y: "b" },
88+
{ id: 1, x: 99, y: "a" },
89+
]);
90+
91+
view.delete();
92+
joined.delete();
93+
right.delete();
94+
left.delete();
95+
});
96+
97+
test("joined table reacts to right table updates", async function () {
98+
const left = await perspective.table([
99+
{ id: 1, x: 10 },
100+
{ id: 2, x: 20 },
101+
]);
102+
103+
const right = await perspective.table([
104+
{ id: 1, y: "a" },
105+
{ id: 2, y: "b" },
106+
]);
107+
108+
const joined = await perspective.join(left, right, "id");
109+
const view = await joined.view();
110+
111+
await right.update([{ id: 1, y: "c" }]);
112+
const json = await view.to_json();
113+
114+
// id=3 only exists in right, so inner join should not include it
115+
expect(json).toHaveLength(3);
116+
117+
expect(json).toEqual([
118+
{ id: 1, x: 10, y: "a" },
119+
{ id: 1, x: 10, y: "c" },
120+
{ id: 2, x: 20, y: "b" },
121+
]);
122+
123+
view.delete();
124+
joined.delete();
125+
right.delete();
126+
left.delete();
127+
});
128+
129+
test("joined table reacts to new matching rows", async function () {
130+
const left = await perspective.table([{ id: 1, x: 10 }]);
131+
132+
const right = await perspective.table([{ id: 2, y: "b" }]);
133+
134+
const joined = await perspective.join(left, right, "id");
135+
const view = await joined.view();
136+
137+
let json = await view.to_json();
138+
expect(json).toHaveLength(0);
139+
140+
// Add matching row to right
141+
await right.update([{ id: 1, y: "a" }]);
142+
json = await view.to_json();
143+
expect(json).toHaveLength(1);
144+
expect(json).toEqual([{ id: 1, x: 10, y: "a" }]);
145+
146+
view.delete();
147+
joined.delete();
148+
right.delete();
149+
left.delete();
150+
});
151+
152+
test("joined table supports views with group_by", async function () {
153+
const left = await perspective.table([
154+
{ id: 1, category: "A", x: 10 },
155+
{ id: 2, category: "A", x: 20 },
156+
{ id: 3, category: "B", x: 30 },
157+
]);
158+
159+
const right = await perspective.table([
160+
{ id: 1, y: 100 },
161+
{ id: 2, y: 200 },
162+
{ id: 3, y: 300 },
163+
]);
164+
165+
const joined = await perspective.join(left, right, "id");
166+
const view = await joined.view({
167+
group_by: ["category"],
168+
columns: ["x", "y"],
169+
});
170+
171+
const json = await view.to_columns();
172+
expect(json["x"]).toEqual([60, 30, 30]);
173+
expect(json["y"]).toEqual([600, 300, 300]);
174+
175+
view.delete();
176+
joined.delete();
177+
right.delete();
178+
left.delete();
179+
});
180+
181+
test("rejects column name conflicts", async function () {
182+
const left = await perspective.table([{ id: 1, value: 10 }]);
183+
const right = await perspective.table([{ id: 1, value: 20 }]);
184+
185+
let error;
186+
try {
187+
await perspective.join(left, right, "id");
188+
} catch (e) {
189+
error = e;
190+
}
191+
192+
expect(error).toBeDefined();
193+
right.delete();
194+
left.delete();
195+
});
196+
197+
test("rejects updates on joined table", async function () {
198+
const left = await perspective.table([{ id: 1, x: 10 }]);
199+
const right = await perspective.table([{ id: 1, y: "a" }]);
200+
201+
const joined = await perspective.join(left, right, "id");
202+
203+
let error;
204+
try {
205+
await joined.update([{ id: 1, x: 99, y: "z" }]);
206+
} catch (e) {
207+
error = e;
208+
}
209+
210+
expect(error).toBeDefined();
211+
212+
joined.delete();
213+
right.delete();
214+
left.delete();
215+
});
216+
});
217+
})(perspective);

0 commit comments

Comments
 (0)