public commit

This commit is contained in:
dswbx
2024-11-16 12:01:47 +01:00
commit 90f80c4280
582 changed files with 49291 additions and 0 deletions

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

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

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

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

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

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

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

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

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

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

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

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