The schema has ON DELETE SET NULL on cables.from_port_id / cables.to_port_id, but the cables CHECK constraint requires exactly one of (port/device/io) to be non-null per side. Setting both refs to NULL on a port-delete violates the CHECK, blowing up the DELETE with a 500. DeletePort now opens a tx, deletes any cable that referenced the port on either side, then deletes the port. Same observable effect from m's POV: cables that point at a deleted port are gone (he can re-draw with the manual cable tool if he still wants them).
360 lines
9.2 KiB
Go
360 lines
9.2 KiB
Go
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// PortCreate is the create-shape for POST /api/projects/:pid/devices/:id/ports.
|
|
type PortCreate struct {
|
|
TypeID int64
|
|
Label string
|
|
XOffset float64
|
|
YOffset float64
|
|
}
|
|
|
|
// PortUpdate is the partial-update shape.
|
|
type PortUpdate struct {
|
|
TypeID *int64
|
|
Label *string
|
|
XOffset *float64
|
|
YOffset *float64
|
|
}
|
|
|
|
// CreatePort inserts a port on a device. The device must exist in the
|
|
// project; the cable type must exist globally.
|
|
func (s *Store) CreatePort(projectID, deviceID int64, p PortCreate) (*Port, error) {
|
|
if _, err := s.GetDevice(projectID, deviceID); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := s.GetCableType(p.TypeID); err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return nil, fmt.Errorf("%w: cable type %d not found", ErrInvalidInput, p.TypeID)
|
|
}
|
|
return nil, err
|
|
}
|
|
label := strings.TrimSpace(p.Label)
|
|
var labelArg any
|
|
if label != "" {
|
|
labelArg = label
|
|
}
|
|
res, err := s.db.Exec(
|
|
`INSERT INTO ports (project_id, device_id, type_id, label, x_offset, y_offset)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
projectID, deviceID, p.TypeID, labelArg, p.XOffset, p.YOffset,
|
|
)
|
|
if err != nil {
|
|
return nil, mapWriteErr(err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return s.GetPort(projectID, id)
|
|
}
|
|
|
|
// GetPort loads a port by id, project-scoped.
|
|
func (s *Store) GetPort(projectID, id int64) (*Port, error) {
|
|
var p Port
|
|
var label, ex sql.NullString
|
|
err := s.db.QueryRow(
|
|
`SELECT id, project_id, device_id, type_id, label, x_offset, y_offset,
|
|
excalidraw_id, created_at, updated_at
|
|
FROM ports WHERE id = ? AND project_id = ?`, id, projectID,
|
|
).Scan(&p.ID, &p.ProjectID, &p.DeviceID, &p.TypeID, &label,
|
|
&p.XOffset, &p.YOffset, &ex, &p.CreatedAt, &p.UpdatedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if label.Valid {
|
|
v := label.String
|
|
p.Label = &v
|
|
}
|
|
if ex.Valid {
|
|
p.ExcalidrawID = &ex.String
|
|
}
|
|
return &p, nil
|
|
}
|
|
|
|
// UpdatePort applies a partial update.
|
|
func (s *Store) UpdatePort(projectID, id int64, u PortUpdate) (*Port, error) {
|
|
cur, err := s.GetPort(projectID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if u.TypeID != nil {
|
|
if _, err := s.GetCableType(*u.TypeID); err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return nil, fmt.Errorf("%w: cable type %d not found", ErrInvalidInput, *u.TypeID)
|
|
}
|
|
return nil, err
|
|
}
|
|
cur.TypeID = *u.TypeID
|
|
}
|
|
if u.Label != nil {
|
|
v := strings.TrimSpace(*u.Label)
|
|
if v == "" {
|
|
cur.Label = nil
|
|
} else {
|
|
cur.Label = &v
|
|
}
|
|
}
|
|
if u.XOffset != nil {
|
|
cur.XOffset = *u.XOffset
|
|
}
|
|
if u.YOffset != nil {
|
|
cur.YOffset = *u.YOffset
|
|
}
|
|
var labelArg any
|
|
if cur.Label != nil {
|
|
labelArg = *cur.Label
|
|
}
|
|
if _, err := s.db.Exec(
|
|
`UPDATE ports
|
|
SET type_id = ?, label = ?, x_offset = ?, y_offset = ?, updated_at = datetime('now')
|
|
WHERE id = ? AND project_id = ?`,
|
|
cur.TypeID, labelArg, cur.XOffset, cur.YOffset, id, projectID,
|
|
); err != nil {
|
|
return nil, mapWriteErr(err)
|
|
}
|
|
return s.GetPort(projectID, id)
|
|
}
|
|
|
|
// DeletePort removes a port from a device. The schema's
|
|
// ON DELETE SET NULL on cables.from_port_id / to_port_id collides with
|
|
// the cable's CHECK ((from_port|from_device|from_io) = 1 non-null), so
|
|
// we instead cascade-delete any cables that referenced the port on
|
|
// either side — same effect from m's POV: the cable is gone, m can
|
|
// re-draw if he still wants it.
|
|
func (s *Store) DeletePort(projectID, id int64) error {
|
|
if _, err := s.GetPort(projectID, id); err != nil {
|
|
return err
|
|
}
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err := tx.Exec(
|
|
`DELETE FROM cables WHERE project_id = ? AND (from_port_id = ? OR to_port_id = ?)`,
|
|
projectID, id, id,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(
|
|
`DELETE FROM ports WHERE id = ? AND project_id = ?`, id, projectID,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// ListPortsForDevice returns every port on one device, project-scoped.
|
|
func (s *Store) ListPortsForDevice(projectID, deviceID int64) ([]Port, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT id, project_id, device_id, type_id, label, x_offset, y_offset,
|
|
excalidraw_id, created_at, updated_at
|
|
FROM ports WHERE project_id = ? AND device_id = ? ORDER BY id`,
|
|
projectID, deviceID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []Port{}
|
|
for rows.Next() {
|
|
var p Port
|
|
var label, ex sql.NullString
|
|
if err := rows.Scan(&p.ID, &p.ProjectID, &p.DeviceID, &p.TypeID, &label,
|
|
&p.XOffset, &p.YOffset, &ex, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
if label.Valid {
|
|
v := label.String
|
|
p.Label = &v
|
|
}
|
|
if ex.Valid {
|
|
p.ExcalidrawID = &ex.String
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ListPortsForProject returns every port in a project, ordered by
|
|
// device_id + id so callers can group cheaply.
|
|
func (s *Store) ListPortsForProject(projectID int64) ([]Port, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT id, project_id, device_id, type_id, label, x_offset, y_offset,
|
|
excalidraw_id, created_at, updated_at
|
|
FROM ports WHERE project_id = ? ORDER BY device_id, id`, projectID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []Port{}
|
|
for rows.Next() {
|
|
var p Port
|
|
var label, ex sql.NullString
|
|
if err := rows.Scan(&p.ID, &p.ProjectID, &p.DeviceID, &p.TypeID,
|
|
&label, &p.XOffset, &p.YOffset, &ex, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
if label.Valid {
|
|
v := label.String
|
|
p.Label = &v
|
|
}
|
|
if ex.Valid {
|
|
p.ExcalidrawID = &ex.String
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// seedPortsFromType inserts the ports for a freshly-created device using
|
|
// the type's `device_type_ports` profile. Port positions are computed by
|
|
// laying out cable-type groups evenly along the configured edge of the
|
|
// device, ordered by sort_order. Within a multi-count group the per-port
|
|
// spacing is also even. Runs inside the same transaction as the device
|
|
// insert so a failure rolls everything back.
|
|
//
|
|
// Layout strategy (v0):
|
|
// - All ports for a given type sit on the type's configured edge.
|
|
// - For each edge, compute total port count N (sum of count across
|
|
// entries on that edge) and distribute as t_i = (i + 1)/(N+1) along
|
|
// the edge length, so ports don't touch the corners.
|
|
// - For top/bottom: x_offset = w * t, y_offset = 0 (top) or h (bottom).
|
|
// - For left/right: x_offset = 0 (left) or w (right), y_offset = h * t.
|
|
// - Labels: '<prefix>' if count==1, '<prefix> N' (1-indexed) if count>1.
|
|
// Empty prefix → NULL label.
|
|
func (s *Store) seedPortsFromType(tx *sql.Tx, projectID, deviceID, typeID int64, width, height float64) error {
|
|
rows, err := tx.Query(
|
|
`SELECT cable_type_id, label_prefix, count, edge, sort_order
|
|
FROM device_type_ports
|
|
WHERE device_type_id = ?
|
|
ORDER BY edge, sort_order, id`, typeID,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
type pendingPort struct {
|
|
cableTypeID int64
|
|
label *string
|
|
xOff float64
|
|
yOff float64
|
|
}
|
|
// Group rows by edge first; emit per-port y-or-x slots inside each edge.
|
|
type groupRow struct {
|
|
cableTypeID int64
|
|
labelPrefix string
|
|
count int
|
|
}
|
|
byEdge := map[string][]groupRow{}
|
|
for rows.Next() {
|
|
var g groupRow
|
|
var edge string
|
|
var sortOrder int
|
|
if err := rows.Scan(&g.cableTypeID, &g.labelPrefix, &g.count, &edge, &sortOrder); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
byEdge[edge] = append(byEdge[edge], g)
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return err
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
var pending []pendingPort
|
|
for _, edge := range []string{"top", "bottom", "left", "right"} {
|
|
groups := byEdge[edge]
|
|
if len(groups) == 0 {
|
|
continue
|
|
}
|
|
total := 0
|
|
for _, g := range groups {
|
|
total += g.count
|
|
}
|
|
if total == 0 {
|
|
continue
|
|
}
|
|
// Emit ports in group + within-group order.
|
|
idx := 0
|
|
for _, g := range groups {
|
|
for k := 0; k < g.count; k++ {
|
|
t := float64(idx+1) / float64(total+1)
|
|
var xOff, yOff float64
|
|
switch edge {
|
|
case "top":
|
|
xOff, yOff = width*t, 0
|
|
case "bottom":
|
|
xOff, yOff = width*t, height
|
|
case "left":
|
|
xOff, yOff = 0, height*t
|
|
case "right":
|
|
xOff, yOff = width, height*t
|
|
}
|
|
var labelPtr *string
|
|
if g.labelPrefix != "" {
|
|
var lbl string
|
|
if g.count == 1 {
|
|
lbl = g.labelPrefix
|
|
} else {
|
|
lbl = g.labelPrefix + " " + itoa(k+1)
|
|
}
|
|
labelPtr = &lbl
|
|
}
|
|
pending = append(pending, pendingPort{
|
|
cableTypeID: g.cableTypeID, label: labelPtr,
|
|
xOff: xOff, yOff: yOff,
|
|
})
|
|
idx++
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, p := range pending {
|
|
var labelArg any
|
|
if p.label != nil {
|
|
labelArg = *p.label
|
|
}
|
|
if _, err := tx.Exec(
|
|
`INSERT INTO ports (project_id, device_id, type_id, label, x_offset, y_offset)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
projectID, deviceID, p.cableTypeID, labelArg, p.xOff, p.yOff,
|
|
); err != nil {
|
|
return mapWriteErr(err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// itoa is a tiny non-allocating int-to-string for port labels.
|
|
func itoa(i int) string {
|
|
if i == 0 {
|
|
return "0"
|
|
}
|
|
buf := [20]byte{}
|
|
pos := len(buf)
|
|
neg := i < 0
|
|
if neg {
|
|
i = -i
|
|
}
|
|
for i > 0 {
|
|
pos--
|
|
buf[pos] = byte('0' + i%10)
|
|
i /= 10
|
|
}
|
|
if neg {
|
|
pos--
|
|
buf[pos] = '-'
|
|
}
|
|
return string(buf[pos:])
|
|
}
|