Converted filter matching module.
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
import { FilterMatch } from "./filter_match";
|
||||||
|
|
||||||
|
export const Filter = {
|
||||||
|
...FilterMatch,
|
||||||
|
};
|
||||||
|
|||||||
310
src/filter_match.test.ts
Normal file
310
src/filter_match.test.ts
Normal 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
118
src/filter_match.ts
Normal 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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user