diff --git a/packages/query-dsl/src/exports.ts b/packages/query-dsl/src/exports.ts index 1d0620a..dd7a312 100644 --- a/packages/query-dsl/src/exports.ts +++ b/packages/query-dsl/src/exports.ts @@ -1,2 +1,3 @@ export * from './query-parser.schemas.js'; export { QueryParser } from './query-parser.js'; +export * from './utils.filter.js'; diff --git a/packages/query-dsl/src/utils.filter.ts b/packages/query-dsl/src/utils.filter.ts new file mode 100644 index 0000000..28907de --- /dev/null +++ b/packages/query-dsl/src/utils.filter.ts @@ -0,0 +1,171 @@ +import type { QueryCondition, QueryConditionNumber, QueryConditionText, QueryFilter } from './query-parser.schemas.js'; + +const getFieldValue = >(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)[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 = >( + 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 = >( + 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 = >(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 = >(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 = >(filter: QueryFilter): ((obj: T) => boolean) => { + return (obj: T) => applyQueryFilter(obj, filter); +}; + +const filterObjects = >(objects: T[], filter: QueryFilter): T[] => { + return objects.filter(createFilterFunction(filter)); +}; + +const isMatch = >(input: T, filter: QueryFilter) => { + const fn = createFilterFunction(filter); + return fn(input); +}; + +export { createFilterFunction, filterObjects, isMatch };