Skip to content

Commit

Permalink
feat(nip-119): AND filter (#59)
Browse files Browse the repository at this point in the history
* feat(nip-119): AND filter
  • Loading branch information
bezysoftware authored Oct 18, 2024
1 parent 3e45a07 commit 478d3c7
Show file tree
Hide file tree
Showing 20 changed files with 602 additions and 312 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ NIPs with a relay-specific implementation are listed here.
- [ ] NIP-50: [Search Capability](https://github.com/nostr-protocol/nips/blob/master/50.md)
- [x] NIP-62: [Request to Vanish](https://github.com/vitorpamplona/nips/blob/right-to-vanish/62.md)
- [x] NIP-70: [Protected events](https://github.com/nostr-protocol/nips/blob/master/70.md)
- [x] NIP-119: [AND operator for filters](https://github.com/nostr-protocol/nips/pull/1365)

## Tests

Expand Down
3 changes: 3 additions & 0 deletions src/Netstr/Extensions/MessagingExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ public static IServiceCollection AddMessaging(this IServiceCollection services)
// RegularEventHandler needs to go last
services.AddSingleton<IEventHandler, RegularEventHandler>();

// RegularEventHandler needs to go last
services.AddSingleton<IEventHandler, RegularEventHandler>();

services.AddEventValidators();
services.AddSubscriptionValidators();

Expand Down
17 changes: 14 additions & 3 deletions src/Netstr/Messaging/MessageHandlers/FilterMessageHandlerBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ namespace Netstr.Messaging.MessageHandlers
/// </summary>
public abstract class FilterMessageHandlerBase : IMessageHandler
{
const char TagModifierOr = '#';
const char TagModifierAnd = '&';

protected readonly IEnumerable<ISubscriptionRequestValidator> validators;
protected readonly IOptions<LimitsOptions> limits;
protected readonly IOptions<AuthOptions> auth;
Expand Down Expand Up @@ -105,21 +108,29 @@ private SubscriptionFilter GetSubscriptionFilter(string subscriptionId, JsonDocu
{
var r = json.DeserializeRequired<SubscriptionFilterRequest>();

if (r.AdditionalData?.Any(x => !x.Key.StartsWith("#") || x.Key.Length != 2) ?? false)
// only single letter tags with AND and OR modifiers are allowed as tag filters
if (r.AdditionalData?.Any(x => (!x.Key.StartsWith(TagModifierOr) && !x.Key.StartsWith(TagModifierAnd)) || x.Key.Length != 2) ?? false)
{
throw new MessageProcessingException(subscriptionId, Messages.UnsupportedFilter);
}

var tags = r.AdditionalData?.ToDictionary(x => x.Key.TrimStart('#'), x => x.Value.DeserializeRequired<string[]>()) ?? new();
Func<Dictionary<string, JsonElement>?, char, Dictionary<string, string[]>> getTags = (data, type) => data?
.Where(x => x.Key.StartsWith(type))
.ToDictionary(x => x.Key.TrimStart(type), x => x.Value.DeserializeRequired<string[]>())
?? new();

var orTags = getTags(r.AdditionalData, TagModifierOr);
var andTags = getTags(r.AdditionalData, TagModifierAnd);

return new SubscriptionFilter(
r.Ids.EmptyIfNull(),
r.Authors.EmptyIfNull(),
r.Kinds.EmptyIfNull(),
r.Since,
r.Until,
r.Limit,
tags);
orTags,
andTags);
}
}
}
7 changes: 3 additions & 4 deletions src/Netstr/Messaging/Models/SubscriptionFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,12 @@ public record SubscriptionFilter(
DateTimeOffset? Since,
DateTimeOffset? Until,
int? Limit,
Dictionary<string, string[]> Tags)
Dictionary<string, string[]> OrTags,
Dictionary<string, string[]> AndTags)
{
public SubscriptionFilter()
: this([], [], [], null, null, null, [])
: this([], [], [], null, null, null, [], [])
{
}
}

public record Tag(string Name, string[] Values) { }
}
18 changes: 16 additions & 2 deletions src/Netstr/Messaging/Subscriptions/MatchingExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ public static IQueryable<EventEntity> WhereAnyFilterMatches(
(filter.Kinds.Contains(x.EventKind) || !filter.Kinds.Any()) &&
(filter.Since <= x.EventCreatedAt || !filter.Since.HasValue) &&
(filter.Until >= x.EventCreatedAt || !filter.Until.HasValue))
.WhereTags(filter.Tags)
.WhereOrTags(filter.OrTags)
.WhereAndTags(filter.AndTags)
.Where(x => !protectedKinds.Contains(x.EventKind) || x.EventPublicKey == authenticatedPublicKey || x.Tags.Any(tag => tag.Name == EventTag.PublicKey && tag.Value == authenticatedPublicKey))
.OrderByDescending(x => x.EventCreatedAt)
.ThenBy(x => x.EventId)
Expand All @@ -53,7 +54,7 @@ public static IQueryable<EventEntity> WhereAnyFilterMatches(
return WhereAnyFilterMatches(entities, filters, [], null, maxLimit);
}

private static IQueryable<EventEntity> WhereTags(this IQueryable<EventEntity> entities, IDictionary<string, string[]> tags)
private static IQueryable<EventEntity> WhereOrTags(this IQueryable<EventEntity> entities, IDictionary<string, string[]> tags)
{
foreach (var tag in tags)
{
Expand All @@ -62,5 +63,18 @@ private static IQueryable<EventEntity> WhereTags(this IQueryable<EventEntity> en

return entities;
}

private static IQueryable<EventEntity> WhereAndTags(this IQueryable<EventEntity> entities, IDictionary<string, string[]> tags)
{
foreach (var tag in tags)
{
foreach (var tagValue in tag.Value)
{
entities = entities.Where(e => e.Tags.Any(etag => etag.Name == tag.Key && etag.Value == tagValue));
}
}

return entities;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ public static bool IsMatch(SubscriptionFilter filter, Event e)
() => filter.Kinds.EmptyOrAny(x => x == e.Kind),
() => !filter.Since.HasValue || filter.Since <= e.CreatedAt,
() => !filter.Until.HasValue || filter.Until >= e.CreatedAt,
() => filter.Tags.All(tag => e.Tags.Any(x => tag.Key == x[0] && tag.Value.Contains(x[1])))
() => filter.OrTags.All(tag => e.Tags.Any(x => tag.Key == x[0] && tag.Value.Contains(x[1]))),
() => filter.AndTags.All(tag => tag.Value.All(tagValue => e.Tags.Any(eTag => eTag[0] == tag.Key && eTag[1] == tagValue)))
];

return filters.All(x => x());
Expand Down
2 changes: 1 addition & 1 deletion src/Netstr/Views/Home/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
}
<tr>
<td>Pubkey</td>
<td><a href="https://nostr.at/@(Model.RelayInformation.PublicKey)" target="_blank">@Model.RelayInformation.PublicKey</a></td>
<td><a href="https://nostrudel.ninja/#/u/@(Model.RelayInformation.PublicKey)" target="_blank">@Model.RelayInformation.PublicKey</a></td>
</tr>
<tr>
<td>Supported NIPs</td>
Expand Down
2 changes: 1 addition & 1 deletion src/Netstr/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
"Description": "A nostr relay",
"PublicKey": "01eb82fef924e5f8c79abf69cfa5ad5508e784af728403692a8bb5890e7e77b5",
"Contact": "bezysoftware@outlook.com",
"SupportedNips": [ 1, 2, 4, 9, 11, 13, 17, 40, 42, 45, 62, 70 ],
"SupportedNips": [ 1, 2, 4, 9, 11, 13, 17, 40, 42, 45, 62, 70, 119 ],
"Version": "v0.0.0"
}
}
2 changes: 1 addition & 1 deletion test/Netstr.Tests/Events/DbFilterEventMatchingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ public void FindEventsWithTags()
var filters = new[]
{
new SubscriptionFilter {
Tags = new () {
OrTags = new () {
["p"] = [ "abcd", "4d5ce768123563bc583697db5e84841fb528f7b708d966f2e546286ce3c72077" ],
["e"] = [ "8da089fad0df548e490d93eccc413ecee63cc9da4901051b0bdcb801032f05d3" ]
}
Expand Down
20 changes: 12 additions & 8 deletions test/Netstr.Tests/Events/FilterEventMatchingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public void TrueForEmptyFilter()
[InlineData("6b3cdd0302ded8068a", false)]
public void IdsFilterTests(string id, bool expectation)
{
var filter = new SubscriptionFilter([id], [], [], null, null, 0, []);
var filter = new SubscriptionFilter([id], [], [], null, null, 0, [], []);
var result = SubscriptionFilterMatcher.IsMatch(filter, this.e);

Assert.Equal(expectation, result);
Expand All @@ -56,7 +56,7 @@ public void IdsFilterTests(string id, bool expectation)
[InlineData("22e804d26ed16b68db52", false)]
public void AuthorsFilterTests(string author, bool expectation)
{
var filter = new SubscriptionFilter([], [author], [], null, null, 0, []);
var filter = new SubscriptionFilter([], [author], [], null, null, 0, [], []);
var result = SubscriptionFilterMatcher.IsMatch(filter, this.e);

Assert.Equal(expectation, result);
Expand All @@ -67,7 +67,7 @@ public void AuthorsFilterTests(string author, bool expectation)
[InlineData(1, true)]
public void KindsFilterTests(int kind, bool expecation)
{
var filter = new SubscriptionFilter([], [], [kind], null, null, 0, []);
var filter = new SubscriptionFilter([], [], [kind], null, null, 0, [], []);
var result = SubscriptionFilterMatcher.IsMatch(filter, this.e);

Assert.Equal(expecation, result);
Expand All @@ -79,7 +79,7 @@ public void KindsFilterTests(int kind, bool expecation)
[InlineData(1648351381, false)]
public void SinceFilterTests(int since, bool expecation)
{
var filter = new SubscriptionFilter([], [], [], DateTimeOffset.FromUnixTimeSeconds(since), null, 0, []);
var filter = new SubscriptionFilter([], [], [], DateTimeOffset.FromUnixTimeSeconds(since), null, 0, [], []);
var result = SubscriptionFilterMatcher.IsMatch(filter, this.e);

Assert.Equal(expecation, result);
Expand All @@ -91,7 +91,7 @@ public void SinceFilterTests(int since, bool expecation)
[InlineData(1648351381, true)]
public void UntilFilterTests(int until, bool expecation)
{
var filter = new SubscriptionFilter([], [], [], null, DateTimeOffset.FromUnixTimeSeconds(until), 0, []);
var filter = new SubscriptionFilter([], [], [], null, DateTimeOffset.FromUnixTimeSeconds(until), 0, [], []);
var result = SubscriptionFilterMatcher.IsMatch(filter, this.e);

Assert.Equal(expecation, result);
Expand All @@ -112,6 +112,7 @@ public void MultipleFiltersTest(string ids, string authors, int kind, int since,
DateTimeOffset.FromUnixTimeSeconds(since),
DateTimeOffset.FromUnixTimeSeconds(until),
0,
[],
[]);

var result = SubscriptionFilterMatcher.IsMatch(filter, this.e);
Expand All @@ -130,7 +131,8 @@ public void SingleTagsMatchTest()
new()
{
["e"] = ["7377fa81fc6c7ae7f7f4ef8938d4a603f7bf98183b35ab128235cc92d4bebf96"]
});
},
[]);
var result = SubscriptionFilterMatcher.IsMatch(filter, this.e);

Assert.True(result);
Expand All @@ -148,7 +150,8 @@ public void MultipleTagsMatchTest()
{
["e"] = ["7377fa81fc6c7ae7f7f4ef8938d4a603f7bf98183b35ab128235cc92d4bebf96"],
["p"] = ["abcd", "8355095016fddbe31fcf1453b26f613553e9758cf2263e190eac8fd96a3d3de9"]
});
},
[]);
var result = SubscriptionFilterMatcher.IsMatch(filter, this.e);

Assert.True(result);
Expand All @@ -166,7 +169,8 @@ public void SomeTagsDoNotMatchTest()
{
["e"] = ["abcd"],
["p"] = ["8355095016fddbe31fcf1453b26f613553e9758cf2263e190eac8fd96a3d3de9"]
});
},
[]);
var result = SubscriptionFilterMatcher.IsMatch(filter, this.e);

