mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
feat/custom-json-schema (#172)
* init * update * finished new repo query, removed old implementation * remove debug folder
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { type ObjectQuery, convert, validate } from "../../../src/core/object/query/object-query";
|
||||
import { type ObjectQuery, convert, validate } from "core/object/query/object-query";
|
||||
|
||||
describe("object-query", () => {
|
||||
const q: ObjectQuery = { name: "Michael" };
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Value, _jsonp } from "../../src/core/utils";
|
||||
import { type RepoQuery, WhereBuilder, type WhereQuery, querySchema } from "../../src/data";
|
||||
import type { RepoQueryIn } from "../../src/data/server/data-query-impl";
|
||||
import { getDummyConnection } from "./helper";
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { getDummyConnection } from "../helper";
|
||||
import { type WhereQuery, WhereBuilder } from "data";
|
||||
|
||||
const decode = (input: RepoQueryIn, expected: RepoQuery) => {
|
||||
const result = Value.Decode(querySchema, input);
|
||||
expect(result).toEqual(expected);
|
||||
};
|
||||
|
||||
describe("data-query-impl", () => {
|
||||
function qb() {
|
||||
const c = getDummyConnection();
|
||||
const kysely = c.dummyConnection.kysely;
|
||||
return kysely.selectFrom("t").selectAll();
|
||||
}
|
||||
function compile(q: WhereQuery) {
|
||||
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
|
||||
return { sql, parameters };
|
||||
}
|
||||
function qb() {
|
||||
const c = getDummyConnection();
|
||||
const kysely = c.dummyConnection.kysely;
|
||||
return kysely.selectFrom("t").selectAll();
|
||||
}
|
||||
function compile(q: WhereQuery) {
|
||||
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
|
||||
return { sql, parameters };
|
||||
}
|
||||
|
||||
describe("WhereBuilder", () => {
|
||||
test("single validation", () => {
|
||||
const tests: [WhereQuery, string, any[]][] = [
|
||||
[{ name: "Michael", age: 40 }, '("name" = ? and "age" = ?)', ["Michael", 40]],
|
||||
@@ -94,64 +87,4 @@ describe("data-query-impl", () => {
|
||||
expect(keys).toEqual(expectedKeys);
|
||||
}
|
||||
});
|
||||
|
||||
test("with", () => {
|
||||
decode({ with: ["posts"] }, { with: { posts: {} } });
|
||||
decode({ with: { posts: {} } }, { with: { posts: {} } });
|
||||
decode({ with: { posts: { limit: 1 } } }, { with: { posts: { limit: 1 } } });
|
||||
decode(
|
||||
{
|
||||
with: {
|
||||
posts: {
|
||||
with: {
|
||||
images: {
|
||||
select: ["id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
with: {
|
||||
posts: {
|
||||
with: {
|
||||
images: {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("data-query-impl: Typebox", () => {
|
||||
test("sort", async () => {
|
||||
const _dflt = { sort: { by: "id", dir: "asc" } };
|
||||
|
||||
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" } });
|
||||
});
|
||||
});
|
||||
@@ -43,8 +43,9 @@ beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("MediaController", () => {
|
||||
test.only("accepts direct", async () => {
|
||||
test("accepts direct", async () => {
|
||||
const app = await makeApp();
|
||||
console.log("app", app);
|
||||
|
||||
const file = Bun.file(path);
|
||||
const name = makeName("png");
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"@libsql/client": "^0.15.2",
|
||||
"@mantine/core": "^7.17.1",
|
||||
"@mantine/hooks": "^7.17.1",
|
||||
"@sinclair/typebox": "0.34.30",
|
||||
"@tanstack/react-form": "^1.0.5",
|
||||
"@uiw/react-codemirror": "^4.23.10",
|
||||
"@xyflow/react": "^12.4.4",
|
||||
@@ -63,13 +64,13 @@
|
||||
"json-schema-form-react": "^0.0.2",
|
||||
"json-schema-library": "10.0.0-rc7",
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
"jsonv-ts": "^0.0.11",
|
||||
"kysely": "^0.27.6",
|
||||
"lodash-es": "^4.17.21",
|
||||
"oauth4webapi": "^2.11.1",
|
||||
"object-path-immutable": "^4.1.2",
|
||||
"radix-ui": "^1.1.3",
|
||||
"swr": "^2.3.3",
|
||||
"lodash-es": "^4.17.21",
|
||||
"@sinclair/typebox": "0.34.30"
|
||||
"swr": "^2.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-s3": "^3.758.0",
|
||||
@@ -101,11 +102,11 @@
|
||||
"kysely-d1": "^0.3.0",
|
||||
"open": "^10.1.0",
|
||||
"openapi-types": "^12.1.3",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss": "^8.5.3",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"posthog-js-lite": "^3.4.2",
|
||||
"picocolors": "^1.1.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
|
||||
@@ -151,7 +151,7 @@ export class App {
|
||||
}
|
||||
|
||||
get fetch(): Hono["fetch"] {
|
||||
return this.server.fetch;
|
||||
return this.server.fetch as any;
|
||||
}
|
||||
|
||||
get module() {
|
||||
|
||||
@@ -34,6 +34,8 @@ type ExpressionMap<Exps extends Expressions> = {
|
||||
? E
|
||||
: never;
|
||||
};
|
||||
type ExpressionKeys<Exps extends Expressions> = Exps[number]["key"];
|
||||
|
||||
type ExpressionCondition<Exps extends Expressions> = {
|
||||
[K in keyof ExpressionMap<Exps>]: { [P in K]: ExpressionMap<Exps>[K] };
|
||||
}[keyof ExpressionMap<Exps>];
|
||||
@@ -195,5 +197,7 @@ export function makeValidator<Exps extends Expressions>(expressions: Exps) {
|
||||
const fns = _build(query, expressions, options);
|
||||
return _validate(fns);
|
||||
},
|
||||
expressions,
|
||||
expressionKeys: expressions.map((e) => e.key) as ExpressionKeys<Exps>,
|
||||
};
|
||||
}
|
||||
|
||||
43
app/src/core/object/schema/index.ts
Normal file
43
app/src/core/object/schema/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { mergeObject } from "core/utils";
|
||||
|
||||
export { jsc, type Options, type Hook } from "./validator";
|
||||
import * as s from "jsonv-ts";
|
||||
|
||||
export { s };
|
||||
|
||||
export class InvalidSchemaError extends Error {
|
||||
constructor(
|
||||
public schema: s.TAnySchema,
|
||||
public value: unknown,
|
||||
public errors: s.ErrorDetail[] = [],
|
||||
) {
|
||||
super(
|
||||
`Invalid schema given for ${JSON.stringify(value, null, 2)}\n\n` +
|
||||
`Error: ${JSON.stringify(errors[0], null, 2)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type ParseOptions = {
|
||||
withDefaults?: boolean;
|
||||
coerse?: boolean;
|
||||
};
|
||||
|
||||
export function parse<S extends s.TAnySchema>(
|
||||
_schema: S,
|
||||
v: unknown,
|
||||
opts: ParseOptions = {},
|
||||
): s.StaticCoerced<S> {
|
||||
const schema = _schema as unknown as s.TSchema;
|
||||
const value = opts.coerse !== false ? schema.coerce(v) : v;
|
||||
const result = schema.validate(value, {
|
||||
shortCircuit: true,
|
||||
ignoreUnsupported: true,
|
||||
});
|
||||
if (!result.valid) throw new InvalidSchemaError(schema, v, result.errors);
|
||||
if (opts.withDefaults) {
|
||||
return mergeObject(schema.template({ withOptional: true }), value) as any;
|
||||
}
|
||||
|
||||
return value as any;
|
||||
}
|
||||
63
app/src/core/object/schema/validator.ts
Normal file
63
app/src/core/object/schema/validator.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { Context, Env, Input, MiddlewareHandler, ValidationTargets } from "hono";
|
||||
import { validator as honoValidator } from "hono/validator";
|
||||
import type { Static, StaticCoerced, TAnySchema } from "jsonv-ts";
|
||||
|
||||
export type Options = {
|
||||
coerce?: boolean;
|
||||
includeSchema?: boolean;
|
||||
};
|
||||
|
||||
type ValidationResult = {
|
||||
valid: boolean;
|
||||
errors: {
|
||||
keywordLocation: string;
|
||||
instanceLocation: string;
|
||||
error: string;
|
||||
data?: unknown;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type Hook<T, E extends Env, P extends string> = (
|
||||
result: { result: ValidationResult; data: T },
|
||||
c: Context<E, P>,
|
||||
) => Response | Promise<Response> | void;
|
||||
|
||||
export const validator = <
|
||||
// @todo: somehow hono prevents the usage of TSchema
|
||||
Schema extends TAnySchema,
|
||||
Target extends keyof ValidationTargets,
|
||||
E extends Env,
|
||||
P extends string,
|
||||
Opts extends Options = Options,
|
||||
Out = Opts extends { coerce: false } ? Static<Schema> : StaticCoerced<Schema>,
|
||||
I extends Input = {
|
||||
in: { [K in Target]: Static<Schema> };
|
||||
out: { [K in Target]: Out };
|
||||
},
|
||||
>(
|
||||
target: Target,
|
||||
schema: Schema,
|
||||
options?: Opts,
|
||||
hook?: Hook<Out, E, P>,
|
||||
): MiddlewareHandler<E, P, I> => {
|
||||
// @ts-expect-error not typed well
|
||||
return honoValidator(target, async (_value, c) => {
|
||||
const value = options?.coerce !== false ? schema.coerce(_value) : _value;
|
||||
// @ts-ignore
|
||||
const result = schema.validate(value);
|
||||
if (!result.valid) {
|
||||
return c.json({ ...result, schema }, 400);
|
||||
}
|
||||
|
||||
if (hook) {
|
||||
const hookResult = hook({ result, data: value as Out }, c);
|
||||
if (hookResult) {
|
||||
return hookResult;
|
||||
}
|
||||
}
|
||||
|
||||
return value as Out;
|
||||
});
|
||||
};
|
||||
|
||||
export const jsc = validator;
|
||||
1
app/src/core/server/lib/index.ts
Normal file
1
app/src/core/server/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { tbValidator } from "./tbValidator";
|
||||
29
app/src/core/server/lib/jscValidator.ts
Normal file
29
app/src/core/server/lib/jscValidator.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Env, Input, MiddlewareHandler, ValidationTargets } from "hono";
|
||||
import { validator } from "hono/validator";
|
||||
import type { Static, TSchema } from "simple-jsonschema-ts";
|
||||
|
||||
export const honoValidator = <
|
||||
Target extends keyof ValidationTargets,
|
||||
E extends Env,
|
||||
P extends string,
|
||||
const Schema extends TSchema = TSchema,
|
||||
Out = Static<Schema>,
|
||||
I extends Input = {
|
||||
in: { [K in Target]: Static<Schema> };
|
||||
out: { [K in Target]: Static<Schema> };
|
||||
},
|
||||
>(
|
||||
target: Target,
|
||||
schema: Schema,
|
||||
): MiddlewareHandler<E, P, I> => {
|
||||
// @ts-expect-error not typed well
|
||||
return validator(target, async (value, c) => {
|
||||
const coersed = schema.coerce(value);
|
||||
const result = schema.validate(coersed);
|
||||
if (!result.valid) {
|
||||
return c.json({ ...result, schema }, 400);
|
||||
}
|
||||
|
||||
return coersed as Out;
|
||||
});
|
||||
};
|
||||
@@ -406,3 +406,16 @@ export function objectToJsLiteral(value: object, indent: number = 0, _level: num
|
||||
|
||||
throw new TypeError(`Unsupported data type: ${t}`);
|
||||
}
|
||||
|
||||
// lodash-es compatible `pick` with perfect type inference
|
||||
export function pick<T extends object, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
|
||||
return keys.reduce(
|
||||
(acc, key) => {
|
||||
if (key in obj) {
|
||||
acc[key] = obj[key];
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Pick<T, K>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { App } from "App";
|
||||
import { type Context, Hono } from "hono";
|
||||
import { type Context, type Env, Hono } from "hono";
|
||||
import * as middlewares from "modules/middlewares";
|
||||
import type { SafeUser } from "auth";
|
||||
|
||||
export type ServerEnv = {
|
||||
export type ServerEnv = Env & {
|
||||
Variables: {
|
||||
app: App;
|
||||
// to prevent resolving auth multiple times
|
||||
|
||||
@@ -1,46 +1,43 @@
|
||||
import {
|
||||
type Static,
|
||||
type StaticDecode,
|
||||
type TSchema,
|
||||
decodeSearch,
|
||||
encodeSearch,
|
||||
parseDecode,
|
||||
} from "core/utils";
|
||||
import { decodeSearch, encodeSearch, parseDecode } from "core/utils";
|
||||
import { isEqual, transform } from "lodash-es";
|
||||
import { useLocation, useSearch as useWouterSearch } from "wouter";
|
||||
import { type s, parse } from "core/object/schema";
|
||||
|
||||
// @todo: migrate to Typebox
|
||||
export function useSearch<Schema extends TSchema = TSchema>(
|
||||
export function useSearch<Schema extends s.TAnySchema = s.TAnySchema>(
|
||||
schema: Schema,
|
||||
defaultValue?: Partial<StaticDecode<Schema>>,
|
||||
defaultValue?: Partial<s.StaticCoerced<Schema>>,
|
||||
) {
|
||||
const searchString = useWouterSearch();
|
||||
const [location, navigate] = useLocation();
|
||||
let value: StaticDecode<Schema> = defaultValue ? parseDecode(schema, defaultValue as any) : {};
|
||||
let value = (defaultValue ? parse(schema, defaultValue as any) : {}) as s.StaticCoerced<Schema>;
|
||||
|
||||
if (searchString.length > 0) {
|
||||
value = parseDecode(schema, decodeSearch(searchString));
|
||||
value = parse(schema, decodeSearch(searchString));
|
||||
//console.log("search:decode", value);
|
||||
}
|
||||
|
||||
// @todo: add option to set multiple keys at once
|
||||
function set<Key extends keyof Static<Schema>>(key: Key, value: Static<Schema>[Key]): void {
|
||||
function set<Key extends keyof s.StaticCoerced<Schema>>(
|
||||
key: Key,
|
||||
value: s.StaticCoerced<Schema>[Key],
|
||||
): void {
|
||||
//console.log("set", key, value);
|
||||
const update = parseDecode(schema, { ...decodeSearch(searchString), [key]: value });
|
||||
const update = parse(schema, { ...decodeSearch(searchString), [key]: value });
|
||||
const search = transform(
|
||||
update as any,
|
||||
(result, value, key) => {
|
||||
if (defaultValue && isEqual(value, defaultValue[key])) return;
|
||||
result[key] = value;
|
||||
},
|
||||
{} as Static<Schema>,
|
||||
{} as s.StaticCoerced<Schema>,
|
||||
);
|
||||
const encoded = encodeSearch(search, { encode: false });
|
||||
navigate(location + (encoded.length > 0 ? "?" + encoded : ""));
|
||||
}
|
||||
|
||||
return {
|
||||
value: value as Required<StaticDecode<Schema>>,
|
||||
value: value as Required<s.StaticCoerced<Schema>>,
|
||||
set,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,8 +11,7 @@ import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
|
||||
import { routes } from "ui/lib/routes";
|
||||
import { EntityForm } from "ui/modules/data/components/EntityForm";
|
||||
import { useEntityForm } from "ui/modules/data/hooks/useEntityForm";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
import { s } from "core/object/schema";
|
||||
|
||||
export function DataEntityCreate({ params }) {
|
||||
const { $data } = useBkndData();
|
||||
@@ -29,7 +28,7 @@ export function DataEntityCreate({ params }) {
|
||||
const $q = useEntityMutate(entity.name);
|
||||
|
||||
// @todo: use entity schema for prefilling
|
||||
const search = useSearch(Type.Object({}), {});
|
||||
const search = useSearch(s.object({}), {});
|
||||
|
||||
function goBack() {
|
||||
window.history.go(-1);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Entity, querySchema } from "data";
|
||||
import { type Entity, repoQuery } from "data";
|
||||
import { Fragment } from "react";
|
||||
import { TbDots } from "react-icons/tb";
|
||||
import { useApiQuery } from "ui/client";
|
||||
@@ -14,20 +14,14 @@ import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import { routes, useNavigate } from "ui/lib/routes";
|
||||
import { useCreateUserModal } from "ui/modules/auth/hooks/use-create-user-modal";
|
||||
import { EntityTable2 } from "ui/modules/data/components/EntityTable2";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
import { s } from "core/object/schema";
|
||||
import { pick } from "core/utils/objects";
|
||||
|
||||
// @todo: migrate to Typebox
|
||||
const searchSchema = Type.Composite(
|
||||
[
|
||||
Type.Pick(querySchema, ["select", "where", "sort"]),
|
||||
Type.Object({
|
||||
page: Type.Optional(Type.Number({ default: 1 })),
|
||||
perPage: Type.Optional(Type.Number({ default: 10 })),
|
||||
}),
|
||||
],
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
const searchSchema = s.partialObject({
|
||||
...pick(repoQuery.properties, ["select", "where", "sort"]),
|
||||
page: s.number({ default: 1 }).optional(),
|
||||
perPage: s.number({ default: 10 }).optional(),
|
||||
});
|
||||
|
||||
const PER_PAGE_OPTIONS = [5, 10, 25];
|
||||
|
||||
@@ -74,8 +68,6 @@ export function DataEntityList({ params }) {
|
||||
const sort = search.value.sort!;
|
||||
const newSort = { by: name, dir: sort.by === name && sort.dir === "asc" ? "desc" : "asc" };
|
||||
|
||||
// // @ts-expect-error - somehow all search keys are optional
|
||||
console.log("new sort", newSort);
|
||||
search.set("sort", newSort as any);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user