feat: migrate tags and contributors from TEXT to TEXT[] arrays
Convert tags and contributors columns from comma-separated strings to PostgreSQL TEXT[] arrays for better data normalization and query performance. Database Changes: - schema.sql: Change tags/contributors from TEXT to TEXT[] - schema.sql: Add GIN indexes for fast array searches - queries.sql: Update search queries to use ANY() operator - queries.sql: Update fuzzy search with unnest() for arrays Generated Code (sqlc): - models.go: Auto-generated with []string types for tags/contributors - queries.sql.go: Auto-generated with proper array handling Handler Changes: - media.go: Update request structs to use []string for tags/contributors - media.go: Remove pgtype.Text wrapping, use direct array assignment - media.go: Add tag normalization in CreateMediaItemHandler - collections.go: Update tags evaluation to join arrays for comparison - collections.go: Add strings import for Join() function Service Changes: - ebook_scanner.go: Update EbookMetadata struct to use []string - ebook_scanner.go: Remove string Join(), assign arrays directly - collection_service.go: Update tags rule evaluation to join arrays - collection_service.go: Add strings import New Utilities: - internal/utils/tags.go: Create NormalizeTags(), JoinTags(), SplitTags() - Normalizes tags by trimming, lowercasing, removing duplicates/empties API Documentation: - bruno/media-items/Create Media Item.bru: Update examples to use arrays - bruno/media-items/Update Media Item.bru: Update examples to use arrays - Update docs: tags/contributors now array of string Breaking Change: - JSON format changes from "tags": "tag1,tag2" to "tags": ["tag1", "tag2"] - Tests already use array format (no changes needed) Benefits: - GIN indexes enable faster array searches - Normalization prevents data quality issues (case, duplicates) - Array operations use PostgreSQL native operators (ANY, &&, unnest) - Better separation of concerns (no string parsing in application)
This commit is contained in:
+30
-25
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/services"
|
||||
"bookhoard/internal/utils"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
@@ -29,27 +30,27 @@ type CreateMediaItemRequest struct {
|
||||
CoverImagePath string `json:"cover_image_path"`
|
||||
Series string `json:"series"`
|
||||
SeriesNumber int32 `json:"series_number"`
|
||||
Tags string `json:"tags"`
|
||||
Tags []string `json:"tags"`
|
||||
ASIN string `json:"asin"`
|
||||
DatePublished string `json:"date_published"`
|
||||
Publisher string `json:"publisher"`
|
||||
Contributors string `json:"contributors"`
|
||||
Contributors []string `json:"contributors"`
|
||||
}
|
||||
|
||||
// UpdateMediaItemRequest represents the request for updating a media item
|
||||
type UpdateMediaItemRequest struct {
|
||||
Title string `json:"title" validate:"required,min=1,max=500"`
|
||||
Author string `json:"author"`
|
||||
ISBN string `json:"isbn"`
|
||||
Description string `json:"description"`
|
||||
CoverImagePath string `json:"cover_image_path"`
|
||||
Series string `json:"series"`
|
||||
SeriesNumber int32 `json:"series_number"`
|
||||
Tags string `json:"tags"`
|
||||
ASIN string `json:"asin"`
|
||||
DatePublished string `json:"date_published"`
|
||||
Publisher string `json:"publisher"`
|
||||
Contributors string `json:"contributors"`
|
||||
Title string `json:"title" validate:"required,min=1,max=500"`
|
||||
Author string `json:"author"`
|
||||
ISBN string `json:"isbn"`
|
||||
Description string `json:"description"`
|
||||
CoverImagePath string `json:"cover_image_path"`
|
||||
Series string `json:"series"`
|
||||
SeriesNumber int32 `json:"series_number"`
|
||||
Tags []string `json:"tags"`
|
||||
ASIN string `json:"asin"`
|
||||
DatePublished string `json:"date_published"`
|
||||
Publisher string `json:"publisher"`
|
||||
Contributors []string `json:"contributors"`
|
||||
}
|
||||
|
||||
// CreateMediaNoteRequest represents the request for creating a media note
|
||||
@@ -419,11 +420,11 @@ func (h *MediaHandler) HandleBulkUpdate(c echo.Context) error {
|
||||
Updates []struct {
|
||||
BookID string `json:"book_id" validate:"required"`
|
||||
Updates struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Author *string `json:"author,omitempty"`
|
||||
Genre *string `json:"genre,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
Tags *string `json:"tags,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Author *string `json:"author,omitempty"`
|
||||
Genre *string `json:"genre,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
} `json:"updates"`
|
||||
} `json:"updates" validate:"required"`
|
||||
}
|
||||
@@ -500,8 +501,8 @@ func (h *MediaHandler) HandleBulkUpdate(c echo.Context) error {
|
||||
if update.Updates.Language != nil {
|
||||
updateParams.Language = pgtype.Text{String: *update.Updates.Language, Valid: true}
|
||||
}
|
||||
if update.Updates.Tags != nil {
|
||||
updateParams.Tags = pgtype.Text{String: *update.Updates.Tags, Valid: true}
|
||||
if update.Updates.Tags != nil && len(update.Updates.Tags) > 0 {
|
||||
updateParams.Tags = update.Updates.Tags
|
||||
}
|
||||
|
||||
_, err = h.db.UpdateMediaItem(c.Request().Context(), updateParams)
|
||||
@@ -888,6 +889,10 @@ func (mh *MediaHandler) CreateMediaItem(c echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
if len(req.Tags) > 0 {
|
||||
req.Tags = utils.NormalizeTags(req.Tags)
|
||||
}
|
||||
|
||||
_, err := mh.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: req.LibraryID, Valid: true})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
@@ -908,11 +913,11 @@ func (mh *MediaHandler) CreateMediaItem(c echo.Context) error {
|
||||
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
|
||||
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
|
||||
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
|
||||
Tags: pgtype.Text{String: req.Tags, Valid: req.Tags != ""},
|
||||
Tags: req.Tags,
|
||||
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
|
||||
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
|
||||
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
|
||||
Contributors: pgtype.Text{String: req.Contributors, Valid: req.Contributors != ""},
|
||||
Contributors: req.Contributors,
|
||||
AddedByAdminID: user.ID,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -958,11 +963,11 @@ func (mh *MediaHandler) UpdateMediaItem(c echo.Context) error {
|
||||
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
|
||||
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
|
||||
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
|
||||
Tags: pgtype.Text{String: req.Tags, Valid: req.Tags != ""},
|
||||
Tags: req.Tags,
|
||||
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
|
||||
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
|
||||
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
|
||||
Contributors: pgtype.Text{String: req.Contributors, Valid: req.Contributors != ""},
|
||||
Contributors: req.Contributors,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
|
||||
Reference in New Issue
Block a user