Skip to content

Commit 5253862

Browse files
committed
[skip/helpers] Add join_one()/join_many() helpers.
Example usage for `join_one()`: ``` type User = { name: string; email: string; }; type Post = { title: string; body: string; author_id: number; }; type PostWithAuthor = { title: string; body: string; author: User; }; ... // The following turns `Post`s and `User`s into `PostWithAuthor`s. join_one(posts, users, { on: "author_id", name: "author", }) ``` Example usage for `join_many()`: ``` type Upvote = { post_id: number; user_id: number; }; type Post = { title: string; body: string; }; type PostWithUpvotes = { title: string; body: string; upvotes: { user_id: number; }[]; }; ... // The following turns `Post`s and `Upvote`s into `PostWithUpvote`s. join_many(posts, upvotes, { on: "post_id", name: "upvotes", }) ```
1 parent 82ef5bc commit 5253862

File tree

3 files changed

+258
-0
lines changed

3 files changed

+258
-0
lines changed

skipruntime-ts/helpers/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ export {
1414
export { SkipExternalService } from "./remote.js";
1515
export { SkipServiceBroker, fetchJSON, type Entrypoint } from "./rest.js";
1616
export { Count, Max, Min, Sum } from "./utils.js";
17+
export { join_one, join_many } from "./join.js";

skipruntime-ts/helpers/src/join.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import type {
2+
EagerCollection,
3+
Json,
4+
JsonObject,
5+
Values,
6+
Mapper,
7+
DepSafe,
8+
} from "@skipruntime/core";
9+
10+
class JoinOneMapper<
11+
IdProperty extends keyof VLeft,
12+
JoinedProperty extends string,
13+
K extends Json,
14+
VLeft extends JsonObject & { [P in IdProperty]: Json },
15+
VRight extends Json,
16+
> implements
17+
Mapper<
18+
K,
19+
VLeft,
20+
K,
21+
Omit<VLeft, IdProperty> & Record<JoinedProperty, VRight>
22+
>
23+
{
24+
constructor(
25+
private readonly right: EagerCollection<VLeft[IdProperty], VRight>,
26+
private readonly on: IdProperty,
27+
private readonly name: JoinedProperty,
28+
) {}
29+
30+
mapEntry(
31+
key: K,
32+
values: Values<VLeft>,
33+
): Iterable<[K, Omit<VLeft, IdProperty> & Record<JoinedProperty, VRight>]> {
34+
return values.toArray().map((v: VLeft) => {
35+
const { [this.on]: key_right, ...value_left } = v;
36+
const value_right = {
37+
[this.name]: this.right.getUnique(key_right),
38+
} as Record<JoinedProperty, VRight>;
39+
const value_out = { ...value_left, ...value_right } as const;
40+
return [key, value_out];
41+
});
42+
}
43+
}
44+
45+
export function join_one<
46+
IdProperty extends keyof V1,
47+
JoinedProperty extends string,
48+
K extends Json,
49+
V1 extends JsonObject & { [P in IdProperty]: Json },
50+
V2 extends Json,
51+
>(
52+
left: EagerCollection<K, V1>,
53+
right: EagerCollection<V1[IdProperty], V2>,
54+
options: {
55+
on: IdProperty;
56+
name: JoinedProperty;
57+
},
58+
): EagerCollection<K, Omit<V1, IdProperty> & Record<JoinedProperty, V2>> {
59+
return left.map(
60+
JoinOneMapper<IdProperty, JoinedProperty, K, V1, V2>,
61+
right,
62+
options.on,
63+
options.name,
64+
);
65+
}
66+
67+
class ProjectionMapper<
68+
ProjectionProperty extends keyof V,
69+
K extends Json,
70+
V extends JsonObject & { [P in ProjectionProperty]: Json },
71+
> implements Mapper<K, V, V[ProjectionProperty], Omit<V, ProjectionProperty>>
72+
{
73+
constructor(private readonly proj_property: ProjectionProperty) {}
74+
75+
mapEntry(
76+
_key: K,
77+
values: Values<V>,
78+
): Iterable<[V[ProjectionProperty], Omit<V, ProjectionProperty>]> {
79+
return values.toArray().map((v: V) => {
80+
const { [this.proj_property]: new_key, ...new_value } = v;
81+
return [new_key, new_value];
82+
});
83+
}
84+
}
85+
86+
class JoinManyMapper<
87+
IdProperty extends keyof VRight,
88+
JoinedProperty extends string,
89+
K extends Json,
90+
VLeft extends JsonObject,
91+
VRight extends JsonObject & { [P in IdProperty]: Json },
92+
> implements
93+
Mapper<
94+
VRight[IdProperty],
95+
VLeft,
96+
VRight[IdProperty],
97+
VLeft & Record<JoinedProperty, Omit<VRight, IdProperty>[]>
98+
>
99+
{
100+
private readonly right: EagerCollection<
101+
VRight[IdProperty],
102+
Omit<VRight, IdProperty>
103+
>;
104+
105+
constructor(
106+
right: EagerCollection<K, VRight>,
107+
on: IdProperty,
108+
private readonly name: JoinedProperty,
109+
) {
110+
this.right = right.map(ProjectionMapper<IdProperty, K, VRight>, on);
111+
}
112+
113+
mapEntry(
114+
key: VRight[IdProperty],
115+
values: Values<VLeft>,
116+
): Iterable<
117+
[
118+
VRight[IdProperty],
119+
VLeft & Record<JoinedProperty, Omit<VRight, IdProperty>[]>,
120+
]
121+
> {
122+
return values.toArray().map((value_left: VLeft) => {
123+
const value_right = { [this.name]: this.right.getArray(key) } as Record<
124+
JoinedProperty,
125+
(Omit<VRight, IdProperty> & DepSafe)[]
126+
>;
127+
const value_out = { ...value_left, ...value_right };
128+
return [key, value_out];
129+
});
130+
}
131+
}
132+
133+
export function join_many<
134+
IdProperty extends keyof V2,
135+
JoinedProperty extends string,
136+
K extends Json,
137+
V1 extends JsonObject,
138+
V2 extends JsonObject & { [P in IdProperty]: Json },
139+
>(
140+
left: EagerCollection<V2[IdProperty], V1>,
141+
right: EagerCollection<K, V2>,
142+
options: {
143+
on: IdProperty;
144+
name: JoinedProperty;
145+
},
146+
): EagerCollection<
147+
V2[IdProperty],
148+
V1 & Record<JoinedProperty, Omit<V2, IdProperty>[]>
149+
> {
150+
return left.map(
151+
JoinManyMapper<IdProperty, JoinedProperty, K, V1, V2>,
152+
right,
153+
options.on,
154+
options.name,
155+
);
156+
}

skipruntime-ts/tests/src/tests.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import {
2121
GenericExternalService,
2222
Sum,
2323
TimerResource,
24+
join_one,
25+
join_many,
2426
} from "@skipruntime/helpers";
2527

2628
import { it as mit, type AsyncFunc } from "mocha";
@@ -759,6 +761,61 @@ const mapWithExceptionOnExternalService: SkipService<Input_SN, Input_SN> = {
759761
},
760762
};
761763

