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,199 @@
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>;
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;
}
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()
});
this._value = valid;
this._config = Object.freeze(valid);
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];
}
}

View File

@@ -0,0 +1,96 @@
import { type FilterQuery, type Primitive, exp, isPrimitive, makeValidator } from "./query";
const expressions = [
exp(
"$eq",
(v: Primitive) => isPrimitive(v),
(e, a) => e === a
),
exp(
"$ne",
(v: Primitive) => isPrimitive(v),
(e, a) => e !== a
),
exp(
"$like",
(v: Primitive) => isPrimitive(v),
(e, a) => {
switch (typeof a) {
case "string":
return (a as string).includes(e as string);
case "number":
return (a as number) === Number(e);
case "boolean":
return (a as boolean) === Boolean(e);
default:
return false;
}
}
),
exp(
"$regex",
(v: RegExp | string) => (v instanceof RegExp ? true : typeof v === "string"),
(e: any, a: any) => {
if (e instanceof RegExp) {
return e.test(a);
}
if (typeof e === "string") {
const regex = new RegExp(e);
return regex.test(a);
}
return false;
}
),
exp(
"$isnull",
(v: boolean | 1 | 0) => true,
(e, a) => (e ? a === null : a !== null)
),
exp(
"$notnull",
(v: boolean | 1 | 0) => true,
(e, a) => (e ? a !== null : a === null)
),
exp(
"$in",
(v: (string | number)[]) => Array.isArray(v),
(e: any, a: any) => e.includes(a)
),
exp(
"$notin",
(v: (string | number)[]) => Array.isArray(v),
(e: any, a: any) => !e.includes(a)
),
exp(
"$gt",
(v: number) => typeof v === "number",
(e: any, a: any) => a > e
),
exp(
"$gte",
(v: number) => typeof v === "number",
(e: any, a: any) => a >= e
),
exp(
"$lt",
(v: number) => typeof v === "number",
(e: any, a: any) => a < e
),
exp(
"$lte",
(v: number) => typeof v === "number",
(e: any, a: any) => a <= e
),
exp(
"$between",
(v: [number, number]) =>
Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === "number"),
(e: any, a: any) => e[0] <= a && a <= e[1]
)
];
export type ObjectQuery = FilterQuery<typeof expressions>;
const validator = makeValidator(expressions);
export const convert = (query: ObjectQuery) => validator.convert(query);
export const validate = (query: ObjectQuery, object: Record<string, any>) =>
validator.validate(query, { object, convert: true });

View File

