package db import ( "context" "database/sql" "errors" "fmt" "strings" ) type CreateProjectParams struct { Name string Slug string Description string } type UpdateProjectParams struct { Name string Slug string Description string } func (r *ProjectRepository) List(ctx context.Context) ([]ProjectListItem, error) { rows, err := r.q.QueryContext( ctx, `SELECT p.id, p.name, p.slug, p.description, p.is_active, p.created_at, p.updated_at, COUNT(DISTINCT pt.tag_id) AS tag_count, COUNT(DISTINCT rel.id) AS release_count FROM projects AS p LEFT JOIN project_tags AS pt ON pt.project_id = p.id LEFT JOIN releases AS rel ON rel.project_id = p.id AND rel.is_active = 1 GROUP BY p.id ORDER BY p.is_active DESC, p.updated_at DESC, p.created_at DESC, p.name COLLATE NOCASE`, ) if err != nil { return nil, fmt.Errorf("query projects: %w", err) } defer rows.Close() projects := make([]ProjectListItem, 0) for rows.Next() { item, err := scanProjectListItem(rows) if err != nil { return nil, fmt.Errorf("scan project list item: %w", err) } projects = append(projects, *item) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate projects: %w", err) } return projects, nil } func (r *ProjectRepository) GetByID(ctx context.Context, id int64) (*Project, error) { project, err := scanProject(r.q.QueryRowContext( ctx, `SELECT id, name, slug, description, is_active, created_at, updated_at FROM projects WHERE id = ?`, id, )) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return nil, fmt.Errorf("scan project by id: %w", err) } return project, nil } func (r *ProjectRepository) GetBySlug(ctx context.Context, slug string) (*Project, error) { project, err := scanProject(r.q.QueryRowContext( ctx, `SELECT id, name, slug, description, is_active, created_at, updated_at FROM projects WHERE slug = ? LIMIT 1`, strings.TrimSpace(slug), )) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return nil, fmt.Errorf("scan project by slug: %w", err) } return project, nil } func (r *ProjectRepository) Create(ctx context.Context, params CreateProjectParams) (*Project, error) { result, err := r.q.ExecContext( ctx, `INSERT INTO projects (name, slug, description) VALUES (?, ?, ?)`, strings.TrimSpace(params.Name), strings.TrimSpace(params.Slug), strings.TrimSpace(params.Description), ) if err != nil { if isUniqueConstraintError(err) { return nil, errors.Join(ErrConflict, fmt.Errorf("insert project: %w", err)) } return nil, fmt.Errorf("insert project: %w", err) } projectID, err := result.LastInsertId() if err != nil { return nil, fmt.Errorf("load inserted project id: %w", err) } return r.GetByID(ctx, projectID) } func (r *ProjectRepository) Update(ctx context.Context, projectID int64, params UpdateProjectParams) (*Project, error) { result, err := r.q.ExecContext( ctx, `UPDATE projects SET name = ?, slug = ?, description = ? WHERE id = ?`, strings.TrimSpace(params.Name), strings.TrimSpace(params.Slug), strings.TrimSpace(params.Description), projectID, ) if err != nil { if isUniqueConstraintError(err) { return nil, errors.Join(ErrConflict, fmt.Errorf("update project: %w", err)) } return nil, fmt.Errorf("update project: %w", err) } rowsAffected, err := result.RowsAffected() if err != nil { return nil, fmt.Errorf("read updated project rows: %w", err) } if rowsAffected == 0 { return nil, ErrNotFound } return r.GetByID(ctx, projectID) } func (r *ProjectRepository) SetActive(ctx context.Context, projectID int64, isActive bool) error { activeValue := 0 if isActive { activeValue = 1 } result, err := r.q.ExecContext( ctx, `UPDATE projects SET is_active = ? WHERE id = ?`, activeValue, projectID, ) if err != nil { return fmt.Errorf("update project active state: %w", err) } rowsAffected, err := result.RowsAffected() if err != nil { return fmt.Errorf("read affected project rows: %w", err) } if rowsAffected == 0 { return ErrNotFound } return nil } func (r *ProjectRepository) ListTags(ctx context.Context, projectID int64) ([]Tag, error) { rows, err := r.q.QueryContext( ctx, `SELECT t.id, t.name, t.slug, t.description, t.created_at, t.updated_at FROM tags AS t INNER JOIN project_tags AS pt ON pt.tag_id = t.id WHERE pt.project_id = ? ORDER BY t.name COLLATE NOCASE`, projectID, ) if err != nil { return nil, fmt.Errorf("query project tags: %w", err) } defer rows.Close() tags := make([]Tag, 0) for rows.Next() { tag, err := scanTag(rows) if err != nil { return nil, fmt.Errorf("scan project tag: %w", err) } tags = append(tags, *tag) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate project tags: %w", err) } return tags, nil } func (r *ProjectRepository) AttachTag(ctx context.Context, projectID, tagID int64) error { if _, err := r.q.ExecContext( ctx, `INSERT OR IGNORE INTO project_tags (project_id, tag_id) VALUES (?, ?)`, projectID, tagID, ); err != nil { return fmt.Errorf("insert project tag link: %w", err) } return nil } func (r *ProjectRepository) DetachTag(ctx context.Context, projectID, tagID int64) error { if _, err := r.q.ExecContext( ctx, `DELETE FROM project_tags WHERE project_id = ? AND tag_id = ?`, projectID, tagID, ); err != nil { return fmt.Errorf("delete project tag link: %w", err) } return nil } func scanProject(scanner rowScanner) (*Project, error) { var ( project Project isActive int createdAtRaw string updatedAtRaw string ) if err := scanner.Scan( &project.ID, &project.Name, &project.Slug, &project.Description, &isActive, &createdAtRaw, &updatedAtRaw, ); err != nil { return nil, err } createdAt, err := parseTimestamp(createdAtRaw) if err != nil { return nil, fmt.Errorf("parse project created_at: %w", err) } updatedAt, err := parseTimestamp(updatedAtRaw) if err != nil { return nil, fmt.Errorf("parse project updated_at: %w", err) } project.IsActive = isActive == 1 project.CreatedAt = createdAt project.UpdatedAt = updatedAt return &project, nil } func scanProjectListItem(scanner rowScanner) (*ProjectListItem, error) { var ( item ProjectListItem isActive int createdAtRaw string updatedAtRaw string ) if err := scanner.Scan( &item.Project.ID, &item.Project.Name, &item.Project.Slug, &item.Project.Description, &isActive, &createdAtRaw, &updatedAtRaw, &item.TagCount, &item.ReleaseCount, ); err != nil { return nil, err } createdAt, err := parseTimestamp(createdAtRaw) if err != nil { return nil, fmt.Errorf("parse project list created_at: %w", err) } updatedAt, err := parseTimestamp(updatedAtRaw) if err != nil { return nil, fmt.Errorf("parse project list updated_at: %w", err) } item.Project.IsActive = isActive == 1 item.Project.CreatedAt = createdAt item.Project.UpdatedAt = updatedAt return &item, nil }