feat: support filtering JS objects using QueryFilter
Some checks failed
Build and release / Build (push) Failing after 1m12s
Build and release / update-release-draft (push) Has been skipped
Build and release / Release (push) Has been skipped

This commit is contained in:
Morten Olsen
2025-12-10 23:30:06 +01:00
parent 904b0f783e
commit c7f9270ef2
2 changed files with 172 additions and 0 deletions

View File

@@ -1,2 +1,3 @@
export * from './query-parser.schemas.js'; export * from './query-parser.schemas.js';
export { QueryParser } from './query-parser.js'; export { QueryParser } from './query-parser.js';
export * from './utils.filter.js';

View File

@@ -0,0 +1,171 @@
import type { QueryCondition, QueryConditionNumber, QueryConditionText, QueryFilter } from './query-parser.schemas.js';
const getFieldValue = <T extends Record<string, unknown>>(obj: T, field: string[]): unknown => {
let current: unknown = obj;
for (const key of field) {
if (current === null || current === undefined) {
return undefined;
}
if (typeof current !== 'object') {
return undefined;
}
current = (current as Record<string, unknown>)[key];
}
return current;
};
/**
* Converts a SQL LIKE pattern to a RegExp.
* Handles % (any characters) and _ (single character) wildcards.
*/
const likeToRegex = (pattern: string): RegExp => {
const escaped = pattern
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape regex special chars
.replace(/%/g, '.*') // % matches any characters
.replace(/_/g, '.'); // _ matches single character
return new RegExp(`^${escaped}$`, 'i');
};
const applyQueryConditionText = <T extends Record<string, unknown>>(
obj: T,
{ field, conditions }: QueryConditionText,
): boolean => {
const value = getFieldValue(obj, field);
if (conditions.equal !== undefined) {
if (conditions.equal === null) {
if (value !== null && value !== undefined) return false;
} else {
if (value !== conditions.equal) return false;
}
}
if (conditions.notEqual !== undefined) {
if (conditions.notEqual === null) {
if (value === null || value === undefined) return false;
} else {
if (value === conditions.notEqual) return false;
}
}
if (conditions.like !== undefined) {
if (typeof value !== 'string') return false;
const regex = likeToRegex(conditions.like);
if (!regex.test(value)) return false;
}
if (conditions.notLike !== undefined) {
if (typeof value !== 'string') return false;
const regex = likeToRegex(conditions.notLike);
if (regex.test(value)) return false;
}
if (conditions.in !== undefined) {
if (!conditions.in.includes(value as string)) return false;
}
if (conditions.notIn !== undefined) {
if (conditions.notIn.includes(value as string)) return false;
}
return true;
};
const applyQueryConditionNumber = <T extends Record<string, unknown>>(
obj: T,
{ field, conditions }: QueryConditionNumber,
): boolean => {
const value = getFieldValue(obj, field);
if (conditions.equals !== undefined) {
if (conditions.equals === null) {
if (value !== null && value !== undefined) return false;
} else {
if (value !== conditions.equals) return false;
}
}
if (conditions.notEquals !== undefined) {
if (conditions.notEquals === null) {
if (value === null || value === undefined) return false;
} else {
if (value === conditions.notEquals) return false;
}
}
if (conditions.greaterThan !== undefined) {
if (typeof value !== 'number' || value <= conditions.greaterThan) return false;
}
if (conditions.greaterThanOrEqual !== undefined) {
if (typeof value !== 'number' || value < conditions.greaterThanOrEqual) return false;
}
if (conditions.lessThan !== undefined) {
if (typeof value !== 'number' || value >= conditions.lessThan) return false;
}
if (conditions.lessThanOrEqual !== undefined) {
if (typeof value !== 'number' || value > conditions.lessThanOrEqual) return false;
}
if (conditions.in !== undefined) {
if (!conditions.in.includes(value as number)) return false;
}
if (conditions.notIn !== undefined) {
if (conditions.notIn.includes(value as number)) return false;
}
return true;
};
const applyQueryCondition = <T extends Record<string, unknown>>(obj: T, options: QueryCondition): boolean => {
switch (options.type) {
case 'text': {
return applyQueryConditionText(obj, options);
}
case 'number': {
return applyQueryConditionNumber(obj, options);
}
default: {
throw new Error(`Unknown filter type`);
}
}
};
const applyQueryFilter = <T extends Record<string, unknown>>(obj: T, filter: QueryFilter): boolean => {
if (filter.type === 'operator') {
if (filter.conditions.length === 0) {
return true;
}
switch (filter.operator) {
case 'or': {
return filter.conditions.some((condition) => applyQueryFilter(obj, condition));
}
case 'and': {
return filter.conditions.every((condition) => applyQueryFilter(obj, condition));
}
}
} else {
return applyQueryCondition(obj, filter);
}
};
const createFilterFunction = <T extends Record<string, unknown>>(filter: QueryFilter): ((obj: T) => boolean) => {
return (obj: T) => applyQueryFilter(obj, filter);
};
const filterObjects = <T extends Record<string, unknown>>(objects: T[], filter: QueryFilter): T[] => {
return objects.filter(createFilterFunction(filter));
};
const isMatch = <T extends Record<string, unknown>>(input: T, filter: QueryFilter) => {
const fn = createFilterFunction(filter);
return fn(input);
};
export { createFilterFunction, filterObjects, isMatch };