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:
2026-02-07 22:53:12 -05:00
parent 98f2913eb5
commit 516cec5a7f
13 changed files with 1831 additions and 229 deletions
+57 -41
View File
@@ -342,7 +342,7 @@ RETURNING id, device_id, media_item_id, bookhoard_uuid, kobo_content_id, content
type CreateDeviceCatalogParams struct {
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
BookhoardUuid pgtype.UUID `db:"bookhoard_uuid" json:"bookhoard_uuid"`
BookhoardUuid pgtype.UUID `db:"bookhoard_uuid" json:"bookhoard_uuid"`
KoboContentID string `db:"kobo_content_id" json:"kobo_content_id"`
ContentIDType pgtype.Text `db:"content_id_type" json:"content_id_type"`
Available pgtype.Bool `db:"available" json:"available"`
@@ -602,11 +602,11 @@ type CreateMediaItemParams struct {
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 pgtype.Text `db:"tags" json:"tags"`
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 pgtype.Text `db:"contributors" json:"contributors"`
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"`
@@ -1824,7 +1824,7 @@ SELECT id, device_id, media_item_id, bookhoard_uuid, kobo_content_id, content_id
`
type GetDeviceCatalogByBookhoardUUIDParams struct {
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
BookhoardUuid pgtype.UUID `db:"bookhoard_uuid" json:"bookhoard_uuid"`
}
@@ -1880,7 +1880,7 @@ type GetDeviceCatalogEntriesRow struct {
ID pgtype.UUID `db:"id" json:"id"`
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
BookhoardUuid pgtype.UUID `db:"bookhoard_uuid" json:"bookhoard_uuid"`
BookhoardUuid pgtype.UUID `db:"bookhoard_uuid" json:"bookhoard_uuid"`
KoboContentID string `db:"kobo_content_id" json:"kobo_content_id"`
ContentIDType pgtype.Text `db:"content_id_type" json:"content_id_type"`
Available pgtype.Bool `db:"available" json:"available"`
@@ -4931,11 +4931,11 @@ type ListMediaItemsRow struct {
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 pgtype.Text `db:"tags" json:"tags"`
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 pgtype.Text `db:"contributors" json:"contributors"`
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"`
@@ -5052,11 +5052,11 @@ type ListMediaItemsByLibraryRow struct {
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 pgtype.Text `db:"tags" json:"tags"`
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 pgtype.Text `db:"contributors" json:"contributors"`
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"`
@@ -5223,11 +5223,11 @@ type ListMediaItemsFilteredRow struct {
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 pgtype.Text `db:"tags" json:"tags"`
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 pgtype.Text `db:"contributors" json:"contributors"`
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"`
@@ -5430,11 +5430,11 @@ type ListMediaItemsSortedRow struct {
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 pgtype.Text `db:"tags" json:"tags"`
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 pgtype.Text `db:"contributors" json:"contributors"`
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"`
@@ -6052,16 +6052,16 @@ WHERE COALESCE(lv.is_visible, true) = true
AND (
mi.title ILIKE $2 OR
mi.author ILIKE $2 OR
mi.series ILIKE $2 OR
mi.tags ILIKE $2 OR
mi.contributors ILIKE $2
mi.series ILIKE $2 OR
$2 = ANY(mi.tags) OR
$2 = ANY(mi.contributors)
)
ORDER BY
CASE
CASE
WHEN mi.title ILIKE $2 THEN 1
WHEN mi.author ILIKE $2 THEN 2
WHEN mi.series ILIKE $2 THEN 3
WHEN mi.tags ILIKE $2 THEN 4
WHEN $2 = ANY(mi.tags) THEN 4
ELSE 5
END,
mi.title ASC
@@ -6088,11 +6088,11 @@ type SearchMediaItemsRow struct {
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 pgtype.Text `db:"tags" json:"tags"`
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 pgtype.Text `db:"contributors" json:"contributors"`
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"`
@@ -6202,23 +6202,39 @@ 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 COALESCE(lv.is_visible, true) = true
AND (
word_similarity($2, mi.title) > 0.3 OR
word_similarity($2, COALESCE(mi.author, '')) > 0.3 OR
word_similarity($2, COALESCE(mi.series, '')) > 0.3 OR
word_similarity($2, COALESCE(mi.tags, '')) > 0.3 OR
word_similarity($2, COALESCE(mi.contributors, '')) > 0.3
)
ORDER BY
GREATEST(
word_similarity($2, mi.title),
word_similarity($2, COALESCE(mi.author, '')),
word_similarity($2, COALESCE(mi.series, '')),
word_similarity($2, COALESCE(mi.tags, '')),
word_similarity($2, COALESCE(mi.contributors, ''))
) DESC,
mi.title ASC
LIMIT $4 OFFSET $3
AND (
word_similarity($2, mi.title) > 0.3 OR
word_similarity($2, COALESCE(mi.author, '')) > 0.3 OR
word_similarity($2, COALESCE(mi.series, '')) > 0.3 OR
EXISTS (
SELECT 1 FROM unnest(mi.tags) AS tag
WHERE word_similarity($2, tag) > 0.3
LIMIT 1
) OR
EXISTS (
SELECT 1 FROM unnest(mi.contributors) AS contributor
WHERE word_similarity($2, contributor) > 0.3
LIMIT 1
)
)
ORDER BY
GREATEST(
word_similarity($2, mi.title),
word_similarity($2, COALESCE(mi.author, '')),
word_similarity($2, COALESCE(mi.series, '')),
COALESCE(
(SELECT MAX(word_similarity($2, tag))
FROM unnest(mi.tags) AS tag),
0
),
COALESCE(
(SELECT MAX(word_similarity($2, contributor))
FROM unnest(mi.contributors) AS contributor),
0
)
) DESC,
mi.title ASC
LIMIT $4 OFFSET $3
`
type SearchMediaItemsFuzzyParams struct {
@@ -6241,11 +6257,11 @@ type SearchMediaItemsFuzzyRow struct {
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 pgtype.Text `db:"tags" json:"tags"`
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 pgtype.Text `db:"contributors" json:"contributors"`
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"`
@@ -6946,11 +6962,11 @@ type UpdateMediaItemParams struct {
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 pgtype.Text `db:"tags" json:"tags"`
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 pgtype.Text `db:"contributors" json:"contributors"`
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"`