Merge pull request #104 from bknd-io/feat/additional-data-api-methods

feat/additional-data-api-methods
This commit is contained in:
dswbx
2025-03-05 08:24:19 +01:00
committed by GitHub
13 changed files with 307 additions and 36 deletions

View File

@@ -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();
}
});
});

View File

@@ -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
});

View File

@@ -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);

View File

@@ -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(

View File

@@ -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": {

View File

@@ -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;
}

View File

@@ -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<Exps extends Expressions>(
expressions: Exps,
path: string[] = [],
): FilterQuery<Exps> {
//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<Exps extends Expressions>(
): ValidationResults {
const $query = options.convert ? _convert<Exps>(_query, expressions) : _query;
//console.log("-----------------", { $query });
//const keys = Object.keys($query);
const result: ValidationResults = {
$and: [],
$or: [],
@@ -150,22 +149,16 @@ function _build<Exps extends Expressions>(
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<Exps extends Expressions>(
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;
}

View File

@@ -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<DataApiOptions> {
};
}
private requireObjectSet(obj: any, message?: string) {
if (!obj || typeof obj !== "object" || Object.keys(obj).length === 0) {
throw new Error(message ?? "object is required");
}
}
readOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
id: PrimaryFieldType,
@@ -29,6 +36,18 @@ export class DataApi extends ModuleApi<DataApiOptions> {
);
}
readOneBy<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
query: Omit<RepoQueryIn, "limit" | "offset" | "sort"> = {},
) {
type T = Pick<RepositoryResponse<Data>, "meta" | "data">;
return this.readMany(entity, {
...query,
limit: 1,
offset: 0,
}).refine((data) => data[0]) as unknown as FetchPromise<ResponseObject<T>>;
}
readMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
query: RepoQueryIn = {},
@@ -63,25 +82,64 @@ export class DataApi extends ModuleApi<DataApiOptions> {
return this.post<RepositoryResponse<Data>>(["entity", entity as any], input);
}
createMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
input: Omit<Data, "id">[],
) {
if (!input || !Array.isArray(input) || input.length === 0) {
throw new Error("input is required");
}
return this.post<RepositoryResponse<Data[]>>(["entity", entity as any], input);
}
updateOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
id: PrimaryFieldType,
input: Partial<Omit<Data, "id">>,
) {
if (!id) throw new Error("ID is required");
return this.patch<RepositoryResponse<Data>>(["entity", entity as any, id], input);
}
updateMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
where: RepoQueryIn["where"],
update: Partial<Omit<Data, "id">>,
) {
this.requireObjectSet(where);
return this.patch<RepositoryResponse<Data[]>>(["entity", entity as any], {
update,
where,
});
}
deleteOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
id: PrimaryFieldType,
) {
if (!id) throw new Error("ID is required");
return this.delete<RepositoryResponse<Data>>(["entity", entity as any, id]);
}
deleteMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
where: RepoQueryIn["where"],
) {
this.requireObjectSet(where);
return this.delete<RepositoryResponse<Data>>(["entity", entity as any], where);
}
count<E extends keyof DB | string>(entity: E, where: RepoQueryIn["where"] = {}) {
return this.post<RepositoryResponse<{ entity: E; count: number }>>(
["entity", entity as any, "fn", "count"],
where,
);
}
exists<E extends keyof DB | string>(entity: E, where: RepoQueryIn["where"] = {}) {
return this.post<RepositoryResponse<{ entity: E; exists: boolean }>>(
["entity", entity as any, "fn", "exists"],
where,
);
}
}

View File

@@ -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);
},
);

View File

@@ -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<MutatorResponse<Output[]>> {
async deleteWhere(where: RepoQuery["where"]): Promise<MutatorResponse<Output[]>> {
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<Input>,
where?: RepoQuery["where"],
where: RepoQuery["where"],
): Promise<MutatorResponse<Output[]>> {
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());

View File

@@ -2,7 +2,6 @@ import {
type BooleanLike,
type FilterQuery,
type Primitive,
type TExpression,
exp,
isBooleanLike,
isPrimitive,

View File

@@ -148,9 +148,10 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions = BaseModul
});
}
delete<Data = any>(_input: TInput, _init?: RequestInit) {
delete<Data = any>(_input: TInput, body?: any, _init?: RequestInit) {
return this.request<Data>(_input, undefined, {
..._init,
body,
method: "DELETE",
});
}
@@ -171,7 +172,7 @@ export function createResponseProxy<Body = any, Data = any>(
body: Body,
data?: Data,
): ResponseObject<Body, Data> {
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<Body = any, Data = any>(
if (prop === "toJSON") {
return () => target;
}
return Reflect.get(target, prop, receiver);
},
has(target, prop) {
@@ -223,12 +225,19 @@ export class FetchPromise<T = ApiResponse<any>> implements Promise<T> {
fetcher?: typeof fetch;
verbose?: boolean;
},
protected refineData?: (data: T) => any,
) {}
get verbose() {
return this.options?.verbose ?? false;
}
refine<N>(fn: (data: T) => N) {
return new FetchPromise(this.request, this.options, fn) as unknown as FetchPromise<
ApiResponse<N>
>;
}
async execute(): Promise<ResponseObject<T>> {
// delay in dev environment
isDebug() && (await new Promise((resolve) => setTimeout(resolve, 200)));
@@ -265,6 +274,15 @@ export class FetchPromise<T = ApiResponse<any>> implements Promise<T> {
resBody = res.body;
}
if (this.refineData) {
try {
resData = this.refineData(resData);
} catch (e) {
console.warn("[FetchPromise] Error in refineData", e);
resData = undefined;
}
}
return createResponseProxy<T>(res, resBody, resData);
}

View File

@@ -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.