fix: support relationship equality filters

This commit is contained in:
lucaronin
2026-05-14 16:01:34 +02:00
2 changed files with 70 additions and 8 deletions

View File

@@ -181,6 +181,53 @@ describe('evaluateView', () => {
expect(result.map((e) => e.title)).toEqual(['A'])
})
it('equals on relationship matches a single-item array by stem', () => {
const view: ViewDefinition = {
name: 'session-trail', icon: null, color: null, sort: null,
filters: {
any: [
{ field: 'belongs_to', op: 'equals', value: 'svc-session-trail' },
{ field: 'related_to', op: 'equals', value: 'svc-session-trail' },
],
},
}
const entries = [
makeEntry({ title: 'Matches', relationships: { related_to: ['[[svc-session-trail]]'] } }),
makeEntry({ title: 'Bracketed value also matches', relationships: { related_to: ['[[svc-session-trail|Trail]]'] } }),
makeEntry({ title: 'Other relation', relationships: { related_to: ['[[unrelated]]'] } }),
makeEntry({ title: 'No rels', relationships: {} }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Matches', 'Bracketed value also matches'])
})
it('equals on relationship requires a single-item array (mirrors Rust semantics)', () => {
const view: ViewDefinition = {
name: 'single', icon: null, color: null, sort: null,
filters: { all: [{ field: 'related_to', op: 'equals', value: 'svc-session-trail' }] },
}
const entries = [
makeEntry({ title: 'Single', relationships: { related_to: ['[[svc-session-trail]]'] } }),
makeEntry({ title: 'Multiple', relationships: { related_to: ['[[svc-session-trail]]', '[[other]]'] } }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Single'])
})
it('not_equals on relationship is the inverse of equals', () => {
const view: ViewDefinition = {
name: 'not-equals', icon: null, color: null, sort: null,
filters: { all: [{ field: 'related_to', op: 'not_equals', value: 'svc-session-trail' }] },
}
const entries = [
makeEntry({ title: 'Single match', relationships: { related_to: ['[[svc-session-trail]]'] } }),
makeEntry({ title: 'Multiple', relationships: { related_to: ['[[svc-session-trail]]', '[[other]]'] } }),
makeEntry({ title: 'Other', relationships: { related_to: ['[[unrelated]]'] } }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Multiple', 'Other'])
})
it('any_of / none_of on relationship always use exact stem match', () => {
const view: ViewDefinition = {
name: 'Exact list', icon: null, color: null, sort: null,

View File

@@ -143,19 +143,34 @@ function hasArrayMatch(values: string[], condVal: string): boolean {
))
}
function isSingleRelationshipMatch(values: string[], condVal: string): boolean {
return values.length === 1 && wikilinkEquals(values[0], condVal)
}
const ARRAY_MATCHERS = {
contains: hasArrayMatch,
not_contains: hasArrayMatch,
equals: isSingleRelationshipMatch,
not_equals: isSingleRelationshipMatch,
} satisfies Partial<Record<FilterCondition['op'], (values: string[], condVal: string) => boolean>>
const NEGATED_ARRAY_OPS = new Set<FilterCondition['op']>(['not_contains', 'not_equals', 'none_of'])
const ARRAY_SET_OPS = new Set<FilterCondition['op']>(['any_of', 'none_of'])
function evaluateArrayCondition(cond: FilterCondition, values: string[], condVal: string, regex: RegExp | null): boolean {
const { op, value } = cond
if (regex) return evaluateRegexArrayCondition(op, values, regex)
if (op === 'contains') return hasArrayMatch(values, condVal)
if (op === 'not_contains') return !hasArrayMatch(values, condVal)
if (op === 'any_of' && Array.isArray(value)) {
return values.some((item) => (value as string[]).some((v) => wikilinkEquals(item, v)))
const matcher = ARRAY_MATCHERS[op as keyof typeof ARRAY_MATCHERS]
if (matcher) {
const matched = matcher(values, condVal)
return NEGATED_ARRAY_OPS.has(op) ? !matched : matched
}
if (op === 'none_of' && Array.isArray(value)) {
return !values.some((item) => (value as string[]).some((v) => wikilinkEquals(item, v)))
}
return false
if (!ARRAY_SET_OPS.has(op)) return false
if (!Array.isArray(value)) return false
const matched = values.some((item) => (value as string[]).some((v) => wikilinkEquals(item, v)))
return NEGATED_ARRAY_OPS.has(op) ? !matched : matched
}
function evaluateRegexScalarCondition(op: FilterCondition['op'], fieldRaw: string, regex: RegExp): boolean {