From daf740d7731f3db063e7517dd44c560b8d74eac8 Mon Sep 17 00:00:00 2001 From: Gareth Johnson <75035963+garethj2@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:24:01 +0100 Subject: [PATCH 1/5] Add relativize haystack filter for target --- package-lock.json | 2 +- package.json | 2 +- spec/filter/relativize.spec.ts | 951 +++++++++++++++++++++++++++++++++ spec/filter/util.spec.ts | 145 ----- src/filter/relativize.ts | 368 +++++++++++++ src/filter/util.ts | 143 ----- src/index.ts | 2 +- 7 files changed, 1322 insertions(+), 291 deletions(-) create mode 100644 spec/filter/relativize.spec.ts delete mode 100644 spec/filter/util.spec.ts create mode 100644 src/filter/relativize.ts delete mode 100644 src/filter/util.ts diff --git a/package-lock.json b/package-lock.json index 6d16c27..0983b5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "3.0.9", "license": "BSD-3-Clause", "devDependencies": { - "@types/jest": "^29.5.2", + "@types/jest": "^29.5.14", "@types/lodash": "^4.14.195", "@types/luxon": "^3.3.0", "@types/moment-range": "^4.0.0", diff --git a/package.json b/package.json index 802c06f..e370e4d 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "unsafe-perm": true }, "devDependencies": { - "@types/jest": "^29.5.2", + "@types/jest": "^29.5.14", "@types/lodash": "^4.14.195", "@types/luxon": "^3.3.0", "@types/moment-range": "^4.0.0", diff --git a/spec/filter/relativize.spec.ts b/spec/filter/relativize.spec.ts new file mode 100644 index 0000000..e7a4911 --- /dev/null +++ b/spec/filter/relativize.spec.ts @@ -0,0 +1,951 @@ +/* + * Copyright (c) 2025, J2 Innovations. All Rights Reserved + */ + +import { HDict } from '../../src/core/dict/HDict' +import { HRef } from '../../src/core/HRef' +import { + makeRelativeHaystackFilter, + makeRelativeHaystackFilterForTarget, + RelativizeResolveFunc, +} from '../../src/filter/relativize' +import { HMarker } from '../../src/core/HMarker' +import { HStr } from '../../src/core/HStr' +import { HSymbol } from '../../src/core/HSymbol' +import { HNamespace } from '../../src/core/HNamespace' + +describe('haystack', () => { + describe('makeRelativeHaystackFilter()', () => { + it('returns a haystack filter with dis', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + dis: 'an equip', + equip: HMarker.make(), + }) + ) + ).toEqual('equip and dis == "an equip"') + }) + + it('returns a haystack filter without a display name with the option disabled', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + dis: 'an equip', + equip: HMarker.make(), + }), + { + useDisplayName: false, + } + ) + ).toEqual('equip') + }) + + it('returns a haystack filter with a nav name', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + navName: 'an equip', + equip: HMarker.make(), + }) + ) + ).toEqual('equip and navName == "an equip"') + }) + + it('returns a haystack filter with a point kind', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + navName: 'a point', + point: HMarker.make(), + kind: HStr.make('Number'), + }) + ) + ).toEqual('point and navName == "a point" and kind == "Number"') + }) + + it('returns a haystack filter with a point kind and path', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + navName: 'a point', + point: HMarker.make(), + kind: HStr.make('Number'), + }), + { prefixPath: ['foo', 'bar'] } + ) + ).toEqual( + 'foo->bar->point and foo->bar->navName == "a point" and foo->bar->kind == "Number"' + ) + }) + + it('returns a haystack filter without the point kind and the option disabled', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + navName: 'a point', + point: HMarker.make(), + kind: HStr.make('Number'), + }), + { + useKind: false, + } + ) + ).toEqual('point and navName == "a point"') + }) + + it('returns a haystack filter with an absolute id as a fallback', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + }) + ) + ).toEqual('id == @id') + }) + + it('returns a haystack filter without excluded tags', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + dis: 'an equip', + equip: HMarker.make(), + his: HMarker.make(), + aux: HMarker.make(), + }) + ) + ).toEqual('equip and dis == "an equip"') + }) + + it('returns a haystack filter without connPoint subtype tags', () => { + const mockNamespace = { + allSubTypesOf: () => [ + HDict.make({ def: HSymbol.make('demoPoint') }), + ], + } as unknown as HNamespace + + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + dis: 'an equip', + equip: HMarker.make(), + demoPoint: HMarker.make(), + }), + { + namespace: mockNamespace, + } + ) + ).toEqual('equip and dis == "an equip"') + }) + + it('returns a haystack filter without custom excluded tags', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + dis: 'an equip', + equip: HMarker.make(), + customTag: HMarker.make(), + }), + { + getExcludedTags: () => ['customTag'], + } + ) + ).toEqual('equip and dis == "an equip"') + }) + }) // makeRelativeHaystackFilter() + + describe('makeRelativeHaystackFilterForTarget()', () => { + describe('for equip and a point', () => { + let equip: HDict + let point: HDict + + beforeAll(() => { + equip = new HDict({ + id: HRef.make('equipId'), + dis: 'an equip', + equip: HMarker.make(), + }) + + point = new HDict({ + id: HRef.make('pointId'), + dis: 'a point', + point: HMarker.make(), + kind: HStr.make('Number'), + equipRef: HRef.make('equipId'), + }) + }) + + it('creates relative filters for an equip record', async () => { + expect( + await makeRelativeHaystackFilterForTarget(equip, point) + ).toBe( + 'equipRef == $id and point and dis == "a point" and kind == "Number"' + ) + }) + }) // for equip and a point + + describe('for a room, an equip and a point', () => { + let room: HDict + let equip: HDict + let point: HDict + let resolve: RelativizeResolveFunc + + beforeAll(() => { + room = new HDict({ + id: HRef.make('roomId'), + dis: 'a room', + room: HMarker.make(), + space: HMarker.make(), + }) + + equip = new HDict({ + id: HRef.make('equipId'), + dis: 'an equip', + equip: HMarker.make(), + spaceRef: HRef.make('roomId'), + }) + + point = new HDict({ + id: HRef.make('pointId'), + dis: 'a point', + point: HMarker.make(), + kind: HStr.make('Number'), + equipRef: HRef.make('equipId'), + }) + + resolve = async (ref: HRef) => { + let result: HDict | undefined + + switch (ref.value) { + case 'equipId': + result = equip + break + case 'roomId': + result = room + break + case 'pointId': + result = point + break + } + + return result + } + }) + + it('creates relative filters for a room target', async () => { + expect( + await makeRelativeHaystackFilterForTarget(room, point, { + resolve, + }) + ).toBe( + 'equipRef->spaceRef == $id and point and dis == "a point" and kind == "Number" and equipRef->equip and equipRef->dis == "an equip"' + ) + }) + + it('creates relative filters for an equip target', async () => { + expect( + await makeRelativeHaystackFilterForTarget(equip, point, { + resolve, + }) + ).toBe( + 'equipRef == $id and point and dis == "a point" and kind == "Number"' + ) + }) + }) // for equip and a point + + describe('options', () => { + let equip: HDict + let point: HDict + + beforeAll(() => { + equip = new HDict({ + id: HRef.make('equipId'), + dis: 'an equip', + equip: HMarker.make(), + }) + + point = new HDict({ + id: HRef.make('pointId'), + dis: 'a point', + point: HMarker.make(), + kind: HStr.make('Number'), + equipRef: HRef.make('equipId'), + }) + }) + + it('excludes intermediate records when includeIntermediateRecords is false', async () => { + const room = new HDict({ + id: HRef.make('roomId'), + dis: 'a room', + room: HMarker.make(), + space: HMarker.make(), + }) + + const midEquip = new HDict({ + id: HRef.make('midEquipId'), + dis: 'mid equip', + equip: HMarker.make(), + spaceRef: HRef.make('roomId'), + }) + + const nestedPoint = new HDict({ + id: HRef.make('nestedPointId'), + dis: 'nested point', + point: HMarker.make(), + kind: HStr.make('Number'), + equipRef: HRef.make('midEquipId'), + }) + + const resolve: RelativizeResolveFunc = async (ref: HRef) => { + if (ref.value === 'midEquipId') return midEquip + if (ref.value === 'roomId') return room + return undefined + } + + const result = await makeRelativeHaystackFilterForTarget( + room, + nestedPoint, + { + resolve, + includeIntermediateRecords: false, + } + ) + + expect(result).toBe( + 'equipRef->spaceRef == $id and point and dis == "nested point" and kind == "Number"' + ) + }) + + it('excludes target macro when includeTargetMacro is false', async () => { + const result = await makeRelativeHaystackFilterForTarget( + equip, + point, + { + includeTargetMacro: false, + } + ) + + expect(result).toBe( + 'point and dis == "a point" and kind == "Number"' + ) + }) + + it('excludes both target macro and intermediate records', async () => { + const result = await makeRelativeHaystackFilterForTarget( + equip, + point, + { + includeTargetMacro: false, + includeIntermediateRecords: false, + } + ) + + expect(result).toBe( + 'point and dis == "a point" and kind == "Number"' + ) + }) + + it('respects useDisplayName option', async () => { + const result = await makeRelativeHaystackFilterForTarget( + equip, + point, + { + useDisplayName: false, + } + ) + + expect(result).toBe( + 'equipRef == $id and point and kind == "Number"' + ) + }) + + it('respects useKind option', async () => { + const result = await makeRelativeHaystackFilterForTarget( + equip, + point, + { + useKind: false, + } + ) + + expect(result).toBe( + 'equipRef == $id and point and dis == "a point"' + ) + }) + }) // options + + describe('edge cases', () => { + it('throws when target has no id', async () => { + const target = new HDict({ + dis: 'no id', + equip: HMarker.make(), + }) + + const point = new HDict({ + id: HRef.make('pointId'), + dis: 'a point', + point: HMarker.make(), + equipRef: HRef.make('equipId'), + }) + + await expect( + makeRelativeHaystackFilterForTarget(target, point) + ).rejects.toThrow('Target record must have an id') + }) + + it('throws when record has no parent ref', async () => { + const equip = new HDict({ + id: HRef.make('equipId'), + dis: 'an equip', + equip: HMarker.make(), + }) + + const orphanRecord = new HDict({ + id: HRef.make('orphanId'), + dis: 'orphan', + }) + + await expect( + makeRelativeHaystackFilterForTarget(equip, orphanRecord) + ).rejects.toThrow('does not have a parent reference') + }) + + it('includes record id in no-parent-ref error message', async () => { + const target = new HDict({ + id: HRef.make('targetId'), + dis: 'target', + equip: HMarker.make(), + }) + + const orphanRecord = new HDict({ + id: HRef.make('myOrphanId'), + dis: 'orphan', + }) + + await expect( + makeRelativeHaystackFilterForTarget(target, orphanRecord) + ).rejects.toThrow('Record myOrphanId does not have a parent reference') + }) + + it('throws when parent ref cannot be resolved', async () => { + const target = new HDict({ + id: HRef.make('targetId'), + dis: 'target', + space: HMarker.make(), + }) + + const record = new HDict({ + id: HRef.make('recordId'), + dis: 'record', + equip: HMarker.make(), + spaceRef: HRef.make('unknownId'), + }) + + const resolve: RelativizeResolveFunc = async () => undefined + + await expect( + makeRelativeHaystackFilterForTarget(target, record, { + resolve, + }) + ).rejects.toThrow( + 'Could not resolve parent record for ref unknownId' + ) + }) + + it('throws when containment depth exceeds limit', async () => { + const target = new HDict({ + id: HRef.make('targetId'), + dis: 'target', + site: HMarker.make(), + }) + + const records: HDict[] = [] + let currentId = 'record0' + + for (let i = 0; i < 15; i++) { + const nextId = `record${i + 1}` + records.push( + new HDict({ + id: HRef.make(currentId), + dis: `record ${i}`, + equip: HMarker.make(), + spaceRef: HRef.make(nextId), + }) + ) + currentId = nextId + } + + const resolve: RelativizeResolveFunc = async (ref: HRef) => { + return records.find((r) => r.get('id')?.equals(ref)) + } + + await expect( + makeRelativeHaystackFilterForTarget(target, records[0], { + resolve, + }) + ).rejects.toThrow('Exceeded maximum containment depth') + }) + + it('supports spaceRef containment hierarchy', async () => { + const space = new HDict({ + id: HRef.make('spaceId'), + dis: 'a space', + space: HMarker.make(), + }) + + const equip = new HDict({ + id: HRef.make('equipId'), + dis: 'an equip', + equip: HMarker.make(), + spaceRef: HRef.make('spaceId'), + }) + + const result = await makeRelativeHaystackFilterForTarget( + space, + equip + ) + + expect(result).toBe( + 'spaceRef == $id and equip and dis == "an equip"' + ) + }) + + it('supports siteRef containment hierarchy', async () => { + const site = new HDict({ + id: HRef.make('siteId'), + dis: 'a site', + site: HMarker.make(), + }) + + const equip = new HDict({ + id: HRef.make('equipId'), + dis: 'an equip', + equip: HMarker.make(), + siteRef: HRef.make('siteId'), + }) + + const result = await makeRelativeHaystackFilterForTarget( + site, + equip + ) + + expect(result).toBe( + 'siteRef == $id and equip and dis == "an equip"' + ) + }) + + it('supports floorRef containment hierarchy', async () => { + const floor = new HDict({ + id: HRef.make('floorId'), + dis: 'a floor', + space: HMarker.make(), + }) + + const equip = new HDict({ + id: HRef.make('equipId'), + dis: 'an equip', + equip: HMarker.make(), + floorRef: HRef.make('floorId'), + }) + + const result = await makeRelativeHaystackFilterForTarget( + floor, + equip + ) + + expect(result).toBe( + 'floorRef == $id and equip and dis == "an equip"' + ) + }) + + it('handles three-level hierarchy correctly', async () => { + const site = new HDict({ + id: HRef.make('siteId'), + dis: 'a site', + site: HMarker.make(), + }) + + const equip = new HDict({ + id: HRef.make('equipId'), + dis: 'an equip', + equip: HMarker.make(), + siteRef: HRef.make('siteId'), + }) + + const point = new HDict({ + id: HRef.make('pointId'), + dis: 'a point', + point: HMarker.make(), + kind: HStr.make('Status'), + equipRef: HRef.make('equipId'), + }) + + const resolve: RelativizeResolveFunc = async (ref: HRef) => { + if (ref.value === 'equipId') return equip + if (ref.value === 'siteId') return site + return undefined + } + + const result = await makeRelativeHaystackFilterForTarget( + site, + point, + { resolve } + ) + + expect(result).toBe( + 'equipRef->siteRef == $id and point and dis == "a point" and kind == "Status" and equipRef->equip and equipRef->dis == "an equip"' + ) + }) + }) // edge cases + + describe('error propagation', () => { + it('propagates error from resolve function', async () => { + const target = new HDict({ + id: HRef.make('targetId'), + dis: 'target', + space: HMarker.make(), + }) + + const record = new HDict({ + id: HRef.make('recordId'), + dis: 'record', + equip: HMarker.make(), + spaceRef: HRef.make('parentId'), + }) + + const resolve: RelativizeResolveFunc = async () => { + throw new Error('Resolve failed') + } + + await expect( + makeRelativeHaystackFilterForTarget(target, record, { + resolve, + }) + ).rejects.toThrow('Resolve failed') + }) + + it('propagates rejected promise from cache', async () => { + const target = new HDict({ + id: HRef.make('targetId'), + dis: 'target', + space: HMarker.make(), + }) + + const record = new HDict({ + id: HRef.make('recordId'), + dis: 'record', + equip: HMarker.make(), + spaceRef: HRef.make('parentId'), + }) + + const resolveCache = new Map>() + resolveCache.set( + 'parentId', + Promise.reject(new Error('Cached resolve failed')) + ) + + await expect( + makeRelativeHaystackFilterForTarget(target, record, { + resolveCache, + }) + ).rejects.toThrow('Cached resolve failed') + }) + + it('throws when resolve is not provided and ref cannot be resolved', async () => { + const target = new HDict({ + id: HRef.make('targetId'), + dis: 'target', + space: HMarker.make(), + }) + + const equipChild = new HDict({ + id: HRef.make('equipId'), + dis: 'equip', + equip: HMarker.make(), + spaceRef: HRef.make('targetId'), + }) + + const point = new HDict({ + id: HRef.make('pointId'), + dis: 'point', + point: HMarker.make(), + kind: HStr.make('Number'), + equipRef: HRef.make('equipId'), + }) + + await expect( + makeRelativeHaystackFilterForTarget(target, point) + ).rejects.toThrow('Could not resolve parent record for ref equipId') + }) + + it('throws when record id is missing in parent ref error', async () => { + const target = new HDict({ + id: HRef.make('targetId'), + dis: 'target', + equip: HMarker.make(), + }) + + const orphanRecord = new HDict({ + dis: 'orphan without id', + }) + + await expect( + makeRelativeHaystackFilterForTarget(target, orphanRecord) + ).rejects.toThrow('does not have a parent reference') + }) + }) // error propagation + describe('resolveCache', () => { + it('populates the cache for resolved refs', async () => { + const site = new HDict({ + id: HRef.make('siteId'), + dis: 'a site', + site: HMarker.make(), + }) + + const room = new HDict({ + id: HRef.make('roomId'), + dis: 'a room', + room: HMarker.make(), + space: HMarker.make(), + siteRef: HRef.make('siteId'), + }) + + const equip = new HDict({ + id: HRef.make('equipId'), + dis: 'an equip', + equip: HMarker.make(), + spaceRef: HRef.make('roomId'), + }) + + const point = new HDict({ + id: HRef.make('pointId'), + dis: 'a point', + point: HMarker.make(), + kind: HStr.make('Number'), + equipRef: HRef.make('equipId'), + }) + + const resolveCache = new Map>() + + const resolve: RelativizeResolveFunc = async (ref: HRef) => { + if (ref.value === 'equipId') return equip + if (ref.value === 'roomId') return room + if (ref.value === 'siteId') return site + return undefined + } + + await makeRelativeHaystackFilterForTarget(site, point, { + resolve, + resolveCache, + }) + + expect(resolveCache.has('equipId')).toBe(true) + expect(resolveCache.has('roomId')).toBe(true) + expect(resolveCache.has('siteId')).toBe(false) + }) + + it('avoids resolve calls when cache contains the ref', async () => { + const room = new HDict({ + id: HRef.make('roomId'), + dis: 'a room', + room: HMarker.make(), + space: HMarker.make(), + }) + + const equip = new HDict({ + id: HRef.make('equipId'), + dis: 'an equip', + equip: HMarker.make(), + spaceRef: HRef.make('roomId'), + }) + + const point = new HDict({ + id: HRef.make('pointId'), + dis: 'a point', + point: HMarker.make(), + kind: HStr.make('Number'), + equipRef: HRef.make('equipId'), + }) + + let resolveCallCount = 0 + + const resolve: RelativizeResolveFunc = async (ref: HRef) => { + resolveCallCount++ + if (ref.value === 'equipId') return equip + if (ref.value === 'roomId') return room + return undefined + } + + const resolveCache = new Map>() + resolveCache.set('equipId', Promise.resolve(equip)) + + await makeRelativeHaystackFilterForTarget(room, point, { + resolve, + resolveCache, + }) + + expect(resolveCallCount).toBe(0) + }) + + it('shares cache across multiple invocations', async () => { + const room = new HDict({ + id: HRef.make('roomId'), + dis: 'a room', + room: HMarker.make(), + space: HMarker.make(), + }) + + const equip = new HDict({ + id: HRef.make('equipId'), + dis: 'equip', + equip: HMarker.make(), + spaceRef: HRef.make('roomId'), + }) + + const point1 = new HDict({ + id: HRef.make('point1Id'), + dis: 'point 1', + point: HMarker.make(), + kind: HStr.make('Number'), + equipRef: HRef.make('equipId'), + }) + + const point2 = new HDict({ + id: HRef.make('point2Id'), + dis: 'point 2', + point: HMarker.make(), + kind: HStr.make('Status'), + equipRef: HRef.make('equipId'), + }) + + let resolveCallCount = 0 + + const resolve: RelativizeResolveFunc = async (ref: HRef) => { + resolveCallCount++ + if (ref.value === 'equipId') return equip + if (ref.value === 'roomId') return room + return undefined + } + + const resolveCache = new Map>() + + await makeRelativeHaystackFilterForTarget(room, point1, { + resolve, + resolveCache, + }) + + const callsAfterFirst = resolveCallCount + expect(callsAfterFirst).toBe(1) + + await makeRelativeHaystackFilterForTarget(room, point2, { + resolve, + resolveCache, + }) + + expect(resolveCallCount).toBe(1) + }) + + it('uses cached promise when available', async () => { + const target = new HDict({ + id: HRef.make('targetId'), + dis: 'target', + space: HMarker.make(), + }) + + const equip = new HDict({ + id: HRef.make('equipId'), + dis: 'an equip', + equip: HMarker.make(), + spaceRef: HRef.make('targetId'), + }) + + const resolve: RelativizeResolveFunc = async (ref: HRef) => { + if (ref.value === 'equipId') return equip + if (ref.value === 'targetId') return target + return undefined + } + + const resolveCache = new Map>() + + const cachedEquip = new HDict({ + id: HRef.make('equipId'), + dis: 'cached equip', + equip: HMarker.make(), + spaceRef: HRef.make('targetId'), + }) + + resolveCache.set('equipId', Promise.resolve(cachedEquip)) + + const point = new HDict({ + id: HRef.make('pointId'), + dis: 'a point', + point: HMarker.make(), + kind: HStr.make('Number'), + equipRef: HRef.make('equipId'), + }) + + const result = await makeRelativeHaystackFilterForTarget( + target, + point, + { + resolve, + resolveCache, + } + ) + + expect(result).toBe( + 'equipRef->spaceRef == $id and point and dis == "a point" and kind == "Number" and equipRef->equip and equipRef->dis == "cached equip"' + ) + }) + + it('does not require resolve function when cache has all refs', async () => { + const room = new HDict({ + id: HRef.make('roomId'), + dis: 'a room', + room: HMarker.make(), + space: HMarker.make(), + }) + + const equip = new HDict({ + id: HRef.make('equipId'), + dis: 'an equip', + equip: HMarker.make(), + spaceRef: HRef.make('roomId'), + }) + + const point = new HDict({ + id: HRef.make('pointId'), + dis: 'a point', + point: HMarker.make(), + kind: HStr.make('Number'), + equipRef: HRef.make('equipId'), + }) + + const resolveCache = new Map>() + resolveCache.set('equipId', Promise.resolve(equip)) + resolveCache.set('roomId', Promise.resolve(room)) + + const result = await makeRelativeHaystackFilterForTarget( + room, + point, + { + resolveCache, + } + ) + + expect(result).toBe( + 'equipRef->spaceRef == $id and point and dis == "a point" and kind == "Number" and equipRef->equip and equipRef->dis == "an equip"' + ) + }) + }) // resolveCache + }) // makeRelativeHaystackFilterForTarget() +}) diff --git a/spec/filter/util.spec.ts b/spec/filter/util.spec.ts deleted file mode 100644 index de894f8..0000000 --- a/spec/filter/util.spec.ts +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright (c) 2025, J2 Innovations. All Rights Reserved - */ - -import { HDict } from '../../src/core/dict/HDict' -import { HRef } from '../../src/core/HRef' -import { makeRelativeHaystackFilter } from '../../src/filter/util' -import { HMarker } from '../../src/core/HMarker' -import { HStr } from '../../src/core/HStr' -import { HSymbol } from '../../src/core/HSymbol' -import { HNamespace } from '../../src/core/HNamespace' - -describe('haystack', () => { - describe('makeRelativeHaystackFilter()', () => { - it('returns a haystack filter with dis', () => { - expect( - makeRelativeHaystackFilter( - new HDict({ - id: HRef.make('id'), - dis: 'an equip', - equip: HMarker.make(), - }) - ) - ).toEqual('equip and dis == "an equip"') - }) - - it('returns a haystack filter without a display name with the option disabled', () => { - expect( - makeRelativeHaystackFilter( - new HDict({ - id: HRef.make('id'), - dis: 'an equip', - equip: HMarker.make(), - }), - { - useDisplayName: false, - } - ) - ).toEqual('equip') - }) - - it('returns a haystack filter with a nav name', () => { - expect( - makeRelativeHaystackFilter( - new HDict({ - id: HRef.make('id'), - navName: 'an equip', - equip: HMarker.make(), - }) - ) - ).toEqual('equip and navName == "an equip"') - }) - - it('returns a haystack filter with a point kind', () => { - expect( - makeRelativeHaystackFilter( - new HDict({ - id: HRef.make('id'), - navName: 'a point', - point: HMarker.make(), - kind: HStr.make('Number'), - }) - ) - ).toEqual('point and navName == "a point" and kind == "Number"') - }) - - it('returns a haystack filter without the point kind and the option disabled', () => { - expect( - makeRelativeHaystackFilter( - new HDict({ - id: HRef.make('id'), - navName: 'a point', - point: HMarker.make(), - kind: HStr.make('Number'), - }), - { - useKind: false, - } - ) - ).toEqual('point and navName == "a point"') - }) - - it('returns a haystack filter with an absolute id as a fallback', () => { - expect( - makeRelativeHaystackFilter( - new HDict({ - id: HRef.make('id'), - }) - ) - ).toEqual('id == @id') - }) - - it('returns a haystack filter without excluded tags', () => { - expect( - makeRelativeHaystackFilter( - new HDict({ - id: HRef.make('id'), - dis: 'an equip', - equip: HMarker.make(), - his: HMarker.make(), - aux: HMarker.make(), - }) - ) - ).toEqual('equip and dis == "an equip"') - }) - - it('returns a haystack filter without connPoint subtype tags', () => { - const mockNamespace = { - allSubTypesOf: () => [ - HDict.make({ def: HSymbol.make('demoPoint') }), - ], - } as unknown as HNamespace - - expect( - makeRelativeHaystackFilter( - new HDict({ - id: HRef.make('id'), - dis: 'an equip', - equip: HMarker.make(), - demoPoint: HMarker.make(), - }), - { - namespace: mockNamespace, - } - ) - ).toEqual('equip and dis == "an equip"') - }) - - it('returns a haystack filter without custom excluded tags', () => { - expect( - makeRelativeHaystackFilter( - new HDict({ - id: HRef.make('id'), - dis: 'an equip', - equip: HMarker.make(), - customTag: HMarker.make(), - }), - { - getExcludedTags: () => ['customTag'], - } - ) - ).toEqual('equip and dis == "an equip"') - }) - }) // makeRelativeHaystackFilter() -}) diff --git a/src/filter/relativize.ts b/src/filter/relativize.ts new file mode 100644 index 0000000..930b246 --- /dev/null +++ b/src/filter/relativize.ts @@ -0,0 +1,368 @@ +/* + * Copyright (c) 2025, J2 Innovations. All Rights Reserved + */ + +import { HNamespace } from '../core/HNamespace' +import { HDict } from '../core/dict/HDict' +import { HMarker } from '../core/HMarker' +import { HRef } from '../core/HRef' +import { HStr } from '../core/HStr' +import { valueIsKind } from '../core/HVal' +import { Kind } from '../core/Kind' +import { HFilterBuilder } from './HFilterBuilder' + +const DIS_TAGS = ['dis', 'name', 'tag', 'navName'] + +const CONTAINMENT_DEPTH = 10 + +/** + * Relativization options. + */ +export interface RelativizeOptions { + /** + * True (or undefined) if the display name should be used in the relative filter. + */ + useDisplayName?: boolean + + /** + * True (or undefined) if a point's kind should be used in the relative filter. + */ + useKind?: boolean + + /** + * The namespace to use for determining excluded tags. + */ + namespace?: HNamespace + + /** + * Optional function to determine the list of tags that should be excluded from the relative filter. + */ + getExcludedTags?: (namespace?: HNamespace) => string[] + + /** + * Optional prefix path. + */ + prefixPath?: string[] +} + +export type RelativizeResolveFunc = (ref: HRef) => Promise + +export interface RelativizeForTargetOptions extends RelativizeOptions { + /** + * An optional function used to resolve a dict for a reference. + */ + resolve?: RelativizeResolveFunc + + /** + * An optional cache used to cache requests for dicts. + * + * If multiple resolution calls are being made, this cache can be used + * between calls to avoid duplicate requests for the same reference. + */ + resolveCache?: Map> + + /** + * True (or undefined) if intermediate records should be included in the relative filters. + */ + includeIntermediateRecords?: boolean + + /** + * True (or undefined) if the target record macro reference should be included in the relative filters. + */ + includeTargetMacro?: boolean +} + +/** + * Make a relative haystack filter for a target record. + * + * The target record doesn't need to be the direct parent of + * the record, but it must be an ancestor in the containment hierarchy. + * + * @param target The target record to set the relative filter for. + * @param record The child record to make the relative filter for. + * @param options The options for relativizing the filter. + * @returns A promise that resolves to the relative haystack filter. + */ +export async function makeRelativeHaystackFilterForTarget( + target: HDict, + record: HDict, + options?: RelativizeForTargetOptions +): Promise { + const dictPathInfo = await resolveDictPath(target, record, options) + + if (!dictPathInfo) { + return '' + } + + const includeTargetMacro = options?.includeTargetMacro ?? true + const includeIntermediateRecords = + options?.includeIntermediateRecords ?? true + + const { dicts, path } = dictPathInfo + + const builder = new HFilterBuilder() + + const prefix = includeTargetMacro + ? dictPathInfo.path.join('->') + ' == $id and ' + : '' + + for (let i = 0; i < dicts.length; ++i) { + const dict = dicts[i] + + makeRelativeHaystackFilterUsingBuilder(builder, dict, { + ...options, + prefixPath: path.slice(0, i), + }) + + if (i === 0 && !includeIntermediateRecords) { + break + } + } + + return prefix + builder.build() +} + +interface DictPathInfo { + dicts: HDict[] + path: string[] +} + +async function resolveDictPath( + target: HDict, + record: HDict, + options?: RelativizeForTargetOptions +): Promise { + // Follow the containment refs to build a tree of records + // with the target as the root. + const targetRef = target.get('id') + + if (!targetRef) { + throw new Error('Target record must have an id') + } + + let dictPathInfo: DictPathInfo | undefined = undefined + let currentRecord: HDict | undefined = record + let count = 0 + + while (currentRecord) { + if (++count > CONTAINMENT_DEPTH) { + throw new Error('Exceeded maximum containment depth') + } + + // Find the parent node for this record. + const parentRefTag = getParentRefTag(currentRecord) + + if (!parentRefTag) { + throw new Error( + `Record ${ + currentRecord.get('id')?.value + } does not have a parent reference` + ) + } + + const parentRef = currentRecord.get(parentRefTag) as HRef + + if (!dictPathInfo) { + dictPathInfo = { dicts: [], path: [] } + } + + dictPathInfo.dicts.push(currentRecord) + dictPathInfo.path.push(parentRefTag) + + // If we're found the target ref we can stop traversing up the tree. + if (parentRef.equals(targetRef)) { + break + } + + const promise = + options?.resolveCache?.get(parentRef.value) ?? + options?.resolve?.(parentRef) + + if (options?.resolveCache && promise !== undefined) { + options.resolveCache.set(parentRef.value, promise) + } + + const parentDict = await promise + + if (parentDict) { + currentRecord = parentDict + } else { + throw new Error( + `Could not resolve parent record for ref ${parentRef.value}` + ) + } + } + + if (!dictPathInfo) { + throw new Error('Could not find a path to the target record') + } + + return dictPathInfo +} + +function getParentRefTag(record: HDict): string | undefined { + if (record.has('equipRef')) { + return 'equipRef' + } + + if (record.has('spaceRef')) { + return 'spaceRef' + } + + // Note: floorRef is deprecated but we still provide suport + // for backwards compatibility. + if (record.has('floorRef')) { + return 'floorRef' + } + + if (record.has('siteRef')) { + return 'siteRef' + } + + return undefined +} + +/** + * Makes a relative haystack filter from a record. + * + * @param record The record. + * @returns A haystack filter. + */ +export function makeRelativeHaystackFilter( + record: HDict, + options?: RelativizeOptions +): string { + const builder = new HFilterBuilder() + makeRelativeHaystackFilterUsingBuilder(builder, record, options) + return builder.build() +} + +export function makeRelativeHaystackFilterUsingBuilder( + builder: HFilterBuilder, + record: HDict, + options?: RelativizeOptions +): void { + const getExcludedTags = + options?.getExcludedTags ?? getDefaultRelativizationExcludedTags + + const excludedTags = new Set(getExcludedTags(options?.namespace)) + + const useDisplayName = options?.useDisplayName ?? true + const useKind = options?.useKind ?? true + + for (const { name, value } of record) { + if (valueIsKind(value, Kind.Marker)) { + addTagToFilter( + name, + record, + builder, + excludedTags, + options?.prefixPath + ) + } + } + + if (useDisplayName) { + for (const tag of DIS_TAGS) { + if ( + addTagToFilter( + tag, + record, + builder, + excludedTags, + options?.prefixPath + ) + ) { + break + } + } + } + + if (useKind && record.has('point') && record.has('kind')) { + addTagToFilter( + 'kind', + record, + builder, + excludedTags, + options?.prefixPath + ) + } + + if (builder.isEmpty() && record.has('id')) { + addTagToFilter('id', record, builder, excludedTags, options?.prefixPath) + } +} + +export function getDefaultRelativizationExcludedTags( + namespace?: HNamespace +): string[] { + const excludedTags = [ + 'aux', + 'his', + 'hisCollectCOV', + 'hisCollectNA', + 'hisTotalized', + 'axStatus', + 'axAnnotated', + ] + + if (namespace) { + const connectorPointTags = namespace + .allSubTypesOf('connPoint') + .map((def) => def.defName) + + excludedTags.push(...connectorPointTags) + } + + return excludedTags +} + +function addTagToFilter( + tagName: string, + record: HDict, + builder: HFilterBuilder, + excludedTags: Set, + prefixPath?: string[] +): boolean { + if (excludedTags.has(tagName)) { + return false + } + + const addAnd = () => { + if (!builder.isEmpty()) { + builder.and() + } + } + + // Build the display name match. + const value = record.get(tagName) + if (value) { + switch (value.getKind()) { + case Kind.Marker: + addAnd() + builder.has(addPathPrefix(tagName, prefixPath)) + return true + case Kind.Bool: + case Kind.Ref: + case Kind.Str: + case Kind.Uri: + case Kind.Number: + case Kind.Date: + case Kind.Time: + case Kind.Symbol: + addAnd() + builder.equals( + addPathPrefix(tagName, prefixPath), + value as HStr + ) + return true + default: + return false + } + } + return false +} + +function addPathPrefix(tag: string, prefixPath?: string[]): string | string[] { + return prefixPath?.length ? [...prefixPath, tag] : tag +} diff --git a/src/filter/util.ts b/src/filter/util.ts deleted file mode 100644 index 0782136..0000000 --- a/src/filter/util.ts +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Copyright (c) 2025, J2 Innovations. All Rights Reserved - */ - -import { HNamespace } from '../core/HNamespace' -import { HDict } from '../core/dict/HDict' -import { HMarker } from '../core/HMarker' -import { HRef } from '../core/HRef' -import { HStr } from '../core/HStr' -import { valueIsKind } from '../core/HVal' -import { Kind } from '../core/Kind' -import { HFilterBuilder } from '../filter/HFilterBuilder' - -/** - * Relativization options. - */ -export interface RelativizeOptions { - /** - * True (or undefined) if the display name should be used in the relative filter. - */ - useDisplayName?: boolean - - /** - * True (or undefined) if a point's kind should be used in the relative filter. - */ - useKind?: boolean - - /** - * The namespace to use for determining excluded tags. - */ - namespace?: HNamespace - - /** - * Optional function to determine the list of tags that should be excluded from the relative filter. - */ - getExcludedTags?: (namespace?: HNamespace) => string[] -} - -/** - * Makes a relative haystack filter from a record. - * - * @param record The record. - * @returns A haystack filter. - */ -export function makeRelativeHaystackFilter( - record: HDict, - options?: RelativizeOptions -): string { - const useDisplayName = options?.useDisplayName ?? true - const useKind = options?.useKind ?? true - const getExcludedTags = - options?.getExcludedTags ?? getDefaultRelativizationExcludedTags - const excludedTags = new Set(getExcludedTags(options?.namespace)) - - const builder = new HFilterBuilder() - - // Build the marker tags. - for (const { name, value } of record) { - if ( - valueIsKind(value, Kind.Marker) && - !excludedTags.has(name) - ) { - if (!builder.isEmpty()) { - builder.and() - } - - builder.has(name) - } - } - - if (useDisplayName) { - // Build the display name match if one is available. - if (!addDisplayNameToFilter(record, builder, 'dis')) { - if (!addDisplayNameToFilter(record, builder, 'name')) { - if (!addDisplayNameToFilter(record, builder, 'tag')) { - addDisplayNameToFilter(record, builder, 'navName') - } - } - } - } - - if (useKind && record.has('point') && record.has('kind')) { - addPointKindToFilter(record, builder) - } - - if (builder.isEmpty() && record.has('id')) { - builder.equals('id', record.get('id') as HRef) - } - - return builder.build() -} - -export function getDefaultRelativizationExcludedTags( - namespace?: HNamespace -): string[] { - const excludedTags = [ - 'aux', - 'his', - 'hisCollectCOV', - 'hisCollectNA', - 'hisTotalized', - 'axStatus', - 'axAnnotated', - ] - - if (namespace) { - const connectorPointTags = namespace - .allSubTypesOf('connPoint') - .map((def) => def.defName) - - excludedTags.push(...connectorPointTags) - } - - return excludedTags -} - -function addPointKindToFilter(record: HDict, builder: HFilterBuilder): void { - const kind = record.get('kind')?.value - if (kind) { - if (!builder.isEmpty()) { - builder.and() - } - builder.equals('kind', kind) - } -} - -function addDisplayNameToFilter( - record: HDict, - builder: HFilterBuilder, - tagName: string -): boolean { - // Build the display name match. - const name = record.get(tagName)?.value - if (name) { - if (!builder.isEmpty()) { - builder.and() - } - - builder.equals(tagName, name) - return true - } - return false -} diff --git a/src/index.ts b/src/index.ts index e8d5946..b8e3c48 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,7 @@ export * from './filter/tokens' export * from './filter/TokenType' export * from './filter/TokenValue' export * from './filter/HFilterBuilder' -export * from './filter/util' +export * from './filter/relativize' // Util export * from './util/LocalizedError' From 074a5a6e45bdd48b7d6a96683af4f079a2e9038f Mon Sep 17 00:00:00 2001 From: Gareth Johnson <75035963+garethj2@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:56:08 +0100 Subject: [PATCH 2/5] Add relativizeOn support --- spec/filter/relativize.spec.ts | 142 +++++++++++++++++++++++++++++++++ src/filter/relativize.ts | 90 ++++++++++++++++++--- 2 files changed, 219 insertions(+), 13 deletions(-) diff --git a/spec/filter/relativize.spec.ts b/spec/filter/relativize.spec.ts index e7a4911..e130314 100644 --- a/spec/filter/relativize.spec.ts +++ b/spec/filter/relativize.spec.ts @@ -13,6 +13,7 @@ import { HMarker } from '../../src/core/HMarker' import { HStr } from '../../src/core/HStr' import { HSymbol } from '../../src/core/HSymbol' import { HNamespace } from '../../src/core/HNamespace' +import { HList } from '../../src/core/list/HList' describe('haystack', () => { describe('makeRelativeHaystackFilter()', () => { @@ -161,6 +162,147 @@ describe('haystack', () => { ) ).toEqual('equip and dis == "an equip"') }) + describe('addRelativizeOnToFilter()', () => { + it('uses relativizeOn tags instead of default behavior', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + dis: 'an equip', + equip: HMarker.make(), + area: HMarker.make(), + zone: HStr.make('A1'), + relativizeOn: HList.make(HStr.make('area'), HStr.make('zone')), + }) + ) + ).toEqual('area and zone == "A1"') + }) + + it('uses single tag from relativizeOn', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + dis: 'an equip', + equip: HMarker.make(), + code: HStr.make('HVAC-001'), + relativizeOn: HList.make(HStr.make('code')), + }) + ) + ).toEqual('code == "HVAC-001"') + }) + + it('ignores relativizeOn when useRelativizeOn is false', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + dis: 'an equip', + equip: HMarker.make(), + code: HStr.make('HVAC-001'), + relativizeOn: HList.make(HStr.make('code')), + }), + { useRelativizeOn: false } + ) + ).toEqual('equip and dis == "an equip"') + }) + + it('falls through to default behavior when relativizeOn is not a list', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + dis: 'an equip', + equip: HMarker.make(), + relativizeOn: HStr.make('notAList'), + }) + ) + ).toEqual('equip and dis == "an equip"') + }) + + it('skips excluded tags in relativizeOn', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + dis: 'an equip', + equip: HMarker.make(), + his: HMarker.make(), + code: HStr.make('HVAC-001'), + relativizeOn: HList.make(HStr.make('his'), HStr.make('code')), + }) + ) + ).toEqual('code == "HVAC-001"') + }) + + it('skips tags in relativizeOn that are not present on the record', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + dis: 'an equip', + equip: HMarker.make(), + code: HStr.make('HVAC-001'), + relativizeOn: HList.make(HStr.make('missingTag'), HStr.make('code')), + }) + ) + ).toEqual('code == "HVAC-001"') + }) + + it('respects prefixPath with relativizeOn', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + dis: 'an equip', + equip: HMarker.make(), + code: HStr.make('HVAC-001'), + relativizeOn: HList.make(HStr.make('equip'), HStr.make('code')), + }), + { prefixPath: ['equipRef'] } + ) + ).toEqual('equipRef->equip and equipRef->code == "HVAC-001"') + }) + + it('falls back to default behavior when relativizeOn tags are missing', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + dis: 'an equip', + equip: HMarker.make(), + relativizeOn: HList.make(HStr.make('missingTag')), + }) + ) + ).toEqual('equip and dis == "an equip"') + }) + + it('uses id when relativizeOn and default behavior produce empty filter', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + relativizeOn: HList.make(HStr.make('missingTag')), + }) + ) + ).toEqual('id == @id') + }) + + it('does not add display name or kind when relativizeOn provides tags', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + dis: 'a point', + point: HMarker.make(), + kind: HStr.make('Number'), + code: HStr.make('TX-001'), + relativizeOn: HList.make(HStr.make('code')), + }) + ) + ).toEqual('code == "TX-001"') + }) + }) // addRelativizeOnToFilter() }) // makeRelativeHaystackFilter() describe('makeRelativeHaystackFilterForTarget()', () => { diff --git a/src/filter/relativize.ts b/src/filter/relativize.ts index 930b246..5cd6043 100644 --- a/src/filter/relativize.ts +++ b/src/filter/relativize.ts @@ -7,12 +7,21 @@ import { HDict } from '../core/dict/HDict' import { HMarker } from '../core/HMarker' import { HRef } from '../core/HRef' import { HStr } from '../core/HStr' +import { HList } from '../core/list/HList' import { valueIsKind } from '../core/HVal' import { Kind } from '../core/Kind' import { HFilterBuilder } from './HFilterBuilder' -const DIS_TAGS = ['dis', 'name', 'tag', 'navName'] +/** + * The tags used for display name relativization. + */ +const DISPLAY_TAGS = ['dis', 'name', 'tag', 'navName'] +/** + * The maximum depth of the containment hierarchy to traverse when relativizing a filter. + * + * This is a safeguard to prevent infinite loops in case of circular references. + */ const CONTAINMENT_DEPTH = 10 /** @@ -43,6 +52,15 @@ export interface RelativizeOptions { * Optional prefix path. */ prefixPath?: string[] + + /** + * True (or undefined) if the relativization should be looked up on the record. + * + * A record can define a `relativizeOn` tag that is a list of tags that should + * be used for relativization. This is used to provide a hint on how relativation for a + * certain record is defined. + */ + useRelativizeOn?: boolean } export type RelativizeResolveFunc = (ref: HRef) => Promise @@ -174,15 +192,7 @@ async function resolveDictPath( break } - const promise = - options?.resolveCache?.get(parentRef.value) ?? - options?.resolve?.(parentRef) - - if (options?.resolveCache && promise !== undefined) { - options.resolveCache.set(parentRef.value, promise) - } - - const parentDict = await promise + const parentDict = await resolveParentDict(parentRef, options) if (parentDict) { currentRecord = parentDict @@ -200,6 +210,21 @@ async function resolveDictPath( return dictPathInfo } +async function resolveParentDict( + parentRef: HRef, + options?: RelativizeForTargetOptions +): Promise { + const promise = + options?.resolveCache?.get(parentRef.value) ?? + options?.resolve?.(parentRef) + + if (options?.resolveCache && promise !== undefined) { + options.resolveCache.set(parentRef.value, promise) + } + + return promise +} + function getParentRefTag(record: HDict): string | undefined { if (record.has('equipRef')) { return 'equipRef' @@ -242,13 +267,19 @@ export function makeRelativeHaystackFilterUsingBuilder( record: HDict, options?: RelativizeOptions ): void { + const useDisplayName = options?.useDisplayName ?? true + const useKind = options?.useKind ?? true + const getExcludedTags = options?.getExcludedTags ?? getDefaultRelativizationExcludedTags const excludedTags = new Set(getExcludedTags(options?.namespace)) - const useDisplayName = options?.useDisplayName ?? true - const useKind = options?.useKind ?? true + // If the record has a relativizeOn tag, use that to build the filter + // instead of the default defined here. + if (addRelativizeOnToFilter(builder, record, excludedTags, options)) { + return + } for (const { name, value } of record) { if (valueIsKind(value, Kind.Marker)) { @@ -263,7 +294,7 @@ export function makeRelativeHaystackFilterUsingBuilder( } if (useDisplayName) { - for (const tag of DIS_TAGS) { + for (const tag of DISPLAY_TAGS) { if ( addTagToFilter( tag, @@ -293,6 +324,39 @@ export function makeRelativeHaystackFilterUsingBuilder( } } +function addRelativizeOnToFilter( + builder: HFilterBuilder, + record: HDict, + excludedTags: Set, + options?: RelativizeOptions +): boolean { + let added = false + + const useRelativizeOn = options?.useRelativizeOn ?? true + + if (useRelativizeOn) { + const relativizeOn = record.get>('relativizeOn') + + if (valueIsKind>(relativizeOn, Kind.List)) { + for (const tag of relativizeOn) { + if ( + addTagToFilter( + tag.value, + record, + builder, + excludedTags, + options?.prefixPath + ) + ) { + added = true + } + } + } + } + + return added +} + export function getDefaultRelativizationExcludedTags( namespace?: HNamespace ): string[] { From 22f7c4590b45114b97e199b4f76fc367507aec0a Mon Sep 17 00:00:00 2001 From: Gareth Johnson <75035963+garethj2@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:40:51 +0100 Subject: [PATCH 3/5] Add more relativizeOn support --- spec/filter/relativize.spec.ts | 354 ++++++++++++++++++++++++++++++--- src/filter/relativize.ts | 173 +++++++++------- 2 files changed, 429 insertions(+), 98 deletions(-) diff --git a/spec/filter/relativize.spec.ts b/spec/filter/relativize.spec.ts index e130314..889a3db 100644 --- a/spec/filter/relativize.spec.ts +++ b/spec/filter/relativize.spec.ts @@ -26,7 +26,7 @@ describe('haystack', () => { equip: HMarker.make(), }) ) - ).toEqual('equip and dis == "an equip"') + ).toEqual('dis == "an equip" and equip') }) it('returns a haystack filter without a display name with the option disabled', () => { @@ -53,7 +53,7 @@ describe('haystack', () => { equip: HMarker.make(), }) ) - ).toEqual('equip and navName == "an equip"') + ).toEqual('navName == "an equip" and equip') }) it('returns a haystack filter with a point kind', () => { @@ -66,7 +66,7 @@ describe('haystack', () => { kind: HStr.make('Number'), }) ) - ).toEqual('point and navName == "a point" and kind == "Number"') + ).toEqual('navName == "a point" and kind == "Number" and point') }) it('returns a haystack filter with a point kind and path', () => { @@ -81,7 +81,7 @@ describe('haystack', () => { { prefixPath: ['foo', 'bar'] } ) ).toEqual( - 'foo->bar->point and foo->bar->navName == "a point" and foo->bar->kind == "Number"' + 'foo->bar->navName == "a point" and foo->bar->kind == "Number" and foo->bar->point' ) }) @@ -98,7 +98,36 @@ describe('haystack', () => { useKind: false, } ) - ).toEqual('point and navName == "a point"') + ).toEqual('navName == "a point" and point') + }) + + it('returns a haystack filter without markers with the option disabled', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + navName: 'a point', + point: HMarker.make(), + kind: HStr.make('Number'), + }), + { + useMarkers: false, + } + ) + ).toEqual('navName == "a point" and kind == "Number"') + }) + + it('does not include kind for non-point records', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + dis: 'an equip', + equip: HMarker.make(), + kind: HStr.make('Dict'), + }) + ) + ).toEqual('dis == "an equip" and equip') }) it('returns a haystack filter with an absolute id as a fallback', () => { @@ -122,7 +151,7 @@ describe('haystack', () => { aux: HMarker.make(), }) ) - ).toEqual('equip and dis == "an equip"') + ).toEqual('dis == "an equip" and equip') }) it('returns a haystack filter without connPoint subtype tags', () => { @@ -144,7 +173,7 @@ describe('haystack', () => { namespace: mockNamespace, } ) - ).toEqual('equip and dis == "an equip"') + ).toEqual('dis == "an equip" and equip') }) it('returns a haystack filter without custom excluded tags', () => { @@ -160,7 +189,7 @@ describe('haystack', () => { getExcludedTags: () => ['customTag'], } ) - ).toEqual('equip and dis == "an equip"') + ).toEqual('dis == "an equip" and equip') }) describe('addRelativizeOnToFilter()', () => { it('uses relativizeOn tags instead of default behavior', () => { @@ -204,7 +233,7 @@ describe('haystack', () => { }), { useRelativizeOn: false } ) - ).toEqual('equip and dis == "an equip"') + ).toEqual('dis == "an equip" and equip') }) it('falls through to default behavior when relativizeOn is not a list', () => { @@ -217,7 +246,7 @@ describe('haystack', () => { relativizeOn: HStr.make('notAList'), }) ) - ).toEqual('equip and dis == "an equip"') + ).toEqual('dis == "an equip" and equip') }) it('skips excluded tags in relativizeOn', () => { @@ -264,7 +293,7 @@ describe('haystack', () => { ).toEqual('equipRef->equip and equipRef->code == "HVAC-001"') }) - it('falls back to default behavior when relativizeOn tags are missing', () => { + it('falls back to defaults when relativizeOn tags are all missing', () => { expect( makeRelativeHaystackFilter( new HDict({ @@ -274,7 +303,7 @@ describe('haystack', () => { relativizeOn: HList.make(HStr.make('missingTag')), }) ) - ).toEqual('equip and dis == "an equip"') + ).toEqual('dis == "an equip" and equip') }) it('uses id when relativizeOn and default behavior produce empty filter', () => { @@ -302,6 +331,265 @@ describe('haystack', () => { ) ).toEqual('code == "TX-001"') }) + + describe('{dis} expansion', () => { + it('expands {dis} to dis when present', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + dis: HStr.make('an equip'), + equip: HMarker.make(), + relativizeOn: HList.make(HStr.make('{dis}')), + }) + ) + ).toEqual('dis == "an equip"') + }) + + it('expands {dis} to name when dis is absent', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + name: HStr.make('my equip'), + equip: HMarker.make(), + relativizeOn: HList.make(HStr.make('{dis}')), + }) + ) + ).toEqual('name == "my equip"') + }) + + it('expands {dis} to tag when dis and name are absent', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + tag: HStr.make('my-tag'), + equip: HMarker.make(), + relativizeOn: HList.make(HStr.make('{dis}')), + }) + ) + ).toEqual('tag == "my-tag"') + }) + + it('expands {dis} to navName when dis, name, and tag are absent', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + navName: HStr.make('nav equip'), + equip: HMarker.make(), + relativizeOn: HList.make(HStr.make('{dis}')), + }) + ) + ).toEqual('navName == "nav equip"') + }) + + it('prefers dis over name when both are present', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + dis: HStr.make('display name'), + name: HStr.make('name tag'), + equip: HMarker.make(), + relativizeOn: HList.make(HStr.make('{dis}')), + }) + ) + ).toEqual('dis == "display name"') + }) + + it('falls back to defaults when {dis} expands to nothing', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + equip: HMarker.make(), + relativizeOn: HList.make(HStr.make('{dis}')), + }) + ) + ).toEqual('equip') + }) + + it('respects useDisplayName: false when {dis} is in relativizeOn', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + dis: HStr.make('an equip'), + equip: HMarker.make(), + relativizeOn: HList.make(HStr.make('{dis}')), + }), + { useDisplayName: false } + ) + ).toEqual('equip') + }) + }) // {dis} expansion + + describe('{markers} expansion', () => { + it('expands {markers} to all marker tags on the record', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + equip: HMarker.make(), + ahu: HMarker.make(), + relativizeOn: HList.make(HStr.make('{markers}')), + }) + ) + ).toEqual('equip and ahu') + }) + + it('does not include non-marker values in {markers} expansion', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + dis: HStr.make('an equip'), + equip: HMarker.make(), + kind: HStr.make('Number'), + relativizeOn: HList.make(HStr.make('{markers}')), + }) + ) + ).toEqual('equip') + }) + + it('skips excluded marker tags in {markers} expansion', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + equip: HMarker.make(), + his: HMarker.make(), + ahu: HMarker.make(), + relativizeOn: HList.make(HStr.make('{markers}')), + }) + ) + ).toEqual('equip and ahu') + }) + + it('falls back to defaults when {markers} expands to nothing', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + dis: HStr.make('an equip'), + relativizeOn: HList.make(HStr.make('{markers}')), + }) + ) + ).toEqual('dis == "an equip"') + }) + + it('respects useMarkers: false when {markers} is in relativizeOn', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + dis: HStr.make('an equip'), + equip: HMarker.make(), + relativizeOn: HList.make(HStr.make('{markers}')), + }), + { useMarkers: false } + ) + ).toEqual('dis == "an equip"') + }) + + it('skips excluded marker tags with custom getExcludedTags in {markers} expansion', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + equip: HMarker.make(), + secret: HMarker.make(), + ahu: HMarker.make(), + relativizeOn: HList.make(HStr.make('{markers}')), + }), + { getExcludedTags: () => ['secret'] } + ) + ).toEqual('equip and ahu') + }) + }) // {markers} expansion + + describe('{kind} expansion', () => { + it('expands {kind} to kind when record is a point with kind', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + point: HMarker.make(), + kind: HStr.make('Number'), + relativizeOn: HList.make(HStr.make('{kind}')), + }) + ) + ).toEqual('kind == "Number"') + }) + + it('expands {kind} to nothing when the record is not a point', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + equip: HMarker.make(), + kind: HStr.make('Dict'), + relativizeOn: HList.make(HStr.make('{kind}')), + }) + ) + ).toEqual('equip') + }) + + it('respects useKind: false when {kind} is in relativizeOn', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + dis: 'a point', + point: HMarker.make(), + kind: HStr.make('Number'), + relativizeOn: HList.make(HStr.make('{kind}')), + }), + { useKind: false } + ) + ).toEqual('dis == "a point" and point') + }) + }) // {kind} expansion + + describe('{dis} and {markers} combined', () => { + it('expands both {dis} and {markers} in a single relativizeOn list', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + dis: HStr.make('an equip'), + equip: HMarker.make(), + ahu: HMarker.make(), + relativizeOn: HList.make( + HStr.make('{dis}'), + HStr.make('{markers}') + ), + }) + ) + ).toEqual('dis == "an equip" and equip and ahu') + }) + + it('works with prefixPath when using {dis} and {markers}', () => { + expect( + makeRelativeHaystackFilter( + new HDict({ + id: HRef.make('id'), + dis: HStr.make('a point'), + point: HMarker.make(), + relativizeOn: HList.make( + HStr.make('{dis}'), + HStr.make('{markers}') + ), + }), + { prefixPath: ['equipRef'] } + ) + ).toEqual( + 'equipRef->dis == "a point" and equipRef->point' + ) + }) + }) // {dis} and {markers} combined }) // addRelativizeOnToFilter() }) // makeRelativeHaystackFilter() @@ -330,7 +618,7 @@ describe('haystack', () => { expect( await makeRelativeHaystackFilterForTarget(equip, point) ).toBe( - 'equipRef == $id and point and dis == "a point" and kind == "Number"' + 'equipRef == $id and dis == "a point" and kind == "Number" and point' ) }) }) // for equip and a point @@ -389,7 +677,7 @@ describe('haystack', () => { resolve, }) ).toBe( - 'equipRef->spaceRef == $id and point and dis == "a point" and kind == "Number" and equipRef->equip and equipRef->dis == "an equip"' + 'equipRef->spaceRef == $id and dis == "a point" and kind == "Number" and point and equipRef->dis == "an equip" and equipRef->equip' ) }) @@ -399,7 +687,7 @@ describe('haystack', () => { resolve, }) ).toBe( - 'equipRef == $id and point and dis == "a point" and kind == "Number"' + 'equipRef == $id and dis == "a point" and kind == "Number" and point' ) }) }) // for equip and a point @@ -463,7 +751,7 @@ describe('haystack', () => { ) expect(result).toBe( - 'equipRef->spaceRef == $id and point and dis == "nested point" and kind == "Number"' + 'equipRef->spaceRef == $id and dis == "nested point" and kind == "Number" and point' ) }) @@ -477,7 +765,7 @@ describe('haystack', () => { ) expect(result).toBe( - 'point and dis == "a point" and kind == "Number"' + 'dis == "a point" and kind == "Number" and point' ) }) @@ -492,7 +780,7 @@ describe('haystack', () => { ) expect(result).toBe( - 'point and dis == "a point" and kind == "Number"' + 'dis == "a point" and kind == "Number" and point' ) }) @@ -506,7 +794,7 @@ describe('haystack', () => { ) expect(result).toBe( - 'equipRef == $id and point and kind == "Number"' + 'equipRef == $id and kind == "Number" and point' ) }) @@ -520,7 +808,21 @@ describe('haystack', () => { ) expect(result).toBe( - 'equipRef == $id and point and dis == "a point"' + 'equipRef == $id and dis == "a point" and point' + ) + }) + + it('respects useMarkers option', async () => { + const result = await makeRelativeHaystackFilterForTarget( + equip, + point, + { + useMarkers: false, + } + ) + + expect(result).toBe( + 'equipRef == $id and dis == "a point" and kind == "Number"' ) }) }) // options @@ -657,7 +959,7 @@ describe('haystack', () => { ) expect(result).toBe( - 'spaceRef == $id and equip and dis == "an equip"' + 'spaceRef == $id and dis == "an equip" and equip' ) }) @@ -681,7 +983,7 @@ describe('haystack', () => { ) expect(result).toBe( - 'siteRef == $id and equip and dis == "an equip"' + 'siteRef == $id and dis == "an equip" and equip' ) }) @@ -705,7 +1007,7 @@ describe('haystack', () => { ) expect(result).toBe( - 'floorRef == $id and equip and dis == "an equip"' + 'floorRef == $id and dis == "an equip" and equip' ) }) @@ -744,7 +1046,7 @@ describe('haystack', () => { ) expect(result).toBe( - 'equipRef->siteRef == $id and point and dis == "a point" and kind == "Status" and equipRef->equip and equipRef->dis == "an equip"' + 'equipRef->siteRef == $id and dis == "a point" and kind == "Status" and point and equipRef->dis == "an equip" and equipRef->equip' ) }) }) // edge cases @@ -1045,7 +1347,7 @@ describe('haystack', () => { ) expect(result).toBe( - 'equipRef->spaceRef == $id and point and dis == "a point" and kind == "Number" and equipRef->equip and equipRef->dis == "cached equip"' + 'equipRef->spaceRef == $id and dis == "a point" and kind == "Number" and point and equipRef->dis == "cached equip" and equipRef->equip' ) }) @@ -1085,7 +1387,7 @@ describe('haystack', () => { ) expect(result).toBe( - 'equipRef->spaceRef == $id and point and dis == "a point" and kind == "Number" and equipRef->equip and equipRef->dis == "an equip"' + 'equipRef->spaceRef == $id and dis == "a point" and kind == "Number" and point and equipRef->dis == "an equip" and equipRef->equip' ) }) }) // resolveCache diff --git a/src/filter/relativize.ts b/src/filter/relativize.ts index 5cd6043..94f13e8 100644 --- a/src/filter/relativize.ts +++ b/src/filter/relativize.ts @@ -24,10 +24,40 @@ const DISPLAY_TAGS = ['dis', 'name', 'tag', 'navName'] */ const CONTAINMENT_DEPTH = 10 +/** + * The display name to expand the display name tags when relativizing a filter. + */ +export const DISPLAY_NAME_RELATIVIZE_ON_ID = '{dis}' + +/** + * The kind of a point to expand when relativizing a filter. + */ +export const POINT_KIND_RELATIVIZE_ON_ID = '{kind}' + +/** + * The markers to expand the marker tags when relativizing a filter. + */ +export const MARKERS_RELATIVIZE_ON_ID = '{markers}' + /** * Relativization options. */ export interface RelativizeOptions { + /** + * True (or undefined) if the relativization should be looked up on the record. + * + * A record can define a `relativizeOn` tag that is a list of tags that should + * be used for relativization. This is used to provide a hint on how relativation for a + * certain record is defined. + * + * The {dis} id can be used to add the first known display name tag on the record. + * + * The {kind} id can be used to add the kind tag when the record is a point. + * + * The {markers} id can be used to expand all marker tags on the record. + */ + useRelativizeOn?: boolean + /** * True (or undefined) if the display name should be used in the relative filter. */ @@ -38,6 +68,11 @@ export interface RelativizeOptions { */ useKind?: boolean + /** + * True (or undefined) if the relative filter should use markers. + */ + useMarkers?: boolean + /** * The namespace to use for determining excluded tags. */ @@ -52,15 +87,6 @@ export interface RelativizeOptions { * Optional prefix path. */ prefixPath?: string[] - - /** - * True (or undefined) if the relativization should be looked up on the record. - * - * A record can define a `relativizeOn` tag that is a list of tags that should - * be used for relativization. This is used to provide a hint on how relativation for a - * certain record is defined. - */ - useRelativizeOn?: boolean } export type RelativizeResolveFunc = (ref: HRef) => Promise @@ -267,56 +293,47 @@ export function makeRelativeHaystackFilterUsingBuilder( record: HDict, options?: RelativizeOptions ): void { - const useDisplayName = options?.useDisplayName ?? true - const useKind = options?.useKind ?? true - const getExcludedTags = options?.getExcludedTags ?? getDefaultRelativizationExcludedTags const excludedTags = new Set(getExcludedTags(options?.namespace)) - // If the record has a relativizeOn tag, use that to build the filter - // instead of the default defined here. - if (addRelativizeOnToFilter(builder, record, excludedTags, options)) { - return - } - - for (const { name, value } of record) { - if (valueIsKind(value, Kind.Marker)) { - addTagToFilter( - name, - record, - builder, - excludedTags, - options?.prefixPath - ) - } - } - - if (useDisplayName) { - for (const tag of DISPLAY_TAGS) { - if ( - addTagToFilter( - tag, - record, - builder, - excludedTags, - options?.prefixPath + const applyRelativizationTags = (tags: string[]) => { + const expanded = tags.flatMap((tag) => { + // Expand out selected ids to their corresponding tags. + if (tag === DISPLAY_NAME_RELATIVIZE_ON_ID) { + if (!(options?.useDisplayName ?? true)) return [] + for (const disTag of DISPLAY_TAGS) { + if (record.has(disTag)) { + return [disTag] + } + } + return [] + } else if (tag === POINT_KIND_RELATIVIZE_ON_ID) { + if (!(options?.useKind ?? true)) return [] + return record.has('point') && record.has('kind') ? ['kind'] : [] + } else if (tag === MARKERS_RELATIVIZE_ON_ID) { + if (!(options?.useMarkers ?? true)) return [] + return record.keys.filter((key) => + valueIsKind(record.get(key), Kind.Marker) ) - ) { - break } + return tag + }) + + for (const tag of expanded) { + addTagToFilter(tag, record, builder, excludedTags, options?.prefixPath) } } - if (useKind && record.has('point') && record.has('kind')) { - addTagToFilter( - 'kind', - record, - builder, - excludedTags, - options?.prefixPath - ) + const { tags, fromRecord } = getRelativizationOnFromRecord(record, options) + applyRelativizationTags(tags) + + // If the record provided a custom relativizeOn list but none of its tags + // produced a filter match, fall back gently to the default behaviour + // rather than jumping straight to the id. + if (builder.isEmpty() && fromRecord) { + applyRelativizationTags(getDefaultRelativizationOnTags(options)) } if (builder.isEmpty() && record.has('id')) { @@ -324,37 +341,49 @@ export function makeRelativeHaystackFilterUsingBuilder( } } -function addRelativizeOnToFilter( - builder: HFilterBuilder, +function getRelativizationOnFromRecord( record: HDict, - excludedTags: Set, options?: RelativizeOptions -): boolean { - let added = false - +): { tags: string[]; fromRecord: boolean } { const useRelativizeOn = options?.useRelativizeOn ?? true if (useRelativizeOn) { - const relativizeOn = record.get>('relativizeOn') - - if (valueIsKind>(relativizeOn, Kind.List)) { - for (const tag of relativizeOn) { - if ( - addTagToFilter( - tag.value, - record, - builder, - excludedTags, - options?.prefixPath - ) - ) { - added = true - } + const relativizeOnList = record.get>('relativizeOn') + + if ( + valueIsKind>(relativizeOnList, Kind.List) && + relativizeOnList.length > 0 + ) { + return { + tags: relativizeOnList.values.map((v) => v.value), + fromRecord: true, } } } - return added + return { tags: getDefaultRelativizationOnTags(options), fromRecord: false } +} + +function getDefaultRelativizationOnTags(options?: RelativizeOptions): string[] { + const defaultRelativizeOnTags: string[] = [] + + const useDisplayName = options?.useDisplayName ?? true + const useKind = options?.useKind ?? true + const useMarkers = options?.useMarkers ?? true + + if (useDisplayName) { + defaultRelativizeOnTags.push(DISPLAY_NAME_RELATIVIZE_ON_ID) + } + + if (useKind) { + defaultRelativizeOnTags.push(POINT_KIND_RELATIVIZE_ON_ID) + } + + if (useMarkers) { + defaultRelativizeOnTags.push(MARKERS_RELATIVIZE_ON_ID) + } + + return defaultRelativizeOnTags } export function getDefaultRelativizationExcludedTags( @@ -417,7 +446,7 @@ function addTagToFilter( addAnd() builder.equals( addPathPrefix(tagName, prefixPath), - value as HStr + value as HRef | HStr ) return true default: From 907ae4f715f2451bc954aa0940b80d70e1f51723 Mon Sep 17 00:00:00 2001 From: Gareth Johnson <75035963+garethj2@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:01:04 +0100 Subject: [PATCH 4/5] Fix linting issues --- spec/filter/relativize.spec.ts | 93 ++++++++++++++++++++++++---------- src/filter/relativize.ts | 8 ++- 2 files changed, 72 insertions(+), 29 deletions(-) diff --git a/spec/filter/relativize.spec.ts b/spec/filter/relativize.spec.ts index 889a3db..905a210 100644 --- a/spec/filter/relativize.spec.ts +++ b/spec/filter/relativize.spec.ts @@ -201,7 +201,10 @@ describe('haystack', () => { equip: HMarker.make(), area: HMarker.make(), zone: HStr.make('A1'), - relativizeOn: HList.make(HStr.make('area'), HStr.make('zone')), + relativizeOn: HList.make( + HStr.make('area'), + HStr.make('zone') + ), }) ) ).toEqual('area and zone == "A1"') @@ -258,7 +261,10 @@ describe('haystack', () => { equip: HMarker.make(), his: HMarker.make(), code: HStr.make('HVAC-001'), - relativizeOn: HList.make(HStr.make('his'), HStr.make('code')), + relativizeOn: HList.make( + HStr.make('his'), + HStr.make('code') + ), }) ) ).toEqual('code == "HVAC-001"') @@ -272,7 +278,10 @@ describe('haystack', () => { dis: 'an equip', equip: HMarker.make(), code: HStr.make('HVAC-001'), - relativizeOn: HList.make(HStr.make('missingTag'), HStr.make('code')), + relativizeOn: HList.make( + HStr.make('missingTag'), + HStr.make('code') + ), }) ) ).toEqual('code == "HVAC-001"') @@ -286,7 +295,10 @@ describe('haystack', () => { dis: 'an equip', equip: HMarker.make(), code: HStr.make('HVAC-001'), - relativizeOn: HList.make(HStr.make('equip'), HStr.make('code')), + relativizeOn: HList.make( + HStr.make('equip'), + HStr.make('code') + ), }), { prefixPath: ['equipRef'] } ) @@ -434,7 +446,9 @@ describe('haystack', () => { id: HRef.make('id'), equip: HMarker.make(), ahu: HMarker.make(), - relativizeOn: HList.make(HStr.make('{markers}')), + relativizeOn: HList.make( + HStr.make('{markers}') + ), }) ) ).toEqual('equip and ahu') @@ -448,7 +462,9 @@ describe('haystack', () => { dis: HStr.make('an equip'), equip: HMarker.make(), kind: HStr.make('Number'), - relativizeOn: HList.make(HStr.make('{markers}')), + relativizeOn: HList.make( + HStr.make('{markers}') + ), }) ) ).toEqual('equip') @@ -462,7 +478,9 @@ describe('haystack', () => { equip: HMarker.make(), his: HMarker.make(), ahu: HMarker.make(), - relativizeOn: HList.make(HStr.make('{markers}')), + relativizeOn: HList.make( + HStr.make('{markers}') + ), }) ) ).toEqual('equip and ahu') @@ -474,7 +492,9 @@ describe('haystack', () => { new HDict({ id: HRef.make('id'), dis: HStr.make('an equip'), - relativizeOn: HList.make(HStr.make('{markers}')), + relativizeOn: HList.make( + HStr.make('{markers}') + ), }) ) ).toEqual('dis == "an equip"') @@ -487,7 +507,9 @@ describe('haystack', () => { id: HRef.make('id'), dis: HStr.make('an equip'), equip: HMarker.make(), - relativizeOn: HList.make(HStr.make('{markers}')), + relativizeOn: HList.make( + HStr.make('{markers}') + ), }), { useMarkers: false } ) @@ -502,7 +524,9 @@ describe('haystack', () => { equip: HMarker.make(), secret: HMarker.make(), ahu: HMarker.make(), - relativizeOn: HList.make(HStr.make('{markers}')), + relativizeOn: HList.make( + HStr.make('{markers}') + ), }), { getExcludedTags: () => ['secret'] } ) @@ -585,9 +609,7 @@ describe('haystack', () => { }), { prefixPath: ['equipRef'] } ) - ).toEqual( - 'equipRef->dis == "a point" and equipRef->point' - ) + ).toEqual('equipRef->dis == "a point" and equipRef->point') }) }) // {dis} and {markers} combined }) // addRelativizeOnToFilter() @@ -877,7 +899,9 @@ describe('haystack', () => { await expect( makeRelativeHaystackFilterForTarget(target, orphanRecord) - ).rejects.toThrow('Record myOrphanId does not have a parent reference') + ).rejects.toThrow( + 'Record myOrphanId does not have a parent reference' + ) }) it('throws when parent ref cannot be resolved', async () => { @@ -1091,7 +1115,10 @@ describe('haystack', () => { spaceRef: HRef.make('parentId'), }) - const resolveCache = new Map>() + const resolveCache = new Map< + string, + Promise + >() resolveCache.set( 'parentId', Promise.reject(new Error('Cached resolve failed')) @@ -1111,13 +1138,6 @@ describe('haystack', () => { space: HMarker.make(), }) - const equipChild = new HDict({ - id: HRef.make('equipId'), - dis: 'equip', - equip: HMarker.make(), - spaceRef: HRef.make('targetId'), - }) - const point = new HDict({ id: HRef.make('pointId'), dis: 'point', @@ -1128,7 +1148,9 @@ describe('haystack', () => { await expect( makeRelativeHaystackFilterForTarget(target, point) - ).rejects.toThrow('Could not resolve parent record for ref equipId') + ).rejects.toThrow( + 'Could not resolve parent record for ref equipId' + ) }) it('throws when record id is missing in parent ref error', async () => { @@ -1178,7 +1200,10 @@ describe('haystack', () => { equipRef: HRef.make('equipId'), }) - const resolveCache = new Map>() + const resolveCache = new Map< + string, + Promise + >() const resolve: RelativizeResolveFunc = async (ref: HRef) => { if (ref.value === 'equipId') return equip @@ -1229,7 +1254,10 @@ describe('haystack', () => { return undefined } - const resolveCache = new Map>() + const resolveCache = new Map< + string, + Promise + >() resolveCache.set('equipId', Promise.resolve(equip)) await makeRelativeHaystackFilterForTarget(room, point, { @@ -1280,7 +1308,10 @@ describe('haystack', () => { return undefined } - const resolveCache = new Map>() + const resolveCache = new Map< + string, + Promise + >() await makeRelativeHaystackFilterForTarget(room, point1, { resolve, @@ -1318,7 +1349,10 @@ describe('haystack', () => { return undefined } - const resolveCache = new Map>() + const resolveCache = new Map< + string, + Promise + >() const cachedEquip = new HDict({ id: HRef.make('equipId'), @@ -1374,7 +1408,10 @@ describe('haystack', () => { equipRef: HRef.make('equipId'), }) - const resolveCache = new Map>() + const resolveCache = new Map< + string, + Promise + >() resolveCache.set('equipId', Promise.resolve(equip)) resolveCache.set('roomId', Promise.resolve(room)) diff --git a/src/filter/relativize.ts b/src/filter/relativize.ts index 94f13e8..c31f501 100644 --- a/src/filter/relativize.ts +++ b/src/filter/relativize.ts @@ -322,7 +322,13 @@ export function makeRelativeHaystackFilterUsingBuilder( }) for (const tag of expanded) { - addTagToFilter(tag, record, builder, excludedTags, options?.prefixPath) + addTagToFilter( + tag, + record, + builder, + excludedTags, + options?.prefixPath + ) } } From a2fc737c35a0e45230e3bc53488703928a30e957 Mon Sep 17 00:00:00 2001 From: Gareth Johnson <75035963+garethj2@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:03:46 +0100 Subject: [PATCH 5/5] Update ci/cd --- .github/workflows/master-pull-request.yaml | 4 ++-- .github/workflows/master-push.yaml | 4 ++-- .github/workflows/release-published.yaml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/master-pull-request.yaml b/.github/workflows/master-pull-request.yaml index 47294c9..9d82a6b 100644 --- a/.github/workflows/master-pull-request.yaml +++ b/.github/workflows/master-pull-request.yaml @@ -10,7 +10,7 @@ jobs: strategy: matrix: - node-version: [22.x] + node-version: [24.x] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: @@ -21,6 +21,6 @@ jobs: with: node-version: ${{ matrix.node-version }} - - run: npm ci --no-optional + - run: npm ci - run: npm run lint - run: npm test diff --git a/.github/workflows/master-push.yaml b/.github/workflows/master-push.yaml index c0a7327..0866d20 100644 --- a/.github/workflows/master-push.yaml +++ b/.github/workflows/master-push.yaml @@ -10,7 +10,7 @@ jobs: strategy: matrix: - node-version: [22.x] + node-version: [24.x] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: @@ -21,7 +21,7 @@ jobs: with: node-version: ${{ matrix.node-version }} - - run: npm ci --no-optional + - run: npm ci - run: npm run lint - run: npm test # A build isn't needed here but we want to make sure it works ok before try this during a publish. diff --git a/.github/workflows/release-published.yaml b/.github/workflows/release-published.yaml index e60d8e2..44ea3bf 100644 --- a/.github/workflows/release-published.yaml +++ b/.github/workflows/release-published.yaml @@ -9,7 +9,7 @@ jobs: strategy: matrix: - node-version: [22.x] + node-version: [24.x] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: @@ -27,7 +27,7 @@ jobs: - run: git config --global user.name "GitHub CD bot" - run: git config --global user.email "github-cd-bot@j2inn.com" - - run: npm ci --no-optional + - run: npm ci # Version the module using the release tag name - run: npm version ${{ github.event.release.tag_name }}