From 43a6d843a3bacdc31d5dd0a69db93bb47db84b9f Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 23 Mar 2026 22:37:37 -0400 Subject: [PATCH] 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. --- internal/database/querier.go | 1 - internal/database/queries.sql.go | 227 +++++--------------------- internal/database/queries/queries.sql | 79 ++++----- 3 files changed, 73 insertions(+), 234 deletions(-) diff --git a/internal/database/querier.go b/internal/database/querier.go index 1eda8b7..643df7b 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -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) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 4de8d0b..939d698 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -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, ) diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 51861d3..f0a228e 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -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');