764+
//// testJoinHelpers
765+
766+
type Post = { title: string; author_id: number };
767+
type User = { name: string };
768+
type Upvote = { user_id: number; post_id: number };
769+
770+
type JoinServiceInputs = {
771+
posts: EagerCollection<number, Post>;
772+
users: EagerCollection<number, User>;
773+
upvotes: EagerCollection<number, Upvote>;
774+
};
775+
776+
type PostWithAuthorAndUpvotes = Omit<Post, "author_id"> & {
777+
author: User;
778+
upvotes: Omit<Upvote, "post_id">[];
779+
};
780+
781+
type JoinServiceResourceInputs = {
782+
posts: EagerCollection<number, PostWithAuthorAndUpvotes>;
783+
};
784+
785+
class PostsResource implements Resource<JoinServiceResourceInputs> {
786+
instantiate(
787+
collections: JoinServiceResourceInputs,
788+
): EagerCollection<number, PostWithAuthorAndUpvotes> {
789+
return collections.posts;
790+
}
791+
}
792+
793+
const joinService: SkipService<JoinServiceInputs, JoinServiceResourceInputs> = {
794+
initialData: {
795+
posts: [],
796+
users: [],
797+
upvotes: [],
798+
},
799+
resources: {
800+
posts: PostsResource,
801+
},
802+
createGraph(inputCollections: JoinServiceInputs) {
803+
const posts_with_author = join_one(
804+
inputCollections.posts,
805+
inputCollections.users,
806+
{
807+
on: "author_id",
808+
name: "author",
809+
},
810+
);
811+
const posts = join_many(posts_with_author, inputCollections.upvotes, {
812+
on: "post_id",
813+
name: "upvotes",
814+
});
815+
return { posts };
816+
},
817+
};
818+
762819
export function initTests(
763820
category: string,
764821
initService: (service: SkipService) => Promise<ServiceInstance>,
@@ -1295,4 +1352,48 @@ export function initTests(
12951352
new RegExp(/^(?:Error: )?Something goes wrong.$/),
12961353
);
12971354
});
1355+
1356+
it("testJoinHelpers", async () => {
1357+
const service = await initService(joinService);
1358+
try {
1359+
service.update("users", [
1360+
[1, [{ name: "Foo" }]],
1361+
[2, [{ name: "Bar" }]],
1362+
]);
1363+
service.update("posts", [
1364+
[1, [{ title: "FooBar", author_id: 1 }]],
1365+
[2, [{ title: "Baz", author_id: 2 }]],
1366+
]);
1367+
service.update("upvotes", [
1368+
[1, [{ post_id: 1, user_id: 1 }]],
1369+
[2, [{ post_id: 1, user_id: 2 }]],
1370+
[3, [{ post_id: 2, user_id: 2 }]],
1371+
]);
1372+
service.instantiateResource("unsafe.fixed.resource.ident", "posts", {});
1373+
expect(service.getAll("posts").payload).toEqual([
1374+
[
1375+
1,
1376+
[
1377+
{
1378+
title: "FooBar",
1379+
author: { name: "Foo" },
1380+
upvotes: [{ user_id: 1 }, { user_id: 2 }],
1381+
},
1382+
],
1383+
],
1384+
[
1385+
2,
1386+
[
1387+
{
1388+
title: "Baz",
1389+
author: { name: "Bar" },
1390+
upvotes: [{ user_id: 2 }],
1391+
},
1392+
],
1393+
],
1394+
]);
1395+
} finally {
1396+
await service.close();
1397+
}
1398+
});
12981399
}

0 commit comments

Comments
 (0)