philipgreat's picture
Deploy traceable order management playground
aa89cb5 verified
Raw History Blame Contribute Delete
9.86 kB
/** Local UI-search normalization. This is deliberately not a federation decoder. */
import { OrderBy, SelectQuery } from './ast';
export type SearchValueType = 'string' | 'number' | 'integer' | 'boolean' | 'date' | 'timestamp' | 'decimal' | 'unsupported';
export interface SearchModel {
fields: Readonly<Record<string, SearchValueType>>;
relations: Readonly<Record<string, string>>;
}
/** Model-derived scalar bindings. Native query keys are trusted, not input JSON. */
export interface GeneratedSearchModel extends SearchModel {
queryFields: Readonly<Record<string, string>>;
}
/**
* Default generated binding for scalar fields. Known relation paths require a
* trusted application binding via mergeDynamicSearch, which owns authorization.
* No relation predicate is guessed or silently dropped.
*/
export function mergeGeneratedScalarSearch(
base: SelectQuery,
source: unknown,
models: Readonly<Record<string, GeneratedSearchModel>>,
warn?: (warning: DynamicSearchWarning) => void,
): { query: SelectQuery; warnings: DynamicSearchWarning[] } {
const field = (path: string): string => {
if (path.includes('.')) throw new Error('Dynamic search requires a trusted relation binding');
const fields = models[base.entity]?.queryFields;
if (!fields || !Object.prototype.hasOwnProperty.call(fields, path)) {
throw new Error('Missing generated scalar search binding');
}
return fields[path];
};
return mergeDynamicSearch(base, source, models, {
filter: (path, predicate) => {
const [operator, value] = Object.entries(predicate as Record<string, unknown>)[0];
if (value === null && operator !== '$eq' && operator !== '$ne') {
throw new Error('Null supports only equality predicates');
}
return { [field(path)]: predicate };
},
order: (path, direction) => direction === 'asc' ? OrderBy.asc(field(path)) : OrderBy.desc(field(path)),
}, warn);
}
export interface DynamicSearchWarning {
code: 'DYNAMIC_SEARCH_UNKNOWN_FIELD';
entity: string;
clause: 'FILTER' | 'ORDER_BY';
fieldPath: string;
}
export interface DynamicSearchInput {
filter?: Record<string, unknown>;
orderBy?: Array<{ field: string; direction: 'asc' | 'desc' }>;
}
/** Trusted application adapters compile canonical model paths using native query APIs. */
export interface DynamicSearchBindings {
filter(path: string, predicate: unknown): unknown;
order(path: string, direction: 'asc' | 'desc'): OrderBy;
}
/** Add validated search clauses to an existing scoped query; never replace its policies. */
export function mergeDynamicSearch(
base: SelectQuery,
source: unknown,
models: Readonly<Record<string, SearchModel>>,
bindings: DynamicSearchBindings,
warn: (warning: DynamicSearchWarning) => void = warning => console.warn({ ...warning, fieldPath: '<omitted>' }),
): { query: SelectQuery; warnings: DynamicSearchWarning[] } {
const normalized = normalizeDynamicSearch(source, base.entity, models, () => {});
const filters = Object.entries(normalized.search.filter ?? {})
.map(([path, predicate]) => bindings.filter(path, predicate));
const orders = (normalized.search.orderBy ?? []).map(order => bindings.order(order.field, order.direction));
if (filters.some(filter => !object(filter))) throw new Error('Invalid trusted search filter binding');
if (orders.some(order => !(order instanceof OrderBy))) throw new Error('Invalid trusted search order binding');
const query = base.clone();
// Native providers also accept legacy generated _filters; retain those constraints explicitly.
const legacy = (base as SelectQuery & { _filters?: unknown[] })._filters ?? [];
const existing = [...legacy, ...(base.filterCondition ? [base.filterCondition] : [])];
if (filters.length || legacy.length) query.filter({ $and: [...existing, ...filters] });
// Append rather than replace stable default ordering. The application owns primary sort policy.
query.orderItems.push(...orders);
normalized.warnings.forEach(warning => warn({ ...warning }));
return { query, warnings: normalized.warnings };
}
const own = (object: object, key: string) => Object.prototype.hasOwnProperty.call(object, key);
const object = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const forbidden = new Set(['__proto__', 'prototype', 'constructor']);
const operators = new Set(['$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$in', '$notIn', '$contains']);
/**
* Metadata, resource limits and warning sink must come from trusted application setup.
* No caller-supplied context, limits, SQL, subqueries or arbitrary AST are accepted.
* Returned clauses still require the normal query authorization/execution pipeline.
*/
export function normalizeDynamicSearch(
source: string | unknown,
entity: string,
models: Readonly<Record<string, SearchModel>>,
warn: (warning: DynamicSearchWarning) => void = warning => console.warn({ ...warning, fieldPath: '<omitted>' }),
maxClauses = 100,
): { search: DynamicSearchInput; warnings: DynamicSearchWarning[] } {
if (!Number.isSafeInteger(maxClauses) || maxClauses < 1) throw new Error('Invalid search limit');
if (!own(models, entity)) throw new Error('Unknown search entity');
let input: unknown;
try { input = typeof source === 'string' ? JSON.parse(source) : source; }
catch { throw new Error('Dynamic search requires valid JSON'); }
if (!object(input) || Object.keys(input).some(key => !['filter', 'orderBy'].includes(key))) {
throw new Error('Unsupported dynamic search input or control');
}
if (input.filter !== undefined && !object(input.filter)) throw new Error('Invalid search filter');
if (input.orderBy !== undefined && !Array.isArray(input.orderBy)) throw new Error('Invalid search ordering');
const filters = Object.entries((input.filter ?? {}) as Record<string, unknown>);
const orders = (input.orderBy ?? []) as unknown[];
if (filters.length + orders.length > maxClauses) throw new Error('Dynamic search exceeds clause limit');
const warnings: DynamicSearchWarning[] = [];
const search: DynamicSearchInput = { filter: Object.create(null), orderBy: [] };
function fieldType(path: string): SearchValueType | undefined {
const segments = path.split('.');
if (segments.length > 16 || segments.some(s => !s || s.startsWith('$') || forbidden.has(s))) {
throw new Error('Invalid dynamic search field path');
}
let model = models[entity];
for (const segment of segments.slice(0, -1)) {
if (!own(model.relations, segment)) return undefined;
const target = model.relations[segment];
if (!own(models, target)) throw new Error('Invalid trusted search relation metadata');
model = models[target];
}
const field = segments[segments.length - 1];
return own(model.fields, field) ? model.fields[field] : undefined;
}
function missing(path: string, clause: DynamicSearchWarning['clause']) {
warnings.push({ code: 'DYNAMIC_SEARCH_UNKNOWN_FIELD', entity, clause, fieldPath: path });
}
function scalar(value: unknown, type: SearchValueType) {
if (value === null) return;
const valid = type === 'date'
? typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) && !value.startsWith('0000-')
&& Number.isFinite(Date.parse(value + 'T00:00:00Z'))
&& new Date(value + 'T00:00:00Z').toISOString().slice(0, 10) === value
: type === 'decimal'
? (typeof value === 'string' && /^[+-]?\d+(?:\.\d+)?$/.test(value))
|| (typeof value === 'number' && Number.isFinite(value))
: type === 'integer' || type === 'timestamp' ? typeof value === 'number' && Number.isSafeInteger(value)
: type === 'number' ? typeof value === 'number' && Number.isFinite(value)
: typeof value === type;
if (!valid) throw new Error('Invalid value for known search field');
}
for (const [path, predicate] of filters) {
const entries = object(predicate) ? Object.entries(predicate) : [['$eq', predicate]];
if (entries.length !== 1 || !operators.has(entries[0][0] as string)) {
throw new Error('Unsupported or malformed dynamic search operator');
}
const [operator, value] = entries[0];
if ((operator === '$in' || operator === '$notIn') && (!Array.isArray(value) || value.length > 1000)) {
throw new Error('Invalid or oversized search value list');
}
const type = fieldType(path);
if (type === undefined) { missing(path, 'FILTER'); continue; }
if (type === 'unsupported') throw new Error('Known search field has no supported scalar binding');
if (operator === '$contains' && type !== 'string') throw new Error('String operator requires a string field');
if (Array.isArray(value)) {
if (operator !== '$in' && operator !== '$notIn') throw new Error('Unexpected search value list');
value.forEach(item => scalar(item, type));
} else scalar(value, type);
search.filter![path] = { [operator as string]: Array.isArray(value) ? [...value] : value };
}
for (const order of orders) {
if (!object(order) || Object.keys(order).some(key => !['field', 'direction'].includes(key))
|| typeof order.field !== 'string' || !['asc', 'desc'].includes(order.direction as string)) {
throw new Error('Invalid dynamic search ordering');
}
const type = fieldType(order.field);
if (type === undefined) { missing(order.field, 'ORDER_BY'); continue; }
if (type === 'unsupported') throw new Error('Known search field has no supported scalar binding');
search.orderBy!.push({ field: order.field, direction: order.direction as 'asc' | 'desc' });
}
// Publish diagnostics only after all fatal validation completes; never log source values.
warnings.forEach(warning => warn({ ...warning }));
return { search, warnings };
}