api.spaceplanner.app

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

commit 38e62fcf004fd95853159418bd83892bbe9d27f2
parent dfc9b0e0d9bc0ffdd81191b418a9164ac45d6163
Author: Jacob R. Edwards <jacob@jacobedwards.org>
Date:   Tue,  3 Sep 2024 16:58:41 -0700

Add furniture

The furniture is split into definitions (say, a specific table in
the house) and placements or maps of those definitions in a given
layout. In this way you can define all the furniture you want to
use once and switch between multiple layouts using that defined
furniture.

Diffstat:
Mcmd/api/floorplans.go | 9+++++++++
Mcmd/api/main.go | 1+
Acmd/api/migration/2024-09-02T02:12:53.sql | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/backend/floorplan_data.go | 348++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
4 files changed, 443 insertions(+), 12 deletions(-)

diff --git a/cmd/api/floorplans.go b/cmd/api/floorplans.go @@ -19,6 +19,15 @@ type Point struct { Y *int `json:"y" binding:"required"` } +func (e *Env) FurnitureTypes(c *gin.Context) { + types, err := e.backend.FurnitureTypes(nil) + if err != nil { + RespondError(c, 500, "%s: Unable to get types", err.Error()) + } else { + Respond(c, 200, types) + } +} + func (e *Env) CreateFloorplan(c *gin.Context) { var req SettableFloorplan diff --git a/cmd/api/main.go b/cmd/api/main.go @@ -89,6 +89,7 @@ func setRoutes(env *Env, r *gin.RouterGroup) { r.GET("/tokens", env.Auth.RefreshHandler) r.GET("/settings", env.GetSettings) r.GET("/services", env.GetServices) + r.GET("/furniture", env.FurnitureTypes) users := r.Group("/users") users.GET("/:user", env.GetUser) diff --git a/cmd/api/migration/2024-09-02T02:12:53.sql b/cmd/api/migration/2024-09-02T02:12:53.sql @@ -0,0 +1,97 @@ +BEGIN; + +CREATE FUNCTION spaceplanner.in (int) + RETURNS bigint + LANGUAGE sql + STABLE + RETURNS NULL ON NULL INPUT + RETURN (SELECT $1 * 96); + +CREATE FUNCTION spaceplanner.ft (int) + RETURNS bigint + LANGUAGE sql + STABLE + RETURNS NULL ON NULL INPUT + RETURN (SELECT spaceplanner.in($1) * 12); + +-- NOTE: A lot of these tables put a floorplan id in them +-- to provide some check of correctness, but it's not enough. +-- What needs to happen is first, for example with pointmaps, +-- both a and b need to be verified as the same floorplan, +-- and also it would be nice to eliminate floorplan ids in these +-- tables that reference others (such as point or furniture maps) +-- and use their references to points/furniture_defs to specify +-- their floorplan + +-- Types of furniture +-- (I don't intend to allow users to create types, who knows) +-- There will probably be a varieties table with specs for +-- certain varieties of furniture, such as a king bed +-- Guess I'd have to consider localization too though. +CREATE TABLE spaceplanner.furniture_types ( + name varchar PRIMARY KEY +); + +-- Add some basic types +INSERT INTO spaceplanner.furniture_types (name) + VALUES ('bed'), ('table'); + +CREATE TABLE spaceplanner.furniture_varieties ( + name varchar PRIMARY KEY, + type varchar + REFERENCES spaceplanner.furniture_types(name) + NOT NULL, + width int NOT NULL, + depth int NOT NULL, + CONSTRAINT varieties_unique_name_within_type UNIQUE (name, type) +); + +-- Taken from <https://en.wikipedia.org/wiki/Bed_size#North_America> +INSERT INTO spaceplanner.furniture_varieties (name, type, width, depth) +VALUES ( + 'King', 'bed', spaceplanner.in(76), spaceplanner.in(80) +), ( + 'Queen', 'bed', spaceplanner.in(60), spaceplanner.in(80) +), ( + 'Double', 'bed', spaceplanner.in(54), spaceplanner.in(75) +), ( + 'Twin', 'bed', spaceplanner.in(39), spaceplanner.in(75) +); + +CREATE TABLE spaceplanner.furniture ( + id bigserial PRIMARY KEY, + floorplan bigint + REFERENCES spaceplanner.floorplans(id) + ON DELETE CASCADE + NOT NULL, + -- This DOES NOT cascade + type varchar REFERENCES spaceplanner.furniture_types(name) + NOT NULL, + name varchar, + width int NOT NULL, + -- Was going to use 'height', but thought it might be confusing + depth int NOT NULL, + CONSTRAINT unique_furniture_name_within_floorplan UNIQUE(floorplan, name) +); + +-- Furnature placements, etc. +CREATE TABLE spaceplanner.furniture_maps ( + id bigserial PRIMARY KEY, + floorplan bigint + REFERENCES spaceplanner.floorplans(id) + ON DELETE CASCADE + NOT NULL, + furniture_id bigint + REFERENCES spaceplanner.furniture(id) + ON DELETE CASCADE + NOT NULL, + layout varchar NOT NULL DEFAULT '1', + -- Probably degree's since I'm simple, but maybe radians? + x int NOT NULL, + y int NOT NULL, + angle int NOT NULL, + CONSTRAINT angle_is_valid_degree CHECK (angle >= 0 AND angle < 360), + CONSTRAINT no_furniture_clone UNIQUE(furniture_id, layout) +); + +COMMIT; diff --git a/internal/backend/floorplan_data.go b/internal/backend/floorplan_data.go @@ -1,14 +1,33 @@ package backend import ( + "database/sql" + "encoding/json" "errors" "fmt" "strconv" "strings" - "database/sql" - "encoding/json" ) +import "log" + +type FloorplanData struct { + Points map[int64]Point `json:"points"` + Pointmaps map[int64]PointMap `json:"pointmaps"` + + // I would like to have this in a FurnitureData struct, + // but don't want to rework the client to accept that + // at the moment. + Furniture map[int64]Furniture `json:"furniture"` + FurnitureMaps map[int64]FurnitureMap `json:"furniture_maps"` +} + +/* + * NOTE: The id fields are not sent in response because it is encoded + * in the data structure. That is, they're in a map keyed by their ID + * (as seen above.) It's done this way so that the client can update + * individual objects with JSON Patch. + */ type Point struct { id int64 `json:"id"` OldID *int64 `json:"old_id,omitempty"` @@ -24,9 +43,26 @@ type PointMap struct { B int64 `json:"b" binding:"required"` } -type FloorplanData struct { - Points map[int64]Point `json:"points"` - Pointmaps map[int64]PointMap `json:"pointmaps"` +// NOTE: I'd like to allow every value to be omitted so +// long as it doesn't violate database rules, but without +// setting to null. +type Furniture struct { + id int64 + OldID *int64 `json:"old_id,omitempty"` + Type string `json:"type" binding:"required"` + Name *string `json:"name"` + Width int `json:"width" binding:"required"` + Depth int `json:"depth" binding:"required"` +} + +type FurnitureMap struct { + id int64 + OldID *int64 `json:"old_id,omitempty"` + FurnitureID int64 `json:"furniture_id" binding:"required"` + Layout string `json:"layout" binding:"required"` + X int `json:"x" binding:"required"` + Y int `json:"y" binding:"required"` + Angle int `json:"angle" binding:"required"` } type rowReference struct { @@ -55,10 +91,77 @@ type DBObject interface { Delete(e *Env, tx *sql.Tx, user, floorplan string) (DBObject, error) } +type FurnitureType struct { + Varieties map[string]FurnitureVariety `json:"varieties,omit_empty"` +} + +type FurnitureVariety struct { + Width int `json:"width"` + Depth int `json:"depth"` +} + type Mappable interface { Key() any } +func (e *Env) FurnitureTypes(tx *sql.Tx) (map[string]FurnitureType, error) { + types, err := e.CacheTxStmt(tx, "furn_types", + `SELECT name from spaceplanner.furniture_types`) + if err != nil { + return nil, err + } + vars, err := e.CacheTxStmt(tx, "furn_vars", + `SELECT type, name, width, depth from spaceplanner.furniture_varieties`) + if err != nil { + return nil, err + } + + rows, err := vars.Query() + if err != nil { + return nil, err + } + defer rows.Close() + + data := make(map[string]FurnitureType) + for rows.Next() { + var tname, vname string + var v FurnitureVariety + if err := rows.Scan(&tname, &vname, &v.Width, &v.Depth); err != nil { + return nil, err + } + + if t, exists := data[tname]; exists { + t.Varieties[vname] = v + } else { + t := FurnitureType{} + t.Varieties = make(map[string]FurnitureVariety) + t.Varieties[vname] = v + data[tname] = t + } + } + rows.Close() + + rows, err = types.Query() + if err != nil { + return nil, err + } + // Is this evaluating the expression at execution or now? + // that would change whether this should be run. + defer rows.Close() + + for rows.Next() { + var key string + if err := rows.Scan(&key); err != nil { + return nil, err + } + if _, exists := data[key]; !exists { + data[key] = FurnitureType{} + } + } + + return data, nil +} + func (e *Env) GetFloorplanData(tx *sql.Tx, user string, floorplan string) (FloorplanData, error) { var data FloorplanData var err error @@ -73,6 +176,16 @@ func (e *Env) GetFloorplanData(tx *sql.Tx, user string, floorplan string) (Floor return data, err } + data.Furniture, err = e.getFloorplanFurnitureDefs(tx, user, floorplan) + if err != nil { + return data, err + } + + data.FurnitureMaps, err = e.getFloorplanFurnitureMaps(tx, user, floorplan) + if err != nil { + return data, err + } + return data, nil } @@ -117,6 +230,56 @@ func (e *Env) getFloorplanPointMaps(tx *sql.Tx, user string, floorplan string) ( return mapArray(pointmaps, mapPointMap) } +func (e *Env) getFloorplanFurnitureDefs(tx *sql.Tx, user string, floorplan string) (map[int64]Furniture, error) { + defsStmt, err := e.CacheTxStmt(tx, "furniture_defs", + `SELECT id, type, name, width, depth + FROM spaceplanner.furniture + WHERE floorplan = floorplan_id($1, $2)`) + if err != nil { + return nil, err + } + + rows, err := defsStmt.Query(user, floorplan) + if err != nil { + return nil, err + } + defer rows.Close() + + defs, err := collectRows(rows, scanFurniture) + if err != nil { + return nil, err + } + + return mapArray(defs, mapFurniture) + +} + +func (e *Env) getFloorplanFurnitureMaps(tx *sql.Tx, user string, floorplan string) (map[int64]FurnitureMap, error) { + mapsStmt, err := e.CacheTxStmt(tx, "furniture_maps", + `SELECT id, furniture_id, layout, x, y, angle + FROM spaceplanner.furniture_maps + WHERE furniture_id IN ( + SELECT id + FROM spaceplanner.furniture + WHERE floorplan = floorplan_id($1, $2) + )`) + if err != nil { + return nil, err + } + + rows, err := mapsStmt.Query(user, floorplan) + if err != nil { + return nil, err + } + defer rows.Close() + + maps, err := collectRows(rows, scanFurnitureMap) + if err != nil { + return nil, err + } + return mapArray(maps, mapFurnitureMap) +} + func (e *Env) ReplaceFloorplanData(tx *sql.Tx, user string, floorplan string, data *FloorplanData) (FloorplanData, error) { mytx := false if (tx == nil) { @@ -192,6 +355,8 @@ func (e *Env) PatchFloorplanData(tx *sql.Tx, user string, floorplan string, patc data := FloorplanData{} data.Points = make(map[int64]Point) data.Pointmaps = make(map[int64]PointMap) + data.Furniture = make(map[int64]Furniture) + data.FurnitureMaps = make(map[int64]FurnitureMap) // Allowed operations are new, replace, and delete // (new is an extention that ensures path's don't exist before creation) @@ -221,6 +386,22 @@ func (e *Env) PatchFloorplanData(tx *sql.Tx, user string, floorplan string, patc if patch.Op != "delete" { data.Pointmaps[pointmap.id] = pointmap } + } else if (ref.table == "furniture") { + def, err := applyPatch[Furniture](e, tx, user, floorplan, &patch, ref, newRefs) + if err != nil { + return data, ref.Error("", err) + } + if patch.Op != "delete" { + data.Furniture[def.id] = def + } + } else if (ref.table == "furniture_maps") { + fm, err := applyPatch[FurnitureMap](e, tx, user, floorplan, &patch, ref, newRefs) + if err != nil { + return data, ref.Error("", err) + } + if patch.Op != "delete" { + data.FurnitureMaps[fm.id] = fm + } } else { return data, ref.Error("Path does not exist", nil) } @@ -256,6 +437,7 @@ func applyPatch[T DBObject](e *Env, tx *sql.Tx, user, floorplan string, patch *P // can't figure it out right now. I'll come back to it later // but for now a few type assertions thing = thing.updateRefs(ref, newRefs).(T) + log.Print(patch, thing) var err error var dbo DBObject @@ -264,14 +446,14 @@ func applyPatch[T DBObject](e *Env, tx *sql.Tx, user, floorplan string, patch *P dbo, err = thing.Create(e, tx, user, floorplan) case "replace": dbo, err = thing.Update(e, tx, user, floorplan) - case "delete": + case "remove": dbo, err = thing.Delete(e, tx, user, floorplan) default: return thing, inputRef.Error("Unsupported operation", nil) } if err != nil { - return thing, inputRef.Error("Unable to perform operation", err) + return thing, inputRef.Error("Unable to perform " + patch.Op + " operation", err) } thing = dbo.(T) @@ -372,6 +554,123 @@ func (e *Env) DeletePointMap(tx *sql.Tx, user string, floorplan string, id int64 return scanPointMap(stmt.QueryRow(user, floorplan, id)) } +func (f Furniture) Create(e *Env, tx *sql.Tx, user, floorplan string) (DBObject, error) { + ins, err := e.CacheTxStmt(tx, "add_furn", + `INSERT INTO spaceplanner.furniture (floorplan, type, name, width, depth) + VALUES (floorplan_id($1, $2), $3, $4, $5, $6) + RETURNING id, type, name, width, depth`) + if err != nil { + return f, err + } + + return scanFurniture(ins.QueryRow(user, floorplan, f.Type, f.Name, f.Width, f.Depth)) +} + +func (f Furniture) Update(e *Env, tx *sql.Tx, user, floorplan string) (DBObject, error) { + update, err := e.CacheTxStmt(tx, "update_furn", + `UPDATE spaceplanner.furniture SET (type, name, width, depth) = + ($4, $5, $6, $7) WHERE floorplan = floorplan_id($1, $2) AND id = $3 + RETURNING id, type, name, width, depth`) + if err != nil { + return f, err + } + + return scanFurniture(update.QueryRow(user, floorplan, f.id, f.Type, f.Name, f.Width, f.Depth)) +} + +func (f Furniture) Delete(e *Env, tx *sql.Tx, user, floorplan string) (DBObject, error) { + del, err := e.CacheTxStmt(tx, "dele_furn", + `DELETE FROM spaceplanner.furniture + WHERE floorplan = floorplan_id($1, $2) AND id = $3 + RETURNING id, type, name, width, depth`) + if err != nil { + return f, err + } + + return scanFurniture(del.QueryRow(user, floorplan, f.id)) +} + +func (f FurnitureMap) Create(e *Env, tx *sql.Tx, user, floorplan string) (DBObject, error) { + ins, err := e.CacheTxStmt(tx, "add_furnmap", + `INSERT INTO spaceplanner.furniture_maps (floorplan, furniture_id, layout, x, y, angle) + VALUES (floorplan_id($1, $2), $3, $4, $5, $6, $7) + RETURNING id, furniture_id, layout, x, y, angle`) + if err != nil { + return f, err + } + + return scanFurnitureMap(ins.QueryRow(user, floorplan, f.FurnitureID, f.Layout, f.X, f.Y, f.Angle)) +} + +func (fm FurnitureMap) Update(e *Env, tx *sql.Tx, user, floorplan string) (DBObject, error) { + update, err := e.CacheTxStmt(tx, "update_furnmap", + `UPDATE spaceplanner.furniture_maps SET (furniture_id, layout, x, y, angle) = + ($4, $5, $6, $7, $8) WHERE floorplan = floorplan_id($1, $2) AND id = $3 + RETURNING id, furniture_id, layout, x, y, angle`) + if err != nil { + return fm, err + } + + return scanFurnitureMap(update.QueryRow(user, floorplan, fm.id, fm.Layout, fm.X, fm.Y, fm.Angle)) +} + +func (f FurnitureMap) Delete(e *Env, tx *sql.Tx, user, floorplan string) (DBObject, error) { + del, err := e.CacheTxStmt(tx, "dele_furnmap", + `DELETE FROM spaceplanner.furniture_maps + WHERE floorplan = floorplan_id($1, $2) AND id = $3 + RETURNING id, furniture_id, layout, x, y, angle`) + if err != nil { + return f, err + } + + log.Printf("Delete map %d from %s/%s", f.id, user,floorplan) + return scanFurnitureMap(del.QueryRow(user, floorplan, f.id)) +} + +func (f Furniture) Ref() rowReference { + return newRef("furniture", f.id) +} + +func (f Furniture) updateRefs(ref rowReference, newRefs map[rowReference]rowReference) DBObject { + if n, exists := newRefs[ref]; exists { + f.id = n.id + } else { + f.id = ref.id + } + return f +} + +func (f Furniture) SetOldID(id int64) DBObject { + if id != f.id { + f.OldID = &id + } + return f +} + +func (fm FurnitureMap) Ref() rowReference { + return newRef("furniture_maps", fm.id) +} + +func (fm FurnitureMap) updateRefs(ref rowReference, newRefs map[rowReference]rowReference) DBObject { + if n, exists := newRefs[ref]; exists { + fm.id = n.id + } else { + fm.id = ref.id + } + if n, exists := newRefs[newRef("furniture", fm.FurnitureID)]; exists { + log.Printf("Furniture Map Furniture ID remapped from %d to %d", fm.id, n.id) + fm.FurnitureID = n.id + } + return fm +} + +func (fm FurnitureMap) SetOldID(id int64) DBObject { + if id != fm.id { + fm.OldID = &id + } + return fm +} + func (p Point) Ref() rowReference { return newRef("points", p.id) } @@ -426,16 +725,19 @@ 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") + if len(parts) < 3 { + return rowReference{}, errors.New(ref + ": Requires table") + } + if parts[0] != "" { + return rowReference{}, errors.New(ref + ": Must start with /") } - id, err := strconv.ParseInt(parts[2], 10, 64) + id, err := strconv.ParseInt(parts[len(parts) - 1], 10, 64) if err != nil { return rowReference{}, errors.New(ref + ": Invalid id") } - - return newRef(parts[1], id), nil + parts = parts[1:len(parts) - 1] + return newRef(strings.Join(parts, "/"), id), nil } func scanPoint(s Scanner) (Point, error) { @@ -452,6 +754,20 @@ func scanPointMap(s Scanner) (PointMap, error) { return pm, err } +func scanFurniture(row Scanner) (Furniture, error) { + var f Furniture + + err := row.Scan(&f.id, &f.Type, &f.Name, &f.Width, &f.Depth) + return f, err +} + +func scanFurnitureMap(row Scanner) (FurnitureMap, error) { + var m FurnitureMap + + err := row.Scan(&m.id, &m.FurnitureID, &m.Layout, &m.X, &m.Y, &m.Angle) + return m, 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)) @@ -473,6 +789,14 @@ func mapPointMap(pm PointMap) (int64, error) { return pm.id, nil } +func mapFurniture(f Furniture) (int64, error) { + return f.id, nil +} + +func mapFurnitureMap(m FurnitureMap) (int64, error) { + return m.id, nil +} + func (ref rowReference) String() string { return fmt.Sprintf("/%s/%d", ref.table, ref.id) }