/* * This file belongs to Hoist, an application development toolkit * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) * * Copyright © 2026 Extremely Heavy Industries Inc. */ import {XH} from '@xh/hoist/core'; import {LocalDate} from '@xh/hoist/utils/datetime'; import {logWarn, throwIf} from '@xh/hoist/utils/js'; import { castArray, difference, escapeRegExp, first, isArray, isEmpty, isEqual, isNil, isObject, isString, isUndefined, uniq } from 'lodash'; import {FieldType, parseFieldValue} from '../Field'; import {Store} from '../Store'; import {StoreRecord} from '../StoreRecord'; import {Filter} from './Filter'; import {FieldFilterOperator, FieldFilterSpec, FilterTestFn} from './Types'; const _warnedFields = new Set(); /** * Filters by comparing the value of a given field to one or more given candidate values using one * of several supported operators. * * Note that the comparison operators `[<,<=,>,>=]` always return false for null/undefined values, * favoring the behavior of Excel over Javascript's implicit conversion of nullish values to 0. * * Immutable. */ export class FieldFilter extends Filter { static isFieldFilter(obj: unknown): obj is FieldFilter { return obj instanceof FieldFilter; } readonly field: string; readonly op: FieldFilterOperator; readonly value: any; static OPERATORS = [ '=', '!=', '>', '>=', '<', '<=', 'like', 'not like', 'begins', 'not begins', 'ends', 'not ends', 'includes', 'excludes' ]; static ARRAY_OPERATORS = [ '=', '!=', 'like', 'not like', 'begins', 'not begins', 'ends', 'not ends', 'includes', 'excludes' ]; static INCLUDE_LIKE_OPERATORS = ['=', 'like', 'begins', 'ends', 'includes']; static EXCLUDE_LIKE_OPERATORS = ['!=', 'not like', 'excludes']; static RANGE_LIKE_OPERATORS = ['>', '>=', '<', '<=']; /** * Constructor - not typically called by apps - create via {@link parseFilter} instead. * @internal */ constructor({field, op, value, valueType}: FieldFilterSpec) { super(); throwIf(!field, 'FieldFilter requires a field'); throwIf(isUndefined(value), 'FieldFilter requires a value'); throwIf( !FieldFilter.OPERATORS.includes(op), `FieldFilter requires valid "op" value. Operator "${op}" not recognized.` ); throwIf( !FieldFilter.ARRAY_OPERATORS.includes(op) && isArray(value), `Operator "${op}" does not support multiple values. Use a CompoundFilter instead.` ); this.field = isString(field) ? field : field.name; this.op = op; this.value = isArray(value) ? uniq(value).map(it => (valueType ? parseFieldValue(it, valueType) : it)) : valueType ? parseFieldValue(value, valueType) : value; Object.freeze(this); } //----------------- // Overrides //----------------- override getTestFn(store?: Store): FilterTestFn { const {field, op, value} = this; let storeFieldType: FieldType; if (store) { const storeField = store.getField(field); if (!storeField) { if (!_warnedFields.has(field)) { _warnedFields.add(field); logWarn( `Unknown field '${field}' - not found in the target store. This filter will be ignored.`, this ); } return () => true; } storeFieldType = storeField.type; } const opFn = this.getOpFn(op, value, storeFieldType); if (!store) return r => opFn(r[field]); return (r: StoreRecord) => { const val = r.get(field); if (opFn(val)) return true; // Maximize chances of matching. Always pass adds ... if (r.isAdd) return true; // ... and check any differing original value as well const committedVal = r.committedData[field]; return committedVal !== val && opFn(committedVal); }; } private getOpFn( op: FieldFilterOperator, value: any, storeFieldType?: FieldType ): (v: any) => boolean { const RANGE_OPS = FieldFilter.RANGE_LIKE_OPERATORS, ARR_OPS = FieldFilter.ARRAY_OPERATORS; // Day-aware filtering for LocalDate value(s) over a timestamp field - compares against // full calendar-day bounds for range and equality operators (#3338). const firstVal = isArray(value) ? value[0] : value; if ( storeFieldType === 'date' && LocalDate.isLocalDate(firstVal) && (RANGE_OPS.includes(op) || op === '=' || op === '!=') ) { return this.getDayBoundedOpFn(op, value); } // Coerce candidate value(s) to the store field's type when filtering against a store. if (storeFieldType) { const fieldType = storeFieldType === 'tags' ? 'string' : storeFieldType; value = isArray(value) ? value.map(v => parseFieldValue(v, fieldType)) : parseFieldValue(value, fieldType); } if (ARR_OPS.includes(op)) { value = castArray(value); } // Treat null, empty string, and empty array (blank `tags`) alike as "blank". const isBlank = (v: any) => isNil(v) || v === '' || (isArray(v) && isEmpty(v)); // Generate optimized closures for hot-loops, using sets, hoisted conditionals let regExps, lookup: Set, opFn: (v: any) => boolean; switch (op) { case '=': lookup = this.lookupSet(value); opFn = lookup ? v => { if (isBlank(v)) v = null; // A blank filter (empty `value`) matches only blank record values. return (v == null && !lookup.size) || lookup.has(v); } : v => { if (isBlank(v)) v = null; return (v == null && isEmpty(value)) || value.some(it => isEqual(v, it)); }; break; case '!=': lookup = this.lookupSet(value); opFn = lookup ? v => { if (isBlank(v)) v = null; return (v != null || !!lookup.size) && !lookup.has(v); } : v => { if (isBlank(v)) v = null; return ( (v != null || !isEmpty(value)) && !value.some(it => isEqual(v, it)) ); }; break; case '>': opFn = v => !isNil(v) && v > value; break; case '>=': opFn = v => !isNil(v) && v >= value; break; case '<': opFn = v => !isNil(v) && v < value; break; case '<=': opFn = v => !isNil(v) && v <= value; break; case 'like': regExps = value.map(v => new RegExp(escapeRegExp(v), 'i')); opFn = v => regExps.some(re => re.test(v)); break; case 'not like': regExps = value.map(v => new RegExp(escapeRegExp(v), 'i')); opFn = v => regExps.every(re => !re.test(v)); break; case 'begins': regExps = value.map(v => new RegExp('^' + escapeRegExp(v), 'i')); opFn = v => regExps.some(re => re.test(v)); break; case 'not begins': regExps = value.map(v => new RegExp('^' + escapeRegExp(v), 'i')); opFn = v => regExps.every(re => !re.test(v)); break; case 'ends': regExps = value.map(v => new RegExp(escapeRegExp(v) + '$', 'i')); opFn = v => regExps.some(re => re.test(v)); break; case 'not ends': regExps = value.map(v => new RegExp(escapeRegExp(v) + '$', 'i')); opFn = v => regExps.every(re => !re.test(v)); break; case 'includes': lookup = new Set(value); opFn = v => !isNil(v) && v.some(it => lookup.has(it)); break; case 'excludes': lookup = new Set(value); opFn = v => isNil(v) || !v.some(it => lookup.has(it)); break; default: throw XH.exception(`Unknown operator: ${op}`); } return opFn; } // Compare a timestamp against full calendar-day bounds `[dayStart, nextDayStart)` so a // LocalDate value filters by date part. Supports range and equality operators (#3338). private getDayBoundedOpFn( op: FieldFilterOperator, value: LocalDate | LocalDate[] ): (v: any) => boolean { const toMillis = (v: any) => (v instanceof Date ? v.getTime() : v); // Equality ops test the date part against any of the candidate day(s). if (op === '=' || op === '!=') { const ranges = castArray(value).map(d => [ d.date.getTime(), d.nextDay().date.getTime() ]), inAnyDay = (t: number) => ranges.some(([start, next]) => t >= start && t < next); return op === '=' ? v => !isNil(v) && inAnyDay(toMillis(v)) : v => isNil(v) || !inAnyDay(toMillis(v)); } // Range ops compare against the bounds of a single day. const day = value as LocalDate, dayStart = day.date.getTime(), nextDayStart = day.nextDay().date.getTime(); switch (op) { case '>': return v => !isNil(v) && toMillis(v) >= nextDayStart; case '>=': return v => !isNil(v) && toMillis(v) >= dayStart; case '<': return v => !isNil(v) && toMillis(v) < dayStart; case '<=': return v => !isNil(v) && toMillis(v) < nextDayStart; default: throw XH.exception(`Unsupported calendar-day operator: ${op}`); } } override equals(other: Filter): boolean { if (other === this) return true; return ( other instanceof FieldFilter && other.field === this.field && other.op === this.op && (isArray(other.value) && isArray(this.value) ? other.value.length === this.value.length && difference(other.value, this.value).length === 0 : other.value === this.value) ); } override toJSON(): FieldFilterSpec { const {field, op, value, serializedValueType} = this; return {field, op, value, ...(serializedValueType ? {valueType: serializedValueType} : {})}; } override removeFieldFilters(field: string = null): Filter { return !field || this.field === field ? null : this; } override removeFunctionFilters(key: string = null): Filter { return this; } //----------------- // Implementation //----------------- private get serializedValueType(): FieldType { const value = isArray(this.value) ? first(this.value) : this.value; if (value instanceof Date) return 'date'; if (value instanceof LocalDate) return 'localDate'; return undefined; } // Set-based candidate lookup for primitives, where `Set.has` (SameValueZero) matches `isEqual` // semantics - null when any candidate is an object (e.g. Date) and requires the isEqual path. private lookupSet(values: any[]): Set { return values.some(isObject) ? null : new Set(values); } }