Add LocationRepository with CRUD operations, hierarchy traversal
(children, subtree by path prefix), and inventory-safe deletion.
Endpoints:
GET /api/locations — list all or ?tree={path} for subtree
POST /api/locations — create (auto-resolves parent_id, depth)
GET /api/locations/{path..} — get by hierarchical path
PUT /api/locations/{path..} — update name, type, metadata
DELETE /api/locations/{path..} — delete (rejects if inventory exists)
Uses chi wildcard routes to support multi-segment paths like
/api/locations/lab/shelf-a/bin-3.
Includes 10 handler integration tests covering CRUD, nesting,
validation, duplicates, tree queries, and delete-not-found.
Closes #81
231 lines
6.3 KiB
Go
231 lines
6.3 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// Location represents a location in the hierarchy.
|
|
type Location struct {
|
|
ID string
|
|
Path string
|
|
Name string
|
|
ParentID *string
|
|
LocationType string
|
|
Depth int
|
|
Metadata map[string]any
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// LocationRepository provides location database operations.
|
|
type LocationRepository struct {
|
|
db *DB
|
|
}
|
|
|
|
// NewLocationRepository creates a new location repository.
|
|
func NewLocationRepository(db *DB) *LocationRepository {
|
|
return &LocationRepository{db: db}
|
|
}
|
|
|
|
// List returns all locations ordered by path.
|
|
func (r *LocationRepository) List(ctx context.Context) ([]*Location, error) {
|
|
rows, err := r.db.pool.Query(ctx, `
|
|
SELECT id, path, name, parent_id, location_type, depth, metadata, created_at
|
|
FROM locations
|
|
ORDER BY path
|
|
`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanLocations(rows)
|
|
}
|
|
|
|
// GetByPath returns a location by its path.
|
|
func (r *LocationRepository) GetByPath(ctx context.Context, path string) (*Location, error) {
|
|
loc := &Location{}
|
|
var meta []byte
|
|
err := r.db.pool.QueryRow(ctx, `
|
|
SELECT id, path, name, parent_id, location_type, depth, metadata, created_at
|
|
FROM locations
|
|
WHERE path = $1
|
|
`, path).Scan(&loc.ID, &loc.Path, &loc.Name, &loc.ParentID, &loc.LocationType, &loc.Depth, &meta, &loc.CreatedAt)
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if meta != nil {
|
|
json.Unmarshal(meta, &loc.Metadata)
|
|
}
|
|
return loc, nil
|
|
}
|
|
|
|
// GetByID returns a location by its ID.
|
|
func (r *LocationRepository) GetByID(ctx context.Context, id string) (*Location, error) {
|
|
loc := &Location{}
|
|
var meta []byte
|
|
err := r.db.pool.QueryRow(ctx, `
|
|
SELECT id, path, name, parent_id, location_type, depth, metadata, created_at
|
|
FROM locations
|
|
WHERE id = $1
|
|
`, id).Scan(&loc.ID, &loc.Path, &loc.Name, &loc.ParentID, &loc.LocationType, &loc.Depth, &meta, &loc.CreatedAt)
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if meta != nil {
|
|
json.Unmarshal(meta, &loc.Metadata)
|
|
}
|
|
return loc, nil
|
|
}
|
|
|
|
// GetChildren returns direct children of a location.
|
|
func (r *LocationRepository) GetChildren(ctx context.Context, parentID string) ([]*Location, error) {
|
|
rows, err := r.db.pool.Query(ctx, `
|
|
SELECT id, path, name, parent_id, location_type, depth, metadata, created_at
|
|
FROM locations
|
|
WHERE parent_id = $1
|
|
ORDER BY path
|
|
`, parentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanLocations(rows)
|
|
}
|
|
|
|
// GetTree returns a location and all its descendants (by path prefix).
|
|
func (r *LocationRepository) GetTree(ctx context.Context, rootPath string) ([]*Location, error) {
|
|
rows, err := r.db.pool.Query(ctx, `
|
|
SELECT id, path, name, parent_id, location_type, depth, metadata, created_at
|
|
FROM locations
|
|
WHERE path = $1 OR path LIKE $2
|
|
ORDER BY path
|
|
`, rootPath, rootPath+"/%")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanLocations(rows)
|
|
}
|
|
|
|
// Create inserts a new location. ParentID and Depth are resolved from the path.
|
|
func (r *LocationRepository) Create(ctx context.Context, loc *Location) error {
|
|
// Auto-calculate depth from path segments
|
|
loc.Depth = strings.Count(loc.Path, "/")
|
|
|
|
// Resolve parent_id from path if not explicitly set
|
|
if loc.ParentID == nil && loc.Depth > 0 {
|
|
parentPath := loc.Path[:strings.LastIndex(loc.Path, "/")]
|
|
parent, err := r.GetByPath(ctx, parentPath)
|
|
if err != nil {
|
|
return fmt.Errorf("looking up parent %q: %w", parentPath, err)
|
|
}
|
|
if parent == nil {
|
|
return fmt.Errorf("parent location %q does not exist", parentPath)
|
|
}
|
|
loc.ParentID = &parent.ID
|
|
}
|
|
|
|
meta, err := json.Marshal(loc.Metadata)
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling metadata: %w", err)
|
|
}
|
|
|
|
return r.db.pool.QueryRow(ctx, `
|
|
INSERT INTO locations (path, name, parent_id, location_type, depth, metadata)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING id, created_at
|
|
`, loc.Path, loc.Name, loc.ParentID, loc.LocationType, loc.Depth, meta).Scan(&loc.ID, &loc.CreatedAt)
|
|
}
|
|
|
|
// Update updates a location's name, type, and metadata.
|
|
func (r *LocationRepository) Update(ctx context.Context, path string, name, locationType string, metadata map[string]any) error {
|
|
meta, err := json.Marshal(metadata)
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling metadata: %w", err)
|
|
}
|
|
tag, err := r.db.pool.Exec(ctx, `
|
|
UPDATE locations
|
|
SET name = $2, location_type = $3, metadata = $4
|
|
WHERE path = $1
|
|
`, path, name, locationType, meta)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return fmt.Errorf("location %q not found", path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Delete removes a location. Returns an error if inventory rows reference it.
|
|
func (r *LocationRepository) Delete(ctx context.Context, path string) error {
|
|
// Check for inventory references
|
|
var count int
|
|
err := r.db.pool.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM inventory
|
|
WHERE location_id = (SELECT id FROM locations WHERE path = $1)
|
|
`, path).Scan(&count)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count > 0 {
|
|
return fmt.Errorf("cannot delete location %q: %d inventory record(s) exist", path, count)
|
|
}
|
|
|
|
// Delete children first (cascade by path prefix), deepest first
|
|
_, err = r.db.pool.Exec(ctx, `
|
|
DELETE FROM locations
|
|
WHERE path LIKE $1
|
|
`, path+"/%")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tag, err := r.db.pool.Exec(ctx, `DELETE FROM locations WHERE path = $1`, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return fmt.Errorf("location %q not found", path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// HasInventory checks if a location (or descendants) have inventory records.
|
|
func (r *LocationRepository) HasInventory(ctx context.Context, path string) (bool, error) {
|
|
var count int
|
|
err := r.db.pool.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM inventory i
|
|
JOIN locations l ON l.id = i.location_id
|
|
WHERE l.path = $1 OR l.path LIKE $2
|
|
`, path, path+"/%").Scan(&count)
|
|
return count > 0, err
|
|
}
|
|
|
|
func scanLocations(rows pgx.Rows) ([]*Location, error) {
|
|
var locs []*Location
|
|
for rows.Next() {
|
|
loc := &Location{}
|
|
var meta []byte
|
|
if err := rows.Scan(&loc.ID, &loc.Path, &loc.Name, &loc.ParentID, &loc.LocationType, &loc.Depth, &meta, &loc.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
if meta != nil {
|
|
json.Unmarshal(meta, &loc.Metadata)
|
|
}
|
|
locs = append(locs, loc)
|
|
}
|
|
return locs, rows.Err()
|
|
}
|