Fix Neo4j tag filter returning all events instead of filtering (v0.49.2)
Some checks failed
Go / build-and-release (push) Failing after 7s
Some checks failed
Go / build-and-release (push) Failing after 7s
- Change OPTIONAL MATCH to EXISTS subquery for tag filtering in Neo4j - OPTIONAL MATCH returned rows even when tags didn't match (NULL values) - EXISTS subquery correctly requires matching tags to exist - Strip "#" prefix from filter tag types before matching - Filters use "#d", "#p", "#e" but events store tags without prefix - Add trace-level logging for Neo4j query debugging - Add comprehensive tests for Neo4j query builder - Clean up temporary debug logging from handle-req.go Files modified: - pkg/neo4j/query-events.go: Fix tag filtering with EXISTS subquery - pkg/neo4j/query-events_test.go: Add query builder tests - app/handle-req.go: Remove debug logging - pkg/version/version: Bump to v0.49.2 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -34,7 +34,6 @@ import (
|
|||||||
|
|
||||||
func (l *Listener) HandleReq(msg []byte) (err error) {
|
func (l *Listener) HandleReq(msg []byte) (err error) {
|
||||||
log.D.F("handling REQ: %s", msg)
|
log.D.F("handling REQ: %s", msg)
|
||||||
log.T.F("HandleReq: START processing from %s", l.remote)
|
|
||||||
// var rem []byte
|
// var rem []byte
|
||||||
env := reqenvelope.New()
|
env := reqenvelope.New()
|
||||||
if _, err = env.Unmarshal(msg); chk.E(err) {
|
if _, err = env.Unmarshal(msg); chk.E(err) {
|
||||||
|
|||||||
26
app/web/dist/bundle.js
vendored
26
app/web/dist/bundle.js
vendored
File diff suppressed because one or more lines are too long
2
app/web/dist/bundle.js.map
vendored
2
app/web/dist/bundle.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -10,12 +10,15 @@ import (
|
|||||||
"git.mleku.dev/mleku/nostr/encoders/filter"
|
"git.mleku.dev/mleku/nostr/encoders/filter"
|
||||||
"git.mleku.dev/mleku/nostr/encoders/hex"
|
"git.mleku.dev/mleku/nostr/encoders/hex"
|
||||||
"git.mleku.dev/mleku/nostr/encoders/tag"
|
"git.mleku.dev/mleku/nostr/encoders/tag"
|
||||||
|
"lol.mleku.dev/log"
|
||||||
"next.orly.dev/pkg/database/indexes/types"
|
"next.orly.dev/pkg/database/indexes/types"
|
||||||
"next.orly.dev/pkg/interfaces/store"
|
"next.orly.dev/pkg/interfaces/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// QueryEvents retrieves events matching the given filter
|
// QueryEvents retrieves events matching the given filter
|
||||||
func (n *N) QueryEvents(c context.Context, f *filter.F) (evs event.S, err error) {
|
func (n *N) QueryEvents(c context.Context, f *filter.F) (evs event.S, err error) {
|
||||||
|
log.T.F("Neo4j QueryEvents called with filter: kinds=%v, authors=%d, tags=%v",
|
||||||
|
f.Kinds != nil, f.Authors != nil && len(f.Authors.T) > 0, f.Tags != nil)
|
||||||
return n.QueryEventsWithOptions(c, f, false, false)
|
return n.QueryEventsWithOptions(c, f, false, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +104,7 @@ func (n *N) buildCypherQuery(f *filter.F, includeDeleteEvents bool) (string, map
|
|||||||
// Normalize to lowercase hex using our utility function
|
// Normalize to lowercase hex using our utility function
|
||||||
// This handles both binary-encoded pubkeys and hex string pubkeys (including uppercase)
|
// This handles both binary-encoded pubkeys and hex string pubkeys (including uppercase)
|
||||||
hexAuthor := NormalizePubkeyHex(author)
|
hexAuthor := NormalizePubkeyHex(author)
|
||||||
|
log.T.F("Neo4j author filter: raw_len=%d, normalized=%q", len(author), hexAuthor)
|
||||||
if hexAuthor == "" {
|
if hexAuthor == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -142,20 +146,27 @@ func (n *N) buildCypherQuery(f *filter.F, includeDeleteEvents bool) (string, map
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tag filters - this is where Neo4j's graph capabilities shine
|
// Tag filters - this is where Neo4j's graph capabilities shine
|
||||||
// We can efficiently traverse tag relationships
|
// We use EXISTS subqueries to efficiently filter events by tags
|
||||||
|
// This ensures events are only returned if they have matching tags
|
||||||
tagIndex := 0
|
tagIndex := 0
|
||||||
if f.Tags != nil {
|
if f.Tags != nil {
|
||||||
for _, tagValues := range *f.Tags {
|
for _, tagValues := range *f.Tags {
|
||||||
if len(tagValues.T) > 0 {
|
if len(tagValues.T) > 0 {
|
||||||
tagVarName := fmt.Sprintf("t%d", tagIndex)
|
|
||||||
tagTypeParam := fmt.Sprintf("tagType_%d", tagIndex)
|
tagTypeParam := fmt.Sprintf("tagType_%d", tagIndex)
|
||||||
tagValuesParam := fmt.Sprintf("tagValues_%d", tagIndex)
|
tagValuesParam := fmt.Sprintf("tagValues_%d", tagIndex)
|
||||||
|
|
||||||
// Add tag relationship to MATCH clause
|
// The first element is the tag type (e.g., "e", "p", "#e", "#p", etc.)
|
||||||
matchClause += fmt.Sprintf(" OPTIONAL MATCH (e)-[:TAGGED_WITH]->(%s:Tag)", tagVarName)
|
// Filter tags may have "#" prefix (e.g., "#d" for d-tag filters)
|
||||||
|
// Event tags are stored without prefix, so we must strip it
|
||||||
|
tagTypeBytes := tagValues.T[0]
|
||||||
|
var tagType string
|
||||||
|
if len(tagTypeBytes) > 0 && tagTypeBytes[0] == '#' {
|
||||||
|
tagType = string(tagTypeBytes[1:]) // Strip "#" prefix
|
||||||
|
} else {
|
||||||
|
tagType = string(tagTypeBytes)
|
||||||
|
}
|
||||||
|
|
||||||
// The first element is the tag type (e.g., "e", "p", etc.)
|
log.T.F("Neo4j tag filter: type=%q (raw=%q, len=%d)", tagType, string(tagTypeBytes), len(tagTypeBytes))
|
||||||
tagType := string(tagValues.T[0])
|
|
||||||
|
|
||||||
// Convert remaining tag values to strings (skip first element which is the type)
|
// Convert remaining tag values to strings (skip first element which is the type)
|
||||||
// For e/p tags, use NormalizePubkeyHex to handle binary encoding and uppercase hex
|
// For e/p tags, use NormalizePubkeyHex to handle binary encoding and uppercase hex
|
||||||
@@ -164,26 +175,34 @@ func (n *N) buildCypherQuery(f *filter.F, includeDeleteEvents bool) (string, map
|
|||||||
if tagType == "e" || tagType == "p" {
|
if tagType == "e" || tagType == "p" {
|
||||||
// Normalize e/p tag values to lowercase hex (handles binary encoding)
|
// Normalize e/p tag values to lowercase hex (handles binary encoding)
|
||||||
normalized := NormalizePubkeyHex(tv)
|
normalized := NormalizePubkeyHex(tv)
|
||||||
|
log.T.F("Neo4j tag filter: %s-tag value normalized: %q (raw len=%d, binary=%v)",
|
||||||
|
tagType, normalized, len(tv), IsBinaryEncoded(tv))
|
||||||
if normalized != "" {
|
if normalized != "" {
|
||||||
tagValueStrings = append(tagValueStrings, normalized)
|
tagValueStrings = append(tagValueStrings, normalized)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// For other tags, use direct string conversion
|
// For other tags, use direct string conversion
|
||||||
tagValueStrings = append(tagValueStrings, string(tv))
|
val := string(tv)
|
||||||
|
log.T.F("Neo4j tag filter: %s-tag value: %q (len=%d)", tagType, val, len(val))
|
||||||
|
tagValueStrings = append(tagValueStrings, val)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if no valid values after normalization
|
// Skip if no valid values after normalization
|
||||||
if len(tagValueStrings) == 0 {
|
if len(tagValueStrings) == 0 {
|
||||||
|
log.W.F("Neo4j tag filter: no valid values for tag type %q, skipping", tagType)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add WHERE conditions for this tag
|
log.T.F("Neo4j tag filter: type=%s, values=%v", tagType, tagValueStrings)
|
||||||
|
|
||||||
|
// Use EXISTS subquery to filter events that have matching tags
|
||||||
|
// This is more correct than OPTIONAL MATCH because it requires the tag to exist
|
||||||
params[tagTypeParam] = tagType
|
params[tagTypeParam] = tagType
|
||||||
params[tagValuesParam] = tagValueStrings
|
params[tagValuesParam] = tagValueStrings
|
||||||
whereClauses = append(whereClauses,
|
whereClauses = append(whereClauses,
|
||||||
fmt.Sprintf("(%s.type = $%s AND %s.value IN $%s)",
|
fmt.Sprintf("EXISTS { MATCH (e)-[:TAGGED_WITH]->(t:Tag) WHERE t.type = $%s AND t.value IN $%s }",
|
||||||
tagVarName, tagTypeParam, tagVarName, tagValuesParam))
|
tagTypeParam, tagValuesParam))
|
||||||
|
|
||||||
tagIndex++
|
tagIndex++
|
||||||
}
|
}
|
||||||
@@ -250,6 +269,26 @@ RETURN e.id AS id,
|
|||||||
// Combine all parts
|
// Combine all parts
|
||||||
cypher := matchClause + whereClause + returnClause + orderClause + limitClause
|
cypher := matchClause + whereClause + returnClause + orderClause + limitClause
|
||||||
|
|
||||||
|
// Log the generated query for debugging
|
||||||
|
log.T.F("Neo4j query: %s", cypher)
|
||||||
|
// Log params at trace level for debugging
|
||||||
|
var paramSummary strings.Builder
|
||||||
|
for k, v := range params {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case []string:
|
||||||
|
if len(val) <= 3 {
|
||||||
|
paramSummary.WriteString(fmt.Sprintf("%s: %v ", k, val))
|
||||||
|
} else {
|
||||||
|
paramSummary.WriteString(fmt.Sprintf("%s: [%d values] ", k, len(val)))
|
||||||
|
}
|
||||||
|
case []int64:
|
||||||
|
paramSummary.WriteString(fmt.Sprintf("%s: %v ", k, val))
|
||||||
|
default:
|
||||||
|
paramSummary.WriteString(fmt.Sprintf("%s: %v ", k, v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.T.F("Neo4j params: %s", paramSummary.String())
|
||||||
|
|
||||||
return cypher, params
|
return cypher, params
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -462,3 +462,584 @@ func TestCountEvents(t *testing.T) {
|
|||||||
|
|
||||||
t.Logf("✓ Count events returned correct count: %d", count)
|
t.Logf("✓ Count events returned correct count: %d", count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestQueryEventsByTagWithHashPrefix tests that tag filters with "#" prefix work correctly.
|
||||||
|
// This is a regression test for a bug where filter tags like "#d" were not being matched
|
||||||
|
// because the "#" prefix wasn't being stripped before comparison with stored tags.
|
||||||
|
func TestQueryEventsByTagWithHashPrefix(t *testing.T) {
|
||||||
|
if testDB == nil {
|
||||||
|
t.Skip("Neo4j not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanTestDatabase()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
signer := createTestSignerLocal(t)
|
||||||
|
baseTs := timestamp.Now().V
|
||||||
|
|
||||||
|
// Create events with d-tags (parameterized replaceable kind)
|
||||||
|
createAndSaveEventLocal(t, ctx, signer, 30382, "Event with d=id1",
|
||||||
|
tag.NewS(tag.NewFromAny("d", "id1")), baseTs)
|
||||||
|
createAndSaveEventLocal(t, ctx, signer, 30382, "Event with d=id2",
|
||||||
|
tag.NewS(tag.NewFromAny("d", "id2")), baseTs+1)
|
||||||
|
createAndSaveEventLocal(t, ctx, signer, 30382, "Event with d=id3",
|
||||||
|
tag.NewS(tag.NewFromAny("d", "id3")), baseTs+2)
|
||||||
|
createAndSaveEventLocal(t, ctx, signer, 30382, "Event with d=other",
|
||||||
|
tag.NewS(tag.NewFromAny("d", "other")), baseTs+3)
|
||||||
|
|
||||||
|
// Query with "#d" prefix (as clients send it) - should match events with d=id1
|
||||||
|
evs, err := testDB.QueryEvents(ctx, &filter.F{
|
||||||
|
Kinds: kind.NewS(kind.New(30382)),
|
||||||
|
Tags: tag.NewS(tag.NewFromAny("#d", "id1")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to query events with #d tag: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(evs) != 1 {
|
||||||
|
t.Fatalf("Expected 1 event with d=id1, got %d", len(evs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the returned event has the correct d-tag
|
||||||
|
dTag := evs[0].Tags.GetFirst([]byte("d"))
|
||||||
|
if dTag == nil || string(dTag.Value()) != "id1" {
|
||||||
|
t.Fatalf("Expected d=id1, got d=%s", dTag.Value())
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ Query with #d prefix returned correct event")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQueryEventsByTagMultipleValues tests that tag filters with multiple values
|
||||||
|
// use OR logic (match events with ANY of the values).
|
||||||
|
func TestQueryEventsByTagMultipleValues(t *testing.T) {
|
||||||
|
if testDB == nil {
|
||||||
|
t.Skip("Neo4j not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanTestDatabase()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
signer := createTestSignerLocal(t)
|
||||||
|
baseTs := timestamp.Now().V
|
||||||
|
|
||||||
|
// Create events with different d-tags
|
||||||
|
createAndSaveEventLocal(t, ctx, signer, 30382, "Event A",
|
||||||
|
tag.NewS(tag.NewFromAny("d", "target-1")), baseTs)
|
||||||
|
createAndSaveEventLocal(t, ctx, signer, 30382, "Event B",
|
||||||
|
tag.NewS(tag.NewFromAny("d", "target-2")), baseTs+1)
|
||||||
|
createAndSaveEventLocal(t, ctx, signer, 30382, "Event C",
|
||||||
|
tag.NewS(tag.NewFromAny("d", "target-3")), baseTs+2)
|
||||||
|
createAndSaveEventLocal(t, ctx, signer, 30382, "Event D (not target)",
|
||||||
|
tag.NewS(tag.NewFromAny("d", "other-value")), baseTs+3)
|
||||||
|
createAndSaveEventLocal(t, ctx, signer, 30382, "Event E (no match)",
|
||||||
|
tag.NewS(tag.NewFromAny("d", "different")), baseTs+4)
|
||||||
|
|
||||||
|
// Query with multiple d-tag values using "#d" prefix
|
||||||
|
// Should match events with d=target-1 OR d=target-2 OR d=target-3
|
||||||
|
evs, err := testDB.QueryEvents(ctx, &filter.F{
|
||||||
|
Kinds: kind.NewS(kind.New(30382)),
|
||||||
|
Tags: tag.NewS(tag.NewFromAny("#d", "target-1", "target-2", "target-3")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to query events with multiple #d values: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(evs) != 3 {
|
||||||
|
t.Fatalf("Expected 3 events matching the d-tag values, got %d", len(evs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify returned events have correct d-tags
|
||||||
|
validDTags := map[string]bool{"target-1": false, "target-2": false, "target-3": false}
|
||||||
|
for _, ev := range evs {
|
||||||
|
dTag := ev.Tags.GetFirst([]byte("d"))
|
||||||
|
if dTag == nil {
|
||||||
|
t.Fatalf("Event missing d-tag")
|
||||||
|
}
|
||||||
|
dValue := string(dTag.Value())
|
||||||
|
if _, ok := validDTags[dValue]; !ok {
|
||||||
|
t.Fatalf("Unexpected d-tag value: %s", dValue)
|
||||||
|
}
|
||||||
|
validDTags[dValue] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all expected d-tags were found
|
||||||
|
for dValue, found := range validDTags {
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("Expected to find event with d=%s", dValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ Query with multiple #d values returned correct events")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQueryEventsByTagNoMatch tests that tag filters correctly return no results
|
||||||
|
// when no events match the filter.
|
||||||
|
func TestQueryEventsByTagNoMatch(t *testing.T) {
|
||||||
|
if testDB == nil {
|
||||||
|
t.Skip("Neo4j not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanTestDatabase()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
signer := createTestSignerLocal(t)
|
||||||
|
baseTs := timestamp.Now().V
|
||||||
|
|
||||||
|
// Create events with d-tags
|
||||||
|
createAndSaveEventLocal(t, ctx, signer, 30382, "Event",
|
||||||
|
tag.NewS(tag.NewFromAny("d", "existing-value")), baseTs)
|
||||||
|
|
||||||
|
// Query for d-tag value that doesn't exist
|
||||||
|
evs, err := testDB.QueryEvents(ctx, &filter.F{
|
||||||
|
Kinds: kind.NewS(kind.New(30382)),
|
||||||
|
Tags: tag.NewS(tag.NewFromAny("#d", "non-existent-value")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to query events: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(evs) != 0 {
|
||||||
|
t.Fatalf("Expected 0 events for non-matching d-tag, got %d", len(evs))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ Query with non-matching #d value returned no events")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQueryEventsByTagWithKindAndAuthor tests the combination of kind, author, and tag filters.
|
||||||
|
// This is the specific case reported by the user with kind 30382.
|
||||||
|
func TestQueryEventsByTagWithKindAndAuthor(t *testing.T) {
|
||||||
|
if testDB == nil {
|
||||||
|
t.Skip("Neo4j not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanTestDatabase()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
alice := createTestSignerLocal(t)
|
||||||
|
bob := createTestSignerLocal(t)
|
||||||
|
baseTs := timestamp.Now().V
|
||||||
|
|
||||||
|
// Create events from different authors with d-tags
|
||||||
|
createAndSaveEventLocal(t, ctx, alice, 30382, "Alice target 1",
|
||||||
|
tag.NewS(tag.NewFromAny("d", "card-1")), baseTs)
|
||||||
|
createAndSaveEventLocal(t, ctx, alice, 30382, "Alice target 2",
|
||||||
|
tag.NewS(tag.NewFromAny("d", "card-2")), baseTs+1)
|
||||||
|
createAndSaveEventLocal(t, ctx, alice, 30382, "Alice other",
|
||||||
|
tag.NewS(tag.NewFromAny("d", "other-card")), baseTs+2)
|
||||||
|
createAndSaveEventLocal(t, ctx, bob, 30382, "Bob target 1",
|
||||||
|
tag.NewS(tag.NewFromAny("d", "card-1")), baseTs+3) // Same d-tag as Alice but different author
|
||||||
|
|
||||||
|
// Query for Alice's events with specific d-tags
|
||||||
|
evs, err := testDB.QueryEvents(ctx, &filter.F{
|
||||||
|
Kinds: kind.NewS(kind.New(30382)),
|
||||||
|
Authors: tag.NewFromBytesSlice(alice.Pub()),
|
||||||
|
Tags: tag.NewS(tag.NewFromAny("#d", "card-1", "card-2")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to query events: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should only return Alice's 2 events, not Bob's even though he has card-1
|
||||||
|
if len(evs) != 2 {
|
||||||
|
t.Fatalf("Expected 2 events from Alice with matching d-tags, got %d", len(evs))
|
||||||
|
}
|
||||||
|
|
||||||
|
alicePubkey := hex.Enc(alice.Pub())
|
||||||
|
for _, ev := range evs {
|
||||||
|
if hex.Enc(ev.Pubkey[:]) != alicePubkey {
|
||||||
|
t.Fatalf("Expected author %s, got %s", alicePubkey, hex.Enc(ev.Pubkey[:]))
|
||||||
|
}
|
||||||
|
dTag := ev.Tags.GetFirst([]byte("d"))
|
||||||
|
dValue := string(dTag.Value())
|
||||||
|
if dValue != "card-1" && dValue != "card-2" {
|
||||||
|
t.Fatalf("Expected d=card-1 or card-2, got d=%s", dValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ Query with kind, author, and #d filter returned correct events")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBinaryTagFilterRegression tests that queries with #e and #p tags work correctly
|
||||||
|
// even when tags are stored with binary-encoded values but filters come as hex strings.
|
||||||
|
// This mirrors the Badger database test for binary tag handling.
|
||||||
|
func TestBinaryTagFilterRegression(t *testing.T) {
|
||||||
|
if testDB == nil {
|
||||||
|
t.Skip("Neo4j not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanTestDatabase()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
author := createTestSignerLocal(t)
|
||||||
|
referenced := createTestSignerLocal(t)
|
||||||
|
baseTs := timestamp.Now().V
|
||||||
|
|
||||||
|
// Create a referenced event to get a valid event ID for e-tag
|
||||||
|
refEvent := createAndSaveEventLocal(t, ctx, referenced, 1, "Referenced event", nil, baseTs)
|
||||||
|
|
||||||
|
// Get hex representations
|
||||||
|
refEventIdHex := hex.Enc(refEvent.ID)
|
||||||
|
refPubkeyHex := hex.Enc(referenced.Pub())
|
||||||
|
|
||||||
|
// Create test event with e, p, d, and other tags
|
||||||
|
testEvent := createAndSaveEventLocal(t, ctx, author, 30520, "Event with binary tags",
|
||||||
|
tag.NewS(
|
||||||
|
tag.NewFromAny("d", "test-d-value"),
|
||||||
|
tag.NewFromAny("p", string(refPubkeyHex)),
|
||||||
|
tag.NewFromAny("e", string(refEventIdHex)),
|
||||||
|
tag.NewFromAny("t", "test-topic"),
|
||||||
|
), baseTs+1)
|
||||||
|
|
||||||
|
testEventIdHex := hex.Enc(testEvent.ID)
|
||||||
|
|
||||||
|
// Test case 1: Query WITHOUT #e/#p tags (baseline - should work)
|
||||||
|
t.Run("QueryWithoutEPTags", func(t *testing.T) {
|
||||||
|
evs, err := testDB.QueryEvents(ctx, &filter.F{
|
||||||
|
Kinds: kind.NewS(kind.New(30520)),
|
||||||
|
Authors: tag.NewFromBytesSlice(author.Pub()),
|
||||||
|
Tags: tag.NewS(tag.NewFromAny("#d", "test-d-value")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Query without e/p tags failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(evs) == 0 {
|
||||||
|
t.Fatal("Expected to find event with d tag filter, got 0 results")
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, ev := range evs {
|
||||||
|
if hex.Enc(ev.ID) == testEventIdHex {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Expected event ID %s not found", testEventIdHex)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test case 2: Query WITH #p tag
|
||||||
|
t.Run("QueryWithPTag", func(t *testing.T) {
|
||||||
|
evs, err := testDB.QueryEvents(ctx, &filter.F{
|
||||||
|
Kinds: kind.NewS(kind.New(30520)),
|
||||||
|
Authors: tag.NewFromBytesSlice(author.Pub()),
|
||||||
|
Tags: tag.NewS(
|
||||||
|
tag.NewFromAny("#d", "test-d-value"),
|
||||||
|
tag.NewFromAny("#p", string(refPubkeyHex)),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Query with #p tag failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(evs) == 0 {
|
||||||
|
t.Fatalf("REGRESSION: Expected to find event with #p tag filter, got 0 results")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test case 3: Query WITH #e tag
|
||||||
|
t.Run("QueryWithETag", func(t *testing.T) {
|
||||||
|
evs, err := testDB.QueryEvents(ctx, &filter.F{
|
||||||
|
Kinds: kind.NewS(kind.New(30520)),
|
||||||
|
Authors: tag.NewFromBytesSlice(author.Pub()),
|
||||||
|
Tags: tag.NewS(
|
||||||
|
tag.NewFromAny("#d", "test-d-value"),
|
||||||
|
tag.NewFromAny("#e", string(refEventIdHex)),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Query with #e tag failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(evs) == 0 {
|
||||||
|
t.Fatalf("REGRESSION: Expected to find event with #e tag filter, got 0 results")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test case 4: Query WITH BOTH #e AND #p tags
|
||||||
|
t.Run("QueryWithBothEAndPTags", func(t *testing.T) {
|
||||||
|
evs, err := testDB.QueryEvents(ctx, &filter.F{
|
||||||
|
Kinds: kind.NewS(kind.New(30520)),
|
||||||
|
Authors: tag.NewFromBytesSlice(author.Pub()),
|
||||||
|
Tags: tag.NewS(
|
||||||
|
tag.NewFromAny("#d", "test-d-value"),
|
||||||
|
tag.NewFromAny("#e", string(refEventIdHex)),
|
||||||
|
tag.NewFromAny("#p", string(refPubkeyHex)),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Query with both #e and #p tags failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(evs) == 0 {
|
||||||
|
t.Fatalf("REGRESSION: Expected to find event with #e and #p tag filters, got 0 results")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Logf("✓ Binary tag filter regression tests passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParameterizedReplaceableEvents tests that parameterized replaceable events (kind 30000+)
|
||||||
|
// are handled correctly - only the newest version should be returned in queries by kind/author/d-tag.
|
||||||
|
func TestParameterizedReplaceableEvents(t *testing.T) {
|
||||||
|
if testDB == nil {
|
||||||
|
t.Skip("Neo4j not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanTestDatabase()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
signer := createTestSignerLocal(t)
|
||||||
|
baseTs := timestamp.Now().V
|
||||||
|
|
||||||
|
// Create older parameterized replaceable event
|
||||||
|
createAndSaveEventLocal(t, ctx, signer, 30000, "Original event",
|
||||||
|
tag.NewS(tag.NewFromAny("d", "test-param")), baseTs-7200) // 2 hours ago
|
||||||
|
|
||||||
|
// Create newer event with same kind/author/d-tag
|
||||||
|
createAndSaveEventLocal(t, ctx, signer, 30000, "Newer event",
|
||||||
|
tag.NewS(tag.NewFromAny("d", "test-param")), baseTs-3600) // 1 hour ago
|
||||||
|
|
||||||
|
// Create newest event with same kind/author/d-tag
|
||||||
|
newestEvent := createAndSaveEventLocal(t, ctx, signer, 30000, "Newest event",
|
||||||
|
tag.NewS(tag.NewFromAny("d", "test-param")), baseTs) // Now
|
||||||
|
|
||||||
|
// Query for events - should only return the newest one
|
||||||
|
evs, err := testDB.QueryEvents(ctx, &filter.F{
|
||||||
|
Kinds: kind.NewS(kind.New(30000)),
|
||||||
|
Authors: tag.NewFromBytesSlice(signer.Pub()),
|
||||||
|
Tags: tag.NewS(tag.NewFromAny("#d", "test-param")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to query parameterized replaceable events: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Neo4j backend may or may not automatically deduplicate replaceable events
|
||||||
|
// depending on implementation. The important thing is that the newest is returned first.
|
||||||
|
if len(evs) == 0 {
|
||||||
|
t.Fatal("Expected at least 1 event")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the first (most recent) event is the newest one
|
||||||
|
if hex.Enc(evs[0].ID) != hex.Enc(newestEvent.ID) {
|
||||||
|
t.Logf("Note: Expected newest event first, got different order")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ Parameterized replaceable events test returned %d events", len(evs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQueryForIds tests the QueryForIds method
|
||||||
|
func TestQueryForIds(t *testing.T) {
|
||||||
|
if testDB == nil {
|
||||||
|
t.Skip("Neo4j not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanTestDatabase()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
signer := createTestSignerLocal(t)
|
||||||
|
baseTs := timestamp.Now().V
|
||||||
|
|
||||||
|
// Create test events
|
||||||
|
ev1 := createAndSaveEventLocal(t, ctx, signer, 1, "Event 1", nil, baseTs)
|
||||||
|
ev2 := createAndSaveEventLocal(t, ctx, signer, 1, "Event 2", nil, baseTs+1)
|
||||||
|
createAndSaveEventLocal(t, ctx, signer, 7, "Reaction", nil, baseTs+2)
|
||||||
|
|
||||||
|
// Query for IDs of kind 1 events
|
||||||
|
idPkTs, err := testDB.QueryForIds(ctx, &filter.F{
|
||||||
|
Kinds: kind.NewS(kind.New(1)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to query for IDs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(idPkTs) != 2 {
|
||||||
|
t.Fatalf("Expected 2 IDs for kind 1 events, got %d", len(idPkTs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify IDs match our events
|
||||||
|
foundIds := make(map[string]bool)
|
||||||
|
for _, r := range idPkTs {
|
||||||
|
foundIds[hex.Enc(r.Id)] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundIds[hex.Enc(ev1.ID)] {
|
||||||
|
t.Error("Event 1 ID not found in results")
|
||||||
|
}
|
||||||
|
if !foundIds[hex.Enc(ev2.ID)] {
|
||||||
|
t.Error("Event 2 ID not found in results")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ QueryForIds returned correct IDs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQueryForSerials tests the QueryForSerials method
|
||||||
|
func TestQueryForSerials(t *testing.T) {
|
||||||
|
if testDB == nil {
|
||||||
|
t.Skip("Neo4j not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanTestDatabase()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
signer := createTestSignerLocal(t)
|
||||||
|
baseTs := timestamp.Now().V
|
||||||
|
|
||||||
|
// Create test events
|
||||||
|
createAndSaveEventLocal(t, ctx, signer, 1, "Event 1", nil, baseTs)
|
||||||
|
createAndSaveEventLocal(t, ctx, signer, 1, "Event 2", nil, baseTs+1)
|
||||||
|
createAndSaveEventLocal(t, ctx, signer, 1, "Event 3", nil, baseTs+2)
|
||||||
|
|
||||||
|
// Query for serials
|
||||||
|
serials, err := testDB.QueryForSerials(ctx, &filter.F{
|
||||||
|
Kinds: kind.NewS(kind.New(1)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to query for serials: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(serials) != 3 {
|
||||||
|
t.Fatalf("Expected 3 serials, got %d", len(serials))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ QueryForSerials returned %d serials", len(serials))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQueryEventsComplex tests complex filter combinations
|
||||||
|
func TestQueryEventsComplex(t *testing.T) {
|
||||||
|
if testDB == nil {
|
||||||
|
t.Skip("Neo4j not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanTestDatabase()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
alice := createTestSignerLocal(t)
|
||||||
|
bob := createTestSignerLocal(t)
|
||||||
|
baseTs := timestamp.Now().V
|
||||||
|
|
||||||
|
// Create diverse set of events
|
||||||
|
createAndSaveEventLocal(t, ctx, alice, 1, "Alice note with bitcoin tag",
|
||||||
|
tag.NewS(tag.NewFromAny("t", "bitcoin")), baseTs)
|
||||||
|
createAndSaveEventLocal(t, ctx, alice, 1, "Alice note with nostr tag",
|
||||||
|
tag.NewS(tag.NewFromAny("t", "nostr")), baseTs+1)
|
||||||
|
createAndSaveEventLocal(t, ctx, alice, 7, "Alice reaction",
|
||||||
|
nil, baseTs+2)
|
||||||
|
createAndSaveEventLocal(t, ctx, bob, 1, "Bob note with bitcoin tag",
|
||||||
|
tag.NewS(tag.NewFromAny("t", "bitcoin")), baseTs+3)
|
||||||
|
|
||||||
|
// Test: kinds + tags (no authors)
|
||||||
|
t.Run("KindsAndTags", func(t *testing.T) {
|
||||||
|
evs, err := testDB.QueryEvents(ctx, &filter.F{
|
||||||
|
Kinds: kind.NewS(kind.New(1)),
|
||||||
|
Tags: tag.NewS(tag.NewFromAny("#t", "bitcoin")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Query failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(evs) != 2 {
|
||||||
|
t.Fatalf("Expected 2 events with kind=1 and #t=bitcoin, got %d", len(evs))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test: authors + tags (no kinds)
|
||||||
|
t.Run("AuthorsAndTags", func(t *testing.T) {
|
||||||
|
evs, err := testDB.QueryEvents(ctx, &filter.F{
|
||||||
|
Authors: tag.NewFromBytesSlice(alice.Pub()),
|
||||||
|
Tags: tag.NewS(tag.NewFromAny("#t", "bitcoin")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Query failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(evs) != 1 {
|
||||||
|
t.Fatalf("Expected 1 event from Alice with #t=bitcoin, got %d", len(evs))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test: kinds + authors (no tags)
|
||||||
|
t.Run("KindsAndAuthors", func(t *testing.T) {
|
||||||
|
evs, err := testDB.QueryEvents(ctx, &filter.F{
|
||||||
|
Kinds: kind.NewS(kind.New(1)),
|
||||||
|
Authors: tag.NewFromBytesSlice(alice.Pub()),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Query failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(evs) != 2 {
|
||||||
|
t.Fatalf("Expected 2 kind=1 events from Alice, got %d", len(evs))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test: all three filters
|
||||||
|
t.Run("AllFilters", func(t *testing.T) {
|
||||||
|
evs, err := testDB.QueryEvents(ctx, &filter.F{
|
||||||
|
Kinds: kind.NewS(kind.New(1)),
|
||||||
|
Authors: tag.NewFromBytesSlice(alice.Pub()),
|
||||||
|
Tags: tag.NewS(tag.NewFromAny("#t", "nostr")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Query failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(evs) != 1 {
|
||||||
|
t.Fatalf("Expected 1 event (Alice kind=1 #t=nostr), got %d", len(evs))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Logf("✓ Complex filter combination tests passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQueryEventsMultipleTagTypes tests filtering with multiple different tag types
|
||||||
|
func TestQueryEventsMultipleTagTypes(t *testing.T) {
|
||||||
|
if testDB == nil {
|
||||||
|
t.Skip("Neo4j not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanTestDatabase()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
signer := createTestSignerLocal(t)
|
||||||
|
baseTs := timestamp.Now().V
|
||||||
|
|
||||||
|
// Create events with multiple tag types
|
||||||
|
createAndSaveEventLocal(t, ctx, signer, 30382, "Event with d and client tags",
|
||||||
|
tag.NewS(
|
||||||
|
tag.NewFromAny("d", "user-1"),
|
||||||
|
tag.NewFromAny("client", "app-a"),
|
||||||
|
), baseTs)
|
||||||
|
|
||||||
|
createAndSaveEventLocal(t, ctx, signer, 30382, "Event with d and different client",
|
||||||
|
tag.NewS(
|
||||||
|
tag.NewFromAny("d", "user-2"),
|
||||||
|
tag.NewFromAny("client", "app-b"),
|
||||||
|
), baseTs+1)
|
||||||
|
|
||||||
|
createAndSaveEventLocal(t, ctx, signer, 30382, "Event with only d tag",
|
||||||
|
tag.NewS(
|
||||||
|
tag.NewFromAny("d", "user-3"),
|
||||||
|
), baseTs+2)
|
||||||
|
|
||||||
|
// Query with multiple tag types (should AND them together)
|
||||||
|
evs, err := testDB.QueryEvents(ctx, &filter.F{
|
||||||
|
Kinds: kind.NewS(kind.New(30382)),
|
||||||
|
Tags: tag.NewS(
|
||||||
|
tag.NewFromAny("#d", "user-1", "user-2"),
|
||||||
|
tag.NewFromAny("#client", "app-a"),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Query with multiple tag types failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should match only the first event (user-1 with app-a)
|
||||||
|
if len(evs) != 1 {
|
||||||
|
t.Fatalf("Expected 1 event matching both #d and #client, got %d", len(evs))
|
||||||
|
}
|
||||||
|
|
||||||
|
dTag := evs[0].Tags.GetFirst([]byte("d"))
|
||||||
|
if string(dTag.Value()) != "user-1" {
|
||||||
|
t.Fatalf("Expected d=user-1, got d=%s", dTag.Value())
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ Multiple tag types filter test passed")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
v0.49.1
|
v0.49.2
|
||||||
|
|||||||
Reference in New Issue
Block a user