mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 20:37:21 +00:00
public commit
This commit is contained in:
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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user