Files
CableGUI/internal/db/clamps.go
mAi 4202d0465f feat(v5 slice 1): clamps schema + store helpers + snapshot
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.
2026-05-16 13:40:53 +02:00

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
}