import type { PrimaryFieldType } from "core/config"; import { getPath, invariant, isPlainObject } from "bknd/utils"; export type Primitive = PrimaryFieldType | 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 { expect!: Expect; constructor( public key: Key, public valid: (v: Expect) => boolean, public validate: (e: any, a: any, ctx: CTX) => any, ) {} } export type TExpression = Expression; export function exp( key: Key, valid: (v: Expect) => boolean, validate: (e: Expect, a: unknown, ctx: CTX) => any, ): Expression { invariant(typeof key === "string", "key must be a string"); invariant(key[0] === "$", "key must start with '$'"); invariant(typeof valid === "function", "valid must be a function"); invariant(typeof validate === "function", "validate must be a function"); return new Expression(key, valid, validate); } type Expressions = Expression[]; type ExpressionMap = { [K in Exps[number]["key"]]: Extract extends Expression ? E : never; }; type ExpressionKeys = Exps[number]["key"]; type ExpressionCondition = { [K in keyof ExpressionMap]: { [P in K]: ExpressionMap[K] }; }[keyof ExpressionMap]; function getExpression( expressions: Exps, key: string, ): Expression { const exp = expressions.find((e) => e.key === key); if (!exp) throw new Error(`Expression does not exist: "${key}"`); return exp as any; } type LiteralExpressionCondition = { [key: string]: undefined | Primitive | ExpressionCondition; }; const OperandOr = "$or" as const; type OperandCondition = { [OperandOr]?: LiteralExpressionCondition | ExpressionCondition; }; export type FilterQuery = | LiteralExpressionCondition | OperandCondition; function _convert( $query: FilterQuery, expressions: Exps, path: string[] = [], ): FilterQuery { invariant(typeof $query === "object", "$query must be an object"); const ExpressionConditionKeys = expressions.map((e) => e.key); const keys = Object.keys($query ?? {}); const operands = [OperandOr] as const; const newQuery: FilterQuery = {}; 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( `Given value at "${[...path, key].join(".")}" is invalid, got "${JSON.stringify(value)}"`, ); } } for (const [key, value] of Object.entries($query)) { // skip undefined values if (value === undefined) { continue; } // if $or, convert each value if (key === "$or") { invariant(isPlainObject(value), "$or must be an object"); 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 (isPlainObject(value)) { // 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 expression key: ${ExpressionConditionKeys.join(", ")}.`, ); } } else { throw new Error( `Invalid value at "${[...path, key].join(".")}", got "${JSON.stringify(value)}"`, ); } } return newQuery; } type ValidationResults = { $and: any[]; $or: any[]; keys: Set }; type BuildOptions = { object?: any; exp_ctx?: any; convert?: boolean; value_is_kv?: boolean; }; function _build( _query: FilterQuery, expressions: Exps, options: BuildOptions, ): ValidationResults { const $query = options.convert ? _convert(_query, expressions) : _query; const result: ValidationResults = { $and: [], $or: [], keys: new Set(), }; 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 value at "${[...path, $op].join(".")}", got "${JSON.stringify(expected)}"`, ); } return exp.validate(expected, actual, options.exp_ctx); } // check $and for (const [key, value] of Object.entries($and)) { if (value === undefined) continue; for (const [$op, $v] of Object.entries(value)) { const objValue = options.value_is_kv ? key : getPath(options.object, key); result.$and.push(__validate($op, $v, objValue, [key])); result.keys.add(key); } } // check $or for (const [key, value] of Object.entries($or ?? {})) { const objValue = options.value_is_kv ? key : getPath(options.object, key); for (const [$op, $v] of Object.entries(value)) { result.$or.push(__validate($op, $v, objValue, [key])); result.keys.add(key); } } 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(expressions: Exps) { if (!expressions.some((e) => e.key === "$eq")) { throw new Error("'$eq' expression is required"); } return { convert: (query: FilterQuery) => _convert(query, expressions), build: (query: FilterQuery, options: BuildOptions) => _build(query, expressions, options), validate: (query: FilterQuery, options: BuildOptions) => { const fns = _build(query, expressions, options); return _validate(fns); }, expressions, expressionKeys: expressions.map((e) => e.key) as ExpressionKeys, }; }