mirror of
https://github.com/layer-systems/relay.git
synced 2026-07-28 23:27:40 +02:00
Add NIP-50 search support
This commit is contained in:
2
go.mod
2
go.mod
@@ -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
|
||||
|
||||
6
main.go
6
main.go
@@ -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
202
search.go
Normal 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, ×tamp, &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
61
search_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user