@travetto/model-query
Version:
Datastore abstraction for advanced query support.
321 lines (281 loc) • 9.61 kB
text/typescript
/* eslint @typescript-eslint/no-unused-vars: ["error", { "args": "none"} ] */
import { DataUtil, SchemaRegistry, ValidationResultError, ValidationError } from '@travetto/schema';
import { Class } from '@travetto/runtime';
import { ModelQuery, Query, PageableModelQuery } from './model/query.ts';
import { TypeUtil } from './internal/types.ts';
type SimpleType = keyof typeof TypeUtil.OPERATORS;
interface State {
path: string;
collect(element: string, message: string): void;
extend(path: string): State;
log(err: string): void;
}
interface ProcessingHandler {
preMember?(state: State, value: unknown): boolean;
onSimpleType(state: State, type: SimpleType, value: unknown, isArray: boolean): void;
onComplexType?(state: State, cls: Class, value: unknown, isArray: boolean): boolean | undefined;
}
// const TOP_LEVEL_OPS = new Set(['$and', '$or', '$not']);
const SELECT = 'select';
const WHERE = 'where';
const SORT = 'sort';
// const GROUP_BY = 'groupBy';
const MULTIPLE_KEYS_ALLOWED = new Set([
'$maxDistance', '$gt',
'$minDistance', '$lt',
'$near'
]);
/**
* Query verification service. Used to verify the query is valid before running.
*/
export class QueryVerifier {
/**
* Internal mapping for various clauses
*/
static #mapping = [
[SELECT, 'processSelectClause'] as const,
[WHERE, 'processWhereClause'] as const,
[SORT, 'processSortClause'] as const,
];
/**
* Handle generic clauses
*/
static processGenericClause<T>(state: State, cls: Class<T>, val: object, handler: ProcessingHandler): void {
const view = SchemaRegistry.getViewSchema(cls);
if (val === undefined || val === null) {
state.log('Value cannot be undefined or null');
return;
}
if (handler.preMember && handler.preMember(state, val)) {
return;
}
for (const [key, value] of Object.entries(val)) {
// Validate value is correct, and key is valid
if (value === undefined || value === null) {
// state.log(`${key} cannot be undefined or null`);
continue;
}
if (handler.preMember && handler.preMember(state, value)) {
continue;
}
if (!(key in view.schema)) {
state.log(`Unknown member ${key} of ${cls.name}`);
continue;
}
// Find field
const field = view.schema[key];
const op = TypeUtil.getDeclaredType(field);
// If a simple operation
if (op) {
handler.onSimpleType(state.extend(key), op, value, field.array);
} else {
// Otherwise recurse
const subCls = field.type;
const subVal = value;
if (handler.onComplexType && handler.onComplexType(state, subCls, subVal, field.array)) {
continue;
}
this.processGenericClause(state.extend(key), subCls, subVal, handler);
}
}
}
/**
* Ensure types match
*/
static typesMatch(declared: string, actual: string | undefined): boolean {
return declared === actual;
}
/**
* Check operator clause
*/
static checkOperatorClause(state: State, declaredType: SimpleType, value: unknown, allowed: Record<string, Set<string>>, isArray: boolean): void {
if (isArray) {
if (Array.isArray(value)) {
// Handle array literal
for (const el of value) {
this.checkOperatorClause(state, declaredType, el, allowed, false);
}
return;
}
}
if (!DataUtil.isPlainObject(value)) {
// Handle literal
const actualType = TypeUtil.getActualType(value);
if (!this.typesMatch(declaredType, actualType)) {
state.log(`Operator clause only supports types of ${declaredType}, not ${actualType}`);
}
return;
} else {
const keys = Object.keys(value).toSorted();
if (keys.length !== 1 && !(
keys.length >= 2 &&
MULTIPLE_KEYS_ALLOWED.has(keys[0]) ||
MULTIPLE_KEYS_ALLOWED.has(keys[1])
)) {
state.log('One and only one operation may be specified in an operator clause');
return;
}
}
// Should only be one?
for (const [k, v] of Object.entries(value)) {
if (k === '$all' || k === '$elemMatch' || k === '$in' || k === '$nin') {
if (!Array.isArray(v)) {
state.log(`${k} operator requires comparison to be an array, not ${typeof v}`);
return;
} else if (v.length === 0) {
state.log(`${k} operator requires comparison to be a non-empty array`);
return;
}
for (const el of v) {
const elAct = TypeUtil.getActualType(el);
if (!this.typesMatch(declaredType, elAct)) {
state.log(`${k} operator requires all values to be ${declaredType}, but ${elAct} was found`);
return;
}
}
} else if (!(k in allowed)) {
state.log(`Operation ${k}, not allowed for field of type ${declaredType}`);
} else {
const actualSubType = TypeUtil.getActualType(v)!;
if (!allowed[k].has(actualSubType)) {
state.log(`Passed in value ${actualSubType} mismatches with expected type(s) ${Array.from(allowed[k])}`);
}
}
}
}
/**
* Process where clause
*/
static processWhereClause<T>(st: State, cls: Class<T>, passed: object): void {
return this.processGenericClause(st, cls, passed, {
preMember: (state: State, value: Record<string, unknown>) => {
const keys = Object.keys(value);
const firstKey = keys[0];
if (!firstKey) {
return false;
}
const sub = value[firstKey];
// Verify boolean clauses
if (firstKey === '$and' || firstKey === '$or') {
if (!Array.isArray(sub)) {
state.log(`${firstKey} requires the value to be an array`);
} else {
// Iterate
for (const el of sub) {
this.processWhereClause(state, cls, el);
}
return true;
}
} else if (firstKey === '$not') {
if (DataUtil.isPlainObject(sub)) {
this.processWhereClause(state, cls, sub);
return true;
} else {
state.log(`${firstKey} requires the value to be an object`);
}
}
return false;
},
onSimpleType: (state: State, type: SimpleType, value: unknown, isArray: boolean) => {
this.checkOperatorClause(state, type, value, TypeUtil.OPERATORS[type], isArray);
},
onComplexType: (state: State, subCls: Class<T>, subVal: T, isArray: boolean): boolean => false
});
}
/**
* Handle group by clause
*/
static processGroupByClause(state: State, value: object): void {
// TODO: Handle group by?
}
/**
* Handle sort clause
*/
static processSortClause<T>(st: State, cls: Class<T>, passed: object): void {
return this.processGenericClause(st, cls, passed, {
onSimpleType: (state, type, value) => {
if (value === 1 || value === -1 || typeof value === 'boolean') {
return;
}
state.log(`Only true, false -1, and 1 are allowed for sorting, not ${JSON.stringify(value)}`);
}
});
}
/**
* Handle select clause
*/
static processSelectClause<T>(st: State, cls: Class<T>, passed: object): void {
return this.processGenericClause(st, cls, passed, {
onSimpleType: (state, type, value) => {
const actual = TypeUtil.getActualType(value);
if (actual === 'number' || actual === 'boolean') {
if (value === 1 || value === 0 || actual === 'boolean') {
return;
}
state.log('Only true, false 0, and 1 are allowed for including/excluding fields');
} else {
/* if (actual === 'string') {
if (!/[A-Za-z_$0-9]/.test(value)) {
state.log(`Only A-Z, a-z, 0-9, '$' and '_' are allowed in aliases for selecting fields`);
return;
}
return;
} else if (isPlainObject(value)) {
if (!('alias' in value)) {
state.log('Alias is a required field for selecting');
return;
} else {
// or { alias: string, calc?: string }
// console.log('Yay');
}
*/}
state.log('Only true, false, 0, and 1 are allowed for selecting fields');
}
});
}
/**
* Verify the query
*/
static verify<T>(cls: Class<T>, query?: ModelQuery<T> | Query<T> | PageableModelQuery<T>): void {
if (!query) {
return;
}
const errors: ValidationError[] = [];
const state = {
path: '',
collect(path: string, message: string): void {
errors.push({ message: `${path}: ${message}`, path, kind: 'model' });
},
log(err: string): void {
this.collect(this.path, err);
},
extend<S extends { path: string }>(this: S, sub: string): S {
return { ...this, path: !this.path ? sub : `${this.path}.${sub}` };
}
};
// Check all the clauses
for (const [key, fn] of this.#mapping) {
if (key === 'sort') {
continue;
}
if (!(key in query)
|| query[key] === undefined
|| query[key] === null
) {
continue;
}
const val = query[key];
const subState = state.extend(key);
if (Array.isArray(val)) {
for (const el of val) {
this[fn](subState, cls, el);
}
} else if (typeof val !== 'string') {
this[fn](subState, cls, val);
}
}
if (errors.length) {
throw new ValidationResultError(errors);
}
}
}