Skip to content
Open
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
Binary file modified bun.lockb
Binary file not shown.
243 changes: 243 additions & 0 deletions filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,4 +237,247 @@ describe('Filter', () => {
expect(getFilterLimit({ '#p': [] })).toEqual(0)
})
})

describe('NIP-91 AND filters', () => {
describe('matchFilter', () => {
test('should return true when all AND filter values are present', () => {
const filter = { '&t': ['meme', 'cat'] }
const event = buildEvent({
tags: [
['t', 'meme'],
['t', 'cat'],
],
})
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})

test('should return false when one AND filter value is missing', () => {
const filter = { '&t': ['meme', 'cat'] }
const event = buildEvent({
tags: [['t', 'meme']],
})
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})

test('should return false when all AND filter values are missing', () => {
const filter = { '&t': ['meme', 'cat'] }
const event = buildEvent({
tags: [['t', 'dog']],
})
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})

test('should return true when AND filter has single value that matches', () => {
const filter = { '&t': ['meme'] }
const event = buildEvent({
tags: [['t', 'meme']],
})
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})

test('should handle multiple AND filters', () => {
const filter = {
'&t': ['meme', 'cat'],
'&p': ['pubkey1', 'pubkey2'],
}
const event = buildEvent({
tags: [
['t', 'meme'],
['t', 'cat'],
['p', 'pubkey1'],
['p', 'pubkey2'],
],
})
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})

test('should return false when one of multiple AND filters fails', () => {
const filter = {
'&t': ['meme', 'cat'],
'&p': ['pubkey1', 'pubkey2'],
}
const event = buildEvent({
tags: [
['t', 'meme'],
['t', 'cat'],
['p', 'pubkey1'],
],
})
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})

test('NIP-91 example: AND takes precedence, OR excludes AND values', () => {
const filter = {
kinds: [1],
'&t': ['meme', 'cat'],
'#t': ['black', 'white'],
}
// Event with both AND values and one OR value
const event1 = buildEvent({
kind: 1,
tags: [
['t', 'meme'],
['t', 'cat'],
['t', 'black'],
],
})
expect(matchFilter(filter, event1)).toEqual(true)

// Event with both AND values and other OR value
const event2 = buildEvent({
kind: 1,
tags: [
['t', 'meme'],
['t', 'cat'],
['t', 'white'],
],
})
expect(matchFilter(filter, event2)).toEqual(true)

// Event with both AND values but no OR values
const event3 = buildEvent({
kind: 1,
tags: [
['t', 'meme'],
['t', 'cat'],
],
})
expect(matchFilter(filter, event3)).toEqual(false)

// Event missing one AND value
const event4 = buildEvent({
kind: 1,
tags: [
['t', 'meme'],
['t', 'black'],
],
})
expect(matchFilter(filter, event4)).toEqual(false)

// Event with AND values that are also in OR (should be excluded from OR check)
const event5 = buildEvent({
kind: 1,
tags: [
['t', 'meme'],
['t', 'cat'],
['t', 'meme'], // duplicate, but AND values should be excluded from OR
],
})
expect(matchFilter(filter, event5)).toEqual(false) // No OR values remain after exclusion
})

test('should exclude AND values from OR filter matching', () => {
const filter = {
'&t': ['meme'],
'#t': ['meme', 'cat'],
}
// Event has 'meme' (in AND) and 'cat' (in OR, not in AND)
const event1 = buildEvent({
tags: [
['t', 'meme'],
['t', 'cat'],
],
})
expect(matchFilter(filter, event1)).toEqual(true)

// Event has only 'meme' (in AND, excluded from OR)
const event2 = buildEvent({
tags: [['t', 'meme']],
})
expect(matchFilter(filter, event2)).toEqual(false) // No OR values remain
})

test('should handle AND filter with empty array', () => {
const filter = { '&t': [] }
const event = buildEvent({
tags: [['t', 'meme']],
})
// Empty AND filter should pass (no requirements)
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})

test('should work with other filter conditions', () => {
const filter = {
kinds: [1],
authors: ['abc'],
'&t': ['meme', 'cat'],
}
const event = buildEvent({
kind: 1,
pubkey: 'abc',
tags: [
['t', 'meme'],
['t', 'cat'],
],
})
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})

test('should work with OR filter only (backward compatibility)', () => {
const filter = { '#t': ['meme', 'cat'] }
const event = buildEvent({
tags: [['t', 'meme']],
})
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})
})

describe('mergeFilters', () => {
test('should merge AND filters', () => {
const result = mergeFilters({ '&t': ['meme'] }, { '&t': ['cat'] })
expect(result).toEqual({
'&t': ['meme', 'cat'],
})
})

test('should merge AND and OR filters separately', () => {
const result = mergeFilters({ '&t': ['meme'], '#t': ['black'] }, { '&t': ['cat'], '#t': ['white'] })
expect(result).toEqual({
'&t': ['meme', 'cat'],
'#t': ['black', 'white'],
})
})

test('should merge mixed filters with AND filters', () => {
const result = mergeFilters({ kinds: [1], '&t': ['meme'], limit: 3 }, { kinds: [2], '&t': ['cat'], limit: 5 })
expect(result).toEqual({
kinds: [1, 2],
'&t': ['meme', 'cat'],
limit: 5,
})
})

test('should deduplicate AND filter values', () => {
const result = mergeFilters({ '&t': ['meme', 'cat'] }, { '&t': ['meme', 'dog'] })
expect(result).toEqual({
'&t': ['meme', 'cat', 'dog'],
})
})
})

describe('getFilterLimit', () => {
test('empty AND filters return 0', () => {
expect(getFilterLimit({ '&t': [] })).toEqual(0)
})

test('should handle AND filters with other conditions', () => {
expect(getFilterLimit({ '&t': ['meme', 'cat'] })).toEqual(Infinity)
expect(getFilterLimit({ ids: ['123'], '&t': ['meme'] })).toEqual(1)
})

test('should handle both AND and OR filters', () => {
expect(getFilterLimit({ '&t': ['meme'], '#p': ['pubkey1'] })).toEqual(Infinity)
expect(getFilterLimit({ '&t': [], '#p': [] })).toEqual(0)
})
})
})
})
45 changes: 42 additions & 3 deletions filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type Filter = {
limit?: number
search?: string
[key: `#${string}`]: string[] | undefined
[key: `&${string}`]: string[] | undefined
}

