Migration 003 adds the solver's per-project input table + the auto flag
that slice 6 will use to distinguish solver-owned cables from m's
hand-drawn ones.
connection_requirements:
- (from_device_id, to_device_id, preferred_cable_type_id) with
preferred_cable_type_id nullable ("solver picks if exactly one type
matches both ends").
- (pair_lo, pair_hi) is the order-normalised MIN/MAX of (from, to),
stored alongside the m-facing from/to so the UI doesn't have to
denormalise.
- UNIQUE (project_id, pair_lo, pair_hi, preferred_cable_type_id) →
(A,B,T) and (B,A,T) collide; (A,B,Power) + (A,B,RJ45) coexist.
- CHECK (from != to). FK CASCADE from devices → requirement vanishes
if either endpoint device is deleted.
Store + 11 new tests:
- pair normalisation rejects the reversed-direction duplicate
- different cable types on the same pair coexist
- self-loop rejected (ErrInvalidInput)
- cross-project device reference rejected
- two null-cable-type reqs on the same pair both succeed (SQLite NULL
!= NULL in UNIQUE — semantically "solver picks both times", second
wins)
- partial PATCH: preferred_cable_type_id tri-state (leave/set/clear),
must_connect bool, notes string
- device delete cascades to its requirements
- snapshot.connection_requirements is non-nil and populated
cables.auto:
- ALTER TABLE cables ADD COLUMN auto INTEGER NOT NULL DEFAULT 0 CHECK
(auto IN (0,1)). Slice 6 sets 1 from the solver; slice 7's manual
cable POST keeps the default 0.
193 lines
6.1 KiB
Go
193 lines
6.1 KiB
Go
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
)
|
|
|
|
// ConnectionRequirementCreate is the create-shape. Server normalises
|
|
// from/to into (pair_lo, pair_hi) so (A,B,T) and (B,A,T) collide.
|
|
type ConnectionRequirementCreate struct {
|
|
FromDeviceID int64
|
|
ToDeviceID int64
|
|
PreferredCableTypeID *int64
|
|
MustConnect *bool // pointer so "absent" defaults to true
|
|
Notes string
|
|
}
|
|
|
|
// ConnectionRequirementUpdate is the partial-update shape. project_id +
|
|
// the device pair are immutable post-create (changing either is best
|
|
// modelled as delete-then-create — keeps pair_lo/pair_hi semantics simple).
|
|
type ConnectionRequirementUpdate struct {
|
|
PreferredCableTypeID FrameRef // tri-state: leave / set / clear
|
|
MustConnect *bool
|
|
Notes *string
|
|
}
|
|
|
|
// CreateConnectionRequirement inserts a new requirement. Validates that
|
|
// both devices live in projectID, that from != to, and that the
|
|
// (project, pair_lo, pair_hi, preferred_cable_type_id) tuple is unique.
|
|
func (s *Store) CreateConnectionRequirement(projectID int64, r ConnectionRequirementCreate) (*ConnectionRequirement, error) {
|
|
if r.FromDeviceID == r.ToDeviceID {
|
|
return nil, fmt.Errorf("%w: from_device_id and to_device_id must differ", ErrInvalidInput)
|
|
}
|
|
if _, err := s.GetProject(projectID); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := s.GetDevice(projectID, r.FromDeviceID); err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return nil, fmt.Errorf("%w: from_device_id %d not in project %d", ErrInvalidInput, r.FromDeviceID, projectID)
|
|
}
|
|
return nil, err
|
|
}
|
|
if _, err := s.GetDevice(projectID, r.ToDeviceID); err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return nil, fmt.Errorf("%w: to_device_id %d not in project %d", ErrInvalidInput, r.ToDeviceID, projectID)
|
|
}
|
|
return nil, err
|
|
}
|
|
if r.PreferredCableTypeID != nil {
|
|
if _, err := s.GetCableType(*r.PreferredCableTypeID); err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return nil, fmt.Errorf("%w: preferred_cable_type_id %d not found", ErrInvalidInput, *r.PreferredCableTypeID)
|
|
}
|
|
return nil, err
|
|
}
|
|
}
|
|
must := true
|
|
if r.MustConnect != nil {
|
|
must = *r.MustConnect
|
|
}
|
|
mustInt := 0
|
|
if must {
|
|
mustInt = 1
|
|
}
|
|
lo, hi := r.FromDeviceID, r.ToDeviceID
|
|
if lo > hi {
|
|
lo, hi = hi, lo
|
|
}
|
|
res, err := s.db.Exec(
|
|
`INSERT INTO connection_requirements
|
|
(project_id, from_device_id, to_device_id, preferred_cable_type_id,
|
|
must_connect, notes, pair_lo, pair_hi)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
projectID, r.FromDeviceID, r.ToDeviceID, nullableInt64(r.PreferredCableTypeID),
|
|
mustInt, r.Notes, lo, hi,
|
|
)
|
|
if err != nil {
|
|
return nil, mapWriteErr(err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return s.GetConnectionRequirement(projectID, id)
|
|
}
|
|
|
|
// GetConnectionRequirement loads one by id, project-scoped.
|
|
func (s *Store) GetConnectionRequirement(projectID, id int64) (*ConnectionRequirement, error) {
|
|
var r ConnectionRequirement
|
|
var ct sql.NullInt64
|
|
var must int
|
|
err := s.db.QueryRow(
|
|
`SELECT id, project_id, from_device_id, to_device_id, preferred_cable_type_id,
|
|
must_connect, notes, created_at, updated_at
|
|
FROM connection_requirements WHERE id = ? AND project_id = ?`, id, projectID,
|
|
).Scan(&r.ID, &r.ProjectID, &r.FromDeviceID, &r.ToDeviceID, &ct,
|
|
&must, &r.Notes, &r.CreatedAt, &r.UpdatedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if ct.Valid {
|
|
v := ct.Int64
|
|
r.PreferredCableTypeID = &v
|
|
}
|
|
r.MustConnect = must != 0
|
|
return &r, nil
|
|
}
|
|
|
|
// ListConnectionRequirements returns every requirement in a project,
|
|
// ordered by id (insertion order).
|
|
func (s *Store) ListConnectionRequirements(projectID int64) ([]ConnectionRequirement, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT id, project_id, from_device_id, to_device_id, preferred_cable_type_id,
|
|
must_connect, notes, created_at, updated_at
|
|
FROM connection_requirements WHERE project_id = ? ORDER BY id`, projectID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []ConnectionRequirement{}
|
|
for rows.Next() {
|
|
var r ConnectionRequirement
|
|
var ct sql.NullInt64
|
|
var must int
|
|
if err := rows.Scan(&r.ID, &r.ProjectID, &r.FromDeviceID, &r.ToDeviceID, &ct,
|
|
&must, &r.Notes, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
if ct.Valid {
|
|
v := ct.Int64
|
|
r.PreferredCableTypeID = &v
|
|
}
|
|
r.MustConnect = must != 0
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// UpdateConnectionRequirement applies a partial update. preferred_cable_type_id
|
|
// uses the FrameRef tri-state; must_connect + notes are plain pointers.
|
|
// The (from, to) pair is immutable on PATCH — delete + recreate to change.
|
|
func (s *Store) UpdateConnectionRequirement(projectID, id int64, u ConnectionRequirementUpdate) (*ConnectionRequirement, error) {
|
|
cur, err := s.GetConnectionRequirement(projectID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if u.PreferredCableTypeID.Set {
|
|
if u.PreferredCableTypeID.ID != nil {
|
|
if _, err := s.GetCableType(*u.PreferredCableTypeID.ID); err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return nil, fmt.Errorf("%w: preferred_cable_type_id %d not found", ErrInvalidInput, *u.PreferredCableTypeID.ID)
|
|
}
|
|
return nil, err
|
|
}
|
|
}
|
|
cur.PreferredCableTypeID = u.PreferredCableTypeID.ID
|
|
}
|
|
if u.MustConnect != nil {
|
|
cur.MustConnect = *u.MustConnect
|
|
}
|
|
if u.Notes != nil {
|
|
cur.Notes = *u.Notes
|
|
}
|
|
mustInt := 0
|
|
if cur.MustConnect {
|
|
mustInt = 1
|
|
}
|
|
if _, err := s.db.Exec(
|
|
`UPDATE connection_requirements
|
|
SET preferred_cable_type_id = ?, must_connect = ?, notes = ?, updated_at = datetime('now')
|
|
WHERE id = ? AND project_id = ?`,
|
|
nullableInt64(cur.PreferredCableTypeID), mustInt, cur.Notes, id, projectID,
|
|
); err != nil {
|
|
return nil, mapWriteErr(err)
|
|
}
|
|
return s.GetConnectionRequirement(projectID, id)
|
|
}
|
|
|
|
// DeleteConnectionRequirement removes a requirement by id, project-scoped.
|
|
func (s *Store) DeleteConnectionRequirement(projectID, id int64) error {
|
|
if _, err := s.GetConnectionRequirement(projectID, id); err != nil {
|
|
return err
|
|
}
|
|
if _, err := s.db.Exec(
|
|
`DELETE FROM connection_requirements WHERE id = ? AND project_id = ?`, id, projectID,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|