Add Phase 2 artifact repository: types, blob store, gRPC service
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>
This commit is contained in:
205
artifacts/tagcat.go
Normal file
205
artifacts/tagcat.go
Normal file
@@ -0,0 +1,205 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user