Assert.False(result);
Expand Down
30 changes: 30 additions & 0 deletions test/Netstr.Tests/NIPs/119.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
Feature: NIP-119
Enable AND within a single tag filter by using an & modifier in filters for indexable tags.

Background:
Given a relay is running
And Alice is connected to relay
| PublicKey | PrivateKey |
| 5758137ec7f38f3d6c3ef103e28cd9312652285dab3497fe5e5f6c5c0ef45e75 | 512a14752ed58380496920da432f1c0cdad952cd4afda3d9bfa51c2051f91b02 |
And Bob is connected to relay
| PublicKey | PrivateKey |
| 5bc683a5d12133a96ac5502c15fe1c2287986cff7baf6283600360e6bb01f627 | 3551fc7617f76632e4542992c0bc01fecb224de639c4b6a1e0956946e8bb8a29 |

Scenario: Tag filter with & is treated as AND
Alice asks for events tagged with both "meme" AND "cat" that have the tag "black" OR "white"
When Bob publishes events
| Id | Content | Kind | Tags | CreatedAt |
| 828a22e778269e7ba35ae7fa8b23d9506561700f176677f7a8dc7858282f4be3 | Cute cat | 1 | [["t", "meme"], ["t", "cat"], ["t", "black"]] | 1722337838 |
| d711c1bdaf9fc9aa9a1b91580d98991531e95d22870817ba122d248b4151fde8 | Cute dog | 1 | [["t", "meme"], ["t", "dog"], ["t", "black"]] | 1722337838 |
And Alice sends a subscription request moarcats
| Kinds | &t | #t |
| 1 | meme,cat | black,white |
And Bob publishes an event
| Id | Content | Kind | Tags | CreatedAt |
| dad216b3cebb2754fcef13dfd6299879cd2b4cb7988e38e36bc01874c90fab47 | Cute cat | 1 | [["t", "meme"], ["t", "cat"], ["t", "white"]] | 1722337840 |
| a88cc99d717189d32aa5361386a0654a7b5a0c99f52e1377821bcf5302f64c76 | Cute dog | 1 | [["t", "meme"], ["t", "dog"], ["t", "white"]] | 1722337840 |
Then Alice receives messages
| Type | Id | EventId |
| EVENT | moarcats | 828a22e778269e7ba35ae7fa8b23d9506561700f176677f7a8dc7858282f4be3 |
| EOSE | moarcats | |
| EVENT | moarcats | dad216b3cebb2754fcef13dfd6299879cd2b4cb7988e38e36bc01874c90fab47 |
Loading

0 comments on commit 478d3c7

Please sign in to comment.