diff --git a/internal/core/metadata/application/device.go b/internal/core/metadata/application/device.go index 72074a5956..c1ee3c4acd 100644 --- a/internal/core/metadata/application/device.go +++ b/internal/core/metadata/application/device.go @@ -54,7 +54,7 @@ func AddDevice(d models.Device, ctx context.Context, dic *di.Container, bypassVa return id, errors.NewCommonEdgeX(errors.KindContractInvalid, fmt.Sprintf("device service '%s' does not exists", d.ServiceName), nil) } - err := validateProfileAndAutoEvent(dic, d) + err := validateParentProfileAndAutoEvent(dic, d) if err != nil { return "", errors.NewCommonEdgeXWrapper(err) } @@ -147,6 +147,13 @@ func DeleteDeviceByName(name string, ctx context.Context, dic *di.Container) err if err != nil { return errors.NewCommonEdgeXWrapper(err) } + childcount, _, err := dbClient.DeviceTree(name, 1, 0, 1, nil) + if err != nil { + return errors.NewCommonEdgeXWrapper(err) + } + if childcount != 0 { + return errors.NewCommonEdgeX(errors.KindStatusConflict, "cannot delete device with children", nil) + } err = dbClient.DeleteDeviceByName(name) if err != nil { return errors.NewCommonEdgeXWrapper(err) @@ -225,7 +232,7 @@ func PatchDevice(dto dtos.UpdateDevice, ctx context.Context, dic *di.Container, requests.ReplaceDeviceModelFieldsWithDTO(&device, dto) - err = validateProfileAndAutoEvent(dic, device) + err = validateParentProfileAndAutoEvent(dic, device) if err != nil { return errors.NewCommonEdgeXWrapper(err) } @@ -295,22 +302,30 @@ func deviceByDTO(dbClient interfaces.DBClient, dto dtos.UpdateDevice) (device mo } // AllDevices query the devices with offset, limit, and labels -func AllDevices(offset int, limit int, labels []string, dic *di.Container) (devices []dtos.Device, totalCount uint32, err errors.EdgeX) { +func AllDevices(offset int, limit int, labels []string, parent string, maxLevels int, dic *di.Container) (devices []dtos.Device, totalCount uint32, err errors.EdgeX) { dbClient := container.DBClientFrom(dic.Get) + var deviceModels []models.Device + if parent != "" { + totalCount, deviceModels, err = dbClient.DeviceTree(parent, maxLevels, offset, limit, labels) + if err != nil { + return devices, totalCount, errors.NewCommonEdgeXWrapper(err) + } + } else { + totalCount, err = dbClient.DeviceCountByLabels(labels) + if err != nil { + return devices, totalCount, errors.NewCommonEdgeXWrapper(err) + } + cont, err := utils.CheckCountRange(totalCount, offset, limit) + if !cont { + return []dtos.Device{}, totalCount, err + } - totalCount, err = dbClient.DeviceCountByLabels(labels) - if err != nil { - return devices, totalCount, errors.NewCommonEdgeXWrapper(err) - } - cont, err := utils.CheckCountRange(totalCount, offset, limit) - if !cont { - return []dtos.Device{}, totalCount, err + deviceModels, err = dbClient.AllDevices(offset, limit, labels) + if err != nil { + return devices, totalCount, errors.NewCommonEdgeXWrapper(err) + } } - deviceModels, err := dbClient.AllDevices(offset, limit, labels) - if err != nil { - return devices, totalCount, errors.NewCommonEdgeXWrapper(err) - } devices = make([]dtos.Device, len(deviceModels)) for i, d := range deviceModels { devices[i] = dtos.FromDeviceModelToDTO(d) @@ -361,11 +376,14 @@ func DevicesByProfileName(offset int, limit int, profileName string, dic *di.Con var noMessagingClientError = goErrors.New("MessageBus Client not available. Please update RequireMessageBus and MessageBus configuration to enable sending System Events via the EdgeX MessageBus") -func validateProfileAndAutoEvent(dic *di.Container, d models.Device) errors.EdgeX { +func validateParentProfileAndAutoEvent(dic *di.Container, d models.Device) errors.EdgeX { if d.ProfileName == "" { // if the profile is not set, skip the validation until we have the profile return nil } + if (d.Name == d.Parent) && (d.Name != "") { + return errors.NewCommonEdgeX(errors.KindContractInvalid, "a device cannot be its own parent", nil) + } dbClient := container.DBClientFrom(dic.Get) dp, err := dbClient.DeviceProfileByName(d.ProfileName) if err != nil { diff --git a/internal/core/metadata/application/device_test.go b/internal/core/metadata/application/device_test.go index fa9f8c73a8..3c188fa63c 100644 --- a/internal/core/metadata/application/device_test.go +++ b/internal/core/metadata/application/device_test.go @@ -24,7 +24,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestValidateProfileAndAutoEvents(t *testing.T) { +func TestValidateParentProfileAndAutoEvents(t *testing.T) { profile := "test-profile" notFountProfileName := "notFoundProfile" source1 := "source1" @@ -107,10 +107,18 @@ func TestValidateProfileAndAutoEvents(t *testing.T) { }, false, }, + {"is own parent", + models.Device{ + ProfileName: profile, + Parent: "me", + Name: "me", + }, + true, + }, } for _, testCase := range tests { t.Run(testCase.name, func(t *testing.T) { - err := validateProfileAndAutoEvent(dic, testCase.device) + err := validateParentProfileAndAutoEvent(dic, testCase.device) if testCase.errorExpected { assert.Error(t, err) } else { diff --git a/internal/core/metadata/controller/http/device.go b/internal/core/metadata/controller/http/device.go index a412a5ffb8..40665c0d97 100644 --- a/internal/core/metadata/controller/http/device.go +++ b/internal/core/metadata/controller/http/device.go @@ -235,7 +235,15 @@ func (dc *DeviceController) AllDevices(c echo.Context) error { if err != nil { return utils.WriteErrorResponse(w, ctx, lc, err, "") } - devices, totalCount, err := application.AllDevices(offset, limit, labels, dc.dic) + parent := utils.ParseQueryStringToString(r, common.DescendantsOf, "") + levels, err := utils.ParseQueryStringToInt(c, common.MaxLevels, 0, -1, math.MaxInt32) + if err != nil { + return utils.WriteErrorResponse(w, ctx, lc, err, "") + } + if levels < 0 { + levels = math.MaxInt32 + } + devices, totalCount, err := application.AllDevices(offset, limit, labels, parent, levels, dc.dic) if err != nil { return utils.WriteErrorResponse(w, ctx, lc, err, "") } diff --git a/internal/core/metadata/controller/http/device_test.go b/internal/core/metadata/controller/http/device_test.go index 932d0c0e27..c72768041a 100644 --- a/internal/core/metadata/controller/http/device_test.go +++ b/internal/core/metadata/controller/http/device_test.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "net/http" "net/http/httptest" "strings" @@ -183,6 +184,8 @@ func TestAddDevice(t *testing.T) { dbClientMock.On("AddDevice", emptyProtocolsModel).Return(emptyProtocolsModel, nil) invalidProtocols := testDevice invalidProtocols.Device.Protocols = map[string]dtos.ProtocolProperties{"others": {}} + ownParent := testDevice + ownParent.Device.Parent = ownParent.Device.Name dic.Update(di.ServiceConstructorMap{ container.DBClientInterfaceName: func(get di.Get) interface{} { @@ -217,6 +220,7 @@ func TestAddDevice(t *testing.T) { {"Invalid - not found device service", []requests.AddDeviceRequest{notFoundService}, http.StatusMultiStatus, http.StatusBadRequest, false, false, false}, {"Invalid - device service unavailable", []requests.AddDeviceRequest{valid}, http.StatusMultiStatus, http.StatusServiceUnavailable, true, false, false}, {"Valid - force add device", []requests.AddDeviceRequest{validForceAdd}, http.StatusMultiStatus, http.StatusCreated, false, true, true}, + {"Invalid - own parent", []requests.AddDeviceRequest{ownParent}, http.StatusMultiStatus, http.StatusBadRequest, false, false, false}, } for _, testCase := range tests { t.Run(testCase.name, func(t *testing.T) { @@ -319,14 +323,18 @@ func TestDeleteDeviceByName(t *testing.T) { device := dtos.ToDeviceModel(buildTestDeviceRequest().Device) noName := "" notFoundName := "notFoundName" + deviceParent := device + deviceParent.Name = "someOtherName" dic := mockDic() dbClientMock := &dbMock.DBClient{} + dbClientMock.On("DeviceTree", device.Name, 1, 0, 1, []string(nil)).Return(uint32(0), nil, nil) dbClientMock.On("DeleteDeviceByName", device.Name).Return(nil) dbClientMock.On("DeleteDeviceByName", notFoundName).Return(edgexErr.NewCommonEdgeX(edgexErr.KindEntityDoesNotExist, "device doesn't exist in the database", nil)) dbClientMock.On("DeviceByName", notFoundName).Return(device, edgexErr.NewCommonEdgeX(edgexErr.KindEntityDoesNotExist, "device doesn't exist in the database", nil)) dbClientMock.On("DeviceByName", device.Name).Return(device, nil) - dbClientMock.On("DeviceServiceByName", device.ServiceName).Return(models.DeviceService{BaseAddress: testBaseAddress}, nil) + dbClientMock.On("DeviceByName", deviceParent.Name).Return(device, nil) + dbClientMock.On("DeviceTree", deviceParent.Name, 1, 0, 1, []string(nil)).Return(uint32(1), []models.Device{device}, nil) dic.Update(di.ServiceConstructorMap{ container.DBClientInterfaceName: func(get di.Get) interface{} { return dbClientMock @@ -344,6 +352,7 @@ func TestDeleteDeviceByName(t *testing.T) { {"Valid - delete device by name", device.Name, http.StatusOK}, {"Invalid - name parameter is empty", noName, http.StatusBadRequest}, {"Invalid - device not found by name", notFoundName, http.StatusNotFound}, + {"Invalid - device has children", deviceParent.Name, http.StatusConflict}, } for _, testCase := range tests { t.Run(testCase.name, func(t *testing.T) { @@ -603,14 +612,17 @@ func TestPatchDevice(t *testing.T) { notFoundService.Device.ServiceName = ¬FoundServiceName dbClientMock.On("DeviceServiceNameExists", *notFoundService.Device.ServiceName).Return(false, nil) - notFountProfileName := "notFoundProfile" + notFoundProfileName := "notFoundProfile" notFoundProfile := testReq - notFoundProfile.Device.ProfileName = ¬FountProfileName + notFoundProfile.Device.ProfileName = ¬FoundProfileName notFoundProfileDeviceModel := dsModels - notFoundProfileDeviceModel.ProfileName = notFountProfileName + notFoundProfileDeviceModel.ProfileName = notFoundProfileName dbClientMock.On("UpdateDevice", notFoundProfileDeviceModel).Return( edgexErr.NewCommonEdgeX(edgexErr.KindEntityDoesNotExist, - fmt.Sprintf("device profile '%s' does not exists", notFountProfileName), nil)) + fmt.Sprintf("device profile '%s' does not exists", notFoundProfileName), nil)) + + ownParent := testReq + ownParent.Device.Parent = ownParent.Device.Name dic.Update(di.ServiceConstructorMap{ container.DBClientInterfaceName: func(get di.Get) interface{} { @@ -642,7 +654,8 @@ func TestPatchDevice(t *testing.T) { {"Invalid - invalid protocols", []requests.UpdateDeviceRequest{invalidProtocols}, http.StatusMultiStatus, http.StatusInternalServerError, true, false}, {"Invalid - not found device service", []requests.UpdateDeviceRequest{notFoundService}, http.StatusMultiStatus, http.StatusBadRequest, false, false}, {"Invalid - device service unavailable", []requests.UpdateDeviceRequest{valid}, http.StatusMultiStatus, http.StatusServiceUnavailable, true, false}, - {"Valid - empty profile", []requests.UpdateDeviceRequest{emptyProfile}, http.StatusMultiStatus, http.StatusOK, true, true}} + {"Valid - empty profile", []requests.UpdateDeviceRequest{emptyProfile}, http.StatusMultiStatus, http.StatusOK, true, true}, + {"Invalid - own parent", []requests.UpdateDeviceRequest{ownParent}, http.StatusMultiStatus, http.StatusBadRequest, false, false}} for _, testCase := range tests { t.Run(testCase.name, func(t *testing.T) { e := echo.New() @@ -749,6 +762,8 @@ func TestAllDevices(t *testing.T) { dbClientMock.On("AllDevices", 0, 5, testDeviceLabels).Return([]models.Device{devices[0], devices[1]}, nil) dbClientMock.On("AllDevices", 1, 2, []string(nil)).Return([]models.Device{devices[1], devices[2]}, nil) dbClientMock.On("AllDevices", 4, 1, testDeviceLabels).Return([]models.Device{}, edgexErr.NewCommonEdgeX(edgexErr.KindRangeNotSatisfiable, "query objects bounds out of range.", nil)) + dbClientMock.On("DeviceTree", "foo", 4, 0, 10, []string(nil)).Return(uint32(expectedDeviceTotalCount), devices, nil) + dbClientMock.On("DeviceTree", "foo", math.MaxInt32, 0, 10, testDeviceLabels).Return(uint32(expectedDeviceTotalCount), devices, nil) dic.Update(di.ServiceConstructorMap{ container.DBClientInterfaceName: func(get di.Get) interface{} { return dbClientMock @@ -762,15 +777,20 @@ func TestAllDevices(t *testing.T) { offset string limit string labels string + descendantsOf string + maxLevels string errorExpected bool expectedCount int expectedTotalCount uint32 expectedStatusCode int }{ - {"Valid - get devices without labels", "0", "10", "", false, 3, expectedDeviceTotalCount, http.StatusOK}, - {"Valid - get devices with labels", "0", "5", strings.Join(testDeviceLabels, ","), false, 2, expectedDeviceTotalCount, http.StatusOK}, - {"Valid - get devices with offset and no labels", "1", "2", "", false, 2, expectedDeviceTotalCount, http.StatusOK}, - {"Invalid - offset out of range", "4", "1", strings.Join(testDeviceLabels, ","), true, 0, expectedDeviceTotalCount, http.StatusRequestedRangeNotSatisfiable}, + {"Valid - get devices without labels", "0", "10", "", "", "", false, 3, expectedDeviceTotalCount, http.StatusOK}, + {"Valid - get devices with labels", "0", "5", strings.Join(testDeviceLabels, ","), "", "", false, 2, expectedDeviceTotalCount, http.StatusOK}, + {"Valid - get devices with offset and no labels", "1", "2", "", "", "", false, 2, expectedDeviceTotalCount, http.StatusOK}, + {"Invalid - offset out of range", "4", "1", strings.Join(testDeviceLabels, ","), "", "", true, 0, expectedDeviceTotalCount, http.StatusRequestedRangeNotSatisfiable}, + {"Valid - get tree without labels", "0", "10", "", "foo", "4", false, 3, expectedDeviceTotalCount, http.StatusOK}, + {"Valid - get tree with labels", "0", "10", strings.Join(testDeviceLabels, ","), "foo", "-1", false, 3, expectedDeviceTotalCount, http.StatusOK}, + {"Invalid - maxLevels bad integer", "4", "1", strings.Join(testDeviceLabels, ","), "foo", "bar", true, 0, 0, http.StatusBadRequest}, } for _, testCase := range tests { t.Run(testCase.name, func(t *testing.T) { @@ -782,6 +802,12 @@ func TestAllDevices(t *testing.T) { if len(testCase.labels) > 0 { query.Add(common.Labels, testCase.labels) } + if testCase.descendantsOf != "" { + query.Add(common.DescendantsOf, testCase.descendantsOf) + } + if testCase.maxLevels != "" { + query.Add(common.MaxLevels, testCase.maxLevels) + } req.URL.RawQuery = query.Encode() require.NoError(t, err) diff --git a/internal/core/metadata/infrastructure/interfaces/db.go b/internal/core/metadata/infrastructure/interfaces/db.go index c6b54a2071..db7675f8e9 100644 --- a/internal/core/metadata/infrastructure/interfaces/db.go +++ b/internal/core/metadata/infrastructure/interfaces/db.go @@ -53,7 +53,7 @@ type DBClient interface { DeviceCountByLabels(labels []string) (uint32, errors.EdgeX) DeviceCountByProfileName(profileName string) (uint32, errors.EdgeX) DeviceCountByServiceName(serviceName string) (uint32, errors.EdgeX) - + DeviceTree(parent string, levels int, offset int, limit int, labels []string) (uint32, []model.Device, errors.EdgeX) AddProvisionWatcher(pw model.ProvisionWatcher) (model.ProvisionWatcher, errors.EdgeX) ProvisionWatcherById(id string) (model.ProvisionWatcher, errors.EdgeX) ProvisionWatcherByName(name string) (model.ProvisionWatcher, errors.EdgeX) diff --git a/internal/core/metadata/infrastructure/interfaces/mocks/DBClient.go b/internal/core/metadata/infrastructure/interfaces/mocks/DBClient.go index fa49d41693..91cd41e0a3 100644 --- a/internal/core/metadata/infrastructure/interfaces/mocks/DBClient.go +++ b/internal/core/metadata/infrastructure/interfaces/mocks/DBClient.go @@ -1044,6 +1044,45 @@ func (_m *DBClient) DeviceServiceNameExists(name string) (bool, errors.EdgeX) { return r0, r1 } +// DeviceTree provides a mock function with given fields: parent, levels, offset, limit, labels +func (_m *DBClient) DeviceTree(parent string, levels int, offset int, limit int, labels []string) (uint32, []models.Device, errors.EdgeX) { + ret := _m.Called(parent, levels, offset, limit, labels) + + if len(ret) == 0 { + panic("no return value specified for DeviceTree") + } + + var r0 uint32 + var r1 []models.Device + var r2 errors.EdgeX + if rf, ok := ret.Get(0).(func(string, int, int, int, []string) (uint32, []models.Device, errors.EdgeX)); ok { + return rf(parent, levels, offset, limit, labels) + } + if rf, ok := ret.Get(0).(func(string, int, int, int, []string) uint32); ok { + r0 = rf(parent, levels, offset, limit, labels) + } else { + r0 = ret.Get(0).(uint32) + } + + if rf, ok := ret.Get(1).(func(string, int, int, int, []string) []models.Device); ok { + r1 = rf(parent, levels, offset, limit, labels) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).([]models.Device) + } + } + + if rf, ok := ret.Get(2).(func(string, int, int, int, []string) errors.EdgeX); ok { + r2 = rf(parent, levels, offset, limit, labels) + } else { + if ret.Get(2) != nil { + r2 = ret.Get(2).(errors.EdgeX) + } + } + + return r0, r1, r2 +} + // DevicesByProfileName provides a mock function with given fields: offset, limit, profileName func (_m *DBClient) DevicesByProfileName(offset int, limit int, profileName string) ([]models.Device, errors.EdgeX) { ret := _m.Called(offset, limit, profileName) diff --git a/internal/pkg/infrastructure/postgres/consts.go b/internal/pkg/infrastructure/postgres/consts.go index 3701dcd5c5..1ee5d92897 100644 --- a/internal/pkg/infrastructure/postgres/consts.go +++ b/internal/pkg/infrastructure/postgres/consts.go @@ -81,6 +81,7 @@ const ( categoriesField = "Categories" createdField = "Created" labelsField = "Labels" + parentField = "Parent" manufacturerField = "Manufacturer" modelField = "Model" nameField = "Name" diff --git a/internal/pkg/infrastructure/postgres/device.go b/internal/pkg/infrastructure/postgres/device.go index de79597965..197a324444 100644 --- a/internal/pkg/infrastructure/postgres/device.go +++ b/internal/pkg/infrastructure/postgres/device.go @@ -10,6 +10,7 @@ import ( "encoding/json" stdErrs "errors" "fmt" + "math" "github.com/google/uuid" "github.com/jackc/pgx/v5" @@ -207,6 +208,70 @@ func (c *Client) DeviceCountByServiceName(serviceName string) (uint32, errors.Ed return getTotalRowsCount(ctx, c.ConnPool, sqlQueryCountByJSONField(deviceTableName), queryObj) } +// Get device objects with matching parent and labels (one level of the tree). +func deviceTreeLevel(ctx context.Context, connPool *pgxpool.Pool, parent string, labels []string) ([]model.Device, errors.EdgeX) { + queryObj := map[string]any{parentField: parent} + if len(labels) != 0 { + queryObj[labelsField] = labels + } + return queryDevices(ctx, connPool, sqlQueryContentByJSONField(deviceTableName), queryObj) +} + +// Get the entire subtree starting with the given parent, descending at most the given number of levels. +func deviceSubTree(ctx context.Context, connPool *pgxpool.Pool, parent string, levels int, labels []string) ([]model.Device, errors.EdgeX) { + var emptyList = []model.Device{} + if levels <= 0 { + return emptyList, nil + } + topLevelList, err := deviceTreeLevel(ctx, connPool, parent, labels) + if err != nil { + return emptyList, err + } + if levels == 1 { + return topLevelList, nil + } + var subtreesAtThisLevel []model.Device + for _, device := range topLevelList { + if device.Name == device.Parent { + message := "Device " + device.Name + " is its own parent, stopping tree query" + return emptyList, errors.NewCommonEdgeX(errors.KindDatabaseError, message, nil) + } + subtree, err := deviceSubTree(ctx, connPool, device.Name, levels-1, labels) + if err != nil { + return emptyList, err + } + subtreesAtThisLevel = append(subtreesAtThisLevel, subtree...) + } + return append(topLevelList, subtreesAtThisLevel...), nil +} + +// Get the full result-set since that's the only way to correctly get totalCount. +// Then return the subset of the result-set that corresponds to the requested offset and limit. +func (c *Client) DeviceTree(parent string, levels int, offset int, limit int, labels []string) (uint32, []model.Device, errors.EdgeX) { + var maxLevels int + var emptyList = []model.Device{} + if levels <= 0 { + maxLevels = math.MaxInt + } else { + maxLevels = levels + } + all_devices, err := deviceSubTree(context.Background(), c.ConnPool, parent, maxLevels, labels) + if err != nil { + return 0, emptyList, err + } + if offset < 0 { + offset = 0 + } + if offset >= len(all_devices) { + return uint32(len(all_devices)), emptyList, nil + } + numToReturn := len(all_devices) - offset + if limit > 0 && limit < numToReturn { + numToReturn = limit + } + return uint32(len(all_devices)), all_devices[offset : offset+numToReturn], nil +} + func deviceNameExists(ctx context.Context, connPool *pgxpool.Pool, name string) (bool, errors.EdgeX) { var exists bool queryObj := map[string]any{nameField: name} diff --git a/internal/pkg/infrastructure/redis/client.go b/internal/pkg/infrastructure/redis/client.go index 60139a903e..20129416f1 100644 --- a/internal/pkg/infrastructure/redis/client.go +++ b/internal/pkg/infrastructure/redis/client.go @@ -485,6 +485,17 @@ func (c *Client) AllDevices(offset int, limit int, labels []string) ([]model.Dev return devices, nil } +func (c *Client) DeviceTree(parent string, levels int, offset int, limit int, labels []string) (uint32, []model.Device, errors.EdgeX) { + conn := c.Pool.Get() + defer conn.Close() + + totalCount, devices, edgeXerr := deviceTree(conn, parent, levels, offset, limit, labels) + if edgeXerr != nil { + return totalCount, devices, errors.NewCommonEdgeXWrapper(edgeXerr) + } + return totalCount, devices, nil +} + // EventsByDeviceName query events by offset, limit and device name func (c *Client) EventsByDeviceName(offset int, limit int, name string) (events []model.Event, edgeXerr errors.EdgeX) { conn := c.Pool.Get() diff --git a/internal/pkg/infrastructure/redis/device.go b/internal/pkg/infrastructure/redis/device.go index 2d13cb0fcf..8e25d5bcbe 100644 --- a/internal/pkg/infrastructure/redis/device.go +++ b/internal/pkg/infrastructure/redis/device.go @@ -8,6 +8,7 @@ package redis import ( "encoding/json" "fmt" + "math" pkgCommon "github.com/edgexfoundry/edgex-go/internal/pkg/common" @@ -22,6 +23,7 @@ const ( DeviceCollection = "md|dv" DeviceCollectionName = DeviceCollection + DBKeySeparator + common.Name DeviceCollectionLabel = DeviceCollection + DBKeySeparator + common.Label + DeviceCollectionParent = DeviceCollection + DBKeySeparator + "parent" DeviceCollectionServiceName = DeviceCollection + DBKeySeparator + common.Service + DBKeySeparator + common.Name DeviceCollectionProfileName = DeviceCollection + DBKeySeparator + common.Profile + DBKeySeparator + common.Name ) @@ -63,6 +65,9 @@ func sendAddDeviceCmd(conn redis.Conn, storedKey string, d models.Device) errors for _, label := range d.Labels { _ = conn.Send(ZADD, CreateKey(DeviceCollectionLabel, label), d.Modified, storedKey) } + if d.Parent != "" { + _ = conn.Send(ZADD, CreateKey(DeviceCollectionParent, d.Parent), d.Modified, storedKey) + } return nil } @@ -168,10 +173,20 @@ func sendDeleteDeviceCmd(conn redis.Conn, storedKey string, device models.Device for _, label := range device.Labels { _ = conn.Send(ZREM, CreateKey(DeviceCollectionLabel, label), storedKey) } + if device.Parent != "" { + _ = conn.Send(ZREM, CreateKey(DeviceCollectionParent, device.Parent), storedKey) + } } // deleteDevice deletes a device func deleteDevice(conn redis.Conn, device models.Device) errors.EdgeX { + numChildren, edgexErr := getMemberNumber(conn, ZCARD, CreateKey(DeviceCollectionParent, device.Name)) + if edgexErr != nil { + return errors.NewCommonEdgeX(errors.KindDatabaseError, "Could not determine if device had any children", edgexErr) + } + if numChildren > 0 { + return errors.NewCommonEdgeX(errors.KindStatusConflict, "Cannot delete device, it has child devices", nil) + } storedKey := deviceStoredKey(device.Id) _ = conn.Send(MULTI) sendDeleteDeviceCmd(conn, storedKey, device) @@ -271,3 +286,78 @@ func updateDevice(conn redis.Conn, d models.Device) errors.EdgeX { return nil } + +// Return all devices with the given parent and labels (one level of the tree). +func deviceTreeLevel(conn redis.Conn, parent string, labels []string) ([]models.Device, errors.EdgeX) { + queryList := []string{CreateKey(DeviceCollectionParent, parent)} + for l := range labels { + queryList = append(queryList, CreateKey(DeviceCollectionLabel, labels[l])) + } + objects, err := intersectionObjectsByKeys(conn, 0, -1, queryList...) + if err != nil { + return []models.Device{}, errors.NewCommonEdgeXWrapper(err) + } + devices := make([]models.Device, len(objects)) + for i, in := range objects { + s := models.Device{} + err := json.Unmarshal(in, &s) + if err != nil { + return []models.Device{}, errors.NewCommonEdgeX(errors.KindDatabaseError, "device format parsing failed from the database", err) + } + if s.Name == s.Parent { + message := "Device " + s.Name + " is its own parent, stopping this query" + return []models.Device{}, errors.NewCommonEdgeX(errors.KindDatabaseError, message, nil) + } + devices[i] = s + } + return devices, nil +} + +// Get the entire subtree starting with the given parent, descending at most the given number of levels. +func deviceSubTree(conn redis.Conn, parent string, levels int, labels []string) ([]models.Device, errors.EdgeX) { + if levels == 0 { + return []models.Device{}, nil + } + devices, err := deviceTreeLevel(conn, parent, labels) + if err != nil { + return []models.Device{}, errors.NewCommonEdgeXWrapper(err) + } + if levels == 1 { + return devices, nil + } + for i := range devices { + subDevices, err := deviceSubTree(conn, devices[i].Name, levels-1, labels) + if err != nil { + return []models.Device{}, errors.NewCommonEdgeXWrapper(err) + } + devices = append(devices, subDevices...) + } + return devices, nil +} + +// Get the full result-set since that's the only way to correctly get totalCount. +// Then return the subset of the result-set that corresponds to the requested offset and limit. +func deviceTree(conn redis.Conn, parent string, levels int, offset int, limit int, labels []string) (uint32, []models.Device, errors.EdgeX) { + var maxLevels int + var emptyList = []models.Device{} + if levels <= 0 { + maxLevels = math.MaxInt + } else { + maxLevels = levels + } + all_devices, err := deviceSubTree(conn, parent, maxLevels, labels) + if err != nil { + return 0, emptyList, err + } + if offset < 0 { + offset = 0 + } + if offset >= len(all_devices) { + return uint32(len(all_devices)), emptyList, nil + } + numToReturn := len(all_devices) - offset + if limit > 0 && limit < numToReturn { + numToReturn = limit + } + return uint32(len(all_devices)), all_devices[offset : offset+numToReturn], nil +} diff --git a/openapi/core-metadata.yaml b/openapi/core-metadata.yaml index 3c7d12cc6b..e11ec1c1dc 100644 --- a/openapi/core-metadata.yaml +++ b/openapi/core-metadata.yaml @@ -170,6 +170,9 @@ components: description: A map of supported protocols for the given device additionalProperties: $ref: '#/components/schemas/ProtocolProperties' + parent: + type: string + description: Parent device name tags: type: object description: A map of tags used to tag the given device @@ -221,6 +224,9 @@ components: description: A map of supported protocols for the given device additionalProperties: $ref: '#/components/schemas/ProtocolProperties' + parent: + type: string + description: Parent device name tags: type: object description: A map of tags used to tag the given device @@ -276,6 +282,9 @@ components: description: A map of supported protocols for the given device additionalProperties: $ref: '#/components/schemas/ProtocolProperties' + parent: + type: string + description: Parent device name tags: type: object description: A map of tags used to tag the given device @@ -1147,6 +1156,22 @@ components: schema: type: string description: "Allows for querying a given object by associated user-defined label. More than one label may be specified via a comma-delimited list." + descendantsParam: + in: query + name: descendantsOf + required: false + schema: + type: string + description: "Filter results to only include objects with parent, grandparent etc. with the specified name." + maxLevelsParam: + in: query + name: maxLevels + required: false + schema: + type: integer + minimum: 0 + default: 0 + description: "The maximum number of levels to descend when querying for descendants. 0 or omitted = unlimited." bypassValidationParam: in: query name: bypassValidation @@ -1793,8 +1818,10 @@ paths: - $ref: '#/components/parameters/offsetParam' - $ref: '#/components/parameters/limitParam' - $ref: '#/components/parameters/labelsParam' + - $ref: '#/components/parameters/descendantsParam' + - $ref: '#/components/parameters/maxLevelsParam' get: - summary: "Given the entire range of devices sorted by last modified descending, returns a portion of that range according to the offset and limit parameters. Devices may also be filtered by label." + summary: "Given the entire range of devices sorted by last modified descending, returns a portion of that range according to the offset and limit parameters. Devices may also be filtered by label or parent." responses: '200': description: "OK" @@ -2013,6 +2040,18 @@ paths: examples: 404Example: $ref: '#/components/examples/404Example' + '409': + description: "Conflict - cannot delete a device with children" + headers: + X-Correlation-ID: + $ref: '#/components/headers/correlatedResponseHeader' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + 409DeleteExample: + $ref: '#/components/examples/409DeleteExample' '500': description: "Internal Server Error" headers: