import { describe, expect, it } from "@effect/vitest" import { Array, Config, Context, Effect, flow, Layer, ManagedRuntime, Redacted, References, Result, S } from "effect-app" import { LogLevels } from "effect-app/utils" import { setupRequestContextFromCurrent } from "../src/api/setupRequest.js" import { and, or, project, where, whereEvery, whereSome } from "../src/Model/query.js" import { makeRepo } from "../src/Model/Repository/makeRepo.js" import { RepositoryRegistryLive } from "../src/Model/Repository/Registry.js" import { CosmosStoreLayer } from "../src/Store/Cosmos.js" import { MemoryStoreLive } from "../src/Store/Memory.js" export const rt = ManagedRuntime.make(Layer.mergeAll( Layer.effect( LogLevels, Effect.gen(function*() { const levels = yield* LogLevels const m = new Map(levels) m.set("@effect-app/infra", "debug") return m }) ), Layer.succeed(References.MinimumLogLevel, "Debug") )) class Something extends S.Class("Something")({ id: S.String, name: S.String, description: S.String, items: S.Array(S.Struct({ id: S.String, value: S.Finite, description: S.String })) }) {} const items = [ new Something({ id: "1", name: "Item 1", description: "This is the first item", items: [ { id: "1-1", value: 10, description: "First item" }, { id: "1-2", value: 20, description: "Second item" } ] }), new Something({ id: "2", name: "Item 2", description: "This is the second item", items: [ { id: "2-1", value: 30, description: "Third item" }, { id: "2-2", value: 40, description: "Fourth item" } ] }) ] // @effect-diagnostics-next-line missingEffectServiceDependency:off class SomethingRepo extends Context.Service()( "SomethingRepo", { make: Effect.gen(function*() { const partitionKey = "test-" + new Date().getTime() return yield* makeRepo("Something", Something, { config: { partitionValue: () => partitionKey } }) }) } ) { static readonly layer = Layer .effect( SomethingRepo, Effect.gen(function*() { const partitionKey = "test-" + new Date().getTime() const repo = SomethingRepo.of( yield* makeRepo("Something", Something, { config: { partitionValue: () => partitionKey } }) ) // not using makeInitial, because it will prevent inserting the various partitionkeyed items yield* repo.saveAndPublish(items).pipe(setupRequestContextFromCurrent("init")) return repo }) ) static readonly Test = this .layer .pipe( Layer.provide(Layer.merge(MemoryStoreLive, RepositoryRegistryLive)) ) static readonly TestCosmos = this .layer .pipe( Layer.provide( Effect .gen(function*() { const url = yield* Config.redacted("STORAGE_URL").pipe( Config.withDefault( Redacted.make( // the emulator doesn't implement array projections :/ so you need an actual cloud instance! "AccountEndpoint=http://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==" ) ) ) return CosmosStoreLayer({ dbName: "test", prefix: "", url }) .pipe(Layer.merge(RepositoryRegistryLive)) }) .pipe(Layer.unwrap) ) ) } describe("select first-level array fields", () => { const test = Effect .gen(function*() { const repo = yield* SomethingRepo const projected = S.Struct({ name: S.String, items: S.Array(S.Struct({ id: S.String, value: S.Finite })) }) // ok crazy lol, "value" is a reserved word in CosmosDB, so we have to use t["value"] as a field name instead of t.value const items = yield* repo.queryRaw(projected, { cosmos: () => ({ query: ` SELECT f.name, ARRAY (SELECT t.id,t["value"] FROM t in f.items) AS items FROM Somethings f`, parameters: [] }), memory: (items: readonly Something[]) => items.map(({ items, name }) => ({ name, items: items.map(({ id, value }) => ({ id, value })) })) }) const items2 = yield* repo.query(project(projected)) const expected = [ { name: "Item 1", items: [ { id: "1-1", value: 10 }, { id: "1-2", value: 20 } ] }, { name: "Item 2", items: [ { id: "2-1", value: 30 }, { id: "2-2", value: 40 } ] } ] expect(items).toStrictEqual(expected) expect(items2).toStrictEqual(expected) }) .pipe(setupRequestContextFromCurrent()) it.skipIf(!process.env["STORAGE_URL"])("works well in CosmosDB", () => test .pipe(Effect.provide(SomethingRepo.TestCosmos), rt.runPromise)) it("works well in Memory", () => test .pipe(Effect.provide(SomethingRepo.Test), rt.runPromise)) }) const projected = S.Struct({ name: S.String, items: S.Array(S.Struct({ id: S.String, value: S.Finite })) }) const expected = [ { name: "Item 2", items: [ { id: "2-1", value: 30 }, { id: "2-2", value: 40 } ] } ] const both = [ { name: "Item 1", items: [ { id: "1-1", value: 10 }, { id: "1-2", value: 20 } ] }, { name: "Item 2", items: [ { id: "2-1", value: 30 }, { id: "2-2", value: 40 } ] } ] // NOTE: right now we cannot specify if all/"every" items must match the filter, or if at least one item (any/"some") must match the filter. // the current implementation is any/some, so we can always filter down in the code to narrow further.. describe("filter first-level array fields as groups", () => { const test = Effect .gen(function*() { const repo = yield* SomethingRepo // ok crazy lol, "value" is a reserved word in CosmosDB, so we have to use t["value"] as a field name instead of t.value // deprecated; joins should be avoided because they're very expensive, and require DISTINCT to avoid duplicates // which might affect results in unexpected ways? const items = yield* repo.queryRaw(projected, { cosmos: () => ({ query: ` SELECT DISTINCT f.name, ARRAY (SELECT t.id,t["value"] FROM t in f.items) AS items FROM Somethings f JOIN items in f.items WHERE (items["value"] > @v1 AND CONTAINS(items["description"], @v2, true))`, parameters: [{ name: "@v1", value: 20 }, { name: "@v2", value: "d item" }] }), memory: Array.filterMap((item: Something) => item.items.some((_) => _.value > 20 && _.description.includes("d item")) ? Result.succeed({ name: item.name, items: item.items.map(({ id, value }) => ({ id, value })) }) : Result.fail(item) ) }) // we use EXISTS by default now: https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/query/subquery#exists-expression const itemsExists = yield* repo.queryRaw(projected, { cosmos: () => ({ query: ` SELECT f.name, ARRAY (SELECT t.id,t["value"] FROM t in f.items) AS items FROM Somethings f WHERE EXISTS(SELECT VALUE item FROM item IN f.items WHERE item["value"] > @v1 AND CONTAINS(item.description, @v2, true))`, parameters: [{ name: "@v1", value: 20 }, { name: "@v2", value: "d item" }] }), memory: Array.filterMap((item: Something) => item.items.some((_) => _.value > 20 && _.description.includes("d item")) ? Result.succeed({ name: item.name, items: item.items.map(({ id, value }) => ({ id, value })) }) : Result.fail(item) ) }) expect(items).toStrictEqual(expected) expect(itemsExists).toStrictEqual(expected) }) .pipe(setupRequestContextFromCurrent()) it.skipIf(!process.env["STORAGE_URL"])("works well in CosmosDB", () => test .pipe(Effect.provide(SomethingRepo.TestCosmos), rt.runPromise)) it("works well in Memory", () => test .pipe(Effect.provide(SomethingRepo.Test), rt.runPromise)) }) describe("1", () => { const test = Effect .gen(function*() { const repo = yield* SomethingRepo const items2 = yield* repo.query( whereSome( "items", where("value", "gt", 20), and("description", "contains", "d item") ), project(projected) ) expect(items2).toStrictEqual(expected) const items2Or = yield* repo.query( whereSome( "items", where("value", "gt", 20), or("description", "contains", "d item") ), project(projected) ) expect(items2Or).toStrictEqual(both) // mixing relation check with scoped relationcheck const items3 = yield* repo.query( whereSome( "items", where("value", "gt", 20), and(where("description", "contains", "d item")) ), project(projected) ) expect(items3).toStrictEqual(expected) const items3Or = yield* repo.query( whereSome( "items", where("value", "gt", 20), or(where("description", "contains", "d item")) ), project(projected) ) expect(items3Or).toStrictEqual(both) const items4 = yield* repo.query( whereSome("items", where("value", "gt", 10)), project(projected) ) expect(items4).toStrictEqual(both) const items5 = yield* repo.query( whereSome("items", "value", "gt", 10), project(projected) ) expect(items5).toStrictEqual(both) }) .pipe(setupRequestContextFromCurrent()) it.skipIf(!process.env["STORAGE_URL"])("works well in CosmosDB", () => test .pipe(Effect.provide(SomethingRepo.TestCosmos), rt.runPromise)) it("works well in Memory", () => test .pipe(Effect.provide(SomethingRepo.Test), rt.runPromise)) }) describe("multi-level", () => { const test = Effect .gen(function*() { const repo = yield* SomethingRepo const itemsCheckWithEvery = yield* repo.query( whereEvery( "items", flow( where("value", "gt", 20), and("description", "contains", "d item") ) ), project(projected) ) expect(itemsCheckWithEvery).toStrictEqual([]) }) .pipe(setupRequestContextFromCurrent()) it.skipIf(!process.env["STORAGE_URL"])("works well in CosmosDB", () => test .pipe(Effect.provide(SomethingRepo.TestCosmos), rt.runPromise)) it("works well in Memory", () => test .pipe(Effect.provide(SomethingRepo.Test), rt.runPromise)) }) // FUTURE: we need something like this instead: /* const subQuery = () => (key: TKey, type: "some" | "every" = "some") => make() // todo: mark that this is sub query on field "items" const test = subQuery()("items", "every") .pipe( where("value", "gt", 20), and("description", "contains", "d item") ) // ideally we can do stuff like: where(subQuery("items")( where("value", "gt", 10), and("description", "contains", "d item") )) */ describe("removeByIds", () => { const test = Effect .gen(function*() { const items = [ new Something({ id: "2-1", name: "Item 1", description: "This is the first item", items: [ { id: "1-1", value: 10, description: "First item" }, { id: "1-2", value: 20, description: "Second item" } ] }), new Something({ id: "2-2", name: "Item 2", description: "This is the second item", items: [ { id: "2-1", value: 30, description: "Third item" }, { id: "2-2", value: 40, description: "Fourth item" } ] }), new Something({ id: "2-3", name: "Item 3", description: "This is the third item", items: [ { id: "2-1", value: 30, description: "Third item" }, { id: "2-2", value: 40, description: "Fourth item" } ] }) ] const repo = yield* SomethingRepo yield* repo.saveAndPublish(items) const itemsAfterSave = yield* repo.all yield* repo.removeById([items[0]!.id, items[1]!.id]) const items2 = yield* repo.all expect(itemsAfterSave.length).toStrictEqual(5) expect(items2.length).toStrictEqual(3) }) .pipe(setupRequestContextFromCurrent()) it.skipIf(!process.env["STORAGE_URL"])("works well in CosmosDB", () => test .pipe(Effect.provide(SomethingRepo.TestCosmos), rt.runPromise)) it("works well in Memory", () => test .pipe(Effect.provide(SomethingRepo.Test), rt.runPromise)) })