Add NIP-50 search support

This commit is contained in:
2026-06-16 23:29:35 +02:00
parent 1feea858f3
commit e616dae5ec
4 changed files with 267 additions and 4 deletions

2
go.mod
View File

@@ -5,6 +5,7 @@ go 1.24.4
require (
github.com/fiatjaf/eventstore v0.16.2
github.com/fiatjaf/khatru v0.19.1
github.com/jmoiron/sqlx v1.4.0
github.com/lib/pq v1.10.9
github.com/nbd-wtf/go-nostr v0.52.3
)
@@ -23,7 +24,6 @@ require (
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/fasthttp/websocket v1.5.12 // indirect
github.com/jmoiron/sqlx v1.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect

View File

@@ -64,7 +64,7 @@ func main() {
relay.Info.Software = "https://github.com/layer-systems/relay"
relay.Info.Version = "0.1.1"
relay.Info.SupportedNIPs = []any{1, 11, 17, 40, 42, 46, 70, 86}
relay.Info.SupportedNIPs = []any{1, 11, 17, 40, 42, 46, 50, 70, 86}
// Open shared database connection with aggressive pool limits
managementDB, err := sql.Open("postgres", getEnv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/khatru-relay?sslmode=disable"))
@@ -92,8 +92,8 @@ func main() {
}
relay.StoreEvent = append(relay.StoreEvent, db.SaveEvent)
relay.QueryEvents = append(relay.QueryEvents, db.QueryEvents)
relay.CountEvents = append(relay.CountEvents, db.CountEvents)
relay.QueryEvents = append(relay.QueryEvents, queryEventsWithSearch(&db))
relay.CountEvents = append(relay.CountEvents, countEventsWithSearch(&db))
relay.DeleteEvent = append(relay.DeleteEvent, db.DeleteEvent)
relay.ReplaceEvent = append(relay.ReplaceEvent, db.ReplaceEvent)

202
search.go Normal file
View File

@@ -0,0 +1,202 @@
package main
import (
"context"
"database/sql"
"fmt"
"strings"
"github.com/fiatjaf/eventstore/postgresql"
"github.com/jmoiron/sqlx"
"github.com/nbd-wtf/go-nostr"
)
func queryEventsWithSearch(db *postgresql.PostgresBackend) func(context.Context, nostr.Filter) (chan *nostr.Event, error) {
return func(ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error) {
if strings.TrimSpace(filter.Search) == "" {
return db.QueryEvents(ctx, filter)
}
query, params, err := searchEventsSQL(db, filter, false)
if err != nil {
return nil, err
}
rows, err := db.DB.QueryContext(ctx, query, params...)
if err != nil && err != sql.ErrNoRows {
return nil, fmt.Errorf("failed to fetch search results using query %q: %w", query, err)
}
ch := make(chan *nostr.Event)
go func() {
defer rows.Close()
defer close(ch)
for rows.Next() {
var evt nostr.Event
var timestamp int64
if err := rows.Scan(&evt.ID, &evt.PubKey, &timestamp, &evt.Kind, &evt.Tags, &evt.Content, &evt.Sig); err != nil {
return
}
evt.CreatedAt = nostr.Timestamp(timestamp)
select {
case ch <- &evt:
case <-ctx.Done():
return
}
}
}()
return ch, nil
}
}
func countEventsWithSearch(db *postgresql.PostgresBackend) func(context.Context, nostr.Filter) (int64, error) {
return func(ctx context.Context, filter nostr.Filter) (int64, error) {
if strings.TrimSpace(filter.Search) == "" {
return db.CountEvents(ctx, filter)
}
query, params, err := searchEventsSQL(db, filter, true)
if err != nil {
return 0, err
}
var count int64
if err = db.DB.QueryRowContext(ctx, query, params...).Scan(&count); err != nil && err != sql.ErrNoRows {
return 0, fmt.Errorf("failed to count search results using query %q: %w", query, err)
}
return count, nil
}
}
func searchEventsSQL(db *postgresql.PostgresBackend, filter nostr.Filter, doCount bool) (string, []any, error) {
conditions := make([]string, 0, 8)
params := make([]any, 0, 32)
if len(filter.IDs) > 0 {
if len(filter.IDs) > db.QueryIDsLimit {
return "", nil, postgresql.TooManyIDs
}
for _, id := range filter.IDs {
params = append(params, id)
}
conditions = append(conditions, `id IN (`+placeholders(len(filter.IDs))+`)`)
}
if len(filter.Authors) > 0 {
if len(filter.Authors) > db.QueryAuthorsLimit {
return "", nil, postgresql.TooManyAuthors
}
for _, author := range filter.Authors {
params = append(params, author)
}
conditions = append(conditions, `pubkey IN (`+placeholders(len(filter.Authors))+`)`)
}
if len(filter.Kinds) > 0 {
if len(filter.Kinds) > db.QueryKindsLimit {
return "", nil, postgresql.TooManyKinds
}
for _, kind := range filter.Kinds {
params = append(params, kind)
}
conditions = append(conditions, `kind IN (`+placeholders(len(filter.Kinds))+`)`)
}
totalTags := 0
for _, values := range filter.Tags {
if len(values) == 0 {
return "", nil, postgresql.EmptyTagSet
}
for _, tagValue := range values {
params = append(params, tagValue)
}
conditions = append(conditions, `tagvalues && ARRAY[`+placeholders(len(values))+`]`)
totalTags += len(values)
if totalTags > db.QueryTagsLimit {
return "", nil, postgresql.TooManyTagValues
}
}
if filter.Since != nil {
conditions = append(conditions, `created_at >= ?`)
params = append(params, int64(*filter.Since))
}
if filter.Until != nil {
conditions = append(conditions, `created_at <= ?`)
params = append(params, int64(*filter.Until))
}
phrase, terms := normalizeSearchQuery(filter.Search)
searchConditions := make([]string, 0, len(terms)+1)
scoreParts := make([]string, 0, len(terms)+1)
if phrase != "" {
searchConditions = append(searchConditions, `content ILIKE ? ESCAPE '\'`)
scoreParts = append(scoreParts, `CASE WHEN content ILIKE ? ESCAPE '\' THEN 100 ELSE 0 END`)
params = append(params, likePattern(phrase))
}
for _, term := range terms {
searchConditions = append(searchConditions, `content ILIKE ? ESCAPE '\'`)
scoreParts = append(scoreParts, `CASE WHEN content ILIKE ? ESCAPE '\' THEN 1 ELSE 0 END`)
params = append(params, likePattern(term))
}
if len(searchConditions) > 0 {
conditions = append(conditions, `(`+strings.Join(searchConditions, " OR ")+`)`)
}
if len(conditions) == 0 {
conditions = append(conditions, `true`)
}
limit := db.QueryLimit
if filter.Limit > 0 && filter.Limit <= db.QueryLimit {
limit = filter.Limit
}
var query string
if doCount {
query = `SELECT COUNT(*) FROM event WHERE ` + strings.Join(conditions, " AND ")
} else {
score := "0"
if len(scoreParts) > 0 {
score = strings.Join(scoreParts, " + ")
if phrase != "" {
params = append(params, likePattern(phrase))
}
for _, term := range terms {
params = append(params, likePattern(term))
}
}
params = append(params, limit)
query = `SELECT id, pubkey, created_at, kind, tags, content, sig FROM event WHERE ` +
strings.Join(conditions, " AND ") +
` ORDER BY (` + score + `) DESC, created_at DESC, id LIMIT ?`
}
return sqlx.Rebind(sqlx.BindType("postgres"), query), params, nil
}
func normalizeSearchQuery(search string) (string, []string) {
fields := strings.Fields(search)
terms := make([]string, 0, len(fields))
for _, field := range fields {
if strings.Contains(field, ":") {
continue
}
terms = append(terms, field)
}
return strings.Join(terms, " "), terms
}
func likePattern(value string) string {
replacer := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
return "%" + replacer.Replace(value) + "%"
}
func placeholders(n int) string {
return strings.TrimRight(strings.Repeat("?,", n), ",")
}

61
search_test.go Normal file
View File

@@ -0,0 +1,61 @@
package main
import (
"reflect"
"strings"
"testing"
"github.com/fiatjaf/eventstore/postgresql"
"github.com/nbd-wtf/go-nostr"
)
func TestNormalizeSearchQueryIgnoresExtensions(t *testing.T) {
phrase, terms := normalizeSearchQuery("include:spam best nostr apps domain:example.com")
if phrase != "best nostr apps" {
t.Fatalf("phrase = %q, want %q", phrase, "best nostr apps")
}
if !reflect.DeepEqual(terms, []string{"best", "nostr", "apps"}) {
t.Fatalf("terms = %#v", terms)
}
}
func TestSearchEventsSQLOrdersByScoreBeforeLimit(t *testing.T) {
db := &postgresql.PostgresBackend{
QueryLimit: 100,
QueryIDsLimit: 500,
QueryAuthorsLimit: 500,
QueryKindsLimit: 10,
QueryTagsLimit: 10,
}
query, params, err := searchEventsSQL(db, nostr.Filter{
Kinds: []int{nostr.KindTextNote},
Search: "include:spam Purple nostr",
Limit: 5,
}, false)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(query, "ORDER BY (CASE WHEN content ILIKE") {
t.Fatalf("query does not order by score: %s", query)
}
if !strings.Contains(query, "DESC, created_at DESC, id LIMIT") {
t.Fatalf("query does not apply limit after score ordering: %s", query)
}
expectedParams := []any{
nostr.KindTextNote,
"%Purple nostr%",
"%Purple%",
"%nostr%",
"%Purple nostr%",
"%Purple%",
"%nostr%",
5,
}
if !reflect.DeepEqual(params, expectedParams) {
t.Fatalf("params = %#v, want %#v", params, expectedParams)
}
}