diff --git a/envelopes_sonic.go b/envelopes_sonic.go index 4cabcbc..c4fbfb6 100644 --- a/envelopes_sonic.go +++ b/envelopes_sonic.go @@ -134,6 +134,7 @@ type sonicVisitor struct { currentFilter *Filter currentFilterTagList []string currentFilterTagName string + currentFilterTagIsAnd bool smp *sonicMessageParser mainEnvelope Envelope @@ -192,7 +193,11 @@ func (sv *sonicVisitor) OnArrayEnd() error { sv.whereWeAre = inFilterObject sv.smp.doneWithIntSlice(sv.currentFilter.Kinds) case inAFilterTag: - sv.currentFilter.Tags[sv.currentFilterTagName] = sv.currentFilterTagList + if sv.currentFilterTagIsAnd { + sv.currentFilter.TagsAnd[sv.currentFilterTagName] = sv.currentFilterTagList + } else { + sv.currentFilter.Tags[sv.currentFilterTagName] = sv.currentFilterTagList + } sv.whereWeAre = inFilterObject sv.smp.doneWithStringSlice(sv.currentFilterTagList) @@ -287,6 +292,15 @@ func (sv *sonicVisitor) OnObjectKey(key string) error { } sv.currentFilterTagList = sv.smp.reusableStringArray sv.currentFilterTagName = key[1:] + sv.currentFilterTagIsAnd = false + sv.whereWeAre = inAFilterTag + } else if len(key) > 1 && key[0] == '&' { + if sv.currentFilter.TagsAnd == nil { + sv.currentFilter.TagsAnd = make(TagMap, 1) + } + sv.currentFilterTagList = sv.smp.reusableStringArray + sv.currentFilterTagName = key[1:] + sv.currentFilterTagIsAnd = true sv.whereWeAre = inAFilterTag } else { return fmt.Errorf("unexpected filter attr %s", key) diff --git a/filter.go b/filter.go index ed4b1f0..9859ba6 100644 --- a/filter.go +++ b/filter.go @@ -13,6 +13,7 @@ type Filter struct { Kinds []int Authors []string Tags TagMap + TagsAnd TagMap Since *Timestamp Until *Timestamp Limit int @@ -85,12 +86,33 @@ func (ef Filter) MatchesIgnoringTimestampConstraints(event *Event) bool { return false } - for f, v := range ef.Tags { - if v != nil && !event.Tags.ContainsAny(f, v) { + // First check AND tags: all values must be present + for f, v := range ef.TagsAnd { + if v != nil && !event.Tags.ContainsAll(f, v) { return false } } + // Then check OR tags: any value must be present, but exclude values that are in TagsAnd + for f, v := range ef.Tags { + if v != nil { + // Exclude values that are in TagsAnd for the same tag name + andValues := ef.TagsAnd[f] + filteredValues := make([]string, 0, len(v)) + for _, val := range v { + if !slices.Contains(andValues, val) { + filteredValues = append(filteredValues, val) + } + } + + // If there are no values left after filtering, skip this tag + // (all values were in TagsAnd, which was already checked) + if len(filteredValues) > 0 && !event.Tags.ContainsAny(f, filteredValues) { + return false + } + } + } + return true } @@ -121,6 +143,20 @@ func FilterEqual(a Filter, b Filter) bool { } } + if len(a.TagsAnd) != len(b.TagsAnd) { + return false + } + + for f, av := range a.TagsAnd { + if bv, ok := b.TagsAnd[f]; !ok { + return false + } else { + if !similar(av, bv) { + return false + } + } + } + if !arePointerValuesEqual(a.Since, b.Since) { return false } @@ -157,6 +193,13 @@ func (ef Filter) Clone() Filter { } } + if ef.TagsAnd != nil { + clone.TagsAnd = make(TagMap, len(ef.TagsAnd)) + for k, v := range ef.TagsAnd { + clone.TagsAnd[k] = slices.Clone(v) + } + } + if ef.Since != nil { since := *ef.Since clone.Since = &since diff --git a/filter_easyjson.go b/filter_easyjson.go index d980045..0df6294 100644 --- a/filter_easyjson.go +++ b/filter_easyjson.go @@ -23,6 +23,7 @@ func easyjson4d398eaaDecodeGithubComNbdWtfGoNostr(in *jlexer.Lexer, out *Filter) return } out.Tags = make(TagMap) + out.TagsAnd = make(TagMap) in.Delim('{') for !in.IsDelim('}') { key := in.UnsafeFieldName(false) @@ -152,6 +153,28 @@ func easyjson4d398eaaDecodeGithubComNbdWtfGoNostr(in *jlexer.Lexer, out *Filter) in.Delim(']') } out.Tags[key[1:]] = tagValues + } else if len(key) > 1 && key[0] == '&' { + tagValues := make([]string, 0, 40) + if !in.IsNull() { + in.Delim('[') + if out.Authors == nil { + if !in.IsDelim(']') { + tagValues = make([]string, 0, 4) + } else { + tagValues = []string{} + } + } else { + tagValues = (tagValues)[:0] + } + for !in.IsDelim(']') { + var v3 string + v3 = string(in.String()) + tagValues = append(tagValues, v3) + in.WantComma() + } + in.Delim(']') + } + out.TagsAnd[key[1:]] = tagValues } else { in.SkipRecursive() } @@ -280,6 +303,25 @@ func easyjson4d398eaaEncodeGithubComNbdWtfGoNostr(out *jwriter.Writer, in Filter out.RawByte(']') } } + for tag, values := range in.TagsAnd { + const prefix string = ",\"authors\":" + if first { + first = false + out.RawString("\"&" + tag + "\":") + } else { + out.RawString(",\"&" + tag + "\":") + } + { + out.RawByte('[') + for i, v := range values { + if i > 0 { + out.RawByte(',') + } + out.String(string(v)) + } + out.RawByte(']') + } + } out.RawByte('}') } diff --git a/filter_test.go b/filter_test.go index 5979eff..0317be8 100644 --- a/filter_test.go +++ b/filter_test.go @@ -151,3 +151,244 @@ func TestTheoreticalLimit(t *testing.T) { require.Equal(t, 24, GetTheoreticalLimit(Filter{Authors: []string{"a", "b", "c", "d", "e", "f"}, Kinds: []int{30023, 30024}, Tags: TagMap{"d": []string{"aaa", "bbb"}}})) require.Equal(t, -1, GetTheoreticalLimit(Filter{Authors: []string{"a", "b", "c", "d", "e", "f"}, Kinds: []int{30023, 30024}})) } + +func TestFilterUnmarshalWithAndTags(t *testing.T) { + raw := `{"kinds":[1],"&t":["meme","cat"],"#t":["black","white"]}` + var f Filter + err := json.Unmarshal([]byte(raw), &f) + assert.NoError(t, err) + + assert.Condition(t, func() (success bool) { + if f.Kinds == nil || len(f.Kinds) != 1 || f.Kinds[0] != 1 { + return false + } + if f.TagsAnd == nil || len(f.TagsAnd) != 1 { + return false + } + if !slices.Contains(f.TagsAnd["t"], "meme") || !slices.Contains(f.TagsAnd["t"], "cat") { + return false + } + if f.Tags == nil || len(f.Tags) != 1 { + return false + } + if !slices.Contains(f.Tags["t"], "black") || !slices.Contains(f.Tags["t"], "white") { + return false + } + return true + }, "failed to parse AND filter correctly") +} + +func TestFilterMarshalWithAndTags(t *testing.T) { + filterj, err := json.Marshal(Filter{ + Kinds: []int{1}, + TagsAnd: TagMap{"t": {"meme", "cat"}}, + Tags: TagMap{"t": {"black", "white"}}, + }) + assert.NoError(t, err) + + // The order might vary, so we check that both &t and #t are present + jsonStr := string(filterj) + assert.Contains(t, jsonStr, `"&t"`) + assert.Contains(t, jsonStr, `"#t"`) + assert.Contains(t, jsonStr, `"meme"`) + assert.Contains(t, jsonStr, `"cat"`) + assert.Contains(t, jsonStr, `"black"`) + assert.Contains(t, jsonStr, `"white"`) +} + +func TestFilterMatchingWithAndTags(t *testing.T) { + // Test: Event must have both "meme" AND "cat" tags + filter := Filter{ + Kinds: []int{1}, + TagsAnd: TagMap{"t": {"meme", "cat"}}, + } + + // Event with both tags - should match + event1 := &Event{ + Kind: 1, + Tags: Tags{ + Tag{"t", "meme"}, + Tag{"t", "cat"}, + }, + } + assert.True(t, filter.Matches(event1), "event with both AND tags should match") + + // Event with only one tag - should not match + event2 := &Event{ + Kind: 1, + Tags: Tags{ + Tag{"t", "meme"}, + }, + } + assert.False(t, filter.Matches(event2), "event with only one AND tag should not match") + + // Event with neither tag - should not match + event3 := &Event{ + Kind: 1, + Tags: Tags{ + Tag{"t", "other"}, + }, + } + assert.False(t, filter.Matches(event3), "event without AND tags should not match") +} + +func TestFilterMatchingWithAndAndOrTags(t *testing.T) { + // Test the example from the spec: + // {"kinds": [1], "&t": ["meme", "cat"], "#t": ["black", "white"]} + // Should match events with BOTH "meme" AND "cat" AND at least one of "black" OR "white" + filter := Filter{ + Kinds: []int{1}, + TagsAnd: TagMap{"t": {"meme", "cat"}}, + Tags: TagMap{"t": {"black", "white"}}, + } + + // Event with meme, cat, and black - should match + event1 := &Event{ + Kind: 1, + Tags: Tags{ + Tag{"t", "meme"}, + Tag{"t", "cat"}, + Tag{"t", "black"}, + }, + } + assert.True(t, filter.Matches(event1), "event with all required tags should match") + + // Event with meme, cat, and white - should match + event2 := &Event{ + Kind: 1, + Tags: Tags{ + Tag{"t", "meme"}, + Tag{"t", "cat"}, + Tag{"t", "white"}, + }, + } + assert.True(t, filter.Matches(event2), "event with meme, cat, and white should match") + + // Event with meme and cat but no black/white - should not match + event3 := &Event{ + Kind: 1, + Tags: Tags{ + Tag{"t", "meme"}, + Tag{"t", "cat"}, + }, + } + assert.False(t, filter.Matches(event3), "event missing OR tag should not match") + + // Event with meme, cat, black, but "meme" and "cat" should be ignored in OR evaluation + // This tests that AND values are excluded from OR + event4 := &Event{ + Kind: 1, + Tags: Tags{ + Tag{"t", "meme"}, + Tag{"t", "cat"}, + Tag{"t", "black"}, + }, + } + assert.True(t, filter.Matches(event4), "event with AND tags and OR tag should match (AND values excluded from OR)") + + // Event with only meme (missing cat) - should not match even if it has black + event5 := &Event{ + Kind: 1, + Tags: Tags{ + Tag{"t", "meme"}, + Tag{"t", "black"}, + }, + } + assert.False(t, filter.Matches(event5), "event missing one AND tag should not match") +} + +func TestFilterMatchingAndTagsExcludedFromOr(t *testing.T) { + // Test that values in AND are excluded from OR evaluation + // If &t: ["meme"] and #t: ["meme", "other"], then "meme" should be ignored in OR + filter := Filter{ + Kinds: []int{1}, + TagsAnd: TagMap{"t": {"meme"}}, + Tags: TagMap{"t": {"meme", "other"}}, + } + + // Event with only "meme" - should NOT match because "other" is still required by OR + event1 := &Event{ + Kind: 1, + Tags: Tags{ + Tag{"t", "meme"}, + }, + } + assert.False(t, filter.Matches(event1), "event with only AND value should not match (OR still requires 'other')") + + // Event with "meme" and "other" - should match + event2 := &Event{ + Kind: 1, + Tags: Tags{ + Tag{"t", "meme"}, + Tag{"t", "other"}, + }, + } + assert.True(t, filter.Matches(event2), "event with AND and OR values should match") + + // Event with only "other" (missing "meme") - should not match + event3 := &Event{ + Kind: 1, + Tags: Tags{ + Tag{"t", "other"}, + }, + } + assert.False(t, filter.Matches(event3), "event missing AND value should not match") + + // Test case where all OR values are in AND - should match if AND is satisfied + filter2 := Filter{ + Kinds: []int{1}, + TagsAnd: TagMap{"t": {"meme", "cat"}}, + Tags: TagMap{"t": {"meme", "cat"}}, + } + event4 := &Event{ + Kind: 1, + Tags: Tags{ + Tag{"t", "meme"}, + Tag{"t", "cat"}, + }, + } + assert.True(t, filter2.Matches(event4), "event with all AND values should match when all OR values are in AND") +} + +func TestFilterEqualityWithAndTags(t *testing.T) { + assert.True(t, FilterEqual( + Filter{Kinds: []int{1}, TagsAnd: TagMap{"t": {"meme", "cat"}}}, + Filter{Kinds: []int{1}, TagsAnd: TagMap{"t": {"cat", "meme"}}}, + ), "filters with same AND tags in different order should be equal") + + assert.False(t, FilterEqual( + Filter{Kinds: []int{1}, TagsAnd: TagMap{"t": {"meme", "cat"}}}, + Filter{Kinds: []int{1}, TagsAnd: TagMap{"t": {"meme"}}}, + ), "filters with different AND tags should not be equal") + + assert.True(t, FilterEqual( + Filter{ + Kinds: []int{1}, + TagsAnd: TagMap{"t": {"meme", "cat"}}, + Tags: TagMap{"t": {"black", "white"}}, + }, + Filter{ + Kinds: []int{1}, + TagsAnd: TagMap{"t": {"cat", "meme"}}, + Tags: TagMap{"t": {"white", "black"}}, + }, + ), "filters with same AND and OR tags should be equal") +} + +func TestFilterCloneWithAndTags(t *testing.T) { + flt := Filter{ + Kinds: []int{1}, + TagsAnd: TagMap{"t": {"meme", "cat"}}, + Tags: TagMap{"t": {"black", "white"}}, + } + clone := flt.Clone() + assert.True(t, FilterEqual(flt, clone), "clone with AND tags should be equal") + + clone1 := flt.Clone() + clone1.TagsAnd["t"] = append(clone1.TagsAnd["t"], "dog") + assert.False(t, FilterEqual(flt, clone1), "modifying clone AND tags should cause inequality") + + clone2 := flt.Clone() + clone2.TagsAnd["new"] = []string{"value"} + assert.False(t, FilterEqual(flt, clone2), "adding new AND tag to clone should cause inequality") +} diff --git a/tags.go b/tags.go index 5818361..0c0a188 100644 --- a/tags.go +++ b/tags.go @@ -258,3 +258,30 @@ func (tags Tags) ContainsAny(tagName string, values []string) bool { return false } + +func (tags Tags) ContainsAll(tagName string, values []string) bool { + if len(values) == 0 { + return true + } + + // Collect all tag values for the given tag name + tagValues := make(map[string]bool) + for _, tag := range tags { + if len(tag) < 2 { + continue + } + + if tag[0] == tagName { + tagValues[tag[1]] = true + } + } + + // Check if all required values are present + for _, value := range values { + if !tagValues[value] { + return false + } + } + + return true +}