From b10b02bf2f211b7d16fb897a2230e3acf6109e4a Mon Sep 17 00:00:00 2001 From: Kanishka Date: Mon, 27 Apr 2026 18:39:42 +0530 Subject: [PATCH] feat: support geohash prefix matching for #g filters --- .changeset/geohash-prefix-filters.md | 9 +++++++ src/repositories/event-repository.ts | 22 +++++++++++++-- src/utils/event.ts | 14 +++++++++- .../repositories/event-repository.spec.ts | 22 +++++++++++++++ test/unit/utils/event.spec.ts | 27 +++++++++++++++++++ 5 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 .changeset/geohash-prefix-filters.md diff --git a/.changeset/geohash-prefix-filters.md b/.changeset/geohash-prefix-filters.md new file mode 100644 index 00000000..7f14c99a --- /dev/null +++ b/.changeset/geohash-prefix-filters.md @@ -0,0 +1,9 @@ +--- +"nostream": patch +--- + +Implement geohash wildcard/prefix behavior for `#g` filters (closes #265): a +criterion ending in `*` matches any event `g` tag whose value starts with the +prefix before `*`; exact matching (no `*`) is unchanged. Only normal geohash +prefixes are intended as input. This is a Nostream extension, not part of +NIP-12. diff --git a/src/repositories/event-repository.ts b/src/repositories/event-repository.ts index 56da579d..fb3e135a 100644 --- a/src/repositories/event-repository.ts +++ b/src/repositories/event-repository.ts @@ -58,6 +58,11 @@ const groupByLengthSpec = groupBy( const logger = createLogger('event-repository') +const isGeohashPrefixCriterion = (filterName: string, criterion: string): boolean => + filterName === '#g' && criterion.endsWith('*') + +const stripGeohashPrefixWildcard = (criterion: string): string => criterion.slice(0, -1) + export class EventRepository implements IEventRepository { public constructor( private readonly masterDbClient: DatabaseClient, @@ -193,8 +198,21 @@ export class EventRepository implements IEventRepository { isEmpty, () => andWhereRaw('1 = 0', bd), forEach( - (criterion: string) => - void orWhereRaw('event_tags.tag_name = ? AND event_tags.tag_value = ?', [filterName[1], criterion], bd), + (criterion: string) => { + if (isGeohashPrefixCriterion(filterName, criterion)) { + return void orWhereRaw( + 'event_tags.tag_name = ? AND event_tags.tag_value LIKE ?', + [filterName[1], `${stripGeohashPrefixWildcard(criterion)}%`], + bd, + ) + } + + return void orWhereRaw( + 'event_tags.tag_name = ? AND event_tags.tag_value = ?', + [filterName[1], criterion], + bd, + ) + }, ), )(criteria) }) diff --git a/src/utils/event.ts b/src/utils/event.ts index 18bad057..b621d389 100644 --- a/src/utils/event.ts +++ b/src/utils/event.ts @@ -40,6 +40,18 @@ export const isEventMatchingFilter = (filter: SubscriptionFilter) => (event: Event): boolean => { const startsWith = (input: string) => (prefix: string) => input.startsWith(prefix) + const isMatchingGenericTagCriterion = (key: string, criterion: string) => (tag: Tag): boolean => { + const [, tagName] = key + if (tag[0] !== tagName) { + return false + } + + if (key === '#g' && criterion.endsWith('*')) { + return tag[1].startsWith(criterion.slice(0, -1)) + } + + return tag[1] === criterion + } // NIP-01: Basic protocol flow description @@ -84,7 +96,7 @@ export const isEventMatchingFilter = Object.entries(filter) .filter(([key, criteria]) => isGenericTagQuery(key) && Array.isArray(criteria)) .some(([key, criteria]) => { - return !event.tags.some((tag) => tag[0] === key[1] && criteria.includes(tag[1])) + return !event.tags.some((tag) => criteria.some((criterion) => isMatchingGenericTagCriterion(key, criterion)(tag))) }) ) { return false diff --git a/test/unit/repositories/event-repository.spec.ts b/test/unit/repositories/event-repository.spec.ts index 6a9ffbfa..f9a90cc8 100644 --- a/test/unit/repositories/event-repository.spec.ts +++ b/test/unit/repositories/event-repository.spec.ts @@ -320,6 +320,28 @@ describe('EventRepository', () => { }) }) + describe('#g', () => { + it('selects geohash tags by prefix when criterion ends with wildcard', () => { + const filters = [{ '#g': ['u4pruyd*'] }] + + const query = repository.findByFilters(filters).toString() + + expect(query).to.equal( + 'select "events".* from "events" left join "event_tags" on "events"."event_id" = "event_tags"."event_id" where (event_tags.tag_name = \'g\' AND event_tags.tag_value LIKE \'u4pruyd%\') order by "event_created_at" asc, "event_id" asc limit 500', + ) + }) + + it('keeps geohash tags exact when criterion has no wildcard', () => { + const filters = [{ '#g': ['u4pruyd'] }] + + const query = repository.findByFilters(filters).toString() + + expect(query).to.equal( + 'select "events".* from "events" left join "event_tags" on "events"."event_id" = "event_tags"."event_id" where (event_tags.tag_name = \'g\' AND event_tags.tag_value = \'u4pruyd\') order by "event_created_at" asc, "event_id" asc limit 500', + ) + }) + }) + describe('#p', () => { it('selects no events given empty list of #p tags', () => { const filters = [{ '#p': [] }] diff --git a/test/unit/utils/event.spec.ts b/test/unit/utils/event.spec.ts index f059f940..b1514b25 100644 --- a/test/unit/utils/event.spec.ts +++ b/test/unit/utils/event.spec.ts @@ -302,6 +302,33 @@ describe('NIP-12', () => { expect(isEventMatchingFilter({ '#r': ['something else'] })(event)).to.be.false }) }) + + describe('#g filter', () => { + beforeEach(() => { + event = { + id: 'cf8de9db67a1d7203512d1d81e6190f5e53abfdc0ac90275f67172b65a5b09a0', + pubkey: 'e8b487c079b0f67c695ae6c4c2552a47f38adfa2533cc5926bd2c102942fdcb7', + created_at: 1645030752, + kind: 1, + tags: [['g', 'u4pruydqqvj']], + content: 'g', + sig: '53d12018d036092794366283eca36df4e0cabd014b6e91bbf684c8bb9bbbe9dedafa77b6b928587e11e05e036227598dded8713e8da17d55076e12242b361542', + } + }) + + it('returns true if #g filter contains a matching geohash prefix wildcard', () => { + expect(isEventMatchingFilter({ '#g': ['u4pruyd*'] })(event)).to.be.true + }) + + it('returns false if #g filter contains a non-matching geohash prefix wildcard', () => { + expect(isEventMatchingFilter({ '#g': ['u4pruz*'] })(event)).to.be.false + }) + + it('keeps #g filter exact when criterion has no wildcard', () => { + expect(isEventMatchingFilter({ '#g': ['u4pruyd'] })(event)).to.be.false + expect(isEventMatchingFilter({ '#g': ['u4pruydqqvj'] })(event)).to.be.true + }) + }) }) describe('NIP-16', () => {