diff --git a/app/__test__/api/DataApi.spec.ts b/app/__test__/api/DataApi.spec.ts index 2e4e4c5..c6bb3df 100644 --- a/app/__test__/api/DataApi.spec.ts +++ b/app/__test__/api/DataApi.spec.ts @@ -40,19 +40,17 @@ describe("DataApi", () => { { const res = (await app.request("/entity/posts")) as Response; - const { data } = await res.json(); + const { data } = (await res.json()) as any; expect(data.length).toEqual(3); } // @ts-ignore tests - const api = new DataApi({ basepath: "/", queryLengthLimit: 50 }); - // @ts-ignore protected - api.fetcher = app.request as typeof fetch; + const api = new DataApi({ basepath: "/", queryLengthLimit: 50 }, app.request as typeof fetch); { const req = api.readMany("posts", { select: ["title"] }); expect(req.request.method).toBe("GET"); const res = await req; - expect(res.data).toEqual(payload); + expect(res.data).toEqual(payload as any); } { @@ -64,7 +62,151 @@ describe("DataApi", () => { }); expect(req.request.method).toBe("POST"); const res = await req; - expect(res.data).toEqual(payload); + expect(res.data).toEqual(payload as any); + } + }); + + it("updates many", async () => { + const schema = proto.em({ + posts: proto.entity("posts", { title: proto.text(), count: proto.number() }), + }); + const em = schemaToEm(schema); + await em.schema().sync({ force: true }); + + const payload = [ + { title: "foo", count: 0 }, + { title: "bar", count: 0 }, + { title: "baz", count: 0 }, + { title: "bla", count: 2 }, + ]; + await em.mutator("posts").insertMany(payload); + + const ctx: any = { em, guard: new Guard() }; + const controller = new DataController(ctx, dataConfig); + const app = controller.getController(); + + // @ts-ignore tests + const api = new DataApi({ basepath: "/" }, app.request as typeof fetch); + { + const req = api.readMany("posts", { + select: ["title", "count"], + }); + const res = await req; + expect(res.data).toEqual(payload as any); + } + + { + // update with empty where + expect(() => api.updateMany("posts", {}, { count: 1 })).toThrow(); + expect(() => api.updateMany("posts", undefined, { count: 1 })).toThrow(); + } + + { + // update + const req = await api.updateMany("posts", { count: 0 }, { count: 1 }); + expect(req.res.status).toBe(200); + } + + { + // compare + const res = await api.readMany("posts", { + select: ["title", "count"], + }); + expect(res.map((p) => p.count)).toEqual([1, 1, 1, 2]); + } + }); + + it("refines", async () => { + const schema = proto.em({ + posts: proto.entity("posts", { title: proto.text() }), + }); + const em = schemaToEm(schema); + await em.schema().sync({ force: true }); + + const payload = [{ title: "foo" }, { title: "bar" }, { title: "baz" }]; + await em.mutator("posts").insertMany(payload); + + const ctx: any = { em, guard: new Guard() }; + const controller = new DataController(ctx, dataConfig); + const app = controller.getController(); + + const api = new DataApi({ basepath: "/" }, app.request as typeof fetch); + const normalOne = api.readOne("posts", 1); + const normal = api.readMany("posts", { select: ["title"], where: { title: "baz" } }); + expect((await normal).data).toEqual([{ title: "baz" }] as any); + + // refine + const refined = normal.refine((data) => data[0]); + expect((await refined).data).toEqual({ title: "baz" } as any); + + // one + const oneBy = api.readOneBy("posts", { where: { title: "baz" }, select: ["title"] }); + const oneByRes = await oneBy; + expect(oneByRes.data).toEqual({ title: "baz" } as any); + expect(oneByRes.body.meta.count).toEqual(1); + }); + + it("exists/count", async () => { + const schema = proto.em({ + posts: proto.entity("posts", { title: proto.text() }), + }); + const em = schemaToEm(schema); + await em.schema().sync({ force: true }); + + const payload = [{ title: "foo" }, { title: "bar" }, { title: "baz" }]; + await em.mutator("posts").insertMany(payload); + + const ctx: any = { em, guard: new Guard() }; + const controller = new DataController(ctx, dataConfig); + const app = controller.getController(); + + const api = new DataApi({ basepath: "/" }, app.request as typeof fetch); + + const exists = api.exists("posts", { id: 1 }); + expect((await exists).exists).toBeTrue(); + + expect((await api.count("posts")).count).toEqual(3); + }); + + it("creates many", async () => { + const schema = proto.em({ + posts: proto.entity("posts", { title: proto.text(), count: proto.number() }), + }); + const em = schemaToEm(schema); + await em.schema().sync({ force: true }); + + const payload = [ + { title: "foo", count: 0 }, + { title: "bar", count: 0 }, + { title: "baz", count: 0 }, + { title: "bla", count: 2 }, + ]; + + const ctx: any = { em, guard: new Guard() }; + const controller = new DataController(ctx, dataConfig); + const app = controller.getController(); + + // @ts-ignore tests + const api = new DataApi({ basepath: "/" }, app.request as typeof fetch); + + { + // create many + const res = await api.createMany("posts", payload); + expect(res.data.length).toEqual(4); + expect(res.ok).toBeTrue(); + } + + { + const req = api.readMany("posts", { + select: ["title", "count"], + }); + const res = await req; + expect(res.data).toEqual(payload as any); + } + + { + // create with empty + expect(() => api.createMany("posts", [])).toThrow(); } }); }); diff --git a/app/__test__/api/ModuleApi.spec.ts b/app/__test__/api/ModuleApi.spec.ts index 59c8816..6f65567 100644 --- a/app/__test__/api/ModuleApi.spec.ts +++ b/app/__test__/api/ModuleApi.spec.ts @@ -89,6 +89,14 @@ describe("ModuleApi", () => { expect(api.delete("/").request.method).toEqual("DELETE"); }); + it("refines", async () => { + const app = new Hono().get("/endpoint", (c) => c.json({ foo: ["bar"] })); + const api = new Api({ host }, app.request as typeof fetch); + + expect((await api.get("/endpoint")).data).toEqual({ foo: ["bar"] }); + expect((await api.get("/endpoint").refine((data) => data.foo)).data).toEqual(["bar"]); + }); + // @todo: test error response // @todo: test method shortcut functions }); diff --git a/app/__test__/data/mutation.simple.test.ts b/app/__test__/data/mutation.simple.test.ts index 8fb0a33..f425733 100644 --- a/app/__test__/data/mutation.simple.test.ts +++ b/app/__test__/data/mutation.simple.test.ts @@ -137,7 +137,7 @@ describe("Mutator simple", async () => { expect((await em.repository(items).findMany()).data.length).toBe(data.length - 2); //console.log((await em.repository(items).findMany()).data); - await em.mutator(items).deleteWhere(); + await em.mutator(items).deleteWhere({ id: { $isnull: 0 } }); expect((await em.repository(items).findMany()).data.length).toBe(0); //expect(res.data.count).toBe(0); diff --git a/app/package.json b/app/package.json index 074b291..7b4d8c0 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.9.0-rc.2", + "version": "0.9.0-rc.3", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "homepage": "https://bknd.io", "repository": { diff --git a/app/src/data/api/DataApi.ts b/app/src/data/api/DataApi.ts index bff63e7..0819b08 100644 --- a/app/src/data/api/DataApi.ts +++ b/app/src/data/api/DataApi.ts @@ -1,6 +1,7 @@ import type { DB } from "core"; import type { EntityData, RepoQueryIn, RepositoryResponse } from "data"; import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules"; +import type { FetchPromise, ResponseObject } from "modules/ModuleApi"; export type DataApiOptions = BaseModuleApiOptions & { queryLengthLimit: number; @@ -18,6 +19,12 @@ export class DataApi extends ModuleApi { }; } + private requireObjectSet(obj: any, message?: string) { + if (!obj || typeof obj !== "object" || Object.keys(obj).length === 0) { + throw new Error(message ?? "object is required"); + } + } + readOne( entity: E, id: PrimaryFieldType, @@ -29,6 +36,18 @@ export class DataApi extends ModuleApi { ); } + readOneBy( + entity: E, + query: Omit = {}, + ) { + type T = Pick, "meta" | "data">; + return this.readMany(entity, { + ...query, + limit: 1, + offset: 0, + }).refine((data) => data[0]) as unknown as FetchPromise>; + } + readMany( entity: E, query: RepoQueryIn = {}, @@ -63,25 +82,64 @@ export class DataApi extends ModuleApi { return this.post>(["entity", entity as any], input); } + createMany( + entity: E, + input: Omit[], + ) { + if (!input || !Array.isArray(input) || input.length === 0) { + throw new Error("input is required"); + } + return this.post>(["entity", entity as any], input); + } + updateOne( entity: E, id: PrimaryFieldType, input: Partial>, ) { + if (!id) throw new Error("ID is required"); return this.patch>(["entity", entity as any, id], input); } + updateMany( + entity: E, + where: RepoQueryIn["where"], + update: Partial>, + ) { + this.requireObjectSet(where); + return this.patch>(["entity", entity as any], { + update, + where, + }); + } + deleteOne( entity: E, id: PrimaryFieldType, ) { + if (!id) throw new Error("ID is required"); return this.delete>(["entity", entity as any, id]); } + deleteMany( + entity: E, + where: RepoQueryIn["where"], + ) { + this.requireObjectSet(where); + return this.delete>(["entity", entity as any], where); + } + count(entity: E, where: RepoQueryIn["where"] = {}) { return this.post>( ["entity", entity as any, "fn", "count"], where, ); } + + exists(entity: E, where: RepoQueryIn["where"] = {}) { + return this.post>( + ["entity", entity as any, "fn", "exists"], + where, + ); + } } diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 232d463..5aae943 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -343,14 +343,20 @@ export class DataController extends Controller { "/:entity", permission(DataPermissions.entityCreate), tb("param", Type.Object({ entity: Type.String() })), + tb("json", Type.Union([Type.Object({}), Type.Array(Type.Object({}))])), async (c) => { const { entity } = c.req.param(); if (!this.entityExists(entity)) { return this.notFound(c); } - const body = (await c.req.json()) as EntityData; - const result = await this.em.mutator(entity).insertOne(body); + const body = (await c.req.json()) as EntityData | EntityData[]; + if (Array.isArray(body)) { + const result = await this.em.mutator(entity).insertMany(body); + return c.json(this.mutatorResult(result), 201); + } + + const result = await this.em.mutator(entity).insertOne(body); return c.json(this.mutatorResult(result), 201); }, ); diff --git a/app/src/data/entities/Mutator.ts b/app/src/data/entities/Mutator.ts index d6f49db..fcac3ef 100644 --- a/app/src/data/entities/Mutator.ts +++ b/app/src/data/entities/Mutator.ts @@ -270,9 +270,14 @@ export class Mutator< } // @todo: decide whether entries should be deleted all at once or one by one (for events) - async deleteWhere(where?: RepoQuery["where"]): Promise> { + async deleteWhere(where: RepoQuery["where"]): Promise> { const entity = this.entity; + // @todo: add a way to delete all by adding force? + if (!where || typeof where !== "object" || Object.keys(where).length === 0) { + throw new Error("Where clause must be provided for mass deletion"); + } + const qb = this.appendWhere(this.conn.deleteFrom(entity.name), where).returning( entity.getSelect(), ); @@ -282,11 +287,16 @@ export class Mutator< async updateWhere( data: Partial, - where?: RepoQuery["where"], + where: RepoQuery["where"], ): Promise> { const entity = this.entity; const validatedData = await this.getValidatedData(data, "update"); + // @todo: add a way to delete all by adding force? + if (!where || typeof where !== "object" || Object.keys(where).length === 0) { + throw new Error("Where clause must be provided for mass update"); + } + const query = this.appendWhere(this.conn.updateTable(entity.name), where) .set(validatedData as any) .returning(entity.getSelect()); diff --git a/app/src/modules/ModuleApi.ts b/app/src/modules/ModuleApi.ts index a5b4a69..d5f8ed5 100644 --- a/app/src/modules/ModuleApi.ts +++ b/app/src/modules/ModuleApi.ts @@ -148,9 +148,10 @@ export abstract class ModuleApi(_input: TInput, _init?: RequestInit) { + delete(_input: TInput, body?: any, _init?: RequestInit) { return this.request(_input, undefined, { ..._init, + body, method: "DELETE", }); } @@ -171,7 +172,7 @@ export function createResponseProxy( body: Body, data?: Data, ): ResponseObject { - let actualData: any = data ?? body; + let actualData: any = typeof data !== "undefined" ? data : body; const _props = ["raw", "body", "ok", "status", "res", "data", "toJSON"]; // that's okay, since you have to check res.ok anyway @@ -189,6 +190,7 @@ export function createResponseProxy( if (prop === "toJSON") { return () => target; } + return Reflect.get(target, prop, receiver); }, has(target, prop) { @@ -223,12 +225,19 @@ export class FetchPromise> implements Promise { fetcher?: typeof fetch; verbose?: boolean; }, + protected refineData?: (data: T) => any, ) {} get verbose() { return this.options?.verbose ?? false; } + refine(fn: (data: T) => N) { + return new FetchPromise(this.request, this.options, fn) as unknown as FetchPromise< + ApiResponse + >; + } + async execute(): Promise> { // delay in dev environment isDebug() && (await new Promise((resolve) => setTimeout(resolve, 200))); @@ -265,6 +274,15 @@ export class FetchPromise> implements Promise { resBody = res.body; } + if (this.refineData) { + try { + resData = this.refineData(resData); + } catch (e) { + console.warn("[FetchPromise] Error in refineData", e); + resData = undefined; + } + } + return createResponseProxy(res, resBody, resData); } diff --git a/docs/usage/sdk.mdx b/docs/usage/sdk.mdx index 919a8ee..e5b5825 100644 --- a/docs/usage/sdk.mdx +++ b/docs/usage/sdk.mdx @@ -88,14 +88,32 @@ const { data } = await api.data.readMany("posts", { limit: 10, offset: 0, select: ["id", "title", "views"], - with: ["comments"], + with: { + // join last 2 comments + comments: { + with: { + // along with the comments' user + users: {} + }, + limit: 2, + sort: "-id" + }, + // also get the first 2 images, but only the path + images: { + select: ["path"], + limit: 2 + } + }, where: { + // same as '{ title: { $eg: "Hello, World!" } }' title: "Hello, World!", + // only with views greater than 100 views: { $gt: 100 } }, - sort: { by: "views", order: "desc" } + // sort by views descending (without "-" would be ascending) + sort: "-views" }); ``` The `with` property automatically adds the related entries to the response. @@ -116,6 +134,15 @@ const { data } = await api.data.createOne("posts", { }); ``` +### `data.createMany([entity], [data])` +To create many records of an entity, use the `createMany` method: +```ts +const { data } = await api.data.createMany("posts", [ + { title: "Hello, World!" }, + { title: "Again, Hello." }, +]); +``` + ### `data.updateOne([entity], [id], [data])` To update a single record of an entity, use the `updateOne` method: ```ts @@ -124,12 +151,26 @@ const { data } = await api.data.updateOne("posts", 1, { }); ``` +### `data.updateMany([entity], [where], [update])` +To update many records of an entity, use the `updateMany` method: +```ts +const { data } = await api.data.updateMany("posts", { views: { $gt: 1 } }, { + title: "viewed more than once" +}); +``` + ### `data.deleteOne([entity], [id])` To delete a single record of an entity, use the `deleteOne` method: ```ts const { data } = await api.data.deleteOne("posts", 1); ``` +### `data.deleteMany([entity], [where])` +To delete many records of an entity, use the `deleteMany` method: +```ts +const { data } = await api.data.deleteMany("posts", { views: { $lte: 1 } }); +``` + ## Auth (`api.auth`) Access the `Auth` specific API methods at `api.auth`. If there is successful authentication, the API will automatically save the token and use it for subsequent requests.