From eec1b352ed33186636e68549dd012b87ce4ecccb Mon Sep 17 00:00:00 2001 From: Jay Date: Fri, 24 Oct 2025 13:48:49 -0400 Subject: [PATCH] Converted filter matching module. --- src/filter.ts | 5 + src/filter_match.test.ts | 310 +++++++++++++++++++++++++++++++++++++++ src/filter_match.ts | 118 +++++++++++++++ 3 files changed, 433 insertions(+) create mode 100644 src/filter_match.test.ts create mode 100644 src/filter_match.ts diff --git a/src/filter.ts b/src/filter.ts index e69de29..dd968f6 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -0,0 +1,5 @@ +import { FilterMatch } from "./filter_match"; + +export const Filter = { + ...FilterMatch, +}; diff --git a/src/filter_match.test.ts b/src/filter_match.test.ts new file mode 100644 index 0000000..3218f0c --- /dev/null +++ b/src/filter_match.test.ts @@ -0,0 +1,310 @@ +import { readFileSync } from "fs"; +import { describe, expect, test } from "vitest"; + +import { FilterMatch } from "./filter_match"; +import type { EventData, FilterData } from "./types"; + +const testEvents: EventData[] = JSON.parse( + readFileSync("src/testdata/test_events.json", "utf-8"), +); + +interface FilterTestCase { + name: string; + filter: FilterData; + expectedIDs: string[]; +} + +const filterTestCases: FilterTestCase[] = [ + { + name: "empty filter", + filter: {}, + expectedIDs: [ + "e751d41f", + "562bc378", + "e67fa7b8", + "5e4c64f1", + "7a5d83d4", + "3a122100", + "4a15d963", + "4b03b69a", + "d39e6f3f", + ], + }, + { + name: "empty id", + filter: { ids: [] }, + expectedIDs: [ + "e751d41f", + "562bc378", + "e67fa7b8", + "5e4c64f1", + "7a5d83d4", + "3a122100", + "4a15d963", + "4b03b69a", + "d39e6f3f", + ], + }, + { + name: "single id prefix", + filter: { ids: ["e751d41f"] }, + expectedIDs: ["e751d41f"], + }, + { + name: "single full id", + filter: { + ids: ["e67fa7b84df6b0bb4c57f8719149de77f58955d7849da1be10b2267c72daad8b"], + }, + expectedIDs: ["e67fa7b8"], + }, + { + name: "multiple id prefixes", + filter: { ids: ["562bc378", "5e4c64f1"] }, + expectedIDs: ["562bc378", "5e4c64f1"], + }, + { + name: "no id match", + filter: { ids: ["ffff"] }, + expectedIDs: [], + }, + { + name: "empty author", + filter: { authors: [] }, + expectedIDs: [ + "e751d41f", + "562bc378", + "e67fa7b8", + "5e4c64f1", + "7a5d83d4", + "3a122100", + "4a15d963", + "4b03b69a", + "d39e6f3f", + ], + }, + { + name: "single author prefix", + filter: { authors: ["d877e187"] }, + expectedIDs: ["e751d41f", "562bc378", "e67fa7b8"], + }, + { + name: "multiple author prefixes", + filter: { authors: ["d877e187", "9e4b726a"] }, + expectedIDs: [ + "e751d41f", + "562bc378", + "e67fa7b8", + "5e4c64f1", + "7a5d83d4", + "3a122100", + ], + }, + { + name: "single author full", + filter: { + authors: [ + "d877e187934bd942a71221b50ff2b426bd0777991b41b6c749119805dc40bcbe", + ], + }, + expectedIDs: ["e751d41f", "562bc378", "e67fa7b8"], + }, + { + name: "no author match", + filter: { authors: ["ffff"] }, + expectedIDs: [], + }, + { + name: "empty kind", + filter: { kinds: [] }, + expectedIDs: [ + "e751d41f", + "562bc378", + "e67fa7b8", + "5e4c64f1", + "7a5d83d4", + "3a122100", + "4a15d963", + "4b03b69a", + "d39e6f3f", + ], + }, + { + name: "single kind", + filter: { kinds: [1] }, + expectedIDs: ["562bc378", "7a5d83d4", "4b03b69a"], + }, + { + name: "multiple kinds", + filter: { kinds: [0, 2] }, + expectedIDs: [ + "e751d41f", + "e67fa7b8", + "5e4c64f1", + "3a122100", + "4a15d963", + "d39e6f3f", + ], + }, + { + name: "no kind match", + filter: { kinds: [99] }, + expectedIDs: [], + }, + { + name: "since only", + filter: { since: 5000 }, + expectedIDs: ["7a5d83d4", "3a122100", "4a15d963", "4b03b69a", "d39e6f3f"], + }, + { + name: "until only", + filter: { until: 3000 }, + expectedIDs: ["e751d41f", "562bc378", "e67fa7b8"], + }, + { + name: "time range", + filter: { since: 4000, until: 6000 }, + expectedIDs: ["5e4c64f1", "7a5d83d4", "3a122100"], + }, + { + name: "outside time range", + filter: { since: 10000 }, + expectedIDs: [], + }, + { + name: "empty tag filter", + filter: { tags: { e: [] } }, + expectedIDs: [ + "e751d41f", + "562bc378", + "e67fa7b8", + "5e4c64f1", + "7a5d83d4", + "3a122100", + "4a15d963", + "4b03b69a", + "d39e6f3f", + ], + }, + { + name: "single letter tag filter: e", + filter: { + tags: { + e: ["5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36"], + }, + }, + expectedIDs: ["562bc378"], + }, + { + name: "multiple tag matches", + filter: { + tags: { + e: [ + "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36", + "ae3f2a91b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7", + ], + }, + }, + expectedIDs: ["562bc378", "3a122100"], + }, + { + name: "multiple tag matches - single event match", + filter: { + tags: { + e: [ + "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36", + "cb7787c460a79187d6a13e75a0f19240e05fafca8ea42288f5765773ea69cf2f", + ], + }, + }, + expectedIDs: ["562bc378"], + }, + { + name: "single letter tag filter: p", + filter: { + tags: { + p: ["91cf9b32f3735070f46c0a86a820a47efa08a5be6c9f4f8cf68e5b5b75c92d60"], + }, + }, + expectedIDs: ["e67fa7b8"], + }, + { + name: "multi letter tag filter", + filter: { tags: { emoji: ["🌊"] } }, + expectedIDs: ["e67fa7b8"], + }, + { + name: "multiple tag filters", + filter: { + tags: { + e: ["ae3f2a91b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7e9a1c5b4d8f2e7a9b6c3d8f7"], + p: ["3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"], + }, + }, + expectedIDs: ["3a122100"], + }, + { + name: "prefix tag filter", + filter: { tags: { p: ["ae3f2a91"] } }, + expectedIDs: [], + }, + { + name: "unknown tag filter", + filter: { tags: { z: ["anything"] } }, + expectedIDs: [], + }, + { + name: "combined author+kind tag filter", + filter: { authors: ["d877e187"], kinds: [1, 2] }, + expectedIDs: ["562bc378", "e67fa7b8"], + }, + { + name: "combined kind+time range tag filter", + filter: { kinds: [0], since: 2000, until: 7000 }, + expectedIDs: ["5e4c64f1", "4a15d963"], + }, + { + name: "combined author+tag tag filter", + filter: { authors: ["e719e8f8"], tags: { power: ["fire"] } }, + expectedIDs: ["4a15d963"], + }, + { + name: "combined tag filter", + filter: { + authors: ["e719e8f8"], + kinds: [0], + since: 5000, + until: 10000, + tags: { power: ["fire"] }, + }, + expectedIDs: ["4a15d963"], + }, +]; + +describe("FilterMatch.matches", () => { + test.each(filterTestCases)("$name", ({ filter, expectedIDs }) => { + const actualIDs = testEvents + .filter((event) => FilterMatch.matches(filter, event)) + .map((event) => event.id.slice(0, 8)); + + expect(actualIDs).toEqual(expectedIDs); + }); +}); + +describe("FilterMatch.matches - skip malformed tags", () => { + test("skips malformed tags during tag matching", () => { + const event: EventData = { + id: "test", + pubkey: "test", + created_at: 0, + kind: 1, + tags: [["malformed"], ["valid", "value"]], + content: "", + sig: "test", + }; + const filter: FilterData = { + tags: { valid: ["value"] }, + }; + + expect(FilterMatch.matches(filter, event)).toBe(true); + }); +}); diff --git a/src/filter_match.ts b/src/filter_match.ts new file mode 100644 index 0000000..6db353b --- /dev/null +++ b/src/filter_match.ts @@ -0,0 +1,118 @@ +import type { EventData, FilterData, Tag, TagFilters } from "./types"; + +/** + * Returns true if candidate starts with any prefix in the list. + */ +function matchesPrefix(candidate: string, prefixes: string[]): boolean { + for (const prefix of prefixes) { + if (candidate.startsWith(prefix)) { + return true; + } + } + return false; +} + +/** + * Returns true if candidate exists in the kinds list. + */ +function matchesKinds(candidate: number, kinds: number[]): boolean { + return kinds.includes(candidate); +} + +/** + * Returns true if timestamp falls within the optional since/until range. + */ +function matchesTimeRange( + timestamp: number, + since?: number, + until?: number, +): boolean { + if (since !== undefined && timestamp < since) { + return false; + } + if (until !== undefined && timestamp > until) { + return false; + } + return true; +} + +/** + * Returns true if event tags satisfy all tag filter requirements. + * Skips tags with fewer than 2 elements during matching. + */ +function matchesTags(eventTags: Tag[], tagFilters: TagFilters): boolean { + // Build index of tag names to their values + const eventIndex = new Map(); + for (const tag of eventTags) { + if (tag.length < 2) continue; + const tagName = tag[0]; + const tagValue = tag[1]; + if (!eventIndex.has(tagName)) { + eventIndex.set(tagName, []); + } + eventIndex.get(tagName)!.push(tagValue); + } + + // Check each filter requirement + for (const [tagName, filterValues] of Object.entries(tagFilters)) { + // Empty filter values match all events + if (filterValues.length === 0) continue; + + const eventValues = eventIndex.get(tagName); + if (!eventValues) return false; + + // Check if any filter value matches any event value (OR within tag) + const found = filterValues.some((filterVal) => + eventValues.includes(filterVal), + ); + + if (!found) return false; + } + + return true; +} + +/** + * Returns true if the event satisfies all filter conditions (AND logic). + * Does not account for custom extensions. + */ +function matches(filter: FilterData, event: EventData): boolean { + // Check ID prefixes + if (filter.ids && filter.ids.length > 0) { + if (!matchesPrefix(event.id, filter.ids)) { + return false; + } + } + + // Check Author prefixes + if (filter.authors && filter.authors.length > 0) { + if (!matchesPrefix(event.pubkey, filter.authors)) { + return false; + } + } + + // Check Kind + if (filter.kinds && filter.kinds.length > 0) { + if (!matchesKinds(event.kind, filter.kinds)) { + return false; + } + } + + // Check Timestamp + if (!matchesTimeRange(event.created_at, filter.since, filter.until)) { + return false; + } + + // Check Tags + if (filter.tags && Object.keys(filter.tags).length > 0) { + if (!matchesTags(event.tags, filter.tags)) { + return false; + } + } + + return true; +} + +export const FilterMatch = { + matches, +};