From ef629321ab96a6773af2234416cc20ccfd6ade94 Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 5 Mar 2025 08:02:57 +0100 Subject: [PATCH 1/3] added readOneBy, updateMany, deleteMany, exists --- app/__test__/api/DataApi.spec.ts | 154 +++++++++++++++++++++- app/__test__/api/ModuleApi.spec.ts | 8 ++ app/__test__/data/mutation.simple.test.ts | 2 +- app/package.json | 2 +- app/src/data/api/DataApi.ts | 58 ++++++++ app/src/data/api/DataController.ts | 10 +- app/src/data/entities/Mutator.ts | 14 +- app/src/modules/ModuleApi.ts | 22 +++- docs/usage/sdk.mdx | 45 ++++++- 9 files changed, 299 insertions(+), 16 deletions(-) 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. From dda02807c16cf7430eed8f0de9999603d013a1f1 Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 5 Mar 2025 08:14:15 +0100 Subject: [PATCH 2/3] fix typing to allow PrimaryFieldType as Primitive in where --- app/__test__/data/specs/WithBuilder.spec.ts | 7 +++---- app/src/core/object/query/query.ts | 16 +++------------- app/src/data/entities/query/WhereBuilder.ts | 1 - 3 files changed, 6 insertions(+), 18 deletions(-) diff --git a/app/__test__/data/specs/WithBuilder.spec.ts b/app/__test__/data/specs/WithBuilder.spec.ts index 094f9a7..db0273c 100644 --- a/app/__test__/data/specs/WithBuilder.spec.ts +++ b/app/__test__/data/specs/WithBuilder.spec.ts @@ -1,5 +1,4 @@ -import { afterAll, describe, expect, test } from "bun:test"; -import { _jsonp } from "../../../src/core/utils"; +import { describe, expect, test } from "bun:test"; import { Entity, EntityManager, @@ -10,7 +9,7 @@ import { WithBuilder, } from "../../../src/data"; import * as proto from "../../../src/data/prototype"; -import { compileQb, prettyPrintQb, schemaToEm } from "../../helper"; +import { schemaToEm } from "../../helper"; import { getDummyConnection } from "../helper"; const { dummyConnection } = getDummyConnection(); @@ -30,7 +29,7 @@ describe("[data] WithBuilder", async () => { ); const em = schemaToEm(schema); - expect(WithBuilder.validateWiths(em, "posts", undefined)).toBe(0); + expect(WithBuilder.validateWiths(em, "posts", undefined as any)).toBe(0); expect(WithBuilder.validateWiths(em, "posts", {})).toBe(0); expect(WithBuilder.validateWiths(em, "posts", { users: {} })).toBe(1); expect( diff --git a/app/src/core/object/query/query.ts b/app/src/core/object/query/query.ts index d70c211..c432daf 100644 --- a/app/src/core/object/query/query.ts +++ b/app/src/core/object/query/query.ts @@ -1,4 +1,6 @@ -export type Primitive = string | number | boolean; +import type { PrimaryFieldType } from "core"; + +export type Primitive = PrimaryFieldType | string | number | boolean; export function isPrimitive(value: any): value is Primitive { return ["string", "number", "boolean"].includes(typeof value); } @@ -63,7 +65,6 @@ function _convert( expressions: Exps, path: string[] = [], ): FilterQuery { - //console.log("-----------------"); const ExpressionConditionKeys = expressions.map((e) => e.key); const keys = Object.keys($query); const operands = [OperandOr] as const; @@ -132,8 +133,6 @@ function _build( ): ValidationResults { const $query = options.convert ? _convert(_query, expressions) : _query; - //console.log("-----------------", { $query }); - //const keys = Object.keys($query); const result: ValidationResults = { $and: [], $or: [], @@ -150,22 +149,16 @@ function _build( if (!exp.valid(expected)) { throw new Error(`Invalid expected value at "${[...path, $op].join(".")}": ${expected}`); } - //console.log("found exp", { key: exp.key, expected, actual }); return exp.validate(expected, actual, options.exp_ctx); } // check $and - //console.log("$and entries", Object.entries($and)); for (const [key, value] of Object.entries($and)) { - //console.log("$op/$v", Object.entries(value)); for (const [$op, $v] of Object.entries(value)) { const objValue = options.value_is_kv ? key : options.object[key]; - //console.log("--check $and", { key, value, objValue, v_i_kv: options.value_is_kv }); - //console.log("validate", { $op, $v, objValue, key }); result.$and.push(__validate($op, $v, objValue, [key])); result.keys.add(key); } - //console.log("-", { key, value }); } // check $or @@ -173,14 +166,11 @@ function _build( const objValue = options.value_is_kv ? key : options.object[key]; for (const [$op, $v] of Object.entries(value)) { - //console.log("validate", { $op, $v, objValue }); result.$or.push(__validate($op, $v, objValue, [key])); result.keys.add(key); } - //console.log("-", { key, value }); } - //console.log("matches", matches); return result; } diff --git a/app/src/data/entities/query/WhereBuilder.ts b/app/src/data/entities/query/WhereBuilder.ts index acafad5..b206bf7 100644 --- a/app/src/data/entities/query/WhereBuilder.ts +++ b/app/src/data/entities/query/WhereBuilder.ts @@ -2,7 +2,6 @@ import { type BooleanLike, type FilterQuery, type Primitive, - type TExpression, exp, isBooleanLike, isPrimitive, From dde5ad365e3f37e1a708815cfc6164fe3cf41580 Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 5 Mar 2025 08:23:40 +0100 Subject: [PATCH 3/3] reduce api bundle size by omitting lodash --- app/src/Api.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/Api.ts b/app/src/Api.ts index 536ae0d..70cbd13 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -2,9 +2,9 @@ import type { SafeUser } from "auth"; import { AuthApi } from "auth/api/AuthApi"; import { DataApi } from "data/api/DataApi"; import { decode } from "hono/jwt"; -import { omit } from "lodash-es"; import { MediaApi } from "media/api/MediaApi"; import { SystemApi } from "modules/SystemApi"; +import { omitKeys } from "core/utils"; export type TApiUser = SafeUser; @@ -122,7 +122,7 @@ export class Api { this.verified = false; if (token) { - this.user = omit(decode(token).payload as any, ["iat", "iss", "exp"]) as any; + this.user = omitKeys(decode(token).payload as any, ["iat", "iss", "exp"]) as any; } else { this.user = undefined; }