a1f6701bac
- gRPC service with MusicBrainz provider - PostgreSQL schema with migrations - Service layer with database-first caching - Repository pattern for data access - YAML configuration support - Research documentation for 17 music metadata projects
261 lines
6.4 KiB
Go
261 lines
6.4 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/metadata-agregator/internal/domain"
|
|
"github.com/metadata-agregator/internal/repository"
|
|
)
|
|
|
|
type ArtistRepository struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewArtistRepository(pool *pgxpool.Pool) *ArtistRepository {
|
|
return &ArtistRepository{pool: pool}
|
|
}
|
|
|
|
func (r *ArtistRepository) GetByID(ctx context.Context, id string) (*domain.Artist, error) {
|
|
query := `
|
|
SELECT id, name, sort_name, artist_type, country, formed_date, disbanded_date,
|
|
description, image_url, source, source_id
|
|
FROM artists
|
|
WHERE id = $1`
|
|
|
|
artist, err := r.scanArtist(ctx, query, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := r.loadExternalIDs(ctx, artist); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return artist, nil
|
|
}
|
|
|
|
func (r *ArtistRepository) GetByExternalID(ctx context.Context, source, sourceID string) (*domain.Artist, error) {
|
|
query := `
|
|
SELECT a.id, a.name, a.sort_name, a.artist_type, a.country, a.formed_date,
|
|
a.disbanded_date, a.description, a.image_url, a.source, a.source_id
|
|
FROM artists a
|
|
JOIN artist_external_ids e ON a.id = e.artist_id
|
|
WHERE e.source = $1 AND e.source_id = $2`
|
|
|
|
artist, err := r.scanArtist(ctx, query, source, sourceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := r.loadExternalIDs(ctx, artist); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return artist, nil
|
|
}
|
|
|
|
func (r *ArtistRepository) Search(ctx context.Context, query string, limit, offset int) (*domain.SearchResult[domain.Artist], error) {
|
|
countQuery := `SELECT COUNT(*) FROM artists WHERE name ILIKE $1`
|
|
searchQuery := `
|
|
SELECT id, name, sort_name, artist_type, country, formed_date, disbanded_date,
|
|
description, image_url, source, source_id
|
|
FROM artists
|
|
WHERE name ILIKE $1
|
|
ORDER BY name
|
|
LIMIT $2 OFFSET $3`
|
|
|
|
pattern := "%" + query + "%"
|
|
|
|
var total int
|
|
if err := r.pool.QueryRow(ctx, countQuery, pattern).Scan(&total); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rows, err := r.pool.Query(ctx, searchQuery, pattern, limit, offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var artists []domain.Artist
|
|
for rows.Next() {
|
|
artist, err := r.scanArtistFromRow(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
artists = append(artists, *artist)
|
|
}
|
|
|
|
return &domain.SearchResult[domain.Artist]{
|
|
Items: artists,
|
|
Total: total,
|
|
Limit: limit,
|
|
Offset: offset,
|
|
}, nil
|
|
}
|
|
|
|
func (r *ArtistRepository) Save(ctx context.Context, artist *domain.Artist) error {
|
|
tx, err := r.pool.Begin(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var source, sourceID string
|
|
if len(artist.ExternalIDs) > 0 {
|
|
source = artist.ExternalIDs[0].Source
|
|
sourceID = artist.ExternalIDs[0].SourceID
|
|
}
|
|
|
|
query := `
|
|
INSERT INTO artists (id, name, sort_name, artist_type, country, formed_date,
|
|
disbanded_date, description, image_url, source, source_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
ON CONFLICT (id) DO UPDATE SET
|
|
name = EXCLUDED.name,
|
|
sort_name = EXCLUDED.sort_name,
|
|
artist_type = EXCLUDED.artist_type,
|
|
country = EXCLUDED.country,
|
|
formed_date = EXCLUDED.formed_date,
|
|
disbanded_date = EXCLUDED.disbanded_date,
|
|
description = EXCLUDED.description,
|
|
image_url = EXCLUDED.image_url,
|
|
updated_at = now()`
|
|
|
|
_, err = tx.Exec(ctx, query,
|
|
artist.ID, artist.Name, nullString(artist.SortName), nullString(artist.Type),
|
|
nullString(artist.Country), artist.FormedDate, artist.DisbandedDate,
|
|
nullString(artist.Description), nullString(artist.ImageURL), source, sourceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, ext := range artist.ExternalIDs {
|
|
extQuery := `
|
|
INSERT INTO artist_external_ids (artist_id, source, source_id, url)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (artist_id, source, source_id) DO UPDATE SET
|
|
url = EXCLUDED.url,
|
|
fetched_at = now()`
|
|
|
|
_, err = tx.Exec(ctx, extQuery, artist.ID, ext.Source, ext.SourceID, nullString(ext.URL))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
func (r *ArtistRepository) scanArtist(ctx context.Context, query string, args ...any) (*domain.Artist, error) {
|
|
row := r.pool.QueryRow(ctx, query, args...)
|
|
|
|
var (
|
|
artist domain.Artist
|
|
sortName *string
|
|
artistType *string
|
|
country *string
|
|
formedDate *time.Time
|
|
disbandDate *time.Time
|
|
description *string
|
|
imageURL *string
|
|
source string
|
|
sourceID *string
|
|
)
|
|
|
|
err := row.Scan(
|
|
&artist.ID, &artist.Name, &sortName, &artistType, &country,
|
|
&formedDate, &disbandDate, &description, &imageURL, &source, &sourceID,
|
|
)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, repository.ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
artist.SortName = derefString(sortName)
|
|
artist.Type = derefString(artistType)
|
|
artist.Country = derefString(country)
|
|
artist.FormedDate = formedDate
|
|
artist.DisbandedDate = disbandDate
|
|
artist.Description = derefString(description)
|
|
artist.ImageURL = derefString(imageURL)
|
|
|
|
return &artist, nil
|
|
}
|
|
|
|
func (r *ArtistRepository) scanArtistFromRow(row pgx.Row) (*domain.Artist, error) {
|
|
var (
|
|
artist domain.Artist
|
|
sortName *string
|
|
artistType *string
|
|
country *string
|
|
formedDate *time.Time
|
|
disbandDate *time.Time
|
|
description *string
|
|
imageURL *string
|
|
source string
|
|
sourceID *string
|
|
)
|
|
|
|
err := row.Scan(
|
|
&artist.ID, &artist.Name, &sortName, &artistType, &country,
|
|
&formedDate, &disbandDate, &description, &imageURL, &source, &sourceID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
artist.SortName = derefString(sortName)
|
|
artist.Type = derefString(artistType)
|
|
artist.Country = derefString(country)
|
|
artist.FormedDate = formedDate
|
|
artist.DisbandedDate = disbandDate
|
|
artist.Description = derefString(description)
|
|
artist.ImageURL = derefString(imageURL)
|
|
|
|
return &artist, nil
|
|
}
|
|
|
|
func (r *ArtistRepository) loadExternalIDs(ctx context.Context, artist *domain.Artist) error {
|
|
query := `SELECT source, source_id, url FROM artist_external_ids WHERE artist_id = $1`
|
|
|
|
rows, err := r.pool.Query(ctx, query, artist.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var ext domain.ExternalID
|
|
var url *string
|
|
if err := rows.Scan(&ext.Source, &ext.SourceID, &url); err != nil {
|
|
return err
|
|
}
|
|
ext.URL = derefString(url)
|
|
artist.ExternalIDs = append(artist.ExternalIDs, ext)
|
|
}
|
|
|
|
return rows.Err()
|
|
}
|
|
|
|
func nullString(s string) *string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return &s
|
|
}
|
|
|
|
func derefString(s *string) string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
return *s
|
|
}
|