feat: add unified search SQL queries with fuzzy filters
- Add SearchMediaItemsUnified query combining search + filters - Add 4 field value search queries (author, genre, series, language) for autocomplete - Support fuzzy text matching via pg_trgm (threshold: 0.3 similarity) - Support exact match with quotes detection for search queries - Add sort parameter support (title ASC/DESC, author ASC/DESC, created_at ASC/DESC, page_count ASC/DESC) - Primary sort by relevance score when searching, secondary by user-specified sort - Combine search query with all filter types in single optimized query - Uses 4 separate simple queries instead of 1 complex query due to sqlc v1.30.0 limitation with CASE in GROUP BY This consolidates the deprecated /filtered and /search endpoints into one unified endpoint.
This commit is contained in:
@@ -262,7 +262,6 @@ type Querier interface {
|
||||
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
|
||||
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
|
||||
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
|
||||
ListMediaItemsFiltered(ctx context.Context, arg ListMediaItemsFilteredParams) ([]ListMediaItemsFilteredRow, error)
|
||||
ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSortedParams) ([]ListMediaItemsSortedRow, error)
|
||||
ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error)
|
||||
ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error)
|
||||
|
||||
@@ -6100,194 +6100,6 @@ func (q *Queries) ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ListMediaItemsFiltered = `-- name: ListMediaItemsFiltered :many
|
||||
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
JOIN library_types lt ON l.library_type_id = lt.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
||||
WHERE mi.library_id = $2
|
||||
AND COALESCE(lv.is_visible, true) = true
|
||||
AND ($3 = '' OR mi.author ILIKE $3)
|
||||
AND ($4 = '' OR mi.series ILIKE $4)
|
||||
AND ($5 = '' OR mi.genre = $5)
|
||||
AND ($6 = '' OR mi.language = $6)
|
||||
AND ($7 = 0 OR mi.copyright_year >= $7)
|
||||
AND ($8 = 0 OR mi.copyright_year <= $8)
|
||||
AND ($9 = false OR mi.cover_image_path IS NOT NULL)
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN $10 = 'title ASC' THEN mi.title
|
||||
ELSE ''
|
||||
END ASC,
|
||||
CASE
|
||||
WHEN $10 = 'title DESC' THEN mi.title
|
||||
ELSE ''
|
||||
END DESC,
|
||||
CASE
|
||||
WHEN $10 = 'author ASC' THEN COALESCE(mi.author, '')
|
||||
ELSE ''
|
||||
END ASC,
|
||||
CASE
|
||||
WHEN $10 = 'author DESC' THEN COALESCE(mi.author, '')
|
||||
ELSE ''
|
||||
END DESC,
|
||||
CASE
|
||||
WHEN $10 = 'created_at ASC' THEN mi.created_at
|
||||
ELSE '1970-01-01'::timestamp
|
||||
END ASC,
|
||||
CASE
|
||||
WHEN $10 = 'created_at DESC' THEN mi.created_at
|
||||
ELSE '1970-01-01'::timestamp
|
||||
END DESC,
|
||||
mi.created_at DESC
|
||||
LIMIT $12 OFFSET $11
|
||||
`
|
||||
|
||||
type ListMediaItemsFilteredParams struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
AuthorFilter interface{} `db:"author_filter" json:"author_filter"`
|
||||
SeriesFilter interface{} `db:"series_filter" json:"series_filter"`
|
||||
GenreFilter interface{} `db:"genre_filter" json:"genre_filter"`
|
||||
LanguageFilter interface{} `db:"language_filter" json:"language_filter"`
|
||||
YearMin interface{} `db:"year_min" json:"year_min"`
|
||||
YearMax interface{} `db:"year_max" json:"year_max"`
|
||||
HasCover interface{} `db:"has_cover" json:"has_cover"`
|
||||
Sort interface{} `db:"sort" json:"sort"`
|
||||
Offset pgtype.Int4 `db:"offset" json:"offset"`
|
||||
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
||||
}
|
||||
|
||||
type ListMediaItemsFilteredRow struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Author pgtype.Text `db:"author" json:"author"`
|
||||
Isbn pgtype.Text `db:"isbn" json:"isbn"`
|
||||
Description pgtype.Text `db:"description" json:"description"`
|
||||
FilePath string `db:"file_path" json:"file_path"`
|
||||
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
|
||||
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
|
||||
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
||||
Series pgtype.Text `db:"series" json:"series"`
|
||||
SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"`
|
||||
Tags []string `db:"tags" json:"tags"`
|
||||
Asin pgtype.Text `db:"asin" json:"asin"`
|
||||
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
|
||||
Publisher pgtype.Text `db:"publisher" json:"publisher"`
|
||||
Contributors []string `db:"contributors" json:"contributors"`
|
||||
Language pgtype.Text `db:"language" json:"language"`
|
||||
Edition pgtype.Text `db:"edition" json:"edition"`
|
||||
PageCount pgtype.Int4 `db:"page_count" json:"page_count"`
|
||||
Genre pgtype.Text `db:"genre" json:"genre"`
|
||||
CopyrightYear pgtype.Int4 `db:"copyright_year" json:"copyright_year"`
|
||||
GoodreadsID pgtype.Text `db:"goodreads_id" json:"goodreads_id"`
|
||||
OpenlibraryID pgtype.Text `db:"openlibrary_id" json:"openlibrary_id"`
|
||||
GoogleBooksID pgtype.Text `db:"google_books_id" json:"google_books_id"`
|
||||
AddedByAdminID pgtype.UUID `db:"added_by_admin_id" json:"added_by_admin_id"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
FormatGroup string `db:"format_group" json:"format_group"`
|
||||
FormatMimetype pgtype.Text `db:"format_mimetype" json:"format_mimetype"`
|
||||
IsReflowable pgtype.Bool `db:"is_reflowable" json:"is_reflowable"`
|
||||
HasFixedLayout pgtype.Bool `db:"has_fixed_layout" json:"has_fixed_layout"`
|
||||
TotalCharacters pgtype.Int8 `db:"total_characters" json:"total_characters"`
|
||||
ChapterCount pgtype.Int4 `db:"chapter_count" json:"chapter_count"`
|
||||
EntitlementID pgtype.Text `db:"entitlement_id" json:"entitlement_id"`
|
||||
RevisionNumber pgtype.Int4 `db:"revision_number" json:"revision_number"`
|
||||
KoboContentID pgtype.Text `db:"kobo_content_id" json:"kobo_content_id"`
|
||||
KoboMetadata []byte `db:"kobo_metadata" json:"kobo_metadata"`
|
||||
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
||||
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
|
||||
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
||||
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
|
||||
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"`
|
||||
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
|
||||
LibraryName string `db:"library_name" json:"library_name"`
|
||||
LibraryTypeName string `db:"library_type_name" json:"library_type_name"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListMediaItemsFiltered(ctx context.Context, arg ListMediaItemsFilteredParams) ([]ListMediaItemsFilteredRow, error) {
|
||||
rows, err := q.db.Query(ctx, ListMediaItemsFiltered,
|
||||
arg.UserID,
|
||||
arg.LibraryID,
|
||||
arg.AuthorFilter,
|
||||
arg.SeriesFilter,
|
||||
arg.GenreFilter,
|
||||
arg.LanguageFilter,
|
||||
arg.YearMin,
|
||||
arg.YearMax,
|
||||
arg.HasCover,
|
||||
arg.Sort,
|
||||
arg.Offset,
|
||||
arg.Limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListMediaItemsFilteredRow{}
|
||||
for rows.Next() {
|
||||
var i ListMediaItemsFilteredRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.LibraryID,
|
||||
&i.Title,
|
||||
&i.Author,
|
||||
&i.Isbn,
|
||||
&i.Description,
|
||||
&i.FilePath,
|
||||
&i.FileSize,
|
||||
&i.MimeType,
|
||||
&i.CoverImagePath,
|
||||
&i.Series,
|
||||
&i.SeriesNumber,
|
||||
&i.Tags,
|
||||
&i.Asin,
|
||||
&i.DatePublished,
|
||||
&i.Publisher,
|
||||
&i.Contributors,
|
||||
&i.Language,
|
||||
&i.Edition,
|
||||
&i.PageCount,
|
||||
&i.Genre,
|
||||
&i.CopyrightYear,
|
||||
&i.GoodreadsID,
|
||||
&i.OpenlibraryID,
|
||||
&i.GoogleBooksID,
|
||||
&i.AddedByAdminID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FormatGroup,
|
||||
&i.FormatMimetype,
|
||||
&i.IsReflowable,
|
||||
&i.HasFixedLayout,
|
||||
&i.TotalCharacters,
|
||||
&i.ChapterCount,
|
||||
&i.EntitlementID,
|
||||
&i.RevisionNumber,
|
||||
&i.KoboContentID,
|
||||
&i.KoboMetadata,
|
||||
&i.TagsSearch,
|
||||
&i.ContributorsSearch,
|
||||
&i.FileSha256,
|
||||
&i.OpfIdentifier,
|
||||
&i.OpfUuid,
|
||||
&i.HashConfidence,
|
||||
&i.LibraryName,
|
||||
&i.LibraryTypeName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ListMediaItemsSorted = `-- name: ListMediaItemsSorted :many
|
||||
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name
|
||||
FROM media_items mi
|
||||
@@ -7602,6 +7414,7 @@ WHERE COALESCE(lv.is_visible, true) = true
|
||||
)
|
||||
)
|
||||
ORDER BY
|
||||
-- Primary sort: relevance score when searching
|
||||
CASE
|
||||
WHEN $10 != '' THEN
|
||||
GREATEST(
|
||||
@@ -7616,8 +7429,42 @@ ORDER BY
|
||||
)
|
||||
ELSE 0
|
||||
END DESC,
|
||||
-- Secondary sort: user-specified sort parameter
|
||||
CASE
|
||||
WHEN $13 = 'title ASC' THEN mi.title
|
||||
ELSE ''
|
||||
END ASC,
|
||||
CASE
|
||||
WHEN $13 = 'title DESC' THEN mi.title
|
||||
ELSE ''
|
||||
END DESC,
|
||||
CASE
|
||||
WHEN $13 = 'author ASC' THEN COALESCE(mi.author, '')
|
||||
ELSE ''
|
||||
END ASC,
|
||||
CASE
|
||||
WHEN $13 = 'author DESC' THEN COALESCE(mi.author, '')
|
||||
ELSE ''
|
||||
END DESC,
|
||||
CASE
|
||||
WHEN $13 = 'created_at ASC' THEN mi.created_at
|
||||
ELSE '1970-01-01'::timestamp
|
||||
END ASC,
|
||||
CASE
|
||||
WHEN $13 = 'created_at DESC' THEN mi.created_at
|
||||
ELSE '1970-01-01'::timestamp
|
||||
END DESC,
|
||||
CASE
|
||||
WHEN $13 = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0')
|
||||
ELSE ''
|
||||
END ASC,
|
||||
CASE
|
||||
WHEN $13 = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0')
|
||||
ELSE ''
|
||||
END DESC,
|
||||
-- Tertiary sort: title (default fallback)
|
||||
mi.title ASC
|
||||
LIMIT $14 OFFSET $13
|
||||
LIMIT $15 OFFSET $14
|
||||
`
|
||||
|
||||
type SearchMediaItemsUnifiedParams struct {
|
||||
@@ -7633,6 +7480,7 @@ type SearchMediaItemsUnifiedParams struct {
|
||||
SearchQuery interface{} `db:"search_query" json:"search_query"`
|
||||
IsExactSearch interface{} `db:"is_exact_search" json:"is_exact_search"`
|
||||
SearchPattern pgtype.Text `db:"search_pattern" json:"search_pattern"`
|
||||
Sort interface{} `db:"sort" json:"sort"`
|
||||
Offset pgtype.Int4 `db:"offset" json:"offset"`
|
||||
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
||||
}
|
||||
@@ -7700,6 +7548,7 @@ func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaIt
|
||||
arg.SearchQuery,
|
||||
arg.IsExactSearch,
|
||||
arg.SearchPattern,
|
||||
arg.Sort,
|
||||
arg.Offset,
|
||||
arg.Limit,
|
||||
)
|
||||
|
||||
@@ -224,50 +224,6 @@ ORDER BY
|
||||
mi.created_at DESC
|
||||
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
||||
|
||||
-- name: ListMediaItemsFiltered :many
|
||||
SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
JOIN library_types lt ON l.library_type_id = lt.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
WHERE mi.library_id = sqlc.narg('library_id')
|
||||
AND COALESCE(lv.is_visible, true) = true
|
||||
AND (sqlc.narg('author_filter') = '' OR mi.author ILIKE sqlc.narg('author_filter'))
|
||||
AND (sqlc.narg('series_filter') = '' OR mi.series ILIKE sqlc.narg('series_filter'))
|
||||
AND (sqlc.narg('genre_filter') = '' OR mi.genre = sqlc.narg('genre_filter'))
|
||||
AND (sqlc.narg('language_filter') = '' OR mi.language = sqlc.narg('language_filter'))
|
||||
AND (sqlc.narg('year_min') = 0 OR mi.copyright_year >= sqlc.narg('year_min'))
|
||||
AND (sqlc.narg('year_max') = 0 OR mi.copyright_year <= sqlc.narg('year_max'))
|
||||
AND (sqlc.narg('has_cover') = false OR mi.cover_image_path IS NOT NULL)
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN sqlc.narg('sort') = 'title ASC' THEN mi.title
|
||||
ELSE ''
|
||||
END ASC,
|
||||
CASE
|
||||
WHEN sqlc.narg('sort') = 'title DESC' THEN mi.title
|
||||
ELSE ''
|
||||
END DESC,
|
||||
CASE
|
||||
WHEN sqlc.narg('sort') = 'author ASC' THEN COALESCE(mi.author, '')
|
||||
ELSE ''
|
||||
END ASC,
|
||||
CASE
|
||||
WHEN sqlc.narg('sort') = 'author DESC' THEN COALESCE(mi.author, '')
|
||||
ELSE ''
|
||||
END DESC,
|
||||
CASE
|
||||
WHEN sqlc.narg('sort') = 'created_at ASC' THEN mi.created_at
|
||||
ELSE '1970-01-01'::timestamp
|
||||
END ASC,
|
||||
CASE
|
||||
WHEN sqlc.narg('sort') = 'created_at DESC' THEN mi.created_at
|
||||
ELSE '1970-01-01'::timestamp
|
||||
END DESC,
|
||||
mi.created_at DESC
|
||||
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
||||
|
||||
|
||||
-- name: UpdateMediaItem :one
|
||||
UPDATE media_items SET
|
||||
title = $2,
|
||||
@@ -508,6 +464,7 @@ WHERE COALESCE(lv.is_visible, true) = true
|
||||
)
|
||||
)
|
||||
ORDER BY
|
||||
-- Primary sort: relevance score when searching
|
||||
CASE
|
||||
WHEN sqlc.narg('search_query') != '' THEN
|
||||
GREATEST(
|
||||
@@ -522,6 +479,40 @@ ORDER BY
|
||||
)
|
||||
ELSE 0
|
||||
END DESC,
|
||||
-- Secondary sort: user-specified sort parameter
|
||||
CASE
|
||||
WHEN sqlc.narg('sort') = 'title ASC' THEN mi.title
|
||||
ELSE ''
|
||||
END ASC,
|
||||
CASE
|
||||
WHEN sqlc.narg('sort') = 'title DESC' THEN mi.title
|
||||
ELSE ''
|
||||
END DESC,
|
||||
CASE
|
||||
WHEN sqlc.narg('sort') = 'author ASC' THEN COALESCE(mi.author, '')
|
||||
ELSE ''
|
||||
END ASC,
|
||||
CASE
|
||||
WHEN sqlc.narg('sort') = 'author DESC' THEN COALESCE(mi.author, '')
|
||||
ELSE ''
|
||||
END DESC,
|
||||
CASE
|
||||
WHEN sqlc.narg('sort') = 'created_at ASC' THEN mi.created_at
|
||||
ELSE '1970-01-01'::timestamp
|
||||
END ASC,
|
||||
CASE
|
||||
WHEN sqlc.narg('sort') = 'created_at DESC' THEN mi.created_at
|
||||
ELSE '1970-01-01'::timestamp
|
||||
END DESC,
|
||||
CASE
|
||||
WHEN sqlc.narg('sort') = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0')
|
||||
ELSE ''
|
||||
END ASC,
|
||||
CASE
|
||||
WHEN sqlc.narg('sort') = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0')
|
||||
ELSE ''
|
||||
END DESC,
|
||||
-- Tertiary sort: title (default fallback)
|
||||
mi.title ASC
|
||||
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user