diff --git a/spec/filter/HaysonFilterAdapter.spec.ts b/spec/filter/HaysonFilterAdapter.spec.ts new file mode 100644 index 0000000..ecbb61f --- /dev/null +++ b/spec/filter/HaysonFilterAdapter.spec.ts @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2026, J2 Innovations. All Rights Reserved + */ + +import { HFilter } from '../../src/filter/HFilter' +import { HAYSON_FILTER_ADAPTER } from '../../src/filter/HaysonFilterAdapter' +import { HDict } from '../../src/core/dict/HDict' +import { HRef } from '../../src/core/HRef' +import { HMarker } from '../../src/core/HMarker' +import { HNum } from '../../src/core/HNum' +import { HList } from '../../src/core/list/HList' +import { HaysonDict } from '../../src/core/hayson' + +describe('HaysonFilterAdapter', function (): void { + function toJson(dict: HDict): HaysonDict { + return dict.toJSON() + } + + it('matches a marker tag via `has`', function (): void { + const json = toJson(HDict.make({ site: HMarker.make() })) + expect( + new HFilter('site').eval({ + dict: json, + adapter: HAYSON_FILTER_ADAPTER, + }) + ).toBe(true) + expect( + new HFilter('equip').eval({ + dict: json, + adapter: HAYSON_FILTER_ADAPTER, + }) + ).toBe(false) + }) + + it('compares a number with a unit', function (): void { + const json = toJson(HDict.make({ temp: HNum.make(72, '°F') })) + expect( + new HFilter('temp > 70°F').eval({ + dict: json, + adapter: HAYSON_FILTER_ADAPTER, + }) + ).toBe(true) + expect( + new HFilter('temp > 80°F').eval({ + dict: json, + adapter: HAYSON_FILTER_ADAPTER, + }) + ).toBe(false) + // Mismatched units must not match. + expect( + new HFilter('temp == 72°C').eval({ + dict: json, + adapter: HAYSON_FILTER_ADAPTER, + }) + ).toBe(false) + }) + + it('treats present-but-falsy values (0, false, empty string) as having a value', function (): void { + const json = toJson(HDict.make({ count: 0, active: false, name: '' })) + expect( + new HFilter('count').eval({ + dict: json, + adapter: HAYSON_FILTER_ADAPTER, + }) + ).toBe(true) + expect( + new HFilter('active').eval({ + dict: json, + adapter: HAYSON_FILTER_ADAPTER, + }) + ).toBe(true) + expect( + new HFilter('name').eval({ + dict: json, + adapter: HAYSON_FILTER_ADAPTER, + }) + ).toBe(true) + expect( + new HFilter('count == 0').eval({ + dict: json, + adapter: HAYSON_FILTER_ADAPTER, + }) + ).toBe(true) + expect( + new HFilter('active == false').eval({ + dict: json, + adapter: HAYSON_FILTER_ADAPTER, + }) + ).toBe(true) + }) + + it('returns true for `missing` when a property is absent', function (): void { + const json = toJson(HDict.make({ foo: HMarker.make() })) + expect( + new HFilter('not goo').eval({ + dict: json, + adapter: HAYSON_FILTER_ADAPTER, + }) + ).toBe(true) + expect( + new HFilter('not foo').eval({ + dict: json, + adapter: HAYSON_FILTER_ADAPTER, + }) + ).toBe(false) + }) + + it('traverses a nested dict path', function (): void { + const json = toJson( + HDict.make({ nested: HDict.make({ foo: HMarker.make() }) }) + ) + expect( + new HFilter('nested->foo').eval({ + dict: json, + adapter: HAYSON_FILTER_ADAPTER, + }) + ).toBe(true) + expect( + new HFilter('nested->goo').eval({ + dict: json, + adapter: HAYSON_FILTER_ADAPTER, + }) + ).toBe(false) + }) + + it('resolves a ref path via the resolve callback', function (): void { + const pointJson = toJson( + HDict.make({ equipRef: HRef.make('equip'), point: HMarker.make() }) + ) + const equipJson = toJson(HDict.make({ equip: HMarker.make() })) + + const resolve = (ref: HRef): HaysonDict | undefined => + ref.value === 'equip' ? equipJson : undefined + + expect( + new HFilter('equipRef->equip').eval({ + dict: pointJson, + adapter: HAYSON_FILTER_ADAPTER, + resolve, + }) + ).toBe(true) + }) + + it('resolves a list of refs', function (): void { + const pointJson = toJson( + HDict.make({ + pointsRef: HList.make([HRef.make('a'), HRef.make('b')]), + }) + ) + const bJson = toJson(HDict.make({ marker: HMarker.make() })) + + const resolve = (ref: HRef): HaysonDict | undefined => + ref.value === 'b' ? bJson : undefined + + expect( + new HFilter('pointsRef->marker').eval({ + dict: pointJson, + adapter: HAYSON_FILTER_ADAPTER, + resolve, + }) + ).toBe(true) + }) + + it('matches a wildcard ref equality', function (): void { + const json = toJson(HDict.make({ equipRef: HRef.make('equip') })) + expect( + new HFilter('equipRef*==@equip').eval({ + dict: json, + adapter: HAYSON_FILTER_ADAPTER, + }) + ).toBe(true) + expect( + new HFilter('equipRef*==@other').eval({ + dict: json, + adapter: HAYSON_FILTER_ADAPTER, + }) + ).toBe(false) + }) + + it('produces the same result as evaluating against the real HDict', function (): void { + const dict = HDict.make({ + site: HMarker.make(), + temp: HNum.make(72, '°F'), + }) + const json = toJson(dict) + + const filter = new HFilter('site and temp > 70°F') + + expect(filter.eval({ dict })).toBe(true) + expect( + filter.eval({ dict: json, adapter: HAYSON_FILTER_ADAPTER }) + ).toBe(true) + }) +}) diff --git a/src/core/util.ts b/src/core/util.ts index 5d5d604..1ce3d46 100644 --- a/src/core/util.ts +++ b/src/core/util.ts @@ -189,6 +189,37 @@ export function toKind(kind: string): Kind | undefined { } } +/** + * Classify the kind of a raw Hayson value without decoding/allocating it + * into a real `HVal`. + * + * @param val The raw Hayson value to classify. + * @returns The kind of the value or undefined if it's null/undefined. + */ +export function getHaysonValueKind( + val: HaysonVal | undefined +): Kind | undefined { + if (val === null || val === undefined) { + return undefined + } + + switch (typeof val) { + case 'string': + return Kind.Str + case 'number': + return Kind.Number + case 'boolean': + return Kind.Bool + } + + if (Array.isArray(val)) { + return Kind.List + } + + // Support new and old Hayson for classifying values. + return toKind((val as { _kind?: string })._kind ?? '') ?? Kind.Dict +} + /** * Returns a default haystack value for the kind. * diff --git a/src/filter/EvalContext.ts b/src/filter/EvalContext.ts index 69f8ad0..5287289 100644 --- a/src/filter/EvalContext.ts +++ b/src/filter/EvalContext.ts @@ -5,21 +5,25 @@ import { HRef } from '../core/HRef' import { HDict } from '../core/dict/HDict' import { HNamespace } from '../core/HNamespace' +import { FilterAdapter } from './FilterAdapter' -export interface EvalContextResolve { - (ref: HRef): HDict | undefined +export interface EvalContextResolve { + (ref: HRef): Subject | undefined } /** * The evaluation context. * * The context is queried during the evaluation of a node for property values. + * + * `Subject` defaults to `HDict` for backwards compatibility. A different + * backing object can be used by supplying a matching `adapter`. */ -export interface EvalContext { +export interface EvalContext { /** * The dict to evaluate the filter on. */ - dict: HDict + dict: Subject /** * An optional method used to resolve a dict from a ref. @@ -29,10 +33,21 @@ export interface EvalContext { * @param ref The ref to resolve. * @returns The dict for the ref or undefined if not found. */ - resolve?: EvalContextResolve + resolve?: EvalContextResolve /** * An optional namespace used for resolving def related queries. + * + * Def related queries (`isa` and relationship filters) require `dict` + * to be a real `HDict`. They will not match against any other subject. */ namespace?: HNamespace + + /** + * An optional adapter used to evaluate the filter against `dict` when + * it isn't a real `HDict`. + * + * If not defined, `dict` is assumed to be a real `HDict`. + */ + adapter?: FilterAdapter } diff --git a/src/filter/FilterAdapter.ts b/src/filter/FilterAdapter.ts new file mode 100644 index 0000000..9936800 --- /dev/null +++ b/src/filter/FilterAdapter.ts @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026, J2 Innovations. All Rights Reserved + */ + +import { HVal, valueIsKind } from '../core/HVal' +import { Kind } from '../core/Kind' +import { HDict } from '../core/dict/HDict' +import { HRef } from '../core/HRef' +import { HList } from '../core/list/HList' + +/** + * Adapts a `Subject` backing object so a haystack filter can be evaluated + * against it without requiring it to be a real `HDict`/`HVal`. + * + * All methods are stateless (take the value being inspected as an argument) + * so a single adapter instance can be reused for every dict being evaluated. + */ +export interface FilterAdapter { + /** + * Look up a named property on the subject. + * + * @param subject The subject to query. + * @param name The property name. + * @returns The raw property value or undefined/null if not found. + */ + get(subject: Subject, name: string): unknown + + /** + * Returns true if the raw value is of the specified kind. + * + * @param value The raw value to test. + * @param kind The kind to test for. + * @returns True if the value is of the specified kind. + */ + isKind(value: unknown, kind: Kind): boolean + + /** + * Narrow a raw dict-like value so path traversal can continue into it. + * + * @param value The raw value to narrow. + * @returns The value as a subject or undefined if it isn't dict-like. + */ + toDict(value: unknown): Subject | undefined + + /** + * Decode a raw ref-like value into a real `HRef` so it can be resolved. + * + * @param value The raw value to decode. + * @returns The decoded ref or undefined if it isn't ref-like. + */ + toRef(value: unknown): HRef | undefined + + /** + * Iterate the raw items in a list-like value. + * + * @param value The raw list-like value. + * @returns An iterable of the list's raw items. + */ + iterate(value: unknown): Iterable + + /** + * Returns true if the raw value equals the filter literal. + * + * @param value The raw value. + * @param literal The haystack literal parsed from the filter. + * @returns True if the values are equal. + */ + equals(value: unknown, literal: HVal): boolean + + /** + * Compares the raw value against the filter literal. + * + * @param value The raw value. + * @param literal The haystack literal parsed from the filter. + * @returns The sort order as negative, 0, or positive. + */ + compareTo(value: unknown, literal: HVal): number +} + +/** + * The default filter adapter used when an `EvalContext` doesn't specify one. + * + * This replicates the behavior of evaluating a filter directly against real + * `HDict`/`HVal` instances. + */ +export const DEFAULT_FILTER_ADAPTER: FilterAdapter = { + get(subject: HDict, name: string): unknown { + return subject.get(name) + }, + + isKind(value: unknown, kind: Kind): boolean { + return valueIsKind(value, kind) + }, + + toDict(value: unknown): HDict | undefined { + return valueIsKind(value, Kind.Dict) ? value : undefined + }, + + toRef(value: unknown): HRef | undefined { + return valueIsKind(value, Kind.Ref) ? value : undefined + }, + + iterate(value: unknown): Iterable { + return valueIsKind(value, Kind.List) ? value : [] + }, + + equals(value: unknown, literal: HVal): boolean { + return (value as HVal).equals(literal) + }, + + compareTo(value: unknown, literal: HVal): number { + return (value as HVal).compareTo(literal) + }, +} diff --git a/src/filter/HFilter.ts b/src/filter/HFilter.ts index 192bbfc..54d878b 100644 --- a/src/filter/HFilter.ts +++ b/src/filter/HFilter.ts @@ -75,7 +75,7 @@ export class HFilter { * @param context The evaluation context. * @returns The result of the evaluation. */ - static eval(node: Node, context: EvalContext): boolean { + static eval(node: Node, context: EvalContext): boolean { return node.eval(context) } @@ -86,7 +86,7 @@ export class HFilter { * @param context The evaluation context. * @returns The result of the evaluation. */ - eval(context: EvalContext): boolean { + eval(context: EvalContext): boolean { return HFilter.eval(this.node, context) } diff --git a/src/filter/HaysonFilterAdapter.ts b/src/filter/HaysonFilterAdapter.ts new file mode 100644 index 0000000..e2e6ae7 --- /dev/null +++ b/src/filter/HaysonFilterAdapter.ts @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026, J2 Innovations. All Rights Reserved + */ + +import { HVal } from '../core/HVal' +import { Kind } from '../core/Kind' +import { HRef } from '../core/HRef' +import { HaysonDict, HaysonRef, HaysonVal } from '../core/hayson' +import { getHaysonValueKind, makeValue } from '../core/util' +import { FilterAdapter } from './FilterAdapter' + +/** + * A reference `FilterAdapter` that evaluates a haystack filter directly + * against raw Hayson JSON (i.e. the output of `HVal#toJSON()`), without + * requiring the caller to wrap each dict in an `HDict`/`DictStore` first. + * + * Only the properties actually touched by a filter are ever decoded into a + * real `HVal` (for equality/comparison); traversal itself is allocation free. + */ +export const HAYSON_FILTER_ADAPTER: FilterAdapter = { + get(subject: HaysonDict, name: string): unknown { + return subject[name] + }, + + isKind(value: unknown, kind: Kind): boolean { + return getHaysonValueKind(value as HaysonVal) === kind + }, + + toDict(value: unknown): HaysonDict | undefined { + return getHaysonValueKind(value as HaysonVal) === Kind.Dict + ? (value as HaysonDict) + : undefined + }, + + toRef(value: unknown): HRef | undefined { + return getHaysonValueKind(value as HaysonVal) === Kind.Ref + ? HRef.make(value as string | HaysonRef) + : undefined + }, + + iterate(value: unknown): Iterable { + return Array.isArray(value) ? value : [] + }, + + equals(value: unknown, literal: HVal): boolean { + return !!makeValue(value as HaysonVal)?.equals(literal) + }, + + compareTo(value: unknown, literal: HVal): number { + return makeValue(value as HaysonVal)?.compareTo(literal) ?? NaN + }, +} diff --git a/src/filter/Node.ts b/src/filter/Node.ts index 51297aa..a9c2143 100644 --- a/src/filter/Node.ts +++ b/src/filter/Node.ts @@ -12,14 +12,13 @@ import { TokenType } from './TokenType' import { TokenPaths } from './TokenPaths' import { HaysonVal } from '../core/hayson' import { EvalContext } from './EvalContext' -import { HVal } from '../core/HVal' import { valueIsKind } from '../core/HVal' import { Kind } from '../core/Kind' import { HDict } from '../core/dict/HDict' import { HRef } from '../core/HRef' import { HSymbol } from '../core/HSymbol' import { TokenRelationship } from './TokenRelationship' -import { HList } from '../core/list/HList' +import { FilterAdapter, DEFAULT_FILTER_ADAPTER } from './FilterAdapter' /** * AWT Node Implementation for a Haystack Filter. @@ -110,7 +109,20 @@ export function isNode(node: any): node is Node { ) } -const EMPTY_HVAL_ARRAY: readonly HVal[] = Object.freeze([]) +const EMPTY_VALUE_ARRAY: readonly unknown[] = Object.freeze([]) + +/** + * @param context The evaluation context. + * @returns The adapter to use, defaulting to the real `HVal` based implementation. + */ +function getAdapter( + context: EvalContext +): FilterAdapter { + return ( + context.adapter ?? + (DEFAULT_FILTER_ADAPTER as unknown as FilterAdapter) + ) +} /** * Get all the values from a path. @@ -119,48 +131,66 @@ const EMPTY_HVAL_ARRAY: readonly HVal[] = Object.freeze([]) * @param paths The path to find. * @returns The values found. */ -function get(context: EvalContext, paths: string[]): readonly HVal[] { +function get( + context: EvalContext, + paths: string[] +): readonly unknown[] { if (!paths.length) { - return EMPTY_HVAL_ARRAY + return EMPTY_VALUE_ARRAY } - const hvalue = context.dict.get(paths[0]) + const adapter = getAdapter(context) + const value = adapter.get(context.dict, paths[0]) - if (!hvalue) { - return EMPTY_HVAL_ARRAY + if (value === undefined || value === null) { + return EMPTY_VALUE_ARRAY } - let hvalList: HVal[] = [hvalue] + let valueList: unknown[] = [value] for (let i = 1; i < paths.length; ++i) { - const newHvalList: HVal[] = [] + const newValueList: unknown[] = [] + + for (const val of valueList) { + const dict = adapter.toDict(val) - for (const hval of hvalList) { - if (valueIsKind(hval, Kind.Dict)) { + if (dict !== undefined) { // If a dict then simply look up a property. - const newHval = hval.get(paths[i]) + const newVal = adapter.get(dict, paths[i]) - if (newHval) { - newHvalList.push(newHval) + if (newVal !== undefined && newVal !== null) { + newValueList.push(newVal) } } else if (typeof context.resolve === 'function') { - if (valueIsKind(hval, Kind.Ref)) { + const ref = adapter.toRef(val) + + if (ref) { // If the value is a ref then look up the record. Then // resolve record before resolving the value from it. - const newHVal = context.resolve(hval)?.get(paths[i]) - - if (newHVal) { - newHvalList.push(newHVal) + const resolved = context.resolve(ref) + const newVal = + resolved !== undefined + ? adapter.get(resolved, paths[i]) + : undefined + + if (newVal !== undefined && newVal !== null) { + newValueList.push(newVal) } - } else if (valueIsKind(hval, Kind.List)) { + } else if (adapter.isKind(val, Kind.List)) { // If the value is a ref list then iterate through each // ref in the list. - for (const val of hval) { - if (valueIsKind(val, Kind.Ref)) { - const newHVal = context.resolve(val)?.get(paths[i]) - - if (newHVal) { - newHvalList.push(newHVal) + for (const item of adapter.iterate(val)) { + const itemRef = adapter.toRef(item) + + if (itemRef) { + const resolved = context.resolve(itemRef) + const newVal = + resolved !== undefined + ? adapter.get(resolved, paths[i]) + : undefined + + if (newVal !== undefined && newVal !== null) { + newValueList.push(newVal) } } } @@ -168,14 +198,14 @@ function get(context: EvalContext, paths: string[]): readonly HVal[] { } } - hvalList = newHvalList + valueList = newValueList - if (!hvalList.length) { + if (!valueList.length) { break } } - return hvalList + return valueList } /** @@ -226,7 +256,7 @@ export interface Node { * @param context The evaluation context. * @returns the resultant evaluation. */ - eval(context: EvalContext): boolean + eval(context: EvalContext): boolean } /** @@ -270,7 +300,7 @@ export abstract class ParentNode implements Node { this.nodes.forEach((node: Node): void => node.accept(visitor)) } - abstract eval(context: EvalContext): boolean + abstract eval(context: EvalContext): boolean } /** @@ -318,7 +348,7 @@ export abstract class LeafNode implements Node { acceptChildNodes(): void {} - abstract eval(context: EvalContext): boolean + abstract eval(context: EvalContext): boolean } /** @@ -345,7 +375,7 @@ export class CondOrNode extends ParentNode { visitor.visitCondOr(this) } - eval(context: EvalContext): boolean { + eval(context: EvalContext): boolean { for (const condAnd of this.$nodes) { if (condAnd.eval(context)) { return true @@ -388,7 +418,7 @@ export class CondAndNode extends ParentNode { visitor.visitCondAnd(this) } - eval(context: EvalContext): boolean { + eval(context: EvalContext): boolean { if (this.$nodes.length) { for (const node of this.$nodes) { if (!node.eval(context)) { @@ -425,7 +455,7 @@ export class ParensNode extends ParentNode { visitor.visitParens(this) } - eval(context: EvalContext): boolean { + eval(context: EvalContext): boolean { return this.$nodes[0].eval(context) } } @@ -454,7 +484,7 @@ export class HasNode extends LeafNode { visitor.visitHas(this) } - eval(context: EvalContext): boolean { + eval(context: EvalContext): boolean { return !!get(context, this.path.paths).length } } @@ -483,7 +513,7 @@ export class MissingNode extends LeafNode { visitor.visitMissing(this) } - eval(context: EvalContext): boolean { + eval(context: EvalContext): boolean { return !get(context, this.path.paths).length } } @@ -528,39 +558,40 @@ export class CmpNode extends LeafNode { visitor.visitCmp(this) } - eval(context: EvalContext): boolean { + eval(context: EvalContext): boolean { + const adapter = getAdapter(context) const value = this.val.value for (const pathValue of get(context, this.path.paths)) { - if (pathValue.isKind(value.getKind())) { + if (adapter.isKind(pathValue, value.getKind())) { switch (this.cmpOp.type) { case TokenType.equals: - if (pathValue.equals(value)) { + if (adapter.equals(pathValue, value)) { return true } break case TokenType.notEquals: - if (!pathValue.equals(value)) { + if (!adapter.equals(pathValue, value)) { return true } break case TokenType.greaterThan: - if (pathValue.compareTo(value) === 1) { + if (adapter.compareTo(pathValue, value) === 1) { return true } break case TokenType.greaterThanOrEqual: - if (pathValue.compareTo(value) >= 0) { + if (adapter.compareTo(pathValue, value) >= 0) { return true } break case TokenType.lessThan: - if (pathValue.compareTo(value) === -1) { + if (adapter.compareTo(pathValue, value) === -1) { return true } break case TokenType.lessThanOrEqual: - if (pathValue.compareTo(value) <= 0) { + if (adapter.compareTo(pathValue, value) <= 0) { return true } break @@ -596,7 +627,12 @@ export class IsANode extends LeafNode { visitor.visitIsA(this) } - eval(context: EvalContext): boolean { + eval(context: EvalContext): boolean { + // isa is only supported when evaluating against a real HDict/namespace. + if (!valueIsKind(context.dict, Kind.Dict)) { + return false + } + const value = this.val.value as HSymbol return !!context?.namespace?.reflect(context.dict)?.fits(value) } @@ -664,17 +700,30 @@ export class RelationshipNode extends LeafNode { visitor.visitRelationship(this) } - eval(context: EvalContext): boolean { + eval(context: EvalContext): boolean { + // Relationships are only supported when evaluating against a real HDict/namespace. + if (!valueIsKind(context.dict, Kind.Dict)) { + return false + } + const relName = this.rel.relationship const relTerm = this.term?.value as HSymbol const ref = this.ref?.value as HRef + const resolve = context.resolve return !!context?.namespace?.hasRelationship({ subject: context.dict, relName, relTerm, ref, - resolve: context?.resolve, + resolve: resolve + ? (dictRef: HRef): HDict | undefined => { + const resolved = resolve(dictRef) + return valueIsKind(resolved, Kind.Dict) + ? resolved + : undefined + } + : undefined, }) } } @@ -707,7 +756,8 @@ export class WildcardEqualsNode extends LeafNode { visitor.visitWildcardEquals(this) } - eval(context: EvalContext): boolean { + eval(context: EvalContext): boolean { + const adapter = getAdapter(context) const wcRef = this.ref.value as HRef const paths = this.id.paths @@ -717,12 +767,13 @@ export class WildcardEqualsNode extends LeafNode { // Keep resolving until we find the reference we're looking. // Please note, this is purposely short circuited to only use the first value. // Therefore ref lists are not supported in this context. - let ref = get(context, paths)[0] + let rawRef = get(context, paths)[0] + let ref = adapter.toRef(rawRef) let matched = false - while (valueIsKind(ref, Kind.Ref)) { - if (ref.equals(wcRef)) { + while (ref) { + if (adapter.equals(ref, wcRef)) { matched = true break } @@ -736,8 +787,9 @@ export class WildcardEqualsNode extends LeafNode { const dict = context.resolve(ref) - if (dict) { - ref = get({ ...context, dict }, paths)[0] + if (dict !== undefined) { + rawRef = get({ ...context, dict }, paths)[0] + ref = adapter.toRef(rawRef) } else { break } diff --git a/src/index.ts b/src/index.ts index b8e3c48..d1727a3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,8 @@ export * from './filter/HFilter' export * from './filter/GenerateHaystackFilterVisitor' export * from './filter/GenerateHaystackFilterV3Visitor' export * from './filter/EvalContext' +export * from './filter/FilterAdapter' +export * from './filter/HaysonFilterAdapter' export * from './filter/Node' export * from './filter/Token' export * from './filter/TokenObj'