diff --git a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/item/ItemResource.java b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/item/ItemResource.java index ea2fa0502b..d96d98635b 100644 --- a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/item/ItemResource.java +++ b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/item/ItemResource.java @@ -16,6 +16,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; @@ -614,6 +615,21 @@ public class ItemResource implements RESTResource { return Response.ok(null, MediaType.TEXT_PLAIN).build(); } + @POST + @RolesAllowed({ Role.ADMIN }) + @Path("/metadata/purge") + @Operation(operationId = "purgeDatabase", summary = "Remove unused/orphaned metadata.", security = { + @SecurityRequirement(name = "oauth2", scopes = { "admin" }) }, responses = { + @ApiResponse(responseCode = "200", description = "OK") }) + public Response purge() { + Collection itemNames = itemRegistry.stream().map(Item::getName) + .collect(Collectors.toCollection(HashSet::new)); + + metadataRegistry.getAll().stream().filter(md -> !itemNames.contains(md.getUID().getItemName())) + .forEach(md -> metadataRegistry.remove(md.getUID())); + return Response.ok().build(); + } + /** * Create or Update an item by supplying an item bean. * diff --git a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/link/ItemChannelLinkResource.java b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/link/ItemChannelLinkResource.java index b61939dbeb..52e82325bd 100644 --- a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/link/ItemChannelLinkResource.java +++ b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/link/ItemChannelLinkResource.java @@ -13,6 +13,7 @@ package org.openhab.core.io.rest.core.internal.link; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -20,6 +21,7 @@ import javax.annotation.security.RolesAllowed; import javax.ws.rs.Consumes; import javax.ws.rs.DELETE; import javax.ws.rs.GET; +import javax.ws.rs.POST; import javax.ws.rs.PUT; import javax.ws.rs.Path; import javax.ws.rs.PathParam; @@ -47,6 +49,7 @@ import org.openhab.core.thing.Channel; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingRegistry; +import org.openhab.core.thing.ThingUID; import org.openhab.core.thing.link.AbstractLink; import org.openhab.core.thing.link.ItemChannelLink; import org.openhab.core.thing.link.ItemChannelLinkRegistry; @@ -141,6 +144,24 @@ public class ItemChannelLinkResource implements RESTResource { return Response.ok(new Stream2JSONInputStream(linkStream)).build(); } + @DELETE + @RolesAllowed({ Role.ADMIN }) + @Path("/{object}") + @Operation(operationId = "removeAllLinksForObject", summary = "Delete all links that refer to an item or thing.", security = { + @SecurityRequirement(name = "oauth2", scopes = { "admin" }) }, responses = { + @ApiResponse(responseCode = "200", description = "OK") }) + public Response removeAllLinksForObject( + @PathParam("object") @Parameter(description = "item name or thing UID") String object) { + int removedLinks; + try { + ThingUID thingUID = new ThingUID(object); + removedLinks = itemChannelLinkRegistry.removeLinksForThing(thingUID); + } catch (IllegalArgumentException e) { + removedLinks = itemChannelLinkRegistry.removeLinksForItem(object); + } + return Response.ok(Map.of("count", removedLinks)).build(); + } + @GET @Path("/{itemName}/{channelUID}") @Produces(MediaType.APPLICATION_JSON) @@ -273,6 +294,17 @@ public class ItemChannelLinkResource implements RESTResource { return Response.ok(null, MediaType.TEXT_PLAIN).build(); } + @POST + @RolesAllowed({ Role.ADMIN }) + @Path("/purge") + @Operation(operationId = "purgeDatabase", summary = "Remove unused/orphaned links.", security = { + @SecurityRequirement(name = "oauth2", scopes = { "admin" }) }, responses = { + @ApiResponse(responseCode = "200", description = "OK") }) + public Response purge() { + itemChannelLinkRegistry.purge(); + return Response.ok().build(); + } + private boolean isEditable(String linkId) { return managedItemChannelLinkProvider.get(linkId) != null; } diff --git a/bundles/org.openhab.core.io.rest.core/src/test/java/org/openhab/core/io/rest/core/internal/link/ItemChannelLinkResourceTest.java b/bundles/org.openhab.core.io.rest.core/src/test/java/org/openhab/core/io/rest/core/internal/link/ItemChannelLinkResourceTest.java new file mode 100644 index 0000000000..45588a5174 --- /dev/null +++ b/bundles/org.openhab.core.io.rest.core/src/test/java/org/openhab/core/io/rest/core/internal/link/ItemChannelLinkResourceTest.java @@ -0,0 +1,95 @@ +/** + * Copyright (c) 2010-2022 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.rest.core.internal.link; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Map; + +import javax.ws.rs.core.Response; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.openhab.core.items.ItemRegistry; +import org.openhab.core.thing.ThingRegistry; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.link.ItemChannelLinkRegistry; +import org.openhab.core.thing.link.ManagedItemChannelLinkProvider; +import org.openhab.core.thing.profiles.ProfileTypeRegistry; +import org.openhab.core.thing.type.ChannelTypeRegistry; + +/** + * The {@link ItemChannelLinkResourceTest} tests the {@link ItemChannelLinkResource} + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class ItemChannelLinkResourceTest { + + private static final int EXPECTED_REMOVED_LINKS = 5; + + private @Mock @NonNullByDefault({}) ItemRegistry itemRegistryMock; + private @Mock @NonNullByDefault({}) ThingRegistry thingRegistryMock; + private @Mock @NonNullByDefault({}) ChannelTypeRegistry channelTypeRegistryMock; + private @Mock @NonNullByDefault({}) ProfileTypeRegistry profileTypeRegistryMock; + private @Mock @NonNullByDefault({}) ItemChannelLinkRegistry itemChannelLinkRegistryMock; + private @Mock @NonNullByDefault({}) ManagedItemChannelLinkProvider managedItemChannelLinkProviderMock; + private @NonNullByDefault({}) ItemChannelLinkResource itemChannelLinkResource; + + @BeforeEach + public void setup() { + itemChannelLinkResource = new ItemChannelLinkResource(itemRegistryMock, thingRegistryMock, + channelTypeRegistryMock, profileTypeRegistryMock, itemChannelLinkRegistryMock, + managedItemChannelLinkProviderMock); + when(itemChannelLinkRegistryMock.removeLinksForItem(any())).thenReturn(EXPECTED_REMOVED_LINKS); + when(itemChannelLinkRegistryMock.removeLinksForThing(any())).thenReturn(EXPECTED_REMOVED_LINKS); + } + + @Test + public void testRemoveAllLinksForItem() { + try (Response response = itemChannelLinkResource.removeAllLinksForObject("testItem")) { + assertThat(response.getStatus(), is(200)); + Object responseEntity = response.getEntity(); + assertThat(responseEntity, instanceOf(Map.class)); + assertThat(((Map) responseEntity).get("count"), is(EXPECTED_REMOVED_LINKS)); + } + + verify(itemChannelLinkRegistryMock).removeLinksForItem(eq("testItem")); + } + + @Test + public void testRemoveAllLinksForThing() { + try (Response response = itemChannelLinkResource.removeAllLinksForObject("binding:type:thing")) { + assertThat(response.getStatus(), is(200)); + Object responseEntity = response.getEntity(); + assertThat(responseEntity, instanceOf(Map.class)); + assertThat(((Map) responseEntity).get("count"), is(EXPECTED_REMOVED_LINKS)); + } + + verify(itemChannelLinkRegistryMock).removeLinksForThing(eq(new ThingUID("binding:type:thing"))); + } +} diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/link/ItemChannelLinkRegistry.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/link/ItemChannelLinkRegistry.java index 094e70f622..7fe2309484 100644 --- a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/link/ItemChannelLinkRegistry.java +++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/link/ItemChannelLinkRegistry.java @@ -12,16 +12,19 @@ */ package org.openhab.core.thing.link; +import java.util.List; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.common.registry.ManagedProvider; import org.openhab.core.events.EventPublisher; import org.openhab.core.items.Item; import org.openhab.core.items.ItemRegistry; import org.openhab.core.service.ReadyService; +import org.openhab.core.thing.Channel; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingRegistry; @@ -127,10 +130,50 @@ public class ItemChannelLinkRegistry extends AbstractLinkRegistry new IllegalStateException("ManagedProvider is not available"))) - .removeLinksForThing(thingUID); + /** + * Remove all links related to a thing + * + * @param thingUID the UID of the thing + * @return the number of removed links + */ + public int removeLinksForThing(final ThingUID thingUID) { + ManagedItemChannelLinkProvider managedProvider = (ManagedItemChannelLinkProvider) getManagedProvider() + .orElseThrow(() -> new IllegalStateException("ManagedProvider is not available")); + return managedProvider.removeLinksForThing(thingUID); + } + + /** + * Remove all links related to an item + * + * @param itemName the name of the item + * @return the number of removed links + */ + public int removeLinksForItem(final String itemName) { + ManagedItemChannelLinkProvider managedProvider = (ManagedItemChannelLinkProvider) getManagedProvider() + .orElseThrow(() -> new IllegalStateException("ManagedProvider is not available")); + return managedProvider.removeLinksForItem(itemName); + } + + /** + * Remove all orphaned (item or channel missing) links + * + * @return the number of removed links + */ + public int purge() { + ManagedProvider managedProvider = getManagedProvider() + .orElseThrow(() -> new IllegalStateException("ManagedProvider is not available")); + + Set allItems = itemRegistry.stream().map(Item::getName).collect(Collectors.toSet()); + Set allChannels = thingRegistry.stream().map(Thing::getChannels).flatMap(List::stream) + .map(Channel::getUID).collect(Collectors.toSet()); + + Set toRemove = managedProvider.getAll().stream() + .filter(link -> !allItems.contains(link.getItemName()) || !allChannels.contains(link.getLinkedUID())) + .map(ItemChannelLink::getUID).collect(Collectors.toSet()); + + toRemove.forEach(managedProvider::remove); + + return toRemove.size(); } @Override diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/link/ManagedItemChannelLinkProvider.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/link/ManagedItemChannelLinkProvider.java index bee729a6ae..edbb578be3 100644 --- a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/link/ManagedItemChannelLinkProvider.java +++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/link/ManagedItemChannelLinkProvider.java @@ -48,12 +48,27 @@ public class ManagedItemChannelLinkProvider extends DefaultAbstractManagedProvid return key; } - public void removeLinksForThing(ThingUID thingUID) { + public int removeLinksForThing(ThingUID thingUID) { + int removedLinks = 0; Collection itemChannelLinks = getAll(); for (ItemChannelLink itemChannelLink : itemChannelLinks) { if (itemChannelLink.getLinkedUID().getThingUID().equals(thingUID)) { this.remove(itemChannelLink.getUID()); + removedLinks++; } } + return removedLinks; + } + + public int removeLinksForItem(String itemName) { + int removedLinks = 0; + Collection itemChannelLinks = getAll(); + for (ItemChannelLink itemChannelLink : itemChannelLinks) { + if (itemChannelLink.getItemName().equals(itemName)) { + this.remove(itemChannelLink.getUID()); + removedLinks++; + } + } + return removedLinks; } } diff --git a/itests/org.openhab.core.thing.tests/src/main/java/org/openhab/core/thing/link/ItemChannelLinkOSGiTest.java b/itests/org.openhab.core.thing.tests/src/main/java/org/openhab/core/thing/link/ItemChannelLinkOSGiTest.java index dc1c376dbf..ddf2fb4890 100644 --- a/itests/org.openhab.core.thing.tests/src/main/java/org/openhab/core/thing/link/ItemChannelLinkOSGiTest.java +++ b/itests/org.openhab.core.thing.tests/src/main/java/org/openhab/core/thing/link/ItemChannelLinkOSGiTest.java @@ -12,17 +12,27 @@ */ package org.openhab.core.thing.link; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; +import java.util.ArrayList; import java.util.Hashtable; +import java.util.List; import java.util.Set; +import java.util.stream.Collectors; import org.eclipse.jdt.annotation.NonNullByDefault; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.openhab.core.items.ManagedItemProvider; import org.openhab.core.library.CoreItemFactory; +import org.openhab.core.library.items.ColorItem; import org.openhab.core.test.java.JavaOSGiTest; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.ManagedThingProvider; @@ -42,6 +52,12 @@ import org.osgi.service.component.ComponentContext; @NonNullByDefault public class ItemChannelLinkOSGiTest extends JavaOSGiTest { + private static final String BULK_BASE_THING_UID = "binding:type:thing"; + private static final String BULK_BASE_ITEM_NAME = "item"; + private static int BULK_ITEM_COUNT = 3; + private static int BULK_THING_COUNT = 3; + private static int BULK_CHANNEL_COUNT = 3; + private static final String ITEM = "item"; private static final ThingTypeUID THING_TYPE_UID = new ThingTypeUID("binding:thing"); private static final ThingUID THING_UID = new ThingUID(THING_TYPE_UID, "thing"); @@ -51,6 +67,7 @@ public class ItemChannelLinkOSGiTest extends JavaOSGiTest { private @NonNullByDefault({}) ManagedItemChannelLinkProvider managedItemChannelLinkProvider; private @NonNullByDefault({}) ItemChannelLinkRegistry itemChannelLinkRegistry; private @NonNullByDefault({}) ManagedThingProvider managedThingProvider; + private @NonNullByDefault({}) ManagedItemProvider managedItemProvider; @BeforeEach public void setup() { @@ -58,6 +75,8 @@ public class ItemChannelLinkOSGiTest extends JavaOSGiTest { managedThingProvider = getService(ManagedThingProvider.class); managedThingProvider.add(ThingBuilder.create(THING_TYPE_UID, THING_UID) .withChannel(ChannelBuilder.create(CHANNEL_UID, CoreItemFactory.COLOR).build()).build()); + managedItemProvider = getService(ManagedItemProvider.class); + itemChannelLinkRegistry = getService(ItemChannelLinkRegistry.class); managedItemChannelLinkProvider = getService(ManagedItemChannelLinkProvider.class); assertNotNull(managedItemChannelLinkProvider); @@ -125,8 +144,94 @@ public class ItemChannelLinkOSGiTest extends JavaOSGiTest { } @Test - public void assertThatgetBoundThingsReturnsEmptySet() { + public void assertThatGetBoundThingsReturnsEmptySet() { Set boundThings = itemChannelLinkRegistry.getBoundThings("notExistingItem"); assertTrue(boundThings.isEmpty()); } + + @Test + public void assertThatAllLinksForItemCanBeDeleted() { + fillRegistryForBulkTests(); + + String itemToRemove = BULK_BASE_ITEM_NAME + "_0_1_1"; + int removed = itemChannelLinkRegistry.removeLinksForItem(itemToRemove); + assertThat(removed, is(1)); + + assertThat(itemChannelLinkRegistry.stream().map(ItemChannelLink::getItemName).collect(Collectors.toList()), + not(hasItem(itemToRemove))); + assertThat(itemChannelLinkRegistry.getAll(), + hasSize(BULK_ITEM_COUNT * BULK_THING_COUNT * BULK_CHANNEL_COUNT - 1)); + } + + @Test + public void assertThatAllLinksForThingCanBeDeleted() { + fillRegistryForBulkTests(); + + ThingUID thingToRemove = new ThingUID(BULK_BASE_THING_UID + "_0_0"); + int removed = itemChannelLinkRegistry.removeLinksForThing(thingToRemove); + assertThat(removed, is(BULK_CHANNEL_COUNT)); + + assertThat(itemChannelLinkRegistry.stream().map(ItemChannelLink::getLinkedUID).map(ChannelUID::getThingUID) + .collect(Collectors.toList()), not(hasItem(thingToRemove))); + assertThat(itemChannelLinkRegistry.getAll(), + hasSize((BULK_ITEM_COUNT * BULK_THING_COUNT - 1) * BULK_CHANNEL_COUNT)); + } + + @Test + public void assertThatCompressOnlyRemovesInvalidLinks() { + fillRegistryForBulkTests(); + + int expected = BULK_ITEM_COUNT * BULK_THING_COUNT * BULK_CHANNEL_COUNT; + + int removed = itemChannelLinkRegistry.purge(); + assertThat(removed, is(0)); + assertThat(itemChannelLinkRegistry.getAll(), hasSize(expected)); + + managedItemProvider.remove(BULK_BASE_ITEM_NAME + "_0_0_0"); + removed = itemChannelLinkRegistry.purge(); + expected -= removed; + assertThat(removed, is(1)); + assertThat(itemChannelLinkRegistry.getAll(), hasSize(expected)); + + managedThingProvider.remove(new ThingUID(BULK_BASE_THING_UID + "_1_0")); + removed = itemChannelLinkRegistry.purge(); + expected -= removed; + assertThat(removed, is(BULK_CHANNEL_COUNT)); + assertThat(itemChannelLinkRegistry.getAll(), hasSize(expected)); + + managedItemProvider.remove(BULK_BASE_ITEM_NAME + "_2_0_0"); + managedThingProvider.remove(new ThingUID(BULK_BASE_THING_UID + "_2_0")); + removed = itemChannelLinkRegistry.purge(); + expected -= removed; + assertThat(removed, is(BULK_CHANNEL_COUNT)); + assertThat(itemChannelLinkRegistry.getAll(), hasSize(expected)); + } + + private void fillRegistryForBulkTests() { + // clear all old links and things + managedItemChannelLinkProvider.getAll().forEach(it -> managedItemChannelLinkProvider.remove(it.getUID())); + managedThingProvider.getAll().forEach(it -> managedThingProvider.remove(it.getUID())); + + for (int i = 0; i < BULK_ITEM_COUNT; i++) { + for (int j = 0; j < BULK_THING_COUNT; j++) { + ThingUID thingUID = new ThingUID(BULK_BASE_THING_UID + "_" + i + "_" + j); + ThingBuilder thingBuilder = ThingBuilder.create(THING_TYPE_UID, thingUID); + List links = new ArrayList<>(); + for (int k = 0; k < BULK_CHANNEL_COUNT; k++) { + String itemName = BULK_BASE_ITEM_NAME + "_" + i + "_" + j + "_" + k; + managedItemProvider.add(new ColorItem(itemName)); + + ChannelUID channelUID = new ChannelUID(thingUID, "channel" + k); + thingBuilder.withChannel(ChannelBuilder.create(channelUID, CoreItemFactory.COLOR).build()); + links.add(new ItemChannelLink(itemName, channelUID)); + } + managedThingProvider.add(thingBuilder.build()); + links.forEach(managedItemChannelLinkProvider::add); + } + } + + waitForAssert(() -> assertThat(itemChannelLinkRegistry.getAll(), + hasSize(BULK_ITEM_COUNT * BULK_THING_COUNT * BULK_CHANNEL_COUNT))); + assertThat(managedThingProvider.getAll(), hasSize(BULK_ITEM_COUNT * BULK_THING_COUNT)); + } }