Migration 007 introduces the v5 routing primitive: - clamps table (project-scoped, optional frame_id, excalidraw_id). - cable_clamps join (cable_id, clamp_id, ord) with PK on (cable_id, ord) and UNIQUE (cable_id, clamp_id) to block a clamp visiting the same cable twice. Store helpers in internal/db/clamps.go: - CreateClamp / GetClamp / ListClamps / UpdateClamp / DeleteClamp — standard project-scoped CRUD. UpdateClamp uses FrameRef tri-state. - AttachClampToCable — appends or inserts at a given ord. Mid-sequence inserts use a two-pass shift (bump by 10000, settle to ord+1) since SQLite UPDATE doesn't support ORDER BY and a single bulk +1 would collide with the UNIQUE (cable_id, ord) PK. - DetachClampFromCable — removes the row then closes the gap. - ReorderCableClamps — replaces the whole sequence in one tx. - ListClampsForCable / ListCableClamps — read helpers. Snapshot now carries clamps + cable_clamps arrays so the frontend can hydrate everything in one call. Tests cover create / update / cascade-delete / attach (append + insert + duplicate-rejected) / detach (gap closes) / reorder / snapshot.
352 lines
9.6 KiB
Go
352 lines
9.6 KiB
Go
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// ClampCreate is the create-shape for a new clamp.
|
|
type ClampCreate struct {
|
|
X float64
|
|
Y float64
|
|
Label string
|
|
FrameID *int64
|
|
}
|
|
|
|
// ClampUpdate is the partial-update shape.
|
|
type ClampUpdate struct {
|
|
X *float64
|
|
Y *float64
|
|
Label *string
|
|
// FrameID tri-state: nil = leave alone; non-nil pointer to nil ptr
|
|
// would be ambiguous, so we use FrameRef like devices.
|
|
FrameID FrameRef
|
|
}
|
|
|
|
// CreateClamp inserts a new clamp inside a project.
|
|
func (s *Store) CreateClamp(projectID int64, c ClampCreate) (*Clamp, error) {
|
|
if _, err := s.GetProject(projectID); err != nil {
|
|
return nil, err
|
|
}
|
|
if c.FrameID != nil {
|
|
if _, err := s.GetFrame(projectID, *c.FrameID); err != nil {
|
|
return nil, fmt.Errorf("%w: frame_id: %v", ErrInvalidInput, err)
|
|
}
|
|
}
|
|
res, err := s.db.Exec(
|
|
`INSERT INTO clamps (project_id, x, y, label, frame_id)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
projectID, c.X, c.Y, strings.TrimSpace(c.Label), nullableInt64(c.FrameID),
|
|
)
|
|
if err != nil {
|
|
return nil, mapWriteErr(err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return s.GetClamp(projectID, id)
|
|
}
|
|
|
|
// GetClamp returns a single clamp scoped to the project.
|
|
func (s *Store) GetClamp(projectID, id int64) (*Clamp, error) {
|
|
var c Clamp
|
|
var frame sql.NullInt64
|
|
var ex sql.NullString
|
|
err := s.db.QueryRow(
|
|
`SELECT id, project_id, x, y, label, frame_id, excalidraw_id, created_at, updated_at
|
|
FROM clamps WHERE id = ? AND project_id = ?`, id, projectID,
|
|
).Scan(&c.ID, &c.ProjectID, &c.X, &c.Y, &c.Label, &frame, &ex, &c.CreatedAt, &c.UpdatedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if frame.Valid {
|
|
v := frame.Int64
|
|
c.FrameID = &v
|
|
}
|
|
if ex.Valid {
|
|
c.ExcalidrawID = &ex.String
|
|
}
|
|
return &c, nil
|
|
}
|
|
|
|
// ListClamps returns every clamp in a project, ordered by id.
|
|
func (s *Store) ListClamps(projectID int64) ([]Clamp, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT id, project_id, x, y, label, frame_id, excalidraw_id, created_at, updated_at
|
|
FROM clamps WHERE project_id = ? ORDER BY id`, projectID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []Clamp{}
|
|
for rows.Next() {
|
|
var c Clamp
|
|
var frame sql.NullInt64
|
|
var ex sql.NullString
|
|
if err := rows.Scan(&c.ID, &c.ProjectID, &c.X, &c.Y, &c.Label, &frame, &ex, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
if frame.Valid {
|
|
v := frame.Int64
|
|
c.FrameID = &v
|
|
}
|
|
if ex.Valid {
|
|
c.ExcalidrawID = &ex.String
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// UpdateClamp applies a partial update.
|
|
func (s *Store) UpdateClamp(projectID, id int64, u ClampUpdate) (*Clamp, error) {
|
|
cur, err := s.GetClamp(projectID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if u.X != nil {
|
|
cur.X = *u.X
|
|
}
|
|
if u.Y != nil {
|
|
cur.Y = *u.Y
|
|
}
|
|
if u.Label != nil {
|
|
cur.Label = strings.TrimSpace(*u.Label)
|
|
}
|
|
if u.FrameID.Set {
|
|
if u.FrameID.ID == nil {
|
|
cur.FrameID = nil
|
|
} else {
|
|
if _, err := s.GetFrame(projectID, *u.FrameID.ID); err != nil {
|
|
return nil, fmt.Errorf("%w: frame_id: %v", ErrInvalidInput, err)
|
|
}
|
|
id := *u.FrameID.ID
|
|
cur.FrameID = &id
|
|
}
|
|
}
|
|
if _, err := s.db.Exec(
|
|
`UPDATE clamps SET x = ?, y = ?, label = ?, frame_id = ?, updated_at = datetime('now')
|
|
WHERE id = ? AND project_id = ?`,
|
|
cur.X, cur.Y, cur.Label, nullableInt64(cur.FrameID), id, projectID,
|
|
); err != nil {
|
|
return nil, mapWriteErr(err)
|
|
}
|
|
return s.GetClamp(projectID, id)
|
|
}
|
|
|
|
// DeleteClamp removes a clamp. cable_clamps rows cascade.
|
|
func (s *Store) DeleteClamp(projectID, id int64) error {
|
|
res, err := s.db.Exec(`DELETE FROM clamps WHERE id = ? AND project_id = ?`, id, projectID)
|
|
if err != nil {
|
|
return mapWriteErr(err)
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListCableClamps returns every (cable_id, clamp_id, ord) row in a
|
|
// project, joined through cables to scope by project_id.
|
|
func (s *Store) ListCableClamps(projectID int64) ([]CableClamp, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT cc.cable_id, cc.clamp_id, cc.ord
|
|
FROM cable_clamps cc
|
|
JOIN cables c ON c.id = cc.cable_id
|
|
WHERE c.project_id = ?
|
|
ORDER BY cc.cable_id, cc.ord`, projectID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []CableClamp{}
|
|
for rows.Next() {
|
|
var cc CableClamp
|
|
if err := rows.Scan(&cc.CableID, &cc.ClampID, &cc.Ord); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, cc)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ListClampsForCable returns the clamps on a cable in ord sequence.
|
|
func (s *Store) ListClampsForCable(projectID, cableID int64) ([]CableClamp, error) {
|
|
if _, err := s.GetCable(projectID, cableID); err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.db.Query(
|
|
`SELECT cable_id, clamp_id, ord
|
|
FROM cable_clamps WHERE cable_id = ? ORDER BY ord`, cableID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []CableClamp{}
|
|
for rows.Next() {
|
|
var cc CableClamp
|
|
if err := rows.Scan(&cc.CableID, &cc.ClampID, &cc.Ord); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, cc)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// AttachClampToCable inserts a (cable, clamp) row. If `ord` is 0, the
|
|
// clamp is appended at the end. Otherwise existing rows at or after
|
|
// `ord` shift up by 1 to make room.
|
|
func (s *Store) AttachClampToCable(projectID, cableID, clampID int64, ord int) (*CableClamp, error) {
|
|
if _, err := s.GetCable(projectID, cableID); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := s.GetClamp(projectID, clampID); err != nil {
|
|
return nil, fmt.Errorf("%w: clamp not found", ErrInvalidInput)
|
|
}
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback()
|
|
// Refuse loops — UNIQUE (cable_id, clamp_id) enforces this, but a
|
|
// pre-check gives a clearer error.
|
|
var exists int
|
|
if err := tx.QueryRow(
|
|
`SELECT COUNT(*) FROM cable_clamps WHERE cable_id = ? AND clamp_id = ?`,
|
|
cableID, clampID,
|
|
).Scan(&exists); err != nil {
|
|
return nil, err
|
|
}
|
|
if exists > 0 {
|
|
return nil, fmt.Errorf("%w: clamp %d already on cable %d", ErrConflict, clampID, cableID)
|
|
}
|
|
var maxOrd sql.NullInt64
|
|
if err := tx.QueryRow(
|
|
`SELECT MAX(ord) FROM cable_clamps WHERE cable_id = ?`, cableID,
|
|
).Scan(&maxOrd); err != nil {
|
|
return nil, err
|
|
}
|
|
current := 0
|
|
if maxOrd.Valid {
|
|
current = int(maxOrd.Int64)
|
|
}
|
|
if ord <= 0 || ord > current+1 {
|
|
ord = current + 1
|
|
} else if ord <= current {
|
|
// Shift existing rows at ord..current up by 1 to free the slot.
|
|
// SQLite UPDATE doesn't support ORDER BY (no UPDATE-with-temp
|
|
// trick available), so a single `ord = ord + 1` would collide
|
|
// with the UNIQUE (cable_id, ord) constraint during the bulk
|
|
// update. Two-pass avoids the conflict: bump to a high offset
|
|
// first, then settle back to ord+1.
|
|
if _, err := tx.Exec(
|
|
`UPDATE cable_clamps SET ord = ord + 10000
|
|
WHERE cable_id = ? AND ord >= ?`, cableID, ord,
|
|
); err != nil {
|
|
return nil, mapWriteErr(err)
|
|
}
|
|
if _, err := tx.Exec(
|
|
`UPDATE cable_clamps SET ord = ord - 10000 + 1
|
|
WHERE cable_id = ? AND ord >= ?`, cableID, 10000+ord,
|
|
); err != nil {
|
|
return nil, mapWriteErr(err)
|
|
}
|
|
}
|
|
if _, err := tx.Exec(
|
|
`INSERT INTO cable_clamps (cable_id, clamp_id, ord) VALUES (?, ?, ?)`,
|
|
cableID, clampID, ord,
|
|
); err != nil {
|
|
return nil, mapWriteErr(err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &CableClamp{CableID: cableID, ClampID: clampID, Ord: ord}, nil
|
|
}
|
|
|
|
// DetachClampFromCable removes a clamp from a cable's polyline. The
|
|
// trailing rows close up to keep `ord` contiguous.
|
|
func (s *Store) DetachClampFromCable(projectID, cableID, clampID int64) error {
|
|
if _, err := s.GetCable(projectID, cableID); err != nil {
|
|
return err
|
|
}
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
var removed sql.NullInt64
|
|
if err := tx.QueryRow(
|
|
`SELECT ord FROM cable_clamps WHERE cable_id = ? AND clamp_id = ?`,
|
|
cableID, clampID,
|
|
).Scan(&removed); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(
|
|
`DELETE FROM cable_clamps WHERE cable_id = ? AND clamp_id = ?`,
|
|
cableID, clampID,
|
|
); err != nil {
|
|
return mapWriteErr(err)
|
|
}
|
|
// Close the gap: anyone with ord > removed slides down by 1.
|
|
if _, err := tx.Exec(
|
|
`UPDATE cable_clamps SET ord = ord - 1
|
|
WHERE cable_id = ? AND ord > ?`, cableID, removed.Int64,
|
|
); err != nil {
|
|
return mapWriteErr(err)
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// ReorderCableClamps replaces the whole clamp sequence on a cable with
|
|
// the given clamp IDs, in order. Every member of clampIDs must already
|
|
// be a valid clamp in the same project; duplicates → ErrConflict.
|
|
func (s *Store) ReorderCableClamps(projectID, cableID int64, clampIDs []int64) ([]CableClamp, error) {
|
|
if _, err := s.GetCable(projectID, cableID); err != nil {
|
|
return nil, err
|
|
}
|
|
seen := map[int64]bool{}
|
|
for _, cid := range clampIDs {
|
|
if seen[cid] {
|
|
return nil, fmt.Errorf("%w: duplicate clamp %d", ErrConflict, cid)
|
|
}
|
|
seen[cid] = true
|
|
if _, err := s.GetClamp(projectID, cid); err != nil {
|
|
return nil, fmt.Errorf("%w: clamp %d not in project", ErrInvalidInput, cid)
|
|
}
|
|
}
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err := tx.Exec(`DELETE FROM cable_clamps WHERE cable_id = ?`, cableID); err != nil {
|
|
return nil, mapWriteErr(err)
|
|
}
|
|
out := make([]CableClamp, 0, len(clampIDs))
|
|
for i, cid := range clampIDs {
|
|
ord := i + 1
|
|
if _, err := tx.Exec(
|
|
`INSERT INTO cable_clamps (cable_id, clamp_id, ord) VALUES (?, ?, ?)`,
|
|
cableID, cid, ord,
|
|
); err != nil {
|
|
return nil, mapWriteErr(err)
|
|
}
|
|
out = append(out, CableClamp{CableID: cableID, ClampID: cid, Ord: ord})
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|