Skip to content

Commit e1e6f63

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 0f15986 commit e1e6f63

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";
@@ -747,6 +749,61 @@ const mapWithExceptionOnExternalService: SkipService<Input_SN, Input_SN> = {
747749
},
748750
};
749751

752+
//// testJoinHelpers
753+
754+
type Post = { title: string; author_id: number };
755+
type User = { name: string };
756+
type Upvote = { user_id: number; post_id: number };
757+
758+
type JoinServiceInputs = {
759+
posts: EagerCollection<number, Post>;
760+
users: EagerCollection<number, User>;
761+
upvotes: EagerCollection<number, Upvote>;
762+
};
763+
764+
type PostWithAuthorAndUpvotes = Omit<Post, "author_id"> & {
765+
author: User;
766+
upvotes: Omit<Upvote, "post_id">[];
767+
};
768+
769+
type JoinServiceResourceInputs = {
770+
posts: EagerCollection<number, PostWithAuthorAndUpvotes>;
771+
};
772+
773+
class PostsResource implements Resource<JoinServiceResourceInputs> {
774+
instantiate(
775+
collections: JoinServiceResourceInputs,
776+
): EagerCollection<number, PostWithAuthorAndUpvotes> {
777+
return collections.posts;
778+
}
779+
}
780+
781+
const joinService: SkipService<JoinServiceInputs, JoinServiceResourceInputs> = {
782+
initialData: {
783+
posts: [],
784+
users: [],
785+
upvotes: [],
786+
},
787+
resources: {
788+
posts: PostsResource,
789+
},
790+
createGraph(inputCollections: JoinServiceInputs) {
791+
const posts_with_author = join_one(
792+
inputCollections.posts,
793+
inputCollections.users,
794+
{
795+
on: "author_id",
796+
name: "author",
797+
},
798+
);
799+
const posts = join_many(posts_with_author, inputCollections.upvotes, {
800+
on: "post_id",
801+
name: "upvotes",
802+
});
803+
return { posts };
804+
},
805+
};
806+
750807
export function initTests(
751808
category: string,
752809
initService: (service: SkipService) => Promise<ServiceInstance>,
@@ -1260,4 +1317,48 @@ export function initTests(
12601317
new RegExp(/^(?:Error: )?Something goes wrong.$/),
12611318
);
12621319
});
1320+
1321+
it("testJoinHelpers", async () => {
1322+
const service = await initService(joinService);
1323+
try {
1324+
service.update("users", [
1325+
[1, [{ name: "Foo" }]],
1326+
[2, [{ name: "Bar" }]],
1327+
]);
1328+
service.update("posts", [
1329+
[1, [{ title: "FooBar", author_id: 1 }]],
1330+
[2, [{ title: "Baz", author_id: 2 }]],
1331+
]);
1332+
service.update("upvotes", [
1333+
[1, [{ post_id: 1, user_id: 1 }]],
1334+
[2, [{ post_id: 1, user_id: 2 }]],
1335+
[3, [{ post_id: 2, user_id: 2 }]],
1336+
]);
1337+
service.instantiateResource("unsafe.fixed.resource.ident", "posts", {});
1338+
expect(service.getAll("posts").payload).toEqual([
1339+
[
1340+
1,
1341+
[
1342+
{
1343+
title: "FooBar",
1344+
author: { name: "Foo" },
1345+
upvotes: [{ user_id: 1 }, { user_id: 2 }],
1346+
},
1347+
],
1348+
],
1349+
[
1350+
2,
1351+
[
1352+
{
1353+
title: "Baz",
1354+
author: { name: "Bar" },
1355+
upvotes: [{ user_id: 2 }],
1356+
},
1357+
],
1358+
],
1359+
]);
1360+
} finally {
1361+
await service.close();
1362+
}
1363+
});
12631364
}

0 commit comments

Comments
 (0)