Converted filter matching module.

This commit is contained in:
Jay
2025-10-24 13:48:49 -04:00
parent 75d454cc7a
commit eec1b352ed
3 changed files with 433 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
import { FilterMatch } from "./filter_match";
export const Filter = {
...FilterMatch,
};

310
src/filter_match.test.ts Normal file
View File

@@ -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);
});
});

118
src/filter_match.ts Normal file
View File

@@ -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<string, string[]>();
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,
};