mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-17 04:46:05 +00:00
feat/custom-json-schema (#172)
* init * update * finished new repo query, removed old implementation * remove debug folder
This commit is contained in:
@@ -8,11 +8,12 @@ import {
|
||||
type MutatorResponse,
|
||||
type RepoQuery,
|
||||
type RepositoryResponse,
|
||||
querySchema,
|
||||
repoQuery,
|
||||
} from "data";
|
||||
import type { Handler } from "hono/types";
|
||||
import type { ModuleBuildContext } from "modules";
|
||||
import { Controller } from "modules/Controller";
|
||||
import { jsc, s } from "core/object/schema";
|
||||
import * as SystemPermissions from "modules/permissions";
|
||||
import type { AppDataConfig } from "../data-schema";
|
||||
const { Type } = tbbox;
|
||||
@@ -205,7 +206,7 @@ export class DataController extends Controller {
|
||||
hono.post(
|
||||
"/:entity/fn/count",
|
||||
permission(DataPermissions.entityRead),
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
jsc("param", s.object({ entity: s.string() })),
|
||||
async (c) => {
|
||||
const { entity } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
@@ -222,7 +223,7 @@ export class DataController extends Controller {
|
||||
hono.post(
|
||||
"/:entity/fn/exists",
|
||||
permission(DataPermissions.entityRead),
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
jsc("param", s.object({ entity: s.string() })),
|
||||
async (c) => {
|
||||
const { entity } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
@@ -242,10 +243,10 @@ export class DataController extends Controller {
|
||||
hono.get(
|
||||
"/:entity",
|
||||
permission(DataPermissions.entityRead),
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
tb("query", querySchema),
|
||||
jsc("param", s.object({ entity: s.string() })),
|
||||
jsc("query", repoQuery),
|
||||
async (c) => {
|
||||
const { entity } = c.req.param();
|
||||
const { entity } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
@@ -260,16 +261,16 @@ export class DataController extends Controller {
|
||||
hono.get(
|
||||
"/:entity/:id",
|
||||
permission(DataPermissions.entityRead),
|
||||
tb(
|
||||
jsc(
|
||||
"param",
|
||||
Type.Object({
|
||||
entity: Type.String(),
|
||||
id: tbNumber,
|
||||
s.object({
|
||||
entity: s.string(),
|
||||
id: s.string(),
|
||||
}),
|
||||
),
|
||||
tb("query", querySchema),
|
||||
jsc("query", repoQuery),
|
||||
async (c) => {
|
||||
const { entity, id } = c.req.param();
|
||||
const { entity, id } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
@@ -284,17 +285,17 @@ export class DataController extends Controller {
|
||||
hono.get(
|
||||
"/:entity/:id/:reference",
|
||||
permission(DataPermissions.entityRead),
|
||||
tb(
|
||||
jsc(
|
||||
"param",
|
||||
Type.Object({
|
||||
entity: Type.String(),
|
||||
id: tbNumber,
|
||||
reference: Type.String(),
|
||||
s.object({
|
||||
entity: s.string(),
|
||||
id: s.string(),
|
||||
reference: s.string(),
|
||||
}),
|
||||
),
|
||||
tb("query", querySchema),
|
||||
jsc("query", repoQuery),
|
||||
async (c) => {
|
||||
const { entity, id, reference } = c.req.param();
|
||||
const { entity, id, reference } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
@@ -312,10 +313,10 @@ export class DataController extends Controller {
|
||||
hono.post(
|
||||
"/:entity/query",
|
||||
permission(DataPermissions.entityRead),
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
tb("json", querySchema),
|
||||
jsc("param", s.object({ entity: s.string() })),
|
||||
jsc("json", repoQuery),
|
||||
async (c) => {
|
||||
const { entity } = c.req.param();
|
||||
const { entity } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
@@ -333,10 +334,11 @@ export class DataController extends Controller {
|
||||
hono.post(
|
||||
"/:entity",
|
||||
permission(DataPermissions.entityCreate),
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
tb("json", Type.Union([Type.Object({}), Type.Array(Type.Object({}))])),
|
||||
jsc("param", s.object({ entity: s.string() })),
|
||||
jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])),
|
||||
//tb("json", Type.Union([Type.Object({}), Type.Array(Type.Object({}))])),
|
||||
async (c) => {
|
||||
const { entity } = c.req.param();
|
||||
const { entity } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
@@ -356,12 +358,12 @@ export class DataController extends Controller {
|
||||
hono.patch(
|
||||
"/:entity",
|
||||
permission(DataPermissions.entityUpdate),
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
tb(
|
||||
jsc("param", s.object({ entity: s.string() })),
|
||||
jsc(
|
||||
"json",
|
||||
Type.Object({
|
||||
update: Type.Object({}),
|
||||
where: querySchema.properties.where,
|
||||
s.object({
|
||||
update: s.object({}),
|
||||
where: repoQuery.properties.where,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
@@ -383,9 +385,9 @@ export class DataController extends Controller {
|
||||
hono.patch(
|
||||
"/:entity/:id",
|
||||
permission(DataPermissions.entityUpdate),
|
||||
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
|
||||
jsc("param", s.object({ entity: s.string(), id: s.number() })),
|
||||
async (c) => {
|
||||
const { entity, id } = c.req.param();
|
||||
const { entity, id } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
@@ -400,9 +402,9 @@ export class DataController extends Controller {
|
||||
hono.delete(
|
||||
"/:entity/:id",
|
||||
permission(DataPermissions.entityDelete),
|
||||
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
|
||||
jsc("param", s.object({ entity: s.string(), id: s.number() })),
|
||||
async (c) => {
|
||||
const { entity, id } = c.req.param();
|
||||
const { entity, id } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
@@ -416,10 +418,10 @@ export class DataController extends Controller {
|
||||
hono.delete(
|
||||
"/:entity",
|
||||
permission(DataPermissions.entityDelete),
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
tb("json", querySchema.properties.where),
|
||||
jsc("param", s.object({ entity: s.string() })),
|
||||
jsc("json", repoQuery.properties.where),
|
||||
async (c) => {
|
||||
const { entity } = c.req.param();
|
||||
const { entity } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return this.notFound(c);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { Entity, EntityData, EntityManager } from "../entities";
|
||||
import { InvalidSearchParamsException } from "../errors";
|
||||
import { MutatorEvents } from "../events";
|
||||
import { RelationMutator } from "../relations";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
import type { RepoQuery } from "../server/query";
|
||||
|
||||
type MutatorQB =
|
||||
| InsertQueryBuilder<any, any, any>
|
||||
|
||||
@@ -20,6 +20,7 @@ export class JoinBuilder {
|
||||
|
||||
// @todo: returns multiple on manytomany (edit: so?)
|
||||
static getJoinedEntityNames(em: EntityManager<any>, entity: Entity, joins: string[]): string[] {
|
||||
console.log("join", joins);
|
||||
return joins.flatMap((join) => {
|
||||
const relation = em.relationOf(entity.name, join);
|
||||
if (!relation) {
|
||||
|
||||
@@ -2,10 +2,9 @@ import type { DB as DefaultDB, PrimaryFieldType } from "core";
|
||||
import { $console } from "core";
|
||||
import { type EmitsEvents, EventManager } from "core/events";
|
||||
import { type SelectQueryBuilder, sql } from "kysely";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
import { InvalidSearchParamsException } from "../../errors";
|
||||
import { MutatorEvents, RepositoryEvents } from "../../events";
|
||||
import { type RepoQuery, defaultQuerySchema } from "../../server/data-query-impl";
|
||||
import { type RepoQuery, getRepoQueryTemplate } from "data/server/query";
|
||||
import {
|
||||
type Entity,
|
||||
type EntityData,
|
||||
@@ -84,14 +83,14 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
}
|
||||
}
|
||||
|
||||
getValidOptions(options?: Partial<RepoQuery>): RepoQuery {
|
||||
getValidOptions(options?: RepoQuery): RepoQuery {
|
||||
const entity = this.entity;
|
||||
// @todo: if not cloned deep, it will keep references and error if multiple requests come in
|
||||
const validated = {
|
||||
...cloneDeep(defaultQuerySchema),
|
||||
...structuredClone(getRepoQueryTemplate()),
|
||||
sort: entity.getDefaultSort(),
|
||||
select: entity.getSelect(),
|
||||
};
|
||||
} satisfies Required<RepoQuery>;
|
||||
|
||||
if (!options) return validated;
|
||||
|
||||
@@ -99,12 +98,15 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
if (!validated.select.includes(options.sort.by)) {
|
||||
throw new InvalidSearchParamsException(`Invalid sort field "${options.sort.by}"`);
|
||||
}
|
||||
if (!["asc", "desc"].includes(options.sort.dir)) {
|
||||
if (!["asc", "desc"].includes(options.sort.dir!)) {
|
||||
throw new InvalidSearchParamsException(`Invalid sort direction "${options.sort.dir}"`);
|
||||
}
|
||||
|
||||
this.checkIndex(entity.name, options.sort.by, "sort");
|
||||
validated.sort = options.sort;
|
||||
validated.sort = {
|
||||
dir: "asc",
|
||||
...options.sort,
|
||||
};
|
||||
}
|
||||
|
||||
if (options.select && options.select.length > 0) {
|
||||
@@ -505,7 +507,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
};
|
||||
}
|
||||
|
||||
async exists(where: Required<RepoQuery["where"]>): Promise<RepositoryExistsResponse> {
|
||||
async exists(where: Required<RepoQuery>["where"]): Promise<RepositoryExistsResponse> {
|
||||
const entity = this.entity;
|
||||
const options = this.getValidOptions({ where });
|
||||
|
||||
@@ -513,7 +515,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
let qb = this.conn.selectFrom(entity.name).select(selector);
|
||||
|
||||
// add mandatory where
|
||||
qb = WhereBuilder.addClause(qb, options.where).limit(1);
|
||||
qb = WhereBuilder.addClause(qb, options.where!).limit(1);
|
||||
|
||||
const { result, ...compiled } = await this.executeQb(qb);
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ const expressions = [
|
||||
export type WhereQuery = FilterQuery<typeof expressions>;
|
||||
|
||||
const validator = makeValidator(expressions);
|
||||
export const expressionKeys = validator.expressionKeys;
|
||||
|
||||
export class WhereBuilder {
|
||||
static addClause<QB extends WhereQb>(qb: QB, query: WhereQuery) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { $console, type PrimaryFieldType } from "core";
|
||||
import { Event, InvalidEventReturn } from "core/events";
|
||||
import type { Entity, EntityData } from "../entities";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
import type { RepoQuery } from "data/server/query";
|
||||
|
||||
export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityData }, EntityData> {
|
||||
static override slug = "mutator-insert-before";
|
||||
|
||||
@@ -10,10 +10,11 @@ export * from "./connection";
|
||||
export {
|
||||
type RepoQuery,
|
||||
type RepoQueryIn,
|
||||
defaultQuerySchema,
|
||||
querySchema,
|
||||
whereSchema,
|
||||
} from "./server/data-query-impl";
|
||||
getRepoQueryTemplate,
|
||||
repoQuery,
|
||||
} from "./server/query";
|
||||
|
||||
export type { WhereQuery } from "./entities/query/WhereBuilder";
|
||||
|
||||
export { KyselyPluginRunner } from "./plugins/KyselyPluginRunner";
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type MutationInstructionResponse,
|
||||
RelationHelper,
|
||||
} from "../relations";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
import type { RepoQuery } from "../server/query";
|
||||
import type { RelationType } from "./relation-types";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Static } from "core/utils";
|
||||
import type { ExpressionBuilder } from "kysely";
|
||||
import { Entity, type EntityManager } from "../entities";
|
||||
import { type Field, PrimaryField } from "../fields";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
import type { RepoQuery } from "../server/query";
|
||||
import { EntityRelation, type KyselyQueryBuilder } from "./EntityRelation";
|
||||
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||
import { RelationField } from "./RelationField";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { snakeToPascalWithSpaces } from "core/utils";
|
||||
import type { Static } from "core/utils";
|
||||
import type { ExpressionBuilder } from "kysely";
|
||||
import type { Entity, EntityManager } from "../entities";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
import type { RepoQuery } from "../server/query";
|
||||
import { EntityRelation, type KyselyQueryBuilder } from "./EntityRelation";
|
||||
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||
import { RelationField, type RelationFieldBaseConfig } from "./RelationField";
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Static } from "core/utils";
|
||||
import type { ExpressionBuilder } from "kysely";
|
||||
import type { Entity, EntityManager } from "../entities";
|
||||
import { NumberField, TextField } from "../fields";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
import type { RepoQuery } from "../server/query";
|
||||
import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./EntityRelation";
|
||||
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||
import { type RelationType, RelationTypes } from "./relation-types";
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import type { TThis } from "@sinclair/typebox";
|
||||
import { type SchemaOptions, type StaticDecode, StringEnum, Value, isObject } from "core/utils";
|
||||
import { WhereBuilder, type WhereQuery } from "../entities";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
|
||||
const NumberOrString = (options: SchemaOptions = {}) =>
|
||||
Type.Transform(Type.Union([Type.Number(), Type.String()], options))
|
||||
.Decode((value) => Number.parseInt(String(value)))
|
||||
.Encode(String);
|
||||
|
||||
const limit = NumberOrString({ default: 10 });
|
||||
const offset = NumberOrString({ default: 0 });
|
||||
|
||||
const sort_default = { by: "id", dir: "asc" };
|
||||
const sort = Type.Transform(
|
||||
Type.Union(
|
||||
[Type.String(), Type.Object({ by: Type.String(), dir: StringEnum(["asc", "desc"]) })],
|
||||
{
|
||||
default: sort_default,
|
||||
},
|
||||
),
|
||||
)
|
||||
.Decode((value): { by: string; dir: "asc" | "desc" } => {
|
||||
if (typeof value === "string") {
|
||||
if (/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(value)) {
|
||||
const dir = value[0] === "-" ? "desc" : "asc";
|
||||
return { by: dir === "desc" ? value.slice(1) : value, dir } as any;
|
||||
} else if (/^{.*}$/.test(value)) {
|
||||
return JSON.parse(value) as any;
|
||||
}
|
||||
|
||||
return sort_default as any;
|
||||
}
|
||||
return value as any;
|
||||
})
|
||||
.Encode((value) => value);
|
||||
|
||||
const stringArray = Type.Transform(
|
||||
Type.Union([Type.String(), Type.Array(Type.String())], { default: [] }),
|
||||
)
|
||||
.Decode((value) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
} else if (value.includes(",")) {
|
||||
return value.split(",");
|
||||
}
|
||||
return [value];
|
||||
})
|
||||
.Encode((value) => (Array.isArray(value) ? value : [value]));
|
||||
|
||||
export const whereSchema = Type.Transform(
|
||||
Type.Union([Type.String(), Type.Object({})], { default: {} }),
|
||||
)
|
||||
.Decode((value) => {
|
||||
const q = typeof value === "string" ? JSON.parse(value) : value;
|
||||
return WhereBuilder.convert(q);
|
||||
})
|
||||
.Encode(JSON.stringify);
|
||||
|
||||
export type RepoWithSchema = Record<
|
||||
string,
|
||||
Omit<RepoQueryIn, "with"> & {
|
||||
with?: unknown;
|
||||
}
|
||||
>;
|
||||
|
||||
export const withSchema = <TSelf extends TThis>(Self: TSelf) =>
|
||||
Type.Transform(
|
||||
Type.Union([Type.String(), Type.Array(Type.String()), Type.Record(Type.String(), Self)]),
|
||||
)
|
||||
.Decode((value) => {
|
||||
// images
|
||||
// images,comments
|
||||
// ["images","comments"]
|
||||
// { "images": {} }
|
||||
|
||||
if (!Array.isArray(value) && isObject(value)) {
|
||||
return value as RepoWithSchema;
|
||||
}
|
||||
|
||||
let _value: any = null;
|
||||
if (typeof value === "string") {
|
||||
// if stringified object
|
||||
if (value.match(/^\{/)) {
|
||||
return JSON.parse(value) as RepoWithSchema;
|
||||
}
|
||||
|
||||
// if stringified array
|
||||
if (value.match(/^\[/)) {
|
||||
_value = JSON.parse(value) as string[];
|
||||
|
||||
// if comma-separated string
|
||||
} else if (value.includes(",")) {
|
||||
_value = value.split(",");
|
||||
|
||||
// if single string
|
||||
} else {
|
||||
_value = [value];
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
_value = value;
|
||||
}
|
||||
|
||||
if (!_value || !Array.isArray(_value) || !_value.every((v) => typeof v === "string")) {
|
||||
throw new Error("Invalid 'with' schema");
|
||||
}
|
||||
|
||||
return _value.reduce((acc, v) => {
|
||||
acc[v] = {};
|
||||
return acc;
|
||||
}, {} as RepoWithSchema);
|
||||
})
|
||||
.Encode((value) => value);
|
||||
|
||||
export const querySchema = Type.Recursive(
|
||||
(Self) =>
|
||||
Type.Partial(
|
||||
Type.Object(
|
||||
{
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
sort: sort,
|
||||
select: stringArray,
|
||||
with: withSchema(Self),
|
||||
join: stringArray,
|
||||
where: whereSchema,
|
||||
},
|
||||
{
|
||||
// @todo: determine if unknown is allowed, it's ignore anyway
|
||||
additionalProperties: false,
|
||||
},
|
||||
),
|
||||
),
|
||||
{ $id: "query-schema" },
|
||||
);
|
||||
|
||||
export type RepoQueryIn = {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sort?: string | { by: string; dir: "asc" | "desc" };
|
||||
select?: string[];
|
||||
with?: string | string[] | Record<string, RepoQueryIn>;
|
||||
join?: string[];
|
||||
where?: WhereQuery;
|
||||
};
|
||||
export type RepoQuery = Required<StaticDecode<typeof querySchema>>;
|
||||
export const defaultQuerySchema = Value.Default(querySchema, {}) as RepoQuery;
|
||||
184
app/src/data/server/query.spec.ts
Normal file
184
app/src/data/server/query.spec.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { test, describe, expect } from "bun:test";
|
||||
import * as q from "./query";
|
||||
import { s as schema, parse as $parse, type ParseOptions } from "core/object/schema";
|
||||
|
||||
const parse = (v: unknown, o: ParseOptions = {}) => $parse(q.repoQuery, v, o);
|
||||
|
||||
// compatibility
|
||||
const decode = (input: any, output: any) => {
|
||||
expect(parse(input)).toEqual(output);
|
||||
};
|
||||
|
||||
describe("server/query", () => {
|
||||
test("limit & offset", () => {
|
||||
expect(() => parse({ limit: false })).toThrow();
|
||||
expect(parse({ limit: "11" })).toEqual({ limit: 11 });
|
||||
expect(parse({ limit: 20 })).toEqual({ limit: 20 });
|
||||
expect(parse({ offset: "1" })).toEqual({ offset: 1 });
|
||||
});
|
||||
|
||||
test("select", () => {
|
||||
expect(parse({ select: "id" })).toEqual({ select: ["id"] });
|
||||
expect(parse({ select: "id,title" })).toEqual({ select: ["id", "title"] });
|
||||
expect(parse({ select: "id,title,desc" })).toEqual({ select: ["id", "title", "desc"] });
|
||||
expect(parse({ select: ["id", "title"] })).toEqual({ select: ["id", "title"] });
|
||||
|
||||
expect(() => parse({ select: "not allowed" })).toThrow();
|
||||
expect(() => parse({ select: "id," })).toThrow();
|
||||
});
|
||||
|
||||
test("join", () => {
|
||||
expect(parse({ join: "id" })).toEqual({ join: ["id"] });
|
||||
expect(parse({ join: "id,title" })).toEqual({ join: ["id", "title"] });
|
||||
expect(parse({ join: ["id", "title"] })).toEqual({ join: ["id", "title"] });
|
||||
});
|
||||
|
||||
test("sort", () => {
|
||||
expect(parse({ sort: "id" }).sort).toEqual({
|
||||
by: "id",
|
||||
dir: "asc",
|
||||
});
|
||||
expect(parse({ sort: "-id" }).sort).toEqual({
|
||||
by: "id",
|
||||
dir: "desc",
|
||||
});
|
||||
expect(parse({ sort: { by: "title" } }).sort).toEqual({
|
||||
by: "title",
|
||||
});
|
||||
expect(
|
||||
parse(
|
||||
{ sort: { by: "id" } },
|
||||
{
|
||||
withDefaults: true,
|
||||
},
|
||||
).sort,
|
||||
).toEqual({
|
||||
by: "id",
|
||||
dir: "asc",
|
||||
});
|
||||
expect(parse({ sort: { by: "count", dir: "desc" } }).sort).toEqual({
|
||||
by: "count",
|
||||
dir: "desc",
|
||||
});
|
||||
// invalid gives default
|
||||
expect(parse({ sort: "not allowed" }).sort).toEqual({
|
||||
by: "id",
|
||||
dir: "asc",
|
||||
});
|
||||
|
||||
// json
|
||||
expect(parse({ sort: JSON.stringify({ by: "count", dir: "desc" }) }).sort).toEqual({
|
||||
by: "count",
|
||||
dir: "desc",
|
||||
});
|
||||
});
|
||||
|
||||
test("sort2", () => {
|
||||
const _dflt = { sort: { by: "id", dir: "asc" } } as const;
|
||||
|
||||
decode({ sort: "" }, _dflt);
|
||||
decode({ sort: "name" }, { sort: { by: "name", dir: "asc" } });
|
||||
decode({ sort: "-name" }, { sort: { by: "name", dir: "desc" } });
|
||||
decode({ sort: "-posts.name" }, { sort: { by: "posts.name", dir: "desc" } });
|
||||
decode({ sort: "-1name" }, _dflt);
|
||||
decode({ sort: { by: "name", dir: "desc" } }, { sort: { by: "name", dir: "desc" } });
|
||||
});
|
||||
|
||||
test("where", () => {
|
||||
expect(parse({ where: { id: 1 } }).where).toEqual({
|
||||
id: { $eq: 1 },
|
||||
});
|
||||
expect(parse({ where: JSON.stringify({ id: 1 }) }).where).toEqual({
|
||||
id: { $eq: 1 },
|
||||
});
|
||||
|
||||
expect(parse({ where: { count: { $gt: 1 } } }).where).toEqual({
|
||||
count: { $gt: 1 },
|
||||
});
|
||||
expect(parse({ where: JSON.stringify({ count: { $gt: 1 } }) }).where).toEqual({
|
||||
count: { $gt: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test("template", () => {
|
||||
expect(
|
||||
q.repoQuery.template({
|
||||
withOptional: true,
|
||||
}),
|
||||
).toEqual({
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
sort: { by: "id", dir: "asc" },
|
||||
where: {},
|
||||
select: [],
|
||||
join: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("with", () => {
|
||||
let example = {
|
||||
limit: 10,
|
||||
with: {
|
||||
posts: { limit: "10", with: ["comments"] },
|
||||
},
|
||||
};
|
||||
expect(parse(example)).toEqual({
|
||||
limit: 10,
|
||||
with: {
|
||||
posts: {
|
||||
limit: 10,
|
||||
with: {
|
||||
comments: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
decode({ with: ["posts"] }, { with: { posts: {} } });
|
||||
decode({ with: { posts: {} } }, { with: { posts: {} } });
|
||||
decode({ with: { posts: { limit: 1 } } }, { with: { posts: { limit: 1 } } });
|
||||
decode(
|
||||
{
|
||||
with: {
|
||||
posts: {
|
||||
with: {
|
||||
images: {
|
||||
limit: "10",
|
||||
select: "id",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
with: {
|
||||
posts: {
|
||||
with: {
|
||||
images: {
|
||||
limit: 10,
|
||||
select: ["id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// over http
|
||||
{
|
||||
const output = { with: { images: {} } };
|
||||
decode({ with: "images" }, output);
|
||||
decode({ with: '["images"]' }, output);
|
||||
decode({ with: ["images"] }, output);
|
||||
decode({ with: { images: {} } }, output);
|
||||
}
|
||||
|
||||
{
|
||||
const output = { with: { images: {}, comments: {} } };
|
||||
decode({ with: "images,comments" }, output);
|
||||
decode({ with: ["images", "comments"] }, output);
|
||||
decode({ with: '["images", "comments"]' }, output);
|
||||
decode({ with: { images: {}, comments: {} } }, output);
|
||||
}
|
||||
});
|
||||
});
|
||||
158
app/src/data/server/query.ts
Normal file
158
app/src/data/server/query.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { s } from "core/object/schema";
|
||||
import { WhereBuilder, type WhereQuery } from "data";
|
||||
import { $console } from "core";
|
||||
import { isObject } from "core/utils";
|
||||
import type { CoercionOptions, TAnyOf } from "jsonv-ts";
|
||||
|
||||
// -------
|
||||
// helpers
|
||||
const stringIdentifier = s.string({
|
||||
// allow "id", "id,title" – but not "id," or "not allowed"
|
||||
pattern: "^(?:[a-zA-Z_$][\\w$]*)(?:,[a-zA-Z_$][\\w$]*)*$",
|
||||
});
|
||||
const numberOrString = <N extends s.UnionSchema>(c: N = {} as N) =>
|
||||
s.anyOf([s.number(), s.string()], {
|
||||
...c,
|
||||
coerse: function (this: s.TSchema, v): number {
|
||||
if (typeof v === "string") {
|
||||
const n = Number.parseInt(v);
|
||||
if (Number.isNaN(n)) return this.default ?? 10;
|
||||
return n;
|
||||
}
|
||||
return v as number;
|
||||
},
|
||||
}) as unknown as s.TSchemaInOut<number | string, number>;
|
||||
const stringArray = s.anyOf(
|
||||
[
|
||||
stringIdentifier,
|
||||
s.array(stringIdentifier, {
|
||||
uniqueItems: true,
|
||||
}),
|
||||
],
|
||||
{
|
||||
default: [],
|
||||
coerce: (v): string[] => {
|
||||
if (Array.isArray(v)) {
|
||||
return v;
|
||||
} else if (typeof v === "string") {
|
||||
if (v.includes(",")) {
|
||||
return v.split(",");
|
||||
}
|
||||
return [v];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// -------
|
||||
// sorting
|
||||
const sortDefault = { by: "id", dir: "asc" };
|
||||
const sortSchema = s.object({
|
||||
by: s.string(),
|
||||
dir: s.string({ enum: ["asc", "desc"] }).optional(),
|
||||
});
|
||||
type SortSchema = s.Static<typeof sortSchema>;
|
||||
const sort = s.anyOf([s.string(), sortSchema], {
|
||||
default: sortDefault,
|
||||
coerce: (v): SortSchema => {
|
||||
if (typeof v === "string") {
|
||||
if (/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(v)) {
|
||||
const dir = v[0] === "-" ? "desc" : "asc";
|
||||
return { by: dir === "desc" ? v.slice(1) : v, dir } as any;
|
||||
} else if (/^{.*}$/.test(v)) {
|
||||
return JSON.parse(v) as any;
|
||||
}
|
||||
|
||||
$console.warn(`Invalid sort given: '${JSON.stringify(v)}'`);
|
||||
return sortDefault as any;
|
||||
}
|
||||
return v as any;
|
||||
},
|
||||
});
|
||||
|
||||
// ------
|
||||
// filter
|
||||
const where = s.anyOf([s.string(), s.object({})], {
|
||||
default: {},
|
||||
coerce: (value: unknown) => {
|
||||
const q = typeof value === "string" ? JSON.parse(value) : value;
|
||||
return WhereBuilder.convert(q);
|
||||
},
|
||||
});
|
||||
//type WhereSchemaIn = s.Static<typeof where>;
|
||||
//type WhereSchema = s.StaticCoerced<typeof where>;
|
||||
|
||||
// ------
|
||||
// with
|
||||
// @todo: waiting for recursion support
|
||||
export type RepoWithSchema = Record<
|
||||
string,
|
||||
Omit<RepoQueryIn, "with"> & {
|
||||
with?: unknown;
|
||||
}
|
||||
>;
|
||||
|
||||
const withSchema = <In, Out = In>(self: s.TSchema): s.TSchemaInOut<In, Out> =>
|
||||
s.anyOf([stringIdentifier, s.array(stringIdentifier), self], {
|
||||
coerce: function (this: TAnyOf<any>, _value: unknown, opts: CoercionOptions = {}) {
|
||||
let value: any = _value;
|
||||
|
||||
if (typeof value === "string") {
|
||||
// if stringified object
|
||||
if (value.match(/^\{/) || value.match(/^\[/)) {
|
||||
value = JSON.parse(value);
|
||||
} else if (value.includes(",")) {
|
||||
value = value.split(",");
|
||||
} else {
|
||||
value = [value];
|
||||
}
|
||||
}
|
||||
|
||||
// Convert arrays to objects
|
||||
if (Array.isArray(value)) {
|
||||
value = value.reduce((acc, v) => {
|
||||
acc[v] = {};
|
||||
return acc;
|
||||
}, {} as any);
|
||||
}
|
||||
|
||||
// Handle object case
|
||||
if (isObject(value)) {
|
||||
for (const k in value) {
|
||||
value[k] = self.coerce(value[k], opts);
|
||||
}
|
||||
}
|
||||
|
||||
return value as unknown as any;
|
||||
},
|
||||
}) as any;
|
||||
|
||||
// ==========
|
||||
// REPO QUERY
|
||||
export const repoQuery = s.recursive((self) =>
|
||||
s.partialObject({
|
||||
limit: numberOrString({ default: 10 }),
|
||||
offset: numberOrString({ default: 0 }),
|
||||
sort,
|
||||
where,
|
||||
select: stringArray,
|
||||
join: stringArray,
|
||||
with: withSchema<RepoWithSchema>(self),
|
||||
}),
|
||||
);
|
||||
export const getRepoQueryTemplate = () =>
|
||||
repoQuery.template({
|
||||
withOptional: true,
|
||||
}) as Required<RepoQuery>;
|
||||
|
||||
export type RepoQueryIn = {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sort?: string | { by: string; dir: "asc" | "desc" };
|
||||
select?: string[];
|
||||
with?: string | string[] | Record<string, RepoQueryIn>;
|
||||
join?: string[];
|
||||
where?: WhereQuery;
|
||||
};
|
||||
export type RepoQuery = s.StaticCoerced<typeof repoQuery>;
|
||||
Reference in New Issue
Block a user