Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 194 additions & 0 deletions spec/filter/HaysonFilterAdapter.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
31 changes: 31 additions & 0 deletions src/core/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
25 changes: 20 additions & 5 deletions src/filter/EvalContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Subject = HDict> {
(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<Subject = HDict> {
/**
* The dict to evaluate the filter on.
*/
dict: HDict
dict: Subject

/**
* An optional method used to resolve a dict from a ref.
Expand All @@ -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<Subject>

/**
* 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<Subject>
}
Loading
Loading