mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 12:37:20 +00:00
public commit
This commit is contained in:
122
app/src/data/AppData.ts
Normal file
122
app/src/data/AppData.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { transformObject } from "core/utils";
|
||||
import { DataPermissions, Entity, EntityIndex, type EntityManager, type Field } from "data";
|
||||
import { Module } from "modules/Module";
|
||||
import { DataController } from "./api/DataController";
|
||||
import {
|
||||
type AppDataConfig,
|
||||
FIELDS,
|
||||
RELATIONS,
|
||||
type TAppDataEntity,
|
||||
type TAppDataRelation,
|
||||
dataConfigSchema
|
||||
} from "./data-schema";
|
||||
|
||||
export class AppData<DB> extends Module<typeof dataConfigSchema> {
|
||||
static constructEntity(name: string, entityConfig: TAppDataEntity) {
|
||||
const fields = transformObject(entityConfig.fields ?? {}, (fieldConfig, name) => {
|
||||
const { type } = fieldConfig;
|
||||
if (!(type in FIELDS)) {
|
||||
throw new Error(`Field type "${type}" not found`);
|
||||
}
|
||||
|
||||
const { field } = FIELDS[type as any];
|
||||
const returnal = new field(name, fieldConfig.config) as Field;
|
||||
return returnal;
|
||||
});
|
||||
|
||||
// @todo: entity must be migrated to typebox
|
||||
return new Entity(
|
||||
name,
|
||||
Object.values(fields),
|
||||
entityConfig.config as any,
|
||||
entityConfig.type as any
|
||||
);
|
||||
}
|
||||
|
||||
static constructRelation(
|
||||
relationConfig: TAppDataRelation,
|
||||
resolver: (name: Entity | string) => Entity
|
||||
) {
|
||||
return new RELATIONS[relationConfig.type].cls(
|
||||
resolver(relationConfig.source),
|
||||
resolver(relationConfig.target),
|
||||
relationConfig.config
|
||||
);
|
||||
}
|
||||
|
||||
override async build() {
|
||||
const entities = transformObject(this.config.entities ?? {}, (entityConfig, name) => {
|
||||
return AppData.constructEntity(name, entityConfig);
|
||||
});
|
||||
|
||||
const _entity = (_e: Entity | string): Entity => {
|
||||
const name = typeof _e === "string" ? _e : _e.name;
|
||||
const entity = entities[name];
|
||||
if (entity) return entity;
|
||||
throw new Error(`Entity "${name}" not found`);
|
||||
};
|
||||
|
||||
const relations = transformObject(this.config.relations ?? {}, (relation) =>
|
||||
AppData.constructRelation(relation, _entity)
|
||||
);
|
||||
|
||||
const indices = transformObject(this.config.indices ?? {}, (index, name) => {
|
||||
const entity = _entity(index.entity)!;
|
||||
const fields = index.fields.map((f) => entity.field(f)!);
|
||||
return new EntityIndex(entity, fields, index.unique, name);
|
||||
});
|
||||
|
||||
for (const entity of Object.values(entities)) {
|
||||
this.ctx.em.addEntity(entity);
|
||||
}
|
||||
|
||||
for (const relation of Object.values(relations)) {
|
||||
this.ctx.em.addRelation(relation);
|
||||
}
|
||||
|
||||
for (const index of Object.values(indices)) {
|
||||
this.ctx.em.addIndex(index);
|
||||
}
|
||||
|
||||
this.ctx.server.route(
|
||||
this.basepath,
|
||||
new DataController(this.ctx, this.config).getController()
|
||||
);
|
||||
this.ctx.guard.registerPermissions(Object.values(DataPermissions));
|
||||
|
||||
this.setBuilt();
|
||||
}
|
||||
|
||||
getSchema() {
|
||||
return dataConfigSchema;
|
||||
}
|
||||
|
||||
get em(): EntityManager<DB> {
|
||||
this.throwIfNotBuilt();
|
||||
return this.ctx.em;
|
||||
}
|
||||
|
||||
private get basepath() {
|
||||
return this.config.basepath ?? "/api/data";
|
||||
}
|
||||
|
||||
override getOverwritePaths() {
|
||||
return [
|
||||
/^entities\..*\.config$/,
|
||||
/^entities\..*\.fields\..*\.config$/
|
||||
///^entities\..*\.fields\..*\.config\.schema$/
|
||||
];
|
||||
}
|
||||
|
||||
/*registerController(server: AppServer) {
|
||||
console.log("adding data controller to", this.basepath);
|
||||
server.add(this.basepath, new DataController(this.em));
|
||||
}*/
|
||||
|
||||
override toJSON(secrets?: boolean): AppDataConfig {
|
||||
return {
|
||||
...this.config,
|
||||
...this.em.toJSON()
|
||||
};
|
||||
}
|
||||
}
|
||||
63
app/src/data/api/DataApi.ts
Normal file
63
app/src/data/api/DataApi.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { EntityData, RepoQuery, RepositoryResponse } from "data";
|
||||
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules";
|
||||
|
||||
export type DataApiOptions = BaseModuleApiOptions & {
|
||||
defaultQuery?: Partial<RepoQuery>;
|
||||
};
|
||||
|
||||
export class DataApi extends ModuleApi<DataApiOptions> {
|
||||
protected override getDefaultOptions(): Partial<DataApiOptions> {
|
||||
return {
|
||||
basepath: "/api/data",
|
||||
defaultQuery: {
|
||||
limit: 10
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async readOne(
|
||||
entity: string,
|
||||
id: PrimaryFieldType,
|
||||
query: Partial<Omit<RepoQuery, "where" | "limit" | "offset">> = {}
|
||||
) {
|
||||
return this.get<RepositoryResponse<EntityData>>([entity, id], query);
|
||||
}
|
||||
|
||||
async readMany(entity: string, query: Partial<RepoQuery> = {}) {
|
||||
return this.get<Pick<RepositoryResponse, "meta" | "data">>(
|
||||
[entity],
|
||||
query ?? this.options.defaultQuery
|
||||
);
|
||||
}
|
||||
|
||||
async readManyByReference(
|
||||
entity: string,
|
||||
id: PrimaryFieldType,
|
||||
reference: string,
|
||||
query: Partial<RepoQuery> = {}
|
||||
) {
|
||||
return this.get<Pick<RepositoryResponse, "meta" | "data">>(
|
||||
[entity, id, reference],
|
||||
query ?? this.options.defaultQuery
|
||||
);
|
||||
}
|
||||
|
||||
async createOne(entity: string, input: EntityData) {
|
||||
return this.post<RepositoryResponse<EntityData>>([entity], input);
|
||||
}
|
||||
|
||||
async updateOne(entity: string, id: PrimaryFieldType, input: EntityData) {
|
||||
return this.patch<RepositoryResponse<EntityData>>([entity, id], input);
|
||||
}
|
||||
|
||||
async deleteOne(entity: string, id: PrimaryFieldType) {
|
||||
return this.delete<RepositoryResponse<EntityData>>([entity, id]);
|
||||
}
|
||||
|
||||
async count(entity: string, where: RepoQuery["where"] = {}) {
|
||||
return this.post<RepositoryResponse<{ entity: string; count: number }>>(
|
||||
[entity, "fn", "count"],
|
||||
where
|
||||
);
|
||||
}
|
||||
}
|
||||
384
app/src/data/api/DataController.ts
Normal file
384
app/src/data/api/DataController.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import { type ClassController, isDebug, tbValidator as tb } from "core";
|
||||
import { Type, objectCleanEmpty, objectTransform } from "core/utils";
|
||||
import {
|
||||
DataPermissions,
|
||||
type EntityData,
|
||||
type EntityManager,
|
||||
FieldClassMap,
|
||||
type MutatorResponse,
|
||||
PrimaryField,
|
||||
type RepoQuery,
|
||||
type RepositoryResponse,
|
||||
TextField,
|
||||
querySchema
|
||||
} from "data";
|
||||
import { Hono } from "hono";
|
||||
import type { Handler } from "hono/types";
|
||||
import type { ModuleBuildContext } from "modules";
|
||||
import { AppData } from "../AppData";
|
||||
import { type AppDataConfig, FIELDS } from "../data-schema";
|
||||
|
||||
export class DataController implements ClassController {
|
||||
constructor(
|
||||
private readonly ctx: ModuleBuildContext,
|
||||
private readonly config: AppDataConfig
|
||||
) {
|
||||
/*console.log(
|
||||
"data controller",
|
||||
this.em.entities.map((e) => e.name)
|
||||
);*/
|
||||
}
|
||||
|
||||
get em(): EntityManager<any> {
|
||||
return this.ctx.em;
|
||||
}
|
||||
|
||||
get guard() {
|
||||
return this.ctx.guard;
|
||||
}
|
||||
|
||||
repoResult<T extends RepositoryResponse<any> = RepositoryResponse>(
|
||||
res: T
|
||||
): Pick<T, "meta" | "data"> {
|
||||
let meta: Partial<RepositoryResponse["meta"]> = {};
|
||||
|
||||
if ("meta" in res) {
|
||||
const { query, ...rest } = res.meta;
|
||||
meta = rest;
|
||||
if (isDebug()) meta.query = query;
|
||||
}
|
||||
|
||||
const template = { data: res.data, meta };
|
||||
|
||||
// @todo: this works but it breaks in FE (need to improve DataTable)
|
||||
//return objectCleanEmpty(template) as any;
|
||||
// filter empty
|
||||
return Object.fromEntries(
|
||||
Object.entries(template).filter(([_, v]) => typeof v !== "undefined" && v !== null)
|
||||
) as any;
|
||||
}
|
||||
|
||||
mutatorResult(res: MutatorResponse | MutatorResponse<EntityData>) {
|
||||
const template = { data: res.data };
|
||||
|
||||
// filter empty
|
||||
//return objectCleanEmpty(template);
|
||||
return Object.fromEntries(Object.entries(template).filter(([_, v]) => v !== undefined));
|
||||
}
|
||||
|
||||
entityExists(entity: string) {
|
||||
try {
|
||||
return !!this.em.entity(entity);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getController(): Hono<any> {
|
||||
const hono = new Hono();
|
||||
const definedEntities = this.em.entities.map((e) => e.name);
|
||||
const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" }))
|
||||
.Decode(Number.parseInt)
|
||||
.Encode(String);
|
||||
|
||||
// @todo: sample implementation how to augment handler with additional info
|
||||
function handler<HH extends Handler>(name: string, h: HH): any {
|
||||
const func = h;
|
||||
// @ts-ignore
|
||||
func.description = name;
|
||||
return func;
|
||||
}
|
||||
|
||||
// add timing
|
||||
/*hono.use("*", async (c, next) => {
|
||||
startTime(c, "data");
|
||||
await next();
|
||||
endTime(c, "data");
|
||||
});*/
|
||||
|
||||
// info
|
||||
hono.get(
|
||||
"/",
|
||||
handler("data info", (c) => {
|
||||
// sample implementation
|
||||
return c.json(this.em.toJSON());
|
||||
})
|
||||
);
|
||||
|
||||
// sync endpoint
|
||||
hono.get("/sync", async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.databaseSync);
|
||||
|
||||
const force = c.req.query("force") === "1";
|
||||
const drop = c.req.query("drop") === "1";
|
||||
//console.log("force", force);
|
||||
const tables = await this.em.schema().introspect();
|
||||
//console.log("tables", tables);
|
||||
const changes = await this.em.schema().sync({
|
||||
force,
|
||||
drop
|
||||
});
|
||||
return c.json({ tables: tables.map((t) => t.name), changes });
|
||||
});
|
||||
|
||||
/**
|
||||
* Function endpoints
|
||||
*/
|
||||
hono
|
||||
// fn: count
|
||||
.post(
|
||||
"/:entity/fn/count",
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
||||
|
||||
const { entity } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return c.notFound();
|
||||
}
|
||||
|
||||
const where = c.req.json() as any;
|
||||
const result = await this.em.repository(entity).count(where);
|
||||
return c.json({ entity, count: result.count });
|
||||
}
|
||||
)
|
||||
// fn: exists
|
||||
.post(
|
||||
"/:entity/fn/exists",
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
||||
|
||||
const { entity } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return c.notFound();
|
||||
}
|
||||
|
||||
const where = c.req.json() as any;
|
||||
const result = await this.em.repository(entity).exists(where);
|
||||
return c.json({ entity, exists: result.exists });
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Read endpoints
|
||||
*/
|
||||
hono
|
||||
// read entity schema
|
||||
.get("/schema.json", async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
||||
const url = new URL(c.req.url);
|
||||
const $id = `${url.origin}${this.config.basepath}/schema.json`;
|
||||
const schemas = Object.fromEntries(
|
||||
this.em.entities.map((e) => [
|
||||
e.name,
|
||||
{
|
||||
$ref: `schemas/${e.name}`
|
||||
}
|
||||
])
|
||||
);
|
||||
return c.json({
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
$id,
|
||||
properties: schemas
|
||||
});
|
||||
})
|
||||
// read schema
|
||||
.get(
|
||||
"/schemas/:entity",
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
||||
|
||||
//console.log("request", c.req.raw);
|
||||
const { entity } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
console.log("not found", entity, definedEntities);
|
||||
return c.notFound();
|
||||
}
|
||||
const _entity = this.em.entity(entity);
|
||||
const schema = _entity.toSchema();
|
||||
const url = new URL(c.req.url);
|
||||
const base = `${url.origin}${this.config.basepath}`;
|
||||
const $id = `${base}/schemas/${entity}`;
|
||||
return c.json({
|
||||
$schema: `${base}/schema.json`,
|
||||
$id,
|
||||
title: _entity.label,
|
||||
$comment: _entity.config.description,
|
||||
...schema
|
||||
});
|
||||
}
|
||||
)
|
||||
// read many
|
||||
.get(
|
||||
"/:entity",
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
tb("query", querySchema),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
||||
|
||||
//console.log("request", c.req.raw);
|
||||
const { entity } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
console.log("not found", entity, definedEntities);
|
||||
return c.notFound();
|
||||
}
|
||||
const options = c.req.valid("query") as RepoQuery;
|
||||
//console.log("before", this.ctx.emgr.Events);
|
||||
const result = await this.em.repository(entity).findMany(options);
|
||||
|
||||
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
|
||||
}
|
||||
)
|
||||
|
||||
// read one
|
||||
.get(
|
||||
"/:entity/:id",
|
||||
tb(
|
||||
"param",
|
||||
Type.Object({
|
||||
entity: Type.String(),
|
||||
id: tbNumber
|
||||
})
|
||||
),
|
||||
tb("query", querySchema),
|
||||
/*zValidator("param", z.object({ entity: z.string(), id: z.coerce.number() })),
|
||||
zValidator("query", repoQuerySchema),*/
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
||||
|
||||
const { entity, id } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
return c.notFound();
|
||||
}
|
||||
const options = c.req.valid("query") as RepoQuery;
|
||||
const result = await this.em.repository(entity).findId(Number(id), options);
|
||||
|
||||
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
|
||||
}
|
||||
)
|
||||
// read many by reference
|
||||
.get(
|
||||
"/:entity/:id/:reference",
|
||||
tb(
|
||||
"param",
|
||||
Type.Object({
|
||||
entity: Type.String(),
|
||||
id: tbNumber,
|
||||
reference: Type.String()
|
||||
})
|
||||
),
|
||||
tb("query", querySchema),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
||||
|
||||
const { entity, id, reference } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
return c.notFound();
|
||||
}
|
||||
|
||||
const options = c.req.valid("query") as RepoQuery;
|
||||
const result = await this.em
|
||||
.repository(entity)
|
||||
.findManyByReference(Number(id), reference, options);
|
||||
|
||||
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
|
||||
}
|
||||
)
|
||||
// func query
|
||||
.post(
|
||||
"/:entity/query",
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
tb("json", querySchema),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
||||
|
||||
const { entity } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
return c.notFound();
|
||||
}
|
||||
const options = (await c.req.valid("json")) as RepoQuery;
|
||||
console.log("options", options);
|
||||
const result = await this.em.repository(entity).findMany(options);
|
||||
|
||||
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Mutation endpoints
|
||||
*/
|
||||
// insert one
|
||||
hono
|
||||
.post("/:entity", tb("param", Type.Object({ entity: Type.String() })), async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityCreate);
|
||||
|
||||
const { entity } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
return c.notFound();
|
||||
}
|
||||
const body = (await c.req.json()) as EntityData;
|
||||
const result = await this.em.mutator(entity).insertOne(body);
|
||||
|
||||
return c.json(this.mutatorResult(result), 201);
|
||||
})
|
||||
// update one
|
||||
.patch(
|
||||
"/:entity/:id",
|
||||
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityUpdate);
|
||||
|
||||
const { entity, id } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
return c.notFound();
|
||||
}
|
||||
const body = (await c.req.json()) as EntityData;
|
||||
const result = await this.em.mutator(entity).updateOne(Number(id), body);
|
||||
|
||||
return c.json(this.mutatorResult(result));
|
||||
}
|
||||
)
|
||||
// delete one
|
||||
.delete(
|
||||
"/:entity/:id",
|
||||
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityDelete);
|
||||
|
||||
const { entity, id } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
return c.notFound();
|
||||
}
|
||||
const result = await this.em.mutator(entity).deleteOne(Number(id));
|
||||
|
||||
return c.json(this.mutatorResult(result));
|
||||
}
|
||||
)
|
||||
|
||||
// delete many
|
||||
.delete(
|
||||
"/:entity",
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
tb("json", querySchema.properties.where),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityDelete);
|
||||
|
||||
//console.log("request", c.req.raw);
|
||||
const { entity } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
return c.notFound();
|
||||
}
|
||||
const where = c.req.valid("json") as RepoQuery["where"];
|
||||
console.log("where", where);
|
||||
|
||||
const result = await this.em.mutator(entity).deleteMany(where);
|
||||
|
||||
return c.json(this.mutatorResult(result));
|
||||
}
|
||||
);
|
||||
|
||||
return hono;
|
||||
}
|
||||
}
|
||||
97
app/src/data/connection/Connection.ts
Normal file
97
app/src/data/connection/Connection.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
type AliasableExpression,
|
||||
type DatabaseIntrospector,
|
||||
type Expression,
|
||||
type Kysely,
|
||||
type KyselyPlugin,
|
||||
type RawBuilder,
|
||||
type SelectQueryBuilder,
|
||||
type SelectQueryNode,
|
||||
type Simplify,
|
||||
sql
|
||||
} from "kysely";
|
||||
|
||||
export type QB = SelectQueryBuilder<any, any, any>;
|
||||
|
||||
export type IndexMetadata = {
|
||||
name: string;
|
||||
table: string;
|
||||
isUnique: boolean;
|
||||
columns: { name: string; order: number }[];
|
||||
};
|
||||
|
||||
export interface ConnectionIntrospector extends DatabaseIntrospector {
|
||||
getIndices(tbl_name?: string): Promise<IndexMetadata[]>;
|
||||
}
|
||||
|
||||
export interface SelectQueryBuilderExpression<O> extends AliasableExpression<O> {
|
||||
get isSelectQueryBuilder(): true;
|
||||
toOperationNode(): SelectQueryNode;
|
||||
}
|
||||
|
||||
export type DbFunctions = {
|
||||
jsonObjectFrom<O>(expr: SelectQueryBuilderExpression<O>): RawBuilder<Simplify<O> | null>;
|
||||
jsonArrayFrom<O>(expr: SelectQueryBuilderExpression<O>): RawBuilder<Simplify<O>[]>;
|
||||
jsonBuildObject<O extends Record<string, Expression<unknown>>>(
|
||||
obj: O
|
||||
): RawBuilder<
|
||||
Simplify<{
|
||||
[K in keyof O]: O[K] extends Expression<infer V> ? V : never;
|
||||
}>
|
||||
>;
|
||||
};
|
||||
|
||||
export abstract class Connection {
|
||||
kysely: Kysely<any>;
|
||||
|
||||
constructor(
|
||||
kysely: Kysely<any>,
|
||||
public fn: Partial<DbFunctions> = {},
|
||||
protected plugins: KyselyPlugin[] = []
|
||||
) {
|
||||
this.kysely = kysely;
|
||||
}
|
||||
|
||||
getIntrospector(): ConnectionIntrospector {
|
||||
return this.kysely.introspection as ConnectionIntrospector;
|
||||
}
|
||||
|
||||
supportsBatching(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
supportsIndices(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async ping(): Promise<boolean> {
|
||||
const res = await sql`SELECT 1`.execute(this.kysely);
|
||||
return res.rows.length > 0;
|
||||
}
|
||||
|
||||
protected async batch<Queries extends QB[]>(
|
||||
queries: [...Queries]
|
||||
): Promise<{
|
||||
[K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>;
|
||||
}> {
|
||||
throw new Error("Batching not supported");
|
||||
}
|
||||
|
||||
async batchQuery<Queries extends QB[]>(
|
||||
queries: [...Queries]
|
||||
): Promise<{
|
||||
[K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>;
|
||||
}> {
|
||||
// bypass if no client support
|
||||
if (!this.supportsBatching()) {
|
||||
const data: any = [];
|
||||
for (const q of queries) {
|
||||
const result = await q.execute();
|
||||
data.push(result);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
return await this.batch(queries);
|
||||
}
|
||||
}
|
||||
100
app/src/data/connection/LibsqlConnection.ts
Normal file
100
app/src/data/connection/LibsqlConnection.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { type Client, type InStatement, createClient } from "@libsql/client/web";
|
||||
import { LibsqlDialect } from "@libsql/kysely-libsql";
|
||||
import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin, sql } from "kysely";
|
||||
import { FilterNumericKeysPlugin } from "../plugins/FilterNumericKeysPlugin";
|
||||
import { KyselyPluginRunner } from "../plugins/KyselyPluginRunner";
|
||||
import type { QB } from "./Connection";
|
||||
import { SqliteConnection } from "./SqliteConnection";
|
||||
import { SqliteIntrospector } from "./SqliteIntrospector";
|
||||
|
||||
export const LIBSQL_PROTOCOLS = ["wss", "https", "libsql"] as const;
|
||||
export type LibSqlCredentials = {
|
||||
url: string;
|
||||
authToken?: string;
|
||||
protocol?: (typeof LIBSQL_PROTOCOLS)[number];
|
||||
};
|
||||
|
||||
class CustomLibsqlDialect extends LibsqlDialect {
|
||||
override createIntrospector(db: Kysely<any>): DatabaseIntrospector {
|
||||
return new SqliteIntrospector(db, {
|
||||
excludeTables: ["libsql_wasm_func_table"]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class LibsqlConnection extends SqliteConnection {
|
||||
private client: Client;
|
||||
|
||||
constructor(client: Client);
|
||||
constructor(credentials: LibSqlCredentials);
|
||||
constructor(clientOrCredentials: Client | LibSqlCredentials) {
|
||||
const plugins = [new FilterNumericKeysPlugin(), new ParseJSONResultsPlugin()];
|
||||
let client: Client;
|
||||
if ("url" in clientOrCredentials) {
|
||||
let { url, authToken, protocol } = clientOrCredentials;
|
||||
if (protocol && LIBSQL_PROTOCOLS.includes(protocol)) {
|
||||
console.log("changing protocol to", protocol);
|
||||
const [, rest] = url.split("://");
|
||||
url = `${protocol}://${rest}`;
|
||||
}
|
||||
|
||||
//console.log("using", url, { protocol });
|
||||
|
||||
client = createClient({ url, authToken });
|
||||
} else {
|
||||
//console.log("-- client provided");
|
||||
client = clientOrCredentials;
|
||||
}
|
||||
|
||||
const kysely = new Kysely({
|
||||
// @ts-expect-error libsql has type issues
|
||||
dialect: new CustomLibsqlDialect({ client }),
|
||||
plugins
|
||||
//log: ["query"],
|
||||
});
|
||||
|
||||
super(kysely, {}, plugins);
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
override supportsBatching(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
override supportsIndices(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
getClient(): Client {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
protected override async batch<Queries extends QB[]>(
|
||||
queries: [...Queries]
|
||||
): Promise<{
|
||||
[K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>;
|
||||
}> {
|
||||
const stms: InStatement[] = queries.map((q) => {
|
||||
const compiled = q.compile();
|
||||
//console.log("compiled", compiled.sql, compiled.parameters);
|
||||
return {
|
||||
sql: compiled.sql,
|
||||
args: compiled.parameters as any[]
|
||||
};
|
||||
});
|
||||
|
||||
const res = await this.client.batch(stms);
|
||||
|
||||
// let it run through plugins
|
||||
const kyselyPlugins = new KyselyPluginRunner(this.plugins);
|
||||
|
||||
const data: any = [];
|
||||
for (const r of res) {
|
||||
const rows = await kyselyPlugins.transformResultRows(r.rows);
|
||||
data.push(rows);
|
||||
}
|
||||
//console.log("data", data);
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
22
app/src/data/connection/SqliteConnection.ts
Normal file
22
app/src/data/connection/SqliteConnection.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Kysely, KyselyPlugin } from "kysely";
|
||||
import { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import { Connection, type DbFunctions } from "./Connection";
|
||||
|
||||
export class SqliteConnection extends Connection {
|
||||
constructor(kysely: Kysely<any>, fn: Partial<DbFunctions> = {}, plugins: KyselyPlugin[] = []) {
|
||||
super(
|
||||
kysely,
|
||||
{
|
||||
...fn,
|
||||
jsonArrayFrom,
|
||||
jsonObjectFrom,
|
||||
jsonBuildObject
|
||||
},
|
||||
plugins
|
||||
);
|
||||
}
|
||||
|
||||
override supportsIndices(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
164
app/src/data/connection/SqliteIntrospector.ts
Normal file
164
app/src/data/connection/SqliteIntrospector.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import type {
|
||||
DatabaseIntrospector,
|
||||
DatabaseMetadata,
|
||||
DatabaseMetadataOptions,
|
||||
ExpressionBuilder,
|
||||
Kysely,
|
||||
SchemaMetadata,
|
||||
TableMetadata,
|
||||
} from "kysely";
|
||||
import { DEFAULT_MIGRATION_LOCK_TABLE, DEFAULT_MIGRATION_TABLE, sql } from "kysely";
|
||||
import type { ConnectionIntrospector, IndexMetadata } from "./Connection";
|
||||
|
||||
export type SqliteIntrospectorConfig = {
|
||||
excludeTables?: string[];
|
||||
};
|
||||
|
||||
export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntrospector {
|
||||
readonly #db: Kysely<any>;
|
||||
readonly _excludeTables: string[] = [];
|
||||
|
||||
constructor(db: Kysely<any>, config: SqliteIntrospectorConfig = {}) {
|
||||
this.#db = db;
|
||||
this._excludeTables = config.excludeTables ?? [];
|
||||
}
|
||||
|
||||
async getSchemas(): Promise<SchemaMetadata[]> {
|
||||
// Sqlite doesn't support schemas.
|
||||
return [];
|
||||
}
|
||||
|
||||
async getIndices(tbl_name?: string): Promise<IndexMetadata[]> {
|
||||
const indices = await this.#db
|
||||
.selectFrom("sqlite_master")
|
||||
.where("type", "=", "index")
|
||||
.$if(!!tbl_name, (eb) => eb.where("tbl_name", "=", tbl_name))
|
||||
.select("name")
|
||||
.$castTo<{ name: string }>()
|
||||
.execute();
|
||||
|
||||
return Promise.all(indices.map(({ name }) => this.#getIndexMetadata(name)));
|
||||
}
|
||||
|
||||
async #getIndexMetadata(index: string): Promise<IndexMetadata> {
|
||||
const db = this.#db;
|
||||
|
||||
// Get the SQL that was used to create the index.
|
||||
const indexDefinition = await db
|
||||
.selectFrom("sqlite_master")
|
||||
.where("name", "=", index)
|
||||
.select(["sql", "tbl_name", "type"])
|
||||
.$castTo<{ sql: string | undefined; tbl_name: string; type: string }>()
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
//console.log("--indexDefinition--", indexDefinition, index);
|
||||
|
||||
// check unique by looking for the word "unique" in the sql
|
||||
const isUnique = indexDefinition.sql?.match(/unique/i) != null;
|
||||
|
||||
const columns = await db
|
||||
.selectFrom(
|
||||
sql<{
|
||||
seqno: number;
|
||||
cid: number;
|
||||
name: string;
|
||||
}>`pragma_index_info(${index})`.as("index_info"),
|
||||
)
|
||||
.select(["seqno", "cid", "name"])
|
||||
.orderBy("cid")
|
||||
.execute();
|
||||
|
||||
return {
|
||||
name: index,
|
||||
table: indexDefinition.tbl_name,
|
||||
isUnique: isUnique,
|
||||
columns: columns.map((col) => ({
|
||||
name: col.name,
|
||||
order: col.seqno,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private excludeTables(tables: string[] = []) {
|
||||
return (eb: ExpressionBuilder<any, any>) => {
|
||||
const and = tables.map((t) => eb("name", "!=", t));
|
||||
return eb.and(and);
|
||||
};
|
||||
}
|
||||
|
||||
async getTables(
|
||||
options: DatabaseMetadataOptions = { withInternalKyselyTables: false },
|
||||
): Promise<TableMetadata[]> {
|
||||
let query = this.#db
|
||||
.selectFrom("sqlite_master")
|
||||
.where("type", "in", ["table", "view"])
|
||||
.where("name", "not like", "sqlite_%")
|
||||
.select("name")
|
||||
.orderBy("name")
|
||||
.$castTo<{ name: string }>();
|
||||
|
||||
if (!options.withInternalKyselyTables) {
|
||||
query = query.where(
|
||||
this.excludeTables([DEFAULT_MIGRATION_TABLE, DEFAULT_MIGRATION_LOCK_TABLE]),
|
||||
);
|
||||
}
|
||||
if (this._excludeTables.length > 0) {
|
||||
query = query.where(this.excludeTables(this._excludeTables));
|
||||
}
|
||||
|
||||
const tables = await query.execute();
|
||||
return Promise.all(tables.map(({ name }) => this.#getTableMetadata(name)));
|
||||
}
|
||||
|
||||
async getMetadata(options?: DatabaseMetadataOptions): Promise<DatabaseMetadata> {
|
||||
return {
|
||||
tables: await this.getTables(options),
|
||||
};
|
||||
}
|
||||
|
||||
async #getTableMetadata(table: string): Promise<TableMetadata> {
|
||||
const db = this.#db;
|
||||
|
||||
// Get the SQL that was used to create the table.
|
||||
const tableDefinition = await db
|
||||
.selectFrom("sqlite_master")
|
||||
.where("name", "=", table)
|
||||
.select(["sql", "type"])
|
||||
.$castTo<{ sql: string | undefined; type: string }>()
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
// Try to find the name of the column that has `autoincrement` 🤦
|
||||
const autoIncrementCol = tableDefinition.sql
|
||||
?.split(/[\(\),]/)
|
||||
?.find((it) => it.toLowerCase().includes("autoincrement"))
|
||||
?.trimStart()
|
||||
?.split(/\s+/)?.[0]
|
||||
?.replace(/["`]/g, "");
|
||||
|
||||
const columns = await db
|
||||
.selectFrom(
|
||||
sql<{
|
||||
name: string;
|
||||
type: string;
|
||||
notnull: 0 | 1;
|
||||
dflt_value: any;
|
||||
}>`pragma_table_info(${table})`.as("table_info"),
|
||||
)
|
||||
.select(["name", "type", "notnull", "dflt_value"])
|
||||
.orderBy("cid")
|
||||
.execute();
|
||||
|
||||
return {
|
||||
name: table,
|
||||
isView: tableDefinition.type === "view",
|
||||
columns: columns.map((col) => ({
|
||||
name: col.name,
|
||||
dataType: col.type,
|
||||
isNullable: !col.notnull,
|
||||
isAutoIncrementing: col.name === autoIncrementCol,
|
||||
hasDefaultValue: col.dflt_value != null,
|
||||
comment: undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
31
app/src/data/connection/SqliteLocalConnection.ts
Normal file
31
app/src/data/connection/SqliteLocalConnection.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { DatabaseIntrospector, SqliteDatabase } from "kysely";
|
||||
import { Kysely, SqliteDialect } from "kysely";
|
||||
import { DeserializeJsonValuesPlugin } from "../plugins/DeserializeJsonValuesPlugin";
|
||||
import { SqliteConnection } from "./SqliteConnection";
|
||||
import { SqliteIntrospector } from "./SqliteIntrospector";
|
||||
|
||||
class CustomSqliteDialect extends SqliteDialect {
|
||||
override createIntrospector(db: Kysely<any>): DatabaseIntrospector {
|
||||
return new SqliteIntrospector(db, {
|
||||
excludeTables: ["test_table"]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class SqliteLocalConnection extends SqliteConnection {
|
||||
constructor(private database: SqliteDatabase) {
|
||||
const plugins = [new DeserializeJsonValuesPlugin()];
|
||||
const kysely = new Kysely({
|
||||
dialect: new CustomSqliteDialect({ database }),
|
||||
plugins
|
||||
//log: ["query"],
|
||||
});
|
||||
|
||||
super(kysely);
|
||||
this.plugins = plugins;
|
||||
}
|
||||
|
||||
override supportsIndices(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
83
app/src/data/data-schema.ts
Normal file
83
app/src/data/data-schema.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { type Static, StringRecord, Type, objectTransform } from "core/utils";
|
||||
import {
|
||||
FieldClassMap,
|
||||
RelationClassMap,
|
||||
RelationFieldClassMap,
|
||||
entityConfigSchema,
|
||||
entityTypes
|
||||
} from "data";
|
||||
import { MediaField, mediaFieldConfigSchema } from "../media/MediaField";
|
||||
|
||||
export const FIELDS = {
|
||||
...FieldClassMap,
|
||||
...RelationFieldClassMap,
|
||||
media: { schema: mediaFieldConfigSchema, field: MediaField }
|
||||
};
|
||||
export type FieldType = keyof typeof FIELDS;
|
||||
|
||||
export const RELATIONS = RelationClassMap;
|
||||
|
||||
export const fieldsSchemaObject = objectTransform(FIELDS, (field, name) => {
|
||||
return Type.Object(
|
||||
{
|
||||
type: Type.Const(name, { default: name, readOnly: true }),
|
||||
config: Type.Optional(field.schema)
|
||||
},
|
||||
{
|
||||
title: name
|
||||
}
|
||||
);
|
||||
});
|
||||
export const fieldsSchema = Type.Union(Object.values(fieldsSchemaObject));
|
||||
export const entityFields = StringRecord(fieldsSchema);
|
||||
export type TAppDataField = Static<typeof fieldsSchema>;
|
||||
export type TAppDataEntityFields = Static<typeof entityFields>;
|
||||
|
||||
export const entitiesSchema = Type.Object({
|
||||
//name: Type.String(),
|
||||
type: Type.Optional(Type.String({ enum: entityTypes, default: "regular", readOnly: true })),
|
||||
config: Type.Optional(entityConfigSchema),
|
||||
fields: Type.Optional(entityFields)
|
||||
});
|
||||
export type TAppDataEntity = Static<typeof entitiesSchema>;
|
||||
|
||||
export const relationsSchema = Object.entries(RelationClassMap).map(([name, relationClass]) => {
|
||||
return Type.Object(
|
||||
{
|
||||
type: Type.Const(name, { default: name, readOnly: true }),
|
||||
source: Type.String(),
|
||||
target: Type.String(),
|
||||
config: Type.Optional(relationClass.schema)
|
||||
},
|
||||
{
|
||||
title: name
|
||||
}
|
||||
);
|
||||
});
|
||||
export type TAppDataRelation = Static<(typeof relationsSchema)[number]>;
|
||||
|
||||
export const indicesSchema = Type.Object(
|
||||
{
|
||||
entity: Type.String(),
|
||||
fields: Type.Array(Type.String(), { minItems: 1 }),
|
||||
//name: Type.Optional(Type.String()),
|
||||
unique: Type.Optional(Type.Boolean({ default: false }))
|
||||
},
|
||||
{
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
|
||||
export const dataConfigSchema = Type.Object(
|
||||
{
|
||||
basepath: Type.Optional(Type.String({ default: "/api/data" })),
|
||||
entities: Type.Optional(StringRecord(entitiesSchema, { default: {} })),
|
||||
relations: Type.Optional(StringRecord(Type.Union(relationsSchema), { default: {} })),
|
||||
indices: Type.Optional(StringRecord(indicesSchema, { default: {} }))
|
||||
},
|
||||
{
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
|
||||
export type AppDataConfig = Static<typeof dataConfigSchema>;
|
||||
238
app/src/data/entities/Entity.ts
Normal file
238
app/src/data/entities/Entity.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { config } from "core";
|
||||
import {
|
||||
type Static,
|
||||
StringEnum,
|
||||
Type,
|
||||
parse,
|
||||
snakeToPascalWithSpaces,
|
||||
transformObject
|
||||
} from "core/utils";
|
||||
import { type Field, PrimaryField, type TActionContext, type TRenderContext } from "../fields";
|
||||
|
||||
// @todo: entity must be migrated to typebox
|
||||
export const entityConfigSchema = Type.Object(
|
||||
{
|
||||
name: Type.Optional(Type.String()),
|
||||
name_singular: Type.Optional(Type.String()),
|
||||
description: Type.Optional(Type.String()),
|
||||
sort_field: Type.Optional(Type.String({ default: config.data.default_primary_field })),
|
||||
sort_dir: Type.Optional(StringEnum(["asc", "desc"], { default: "asc" }))
|
||||
},
|
||||
{
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
|
||||
export type EntityConfig = Static<typeof entityConfigSchema>;
|
||||
|
||||
export type EntityData = Record<string, any>;
|
||||
export type EntityJSON = ReturnType<Entity["toJSON"]>;
|
||||
|
||||
/**
|
||||
* regular: normal defined entity
|
||||
* system: generated by the system, e.g. "users" from auth
|
||||
* generated: result of a relation, e.g. many-to-many relation's connection entity
|
||||
*/
|
||||
export const entityTypes = ["regular", "system", "generated"] as const;
|
||||
export type TEntityType = (typeof entityTypes)[number];
|
||||
|
||||
/**
|
||||
* @todo: add check for adding fields (primary and relation not allowed)
|
||||
* @todo: add option to disallow api deletes (or api actions in general)
|
||||
*/
|
||||
export class Entity<
|
||||
EntityName extends string = string,
|
||||
Fields extends Record<string, Field<any, any, any>> = Record<string, Field<any, any, any>>
|
||||
> {
|
||||
readonly #_name!: EntityName;
|
||||
readonly #_fields!: Fields; // only for types
|
||||
|
||||
readonly name: string;
|
||||
readonly fields: Field[];
|
||||
readonly config: EntityConfig;
|
||||
protected data: EntityData[] | undefined;
|
||||
readonly type: TEntityType = "regular";
|
||||
|
||||
constructor(name: string, fields?: Field[], config?: EntityConfig, type?: TEntityType) {
|
||||
if (typeof name !== "string" || name.length === 0) {
|
||||
throw new Error("Entity name must be a non-empty string");
|
||||
}
|
||||
|
||||
this.name = name;
|
||||
this.config = parse(entityConfigSchema, config || {}) as EntityConfig;
|
||||
|
||||
// add id field if not given
|
||||
// @todo: add test
|
||||
const primary_count = fields?.filter((field) => field instanceof PrimaryField).length ?? 0;
|
||||
if (primary_count > 1) {
|
||||
throw new Error(`Entity "${name}" has more than one primary field`);
|
||||
}
|
||||
this.fields = primary_count === 1 ? [] : [new PrimaryField()];
|
||||
|
||||
if (fields) {
|
||||
fields.forEach((field) => this.addField(field));
|
||||
}
|
||||
|
||||
if (type) this.type = type;
|
||||
}
|
||||
|
||||
static create(args: {
|
||||
name: string;
|
||||
fields?: Field[];
|
||||
config?: EntityConfig;
|
||||
type?: TEntityType;
|
||||
}) {
|
||||
return new Entity(args.name, args.fields, args.config, args.type);
|
||||
}
|
||||
|
||||
// @todo: add test
|
||||
getType(): TEntityType {
|
||||
return this.type;
|
||||
}
|
||||
|
||||
getSelect(alias?: string, context?: TActionContext | TRenderContext): string[] {
|
||||
return this.getFields()
|
||||
.filter((field) => !field.isHidden(context ?? "read"))
|
||||
.map((field) => (alias ? `${alias}.${field.name} as ${field.name}` : field.name));
|
||||
}
|
||||
|
||||
getDefaultSort() {
|
||||
return {
|
||||
by: this.config.sort_field,
|
||||
dir: this.config.sort_dir
|
||||
};
|
||||
}
|
||||
|
||||
getAliasedSelectFrom(
|
||||
select: string[],
|
||||
_alias?: string,
|
||||
context?: TActionContext | TRenderContext
|
||||
): string[] {
|
||||
const alias = _alias ?? this.name;
|
||||
return this.getFields()
|
||||
.filter(
|
||||
(field) =>
|
||||
!field.isVirtual() &&
|
||||
!field.isHidden(context ?? "read") &&
|
||||
select.includes(field.name)
|
||||
)
|
||||
.map((field) => (alias ? `${alias}.${field.name} as ${field.name}` : field.name));
|
||||
}
|
||||
|
||||
getFillableFields(context?: TActionContext, include_virtual?: boolean): Field[] {
|
||||
return this.getFields(include_virtual).filter((field) => field.isFillable(context));
|
||||
}
|
||||
|
||||
getRequiredFields(): Field[] {
|
||||
return this.getFields().filter((field) => field.isRequired());
|
||||
}
|
||||
|
||||
getDefaultObject(): EntityData {
|
||||
return this.getFields().reduce((acc, field) => {
|
||||
if (field.hasDefault()) {
|
||||
acc[field.name] = field.getDefault();
|
||||
}
|
||||
return acc;
|
||||
}, {} as EntityData);
|
||||
}
|
||||
|
||||
getField(name: string): Field | undefined {
|
||||
return this.fields.find((field) => field.name === name);
|
||||
}
|
||||
|
||||
__experimental_replaceField(name: string, field: Field) {
|
||||
const index = this.fields.findIndex((f) => f.name === name);
|
||||
if (index === -1) {
|
||||
throw new Error(`Field "${name}" not found on entity "${this.name}"`);
|
||||
}
|
||||
|
||||
this.fields[index] = field;
|
||||
}
|
||||
|
||||
getPrimaryField(): PrimaryField {
|
||||
return this.fields[0] as PrimaryField;
|
||||
}
|
||||
|
||||
id(): PrimaryField {
|
||||
return this.getPrimaryField();
|
||||
}
|
||||
|
||||
get label(): string {
|
||||
return snakeToPascalWithSpaces(this.config.name ?? this.name);
|
||||
}
|
||||
|
||||
field(name: string): Field | undefined {
|
||||
return this.getField(name);
|
||||
}
|
||||
|
||||
getFields(include_virtual: boolean = false): Field[] {
|
||||
if (include_virtual) return this.fields;
|
||||
return this.fields.filter((f) => !f.isVirtual());
|
||||
}
|
||||
|
||||
addField(field: Field) {
|
||||
const existing = this.getField(field.name);
|
||||
// make unique name check
|
||||
if (existing) {
|
||||
// @todo: for now adding a graceful method
|
||||
if (JSON.stringify(existing) === JSON.stringify(field)) {
|
||||
/*console.warn(
|
||||
`Field "${field.name}" already exists on entity "${this.name}", but it's the same, so skipping.`,
|
||||
);*/
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Field "${field.name}" already exists on entity "${this.name}"`);
|
||||
}
|
||||
|
||||
this.fields.push(field);
|
||||
}
|
||||
|
||||
__setData(data: EntityData[]) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
isValidData(data: EntityData, context: TActionContext, explain?: boolean): boolean {
|
||||
const fields = this.getFillableFields(context, false);
|
||||
//const fields = this.fields;
|
||||
//console.log("data", data);
|
||||
for (const field of fields) {
|
||||
if (!field.isValid(data[field.name], context)) {
|
||||
console.log("Entity.isValidData:invalid", context, field.name, data[field.name]);
|
||||
if (explain) {
|
||||
throw new Error(`Field "${field.name}" has invalid data: "${data[field.name]}"`);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
toSchema(clean?: boolean): object {
|
||||
const fields = Object.fromEntries(this.fields.map((field) => [field.name, field]));
|
||||
const schema = Type.Object(
|
||||
transformObject(fields, (field) => ({
|
||||
title: field.config.label,
|
||||
$comment: field.config.description,
|
||||
$field: field.type,
|
||||
readOnly: !field.isFillable("update") ? true : undefined,
|
||||
writeOnly: !field.isFillable("create") ? true : undefined,
|
||||
...field.toJsonSchema()
|
||||
}))
|
||||
);
|
||||
|
||||
return clean ? JSON.parse(JSON.stringify(schema)) : schema;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
//name: this.name,
|
||||
type: this.type,
|
||||
//fields: transformObject(this.fields, (field) => field.toJSON()),
|
||||
fields: Object.fromEntries(this.fields.map((field) => [field.name, field.toJSON()])),
|
||||
config: this.config
|
||||
};
|
||||
}
|
||||
}
|
||||
266
app/src/data/entities/EntityManager.ts
Normal file
266
app/src/data/entities/EntityManager.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { EventManager } from "core/events";
|
||||
import { sql } from "kysely";
|
||||
import { Connection } from "../connection/Connection";
|
||||
import {
|
||||
EntityNotDefinedException,
|
||||
TransformRetrieveFailedException,
|
||||
UnableToConnectException
|
||||
} from "../errors";
|
||||
import { MutatorEvents, RepositoryEvents } from "../events";
|
||||
import type { EntityIndex } from "../fields/indices/EntityIndex";
|
||||
import type { EntityRelation } from "../relations";
|
||||
import { RelationAccessor } from "../relations/RelationAccessor";
|
||||
import { SchemaManager } from "../schema/SchemaManager";
|
||||
import { Entity } from "./Entity";
|
||||
import { type EntityData, Mutator, Repository } from "./index";
|
||||
|
||||
export class EntityManager<DB> {
|
||||
connection: Connection;
|
||||
|
||||
private _entities: Entity[] = [];
|
||||
private _relations: EntityRelation[] = [];
|
||||
private _indices: EntityIndex[] = [];
|
||||
private _schema?: SchemaManager;
|
||||
readonly emgr: EventManager<typeof EntityManager.Events>;
|
||||
static readonly Events = { ...MutatorEvents, ...RepositoryEvents };
|
||||
|
||||
constructor(
|
||||
entities: Entity[],
|
||||
connection: Connection,
|
||||
relations: EntityRelation[] = [],
|
||||
indices: EntityIndex[] = [],
|
||||
emgr?: EventManager<any>
|
||||
) {
|
||||
// add entities & relations
|
||||
entities.forEach((entity) => this.addEntity(entity));
|
||||
relations.forEach((relation) => this.addRelation(relation));
|
||||
indices.forEach((index) => this.addIndex(index));
|
||||
|
||||
if (!(connection instanceof Connection)) {
|
||||
throw new UnableToConnectException("");
|
||||
}
|
||||
|
||||
this.connection = connection;
|
||||
this.emgr = emgr ?? new EventManager();
|
||||
//console.log("registering events", EntityManager.Events);
|
||||
this.emgr.registerEvents(EntityManager.Events);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forks the EntityManager without the EventManager.
|
||||
* This is useful when used inside an event handler.
|
||||
*/
|
||||
fork(): EntityManager<DB> {
|
||||
return new EntityManager(this._entities, this.connection, this._relations, this._indices);
|
||||
}
|
||||
|
||||
get entities(): Entity[] {
|
||||
return this._entities;
|
||||
}
|
||||
|
||||
get relations(): RelationAccessor {
|
||||
return new RelationAccessor(this._relations);
|
||||
}
|
||||
|
||||
get indices(): EntityIndex[] {
|
||||
return this._indices;
|
||||
}
|
||||
|
||||
async ping(): Promise<boolean> {
|
||||
const res = await sql`SELECT 1`.execute(this.connection.kysely);
|
||||
return res.rows.length > 0;
|
||||
}
|
||||
|
||||
addEntity(entity: Entity) {
|
||||
const existing = this.entities.find((e) => e.name === entity.name);
|
||||
// check if already exists by name
|
||||
if (existing) {
|
||||
// @todo: for now adding a graceful method
|
||||
if (JSON.stringify(existing) === JSON.stringify(entity)) {
|
||||
//console.warn(`Entity "${entity.name}" already exists, but it's the same, so skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Entity "${entity.name}" already exists`);
|
||||
}
|
||||
|
||||
this.entities.push(entity);
|
||||
}
|
||||
|
||||
entity(name: string): Entity {
|
||||
const entity = this.entities.find((e) => e.name === name);
|
||||
if (!entity) {
|
||||
throw new EntityNotDefinedException(name);
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
hasEntity(entity: string): boolean;
|
||||
hasEntity(entity: Entity): boolean;
|
||||
hasEntity(nameOrEntity: string | Entity): boolean {
|
||||
const name = typeof nameOrEntity === "string" ? nameOrEntity : nameOrEntity.name;
|
||||
return this.entities.some((e) => e.name === name);
|
||||
}
|
||||
|
||||
hasIndex(index: string): boolean;
|
||||
hasIndex(index: EntityIndex): boolean;
|
||||
hasIndex(nameOrIndex: string | EntityIndex): boolean {
|
||||
const name = typeof nameOrIndex === "string" ? nameOrIndex : nameOrIndex.name;
|
||||
return this.indices.some((e) => e.name === name);
|
||||
}
|
||||
|
||||
addRelation(relation: EntityRelation) {
|
||||
// check if entities are registered
|
||||
if (!this.entity(relation.source.entity.name) || !this.entity(relation.target.entity.name)) {
|
||||
throw new Error("Relation source or target entity not found");
|
||||
}
|
||||
|
||||
// @todo: potentially add name to relation in order to have multiple
|
||||
const found = this._relations.find((r) => {
|
||||
const equalSourceTarget =
|
||||
r.source.entity.name === relation.source.entity.name &&
|
||||
r.target.entity.name === relation.target.entity.name;
|
||||
const equalReferences =
|
||||
r.source.reference === relation.source.reference &&
|
||||
r.target.reference === relation.target.reference;
|
||||
|
||||
return (
|
||||
//r.type === relation.type && // ignore type for now
|
||||
equalSourceTarget && equalReferences
|
||||
);
|
||||
});
|
||||
|
||||
if (found) {
|
||||
throw new Error(
|
||||
`Relation "${relation.type}" between "${relation.source.entity.name}" ` +
|
||||
`and "${relation.target.entity.name}" already exists`
|
||||
);
|
||||
}
|
||||
|
||||
this._relations.push(relation);
|
||||
relation.initialize(this);
|
||||
}
|
||||
|
||||
relationsOf(entity_name: string): EntityRelation[] {
|
||||
return this.relations.relationsOf(this.entity(entity_name));
|
||||
}
|
||||
|
||||
relationOf(entity_name: string, reference: string): EntityRelation | undefined {
|
||||
return this.relations.relationOf(this.entity(entity_name), reference);
|
||||
}
|
||||
|
||||
hasRelations(entity_name: string): boolean {
|
||||
return this.relations.hasRelations(this.entity(entity_name));
|
||||
}
|
||||
|
||||
relatedEntitiesOf(entity_name: string): Entity[] {
|
||||
return this.relations.relatedEntitiesOf(this.entity(entity_name));
|
||||
}
|
||||
|
||||
relationReferencesOf(entity_name: string): string[] {
|
||||
return this.relations.relationReferencesOf(this.entity(entity_name));
|
||||
}
|
||||
|
||||
repository(_entity: Entity | string) {
|
||||
const entity = _entity instanceof Entity ? _entity : this.entity(_entity);
|
||||
return new Repository(this, entity, this.emgr);
|
||||
}
|
||||
|
||||
repo<E extends Entity>(
|
||||
_entity: E
|
||||
): Repository<
|
||||
DB,
|
||||
E extends Entity<infer Name> ? (Name extends keyof DB ? Name : never) : never
|
||||
> {
|
||||
return new Repository(this, _entity, this.emgr);
|
||||
}
|
||||
|
||||
_repo<TB extends keyof DB>(_entity: TB): Repository<DB, TB> {
|
||||
const entity = this.entity(_entity as any);
|
||||
return new Repository(this, entity, this.emgr);
|
||||
}
|
||||
|
||||
mutator(_entity: Entity | string) {
|
||||
const entity = _entity instanceof Entity ? _entity : this.entity(_entity);
|
||||
return new Mutator(this, entity, this.emgr);
|
||||
}
|
||||
|
||||
addIndex(index: EntityIndex, force = false) {
|
||||
// check if already exists by name
|
||||
if (this.indices.find((e) => e.name === index.name)) {
|
||||
if (force) {
|
||||
throw new Error(`Index "${index.name}" already exists`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this._indices.push(index);
|
||||
}
|
||||
|
||||
getIndicesOf(_entity: Entity | string): EntityIndex[] {
|
||||
const entity = _entity instanceof Entity ? _entity : this.entity(_entity);
|
||||
return this.indices.filter((index) => index.entity.name === entity.name);
|
||||
}
|
||||
|
||||
schema() {
|
||||
if (!this._schema) {
|
||||
this._schema = new SchemaManager(this);
|
||||
}
|
||||
|
||||
return this._schema;
|
||||
}
|
||||
|
||||
// @todo: centralize and add tests
|
||||
hydrate(entity_name: string, _data: EntityData[]) {
|
||||
const entity = this.entity(entity_name);
|
||||
const data: EntityData[] = [];
|
||||
|
||||
for (const row of _data) {
|
||||
for (let [key, value] of Object.entries(row)) {
|
||||
const field = entity.getField(key);
|
||||
|
||||
if (!field || field.isVirtual()) {
|
||||
// if relation, use related entity to hydrate
|
||||
const relation = this.relationOf(entity_name, key);
|
||||
if (relation) {
|
||||
if (!value) continue;
|
||||
|
||||
value = relation.hydrate(key, Array.isArray(value) ? value : [value], this);
|
||||
row[key] = value;
|
||||
continue;
|
||||
} else if (field?.isVirtual()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Field "${key}" not found on entity "${entity.name}"`);
|
||||
}
|
||||
|
||||
try {
|
||||
if (value === null && field.hasDefault()) {
|
||||
row[key] = field.getDefault();
|
||||
}
|
||||
|
||||
row[key] = field.transformRetrieve(value as any);
|
||||
} catch (e: any) {
|
||||
throw new TransformRetrieveFailedException(
|
||||
`"${field.type}" field "${key}" on entity "${entity.name}": ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
data.push(row);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
entities: Object.fromEntries(this.entities.map((e) => [e.name, e.toJSON()])),
|
||||
relations: Object.fromEntries(this.relations.all.map((r) => [r.getName(), r.toJSON()])),
|
||||
//relations: this.relations.all.map((r) => r.toJSON()),
|
||||
indices: Object.fromEntries(this.indices.map((i) => [i.name, i.toJSON()]))
|
||||
};
|
||||
}
|
||||
}
|
||||
270
app/src/data/entities/Mutator.ts
Normal file
270
app/src/data/entities/Mutator.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import { type EmitsEvents, EventManager } from "core/events";
|
||||
import type { DeleteQueryBuilder, InsertQueryBuilder, UpdateQueryBuilder } from "kysely";
|
||||
import { type TActionContext, WhereBuilder } from "..";
|
||||
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";
|
||||
|
||||
type MutatorQB =
|
||||
| InsertQueryBuilder<any, any, any>
|
||||
| UpdateQueryBuilder<any, any, any, any>
|
||||
| DeleteQueryBuilder<any, any, any>;
|
||||
|
||||
type MutatorUpdateOrDelete =
|
||||
| UpdateQueryBuilder<any, any, any, any>
|
||||
| DeleteQueryBuilder<any, any, any>;
|
||||
|
||||
export type MutatorResponse<T = EntityData[]> = {
|
||||
entity: Entity;
|
||||
sql: string;
|
||||
parameters: any[];
|
||||
result: EntityData[];
|
||||
data: T;
|
||||
};
|
||||
|
||||
export class Mutator<DB> implements EmitsEvents {
|
||||
em: EntityManager<DB>;
|
||||
entity: Entity;
|
||||
static readonly Events = MutatorEvents;
|
||||
emgr: EventManager<typeof MutatorEvents>;
|
||||
|
||||
// @todo: current hacky workaround to disable creation of system entities
|
||||
__unstable_disable_system_entity_creation = true;
|
||||
__unstable_toggleSystemEntityCreation(value: boolean) {
|
||||
this.__unstable_disable_system_entity_creation = value;
|
||||
}
|
||||
|
||||
constructor(em: EntityManager<DB>, entity: Entity, emgr?: EventManager<any>) {
|
||||
this.em = em;
|
||||
this.entity = entity;
|
||||
this.emgr = emgr ?? new EventManager(MutatorEvents);
|
||||
}
|
||||
|
||||
private get conn() {
|
||||
return this.em.connection.kysely;
|
||||
}
|
||||
|
||||
async getValidatedData(data: EntityData, context: TActionContext): Promise<EntityData> {
|
||||
const entity = this.entity;
|
||||
if (!context) {
|
||||
throw new Error("Context must be provided for validation");
|
||||
}
|
||||
|
||||
const keys = Object.keys(data);
|
||||
const validatedData: EntityData = {};
|
||||
|
||||
// get relational references/keys
|
||||
const relationMutator = new RelationMutator(entity, this.em);
|
||||
const relational_keys = relationMutator.getRelationalKeys();
|
||||
|
||||
for (const key of keys) {
|
||||
if (relational_keys.includes(key)) {
|
||||
const result = await relationMutator.persistRelation(key, data[key]);
|
||||
|
||||
// if relation field (include key and value in validatedData)
|
||||
if (Array.isArray(result)) {
|
||||
//console.log("--- (instructions)", result);
|
||||
const [relation_key, relation_value] = result;
|
||||
validatedData[relation_key] = relation_value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const field = entity.getField(key);
|
||||
if (!field) {
|
||||
throw new Error(
|
||||
`Field "${key}" not found on entity "${entity.name}". Fields: ${entity
|
||||
.getFillableFields()
|
||||
.map((f) => f.name)
|
||||
.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
// we should never get here, but just to be sure (why?)
|
||||
if (!field.isFillable(context)) {
|
||||
throw new Error(`Field "${key}" is not fillable on entity "${entity.name}"`);
|
||||
}
|
||||
|
||||
validatedData[key] = await field.transformPersist(data[key], this.em, context);
|
||||
}
|
||||
|
||||
if (Object.keys(validatedData).length === 0) {
|
||||
throw new Error(`No data left to update "${entity.name}"`);
|
||||
}
|
||||
|
||||
return validatedData;
|
||||
}
|
||||
|
||||
protected async many(qb: MutatorQB): Promise<MutatorResponse> {
|
||||
const entity = this.entity;
|
||||
const { sql, parameters } = qb.compile();
|
||||
//console.log("mutatoar:exec", sql, parameters);
|
||||
const result = await qb.execute();
|
||||
|
||||
const data = this.em.hydrate(entity.name, result) as EntityData[];
|
||||
|
||||
return {
|
||||
entity,
|
||||
sql,
|
||||
parameters: [...parameters],
|
||||
result: result,
|
||||
data
|
||||
};
|
||||
}
|
||||
|
||||
protected async single(qb: MutatorQB): Promise<MutatorResponse<EntityData>> {
|
||||
const { data, ...response } = await this.many(qb);
|
||||
return { ...response, data: data[0]! };
|
||||
}
|
||||
|
||||
async insertOne(data: EntityData): Promise<MutatorResponse<EntityData>> {
|
||||
const entity = this.entity;
|
||||
if (entity.type === "system" && this.__unstable_disable_system_entity_creation) {
|
||||
throw new Error(`Creation of system entity "${entity.name}" is disabled`);
|
||||
}
|
||||
|
||||
// @todo: establish the original order from "data"
|
||||
const validatedData = {
|
||||
...entity.getDefaultObject(),
|
||||
...(await this.getValidatedData(data, "create"))
|
||||
};
|
||||
|
||||
await this.emgr.emit(new Mutator.Events.MutatorInsertBefore({ entity, data: validatedData }));
|
||||
|
||||
// check if required fields are present
|
||||
const required = entity.getRequiredFields();
|
||||
for (const field of required) {
|
||||
if (
|
||||
typeof validatedData[field.name] === "undefined" ||
|
||||
validatedData[field.name] === null
|
||||
) {
|
||||
throw new Error(`Field "${field.name}" is required`);
|
||||
}
|
||||
}
|
||||
|
||||
const query = this.conn
|
||||
.insertInto(entity.name)
|
||||
.values(validatedData)
|
||||
.returning(entity.getSelect());
|
||||
|
||||
const res = await this.single(query);
|
||||
|
||||
await this.emgr.emit(new Mutator.Events.MutatorInsertAfter({ entity, data: res.data }));
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async updateOne(id: PrimaryFieldType, data: EntityData): Promise<MutatorResponse<EntityData>> {
|
||||
const entity = this.entity;
|
||||
if (!Number.isInteger(id)) {
|
||||
throw new Error("ID must be provided for update");
|
||||
}
|
||||
|
||||
const validatedData = await this.getValidatedData(data, "update");
|
||||
|
||||
await this.emgr.emit(
|
||||
new Mutator.Events.MutatorUpdateBefore({ entity, entityId: id, data: validatedData })
|
||||
);
|
||||
|
||||
const query = this.conn
|
||||
.updateTable(entity.name)
|
||||
.set(validatedData)
|
||||
.where(entity.id().name, "=", id)
|
||||
.returning(entity.getSelect());
|
||||
|
||||
const res = await this.single(query);
|
||||
|
||||
await this.emgr.emit(
|
||||
new Mutator.Events.MutatorUpdateAfter({ entity, entityId: id, data: res.data })
|
||||
);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<EntityData>> {
|
||||
const entity = this.entity;
|
||||
if (!Number.isInteger(id)) {
|
||||
throw new Error("ID must be provided for deletion");
|
||||
}
|
||||
|
||||
await this.emgr.emit(new Mutator.Events.MutatorDeleteBefore({ entity, entityId: id }));
|
||||
|
||||
const query = this.conn
|
||||
.deleteFrom(entity.name)
|
||||
.where(entity.id().name, "=", id)
|
||||
.returning(entity.getSelect());
|
||||
|
||||
const res = await this.single(query);
|
||||
|
||||
await this.emgr.emit(
|
||||
new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data })
|
||||
);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
private getValidOptions(options?: Partial<RepoQuery>): Partial<RepoQuery> {
|
||||
const entity = this.entity;
|
||||
const validated: Partial<RepoQuery> = {};
|
||||
|
||||
if (options?.where) {
|
||||
// @todo: add tests for aliased fields in where
|
||||
const invalid = WhereBuilder.getPropertyNames(options.where).filter((field) => {
|
||||
return typeof entity.getField(field) === "undefined";
|
||||
});
|
||||
|
||||
if (invalid.length > 0) {
|
||||
throw new InvalidSearchParamsException(`Invalid where field(s): ${invalid.join(", ")}`);
|
||||
}
|
||||
|
||||
validated.where = options.where;
|
||||
}
|
||||
|
||||
return validated;
|
||||
}
|
||||
|
||||
private appendWhere<QB extends MutatorUpdateOrDelete>(qb: QB, _where?: RepoQuery["where"]): QB {
|
||||
const entity = this.entity;
|
||||
|
||||
const alias = entity.name;
|
||||
const aliased = (field: string) => `${alias}.${field}`;
|
||||
|
||||
// add where if present
|
||||
if (_where) {
|
||||
// @todo: add tests for aliased fields in where
|
||||
const invalid = WhereBuilder.getPropertyNames(_where).filter((field) => {
|
||||
return typeof entity.getField(field) === "undefined";
|
||||
});
|
||||
|
||||
if (invalid.length > 0) {
|
||||
throw new InvalidSearchParamsException(`Invalid where field(s): ${invalid.join(", ")}`);
|
||||
}
|
||||
|
||||
return WhereBuilder.addClause(qb, _where);
|
||||
}
|
||||
|
||||
return qb;
|
||||
}
|
||||
|
||||
// @todo: decide whether entries should be deleted all at once or one by one (for events)
|
||||
async deleteMany(where?: RepoQuery["where"]): Promise<MutatorResponse<EntityData>> {
|
||||
const entity = this.entity;
|
||||
|
||||
const qb = this.appendWhere(this.conn.deleteFrom(entity.name), where).returning(
|
||||
entity.getSelect()
|
||||
);
|
||||
|
||||
//await this.emgr.emit(new Mutator.Events.MutatorDeleteBefore({ entity, entityId: id }));
|
||||
|
||||
const res = await this.many(qb);
|
||||
|
||||
/*await this.emgr.emit(
|
||||
new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data })
|
||||
);*/
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
6
app/src/data/entities/index.ts
Normal file
6
app/src/data/entities/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./Entity";
|
||||
export * from "./EntityManager";
|
||||
export * from "./Mutator";
|
||||
export * from "./query/Repository";
|
||||
export * from "./query/WhereBuilder";
|
||||
export * from "./query/WithBuilder";
|
||||
51
app/src/data/entities/query/JoinBuilder.ts
Normal file
51
app/src/data/entities/query/JoinBuilder.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { ManyToManyRelation, ManyToOneRelation } from "../../relations";
|
||||
import type { Entity } from "../Entity";
|
||||
import type { EntityManager } from "../EntityManager";
|
||||
import type { RepositoryQB } from "./Repository";
|
||||
|
||||
export class JoinBuilder {
|
||||
private static buildClause(
|
||||
em: EntityManager<any>,
|
||||
qb: RepositoryQB,
|
||||
entity: Entity,
|
||||
withString: string,
|
||||
) {
|
||||
const relation = em.relationOf(entity.name, withString);
|
||||
if (!relation) {
|
||||
throw new Error(`Relation "${withString}" not found`);
|
||||
}
|
||||
|
||||
return relation.buildJoin(entity, qb, withString);
|
||||
}
|
||||
|
||||
// @todo: returns multiple on manytomany (edit: so?)
|
||||
static getJoinedEntityNames(em: EntityManager<any>, entity: Entity, joins: string[]): string[] {
|
||||
return joins.flatMap((join) => {
|
||||
const relation = em.relationOf(entity.name, join);
|
||||
if (!relation) {
|
||||
throw new Error(`Relation "${join}" not found`);
|
||||
}
|
||||
|
||||
const other = relation.other(entity);
|
||||
|
||||
if (relation instanceof ManyToOneRelation) {
|
||||
return [other.entity.name];
|
||||
} else if (relation instanceof ManyToManyRelation) {
|
||||
return [other.entity.name, relation.connectionEntity.name];
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
static addClause(em: EntityManager<any>, qb: RepositoryQB, entity: Entity, joins: string[]) {
|
||||
if (joins.length === 0) return qb;
|
||||
|
||||
let newQb = qb;
|
||||
for (const entry of joins) {
|
||||
newQb = JoinBuilder.buildClause(em, newQb, entity, entry);
|
||||
}
|
||||
|
||||
return newQb;
|
||||
}
|
||||
}
|
||||
407
app/src/data/entities/query/Repository.ts
Normal file
407
app/src/data/entities/query/Repository.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
import type { PrimaryFieldType } 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, RepositoryFindManyBefore } from "../../events";
|
||||
import { type RepoQuery, defaultQuerySchema } from "../../server/data-query-impl";
|
||||
import {
|
||||
type Entity,
|
||||
type EntityData,
|
||||
type EntityManager,
|
||||
WhereBuilder,
|
||||
WithBuilder
|
||||
} from "../index";
|
||||
import { JoinBuilder } from "./JoinBuilder";
|
||||
|
||||
export type RepositoryQB = SelectQueryBuilder<any, any, any>;
|
||||
|
||||
export type RepositoryRawResponse = {
|
||||
sql: string;
|
||||
parameters: any[];
|
||||
result: EntityData[];
|
||||
};
|
||||
export type RepositoryResponse<T = EntityData[]> = RepositoryRawResponse & {
|
||||
entity: Entity;
|
||||
data: T;
|
||||
meta: {
|
||||
total: number;
|
||||
count: number;
|
||||
items: number;
|
||||
time?: number;
|
||||
query?: {
|
||||
sql: string;
|
||||
parameters: readonly any[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type RepositoryCountResponse = RepositoryRawResponse & {
|
||||
count: number;
|
||||
};
|
||||
export type RepositoryExistsResponse = RepositoryRawResponse & {
|
||||
exists: boolean;
|
||||
};
|
||||
|
||||
export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEvents {
|
||||
em: EntityManager<DB>;
|
||||
entity: Entity;
|
||||
static readonly Events = RepositoryEvents;
|
||||
emgr: EventManager<typeof Repository.Events>;
|
||||
|
||||
constructor(em: EntityManager<DB>, entity: Entity, emgr?: EventManager<any>) {
|
||||
this.em = em;
|
||||
this.entity = entity;
|
||||
this.emgr = emgr ?? new EventManager(MutatorEvents);
|
||||
}
|
||||
|
||||
private cloneFor(entity: Entity) {
|
||||
return new Repository(this.em, entity, this.emgr);
|
||||
}
|
||||
|
||||
private get conn() {
|
||||
return this.em.connection.kysely;
|
||||
}
|
||||
|
||||
private getValidOptions(options?: Partial<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),
|
||||
sort: entity.getDefaultSort(),
|
||||
select: entity.getSelect()
|
||||
};
|
||||
//console.log("validated", validated);
|
||||
|
||||
if (!options) return validated;
|
||||
|
||||
if (options.sort) {
|
||||
if (!validated.select.includes(options.sort.by)) {
|
||||
throw new InvalidSearchParamsException(`Invalid sort field "${options.sort.by}"`);
|
||||
}
|
||||
if (!["asc", "desc"].includes(options.sort.dir)) {
|
||||
throw new InvalidSearchParamsException(`Invalid sort direction "${options.sort.dir}"`);
|
||||
}
|
||||
|
||||
validated.sort = options.sort;
|
||||
}
|
||||
|
||||
if (options.select && options.select.length > 0) {
|
||||
const invalid = options.select.filter((field) => !validated.select.includes(field));
|
||||
|
||||
if (invalid.length > 0) {
|
||||
throw new InvalidSearchParamsException(
|
||||
`Invalid select field(s): ${invalid.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
validated.select = options.select;
|
||||
}
|
||||
|
||||
if (options.with && options.with.length > 0) {
|
||||
for (const entry of options.with) {
|
||||
const related = this.em.relationOf(entity.name, entry);
|
||||
if (!related) {
|
||||
throw new InvalidSearchParamsException(
|
||||
`WITH: "${entry}" is not a relation of "${entity.name}"`
|
||||
);
|
||||
}
|
||||
|
||||
validated.with.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.join && options.join.length > 0) {
|
||||
for (const entry of options.join) {
|
||||
const related = this.em.relationOf(entity.name, entry);
|
||||
if (!related) {
|
||||
throw new InvalidSearchParamsException(
|
||||
`JOIN: "${entry}" is not a relation of "${entity.name}"`
|
||||
);
|
||||
}
|
||||
|
||||
validated.join.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.where) {
|
||||
// @todo: auto-alias base entity when using joins! otherwise "id" is ambiguous
|
||||
const aliases = [entity.name];
|
||||
if (validated.join.length > 0) {
|
||||
aliases.push(...JoinBuilder.getJoinedEntityNames(this.em, entity, validated.join));
|
||||
}
|
||||
|
||||
// @todo: add tests for aliased fields in where
|
||||
const invalid = WhereBuilder.getPropertyNames(options.where).filter((field) => {
|
||||
if (field.includes(".")) {
|
||||
const [alias, prop] = field.split(".") as [string, string];
|
||||
if (!aliases.includes(alias)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !this.em.entity(alias).getField(prop);
|
||||
}
|
||||
|
||||
return typeof entity.getField(field) === "undefined";
|
||||
});
|
||||
|
||||
if (invalid.length > 0) {
|
||||
throw new InvalidSearchParamsException(`Invalid where field(s): ${invalid.join(", ")}`);
|
||||
}
|
||||
|
||||
validated.where = options.where;
|
||||
}
|
||||
|
||||
// pass unfiltered
|
||||
if (options.limit) validated.limit = options.limit;
|
||||
if (options.offset) validated.offset = options.offset;
|
||||
|
||||
return validated;
|
||||
}
|
||||
|
||||
protected async performQuery(qb: RepositoryQB): Promise<RepositoryResponse> {
|
||||
const entity = this.entity;
|
||||
const compiled = qb.compile();
|
||||
/*const { sql, parameters } = qb.compile();
|
||||
console.log("many", sql, parameters);*/
|
||||
|
||||
const start = performance.now();
|
||||
const selector = (as = "count") => this.conn.fn.countAll<number>().as(as);
|
||||
const countQuery = qb
|
||||
.clearSelect()
|
||||
.select(selector())
|
||||
.clearLimit()
|
||||
.clearOffset()
|
||||
.clearGroupBy()
|
||||
.clearOrderBy();
|
||||
const totalQuery = this.conn.selectFrom(entity.name).select(selector("total"));
|
||||
|
||||
try {
|
||||
const [_count, _total, result] = await this.em.connection.batchQuery([
|
||||
countQuery,
|
||||
totalQuery,
|
||||
qb
|
||||
]);
|
||||
//console.log("result", { _count, _total });
|
||||
|
||||
const time = Number.parseFloat((performance.now() - start).toFixed(2));
|
||||
const data = this.em.hydrate(entity.name, result);
|
||||
|
||||
return {
|
||||
entity,
|
||||
sql: compiled.sql,
|
||||
parameters: [...compiled.parameters],
|
||||
result,
|
||||
data,
|
||||
meta: {
|
||||
total: _total[0]?.total ?? 0,
|
||||
count: _count[0]?.count ?? 0, // @todo: better graceful method
|
||||
items: result.length,
|
||||
time,
|
||||
query: { sql: compiled.sql, parameters: compiled.parameters }
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("many error", e, compiled);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
protected async single(
|
||||
qb: RepositoryQB,
|
||||
options: RepoQuery
|
||||
): Promise<RepositoryResponse<EntityData>> {
|
||||
await this.emgr.emit(
|
||||
new Repository.Events.RepositoryFindOneBefore({ entity: this.entity, options })
|
||||
);
|
||||
|
||||
const { data, ...response } = await this.performQuery(qb);
|
||||
|
||||
await this.emgr.emit(
|
||||
new Repository.Events.RepositoryFindOneAfter({
|
||||
entity: this.entity,
|
||||
options,
|
||||
data: data[0]!
|
||||
})
|
||||
);
|
||||
|
||||
return { ...response, data: data[0]! };
|
||||
}
|
||||
|
||||
private buildQuery(
|
||||
_options?: Partial<RepoQuery>,
|
||||
exclude_options: (keyof RepoQuery)[] = []
|
||||
): { qb: RepositoryQB; options: RepoQuery } {
|
||||
const entity = this.entity;
|
||||
const options = this.getValidOptions(_options);
|
||||
|
||||
const alias = entity.name;
|
||||
const aliased = (field: string) => `${alias}.${field}`;
|
||||
let qb = this.conn
|
||||
.selectFrom(entity.name)
|
||||
.select(entity.getAliasedSelectFrom(options.select, alias));
|
||||
|
||||
//console.log("build query options", options);
|
||||
if (!exclude_options.includes("with") && options.with) {
|
||||
qb = WithBuilder.addClause(this.em, qb, entity, options.with);
|
||||
}
|
||||
|
||||
if (!exclude_options.includes("join") && options.join) {
|
||||
qb = JoinBuilder.addClause(this.em, qb, entity, options.join);
|
||||
}
|
||||
|
||||
// add where if present
|
||||
if (!exclude_options.includes("where") && options.where) {
|
||||
qb = WhereBuilder.addClause(qb, options.where);
|
||||
}
|
||||
|
||||
if (!exclude_options.includes("limit")) qb = qb.limit(options.limit);
|
||||
if (!exclude_options.includes("offset")) qb = qb.offset(options.offset);
|
||||
|
||||
// sorting
|
||||
if (!exclude_options.includes("sort")) {
|
||||
qb = qb.orderBy(aliased(options.sort.by), options.sort.dir);
|
||||
}
|
||||
|
||||
return { qb, options };
|
||||
}
|
||||
|
||||
async findId(
|
||||
id: PrimaryFieldType,
|
||||
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>
|
||||
): Promise<RepositoryResponse<DB[TB]>> {
|
||||
const { qb, options } = this.buildQuery(
|
||||
{
|
||||
..._options,
|
||||
where: { [this.entity.getPrimaryField().name]: id },
|
||||
limit: 1
|
||||
},
|
||||
["offset", "sort"]
|
||||
);
|
||||
|
||||
return this.single(qb, options) as any;
|
||||
}
|
||||
|
||||
async findOne(
|
||||
where: RepoQuery["where"],
|
||||
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>
|
||||
): Promise<RepositoryResponse<DB[TB] | undefined>> {
|
||||
const { qb, options } = this.buildQuery(
|
||||
{
|
||||
..._options,
|
||||
where,
|
||||
limit: 1
|
||||
},
|
||||
["offset", "sort"]
|
||||
);
|
||||
|
||||
return this.single(qb, options) as any;
|
||||
}
|
||||
|
||||
async findMany(_options?: Partial<RepoQuery>): Promise<RepositoryResponse<DB[TB][]>> {
|
||||
const { qb, options } = this.buildQuery(_options);
|
||||
//console.log("findMany:options", options);
|
||||
|
||||
await this.emgr.emit(
|
||||
new Repository.Events.RepositoryFindManyBefore({ entity: this.entity, options })
|
||||
);
|
||||
|
||||
const res = await this.performQuery(qb);
|
||||
|
||||
await this.emgr.emit(
|
||||
new Repository.Events.RepositoryFindManyAfter({
|
||||
entity: this.entity,
|
||||
options,
|
||||
data: res.data
|
||||
})
|
||||
);
|
||||
|
||||
return res as any;
|
||||
}
|
||||
|
||||
// @todo: add unit tests, specially for many to many
|
||||
async findManyByReference(
|
||||
id: PrimaryFieldType,
|
||||
reference: string,
|
||||
_options?: Partial<Omit<RepoQuery, "limit" | "offset">>
|
||||
): Promise<RepositoryResponse<EntityData>> {
|
||||
const entity = this.entity;
|
||||
const listable_relations = this.em.relations.listableRelationsOf(entity);
|
||||
const relation = listable_relations.find((r) => r.ref(reference).reference === reference);
|
||||
|
||||
if (!relation) {
|
||||
throw new Error(
|
||||
`Relation "${reference}" not found or not listable on entity "${entity.name}"`
|
||||
);
|
||||
}
|
||||
|
||||
const newEntity = relation.other(entity).entity;
|
||||
const refQueryOptions = relation.getReferenceQuery(newEntity, id as number, reference);
|
||||
if (!("where" in refQueryOptions) || Object.keys(refQueryOptions.where as any).length === 0) {
|
||||
throw new Error(
|
||||
`Invalid reference query for "${reference}" on entity "${newEntity.name}"`
|
||||
);
|
||||
}
|
||||
|
||||
const findManyOptions = {
|
||||
..._options,
|
||||
...refQueryOptions,
|
||||
where: {
|
||||
...refQueryOptions.where,
|
||||
..._options?.where
|
||||
}
|
||||
};
|
||||
|
||||
//console.log("findManyOptions", newEntity.name, findManyOptions);
|
||||
return this.cloneFor(newEntity).findMany(findManyOptions);
|
||||
}
|
||||
|
||||
async count(where?: RepoQuery["where"]): Promise<RepositoryCountResponse> {
|
||||
const entity = this.entity;
|
||||
const options = this.getValidOptions({ where });
|
||||
|
||||
const selector = this.conn.fn.count<number>(sql`*`).as("count");
|
||||
let qb = this.conn.selectFrom(entity.name).select(selector);
|
||||
|
||||
// add where if present
|
||||
if (options.where) {
|
||||
qb = WhereBuilder.addClause(qb, options.where);
|
||||
}
|
||||
|
||||
const compiled = qb.compile();
|
||||
const result = await qb.execute();
|
||||
|
||||
return {
|
||||
sql: compiled.sql,
|
||||
parameters: [...compiled.parameters],
|
||||
result,
|
||||
count: result[0]?.count ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
async exists(where: Required<RepoQuery["where"]>): Promise<RepositoryExistsResponse> {
|
||||
const entity = this.entity;
|
||||
const options = this.getValidOptions({ where });
|
||||
|
||||
const selector = this.conn.fn.count<number>(sql`*`).as("count");
|
||||
let qb = this.conn.selectFrom(entity.name).select(selector);
|
||||
|
||||
// add mandatory where
|
||||
qb = WhereBuilder.addClause(qb, options.where);
|
||||
|
||||
// we only need 1
|
||||
qb = qb.limit(1);
|
||||
|
||||
const compiled = qb.compile();
|
||||
//console.log("exists query", compiled.sql, compiled.parameters);
|
||||
const result = await qb.execute();
|
||||
//console.log("result", result);
|
||||
|
||||
return {
|
||||
sql: compiled.sql,
|
||||
parameters: [...compiled.parameters],
|
||||
result,
|
||||
exists: result[0]!.count > 0
|
||||
};
|
||||
}
|
||||
}
|
||||
132
app/src/data/entities/query/WhereBuilder.ts
Normal file
132
app/src/data/entities/query/WhereBuilder.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
type BooleanLike,
|
||||
type FilterQuery,
|
||||
type Primitive,
|
||||
type TExpression,
|
||||
exp,
|
||||
isBooleanLike,
|
||||
isPrimitive,
|
||||
makeValidator
|
||||
} from "core";
|
||||
import type {
|
||||
DeleteQueryBuilder,
|
||||
ExpressionBuilder,
|
||||
ExpressionWrapper,
|
||||
SelectQueryBuilder,
|
||||
UpdateQueryBuilder
|
||||
} from "kysely";
|
||||
import type { RepositoryQB } from "./Repository";
|
||||
|
||||
type Builder = ExpressionBuilder<any, any>;
|
||||
type Wrapper = ExpressionWrapper<any, any, any>;
|
||||
type WhereQb =
|
||||
| SelectQueryBuilder<any, any, any>
|
||||
| UpdateQueryBuilder<any, any, any, any>
|
||||
| DeleteQueryBuilder<any, any, any>;
|
||||
|
||||
function key(e: unknown): string {
|
||||
if (typeof e !== "string") {
|
||||
throw new Error(`Invalid key: ${e}`);
|
||||
}
|
||||
return e as string;
|
||||
}
|
||||
|
||||
const expressions: TExpression<any, any, any>[] = [
|
||||
exp(
|
||||
"$eq",
|
||||
(v: Primitive) => isPrimitive(v),
|
||||
(v, k, eb: Builder) => eb(key(k), "=", v)
|
||||
),
|
||||
exp(
|
||||
"$ne",
|
||||
(v: Primitive) => isPrimitive(v),
|
||||
(v, k, eb: Builder) => eb(key(k), "!=", v)
|
||||
),
|
||||
exp(
|
||||
"$gt",
|
||||
(v: Primitive) => isPrimitive(v),
|
||||
(v, k, eb: Builder) => eb(key(k), ">", v)
|
||||
),
|
||||
exp(
|
||||
"$gte",
|
||||
(v: Primitive) => isPrimitive(v),
|
||||
(v, k, eb: Builder) => eb(key(k), ">=", v)
|
||||
),
|
||||
exp(
|
||||
"$lt",
|
||||
(v: Primitive) => isPrimitive(v),
|
||||
(v, k, eb: Builder) => eb(key(k), "<", v)
|
||||
),
|
||||
exp(
|
||||
"$lte",
|
||||
(v: Primitive) => isPrimitive(v),
|
||||
(v, k, eb: Builder) => eb(key(k), "<=", v)
|
||||
),
|
||||
exp(
|
||||
"$isnull",
|
||||
(v: BooleanLike) => isBooleanLike(v),
|
||||
(v, k, eb: Builder) => eb(key(k), v ? "is" : "is not", null)
|
||||
),
|
||||
exp(
|
||||
"$in",
|
||||
(v: any[]) => Array.isArray(v),
|
||||
(v, k, eb: Builder) => eb(key(k), "in", v)
|
||||
),
|
||||
exp(
|
||||
"$notin",
|
||||
(v: any[]) => Array.isArray(v),
|
||||
(v, k, eb: Builder) => eb(key(k), "not in", v)
|
||||
),
|
||||
exp(
|
||||
"$between",
|
||||
(v: [number, number]) => Array.isArray(v) && v.length === 2,
|
||||
(v, k, eb: Builder) => eb.between(key(k), v[0], v[1])
|
||||
),
|
||||
exp(
|
||||
"$like",
|
||||
(v: Primitive) => isPrimitive(v),
|
||||
(v, k, eb: Builder) => eb(key(k), "like", String(v).replace(/\*/g, "%"))
|
||||
)
|
||||
];
|
||||
|
||||
export type WhereQuery = FilterQuery<typeof expressions>;
|
||||
|
||||
const validator = makeValidator(expressions);
|
||||
|
||||
export class WhereBuilder {
|
||||
static addClause<QB extends WhereQb>(qb: QB, query: WhereQuery) {
|
||||
if (Object.keys(query).length === 0) {
|
||||
return qb;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return qb.where((eb) => {
|
||||
const fns = validator.build(query, {
|
||||
value_is_kv: true,
|
||||
exp_ctx: eb,
|
||||
convert: true
|
||||
});
|
||||
|
||||
if (fns.$or.length > 0 && fns.$and.length > 0) {
|
||||
return eb.and(fns.$and).or(eb.and(fns.$or));
|
||||
} else if (fns.$or.length > 0) {
|
||||
return eb.or(fns.$or);
|
||||
}
|
||||
|
||||
return eb.and(fns.$and);
|
||||
});
|
||||
}
|
||||
|
||||
static convert(query: WhereQuery): WhereQuery {
|
||||
return validator.convert(query);
|
||||
}
|
||||
|
||||
static getPropertyNames(query: WhereQuery): string[] {
|
||||
const { keys } = validator.build(query, {
|
||||
value_is_kv: true,
|
||||
exp_ctx: () => null,
|
||||
convert: true
|
||||
});
|
||||
return Array.from(keys);
|
||||
}
|
||||
}
|
||||
42
app/src/data/entities/query/WithBuilder.ts
Normal file
42
app/src/data/entities/query/WithBuilder.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Entity, EntityManager, RepositoryQB } from "../../entities";
|
||||
|
||||
export class WithBuilder {
|
||||
private static buildClause(
|
||||
em: EntityManager<any>,
|
||||
qb: RepositoryQB,
|
||||
entity: Entity,
|
||||
withString: string
|
||||
) {
|
||||
const relation = em.relationOf(entity.name, withString);
|
||||
if (!relation) {
|
||||
throw new Error(`Relation "${withString}" not found`);
|
||||
}
|
||||
|
||||
const cardinality = relation.ref(withString).cardinality;
|
||||
//console.log("with--builder", { entity: entity.name, withString, cardinality });
|
||||
|
||||
const fns = em.connection.fn;
|
||||
const jsonFrom = cardinality === 1 ? fns.jsonObjectFrom : fns.jsonArrayFrom;
|
||||
|
||||
if (!jsonFrom) {
|
||||
throw new Error("Connection does not support jsonObjectFrom/jsonArrayFrom");
|
||||
}
|
||||
|
||||
try {
|
||||
return relation.buildWith(entity, qb, jsonFrom, withString);
|
||||
} catch (e) {
|
||||
throw new Error(`Could not build "with" relation "${withString}": ${(e as any).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
static addClause(em: EntityManager<any>, qb: RepositoryQB, entity: Entity, withs: string[]) {
|
||||
if (withs.length === 0) return qb;
|
||||
|
||||
let newQb = qb;
|
||||
for (const entry of withs) {
|
||||
newQb = WithBuilder.buildClause(em, newQb, entity, entry);
|
||||
}
|
||||
|
||||
return newQb;
|
||||
}
|
||||
}
|
||||
77
app/src/data/errors.ts
Normal file
77
app/src/data/errors.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Exception } from "core";
|
||||
import type { TypeInvalidError } from "core/utils";
|
||||
import type { Entity } from "./entities";
|
||||
import type { Field } from "./fields";
|
||||
|
||||
export class UnableToConnectException extends Exception {
|
||||
override name = "UnableToConnectException";
|
||||
override code = 500;
|
||||
}
|
||||
|
||||
export class InvalidSearchParamsException extends Exception {
|
||||
override name = "InvalidSearchParamsException";
|
||||
override code = 422;
|
||||
}
|
||||
|
||||
export class TransformRetrieveFailedException extends Exception {
|
||||
override name = "TransformRetrieveFailedException";
|
||||
override code = 422;
|
||||
}
|
||||
|
||||
export class TransformPersistFailedException extends Exception {
|
||||
override name = "TransformPersistFailedException";
|
||||
override code = 422;
|
||||
|
||||
static invalidType(property: string, expected: string, given: any) {
|
||||
const givenValue = typeof given === "object" ? JSON.stringify(given) : given;
|
||||
const message =
|
||||
`Property "${property}" must be of type "${expected}", ` +
|
||||
`"${givenValue}" of type "${typeof given}" given.`;
|
||||
return new TransformPersistFailedException(message);
|
||||
}
|
||||
|
||||
static required(property: string) {
|
||||
return new TransformPersistFailedException(`Property "${property}" is required`);
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidFieldConfigException extends Exception {
|
||||
override name = "InvalidFieldConfigException";
|
||||
override code = 400;
|
||||
|
||||
constructor(
|
||||
field: Field<any, any, any>,
|
||||
public given: any,
|
||||
error: TypeInvalidError
|
||||
) {
|
||||
console.error("InvalidFieldConfigException", {
|
||||
given,
|
||||
error: error.firstToString()
|
||||
});
|
||||
super(`Invalid Field config given for field "${field.name}": ${error.firstToString()}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class EntityNotDefinedException extends Exception {
|
||||
override name = "EntityNotDefinedException";
|
||||
override code = 400;
|
||||
|
||||
constructor(entity?: Entity | string) {
|
||||
if (!entity) {
|
||||
super("Cannot find an entity that is undefined");
|
||||
} else {
|
||||
super(`Entity "${typeof entity !== "string" ? entity.name : entity}" not defined`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class EntityNotFoundException extends Exception {
|
||||
override name = "EntityNotFoundException";
|
||||
override code = 404;
|
||||
|
||||
constructor(entity: Entity | string, id: any) {
|
||||
super(
|
||||
`Entity "${typeof entity !== "string" ? entity.name : entity}" with id "${id}" not found`
|
||||
);
|
||||
}
|
||||
}
|
||||
74
app/src/data/events/index.ts
Normal file
74
app/src/data/events/index.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import { Event } from "core/events";
|
||||
import type { Entity, EntityData } from "../entities";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
|
||||
export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityData }> {
|
||||
static override slug = "mutator-insert-before";
|
||||
}
|
||||
export class MutatorInsertAfter extends Event<{ entity: Entity; data: EntityData }> {
|
||||
static override slug = "mutator-insert-after";
|
||||
}
|
||||
export class MutatorUpdateBefore extends Event<{
|
||||
entity: Entity;
|
||||
entityId: PrimaryFieldType;
|
||||
data: EntityData;
|
||||
}> {
|
||||
static override slug = "mutator-update-before";
|
||||
}
|
||||
export class MutatorUpdateAfter extends Event<{
|
||||
entity: Entity;
|
||||
entityId: PrimaryFieldType;
|
||||
data: EntityData;
|
||||
}> {
|
||||
static override slug = "mutator-update-after";
|
||||
}
|
||||
export class MutatorDeleteBefore extends Event<{ entity: Entity; entityId: PrimaryFieldType }> {
|
||||
static override slug = "mutator-delete-before";
|
||||
}
|
||||
export class MutatorDeleteAfter extends Event<{
|
||||
entity: Entity;
|
||||
entityId: PrimaryFieldType;
|
||||
data: EntityData;
|
||||
}> {
|
||||
static override slug = "mutator-delete-after";
|
||||
}
|
||||
|
||||
export const MutatorEvents = {
|
||||
MutatorInsertBefore,
|
||||
MutatorInsertAfter,
|
||||
MutatorUpdateBefore,
|
||||
MutatorUpdateAfter,
|
||||
MutatorDeleteBefore,
|
||||
MutatorDeleteAfter
|
||||
};
|
||||
|
||||
export class RepositoryFindOneBefore extends Event<{ entity: Entity; options: RepoQuery }> {
|
||||
static override slug = "repository-find-one-before";
|
||||
}
|
||||
export class RepositoryFindOneAfter extends Event<{
|
||||
entity: Entity;
|
||||
options: RepoQuery;
|
||||
data: EntityData;
|
||||
}> {
|
||||
static override slug = "repository-find-one-after";
|
||||
}
|
||||
|
||||
export class RepositoryFindManyBefore extends Event<{ entity: Entity; options: RepoQuery }> {
|
||||
static override slug = "repository-find-many-before";
|
||||
static another = "one";
|
||||
}
|
||||
export class RepositoryFindManyAfter extends Event<{
|
||||
entity: Entity;
|
||||
options: RepoQuery;
|
||||
data: EntityData;
|
||||
}> {
|
||||
static override slug = "repository-find-many-after";
|
||||
}
|
||||
|
||||
export const RepositoryEvents = {
|
||||
RepositoryFindOneBefore,
|
||||
RepositoryFindOneAfter,
|
||||
RepositoryFindManyBefore,
|
||||
RepositoryFindManyAfter
|
||||
};
|
||||
88
app/src/data/fields/BooleanField.ts
Normal file
88
app/src/data/fields/BooleanField.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { type Static, Type } from "core/utils";
|
||||
import type { EntityManager } from "data";
|
||||
import { TransformPersistFailedException } from "../errors";
|
||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||
|
||||
export const booleanFieldConfigSchema = Type.Composite([
|
||||
Type.Object({
|
||||
default_value: Type.Optional(Type.Boolean({ default: false }))
|
||||
}),
|
||||
baseFieldConfigSchema
|
||||
]);
|
||||
|
||||
export type BooleanFieldConfig = Static<typeof booleanFieldConfigSchema>;
|
||||
|
||||
export class BooleanField<Required extends true | false = false> extends Field<
|
||||
BooleanFieldConfig,
|
||||
boolean,
|
||||
Required
|
||||
> {
|
||||
override readonly type = "boolean";
|
||||
|
||||
protected getSchema() {
|
||||
return booleanFieldConfigSchema;
|
||||
}
|
||||
|
||||
override getValue(value: unknown, context: TRenderContext) {
|
||||
switch (context) {
|
||||
case "table":
|
||||
return value ? "Yes" : "No";
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
schema() {
|
||||
// @todo: potentially use "integer" instead
|
||||
return this.useSchemaHelper("boolean");
|
||||
}
|
||||
|
||||
override getHtmlConfig() {
|
||||
return {
|
||||
...super.getHtmlConfig(),
|
||||
element: "boolean"
|
||||
};
|
||||
}
|
||||
|
||||
override transformRetrieve(value: unknown): boolean | null {
|
||||
//console.log("Boolean:transformRetrieve:value", value);
|
||||
if (typeof value === "undefined" || value === null) {
|
||||
if (this.isRequired()) return false;
|
||||
if (this.hasDefault()) return this.getDefault();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return value === "1";
|
||||
}
|
||||
|
||||
// cast to boolean, as it might be stored as number
|
||||
return !!value;
|
||||
}
|
||||
|
||||
override async transformPersist(
|
||||
val: unknown,
|
||||
em: EntityManager<any>,
|
||||
context: TActionContext
|
||||
): Promise<boolean | undefined> {
|
||||
const value = await super.transformPersist(val, em, context);
|
||||
if (this.nullish(value)) {
|
||||
return this.isRequired() ? Boolean(this.config.default_value) : undefined;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return value !== 0;
|
||||
}
|
||||
|
||||
if (typeof value !== "boolean") {
|
||||
throw TransformPersistFailedException.invalidType(this.name, "boolean", value);
|
||||
}
|
||||
|
||||
return value as boolean;
|
||||
}
|
||||
|
||||
override toJsonSchema() {
|
||||
return this.toSchemaWrapIfRequired(Type.Boolean({ default: this.getDefault() }));
|
||||
}
|
||||
}
|
||||
151
app/src/data/fields/DateField.ts
Normal file
151
app/src/data/fields/DateField.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { type Static, StringEnum, Type, dayjs } from "core/utils";
|
||||
import type { EntityManager } from "../entities";
|
||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||
|
||||
export const dateFieldConfigSchema = Type.Composite(
|
||||
[
|
||||
Type.Object({
|
||||
//default_value: Type.Optional(Type.Date()),
|
||||
type: StringEnum(["date", "datetime", "week"] as const, { default: "date" }),
|
||||
timezone: Type.Optional(Type.String()),
|
||||
min_date: Type.Optional(Type.String()),
|
||||
max_date: Type.Optional(Type.String())
|
||||
}),
|
||||
baseFieldConfigSchema
|
||||
],
|
||||
{
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
|
||||
export type DateFieldConfig = Static<typeof dateFieldConfigSchema>;
|
||||
|
||||
export class DateField<Required extends true | false = false> extends Field<
|
||||
DateFieldConfig,
|
||||
Date,
|
||||
Required
|
||||
> {
|
||||
override readonly type = "date";
|
||||
|
||||
protected getSchema() {
|
||||
return dateFieldConfigSchema;
|
||||
}
|
||||
|
||||
override schema() {
|
||||
const type = this.config.type === "datetime" ? "datetime" : "date";
|
||||
return this.useSchemaHelper(type);
|
||||
}
|
||||
|
||||
override getHtmlConfig() {
|
||||
const htmlType = this.config.type === "datetime" ? "datetime-local" : this.config.type;
|
||||
|
||||
return {
|
||||
...super.getHtmlConfig(),
|
||||
element: "date",
|
||||
props: {
|
||||
type: htmlType
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private parseDateFromString(value: string): Date {
|
||||
//console.log("parseDateFromString", value);
|
||||
if (this.config.type === "week" && value.includes("-W")) {
|
||||
const [year, week] = value.split("-W").map((n) => Number.parseInt(n, 10)) as [
|
||||
number,
|
||||
number
|
||||
];
|
||||
//console.log({ year, week });
|
||||
// @ts-ignore causes errors on build?
|
||||
return dayjs().year(year).week(week).toDate();
|
||||
}
|
||||
|
||||
return new Date(value);
|
||||
}
|
||||
|
||||
override getValue(value: string, context?: TRenderContext): string | undefined {
|
||||
if (value === null || !value) return;
|
||||
//console.log("getValue", { value, context });
|
||||
const date = this.parseDateFromString(value);
|
||||
//console.log("getValue.date", date);
|
||||
|
||||
if (context === "submit") {
|
||||
try {
|
||||
return date.toISOString();
|
||||
} catch (e) {
|
||||
//console.warn("DateField.getValue:value/submit", value, e);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.config.type === "week") {
|
||||
try {
|
||||
return `${date.getFullYear()}-W${dayjs(date).week()}`;
|
||||
} catch (e) {
|
||||
console.warn("error - DateField.getValue:week", value, e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const utc = new Date();
|
||||
const offset = utc.getTimezoneOffset();
|
||||
//console.log("offset", offset);
|
||||
const local = new Date(date.getTime() - offset * 60000);
|
||||
|
||||
return this.formatDate(local);
|
||||
} catch (e) {
|
||||
console.warn("DateField.getValue:value", value);
|
||||
console.warn("DateField.getValue:e", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
formatDate(_date: Date): string {
|
||||
switch (this.config.type) {
|
||||
case "datetime":
|
||||
return _date.toISOString().split(".")[0]!.replace("T", " ");
|
||||
default:
|
||||
return _date.toISOString().split("T")[0]!;
|
||||
/*case "week": {
|
||||
const date = dayjs(_date);
|
||||
return `${date.year()}-W${date.week()}`;
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
override transformRetrieve(_value: string): Date | null {
|
||||
//console.log("transformRetrieve DateField", _value);
|
||||
const value = super.transformRetrieve(_value);
|
||||
if (value === null) return null;
|
||||
|
||||
try {
|
||||
return new Date(value);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
override async transformPersist(
|
||||
_value: any,
|
||||
em: EntityManager<any>,
|
||||
context: TActionContext
|
||||
): Promise<string | undefined> {
|
||||
const value = await super.transformPersist(_value, em, context);
|
||||
if (this.nullish(value)) return value;
|
||||
|
||||
//console.log("transformPersist DateField", value);
|
||||
switch (this.config.type) {
|
||||
case "date":
|
||||
case "week":
|
||||
return new Date(value).toISOString().split("T")[0]!;
|
||||
default:
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
// @todo: check this
|
||||
override toJsonSchema() {
|
||||
return this.toSchemaWrapIfRequired(Type.String({ default: this.getDefault() }));
|
||||
}
|
||||
}
|
||||
153
app/src/data/fields/EnumField.ts
Normal file
153
app/src/data/fields/EnumField.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Const, type Static, StringEnum, StringRecord, Type } from "core/utils";
|
||||
import type { EntityManager } from "data";
|
||||
import { TransformPersistFailedException } from "../errors";
|
||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||
|
||||
export const enumFieldConfigSchema = Type.Composite(
|
||||
[
|
||||
Type.Object({
|
||||
default_value: Type.Optional(Type.String()),
|
||||
options: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Object(
|
||||
{
|
||||
type: Const("strings"),
|
||||
values: Type.Array(Type.String())
|
||||
},
|
||||
{ title: "Strings" }
|
||||
),
|
||||
Type.Object(
|
||||
{
|
||||
type: Const("objects"),
|
||||
values: Type.Array(
|
||||
Type.Object({
|
||||
label: Type.String(),
|
||||
value: Type.String()
|
||||
})
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "Objects",
|
||||
additionalProperties: false
|
||||
}
|
||||
)
|
||||
])
|
||||
)
|
||||
}),
|
||||
baseFieldConfigSchema
|
||||
],
|
||||
{
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
|
||||
export type EnumFieldConfig = Static<typeof enumFieldConfigSchema>;
|
||||
|
||||
export class EnumField<Required extends true | false = false, TypeOverride = string> extends Field<
|
||||
EnumFieldConfig,
|
||||
TypeOverride,
|
||||
Required
|
||||
> {
|
||||
override readonly type = "enum";
|
||||
|
||||
constructor(name: string, config: Partial<EnumFieldConfig>) {
|
||||
super(name, config);
|
||||
|
||||
/*if (this.config.options.values.length === 0) {
|
||||
throw new Error(`Enum field "${this.name}" requires at least one option`);
|
||||
}*/
|
||||
|
||||
if (this.config.default_value && !this.isValidValue(this.config.default_value)) {
|
||||
throw new Error(`Default value "${this.config.default_value}" is not a valid option`);
|
||||
}
|
||||
}
|
||||
|
||||
protected getSchema() {
|
||||
return enumFieldConfigSchema;
|
||||
}
|
||||
|
||||
override schema() {
|
||||
return this.useSchemaHelper("text");
|
||||
}
|
||||
|
||||
getOptions(): { label: string; value: string }[] {
|
||||
const options = this.config?.options ?? { type: "strings", values: [] };
|
||||
|
||||
/*if (options.values?.length === 0) {
|
||||
throw new Error(`Enum field "${this.name}" requires at least one option`);
|
||||
}*/
|
||||
|
||||
if (options.type === "strings") {
|
||||
return options.values?.map((option) => ({ label: option, value: option }));
|
||||
}
|
||||
|
||||
return options?.values;
|
||||
}
|
||||
|
||||
isValidValue(value: string): boolean {
|
||||
const valid_values = this.getOptions().map((option) => option.value);
|
||||
return valid_values.includes(value);
|
||||
}
|
||||
|
||||
override getValue(value: any, context: TRenderContext) {
|
||||
if (!this.isValidValue(value)) {
|
||||
return this.hasDefault() ? this.getDefault() : null;
|
||||
}
|
||||
|
||||
switch (context) {
|
||||
case "table":
|
||||
return this.getOptions().find((option) => option.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform value after retrieving from database
|
||||
* @param value
|
||||
*/
|
||||
override transformRetrieve(value: string | null): string | null {
|
||||
const val = super.transformRetrieve(value);
|
||||
|
||||
if (val === null && this.hasDefault()) {
|
||||
return this.getDefault();
|
||||
}
|
||||
|
||||
if (!this.isValidValue(val)) {
|
||||
return this.hasDefault() ? this.getDefault() : null;
|
||||
}
|
||||
|
||||
return val;
|
||||
}
|
||||
|
||||
override async transformPersist(
|
||||
_value: any,
|
||||
em: EntityManager<any>,
|
||||
context: TActionContext
|
||||
): Promise<string | undefined> {
|
||||
const value = await super.transformPersist(_value, em, context);
|
||||
if (this.nullish(value)) return value;
|
||||
|
||||
if (!this.isValidValue(value)) {
|
||||
throw new TransformPersistFailedException(
|
||||
`Field "${this.name}" must be one of the following values: ${this.getOptions()
|
||||
.map((o) => o.value)
|
||||
.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
override toJsonSchema() {
|
||||
const options = this.config?.options ?? { type: "strings", values: [] };
|
||||
const values =
|
||||
options.values?.map((option) => (typeof option === "string" ? option : option.value)) ??
|
||||
[];
|
||||
return this.toSchemaWrapIfRequired(
|
||||
StringEnum(values, {
|
||||
default: this.getDefault()
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
244
app/src/data/fields/Field.ts
Normal file
244
app/src/data/fields/Field.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import {
|
||||
type Static,
|
||||
StringEnum,
|
||||
type TSchema,
|
||||
Type,
|
||||
TypeInvalidError,
|
||||
parse,
|
||||
snakeToPascalWithSpaces
|
||||
} from "core/utils";
|
||||
import type { ColumnBuilderCallback, ColumnDataType, ColumnDefinitionBuilder } from "kysely";
|
||||
import type { HTMLInputTypeAttribute, InputHTMLAttributes } from "react";
|
||||
import type { EntityManager } from "../entities";
|
||||
import { InvalidFieldConfigException, TransformPersistFailedException } from "../errors";
|
||||
|
||||
export const ActionContext = ["create", "read", "update", "delete"] as const;
|
||||
export type TActionContext = (typeof ActionContext)[number];
|
||||
|
||||
export const RenderContext = ["form", "table", "read", "submit"] as const;
|
||||
export type TRenderContext = (typeof RenderContext)[number];
|
||||
|
||||
const TmpContext = ["create", "read", "update", "delete", "form", "table", "submit"] as const;
|
||||
export type TmpActionAndRenderContext = (typeof TmpContext)[number];
|
||||
|
||||
const DEFAULT_REQUIRED = false;
|
||||
const DEFAULT_FILLABLE = true;
|
||||
const DEFAULT_HIDDEN = false;
|
||||
|
||||
// @todo: add refine functions (e.g. if required, but not fillable, needs default value)
|
||||
export const baseFieldConfigSchema = Type.Object(
|
||||
{
|
||||
label: Type.Optional(Type.String()),
|
||||
description: Type.Optional(Type.String()),
|
||||
required: Type.Optional(Type.Boolean({ default: DEFAULT_REQUIRED })),
|
||||
fillable: Type.Optional(
|
||||
Type.Union(
|
||||
[
|
||||
Type.Boolean({ title: "Boolean", default: DEFAULT_FILLABLE }),
|
||||
Type.Array(StringEnum(ActionContext), { title: "Context", uniqueItems: true })
|
||||
],
|
||||
{
|
||||
default: DEFAULT_FILLABLE
|
||||
}
|
||||
)
|
||||
),
|
||||
hidden: Type.Optional(
|
||||
Type.Union(
|
||||
[
|
||||
Type.Boolean({ title: "Boolean", default: DEFAULT_HIDDEN }),
|
||||
// @todo: tmp workaround
|
||||
Type.Array(StringEnum(TmpContext), { title: "Context", uniqueItems: true })
|
||||
],
|
||||
{
|
||||
default: DEFAULT_HIDDEN
|
||||
}
|
||||
)
|
||||
),
|
||||
// if field is virtual, it will not call transformPersist & transformRetrieve
|
||||
virtual: Type.Optional(Type.Boolean()),
|
||||
default_value: Type.Optional(Type.Any())
|
||||
},
|
||||
{
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
export type BaseFieldConfig = Static<typeof baseFieldConfigSchema>;
|
||||
|
||||
export type SchemaResponse = [string, ColumnDataType, ColumnBuilderCallback] | undefined;
|
||||
|
||||
export abstract class Field<
|
||||
Config extends BaseFieldConfig = BaseFieldConfig,
|
||||
Type = any,
|
||||
Required extends true | false = false
|
||||
> {
|
||||
_required!: Required;
|
||||
_type!: Type;
|
||||
|
||||
/**
|
||||
* Property name that gets persisted on database
|
||||
*/
|
||||
readonly name: string;
|
||||
readonly type: string = "field";
|
||||
readonly config: Config;
|
||||
|
||||
constructor(name: string, config?: Partial<Config>) {
|
||||
this.name = name;
|
||||
this._type;
|
||||
this._required;
|
||||
|
||||
try {
|
||||
this.config = parse(this.getSchema(), config || {}) as Config;
|
||||
} catch (e) {
|
||||
if (e instanceof TypeInvalidError) {
|
||||
throw new InvalidFieldConfigException(this, config, e);
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
getType() {
|
||||
return this.type;
|
||||
}
|
||||
|
||||
protected abstract getSchema(): TSchema;
|
||||
|
||||
protected useSchemaHelper(
|
||||
type: ColumnDataType,
|
||||
builder?: (col: ColumnDefinitionBuilder) => ColumnDefinitionBuilder
|
||||
): SchemaResponse {
|
||||
return [
|
||||
this.name,
|
||||
type,
|
||||
(col: ColumnDefinitionBuilder) => {
|
||||
if (builder) return builder(col);
|
||||
return col;
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Used in SchemaManager.ts
|
||||
* @param em
|
||||
*/
|
||||
abstract schema(em: EntityManager<any>): SchemaResponse;
|
||||
|
||||
hasDefault() {
|
||||
return this.config.default_value !== undefined;
|
||||
}
|
||||
|
||||
getDefault() {
|
||||
return this.config?.default_value;
|
||||
}
|
||||
|
||||
isFillable(context?: TActionContext): boolean {
|
||||
if (Array.isArray(this.config.fillable)) {
|
||||
return context ? this.config.fillable.includes(context) : DEFAULT_FILLABLE;
|
||||
}
|
||||
return !!this.config.fillable;
|
||||
}
|
||||
|
||||
isHidden(context?: TmpActionAndRenderContext): boolean {
|
||||
if (Array.isArray(this.config.hidden)) {
|
||||
return context ? this.config.hidden.includes(context as any) : DEFAULT_HIDDEN;
|
||||
}
|
||||
return this.config.hidden ?? false;
|
||||
}
|
||||
|
||||
isRequired(): boolean {
|
||||
return this.config?.required ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Virtual fields are not persisted or retrieved from database
|
||||
* Used for MediaField, to add specifics about uploads, etc.
|
||||
*/
|
||||
isVirtual(): boolean {
|
||||
return this.config.virtual ?? false;
|
||||
}
|
||||
|
||||
getLabel(): string {
|
||||
return this.config.label ?? snakeToPascalWithSpaces(this.name);
|
||||
}
|
||||
|
||||
getDescription(): string | undefined {
|
||||
return this.config.description;
|
||||
}
|
||||
|
||||
/**
|
||||
* [GET] DB -> field.transformRetrieve -> [sent]
|
||||
* table: form.getValue("table")
|
||||
* form: form.getValue("form") -> modified -> form.getValue("submit") -> [sent]
|
||||
*
|
||||
* [PATCH] body parse json -> field.transformPersist -> [stored]
|
||||
*
|
||||
* @param value
|
||||
* @param context
|
||||
*/
|
||||
getValue(value: any, context?: TRenderContext) {
|
||||
return value;
|
||||
}
|
||||
|
||||
getHtmlConfig(): { element: HTMLInputTypeAttribute | string; props?: InputHTMLAttributes<any> } {
|
||||
return {
|
||||
element: "input",
|
||||
props: { type: "text" }
|
||||
};
|
||||
}
|
||||
|
||||
isValid(value: any, context: TActionContext): boolean {
|
||||
if (value) {
|
||||
return this.isFillable(context);
|
||||
} else {
|
||||
return !this.isRequired();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform value after retrieving from database
|
||||
* @param value
|
||||
*/
|
||||
transformRetrieve(value: any): any {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform value before persisting to database
|
||||
* @param value
|
||||
* @param em EntityManager (optional, for relation fields)
|
||||
*/
|
||||
async transformPersist(
|
||||
value: unknown,
|
||||
em: EntityManager<any>,
|
||||
context: TActionContext
|
||||
): Promise<any> {
|
||||
if (this.nullish(value)) {
|
||||
if (this.isRequired() && !this.hasDefault()) {
|
||||
throw TransformPersistFailedException.required(this.name);
|
||||
}
|
||||
return this.getDefault();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
protected toSchemaWrapIfRequired<Schema extends TSchema>(schema: Schema) {
|
||||
return this.isRequired() ? schema : Type.Optional(schema);
|
||||
}
|
||||
|
||||
protected nullish(value: any) {
|
||||
return value === null || value === undefined;
|
||||
}
|
||||
|
||||
toJsonSchema(): TSchema {
|
||||
return this.toSchemaWrapIfRequired(Type.Any());
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
//name: this.name,
|
||||
type: this.type,
|
||||
config: this.config
|
||||
};
|
||||
}
|
||||
}
|
||||
104
app/src/data/fields/JsonField.ts
Normal file
104
app/src/data/fields/JsonField.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { type Static, Type } from "core/utils";
|
||||
import type { EntityManager } from "data";
|
||||
import { TransformPersistFailedException } from "../errors";
|
||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||
|
||||
export const jsonFieldConfigSchema = Type.Composite([baseFieldConfigSchema, Type.Object({})]);
|
||||
|
||||
export type JsonFieldConfig = Static<typeof jsonFieldConfigSchema>;
|
||||
|
||||
export class JsonField<Required extends true | false = false, TypeOverride = object> extends Field<
|
||||
JsonFieldConfig,
|
||||
TypeOverride,
|
||||
Required
|
||||
> {
|
||||
override readonly type = "json";
|
||||
|
||||
protected getSchema() {
|
||||
return jsonFieldConfigSchema;
|
||||
}
|
||||
|
||||
override schema() {
|
||||
return this.useSchemaHelper("text");
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform value after retrieving from database
|
||||
* @param value
|
||||
*/
|
||||
override transformRetrieve(value: any): any {
|
||||
const val = super.transformRetrieve(value);
|
||||
|
||||
if (val === null && this.hasDefault()) {
|
||||
return this.getDefault();
|
||||
}
|
||||
|
||||
if (this.isSerialized(val)) {
|
||||
return JSON.parse(val);
|
||||
}
|
||||
|
||||
return val;
|
||||
}
|
||||
|
||||
isSerializable(value: any) {
|
||||
try {
|
||||
const stringified = JSON.stringify(value);
|
||||
if (stringified === JSON.stringify(JSON.parse(stringified))) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
isSerialized(value: any) {
|
||||
try {
|
||||
if (typeof value === "string") {
|
||||
return value === JSON.stringify(JSON.parse(value));
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
override getValue(value: any, context: TRenderContext): any {
|
||||
switch (context) {
|
||||
case "form":
|
||||
if (value === null) return "";
|
||||
return JSON.stringify(value, null, 2);
|
||||
case "table":
|
||||
if (value === null) return null;
|
||||
return JSON.stringify(value);
|
||||
case "submit":
|
||||
if (typeof value === "string" && value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
override async transformPersist(
|
||||
_value: any,
|
||||
em: EntityManager<any>,
|
||||
context: TActionContext
|
||||
): Promise<string | undefined> {
|
||||
const value = await super.transformPersist(_value, em, context);
|
||||
//console.log("value", value);
|
||||
if (this.nullish(value)) return value;
|
||||
|
||||
if (!this.isSerializable(value)) {
|
||||
throw new TransformPersistFailedException(
|
||||
`Field "${this.name}" must be serializable to JSON.`
|
||||
);
|
||||
}
|
||||
|
||||
if (this.isSerialized(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
}
|
||||
132
app/src/data/fields/JsonSchemaField.ts
Normal file
132
app/src/data/fields/JsonSchemaField.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { type Schema as JsonSchema, Validator } from "@cfworker/json-schema";
|
||||
import { Default, FromSchema, type Static, Type } from "core/utils";
|
||||
import type { EntityManager } from "data";
|
||||
import { TransformPersistFailedException } from "../errors";
|
||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||
|
||||
export const jsonSchemaFieldConfigSchema = Type.Composite(
|
||||
[
|
||||
Type.Object({
|
||||
schema: Type.Object({}, { default: {} }),
|
||||
ui_schema: Type.Optional(Type.Object({})),
|
||||
default_from_schema: Type.Optional(Type.Boolean())
|
||||
}),
|
||||
baseFieldConfigSchema
|
||||
],
|
||||
{
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
|
||||
export type JsonSchemaFieldConfig = Static<typeof jsonSchemaFieldConfigSchema>;
|
||||
|
||||
export class JsonSchemaField<
|
||||
Required extends true | false = false,
|
||||
TypeOverride = object
|
||||
> extends Field<JsonSchemaFieldConfig, TypeOverride, Required> {
|
||||
override readonly type = "jsonschema";
|
||||
private validator: Validator;
|
||||
|
||||
constructor(name: string, config: Partial<JsonSchemaFieldConfig>) {
|
||||
super(name, config);
|
||||
this.validator = new Validator(this.getJsonSchema());
|
||||
}
|
||||
|
||||
protected getSchema() {
|
||||
return jsonSchemaFieldConfigSchema;
|
||||
}
|
||||
|
||||
override schema() {
|
||||
return this.useSchemaHelper("text");
|
||||
}
|
||||
|
||||
getJsonSchema(): JsonSchema {
|
||||
return this.config?.schema as JsonSchema;
|
||||
}
|
||||
|
||||
getJsonUiSchema() {
|
||||
return this.config.ui_schema ?? {};
|
||||
}
|
||||
|
||||
override isValid(value: any, context: TActionContext = "update"): boolean {
|
||||
const parentValid = super.isValid(value, context);
|
||||
//console.log("jsonSchemaField:isValid", this.getJsonSchema(), this.name, value, parentValid);
|
||||
|
||||
if (parentValid) {
|
||||
// already checked in parent
|
||||
if (!value || typeof value !== "object") {
|
||||
//console.log("jsonschema:valid: not checking", this.name, value, context);
|
||||
return true;
|
||||
}
|
||||
|
||||
const result = this.validator.validate(value);
|
||||
//console.log("jsonschema:errors", this.name, result.errors);
|
||||
return result.valid;
|
||||
} else {
|
||||
//console.log("jsonschema:invalid", this.name, value, context);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
override getValue(value: any, context: TRenderContext): any {
|
||||
switch (context) {
|
||||
case "form":
|
||||
if (value === null) return "";
|
||||
return value;
|
||||
case "table":
|
||||
if (value === null) return null;
|
||||
return value;
|
||||
case "submit":
|
||||
break;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
override transformRetrieve(value: any): any {
|
||||
const val = super.transformRetrieve(value);
|
||||
|
||||
if (val === null) {
|
||||
if (this.config.default_from_schema) {
|
||||
try {
|
||||
return Default(FromSchema(this.getJsonSchema()), {});
|
||||
} catch (e) {
|
||||
//console.error("jsonschema:transformRetrieve", e);
|
||||
return null;
|
||||
}
|
||||
} else if (this.hasDefault()) {
|
||||
return this.getDefault();
|
||||
}
|
||||
}
|
||||
|
||||
return val;
|
||||
}
|
||||
|
||||
override async transformPersist(
|
||||
_value: any,
|
||||
em: EntityManager<any>,
|
||||
context: TActionContext
|
||||
): Promise<string | undefined> {
|
||||
const value = await super.transformPersist(_value, em, context);
|
||||
if (this.nullish(value)) return value;
|
||||
|
||||
if (!this.isValid(value)) {
|
||||
throw new TransformPersistFailedException(this.name, value);
|
||||
}
|
||||
|
||||
if (!value || typeof value !== "object") return this.getDefault();
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
override toJsonSchema() {
|
||||
const schema = this.getJsonSchema() ?? { type: "object" };
|
||||
return this.toSchemaWrapIfRequired(
|
||||
FromSchema({
|
||||
default: this.getDefault(),
|
||||
...schema
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
100
app/src/data/fields/NumberField.ts
Normal file
100
app/src/data/fields/NumberField.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { type Static, Type } from "core/utils";
|
||||
import type { EntityManager } from "data";
|
||||
import { TransformPersistFailedException } from "../errors";
|
||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||
|
||||
export const numberFieldConfigSchema = Type.Composite(
|
||||
[
|
||||
Type.Object({
|
||||
default_value: Type.Optional(Type.Number()),
|
||||
minimum: Type.Optional(Type.Number()),
|
||||
maximum: Type.Optional(Type.Number()),
|
||||
exclusiveMinimum: Type.Optional(Type.Number()),
|
||||
exclusiveMaximum: Type.Optional(Type.Number()),
|
||||
multipleOf: Type.Optional(Type.Number())
|
||||
}),
|
||||
baseFieldConfigSchema
|
||||
],
|
||||
{
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
|
||||
export type NumberFieldConfig = Static<typeof numberFieldConfigSchema>;
|
||||
|
||||
export class NumberField<Required extends true | false = false> extends Field<
|
||||
NumberFieldConfig,
|
||||
number,
|
||||
Required
|
||||
> {
|
||||
override readonly type = "number";
|
||||
|
||||
protected getSchema() {
|
||||
return numberFieldConfigSchema;
|
||||
}
|
||||
|
||||
override getHtmlConfig() {
|
||||
return {
|
||||
element: "input",
|
||||
props: {
|
||||
type: "number",
|
||||
pattern: "d*",
|
||||
inputMode: "numeric"
|
||||
} as any // @todo: react expects "inputMode", but type dictates "inputmode"
|
||||
};
|
||||
}
|
||||
|
||||
schema() {
|
||||
return this.useSchemaHelper("integer");
|
||||
}
|
||||
|
||||
override getValue(value: any, context?: TRenderContext): any {
|
||||
if (typeof value === "undefined" || value === null) return null;
|
||||
|
||||
switch (context) {
|
||||
case "submit":
|
||||
return Number.parseInt(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
override async transformPersist(
|
||||
_value: unknown,
|
||||
em: EntityManager<any>,
|
||||
context: TActionContext
|
||||
): Promise<number | undefined> {
|
||||
const value = await super.transformPersist(_value, em, context);
|
||||
|
||||
if (!this.nullish(value) && typeof value !== "number") {
|
||||
throw TransformPersistFailedException.invalidType(this.name, "number", value);
|
||||
}
|
||||
|
||||
if (this.config.maximum && (value as number) > this.config.maximum) {
|
||||
throw new TransformPersistFailedException(
|
||||
`Field "${this.name}" cannot be greater than ${this.config.maximum}`
|
||||
);
|
||||
}
|
||||
|
||||
if (this.config.minimum && (value as number) < this.config.minimum) {
|
||||
throw new TransformPersistFailedException(
|
||||
`Field "${this.name}" cannot be less than ${this.config.minimum}`
|
||||
);
|
||||
}
|
||||
|
||||
return value as number;
|
||||
}
|
||||
|
||||
override toJsonSchema() {
|
||||
return this.toSchemaWrapIfRequired(
|
||||
Type.Number({
|
||||
default: this.getDefault(),
|
||||
minimum: this.config?.minimum,
|
||||
maximum: this.config?.maximum,
|
||||
exclusiveMinimum: this.config?.exclusiveMinimum,
|
||||
exclusiveMaximum: this.config?.exclusiveMaximum,
|
||||
multipleOf: this.config?.multipleOf
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
46
app/src/data/fields/PrimaryField.ts
Normal file
46
app/src/data/fields/PrimaryField.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { config } from "core";
|
||||
import { type Static, Type } from "core/utils";
|
||||
import { Field, baseFieldConfigSchema } from "./Field";
|
||||
|
||||
export const primaryFieldConfigSchema = Type.Composite([
|
||||
Type.Omit(baseFieldConfigSchema, ["required"]),
|
||||
Type.Object({
|
||||
required: Type.Optional(Type.Literal(false))
|
||||
})
|
||||
]);
|
||||
|
||||
export type PrimaryFieldConfig = Static<typeof primaryFieldConfigSchema>;
|
||||
|
||||
export class PrimaryField<Required extends true | false = false> extends Field<
|
||||
PrimaryFieldConfig,
|
||||
string,
|
||||
Required
|
||||
> {
|
||||
override readonly type = "primary";
|
||||
|
||||
constructor(name: string = config.data.default_primary_field) {
|
||||
super(name, { fillable: false, required: false });
|
||||
}
|
||||
|
||||
override isRequired(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected getSchema() {
|
||||
return baseFieldConfigSchema;
|
||||
}
|
||||
|
||||
schema() {
|
||||
return this.useSchemaHelper("integer", (col) => {
|
||||
return col.primaryKey().notNull().autoIncrement();
|
||||
});
|
||||
}
|
||||
|
||||
override async transformPersist(value: any): Promise<number> {
|
||||
throw new Error("This function should not be called");
|
||||
}
|
||||
|
||||
override toJsonSchema() {
|
||||
return this.toSchemaWrapIfRequired(Type.Number({ writeOnly: undefined }));
|
||||
}
|
||||
}
|
||||
120
app/src/data/fields/TextField.ts
Normal file
120
app/src/data/fields/TextField.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { type Static, Type } from "core/utils";
|
||||
import type { EntityManager } from "data";
|
||||
import { TransformPersistFailedException } from "../errors";
|
||||
import { Field, type TActionContext, baseFieldConfigSchema } from "./Field";
|
||||
|
||||
export const textFieldConfigSchema = Type.Composite(
|
||||
[
|
||||
Type.Object({
|
||||
default_value: Type.Optional(Type.String()),
|
||||
minLength: Type.Optional(Type.Number()),
|
||||
maxLength: Type.Optional(Type.Number()),
|
||||
pattern: Type.Optional(Type.String()),
|
||||
html_config: Type.Optional(
|
||||
Type.Object({
|
||||
element: Type.Optional(Type.String({ default: "input" })),
|
||||
props: Type.Optional(
|
||||
Type.Object(
|
||||
{},
|
||||
{
|
||||
additionalProperties: Type.Union([
|
||||
Type.String({ title: "String" }),
|
||||
Type.Number({ title: "Number" })
|
||||
])
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
}),
|
||||
baseFieldConfigSchema
|
||||
],
|
||||
{
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
|
||||
export type TextFieldConfig = Static<typeof textFieldConfigSchema>;
|
||||
|
||||
export class TextField<Required extends true | false = false> extends Field<
|
||||
TextFieldConfig,
|
||||
string,
|
||||
Required
|
||||
> {
|
||||
override readonly type = "text";
|
||||
|
||||
protected getSchema() {
|
||||
return textFieldConfigSchema;
|
||||
}
|
||||
|
||||
override schema() {
|
||||
return this.useSchemaHelper("text");
|
||||
}
|
||||
|
||||
override getHtmlConfig() {
|
||||
if (this.config.html_config) {
|
||||
return this.config.html_config as any;
|
||||
}
|
||||
|
||||
return super.getHtmlConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform value after retrieving from database
|
||||
* @param value
|
||||
*/
|
||||
override transformRetrieve(value: string): string | null {
|
||||
const val = super.transformRetrieve(value);
|
||||
|
||||
// @todo: now sure about these two
|
||||
if (this.config.maxLength) {
|
||||
return val.substring(0, this.config.maxLength);
|
||||
}
|
||||
|
||||
if (this.isRequired()) {
|
||||
return val ? val.toString() : "";
|
||||
}
|
||||
|
||||
return val;
|
||||
}
|
||||
|
||||
override async transformPersist(
|
||||
_value: any,
|
||||
em: EntityManager<any>,
|
||||
context: TActionContext
|
||||
): Promise<string | undefined> {
|
||||
let value = await super.transformPersist(_value, em, context);
|
||||
|
||||
if (this.nullish(value)) return value;
|
||||
|
||||
// transform to string
|
||||
if (value !== null && typeof value !== "string") {
|
||||
value = String(value);
|
||||
}
|
||||
|
||||
if (this.config.maxLength && value?.length > this.config.maxLength) {
|
||||
throw new TransformPersistFailedException(
|
||||
`Field "${this.name}" must be at most ${this.config.maxLength} character(s)`
|
||||
);
|
||||
}
|
||||
|
||||
if (this.config.minLength && value?.length < this.config.minLength) {
|
||||
throw new TransformPersistFailedException(
|
||||
`Field "${this.name}" must be at least ${this.config.minLength} character(s)`
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
override toJsonSchema() {
|
||||
return this.toSchemaWrapIfRequired(
|
||||
Type.String({
|
||||
default: this.getDefault(),
|
||||
minLength: this.config?.minLength,
|
||||
maxLength: this.config?.maxLength,
|
||||
pattern: this.config?.pattern
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
32
app/src/data/fields/VirtualField.ts
Normal file
32
app/src/data/fields/VirtualField.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { type Static, Type } from "core/utils";
|
||||
import { Field, baseFieldConfigSchema } from "./Field";
|
||||
|
||||
export const virtualFieldConfigSchema = Type.Composite([baseFieldConfigSchema, Type.Object({})]);
|
||||
|
||||
export type VirtualFieldConfig = Static<typeof virtualFieldConfigSchema>;
|
||||
|
||||
export class VirtualField extends Field<VirtualFieldConfig> {
|
||||
override readonly type = "virtual";
|
||||
|
||||
constructor(name: string, config?: Partial<VirtualFieldConfig>) {
|
||||
// field must be virtual, as it doesn't store a reference to the entity
|
||||
super(name, { ...config, fillable: false, virtual: true });
|
||||
}
|
||||
|
||||
protected getSchema() {
|
||||
return virtualFieldConfigSchema;
|
||||
}
|
||||
|
||||
schema() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
override toJsonSchema() {
|
||||
return this.toSchemaWrapIfRequired(
|
||||
Type.Any({
|
||||
default: this.getDefault(),
|
||||
readOnly: true
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
55
app/src/data/fields/index.ts
Normal file
55
app/src/data/fields/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { BooleanField, type BooleanFieldConfig, booleanFieldConfigSchema } from "./BooleanField";
|
||||
import { DateField, type DateFieldConfig, dateFieldConfigSchema } from "./DateField";
|
||||
import { EnumField, type EnumFieldConfig, enumFieldConfigSchema } from "./EnumField";
|
||||
import { JsonField, type JsonFieldConfig, jsonFieldConfigSchema } from "./JsonField";
|
||||
import {
|
||||
JsonSchemaField,
|
||||
type JsonSchemaFieldConfig,
|
||||
jsonSchemaFieldConfigSchema
|
||||
} from "./JsonSchemaField";
|
||||
import { NumberField, type NumberFieldConfig, numberFieldConfigSchema } from "./NumberField";
|
||||
import { PrimaryField, type PrimaryFieldConfig, primaryFieldConfigSchema } from "./PrimaryField";
|
||||
import { TextField, type TextFieldConfig, textFieldConfigSchema } from "./TextField";
|
||||
|
||||
export {
|
||||
PrimaryField,
|
||||
primaryFieldConfigSchema,
|
||||
type PrimaryFieldConfig,
|
||||
BooleanField,
|
||||
booleanFieldConfigSchema,
|
||||
type BooleanFieldConfig,
|
||||
DateField,
|
||||
dateFieldConfigSchema,
|
||||
type DateFieldConfig,
|
||||
EnumField,
|
||||
enumFieldConfigSchema,
|
||||
type EnumFieldConfig,
|
||||
JsonField,
|
||||
jsonFieldConfigSchema,
|
||||
type JsonFieldConfig,
|
||||
JsonSchemaField,
|
||||
jsonSchemaFieldConfigSchema,
|
||||
type JsonSchemaFieldConfig,
|
||||
NumberField,
|
||||
numberFieldConfigSchema,
|
||||
type NumberFieldConfig,
|
||||
TextField,
|
||||
textFieldConfigSchema,
|
||||
type TextFieldConfig
|
||||
};
|
||||
|
||||
export * from "./Field";
|
||||
export * from "./PrimaryField";
|
||||
export * from "./VirtualField";
|
||||
export * from "./indices/EntityIndex";
|
||||
|
||||
export const FieldClassMap = {
|
||||
primary: { schema: primaryFieldConfigSchema, field: PrimaryField },
|
||||
text: { schema: textFieldConfigSchema, field: TextField },
|
||||
number: { schema: numberFieldConfigSchema, field: NumberField },
|
||||
boolean: { schema: booleanFieldConfigSchema, field: BooleanField },
|
||||
date: { schema: dateFieldConfigSchema, field: DateField },
|
||||
enum: { schema: enumFieldConfigSchema, field: EnumField },
|
||||
json: { schema: jsonFieldConfigSchema, field: JsonField },
|
||||
jsonschema: { schema: jsonSchemaFieldConfigSchema, field: JsonSchemaField }
|
||||
} as const;
|
||||
46
app/src/data/fields/indices/EntityIndex.ts
Normal file
46
app/src/data/fields/indices/EntityIndex.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Entity } from "../../entities";
|
||||
import { Field } from "../Field";
|
||||
|
||||
export class EntityIndex {
|
||||
constructor(
|
||||
public entity: Entity,
|
||||
public fields: Field[],
|
||||
public unique: boolean = false,
|
||||
public name?: string
|
||||
) {
|
||||
if (fields.length === 0) {
|
||||
throw new Error("Indices must contain at least one field");
|
||||
}
|
||||
if (fields.some((f) => !(f instanceof Field))) {
|
||||
throw new Error("All fields must be instances of Field");
|
||||
}
|
||||
|
||||
if (unique) {
|
||||
const firstRequired = fields[0]?.isRequired();
|
||||
if (!firstRequired) {
|
||||
throw new Error(
|
||||
`Unique indices must have first field as required: ${fields
|
||||
.map((f) => f.name)
|
||||
.join(", ")}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
this.name = [
|
||||
unique ? "idx_unique" : "idx",
|
||||
entity.name,
|
||||
...fields.map((f) => f.name)
|
||||
].join("_");
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
entity: this.entity.name,
|
||||
fields: this.fields.map((f) => f.name),
|
||||
//name: this.name,
|
||||
unique: this.unique
|
||||
};
|
||||
}
|
||||
}
|
||||
48
app/src/data/helper.ts
Normal file
48
app/src/data/helper.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { EntityData, Field } from "data";
|
||||
import { transform } from "lodash-es";
|
||||
|
||||
export function getDefaultValues(fields: Field[], data: EntityData): EntityData {
|
||||
return transform(
|
||||
fields,
|
||||
(acc, field) => {
|
||||
// form fields don't like "null" or "undefined", so return empty string
|
||||
acc[field.name] = field.getValue(data?.[field.name], "form") ?? "";
|
||||
},
|
||||
{} as EntityData
|
||||
);
|
||||
}
|
||||
|
||||
export function getChangeSet(
|
||||
action: string,
|
||||
formData: EntityData,
|
||||
data: EntityData,
|
||||
fields: Field[]
|
||||
): EntityData {
|
||||
return transform(
|
||||
formData,
|
||||
(acc, _value, key) => {
|
||||
const field = fields.find((f) => f.name === key);
|
||||
// @todo: filtering virtual here, need to check (because of media)
|
||||
if (!field || field.isVirtual()) return;
|
||||
const value = _value === "" ? null : _value;
|
||||
|
||||
const newValue = field.getValue(value, "submit");
|
||||
// @todo: add typing for "action"
|
||||
if (action === "create" || newValue !== data[key]) {
|
||||
acc[key] = newValue;
|
||||
console.log("changed", {
|
||||
key,
|
||||
value,
|
||||
valueType: typeof value,
|
||||
prev: data[key],
|
||||
newValue,
|
||||
new: value,
|
||||
sent: acc[key]
|
||||
});
|
||||
} else {
|
||||
//console.log("no change", key, value, data[key]);
|
||||
}
|
||||
},
|
||||
{} as typeof formData
|
||||
);
|
||||
}
|
||||
28
app/src/data/index.ts
Normal file
28
app/src/data/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { MutatorEvents, RepositoryEvents } from "./events";
|
||||
|
||||
export * from "./fields";
|
||||
export * from "./entities";
|
||||
export * from "./relations";
|
||||
export * from "./schema/SchemaManager";
|
||||
|
||||
export {
|
||||
type RepoQuery,
|
||||
defaultQuerySchema,
|
||||
querySchema,
|
||||
whereSchema
|
||||
} from "./server/data-query-impl";
|
||||
|
||||
export { whereRepoSchema as deprecated__whereRepoSchema } from "./server/query";
|
||||
|
||||
export { Connection } from "./connection/Connection";
|
||||
export { LibsqlConnection, type LibSqlCredentials } from "./connection/LibsqlConnection";
|
||||
export { SqliteConnection } from "./connection/SqliteConnection";
|
||||
export { SqliteLocalConnection } from "./connection/SqliteLocalConnection";
|
||||
|
||||
export const DatabaseEvents = {
|
||||
...MutatorEvents,
|
||||
...RepositoryEvents
|
||||
};
|
||||
export { MutatorEvents, RepositoryEvents };
|
||||
|
||||
export * as DataPermissions from "./permissions";
|
||||
9
app/src/data/permissions/index.ts
Normal file
9
app/src/data/permissions/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Permission } from "core";
|
||||
|
||||
export const entityRead = new Permission("data.entity.read");
|
||||
export const entityCreate = new Permission("data.entity.create");
|
||||
export const entityUpdate = new Permission("data.entity.update");
|
||||
export const entityDelete = new Permission("data.entity.delete");
|
||||
export const databaseSync = new Permission("data.database.sync");
|
||||
export const rawQuery = new Permission("data.raw.query");
|
||||
export const rawMutate = new Permission("data.raw.mutate");
|
||||
36
app/src/data/plugins/DeserializeJsonValuesPlugin.ts
Normal file
36
app/src/data/plugins/DeserializeJsonValuesPlugin.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type {
|
||||
KyselyPlugin,
|
||||
PluginTransformQueryArgs,
|
||||
PluginTransformResultArgs,
|
||||
QueryResult,
|
||||
RootOperationNode,
|
||||
UnknownRow,
|
||||
} from "kysely";
|
||||
|
||||
type KeyValueObject = { [key: string]: any };
|
||||
|
||||
export class DeserializeJsonValuesPlugin implements KyselyPlugin {
|
||||
transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
|
||||
return args.node;
|
||||
}
|
||||
transformResult(
|
||||
args: PluginTransformResultArgs
|
||||
): Promise<QueryResult<UnknownRow>> {
|
||||
return Promise.resolve({
|
||||
...args.result,
|
||||
rows: args.result.rows.map((row: KeyValueObject) => {
|
||||
const result: KeyValueObject = {};
|
||||
for (const key in row) {
|
||||
try {
|
||||
// Attempt to parse the value as JSON
|
||||
result[key] = JSON.parse(row[key]);
|
||||
} catch (error) {
|
||||
// If parsing fails, keep the original value
|
||||
result[key] = row[key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
31
app/src/data/plugins/FilterNumericKeysPlugin.ts
Normal file
31
app/src/data/plugins/FilterNumericKeysPlugin.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type {
|
||||
KyselyPlugin,
|
||||
PluginTransformQueryArgs,
|
||||
PluginTransformResultArgs,
|
||||
QueryResult,
|
||||
RootOperationNode,
|
||||
UnknownRow,
|
||||
} from "kysely";
|
||||
|
||||
type KeyValueObject = { [key: string]: any };
|
||||
|
||||
export class FilterNumericKeysPlugin implements KyselyPlugin {
|
||||
transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
|
||||
return args.node;
|
||||
}
|
||||
transformResult(args: PluginTransformResultArgs): Promise<QueryResult<UnknownRow>> {
|
||||
return Promise.resolve({
|
||||
...args.result,
|
||||
rows: args.result.rows.map((row: KeyValueObject) => {
|
||||
const filteredObj: KeyValueObject = {};
|
||||
for (const key in row) {
|
||||
if (Number.isNaN(+key)) {
|
||||
// Check if the key is not a number
|
||||
filteredObj[key] = row[key];
|
||||
}
|
||||
}
|
||||
return filteredObj;
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
23
app/src/data/plugins/KyselyPluginRunner.ts
Normal file
23
app/src/data/plugins/KyselyPluginRunner.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { KyselyPlugin, UnknownRow } from "kysely";
|
||||
|
||||
// @todo: add test
|
||||
export class KyselyPluginRunner {
|
||||
protected plugins: Set<KyselyPlugin>;
|
||||
|
||||
constructor(plugins: KyselyPlugin[] = []) {
|
||||
this.plugins = new Set(plugins);
|
||||
}
|
||||
|
||||
async transformResultRows<T>(rows: T[]): Promise<T[]> {
|
||||
let copy = rows;
|
||||
for (const plugin of this.plugins) {
|
||||
const res = await plugin.transformResult({
|
||||
queryId: "1" as any,
|
||||
result: { rows: copy as UnknownRow[] },
|
||||
});
|
||||
copy = res.rows as T[];
|
||||
}
|
||||
|
||||
return copy as T[];
|
||||
}
|
||||
}
|
||||
295
app/src/data/prototype/index.ts
Normal file
295
app/src/data/prototype/index.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import {
|
||||
BooleanField,
|
||||
type BooleanFieldConfig,
|
||||
DateField,
|
||||
type DateFieldConfig,
|
||||
Entity,
|
||||
type EntityConfig,
|
||||
EnumField,
|
||||
type EnumFieldConfig,
|
||||
type Field,
|
||||
JsonField,
|
||||
type JsonFieldConfig,
|
||||
JsonSchemaField,
|
||||
type JsonSchemaFieldConfig,
|
||||
ManyToManyRelation,
|
||||
type ManyToManyRelationConfig,
|
||||
ManyToOneRelation,
|
||||
type ManyToOneRelationConfig,
|
||||
NumberField,
|
||||
type NumberFieldConfig,
|
||||
OneToOneRelation,
|
||||
type OneToOneRelationConfig,
|
||||
PolymorphicRelation,
|
||||
type PolymorphicRelationConfig,
|
||||
type TEntityType,
|
||||
TextField,
|
||||
type TextFieldConfig
|
||||
} from "data";
|
||||
import type { Generated } from "kysely";
|
||||
import { MediaField, type MediaFieldConfig, type MediaItem } from "media/MediaField";
|
||||
|
||||
type Options<Config = any> = {
|
||||
entity: { name: string; fields: Record<string, Field<any, any, any>> };
|
||||
field_name: string;
|
||||
config: Config;
|
||||
is_required: boolean;
|
||||
};
|
||||
|
||||
const FieldMap = {
|
||||
text: (o: Options) => new TextField(o.field_name, { ...o.config, required: o.is_required }),
|
||||
number: (o: Options) => new NumberField(o.field_name, { ...o.config, required: o.is_required }),
|
||||
date: (o: Options) => new DateField(o.field_name, { ...o.config, required: o.is_required }),
|
||||
datetime: (o: Options) => new DateField(o.field_name, { ...o.config, required: o.is_required }),
|
||||
boolean: (o: Options) =>
|
||||
new BooleanField(o.field_name, { ...o.config, required: o.is_required }),
|
||||
enumm: (o: Options) => new EnumField(o.field_name, { ...o.config, required: o.is_required }),
|
||||
json: (o: Options) => new JsonField(o.field_name, { ...o.config, required: o.is_required }),
|
||||
jsonSchema: (o: Options) =>
|
||||
new JsonSchemaField(o.field_name, { ...o.config, required: o.is_required }),
|
||||
media: (o: Options) =>
|
||||
new MediaField(o.field_name, { ...o.config, entity: o.entity.name, required: o.is_required }),
|
||||
medium: (o: Options) =>
|
||||
new MediaField(o.field_name, { ...o.config, entity: o.entity.name, required: o.is_required })
|
||||
} as const;
|
||||
type TFieldType = keyof typeof FieldMap;
|
||||
|
||||
export function text(
|
||||
config?: Omit<TextFieldConfig, "required">
|
||||
): TextField<false> & { required: () => TextField<true> } {
|
||||
return new FieldPrototype("text", config, false) as any;
|
||||
}
|
||||
export function number(
|
||||
config?: Omit<NumberFieldConfig, "required">
|
||||
): NumberField<false> & { required: () => NumberField<true> } {
|
||||
return new FieldPrototype("number", config, false) as any;
|
||||
}
|
||||
export function date(
|
||||
config?: Omit<DateFieldConfig, "required" | "type">
|
||||
): DateField<false> & { required: () => DateField<true> } {
|
||||
return new FieldPrototype("date", { ...config, type: "date" }, false) as any;
|
||||
}
|
||||
export function datetime(
|
||||
config?: Omit<DateFieldConfig, "required" | "type">
|
||||
): DateField<false> & { required: () => DateField<true> } {
|
||||
return new FieldPrototype("date", { ...config, type: "datetime" }, false) as any;
|
||||
}
|
||||
export function week(
|
||||
config?: Omit<DateFieldConfig, "required" | "type">
|
||||
): DateField<false> & { required: () => DateField<true> } {
|
||||
return new FieldPrototype("date", { ...config, type: "week" }, false) as any;
|
||||
}
|
||||
export function boolean(
|
||||
config?: Omit<BooleanFieldConfig, "required">
|
||||
): BooleanField<false> & { required: () => BooleanField<true> } {
|
||||
return new FieldPrototype("boolean", config, false) as any;
|
||||
}
|
||||
export function enumm<TypeOverride = string>(
|
||||
config?: Omit<EnumFieldConfig, "required" | "options"> & {
|
||||
enum: string[] | { label: string; value: string }[];
|
||||
}
|
||||
): EnumField<false, TypeOverride> & {
|
||||
required: () => EnumField<true, TypeOverride>;
|
||||
} {
|
||||
const type = typeof config?.enum?.[0] !== "string" ? "objects" : "strings";
|
||||
const actual_config = {
|
||||
options: {
|
||||
type,
|
||||
values: config?.enum ?? []
|
||||
}
|
||||
};
|
||||
return new FieldPrototype("enumm", actual_config, false) as any;
|
||||
}
|
||||
export function json<TypeOverride = object>(
|
||||
config?: Omit<JsonFieldConfig, "required">
|
||||
): JsonField<false, TypeOverride> & { required: () => JsonField<true, TypeOverride> } {
|
||||
return new FieldPrototype("json", config, false) as any;
|
||||
}
|
||||
export function jsonSchema<TypeOverride = object>(
|
||||
config?: Omit<JsonSchemaFieldConfig, "required">
|
||||
): JsonField<false, TypeOverride> & { required: () => JsonSchemaField<true, TypeOverride> } {
|
||||
return new FieldPrototype("jsonSchema", config, false) as any;
|
||||
}
|
||||
export function media(config?: Omit<MediaFieldConfig, "entity">): MediaField<false> {
|
||||
return new FieldPrototype("media", config, false) as any;
|
||||
}
|
||||
export function medium(
|
||||
config?: Omit<MediaFieldConfig, "required" | "entity" | "max_items">
|
||||
): MediaField<false, MediaItem> {
|
||||
return new FieldPrototype("media", { ...config, max_items: 1 }, false) as any;
|
||||
}
|
||||
export function make<Actual extends Field<any, any>>(name: string, field: Actual): Actual {
|
||||
if (field instanceof FieldPrototype) {
|
||||
return field.make(name) as Actual;
|
||||
}
|
||||
throw new Error("Invalid field");
|
||||
}
|
||||
|
||||
export class FieldPrototype {
|
||||
constructor(
|
||||
public type: TFieldType,
|
||||
public config: any,
|
||||
public is_required: boolean
|
||||
) {}
|
||||
|
||||
required() {
|
||||
this.is_required = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
getField(o: Options): Field {
|
||||
if (!FieldMap[this.type]) {
|
||||
throw new Error(`Unknown field type: ${this.type}`);
|
||||
}
|
||||
try {
|
||||
return FieldMap[this.type](o) as unknown as Field;
|
||||
} catch (e) {
|
||||
throw new Error(`Faild to construct field "${this.type}": ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
make(field_name: string): Field {
|
||||
if (!FieldMap[this.type]) {
|
||||
throw new Error(`Unknown field type: ${this.type}`);
|
||||
}
|
||||
try {
|
||||
return FieldMap[this.type]({
|
||||
entity: { name: "unknown", fields: {} },
|
||||
field_name,
|
||||
config: this.config,
|
||||
is_required: this.is_required
|
||||
}) as unknown as Field;
|
||||
} catch (e) {
|
||||
throw new Error(`Faild to construct field "${this.type}": ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//type Entity<Fields extends Record<string, Field<any, any>> = {}> = { name: string; fields: Fields };
|
||||
|
||||
export function entity<
|
||||
EntityName extends string,
|
||||
Fields extends Record<string, Field<any, any, any>>
|
||||
>(
|
||||
name: EntityName,
|
||||
fields: Fields,
|
||||
config?: EntityConfig,
|
||||
type?: TEntityType
|
||||
): Entity<EntityName, Fields> {
|
||||
const _fields: Field[] = [];
|
||||
for (const [field_name, field] of Object.entries(fields)) {
|
||||
const f = field as unknown as FieldPrototype;
|
||||
const o: Options = {
|
||||
entity: { name, fields },
|
||||
field_name,
|
||||
config: f.config,
|
||||
is_required: f.is_required
|
||||
};
|
||||
_fields.push(f.getField(o));
|
||||
}
|
||||
return new Entity(name, _fields, config, type);
|
||||
}
|
||||
|
||||
export function relation<Local extends Entity>(local: Local) {
|
||||
return {
|
||||
manyToOne: <Foreign extends Entity>(foreign: Foreign, config?: ManyToOneRelationConfig) => {
|
||||
return new ManyToOneRelation(local, foreign, config);
|
||||
},
|
||||
oneToOne: <Foreign extends Entity>(foreign: Foreign, config?: OneToOneRelationConfig) => {
|
||||
return new OneToOneRelation(local, foreign, config);
|
||||
},
|
||||
manyToMany: <Foreign extends Entity>(
|
||||
foreign: Foreign,
|
||||
config?: ManyToManyRelationConfig,
|
||||
additionalFields?: Record<string, Field<any, any, any>>
|
||||
) => {
|
||||
const add_fields: Field[] = [];
|
||||
if (additionalFields) {
|
||||
const fields = additionalFields!;
|
||||
const _fields: Field[] = [];
|
||||
const entity_name =
|
||||
config?.connectionTable ?? ManyToManyRelation.defaultConnectionTable(local, foreign);
|
||||
for (const [field_name, field] of Object.entries(additionalFields)) {
|
||||
const f = field as unknown as FieldPrototype;
|
||||
const o: Options = {
|
||||
entity: { name: entity_name, fields },
|
||||
field_name,
|
||||
config: f.config,
|
||||
is_required: f.is_required
|
||||
};
|
||||
_fields.push(f.getField(o));
|
||||
}
|
||||
add_fields.push(_fields as any);
|
||||
}
|
||||
|
||||
return new ManyToManyRelation(local, foreign, config as any, add_fields);
|
||||
},
|
||||
polyToOne: <Foreign extends Entity>(
|
||||
foreign: Foreign,
|
||||
config?: Omit<PolymorphicRelationConfig, "targetCardinality">
|
||||
) => {
|
||||
return new PolymorphicRelation(local, foreign, { ...config, targetCardinality: 1 });
|
||||
},
|
||||
polyToMany: <Foreign extends Entity>(
|
||||
foreign: Foreign,
|
||||
config?: PolymorphicRelationConfig
|
||||
) => {
|
||||
return new PolymorphicRelation(local, foreign, config);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
type InferEntityFields<T> = T extends Entity<infer _N, infer Fields>
|
||||
? {
|
||||
[K in keyof Fields]: Fields[K] extends { _type: infer Type; _required: infer Required }
|
||||
? Required extends true
|
||||
? Type
|
||||
: Type | undefined
|
||||
: never;
|
||||
}
|
||||
: never;
|
||||
|
||||
export type InferFields<Fields> = Fields extends Record<string, Field<any, any, any>>
|
||||
? {
|
||||
[K in keyof Fields]: Fields[K] extends { _type: infer Type; _required: infer Required }
|
||||
? Required extends true
|
||||
? Type
|
||||
: Type | undefined
|
||||
: never;
|
||||
}
|
||||
: never;
|
||||
|
||||
type Prettify<T> = {
|
||||
[K in keyof T]: T[K];
|
||||
};
|
||||
export type Simplify<T> = { [KeyType in keyof T]: T[KeyType] } & {};
|
||||
|
||||
// from https://github.com/type-challenges/type-challenges/issues/28200
|
||||
type Merge<T> = {
|
||||
[K in keyof T]: T[K];
|
||||
};
|
||||
type OptionalUndefined<
|
||||
T,
|
||||
Props extends keyof T = keyof T,
|
||||
OptionsProps extends keyof T = Props extends keyof T
|
||||
? undefined extends T[Props]
|
||||
? Props
|
||||
: never
|
||||
: never
|
||||
> = Merge<
|
||||
{
|
||||
[K in OptionsProps]?: T[K];
|
||||
} & {
|
||||
[K in Exclude<keyof T, OptionsProps>]: T[K];
|
||||
}
|
||||
>;
|
||||
|
||||
type InferField<Field> = Field extends { _type: infer Type; _required: infer Required }
|
||||
? Required extends true
|
||||
? Type
|
||||
: Type | undefined
|
||||
: never;
|
||||
|
||||
export type InsertSchema<T> = Simplify<OptionalUndefined<InferEntityFields<T>>>;
|
||||
export type Schema<T> = { id: Generated<number> } & InsertSchema<T>;
|
||||
export type FieldSchema<T> = Simplify<OptionalUndefined<InferFields<T>>>;
|
||||
231
app/src/data/relations/EntityRelation.ts
Normal file
231
app/src/data/relations/EntityRelation.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { type Static, Type, parse } from "core/utils";
|
||||
import type { SelectQueryBuilder } from "kysely";
|
||||
import type { Entity, EntityData, EntityManager } from "../entities";
|
||||
import {
|
||||
type EntityRelationAnchor,
|
||||
type MutationInstructionResponse,
|
||||
RelationHelper
|
||||
} from "../relations";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
import type { RelationType } from "./relation-types";
|
||||
|
||||
export type KyselyJsonFrom = any;
|
||||
export type KyselyQueryBuilder = SelectQueryBuilder<any, any, any>;
|
||||
|
||||
/*export type RelationConfig = {
|
||||
mappedBy?: string;
|
||||
inversedBy?: string;
|
||||
sourceCardinality?: number;
|
||||
connectionTable?: string;
|
||||
connectionTableMappedName?: string;
|
||||
required?: boolean;
|
||||
};*/
|
||||
|
||||
export type BaseRelationConfig = Static<typeof EntityRelation.schema>;
|
||||
|
||||
// @todo: add generic type for relation config
|
||||
export abstract class EntityRelation<
|
||||
Schema extends typeof EntityRelation.schema = typeof EntityRelation.schema
|
||||
> {
|
||||
config: Static<Schema>;
|
||||
|
||||
source: EntityRelationAnchor;
|
||||
target: EntityRelationAnchor;
|
||||
|
||||
// @todo: add unit tests
|
||||
// allowed directions, used in RelationAccessor for visibility
|
||||
directions: ("source" | "target")[] = ["source", "target"];
|
||||
|
||||
static schema = Type.Object({
|
||||
mappedBy: Type.Optional(Type.String()),
|
||||
inversedBy: Type.Optional(Type.String()),
|
||||
required: Type.Optional(Type.Boolean())
|
||||
});
|
||||
|
||||
// don't make protected, App requires it to instantiatable
|
||||
constructor(
|
||||
source: EntityRelationAnchor,
|
||||
target: EntityRelationAnchor,
|
||||
config: Partial<Static<Schema>> = {}
|
||||
) {
|
||||
this.source = source;
|
||||
this.target = target;
|
||||
|
||||
const schema = (this.constructor as typeof EntityRelation).schema;
|
||||
// @ts-ignore for now
|
||||
this.config = parse(schema, config);
|
||||
}
|
||||
|
||||
abstract initialize(em: EntityManager<any>): void;
|
||||
|
||||
/**
|
||||
* Build the "with" part of the query.
|
||||
*
|
||||
* @param entity requesting entity, so target needs to be added
|
||||
* @param qb
|
||||
* @param jsonFrom
|
||||
*/
|
||||
abstract buildWith(
|
||||
entity: Entity,
|
||||
qb: KyselyQueryBuilder,
|
||||
jsonFrom: KyselyJsonFrom,
|
||||
reference: string
|
||||
): KyselyQueryBuilder;
|
||||
|
||||
abstract buildJoin(
|
||||
entity: Entity,
|
||||
qb: KyselyQueryBuilder,
|
||||
reference: string
|
||||
): KyselyQueryBuilder;
|
||||
|
||||
getReferenceQuery(entity: Entity, id: number, reference: string): Partial<RepoQuery> {
|
||||
return {};
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
helper(entity_name: string): RelationHelper {
|
||||
return new RelationHelper(this, entity_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the other side of the relation quickly
|
||||
* @param entity
|
||||
*/
|
||||
other(entity: Entity | string): EntityRelationAnchor {
|
||||
const entity_name = typeof entity === "string" ? entity : entity.name;
|
||||
|
||||
// special case for self referencing, check which side is not cardinality 1
|
||||
if (this.source.entity.name === this.target.entity.name) {
|
||||
return this.source.cardinality === 1 ? this.target : this.source;
|
||||
}
|
||||
|
||||
if (this.source.entity.name === entity_name) {
|
||||
return this.target;
|
||||
} else if (this.target.entity.name === entity_name) {
|
||||
return this.source;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Entity "${entity_name}" is not part of the relation ` +
|
||||
`"${this.source.entity.name} <-> ${this.target.entity.name}"`
|
||||
);
|
||||
}
|
||||
|
||||
ref(reference: string): EntityRelationAnchor {
|
||||
return this.source.reference === reference ? this.source : this.target;
|
||||
}
|
||||
|
||||
otherRef(reference: string): EntityRelationAnchor {
|
||||
return this.source.reference === reference ? this.target : this.source;
|
||||
}
|
||||
|
||||
// @todo: add unit tests
|
||||
visibleFrom(from: "source" | "target"): boolean {
|
||||
return this.directions.includes(from);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate the relation. "entity" represents where the payload belongs to.
|
||||
* E.g. if entity is "categories", then value is the result of categories
|
||||
*
|
||||
* IMPORTANT: This method is called from EM, high potential of recursion!
|
||||
*
|
||||
* @param entity
|
||||
* @param value
|
||||
* @param em
|
||||
*/
|
||||
hydrate(entity: Entity | string, value: EntityData[], em: EntityManager<any>) {
|
||||
const entity_name = typeof entity === "string" ? entity : entity.name;
|
||||
const anchor = this.ref(entity_name);
|
||||
const hydrated = em.hydrate(anchor.entity.name, value);
|
||||
|
||||
if (anchor.cardinality === 1) {
|
||||
if (Array.isArray(hydrated) && hydrated.length > 1) {
|
||||
throw new Error(
|
||||
`Failed to hydrate "${anchor.entity.name}" ` +
|
||||
`with value: ${JSON.stringify(value)} (cardinality: 1)`
|
||||
);
|
||||
}
|
||||
|
||||
return hydrated[0];
|
||||
}
|
||||
|
||||
if (!hydrated) {
|
||||
throw new Error(
|
||||
`Failed to hydrate "${anchor.entity.name}" ` +
|
||||
`with value: ${JSON.stringify(value)} (cardinality: -)`
|
||||
);
|
||||
}
|
||||
|
||||
return hydrated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the relation is listable for the given entity
|
||||
* If the given entity is the one with the local reference, then it's not listable
|
||||
* Only if there are multiple, which is generally the other side (except for 1:1)
|
||||
* @param entity
|
||||
*/
|
||||
isListableFor(entity: Entity): boolean {
|
||||
//console.log("isListableFor", entity.name, this.source.entity.name, this.target.entity.name);
|
||||
return this.target.entity.name === entity.name;
|
||||
}
|
||||
|
||||
abstract type(): RelationType;
|
||||
|
||||
get required(): boolean {
|
||||
return !!this.config.required;
|
||||
}
|
||||
|
||||
async $set(
|
||||
em: EntityManager<any>,
|
||||
key: string,
|
||||
value: unknown
|
||||
): Promise<void | MutationInstructionResponse> {
|
||||
throw new Error("$set is not allowed");
|
||||
}
|
||||
|
||||
async $create(
|
||||
em: EntityManager<any>,
|
||||
key: string,
|
||||
value: unknown
|
||||
): Promise<void | MutationInstructionResponse> {
|
||||
throw new Error("$create is not allowed");
|
||||
}
|
||||
|
||||
async $attach(
|
||||
em: EntityManager<any>,
|
||||
key: string,
|
||||
value: unknown
|
||||
): Promise<void | MutationInstructionResponse> {
|
||||
throw new Error("$attach is not allowed");
|
||||
}
|
||||
|
||||
async $detach(
|
||||
em: EntityManager<any>,
|
||||
key: string,
|
||||
value: unknown
|
||||
): Promise<void | MutationInstructionResponse> {
|
||||
throw new Error("$detach is not allowed");
|
||||
}
|
||||
|
||||
getName(): string {
|
||||
const parts = [
|
||||
this.type().replace(":", ""),
|
||||
this.source.entity.name,
|
||||
this.target.entity.name,
|
||||
this.config.mappedBy,
|
||||
this.config.inversedBy
|
||||
].filter(Boolean);
|
||||
return parts.join("_");
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
type: this.type(),
|
||||
source: this.source.entity.name,
|
||||
target: this.target.entity.name,
|
||||
config: this.config
|
||||
};
|
||||
}
|
||||
}
|
||||
25
app/src/data/relations/EntityRelationAnchor.ts
Normal file
25
app/src/data/relations/EntityRelationAnchor.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Entity } from "../entities";
|
||||
|
||||
export class EntityRelationAnchor {
|
||||
entity: Entity;
|
||||
cardinality?: number;
|
||||
|
||||
/**
|
||||
* The name that the other entity will use to reference this entity
|
||||
*/
|
||||
reference: string;
|
||||
|
||||
constructor(entity: Entity, name: string, cardinality?: number) {
|
||||
this.entity = entity;
|
||||
this.cardinality = cardinality;
|
||||
this.reference = name;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
entity: this.entity.name,
|
||||
cardinality: this.cardinality,
|
||||
name: this.reference,
|
||||
};
|
||||
}
|
||||
}
|
||||
189
app/src/data/relations/ManyToManyRelation.ts
Normal file
189
app/src/data/relations/ManyToManyRelation.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { type Static, Type } from "core/utils";
|
||||
import { Entity, type EntityManager } from "../entities";
|
||||
import { type Field, PrimaryField, VirtualField } from "../fields";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./EntityRelation";
|
||||
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||
import { RelationField } from "./RelationField";
|
||||
import { type RelationType, RelationTypes } from "./relation-types";
|
||||
|
||||
export type ManyToManyRelationConfig = Static<typeof ManyToManyRelation.schema>;
|
||||
|
||||
export class ManyToManyRelation extends EntityRelation<typeof ManyToManyRelation.schema> {
|
||||
connectionEntity: Entity;
|
||||
additionalFields: Field[] = [];
|
||||
connectionTableMappedName: string;
|
||||
private em?: EntityManager<any>;
|
||||
|
||||
static override schema = Type.Composite(
|
||||
[
|
||||
EntityRelation.schema,
|
||||
Type.Object({
|
||||
connectionTable: Type.Optional(Type.String()),
|
||||
connectionTableMappedName: Type.Optional(Type.String())
|
||||
})
|
||||
],
|
||||
{
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
|
||||
constructor(
|
||||
source: Entity,
|
||||
target: Entity,
|
||||
config?: ManyToManyRelationConfig,
|
||||
additionalFields?: Field[]
|
||||
) {
|
||||
const connectionTable =
|
||||
config?.connectionTable || ManyToManyRelation.defaultConnectionTable(source, target);
|
||||
|
||||
const sourceAnchor = new EntityRelationAnchor(source, source.name);
|
||||
const targetAnchor = new EntityRelationAnchor(target, target.name);
|
||||
super(sourceAnchor, targetAnchor, config);
|
||||
|
||||
this.connectionEntity = new Entity(connectionTable, additionalFields, undefined, "generated");
|
||||
|
||||
this.connectionTableMappedName = config?.connectionTableMappedName || connectionTable;
|
||||
this.additionalFields = additionalFields || [];
|
||||
//this.connectionTable = connectionTable;
|
||||
}
|
||||
|
||||
static defaultConnectionTable(source: Entity, target: Entity) {
|
||||
return `${source.name}_${target.name}`;
|
||||
}
|
||||
|
||||
type(): RelationType {
|
||||
return RelationTypes.ManyToMany;
|
||||
}
|
||||
|
||||
/**
|
||||
* Many to many is always listable in both directions
|
||||
*/
|
||||
override isListableFor(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
getField(entity: Entity): RelationField {
|
||||
const conn = this.connectionEntity;
|
||||
const selfField = conn.fields.find(
|
||||
(f) => f instanceof RelationField && f.target() === entity.name
|
||||
)!;
|
||||
|
||||
if (!selfField || !(selfField instanceof RelationField)) {
|
||||
throw new Error(
|
||||
`Connection entity "${conn.name}" does not have a relation to "${entity.name}"`
|
||||
);
|
||||
}
|
||||
|
||||
return selfField;
|
||||
}
|
||||
|
||||
private getQueryInfo(entity: Entity) {
|
||||
const other = this.other(entity);
|
||||
const conn = this.connectionEntity;
|
||||
const entityField = this.getField(entity);
|
||||
const otherField = this.getField(other.entity);
|
||||
const join = [
|
||||
conn.name,
|
||||
`${other.entity.name}.${other.entity.getPrimaryField().name}`,
|
||||
`${conn.name}.${otherField.name}`
|
||||
] as const;
|
||||
|
||||
const entityRef = `${entity.name}.${entity.getPrimaryField().name}`;
|
||||
const otherRef = `${conn.name}.${entityField.name}`;
|
||||
|
||||
const groupBy = `${entity.name}.${entity.getPrimaryField().name}`;
|
||||
|
||||
return {
|
||||
other,
|
||||
join,
|
||||
entityRef,
|
||||
otherRef,
|
||||
groupBy
|
||||
};
|
||||
}
|
||||
|
||||
override getReferenceQuery(entity: Entity, id: number): Partial<RepoQuery> {
|
||||
const conn = this.connectionEntity;
|
||||
|
||||
return {
|
||||
where: {
|
||||
[`${conn.name}.${entity.name}_${entity.getPrimaryField().name}`]: id
|
||||
},
|
||||
join: [this.target.reference]
|
||||
};
|
||||
}
|
||||
|
||||
buildJoin(entity: Entity, qb: KyselyQueryBuilder) {
|
||||
const { other, join, entityRef, otherRef, groupBy } = this.getQueryInfo(entity);
|
||||
|
||||
return qb
|
||||
.innerJoin(other.entity.name, entityRef, otherRef)
|
||||
.innerJoin(...join)
|
||||
.groupBy(groupBy);
|
||||
}
|
||||
|
||||
buildWith(entity: Entity, qb: KyselyQueryBuilder, jsonFrom: KyselyJsonFrom) {
|
||||
if (!this.em) {
|
||||
throw new Error("EntityManager not set, can't build");
|
||||
}
|
||||
const jsonBuildObject = this.em.connection.fn.jsonBuildObject;
|
||||
if (!jsonBuildObject) {
|
||||
throw new Error("Connection does not support jsonBuildObject");
|
||||
}
|
||||
|
||||
const limit = 5;
|
||||
const { other, join, entityRef, otherRef } = this.getQueryInfo(entity);
|
||||
const additionalFields = this.connectionEntity.fields.filter(
|
||||
(f) => !(f instanceof RelationField || f instanceof PrimaryField)
|
||||
);
|
||||
|
||||
return qb.select((eb) => {
|
||||
const select: any[] = other.entity.getSelect(other.entity.name);
|
||||
// @todo: also add to find by references
|
||||
if (additionalFields.length > 0) {
|
||||
const conn = this.connectionEntity.name;
|
||||
select.push(
|
||||
jsonBuildObject(
|
||||
Object.fromEntries(
|
||||
additionalFields.map((f) => [f.name, eb.ref(`${conn}.${f.name}`)])
|
||||
)
|
||||
).as(this.connectionTableMappedName)
|
||||
);
|
||||
}
|
||||
|
||||
return jsonFrom(
|
||||
eb
|
||||
.selectFrom(other.entity.name)
|
||||
.select(select)
|
||||
.whereRef(entityRef, "=", otherRef)
|
||||
.innerJoin(...join)
|
||||
.limit(limit)
|
||||
).as(other.reference);
|
||||
});
|
||||
}
|
||||
|
||||
initialize(em: EntityManager<any>) {
|
||||
this.em = em;
|
||||
|
||||
//this.connectionEntity.addField(new RelationField(this.source.entity));
|
||||
//this.connectionEntity.addField(new RelationField(this.target.entity));
|
||||
this.connectionEntity.addField(RelationField.create(this, this.source));
|
||||
this.connectionEntity.addField(RelationField.create(this, this.target));
|
||||
|
||||
// @todo: check this
|
||||
for (const field of this.additionalFields) {
|
||||
this.source.entity.addField(new VirtualField(this.connectionTableMappedName));
|
||||
this.target.entity.addField(new VirtualField(this.connectionTableMappedName));
|
||||
}
|
||||
|
||||
em.addEntity(this.connectionEntity);
|
||||
}
|
||||
|
||||
override getName(): string {
|
||||
return [
|
||||
super.getName(),
|
||||
[this.connectionEntity.name, this.connectionTableMappedName].filter(Boolean)
|
||||
].join("_");
|
||||
}
|
||||
}
|
||||
228
app/src/data/relations/ManyToOneRelation.ts
Normal file
228
app/src/data/relations/ManyToOneRelation.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import { snakeToPascalWithSpaces } from "core/utils";
|
||||
import { type Static, Type } from "core/utils";
|
||||
import type { Entity, EntityManager } from "../entities";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./EntityRelation";
|
||||
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||
import { RelationField, type RelationFieldBaseConfig } from "./RelationField";
|
||||
import type { MutationInstructionResponse } from "./RelationMutator";
|
||||
import { type RelationType, RelationTypes } from "./relation-types";
|
||||
|
||||
/**
|
||||
* Source entity receives the mapping field
|
||||
*
|
||||
* Many-to-one (many) [sources] has (one) [target]
|
||||
* Example: [posts] has (one) [user]
|
||||
* posts gets a users_id field
|
||||
*/
|
||||
|
||||
export type ManyToOneRelationConfig = Static<typeof ManyToOneRelation.schema>;
|
||||
|
||||
export class ManyToOneRelation extends EntityRelation<typeof ManyToOneRelation.schema> {
|
||||
private fieldConfig?: RelationFieldBaseConfig;
|
||||
static DEFAULTS = {
|
||||
with_limit: 5
|
||||
};
|
||||
|
||||
static override schema = Type.Composite(
|
||||
[
|
||||
EntityRelation.schema,
|
||||
Type.Object({
|
||||
sourceCardinality: Type.Optional(Type.Number()),
|
||||
with_limit: Type.Optional(
|
||||
Type.Number({ default: ManyToOneRelation.DEFAULTS.with_limit })
|
||||
),
|
||||
fieldConfig: Type.Optional(
|
||||
Type.Object({
|
||||
label: Type.String()
|
||||
})
|
||||
)
|
||||
})
|
||||
],
|
||||
{
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
|
||||
constructor(
|
||||
source: Entity,
|
||||
target: Entity,
|
||||
config: Partial<Static<typeof ManyToOneRelation.schema>> = {}
|
||||
) {
|
||||
const mappedBy = config.mappedBy || target.name;
|
||||
const inversedBy = config.inversedBy || source.name;
|
||||
|
||||
// if source can be multiple, allow it. otherwise unlimited
|
||||
const sourceCardinality =
|
||||
typeof config.sourceCardinality === "number" && config.sourceCardinality > 0
|
||||
? config.sourceCardinality
|
||||
: undefined;
|
||||
const sourceAnchor = new EntityRelationAnchor(source, inversedBy, sourceCardinality);
|
||||
const targetAnchor = new EntityRelationAnchor(target, mappedBy, 1);
|
||||
super(sourceAnchor, targetAnchor, config);
|
||||
|
||||
this.fieldConfig = config.fieldConfig ?? {};
|
||||
// set relation required or not
|
||||
//this.required = !!config.required;
|
||||
}
|
||||
|
||||
type(): RelationType {
|
||||
return RelationTypes.ManyToOne;
|
||||
}
|
||||
|
||||
override initialize(em: EntityManager<any>) {
|
||||
const defaultLabel = snakeToPascalWithSpaces(this.target.reference);
|
||||
|
||||
// add required mapping field on source
|
||||
const field = RelationField.create(this, this.target, {
|
||||
label: defaultLabel,
|
||||
...this.fieldConfig
|
||||
});
|
||||
|
||||
if (!this.source.entity.field(field.name)) {
|
||||
this.source.entity.addField(
|
||||
RelationField.create(this, this.target, {
|
||||
label: defaultLabel,
|
||||
...this.fieldConfig
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the RelationField
|
||||
*/
|
||||
getField(): RelationField {
|
||||
const id = this.target.entity.getPrimaryField().name;
|
||||
const field = this.source.entity.getField(`${this.target.reference}_${id}`);
|
||||
|
||||
if (!(field instanceof RelationField)) {
|
||||
throw new Error(
|
||||
`Field "${this.target.reference}_${id}" not found on entity "${this.source.entity.name}"`
|
||||
);
|
||||
}
|
||||
|
||||
return field;
|
||||
}
|
||||
|
||||
private queryInfo(entity: Entity, reference: string) {
|
||||
const side = this.source.reference === reference ? "source" : "target";
|
||||
const self = this[side];
|
||||
const other = this[side === "source" ? "target" : "source"];
|
||||
let relationRef: string;
|
||||
let entityRef: string;
|
||||
let otherRef: string;
|
||||
if (side === "source") {
|
||||
relationRef = this.source.reference;
|
||||
entityRef = `${relationRef}.${this.getField().name}`;
|
||||
otherRef = `${entity.name}.${self.entity.getPrimaryField().name}`;
|
||||
} else {
|
||||
relationRef = this.target.reference;
|
||||
entityRef = `${relationRef}.${entity.getPrimaryField().name}`;
|
||||
otherRef = `${entity.name}.${this.getField().name}`;
|
||||
}
|
||||
|
||||
const groupBy = `${entity.name}.${entity.getPrimaryField().name}`;
|
||||
//console.log("queryInfo", entity.name, { reference, side, relationRef, entityRef, otherRef });
|
||||
|
||||
return {
|
||||
other,
|
||||
self,
|
||||
relationRef,
|
||||
entityRef,
|
||||
otherRef,
|
||||
groupBy
|
||||
};
|
||||
}
|
||||
|
||||
override getReferenceQuery(entity: Entity, id: number, reference: string): Partial<RepoQuery> {
|
||||
const side = this.source.reference === reference ? "source" : "target";
|
||||
const self = this[side];
|
||||
const other = this[side === "source" ? "target" : "source"];
|
||||
const otherRef = `${other.reference}_${other.entity.getPrimaryField().name}`;
|
||||
|
||||
return {
|
||||
where: {
|
||||
[otherRef]: id
|
||||
},
|
||||
join: other.entity.name === self.entity.name ? [] : [other.entity.name]
|
||||
};
|
||||
}
|
||||
|
||||
buildJoin(entity: Entity, qb: KyselyQueryBuilder, reference: string) {
|
||||
const { self, entityRef, otherRef, groupBy } = this.queryInfo(entity, reference);
|
||||
return qb.innerJoin(self.entity.name, entityRef, otherRef).groupBy(groupBy);
|
||||
}
|
||||
|
||||
buildWith(entity: Entity, qb: KyselyQueryBuilder, jsonFrom: KyselyJsonFrom, reference: string) {
|
||||
const { self, entityRef, otherRef, relationRef } = this.queryInfo(entity, reference);
|
||||
const limit =
|
||||
self.cardinality === 1
|
||||
? 1
|
||||
: this.config.with_limit ?? ManyToOneRelation.DEFAULTS.with_limit;
|
||||
//console.log("buildWith", entity.name, reference, { limit });
|
||||
|
||||
return qb.select((eb) =>
|
||||
jsonFrom(
|
||||
eb
|
||||
.selectFrom(`${self.entity.name} as ${relationRef}`)
|
||||
.select(self.entity.getSelect(relationRef))
|
||||
.whereRef(entityRef, "=", otherRef)
|
||||
.limit(limit)
|
||||
).as(relationRef)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* $set is performed using the reference:
|
||||
* { [reference]: { $set: { id: 1 } } }
|
||||
*
|
||||
* It must resolve from [reference] ("users") to field ("user_id")
|
||||
* -> returns instructions
|
||||
*/
|
||||
override async $set(
|
||||
em: EntityManager<any>,
|
||||
key: string,
|
||||
value: object
|
||||
): Promise<void | MutationInstructionResponse> {
|
||||
if (typeof value !== "object") {
|
||||
throw new Error(`Invalid value for relation field "${key}" given, expected object.`);
|
||||
}
|
||||
|
||||
const entity = this.source.entity;
|
||||
const helper = this.helper(entity.name);
|
||||
const info = helper.getMutationInfo();
|
||||
if (!info.$set) {
|
||||
throw new Error(`Cannot perform $set for relation "${key}"`);
|
||||
}
|
||||
|
||||
const local_field = info.local_field;
|
||||
const field = this.getField();
|
||||
// @ts-ignore
|
||||
const primaryReference = value[Object.keys(value)[0]] as PrimaryFieldType;
|
||||
|
||||
if (!local_field || !(field instanceof RelationField)) {
|
||||
throw new Error(`Cannot perform $set for relation "${key}"`);
|
||||
}
|
||||
|
||||
// if "{ $set: { id: null } }" given, and not required, allow it
|
||||
if (primaryReference === null && !field.isRequired()) {
|
||||
return [local_field, null] satisfies MutationInstructionResponse;
|
||||
}
|
||||
|
||||
const query = await em.repository(field.target()).exists({
|
||||
[field.targetField()]: primaryReference as any
|
||||
});
|
||||
|
||||
if (!query.exists) {
|
||||
const idProp = field.targetField();
|
||||
throw new Error(
|
||||
`Cannot connect "${entity.name}.${key}" to ` +
|
||||
`"${field.target()}.${idProp}" = "${primaryReference}": not found.`
|
||||
);
|
||||
}
|
||||
|
||||
return [local_field, primaryReference] satisfies MutationInstructionResponse;
|
||||
}
|
||||
}
|
||||
77
app/src/data/relations/OneToOneRelation.ts
Normal file
77
app/src/data/relations/OneToOneRelation.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { Entity, EntityManager } from "../entities";
|
||||
import { ManyToOneRelation, type ManyToOneRelationConfig } from "./ManyToOneRelation";
|
||||
import type { MutationInstructionResponse } from "./RelationMutator";
|
||||
import { type RelationType, RelationTypes } from "./relation-types";
|
||||
|
||||
/**
|
||||
* Both source and target receive a mapping field
|
||||
* @todo: determine if it should be removed
|
||||
*/
|
||||
export type OneToOneRelationConfig = ManyToOneRelationConfig;
|
||||
|
||||
/* export type OneToOneRelationConfig = {
|
||||
mappedBy?: string; // author|users
|
||||
inversedBy?: string; // posts
|
||||
required?: boolean;
|
||||
}; */
|
||||
|
||||
export class OneToOneRelation extends ManyToOneRelation {
|
||||
constructor(source: Entity, target: Entity, config?: OneToOneRelationConfig) {
|
||||
const { mappedBy, inversedBy, required } = config || {};
|
||||
super(source, target, {
|
||||
mappedBy,
|
||||
inversedBy,
|
||||
sourceCardinality: 1,
|
||||
required
|
||||
});
|
||||
}
|
||||
|
||||
override type(): RelationType {
|
||||
return RelationTypes.OneToOne;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-to-one relations are not listable in either direction
|
||||
*/
|
||||
override isListableFor(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// need to override since it inherits manytoone
|
||||
override async $set(
|
||||
em: EntityManager<any>,
|
||||
key: string,
|
||||
value: object
|
||||
): Promise<MutationInstructionResponse> {
|
||||
throw new Error("$set is not allowed");
|
||||
}
|
||||
|
||||
override async $create(
|
||||
em: EntityManager<any>,
|
||||
key: string,
|
||||
value: unknown
|
||||
): Promise<void | MutationInstructionResponse> {
|
||||
if (value === null || typeof value !== "object") {
|
||||
throw new Error(`Invalid value for relation field "${key}" given, expected object.`);
|
||||
}
|
||||
|
||||
const target = this.other(this.source.entity).entity;
|
||||
const helper = this.helper(this.source.entity.name);
|
||||
const info = helper.getMutationInfo();
|
||||
const primary = info.primary;
|
||||
const local_field = info.local_field;
|
||||
if (!info.$create || !primary || !local_field) {
|
||||
throw new Error(`Cannot perform $create for relation "${key}"`);
|
||||
}
|
||||
|
||||
// create the relational entity
|
||||
try {
|
||||
const { data } = await em.mutator(target).insertOne(value);
|
||||
|
||||
const retrieved_value = data[primary];
|
||||
return [local_field, retrieved_value] satisfies MutationInstructionResponse;
|
||||
} catch (e) {
|
||||
throw new Error(`Error performing $create on "${target.name}".`);
|
||||
}
|
||||
}
|
||||
}
|
||||
130
app/src/data/relations/PolymorphicRelation.ts
Normal file
130
app/src/data/relations/PolymorphicRelation.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { type Static, Type } from "core/utils";
|
||||
import type { Entity, EntityManager } from "../entities";
|
||||
import { NumberField, TextField } from "../fields";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./EntityRelation";
|
||||
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||
import { type RelationType, RelationTypes } from "./relation-types";
|
||||
|
||||
export type PolymorphicRelationConfig = Static<typeof PolymorphicRelation.schema>;
|
||||
|
||||
// @todo: what about cascades?
|
||||
export class PolymorphicRelation extends EntityRelation<typeof PolymorphicRelation.schema> {
|
||||
static override schema = Type.Composite(
|
||||
[
|
||||
EntityRelation.schema,
|
||||
Type.Object({
|
||||
targetCardinality: Type.Optional(Type.Number())
|
||||
})
|
||||
],
|
||||
{
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
|
||||
constructor(source: Entity, target: Entity, config: Partial<PolymorphicRelationConfig> = {}) {
|
||||
const mappedBy = config.mappedBy || target.name;
|
||||
const inversedBy = config.inversedBy || source.name;
|
||||
|
||||
// if target can be multiple, allow it. otherwise unlimited
|
||||
const targetCardinality =
|
||||
typeof config.targetCardinality === "number" && config.targetCardinality > 0
|
||||
? config.targetCardinality
|
||||
: undefined;
|
||||
const sourceAnchor = new EntityRelationAnchor(source, inversedBy, 1);
|
||||
const targetAnchor = new EntityRelationAnchor(target, mappedBy, targetCardinality);
|
||||
super(sourceAnchor, targetAnchor, config);
|
||||
|
||||
this.directions = ["source"];
|
||||
}
|
||||
|
||||
type(): RelationType {
|
||||
return RelationTypes.Polymorphic;
|
||||
}
|
||||
|
||||
private queryInfo(entity: Entity) {
|
||||
const other = this.other(entity);
|
||||
const whereLhs = `${other.entity.name}.${this.getReferenceField().name}`;
|
||||
const reference = `${entity.name}.${this.config.mappedBy}`;
|
||||
|
||||
// this is used for "getReferenceQuery"
|
||||
const reference_other = `${other.entity.name}.${this.config.mappedBy}`;
|
||||
|
||||
const entityRef = `${entity.name}.${entity.getPrimaryField().name}`;
|
||||
const otherRef = `${other.entity.name}.${this.getEntityIdField().name}`;
|
||||
|
||||
const groupBy = `${entity.name}.${entity.getPrimaryField().name}`;
|
||||
|
||||
return {
|
||||
other,
|
||||
whereLhs,
|
||||
reference,
|
||||
reference_other,
|
||||
entityRef,
|
||||
otherRef,
|
||||
groupBy
|
||||
};
|
||||
}
|
||||
|
||||
buildJoin(entity: Entity, qb: KyselyQueryBuilder) {
|
||||
const { other, whereLhs, reference, entityRef, otherRef, groupBy } = this.queryInfo(entity);
|
||||
|
||||
return qb
|
||||
.innerJoin(other.entity.name, (join) =>
|
||||
join.onRef(entityRef, "=", otherRef).on(whereLhs, "=", reference)
|
||||
)
|
||||
.groupBy(groupBy);
|
||||
}
|
||||
|
||||
override getReferenceQuery(entity: Entity, id: number): Partial<RepoQuery> {
|
||||
const info = this.queryInfo(entity);
|
||||
|
||||
return {
|
||||
where: {
|
||||
[this.getReferenceField().name]: info.reference_other,
|
||||
[this.getEntityIdField().name]: id
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
buildWith(entity: Entity, qb: KyselyQueryBuilder, jsonFrom: KyselyJsonFrom) {
|
||||
const { other, whereLhs, reference, entityRef, otherRef } = this.queryInfo(entity);
|
||||
const limit = other.cardinality === 1 ? 1 : 5;
|
||||
|
||||
return qb.select((eb) =>
|
||||
jsonFrom(
|
||||
eb
|
||||
.selectFrom(other.entity.name)
|
||||
.select(other.entity.getSelect(other.entity.name))
|
||||
.where(whereLhs, "=", reference)
|
||||
.whereRef(entityRef, "=", otherRef)
|
||||
.limit(limit)
|
||||
).as(other.reference)
|
||||
);
|
||||
}
|
||||
|
||||
override isListableFor(entity: Entity): boolean {
|
||||
// @todo: only make listable if many? check cardinality
|
||||
return this.source.entity.name === entity.name && this.target.cardinality !== 1;
|
||||
}
|
||||
|
||||
getReferenceField(): TextField {
|
||||
return new TextField("reference", { hidden: true, fillable: ["create"] });
|
||||
}
|
||||
|
||||
getEntityIdField(): NumberField {
|
||||
return new NumberField("entity_id", { hidden: true, fillable: ["create"] });
|
||||
}
|
||||
|
||||
initialize(em: EntityManager<any>) {
|
||||
const referenceField = this.getReferenceField();
|
||||
const entityIdField = this.getEntityIdField();
|
||||
|
||||
if (!this.target.entity.field(referenceField.name)) {
|
||||
this.target.entity.addField(referenceField);
|
||||
}
|
||||
if (!this.target.entity.field(entityIdField.name)) {
|
||||
this.target.entity.addField(entityIdField);
|
||||
}
|
||||
}
|
||||
}
|
||||
74
app/src/data/relations/RelationAccessor.ts
Normal file
74
app/src/data/relations/RelationAccessor.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { Entity } from "../entities";
|
||||
import type { EntityRelation } from "../relations";
|
||||
|
||||
export class RelationAccessor {
|
||||
private readonly _relations: EntityRelation[] = [];
|
||||
|
||||
constructor(relations: EntityRelation[]) {
|
||||
this._relations = relations;
|
||||
}
|
||||
|
||||
get all(): EntityRelation[] {
|
||||
return this._relations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for the relations of [entity_name]
|
||||
*/
|
||||
relationsOf(entity: Entity): EntityRelation[] {
|
||||
return this._relations.filter((relation) => {
|
||||
return (
|
||||
(relation.visibleFrom("source") && relation.source.entity.name === entity.name) ||
|
||||
(relation.visibleFrom("target") && relation.target.entity.name === entity.name)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
sourceRelationsOf(entity: Entity): EntityRelation[] {
|
||||
return this._relations.filter((relation) => {
|
||||
return relation.source.entity.name === entity.name;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for relations that have [entity] as target
|
||||
* - meaning it returns entities that holds a local reference field
|
||||
*/
|
||||
targetRelationsOf(entity: Entity): EntityRelation[] {
|
||||
return this._relations.filter((relation) => {
|
||||
return relation.visibleFrom("target") && relation.target.entity.name === entity.name;
|
||||
});
|
||||
}
|
||||
|
||||
listableRelationsOf(entity: Entity): EntityRelation[] {
|
||||
return this.relationsOf(entity).filter((relation) => relation.isListableFor(entity));
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for the relations of [entity_name] and
|
||||
* return the one that has [reference] as source or target.
|
||||
*/
|
||||
relationOf(entity: Entity, reference: string): EntityRelation | undefined {
|
||||
return this.relationsOf(entity).find((r) => {
|
||||
return r.source.reference === reference || r.target.reference === reference;
|
||||
});
|
||||
}
|
||||
|
||||
hasRelations(entity: Entity): boolean {
|
||||
return this.relationsOf(entity).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of related entities of [entity_name]
|
||||
*/
|
||||
relatedEntitiesOf(entity: Entity): Entity[] {
|
||||
return this.relationsOf(entity).map((r) => r.other(entity).entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relation names of [entity_name]
|
||||
*/
|
||||
relationReferencesOf(entity): string[] {
|
||||
return this.relationsOf(entity).map((r) => r.other(entity).reference);
|
||||
}
|
||||
}
|
||||
101
app/src/data/relations/RelationField.ts
Normal file
101
app/src/data/relations/RelationField.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { type Static, StringEnum, Type } from "core/utils";
|
||||
import type { EntityManager } from "../entities";
|
||||
import { Field, type SchemaResponse, baseFieldConfigSchema } from "../fields";
|
||||
import type { EntityRelation } from "./EntityRelation";
|
||||
import type { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||
|
||||
const CASCADES = ["cascade", "set null", "set default", "restrict", "no action"] as const;
|
||||
|
||||
export const relationFieldConfigSchema = Type.Composite([
|
||||
baseFieldConfigSchema,
|
||||
Type.Object({
|
||||
reference: Type.String(),
|
||||
target: Type.String(), // @todo: potentially has to be an instance!
|
||||
target_field: Type.Optional(Type.String({ default: "id" })),
|
||||
on_delete: Type.Optional(StringEnum(CASCADES, { default: "set null" }))
|
||||
})
|
||||
]);
|
||||
/*export const relationFieldConfigSchema = baseFieldConfigSchema.extend({
|
||||
reference: z.string(),
|
||||
target: z.string(),
|
||||
target_field: z.string().catch("id"),
|
||||
});*/
|
||||
|
||||
export type RelationFieldConfig = Static<typeof relationFieldConfigSchema>;
|
||||
export type RelationFieldBaseConfig = { label?: string };
|
||||
|
||||
export class RelationField extends Field<RelationFieldConfig> {
|
||||
override readonly type = "relation";
|
||||
|
||||
protected getSchema() {
|
||||
return relationFieldConfigSchema;
|
||||
}
|
||||
|
||||
/*constructor(name: string, config?: Partial<RelationFieldConfig>) {
|
||||
//relation_name = relation_name || target.name;
|
||||
//const name = [relation_name, target.getPrimaryField().name].join("_");
|
||||
super(name, config);
|
||||
|
||||
//console.log(this.config);
|
||||
//this.relation.target = target;
|
||||
//this.relation.name = relation_name;
|
||||
}*/
|
||||
|
||||
static create(
|
||||
relation: EntityRelation,
|
||||
target: EntityRelationAnchor,
|
||||
config?: RelationFieldBaseConfig
|
||||
) {
|
||||
const name = [
|
||||
target.reference ?? target.entity.name,
|
||||
target.entity.getPrimaryField().name
|
||||
].join("_");
|
||||
//console.log('name', name);
|
||||
return new RelationField(name, {
|
||||
...config,
|
||||
required: relation.required,
|
||||
reference: target.reference,
|
||||
target: target.entity.name,
|
||||
target_field: target.entity.getPrimaryField().name
|
||||
});
|
||||
}
|
||||
|
||||
reference() {
|
||||
return this.config.reference;
|
||||
}
|
||||
|
||||
target() {
|
||||
return this.config.target;
|
||||
}
|
||||
|
||||
targetField(): string {
|
||||
return this.config.target_field!;
|
||||
}
|
||||
|
||||
override schema(): SchemaResponse {
|
||||
return this.useSchemaHelper("integer", (col) => {
|
||||
//col.references('person.id').onDelete('cascade').notNull()
|
||||
// @todo: implement cascading?
|
||||
|
||||
return col
|
||||
.references(`${this.config.target}.${this.config.target_field}`)
|
||||
.onDelete(this.config.on_delete ?? "set null");
|
||||
});
|
||||
}
|
||||
|
||||
override transformRetrieve(value: any): any {
|
||||
return value;
|
||||
}
|
||||
|
||||
override async transformPersist(value: any, em: EntityManager<any>): Promise<any> {
|
||||
throw new Error("This function should not be called");
|
||||
}
|
||||
|
||||
override toJsonSchema() {
|
||||
return this.toSchemaWrapIfRequired(
|
||||
Type.Number({
|
||||
$ref: `${this.config?.target}#/properties/${this.config?.target_field}`
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
86
app/src/data/relations/RelationHelper.ts
Normal file
86
app/src/data/relations/RelationHelper.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
type EntityRelation,
|
||||
type EntityRelationAnchor,
|
||||
type ManyToOneRelation,
|
||||
type OneToOneRelation,
|
||||
RelationTypes,
|
||||
} from "../relations";
|
||||
|
||||
export const MutationOperations = ["$set", "$create", "$attach", "$detach"] as const;
|
||||
export type MutationOperation = (typeof MutationOperations)[number];
|
||||
|
||||
export class RelationHelper {
|
||||
relation: EntityRelation;
|
||||
access: "source" | "target";
|
||||
self: EntityRelationAnchor;
|
||||
other: EntityRelationAnchor;
|
||||
|
||||
constructor(relation: EntityRelation, entity_name: string) {
|
||||
this.relation = relation;
|
||||
|
||||
if (relation.source.entity.name === entity_name) {
|
||||
this.access = "source";
|
||||
this.self = relation.source;
|
||||
this.other = relation.target;
|
||||
} else if (relation.target.entity.name === entity_name) {
|
||||
this.access = "target";
|
||||
this.self = relation.target;
|
||||
this.other = relation.source;
|
||||
} else {
|
||||
throw new Error(
|
||||
`Entity "${entity_name}" is not part of the relation ` +
|
||||
`"${relation.source.entity.name} <-> ${relation.target.entity.name}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// @todo: add to respective relations
|
||||
getMutationInfo() {
|
||||
const ops: Record<MutationOperation, boolean> = {
|
||||
$set: false,
|
||||
$create: false,
|
||||
$attach: false,
|
||||
$detach: false,
|
||||
};
|
||||
|
||||
let local_field: string | undefined;
|
||||
let primary: string | undefined;
|
||||
|
||||
switch (this.relation.type()) {
|
||||
case RelationTypes.ManyToOne:
|
||||
// only if owning side (source), target is always single (just to assure)
|
||||
if (typeof this.self.cardinality === "undefined" && this.other.cardinality === 1) {
|
||||
ops.$set = true;
|
||||
local_field = (this.relation as ManyToOneRelation).getField()?.name;
|
||||
primary = this.other.entity.getPrimaryField().name;
|
||||
}
|
||||
|
||||
break;
|
||||
case RelationTypes.OneToOne:
|
||||
// only if owning side (source)
|
||||
if (this.access === "source") {
|
||||
ops.$create = true;
|
||||
ops.$set = true; // @todo: for now allow
|
||||
local_field = (this.relation as OneToOneRelation).getField()?.name;
|
||||
primary = this.other.entity.getPrimaryField().name;
|
||||
}
|
||||
break;
|
||||
case RelationTypes.ManyToMany:
|
||||
if (this.access === "source") {
|
||||
ops.$attach = true;
|
||||
ops.$detach = true;
|
||||
primary = this.other.entity.getPrimaryField().name;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
reference: this.other.reference,
|
||||
local_field,
|
||||
...ops,
|
||||
primary,
|
||||
cardinality: this.other.cardinality,
|
||||
relation_type: this.relation.type(),
|
||||
};
|
||||
}
|
||||
}
|
||||
121
app/src/data/relations/RelationMutator.ts
Normal file
121
app/src/data/relations/RelationMutator.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import type { Entity, EntityManager } from "../entities";
|
||||
import {
|
||||
type EntityRelation,
|
||||
type MutationOperation,
|
||||
MutationOperations,
|
||||
RelationField
|
||||
} from "../relations";
|
||||
|
||||
export type MutationInstructionResponse = [string, PrimaryFieldType | null];
|
||||
|
||||
export class RelationMutator {
|
||||
constructor(
|
||||
protected entity: Entity,
|
||||
protected em: EntityManager<any>
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns all keys that are somehow relational.
|
||||
* Includes local fields (users_id) and references (users|author)
|
||||
*
|
||||
* @param em
|
||||
* @param entity_name
|
||||
*
|
||||
* @returns string[]
|
||||
*/
|
||||
getRelationalKeys(): string[] {
|
||||
const references: string[] = [];
|
||||
this.em.relationsOf(this.entity.name).map((r) => {
|
||||
const info = r.helper(this.entity.name).getMutationInfo();
|
||||
references.push(info.reference);
|
||||
info.local_field && references.push(info.local_field);
|
||||
});
|
||||
return references;
|
||||
}
|
||||
|
||||
async persistRelationField(
|
||||
field: RelationField,
|
||||
key: string,
|
||||
value: PrimaryFieldType
|
||||
): Promise<MutationInstructionResponse> {
|
||||
// allow empty if field is not required
|
||||
if (value === null && !field.isRequired()) {
|
||||
return [key, value];
|
||||
}
|
||||
|
||||
// make sure it's a primitive value
|
||||
// @todo: this is not a good way of checking primitives. Null is also an object
|
||||
if (typeof value === "object") {
|
||||
console.log("value", value);
|
||||
throw new Error(`Invalid value for relation field "${key}" given, expected primitive.`);
|
||||
}
|
||||
|
||||
const query = await this.em.repository(field.target()).exists({
|
||||
[field.targetField()]: value
|
||||
});
|
||||
|
||||
if (!query.exists) {
|
||||
const idProp = field.targetField();
|
||||
throw new Error(
|
||||
`Cannot connect "${this.entity.name}.${key}" to ` +
|
||||
`"${field.target()}.${idProp}" = "${value}": not found.`
|
||||
);
|
||||
}
|
||||
|
||||
return [key, value];
|
||||
}
|
||||
|
||||
async persistReference(
|
||||
relation: EntityRelation,
|
||||
key: string,
|
||||
value: unknown
|
||||
): Promise<void | MutationInstructionResponse> {
|
||||
if (typeof value !== "object" || value === null || typeof value === "undefined") {
|
||||
throw new Error(
|
||||
`Invalid value for relation "${key}" given, expected object to persist reference. Like '{$set: {id: 1}}'.`
|
||||
);
|
||||
}
|
||||
|
||||
const operation = Object.keys(value)[0] as MutationOperation;
|
||||
if (!MutationOperations.includes(operation)) {
|
||||
throw new Error(
|
||||
`Invalid operation "${operation}" for relation "${key}". ` +
|
||||
`Allowed: ${MutationOperations.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const payload = value[operation];
|
||||
return await relation[operation](this.em, key, payload);
|
||||
}
|
||||
|
||||
async persistRelation(key: string, value: unknown): Promise<void | MutationInstructionResponse> {
|
||||
// if field (e.g. 'user_id')
|
||||
// relation types: n:1, 1:1 (mapping entity)
|
||||
const field = this.entity.getField(key);
|
||||
if (field instanceof RelationField) {
|
||||
return this.persistRelationField(field, key, value as PrimaryFieldType);
|
||||
}
|
||||
|
||||
/**
|
||||
* If reference given, value operations are given
|
||||
*
|
||||
* Could be:
|
||||
* { $set: { id: 1 } }
|
||||
* { $set: [{ id: 1 }, { id: 2 }] }
|
||||
* { $create: { theme: "dark" } }
|
||||
* { $attach: [{ id: 1 }, { id: 2 }] }
|
||||
* { $detach: [{ id: 1 }, { id: 2 }] }
|
||||
*/
|
||||
const relation = this.em.relationOf(this.entity.name, key);
|
||||
if (relation) {
|
||||
return this.persistReference(relation, key, value);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Relation "${key}" failed to resolve on entity "${this.entity.name}": ` +
|
||||
"Unable to resolve relation origin."
|
||||
);
|
||||
}
|
||||
}
|
||||
50
app/src/data/relations/index.ts
Normal file
50
app/src/data/relations/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { ManyToManyRelation, type ManyToManyRelationConfig } from "./ManyToManyRelation";
|
||||
import { ManyToOneRelation, type ManyToOneRelationConfig } from "./ManyToOneRelation";
|
||||
import { OneToOneRelation, type OneToOneRelationConfig } from "./OneToOneRelation";
|
||||
import { PolymorphicRelation, type PolymorphicRelationConfig } from "./PolymorphicRelation";
|
||||
import { type RelationType, RelationTypes } from "./relation-types";
|
||||
|
||||
export * from "./EntityRelation";
|
||||
export * from "./EntityRelationAnchor";
|
||||
export * from "./RelationHelper";
|
||||
export * from "./RelationMutator";
|
||||
export * from "./RelationAccessor";
|
||||
|
||||
import {
|
||||
RelationField,
|
||||
type RelationFieldBaseConfig,
|
||||
type RelationFieldConfig,
|
||||
relationFieldConfigSchema
|
||||
} from "./RelationField";
|
||||
|
||||
export {
|
||||
OneToOneRelation,
|
||||
type OneToOneRelationConfig,
|
||||
ManyToOneRelation,
|
||||
type ManyToOneRelationConfig,
|
||||
ManyToManyRelation,
|
||||
type ManyToManyRelationConfig,
|
||||
PolymorphicRelation,
|
||||
type PolymorphicRelationConfig,
|
||||
RelationTypes,
|
||||
type RelationType,
|
||||
// field
|
||||
RelationField,
|
||||
relationFieldConfigSchema,
|
||||
type RelationFieldBaseConfig,
|
||||
type RelationFieldConfig
|
||||
};
|
||||
|
||||
export const RelationClassMap = {
|
||||
[RelationTypes.OneToOne]: { schema: OneToOneRelation.schema, cls: OneToOneRelation },
|
||||
[RelationTypes.ManyToOne]: { schema: ManyToOneRelation.schema, cls: ManyToOneRelation },
|
||||
[RelationTypes.ManyToMany]: { schema: ManyToManyRelation.schema, cls: ManyToManyRelation },
|
||||
[RelationTypes.Polymorphic]: {
|
||||
schema: PolymorphicRelation.schema,
|
||||
cls: PolymorphicRelation
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const RelationFieldClassMap = {
|
||||
relation: { schema: relationFieldConfigSchema, field: RelationField }
|
||||
} as const;
|
||||
7
app/src/data/relations/relation-types.ts
Normal file
7
app/src/data/relations/relation-types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const RelationTypes = {
|
||||
OneToOne: "1:1",
|
||||
ManyToOne: "n:1",
|
||||
ManyToMany: "m:n",
|
||||
Polymorphic: "poly",
|
||||
} as const;
|
||||
export type RelationType = (typeof RelationTypes)[keyof typeof RelationTypes];
|
||||
349
app/src/data/schema/SchemaManager.ts
Normal file
349
app/src/data/schema/SchemaManager.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import type { AlterTableColumnAlteringBuilder, CompiledQuery, TableMetadata } from "kysely";
|
||||
import type { IndexMetadata } from "../connection/Connection";
|
||||
import type { Entity, EntityManager } from "../entities";
|
||||
import { PrimaryField, type SchemaResponse } from "../fields";
|
||||
|
||||
type IntrospectedTable = TableMetadata & {
|
||||
indices: IndexMetadata[];
|
||||
};
|
||||
|
||||
type SchemaTable = {
|
||||
name: string;
|
||||
columns: string[];
|
||||
};
|
||||
|
||||
type SchemaDiffTable = {
|
||||
name: string;
|
||||
isNew: boolean;
|
||||
isDrop?: boolean;
|
||||
columns: {
|
||||
add: string[];
|
||||
drop: string[];
|
||||
change: string[];
|
||||
};
|
||||
indices: {
|
||||
add: string[];
|
||||
drop: string[];
|
||||
};
|
||||
};
|
||||
|
||||
type ColumnDiff = {
|
||||
name: string;
|
||||
changes: {
|
||||
attribute: string;
|
||||
prev: any;
|
||||
next: any;
|
||||
}[];
|
||||
};
|
||||
|
||||
/**
|
||||
* @todo: add modified fields
|
||||
* @todo: add drop tables
|
||||
*
|
||||
* @todo: change exclude tables to startWith, then add "bknd_" tables
|
||||
*/
|
||||
|
||||
export class SchemaManager {
|
||||
static EXCLUDE_TABLES = ["libsql_wasm_func_table", "sqlite_sequence", "_cf_KV"];
|
||||
|
||||
constructor(private readonly em: EntityManager<any>) {}
|
||||
|
||||
private getIntrospector() {
|
||||
if (!this.em.connection.supportsIndices()) {
|
||||
throw new Error("Indices are not supported by the current connection");
|
||||
}
|
||||
|
||||
return this.em.connection.getIntrospector();
|
||||
}
|
||||
|
||||
async introspect(): Promise<IntrospectedTable[]> {
|
||||
const tables = await this.getIntrospector().getTables({
|
||||
withInternalKyselyTables: false
|
||||
});
|
||||
|
||||
const indices = await this.getIntrospector().getIndices();
|
||||
|
||||
const cleanTables: any[] = [];
|
||||
for (const table of tables) {
|
||||
if (SchemaManager.EXCLUDE_TABLES.includes(table.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cleanTables.push({
|
||||
...table,
|
||||
indices: indices.filter((index) => index.table === table.name)
|
||||
});
|
||||
}
|
||||
|
||||
return cleanTables;
|
||||
}
|
||||
|
||||
getIntrospectionFromEntity(entity: Entity): IntrospectedTable {
|
||||
const fields = entity.getFields(false);
|
||||
const indices = this.em.getIndicesOf(entity);
|
||||
|
||||
// this is intentionally setting values to defaults, like "nullable" and "default"
|
||||
// that is because sqlite is the main focus, but in the future,
|
||||
// we might want to support full sync with extensive schema updates (e.g. postgres)
|
||||
return {
|
||||
name: entity.name,
|
||||
isView: false,
|
||||
columns: fields.map((field) => ({
|
||||
name: field.name,
|
||||
dataType: "TEXT", // doesn't matter
|
||||
isNullable: true, // managed by the field
|
||||
isAutoIncrementing: field instanceof PrimaryField,
|
||||
hasDefaultValue: false, // managed by the field
|
||||
comment: undefined
|
||||
})),
|
||||
indices: indices.map((index) => ({
|
||||
name: index.name,
|
||||
table: entity.name,
|
||||
isUnique: index.unique,
|
||||
columns: index.fields.map((f) => ({
|
||||
name: f.name,
|
||||
order: 0 // doesn't matter
|
||||
}))
|
||||
})) as any
|
||||
};
|
||||
}
|
||||
|
||||
async getDiff(): Promise<SchemaDiffTable[]> {
|
||||
const introspection = await this.introspect();
|
||||
const entityStates = this.em.entities.map((e) => this.getIntrospectionFromEntity(e));
|
||||
|
||||
const diff: SchemaDiffTable[] = [];
|
||||
const namesFn = (c: { name: string }) => c.name;
|
||||
|
||||
// @todo: add drop tables (beware, there a system tables!)
|
||||
introspection
|
||||
.filter((table) => {
|
||||
if (/bknd/.test(table.name) || table.isView) {
|
||||
return false;
|
||||
}
|
||||
return !entityStates.map((e) => e.name).includes(table.name);
|
||||
})
|
||||
.forEach((t) => {
|
||||
diff.push({
|
||||
name: t.name,
|
||||
isDrop: true,
|
||||
isNew: false,
|
||||
columns: {
|
||||
add: [],
|
||||
drop: [],
|
||||
change: []
|
||||
},
|
||||
indices: {
|
||||
add: [],
|
||||
drop: []
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
for (const entity of entityStates) {
|
||||
const table = introspection.find((t) => t.name === entity.name);
|
||||
|
||||
if (!table) {
|
||||
// If the table is completely new
|
||||
diff.push({
|
||||
name: entity.name,
|
||||
isNew: true,
|
||||
columns: {
|
||||
add: entity.columns.map(namesFn),
|
||||
drop: [],
|
||||
change: []
|
||||
},
|
||||
indices: {
|
||||
add: entity.indices.map(namesFn),
|
||||
drop: []
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// If the table exists, check for new columns
|
||||
const newColumns = entity.columns.filter(
|
||||
(newColumn) => !table.columns.map(namesFn).includes(newColumn.name)
|
||||
);
|
||||
|
||||
// check for columns to drop
|
||||
const dropColumns = table.columns.filter(
|
||||
(oldColumn) => !entity.columns.map(namesFn).includes(oldColumn.name)
|
||||
);
|
||||
|
||||
// check for changed columns
|
||||
const columnDiffs: ColumnDiff[] = [];
|
||||
for (const entity_col of entity.columns) {
|
||||
const db_col = table.columns.find((c) => c.name === entity_col.name);
|
||||
const col_diffs: ColumnDiff["changes"] = [];
|
||||
for (const [key, value] of Object.entries(entity_col)) {
|
||||
if (db_col && db_col[key] !== value) {
|
||||
col_diffs.push({
|
||||
attribute: key,
|
||||
prev: db_col[key],
|
||||
next: value
|
||||
});
|
||||
}
|
||||
}
|
||||
if (Object.keys(col_diffs).length > 0) {
|
||||
columnDiffs.push({
|
||||
name: entity_col.name,
|
||||
changes: col_diffs
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// new indices
|
||||
const newIndices = entity.indices.filter(
|
||||
(newIndex) => !table.indices.map((i) => i.name).includes(newIndex.name)
|
||||
);
|
||||
|
||||
const dropIndices = table.indices.filter(
|
||||
(oldIndex) => !entity.indices.map((i) => i.name).includes(oldIndex.name)
|
||||
);
|
||||
|
||||
const anythingChanged = [
|
||||
newColumns,
|
||||
dropColumns,
|
||||
//columnDiffs, // ignored
|
||||
newIndices,
|
||||
dropIndices
|
||||
].some((arr) => arr.length > 0);
|
||||
|
||||
if (anythingChanged) {
|
||||
diff.push({
|
||||
name: entity.name,
|
||||
isNew: false,
|
||||
columns: {
|
||||
add: newColumns.map(namesFn),
|
||||
drop: dropColumns.map(namesFn),
|
||||
// @todo: this is ignored for now
|
||||
//change: columnDiffs.map(namesFn),
|
||||
change: []
|
||||
},
|
||||
indices: {
|
||||
add: newIndices.map(namesFn),
|
||||
drop: dropIndices.map(namesFn)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diff;
|
||||
}
|
||||
|
||||
private collectFieldSchemas(table: string, columns: string[]) {
|
||||
const schemas: SchemaResponse[] = [];
|
||||
if (columns.length === 0) {
|
||||
return schemas;
|
||||
}
|
||||
|
||||
for (const column of columns) {
|
||||
const field = this.em.entity(table).getField(column)!;
|
||||
const fieldSchema = field.schema(this.em);
|
||||
if (Array.isArray(fieldSchema) && fieldSchema.length === 3) {
|
||||
schemas.push(fieldSchema);
|
||||
//throw new Error(`Field "${field.name}" on entity "${table}" has no schema`);
|
||||
}
|
||||
}
|
||||
|
||||
return schemas;
|
||||
}
|
||||
|
||||
async sync(config: { force?: boolean; drop?: boolean } = { force: false, drop: false }) {
|
||||
const diff = await this.getDiff();
|
||||
let updates: number = 0;
|
||||
const statements: { sql: string; parameters: readonly unknown[] }[] = [];
|
||||
const schema = this.em.connection.kysely.schema;
|
||||
|
||||
for (const table of diff) {
|
||||
const qbs: { compile(): CompiledQuery; execute(): Promise<void> }[] = [];
|
||||
let local_updates: number = 0;
|
||||
const addFieldSchemas = this.collectFieldSchemas(table.name, table.columns.add);
|
||||
const dropFields = table.columns.drop;
|
||||
const dropIndices = table.indices.drop;
|
||||
|
||||
if (table.isDrop) {
|
||||
updates++;
|
||||
local_updates++;
|
||||
if (config.drop) {
|
||||
qbs.push(schema.dropTable(table.name));
|
||||
}
|
||||
} else if (table.isNew) {
|
||||
let createQb = schema.createTable(table.name);
|
||||
// add fields
|
||||
for (const fieldSchema of addFieldSchemas) {
|
||||
updates++;
|
||||
local_updates++;
|
||||
// @ts-ignore
|
||||
createQb = createQb.addColumn(...fieldSchema);
|
||||
}
|
||||
|
||||
qbs.push(createQb);
|
||||
} else {
|
||||
// if fields to add
|
||||
if (addFieldSchemas.length > 0) {
|
||||
// add fields
|
||||
for (const fieldSchema of addFieldSchemas) {
|
||||
updates++;
|
||||
local_updates++;
|
||||
// @ts-ignore
|
||||
qbs.push(schema.alterTable(table.name).addColumn(...fieldSchema));
|
||||
}
|
||||
}
|
||||
|
||||
// if fields to drop
|
||||
if (config.drop && dropFields.length > 0) {
|
||||
// drop fields
|
||||
for (const column of dropFields) {
|
||||
updates++;
|
||||
local_updates++;
|
||||
qbs.push(schema.alterTable(table.name).dropColumn(column));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add indices
|
||||
for (const index of table.indices.add) {
|
||||
const indices = this.em.getIndicesOf(table.name);
|
||||
const fieldIndex = indices.find((i) => i.name === index)!;
|
||||
let qb = schema
|
||||
.createIndex(index)
|
||||
.on(table.name)
|
||||
.columns(fieldIndex.fields.map((f) => f.name));
|
||||
if (fieldIndex.unique) {
|
||||
qb = qb.unique();
|
||||
}
|
||||
qbs.push(qb);
|
||||
local_updates++;
|
||||
updates++;
|
||||
}
|
||||
|
||||
// drop indices
|
||||
if (config.drop) {
|
||||
for (const index of dropIndices) {
|
||||
qbs.push(schema.dropIndex(index));
|
||||
local_updates++;
|
||||
updates++;
|
||||
}
|
||||
}
|
||||
|
||||
if (local_updates === 0) continue;
|
||||
|
||||
// iterate through built qbs
|
||||
for (const qb of qbs) {
|
||||
const { sql, parameters } = qb.compile();
|
||||
statements.push({ sql, parameters });
|
||||
|
||||
if (config.force) {
|
||||
try {
|
||||
await qb.execute();
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to execute query: ${sql}: ${(e as any).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return statements;
|
||||
}
|
||||
}
|
||||
77
app/src/data/server/data-query-impl.ts
Normal file
77
app/src/data/server/data-query-impl.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
type SchemaOptions,
|
||||
type Static,
|
||||
type StaticDecode,
|
||||
StringEnum,
|
||||
Type,
|
||||
Value
|
||||
} from "core/utils";
|
||||
import type { Simplify } from "type-fest";
|
||||
import { WhereBuilder } from "../entities";
|
||||
|
||||
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 });
|
||||
|
||||
// @todo: allow "id" and "-id"
|
||||
const sort = Type.Transform(
|
||||
Type.Union(
|
||||
[Type.String(), Type.Object({ by: Type.String(), dir: StringEnum(["asc", "desc"]) })],
|
||||
{
|
||||
default: { by: "id", dir: "asc" }
|
||||
}
|
||||
)
|
||||
)
|
||||
.Decode((value) => {
|
||||
if (typeof value === "string") {
|
||||
return JSON.parse(value);
|
||||
}
|
||||
return value;
|
||||
})
|
||||
.Encode(JSON.stringify);
|
||||
|
||||
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 const querySchema = Type.Object(
|
||||
{
|
||||
limit: Type.Optional(limit),
|
||||
offset: Type.Optional(offset),
|
||||
sort: Type.Optional(sort),
|
||||
select: Type.Optional(stringArray),
|
||||
with: Type.Optional(stringArray),
|
||||
join: Type.Optional(stringArray),
|
||||
where: Type.Optional(whereSchema)
|
||||
},
|
||||
{
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
|
||||
export type RepoQueryIn = Simplify<Static<typeof querySchema>>;
|
||||
export type RepoQuery = Required<StaticDecode<typeof querySchema>>;
|
||||
export const defaultQuerySchema = Value.Default(querySchema, {}) as RepoQuery;
|
||||
112
app/src/data/server/query.ts
Normal file
112
app/src/data/server/query.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const date = z.union([z.date(), z.string()]);
|
||||
const numeric = z.union([z.number(), date]);
|
||||
const boolean = z.union([z.boolean(), z.literal(1), z.literal(0)]);
|
||||
const value = z.union([z.string(), boolean, numeric]);
|
||||
|
||||
const expressionCond = z.union([
|
||||
z.object({ $eq: value }).strict(),
|
||||
z.object({ $ne: value }).strict(),
|
||||
z.object({ $isnull: boolean }).strict(),
|
||||
z.object({ $notnull: boolean }).strict(),
|
||||
z.object({ $in: z.array(value) }).strict(),
|
||||
z.object({ $notin: z.array(value) }).strict(),
|
||||
z.object({ $gt: numeric }).strict(),
|
||||
z.object({ $gte: numeric }).strict(),
|
||||
z.object({ $lt: numeric }).strict(),
|
||||
z.object({ $lte: numeric }).strict(),
|
||||
z.object({ $between: z.array(numeric).min(2).max(2) }).strict()
|
||||
] as const);
|
||||
|
||||
// prettier-ignore
|
||||
const nonOperandString = z
|
||||
.string()
|
||||
.regex(/^(?!\$).*/)
|
||||
.min(1);
|
||||
|
||||
// {name: 'Michael'}
|
||||
const literalCond = z.record(nonOperandString, value);
|
||||
|
||||
// { status: { $eq: 1 } }
|
||||
const literalExpressionCond = z.record(nonOperandString, value.or(expressionCond));
|
||||
|
||||
const operandCond = z
|
||||
.object({
|
||||
$and: z.array(literalCond.or(expressionCond).or(literalExpressionCond)).optional(),
|
||||
$or: z.array(literalCond.or(expressionCond).or(literalExpressionCond)).optional()
|
||||
})
|
||||
.strict();
|
||||
|
||||
const literalSchema = literalCond.or(literalExpressionCond);
|
||||
export type LiteralSchemaIn = z.input<typeof literalSchema>;
|
||||
export type LiteralSchema = z.output<typeof literalSchema>;
|
||||
|
||||
export const filterSchema = literalSchema.or(operandCond);
|
||||
export type FilterSchemaIn = z.input<typeof filterSchema>;
|
||||
export type FilterSchema = z.output<typeof filterSchema>;
|
||||
|
||||
const stringArray = z
|
||||
.union([
|
||||
z.string().transform((v) => {
|
||||
if (v.includes(",")) return v.split(",");
|
||||
return v;
|
||||
}),
|
||||
z.array(z.string())
|
||||
])
|
||||
.default([])
|
||||
.transform((v) => (Array.isArray(v) ? v : [v]));
|
||||
|
||||
export const whereRepoSchema = z
|
||||
.preprocess((v: unknown) => {
|
||||
try {
|
||||
return JSON.parse(v as string);
|
||||
} catch {
|
||||
return v;
|
||||
}
|
||||
}, filterSchema)
|
||||
.default({});
|
||||
|
||||
const repoQuerySchema = z.object({
|
||||
limit: z.coerce.number().default(10),
|
||||
offset: z.coerce.number().default(0),
|
||||
sort: z
|
||||
.preprocess(
|
||||
(v: unknown) => {
|
||||
try {
|
||||
return JSON.parse(v as string);
|
||||
} catch {
|
||||
return v;
|
||||
}
|
||||
},
|
||||
z.union([
|
||||
z.string().transform((v) => {
|
||||
if (v.includes(":")) {
|
||||
let [field, dir] = v.split(":") as [string, string];
|
||||
if (!["asc", "desc"].includes(dir)) dir = "asc";
|
||||
return { by: field, dir } as { by: string; dir: "asc" | "desc" };
|
||||
} else {
|
||||
return { by: v, dir: "asc" } as { by: string; dir: "asc" | "desc" };
|
||||
}
|
||||
}),
|
||||
z.object({
|
||||
by: z.string(),
|
||||
dir: z.enum(["asc", "desc"])
|
||||
})
|
||||
])
|
||||
)
|
||||
.default({ by: "id", dir: "asc" }),
|
||||
select: stringArray,
|
||||
with: stringArray,
|
||||
join: stringArray,
|
||||
debug: z
|
||||
.preprocess((v) => {
|
||||
if (["0", "false"].includes(String(v))) return false;
|
||||
return Boolean(v);
|
||||
}, z.boolean())
|
||||
.default(false), //z.coerce.boolean().catch(false),
|
||||
where: whereRepoSchema
|
||||
});
|
||||
|
||||
type RepoQueryIn = z.input<typeof repoQuerySchema>;
|
||||
type RepoQuery = z.output<typeof repoQuerySchema>;
|
||||
78
app/src/data/test-types.ts
Normal file
78
app/src/data/test-types.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
type Field<Type, Required extends true | false> = {
|
||||
_type: Type;
|
||||
_required: Required;
|
||||
};
|
||||
type TextField<Required extends true | false = false> = Field<string, Required> & {
|
||||
_type: string;
|
||||
required: () => TextField<true>;
|
||||
};
|
||||
type NumberField<Required extends true | false = false> = Field<number, Required> & {
|
||||
_type: number;
|
||||
required: () => NumberField<true>;
|
||||
};
|
||||
|
||||
type Entity<Fields extends Record<string, Field<any, any>> = {}> = { name: string; fields: Fields };
|
||||
|
||||
function entity<Fields extends Record<string, Field<any, any>>>(
|
||||
name: string,
|
||||
fields: Fields,
|
||||
): Entity<Fields> {
|
||||
return { name, fields };
|
||||
}
|
||||
|
||||
function text(): TextField<false> {
|
||||
return {} as any;
|
||||
}
|
||||
function number(): NumberField<false> {
|
||||
return {} as any;
|
||||
}
|
||||
|
||||
const field1 = text();
|
||||
const field1_req = text().required();
|
||||
const field2 = number();
|
||||
const user = entity("users", {
|
||||
name: text().required(),
|
||||
bio: text(),
|
||||
age: number(),
|
||||
some: number().required(),
|
||||
});
|
||||
|
||||
type InferEntityFields<T> = T extends Entity<infer Fields>
|
||||
? {
|
||||
[K in keyof Fields]: Fields[K] extends { _type: infer Type; _required: infer Required }
|
||||
? Required extends true
|
||||
? Type
|
||||
: Type | undefined
|
||||
: never;
|
||||
}
|
||||
: never;
|
||||
|
||||
type Prettify<T> = {
|
||||
[K in keyof T]: T[K];
|
||||
};
|
||||
export type Simplify<T> = { [KeyType in keyof T]: T[KeyType] } & {};
|
||||
|
||||
// from https://github.com/type-challenges/type-challenges/issues/28200
|
||||
type Merge<T> = {
|
||||
[K in keyof T]: T[K];
|
||||
};
|
||||
type OptionalUndefined<
|
||||
T,
|
||||
Props extends keyof T = keyof T,
|
||||
OptionsProps extends keyof T = Props extends keyof T
|
||||
? undefined extends T[Props]
|
||||
? Props
|
||||
: never
|
||||
: never,
|
||||
> = Merge<
|
||||
{
|
||||
[K in OptionsProps]?: T[K];
|
||||
} & {
|
||||
[K in Exclude<keyof T, OptionsProps>]: T[K];
|
||||
}
|
||||
>;
|
||||
|
||||
type UserFields = InferEntityFields<typeof user>;
|
||||
type UserFields2 = Simplify<OptionalUndefined<UserFields>>;
|
||||
|
||||
const obj: UserFields2 = { name: "h", age: 1, some: 1 };
|
||||
Reference in New Issue
Block a user