From 77cf0b827b5a3e2a0a01591fdd5adc6761179447 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 17 Oct 2026 16:51:30 -0400 Subject: [PATCH] refactor(db): GetCollectionItemsForDashboard embeds the media row; excluded filter moves into SQL; third converter dies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SELECT mi.*, ci.excluded forced every caller to filter tombstone exclusions in Go over a 69-field row, then a 45-field hand converter (getCollectionItemsRowToMediaItems) copied it field-by-field into MediaItems for BuildSections. The query now filters (ci.excluded IS NOT TRUE) itself and returns sqlc.embed(mi) — callers consume .MediaItems directly, the Go filter loops die in both collection-detail ladders (JSON + SSR), and the converter is deleted. Pins, H1/H3 tests, unit suites green. --- internal/database/querier.go | 3 + internal/database/queries.sql.go | 213 +++++++++---------------- internal/database/queries/queries.sql | 6 +- internal/handlers/cards.go | 21 +-- internal/handlers/collections.go | 10 +- internal/router/frontend.go | 9 +- internal/services/dashboard_service.go | 58 +------ 7 files changed, 102 insertions(+), 218 deletions(-) diff --git a/internal/database/querier.go b/internal/database/querier.go index 71ef3660..91f7eb47 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -174,6 +174,9 @@ type Querier interface { GetCollection(ctx context.Context, id pgtype.UUID) (Collections, error) // Get collection items GetCollectionItems(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsRow, error) + // The excluded filter lives here (was: callers filtered ci.excluded in + // Go over a 69-field row + a 45-field converter to MediaItems; embed + // returns the real row so neither exists). GetCollectionItemsForDashboard(ctx context.Context, arg GetCollectionItemsForDashboardParams) ([]GetCollectionItemsForDashboardRow, error) // Get collection with book count GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID) (GetCollectionWithBookCountRow, error) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 1010a052..5a297249 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -2832,12 +2832,13 @@ func (q *Queries) GetCollectionItems(ctx context.Context, collectionID pgtype.UU } const GetCollectionItemsForDashboard = `-- name: GetCollectionItemsForDashboard :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.narrators, mi.abridged, 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.imported_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.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, ci.excluded FROM media_items mi +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.narrators, mi.abridged, 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.imported_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.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi INNER JOIN collection_items ci ON ci.media_item_id = mi.id WHERE ci.collection_id = $1 AND ($2::uuid IS NULL OR mi.library_id = $2::uuid) AND mi.archived_at IS NULL AND mi.missing_scan_count = 0 + AND (ci.excluded IS NOT TRUE) ORDER BY ci.added_at DESC LIMIT $3 ` @@ -2849,77 +2850,12 @@ type GetCollectionItemsForDashboardParams struct { } type GetCollectionItemsForDashboardRow 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"` - Narrators []string `db:"narrators" json:"narrators"` - Abridged bool `db:"abridged" json:"abridged"` - 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"` - ImportedAt pgtype.Timestamptz `db:"imported_at" json:"imported_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"` - MangaType pgtype.Text `db:"manga_type" json:"manga_type"` - ReadingDirection pgtype.Text `db:"reading_direction" json:"reading_direction"` - SeriesCount pgtype.Int4 `db:"series_count" json:"series_count"` - Volume pgtype.Int4 `db:"volume" json:"volume"` - Imprint pgtype.Text `db:"imprint" json:"imprint"` - AgeRating pgtype.Text `db:"age_rating" json:"age_rating"` - WebUrl pgtype.Text `db:"web_url" json:"web_url"` - StoryArc pgtype.Text `db:"story_arc" json:"story_arc"` - IsBlackAndWhite pgtype.Bool `db:"is_black_and_white" json:"is_black_and_white"` - MetadataNotes pgtype.Text `db:"metadata_notes" json:"metadata_notes"` - CommunityRating pgtype.Float8 `db:"community_rating" json:"community_rating"` - AlternateInfo []byte `db:"alternate_info" json:"alternate_info"` - ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"` - Summary pgtype.Text `db:"summary" json:"summary"` - MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"` - MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"` - ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"` - ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"` - LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"` - AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"` - TagsSearch []string `db:"tags_search" json:"tags_search"` - ContributorsSearch []string `db:"contributors_search" json:"contributors_search"` - NarratorsSearch []string `db:"narrators_search" json:"narrators_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"` - Excluded pgtype.Bool `db:"excluded" json:"excluded"` + MediaItems MediaItems `db:"media_items" json:"media_items"` } +// The excluded filter lives here (was: callers filtered ci.excluded in +// Go over a 69-field row + a 45-field converter to MediaItems; embed +// returns the real row so neither exists). func (q *Queries) GetCollectionItemsForDashboard(ctx context.Context, arg GetCollectionItemsForDashboardParams) ([]GetCollectionItemsForDashboardRow, error) { rows, err := q.db.Query(ctx, GetCollectionItemsForDashboard, arg.CollectionID, arg.LibraryID, arg.Limit) if err != nil { @@ -2930,75 +2866,74 @@ func (q *Queries) GetCollectionItemsForDashboard(ctx context.Context, arg GetCol for rows.Next() { var i GetCollectionItemsForDashboardRow 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.Narrators, - &i.Abridged, - &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.ImportedAt, - &i.UpdatedAt, - &i.FormatGroup, - &i.FormatMimetype, - &i.IsReflowable, - &i.HasFixedLayout, - &i.TotalCharacters, - &i.ChapterCount, - &i.EntitlementID, - &i.RevisionNumber, - &i.KoboContentID, - &i.KoboMetadata, - &i.MangaType, - &i.ReadingDirection, - &i.SeriesCount, - &i.Volume, - &i.Imprint, - &i.AgeRating, - &i.WebUrl, - &i.StoryArc, - &i.IsBlackAndWhite, - &i.MetadataNotes, - &i.CommunityRating, - &i.AlternateInfo, - &i.ScanInformation, - &i.Summary, - &i.MetadataOverrides, - &i.MissingScanCount, - &i.ArchivedAt, - &i.ChapterMetadata, - &i.LibraryTypeName, - &i.AudioMetadata, - &i.TagsSearch, - &i.ContributorsSearch, - &i.NarratorsSearch, - &i.FileSha256, - &i.OpfIdentifier, - &i.OpfUuid, - &i.HashConfidence, - &i.Excluded, + &i.MediaItems.ID, + &i.MediaItems.LibraryID, + &i.MediaItems.Title, + &i.MediaItems.Author, + &i.MediaItems.Isbn, + &i.MediaItems.Description, + &i.MediaItems.FilePath, + &i.MediaItems.FileSize, + &i.MediaItems.MimeType, + &i.MediaItems.CoverImagePath, + &i.MediaItems.Series, + &i.MediaItems.SeriesNumber, + &i.MediaItems.Tags, + &i.MediaItems.Narrators, + &i.MediaItems.Abridged, + &i.MediaItems.Asin, + &i.MediaItems.DatePublished, + &i.MediaItems.Publisher, + &i.MediaItems.Contributors, + &i.MediaItems.Language, + &i.MediaItems.Edition, + &i.MediaItems.PageCount, + &i.MediaItems.Genre, + &i.MediaItems.CopyrightYear, + &i.MediaItems.GoodreadsID, + &i.MediaItems.OpenlibraryID, + &i.MediaItems.GoogleBooksID, + &i.MediaItems.AddedByAdminID, + &i.MediaItems.CreatedAt, + &i.MediaItems.ImportedAt, + &i.MediaItems.UpdatedAt, + &i.MediaItems.FormatGroup, + &i.MediaItems.FormatMimetype, + &i.MediaItems.IsReflowable, + &i.MediaItems.HasFixedLayout, + &i.MediaItems.TotalCharacters, + &i.MediaItems.ChapterCount, + &i.MediaItems.EntitlementID, + &i.MediaItems.RevisionNumber, + &i.MediaItems.KoboContentID, + &i.MediaItems.KoboMetadata, + &i.MediaItems.MangaType, + &i.MediaItems.ReadingDirection, + &i.MediaItems.SeriesCount, + &i.MediaItems.Volume, + &i.MediaItems.Imprint, + &i.MediaItems.AgeRating, + &i.MediaItems.WebUrl, + &i.MediaItems.StoryArc, + &i.MediaItems.IsBlackAndWhite, + &i.MediaItems.MetadataNotes, + &i.MediaItems.CommunityRating, + &i.MediaItems.AlternateInfo, + &i.MediaItems.ScanInformation, + &i.MediaItems.Summary, + &i.MediaItems.MetadataOverrides, + &i.MediaItems.MissingScanCount, + &i.MediaItems.ArchivedAt, + &i.MediaItems.ChapterMetadata, + &i.MediaItems.LibraryTypeName, + &i.MediaItems.AudioMetadata, + &i.MediaItems.TagsSearch, + &i.MediaItems.ContributorsSearch, + &i.MediaItems.NarratorsSearch, + &i.MediaItems.FileSha256, + &i.MediaItems.OpfIdentifier, + &i.MediaItems.OpfUuid, + &i.MediaItems.HashConfidence, ); err != nil { return nil, err } diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index ff4bc7dc..cbd157d2 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -2791,12 +2791,16 @@ ORDER BY mi.created_at DESC LIMIT sqlc.narg('limit'); -- name: GetCollectionItemsForDashboard :many -SELECT mi.*, ci.excluded FROM media_items mi +-- The excluded filter lives here (was: callers filtered ci.excluded in +-- Go over a 69-field row + a 45-field converter to MediaItems; embed +-- returns the real row so neither exists). +SELECT sqlc.embed(mi) FROM media_items mi INNER JOIN collection_items ci ON ci.media_item_id = mi.id WHERE ci.collection_id = sqlc.narg('collection_id') AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) AND mi.archived_at IS NULL AND mi.missing_scan_count = 0 + AND (ci.excluded IS NOT TRUE) ORDER BY ci.added_at DESC LIMIT sqlc.narg('limit'); diff --git a/internal/handlers/cards.go b/internal/handlers/cards.go index df4a8be5..42a2cb53 100644 --- a/internal/handlers/cards.go +++ b/internal/handlers/cards.go @@ -125,20 +125,21 @@ func BookCardsFromMediaItems(rows []database.MediaItems, libTypes map[string]str } // BookCardsFromCollectionDashRows builds cards from the -// collection-items join (GetCollectionItemsForDashboard). The row's ID -// IS the media item id. The Excluded filter stays at call sites — only -// the two collection-detail surfaces exclude tombstones. +// collection-items join (GetCollectionItemsForDashboard). The query +// embeds the media row and filters excluded rows in SQL, so the adapter +// reads through the embed. func BookCardsFromCollectionDashRows(rows []database.GetCollectionItemsForDashboardRow, libTypes map[string]string) []BookInfo { cards := make([]BookInfo, 0, len(rows)) for _, r := range rows { + mi := r.MediaItems cards = append(cards, buildBookCard(cardSource{ - ID: uuid.UUID(r.ID.Bytes), - Title: r.Title, - Author: utils.PgTextString(r.Author), - LibraryID: r.LibraryID, - CoverPath: r.CoverImagePath, - FormatGroup: r.FormatGroup, - LibraryType: libTypes[uuid.UUID(r.LibraryID.Bytes).String()], + ID: uuid.UUID(mi.ID.Bytes), + Title: mi.Title, + Author: utils.PgTextString(mi.Author), + LibraryID: mi.LibraryID, + CoverPath: mi.CoverImagePath, + FormatGroup: mi.FormatGroup, + LibraryType: libTypes[uuid.UUID(mi.LibraryID.Bytes).String()], })) } return cards diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index 75c4c965..e30121b3 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -293,13 +293,9 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error { if collErr != nil { bookList = BookCardsFromCollectionDashRows(nil, libTypes) } else { - var validItems []database.GetCollectionItemsForDashboardRow - for _, item := range collItems { - if !item.Excluded.Valid || !item.Excluded.Bool { - validItems = append(validItems, item) - } - } - bookList = BookCardsFromCollectionDashRows(validItems, libTypes) + // The query filters excluded rows in SQL; cards build straight + // from the embedded media rows. + bookList = BookCardsFromCollectionDashRows(collItems, libTypes) } } else { books, booksErr := h.GetCollectionBooksData(c, collectionID) diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 7cc5a0bc..397a8668 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -752,13 +752,8 @@ func registerFrontendRoutes(cfg *Config) { if collErr != nil { books = handlers.BookCardsFromCollectionDashRows(nil, libTypes) } else { - var validItems []database.GetCollectionItemsForDashboardRow - for _, item := range collItems { - if !item.Excluded.Valid || !item.Excluded.Bool { - validItems = append(validItems, item) - } - } - books = handlers.BookCardsFromCollectionDashRows(validItems, libTypes) + // The query filters excluded rows in SQL. + books = handlers.BookCardsFromCollectionDashRows(collItems, libTypes) } } else { collItems, collErr := cfg.Queries.GetCollectionItems(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true}) diff --git a/internal/services/dashboard_service.go b/internal/services/dashboard_service.go index 4b562612..d3ae4f84 100644 --- a/internal/services/dashboard_service.go +++ b/internal/services/dashboard_service.go @@ -2,7 +2,6 @@ package services import ( "bookhoard/internal/database" - "bookhoard/internal/utils" "context" "encoding/json" "errors" @@ -13,55 +12,6 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -func getCollectionItemsRowToMediaItems(item database.GetCollectionItemsForDashboardRow) database.MediaItems { - return database.MediaItems{ - ID: item.ID, - LibraryID: item.LibraryID, - Title: item.Title, - Author: item.Author, - Isbn: item.Isbn, - Description: item.Description, - FilePath: utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: true}), - FileSize: item.FileSize, - MimeType: item.MimeType, - CoverImagePath: pgtype.Text{String: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), Valid: true}, - Series: item.Series, - SeriesNumber: item.SeriesNumber, - Tags: item.Tags, - Asin: item.Asin, - DatePublished: item.DatePublished, - Publisher: item.Publisher, - Contributors: item.Contributors, - Language: item.Language, - Edition: item.Edition, - PageCount: item.PageCount, - Genre: item.Genre, - CopyrightYear: item.CopyrightYear, - GoodreadsID: item.GoodreadsID, - OpenlibraryID: item.OpenlibraryID, - GoogleBooksID: item.GoogleBooksID, - AddedByAdminID: item.AddedByAdminID, - CreatedAt: item.CreatedAt, - UpdatedAt: item.UpdatedAt, - FormatGroup: item.FormatGroup, - FormatMimetype: item.FormatMimetype, - IsReflowable: item.IsReflowable, - HasFixedLayout: item.HasFixedLayout, - TotalCharacters: item.TotalCharacters, - ChapterCount: item.ChapterCount, - EntitlementID: item.EntitlementID, - RevisionNumber: item.RevisionNumber, - KoboContentID: item.KoboContentID, - KoboMetadata: item.KoboMetadata, - TagsSearch: item.TagsSearch, - ContributorsSearch: item.ContributorsSearch, - FileSha256: item.FileSha256, - OpfIdentifier: item.OpfIdentifier, - OpfUuid: item.OpfUuid, - HashConfidence: item.HashConfidence, - } -} - type DashboardService struct { db *database.Queries collectionService *CollectionService @@ -277,11 +227,11 @@ func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll data return nil, err } + // The query filters excluded rows in SQL and embeds the media row, + // so the items are MediaItems as-is. var manualNonExcluded []database.MediaItems - for _, item := range manualItems { - if !item.Excluded.Valid || !item.Excluded.Bool { - manualNonExcluded = append(manualNonExcluded, getCollectionItemsRowToMediaItems(item)) - } + for _, row := range manualItems { + manualNonExcluded = append(manualNonExcluded, row.MediaItems) } var autoItems []database.MediaItems