import { config } from "core"; import { $console, type Static, StringEnum, parse, snakeToPascalWithSpaces, transformObject, } from "core/utils"; import { type Field, PrimaryField, primaryFieldTypes, type TActionContext, type TRenderContext, } from "../fields"; import * as tbbox from "@sinclair/typebox"; const { Type } = tbbox; // @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" })), primary_format: Type.Optional(StringEnum(primaryFieldTypes)), }, { additionalProperties: false, }, ); export type EntityConfig = Static; export type EntityData = Record; export type EntityJSON = ReturnType; /** * 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]; const ENTITY_SYMBOL = Symbol.for("bknd:entity"); /** * @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> = Record>, > { 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(undefined, { format: this.config.primary_format, }), ]; if (fields) { fields.forEach((field) => this.addField(field)); } if (type) this.type = type; this[ENTITY_SYMBOL] = true; } // this is currently required as there could be multiple variants // we need to migrate to a mono repo static isEntity(e: unknown): e is Entity { if (!e) return false; return e[ENTITY_SYMBOL] === true; } 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 ?? "id", dir: this.config.sort_dir ?? "asc", }; } 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); } __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 this.config.name ?? snakeToPascalWithSpaces(this.name); } field(name: string): Field | undefined { return this.getField(name); } hasField(name: string): boolean; hasField(field: Field): boolean; hasField(nameOrField: string | Field): boolean { const name = typeof nameOrField === "string" ? nameOrField : nameOrField.name; return this.fields.findIndex((field) => field.name === name) !== -1; } 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; } // @todo: add tests isValidData( data: EntityData, context: TActionContext, options?: { explain?: boolean; ignoreUnknown?: boolean; }, ): boolean { if (typeof data !== "object") { if (options?.explain) { throw new Error(`Entity "${this.name}" data must be an object`); } } const fields = this.getFillableFields(context, false); if (options?.ignoreUnknown !== true) { const field_names = fields.map((f) => f.name); const given_keys = Object.keys(data); const unknown_keys = given_keys.filter((key) => !field_names.includes(key)); if (unknown_keys.length > 0) { if (options?.explain) { throw new Error( `Entity "${this.name}" data must only contain known keys, unknown: "${unknown_keys}"`, ); } } } for (const field of fields) { if (!field.isValid(data?.[field.name], context)) { $console.warn( "invalid data given for", this.name, context, field.name, data[field.name], ); if (options?.explain) { throw new Error(`Field "${field.name}" has invalid data: "${data[field.name]}"`); } return false; } } return true; } toSchema(options?: { clean: boolean; context?: "create" | "update" }): object { let fields: Field[]; switch (options?.context) { case "create": case "update": fields = this.getFillableFields(options.context); break; default: fields = this.getFields(true); } const _fields = Object.fromEntries(fields.map((field) => [field.name, field])); const schema = Type.Object( transformObject(_fields, (field) => { const fillable = field.isFillable(options?.context); return { title: field.config.label, $comment: field.config.description, $field: field.type, readOnly: !fillable ? true : undefined, ...field.toJsonSchema(), }; }), { additionalProperties: false }, ); return options?.clean ? JSON.parse(JSON.stringify(schema)) : schema; } toTypes() { return { name: this.name, type: this.type, comment: this.config.description, fields: Object.fromEntries(this.getFields().map((field) => [field.name, field.toType()])), }; } toJSON() { return { type: this.type, fields: Object.fromEntries(this.fields.map((field) => [field.name, field.toJSON()])), config: this.config, }; } }