From 85c8c60a4074362b688b1185e21188725a0e7951 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Sun, 27 Sep 2026 22:20:19 +0530 Subject: [PATCH 1/2] fix: match filters with several tag names and apply limit per filter --- .../tag-filters-and-per-filter-limits.md | 5 + src/repositories/event-repository.ts | 109 +++++++------ .../features/nip-01/nip-01.feature | 24 +++ .../features/nip-01/nip-01.feature.ts | 101 ++++++++++++ .../features/nip-45/nip-45.feature | 32 ++++ .../features/nip-45/nip-45.feature.ts | 84 ++++++++++ .../repositories/event-repository.spec.ts | 151 ++++++++++++++---- 7 files changed, 421 insertions(+), 85 deletions(-) create mode 100644 .changeset/tag-filters-and-per-filter-limits.md create mode 100644 test/integration/features/nip-45/nip-45.feature create mode 100644 test/integration/features/nip-45/nip-45.feature.ts diff --git a/.changeset/tag-filters-and-per-filter-limits.md b/.changeset/tag-filters-and-per-filter-limits.md new file mode 100644 index 00000000..93e0cc91 --- /dev/null +++ b/.changeset/tag-filters-and-per-filter-limits.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +fix: match filters with several tag names and apply limit per filter diff --git a/src/repositories/event-repository.ts b/src/repositories/event-repository.ts index 822b7fa0..e5052b49 100644 --- a/src/repositories/event-repository.ts +++ b/src/repositories/event-repository.ts @@ -45,7 +45,6 @@ const DEFAULT_TS_CONFIG = 'simple' const DEFAULT_MAX_SEARCH_QUERY_LENGTH = 256 interface FilterConditionFlags { - isTagQuery: boolean isSearchQuery: boolean } @@ -61,47 +60,38 @@ export class EventRepository implements IEventRepository { if (!Array.isArray(filters) || !filters.length) { throw new Error('Filters cannot be empty') } + const isSearchQueries: boolean[] = [] const queries = filters.map((currentFilter) => { const builder = this.readReplicaDbClient('events') - const { isTagQuery, isSearchQuery } = this.applyFilterConditions(builder, currentFilter) + const { isSearchQuery } = this.applyFilterConditions(builder, currentFilter) + isSearchQueries.push(isSearchQuery) if (isSearchQuery) { - // NIP-50: sort by relevance (ts_rank) descending, then by event_id for stability const tsConfig = this.getNip50Language() const nip50Settings = this.settings?.() const maxLen = nip50Settings?.nip50?.maxQueryLength ?? DEFAULT_MAX_SEARCH_QUERY_LENGTH const searchQuery = currentFilter.search.trim().slice(0, maxLen) - const limit = typeof currentFilter.limit === 'number' ? currentFilter.limit : DEFAULT_FILTER_LIMIT const searchSelection = this.readReplicaDbClient.raw( 'events.*, ts_rank(to_tsvector(?::regconfig, event_content), plainto_tsquery(?::regconfig, ?)) AS search_rank', [tsConfig, tsConfig, searchQuery], ) - // De-duplicate rows multiplied by the event_tags left join when search is combined with a generic tag filter - if (isTagQuery) { - builder.distinct(searchSelection) - } else { - builder.select(searchSelection) - } - builder.limit(limit).orderBy('search_rank', 'DESC').orderBy('event_id', 'asc') - } else if (typeof currentFilter.limit === 'number') { - builder.limit(currentFilter.limit).orderBy('event_created_at', 'DESC').orderBy('event_id', 'asc') - } else { - builder.limit(DEFAULT_FILTER_LIMIT).orderBy('event_created_at', 'asc').orderBy('event_id', 'asc') + builder.select(searchSelection) } - if (isTagQuery && !isSearchQuery) { - builder.distinct('events.*') - } + builder.limit(typeof currentFilter.limit === 'number' ? currentFilter.limit : DEFAULT_FILTER_LIMIT) + this.applyFilterOrder(builder, currentFilter, isSearchQuery) return builder }) - const [query, ...subqueries] = queries - if (subqueries.length) { - query.union(subqueries, true) + if (queries.length === 1) { + return queries[0] } + const query = this.unionFilterQueries(queries) + this.applyFilterOrder(query, filters[0], isSearchQueries[0]) + return query } @@ -130,16 +120,31 @@ export class EventRepository implements IEventRepository { return builder }) - const [query, ...subqueries] = queries - if (subqueries.length) { - query.union(subqueries, true) - } + const query = queries.length === 1 ? queries[0] : this.unionFilterQueries(queries) - const result = await this.readReplicaDbClient.from(query.as('matching_events')).countDistinct({ count: 'event_id' }).first() + // Rows are already unique: EXISTS never repeats an event and UNION drops overlaps between filters. + const result = await this.readReplicaDbClient.from(query.as('matching_events')).count({ count: '*' }).first() return Number(result?.count ?? 0) } + // Wraps every query so each keeps its own ORDER BY and LIMIT. first.union(rest) would put the + // first query's ORDER BY and LIMIT after the UNION, applying them to the whole result. + private unionFilterQueries(queries: any[]): any { + return this.readReplicaDbClient.union(queries, true) + } + + private applyFilterOrder(builder: any, currentFilter: SubscriptionFilter, isSearchQuery: boolean): void { + if (isSearchQuery) { + // NIP-50: sort by relevance (ts_rank) descending, then by event_id for stability + builder.orderBy('search_rank', 'DESC').orderBy('event_id', 'asc') + } else if (typeof currentFilter.limit === 'number') { + builder.orderBy('event_created_at', 'DESC').orderBy('event_id', 'asc') + } else { + builder.orderBy('event_created_at', 'asc').orderBy('event_id', 'asc') + } + } + private applyFilterConditions(builder: any, currentFilter: SubscriptionFilter): FilterConditionFlags { this.applyHexFilterConditions(builder, currentFilter) @@ -171,13 +176,9 @@ export class EventRepository implements IEventRepository { } } - const isTagQuery = this.applyGenericTagFilterConditions(builder, currentFilter) - - if (isTagQuery) { - builder.leftJoin('event_tags', 'events.event_id', 'event_tags.event_id') - } + this.applyGenericTagFilterConditions(builder, currentFilter) - return { isTagQuery, isSearchQuery } + return { isSearchQuery } } /** Resolve the PostgreSQL text-search configuration name from settings. */ @@ -247,34 +248,42 @@ export class EventRepository implements IEventRepository { ) } - private applyGenericTagFilterConditions(builder: any, currentFilter: SubscriptionFilter): boolean { + private applyGenericTagFilterConditions(builder: any, currentFilter: SubscriptionFilter): void { const tagFilters = Object.entries(currentFilter).filter(([filterName]) => isGenericTagQuery(filterName)) tagFilters.forEach(([filterName, criteria]) => { this.applyGenericTagCriteria(builder, filterName, criteria as string[]) }) - - return tagFilters.length > 0 } + // One EXISTS per tag name, so every tag name must match. A single join on event_tags can't do + // that, since each tag row has only one name. private applyGenericTagCriteria(builder: any, filterName: string, criteria: string[]): void { - builder.andWhere((bd) => { - if (!criteria.length) { + if (!criteria.length) { + builder.andWhere((bd) => { bd.andWhereRaw('1 = 0') - return - } - - criteria.forEach((criterion) => { - if (isGeohashPrefixCriterion(filterName, criterion)) { - bd.orWhereRaw('event_tags.tag_name = ? AND event_tags.tag_value LIKE ?', [ - filterName[1], - `${stripGeohashPrefixWildcard(criterion)}%`, - ]) - return - } - - bd.orWhereRaw('event_tags.tag_name = ? AND event_tags.tag_value = ?', [filterName[1], criterion]) }) + return + } + + const values = criteria.filter((criterion) => !isGeohashPrefixCriterion(filterName, criterion)) + const prefixes = criteria.filter((criterion) => isGeohashPrefixCriterion(filterName, criterion)) + + builder.whereExists((subquery) => { + subquery + .select(this.readReplicaDbClient.raw('1')) + .from('event_tags') + .whereColumn('event_tags.event_id', 'events.event_id') + .where('event_tags.tag_name', filterName[1]) + .where((bd) => { + if (values.length) { + bd.whereIn('event_tags.tag_value', values) + } + + prefixes.forEach((prefix) => { + bd.orWhere('event_tags.tag_value', 'like', `${stripGeohashPrefixWildcard(prefix)}%`) + }) + }) }) } diff --git a/test/integration/features/nip-01/nip-01.feature b/test/integration/features/nip-01/nip-01.feature index be0c2ca9..1534d0eb 100644 --- a/test/integration/features/nip-01/nip-01.feature +++ b/test/integration/features/nip-01/nip-01.feature @@ -91,3 +91,27 @@ Feature: NIP-01 And Bob receives a text_note event from Bob with content "Three" When Alice subscribes to author Bob with a limit of 2 Then Alice receives 2 text_note events from Bob and EOSE + + Scenario: Alice gets Bob's stored event that carries two different tags + Given someone called Alice + And someone called Bob + When Bob sends a text_note event with content "both tags" and tags t "nostr" and r "wss://relay.example.com" + And Bob sends a text_note event with content "only t" and tag t containing "nostr" + And Alice subscribes to events from Bob with tag t "nostr" and tag r "wss://relay.example.com" + Then Alice receives 1 stored text_note event from Bob with content "both tags" and EOSE + + Scenario: Alice gets Bob's stored event by ID and tag + Given someone called Alice + And someone called Bob + When Bob sends a text_note event with content "tagged" and tag t containing "nostr" + And Alice subscribes to the last event from Bob with tag t "nostr" + Then Alice receives 1 stored text_note event from Bob with content "tagged" and EOSE + + Scenario: Alice gets Bob's past events from two filters when the first has a limit + Given someone called Alice + And someone called Bob + When Bob sends a text_note event with content "One" + And Bob sends a text_note event with content "Two" + And Bob sends a set_metadata event + And Alice subscribes to text_note events from Bob with a limit of 1 or set_metadata events from Bob + Then Alice receives 2 stored events from Bob and EOSE diff --git a/test/integration/features/nip-01/nip-01.feature.ts b/test/integration/features/nip-01/nip-01.feature.ts index 4f9cb3f1..9a14bce6 100644 --- a/test/integration/features/nip-01/nip-01.feature.ts +++ b/test/integration/features/nip-01/nip-01.feature.ts @@ -128,6 +128,82 @@ When( }, ) +When( + /^(\w+) sends a text_note event with content "([^"]+)" and tags (\w) "([^"]+)" and (\w) "([^"]+)"$/, + async function (name: string, content: string, tag1: string, value1: string, tag2: string, value2: string) { + const ws = this.parameters.clients[name] as WebSocket + const { pubkey, privkey } = this.parameters.identities[name] + + const event: Event = await createEvent( + { + pubkey, + kind: 1, + content, + tags: [ + [tag1, value1], + [tag2, value2], + ], + }, + privkey, + ) + + await sendEvent(ws, event) + this.parameters.events[name].push(event) + }, +) + +When( + /^(\w+) subscribes to events from (\w+) with tag (\w) "([^"]+)" and tag (\w) "([^"]+)"$/, + async function ( + this: World>, + name: string, + author: string, + tag1: string, + value1: string, + tag2: string, + value2: string, + ) { + const ws = this.parameters.clients[name] as WebSocket + const pubkey = this.parameters.identities[author].pubkey + const subscription = { + name: `test-${Math.random()}`, + filters: [{ authors: [pubkey], [`#${tag1}`]: [value1], [`#${tag2}`]: [value2] }], + } + this.parameters.subscriptions[name].push(subscription) + + await createSubscription(ws, subscription.name, subscription.filters) + }, +) + +When( + /^(\w+) subscribes to the last event from (\w+) with tag (\w) "([^"]+)"$/, + async function (this: World>, name: string, author: string, tag: string, value: string) { + const ws = this.parameters.clients[name] as WebSocket + const event = this.parameters.events[author][this.parameters.events[author].length - 1] + const subscription = { name: `test-${Math.random()}`, filters: [{ ids: [event.id], [`#${tag}`]: [value] }] } + this.parameters.subscriptions[name].push(subscription) + + await createSubscription(ws, subscription.name, subscription.filters) + }, +) + +When( + /^(\w+) subscribes to text_note events from (\w+) with a limit of (\d+) or set_metadata events from (\w+)$/, + async function (this: World>, name: string, author1: string, limit: string, author2: string) { + const ws = this.parameters.clients[name] as WebSocket + const subscription = { + name: `test-${Math.random()}`, + filters: [ + { kinds: [1], authors: [this.parameters.identities[author1].pubkey], limit: Number(limit) }, + { kinds: [0], authors: [this.parameters.identities[author2].pubkey] }, + ], + } + this.parameters.subscriptions[name].push(subscription) + + await createSubscription(ws, subscription.name, subscription.filters) + }, +) + When( /^(\w+) sends a text_note event with content "([^"]+)" on (\d+)$/, async function (name: string, content: string, createdAt: string) { @@ -253,3 +329,28 @@ Then(/(\w+) receives an? (\w+) result/, async function (name: string, successful expect(command[2]).to.equal(successful === 'successful') }) + +Then( + /^(\w+) receives 1 stored text_note event from (\w+) with content "([^"]+)" and EOSE$/, + async function (this: World>, name: string, author: string, content: string) { + const ws = this.parameters.clients[name] as WebSocket + const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1] + const [event] = await waitForEventCount(ws, subscription.name, 1, true) + + expect(event.kind).to.equal(1) + expect(event.pubkey).to.equal(this.parameters.identities[author].pubkey) + expect(event.content).to.equal(content) + }, +) + +Then( + /^(\w+) receives 2 stored events from (\w+) and EOSE$/, + async function (this: World>, name: string, author: string) { + const ws = this.parameters.clients[name] as WebSocket + const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1] + const events = await waitForEventCount(ws, subscription.name, 2, true) + + expect(events.map((event) => event.kind).sort()).to.deep.equal([0, 1]) + expect(events.every((event) => event.pubkey === this.parameters.identities[author].pubkey)).to.equal(true) + }, +) diff --git a/test/integration/features/nip-45/nip-45.feature b/test/integration/features/nip-45/nip-45.feature new file mode 100644 index 00000000..43ff2019 --- /dev/null +++ b/test/integration/features/nip-45/nip-45.feature @@ -0,0 +1,32 @@ +Feature: NIP-45 + Scenario: Alice counts Bob's text_note events + Given someone called Alice + And someone called Bob + When Bob sends a text_note event with content "One" + And Bob sends a text_note event with content "Two" + And Alice counts text_note events from Bob + Then Alice receives a count of 2 + + Scenario: Alice counts Bob's events that carry two different tags + Given someone called Alice + And someone called Bob + When Bob sends a text_note event with content "both tags" and tags t "nostr" and r "wss://relay.example.com" + And Bob sends a text_note event with content "only t" and tag t containing "nostr" + And Alice counts events from Bob with tag t "nostr" and tag r "wss://relay.example.com" + Then Alice receives a count of 1 + + Scenario: Alice counts Bob's event by ID and tag + Given someone called Alice + And someone called Bob + When Bob sends a text_note event with content "tagged" and tag t containing "nostr" + And Alice counts the last event from Bob with tag t "nostr" + Then Alice receives a count of 1 + + Scenario: Alice counts Bob's events with two filters when the first has a limit + Given someone called Alice + And someone called Bob + When Bob sends a text_note event with content "One" + And Bob sends a text_note event with content "Two" + And Bob sends a set_metadata event + And Alice counts text_note events from Bob with a limit of 1 or set_metadata events from Bob + Then Alice receives a count of 2 diff --git a/test/integration/features/nip-45/nip-45.feature.ts b/test/integration/features/nip-45/nip-45.feature.ts new file mode 100644 index 00000000..622d650b --- /dev/null +++ b/test/integration/features/nip-45/nip-45.feature.ts @@ -0,0 +1,84 @@ +import { Then, When, World } from '@cucumber/cucumber' +import chai from 'chai' +import { Observable } from 'rxjs' +import { WebSocket } from 'ws' + +import { MessageType, OutgoingMessage } from '../../../../src/@types/messages' +import { SubscriptionFilter } from '../../../../src/@types/subscription' +import { streams } from '../shared' + +const { expect } = chai + +function sendCount(world: World>, name: string, filters: SubscriptionFilter[]): void { + const ws = world.parameters.clients[name] as WebSocket + const queryId = `count-${Math.random()}` + + world.parameters.counts = world.parameters.counts ?? {} + world.parameters.counts[name] = new Promise((resolve, reject) => { + const observable = streams.get(ws) as Observable + + const subscription = observable.subscribe((message: OutgoingMessage) => { + if (message[1] !== queryId) { + return + } + + subscription.unsubscribe() + if (message[0] === MessageType.COUNT) { + resolve(message[2].count) + } else { + reject(new Error(`COUNT ${queryId} was not answered with a count: ${JSON.stringify(message)}`)) + } + }) + }) + + ws.send(JSON.stringify(['COUNT', queryId, ...filters])) +} + +When( + /^(\w+) counts text_note events from (\w+)$/, + function (this: World>, name: string, author: string) { + sendCount(this, name, [{ kinds: [1], authors: [this.parameters.identities[author].pubkey] }]) + }, +) + +When( + /^(\w+) counts events from (\w+) with tag (\w) "([^"]+)" and tag (\w) "([^"]+)"$/, + function ( + this: World>, + name: string, + author: string, + tag1: string, + value1: string, + tag2: string, + value2: string, + ) { + sendCount(this, name, [ + { authors: [this.parameters.identities[author].pubkey], [`#${tag1}`]: [value1], [`#${tag2}`]: [value2] }, + ]) + }, +) + +When( + /^(\w+) counts the last event from (\w+) with tag (\w) "([^"]+)"$/, + function (this: World>, name: string, author: string, tag: string, value: string) { + const event = this.parameters.events[author][this.parameters.events[author].length - 1] + sendCount(this, name, [{ ids: [event.id], [`#${tag}`]: [value] }]) + }, +) + +When( + /^(\w+) counts text_note events from (\w+) with a limit of (\d+) or set_metadata events from (\w+)$/, + function (this: World>, name: string, author1: string, limit: string, author2: string) { + sendCount(this, name, [ + { kinds: [1], authors: [this.parameters.identities[author1].pubkey], limit: Number(limit) }, + { kinds: [0], authors: [this.parameters.identities[author2].pubkey] }, + ]) + }, +) + +Then( + /^(\w+) receives a count of (\d+)$/, + async function (this: World>, name: string, count: string) { + expect(await this.parameters.counts[name]).to.equal(Number(count)) + }, +) diff --git a/test/unit/repositories/event-repository.spec.ts b/test/unit/repositories/event-repository.spec.ts index d36294a7..896d21e8 100644 --- a/test/unit/repositories/event-repository.spec.ts +++ b/test/unit/repositories/event-repository.spec.ts @@ -295,7 +295,7 @@ describe('EventRepository', () => { const query = repository.findByFilters(filters).toString() expect(query).to.equal( - 'select distinct "events".* from "events" left join "event_tags" on "events"."event_id" = "event_tags"."event_id" where (1 = 0) order by "event_created_at" asc, "event_id" asc limit 500', + 'select * from "events" where (1 = 0) order by "event_created_at" asc, "event_id" asc limit 500', ) }) @@ -305,7 +305,7 @@ describe('EventRepository', () => { const query = repository.findByFilters(filters).toString() expect(query).to.equal( - 'select distinct "events".* from "events" left join "event_tags" on "events"."event_id" = "event_tags"."event_id" where (event_tags.tag_name = \'e\' AND event_tags.tag_value = \'aaaaaa\') order by "event_created_at" asc, "event_id" asc limit 500', + 'select * from "events" where exists (select 1 from "event_tags" where "event_tags"."event_id" = "events"."event_id" and "event_tags"."tag_name" = \'e\' and ("event_tags"."tag_value" in (\'aaaaaa\'))) order by "event_created_at" asc, "event_id" asc limit 500', ) }) @@ -315,7 +315,7 @@ describe('EventRepository', () => { const query = repository.findByFilters(filters).toString() expect(query).to.equal( - 'select distinct "events".* from "events" left join "event_tags" on "events"."event_id" = "event_tags"."event_id" where (event_tags.tag_name = \'e\' AND event_tags.tag_value = \'aaaaaa\' or event_tags.tag_name = \'e\' AND event_tags.tag_value = \'bbbbbb\') order by "event_created_at" asc, "event_id" asc limit 500', + 'select * from "events" where exists (select 1 from "event_tags" where "event_tags"."event_id" = "events"."event_id" and "event_tags"."tag_name" = \'e\' and ("event_tags"."tag_value" in (\'aaaaaa\', \'bbbbbb\'))) order by "event_created_at" asc, "event_id" asc limit 500', ) }) }) @@ -327,7 +327,7 @@ describe('EventRepository', () => { const query = repository.findByFilters(filters).toString() expect(query).to.equal( - 'select distinct "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', + 'select * from "events" where exists (select 1 from "event_tags" where "event_tags"."event_id" = "events"."event_id" and "event_tags"."tag_name" = \'g\' and ("event_tags"."tag_value" like \'u4pruyd%\')) order by "event_created_at" asc, "event_id" asc limit 500', ) }) @@ -337,7 +337,17 @@ describe('EventRepository', () => { const query = repository.findByFilters(filters).toString() expect(query).to.equal( - 'select distinct "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', + 'select * from "events" where exists (select 1 from "event_tags" where "event_tags"."event_id" = "events"."event_id" and "event_tags"."tag_name" = \'g\' and ("event_tags"."tag_value" in (\'u4pruyd\'))) order by "event_created_at" asc, "event_id" asc limit 500', + ) + }) + + it('matches exact geohashes and prefixes in the same tag condition', () => { + const filters = [{ '#g': ['u4pruyd*', 'u4xx'] }] + + const query = repository.findByFilters(filters).toString() + + expect(query).to.equal( + 'select * from "events" where exists (select 1 from "event_tags" where "event_tags"."event_id" = "events"."event_id" and "event_tags"."tag_name" = \'g\' and ("event_tags"."tag_value" in (\'u4xx\') or "event_tags"."tag_value" like \'u4pruyd%\')) order by "event_created_at" asc, "event_id" asc limit 500', ) }) }) @@ -349,7 +359,7 @@ describe('EventRepository', () => { const query = repository.findByFilters(filters).toString() expect(query).to.equal( - 'select distinct "events".* from "events" left join "event_tags" on "events"."event_id" = "event_tags"."event_id" where (1 = 0) order by "event_created_at" asc, "event_id" asc limit 500', + 'select * from "events" where (1 = 0) order by "event_created_at" asc, "event_id" asc limit 500', ) }) @@ -359,7 +369,7 @@ describe('EventRepository', () => { const query = repository.findByFilters(filters).toString() expect(query).to.equal( - 'select distinct "events".* from "events" left join "event_tags" on "events"."event_id" = "event_tags"."event_id" where (event_tags.tag_name = \'p\' AND event_tags.tag_value = \'aaaaaa\') order by "event_created_at" asc, "event_id" asc limit 500', + 'select * from "events" where exists (select 1 from "event_tags" where "event_tags"."event_id" = "events"."event_id" and "event_tags"."tag_name" = \'p\' and ("event_tags"."tag_value" in (\'aaaaaa\'))) order by "event_created_at" asc, "event_id" asc limit 500', ) }) @@ -369,7 +379,7 @@ describe('EventRepository', () => { const query = repository.findByFilters(filters).toString() expect(query).to.equal( - 'select distinct "events".* from "events" left join "event_tags" on "events"."event_id" = "event_tags"."event_id" where (event_tags.tag_name = \'p\' AND event_tags.tag_value = \'aaaaaa\' or event_tags.tag_name = \'p\' AND event_tags.tag_value = \'bbbbbb\') order by "event_created_at" asc, "event_id" asc limit 500', + 'select * from "events" where exists (select 1 from "event_tags" where "event_tags"."event_id" = "events"."event_id" and "event_tags"."tag_name" = \'p\' and ("event_tags"."tag_value" in (\'aaaaaa\', \'bbbbbb\'))) order by "event_created_at" asc, "event_id" asc limit 500', ) }) }) @@ -381,7 +391,7 @@ describe('EventRepository', () => { const query = repository.findByFilters(filters).toString() expect(query).to.equal( - 'select distinct "events".* from "events" left join "event_tags" on "events"."event_id" = "event_tags"."event_id" where (1 = 0) order by "event_created_at" asc, "event_id" asc limit 500', + 'select * from "events" where (1 = 0) order by "event_created_at" asc, "event_id" asc limit 500', ) }) @@ -391,7 +401,7 @@ describe('EventRepository', () => { const query = repository.findByFilters(filters).toString() expect(query).to.equal( - 'select distinct "events".* from "events" left join "event_tags" on "events"."event_id" = "event_tags"."event_id" where (event_tags.tag_name = \'r\' AND event_tags.tag_value = \'aaaaaa\') order by "event_created_at" asc, "event_id" asc limit 500', + 'select * from "events" where exists (select 1 from "event_tags" where "event_tags"."event_id" = "events"."event_id" and "event_tags"."tag_name" = \'r\' and ("event_tags"."tag_value" in (\'aaaaaa\'))) order by "event_created_at" asc, "event_id" asc limit 500', ) }) @@ -401,7 +411,7 @@ describe('EventRepository', () => { const query = repository.findByFilters(filters).toString() expect(query).to.equal( - 'select distinct "events".* from "events" left join "event_tags" on "events"."event_id" = "event_tags"."event_id" where (event_tags.tag_name = \'r\' AND event_tags.tag_value = \'aaaaaa\' or event_tags.tag_name = \'r\' AND event_tags.tag_value = \'bbbbbb\') order by "event_created_at" asc, "event_id" asc limit 500', + 'select * from "events" where exists (select 1 from "event_tags" where "event_tags"."event_id" = "events"."event_id" and "event_tags"."tag_name" = \'r\' and ("event_tags"."tag_value" in (\'aaaaaa\', \'bbbbbb\'))) order by "event_created_at" asc, "event_id" asc limit 500', ) }) }) @@ -413,7 +423,33 @@ describe('EventRepository', () => { const query = repository.findByFilters(filters).toString() expect(query).to.equal( - 'select distinct "events".* from "events" left join "event_tags" on "events"."event_id" = "event_tags"."event_id" where (event_tags.tag_name = \'d\' AND event_tags.tag_value = \'\') order by "event_created_at" asc, "event_id" asc limit 500', + 'select * from "events" where exists (select 1 from "event_tags" where "event_tags"."event_id" = "events"."event_id" and "event_tags"."tag_name" = \'d\' and ("event_tags"."tag_value" in (\'\'))) order by "event_created_at" asc, "event_id" asc limit 500', + ) + }) + }) + + describe('ids and #e', () => { + it('matches tags without joining event_tags, so event_id stays unambiguous', () => { + const filters = [ + { ids: ['6b3cdd0302ded8068ad3f0269c74423ca4fee460f800f3d90103b63f14400407'], '#e': ['aaaaaa'] }, + ] + + const query = repository.findByFilters(filters).toString() + + expect(query).to.equal( + 'select * from "events" where ("event_id" in (X\'6b3cdd0302ded8068ad3f0269c74423ca4fee460f800f3d90103b63f14400407\')) and exists (select 1 from "event_tags" where "event_tags"."event_id" = "events"."event_id" and "event_tags"."tag_name" = \'e\' and ("event_tags"."tag_value" in (\'aaaaaa\'))) order by "event_created_at" asc, "event_id" asc limit 500', + ) + }) + }) + + describe('#e and #p', () => { + it('requires a match for every tag name, one EXISTS per name', () => { + const filters = [{ '#e': ['aaaaaa'], '#p': ['bbbbbb', 'cccccc'] }] + + const query = repository.findByFilters(filters).toString() + + expect(query).to.equal( + 'select * from "events" where exists (select 1 from "event_tags" where "event_tags"."event_id" = "events"."event_id" and "event_tags"."tag_name" = \'e\' and ("event_tags"."tag_value" in (\'aaaaaa\'))) and exists (select 1 from "event_tags" where "event_tags"."event_id" = "events"."event_id" and "event_tags"."tag_name" = \'p\' and ("event_tags"."tag_value" in (\'bbbbbb\', \'cccccc\'))) order by "event_created_at" asc, "event_id" asc limit 500', ) }) }) @@ -426,7 +462,17 @@ describe('EventRepository', () => { const query = repository.findByFilters(filters).toString() expect(query).to.equal( - '(select * from "events") union (select * from "events" order by "event_created_at" asc, "event_id" asc limit 500) order by "event_created_at" asc, "event_id" asc limit 500', + '(select * from "events" order by "event_created_at" asc, "event_id" asc limit 500) union (select * from "events" order by "event_created_at" asc, "event_id" asc limit 500) order by "event_created_at" asc, "event_id" asc', + ) + }) + + it('keeps the first filter limit inside its own query', () => { + const filters = [{ kinds: [7], limit: 1 }, { kinds: [1] }] + + const query = repository.findByFilters(filters).toString() + + expect(query).to.equal( + '(select * from "events" where "event_kind" in (7) order by "event_created_at" DESC, "event_id" asc limit 1) union (select * from "events" where "event_kind" in (1) order by "event_created_at" asc, "event_id" asc limit 500) order by "event_created_at" DESC, "event_id" asc', ) }) }) @@ -445,7 +491,7 @@ describe('EventRepository', () => { const query = repository.findByFilters(filters).toString() expect(query).to.equal( - '(select * from "events" where "event_kind" in (1)) union (select * from "events" where (substring("event_id" from 1 for 3) BETWEEN E\'\\\\xaaaaa0\' AND E\'\\\\xaaaaaf\') order by "event_created_at" asc, "event_id" asc limit 500) union (select * from "events" where (substring("event_pubkey" from 1 for 3) BETWEEN E\'\\\\xbbbbb0\' AND E\'\\\\xbbbbbf\') order by "event_created_at" asc, "event_id" asc limit 500) union (select * from "events" where "event_created_at" >= 1000 order by "event_created_at" asc, "event_id" asc limit 500) union (select * from "events" where "event_created_at" <= 1000 order by "event_created_at" asc, "event_id" asc limit 500) union (select * from "events" order by "event_created_at" DESC, "event_id" asc limit 1000) order by "event_created_at" asc, "event_id" asc limit 500', + '(select * from "events" where "event_kind" in (1) order by "event_created_at" asc, "event_id" asc limit 500) union (select * from "events" where (substring("event_id" from 1 for 3) BETWEEN E\'\\\\xaaaaa0\' AND E\'\\\\xaaaaaf\') order by "event_created_at" asc, "event_id" asc limit 500) union (select * from "events" where (substring("event_pubkey" from 1 for 3) BETWEEN E\'\\\\xbbbbb0\' AND E\'\\\\xbbbbbf\') order by "event_created_at" asc, "event_id" asc limit 500) union (select * from "events" where "event_created_at" >= 1000 order by "event_created_at" asc, "event_id" asc limit 500) union (select * from "events" where "event_created_at" <= 1000 order by "event_created_at" asc, "event_id" asc limit 500) union (select * from "events" order by "event_created_at" DESC, "event_id" asc limit 1000) order by "event_created_at" asc, "event_id" asc', ) }) }) @@ -501,16 +547,17 @@ describe('EventRepository', () => { expect(query).to.include('"event_kind" in (1)') }) - it('de-duplicates results when search is combined with a generic tag filter', () => { + it('matches generic tags with EXISTS when search is combined with a tag filter', () => { const filters = [{ search: 'bitcoin', '#p': ['a', 'b'] }] const query = searchEnabledRepository.findByFilters(filters).toString() - expect(query).to.include('select distinct events.*') - expect(query).to.include('ts_rank(') - expect(query).to.include('left join "event_tags" on "events"."event_id" = "event_tags"."event_id"') + expect(query).to.include('select events.*, ts_rank(') + expect(query).to.not.include('distinct') + expect(query).to.not.include('join') expect(query).to.include("plainto_tsquery('simple'::regconfig, 'bitcoin')") - expect(query).to.include("event_tags.tag_name = 'p'") + expect(query).to.include('exists (select 1 from "event_tags"') + expect(query).to.include('"event_tags"."tag_name" = \'p\'') }) it('ignores search filter when NIP-50 is disabled', () => { @@ -573,7 +620,7 @@ describe('EventRepository', () => { it('returns count value from query result', async () => { sandbox.stub(rrDbClient, 'from').returns({ - countDistinct: () => ({ + count: () => ({ first: async () => ({ count: '42' }), }), } as any) @@ -583,21 +630,21 @@ describe('EventRepository', () => { expect(result).to.equal(42) }) - it('uses countDistinct on event_id to avoid duplicate counts', async () => { - const countDistinctStub = sandbox.stub().returns({ + it('counts rows with count(*) since no filter yields an event twice', async () => { + const countStub = sandbox.stub().returns({ first: async () => ({ count: '1' }), }) - sandbox.stub(rrDbClient, 'from').returns({ countDistinct: countDistinctStub } as any) + sandbox.stub(rrDbClient, 'from').returns({ count: countStub } as any) await repository.countByFilters([{ '#e': ['aaaaaa'] } as any]) - expect(countDistinctStub).to.have.been.calledOnceWithExactly({ count: 'event_id' }) + expect(countStub).to.have.been.calledOnceWithExactly({ count: '*' }) }) it('builds union query when there are multiple filters', async () => { const fromStub = sandbox.stub(rrDbClient, 'from').returns({ - countDistinct: () => ({ + count: () => ({ first: async () => ({ count: '1' }), }), } as any) @@ -608,9 +655,9 @@ describe('EventRepository', () => { expect(sql).to.include(' union ') }) - it('joins tags table for generic tag filters', async () => { + it('matches generic tag filters with EXISTS on event_tags', async () => { const fromStub = sandbox.stub(rrDbClient, 'from').returns({ - countDistinct: () => ({ + count: () => ({ first: async () => ({ count: '1' }), }), } as any) @@ -618,14 +665,48 @@ describe('EventRepository', () => { await repository.countByFilters([{ '#e': ['aaaaaa'] } as any]) const sql = fromStub.firstCall.args[0].toString() - expect(sql).to.include('left join "event_tags"') - expect(sql).to.include('event_tags.tag_name') - expect(sql).to.include('event_tags.tag_value') + expect(sql).to.include('exists (select 1 from "event_tags"') + expect(sql).to.include('"event_tags"."tag_name" = \'e\'') + expect(sql).to.include('"event_tags"."tag_value" in (\'aaaaaa\')') + expect(sql).to.not.include('join') + }) + + it('requires a match for every tag name in a filter', async () => { + const fromStub = sandbox.stub(rrDbClient, 'from').returns({ + count: () => ({ + first: async () => ({ count: '1' }), + }), + } as any) + + await repository.countByFilters([{ '#e': ['aaaaaa'], '#p': ['bbbbbb'] } as any]) + + const sql = fromStub.firstCall.args[0].toString() + expect(sql.match(/exists \(select 1 from "event_tags"/g)).to.have.lengthOf(2) + expect(sql).to.include('"event_tags"."tag_name" = \'e\' and ("event_tags"."tag_value" in (\'aaaaaa\')))') + expect(sql).to.include( + ') and exists (select 1 from "event_tags" where "event_tags"."event_id" = "events"."event_id" and "event_tags"."tag_name" = \'p\'', + ) + }) + + it('keeps each filter limit inside its own branch of the union', async () => { + const fromStub = sandbox.stub(rrDbClient, 'from').returns({ + count: () => ({ + first: async () => ({ count: '1' }), + }), + } as any) + + await repository.countByFilters([{ kinds: [7], limit: 1 }, { kinds: [1] }]) + + const sql = fromStub.firstCall.args[0].toString() + expect(sql).to.match(/^\(select "events"\."event_id" from "events" where "event_kind" in \(7\) /) + expect(sql).to.include('order by "event_created_at" DESC, "event_id" asc limit 1) union (select') + // Nothing may follow the last branch, or it would apply to the whole union. + expect(sql).to.match(/\)$/) }) it('applies limit ordering when a filter includes limit', async () => { const fromStub = sandbox.stub(rrDbClient, 'from').returns({ - countDistinct: () => ({ + count: () => ({ first: async () => ({ count: '1' }), }), } as any) @@ -638,7 +719,7 @@ describe('EventRepository', () => { it('filters out deleted and expired events', async () => { const fromStub = sandbox.stub(rrDbClient, 'from').returns({ - countDistinct: () => ({ + count: () => ({ first: async () => ({ count: '1' }), }), } as any) @@ -653,7 +734,7 @@ describe('EventRepository', () => { it('projects event_id exactly once for generic tag filters', async () => { const fromStub = sandbox.stub(rrDbClient, 'from').returns({ - countDistinct: () => ({ + count: () => ({ first: async () => ({ count: '1' }), }), } as any) @@ -661,13 +742,13 @@ describe('EventRepository', () => { await repository.countByFilters([{ '#e': ['aaaaaa'] } as any]) const sql = fromStub.firstCall.args[0].toString() - expect(sql).to.include('select "events"."event_id" from "events" left join "event_tags"') + expect(sql).to.include('select "events"."event_id" from "events" where exists (select 1 from "event_tags"') expect(sql).to.not.include('"events"."event_id", "events"."event_id"') }) it('projects the same columns in both branches of a mixed union', async () => { const fromStub = sandbox.stub(rrDbClient, 'from').returns({ - countDistinct: () => ({ + count: () => ({ first: async () => ({ count: '1' }), }), } as any) From fd4ce5ad0ee27214d890e8f88116dbcd47af93c4 Mon Sep 17 00:00:00 2001 From: Priyanshubhartistm Date: Wed, 30 Sep 2026 13:51:35 +0530 Subject: [PATCH 2/2] fix(nip56): update countByFilters test mock for count() rename Signed-off-by: Priyanshubhartistm --- test/unit/repositories/event-repository.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/repositories/event-repository.spec.ts b/test/unit/repositories/event-repository.spec.ts index 4d506366..1bccee29 100644 --- a/test/unit/repositories/event-repository.spec.ts +++ b/test/unit/repositories/event-repository.spec.ts @@ -630,7 +630,7 @@ describe('EventRepository', () => { it('applies the exclusion to countByFilters too', async () => { const fromStub = sandbox.stub(rrDbClient, 'from').returns({ - countDistinct: () => ({ + count: () => ({ first: async () => ({ count: '0' }), }), } as any)