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() }