b93e14ae88f05c6e01d25ab41d2ecb7456d769b3 / internal/db/releases.go · 5429 bytes · raw
package db
import (
"database/sql"
"errors"
"fmt"
)
// ErrReleaseExists marks a tag that already has a release in the repo.
var ErrReleaseExists = errors.New("release already exists for tag")
// Release is a row in the releases table: a title and markdown notes
// attached to a tag that already exists in the repository.
type Release struct {
ID int64
RepoID int64
Tag string
Title string
Notes string
AuthorID sql.NullInt64
CreatedAt int64
}
// ReleaseAsset is a file uploaded to a release. StoredName is the opaque
// name on disk; Filename is the name shown to users.
type ReleaseAsset struct {
ID int64
ReleaseID int64
Filename string
StoredName string
Size int64
CreatedAt int64
}
const releaseColumns = `SELECT id, repo_id, tag, title, notes, author_id, created_at FROM releases WHERE `
// CreateRelease stores a release for repoID's tag. A tag may have at most
// one release (UNIQUE(repo_id, tag)); a duplicate returns ErrReleaseExists.
func CreateRelease(database *sql.DB, repoID int64, tag, title, notes string, authorID int64) (Release, error) {
id, err := execLastID(database,
`INSERT INTO releases (repo_id, tag, title, notes, author_id) VALUES (?, ?, ?, ?, ?)`,
repoID, tag, title, notes, nullableID(authorID))
if err != nil {
if isUniqueViolation(err) {
return Release{}, fmt.Errorf("create release %s: %w", tag, ErrReleaseExists)
}
return Release{}, fmt.Errorf("create release: %w", err)
}
return GetReleaseByID(database, id)
}
// GetReleaseByID looks up a release by primary key.
func GetReleaseByID(database *sql.DB, id int64) (Release, error) {
return getRelease(database, releaseColumns+`id = ?`, id)
}
// GetReleaseByTag looks up a repo's release by tag.
func GetReleaseByTag(database *sql.DB, repoID int64, tag string) (Release, error) {
return getRelease(database, releaseColumns+`repo_id = ? AND tag = ?`, repoID, tag)
}
// scanRelease reads one releases row (see releaseColumns) into r.
func scanRelease(s rowScanner, r *Release) error {
return s.Scan(&r.ID, &r.RepoID, &r.Tag, &r.Title, &r.Notes, &r.AuthorID, &r.CreatedAt)
}
func getRelease(database *sql.DB, query string, args ...any) (Release, error) {
var r Release
err := scanRelease(database.QueryRow(query, args...), &r)
if errors.Is(err, sql.ErrNoRows) {
return Release{}, fmt.Errorf("release: %w", ErrNotFound)
}
if err != nil {
return Release{}, fmt.Errorf("get release: %w", err)
}
return r, nil
}
// ListReleases returns a repo's releases, newest first.
func ListReleases(database *sql.DB, repoID int64) ([]Release, error) {
releases, err := listQuery(database,
releaseColumns+`repo_id = ? ORDER BY created_at DESC, id DESC`, []any{repoID}, scanRelease)
if err != nil {
return nil, fmt.Errorf("list releases: %w", err)
}
return releases, nil
}
// DeleteRelease removes a release, scoped to its repo; its assets cascade.
func DeleteRelease(database *sql.DB, repoID, id int64) error {
return execScoped(database, "delete release",
`DELETE FROM releases WHERE id = ? AND repo_id = ?`, id, repoID)
}
const releaseAssetColumns = `SELECT id, release_id, filename, stored_name, size, created_at FROM release_assets WHERE `
// CreateReleaseAsset records an uploaded file on a release.
func CreateReleaseAsset(database *sql.DB, releaseID int64, filename, storedName string, size int64) (ReleaseAsset, error) {
id, err := execLastID(database,
`INSERT INTO release_assets (release_id, filename, stored_name, size) VALUES (?, ?, ?, ?)`,
releaseID, filename, storedName, size)
if err != nil {
return ReleaseAsset{}, fmt.Errorf("create release asset: %w", err)
}
return getReleaseAsset(database, releaseAssetColumns+`id = ?`, id)
}
// scanReleaseAsset reads one release_assets row into a.
func scanReleaseAsset(s rowScanner, a *ReleaseAsset) error {
return s.Scan(&a.ID, &a.ReleaseID, &a.Filename, &a.StoredName, &a.Size, &a.CreatedAt)
}
func getReleaseAsset(database *sql.DB, query string, args ...any) (ReleaseAsset, error) {
var a ReleaseAsset
err := scanReleaseAsset(database.QueryRow(query, args...), &a)
if errors.Is(err, sql.ErrNoRows) {
return ReleaseAsset{}, fmt.Errorf("release asset: %w", ErrNotFound)
}
if err != nil {
return ReleaseAsset{}, fmt.Errorf("get release asset: %w", err)
}
return a, nil
}
// ListReleaseAssets returns a release's assets oldest first.
func ListReleaseAssets(database *sql.DB, releaseID int64) ([]ReleaseAsset, error) {
assets, err := listQuery(database,
releaseAssetColumns+`release_id = ? ORDER BY created_at, id`, []any{releaseID}, scanReleaseAsset)
if err != nil {
return nil, fmt.Errorf("list release assets: %w", err)
}
return assets, nil
}
// GetReleaseAssetForRepo looks up an asset scoped to a repo, so a download
// cannot reach another repo's release by guessing an id.
func GetReleaseAssetForRepo(database *sql.DB, repoID, id int64) (ReleaseAsset, error) {
return getReleaseAsset(database,
`SELECT a.id, a.release_id, a.filename, a.stored_name, a.size, a.created_at
FROM release_assets a JOIN releases r ON r.id = a.release_id
WHERE a.id = ? AND r.repo_id = ?`, id, repoID)
}
// DeleteReleaseAsset removes an asset, scoped to its release.
func DeleteReleaseAsset(database *sql.DB, releaseID, id int64) error {
return execScoped(database, "delete release asset",
`DELETE FROM release_assets WHERE id = ? AND release_id = ?`, id, releaseID)
}