feat/custom-json-schema (#172)

* init

* update

* finished new repo query, removed old implementation

* remove debug folder
This commit is contained in:
dswbx
2025-05-22 08:52:25 +02:00
committed by GitHub
parent 6694c63990
commit 773df544dd
31 changed files with 614 additions and 424 deletions

View File

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

View File

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

View File

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

View File

@@ -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",

View File

@@ -151,7 +151,7 @@ export class App {
}
get fetch(): Hono["fetch"] {
return this.server.fetch;
return this.server.fetch as any;
}
get module() {

View File

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

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

View 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;

View File

@@ -0,0 +1 @@
export { tbValidator } from "./tbValidator";

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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>;

View File

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

View File

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

View File

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

View File

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