diff --git a/skipruntime-ts/helpers/src/index.ts b/skipruntime-ts/helpers/src/index.ts index a1848dc25a..dbab20da87 100644 --- a/skipruntime-ts/helpers/src/index.ts +++ b/skipruntime-ts/helpers/src/index.ts @@ -14,3 +14,4 @@ export { export { SkipExternalService } from "./remote.js"; export { SkipServiceBroker, fetchJSON, type Entrypoint } from "./rest.js"; export { Count, Max, Min, Sum } from "./utils.js"; +export { join_one, join_many, join_many_through } from "./join.js"; diff --git a/skipruntime-ts/helpers/src/join.ts b/skipruntime-ts/helpers/src/join.ts new file mode 100644 index 0000000000..9c2156252d --- /dev/null +++ b/skipruntime-ts/helpers/src/join.ts @@ -0,0 +1,250 @@ +import type { + EagerCollection, + Json, + JsonObject, + Values, + Mapper, + DepSafe, +} from "@skipruntime/core"; + +class JoinOneMapper< + IdProperty extends keyof VLeft, + AsProperty extends string, + KLeft extends Json, + VLeft extends JsonObject & { [P in IdProperty]: Json }, + VRight extends Json, +> implements + Mapper< + KLeft, + VLeft, + KLeft, + Omit & Record + > +{ + constructor( + private readonly right: EagerCollection, + private readonly id: IdProperty, + private readonly as: AsProperty, + ) {} + + mapEntry( + key: KLeft, + values: Values, + ): Iterable<[KLeft, Omit & Record]> { + return values.toArray().map((v: VLeft) => { + const { [this.id]: key_right, ...value_left } = v; + const value_right = { + [this.as]: this.right.getUnique(key_right), + } as Record; + const value_out = { ...value_left, ...value_right }; + return [key, value_out]; + }); + } +} + +export function join_one< + IdProperty extends keyof VLeft, + AsProperty extends string, + KLeft extends Json, + VLeft extends JsonObject & { [P in IdProperty]: Json }, + VRight extends Json, +>(options: { + left: EagerCollection; + right: EagerCollection; + id: IdProperty; + as: AsProperty; +}): EagerCollection< + KLeft, + Omit & Record +> { + return options.left.map( + JoinOneMapper, + options.right, + options.id, + options.as, + ); +} + +class ProjectionMapper< + ProjectionProperty extends keyof V, + K extends Json, + V extends JsonObject & { [P in ProjectionProperty]: Json }, +> implements Mapper +{ + constructor(private readonly proj_property: ProjectionProperty) {} + + mapEntry(_key: K, values: Values): Iterable<[V[ProjectionProperty], V]> { + return values.toArray().map((v: V) => { + return [v[this.proj_property], v]; + }); + } +} + +class JoinManyMapper< + IdProperty extends keyof VRight, + AsProperty extends string, + K extends Json, + VLeft extends JsonObject, + VRight extends JsonObject & { [P in IdProperty]: Json }, +> implements + Mapper< + VRight[IdProperty], + VLeft, + VRight[IdProperty], + VLeft & Record + > +{ + private readonly right: EagerCollection; + + constructor( + right: EagerCollection, + on: IdProperty, + private readonly as: AsProperty, + ) { + this.right = right.map(ProjectionMapper, on); + } + + mapEntry( + key: VRight[IdProperty], + values: Values, + ): Iterable<[VRight[IdProperty], VLeft & Record]> { + return values.toArray().map((value_left: VLeft) => { + const value_right = { [this.as]: this.right.getArray(key) } as Record< + AsProperty, + (VRight & DepSafe)[] + >; + const value_out = { ...value_left, ...value_right }; + return [key, value_out]; + }); + } +} + +class SelectionMapper< + P extends keyof V, + K extends Json, + V extends JsonObject & { [T in P]: Json }, +> implements Mapper +{ + constructor(private readonly prop: P) {} + + mapEntry(key: K, values: Values): Iterable<[K, V[P]]> { + return values.toArray().map((v) => { + return [key, v[this.prop]]; + }); + } +} + +class JoinManyThroughMapper< + IdLeftProperty extends keyof VThrough, + IdRightProperty extends keyof VThrough, + AsProperty extends string, + VLeft extends JsonObject, + VRight extends Json, + KThrough extends Json, + VThrough extends JsonObject & { + [P in IdLeftProperty | IdRightProperty]: Json; + }, +> implements + Mapper< + VThrough[IdLeftProperty], + VLeft, + VThrough[IdLeftProperty], + VLeft & Record + > +{ + private readonly through: EagerCollection< + VThrough[IdLeftProperty], + VThrough[IdRightProperty] + >; + + constructor( + private readonly right: EagerCollection, + through: EagerCollection, + id_left: IdLeftProperty, + id_right: IdRightProperty, + private readonly as: AsProperty, + ) { + this.through = through + .map(ProjectionMapper, id_left) + .map( + SelectionMapper, + id_right, + ); + } + + mapEntry( + key: VThrough[IdLeftProperty], + values: Values, + ): Iterable< + [VThrough[IdLeftProperty], VLeft & Record] + > { + return values.toArray().map((value_left: VLeft) => { + const value_right = { + [this.as]: this.through + .getArray(key) + .map((id_right) => this.right.getUnique(id_right)), + } as Record; + const value_out = { ...value_left, ...value_right }; + return [key, value_out]; + }); + } +} + +export function join_many_through< + IdLeftProperty extends keyof VThrough, + IdRightProperty extends keyof VThrough, + AsProperty extends string, + VLeft extends JsonObject, + VRight extends Json, + KThrough extends Json, + VThrough extends JsonObject & { + [P in IdLeftProperty | IdRightProperty]: Json; + }, +>(options: { + left: EagerCollection; + right: EagerCollection; + as: AsProperty; + id_left: IdLeftProperty; + id_right: IdRightProperty; + through: EagerCollection; +}): EagerCollection< + VThrough[IdLeftProperty], + VLeft & Record +> { + return options.left.map( + JoinManyThroughMapper< + IdLeftProperty, + IdRightProperty, + AsProperty, + VLeft, + VRight, + KThrough, + VThrough + >, + options.right, + options.through, + options.id_left, + options.id_right, + options.as, + ); +} + +export function join_many< + IdProperty extends keyof VRight, + AsProperty extends string, + KRight extends Json, + VLeft extends JsonObject, + VRight extends JsonObject & { [K in IdProperty]: Json }, +>(options: { + left: EagerCollection; + right: EagerCollection; + id: IdProperty; + as: AsProperty; +}): EagerCollection> { + return options.left.map( + JoinManyMapper, + options.right, + options.id, + options.as, + ); +} diff --git a/skipruntime-ts/tests/src/tests.ts b/skipruntime-ts/tests/src/tests.ts index 218787989f..a97e86703f 100644 --- a/skipruntime-ts/tests/src/tests.ts +++ b/skipruntime-ts/tests/src/tests.ts @@ -21,6 +21,9 @@ import { GenericExternalService, Sum, TimerResource, + join_one, + join_many, + join_many_through, } from "@skipruntime/helpers"; import { it as mit, type AsyncFunc } from "mocha"; @@ -759,6 +762,86 @@ const mapWithExceptionOnExternalService: SkipService = { }, }; +//// testJoinHelpers + +type Post = { title: string; author_id: number }; +type User = { name: string }; +type Upvote = { user_id: number; post_id: number }; + +type JoinServiceInputs = { + posts: EagerCollection; + users: EagerCollection; + upvotes: EagerCollection; +}; + +type PostWithAuthorAndUpvotes = Omit & { + author: User; + upvotes: Upvote[]; +}; + +type PostWithAuthorAndUpvoters = Omit & { + author: User; + upvoters: User[]; +}; + +type JoinServiceResourceInputs = { + posts1: EagerCollection; + posts2: EagerCollection; +}; + +class Posts1Resource implements Resource { + instantiate( + collections: JoinServiceResourceInputs, + ): EagerCollection { + return collections.posts1; + } +} + +class Posts2Resource implements Resource { + instantiate( + collections: JoinServiceResourceInputs, + ): EagerCollection { + return collections.posts2; + } +} + +const joinService: SkipService = { + initialData: { + posts: [], + users: [], + upvotes: [], + }, + resources: { + posts1: Posts1Resource, + posts2: Posts2Resource, + }, + createGraph(inputCollections: JoinServiceInputs) { + const posts_with_author = join_one( + { + left: inputCollections.posts, + right: inputCollections.users, + id: "author_id", + as: "author", + }, + ); + const posts1 = join_many({ + left: posts_with_author, + right: inputCollections.upvotes, + id: "post_id", + as: "upvotes", + }); + const posts2 = join_many_through({ + left: posts_with_author, + right: inputCollections.users, + through: inputCollections.upvotes, + id_left: "post_id", + id_right: "user_id", + as: "upvoters", + }); + return { posts1, posts2 }; + }, +}; + export function initTests( category: string, initService: (service: SkipService) => Promise, @@ -1295,4 +1378,71 @@ export function initTests( new RegExp(/^(?:Error: )?Something goes wrong.$/), ); }); + + it("testJoinHelpers", async () => { + const service = await initService(joinService); + try { + service.update("users", [ + [1, [{ name: "Foo" }]], + [2, [{ name: "Bar" }]], + ]); + service.update("posts", [ + [1, [{ title: "FooBar", author_id: 1 }]], + [2, [{ title: "Baz", author_id: 2 }]], + ]); + service.update("upvotes", [ + [1, [{ post_id: 1, user_id: 1 }]], + [2, [{ post_id: 1, user_id: 2 }]], + [3, [{ post_id: 2, user_id: 2 }]], + ]); + service.instantiateResource("unsafe.fixed.resource.ident1", "posts1", {}); + service.instantiateResource("unsafe.fixed.resource.ident2", "posts2", {}); + expect(service.getAll("posts1").payload).toEqual([ + [ + 1, + [ + { + title: "FooBar", + author: { name: "Foo" }, + upvotes: [{ post_id: 1, user_id: 1 }, { post_id: 1, user_id: 2 }], + }, + ], + ], + [ + 2, + [ + { + title: "Baz", + author: { name: "Bar" }, + upvotes: [{ post_id: 2, user_id: 2 }], + }, + ], + ], + ]); + expect(service.getAll("posts2").payload).toEqual([ + [ + 1, + [ + { + title: "FooBar", + author: { name: "Foo" }, + upvoters: [{ name: "Foo" }, { name: "Bar" }], + }, + ], + ], + [ + 2, + [ + { + title: "Baz", + author: { name: "Bar" }, + upvoters: [{ name: "Bar" }], + }, + ], + ], + ]); + } finally { + await service.close(); + } + }); }