Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -355,3 +355,12 @@ func IsUniqueViolation(err error) (column string, ok bool) {

return pgErr.ColumnName, true
}

// Value dereferences a pointer and returns the value, or zero value if nil.
func Value[T any](p *T) (v T) {
if p == nil {
return
}

return *p
}
2 changes: 2 additions & 0 deletions app/ds/book.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ func BookFromContext(ctx context.Context) *Book {
// BooksFilter is used to filter and paginate user queries.
type BooksFilter struct {
EntitiesFilter

Author string
}

// BookAuthor represents an author of a book.
Expand Down
15 changes: 8 additions & 7 deletions app/ds/ds.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,12 @@ func DtBetween(from, to time.Time) *FilterDT {

// FilterString defines options for filtering string-type data.
type FilterString struct {
NotNull *bool
NotEmpty *bool
ExactMatch *string
Contains *string
NotContains *string
StartsWith *string
EndsWith *string
Null *bool
Empty *bool
Equal *string
Contains *string
StartsWith *string
EndsWith *string
Not bool
CaseSensitive bool
}
1 change: 1 addition & 0 deletions app/ds/topic.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type TopicsFilter struct {
Page int
PerPage int
Type EntityType
Name *FilterString
PublicIDs []string
WithCount bool
OrderBy string
Expand Down
39 changes: 39 additions & 0 deletions app/repo/book_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@ func (r *Repo) FilterBooks(ctx context.Context, f ds.BooksFilter) (books []ds.Bo
)`
}

var whereAuthor string
if f.Author != "" {
whereAuthor = `EXISTS (SELECT 1 FROM jsonb_to_recordset(b.authors) AS a(name text) WHERE a.name = ?)`
}

count, err = r.filter("entities e", "e").
columns(`
e.id AS id,
Expand All @@ -152,6 +157,8 @@ func (r *Repo) FilterBooks(ctx context.Context, f ds.BooksFilter) (books []ds.Bo
join("LEFT JOIN users u ON e.owner_id = u.id").
where("e.type", ds.EntityTypeBook).
whereRaw(whereTopics, f.Topics).
whereRaw(whereAuthor, f.Author).
filterString("e.title", f.Title).
paginate(f.Page, f.PerPage).
createdAt(f.CreatedAt).
deletedAt(f.DeletedAt).
Expand All @@ -164,6 +171,11 @@ func (r *Repo) FilterBooks(ctx context.Context, f ds.BooksFilter) (books []ds.Bo
withCount(f.WithCount).
scan(ctx, &books)

if err != nil {
err = fmt.Errorf("filter books: %w", err)
return
}

// topics
if len(books) > 0 {
ids := make([]ds.ID, len(books))
Expand Down Expand Up @@ -196,3 +208,30 @@ func (r *Repo) FilterBooks(ctx context.Context, f ds.BooksFilter) (books []ds.Bo

return
}

// SearchBookAuthors searches book authors by name.
func (r *Repo) SearchBookAuthors(ctx context.Context, query string) ([]ds.BookAuthor, error) {
_, span := r.tracer.Start(ctx, "SearchBookAuthors")
defer span.End()

sql := `
SELECT DISTINCT a.name
FROM entities e
JOIN books b ON b.id = e.id
JOIN LATERAL jsonb_to_recordset(b.authors) AS a(name text) ON true
WHERE e.type = 'book'
AND e.status = 'approved'
AND e.visibility = 'public'
AND e.deleted_at IS NULL
AND a.name ILIKE $1
LIMIT 25
`

var rows []ds.BookAuthor
err := pgxscan.Select(ctx, r.db, &rows, sql, "%"+query+"%")
if err != nil {
return nil, fmt.Errorf("search book authors: %w", err)
}

return rows, nil
}
55 changes: 55 additions & 0 deletions app/repo/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,61 @@ func (b *filterBuilder) withCount(ok bool) *filterBuilder {
return b
}

func (b *filterBuilder) filterString(col string, f *ds.FilterString) *filterBuilder {
if f == nil {
return b
}

if app.Value(f.Null) {
if f.Not {
b.qb = b.qb.Where(col + " IS NOT NULL")
} else {
b.qb = b.qb.Where(col + " IS NULL")
}
}

if app.Value(f.Empty) {
if f.Not {
b.qb = b.qb.Where(col + " != ''")
} else {
b.qb = b.qb.Where(col + " = ''")
}
}

if v := app.Value(f.Equal); v != "" {
if f.Not {
b.qb = b.qb.Where(col+" != ?", v)
} else {
b.qb = b.qb.Where(col+" = ?", v)
}
}

likeOP := "ILIKE"
if f.CaseSensitive {
likeOP = "LIKE"
}
if f.Not {
likeOP = "NOT " + likeOP
}

if v := app.Value(f.Contains); v != "" {
v = "%" + v + "%"
b.qb = b.qb.Where(col+" "+likeOP+" ?", v)
}

if v := app.Value(f.StartsWith); v != "" {
v += "%"
b.qb = b.qb.Where(col+" "+likeOP+" ?", v)
}

if v := app.Value(f.EndsWith); v != "" {
v = "%" + v
b.qb = b.qb.Where(col+" "+likeOP+" ?", v)
}

return b
}

func (b *filterBuilder) sql() (sql string, args []any, err error) {
lb := *b
if !lb.columnsSet {
Expand Down
7 changes: 6 additions & 1 deletion app/repo/topic_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package repo

import (
"context"
"fmt"

"github.com/georgysavva/scany/v2/pgxscan"
"github.com/gopl-dev/server/app"
Expand All @@ -20,17 +21,21 @@ func (r *Repo) FilterTopics(ctx context.Context, f ds.TopicsFilter) (data []ds.T

if f.OrderBy == "" {
f.OrderBy = "name"
f.OrderDirection = "asc"
f.OrderDirection = "ASC"
}

count, err = r.filter("topics").
columns(`*`).
where("type", f.Type).
filterString("name", f.Name).
paginate(f.Page, f.PerPage).
order(f.OrderBy, f.OrderDirection).
withCount(f.WithCount).
apply(whereIn("public_id", f.PublicIDs)).
scan(ctx, &data)
if err != nil {
err = fmt.Errorf("filter topics: %w", err)
}

return
}
Expand Down
96 changes: 96 additions & 0 deletions app/service/book_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/gopl-dev/server/app/ds/prop"
"github.com/gopl-dev/server/app/repo"
"github.com/gopl-dev/server/email"
"golang.org/x/sync/errgroup"
)

var (
Expand Down Expand Up @@ -588,3 +589,98 @@ func (s *Service) GetBookByRef(ctx context.Context, ref any) (*ds.Book, error) {

return nil, ErrInvalidRefID
}

// SearchBookType represents the type of a search result.
type SearchBookType string

const (
// SearchBookTypeBook is a book result.
SearchBookTypeBook SearchBookType = "book"
// SearchBookTypeAuthor is an author result.
SearchBookTypeAuthor SearchBookType = "author"
// SearchBookTypeTopic is a topic result.
SearchBookTypeTopic SearchBookType = "topic"
)

// SearchBooksResult represents a single search result item.
type SearchBooksResult struct {
Type SearchBookType `json:"type"`
Name string `json:"name"`
URL string `json:"url"`
}

// SearchBooks searches books, books authors and books topics by query string.
func (s *Service) SearchBooks(ctx context.Context, query string) ([]SearchBooksResult, error) {
ctx, span := s.tracer.Start(ctx, "SearchBooks")
defer span.End()

if query == "" {
return []SearchBooksResult{}, nil
}

var (
books []ds.Book
authors []ds.BookAuthor
topics []ds.Topic
)

g, ctx := errgroup.WithContext(ctx)

g.Go(func() (err error) {
books, _, err = s.db.FilterBooks(ctx, ds.BooksFilter{EntitiesFilter: ds.EntitiesFilter{
PerPage: 25, //nolint:mnd
WithCount: false,
Title: &ds.FilterString{Contains: &query},
Visibility: []ds.EntityVisibility{ds.EntityVisibilityPublic},
Status: []ds.EntityStatus{ds.EntityStatusApproved},
}})
return
})

g.Go(func() (err error) {
authors, err = s.db.SearchBookAuthors(ctx, query)
return
})

g.Go(func() (err error) {
topics, _, err = s.db.FilterTopics(ctx, ds.TopicsFilter{
PerPage: 25, //nolint:mnd
Type: ds.EntityTypeBook,
Name: &ds.FilterString{Contains: &query},
})
return
})

err := g.Wait()
if err != nil {
return nil, err
}

results := make([]SearchBooksResult, 0, len(books)+len(authors)+len(topics))

for _, r := range books {
results = append(results, SearchBooksResult{
Type: SearchBookTypeBook,
Name: r.Title,
URL: "/books/" + r.PublicID + "/",
})
}

for _, r := range authors {
results = append(results, SearchBooksResult{
Type: SearchBookTypeAuthor,
Name: r.Name,
URL: "/books/?author=" + r.Name,
})
}

for _, r := range topics {
results = append(results, SearchBooksResult{
Type: SearchBookTypeTopic,
Name: r.Name,
URL: "/books/?topics=" + r.PublicID,
})
}

return results, nil
}
Loading
Loading