Build the complete artifact pillar with five packages: - artifacts: Artifact, Snapshot, Citation, Publisher types with Get/Store DB methods, tag/category management, metadata ops, YAML import - blob: content-addressable store (SHA256, hierarchical dir layout) - proto: protobuf definitions (common.proto, artifacts.proto) with buf linting and code generation - server: gRPC ArtifactService implementation (create/get artifacts, store/retrieve blobs, manage tags/categories, search by tag) All FK insertion ordering is correct (parent rows before children). Full test coverage across artifacts, blob, and server packages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
206 lines
5.9 KiB
Go
206 lines
5.9 KiB
Go
package artifacts
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.wntrmute.dev/kyle/exo/core"
|
|
)
|
|
|
|
// GetTag returns the tag ID for a given tag string. Returns empty string if
|
|
// the tag doesn't exist.
|
|
func GetTag(ctx context.Context, tx *sql.Tx, tag string) (string, error) {
|
|
var id string
|
|
row := tx.QueryRowContext(ctx, `SELECT id FROM tags WHERE tag=?`, tag)
|
|
if err := row.Scan(&id); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", nil
|
|
}
|
|
return "", fmt.Errorf("artifacts: failed to look up tag %q: %w", tag, err)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// CreateTag idempotently creates a tag. If the tag already exists, this is a no-op.
|
|
func CreateTag(ctx context.Context, tx *sql.Tx, tag string) error {
|
|
id, err := GetTag(ctx, tx, tag)
|
|
if err != nil {
|
|
return fmt.Errorf("artifacts: creating tag failed: %w", err)
|
|
}
|
|
if id != "" {
|
|
return nil
|
|
}
|
|
|
|
id = core.NewUUID()
|
|
_, err = tx.ExecContext(ctx, `INSERT INTO tags (id, tag) VALUES (?, ?)`, id, tag)
|
|
if err != nil {
|
|
return fmt.Errorf("artifacts: creating tag %q failed: %w", tag, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetAllTags returns all tag strings, sorted alphabetically.
|
|
func GetAllTags(ctx context.Context, tx *sql.Tx) ([]string, error) {
|
|
rows, err := tx.QueryContext(ctx, `SELECT tag FROM tags`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("artifacts: failed to get all tags: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var tags []string
|
|
for rows.Next() {
|
|
var tag string
|
|
if err := rows.Scan(&tag); err != nil {
|
|
return nil, fmt.Errorf("artifacts: failed to scan tag: %w", err)
|
|
}
|
|
tags = append(tags, tag)
|
|
}
|
|
sort.Strings(tags)
|
|
return tags, rows.Err()
|
|
}
|
|
|
|
// GetCategory returns the category ID for a given category string.
|
|
// Returns empty string if the category doesn't exist.
|
|
func GetCategory(ctx context.Context, tx *sql.Tx, category string) (string, error) {
|
|
var id string
|
|
row := tx.QueryRowContext(ctx, `SELECT id FROM categories WHERE category=?`, category)
|
|
if err := row.Scan(&id); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", nil
|
|
}
|
|
return "", fmt.Errorf("artifacts: failed to look up category %q: %w", category, err)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// CreateCategory idempotently creates a category.
|
|
func CreateCategory(ctx context.Context, tx *sql.Tx, category string) error {
|
|
id, err := GetCategory(ctx, tx, category)
|
|
if err != nil {
|
|
return fmt.Errorf("artifacts: creating category failed: %w", err)
|
|
}
|
|
if id != "" {
|
|
return nil
|
|
}
|
|
|
|
id = core.NewUUID()
|
|
_, err = tx.ExecContext(ctx, `INSERT INTO categories (id, category) VALUES (?, ?)`, id, category)
|
|
if err != nil {
|
|
return fmt.Errorf("artifacts: creating category %q failed: %w", category, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetAllCategories returns all category strings, sorted alphabetically.
|
|
func GetAllCategories(ctx context.Context, tx *sql.Tx) ([]string, error) {
|
|
rows, err := tx.QueryContext(ctx, `SELECT category FROM categories`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("artifacts: failed to get all categories: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var categories []string
|
|
for rows.Next() {
|
|
var category string
|
|
if err := rows.Scan(&category); err != nil {
|
|
return nil, fmt.Errorf("artifacts: failed to scan category: %w", err)
|
|
}
|
|
categories = append(categories, category)
|
|
}
|
|
sort.Strings(categories)
|
|
return categories, rows.Err()
|
|
}
|
|
|
|
// tagsFromTagIDs resolves a list of tag UUIDs to their string values.
|
|
func tagsFromTagIDs(ctx context.Context, tx *sql.Tx, idList []string) (map[string]bool, error) {
|
|
if len(idList) == 0 {
|
|
return map[string]bool{}, nil
|
|
}
|
|
|
|
placeholders := make([]string, len(idList))
|
|
args := make([]any, len(idList))
|
|
for i, id := range idList {
|
|
placeholders[i] = "?"
|
|
args[i] = id
|
|
}
|
|
|
|
query := `SELECT tag FROM tags WHERE id IN (` + strings.Join(placeholders, ",") + `)` //nolint:gosec // placeholders are literal "?" strings, not user input
|
|
rows, err := tx.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("artifacts: failed to resolve tag IDs: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
tags := map[string]bool{}
|
|
for rows.Next() {
|
|
var tag string
|
|
if err := rows.Scan(&tag); err != nil {
|
|
return nil, err
|
|
}
|
|
tags[tag] = true
|
|
}
|
|
return tags, rows.Err()
|
|
}
|
|
|
|
// categoriesFromCategoryIDs resolves a list of category UUIDs to their string values.
|
|
func categoriesFromCategoryIDs(ctx context.Context, tx *sql.Tx, idList []string) (map[string]bool, error) {
|
|
if len(idList) == 0 {
|
|
return map[string]bool{}, nil
|
|
}
|
|
|
|
placeholders := make([]string, len(idList))
|
|
args := make([]any, len(idList))
|
|
for i, id := range idList {
|
|
placeholders[i] = "?"
|
|
args[i] = id
|
|
}
|
|
|
|
query := `SELECT category FROM categories WHERE id IN (` + strings.Join(placeholders, ",") + `)` //nolint:gosec // placeholders are literal "?" strings, not user input
|
|
rows, err := tx.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("artifacts: failed to resolve category IDs: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
categories := map[string]bool{}
|
|
for rows.Next() {
|
|
var category string
|
|
if err := rows.Scan(&category); err != nil {
|
|
return nil, err
|
|
}
|
|
categories[category] = true
|
|
}
|
|
return categories, rows.Err()
|
|
}
|
|
|
|
// GetArtifactIDsForTag returns artifact IDs that have the given tag.
|
|
func GetArtifactIDsForTag(ctx context.Context, tx *sql.Tx, tag string) ([]string, error) {
|
|
tagID, err := GetTag(ctx, tx, tag)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("artifacts: failed to look up tag ID: %w", err)
|
|
}
|
|
if tagID == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
rows, err := tx.QueryContext(ctx, `SELECT artifact_id FROM artifact_tags WHERE tag_id=?`, tagID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("artifacts: failed to get artifact IDs for tag: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var ids []string
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, fmt.Errorf("artifacts: failed to scan artifact ID: %w", err)
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
return ids, rows.Err()
|
|
}
|