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)
|
||||
}
|
||||
}
|
||||
116
api/internal/store/store.go
Normal file
116
api/internal/store/store.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// Package store defines persistence for the domain entities. The interface
|
||||
// exists so that the query implementation can be swapped (a different SQL
|
||||
// dialect, or an in-memory fake in tests) without touching the service layer.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"go-places-api/internal/domain"
|
||||
"go-places-api/internal/geo"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNotFound is returned when a lookup matches no row.
|
||||
ErrNotFound = errors.New("not found")
|
||||
// ErrConflict is returned when a unique constraint would be violated.
|
||||
ErrConflict = errors.New("conflict")
|
||||
)
|
||||
|
||||
// ImageFilter narrows a picture search.
|
||||
type ImageFilter struct {
|
||||
// PlaceID restricts results to one place when non-empty.
|
||||
PlaceID string
|
||||
// CreatorID restricts results to one author when non-empty.
|
||||
CreatorID string
|
||||
// Within restricts results to pictures inside this box and radius. A nil
|
||||
// Coordinate disables the geographic restriction.
|
||||
Within *NearbyQuery
|
||||
// Limit caps the number of rows. Zero means unlimited.
|
||||
Limit int
|
||||
// Offset skips rows for pagination.
|
||||
Offset int
|
||||
}
|
||||
|
||||
// NearbyQuery selects entities within a radius of a point.
|
||||
type NearbyQuery struct {
|
||||
Center domain.Coordinate
|
||||
RadiusMeters float64
|
||||
}
|
||||
|
||||
// Store is the persistence contract required by the service layer.
|
||||
type Store interface {
|
||||
// CreatePlace inserts a place and returns it as persisted.
|
||||
CreatePlace(ctx context.Context, p domain.Place) (domain.Place, error)
|
||||
// GetPlace loads a place, its pictures, comments and author.
|
||||
GetPlace(ctx context.Context, id string) (domain.Place, error)
|
||||
// UpdatePlace replaces the mutable fields of a place.
|
||||
UpdatePlace(ctx context.Context, p domain.Place) (domain.Place, error)
|
||||
// ListPlaces returns places newest first.
|
||||
ListPlaces(ctx context.Context, limit, offset int) ([]domain.Place, error)
|
||||
// DeletePlace removes a place and everything attached to it.
|
||||
DeletePlace(ctx context.Context, id string) error
|
||||
|
||||
// CreateImage inserts a picture record.
|
||||
CreateImage(ctx context.Context, img domain.Image) (domain.Image, error)
|
||||
// GetImage loads one picture.
|
||||
GetImage(ctx context.Context, id string) (domain.Image, error)
|
||||
// GetImageByStorageKey loads the picture a blob key belongs to. The media
|
||||
// route needs it to learn the declared content type and size of a stored
|
||||
// object without trusting the file's own bytes.
|
||||
GetImageByStorageKey(ctx context.Context, key string) (domain.Image, error)
|
||||
// UpdateImage replaces the mutable fields of a picture.
|
||||
UpdateImage(ctx context.Context, img domain.Image) (domain.Image, error)
|
||||
// ListImages returns pictures matching the filter, newest first.
|
||||
ListImages(ctx context.Context, f ImageFilter) ([]domain.Image, error)
|
||||
// ImagesWithin returns pictures whose capture point lies inside the radius.
|
||||
ImagesWithin(ctx context.Context, q NearbyQuery) ([]domain.Image, error)
|
||||
// ReplaceBadges swaps the badge set for a picture.
|
||||
ReplaceBadges(ctx context.Context, imageID string, badges []domain.Badge) error
|
||||
// CreateImageLink records that two pictures were taken at the same place.
|
||||
// Re-recording the same pair updates the measured distance.
|
||||
CreateImageLink(ctx context.Context, imageID, relatedImageID string, distanceMeters float64, reason string) error
|
||||
// ListImageLinks returns the recorded links for a picture in both
|
||||
// directions, so a link is visible from either end.
|
||||
ListImageLinks(ctx context.Context, imageID string) ([]domain.Link, error)
|
||||
// SetAnalysisState records the outcome of the analysis pipeline.
|
||||
SetAnalysisState(ctx context.Context, imageID string, status domain.AnalysisStatus, failure string) error
|
||||
// DeleteImage removes a picture and its badges.
|
||||
DeleteImage(ctx context.Context, id string) error
|
||||
|
||||
// AddComment inserts a comment and returns it as persisted.
|
||||
AddComment(ctx context.Context, c domain.Comment) (domain.Comment, error)
|
||||
// ListComments returns the comments on a place, oldest first.
|
||||
ListComments(ctx context.Context, placeID string) ([]domain.Comment, error)
|
||||
|
||||
// UpsertUser stores the identity asserted by the auth layer.
|
||||
UpsertUser(ctx context.Context, u domain.User) error
|
||||
// GetUser loads a user by identifier.
|
||||
GetUser(ctx context.Context, id string) (domain.User, error)
|
||||
|
||||
// Close releases resources held by the store.
|
||||
Close() error
|
||||
}
|
||||
|
||||
// BoundingBox converts a nearby query into the coarse window used to pre-filter
|
||||
// rows in SQL. Exposed here so implementations and tests agree on the filter.
|
||||
func (q NearbyQuery) BoundingBox() geo.BoundingBox {
|
||||
return geo.Around(q.Center, q.RadiusMeters)
|
||||
}
|
||||
|
||||
// ErrInvalidInput is returned when a write would violate a domain rule, such as
|
||||
// linking a picture to itself.
|
||||
var ErrInvalidInput = errors.New("invalid input")
|
||||
|
||||
// ImageGetter is the optional extra surface the service uses for storage-key
|
||||
// lookups. It is separate from Store so an implementation that cannot map a key
|
||||
// back to a record simply does not satisfy it.
|
||||
type ImageGetter interface {
|
||||
GetImageByStorageKey(ctx context.Context, key string) (domain.Image, error)
|
||||
}
|
||||
|
||||
// Pinger is the optional extra surface used by the readiness probe.
|
||||
type Pinger interface {
|
||||
Ping(ctx context.Context) error
|
||||
}
|
||||
667
api/internal/store/store_test.go
Normal file
667
api/internal/store/store_test.go
Normal file
|
|
@ -0,0 +1,667 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-places-api/internal/domain"
|
||||
)
|
||||
|
||||
// newTestStore opens a throwaway SQLite database. The foreign_keys pragma is
|
||||
// requested explicitly because SQLite ignores declared constraints unless it is
|
||||
// enabled per connection.
|
||||
func newTestStore(t *testing.T) *SQLStore {
|
||||
t.Helper()
|
||||
|
||||
dsn := "file:" + filepath.Join(t.TempDir(), "go-places.db") +
|
||||
"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)"
|
||||
s, err := OpenSQL(dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQL: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
// seedUser inserts the author every place and comment must reference.
|
||||
func seedUser(t *testing.T, s *SQLStore, id string) domain.User {
|
||||
t.Helper()
|
||||
|
||||
u := domain.User{ID: id, Username: "ada-" + id, Email: id + "@example.com"}
|
||||
if err := s.UpsertUser(context.Background(), u); err != nil {
|
||||
t.Fatalf("UpsertUser: %v", err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// seedPlace creates a place at an optional coordinate.
|
||||
func seedPlace(t *testing.T, s *SQLStore, creator, title string, coord *domain.Coordinate) domain.Place {
|
||||
t.Helper()
|
||||
|
||||
p, err := s.CreatePlace(context.Background(), domain.Place{
|
||||
Title: title,
|
||||
Description: "notes for " + title,
|
||||
Coordinate: coord,
|
||||
LinkRadiusMeters: 150,
|
||||
Creator: domain.User{ID: creator},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePlace(%q): %v", title, err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// seedImage creates a picture belonging to a place.
|
||||
func seedImage(t *testing.T, s *SQLStore, placeID, key string, coord *domain.Coordinate) domain.Image {
|
||||
t.Helper()
|
||||
|
||||
img, err := s.CreateImage(context.Background(), domain.Image{
|
||||
PlaceID: placeID,
|
||||
StorageKey: key,
|
||||
ContentType: "image/jpeg",
|
||||
ByteSize: 2048,
|
||||
Coordinate: coord,
|
||||
AnalysisStatus: domain.AnalysisPending,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateImage(%q): %v", key, err)
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func coord(lat, lon float64) *domain.Coordinate {
|
||||
return &domain.Coordinate{Latitude: lat, Longitude: lon}
|
||||
}
|
||||
|
||||
func TestPlaceRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
user := seedUser(t, s, "user-1")
|
||||
|
||||
created := seedPlace(t, s, user.ID, "Montmartre", coord(48.8867, 2.3431))
|
||||
if created.ID == "" {
|
||||
t.Error("CreatePlace did not assign an ID")
|
||||
}
|
||||
if created.CreatedAt.IsZero() {
|
||||
t.Error("CreatedAt was not set")
|
||||
}
|
||||
|
||||
got, err := s.GetPlace(ctx, created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPlace: %v", err)
|
||||
}
|
||||
if got.Title != "Montmartre" || got.Description != "notes for Montmartre" {
|
||||
t.Errorf("round trip lost fields: %+v", got)
|
||||
}
|
||||
if got.Coordinate == nil {
|
||||
t.Fatal("Coordinate was not persisted")
|
||||
}
|
||||
if d := got.Coordinate.Latitude - 48.8867; d > 1e-9 || d < -1e-9 {
|
||||
t.Errorf("Latitude = %v, want 48.8867", got.Coordinate.Latitude)
|
||||
}
|
||||
if got.LinkRadiusMeters != 150 {
|
||||
t.Errorf("LinkRadiusMeters = %v, want 150", got.LinkRadiusMeters)
|
||||
}
|
||||
if got.Creator.ID != user.ID {
|
||||
t.Errorf("Creator.ID = %q, want %q", got.Creator.ID, user.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceWithoutCoordinateStaysNil(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// (0,0) is a legitimate coordinate, so "unset" must be represented by nil
|
||||
// rather than by zero values. Check the round trip does not invent a point.
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
user := seedUser(t, s, "user-nocoord")
|
||||
|
||||
created := seedPlace(t, s, user.ID, "Unknown Spot", nil)
|
||||
got, err := s.GetPlace(ctx, created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPlace: %v", err)
|
||||
}
|
||||
if got.Coordinate != nil {
|
||||
t.Errorf("Coordinate = %+v, want nil", got.Coordinate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPlaceMissingReturnsErrNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestStore(t)
|
||||
if _, err := s.GetPlace(context.Background(), "does-not-exist"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePlaceReplacesMutableFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
user := seedUser(t, s, "user-2")
|
||||
created := seedPlace(t, s, user.ID, "Old Title", coord(10, 10))
|
||||
|
||||
created.Title = "New Title"
|
||||
created.Description = "rewritten"
|
||||
created.LinkRadiusMeters = 42
|
||||
updated, err := s.UpdatePlace(ctx, created)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdatePlace: %v", err)
|
||||
}
|
||||
if updated.Title != "New Title" || updated.LinkRadiusMeters != 42 {
|
||||
t.Errorf("UpdatePlace returned %+v", updated)
|
||||
}
|
||||
|
||||
got, err := s.GetPlace(ctx, created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPlace: %v", err)
|
||||
}
|
||||
if got.Title != "New Title" || got.Description != "rewritten" || got.LinkRadiusMeters != 42 {
|
||||
t.Errorf("stored value did not change: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPlacesIsNewestFirstAndPaginates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
user := seedUser(t, s, "user-3")
|
||||
|
||||
// CreatedAt is stamped by the store, so insert in sequence and rely on the
|
||||
// sub-millisecond difference the store writes.
|
||||
const n = 3
|
||||
for i := range n {
|
||||
seedPlace(t, s, user.ID, "place-"+string(rune('a'+i)), nil)
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
|
||||
all, err := s.ListPlaces(ctx, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPlaces: %v", err)
|
||||
}
|
||||
if len(all) != n {
|
||||
t.Fatalf("ListPlaces returned %d places, want %d", len(all), n)
|
||||
}
|
||||
for i := 1; i < len(all); i++ {
|
||||
if all[i-1].CreatedAt.Before(all[i].CreatedAt) {
|
||||
t.Errorf("results are not newest first: %v then %v", all[i-1].CreatedAt, all[i].CreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
page, err := s.ListPlaces(ctx, 1, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPlaces(limit=1): %v", err)
|
||||
}
|
||||
if len(page) != 1 {
|
||||
t.Errorf("limit=1 returned %d places", len(page))
|
||||
}
|
||||
if page[0].ID != all[0].ID {
|
||||
t.Errorf("first page = %q, want the newest %q", page[0].ID, all[0].ID)
|
||||
}
|
||||
|
||||
second, err := s.ListPlaces(ctx, 1, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPlaces(limit=1,offset=1): %v", err)
|
||||
}
|
||||
if len(second) != 1 || second[0].ID != all[1].ID {
|
||||
t.Errorf("offset did not skip one row: %+v", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePlaceCascadesToImagesCommentsAndLinks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The schema relies on ON DELETE CASCADE. If foreign keys are not enabled the
|
||||
// rows survive, which would leak every attachment of a deleted place.
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
user := seedUser(t, s, "user-4")
|
||||
place := seedPlace(t, s, user.ID, "Doomed", coord(1, 1))
|
||||
other := seedPlace(t, s, user.ID, "Survivor", coord(1, 1))
|
||||
|
||||
a := seedImage(t, s, place.ID, "a.jpg", coord(1, 1))
|
||||
b := seedImage(t, s, other.ID, "b.jpg", coord(1, 1))
|
||||
|
||||
if _, err := s.AddComment(ctx, domain.Comment{
|
||||
PlaceID: place.ID, Content: "nice", Creator: domain.User{ID: user.ID},
|
||||
}); err != nil {
|
||||
t.Fatalf("AddComment: %v", err)
|
||||
}
|
||||
if err := s.CreateImageLink(ctx, a.ID, b.ID, 3.5, "same-place"); err != nil {
|
||||
t.Fatalf("CreateImageLink: %v", err)
|
||||
}
|
||||
|
||||
if err := s.DeletePlace(ctx, place.ID); err != nil {
|
||||
t.Fatalf("DeletePlace: %v", err)
|
||||
}
|
||||
|
||||
if _, err := s.GetPlace(ctx, place.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("place still readable: err = %v", err)
|
||||
}
|
||||
if _, err := s.GetImage(ctx, a.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("image survived the cascade: err = %v", err)
|
||||
}
|
||||
comments, err := s.ListComments(ctx, place.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListComments: %v", err)
|
||||
}
|
||||
if len(comments) != 0 {
|
||||
t.Errorf("%d comments survived the cascade", len(comments))
|
||||
}
|
||||
links, err := s.ListImageLinks(ctx, b.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListImageLinks: %v", err)
|
||||
}
|
||||
if len(links) != 0 {
|
||||
t.Errorf("%d links survived the cascade", len(links))
|
||||
}
|
||||
|
||||
// The unrelated picture must be untouched.
|
||||
if _, err := s.GetImage(ctx, b.ID); err != nil {
|
||||
t.Errorf("unrelated image was removed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageCarriesMetadataAndBadges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
user := seedUser(t, s, "user-5")
|
||||
place := seedPlace(t, s, user.ID, "With Photo", coord(48, 2))
|
||||
|
||||
taken := time.Date(2026, 6, 1, 14, 23, 45, 0, time.UTC)
|
||||
img, err := s.CreateImage(ctx, domain.Image{
|
||||
PlaceID: place.ID,
|
||||
StorageKey: "photos/eiffel.jpg",
|
||||
ContentType: "image/jpeg",
|
||||
ByteSize: 4096,
|
||||
Width: 1920,
|
||||
Height: 1080,
|
||||
Caption: "the view",
|
||||
Coordinate: coord(48.8584, 2.2945),
|
||||
TakenAt: &taken,
|
||||
CameraMake: "Canon",
|
||||
CameraModel: "EOS R6",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateImage: %v", err)
|
||||
}
|
||||
if img.AnalysisStatus != domain.AnalysisPending {
|
||||
t.Errorf("AnalysisStatus = %q, want %q", img.AnalysisStatus, domain.AnalysisPending)
|
||||
}
|
||||
|
||||
if err := s.ReplaceBadges(ctx, img.ID, []domain.Badge{
|
||||
{Label: "sky", Display: "Sky", Confidence: 0.91, Source: "local"},
|
||||
{Label: "water", Display: "Water", Confidence: 0.42, Source: "local"},
|
||||
}); err != nil {
|
||||
t.Fatalf("ReplaceBadges: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.GetImage(ctx, img.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetImage: %v", err)
|
||||
}
|
||||
if got.Caption != "the view" || got.Width != 1920 || got.Height != 1080 {
|
||||
t.Errorf("image fields lost: %+v", got)
|
||||
}
|
||||
if got.TakenAt == nil || !got.TakenAt.Equal(taken) {
|
||||
t.Errorf("TakenAt = %v, want %v", got.TakenAt, taken)
|
||||
}
|
||||
if got.CameraMake != "Canon" || got.CameraModel != "EOS R6" {
|
||||
t.Errorf("camera = %q/%q, want Canon/EOS R6", got.CameraMake, got.CameraModel)
|
||||
}
|
||||
if len(got.Badges) != 2 {
|
||||
t.Fatalf("got %d badges, want 2", len(got.Badges))
|
||||
}
|
||||
// Badges come back keyed by label so callers can look one up.
|
||||
byLabel := map[string]domain.Badge{}
|
||||
for _, b := range got.Badges {
|
||||
byLabel[b.Label] = b
|
||||
}
|
||||
if sky := byLabel["sky"]; sky.Display != "Sky" || sky.Source != "local" {
|
||||
t.Errorf("sky badge = %+v", sky)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceBadgesIsIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
user := seedUser(t, s, "user-6")
|
||||
place := seedPlace(t, s, user.ID, "Repeated Analysis", nil)
|
||||
img := seedImage(t, s, place.ID, "r.jpg", nil)
|
||||
|
||||
for range 2 {
|
||||
if err := s.ReplaceBadges(ctx, img.ID, []domain.Badge{
|
||||
{Label: "sky", Display: "Sky", Confidence: 0.5, Source: "local"},
|
||||
}); err != nil {
|
||||
t.Fatalf("ReplaceBadges: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := s.GetImage(ctx, img.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetImage: %v", err)
|
||||
}
|
||||
if len(got.Badges) != 1 {
|
||||
t.Errorf("ReplaceBadges duplicated rows: got %d badges", len(got.Badges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAnalysisStateRecordsFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
user := seedUser(t, s, "user-7")
|
||||
place := seedPlace(t, s, user.ID, "Failing", nil)
|
||||
img := seedImage(t, s, place.ID, "f.jpg", nil)
|
||||
|
||||
if err := s.SetAnalysisState(ctx, img.ID, domain.AnalysisFailed, "decode failed"); err != nil {
|
||||
t.Fatalf("SetAnalysisState: %v", err)
|
||||
}
|
||||
got, err := s.GetImage(ctx, img.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetImage: %v", err)
|
||||
}
|
||||
if got.AnalysisStatus != domain.AnalysisFailed {
|
||||
t.Errorf("AnalysisStatus = %q, want %q", got.AnalysisStatus, domain.AnalysisFailed)
|
||||
}
|
||||
if got.AnalysisError != "decode failed" {
|
||||
t.Errorf("AnalysisError = %q", got.AnalysisError)
|
||||
}
|
||||
|
||||
// A later successful run must clear the stale error, otherwise a recovered
|
||||
// picture keeps reporting why it failed.
|
||||
if err := s.SetAnalysisState(ctx, img.ID, domain.AnalysisCompleted, ""); err != nil {
|
||||
t.Fatalf("SetAnalysisState(completed): %v", err)
|
||||
}
|
||||
got, err = s.GetImage(ctx, img.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetImage: %v", err)
|
||||
}
|
||||
if got.AnalysisStatus != domain.AnalysisCompleted || got.AnalysisError != "" {
|
||||
t.Errorf("state = %q/%q, want completed with no error", got.AnalysisStatus, got.AnalysisError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetImageByStorageKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The media route uses this to serve a blob with the recorded content type
|
||||
// and size instead of trusting the bytes on disk.
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
user := seedUser(t, s, "user-8")
|
||||
place := seedPlace(t, s, user.ID, "Keyed", nil)
|
||||
want := seedImage(t, s, place.ID, "media/2026/photo.webp", nil)
|
||||
|
||||
got, err := s.GetImageByStorageKey(ctx, "media/2026/photo.webp")
|
||||
if err != nil {
|
||||
t.Fatalf("GetImageByStorageKey: %v", err)
|
||||
}
|
||||
if got.ID != want.ID {
|
||||
t.Errorf("got image %q, want %q", got.ID, want.ID)
|
||||
}
|
||||
if got.ContentType != "image/webp" && got.ContentType != "image/jpeg" {
|
||||
t.Errorf("ContentType = %q", got.ContentType)
|
||||
}
|
||||
if _, err := s.GetImageByStorageKey(ctx, "media/nope.jpg"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageLinkIsVisibleFromBothEnds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
user := seedUser(t, s, "user-9")
|
||||
place := seedPlace(t, s, user.ID, "Linked", coord(48.85, 2.29))
|
||||
a := seedImage(t, s, place.ID, "a.jpg", coord(48.8566, 2.2943))
|
||||
b := seedImage(t, s, place.ID, "b.jpg", coord(48.8570, 2.2950))
|
||||
|
||||
if err := s.CreateImageLink(ctx, a.ID, b.ID, 63.2, "same-place"); err != nil {
|
||||
t.Fatalf("CreateImageLink: %v", err)
|
||||
}
|
||||
|
||||
fromA, err := s.ListImageLinks(ctx, a.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListImageLinks(a): %v", err)
|
||||
}
|
||||
if len(fromA) != 1 {
|
||||
t.Fatalf("from a: got %d links, want 1", len(fromA))
|
||||
}
|
||||
if fromA[0].RelatedImageID != b.ID {
|
||||
t.Errorf("from a: related = %q, want %q", fromA[0].RelatedImageID, b.ID)
|
||||
}
|
||||
if fromA[0].DistanceMeters != 63.2 {
|
||||
t.Errorf("DistanceMeters = %v, want 63.2", fromA[0].DistanceMeters)
|
||||
}
|
||||
if fromA[0].Reason != "same-place" {
|
||||
t.Errorf("Reason = %q, want same-place", fromA[0].Reason)
|
||||
}
|
||||
|
||||
fromB, err := s.ListImageLinks(ctx, b.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListImageLinks(b): %v", err)
|
||||
}
|
||||
if len(fromB) != 1 || fromB[0].RelatedImageID != a.ID {
|
||||
t.Errorf("the link is not visible from b: %+v", fromB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateImageLinkUpdatesDistanceAndRejectsSelfLink(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
user := seedUser(t, s, "user-10")
|
||||
place := seedPlace(t, s, user.ID, "Relinked", nil)
|
||||
a := seedImage(t, s, place.ID, "a.jpg", nil)
|
||||
b := seedImage(t, s, place.ID, "b.jpg", nil)
|
||||
|
||||
if err := s.CreateImageLink(ctx, a.ID, b.ID, 10, "within-radius"); err != nil {
|
||||
t.Fatalf("CreateImageLink: %v", err)
|
||||
}
|
||||
// Re-running the linking pass must not create a duplicate edge.
|
||||
if err := s.CreateImageLink(ctx, a.ID, b.ID, 12.5, "within-radius"); err != nil {
|
||||
t.Fatalf("CreateImageLink again: %v", err)
|
||||
}
|
||||
links, err := s.ListImageLinks(ctx, a.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListImageLinks: %v", err)
|
||||
}
|
||||
if len(links) != 1 {
|
||||
t.Fatalf("got %d links, want 1", len(links))
|
||||
}
|
||||
if links[0].DistanceMeters != 12.5 {
|
||||
t.Errorf("DistanceMeters = %v, want the updated 12.5", links[0].DistanceMeters)
|
||||
}
|
||||
|
||||
if err := s.CreateImageLink(ctx, a.ID, a.ID, 0, "same-place"); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("self link err = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentsAreOldestFirstWithAuthor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
user := seedUser(t, s, "user-11")
|
||||
place := seedPlace(t, s, user.ID, "Talked About", nil)
|
||||
|
||||
for i, content := range []string{"first", "second", "third"} {
|
||||
if _, err := s.AddComment(ctx, domain.Comment{
|
||||
PlaceID: place.ID, Content: content, Creator: domain.User{ID: user.ID},
|
||||
}); err != nil {
|
||||
t.Fatalf("AddComment(%d): %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := s.ListComments(ctx, place.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListComments: %v", err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %d comments, want 3", len(got))
|
||||
}
|
||||
want := []string{"first", "second", "third"}
|
||||
for i, c := range got {
|
||||
if c.Content != want[i] {
|
||||
t.Errorf("comment %d = %q, want %q", i, c.Content, want[i])
|
||||
}
|
||||
if c.ID == "" {
|
||||
t.Errorf("comment %d has no ID", i)
|
||||
}
|
||||
if c.Creator.ID != user.ID {
|
||||
t.Errorf("comment %d creator = %q, want %q", i, c.Creator.ID, user.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddCommentRejectsUnknownAuthor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A comment must not be able to invent an author that does not exist.
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
user := seedUser(t, s, "user-12")
|
||||
place := seedPlace(t, s, user.ID, "Guarded", nil)
|
||||
|
||||
_, err := s.AddComment(ctx, domain.Comment{
|
||||
PlaceID: place.ID, Content: "ghost", Creator: domain.User{ID: "nobody"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("AddComment accepted an unknown author")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListImagesFiltersByPlaceCreatorAndBox(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
ada := seedUser(t, s, "ada")
|
||||
bo := seedUser(t, s, "bo")
|
||||
|
||||
eiffel := seedPlace(t, s, ada.ID, "Eiffel", coord(48.8584, 2.2945))
|
||||
louvre := seedPlace(t, s, bo.ID, "Louvre", coord(48.8606, 2.3376))
|
||||
|
||||
near := seedImage(t, s, eiffel.ID, "near.jpg", coord(48.8585, 2.2946))
|
||||
// Roughly 3 km away: outside a 150 m box but inside a 5 km one.
|
||||
far := seedImage(t, s, louvre.ID, "far.jpg", coord(48.8730, 2.3320))
|
||||
// No coordinate at all: geographic filters must skip it rather than fail.
|
||||
noCoord := seedImage(t, s, eiffel.ID, "nolat.jpg", nil)
|
||||
|
||||
byPlace, err := s.ListImages(ctx, ImageFilter{PlaceID: eiffel.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("ListImages(place): %v", err)
|
||||
}
|
||||
if len(byPlace) != 2 {
|
||||
t.Errorf("place filter returned %d images, want 2", len(byPlace))
|
||||
}
|
||||
|
||||
byCreator, err := s.ListImages(ctx, ImageFilter{CreatorID: ada.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("ListImages(creator): %v", err)
|
||||
}
|
||||
if len(byCreator) != 2 {
|
||||
t.Errorf("creator filter returned %d images, want 2", len(byCreator))
|
||||
}
|
||||
|
||||
// Creator and place filters combine.
|
||||
both, err := s.ListImages(ctx, ImageFilter{PlaceID: eiffel.ID, CreatorID: bo.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("ListImages(place+creator): %v", err)
|
||||
}
|
||||
if len(both) != 0 {
|
||||
t.Errorf("combined filter returned %d images, want 0", len(both))
|
||||
}
|
||||
|
||||
tight := NearbyQuery{Center: domain.Coordinate{Latitude: 48.8584, Longitude: 2.2945}, RadiusMeters: 150}
|
||||
withinTight, err := s.ListImages(ctx, ImageFilter{Within: &tight})
|
||||
if err != nil {
|
||||
t.Fatalf("ListImages(within 150m): %v", err)
|
||||
}
|
||||
if len(withinTight) != 1 || withinTight[0].ID != near.ID {
|
||||
t.Errorf("150 m box returned %d images, want just the nearby one", len(withinTight))
|
||||
}
|
||||
for _, got := range withinTight {
|
||||
if got.ID == far.ID {
|
||||
t.Error("the 3 km image was inside the 150 m box")
|
||||
}
|
||||
}
|
||||
|
||||
wide := NearbyQuery{Center: domain.Coordinate{Latitude: 48.8584, Longitude: 2.2945}, RadiusMeters: 5000}
|
||||
withinWide, err := s.ListImages(ctx, ImageFilter{Within: &wide})
|
||||
if err != nil {
|
||||
t.Fatalf("ListImages(within 5km): %v", err)
|
||||
}
|
||||
if len(withinWide) != 2 {
|
||||
t.Errorf("5 km box returned %d images, want 2", len(withinWide))
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for _, got := range withinWide {
|
||||
ids[got.ID] = true
|
||||
}
|
||||
if !ids[near.ID] || !ids[far.ID] {
|
||||
t.Error("5 km box should contain both the near and the far image")
|
||||
}
|
||||
if ids[noCoord.ID] {
|
||||
t.Error("a picture without a coordinate must never match a geographic filter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertUserIsIdempotentAndGetUserReadsBack(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
first := domain.User{ID: "u1", Username: "ada", Email: "ada@example.com"}
|
||||
if err := s.UpsertUser(ctx, first); err != nil {
|
||||
t.Fatalf("UpsertUser: %v", err)
|
||||
}
|
||||
// The auth layer re-asserts identity on every request, so a second call must
|
||||
// update rather than fail.
|
||||
second := domain.User{ID: "u1", Username: "ada-lovelace", Email: "ada@example.com"}
|
||||
if err := s.UpsertUser(ctx, second); err != nil {
|
||||
t.Fatalf("UpsertUser again: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.GetUser(ctx, "u1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser: %v", err)
|
||||
}
|
||||
if got.Username != "ada-lovelace" {
|
||||
t.Errorf("Username = %q, want the updated value", got.Username)
|
||||
}
|
||||
|
||||
if _, err := s.GetUser(ctx, "missing"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPingSucceedsOnOpenStore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestStore(t)
|
||||
if err := s.Ping(context.Background()); err != nil {
|
||||
t.Errorf("Ping: %v", err)
|
||||
}
|
||||
if err := s.PingContext(context.Background()); err != nil {
|
||||
t.Errorf("PingContext: %v", err)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue