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
9 changes: 9 additions & 0 deletions .changeset/geohash-prefix-filters.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 20 additions & 2 deletions src/repositories/event-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ const groupByLengthSpec = groupBy<string, 'exact' | 'even' | 'odd'>(

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,
Expand Down Expand Up @@ -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)
})
Expand Down
14 changes: 13 additions & 1 deletion src/utils/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions test/unit/repositories/event-repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': [] }]
Expand Down
27 changes: 27 additions & 0 deletions test/unit/utils/event.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading