api.spaceplanner.app

Spaceplanner API
git clone git://jacobedwards.org/api.spaceplanner.app
Log | Files | Refs

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:
Mcmd/api/floorplans.go | 32+++++++++++++++++++++++++++++++-
Mcmd/api/main.go | 4++++
Acmd/api/migration/2024-08-13T22:49:12.sql | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcmd/api/patch.go | 16++++++----------
Mcmd/api/users.go | 2+-
Minternal/backend/floorplan_data.go | 375+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Ainternal/backend/patch.go | 8++++++++
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"` +}