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 }