Schema already in 001_init.sql; this is just the Go store layer. IO markers are project-scoped wall-outlet terminators (a cable's "this end plugs into a wall socket outside the diagram" endpoint). Power-by-convention; no schema-level type enforcement. - CreateIOMarker validates frame_id is in the same project (cross-project ref → ErrInvalidInput), defaults label to "IO" when blank. - GetIOMarker is project-scoped — wrong-project read returns ErrNotFound. - UpdateIOMarker uses the FrameRef tri-state for frame_id (same as DeviceUpdate) so callers can clear it explicitly. - DeleteIOMarker is direct delete — ON DELETE SET NULL from the schema drops the io_markers.frame_id ref cleanly when the frame is deleted (verified by TestDeleteFrame_SetsIOMarkerFrameIDToNull). Snapshot now populates IOMarkers from the store; field type tightened from []any to []IOMarker. 7 new table-driven tests, all green with -race.
181 lines
4.8 KiB
Go
181 lines
4.8 KiB
Go
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// IOMarker is a wall-outlet terminator inside a project. Mostly Power
|
|
// by convention; the schema doesn't enforce it.
|
|
type IOMarker struct {
|
|
ID int64 `json:"id"`
|
|
ProjectID int64 `json:"project_id"`
|
|
FrameID *int64 `json:"frame_id"`
|
|
Label string `json:"label"`
|
|
X float64 `json:"x"`
|
|
Y float64 `json:"y"`
|
|
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// IOMarkerCreate is the create-shape.
|
|
type IOMarkerCreate struct {
|
|
FrameID *int64
|
|
Label string
|
|
X float64
|
|
Y float64
|
|
}
|
|
|
|
// IOMarkerUpdate is the partial-update shape. project_id deliberately not
|
|
// settable; frame_id uses the same tri-state shape as DeviceUpdate.FrameID.
|
|
type IOMarkerUpdate struct {
|
|
Label *string
|
|
FrameID FrameRef
|
|
X *float64
|
|
Y *float64
|
|
}
|
|
|
|
// CreateIOMarker inserts a new IO marker. If frame_id is set, it must
|
|
// reference a frame in the same project.
|
|
func (s *Store) CreateIOMarker(projectID int64, m IOMarkerCreate) (*IOMarker, error) {
|
|
label := strings.TrimSpace(m.Label)
|
|
if label == "" {
|
|
label = "IO"
|
|
}
|
|
if _, err := s.GetProject(projectID); err != nil {
|
|
return nil, err
|
|
}
|
|
if m.FrameID != nil {
|
|
if _, err := s.GetFrame(projectID, *m.FrameID); err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return nil, fmt.Errorf("%w: frame_id %d not in project %d", ErrInvalidInput, *m.FrameID, projectID)
|
|
}
|
|
return nil, err
|
|
}
|
|
}
|
|
res, err := s.db.Exec(
|
|
`INSERT INTO io_markers (project_id, frame_id, label, x, y)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
projectID, nullableInt64(m.FrameID), label, m.X, m.Y,
|
|
)
|
|
if err != nil {
|
|
return nil, mapWriteErr(err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return s.GetIOMarker(projectID, id)
|
|
}
|
|
|
|
// GetIOMarker loads an IO marker, project-scoped.
|
|
func (s *Store) GetIOMarker(projectID, id int64) (*IOMarker, error) {
|
|
var m IOMarker
|
|
var frame sql.NullInt64
|
|
var ex sql.NullString
|
|
err := s.db.QueryRow(
|
|
`SELECT id, project_id, frame_id, label, x, y, excalidraw_id, created_at, updated_at
|
|
FROM io_markers WHERE id = ? AND project_id = ?`, id, projectID,
|
|
).Scan(&m.ID, &m.ProjectID, &frame, &m.Label, &m.X, &m.Y, &ex, &m.CreatedAt, &m.UpdatedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if frame.Valid {
|
|
v := frame.Int64
|
|
m.FrameID = &v
|
|
}
|
|
if ex.Valid {
|
|
m.ExcalidrawID = &ex.String
|
|
}
|
|
return &m, nil
|
|
}
|
|
|
|
// ListIOMarkers returns every IO marker in a project, ordered by creation.
|
|
func (s *Store) ListIOMarkers(projectID int64) ([]IOMarker, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT id, project_id, frame_id, label, x, y, excalidraw_id, created_at, updated_at
|
|
FROM io_markers WHERE project_id = ? ORDER BY created_at, id`, projectID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []IOMarker{}
|
|
for rows.Next() {
|
|
var m IOMarker
|
|
var frame sql.NullInt64
|
|
var ex sql.NullString
|
|
if err := rows.Scan(&m.ID, &m.ProjectID, &frame, &m.Label, &m.X, &m.Y,
|
|
&ex, &m.CreatedAt, &m.UpdatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
if frame.Valid {
|
|
v := frame.Int64
|
|
m.FrameID = &v
|
|
}
|
|
if ex.Valid {
|
|
m.ExcalidrawID = &ex.String
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// UpdateIOMarker applies a partial update. project_id is locked; frame_id
|
|
// tri-state mirrors DeviceUpdate.FrameID.
|
|
func (s *Store) UpdateIOMarker(projectID, id int64, u IOMarkerUpdate) (*IOMarker, error) {
|
|
cur, err := s.GetIOMarker(projectID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if u.Label != nil {
|
|
v := strings.TrimSpace(*u.Label)
|
|
if v == "" {
|
|
return nil, fmt.Errorf("%w: label cannot be empty", ErrInvalidInput)
|
|
}
|
|
cur.Label = v
|
|
}
|
|
if u.X != nil {
|
|
cur.X = *u.X
|
|
}
|
|
if u.Y != nil {
|
|
cur.Y = *u.Y
|
|
}
|
|
if u.FrameID.Set {
|
|
if u.FrameID.ID != nil {
|
|
if _, err := s.GetFrame(projectID, *u.FrameID.ID); err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return nil, fmt.Errorf("%w: frame_id %d not in project %d", ErrInvalidInput, *u.FrameID.ID, projectID)
|
|
}
|
|
return nil, err
|
|
}
|
|
}
|
|
cur.FrameID = u.FrameID.ID
|
|
}
|
|
if _, err := s.db.Exec(
|
|
`UPDATE io_markers
|
|
SET frame_id = ?, label = ?, x = ?, y = ?, updated_at = datetime('now')
|
|
WHERE id = ? AND project_id = ?`,
|
|
nullableInt64(cur.FrameID), cur.Label, cur.X, cur.Y, id, projectID,
|
|
); err != nil {
|
|
return nil, mapWriteErr(err)
|
|
}
|
|
return s.GetIOMarker(projectID, id)
|
|
}
|
|
|
|
// DeleteIOMarker removes an IO marker from a project.
|
|
func (s *Store) DeleteIOMarker(projectID, id int64) error {
|
|
if _, err := s.GetIOMarker(projectID, id); err != nil {
|
|
return err
|
|
}
|
|
if _, err := s.db.Exec(
|
|
`DELETE FROM io_markers WHERE id = ? AND project_id = ?`, id, projectID,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|