From f1f455f8aaaad493f4e8f23e6910fd1f1f7fb476 Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Fri, 27 Jan 2023 14:07:03 -0900 Subject: [PATCH] location tree API --- .../app/api/handlers/v1/v1_ctrl_locations.go | 21 ++ backend/app/api/routes.go | 1 + backend/app/api/static/docs/docs.go | 56 +++++ backend/app/api/static/docs/swagger.json | 56 +++++ backend/app/api/static/docs/swagger.yaml | 32 +++ backend/internal/data/repo/repo_locations.go | 99 ++++++++- .../internal/data/repo/repo_locations_test.go | 194 ++++++++++++++++-- 7 files changed, 445 insertions(+), 14 deletions(-) diff --git a/backend/app/api/handlers/v1/v1_ctrl_locations.go b/backend/app/api/handlers/v1/v1_ctrl_locations.go index 12e3c29..9f14304 100644 --- a/backend/app/api/handlers/v1/v1_ctrl_locations.go +++ b/backend/app/api/handlers/v1/v1_ctrl_locations.go @@ -11,6 +11,27 @@ import ( "github.com/rs/zerolog/log" ) +// HandleLocationTreeQuery godoc +// @Summary Get All Locations +// @Tags Locations +// @Produce json +// @Success 200 {object} server.Results{items=[]repo.TreeItem} +// @Router /v1/locations/tree [GET] +// @Security Bearer +func (ctrl *V1Controller) HandleLocationTreeQuery() server.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + user := services.UseUserCtx(r.Context()) + + locTree, err := ctrl.repo.Locations.Tree(r.Context(), user.GroupID) + if err != nil { + log.Err(err).Msg("failed to get locations tree") + return validate.NewRequestError(err, http.StatusInternalServerError) + } + + return server.Respond(w, http.StatusOK, server.Results{Items: locTree}) + } +} + // HandleLocationGetAll godoc // @Summary Get All Locations // @Tags Locations diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index b5ae495..3475e7a 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -91,6 +91,7 @@ func (a *app) mountRoutes(repos *repo.AllRepos) { a.server.Get(v1Base("/locations"), v1Ctrl.HandleLocationGetAll(), userMW...) a.server.Post(v1Base("/locations"), v1Ctrl.HandleLocationCreate(), userMW...) + a.server.Get(v1Base("/locations/tree"), v1Ctrl.HandleLocationTreeQuery(), userMW...) a.server.Get(v1Base("/locations/{id}"), v1Ctrl.HandleLocationGet(), userMW...) a.server.Put(v1Base("/locations/{id}"), v1Ctrl.HandleLocationUpdate(), userMW...) a.server.Delete(v1Base("/locations/{id}"), v1Ctrl.HandleLocationDelete(), userMW...) diff --git a/backend/app/api/static/docs/docs.go b/backend/app/api/static/docs/docs.go index 4aa7410..b2b798d 100644 --- a/backend/app/api/static/docs/docs.go +++ b/backend/app/api/static/docs/docs.go @@ -1045,6 +1045,45 @@ const docTemplate = `{ } } }, + "/v1/locations/tree": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Locations" + ], + "summary": "Get All Locations", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/server.Results" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/repo.TreeItem" + } + } + } + } + ] + } + } + } + } + }, "/v1/locations/{id}": { "get": { "security": [ @@ -2116,6 +2155,23 @@ const docTemplate = `{ } } }, + "repo.TreeItem": { + "type": "object", + "properties": { + "children": { + "type": "array", + "items": { + "$ref": "#/definitions/repo.TreeItem" + } + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "repo.UserOut": { "type": "object", "properties": { diff --git a/backend/app/api/static/docs/swagger.json b/backend/app/api/static/docs/swagger.json index 6e2c0cf..30a25ee 100644 --- a/backend/app/api/static/docs/swagger.json +++ b/backend/app/api/static/docs/swagger.json @@ -1037,6 +1037,45 @@ } } }, + "/v1/locations/tree": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Locations" + ], + "summary": "Get All Locations", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/server.Results" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/repo.TreeItem" + } + } + } + } + ] + } + } + } + } + }, "/v1/locations/{id}": { "get": { "security": [ @@ -2108,6 +2147,23 @@ } } }, + "repo.TreeItem": { + "type": "object", + "properties": { + "children": { + "type": "array", + "items": { + "$ref": "#/definitions/repo.TreeItem" + } + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "repo.UserOut": { "type": "object", "properties": { diff --git a/backend/app/api/static/docs/swagger.yaml b/backend/app/api/static/docs/swagger.yaml index a6ef340..f3a730f 100644 --- a/backend/app/api/static/docs/swagger.yaml +++ b/backend/app/api/static/docs/swagger.yaml @@ -459,6 +459,17 @@ definitions: total: type: number type: object + repo.TreeItem: + properties: + children: + items: + $ref: '#/definitions/repo.TreeItem' + type: array + id: + type: string + name: + type: string + type: object repo.UserOut: properties: email: @@ -1301,6 +1312,27 @@ paths: summary: updates a location tags: - Locations + /v1/locations/tree: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/server.Results' + - properties: + items: + items: + $ref: '#/definitions/repo.TreeItem' + type: array + type: object + security: + - Bearer: [] + summary: Get All Locations + tags: + - Locations /v1/qrcode: get: parameters: diff --git a/backend/internal/data/repo/repo_locations.go b/backend/internal/data/repo/repo_locations.go index 641b3e3..32ecdb2 100644 --- a/backend/internal/data/repo/repo_locations.go +++ b/backend/internal/data/repo/repo_locations.go @@ -152,7 +152,9 @@ func (r *LocationRepository) getOne(ctx context.Context, where ...predicate.Loca Where(where...). WithGroup(). WithItems(func(iq *ent.ItemQuery) { - iq.Where(item.Archived(false)).WithLabel() + iq.Where(item.Archived(false)). + Order(ent.Asc(item.FieldName)). + WithLabel() }). WithParent(). WithChildren(). @@ -218,3 +220,98 @@ func (r *LocationRepository) DeleteByGroup(ctx context.Context, GID, ID uuid.UUI _, err := r.db.Location.Delete().Where(location.ID(ID), location.HasGroupWith(group.ID(GID))).Exec(ctx) return err } + +type TreeItem struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Children []*TreeItem `json:"children"` +} + +type FlatTreeItem struct { + ID uuid.UUID + Name string + ParentID uuid.UUID + Level int +} + +func (lr *LocationRepository) Tree(ctx context.Context, GID uuid.UUID) ([]TreeItem, error) { + query := ` + WITH recursive location_tree(id, NAME, location_children, level) AS + ( + SELECT id, + NAME, + location_children, + 0 AS level + FROM locations + WHERE location_children IS NULL + AND group_locations = ? + UNION ALL + SELECT c.id, + c.NAME, + c.location_children, + level + 1 + FROM locations c + JOIN location_tree p + ON c.location_children = p.id + WHERE level < 10 -- prevent infinite loop & excessive recursion + ) + SELECT id, + NAME, + level, + location_children + FROM location_tree + ORDER BY level, + NAME;` + + rows, err := lr.db.Sql().QueryContext(ctx, query, GID) + if err != nil { + return nil, err + } + defer rows.Close() + + var locations []FlatTreeItem + for rows.Next() { + var location FlatTreeItem + if err := rows.Scan(&location.ID, &location.Name, &location.Level, &location.ParentID); err != nil { + return nil, err + } + locations = append(locations, location) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return ConvertLocationsToTree(locations), nil +} + +func ConvertLocationsToTree(locations []FlatTreeItem) []TreeItem { + var locationMap = make(map[uuid.UUID]*TreeItem, len(locations)) + + var rootIds []uuid.UUID + + for _, location := range locations { + loc := &TreeItem{ + ID: location.ID, + Name: location.Name, + Children: []*TreeItem{}, + } + + locationMap[location.ID] = loc + if location.ParentID != uuid.Nil { + parent, ok := locationMap[location.ParentID] + if ok { + parent.Children = append(parent.Children, loc) + } + } else { + rootIds = append(rootIds, location.ID) + } + } + + roots := make([]TreeItem, 0, len(rootIds)) + for _, id := range rootIds { + roots = append(roots, *locationMap[id]) + } + + return roots +} diff --git a/backend/internal/data/repo/repo_locations_test.go b/backend/internal/data/repo/repo_locations_test.go index 5085d3b..73ed743 100644 --- a/backend/internal/data/repo/repo_locations_test.go +++ b/backend/internal/data/repo/repo_locations_test.go @@ -2,8 +2,11 @@ package repo import ( "context" + "encoding/json" "testing" + "github.com/google/uuid" + "github.com/hay-kot/homebox/backend/internal/data/ent" "github.com/stretchr/testify/assert" ) @@ -14,6 +17,30 @@ func locationFactory() LocationCreate { } } +func useLocations(t *testing.T, len int) []LocationOut { + t.Helper() + + out := make([]LocationOut, len) + + for i := 0; i < len; i++ { + loc, err := tRepos.Locations.Create(context.Background(), tGroup.ID, locationFactory()) + assert.NoError(t, err) + out[i] = loc + } + + t.Cleanup(func() { + for _, loc := range out { + err := tRepos.Locations.Delete(context.Background(), loc.ID) + + if err != nil { + assert.True(t, ent.IsNotFound(err)) + } + } + }) + + return out +} + func TestLocationRepository_Get(t *testing.T) { loc, err := tRepos.Locations.Create(context.Background(), tGroup.ID, locationFactory()) assert.NoError(t, err) @@ -29,13 +56,9 @@ func TestLocationRepository_Get(t *testing.T) { func TestLocationRepositoryGetAllWithCount(t *testing.T) { ctx := context.Background() - result, err := tRepos.Locations.Create(ctx, tGroup.ID, LocationCreate{ - Name: fk.Str(10), - Description: fk.Str(100), - }) - assert.NoError(t, err) + result := useLocations(t, 1)[0] - _, err = tRepos.Items.Create(ctx, tGroup.ID, ItemCreate{ + _, err := tRepos.Items.Create(ctx, tGroup.ID, ItemCreate{ Name: fk.Str(10), Description: fk.Str(100), LocationID: result.ID, @@ -55,8 +78,7 @@ func TestLocationRepositoryGetAllWithCount(t *testing.T) { } func TestLocationRepository_Create(t *testing.T) { - loc, err := tRepos.Locations.Create(context.Background(), tGroup.ID, locationFactory()) - assert.NoError(t, err) + loc := useLocations(t, 1)[0] // Get by ID foundLoc, err := tRepos.Locations.Get(context.Background(), loc.ID) @@ -68,8 +90,7 @@ func TestLocationRepository_Create(t *testing.T) { } func TestLocationRepository_Update(t *testing.T) { - loc, err := tRepos.Locations.Create(context.Background(), tGroup.ID, locationFactory()) - assert.NoError(t, err) + loc := useLocations(t, 1)[0] updateData := LocationUpdate{ ID: loc.ID, @@ -92,12 +113,159 @@ func TestLocationRepository_Update(t *testing.T) { } func TestLocationRepository_Delete(t *testing.T) { - loc, err := tRepos.Locations.Create(context.Background(), tGroup.ID, locationFactory()) - assert.NoError(t, err) + loc := useLocations(t, 1)[0] - err = tRepos.Locations.Delete(context.Background(), loc.ID) + err := tRepos.Locations.Delete(context.Background(), loc.ID) assert.NoError(t, err) _, err = tRepos.Locations.Get(context.Background(), loc.ID) assert.Error(t, err) } + +func TestItemRepository_TreeQuery(t *testing.T) { + locs := useLocations(t, 3) + + // Set relations + _, err := tRepos.Locations.UpdateOneByGroup(context.Background(), tGroup.ID, locs[0].ID, LocationUpdate{ + ID: locs[0].ID, + ParentID: locs[1].ID, + Name: locs[0].Name, + Description: locs[0].Description, + }) + assert.NoError(t, err) + + locations, err := tRepos.Locations.Tree(context.Background(), tGroup.ID) + + assert.NoError(t, err) + + assert.Equal(t, 3, len(locations)) + + for _, loc := range locs { + found := false + + for _, l := range locations { + if l.ID == loc.ID { + found = true + } + } + + assert.True(t, found) + } +} + +func TestConvertLocationsToTree(t *testing.T) { + uuid1, uuid2, uuid3, uuid4 := uuid.New(), uuid.New(), uuid.New(), uuid.New() + + testCases := []struct { + name string + locations []FlatTreeItem + expected []TreeItem + }{ + { + name: "Convert locations to tree", + locations: []FlatTreeItem{ + { + ID: uuid1, + Name: "Root1", + ParentID: uuid.Nil, + Level: 0, + }, + { + ID: uuid2, + Name: "Child1", + ParentID: uuid1, + Level: 1, + }, + { + ID: uuid3, + Name: "Child2", + ParentID: uuid1, + Level: 1, + }, + }, + expected: []TreeItem{ + { + ID: uuid1, + Name: "Root1", + Children: []*TreeItem{ + { + ID: uuid2, + Name: "Child1", + Children: []*TreeItem{}, + }, + { + ID: uuid3, + Name: "Child2", + Children: []*TreeItem{}, + }, + }, + }, + }, + }, + { + name: "Convert locations to tree with deeply nested children", + locations: []FlatTreeItem{ + { + ID: uuid1, + Name: "Root1", + ParentID: uuid.Nil, + Level: 0, + }, + { + ID: uuid2, + Name: "Child1", + ParentID: uuid1, + Level: 1, + }, + { + ID: uuid3, + Name: "Child2", + ParentID: uuid2, + Level: 2, + }, + { + ID: uuid4, + Name: "Child3", + ParentID: uuid3, + Level: 3, + }, + }, + expected: []TreeItem{ + { + ID: uuid1, + Name: "Root1", + Children: []*TreeItem{ + { + ID: uuid2, + Name: "Child1", + Children: []*TreeItem{ + { + ID: uuid3, + Name: "Child2", + Children: []*TreeItem{ + { + ID: uuid4, + Name: "Child3", + Children: []*TreeItem{}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := ConvertLocationsToTree(tc.locations) + + // Compare JSON strings + expected, _ := json.Marshal(tc.expected) + got, _ := json.Marshal(result) + assert.Equal(t, string(expected), string(got)) + }) + } +}