@@ -0,0 +1,209 @@
export type Primitive = string | number | boolean;
export function isPrimitive(value: any): value is Primitive {
return ["string", "number", "boolean"].includes(typeof value);
}
export type BooleanLike = boolean | 0 | 1;
export function isBooleanLike(value: any): value is boolean {
return [true, false, 0, 1].includes(value);
}
export class Expression<Key, Expect = unknown, CTX = any> {
expect!: Expect;
constructor(
public key: Key,
public valid: (v: Expect) => boolean,
public validate: (e: any, a: any, ctx: CTX) => any
) {}
}
export type TExpression<Key, Expect = unknown, CTX = any> = Expression<Key, Expect, CTX>;
export function exp<const Key, const Expect, CTX = any>(
key: Key,
valid: (v: Expect) => boolean,
validate: (e: Expect, a: unknown, ctx: CTX) => any
): Expression<Key, Expect, CTX> {
return new Expression(key, valid, validate);
}
type Expressions = Expression<any, any>[];
type ExpressionMap<Exps extends Expressions> = {
[K in Exps[number]["key"]]: Extract<Exps[number], { key: K }> extends Expression<K, infer E>
? E
: never;
};
type ExpressionCondition<Exps extends Expressions> = {
[K in keyof ExpressionMap<Exps>]: { [P in K]: ExpressionMap<Exps>[K] };
}[keyof ExpressionMap<Exps>];
function getExpression<Exps extends Expressions>(
expressions: Exps,
key: string
): Expression<any, any> {
const exp = expressions.find((e) => e.key === key);
if (!exp) throw new Error(`Expression does not exist: "${key}"`);
return exp as any;
}
type LiteralExpressionCondition<Exps extends Expressions> = {
[key: string]: Primitive | ExpressionCondition<Exps>;
};
const OperandOr = "$or";
type OperandCondition<Exps extends Expressions> = {
[OperandOr]?: LiteralExpressionCondition<Exps> | ExpressionCondition<Exps>;
};
export type FilterQuery<Exps extends Expressions> =
| LiteralExpressionCondition<Exps>
| OperandCondition<Exps>;
function _convert<Exps extends Expressions>(
$query: FilterQuery<Exps>,
expressions: Exps,
path: string[] = []
): FilterQuery<Exps> {
//console.log("-----------------");
const ExpressionConditionKeys = expressions.map((e) => e.key);
const keys = Object.keys($query);
const operands = [OperandOr] as const;
const newQuery: FilterQuery<Exps> = {};
if (keys.some((k) => k.startsWith("$") && !operands.includes(k as any))) {
throw new Error(`Invalid key '${keys}'. Keys must not start with '$'.`);
}
if (path.length > 0 && keys.some((k) => operands.includes(k as any))) {
throw new Error(`Operand ${OperandOr} can only appear at the top level.`);
}
function validate(key: string, value: any, path: string[] = []) {
const exp = getExpression(expressions, key as any);
if (exp.valid(value) === false) {
throw new Error(`Invalid value at "${[...path, key].join(".")}": ${value}`);
}
}
for (const [key, value] of Object.entries($query)) {
// if $or, convert each value
if (key === "$or") {
newQuery.$or = _convert(value, expressions, [...path, key]);
// if primitive, assume $eq
} else if (isPrimitive(value)) {
validate("$eq", value, path);
newQuery[key] = { $eq: value };
// if object, check for expressions
} else if (typeof value === "object") {
// when object is given, check if all keys are expressions
const invalid = Object.keys(value).filter(
(f) => !ExpressionConditionKeys.includes(f as any)
);
if (invalid.length === 0) {
newQuery[key] = {};
// validate each expression
for (const [k, v] of Object.entries(value)) {
validate(k, v, [...path, key]);
newQuery[key][k] = v;
}
} else {
throw new Error(
`Invalid key(s) at "${key}": ${invalid.join(", ")}. Expected expressions.`
);
}
}
}
return newQuery;
}
type ValidationResults = { $and: any[]; $or: any[]; keys: Set<string> };
type BuildOptions = {
object?: any;
exp_ctx?: any;
convert?: boolean;
value_is_kv?: boolean;
};
function _build<Exps extends Expressions>(
_query: FilterQuery<Exps>,
expressions: Exps,
options: BuildOptions
): ValidationResults {
const $query = options.convert ? _convert<Exps>(_query, expressions) : _query;
//console.log("-----------------", { $query });
//const keys = Object.keys($query);
const result: ValidationResults = {
$and: [],
$or: [],
keys: new Set<string>()
};
const { $or, ...$and } = $query;
function __validate($op: string, expected: any, actual: any, path: string[] = []) {
const exp = getExpression(expressions, $op as any);
if (!exp) {
throw new Error(`Expression does not exist: "${$op}"`);
}
if (!exp.valid(expected)) {
throw new Error(`Invalid expected value at "${[...path, $op].join(".")}": ${expected}`);
}
//console.log("found exp", { key: exp.key, expected, actual });
return exp.validate(expected, actual, options.exp_ctx);
}
// check $and
//console.log("$and entries", Object.entries($and));
for (const [key, value] of Object.entries($and)) {
//console.log("$op/$v", Object.entries(value));
for (const [$op, $v] of Object.entries(value)) {
const objValue = options.value_is_kv ? key : options.object[key];
//console.log("--check $and", { key, value, objValue, v_i_kv: options.value_is_kv });
//console.log("validate", { $op, $v, objValue, key });
result.$and.push(__validate($op, $v, objValue, [key]));
result.keys.add(key);
}
//console.log("-", { key, value });
}
// check $or
for (const [key, value] of Object.entries($or ?? {})) {
const objValue = options.value_is_kv ? key : options.object[key];
for (const [$op, $v] of Object.entries(value)) {
//console.log("validate", { $op, $v, objValue });
result.$or.push(__validate($op, $v, objValue, [key]));
result.keys.add(key);
}
//console.log("-", { key, value });
}
//console.log("matches", matches);
return result;
}
function _validate(results: ValidationResults): boolean {
const matches: { $and?: boolean; $or?: boolean } = {
$and: undefined,
$or: undefined
};
matches.$and = results.$and.every((r) => Boolean(r));
matches.$or = results.$or.some((r) => Boolean(r));
return !!matches.$and || !!matches.$or;
}
export function makeValidator<Exps extends Expressions>(expressions: Exps) {
return {
convert: (query: FilterQuery<Exps>) => _convert(query, expressions),
build: (query: FilterQuery<Exps>, options: BuildOptions) =>
_build(query, expressions, options),
validate: (query: FilterQuery<Exps>, options: BuildOptions) => {
const fns = _build(query, expressions, options);
return _validate(fns);
}
};
}