commit c10d1b070d4990184385e41ec6e6d89b32bec8da
parent cc906558099d07bae600bd7cf5228e3668f366d1
Author: Jacob R. Edwards <jacob@jacobedwards.org>
Date: Fri, 16 Aug 2024 17:49:04 -0700
Add floorplan patching and fix deletion
Not entirely happy with how long and verbose the patching functions
are, but I'm not sure how to shorten them right now.
Diffstat:
7 files changed, 475 insertions(+), 19 deletions(-)
diff --git a/cmd/api/floorplans.go b/cmd/api/floorplans.go
@@ -40,7 +40,7 @@ func (e *Env) UpdateFloorplan(c *gin.Context) {
user := c.Param("user")
name := c.Param("floorplan")
- patches := make([]Patch, 16)
+ patches := make([]backend.Patch, 16)
if err := c.ShouldBind(&patches); err != nil {
RespondError(c, 400, "Unable to read patchset")
return
@@ -122,6 +122,36 @@ func (e *Env) GetFloorplan(c *gin.Context) {
}
}
+func (e *Env) GetFloorplanData(c *gin.Context) {
+ user := c.Param("user")
+ floorplan := c.Param("floorplan")
+
+ data, err := e.backend.GetFloorplanData(nil, user, floorplan)
+ if err != nil {
+ RespondError(c, 400, "%s", err.Error())
+ } else {
+ Respond(c, http.StatusOK, data)
+ }
+}
+
+func (e *Env) PatchFloorplanData(c *gin.Context) {
+ var patch []backend.Patch
+ user := c.Param("user")
+ floorplan := c.Param("floorplan")
+
+ if err := c.ShouldBind(&patch); err != nil {
+ RespondError(c, 400, "%s: Unable to get patch", err.Error())
+ return
+ }
+
+ data, err := e.backend.PatchFloorplanData(nil, user, floorplan, patch)
+ if err != nil {
+ RespondError(c, 400, "%s: Unable to patch floorplan", err.Error())
+ } else {
+ Respond(c, http.StatusOK, data)
+ }
+}
+
func patchableToSettable(data map[string]interface{}) (*SettableFloorplan, error) {
// Seems stupidly inefficient, but I can't find a better way at the moment
jsondata, err := json.Marshal(data)
diff --git a/cmd/api/main.go b/cmd/api/main.go
@@ -95,6 +95,10 @@ func setAuthenticatedRoutes(env *Env, r *gin.RouterGroup) {
fp.PATCH("", env.UpdateFloorplan)
fp.DELETE("", env.DeleteFloorplan)
fp.GET("", env.GetFloorplan)
+
+ fpdata := fp.Group("/data")
+ fpdata.GET("", env.GetFloorplanData)
+ fpdata.PATCH("", env.PatchFloorplanData)
}
func noRoute(c *gin.Context) {
diff --git a/cmd/api/migration/2024-08-13T22:49:12.sql b/cmd/api/migration/2024-08-13T22:49:12.sql
@@ -0,0 +1,57 @@
+BEGIN;
+
+-- Check not null in points
+ALTER TABLE spaceplanner.floorplan_points ADD CONSTRAINT not_null CHECK (
+ x NOTNULL AND y NOTNULL AND floorplan NOTNULL
+);
+
+-- Make id bigserial in points
+ALTER TABLE spaceplanner.floorplan_points ALTER COLUMN id TYPE bigint ;
+CREATE SEQUENCE floorplan_points_id_seq;
+ALTER SEQUENCE floorplan_points_id_seq OWNED BY spaceplanner.floorplan_points.id;
+ALTER TABLE spaceplanner.floorplan_points ALTER COLUMN id SET DEFAULT nextval('floorplan_points_id_seq');
+
+-- Change primary key in points
+ALTER TABLE spaceplanner.floorplan_points DROP CONSTRAINT floorplan_points_pkey;
+ALTER TABLE spaceplanner.floorplan_points ADD CONSTRAINT floorplan_points_pkey PRIMARY KEY (id);
+
+-- Cascade delete on points (floorplan)
+ALTER TABLE spaceplanner.floorplan_points ADD CONSTRAINT points_floorplan_fkey
+ FOREIGN KEY (floorplan) REFERENCES spaceplanner.floorplans(id) ON DELETE CASCADE ;
+
+-- Add pointmaps
+CREATE TABLE spaceplanner.floorplan_pointmaps (
+ id bigserial PRIMARY KEY,
+ floorplan bigint REFERENCES spaceplanner.floorplans(id) NOT NULL
+ ON DELETE CASCADE,
+ a bigint REFERENCES spaceplanner.floorplan_points(id) NOT NULL
+ ON DELETE CASCADE,
+ b bigint REFERENCES spaceplanner.floorplan_points(id) NOT NULL
+ ON DELETE CASCADE,
+ type varchar NOT NULL,
+ CONSTRAINT unique_pointmap UNIQUE (floorplan, type, a, b),
+ CONSTRAINT different_ids CHECK (a <> b),
+ CONSTRAINT valid_type CHECK (type = 'wall' OR type = 'door')
+);
+
+CREATE FUNCTION spaceplanner.pointmap_points_same_floorplan()
+ RETURNS trigger AS $pointmap_points_same_floorplan$
+ DECLARE
+ bad int;
+ BEGIN
+ SELECT count(floorplan) INTO bad
+ FROM spaceplanner.floorplan_points
+ WHERE (id = NEW.a OR id = NEW.b) AND floorplan <> NEW.floorplan;
+ IF (bad <> 0) THEN
+ RAISE EXCEPTION 'points must be from the same floorplan as pointmap';
+ END IF;
+ RETURN NEW;
+ END;
+ $pointmap_points_same_floorplan$ LANGUAGE plpgsql;
+
+CREATE CONSTRAINT TRIGGER check_pointmap_points_floorplan
+ AFTER INSERT OR UPDATE ON floorplan_pointmaps
+ FOR EACH ROW
+ EXECUTE FUNCTION spaceplanner.pointmap_points_same_floorplan();
+
+COMMIT;
diff --git a/cmd/api/patch.go b/cmd/api/patch.go
@@ -1,15 +1,11 @@
package main
-import "errors"
+import (
+ "errors"
+ "jacobedwards.org/spaceplanner.app/internal/backend"
+)
-type Patch struct {
- Op string `json:"op" binding:"required"`
- Path string `json:"path" binding:"required"`
- From string `json:"from"`
- Value interface{} `json:"value"`
-}
-
-func applyPatchset(data map[string]interface{}, patches []Patch) error {
+func applyPatchset(data map[string]interface{}, patches []backend.Patch) error {
for _, patch := range patches {
if err := applyPatch(data, patch); err != nil {
return err
@@ -18,7 +14,7 @@ func applyPatchset(data map[string]interface{}, patches []Patch) error {
return nil
}
-func applyPatch(data map[string]interface{}, p Patch) error {
+func applyPatch(data map[string]interface{}, p backend.Patch) error {
// I'm aware path's are suppost to be able to be of any depth. Later.
v, exists := data[p.Path]
switch p.Op {
diff --git a/cmd/api/users.go b/cmd/api/users.go
@@ -81,7 +81,7 @@ func (e *Env) UpdateUserSettings(c *gin.Context) {
return
}
- patches := make([]Patch, 16)
+ patches := make([]backend.Patch, 16)
if err := c.ShouldBind(&patches); err != nil {
RespondError(c, 400, "Unable to read patchset")
return
diff --git a/internal/backend/floorplan_data.go b/internal/backend/floorplan_data.go
@@ -1,24 +1,56 @@
package backend
import (
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
"database/sql"
+ "encoding/json"
)
type Point struct {
- Id int `json:"id" binding:"required"`
+ id int64 `json:"id"`
+ OldId *int64 `json:"old_id,omitempty"`
X int `json:"x" binding:"required"`
Y int `json:"y" binding:"required"`
}
+type PointMap struct {
+ id int64
+ OldId *int64 `json:"old_id,omitempty"`
+ Type string `json:"type" binding:"required"`
+ A int64 `json:"a" binding:"required"`
+ B int64 `json:"b" binding:"required"`
+}
+
type FloorplanData struct {
- points []Point
+ Points map[int64]Point `json:"points"`
+ Pointmaps map[int64]PointMap `json:"pointmaps"`
+}
+
+type rowReference struct {
+ table string
+ id int64
+}
+
+type PatchError struct {
+ ref *rowReference
+ rawref *string
+ msg *string
+ extra error
}
func (e *Env) GetFloorplanData(tx *sql.Tx, user string, floorplan string) (FloorplanData, error) {
var data FloorplanData
var err error
- data.points, err = e.getFloorplanPoints(tx, user, floorplan)
+ data.Points, err = e.getFloorplanPoints(tx, user, floorplan)
+ if err != nil {
+ return data, err
+ }
+
+ data.Pointmaps, err = e.getFloorplanPointMaps(tx, user, floorplan)
if err != nil {
return data, err
}
@@ -26,9 +58,30 @@ func (e *Env) GetFloorplanData(tx *sql.Tx, user string, floorplan string) (Floor
return data, nil
}
-func (e *Env) getFloorplanPoints(tx *sql.Tx, user string, floorplan string) ([]Point, error) {
+func (e *Env) getFloorplanPoints(tx *sql.Tx, user string, floorplan string) (map[int64]Point, error) {
stmt, err := e.CacheTxStmt(tx, "get_points",
- `SELECT (id, x, y) FROM spaceplanner.floorplan_points WHERE
+ `SELECT id, x, y FROM spaceplanner.floorplan_points WHERE
+ floorplan = spaceplanner.floorplan_id($1, $2)`)
+ if err != nil {
+ return nil, err
+ }
+
+ rows, err := stmt.Query(user, floorplan)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ points, err := collectRows(rows, scanPoint)
+ if err != nil {
+ return nil, err
+ }
+ return mapArray(points, mapPoint)
+}
+
+func (e *Env) getFloorplanPointMaps(tx *sql.Tx, user string, floorplan string) (map[int64]PointMap, error) {
+ stmt, err := e.CacheTxStmt(tx, "get_pointmaps",
+ `SELECT id, type, a, b FROM spaceplanner.floorplan_pointmaps WHERE
floorplan = spaceplanner.floorplan_id($1, $2)`)
if err != nil {
return nil, err
@@ -39,12 +92,320 @@ func (e *Env) getFloorplanPoints(tx *sql.Tx, user string, floorplan string) ([]P
return nil, err
}
defer rows.Close()
- return collectRows(rows, scanPoint)
+ pointmaps, err := collectRows(rows, scanPointMap)
+ if err != nil {
+ return nil, err
+ }
+ return mapArray(pointmaps, mapPointMap)
+}
+
+func (e *Env) PatchFloorplanData(tx *sql.Tx, user string, floorplan string, patches []Patch) (FloorplanData, error) {
+ var err error
+ mytx := false
+
+ if (tx == nil) {
+ tx, err = e.DB.Begin()
+ if err != nil {
+ return FloorplanData{}, err
+ }
+ defer tx.Rollback()
+ mytx = true
+ }
+
+ new_point_ids := make(map[int64]int64)
+ new_pointmap_ids := make(map[int64]int64)
+
+ data := FloorplanData{}
+ data.Points = make(map[int64]Point)
+ data.Pointmaps = make(map[int64]PointMap)
+
+ // Allowed operations are new, replace, and delete
+ // (new is an extention that ensures path's don't exist before creation)
+ for _, patch := range patches {
+ ref, err := parseRowReference(patch.Path)
+ if err != nil {
+ return data, PatchError{}.New(nil, &patch.Path, "Invalid path", err)
+ }
+
+ if (patch.Op == "new" || patch.Op == "replace") && patch.Value == nil {
+ return data, ref.Error("Requires value", nil)
+ }
+
+ if (ref.table == "points") {
+ point, err := e.patchPoint(tx, user, floorplan, &patch, &ref, new_point_ids)
+ if err != nil {
+ return data, ref.Error("", err)
+ }
+ data.Points[point.id] = *point
+ } else if (ref.table == "pointmaps") {
+ pointmap, err := e.patchPointMap(tx, user, floorplan, &patch, &ref, new_pointmap_ids, new_point_ids)
+ if err != nil {
+ return data, ref.Error("", err)
+ }
+ data.Pointmaps[pointmap.id] = *pointmap
+ } else {
+ return data, ref.Error("Path does not exist", nil)
+ }
+ }
+
+ if (mytx) {
+ err = tx.Commit()
+ if err != nil {
+ return data, PatchError{}.New(nil, nil, "Unable to commit patch", nil)
+ }
+ }
+
+ return data, nil
+}
+
+func (e *Env) patchPoint(tx *sql.Tx, user string, floorplan string, patch *Patch,
+ ref *rowReference, idmap map[int64]int64) (*Point, error) {
+ if ref.table != "points" {
+ return nil, ref.Error("Expected points", nil)
+ }
+
+ id := ref.id
+ if newid, exists := idmap[ref.id]; exists {
+ id = newid
+ }
+
+ if (patch.Op == "remove") {
+ if _, err := e.deletePoint(tx, user, floorplan, id); err != nil {
+ return nil, ref.Error("", err)
+ }
+ return nil, nil
+ }
+
+ var point Point
+ if err := remarshal(patch.Value, &point); err != nil {
+ return nil, ref.Error("parse value", err)
+ }
+
+ var err error
+ if (patch.Op == "new") {
+ point, err = e.addPoint(tx, user, floorplan, point)
+ if point.id != id {
+ idmap[id] = point.id
+ point.OldId = &id
+ }
+ } else if (patch.Op == "replace") {
+ point.id = id
+ point, err = e.replacePoint(tx, user, floorplan, point)
+ } else {
+ return nil, ref.Error("Unsupported operation", nil)
+ }
+
+ if err != nil {
+ return nil, err
+ }
+
+ return &point, nil
+}
+
+func (e *Env) patchPointMap(tx *sql.Tx, user string, floorplan string, patch *Patch,
+ ref *rowReference, idmap map[int64]int64, pointidmap map[int64]int64) (*PointMap, error) {
+ if ref.table != "pointmaps" {
+ return nil, ref.Error("Expected pointmaps", nil)
+ }
+
+ id := ref.id
+ if newid, exists := idmap[ref.id]; exists {
+ id = newid
+ }
+
+ if (patch.Op == "remove") {
+ if _, err := e.deletePointMap(tx, user, floorplan, id); err != nil {
+ return nil, ref.Error("", err)
+ }
+ return nil, nil
+ }
+
+ var pointmap PointMap
+ if err := remarshal(patch.Value, &pointmap); err != nil {
+ return nil, ref.Error("", err)
+ }
+ if newid, exists := pointidmap[pointmap.A]; exists {
+ pointmap.A = newid
+ }
+ if newid, exists := pointidmap[pointmap.B]; exists {
+ pointmap.B = newid
+ }
+
+ var err error
+ if (patch.Op == "new") {
+ pointmap, err = e.addPointMap(tx, user, floorplan, pointmap)
+ if pointmap.id != id {
+ pointidmap[id] = pointmap.id
+ pointmap.OldId = &id
+ }
+ } else if (patch.Op == "replace") {
+ pointmap.id = id
+ pointmap, err = e.replacePointMap(tx, user, floorplan, pointmap)
+ } else {
+ return nil, ref.Error("Unsupported operation", nil)
+ }
+
+ if err != nil {
+ return nil, ref.Error("", err)
+ }
+
+ return &pointmap, nil
+}
+
+func remarshal[T any](value interface{}, result *T) (error) {
+ s, err := json.Marshal(value)
+ if err != nil {
+ return err
+ }
+ return json.Unmarshal(s, result)
+}
+
+func (e *Env) addPoint(tx *sql.Tx, user string, floorplan string, point Point) (Point, error) {
+ stmt, err := e.CacheTxStmt(tx, "add_point",`INSERT INTO spaceplanner.floorplan_points (floorplan, x, y)
+ VALUES (spaceplanner.floorplan_id($1, $2), $3, $4) RETURNING id, x, y`)
+ if err != nil {
+ return Point{}, err
+ }
+
+ return scanPoint(stmt.QueryRow(user, floorplan, point.X, point.Y))
+}
+
+func (e *Env) replacePoint(tx *sql.Tx, user string, floorplan string, point Point) (Point, error) {
+ stmt, err := e.CacheTxStmt(tx, "repl_point", `UPDATE spaceplanner.floorplan_points SET (x, y) = ($4, $5)
+ WHERE floorplan = floorplan_id($1, $2) AND id = $3 RETURNING id, x, y`)
+ if err != nil {
+ return Point{}, err
+ }
+
+ return scanPoint(stmt.QueryRow(user, floorplan, point.id, point.X, point.Y))
+}
+
+func (e *Env) deletePoint(tx *sql.Tx, user string, floorplan string, id int64) (Point, error) {
+ stmt, err := e.CacheTxStmt(tx, "dele_point",
+ `DELETE spaceplanner.floorplan_points WHERE floorplan = floorplan_id($1, $2) AND id = $3`)
+ if err != nil {
+ return Point{}, err
+ }
+
+ return scanPoint(stmt.QueryRow(user, floorplan, id))
+}
+
+func (e *Env) addPointMap(tx *sql.Tx, user string, floorplan string, pointmap PointMap) (PointMap, error) {
+ stmt, err := e.CacheTxStmt(tx, "add_pointmap",
+ `INSERT INTO spaceplanner.floorplan_pointmaps (floorplan, type, a, b) VALUES (
+ floorplan_id($1, $2), $3, $4, $5
+ ) RETURNING id, type, a, b`)
+ if err != nil {
+ return PointMap{}, err
+ }
+
+ return scanPointMap(stmt.QueryRow(user, floorplan, pointmap.Type, pointmap.A, pointmap.B))
+}
+
+func (e *Env) replacePointMap(tx *sql.Tx, user string, floorplan string, pointmap PointMap) (PointMap, error) {
+ stmt, err := e.CacheTxStmt(tx, "repl_pointmap",
+ `UPDATE spaceplanner.floorplan_pointmaps SET (type, a, b) =
+ ($4, $5, $6) WHERE floorplan = floorplan_id($1, $2) AND id = $3
+ RETURNING id, type, a, b`)
+ if err != nil {
+ return PointMap{}, err
+ }
+
+ return scanPointMap(stmt.QueryRow(user, floorplan, pointmap.id, pointmap.Type, pointmap.A, pointmap.B))
+}
+
+func (e *Env) deletePointMap(tx *sql.Tx, user string, floorplan string, id int64) (PointMap, error) {
+ stmt, err := e.CacheTxStmt(tx, "dele_pointmap",
+ `DELETE FROM spaceplanner.floorplan_pointmaps
+ WHERE floorplan = floorplan_id($1, $2) AND id = $3`)
+ if err != nil {
+ return PointMap{}, err
+ }
+
+ return scanPointMap(stmt.QueryRow(user, floorplan, id))
+}
+
+
+func parseRowReference(ref string) (rowReference, error) {
+ // Ref should look like this: /type/id so three
+ // segments (including before initial slash)
+ parts := strings.Split(ref, "/")
+ if (len(parts) != 3) {
+ return rowReference{}, errors.New(ref + ": Invalid reference")
+ }
+
+ id, err := strconv.ParseInt(parts[2], 10, 64)
+ if err != nil {
+ return rowReference{}, errors.New(ref + ": Invalid id")
+ }
+
+ return rowReference{table: parts[1], id: id}, nil
}
func scanPoint(row Scanner) (Point, error) {
var point Point
- err := row.Scan(&point.Id, &point.X, &point.Y)
+ err := row.Scan(&point.id, &point.X, &point.Y)
return point, err
}
+
+func scanPointMap(row Scanner) (PointMap, error) {
+ var pointmap PointMap
+
+ err := row.Scan(&pointmap.id, &pointmap.Type, &pointmap.A, &pointmap.B)
+ return pointmap, err
+}
+
+func mapArray[K comparable, V any](array []V, mapper func(V) (K, error)) (map[K]V, error) {
+ m := make(map[K]V, len(array))
+
+ for _, v := range array {
+ key, err := mapper(v)
+ if err != nil {
+ return nil, err
+ }
+ m[key] = v
+ }
+ return m, nil
+}
+
+func mapPoint(p Point) (int64, error) {
+ return p.id, nil
+}
+
+func mapPointMap(p PointMap) (int64, error) {
+ return p.id, nil
+}
+
+func (ref rowReference) Error(msg string, extra error) PatchError {
+ return PatchError{}.New(&ref, nil, msg, extra)
+}
+
+func (e PatchError) New(ref *rowReference, rawref *string, msg string, extra error) PatchError {
+ e.ref = ref
+ e.rawref = rawref
+ if len(msg) > 0 {
+ e.msg = &msg
+ }
+ e.extra = extra
+ return e;
+}
+
+func (e PatchError) Error() string {
+ var err string
+
+ // Assume msg or extra will be defined (they should be)
+ if e.ref != nil {
+ err = fmt.Sprintf("/%s/%d: ", e.ref.table, e.ref.id)
+ } else if e.rawref != nil {
+ err = fmt.Sprintf("%s: ", e.rawref)
+ }
+
+ if e.msg != nil{
+ err = fmt.Sprintf("%s: %s", err, e.msg)
+ }
+ if e.extra != nil {
+ err = fmt.Sprintf("%s: %s", err, e.extra.Error())
+ }
+ return err
+}
diff --git a/internal/backend/patch.go b/internal/backend/patch.go
@@ -0,0 +1,8 @@
+package backend
+
+type Patch struct {
+ Op string `json:"op" binding:"required"`
+ Path string `json:"path" binding:"required"`
+ From string `json:"from"`
+ Value interface{} `json:"value"`
+}