export function matchFilter(filter: Filter, event: Event): boolean {
Expand All @@ -23,11 +24,43 @@ export function matchFilter(filter: Filter, event: Event): boolean {
return false
}

// Track AND filter values to exclude from OR filters
const andFilterValues: Map<string, Set<string>> = new Map()

// Process AND filters first (they take precedence)
for (let f in filter) {
if (f[0] === '&') {
let tagName = f.slice(1)
let values = filter[`&${tagName}` as `&${string}`]
if (values) {
// Check that ALL values in the AND filter are present in the event's tags
const eventTagValues = new Set(event.tags.filter(([t]) => t === tagName).map(([, v]) => v))
for (let i = 0; i < values.length; i++) {
if (!eventTagValues.has(values[i])) return false
}

// Store AND filter values to exclude from OR filters
andFilterValues.set(tagName, new Set(values))
}
}
}

// Process OR filters (excluding values that appear in AND filters)
for (let f in filter) {
if (f[0] === '#') {
let tagName = f.slice(1)
let values = filter[`#${tagName}`]
if (values && !event.tags.find(([t, v]) => t === f.slice(1) && values!.indexOf(v) !== -1)) return false
if (values) {
// Exclude values that are in the corresponding AND filter
const andValues = andFilterValues.get(tagName)
const orValues = andValues ? values.filter(v => !andValues.has(v)) : values

// If all OR values were excluded, the OR filter cannot be satisfied
if (orValues.length === 0) return false

// Check if any of the remaining OR values match
if (!event.tags.find(([t, v]) => t === tagName && orValues.indexOf(v) !== -1)) return false
}
}
}

Expand All @@ -51,7 +84,13 @@ export function mergeFilters(...filters: Filter[]): Filter {
for (let i = 0; i < filters.length; i++) {
let filter = filters[i]
Object.entries(filter).forEach(([property, values]) => {
if (property === 'kinds' || property === 'ids' || property === 'authors' || property[0] === '#') {
if (
property === 'kinds' ||
property === 'ids' ||
property === 'authors' ||
property[0] === '#' ||
property[0] === '&'
) {
// @ts-ignore
result[property] = result[property] || []
// @ts-ignore
Expand Down Expand Up @@ -82,7 +121,7 @@ export function getFilterLimit(filter: Filter): number {
if (filter.authors && !filter.authors.length) return 0

for (const [key, value] of Object.entries(filter)) {
if (key[0] === '#' && Array.isArray(value) && !value.length) return 0
if ((key[0] === '#' || key[0] === '&') && Array.isArray(value) && !value.length) return 0
}

return Math.min(
Expand Down