initial commit
This commit is contained in:
commit
1253955070
75 changed files with 13972 additions and 0 deletions
795
api/internal/store/sql.go
Normal file
795
api/internal/store/sql.go
Normal file
|
|
@ -0,0 +1,795 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
_ "modernc.org/sqlite" // registers the "sqlite" driver, CGO-free
|
||||
|
||||
"go-places-api/internal/domain"
|
||||
)
|
||||
|
||||
// SQLStore is the SQLite-backed implementation of Store.
|
||||
type SQLStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
var _ Store = (*SQLStore)(nil)
|
||||
|
||||
// OpenSQL opens the SQLite database at dsn and applies the schema. The dsn is a
|
||||
// SQLite connection string such as "file:go-places.db?_pragma=foreign_keys(1)".
|
||||
func OpenSQL(dsn string) (*SQLStore, error) {
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: open database: %w", err)
|
||||
}
|
||||
|
||||
// SQLite serialises writers. A single connection avoids "database is
|
||||
// locked" errors under concurrent uploads without needing a write mutex.
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
db.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("store: ping database: %w", err)
|
||||
}
|
||||
|
||||
s := &SQLStore{db: db}
|
||||
if err := s.migrate(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Close releases the underlying database handle.
|
||||
func (s *SQLStore) Close() error { return s.db.Close() }
|
||||
|
||||
// DB exposes the handle for health checks.
|
||||
func (s *SQLStore) DB() *sql.DB { return s.db }
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS places (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
link_radius_meters REAL NOT NULL DEFAULT 150,
|
||||
creator_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_places_creator ON places(creator_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_places_geo ON places(latitude, longitude);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS images (
|
||||
id TEXT PRIMARY KEY,
|
||||
place_id TEXT NOT NULL REFERENCES places(id) ON DELETE CASCADE,
|
||||
storage_key TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
byte_size INTEGER NOT NULL,
|
||||
width INTEGER NOT NULL DEFAULT 0,
|
||||
height INTEGER NOT NULL DEFAULT 0,
|
||||
caption TEXT NOT NULL DEFAULT '',
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
taken_at TEXT,
|
||||
camera_make TEXT NOT NULL DEFAULT '',
|
||||
camera_model TEXT NOT NULL DEFAULT '',
|
||||
analysis_status TEXT NOT NULL DEFAULT 'pending',
|
||||
analysis_error TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_images_place ON images(place_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_images_created ON images(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_images_geo ON images(latitude, longitude);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS badges (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
image_id TEXT NOT NULL REFERENCES images(id) ON DELETE CASCADE,
|
||||
label TEXT NOT NULL,
|
||||
display TEXT NOT NULL DEFAULT '',
|
||||
confidence REAL NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
UNIQUE(image_id, label, source)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_badges_image ON badges(image_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id TEXT PRIMARY KEY,
|
||||
place_id TEXT NOT NULL REFERENCES places(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
creator_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_place ON comments(place_id, created_at);
|
||||
|
||||
-- Evidence that two pictures were taken at the same place. Both directions are
|
||||
-- stored so a link is discoverable from either picture.
|
||||
CREATE TABLE IF NOT EXISTS image_links (
|
||||
image_id TEXT NOT NULL REFERENCES images(id) ON DELETE CASCADE,
|
||||
related_image_id TEXT NOT NULL REFERENCES images(id) ON DELETE CASCADE,
|
||||
distance_meters REAL NOT NULL DEFAULT 0,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (image_id, related_image_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_image_links_related ON image_links(related_image_id);
|
||||
`
|
||||
|
||||
func (s *SQLStore) migrate(ctx context.Context) error {
|
||||
if _, err := s.db.ExecContext(ctx, schema); err != nil {
|
||||
return fmt.Errorf("store: apply schema: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- places ---
|
||||
|
||||
func (s *SQLStore) CreatePlace(ctx context.Context, p domain.Place) (domain.Place, error) {
|
||||
if p.ID == "" {
|
||||
p.ID = newID()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
p.CreatedAt = now
|
||||
// Timestamps are stored as RFC3339 so the schema stays portable to engines
|
||||
// without a native timestamp type.
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO places (id, title, description, latitude, longitude,
|
||||
link_radius_meters, creator_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
p.ID, p.Title, p.Description,
|
||||
nullableFloat(p.Coordinate, func(c domain.Coordinate) float64 { return c.Latitude }),
|
||||
nullableFloat(p.Coordinate, func(c domain.Coordinate) float64 { return c.Longitude }),
|
||||
p.LinkRadiusMeters, p.Creator.ID,
|
||||
formatTime(p.CreatedAt), formatTime(now),
|
||||
)
|
||||
if err != nil {
|
||||
return domain.Place{}, wrapWrite(err, "place")
|
||||
}
|
||||
return s.GetPlace(ctx, p.ID)
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetPlace(ctx context.Context, id string) (domain.Place, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT p.id, p.title, p.description, p.latitude, p.longitude,
|
||||
p.link_radius_meters, p.created_at,
|
||||
u.id, u.username, u.email
|
||||
FROM places p
|
||||
JOIN users u ON u.id = p.creator_id
|
||||
WHERE p.id = ?`, id)
|
||||
|
||||
p, err := scanPlace(row)
|
||||
if err != nil {
|
||||
return domain.Place{}, err
|
||||
}
|
||||
|
||||
images, err := s.ListImages(ctx, ImageFilter{PlaceID: id})
|
||||
if err != nil {
|
||||
return domain.Place{}, err
|
||||
}
|
||||
comments, err := s.ListComments(ctx, id)
|
||||
if err != nil {
|
||||
return domain.Place{}, err
|
||||
}
|
||||
p.Images = images
|
||||
p.Comments = comments
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) UpdatePlace(ctx context.Context, p domain.Place) (domain.Place, error) {
|
||||
res, err := s.db.ExecContext(ctx, `
|
||||
UPDATE places
|
||||
SET title = ?, description = ?, latitude = ?, longitude = ?,
|
||||
link_radius_meters = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
p.Title, p.Description,
|
||||
nullableFloat(p.Coordinate, func(c domain.Coordinate) float64 { return c.Latitude }),
|
||||
nullableFloat(p.Coordinate, func(c domain.Coordinate) float64 { return c.Longitude }),
|
||||
p.LinkRadiusMeters, formatTime(time.Now().UTC()), p.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.Place{}, wrapWrite(err, "place")
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return domain.Place{}, ErrNotFound
|
||||
}
|
||||
return s.GetPlace(ctx, p.ID)
|
||||
}
|
||||
|
||||
func (s *SQLStore) ListPlaces(ctx context.Context, limit, offset int) ([]domain.Place, error) {
|
||||
query := `
|
||||
SELECT p.id, p.title, p.description, p.latitude, p.longitude,
|
||||
p.link_radius_meters, p.created_at,
|
||||
u.id, u.username, u.email
|
||||
FROM places p
|
||||
JOIN users u ON u.id = p.creator_id
|
||||
ORDER BY p.created_at DESC`
|
||||
args := []any{}
|
||||
if limit > 0 {
|
||||
query += " LIMIT ?"
|
||||
args = append(args, limit)
|
||||
if offset > 0 {
|
||||
query += " OFFSET ?"
|
||||
args = append(args, offset)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list places: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []domain.Place
|
||||
for rows.Next() {
|
||||
p, err := scanPlace(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLStore) DeletePlace(ctx context.Context, id string) error {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM places WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete place: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- images ---
|
||||
|
||||
func (s *SQLStore) CreateImage(ctx context.Context, img domain.Image) (domain.Image, error) {
|
||||
if img.ID == "" {
|
||||
img.ID = newID()
|
||||
}
|
||||
if img.AnalysisStatus == "" {
|
||||
img.AnalysisStatus = domain.AnalysisPending
|
||||
}
|
||||
if img.CreatedAt.IsZero() {
|
||||
img.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO images (id, place_id, storage_key, content_type, byte_size,
|
||||
width, height, caption, latitude, longitude, taken_at,
|
||||
camera_make, camera_model, analysis_status, analysis_error,
|
||||
created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
img.ID, img.PlaceID, img.StorageKey, img.ContentType, img.ByteSize,
|
||||
img.Width, img.Height, img.Caption,
|
||||
nullableFloat(img.Coordinate, func(c domain.Coordinate) float64 { return c.Latitude }),
|
||||
nullableFloat(img.Coordinate, func(c domain.Coordinate) float64 { return c.Longitude }),
|
||||
nullableTime(img.TakenAt),
|
||||
img.CameraMake, img.CameraModel, string(img.AnalysisStatus), img.AnalysisError,
|
||||
formatTime(img.CreatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return domain.Image{}, wrapWrite(err, "image")
|
||||
}
|
||||
|
||||
if len(img.Badges) > 0 {
|
||||
if err := s.ReplaceBadges(ctx, img.ID, img.Badges); err != nil {
|
||||
return domain.Image{}, err
|
||||
}
|
||||
}
|
||||
return s.GetImage(ctx, img.ID)
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetImage(ctx context.Context, id string) (domain.Image, error) {
|
||||
row := s.db.QueryRowContext(ctx, imageSelect+` WHERE i.id = ?`, id)
|
||||
img, err := scanImage(row)
|
||||
if err != nil {
|
||||
return domain.Image{}, err
|
||||
}
|
||||
badges, err := s.badgesFor(ctx, []string{id})
|
||||
if err != nil {
|
||||
return domain.Image{}, err
|
||||
}
|
||||
img.Badges = badges[id]
|
||||
return img, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetImageByStorageKey(ctx context.Context, key string) (domain.Image, error) {
|
||||
row := s.db.QueryRowContext(ctx, imageSelect+` WHERE i.storage_key = ? LIMIT 1`, key)
|
||||
img, err := scanImage(row)
|
||||
if err != nil {
|
||||
return domain.Image{}, err
|
||||
}
|
||||
badges, err := s.badgesFor(ctx, []string{img.ID})
|
||||
if err != nil {
|
||||
return domain.Image{}, err
|
||||
}
|
||||
img.Badges = badges[img.ID]
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// Ping verifies the database is reachable. It backs the readiness probe, which
|
||||
// reports the service as unavailable without restarting it.
|
||||
func (s *SQLStore) Ping(ctx context.Context) error { return s.db.PingContext(ctx) }
|
||||
|
||||
// PingContext is an alias for Ping kept for callers that name the method after
|
||||
// database/sql's own convention.
|
||||
func (s *SQLStore) PingContext(ctx context.Context) error { return s.db.PingContext(ctx) }
|
||||
|
||||
func (s *SQLStore) UpdateImage(ctx context.Context, img domain.Image) (domain.Image, error) {
|
||||
res, err := s.db.ExecContext(ctx, `
|
||||
UPDATE images
|
||||
SET caption = ?, latitude = ?, longitude = ?, taken_at = ?,
|
||||
camera_make = ?, camera_model = ?,
|
||||
analysis_status = ?, analysis_error = ?
|
||||
WHERE id = ?`,
|
||||
img.Caption,
|
||||
nullableFloat(img.Coordinate, func(c domain.Coordinate) float64 { return c.Latitude }),
|
||||
nullableFloat(img.Coordinate, func(c domain.Coordinate) float64 { return c.Longitude }),
|
||||
nullableTime(img.TakenAt),
|
||||
img.CameraMake, img.CameraModel,
|
||||
string(img.AnalysisStatus), img.AnalysisError, img.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.Image{}, wrapWrite(err, "image")
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return domain.Image{}, ErrNotFound
|
||||
}
|
||||
return s.GetImage(ctx, img.ID)
|
||||
}
|
||||
|
||||
func (s *SQLStore) ListImages(ctx context.Context, f ImageFilter) ([]domain.Image, error) {
|
||||
var (
|
||||
where []string
|
||||
args []any
|
||||
)
|
||||
if f.PlaceID != "" {
|
||||
where = append(where, "i.place_id = ?")
|
||||
args = append(args, f.PlaceID)
|
||||
}
|
||||
if f.CreatorID != "" {
|
||||
where = append(where, "p.creator_id = ?")
|
||||
args = append(args, f.CreatorID)
|
||||
}
|
||||
if f.Within != nil {
|
||||
// Pre-filter with the bounding box; the exact haversine test is applied
|
||||
// by the service layer afterwards.
|
||||
b := f.Within.BoundingBox()
|
||||
where = append(where,
|
||||
"i.latitude IS NOT NULL", "i.longitude IS NOT NULL",
|
||||
"i.latitude BETWEEN ? AND ?", "i.longitude BETWEEN ? AND ?")
|
||||
args = append(args, b.MinLat, b.MaxLat, b.MinLon, b.MaxLon)
|
||||
}
|
||||
|
||||
query := imageSelect
|
||||
if len(where) > 0 {
|
||||
query += " WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
query += " ORDER BY i.created_at DESC"
|
||||
if f.Limit > 0 {
|
||||
query += " LIMIT ?"
|
||||
args = append(args, f.Limit)
|
||||
if f.Offset > 0 {
|
||||
query += " OFFSET ?"
|
||||
args = append(args, f.Offset)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list images: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []domain.Image
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
img, err := scanImage(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, img)
|
||||
ids = append(ids, img.ID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
badges, err := s.badgesFor(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
out[i].Badges = badges[out[i].ID]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) ImagesWithin(ctx context.Context, q NearbyQuery) ([]domain.Image, error) {
|
||||
return s.ListImages(ctx, ImageFilter{Within: &q})
|
||||
}
|
||||
|
||||
func (s *SQLStore) ReplaceBadges(ctx context.Context, imageID string, badges []domain.Badge) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: begin badge transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM badges WHERE image_id = ?`, imageID); err != nil {
|
||||
return fmt.Errorf("store: clear badges: %w", err)
|
||||
}
|
||||
for _, b := range badges {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT OR REPLACE INTO badges (image_id, label, display, confidence, source)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
imageID, b.Label, b.Display, b.Confidence, b.Source,
|
||||
); err != nil {
|
||||
return fmt.Errorf("store: insert badge: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("store: commit badges: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) SetAnalysisState(ctx context.Context, imageID string, status domain.AnalysisStatus, failure string) error {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE images SET analysis_status = ?, analysis_error = ? WHERE id = ?`,
|
||||
string(status), failure, imageID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: set analysis state: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) DeleteImage(ctx context.Context, id string) error {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM images WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete image: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- image links ---
|
||||
|
||||
func (s *SQLStore) CreateImageLink(ctx context.Context, imageID, relatedImageID string, distanceMeters float64, reason string) error {
|
||||
if imageID == relatedImageID {
|
||||
return fmt.Errorf("%w: a picture cannot be linked to itself", ErrInvalidInput)
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO image_links (image_id, related_image_id, distance_meters, reason, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(image_id, related_image_id) DO UPDATE SET
|
||||
distance_meters = excluded.distance_meters,
|
||||
reason = excluded.reason`,
|
||||
imageID, relatedImageID, distanceMeters, reason, formatTime(time.Now().UTC()))
|
||||
if err != nil {
|
||||
return wrapWrite(err, "image link")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) ListImageLinks(ctx context.Context, imageID string) ([]domain.Link, error) {
|
||||
// The stored rows record the direction they were written in, so this query
|
||||
// flips the orientation for rows where the picture is the related end.
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT image_id, related_image_id, distance_meters, reason,
|
||||
CASE WHEN image_id = ? THEN 0 ELSE 1 END AS direction
|
||||
FROM image_links
|
||||
WHERE image_id = ? OR related_image_id = ?
|
||||
ORDER BY distance_meters ASC, related_image_id ASC`, imageID, imageID, imageID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list image links: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []domain.Link
|
||||
for rows.Next() {
|
||||
var (
|
||||
l domain.Link
|
||||
storedA string
|
||||
storedB string
|
||||
direction int
|
||||
)
|
||||
if err := rows.Scan(&storedA, &storedB, &l.DistanceMeters, &l.Reason, &direction); err != nil {
|
||||
return nil, fmt.Errorf("store: scan image link: %w", err)
|
||||
}
|
||||
if direction == 0 {
|
||||
l.ImageID, l.RelatedImageID = storedA, storedB
|
||||
} else {
|
||||
l.ImageID, l.RelatedImageID = storedB, storedA
|
||||
}
|
||||
l.SamePlace = l.Reason == "same-place"
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- comments ---
|
||||
|
||||
func (s *SQLStore) AddComment(ctx context.Context, c domain.Comment) (domain.Comment, error) {
|
||||
if c.ID == "" {
|
||||
c.ID = newID()
|
||||
}
|
||||
if c.CreatedAt.IsZero() {
|
||||
c.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO comments (id, place_id, content, creator_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
c.ID, c.PlaceID, c.Content, c.Creator.ID, formatTime(c.CreatedAt))
|
||||
if err != nil {
|
||||
return domain.Comment{}, wrapWrite(err, "comment")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) ListComments(ctx context.Context, placeID string) ([]domain.Comment, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT c.id, c.place_id, c.content, c.created_at,
|
||||
u.id, u.username, u.email
|
||||
FROM comments c
|
||||
JOIN users u ON u.id = c.creator_id
|
||||
WHERE c.place_id = ?
|
||||
ORDER BY c.created_at`, placeID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list comments: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []domain.Comment
|
||||
for rows.Next() {
|
||||
var (
|
||||
c domain.Comment
|
||||
created timeText
|
||||
)
|
||||
err := rows.Scan(&c.ID, &c.PlaceID, &c.Content, &created, &c.Creator.ID, &c.Creator.Username, &c.Creator.Email)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan comment: %w", err)
|
||||
}
|
||||
c.CreatedAt = created.Time
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- users ---
|
||||
|
||||
func (s *SQLStore) UpsertUser(ctx context.Context, u domain.User) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO users (id, username, email, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
username = CASE WHEN excluded.username = '' THEN users.username ELSE excluded.username END,
|
||||
email = CASE WHEN excluded.email = '' THEN users.email ELSE excluded.email END`,
|
||||
u.ID, u.Username, u.Email, formatTime(time.Now().UTC()))
|
||||
if err != nil {
|
||||
return wrapWrite(err, "user")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetUser(ctx context.Context, id string) (domain.User, error) {
|
||||
var u domain.User
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, username, email FROM users WHERE id = ?`, id).
|
||||
Scan(&u.ID, &u.Username, &u.Email)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.User{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.User{}, fmt.Errorf("store: get user: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- helpers ---
|
||||
|
||||
const imageSelect = `
|
||||
SELECT i.id, i.place_id, i.storage_key, i.content_type, i.byte_size,
|
||||
i.width, i.height, i.caption, i.latitude, i.longitude, i.taken_at,
|
||||
i.camera_make, i.camera_model, i.analysis_status, i.analysis_error,
|
||||
i.created_at, p.creator_id
|
||||
FROM images i
|
||||
JOIN places p ON p.id = i.place_id`
|
||||
|
||||
// scanner is satisfied by both *sql.Row and *sql.Rows.
|
||||
type scanner interface{ Scan(dest ...any) error }
|
||||
|
||||
func scanPlace(sc scanner) (domain.Place, error) {
|
||||
var (
|
||||
p domain.Place
|
||||
lat sql.NullFloat64
|
||||
lon sql.NullFloat64
|
||||
created timeText
|
||||
)
|
||||
err := sc.Scan(&p.ID, &p.Title, &p.Description, &lat, &lon,
|
||||
&p.LinkRadiusMeters, &created,
|
||||
&p.Creator.ID, &p.Creator.Username, &p.Creator.Email)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.Place{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.Place{}, fmt.Errorf("store: scan place: %w", err)
|
||||
}
|
||||
if lat.Valid && lon.Valid {
|
||||
p.Coordinate = &domain.Coordinate{Latitude: lat.Float64, Longitude: lon.Float64}
|
||||
}
|
||||
p.CreatedAt = created.Time
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func scanImage(sc scanner) (domain.Image, error) {
|
||||
var (
|
||||
img domain.Image
|
||||
lat sql.NullFloat64
|
||||
lon sql.NullFloat64
|
||||
takenAt nullTimeText
|
||||
createdAt timeText
|
||||
status string
|
||||
creatorID string
|
||||
)
|
||||
err := sc.Scan(&img.ID, &img.PlaceID, &img.StorageKey, &img.ContentType, &img.ByteSize,
|
||||
&img.Width, &img.Height, &img.Caption, &lat, &lon, &takenAt,
|
||||
&img.CameraMake, &img.CameraModel, &status, &img.AnalysisError, &createdAt, &creatorID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.Image{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.Image{}, fmt.Errorf("store: scan image: %w", err)
|
||||
}
|
||||
if lat.Valid && lon.Valid {
|
||||
img.Coordinate = &domain.Coordinate{Latitude: lat.Float64, Longitude: lon.Float64}
|
||||
}
|
||||
img.TakenAt = takenAt.Time
|
||||
img.CreatedAt = createdAt.Time
|
||||
img.AnalysisStatus = domain.AnalysisStatus(status)
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// badgesFor loads badges for many pictures in one round trip.
|
||||
func (s *SQLStore) badgesFor(ctx context.Context, imageIDs []string) (map[string][]domain.Badge, error) {
|
||||
out := map[string][]domain.Badge{}
|
||||
if len(imageIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(imageIDs)), ",")
|
||||
args := make([]any, len(imageIDs))
|
||||
for i, id := range imageIDs {
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT image_id, label, display, confidence, source
|
||||
FROM badges
|
||||
WHERE image_id IN (`+placeholders+`)
|
||||
ORDER BY confidence DESC, label ASC`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: load badges: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
imageID string
|
||||
b domain.Badge
|
||||
)
|
||||
if err := rows.Scan(&imageID, &b.Label, &b.Display, &b.Confidence, &b.Source); err != nil {
|
||||
return nil, fmt.Errorf("store: scan badge: %w", err)
|
||||
}
|
||||
out[imageID] = append(out[imageID], b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func newID() string { return uuid.NewString() }
|
||||
|
||||
func formatTime(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) }
|
||||
|
||||
func nullableFloat(c *domain.Coordinate, get func(domain.Coordinate) float64) any {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return get(*c)
|
||||
}
|
||||
|
||||
func nullableTime(t *time.Time) any {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return formatTime(*t)
|
||||
}
|
||||
|
||||
// timeText scans an RFC3339 timestamp column into a time.Time.
|
||||
type timeText struct{ Time time.Time }
|
||||
|
||||
// Scan implements sql.Scanner.
|
||||
func (t *timeText) Scan(src any) error {
|
||||
var s string
|
||||
switch v := src.(type) {
|
||||
case string:
|
||||
s = v
|
||||
case []byte:
|
||||
s = string(v)
|
||||
case time.Time:
|
||||
t.Time = v.UTC()
|
||||
return nil
|
||||
case nil:
|
||||
return errors.New("store: unexpected NULL timestamp")
|
||||
default:
|
||||
return fmt.Errorf("store: cannot scan %T into time", src)
|
||||
}
|
||||
// Try RFC3339Nano first, then date-only format YYYY-MM-DD
|
||||
parsedTime := time.Time{}
|
||||
if parsed, err := time.Parse(time.RFC3339Nano, s); err == nil {
|
||||
parsedTime = parsed
|
||||
} else if parsed, err := time.Parse("2006-01-02", s); err == nil {
|
||||
parsedTime = parsed
|
||||
} else {
|
||||
return fmt.Errorf("store: parse timestamp %q: %w", s, nil)
|
||||
}
|
||||
t.Time = parsedTime.UTC()
|
||||
return nil
|
||||
}
|
||||
|
||||
// nullTimeText scans a nullable RFC3339 timestamp column.
|
||||
type nullTimeText struct{ Time *time.Time }
|
||||
|
||||
// Scan implements sql.Scanner.
|
||||
func (t *nullTimeText) Scan(src any) error {
|
||||
if src == nil {
|
||||
t.Time = nil
|
||||
return nil
|
||||
}
|
||||
var inner timeText
|
||||
if err := inner.Scan(src); err != nil {
|
||||
return err
|
||||
}
|
||||
v := inner.Time
|
||||
t.Time = &v
|
||||
return nil
|
||||
}
|
||||
|
||||
// wrapWrite converts driver constraint failures into the package sentinel
|
||||
// errors so callers can branch on them with errors.Is.
|
||||
func wrapWrite(err error, entity string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
switch {
|
||||
case strings.Contains(msg, "unique constraint failed"),
|
||||
strings.Contains(msg, "constraint failed"):
|
||||
return fmt.Errorf("%w: duplicate %s: %v", ErrConflict, entity, err)
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return ErrNotFound
|
||||
default:
|
||||
return fmt.Errorf("store: write %s: %w", entity, err)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue