Files
bknd/app/src/core/object/SchemaObject.ts
2024-12-10 15:50:41 +01:00

214 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { cloneDeep, get, has, mergeWith, omit, set } from "lodash-es";
import {
Default,
type Static,
type TObject,
getFullPathKeys,
mark,
parse,
stripMark
} from "../utils";
export type SchemaObjectOptions<Schema extends TObject> = {
onUpdate?: (config: Static<Schema>) => void | Promise<void>;
onBeforeUpdate?: (
from: Static<Schema>,
to: Static<Schema>
) => Static<Schema> | Promise<Static<Schema>>;
restrictPaths?: string[];
overwritePaths?: (RegExp | string)[];
forceParse?: boolean;
};
export class SchemaObject<Schema extends TObject> {
private readonly _default: Partial<Static<Schema>>;
private _value: Static<Schema>;
private _config: Static<Schema>;
private _restriction_bypass: boolean = false;
constructor(
private _schema: Schema,
initial?: Partial<Static<Schema>>,
private options?: SchemaObjectOptions<Schema>
) {
this._default = Default(_schema, {} as any) as any;
this._value = initial
? parse(_schema, cloneDeep(initial as any), {
forceParse: this.isForceParse(),
skipMark: this.isForceParse()
})
: this._default;
this._config = Object.freeze(this._value);
}
protected isForceParse(): boolean {
return this.options?.forceParse ?? true;
}
default(): Static<Schema> {
return this._default;
}
private async onBeforeUpdate(from: Static<Schema>, to: Static<Schema>): Promise<Static<Schema>> {
if (this.options?.onBeforeUpdate) {
return this.options.onBeforeUpdate(from, to);
}
return to;
}
get(options?: { stripMark?: boolean }): Static<Schema> {
if (options?.stripMark) {
return stripMark(this._config);
}
return this._config;
}
async set(config: Static<Schema>, noEmit?: boolean): Promise<Static<Schema>> {
const valid = parse(this._schema, cloneDeep(config) as any, {
forceParse: true,
skipMark: this.isForceParse()
});
// regardless of "noEmit" this should always be triggered
const updatedConfig = await this.onBeforeUpdate(this._config, valid);
this._value = updatedConfig;
this._config = Object.freeze(updatedConfig);
if (noEmit !== true) {
await this.options?.onUpdate?.(this._config);
}
return this._config;
}
bypass() {
this._restriction_bypass = true;
return this;
}
throwIfRestricted(object: object): void;
throwIfRestricted(path: string): void;
throwIfRestricted(pathOrObject: string | object): void {
// only bypass once
if (this._restriction_bypass) {
this._restriction_bypass = false;
return;
}
const paths = this.options?.restrictPaths ?? [];
if (Array.isArray(paths) && paths.length > 0) {
for (const path of paths) {
const restricted =
typeof pathOrObject === "string"
? pathOrObject.startsWith(path)
: has(pathOrObject, path);
if (restricted) {
throw new Error(`Path "${path}" is restricted`);
}
}
}
return;
}
async patch(path: string, value: any): Promise<[Partial<Static<Schema>>, Static<Schema>]> {
const current = cloneDeep(this._config);
const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value;
this.throwIfRestricted(partial);
//console.log(getFullPathKeys(value).map((k) => path + "." + k));
// overwrite arrays and primitives, only deep merge objects
// @ts-ignore
const config = mergeWith(current, partial, (objValue, srcValue) => {
if (Array.isArray(objValue) && Array.isArray(srcValue)) {
return srcValue;
}
});
//console.log("overwritePaths", this.options?.overwritePaths);
if (this.options?.overwritePaths) {
const keys = getFullPathKeys(value).map((k) => path + "." + k);
const overwritePaths = keys.filter((k) => {
return this.options?.overwritePaths?.some((p) => {
if (typeof p === "string") {
return k === p;
} else {
return p.test(k);
}
});
});
//console.log("overwritePaths", keys, overwritePaths);
if (overwritePaths.length > 0) {
// filter out less specific paths (but only if more than 1)
const specific =
overwritePaths.length > 1
? overwritePaths.filter((k) =>
overwritePaths.some((k2) => {
//console.log("keep?", { k, k2 }, k2 !== k && k2.startsWith(k));
return k2 !== k && k2.startsWith(k);
})
)
: overwritePaths;
//console.log("specific", specific);
for (const p of specific) {
set(config, p, get(partial, p));
}
}
}
//console.log("patch", { path, value, partial, config, current });
const newConfig = await this.set(config);
return [partial, newConfig];
}
async overwrite(path: string, value: any): Promise<[Partial<Static<Schema>>, Static<Schema>]> {
const current = cloneDeep(this._config);
const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value;
this.throwIfRestricted(partial);
//console.log(getFullPathKeys(value).map((k) => path + "." + k));
// overwrite arrays and primitives, only deep merge objects
// @ts-ignore
const config = set(current, path, value);
//console.log("overwrite", { path, value, partial, config, current });
const newConfig = await this.set(config);
return [partial, newConfig];
}
has(path: string): boolean {
const p = path.split(".");
if (p.length > 1) {
const parent = p.slice(0, -1).join(".");
if (!has(this._config, parent)) {
console.log("parent", parent, JSON.stringify(this._config, null, 2));
throw new Error(`Parent path "${parent}" does not exist`);
}
}
return has(this._config, path);
}
async remove(path: string): Promise<[Partial<Static<Schema>>, Static<Schema>]> {
this.throwIfRestricted(path);
if (!this.has(path)) {
throw new Error(`Path "${path}" does not exist`);
}
const current = cloneDeep(this._config);
const removed = get(current, path) as Partial<Static<Schema>>;
const config = omit(current, path);
const newConfig = await this.set(config);
return [removed, newConfig];
}
}