diff --git a/application/src/main/data/upgrade/3.6.3/schema_update.sql b/application/src/main/data/upgrade/3.6.3/schema_update.sql index d7d8887d13..621b68ff16 100644 --- a/application/src/main/data/upgrade/3.6.3/schema_update.sql +++ b/application/src/main/data/upgrade/3.6.3/schema_update.sql @@ -14,6 +14,105 @@ -- limitations under the License. -- +-- UPDATE PUBLIC CUSTOMERS START + +ALTER TABLE customer ADD COLUMN IF NOT EXISTS is_public boolean DEFAULT false; +UPDATE customer SET is_public = true WHERE title = 'Public'; + +-- UPDATE PUBLIC CUSTOMERS END + +-- UPDATE CUSTOMERS WITH SAME TITLE START + +CREATE OR REPLACE PROCEDURE update_customers_with_the_same_title() + LANGUAGE plpgsql +AS +$$ +DECLARE + customer_record RECORD; + dashboard_record RECORD; + title_exists BOOLEAN; + new_title TEXT; + updated_json JSONB; +BEGIN + RAISE NOTICE 'Starting the customer and dashboard update process.'; + + FOR customer_record IN + SELECT id, tenant_id, title, duplicate_number + FROM ( + SELECT + id, + tenant_id, + title, + ROW_NUMBER() OVER(PARTITION BY tenant_id, title ORDER BY id) AS duplicate_number + FROM customer + ) AS duplicate_customers + WHERE duplicate_number > 1 + LOOP + -- Attempt with 'duplicate' suffix + new_title := customer_record.title || ' duplicate ' || (customer_record.duplicate_number - 1)::TEXT; + + -- Check if new_title already exists for the same tenant_id + SELECT EXISTS ( + SELECT 1 + FROM customer + WHERE tenant_id = customer_record.tenant_id + AND title = new_title + ) INTO title_exists; + + -- If generated title exists, use customer id instead to create a unique title + IF title_exists THEN + new_title := customer_record.title || ' duplicate ' || customer_record.id::TEXT; + END IF; + + -- Update the customer title + UPDATE customer + SET title = new_title + WHERE id = customer_record.id; + RAISE NOTICE 'Updated customer with id: % with new title: %', customer_record.id, new_title; + + -- Find and update related dashboards for the customer + FOR dashboard_record IN + SELECT d.id, d.assigned_customers + FROM dashboard d + JOIN relation r ON d.id = r.to_id + WHERE r.from_id = customer_record.id + AND r.to_type = 'DASHBOARD' + AND r.relation_type_group = 'DASHBOARD' + AND r.relation_type = 'Contains' + LOOP + -- Update each assigned_customers entry where the customerId matches + updated_json := (SELECT jsonb_agg( + CASE + WHEN (value -> 'customerId' ->> 'id')::uuid = customer_record.id + THEN jsonb_set(value, '{title}', ('"' || new_title || '"')::jsonb) + ELSE value + END + ) + FROM jsonb_array_elements(dashboard_record.assigned_customers::jsonb)); + + UPDATE dashboard + SET assigned_customers = updated_json + WHERE id = dashboard_record.id; + RAISE NOTICE 'Updated dashboard with id: % with new assigned_customers: %', dashboard_record.id, updated_json; + END LOOP; + END LOOP; + RAISE NOTICE 'Customers and dashboards update process completed successfully!'; +END; +$$; + +call update_customers_with_the_same_title(); + +DROP PROCEDURE IF EXISTS update_customers_with_the_same_title; + +-- UPDATE CUSTOMERS WITH SAME TITLE END + +-- CUSTOMER UNIQUE CONSTRAINT UPDATE START + +ALTER TABLE customer DROP CONSTRAINT IF EXISTS customer_title_unq_key; +ALTER TABLE customer ADD CONSTRAINT customer_title_unq_key UNIQUE (tenant_id, title); + +-- CUSTOMER UNIQUE CONSTRAINT UPDATE END + -- create new attribute_kv table schema DO $$ diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 33146b1b82..88398f2d9b 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -505,6 +505,12 @@ cache: assets: timeToLiveInMinutes: "${CACHE_SPECS_ASSETS_TTL:1440}" # Asset cache TTL maxSize: "${CACHE_SPECS_ASSETS_MAX_SIZE:10000}" # 0 means the cache is disabled + customers: + timeToLiveInMinutes: "${CACHE_SPECS_CUSTOMERS_TTL:1440}" # Customer cache TTL + maxSize: "${CACHE_SPECS_CUSTOMERS_MAX_SIZE:10000}" # 0 means the cache is disabled + users: + timeToLiveInMinutes: "${CACHE_SPECS_USERS_TTL:1440}" # User cache TTL + maxSize: "${CACHE_SPECS_USERS_MAX_SIZE:10000}" # 0 means the cache is disabled entityViews: timeToLiveInMinutes: "${CACHE_SPECS_ENTITY_VIEWS_TTL:1440}" # Entity view cache TTL maxSize: "${CACHE_SPECS_ENTITY_VIEWS_MAX_SIZE:10000}" # 0 means the cache is disabled diff --git a/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java index 370ba41422..38c40c904b 100644 --- a/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java @@ -353,7 +353,7 @@ public class CustomerControllerTest extends AbstractControllerTest { } @Test - public void testFindCustomersByTitle() throws Exception { + public void testFindCustomersWithTitleAsTextSearch() throws Exception { TenantId tenantId = savedTenant.getId(); String title1 = "Customer title 1"; @@ -425,6 +425,31 @@ public class CustomerControllerTest extends AbstractControllerTest { Assert.assertEquals(0, pageData.getData().size()); } + @Test + public void testFindCustomerByTitle() throws Exception { + Customer customer = new Customer(); + customer.setTitle("My customer"); + + Mockito.reset(tbClusterService, auditLogService); + + Customer savedCustomer = doPost("/api/customer", customer, Customer.class); + + testNotifyEntityAllOneTime(savedCustomer, savedCustomer.getId(), savedCustomer.getId(), savedCustomer.getTenantId(), + new CustomerId(CustomerId.NULL_UUID), tenantAdmin.getId(), tenantAdmin.getEmail(), + ActionType.ADDED); + + Assert.assertNotNull(savedCustomer); + Assert.assertNotNull(savedCustomer.getId()); + Assert.assertTrue(savedCustomer.getCreatedTime() > 0); + Assert.assertEquals(customer.getTitle(), savedCustomer.getTitle()); + + Customer foundCustomer = doGet("/api/tenant/customers?customerTitle=" + savedCustomer.getTitle(), Customer.class); + Assert.assertEquals(foundCustomer, savedCustomer); + + doDelete("/api/customer/" + savedCustomer.getId().getId().toString()) + .andExpect(status().isOk()); + } + @Test public void testDeleteCustomerWithDeleteRelationsOk() throws Exception { CustomerId customerId = createCustomer("Customer for Test WithRelationsOk").getId(); diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java index 3287af19b6..194a0a0c27 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java @@ -275,10 +275,9 @@ public class EntityViewControllerTest extends AbstractControllerTest { } @Test - public void testAssignAndUnAssignedEntityViewToCustomer() throws Exception { + public void testAssignAndUnassignEntityViewToCustomer() throws Exception { EntityView view = getNewSavedEntityView("Test entity view"); Customer savedCustomer = doPost("/api/customer", getNewCustomer("My customer"), Customer.class); - view.setCustomerId(savedCustomer.getId()); Mockito.reset(tbClusterService, auditLogService); @@ -304,17 +303,17 @@ public class EntityViewControllerTest extends AbstractControllerTest { ActionType.ASSIGNED_TO_CUSTOMER, ActionType.UPDATED, foundView.getId().getId().toString(), foundView.getCustomerId().getId().toString(), savedCustomer.getTitle()); - EntityView unAssignedView = doDelete("/api/customer/entityView/" + savedView.getId().getId().toString(), EntityView.class); - assertEquals(ModelConstants.NULL_UUID, unAssignedView.getCustomerId().getId()); + EntityView unassignedView = doDelete("/api/customer/entityView/" + savedView.getId().getId().toString(), EntityView.class); + assertEquals(ModelConstants.NULL_UUID, unassignedView.getCustomerId().getId()); foundView = doGet("/api/entityView/" + savedView.getId().getId().toString(), EntityView.class); assertEquals(ModelConstants.NULL_UUID, foundView.getCustomerId().getId()); testBroadcastEntityStateChangeEventTime(foundView.getId(), foundView.getTenantId(), 1); - testNotifyAssignUnassignEntityAllOneTime(unAssignedView, savedView.getId(), savedView.getId(), - tenantId, savedView.getCustomerId(), tenantAdminUserId, TENANT_ADMIN_EMAIL, + testNotifyAssignUnassignEntityAllOneTime(unassignedView, unassignedView.getId(), unassignedView.getId(), + tenantId, savedCustomer.getId(), tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.UNASSIGNED_FROM_CUSTOMER, ActionType.UPDATED, - assignedView.getId().getId().toString(), savedView.getCustomerId().getId().toString(), savedCustomer.getTitle()); + assignedView.getId().getId().toString(), savedCustomer.getId().getId().toString(), savedCustomer.getTitle()); } @Test diff --git a/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java index 72930e0327..1cb0c4c618 100644 --- a/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java @@ -279,7 +279,7 @@ public class UserControllerTest extends AbstractControllerTest { user.setTenantId(tenantId); user.setEmail(TENANT_ADMIN_EMAIL); - String msgError = "User with email '" + TENANT_ADMIN_EMAIL + "' already present in database"; + String msgError = "User with email '" + TENANT_ADMIN_EMAIL + "' already present in database!"; doPost("/api/user", user) .andExpect(status().isBadRequest()) .andExpect(statusReason(containsString(msgError))); diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/customer/CustomerCacheEvictEvent.java b/common/cache/src/main/java/org/thingsboard/server/cache/customer/CustomerCacheEvictEvent.java new file mode 100644 index 0000000000..466d5e1702 --- /dev/null +++ b/common/cache/src/main/java/org/thingsboard/server/cache/customer/CustomerCacheEvictEvent.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.cache.customer; + +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.thingsboard.server.common.data.id.TenantId; + +@Data +@RequiredArgsConstructor +public class CustomerCacheEvictEvent { + + private final TenantId tenantId; + private final String newTitle; + private final String oldTitle; + +} diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/customer/CustomerCacheKey.java b/common/cache/src/main/java/org/thingsboard/server/cache/customer/CustomerCacheKey.java new file mode 100644 index 0000000000..b556992a0c --- /dev/null +++ b/common/cache/src/main/java/org/thingsboard/server/cache/customer/CustomerCacheKey.java @@ -0,0 +1,44 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.cache.customer; + +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.thingsboard.server.common.data.id.TenantId; + +import java.io.Serial; +import java.io.Serializable; + +@Getter +@EqualsAndHashCode +@RequiredArgsConstructor +@Builder +public class CustomerCacheKey implements Serializable { + + @Serial + private static final long serialVersionUID = 5706958428811356925L; + + private final TenantId tenantId; + private final String title; + + @Override + public String toString() { + return tenantId + "_" + title; + } + +} diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/customer/CustomerCaffeineCache.java b/common/cache/src/main/java/org/thingsboard/server/cache/customer/CustomerCaffeineCache.java new file mode 100644 index 0000000000..8c18977ef6 --- /dev/null +++ b/common/cache/src/main/java/org/thingsboard/server/cache/customer/CustomerCaffeineCache.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.cache.customer; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; +import org.thingsboard.server.cache.CaffeineTbTransactionalCache; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.Customer; + +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true) +@Service("CustomerCache") +public class CustomerCaffeineCache extends CaffeineTbTransactionalCache { + + public CustomerCaffeineCache(CacheManager cacheManager) { + super(cacheManager, CacheConstants.CUSTOMER_CACHE); + } + +} diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/customer/CustomerRedisCache.java b/common/cache/src/main/java/org/thingsboard/server/cache/customer/CustomerRedisCache.java new file mode 100644 index 0000000000..bd031f89bd --- /dev/null +++ b/common/cache/src/main/java/org/thingsboard/server/cache/customer/CustomerRedisCache.java @@ -0,0 +1,35 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.cache.customer; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.stereotype.Service; +import org.thingsboard.server.cache.CacheSpecsMap; +import org.thingsboard.server.cache.RedisTbTransactionalCache; +import org.thingsboard.server.cache.TBRedisCacheConfiguration; +import org.thingsboard.server.cache.TbJsonRedisSerializer; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.Customer; + +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") +@Service("CustomerCache") +public class CustomerRedisCache extends RedisTbTransactionalCache { + + public CustomerRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { + super(CacheConstants.CUSTOMER_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJsonRedisSerializer<>(Customer.class)); + } +} diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/EntityContainer.java b/common/cache/src/main/java/org/thingsboard/server/cache/user/UserCacheEvictEvent.java similarity index 68% rename from rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/EntityContainer.java rename to common/cache/src/main/java/org/thingsboard/server/cache/user/UserCacheEvictEvent.java index 457f19460b..bdfbedbc92 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/EntityContainer.java +++ b/common/cache/src/main/java/org/thingsboard/server/cache/user/UserCacheEvictEvent.java @@ -13,16 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.rule.engine.util; +package org.thingsboard.server.cache.user; import lombok.Data; -import org.thingsboard.server.common.data.EntityType; -import org.thingsboard.server.common.data.id.EntityId; +import lombok.RequiredArgsConstructor; +import org.thingsboard.server.common.data.id.TenantId; @Data -public class EntityContainer { +@RequiredArgsConstructor +public class UserCacheEvictEvent { - private EntityId entityId; - private EntityType entityType; + private final TenantId tenantId; + private final String newEmail; + private final String oldEmail; } diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/user/UserCacheKey.java b/common/cache/src/main/java/org/thingsboard/server/cache/user/UserCacheKey.java new file mode 100644 index 0000000000..f3ca15d2a1 --- /dev/null +++ b/common/cache/src/main/java/org/thingsboard/server/cache/user/UserCacheKey.java @@ -0,0 +1,44 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.cache.user; + +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.thingsboard.server.common.data.id.TenantId; + +import java.io.Serial; +import java.io.Serializable; + +@Getter +@EqualsAndHashCode +@RequiredArgsConstructor +@Builder +public class UserCacheKey implements Serializable { + + @Serial + private static final long serialVersionUID = 7357353074893750678L; + + private final TenantId tenantId; + private final String email; + + @Override + public String toString() { + return tenantId + "_" + email; + } + +} diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/user/UserCaffeineCache.java b/common/cache/src/main/java/org/thingsboard/server/cache/user/UserCaffeineCache.java new file mode 100644 index 0000000000..07e63424ce --- /dev/null +++ b/common/cache/src/main/java/org/thingsboard/server/cache/user/UserCaffeineCache.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.cache.user; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; +import org.thingsboard.server.cache.CaffeineTbTransactionalCache; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.User; + +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true) +@Service("UserCache") +public class UserCaffeineCache extends CaffeineTbTransactionalCache { + + public UserCaffeineCache(CacheManager cacheManager) { + super(cacheManager, CacheConstants.USER_CACHE); + } + +} diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/user/UserRedisCache.java b/common/cache/src/main/java/org/thingsboard/server/cache/user/UserRedisCache.java new file mode 100644 index 0000000000..545f65b692 --- /dev/null +++ b/common/cache/src/main/java/org/thingsboard/server/cache/user/UserRedisCache.java @@ -0,0 +1,35 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.cache.user; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.stereotype.Service; +import org.thingsboard.server.cache.CacheSpecsMap; +import org.thingsboard.server.cache.RedisTbTransactionalCache; +import org.thingsboard.server.cache.TBRedisCacheConfiguration; +import org.thingsboard.server.cache.TbJsonRedisSerializer; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.User; + +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") +@Service("UserCache") +public class UserRedisCache extends RedisTbTransactionalCache { + + public UserRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { + super(CacheConstants.USER_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJsonRedisSerializer<>(User.class)); + } +} diff --git a/common/cache/src/test/java/org/thingsboard/server/cache/CacheSpecsMapTest.java b/common/cache/src/test/java/org/thingsboard/server/cache/CacheSpecsMapTest.java index 18bf1c9f40..aec5e01f39 100644 --- a/common/cache/src/test/java/org/thingsboard/server/cache/CacheSpecsMapTest.java +++ b/common/cache/src/test/java/org/thingsboard/server/cache/CacheSpecsMapTest.java @@ -26,6 +26,7 @@ import org.springframework.cache.support.SimpleCacheManager; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.thingsboard.server.common.data.CacheConstants; import static org.assertj.core.api.Assertions.assertThat; @@ -53,8 +54,8 @@ public class CacheSpecsMapTest { @Test public void givenCacheConfig_whenCacheManagerReady_thenVerifyExistedCachesWithNoTransactionAwareCacheDecorator() { // We no longer use built-in transaction support for the caches, because we have our own cache cleanup and transaction logic that implements CAS. - assertThat(cacheManager.getCache("relations")).isInstanceOf(CaffeineCache.class); - assertThat(cacheManager.getCache("devices")).isInstanceOf(CaffeineCache.class); + assertThat(cacheManager.getCache(CacheConstants.RELATIONS_CACHE)).isInstanceOf(CaffeineCache.class); + assertThat(cacheManager.getCache(CacheConstants.DEVICE_CACHE)).isInstanceOf(CaffeineCache.class); } @Test diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java index 9fda1c6bb5..e004dd218d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java @@ -21,6 +21,8 @@ public class CacheConstants { public static final String DEVICE_CACHE = "devices"; public static final String SESSIONS_CACHE = "sessions"; public static final String ASSET_CACHE = "assets"; + public static final String CUSTOMER_CACHE = "customers"; + public static final String USER_CACHE = "users"; public static final String ENTITY_VIEW_CACHE = "entityViews"; public static final String EDGE_CACHE = "edges"; public static final String CLAIM_DEVICES_CACHE = "claimDevices"; diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java index f7342e7634..6ad899b805 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java @@ -184,6 +184,9 @@ public class BaseAssetService extends AbstractCachedEntityService, TenantEntityDao, ExportableE PageData findCustomersByTenantId(UUID tenantId, PageLink pageLink); /** - * Find customers by tenantId and customer title. + * Find customer by tenantId and customer title. * * @param tenantId the tenantId * @param title the customer title * @return the optional customer object */ - Optional findCustomersByTenantIdAndTitle(UUID tenantId, String title); + Optional findCustomerByTenantIdAndTitle(UUID tenantId, String title); + + /** + * Find public customer by tenantId. + * + * @param tenantId the tenantId + * @return the optional public customer object + */ + Optional findPublicCustomerByTenantId(UUID tenantId); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java index 04308cbb2e..e29e0b5732 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java @@ -15,15 +15,21 @@ */ package org.thingsboard.server.dao.customer; +import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.util.ConcurrentReferenceHashMap; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.cache.customer.CustomerCacheEvictEvent; +import org.thingsboard.server.cache.customer.CustomerCacheKey; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.HasId; @@ -33,7 +39,7 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.device.DeviceService; -import org.thingsboard.server.dao.entity.AbstractEntityService; +import org.thingsboard.server.dao.entity.AbstractCachedEntityService; import org.thingsboard.server.dao.entity.EntityCountService; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; @@ -44,17 +50,24 @@ import org.thingsboard.server.dao.service.Validator; import org.thingsboard.server.dao.usagerecord.ApiUsageStateService; import org.thingsboard.server.dao.user.UserService; +import java.util.ArrayList; +import java.util.List; import java.util.Optional; +import java.util.concurrent.ConcurrentMap; import static org.thingsboard.server.dao.service.Validator.validateId; @Service("CustomerDaoService") @Slf4j -public class CustomerServiceImpl extends AbstractEntityService implements CustomerService { +public class CustomerServiceImpl extends AbstractCachedEntityService implements CustomerService { public static final String PUBLIC_CUSTOMER_TITLE = "Public"; public static final String INCORRECT_CUSTOMER_ID = "Incorrect customerId "; public static final String INCORRECT_TENANT_ID = "Incorrect tenantId "; + public static final String PUBLIC_CUSTOMER_ADDITIONAL_INFO_STR = "{ \"isPublic\": true }"; + public static final JsonNode PUBLIC_CUSTOMER_ADDITIONAL_INFO_JSON = JacksonUtil.toJsonNode(PUBLIC_CUSTOMER_ADDITIONAL_INFO_STR); + + private final ConcurrentMap publicCustomerCreationLocks = new ConcurrentReferenceHashMap<>(); @Autowired private CustomerDao customerDao; @@ -81,6 +94,17 @@ public class CustomerServiceImpl extends AbstractEntityService implements Custom @Autowired private EntityCountService countService; + @TransactionalEventListener(classes = CustomerCacheEvictEvent.class) + @Override + public void handleEvictEvent(CustomerCacheEvictEvent event) { + List keys = new ArrayList<>(2); + keys.add(new CustomerCacheKey(event.getTenantId(), event.getNewTitle())); + if (StringUtils.isNotEmpty(event.getOldTitle()) && !event.getOldTitle().equals(event.getNewTitle())) { + keys.add(new CustomerCacheKey(event.getTenantId(), event.getOldTitle())); + } + cache.evict(keys); + } + @Override public Customer findCustomerById(TenantId tenantId, CustomerId customerId) { log.trace("Executing findCustomerById [{}]", customerId); @@ -92,7 +116,9 @@ public class CustomerServiceImpl extends AbstractEntityService implements Custom public Optional findCustomerByTenantIdAndTitle(TenantId tenantId, String title) { log.trace("Executing findCustomerByTenantIdAndTitle [{}] [{}]", tenantId, title); validateId(tenantId, id -> INCORRECT_TENANT_ID + id); - return customerDao.findCustomersByTenantIdAndTitle(tenantId.getId(), title); + return Optional.ofNullable(cache.getAndPutInTransaction(new CustomerCacheKey(tenantId, title), + () -> customerDao.findCustomerByTenantIdAndTitle(tenantId.getId(), title) + .orElse(null), true)); } @Override @@ -103,23 +129,38 @@ public class CustomerServiceImpl extends AbstractEntityService implements Custom } @Override + @Transactional public Customer saveCustomer(Customer customer) { + return saveCustomer(customer, true); + } + + private Customer saveCustomer(Customer customer, boolean doValidate) { log.trace("Executing saveCustomer [{}]", customer); - customerValidator.validate(customer, Customer::getTenantId); + String oldCustomerTitle = null; + if (doValidate) { + Customer oldCustomer = customerValidator.validate(customer, Customer::getTenantId); + if (oldCustomer != null) { + oldCustomerTitle = oldCustomer.getTitle(); + } + } + var evictEvent = new CustomerCacheEvictEvent(customer.getTenantId(), customer.getTitle(), oldCustomerTitle); try { - Customer savedCustomer = customerDao.save(customer.getTenantId(), customer); + Customer savedCustomer = customerDao.saveAndFlush(customer.getTenantId(), customer); dashboardService.updateCustomerDashboards(savedCustomer.getTenantId(), savedCustomer.getId()); if (customer.getId() == null) { countService.publishCountEntityEvictEvent(savedCustomer.getTenantId(), EntityType.CUSTOMER); } + publishEvictEvent(evictEvent); eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(savedCustomer.getTenantId()) .entityId(savedCustomer.getId()).created(customer.getId() == null).build()); return savedCustomer; } catch (Exception e) { - checkConstraintViolation(e, "customer_external_id_unq_key", "Customer with such external id already exists!"); + handleEvictEvent(evictEvent); + checkConstraintViolation(e, + "customer_title_unq_key", "Customer with such title already exists!", + "customer_external_id_unq_key", "Customer with such external id already exists!"); throw e; } - } @Override @@ -142,27 +183,32 @@ public class CustomerServiceImpl extends AbstractEntityService implements Custom customerDao.removeById(tenantId, customerId.getId()); countService.publishCountEntityEvictEvent(tenantId, EntityType.CUSTOMER); eventPublisher.publishEvent(DeleteEntityEvent.builder().tenantId(tenantId).entityId(customerId).build()); + publishEvictEvent(new CustomerCacheEvictEvent(customer.getTenantId(), customer.getTitle(), null)); } @Override + @Transactional public Customer findOrCreatePublicCustomer(TenantId tenantId) { log.trace("Executing findOrCreatePublicCustomer, tenantId [{}]", tenantId); - Validator.validateId(tenantId, id -> INCORRECT_CUSTOMER_ID + id); - Optional publicCustomerOpt = customerDao.findCustomersByTenantIdAndTitle(tenantId.getId(), PUBLIC_CUSTOMER_TITLE); + Validator.validateId(tenantId, id -> INCORRECT_TENANT_ID + tenantId); + Optional publicCustomerOpt = customerDao.findPublicCustomerByTenantId(tenantId.getId()); if (publicCustomerOpt.isPresent()) { return publicCustomerOpt.get(); - } else { - Customer publicCustomer = new Customer(); + } + synchronized (publicCustomerCreationLocks.computeIfAbsent(tenantId, k -> new Object())) { + publicCustomerOpt = customerDao.findPublicCustomerByTenantId(tenantId.getId()); + if (publicCustomerOpt.isPresent()) { + return publicCustomerOpt.get(); + } + var publicCustomer = new Customer(); publicCustomer.setTenantId(tenantId); publicCustomer.setTitle(PUBLIC_CUSTOMER_TITLE); try { - publicCustomer.setAdditionalInfo(JacksonUtil.toJsonNode("{ \"isPublic\": true }")); + publicCustomer.setAdditionalInfo(PUBLIC_CUSTOMER_ADDITIONAL_INFO_JSON); } catch (IllegalArgumentException e) { throw new IncorrectParameterException("Unable to create public customer.", e); } - Customer savedCustomer = customerDao.save(tenantId, publicCustomer); - countService.publishCountEntityEvictEvent(tenantId, EntityType.CUSTOMER); - return savedCustomer; + return saveCustomer(publicCustomer, false); } } @@ -181,8 +227,8 @@ public class CustomerServiceImpl extends AbstractEntityService implements Custom customersByTenantRemover.removeEntities(tenantId, tenantId); } - private PaginatedRemover customersByTenantRemover = - new PaginatedRemover() { + private final PaginatedRemover customersByTenantRemover = + new PaginatedRemover<>() { @Override protected PageData findEntities(TenantId tenantId, TenantId id, PageLink pageLink) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/dashboard/DashboardServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/dashboard/DashboardServiceImpl.java index 7f90d82af7..3e72a8d526 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/dashboard/DashboardServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/dashboard/DashboardServiceImpl.java @@ -171,8 +171,6 @@ public class DashboardServiceImpl extends AbstractEntityService implements Dashb } } - - @Override public Dashboard assignDashboardToCustomer(TenantId tenantId, DashboardId dashboardId, CustomerId customerId) { Dashboard dashboard = findDashboardById(tenantId, dashboardId); @@ -216,12 +214,10 @@ public class DashboardServiceImpl extends AbstractEntityService implements Dashb } } - private Dashboard updateAssignedCustomer(TenantId tenantId, DashboardId dashboardId, Customer customer) { + private void updateAssignedCustomer(TenantId tenantId, DashboardId dashboardId, Customer customer) { Dashboard dashboard = findDashboardById(tenantId, dashboardId); if (dashboard.updateAssignedCustomer(customer)) { - return saveDashboard(dashboard); - } else { - return dashboard; + saveDashboard(dashboard); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java index ff00db3564..749735ea05 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java @@ -306,6 +306,9 @@ public class DeviceServiceImpl extends AbstractCachedEntityService { @Column(name = ModelConstants.EMAIL_PROPERTY) private String email; + @Column(name = ModelConstants.CUSTOMER_IS_PUBLIC_PROPERTY) + private boolean isPublic; + @Convert(converter = JsonConverter.class) @Column(name = ModelConstants.CUSTOMER_ADDITIONAL_INFO_PROPERTY) private JsonNode additionalInfo; @@ -94,6 +97,7 @@ public final class CustomerEntity extends BaseSqlEntity { this.phone = customer.getPhone(); this.email = customer.getEmail(); this.additionalInfo = customer.getAdditionalInfo(); + this.isPublic = customer.isPublic(); if (customer.getExternalId() != null) { this.externalId = customer.getExternalId().getId(); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/CustomerDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/CustomerDataValidator.java index 69ca891e7d..2203333614 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/CustomerDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/CustomerDataValidator.java @@ -27,8 +27,6 @@ import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.tenant.TenantService; -import java.util.Optional; - @Component public class CustomerDataValidator extends DataValidator { @@ -41,24 +39,15 @@ public class CustomerDataValidator extends DataValidator { @Override protected void validateCreate(TenantId tenantId, Customer customer) { validateNumberOfEntitiesPerTenant(tenantId, EntityType.CUSTOMER); - customerDao.findCustomersByTenantIdAndTitle(customer.getTenantId().getId(), customer.getTitle()).ifPresent( - c -> { - throw new DataValidationException("Customer with such title already exists!"); - } - ); } @Override protected Customer validateUpdate(TenantId tenantId, Customer customer) { - Optional customerOpt = customerDao.findCustomersByTenantIdAndTitle(customer.getTenantId().getId(), customer.getTitle()); - customerOpt.ifPresent( - c -> { - if (!c.getId().equals(customer.getId())) { - throw new DataValidationException("Customer with such title already exists!"); - } - } - ); - return customerOpt.orElse(null); + Customer old = customerDao.findById(customer.getTenantId(), customer.getId().getId()); + if (old == null) { + throw new DataValidationException("Can't update non existing customer!"); + } + return old; } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/UserDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/UserDataValidator.java index e2b570f6c4..dd02511f5c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/UserDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/UserDataValidator.java @@ -122,11 +122,6 @@ public class UserDataValidator extends DataValidator { break; } - User existentUserWithEmail = userService.findUserByEmail(tenantId, user.getEmail()); - if (existentUserWithEmail != null && !isSameData(existentUserWithEmail, user)) { - throw new DataValidationException("User with email '" + user.getEmail() + "' " - + " already present in database!"); - } if (!tenantId.getId().equals(ModelConstants.NULL_UUID)) { if (!tenantService.tenantExists(user.getTenantId())) { throw new DataValidationException("User is referencing to non-existent tenant!"); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java index 82202faf3b..68d75e641c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java @@ -38,6 +38,11 @@ public interface CustomerRepository extends JpaRepository, CustomerEntity findByTenantIdAndTitle(UUID tenantId, String title); + @Query(value = "SELECT * FROM customer c WHERE c.tenant_id = :tenantId " + + "AND c.is_public IS TRUE ORDER BY c.id ASC LIMIT 1", nativeQuery = true) + CustomerEntity findPublicCustomerByTenantId(@Param("tenantId") UUID tenantId); + + Long countByTenantId(UUID tenantId); @Query("SELECT externalId FROM CustomerEntity WHERE id = :id") diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java index c8c10814e1..50b5bbd81c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java @@ -30,7 +30,6 @@ import org.thingsboard.server.dao.model.sql.CustomerEntity; import org.thingsboard.server.dao.sql.JpaAbstractDao; import org.thingsboard.server.dao.util.SqlDao; -import java.util.Objects; import java.util.Optional; import java.util.UUID; @@ -63,9 +62,13 @@ public class JpaCustomerDao extends JpaAbstractDao imp } @Override - public Optional findCustomersByTenantIdAndTitle(UUID tenantId, String title) { - Customer customer = DaoUtil.getData(customerRepository.findByTenantIdAndTitle(tenantId, title)); - return Optional.ofNullable(customer); + public Optional findCustomerByTenantIdAndTitle(UUID tenantId, String title) { + return Optional.ofNullable(DaoUtil.getData(customerRepository.findByTenantIdAndTitle(tenantId, title))); + } + + @Override + public Optional findPublicCustomerByTenantId(UUID tenantId) { + return Optional.ofNullable(DaoUtil.getData(customerRepository.findPublicCustomerByTenantId(tenantId))); } @Override @@ -80,7 +83,7 @@ public class JpaCustomerDao extends JpaAbstractDao imp @Override public Customer findByTenantIdAndName(UUID tenantId, String name) { - return findCustomersByTenantIdAndTitle(tenantId, name).orElse(null); + return findCustomerByTenantIdAndTitle(tenantId, name).orElse(null); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java index 4b804353ad..8e61516e61 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java @@ -27,8 +27,12 @@ import org.springframework.security.authentication.DisabledException; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionalEventListener; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.cache.user.UserCacheEvictEvent; +import org.thingsboard.server.cache.user.UserCacheKey; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.id.CustomerId; @@ -47,7 +51,7 @@ import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.common.data.security.event.UserCredentialsInvalidationEvent; import org.thingsboard.server.common.data.settings.UserSettings; import org.thingsboard.server.common.data.settings.UserSettingsType; -import org.thingsboard.server.dao.entity.AbstractEntityService; +import org.thingsboard.server.dao.entity.AbstractCachedEntityService; import org.thingsboard.server.dao.entity.EntityCountService; import org.thingsboard.server.dao.eventsourcing.ActionEntityEvent; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; @@ -56,6 +60,7 @@ import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.PaginatedRemover; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -71,7 +76,7 @@ import static org.thingsboard.server.dao.service.Validator.validateString; @Service("UserDaoService") @Slf4j @RequiredArgsConstructor -public class UserServiceImpl extends AbstractEntityService implements UserService { +public class UserServiceImpl extends AbstractCachedEntityService implements UserService { public static final String USER_PASSWORD_HISTORY = "userPasswordHistory"; @@ -97,6 +102,17 @@ public class UserServiceImpl extends AbstractEntityService implements UserServic private final ApplicationEventPublisher eventPublisher; private final EntityCountService countService; + @TransactionalEventListener(classes = UserCacheEvictEvent.class) + @Override + public void handleEvictEvent(UserCacheEvictEvent event) { + List keys = new ArrayList<>(2); + keys.add(new UserCacheKey(event.getTenantId(), event.getNewEmail())); + if (StringUtils.isNotEmpty(event.getOldEmail()) && !event.getOldEmail().equals(event.getNewEmail())) { + keys.add(new UserCacheKey(event.getTenantId(), event.getOldEmail())); + } + cache.evict(keys); + } + @Override public User findUserByEmail(TenantId tenantId, String email) { log.trace("Executing findUserByEmail [{}]", email); @@ -113,7 +129,8 @@ public class UserServiceImpl extends AbstractEntityService implements UserServic log.trace("Executing findUserByTenantIdAndEmail [{}][{}]", tenantId, email); validateId(tenantId, id -> INCORRECT_TENANT_ID + id); validateString(email, e -> "Incorrect email " + e); - return userDao.findByTenantIdAndEmail(tenantId, email); + return cache.getAndPutInTransaction(new UserCacheKey(tenantId, email), + () -> userDao.findByTenantIdAndEmail(tenantId, email), true); } @Override @@ -131,28 +148,38 @@ public class UserServiceImpl extends AbstractEntityService implements UserServic } @Override + @Transactional public User saveUser(TenantId tenantId, User user) { log.trace("Executing saveUser [{}]", user); User oldUser = userValidator.validate(user, User::getTenantId); if (!userLoginCaseSensitive) { user.setEmail(user.getEmail().toLowerCase()); } - User savedUser = userDao.save(user.getTenantId(), user); - if (user.getId() == null) { - countService.publishCountEntityEvictEvent(savedUser.getTenantId(), EntityType.USER); - UserCredentials userCredentials = new UserCredentials(); - userCredentials.setEnabled(false); - userCredentials.setActivateToken(generateSafeToken(DEFAULT_TOKEN_LENGTH)); - userCredentials.setUserId(new UserId(savedUser.getUuidId())); - userCredentials.setAdditionalInfo(JacksonUtil.newObjectNode()); - userCredentialsDao.save(user.getTenantId(), userCredentials); + var evictEvent = new UserCacheEvictEvent(user.getTenantId(), user.getEmail(), oldUser != null ? oldUser.getEmail() : null); + User savedUser; + try { + savedUser = userDao.saveAndFlush(user.getTenantId(), user); + publishEvictEvent(evictEvent); + if (user.getId() == null) { + countService.publishCountEntityEvictEvent(savedUser.getTenantId(), EntityType.USER); + UserCredentials userCredentials = new UserCredentials(); + userCredentials.setEnabled(false); + userCredentials.setActivateToken(generateSafeToken(DEFAULT_TOKEN_LENGTH)); + userCredentials.setUserId(new UserId(savedUser.getUuidId())); + userCredentials.setAdditionalInfo(JacksonUtil.newObjectNode()); + userCredentialsDao.save(user.getTenantId(), userCredentials); + } + eventPublisher.publishEvent(SaveEntityEvent.builder() + .tenantId(tenantId == null ? TenantId.SYS_TENANT_ID : tenantId) + .entity(savedUser) + .oldEntity(oldUser) + .entityId(savedUser.getId()) + .created(user.getId() == null).build()); + } catch (Exception t) { + handleEvictEvent(evictEvent); + checkConstraintViolation(t, "tb_user_email_key", "User with email '" + user.getEmail() + "' already present in database!"); + throw t; } - eventPublisher.publishEvent(SaveEntityEvent.builder() - .tenantId(tenantId == null ? TenantId.SYS_TENANT_ID : tenantId) - .entity(savedUser) - .oldEntity(oldUser) - .entityId(savedUser.getId()) - .created(user.getId() == null).build()); return savedUser; } @@ -263,6 +290,7 @@ public class UserServiceImpl extends AbstractEntityService implements UserServic userCredentialsDao.removeByUserId(tenantId, userId); userAuthSettingsDao.removeByUserId(userId); deleteEntityRelations(tenantId, userId); + publishEvictEvent(new UserCacheEvictEvent(user.getTenantId(), user.getEmail(), null)); userDao.removeById(tenantId, userId.getId()); eventPublisher.publishEvent(new UserCredentialsInvalidationEvent(userId)); diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index 2236c7e6f8..9a16ddf394 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -144,6 +144,8 @@ CREATE TABLE IF NOT EXISTS customer ( title varchar(255), zip varchar(255), external_id uuid, + is_public boolean, + CONSTRAINT customer_title_unq_key UNIQUE (tenant_id, title), CONSTRAINT customer_external_id_unq_key UNIQUE (tenant_id, external_id) ); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/CustomerServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/CustomerServiceTest.java index e70a25a7e6..e3e9c0f306 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/CustomerServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/CustomerServiceTest.java @@ -37,6 +37,7 @@ import org.thingsboard.server.dao.exception.DataValidationException; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; @@ -54,7 +55,7 @@ public class CustomerServiceTest extends AbstractServiceTest { @Before public void before() { executor = MoreExecutors.listeningDecorator(ThingsBoardExecutors.newWorkStealingPool(8, getClass())); - } + } @After public void after() { @@ -181,7 +182,7 @@ public class CustomerServiceTest extends AbstractServiceTest { } @Test - public void testFindCustomersByTenantIdAndTitle() throws Exception { + public void testFindCustomersByTenantIdAndTitleAsTextSearch() throws Exception { String title1 = "Customer title 1"; List> futures = new ArrayList<>(143); for (int i = 0; i < 143; i++) { @@ -261,4 +262,25 @@ public class CustomerServiceTest extends AbstractServiceTest { Assert.assertFalse(pageData.hasNext()); Assert.assertEquals(0, pageData.getData().size()); } + + @Test + public void testFindCustomerByTitle() { + Customer customer = new Customer(); + customer.setTenantId(tenantId); + customer.setTitle("My customer"); + Customer savedCustomer = customerService.saveCustomer(customer); + + Assert.assertNotNull(savedCustomer); + Assert.assertNotNull(savedCustomer.getId()); + Assert.assertTrue(savedCustomer.getCreatedTime() > 0); + Assert.assertEquals(customer.getTenantId(), savedCustomer.getTenantId()); + Assert.assertEquals(customer.getTitle(), savedCustomer.getTitle()); + + Optional foundCustomerOpt = customerService.findCustomerByTenantIdAndTitle(tenantId, savedCustomer.getTitle()); + Assert.assertTrue(foundCustomerOpt.isPresent()); + Assert.assertEquals(foundCustomerOpt.get().getTitle(), savedCustomer.getTitle()); + + customerService.deleteCustomer(tenantId, savedCustomer.getId()); + } + } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/UserServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/UserServiceTest.java index 34beec111b..22da871319 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/UserServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/UserServiceTest.java @@ -15,10 +15,10 @@ */ package org.thingsboard.server.dao.service; +import org.assertj.core.api.Assertions; import org.junit.Assert; import org.junit.Before; import org.junit.Test; -import org.junit.jupiter.api.Assertions; import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Customer; @@ -72,7 +72,7 @@ public class UserServiceTest extends AbstractServiceTest { customerUser.setEmail("customer@thingsboard.org"); customerUser = userService.saveUser(tenantId, customerUser); - userSettings = createUserSettings(customerUser.getId()); + UserSettings userSettings = createUserSettings(customerUser.getId()); } @Test @@ -90,6 +90,21 @@ public class UserServiceTest extends AbstractServiceTest { Assert.assertNull(user); } + @Test + public void testFindUserByTenantIdAndEmail() { + User user = userService.findUserByTenantIdAndEmail(SYSTEM_TENANT_ID, "sysadmin@thingsboard.org"); + Assert.assertNotNull(user); + Assert.assertEquals(Authority.SYS_ADMIN, user.getAuthority()); + user = userService.findUserByTenantIdAndEmail(tenantId, "tenant@thingsboard.org"); + Assert.assertNotNull(user); + Assert.assertEquals(Authority.TENANT_ADMIN, user.getAuthority()); + user = userService.findUserByTenantIdAndEmail(tenantId, "customer@thingsboard.org"); + Assert.assertNotNull(user); + Assert.assertEquals(Authority.CUSTOMER_USER, user.getAuthority()); + user = userService.findUserByTenantIdAndEmail(tenantId, "fake@thingsboard.org"); + Assert.assertNull(user); + } + @Test public void testFindUserById() { User user = userService.findUserByEmail(SYSTEM_TENANT_ID, "sysadmin@thingsboard.org"); @@ -142,36 +157,36 @@ public class UserServiceTest extends AbstractServiceTest { public void testSaveUserWithSameEmail() { User tenantAdminUser = userService.findUserByEmail(tenantId, "tenant@thingsboard.org"); tenantAdminUser.setEmail("sysadmin@thingsboard.org"); - Assertions.assertThrows(DataValidationException.class, () -> { - userService.saveUser(tenantId, tenantAdminUser); - }); + Assertions.assertThatThrownBy(() -> userService.saveUser(tenantId, tenantAdminUser)) + .isInstanceOf(DataValidationException.class) + .hasMessage("User with email 'sysadmin@thingsboard.org' already present in database!"); } @Test public void testSaveUserWithInvalidEmail() { User tenantAdminUser = userService.findUserByEmail(tenantId, "tenant@thingsboard.org"); tenantAdminUser.setEmail("tenant_thingsboard.org"); - Assertions.assertThrows(DataValidationException.class, () -> { - userService.saveUser(tenantId, tenantAdminUser); - }); + Assertions.assertThatThrownBy(() -> userService.saveUser(tenantId, tenantAdminUser)) + .isInstanceOf(DataValidationException.class) + .hasMessage("Invalid email address format 'tenant_thingsboard.org'!"); } @Test public void testSaveUserWithEmptyEmail() { User tenantAdminUser = userService.findUserByEmail(tenantId, "tenant@thingsboard.org"); tenantAdminUser.setEmail(null); - Assertions.assertThrows(DataValidationException.class, () -> { - userService.saveUser(tenantId, tenantAdminUser); - }); + Assertions.assertThatThrownBy(() -> userService.saveUser(tenantId, tenantAdminUser)) + .isInstanceOf(DataValidationException.class) + .hasMessage("User email should be specified!"); } @Test public void testSaveUserWithoutTenant() { User tenantAdminUser = userService.findUserByEmail(tenantId, "tenant@thingsboard.org"); tenantAdminUser.setTenantId(null); - Assertions.assertThrows(DataValidationException.class, () -> { - userService.saveUser(tenantId, tenantAdminUser); - }); + Assertions.assertThatThrownBy(() -> userService.saveUser(tenantId, tenantAdminUser)) + .isInstanceOf(DataValidationException.class) + .hasMessage("Tenant administrator should be assigned to tenant!"); } @Test diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDaoTest.java index 91ce967d45..09fc68416f 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDaoTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDaoTest.java @@ -19,12 +19,14 @@ import com.datastax.oss.driver.api.core.uuid.Uuids; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.AbstractJpaDaoTest; import org.thingsboard.server.dao.customer.CustomerDao; +import org.thingsboard.server.dao.customer.CustomerServiceImpl; import java.util.Optional; import java.util.UUID; @@ -67,11 +69,27 @@ public class JpaCustomerDaoTest extends AbstractJpaDaoTest { createCustomer(tenantId, i); } - Optional customerOpt = customerDao.findCustomersByTenantIdAndTitle(tenantId, "CUSTOMER_5"); + Optional customerOpt = customerDao.findCustomerByTenantIdAndTitle(tenantId, "CUSTOMER_5"); assertTrue(customerOpt.isPresent()); assertEquals("CUSTOMER_5", customerOpt.get().getTitle()); } + @Test + public void testFindPublicCustomerByTenantId() { + UUID tenantId = Uuids.timeBased(); + + Optional customerOpt = customerDao.findPublicCustomerByTenantId(tenantId); + assertTrue(customerOpt.isEmpty()); + + String publicCustomerTitle = StringUtils.randomAlphanumeric(10); + createPublicCustomer(tenantId, publicCustomerTitle); + customerOpt = customerDao.findPublicCustomerByTenantId(tenantId); + assertTrue(customerOpt.isPresent()); + Customer customer = customerOpt.get(); + assertTrue(customer.isPublic()); + assertEquals(publicCustomerTitle, customer.getTitle()); + } + private void createCustomer(UUID tenantId, int index) { Customer customer = new Customer(); customer.setId(new CustomerId(Uuids.timeBased())); @@ -79,4 +97,13 @@ public class JpaCustomerDaoTest extends AbstractJpaDaoTest { customer.setTitle("CUSTOMER_" + index); customerDao.save(TenantId.fromUUID(tenantId), customer); } + + private void createPublicCustomer(UUID tenantId, String publicCustomerTitle) { + Customer customer = new Customer(); + customer.setId(new CustomerId(Uuids.timeBased())); + customer.setTenantId(TenantId.fromUUID(tenantId)); + customer.setTitle(publicCustomerTitle); + customer.setAdditionalInfo(CustomerServiceImpl.PUBLIC_CUSTOMER_ADDITIONAL_INFO_JSON); + customerDao.save(TenantId.fromUUID(tenantId), customer); + } } diff --git a/dao/src/test/resources/application-test.properties b/dao/src/test/resources/application-test.properties index 73c1495af8..be8a69f689 100644 --- a/dao/src/test/resources/application-test.properties +++ b/dao/src/test/resources/application-test.properties @@ -26,6 +26,12 @@ cache.specs.sessions.maxSize=100000 cache.specs.assets.timeToLiveInMinutes=1440 cache.specs.assets.maxSize=100000 +cache.specs.customers.timeToLiveInMinutes=1440 +cache.specs.customers.maxSize=10000 + +cache.specs.users.timeToLiveInMinutes=1440 +cache.specs.users.maxSize=10000 + cache.specs.entityViews.timeToLiveInMinutes=1440 cache.specs.entityViews.maxSize=100000 diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAbstractCustomerActionNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAbstractCustomerActionNode.java index a105d01268..1caa94b593 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAbstractCustomerActionNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAbstractCustomerActionNode.java @@ -15,45 +15,52 @@ */ package org.thingsboard.rule.engine.action; -import com.google.common.cache.CacheBuilder; -import com.google.common.cache.CacheLoader; -import com.google.common.cache.LoadingCache; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import lombok.AllArgsConstructor; -import lombok.Data; +import com.google.common.util.concurrent.MoreExecutors; import lombok.extern.slf4j.Slf4j; +import org.springframework.util.ConcurrentReferenceHashMap; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.msg.TbMsg; -import org.thingsboard.server.dao.customer.CustomerService; +import java.util.EnumSet; +import java.util.NoSuchElementException; import java.util.Optional; -import java.util.concurrent.TimeUnit; +import java.util.Set; +import java.util.concurrent.ConcurrentMap; +import java.util.stream.Collectors; import static org.thingsboard.common.util.DonAsynchron.withCallback; @Slf4j public abstract class TbAbstractCustomerActionNode implements TbNode { - protected C config; + private ConcurrentMap customerCreationLocks; + + private static final Set supportedEntityTypes = EnumSet.of(EntityType.ASSET, EntityType.DEVICE, + EntityType.ENTITY_VIEW, EntityType.DASHBOARD, EntityType.EDGE); - private LoadingCache> customerIdCache; + private static final String supportedEntityTypesStr = supportedEntityTypes.stream().map(Enum::name).collect(Collectors.joining(", ")); + + protected C config; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { this.config = loadCustomerNodeActionConfig(configuration); - CacheBuilder cacheBuilder = CacheBuilder.newBuilder(); - if (this.config.getCustomerCacheExpiration() > 0) { - cacheBuilder.expireAfterWrite(this.config.getCustomerCacheExpiration(), TimeUnit.SECONDS); + if (createCustomerIfNotExists()) { + customerCreationLocks = new ConcurrentReferenceHashMap<>(); } - customerIdCache = cacheBuilder - .build(new CustomerCacheLoader(ctx, createCustomerIfNotExists())); } protected abstract boolean createCustomerIfNotExists(); @@ -62,77 +69,76 @@ public abstract class TbAbstractCustomerActionNode ctx.tellSuccess(msg), - t -> ctx.tellFailure(msg, t), ctx.getDbCallbackExecutor()); + t -> ctx.tellFailure(msg, t), MoreExecutors.directExecutor()); } - private ListenableFuture processCustomerAction(TbContext ctx, TbMsg msg) { - ListenableFuture customerIdFeature = getCustomer(ctx, msg); - return Futures.transform(customerIdFeature, customerId -> { - doProcessCustomerAction(ctx, msg, customerId); - return null; - }, ctx.getDbCallbackExecutor() - ); - } - - protected abstract void doProcessCustomerAction(TbContext ctx, TbMsg msg, CustomerId customerId); - - protected ListenableFuture getCustomer(TbContext ctx, TbMsg msg) { - String customerTitle = TbNodeUtils.processPattern(this.config.getCustomerNamePattern(), msg); - CustomerKey key = new CustomerKey(customerTitle); - return ctx.getDbCallbackExecutor().executeAsync(() -> { - Optional customerId = customerIdCache.get(key); - if (!customerId.isPresent()) { - throw new RuntimeException("No customer found with name '" + key.getCustomerTitle() + "'."); + protected abstract ListenableFuture processCustomerAction(TbContext ctx, TbMsg msg); + + protected ListenableFuture getCustomerIdFuture(TbContext ctx, TbMsg msg) { + var tenantId = ctx.getTenantId(); + var customerTitle = TbNodeUtils.processPattern(this.config.getCustomerNamePattern(), msg); + ListenableFuture> customerByTitleFuture = findCustomerByTitleAsync(ctx, customerTitle); + if (createCustomerIfNotExists()) { + return Futures.transform(customerByTitleFuture, customerOpt -> { + if (customerOpt.isPresent()) { + return customerOpt.get().getId(); + } + var customerCreationLockKey = new CustomerCreationLockKey(tenantId, customerTitle); + synchronized (customerCreationLocks.computeIfAbsent(customerCreationLockKey, k -> new Object())) { + customerOpt = ctx.getCustomerService().findCustomerByTenantIdAndTitle(tenantId, customerTitle); + if (customerOpt.isPresent()) { + return customerOpt.get().getId(); + } + var newCustomer = new Customer(); + newCustomer.setTitle(customerTitle); + newCustomer.setTenantId(tenantId); + var savedCustomer = ctx.getCustomerService().saveCustomer(newCustomer); + ctx.enqueue(ctx.customerCreatedMsg(savedCustomer, ctx.getSelfId()), + () -> log.trace("Pushed Customer Created message: {}", savedCustomer), + throwable -> log.warn("Failed to push Customer Created message: {}", savedCustomer, throwable)); + return savedCustomer.getId(); + } + }, MoreExecutors.directExecutor()); + } + return Futures.transform(customerByTitleFuture, customerOpt -> { + if (customerOpt.isEmpty()) { + throw new NoSuchElementException("Customer with title '" + customerTitle + "' doesn't exist!"); } - return customerId.get(); - }); + return customerOpt.get().getId(); + }, MoreExecutors.directExecutor()); } - @Override - public void destroy() { - if (customerIdCache != null) { - customerIdCache.invalidateAll(); - } + ListenableFuture> findCustomerByTitleAsync(TbContext ctx, String customerTitle) { + return ctx.getDbCallbackExecutor().executeAsync(() -> + ctx.getCustomerService().findCustomerByTenantIdAndTitle(ctx.getTenantId(), customerTitle)); } - @Data - @AllArgsConstructor - private static class CustomerKey { - private String customerTitle; + private static String unsupportedOriginatorTypeErrorMessage(EntityType originatorType) { + return "Unsupported originator type '" + originatorType + + "'! Only " + supportedEntityTypesStr + " types are allowed."; } - private static class CustomerCacheLoader extends CacheLoader> { - - private final TbContext ctx; - private final boolean createIfNotExists; - - private CustomerCacheLoader(TbContext ctx, boolean createIfNotExists) { - this.ctx = ctx; - this.createIfNotExists = createIfNotExists; - } - - @Override - public Optional load(CustomerKey key) { - CustomerService service = ctx.getCustomerService(); - Optional customerOptional = - service.findCustomerByTenantIdAndTitle(ctx.getTenantId(), key.getCustomerTitle()); - if (customerOptional.isPresent()) { - return Optional.of(customerOptional.get().getId()); - } else if (createIfNotExists) { - Customer newCustomer = new Customer(); - newCustomer.setTitle(key.getCustomerTitle()); - newCustomer.setTenantId(ctx.getTenantId()); - Customer savedCustomer = service.saveCustomer(newCustomer); - ctx.enqueue(ctx.customerCreatedMsg(savedCustomer, ctx.getSelfId()), - () -> log.trace("Pushed Customer Created message: {}", savedCustomer), - throwable -> log.warn("Failed to push Customer Created message: {}", savedCustomer, throwable)); - return Optional.of(savedCustomer.getId()); + @Override + public TbPair upgrade(int fromVersion, JsonNode oldConfiguration) { + boolean hasChanges = false; + switch (fromVersion) { + case 0 -> { + if (oldConfiguration.has("customerCacheExpiration")) { + ((ObjectNode) oldConfiguration).remove("customerCacheExpiration"); + hasChanges = true; + } } - return Optional.empty(); } + return new TbPair<>(hasChanges, oldConfiguration); + } + private record CustomerCreationLockKey(TenantId tenantId, String customerTitle) { } } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAbstractCustomerActionNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAbstractCustomerActionNodeConfiguration.java index 289a1d5eb0..ed4eda16df 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAbstractCustomerActionNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAbstractCustomerActionNodeConfiguration.java @@ -21,6 +21,5 @@ import lombok.Data; public abstract class TbAbstractCustomerActionNodeConfiguration { private String customerNamePattern; - private long customerCacheExpiration; } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAbstractRelationActionNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAbstractRelationActionNode.java index 3bef21d779..bdd5d91320 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAbstractRelationActionNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAbstractRelationActionNode.java @@ -15,269 +15,299 @@ */ package org.thingsboard.rule.engine.action; -import com.google.common.cache.CacheBuilder; -import com.google.common.cache.CacheLoader; -import com.google.common.cache.LoadingCache; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; +import com.google.common.util.concurrent.MoreExecutors; import lombok.extern.slf4j.Slf4j; +import org.springframework.util.ConcurrentReferenceHashMap; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.rule.engine.api.util.TbNodeUtils; -import org.thingsboard.rule.engine.util.EntityContainer; import org.thingsboard.server.common.data.Customer; -import org.thingsboard.server.common.data.DashboardInfo; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntityType; -import org.thingsboard.server.common.data.EntityView; -import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.asset.Asset; -import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.id.EntityId; -import org.thingsboard.server.common.data.id.EntityIdFactory; -import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.RelationTypeGroup; +import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.msg.TbMsg; -import org.thingsboard.server.dao.asset.AssetService; -import org.thingsboard.server.dao.customer.CustomerService; -import org.thingsboard.server.dao.dashboard.DashboardService; -import org.thingsboard.server.dao.device.DeviceService; -import org.thingsboard.server.dao.edge.EdgeService; -import org.thingsboard.server.dao.entityview.EntityViewService; -import org.thingsboard.server.dao.user.UserService; -import java.util.List; +import java.util.EnumSet; +import java.util.NoSuchElementException; import java.util.Optional; -import java.util.concurrent.TimeUnit; - -import static org.thingsboard.common.util.DonAsynchron.withCallback; -import static org.thingsboard.server.common.data.msg.TbNodeConnectionType.FAILURE; -import static org.thingsboard.server.common.data.msg.TbNodeConnectionType.SUCCESS; +import java.util.Set; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.Executor; +import java.util.stream.Collectors; @Slf4j public abstract class TbAbstractRelationActionNode implements TbNode { - protected C config; + private ConcurrentMap entitiesCreationLocks; - private LoadingCache entityIdCache; + private static final Set supportedEntityTypes = EnumSet.of(EntityType.TENANT, EntityType.DEVICE, + EntityType.ASSET, EntityType.CUSTOMER, EntityType.ENTITY_VIEW, EntityType.DASHBOARD, EntityType.EDGE, EntityType.USER); - @Override - public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = loadEntityNodeActionConfig(configuration); - CacheBuilder cacheBuilder = CacheBuilder.newBuilder(); - if (this.config.getEntityCacheExpiration() > 0) { - cacheBuilder.expireAfterWrite(this.config.getEntityCacheExpiration(), TimeUnit.SECONDS); - } - entityIdCache = cacheBuilder.build(new EntityCacheLoader(ctx, createEntityIfNotExists())); - } + private static final String supportedEntityTypesStr = supportedEntityTypes.stream().map(Enum::name).collect(Collectors.joining(" ,")); - @Override - public void onMsg(TbContext ctx, TbMsg msg) { - String relationType = processPattern(msg, config.getRelationType()); - withCallback(processEntityRelationAction(ctx, msg, relationType), - filterResult -> ctx.tellNext(filterResult.getMsg(), filterResult.isResult() ? SUCCESS : FAILURE), t -> ctx.tellFailure(msg, t), ctx.getDbCallbackExecutor()); - } + protected C config; @Override - public void destroy() { - if (entityIdCache != null) { - entityIdCache.invalidateAll(); + public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { + this.config = loadEntityNodeActionConfig(configuration); + if (createEntityIfNotExists()) { + entitiesCreationLocks = new ConcurrentReferenceHashMap<>(); } } - protected ListenableFuture processEntityRelationAction(TbContext ctx, TbMsg msg, String relationType) { - return Futures.transformAsync(getEntity(ctx, msg), entityContainer -> doProcessEntityRelationAction(ctx, msg, entityContainer, relationType), ctx.getDbCallbackExecutor()); - } - protected abstract boolean createEntityIfNotExists(); - protected abstract ListenableFuture doProcessEntityRelationAction(TbContext ctx, TbMsg msg, EntityContainer entityContainer, String relationType); - protected abstract C loadEntityNodeActionConfig(TbNodeConfiguration configuration) throws TbNodeException; - protected ListenableFuture getEntity(TbContext ctx, TbMsg msg) { - String entityName = processPattern(msg, this.config.getEntityNamePattern()); - String type; - if (this.config.getEntityTypePattern() != null) { - type = processPattern(msg, this.config.getEntityTypePattern()); - } else { - type = null; - } - EntityType entityType = EntityType.valueOf(this.config.getEntityType()); - EntityKey key = new EntityKey(entityName, type, entityType); - return ctx.getDbCallbackExecutor().executeAsync(() -> { - EntityContainer entityContainer = entityIdCache.get(key); - if (entityContainer.getEntityId() == null) { - throw new RuntimeException("No entity found with type '" + key.getEntityType() + "' and name '" + key.getEntityName() + "'."); - } - return entityContainer; - }); + protected String processPattern(TbMsg msg, String pattern) { + return TbNodeUtils.processPattern(pattern, msg); } - protected SearchDirectionIds processSingleSearchDirection(TbMsg msg, EntityContainer entityContainer) { - SearchDirectionIds searchDirectionIds = new SearchDirectionIds(); - if (EntitySearchDirection.FROM.name().equals(this.config.getDirection())) { - searchDirectionIds.setFromId(EntityIdFactory.getByTypeAndId(entityContainer.getEntityType().name(), entityContainer.getEntityId().toString())); - searchDirectionIds.setToId(msg.getOriginator()); - searchDirectionIds.setOriginatorDirectionFrom(false); - } else { - searchDirectionIds.setToId(EntityIdFactory.getByTypeAndId(entityContainer.getEntityType().name(), entityContainer.getEntityId().toString())); - searchDirectionIds.setFromId(msg.getOriginator()); - searchDirectionIds.setOriginatorDirectionFrom(true); + protected ListenableFuture getTargetEntityId(TbContext ctx, TbMsg msg) { + var entityType = config.getEntityType(); + var tenantId = ctx.getTenantId(); + if (EntityType.TENANT.equals(entityType)) { + return ctx.getDbCallbackExecutor().executeAsync(() -> tenantId); } - return searchDirectionIds; - } - - protected ListenableFuture> processListSearchDirection(TbContext ctx, TbMsg msg) { - if (EntitySearchDirection.FROM.name().equals(this.config.getDirection())) { - return ctx.getRelationService().findByToAndTypeAsync(ctx.getTenantId(), msg.getOriginator(), processPattern(msg, this.config.getRelationType()), RelationTypeGroup.COMMON); - } else { - return ctx.getRelationService().findByFromAndTypeAsync(ctx.getTenantId(), msg.getOriginator(), processPattern(msg, this.config.getRelationType()), RelationTypeGroup.COMMON); + var targetEntityName = processPattern(msg, config.getEntityNamePattern()); + boolean createEntityIfNotExists = createEntityIfNotExists(); + switch (entityType) { + case DEVICE -> { + ListenableFuture deviceByNameFuture = findDeviceByNameAsync(ctx, targetEntityName); + if (createEntityIfNotExists) { + return Futures.transform(deviceByNameFuture, device -> { + if (device != null) { + return device.getId(); + } + var entityCreationLockKey = new EntityCreationLockKey(tenantId, entityType, targetEntityName); + synchronized (entitiesCreationLocks.computeIfAbsent(entityCreationLockKey, k -> new Object())) { + device = ctx.getDeviceService().findDeviceByTenantIdAndName(tenantId, targetEntityName); + if (device != null) { + return device.getId(); + } + var deviceProfileName = processPattern(msg, config.getEntityTypePattern()); + var newDevice = new Device(); + newDevice.setName(targetEntityName); + newDevice.setType(deviceProfileName); + newDevice.setTenantId(tenantId); + var savedDevice = ctx.getDeviceService().saveDevice(newDevice); + ctx.getClusterService().onDeviceUpdated(savedDevice, null); + ctx.enqueue(ctx.deviceCreatedMsg(savedDevice, ctx.getSelfId()), + () -> log.trace("Pushed Device Created message: {}", savedDevice), + throwable -> log.warn("Failed to push Device Created message: {}", savedDevice, throwable)); + return savedDevice.getId(); + } + }, MoreExecutors.directExecutor()); + } + return Futures.transform(deviceByNameFuture, device -> { + if (device == null) { + throw new NoSuchElementException("Device with name '" + targetEntityName + "' doesn't exist!"); + } + return device.getId(); + }, MoreExecutors.directExecutor()); + } + case ASSET -> { + ListenableFuture assetByNameFuture = findAssetByNameAsync(ctx, targetEntityName); + if (createEntityIfNotExists) { + return Futures.transform(assetByNameFuture, asset -> { + if (asset != null) { + return asset.getId(); + } + var entityCreationLockKey = new EntityCreationLockKey(tenantId, entityType, targetEntityName); + synchronized (entitiesCreationLocks.computeIfAbsent(entityCreationLockKey, k -> new Object())) { + asset = ctx.getAssetService().findAssetByTenantIdAndName(tenantId, targetEntityName); + if (asset != null) { + return asset.getId(); + } + var assetProfileName = processPattern(msg, config.getEntityTypePattern()); + var newAsset = new Asset(); + newAsset.setName(targetEntityName); + newAsset.setType(assetProfileName); + newAsset.setTenantId(tenantId); + var savedAsset = ctx.getAssetService().saveAsset(newAsset); + ctx.enqueue(ctx.assetCreatedMsg(savedAsset, ctx.getSelfId()), + () -> log.trace("Pushed Asset Created message: {}", savedAsset), + throwable -> log.warn("Failed to push Asset Created message: {}", savedAsset, throwable)); + return savedAsset.getId(); + } + }, MoreExecutors.directExecutor()); + } + return Futures.transform(assetByNameFuture, asset -> { + if (asset == null) { + throw new NoSuchElementException("Asset with name '" + targetEntityName + "' doesn't exist!"); + } + return asset.getId(); + }, MoreExecutors.directExecutor()); + } + case CUSTOMER -> { + ListenableFuture> customerByTitleFuture = findCustomerByTitleAsync(ctx, targetEntityName); + if (createEntityIfNotExists) { + return Futures.transform(customerByTitleFuture, customerOpt -> { + if (customerOpt.isPresent()) { + return customerOpt.get().getId(); + } + var entityCreationLockKey = new EntityCreationLockKey(tenantId, entityType, targetEntityName); + synchronized (entitiesCreationLocks.computeIfAbsent(entityCreationLockKey, k -> new Object())) { + customerOpt = ctx.getCustomerService().findCustomerByTenantIdAndTitle(tenantId, targetEntityName); + if (customerOpt.isPresent()) { + return customerOpt.get().getId(); + } + var newCustomer = new Customer(); + newCustomer.setTitle(targetEntityName); + newCustomer.setTenantId(tenantId); + var savedCustomer = ctx.getCustomerService().saveCustomer(newCustomer); + ctx.enqueue(ctx.customerCreatedMsg(savedCustomer, ctx.getSelfId()), + () -> log.trace("Pushed Customer Created message: {}", savedCustomer), + throwable -> log.warn("Failed to push Customer Created message: {}", savedCustomer, throwable)); + return savedCustomer.getId(); + } + }, MoreExecutors.directExecutor()); + } + return Futures.transform(customerByTitleFuture, customerOpt -> { + if (customerOpt.isEmpty()) { + throw new NoSuchElementException("Customer with title '" + targetEntityName + "' doesn't exist!"); + } + return customerOpt.get().getId(); + }, MoreExecutors.directExecutor()); + } + case ENTITY_VIEW -> { + return ctx.getDbCallbackExecutor().executeAsync(() -> { + var entityViewService = ctx.getEntityViewService(); + var entityView = entityViewService.findEntityViewByTenantIdAndName(tenantId, targetEntityName); + if (entityView != null) { + return entityView.getId(); + } + throw new NoSuchElementException("EntityView with name '" + targetEntityName + "' doesn't exist!"); + }); + } + case EDGE -> { + return ctx.getDbCallbackExecutor().executeAsync(() -> { + var edgeService = ctx.getEdgeService(); + var edge = edgeService.findEdgeByTenantIdAndName(tenantId, targetEntityName); + if (edge != null) { + return edge.getId(); + } + throw new NoSuchElementException("Edge with name '" + targetEntityName + "' doesn't exist!"); + }); + } + case DASHBOARD -> { + return ctx.getDbCallbackExecutor().executeAsync(() -> { + var dashboardService = ctx.getDashboardService(); + var dashboardInfo = dashboardService.findFirstDashboardInfoByTenantIdAndName(tenantId, targetEntityName); + if (dashboardInfo != null) { + return dashboardInfo.getId(); + } + throw new NoSuchElementException("Dashboard with title '" + targetEntityName + "' doesn't exist!"); + }); + } + case USER -> { + return ctx.getDbCallbackExecutor().executeAsync(() -> { + var userService = ctx.getUserService(); + var user = userService.findUserByTenantIdAndEmail(tenantId, targetEntityName); + if (user != null) { + return user.getId(); + } + throw new NoSuchElementException("User with email '" + targetEntityName + "' doesn't exist!"); + }); + } + default -> throw new IllegalArgumentException(unsupportedEntityTypeErrorMessage(entityType)); } } - protected String processPattern(TbMsg msg, String pattern) { - return TbNodeUtils.processPattern(pattern, msg); + ListenableFuture findDeviceByNameAsync(TbContext ctx, String deviceName) { + return ctx.getDbCallbackExecutor().executeAsync(() -> + ctx.getDeviceService().findDeviceByTenantIdAndName(ctx.getTenantId(), deviceName)); } - @Data - @AllArgsConstructor - private static class EntityKey { - private String entityName; - private String type; - private EntityType entityType; + ListenableFuture findAssetByNameAsync(TbContext ctx, String assetName) { + return ctx.getDbCallbackExecutor().executeAsync(() -> + ctx.getAssetService().findAssetByTenantIdAndName(ctx.getTenantId(), assetName)); } - @Data - protected static class SearchDirectionIds { - private EntityId fromId; - private EntityId toId; - private boolean originatorDirectionFrom; + ListenableFuture> findCustomerByTitleAsync(TbContext ctx, String customerTitle) { + return ctx.getDbCallbackExecutor().executeAsync(() -> + ctx.getCustomerService().findCustomerByTenantIdAndTitle(ctx.getTenantId(), customerTitle)); } - private static class EntityCacheLoader extends CacheLoader { + protected ListenableFuture deleteRelationsByTypeAndDirection(TbContext ctx, TbMsg msg, Executor executor) { + var relationType = processPattern(msg, config.getRelationType()); + return deleteRelationsByTypeAndDirection(ctx, msg, relationType, executor); + } - private final TbContext ctx; - private final boolean createIfNotExists; + protected ListenableFuture deleteRelationsByTypeAndDirection(TbContext ctx, TbMsg msg, String relationType, Executor executor) { + var tenantId = ctx.getTenantId(); + var originator = msg.getOriginator(); + var relationService = ctx.getRelationService(); + var originatorRelationsFuture = EntitySearchDirection.FROM.equals(config.getDirection()) ? + relationService.findByFromAndTypeAsync(tenantId, originator, relationType, RelationTypeGroup.COMMON) : + relationService.findByToAndTypeAsync(tenantId, originator, relationType, RelationTypeGroup.COMMON); + return Futures.transformAsync(originatorRelationsFuture, originatorRelations -> { + if (originatorRelations.isEmpty()) { + return Futures.immediateFuture(true); + } + var deleteRelationFutures = originatorRelations.stream() + .map(entityRelation -> relationService.deleteRelationAsync(tenantId, entityRelation)) + .collect(Collectors.toList()); + return Futures.transform(Futures.allAsList(deleteRelationFutures), deleteResults -> + deleteResults.stream().allMatch(Boolean::booleanValue), executor); + }, executor); + } - private EntityCacheLoader(TbContext ctx, boolean createIfNotExists) { - this.ctx = ctx; - this.createIfNotExists = createIfNotExists; + protected void checkIfConfigEntityTypeIsSupported(EntityType entityType) throws TbNodeException { + if (!supportedEntityTypes.contains(entityType)) { + throw new TbNodeException(unsupportedEntityTypeErrorMessage(entityType), true); } + } - @Override - public EntityContainer load(EntityKey key) { - return loadEntity(key); - } + private static String unsupportedEntityTypeErrorMessage(EntityType entityType) { + return "Unsupported entity type '" + entityType + + "'! Only " + supportedEntityTypesStr + " types are allowed."; + } - private EntityContainer loadEntity(EntityKey entitykey) { - EntityType type = entitykey.getEntityType(); - EntityContainer targetEntity = new EntityContainer(); - targetEntity.setEntityType(type); - switch (type) { - case DEVICE: - DeviceService deviceService = ctx.getDeviceService(); - Device device = deviceService.findDeviceByTenantIdAndName(ctx.getTenantId(), entitykey.getEntityName()); - if (device != null) { - targetEntity.setEntityId(device.getId()); - } else if (createIfNotExists) { - Device newDevice = new Device(); - newDevice.setName(entitykey.getEntityName()); - newDevice.setType(entitykey.getType()); - newDevice.setTenantId(ctx.getTenantId()); - Device savedDevice = deviceService.saveDevice(newDevice); - ctx.enqueue(ctx.deviceCreatedMsg(savedDevice, ctx.getSelfId()), - () -> log.trace("Pushed Device Created message: {}", savedDevice), - throwable -> log.warn("Failed to push Device Created message: {}", savedDevice, throwable)); - targetEntity.setEntityId(savedDevice.getId()); - } - break; - case ASSET: - AssetService assetService = ctx.getAssetService(); - Asset asset = assetService.findAssetByTenantIdAndName(ctx.getTenantId(), entitykey.getEntityName()); - if (asset != null) { - targetEntity.setEntityId(asset.getId()); - } else if (createIfNotExists) { - Asset newAsset = new Asset(); - newAsset.setName(entitykey.getEntityName()); - newAsset.setType(entitykey.getType()); - newAsset.setTenantId(ctx.getTenantId()); - Asset savedAsset = assetService.saveAsset(newAsset); - ctx.enqueue(ctx.assetCreatedMsg(savedAsset, ctx.getSelfId()), - () -> log.trace("Pushed Asset Created message: {}", savedAsset), - throwable -> log.warn("Failed to push Asset Created message: {}", savedAsset, throwable)); - targetEntity.setEntityId(savedAsset.getId()); - } - break; - case CUSTOMER: - CustomerService customerService = ctx.getCustomerService(); - Optional customerOptional = customerService.findCustomerByTenantIdAndTitle(ctx.getTenantId(), entitykey.getEntityName()); - if (customerOptional.isPresent()) { - targetEntity.setEntityId(customerOptional.get().getId()); - } else if (createIfNotExists) { - Customer newCustomer = new Customer(); - newCustomer.setTitle(entitykey.getEntityName()); - newCustomer.setTenantId(ctx.getTenantId()); - Customer savedCustomer = customerService.saveCustomer(newCustomer); - ctx.enqueue(ctx.customerCreatedMsg(savedCustomer, ctx.getSelfId()), - () -> log.trace("Pushed Customer Created message: {}", savedCustomer), - throwable -> log.warn("Failed to push Customer Created message: {}", savedCustomer, throwable)); - targetEntity.setEntityId(savedCustomer.getId()); - } - break; - case TENANT: - targetEntity.setEntityId(ctx.getTenantId()); - break; - case ENTITY_VIEW: - EntityViewService entityViewService = ctx.getEntityViewService(); - EntityView entityView = entityViewService.findEntityViewByTenantIdAndName(ctx.getTenantId(), entitykey.getEntityName()); - if (entityView != null) { - targetEntity.setEntityId(entityView.getId()); - } - break; - case EDGE: - EdgeService edgeService = ctx.getEdgeService(); - Edge edge = edgeService.findEdgeByTenantIdAndName(ctx.getTenantId(), entitykey.getEntityName()); - if (edge != null) { - targetEntity.setEntityId(edge.getId()); - } + @Override + public TbPair upgrade(int fromVersion, JsonNode oldConfiguration) throws TbNodeException { + boolean hasChanges = false; + var config = (ObjectNode) oldConfiguration; + switch (fromVersion) { + case 0 -> { + if (!config.has("entityCacheExpiration")) { break; - case DASHBOARD: - DashboardService dashboardService = ctx.getDashboardService(); - DashboardInfo dashboardInfo = dashboardService.findFirstDashboardInfoByTenantIdAndName(ctx.getTenantId(), entitykey.getEntityName()); - if (dashboardInfo != null) { - targetEntity.setEntityId(dashboardInfo.getId()); - } + } + config.remove("entityCacheExpiration"); + + var directionPropertyName = "direction"; + if (!config.has(directionPropertyName)) { + throw new TbNodeException("property to update: '" + directionPropertyName + "' doesn't exists in configuration!"); + } + String direction = config.get(directionPropertyName).asText(); + if (EntitySearchDirection.TO.name().equals(direction)) { + config.put(directionPropertyName, EntitySearchDirection.FROM.name()); + hasChanges = true; break; - case USER: - UserService userService = ctx.getUserService(); - User user = userService.findUserByTenantIdAndEmail(ctx.getTenantId(), entitykey.getEntityName()); - if (user != null) { - targetEntity.setEntityId(user.getId()); - } + } + if (EntitySearchDirection.FROM.name().equals(direction)) { + config.put(directionPropertyName, EntitySearchDirection.TO.name()); + hasChanges = true; break; - default: - return targetEntity; + } + throw new TbNodeException("property to update: '" + directionPropertyName + "' has invalid value!"); } - return targetEntity; } + return new TbPair<>(hasChanges, config); } - @Data - @NoArgsConstructor - @AllArgsConstructor - protected static class RelationContainer { - - private TbMsg msg; - private boolean result; - + private record EntityCreationLockKey(TenantId tenantId, EntityType entityType, String entityName) { } - } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAbstractRelationActionNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAbstractRelationActionNodeConfiguration.java index 94341aecda..8f095ed14e 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAbstractRelationActionNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAbstractRelationActionNodeConfiguration.java @@ -16,17 +16,17 @@ package org.thingsboard.rule.engine.action; import lombok.Data; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; @Data public abstract class TbAbstractRelationActionNodeConfiguration { - private String direction; + private EntitySearchDirection direction; private String relationType; - private String entityType; + private EntityType entityType; private String entityNamePattern; private String entityTypePattern; - private long entityCacheExpiration; - } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAssignToCustomerNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAssignToCustomerNode.java index 17bd3b3b84..0d23d0f17e 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAssignToCustomerNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAssignToCustomerNode.java @@ -15,15 +15,16 @@ */ package org.thingsboard.rule.engine.action; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.rule.engine.api.util.TbNodeUtils; -import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.AssetId; -import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EdgeId; @@ -36,12 +37,13 @@ import org.thingsboard.server.common.msg.TbMsg; type = ComponentType.ACTION, name = "assign to customer", configClazz = TbAssignToCustomerNodeConfiguration.class, - nodeDescription = "Assign Message Originator Entity to Customer", - nodeDetails = "Finds target Customer by customer name pattern and then assign Originator Entity to this customer. " + - "Will create new Customer if it doesn't exists and 'Create new Customer if not exists' is set to true.", + nodeDescription = "Assign message originator entity to customer", + nodeDetails = "Finds target customer by title and assign message originator entity to this customer. " + + "Rule node will create a new customer if it doesn't exist, and 'Create new customer if it doesn't exist' enabled.", uiResources = {"static/rulenode/rulenode-core-config.js"}, configDirective = "tbActionNodeAssignToCustomerConfig", - icon = "add_circle" + icon = "add_circle", + version = 1 ) public class TbAssignToCustomerNode extends TbAbstractCustomerActionNode { @@ -56,53 +58,25 @@ public class TbAssignToCustomerNode extends TbAbstractCustomerActionNode processCustomerAction(TbContext ctx, TbMsg msg) { + var customerIdFuture = getCustomerIdFuture(ctx, msg); + return Futures.transformAsync(customerIdFuture, customerId -> + ctx.getDbCallbackExecutor().submit(() -> { + var originator = msg.getOriginator(); + switch (originator.getEntityType()) { + case ASSET -> + ctx.getAssetService().assignAssetToCustomer(ctx.getTenantId(), new AssetId(originator.getId()), customerId); + case DEVICE -> + ctx.getDeviceService().assignDeviceToCustomer(ctx.getTenantId(), new DeviceId(originator.getId()), customerId); + case ENTITY_VIEW -> + ctx.getEntityViewService().assignEntityViewToCustomer(ctx.getTenantId(), new EntityViewId(originator.getId()), customerId); + case EDGE -> + ctx.getEdgeService().assignEdgeToCustomer(ctx.getTenantId(), new EdgeId(originator.getId()), customerId); + case DASHBOARD -> + ctx.getDashboardService().assignDashboardToCustomer(ctx.getTenantId(), new DashboardId(originator.getId()), customerId); + } + return null; + }), MoreExecutors.directExecutor()); } } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAssignToCustomerNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAssignToCustomerNodeConfiguration.java index 9502a05930..626f5e07ac 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAssignToCustomerNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAssignToCustomerNodeConfiguration.java @@ -16,19 +16,20 @@ package org.thingsboard.rule.engine.action; import lombok.Data; +import lombok.EqualsAndHashCode; import org.thingsboard.rule.engine.api.NodeConfiguration; @Data +@EqualsAndHashCode(callSuper = true) public class TbAssignToCustomerNodeConfiguration extends TbAbstractCustomerActionNodeConfiguration implements NodeConfiguration { private boolean createCustomerIfNotExists; @Override public TbAssignToCustomerNodeConfiguration defaultConfiguration() { - TbAssignToCustomerNodeConfiguration configuration = new TbAssignToCustomerNodeConfiguration(); + var configuration = new TbAssignToCustomerNodeConfiguration(); configuration.setCustomerNamePattern(""); configuration.setCreateCustomerIfNotExists(false); - configuration.setCustomerCacheExpiration(300); return configuration; } } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateRelationNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateRelationNode.java index 85fcb84e2a..7571695b49 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateRelationNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateRelationNode.java @@ -17,52 +17,62 @@ package org.thingsboard.rule.engine.action; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; import lombok.extern.slf4j.Slf4j; -import org.springframework.util.CollectionUtils; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.rule.engine.api.util.TbNodeUtils; -import org.thingsboard.rule.engine.util.EntityContainer; -import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.data.id.AssetId; -import org.thingsboard.server.common.data.id.CustomerId; -import org.thingsboard.server.common.data.id.DashboardId; -import org.thingsboard.server.common.data.id.DeviceId; -import org.thingsboard.server.common.data.id.EdgeId; -import org.thingsboard.server.common.data.id.EntityViewId; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.msg.TbMsg; -import java.util.ArrayList; -import java.util.List; +import static org.thingsboard.common.util.DonAsynchron.withCallback; @Slf4j @RuleNode( type = ComponentType.ACTION, name = "create relation", configClazz = TbCreateRelationNodeConfiguration.class, - nodeDescription = "Finds target Entity by entity name pattern and (entity type pattern for Asset, Device) and then create a relation to Originator Entity by type and direction." + - " If Selected entity type: Asset, Device or Customer will create new Entity if it doesn't exist and selected checkbox 'Create new entity if not exists'.
" + - " In case that relation from the message originator to the selected entity not exist and If selected checkbox 'Remove current relations'," + - " before creating the new relation all existed relations to message originator by type and direction will be removed.
" + - " If relation from the message originator to the selected entity created and If selected checkbox 'Change originator to related entity'," + - " outbound message will be processed as a message from this entity.", - nodeDetails = "If the relation already exists or successfully created - Message send via Success chain, otherwise Failure chain will be used.", + nodeDescription = "Finds target entity specified in the configuration and creates a relation with the " + + "incoming message originator based on the configured direction and type.", + nodeDetails = "Useful when you need to create relations between entities dynamically depending on " + + "incoming message payload, message originator type, name, etc.

" + + "Target entity configuration: " + + "
  • Device - use a device with the specified name as the target entity to create a relation with. " + + "When selected, rule node allows us to use advanced mode to enable device creation if it doesn't exist. " + + "In advanced mode, device profile name should be specified.
  • " + + "
  • Asset - use an asset with the specified name as the target entity to create a relation with. " + + "When selected, rule node allows us to use advanced mode to enable device creation if it doesn't exist. " + + "In advanced mode, asset profile name should be specified.
  • " + + "
  • Entity View - use entity view with the specified name as the target entity to create a relation with.
  • " + + "
  • Tenant - use current tenant as target entity to create a relation with.
  • " + + "
  • Customer - use customer with the specified title as the target entity to create a relation with. " + + "When selected, rule node allows us to use advanced mode to enable customer creation if it doesn't exist.
  • " + + "
  • Dashboard - use a dashboard with the specified title as the target entity to create a relation with.
  • " + + "
  • User - use a user with the specified email as the target entity to create a relation with.
  • " + + "
  • Edge - use an edge with the specified name as the target entity to create a relation with.
" + + "Advanced settings: " + + "
  • Remove current relations - removes current relations with originator of the incoming message based on direction and type. " + + "Useful in GPS tracking use cases where relation acts as a temporary indicator of a tracker presence in specific geofence.
  • " + + "
  • Change originator to target entity - useful when you need to process submitted message as a message from target entity.
" + + "Output connections: Success - if the relation already exists or successfully created, otherwise Failure.", uiResources = {"static/rulenode/rulenode-core-config.js"}, configDirective = "tbActionNodeCreateRelationConfig", - icon = "add_circle" + icon = "add_circle", + version = 1 ) public class TbCreateRelationNode extends TbAbstractRelationActionNode { @Override protected TbCreateRelationNodeConfiguration loadEntityNodeActionConfig(TbNodeConfiguration configuration) throws TbNodeException { - return TbNodeUtils.convert(configuration, TbCreateRelationNodeConfiguration.class); + var createRelationNodeConfiguration = TbNodeUtils.convert(configuration, TbCreateRelationNodeConfiguration.class); + checkIfConfigEntityTypeIsSupported(createRelationNodeConfiguration.getEntityType()); + return createRelationNodeConfiguration; } @Override @@ -71,167 +81,59 @@ public class TbCreateRelationNode extends TbAbstractRelationActionNode doProcessEntityRelationAction(TbContext ctx, TbMsg msg, EntityContainer entity, String relationType) { - ListenableFuture future = createRelationIfAbsent(ctx, msg, entity, relationType); - return Futures.transform(future, result -> { - if (result && config.isChangeOriginatorToRelatedEntity()) { - TbMsg tbMsg = ctx.transformMsgOriginator(msg, entity.getEntityId()); - return new RelationContainer(tbMsg, result); + public void onMsg(TbContext ctx, TbMsg msg) { + var targetEntityIdFuture = getTargetEntityId(ctx, msg); + var createRelationResultFuture = Futures.transformAsync(targetEntityIdFuture, targetEntityId -> { + var originator = msg.getOriginator(); + var relationType = processPattern(msg, config.getRelationType()); + if (config.isRemoveCurrentRelations()) { + var removalOfCurrentRelationsFuture = deleteRelationsByTypeAndDirection(ctx, msg, relationType, MoreExecutors.directExecutor()); + return Futures.transformAsync(removalOfCurrentRelationsFuture, __ -> + checkRelationAndCreateIfAbsent(ctx, originator, targetEntityId, relationType), MoreExecutors.directExecutor()); } - return new RelationContainer(msg, result); - }, ctx.getDbCallbackExecutor()); - } - - private ListenableFuture createRelationIfAbsent(TbContext ctx, TbMsg msg, EntityContainer entityContainer, String relationType) { - SearchDirectionIds sdId = processSingleSearchDirection(msg, entityContainer); - return Futures.transformAsync(deleteCurrentRelationsIfNeeded(ctx, msg, sdId, relationType), v -> - checkRelationAndCreateIfAbsent(ctx, entityContainer, relationType, sdId), - ctx.getDbCallbackExecutor()); - } - - private ListenableFuture deleteCurrentRelationsIfNeeded(TbContext ctx, TbMsg msg, SearchDirectionIds sdId, String relationType) { - if (config.isRemoveCurrentRelations()) { - return deleteOriginatorRelations(ctx, findOriginatorRelations(ctx, msg, sdId, relationType)); + return checkRelationAndCreateIfAbsent(ctx, originator, targetEntityId, relationType); + }, MoreExecutors.directExecutor()); + if (!config.isChangeOriginatorToRelatedEntity()) { + withCallback(createRelationResultFuture, + relationCreated -> { + if (relationCreated) { + ctx.tellSuccess(msg); + return; + } + ctx.tellFailure(msg, new RuntimeException("Failed to create originator relation with target entity!")); + }, + t -> ctx.tellFailure(msg, t), MoreExecutors.directExecutor()); + return; } - return Futures.immediateFuture(null); - } - - private ListenableFuture> findOriginatorRelations(TbContext ctx, TbMsg msg, SearchDirectionIds sdId, String relationType) { - if (sdId.isOriginatorDirectionFrom()) { - return ctx.getRelationService().findByFromAndTypeAsync(ctx.getTenantId(), msg.getOriginator(), relationType, RelationTypeGroup.COMMON); - } else { - return ctx.getRelationService().findByToAndTypeAsync(ctx.getTenantId(), msg.getOriginator(), relationType, RelationTypeGroup.COMMON); - } - } - - private ListenableFuture deleteOriginatorRelations(TbContext ctx, ListenableFuture> originatorRelationsFuture) { - return Futures.transformAsync(originatorRelationsFuture, originatorRelations -> { - List> list = new ArrayList<>(); - if (!CollectionUtils.isEmpty(originatorRelations)) { - for (EntityRelation relation : originatorRelations) { - list.add(ctx.getRelationService().deleteRelationAsync(ctx.getTenantId(), relation)); - } - } - return Futures.transform(Futures.allAsList(list), result -> null, ctx.getDbCallbackExecutor()); - }, ctx.getDbCallbackExecutor()); - } - - private ListenableFuture checkRelationAndCreateIfAbsent(TbContext ctx, EntityContainer entityContainer, String relationType, SearchDirectionIds sdId) { - return Futures.transformAsync(checkRelation(ctx, sdId, relationType), relationPresent -> { - if (relationPresent) { - return Futures.immediateFuture(true); - } - return processCreateRelation(ctx, entityContainer, sdId, relationType); - }, ctx.getDbCallbackExecutor()); - } - - private ListenableFuture checkRelation(TbContext ctx, SearchDirectionIds sdId, String relationType) { - return ctx.getRelationService().checkRelationAsync(ctx.getTenantId(), sdId.getFromId(), sdId.getToId(), relationType, RelationTypeGroup.COMMON); - } - - private ListenableFuture processCreateRelation(TbContext ctx, EntityContainer entityContainer, SearchDirectionIds sdId, String relationType) { - switch (entityContainer.getEntityType()) { - case ASSET: - return processAsset(ctx, entityContainer, sdId, relationType); - case DEVICE: - return processDevice(ctx, entityContainer, sdId, relationType); - case CUSTOMER: - return processCustomer(ctx, entityContainer, sdId, relationType); - case DASHBOARD: - return processDashboard(ctx, entityContainer, sdId, relationType); - case ENTITY_VIEW: - return processView(ctx, entityContainer, sdId, relationType); - case EDGE: - return processEdge(ctx, entityContainer, sdId, relationType); - case TENANT: - return processTenant(ctx, entityContainer, sdId, relationType); - case USER: - return processUser(ctx, entityContainer, sdId, relationType); - } - return Futures.immediateFuture(true); - } - - private ListenableFuture processView(TbContext ctx, EntityContainer entityContainer, SearchDirectionIds sdId, String relationType) { - return Futures.transformAsync(ctx.getEntityViewService().findEntityViewByIdAsync(ctx.getTenantId(), new EntityViewId(entityContainer.getEntityId().getId())), entityView -> { - if (entityView != null) { - return processSave(ctx, sdId, relationType); - } else { - return Futures.immediateFuture(true); + withCallback(Futures.allAsList(targetEntityIdFuture, createRelationResultFuture), result -> { + var targetEntityId = (EntityId) result.get(0); + var relationCreated = (Boolean) result.get(1); + if (relationCreated) { + var transformedMsg = ctx.transformMsgOriginator(msg, targetEntityId); + ctx.tellSuccess(transformedMsg); + return; } - }, ctx.getDbCallbackExecutor()); + ctx.tellFailure(msg, new RuntimeException("Failed to create originator relation with target entity!")); + }, t -> ctx.tellFailure(msg, t), MoreExecutors.directExecutor()); } - private ListenableFuture processEdge(TbContext ctx, EntityContainer entityContainer, SearchDirectionIds sdId, String relationType) { - return Futures.transformAsync(ctx.getEdgeService().findEdgeByIdAsync(ctx.getTenantId(), new EdgeId(entityContainer.getEntityId().getId())), edge -> { - if (edge != null) { - return processSave(ctx, sdId, relationType); - } else { - return Futures.immediateFuture(true); - } - }, ctx.getDbCallbackExecutor()); - } - - private ListenableFuture processDevice(TbContext ctx, EntityContainer entityContainer, SearchDirectionIds sdId, String relationType) { - Device device = ctx.getDeviceService().findDeviceById(ctx.getTenantId(), new DeviceId(entityContainer.getEntityId().getId())); - if (device != null) { - return processSave(ctx, sdId, relationType); + private ListenableFuture checkRelationAndCreateIfAbsent(TbContext ctx, EntityId originator, EntityId targetEntityId, String relationType) { + EntityId fromId; + EntityId toId; + if (EntitySearchDirection.FROM.equals(config.getDirection())) { + fromId = originator; + toId = targetEntityId; } else { - return Futures.immediateFuture(true); + toId = originator; + fromId = targetEntityId; } - } - - private ListenableFuture processAsset(TbContext ctx, EntityContainer entityContainer, SearchDirectionIds sdId, String relationType) { - return Futures.transformAsync(ctx.getAssetService().findAssetByIdAsync(ctx.getTenantId(), new AssetId(entityContainer.getEntityId().getId())), asset -> { - if (asset != null) { - return processSave(ctx, sdId, relationType); - } else { - return Futures.immediateFuture(true); - } - }, ctx.getDbCallbackExecutor()); - } - - private ListenableFuture processCustomer(TbContext ctx, EntityContainer entityContainer, SearchDirectionIds sdId, String relationType) { - return Futures.transformAsync(ctx.getCustomerService().findCustomerByIdAsync(ctx.getTenantId(), new CustomerId(entityContainer.getEntityId().getId())), customer -> { - if (customer != null) { - return processSave(ctx, sdId, relationType); - } else { - return Futures.immediateFuture(true); - } - }, ctx.getDbCallbackExecutor()); - } - - private ListenableFuture processDashboard(TbContext ctx, EntityContainer entityContainer, SearchDirectionIds sdId, String relationType) { - return Futures.transformAsync(ctx.getDashboardService().findDashboardByIdAsync(ctx.getTenantId(), new DashboardId(entityContainer.getEntityId().getId())), dashboard -> { - if (dashboard != null) { - return processSave(ctx, sdId, relationType); - } else { - return Futures.immediateFuture(true); - } - }, ctx.getDbCallbackExecutor()); - } - - private ListenableFuture processTenant(TbContext ctx, EntityContainer entityContainer, SearchDirectionIds sdId, String relationType) { - return Futures.transformAsync(ctx.getTenantService().findTenantByIdAsync(ctx.getTenantId(), TenantId.fromUUID(entityContainer.getEntityId().getId())), tenant -> { - if (tenant != null) { - return processSave(ctx, sdId, relationType); - } else { - return Futures.immediateFuture(true); - } - }, ctx.getDbCallbackExecutor()); - } - - private ListenableFuture processUser(TbContext ctx, EntityContainer entityContainer, SearchDirectionIds sdId, String relationType) { - return Futures.transformAsync(ctx.getUserService().findUserByIdAsync(ctx.getTenantId(), new UserId(entityContainer.getEntityId().getId())), user -> { - if (user != null) { - return processSave(ctx, sdId, relationType); - } else { - return Futures.immediateFuture(true); - } - }, ctx.getDbCallbackExecutor()); - } - - private ListenableFuture processSave(TbContext ctx, SearchDirectionIds sdId, String relationType) { - return ctx.getRelationService().saveRelationAsync(ctx.getTenantId(), new EntityRelation(sdId.getFromId(), sdId.getToId(), relationType, RelationTypeGroup.COMMON)); + var checkRelationFuture = ctx.getRelationService().checkRelationAsync(ctx.getTenantId(), fromId, toId, relationType, RelationTypeGroup.COMMON); + return Futures.transformAsync(checkRelationFuture, relationExists -> + relationExists ? + Futures.immediateFuture(true) : + ctx.getRelationService(). + saveRelationAsync(ctx.getTenantId(), new EntityRelation(fromId, toId, relationType, RelationTypeGroup.COMMON)), + MoreExecutors.directExecutor()); } } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateRelationNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateRelationNodeConfiguration.java index e2601183f3..da41fed7d2 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateRelationNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateRelationNodeConfiguration.java @@ -16,10 +16,13 @@ package org.thingsboard.rule.engine.action; import lombok.Data; +import lombok.EqualsAndHashCode; import org.thingsboard.rule.engine.api.NodeConfiguration; +import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; @Data +@EqualsAndHashCode(callSuper = true) public class TbCreateRelationNodeConfiguration extends TbAbstractRelationActionNodeConfiguration implements NodeConfiguration { private boolean createEntityIfNotExists; @@ -29,10 +32,9 @@ public class TbCreateRelationNodeConfiguration extends TbAbstractRelationActionN @Override public TbCreateRelationNodeConfiguration defaultConfiguration() { TbCreateRelationNodeConfiguration configuration = new TbCreateRelationNodeConfiguration(); - configuration.setDirection(EntitySearchDirection.FROM.name()); - configuration.setRelationType("Contains"); + configuration.setDirection(EntitySearchDirection.FROM); + configuration.setRelationType(EntityRelation.CONTAINS_TYPE); configuration.setEntityNamePattern(""); - configuration.setEntityCacheExpiration(300); configuration.setCreateEntityIfNotExists(false); configuration.setRemoveCurrentRelations(false); configuration.setChangeOriginatorToRelatedEntity(false); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeleteRelationNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeleteRelationNode.java index 4db46da439..dbc914065f 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeleteRelationNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeleteRelationNode.java @@ -17,20 +17,20 @@ package org.thingsboard.rule.engine.action; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.rule.engine.api.util.TbNodeUtils; -import org.thingsboard.rule.engine.util.EntityContainer; +import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.plugin.ComponentType; -import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.msg.TbMsg; -import java.util.ArrayList; -import java.util.List; +import static org.thingsboard.common.util.DonAsynchron.withCallback; @Slf4j @@ -38,18 +38,36 @@ import java.util.List; type = ComponentType.ACTION, name = "delete relation", configClazz = TbDeleteRelationNodeConfiguration.class, - nodeDescription = "Finds target Entity by entity name pattern and then delete a relation to Originator Entity by type and direction" + - " if 'Delete single entity' is set to true, otherwise rule node will delete all relations to the originator of the message by type and direction.", - nodeDetails = "If the relation(s) successfully deleted - Message send via Success chain, otherwise Failure chain will be used.", + nodeDescription = "Deletes relation with the incoming message originator based on the configured direction and type.", + nodeDetails = "Useful when you need to remove relations between entities dynamically depending on incoming message payload, " + + "message originator type, name, etc.

" + + "If Delete relation with specific entity enabled, target entity to delete relation with should be specified. " + + "Otherwise, rule node will delete all relations with the message originator based on the configured direction and type.

" + + "Target entity configuration: " + + "
  • Device - use a device with the specified name as the target entity to delete relation with.
  • " + + "
  • Asset - use an asset with the specified name as the target entity to delete relation with.
  • " + + "
  • Entity View - use entity view with the specified name as the target entity to delete relation with.
  • " + + "
  • Tenant - use current tenant as target entity to delete relation with.
  • " + + "
  • Customer - use customer with the specified title as the target entity to delete relation with.
  • " + + "
  • Dashboard - use a dashboard with the specified title as the target entity to delete relation with.
  • " + + "
  • User - use a user with the specified email as the target entity to delete relation with.
  • " + + "
  • Edge - use an edge with the specified name as the target entity to delete relation with.
" + + "Output connections: Success - If the relation(s) successfully deleted, otherwise Failure.", uiResources = {"static/rulenode/rulenode-core-config.js"}, configDirective = "tbActionNodeDeleteRelationConfig", - icon = "remove_circle" + icon = "remove_circle", + version = 1 ) public class TbDeleteRelationNode extends TbAbstractRelationActionNode { @Override protected TbDeleteRelationNodeConfiguration loadEntityNodeActionConfig(TbNodeConfiguration configuration) throws TbNodeException { - return TbNodeUtils.convert(configuration, TbDeleteRelationNodeConfiguration.class); + var deleteRelationNodeConfiguration = TbNodeUtils.convert(configuration, TbDeleteRelationNodeConfiguration.class); + if (!deleteRelationNodeConfiguration.isDeleteForSingleEntity()) { + return deleteRelationNodeConfiguration; + } + checkIfConfigEntityTypeIsSupported(deleteRelationNodeConfiguration.getEntityType()); + return deleteRelationNodeConfiguration; } @Override @@ -58,57 +76,41 @@ public class TbDeleteRelationNode extends TbAbstractRelationActionNode processEntityRelationAction(TbContext ctx, TbMsg msg, String relationType) { - return getRelationContainerListenableFuture(ctx, msg, relationType); - } - - @Override - protected ListenableFuture doProcessEntityRelationAction(TbContext ctx, TbMsg msg, EntityContainer entityContainer, String relationType) { - return Futures.transform(processSingle(ctx, msg, entityContainer, relationType), result -> new RelationContainer(msg, result), ctx.getDbCallbackExecutor()); + public void onMsg(TbContext ctx, TbMsg msg) { + ListenableFuture deleteResultFuture = config.isDeleteForSingleEntity() ? + Futures.transformAsync(getTargetEntityId(ctx, msg), targetEntityId -> + deleteRelationToSpecificEntity(ctx, msg, targetEntityId), MoreExecutors.directExecutor()) : + deleteRelationsByTypeAndDirection(ctx, msg, ctx.getDbCallbackExecutor()); + withCallback(deleteResultFuture, deleted -> { + if (deleted) { + ctx.tellSuccess(msg); + return; + } + ctx.tellFailure(msg, new RuntimeException("Failed to delete relation(s) with originator!")); + }, + t -> ctx.tellFailure(msg, t), MoreExecutors.directExecutor()); } - private ListenableFuture getRelationContainerListenableFuture(TbContext ctx, TbMsg msg, String relationType) { - if (config.isDeleteForSingleEntity()) { - return Futures.transformAsync(getEntity(ctx, msg), entityContainer -> doProcessEntityRelationAction(ctx, msg, entityContainer, relationType), ctx.getDbCallbackExecutor()); + private ListenableFuture deleteRelationToSpecificEntity(TbContext ctx, TbMsg msg, EntityId targetEntityId) { + EntityId fromId; + EntityId toId; + if (EntitySearchDirection.FROM.equals(config.getDirection())) { + fromId = msg.getOriginator(); + toId = targetEntityId; } else { - return Futures.transform(processList(ctx, msg), result -> new RelationContainer(msg, result), ctx.getDbCallbackExecutor()); + toId = msg.getOriginator(); + fromId = targetEntityId; } - } - - private ListenableFuture processList(TbContext ctx, TbMsg msg) { - return Futures.transformAsync(processListSearchDirection(ctx, msg), entityRelations -> { - if (entityRelations.isEmpty()) { - return Futures.immediateFuture(true); - } else { - List> listenableFutureList = new ArrayList<>(); - for (EntityRelation entityRelation : entityRelations) { - listenableFutureList.add(ctx.getRelationService().deleteRelationAsync(ctx.getTenantId(), entityRelation)); - } - return Futures.transformAsync(Futures.allAsList(listenableFutureList), booleans -> { - for (Boolean bool : booleans) { - if (!bool) { - return Futures.immediateFuture(false); - } + var relationType = processPattern(msg, config.getRelationType()); + var tenantId = ctx.getTenantId(); + var relationService = ctx.getRelationService(); + return Futures.transformAsync(relationService.checkRelationAsync(tenantId, fromId, toId, relationType, RelationTypeGroup.COMMON), + relationExists -> { + if (relationExists) { + return relationService.deleteRelationAsync(tenantId, fromId, toId, relationType, RelationTypeGroup.COMMON); } return Futures.immediateFuture(true); - }, ctx.getDbCallbackExecutor()); - } - }, ctx.getDbCallbackExecutor()); - } - - private ListenableFuture processSingle(TbContext ctx, TbMsg msg, EntityContainer entityContainer, String relationType) { - SearchDirectionIds sdId = processSingleSearchDirection(msg, entityContainer); - return Futures.transformAsync(ctx.getRelationService().checkRelationAsync(ctx.getTenantId(), sdId.getFromId(), sdId.getToId(), relationType, RelationTypeGroup.COMMON), - result -> { - if (result) { - return processSingleDeleteRelation(ctx, sdId, relationType); - } - return Futures.immediateFuture(true); - }, ctx.getDbCallbackExecutor()); - } - - private ListenableFuture processSingleDeleteRelation(TbContext ctx, SearchDirectionIds sdId, String relationType) { - return ctx.getRelationService().deleteRelationAsync(ctx.getTenantId(), sdId.getFromId(), sdId.getToId(), relationType, RelationTypeGroup.COMMON); + }, MoreExecutors.directExecutor()); } } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeleteRelationNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeleteRelationNodeConfiguration.java index f0d2ec476e..304d060747 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeleteRelationNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeleteRelationNodeConfiguration.java @@ -16,10 +16,13 @@ package org.thingsboard.rule.engine.action; import lombok.Data; +import lombok.EqualsAndHashCode; import org.thingsboard.rule.engine.api.NodeConfiguration; +import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; @Data +@EqualsAndHashCode(callSuper = true) public class TbDeleteRelationNodeConfiguration extends TbAbstractRelationActionNodeConfiguration implements NodeConfiguration { private boolean deleteForSingleEntity; @@ -27,11 +30,10 @@ public class TbDeleteRelationNodeConfiguration extends TbAbstractRelationActionN @Override public TbDeleteRelationNodeConfiguration defaultConfiguration() { TbDeleteRelationNodeConfiguration configuration = new TbDeleteRelationNodeConfiguration(); - configuration.setDeleteForSingleEntity(true); - configuration.setDirection(EntitySearchDirection.FROM.name()); - configuration.setRelationType("Contains"); + configuration.setDeleteForSingleEntity(false); + configuration.setDirection(EntitySearchDirection.FROM); + configuration.setRelationType(EntityRelation.CONTAINS_TYPE); configuration.setEntityNamePattern(""); - configuration.setEntityCacheExpiration(300); return configuration; } } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbUnassignFromCustomerNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbUnassignFromCustomerNode.java index 9b369d4057..ba33d91feb 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbUnassignFromCustomerNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbUnassignFromCustomerNode.java @@ -15,14 +15,17 @@ */ package org.thingsboard.rule.engine.action; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.id.AssetId; -import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EdgeId; @@ -34,11 +37,15 @@ import org.thingsboard.server.common.msg.TbMsg; type = ComponentType.ACTION, name = "unassign from customer", configClazz = TbUnassignFromCustomerNodeConfiguration.class, - nodeDescription = "Unassign Message Originator Entity from Customer", - nodeDetails = "Finds target Entity Customer by Customer name pattern and then unassign Originator Entity from this customer.", + nodeDescription = "Unassign message originator entity from customer", + nodeDetails = "If the message originator is not assigned to any customer, rule node will do nothing.

" + + "If the incoming message originator is a dashboard, will try to search for the customer by title specified in the configuration. " + + "If customer doesn't exist, the exception will be thrown. Otherwise will unassign the dashboard from retrieved customer.

" + + "Other entities can be assigned only to one customer, so specified customer title in the configuration will be ignored if the originator isn't a dashboard.", uiResources = {"static/rulenode/rulenode-core-config.js"}, configDirective = "tbActionNodeUnAssignToCustomerConfig", - icon = "remove_circle" + icon = "remove_circle", + version = 1 ) public class TbUnassignFromCustomerNode extends TbAbstractCustomerActionNode { @@ -53,48 +60,33 @@ public class TbUnassignFromCustomerNode extends TbAbstractCustomerActionNode processCustomerAction(TbContext ctx, TbMsg msg) { + var originator = msg.getOriginator(); + var originatorType = originator.getEntityType(); + var tenantId = ctx.getTenantId(); + if (EntityType.DASHBOARD.equals(originatorType)) { + if (StringUtils.isEmpty(config.getCustomerNamePattern())) { + throw new RuntimeException("Failed to unassign dashboard with id '" + + originator.getId() + "' from customer! Customer title should be specified!"); + } + var customerIdFuture = getCustomerIdFuture(ctx, msg); + return Futures.transformAsync(customerIdFuture, customerId -> { + ctx.getDashboardService().unassignDashboardFromCustomer(tenantId, new DashboardId(originator.getId()), customerId); + return Futures.immediateFuture(null); + }, MoreExecutors.directExecutor()); } + return ctx.getDbCallbackExecutor().submit(() -> { + switch (originatorType) { + case ASSET -> + ctx.getAssetService().unassignAssetFromCustomer(tenantId, new AssetId(originator.getId())); + case DEVICE -> + ctx.getDeviceService().unassignDeviceFromCustomer(tenantId, new DeviceId(originator.getId())); + case ENTITY_VIEW -> + ctx.getEntityViewService().unassignEntityViewFromCustomer(tenantId, new EntityViewId(originator.getId())); + case EDGE -> ctx.getEdgeService().unassignEdgeFromCustomer(tenantId, new EdgeId(originator.getId())); + } + return null; + }); } - private void processUnnasignAsset(TbContext ctx, TbMsg msg) { - ctx.getAssetService().unassignAssetFromCustomer(ctx.getTenantId(), new AssetId(msg.getOriginator().getId())); - } - - private void processUnnasignDevice(TbContext ctx, TbMsg msg) { - ctx.getDeviceService().unassignDeviceFromCustomer(ctx.getTenantId(), new DeviceId(msg.getOriginator().getId())); - } - - private void processUnnasignDashboard(TbContext ctx, TbMsg msg, CustomerId customerId) { - ctx.getDashboardService().unassignDashboardFromCustomer(ctx.getTenantId(), new DashboardId(msg.getOriginator().getId()), customerId); - } - - private void processUnassignEntityView(TbContext ctx, TbMsg msg) { - ctx.getEntityViewService().unassignEntityViewFromCustomer(ctx.getTenantId(), new EntityViewId(msg.getOriginator().getId())); - } - - private void processUnassignEdge(TbContext ctx, TbMsg msg) { - ctx.getEdgeService().unassignEdgeFromCustomer(ctx.getTenantId(), new EdgeId(msg.getOriginator().getId())); - } } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbUnassignFromCustomerNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbUnassignFromCustomerNodeConfiguration.java index bb1323421f..3fbdfb0450 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbUnassignFromCustomerNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbUnassignFromCustomerNodeConfiguration.java @@ -16,16 +16,17 @@ package org.thingsboard.rule.engine.action; import lombok.Data; +import lombok.EqualsAndHashCode; import org.thingsboard.rule.engine.api.NodeConfiguration; @Data +@EqualsAndHashCode(callSuper = true) public class TbUnassignFromCustomerNodeConfiguration extends TbAbstractCustomerActionNodeConfiguration implements NodeConfiguration { @Override public TbUnassignFromCustomerNodeConfiguration defaultConfiguration() { - TbUnassignFromCustomerNodeConfiguration configuration = new TbUnassignFromCustomerNodeConfiguration(); + var configuration = new TbUnassignFromCustomerNodeConfiguration(); configuration.setCustomerNamePattern(""); - configuration.setCustomerCacheExpiration(300); return configuration; } } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckRelationNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckRelationNode.java index c1a24266d8..0356548a03 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckRelationNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckRelationNode.java @@ -60,8 +60,6 @@ import static org.thingsboard.common.util.DonAsynchron.withCallback; configDirective = "tbFilterNodeCheckRelationConfig") public class TbCheckRelationNode implements TbNode { - private static final String DIRECTION_PROPERTY_NAME = "direction"; - private TbCheckRelationNodeConfiguration config; private EntityId singleEntityId; @@ -114,19 +112,20 @@ public class TbCheckRelationNode implements TbNode { public TbPair upgrade(int fromVersion, JsonNode oldConfiguration) throws TbNodeException { if (fromVersion == 0) { var newConfigObjectNode = (ObjectNode) oldConfiguration; - if (!newConfigObjectNode.has(DIRECTION_PROPERTY_NAME)) { - throw new TbNodeException("property to update: '" + DIRECTION_PROPERTY_NAME + "' doesn't exists in configuration!"); + var directionPropertyName = "direction"; + if (!newConfigObjectNode.has(directionPropertyName)) { + throw new TbNodeException("property to update: '" + directionPropertyName + "' doesn't exists in configuration!"); } - String direction = newConfigObjectNode.get(DIRECTION_PROPERTY_NAME).asText(); + String direction = newConfigObjectNode.get(directionPropertyName).asText(); if (EntitySearchDirection.TO.name().equals(direction)) { - newConfigObjectNode.put(DIRECTION_PROPERTY_NAME, EntitySearchDirection.FROM.name()); + newConfigObjectNode.put(directionPropertyName, EntitySearchDirection.FROM.name()); return new TbPair<>(true, newConfigObjectNode); } if (EntitySearchDirection.FROM.name().equals(direction)) { - newConfigObjectNode.put(DIRECTION_PROPERTY_NAME, EntitySearchDirection.TO.name()); + newConfigObjectNode.put(directionPropertyName, EntitySearchDirection.TO.name()); return new TbPair<>(true, newConfigObjectNode); } - throw new TbNodeException("property to update: '" + DIRECTION_PROPERTY_NAME + "' has invalid value!"); + throw new TbNodeException("property to update: '" + directionPropertyName + "' has invalid value!"); } return new TbPair<>(false, oldConfiguration); } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbAssignToCustomerNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbAssignToCustomerNodeTest.java new file mode 100644 index 0000000000..6b307b84f7 --- /dev/null +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbAssignToCustomerNodeTest.java @@ -0,0 +1,335 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.rule.engine.action; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.ListeningExecutor; +import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest; +import org.thingsboard.rule.engine.TestDbCallbackExecutor; +import org.thingsboard.rule.engine.api.TbContext; +import org.thingsboard.rule.engine.api.TbNode; +import org.thingsboard.rule.engine.api.TbNodeConfiguration; +import org.thingsboard.rule.engine.api.TbNodeException; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.dao.asset.AssetService; +import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.dao.dashboard.DashboardService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.edge.EdgeService; +import org.thingsboard.server.dao.entityview.EntityViewService; + +import java.util.Arrays; +import java.util.EnumSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TbAssignToCustomerNodeTest extends AbstractRuleNodeUpgradeTest { + + private static final Set supportedEntityTypes = EnumSet.of(EntityType.DEVICE, EntityType.ASSET, + EntityType.ENTITY_VIEW, EntityType.EDGE, EntityType.DASHBOARD); + + private static final String supportedEntityTypesStr = supportedEntityTypes.stream().map(Enum::name).collect(Collectors.joining(", ")); + + private static final Set unsupportedEntityTypes = Arrays.stream(EntityType.values()) + .filter(type -> !supportedEntityTypes.contains(type)).collect(Collectors.toUnmodifiableSet()); + + private final Device DEVICE = new Device(); + private final Asset ASSET = new Asset(); + private final EntityView ENTITY_VIEW = new EntityView(); + private final Edge EDGE = new Edge(); + private final Dashboard DASHBOARD = new Dashboard(); + + private final TenantId TENANT_ID = new TenantId(UUID.fromString("c818385f-e661-407f-8c52-daf2dddf406d")); + private final RuleNodeId RULE_NODE_ID = new RuleNodeId(UUID.fromString("c3570bd0-c0bc-4609-97a4-6f57d7c8b809")); + + private final ListeningExecutor DB_EXECUTOR = new TestDbCallbackExecutor(); + + private static Stream givenUnsupportedOriginatorType_whenOnMsg_thenVerifyExceptionThrown() { + return unsupportedEntityTypes.stream().flatMap(type -> Stream.of(Arguments.of(type))); + } + + private static Stream givenSupportedOriginatorTypeAndCustomerTitle_whenOnMsg_thenVerify() { + return supportedEntityTypes.stream() + .flatMap(type -> Stream.of(Arguments.of(type, StringUtils.randomAlphabetic(5)))); + } + + private TbAssignToCustomerNode node; + private TbAssignToCustomerNodeConfiguration config; + + @Mock + private TbContext ctxMock; + + @Mock + private CustomerService customerServiceMock; + + @Mock + private DeviceService deviceServiceMock; + + @Mock + private AssetService assetServiceMock; + + @Mock + private EntityViewService entityViewServiceMock; + + @Mock + private EdgeService edgeServiceMock; + + @Mock + private DashboardService dashboardServiceMock; + + @BeforeEach + public void setUp() throws TbNodeException { + node = spy(new TbAssignToCustomerNode()); + config = new TbAssignToCustomerNodeConfiguration().defaultConfiguration(); + } + + @Override + protected TbNode getTestNode() { + return node; + } + + @Test + void givenDefaultConfig_whenVerify_thenOK() { + var defaultConfig = new TbAssignToCustomerNodeConfiguration().defaultConfiguration(); + assertThat(defaultConfig.getCustomerNamePattern()).isEmpty(); + assertThat(defaultConfig.isCreateCustomerIfNotExists()).isFalse(); + } + + @ParameterizedTest + @MethodSource + void givenUnsupportedOriginatorType_whenOnMsg_thenVerifyExceptionThrown(EntityType originatorType) { + // GIVEN + var originator = toOriginator(originatorType); + var msg = getTbMsg(originator); + + // WHEN + var exception = assertThrows(RuntimeException.class, () -> node.onMsg(ctxMock, msg)); + + // THEN + assertThat(exception.getMessage()).isEqualTo("Unsupported originator type '" + originatorType + + "'! Only " + supportedEntityTypesStr + " types are allowed."); + verifyNoInteractions(ctxMock); + verifyNoInteractions(customerServiceMock); + } + + @ParameterizedTest + @MethodSource("givenSupportedOriginatorTypeAndCustomerTitle_whenOnMsg_thenVerify") + void givenSupportedOriginatorTypeAndCustomerTitle_whenOnMsg_thenVerify(EntityType type, String customerTitle) throws TbNodeException { + // GIVEN + + when(ctxMock.getTenantId()).thenReturn(TENANT_ID); + when(ctxMock.getDbCallbackExecutor()).thenReturn(DB_EXECUTOR); + when(ctxMock.getCustomerService()).thenReturn(customerServiceMock); + + config.setCustomerNamePattern(customerTitle); + var nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + node.init(ctxMock, nodeConfiguration); + + var originator = toOriginator(type); + var msg = getTbMsg(originator); + var customer = createCustomer(customerTitle); + + when(customerServiceMock.findCustomerByTenantIdAndTitle(eq(TENANT_ID), eq(customerTitle))).thenReturn(Optional.of(customer)); + Map> entityTypeToAssignConsumerMap = mockMethodCallsForSupportedTypes(); + entityTypeToAssignConsumerMap.get(type).accept(originator); + + // WHEN + node.onMsg(ctxMock, msg); + + // THEN + verifyMsgSuccess(msg); + verifyNoMoreInteractions(ctxMock); + } + + @ParameterizedTest + @MethodSource("givenSupportedOriginatorTypeAndCustomerTitle_whenOnMsg_thenVerify") + void givenSupportedOriginatorTypeAndCustomerTitle_whenOnMsg_thenVerifyCustomerCreatedAndSuccessOutMsg(EntityType type, String customerTitle) throws TbNodeException { + // GIVEN + + when(ctxMock.getTenantId()).thenReturn(TENANT_ID); + when(ctxMock.getDbCallbackExecutor()).thenReturn(DB_EXECUTOR); + when(ctxMock.getCustomerService()).thenReturn(customerServiceMock); + when(ctxMock.getSelfId()).thenReturn(RULE_NODE_ID); + + config.setCreateCustomerIfNotExists(true); + config.setCustomerNamePattern(customerTitle); + var nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + node.init(ctxMock, nodeConfiguration); + + var originator = toOriginator(type); + var msg = getTbMsg(originator); + var customer = createCustomer(customerTitle); + + when(customerServiceMock.findCustomerByTenantIdAndTitle(eq(TENANT_ID), eq(customerTitle))).thenReturn(Optional.empty()); + when(customerServiceMock.saveCustomer(any(Customer.class))).thenReturn(customer); + Map> entityTypeToEntityIdConsumerMap = mockMethodCallsForSupportedTypes(); + entityTypeToEntityIdConsumerMap.get(type).accept(originator); + + // WHEN + node.onMsg(ctxMock, msg); + + // THEN + ArgumentCaptor runnableCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(ctxMock).enqueue(any(), runnableCaptor.capture(), any()); + runnableCaptor.getValue().run(); + verify(ctxMock).customerCreatedMsg(any(), eq(RULE_NODE_ID)); + verifyMsgSuccess(msg); + } + + @ParameterizedTest + @MethodSource("givenSupportedOriginatorTypeAndCustomerTitle_whenOnMsg_thenVerify") + void givenSupportedOriginatorTypeAndCustomerTitle_whenOnMsg_thenVerifyCustomerNotFound(EntityType type, String customerTitle) throws TbNodeException { + // GIVEN + + when(ctxMock.getTenantId()).thenReturn(TENANT_ID); + when(ctxMock.getDbCallbackExecutor()).thenReturn(DB_EXECUTOR); + when(ctxMock.getCustomerService()).thenReturn(customerServiceMock); + + config.setCustomerNamePattern(customerTitle); + var nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + node.init(ctxMock, nodeConfiguration); + + var originator = toOriginator(type); + var msg = getTbMsg(originator); + + when(customerServiceMock.findCustomerByTenantIdAndTitle(eq(TENANT_ID), eq(customerTitle))).thenReturn(Optional.empty()); + + // WHEN + node.onMsg(ctxMock, msg); + + // THEN + ArgumentCaptor throwableCaptor = ArgumentCaptor.forClass(Throwable.class); + verify(ctxMock).tellFailure(eq(msg), throwableCaptor.capture()); + assertThat(throwableCaptor.getValue()).hasMessage("Customer with title '" + customerTitle + "' doesn't exist!"); + + verifyNoMoreInteractions(customerServiceMock); + verifyNoMoreInteractions(ctxMock); + } + + private Map> mockMethodCallsForSupportedTypes() { + return Map.of( + EntityType.DEVICE, id -> { + when(ctxMock.getDeviceService()).thenReturn(deviceServiceMock); + when(deviceServiceMock.assignDeviceToCustomer(eq(TENANT_ID), (DeviceId) eq(id), any())) + .thenReturn(DEVICE); + }, + EntityType.ASSET, id -> { + when(ctxMock.getAssetService()).thenReturn(assetServiceMock); + when(assetServiceMock.assignAssetToCustomer(eq(TENANT_ID), (AssetId) eq(id), any())) + .thenReturn(ASSET); + }, + EntityType.ENTITY_VIEW, id -> { + when(ctxMock.getEntityViewService()).thenReturn(entityViewServiceMock); + when(entityViewServiceMock.assignEntityViewToCustomer(eq(TENANT_ID), (EntityViewId) eq(id), any())) + .thenReturn(ENTITY_VIEW); + }, + EntityType.EDGE, id -> { + when(ctxMock.getEdgeService()).thenReturn(edgeServiceMock); + when(edgeServiceMock.assignEdgeToCustomer(eq(TENANT_ID), (EdgeId) eq(id), any())) + .thenReturn(EDGE); + }, + EntityType.DASHBOARD, id -> { + when(ctxMock.getDashboardService()).thenReturn(dashboardServiceMock); + when(dashboardServiceMock.assignDashboardToCustomer(eq(TENANT_ID), (DashboardId) eq(id), any())) + .thenReturn(DASHBOARD); + } + ); + } + + private void verifyMsgSuccess(TbMsg expectedMsg) { + verify(ctxMock).tellSuccess(eq(expectedMsg)); + verify(ctxMock, never()).tellFailure(any(), any()); + } + + private Customer createCustomer(String customerTitle) { + var customer = new Customer(); + customer.setTitle(customerTitle); + customer.setId(new CustomerId(UUID.randomUUID())); + customer.setTenantId(TENANT_ID); + return customer; + } + + private TbMsg getTbMsg(EntityId originator) { + return TbMsg.newMsg(TbMsgType.NA, originator, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); + } + + private EntityId toOriginator(EntityType type) { + return EntityIdFactory.getByTypeAndId(type.name(), UUID.randomUUID().toString()); + } + + // Rule nodes upgrade + private static Stream givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() { + return Stream.of( + // default config for version 0 + Arguments.of(0, + "{\"customerNamePattern\":\"\",\"createCustomerIfNotExists\":\"false\",\"customerCacheExpiration\":300}", + true, + "{\"customerNamePattern\":\"\",\"createCustomerIfNotExists\":\"false\"}"), + // default config for version 1 with upgrade from version 0 + Arguments.of(0, + "{\"customerNamePattern\":\"\",\"createCustomerIfNotExists\":\"false\"}", + false, + "{\"customerNamePattern\":\"\",\"createCustomerIfNotExists\":\"false\"}") + ); + } + +} diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbCreateRelationNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbCreateRelationNodeTest.java index ec2b4a581f..56f1a99a00 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbCreateRelationNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbCreateRelationNodeTest.java @@ -15,193 +15,708 @@ */ package org.thingsboard.rule.engine.action; -import com.datastax.oss.driver.api.core.uuid.Uuids; import com.google.common.util.concurrent.Futures; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.ArgumentCaptor; import org.mockito.Mock; -import org.mockito.junit.MockitoJUnitRunner; +import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ListeningExecutor; +import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest; import org.thingsboard.rule.engine.TestDbCallbackExecutor; import org.thingsboard.rule.engine.api.TbContext; +import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.EntityId; -import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.msg.TbMsgType; -import org.thingsboard.server.common.data.msg.TbNodeConnectionType; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.msg.TbMsg; -import org.thingsboard.server.common.msg.TbMsgDataType; import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.dao.asset.AssetService; +import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.dao.dashboard.DashboardService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.edge.EdgeService; +import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.relation.RelationService; - -import java.util.Collections; - -import static org.junit.Assert.assertEquals; +import org.thingsboard.server.dao.user.UserService; + +import java.util.Arrays; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; -@RunWith(MockitoJUnitRunner.class) -public class TbCreateRelationNodeTest { +@ExtendWith(MockitoExtension.class) +public class TbCreateRelationNodeTest extends AbstractRuleNodeUpgradeTest { - private TbCreateRelationNode node; + private static final Set supportedEntityTypes = EnumSet.of(EntityType.TENANT, EntityType.DEVICE, + EntityType.ASSET, EntityType.CUSTOMER, EntityType.ENTITY_VIEW, EntityType.DASHBOARD, EntityType.EDGE, EntityType.USER); + + private static final String supportedEntityTypesStr = supportedEntityTypes.stream().map(Enum::name).collect(Collectors.joining(" ,")); + + private static final Set unsupportedEntityTypes = Arrays.stream(EntityType.values()) + .filter(type -> !supportedEntityTypes.contains(type)).collect(Collectors.toUnmodifiableSet()); + + private static Stream givenSupportedEntityType_whenOnMsg_thenVerifyEntityNotFoundExceptionThrown() { + return supportedEntityTypes.stream().filter(entityType -> !entityType.equals(EntityType.TENANT)).map(Arguments::of); + } + + private static final TenantId tenantId = new TenantId(UUID.fromString("6fc86fc9-b25c-4893-b340-51cf4e101ab2")); + private static final DeviceId deviceId = new DeviceId(UUID.fromString("191ab124-1d8d-4749-97c6-fc84c113c1f5")); + private static final AssetId assetId = new AssetId(UUID.fromString("a47a5867-deab-4333-b845-88cb1695990c")); + private static final CustomerId customerId = new CustomerId(UUID.fromString("4af69229-273d-40de-9fba-f49f87373d23")); + private static final EntityViewId entityViewId = new EntityViewId(UUID.fromString("d4c22c9c-07f5-474d-9d16-b63f0e71f914")); + private static final EdgeId edgeId = new EdgeId(UUID.fromString("7c653959-558d-4661-aac7-c1866eef286b")); + private static final DashboardId dashboardId = new DashboardId(UUID.fromString("6fcfbcb0-21e4-4b0b-a0d6-399ca6959cb2")); + + private static Stream givenSupportedEntityType_whenOnMsg_thenVerifyConditions() { + return Stream.of( + Arguments.of(new Device(deviceId)), + Arguments.of(new Asset(assetId)), + Arguments.of(new Customer(customerId)), + Arguments.of(new EntityView(entityViewId)), + Arguments.of(new Edge(edgeId)), + Arguments.of(new Dashboard(dashboardId)), + Arguments.of(new Tenant(tenantId)) + ); + } + private static Stream givenSupportedEntityTypeToCreateEntityIfNotExists_whenOnMsg_thenVerifyConditions() { + return Stream.of( + Arguments.of(new Device(deviceId)), + Arguments.of(new Asset(assetId)), + Arguments.of(new Customer(customerId)) + ); + } + + private final DeviceId originatorId = new DeviceId(UUID.fromString("860634b1-8a1e-4693-9ae8-e779c7f5f4da")); + private final RuleNodeId ruleNodeId = new RuleNodeId(UUID.fromString("d05a0491-ee7a-484a-8c1b-91111ef39287")); + + private final ListeningExecutor dbExecutor = new TestDbCallbackExecutor(); + + @Mock + private TbContext ctxMock; + @Mock + private AssetService assetServiceMock; + @Mock + private DeviceService deviceServiceMock; + @Mock + private EntityViewService entityViewServiceMock; @Mock - private TbContext ctx; + private CustomerService customerServiceMock; @Mock - private AssetService assetService; + private EdgeService edgeServiceMock; @Mock - private RelationService relationService; + private UserService userServiceMock; + @Mock + private DashboardService dashboardServiceMock; + @Mock + private TbClusterService clusterServiceMock; + @Mock + private RelationService relationServiceMock; - private TbMsg msg; + private TbCreateRelationNode node; + private TbCreateRelationNodeConfiguration config; - private RuleChainId ruleChainId = new RuleChainId(Uuids.timeBased()); - private RuleNodeId ruleNodeId = new RuleNodeId(Uuids.timeBased()); + @BeforeEach + public void setUp() throws TbNodeException { + node = spy(new TbCreateRelationNode()); + config = new TbCreateRelationNodeConfiguration().defaultConfiguration(); + } - private ListeningExecutor dbExecutor; + @Test + void givenDefaultConfig_whenVerify_thenOK() { + var defaultConfig = new TbCreateRelationNodeConfiguration().defaultConfiguration(); + assertThat(defaultConfig.getDirection()).isEqualTo(EntitySearchDirection.FROM); + assertThat(defaultConfig.getRelationType()).isEqualTo(EntityRelation.CONTAINS_TYPE); + assertThat(defaultConfig.getEntityNamePattern()).isEqualTo(""); + assertThat(defaultConfig.getEntityTypePattern()).isEqualTo(null); + assertThat(defaultConfig.getEntityType()).isEqualTo(null); + assertThat(defaultConfig.isCreateEntityIfNotExists()).isFalse(); + assertThat(defaultConfig.isRemoveCurrentRelations()).isFalse(); + assertThat(defaultConfig.isChangeOriginatorToRelatedEntity()).isFalse(); + } - @Before - public void before() { - dbExecutor = new TestDbCallbackExecutor(); + @ParameterizedTest + @EnumSource(EntityType.class) + void givenEntityType_whenInit_thenVerifyExceptionThrownIfTypeIsUnsupported(EntityType entityType) { + // GIVEN + config.setEntityType(entityType); + var nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + + // WHEN-THEN + if (unsupportedEntityTypes.contains(entityType)) { + assertThatThrownBy(() -> node.init(ctxMock, nodeConfiguration)) + .isInstanceOf(TbNodeException.class) + .hasMessage("Unsupported entity type '" + entityType + + "'! Only " + supportedEntityTypesStr + " types are allowed."); + } else { + assertThatCode(() -> node.init(ctxMock, nodeConfiguration)).doesNotThrowAnyException(); + } + verifyNoInteractions(ctxMock); } - @Test - public void testCreateNewRelation() throws TbNodeException { - init(createRelationNodeConfig()); + @ParameterizedTest + @MethodSource + void givenSupportedEntityType_whenOnMsg_thenVerifyEntityNotFoundExceptionThrown(EntityType entityType) throws TbNodeException { + // GIVEN + config.setEntityType(entityType); + config.setEntityNamePattern("${name}"); + config.setEntityTypePattern("${type}"); + + var nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + node.init(ctxMock, nodeConfiguration); + + when(ctxMock.getTenantId()).thenReturn(tenantId); + when(ctxMock.getDbCallbackExecutor()).thenReturn(dbExecutor); + + var mockMethodCallsMap = mockEntityServiceCallsEntityNotFound(); + mockMethodCallsMap.get(entityType).run(); + + var md = getMetadataWithNameTemplate(); + var msg = getTbMsg(originatorId, md); + + // todo fix TestDbCallbackExecutor exception handling. + switch (entityType) { + case CUSTOMER -> { + node.onMsg(ctxMock, msg); + ArgumentCaptor throwableCaptor = ArgumentCaptor.forClass(Throwable.class); + verify(ctxMock).tellFailure(eq(msg), throwableCaptor.capture()); + assertThat(throwableCaptor.getValue()) + .isInstanceOf(NoSuchElementException.class) + .hasMessage(EntityType.CUSTOMER.getNormalName() + " with title 'EntityName' doesn't exist!"); + } + case DEVICE, ASSET -> { + node.onMsg(ctxMock, msg); + ArgumentCaptor throwableCaptor = ArgumentCaptor.forClass(Throwable.class); + verify(ctxMock).tellFailure(eq(msg), throwableCaptor.capture()); + assertThat(throwableCaptor.getValue()) + .isInstanceOf(NoSuchElementException.class) + .hasMessage(entityType.getNormalName() + " with name 'EntityName' doesn't exist!"); + } + default -> assertThatThrownBy(() -> node.onMsg(ctxMock, msg)) + .isInstanceOf(RuntimeException.class).hasCauseInstanceOf(NoSuchElementException.class); + } + } - DeviceId deviceId = new DeviceId(Uuids.timeBased()); + @ParameterizedTest + @MethodSource("givenSupportedEntityType_whenOnMsg_thenVerifyConditions") + void givenSupportedEntityType_whenOnMsg_thenVerifyRelationCreatedAndOutMsgSuccess(HasId entity) throws TbNodeException { + // GIVEN + var entityId = (EntityId) entity.getId(); + var entityType = entityId.getEntityType(); - AssetId assetId = new AssetId(Uuids.timeBased()); - Asset asset = new Asset(); - asset.setId(assetId); + config.setEntityType(entityType); + config.setEntityNamePattern("${name}"); + config.setEntityTypePattern("${type}"); + config.setCreateEntityIfNotExists(false); + config.setChangeOriginatorToRelatedEntity(false); + config.setRemoveCurrentRelations(false); - when(assetService.findAssetByTenantIdAndName(any(), eq("AssetName"))).thenReturn(asset); - when(assetService.findAssetByIdAsync(any(), eq(assetId))).thenReturn(Futures.immediateFuture(asset)); + var nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + node.init(ctxMock, nodeConfiguration); - TbMsgMetaData metaData = new TbMsgMetaData(); - metaData.putValue("name", "AssetName"); - metaData.putValue("type", "AssetType"); - msg = TbMsg.newMsg(TbMsgType.ENTITY_CREATED, deviceId, metaData, TbMsgDataType.JSON, TbMsg.EMPTY_JSON_OBJECT, ruleChainId, ruleNodeId); + when(ctxMock.getTenantId()).thenReturn(tenantId); + when(ctxMock.getDbCallbackExecutor()).thenReturn(dbExecutor); + when(ctxMock.getRelationService()).thenReturn(relationServiceMock); - when(ctx.getRelationService().checkRelationAsync(any(), eq(assetId), eq(deviceId), eq(EntityRelation.CONTAINS_TYPE), eq(RelationTypeGroup.COMMON))) - .thenReturn(Futures.immediateFuture(false)); - when(ctx.getRelationService().saveRelationAsync(any(), eq(new EntityRelation(assetId, deviceId, EntityRelation.CONTAINS_TYPE, RelationTypeGroup.COMMON)))) - .thenReturn(Futures.immediateFuture(true)); + var mockMethodCallsMap = mockEntityServiceCallsCreateEntityIfNotExistsDisabled(); + mockMethodCallsMap.get(entityType).accept(entity); - node.onMsg(ctx, msg); - verify(ctx).tellNext(msg, TbNodeConnectionType.SUCCESS); + when(relationServiceMock.checkRelationAsync(any(), any(), any(), any(), any())).thenReturn(Futures.immediateFuture(false)); + when(relationServiceMock.saveRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true)); + + var md = getMetadataWithNameTemplate(); + var msg = getTbMsg(originatorId, md); + + // WHEN + node.onMsg(ctxMock, msg); + + // THEN + var verifyMethodCallsMap = verifyEntityServiceCallsCreateEntityIfNotExistsDisabled(); + verifyMethodCallsMap.get(entityType).run(); + + verify(relationServiceMock).checkRelationAsync(eq(tenantId), eq(originatorId), eq(entityId), eq(EntityRelation.CONTAINS_TYPE), eq(RelationTypeGroup.COMMON)); + verify(relationServiceMock).saveRelationAsync(eq(tenantId), eq(new EntityRelation(originatorId, entityId, EntityRelation.CONTAINS_TYPE, RelationTypeGroup.COMMON))); + + verify(ctxMock).tellSuccess(eq(msg)); + verify(ctxMock, never()).tellFailure(any(), any()); + verify(ctxMock).getDbCallbackExecutor(); + verifyNoMoreInteractions(ctxMock, relationServiceMock); } - @Test - public void testDeleteCurrentRelationsCreateNewRelation() throws TbNodeException { - init(createRelationNodeConfigWithRemoveCurrentRelations()); + @ParameterizedTest + @MethodSource("givenSupportedEntityType_whenOnMsg_thenVerifyConditions") + void givenSupportedEntityType_whenOnMsg_thenVerifyDeleteCurrentRelationCreateNewRelationAndOutMsgSuccess(HasId entity) throws TbNodeException { + // GIVEN + var entityId = (EntityId) entity.getId(); + var entityType = entityId.getEntityType(); + + config.setEntityType(entityType); + config.setEntityNamePattern("${name}"); + config.setEntityTypePattern("${type}"); + config.setCreateEntityIfNotExists(false); + config.setChangeOriginatorToRelatedEntity(false); + config.setRemoveCurrentRelations(true); + + var nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + node.init(ctxMock, nodeConfiguration); + + when(ctxMock.getTenantId()).thenReturn(tenantId); + when(ctxMock.getDbCallbackExecutor()).thenReturn(dbExecutor); + when(ctxMock.getRelationService()).thenReturn(relationServiceMock); + + var mockMethodCallsMap = mockEntityServiceCallsCreateEntityIfNotExistsDisabled(); + mockMethodCallsMap.get(entityType).accept(entity); + + var relationToDelete = new EntityRelation(); + when(relationServiceMock.findByFromAndTypeAsync(any(), any(), any(), any())).thenReturn(Futures.immediateFuture(List.of(relationToDelete))); + when(relationServiceMock.deleteRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true)); + when(relationServiceMock.checkRelationAsync(any(), any(), any(), any(), any())).thenReturn(Futures.immediateFuture(false)); + when(relationServiceMock.saveRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true)); + + var md = getMetadataWithNameTemplate(); + var msg = getTbMsg(originatorId, md); + + // WHEN + node.onMsg(ctxMock, msg); + + // THEN + var verifyMethodCallsMap = verifyEntityServiceCallsCreateEntityIfNotExistsDisabled(); + verifyMethodCallsMap.get(entityType).run(); + + verify(relationServiceMock).findByFromAndTypeAsync(eq(tenantId), eq(originatorId), eq(EntityRelation.CONTAINS_TYPE), eq(RelationTypeGroup.COMMON)); + verify(relationServiceMock).deleteRelationAsync(eq(tenantId), eq(relationToDelete)); + verify(relationServiceMock).checkRelationAsync(eq(tenantId), eq(originatorId), eq(entityId), eq(EntityRelation.CONTAINS_TYPE), eq(RelationTypeGroup.COMMON)); + verify(relationServiceMock).saveRelationAsync(eq(tenantId), eq(new EntityRelation(originatorId, entityId, EntityRelation.CONTAINS_TYPE, RelationTypeGroup.COMMON))); + + verify(ctxMock).tellSuccess(eq(msg)); + verify(ctxMock, never()).tellFailure(any(), any()); + verify(ctxMock).getDbCallbackExecutor(); + verifyNoMoreInteractions(ctxMock, relationServiceMock); + } + + @ParameterizedTest + @MethodSource("givenSupportedEntityType_whenOnMsg_thenVerifyConditions") + void givenSupportedEntityType_whenOnMsg_thenVerifyDeleteCurrentRelationsCreateNewRelationAndOutMsgSuccess(HasId entity) throws TbNodeException { + // GIVEN + var entityId = (EntityId) entity.getId(); + var entityType = entityId.getEntityType(); - DeviceId deviceId = new DeviceId(Uuids.timeBased()); + config.setEntityType(entityType); + config.setEntityNamePattern("${name}"); + config.setEntityTypePattern("${type}"); + config.setCreateEntityIfNotExists(false); + config.setChangeOriginatorToRelatedEntity(false); + config.setRemoveCurrentRelations(true); - AssetId assetId = new AssetId(Uuids.timeBased()); - Asset asset = new Asset(); - asset.setId(assetId); + var nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + node.init(ctxMock, nodeConfiguration); - when(assetService.findAssetByTenantIdAndName(any(), eq("AssetName"))).thenReturn(asset); - when(assetService.findAssetByIdAsync(any(), eq(assetId))).thenReturn(Futures.immediateFuture(asset)); + when(ctxMock.getTenantId()).thenReturn(tenantId); + when(ctxMock.getDbCallbackExecutor()).thenReturn(dbExecutor); + when(ctxMock.getRelationService()).thenReturn(relationServiceMock); - TbMsgMetaData metaData = new TbMsgMetaData(); - metaData.putValue("name", "AssetName"); - metaData.putValue("type", "AssetType"); - msg = TbMsg.newMsg(TbMsgType.ENTITY_CREATED, deviceId, metaData, TbMsgDataType.JSON, TbMsg.EMPTY_JSON_OBJECT, ruleChainId, ruleNodeId); + var mockMethodCallsMap = mockEntityServiceCallsCreateEntityIfNotExistsDisabled(); + mockMethodCallsMap.get(entityType).accept(entity); - EntityRelation relation = new EntityRelation(); - when(ctx.getRelationService().findByToAndTypeAsync(any(), eq(msg.getOriginator()), eq(EntityRelation.CONTAINS_TYPE), eq(RelationTypeGroup.COMMON))) - .thenReturn(Futures.immediateFuture(Collections.singletonList(relation))); - when(ctx.getRelationService().deleteRelationAsync(any(), eq(relation))).thenReturn(Futures.immediateFuture(true)); - when(ctx.getRelationService().checkRelationAsync(any(), eq(assetId), eq(deviceId), eq(EntityRelation.CONTAINS_TYPE), eq(RelationTypeGroup.COMMON))) - .thenReturn(Futures.immediateFuture(false)); - when(ctx.getRelationService().saveRelationAsync(any(), eq(new EntityRelation(assetId, deviceId, EntityRelation.CONTAINS_TYPE, RelationTypeGroup.COMMON)))) - .thenReturn(Futures.immediateFuture(true)); + var firstRelationToDelete = new EntityRelation(); + var secondRelationToDelete = new EntityRelation(); + var relationsToDelete = List.of(firstRelationToDelete, secondRelationToDelete); - node.onMsg(ctx, msg); - verify(ctx).tellNext(msg, TbNodeConnectionType.SUCCESS); + when(relationServiceMock.findByFromAndTypeAsync(any(), any(), any(), any())).thenReturn(Futures.immediateFuture(relationsToDelete)); + when(relationServiceMock.deleteRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true)); + when(relationServiceMock.checkRelationAsync(any(), any(), any(), any(), any())).thenReturn(Futures.immediateFuture(false)); + when(relationServiceMock.saveRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true)); + + var md = getMetadataWithNameTemplate(); + var msg = getTbMsg(originatorId, md); + + // WHEN + node.onMsg(ctxMock, msg); + + // THEN + var verifyMethodCallsMap = verifyEntityServiceCallsCreateEntityIfNotExistsDisabled(); + verifyMethodCallsMap.get(entityType).run(); + + verify(relationServiceMock).findByFromAndTypeAsync(eq(tenantId), eq(originatorId), eq(EntityRelation.CONTAINS_TYPE), eq(RelationTypeGroup.COMMON)); + + var entityRelationCaptor = ArgumentCaptor.forClass(EntityRelation.class); + + verify(relationServiceMock, times(2)).deleteRelationAsync(eq(tenantId), entityRelationCaptor.capture()); + assertThat(relationsToDelete).containsExactlyInAnyOrderElementsOf(relationsToDelete); + + verify(relationServiceMock).checkRelationAsync(eq(tenantId), eq(originatorId), eq(entityId), eq(EntityRelation.CONTAINS_TYPE), eq(RelationTypeGroup.COMMON)); + verify(relationServiceMock).saveRelationAsync(eq(tenantId), eq(new EntityRelation(originatorId, entityId, EntityRelation.CONTAINS_TYPE, RelationTypeGroup.COMMON))); + + verify(ctxMock).tellSuccess(eq(msg)); + verify(ctxMock, never()).tellFailure(any(), any()); + verify(ctxMock).getDbCallbackExecutor(); + verifyNoMoreInteractions(ctxMock, relationServiceMock); } - @Test - public void testCreateNewRelationAndChangeOriginator() throws TbNodeException { - init(createRelationNodeConfigWithChangeOriginator()); + @ParameterizedTest + @MethodSource("givenSupportedEntityType_whenOnMsg_thenVerifyConditions") + void givenSupportedEntityType_whenOnMsg_thenVerifyRelationCreatedAndOriginatorChanged(HasId entity) throws TbNodeException { + // GIVEN + var entityId = (EntityId) entity.getId(); + var entityType = entityId.getEntityType(); + + config.setEntityType(entityType); + config.setEntityNamePattern("${name}"); + config.setEntityTypePattern("${type}"); + config.setCreateEntityIfNotExists(false); + config.setChangeOriginatorToRelatedEntity(true); + config.setRemoveCurrentRelations(false); + + var nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + node.init(ctxMock, nodeConfiguration); + + when(ctxMock.getTenantId()).thenReturn(tenantId); + when(ctxMock.getDbCallbackExecutor()).thenReturn(dbExecutor); + when(ctxMock.getRelationService()).thenReturn(relationServiceMock); + + var mockMethodCallsMap = mockEntityServiceCallsCreateEntityIfNotExistsDisabled(); + mockMethodCallsMap.get(entityType).accept(entity); + + when(relationServiceMock.checkRelationAsync(any(), any(), any(), any(), any())).thenReturn(Futures.immediateFuture(false)); + when(relationServiceMock.saveRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true)); + + var md = getMetadataWithNameTemplate(); + var msg = getTbMsg(originatorId, md); + + var msgAfterOriginatorChanged = TbMsg.transformMsgOriginator(msg, originatorId); + when(ctxMock.transformMsgOriginator(any(), any())).thenReturn(msgAfterOriginatorChanged); + + // WHEN + node.onMsg(ctxMock, msg); - DeviceId deviceId = new DeviceId(Uuids.timeBased()); + // THEN + var verifyMethodCallsMap = verifyEntityServiceCallsCreateEntityIfNotExistsDisabled(); + verifyMethodCallsMap.get(entityType).run(); - AssetId assetId = new AssetId(Uuids.timeBased()); - Asset asset = new Asset(); - asset.setId(assetId); + verify(relationServiceMock).checkRelationAsync(eq(tenantId), eq(originatorId), eq(entityId), eq(EntityRelation.CONTAINS_TYPE), eq(RelationTypeGroup.COMMON)); + verify(relationServiceMock).saveRelationAsync(eq(tenantId), eq(new EntityRelation(originatorId, entityId, EntityRelation.CONTAINS_TYPE, RelationTypeGroup.COMMON))); - when(assetService.findAssetByTenantIdAndName(any(), eq("AssetName"))).thenReturn(asset); - when(assetService.findAssetByIdAsync(any(), eq(assetId))).thenReturn(Futures.immediateFuture(asset)); + verify(ctxMock).transformMsgOriginator(eq(msg), eq(entityId)); + verify(ctxMock).tellSuccess(eq(msgAfterOriginatorChanged)); + verify(ctxMock, never()).tellFailure(any(), any()); + verify(ctxMock).getDbCallbackExecutor(); + verifyNoMoreInteractions(ctxMock, relationServiceMock); + } + + @ParameterizedTest + @MethodSource("givenSupportedEntityTypeToCreateEntityIfNotExists_whenOnMsg_thenVerifyConditions") + void givenSupportedEntityType_whenOnMsg_thenVerifyRelationAndEntityCreatedAndOutMsgSuccess(HasId entity) throws TbNodeException { + // GIVEN + var entityId = (EntityId) entity.getId(); + var entityType = entityId.getEntityType(); + + config.setEntityType(entityType); + config.setEntityNamePattern("${name}"); + config.setEntityTypePattern("${type}"); + config.setCreateEntityIfNotExists(true); + config.setChangeOriginatorToRelatedEntity(false); + config.setRemoveCurrentRelations(false); + + var nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + node.init(ctxMock, nodeConfiguration); + + when(ctxMock.getTenantId()).thenReturn(tenantId); + when(ctxMock.getSelfId()).thenReturn(ruleNodeId); + when(ctxMock.getDbCallbackExecutor()).thenReturn(dbExecutor); + when(ctxMock.getRelationService()).thenReturn(relationServiceMock); - TbMsgMetaData metaData = new TbMsgMetaData(); - metaData.putValue("name", "AssetName"); - metaData.putValue("type", "AssetType"); - msg = TbMsg.newMsg(TbMsgType.ENTITY_CREATED, deviceId, metaData, TbMsgDataType.JSON, TbMsg.EMPTY_JSON_OBJECT, ruleChainId, ruleNodeId); + var mockMethodCallsMap = mockEntityServiceCallsCreateEntityIfNotExistsEnabled(); + var entityCreatedMsg = TbMsg.newMsg(TbMsgType.ENTITY_CREATED, entityId, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); + mockMethodCallsMap.get(entityType).accept(entity, entityCreatedMsg); - when(ctx.getRelationService().checkRelationAsync(any(), eq(assetId), eq(deviceId), eq(EntityRelation.CONTAINS_TYPE), eq(RelationTypeGroup.COMMON))) - .thenReturn(Futures.immediateFuture(false)); - when(ctx.getRelationService().saveRelationAsync(any(), eq(new EntityRelation(assetId, deviceId, EntityRelation.CONTAINS_TYPE, RelationTypeGroup.COMMON)))) - .thenReturn(Futures.immediateFuture(true)); + when(relationServiceMock.checkRelationAsync(any(), any(), any(), any(), any())).thenReturn(Futures.immediateFuture(false)); + when(relationServiceMock.saveRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true)); - node.onMsg(ctx, msg); - ArgumentCaptor msgCaptor = ArgumentCaptor.forClass(TbMsg.class); - ArgumentCaptor originatorCaptor = ArgumentCaptor.forClass(EntityId.class); - verify(ctx).transformMsgOriginator(msgCaptor.capture(), originatorCaptor.capture()); + var md = getMetadataWithNameTemplate(); + var msg = getTbMsg(originatorId, md); - assertEquals(assetId, originatorCaptor.getValue()); + // WHEN + node.onMsg(ctxMock, msg); + + // THEN + var verifyMethodCallsMap = verifyEntityServiceCallsCreateEntityIfNotExistsEnabled(); + verifyMethodCallsMap.get(entityType).accept(entity, entityCreatedMsg); + + verify(relationServiceMock).checkRelationAsync(eq(tenantId), eq(originatorId), eq(entityId), eq(EntityRelation.CONTAINS_TYPE), eq(RelationTypeGroup.COMMON)); + verify(relationServiceMock).saveRelationAsync(eq(tenantId), eq(new EntityRelation(originatorId, entityId, EntityRelation.CONTAINS_TYPE, RelationTypeGroup.COMMON))); + + verify(ctxMock).tellSuccess(eq(msg)); + verify(ctxMock, never()).tellFailure(any(), any()); + verify(ctxMock).getDbCallbackExecutor(); + verifyNoMoreInteractions(ctxMock, relationServiceMock); } - public void init(TbCreateRelationNodeConfiguration configuration) throws TbNodeException { - TbNodeConfiguration nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(configuration)); + private Map> mockEntityServiceCallsCreateEntityIfNotExistsDisabled() { + return Map.of( + EntityType.DEVICE, hasId -> { + var device = (Device) hasId; + when(ctxMock.getDeviceService()).thenReturn(deviceServiceMock); + when(deviceServiceMock.findDeviceByTenantIdAndName(any(), any())).thenReturn(device); + }, + EntityType.ASSET, hasId -> { + var asset = (Asset) hasId; + when(ctxMock.getAssetService()).thenReturn(assetServiceMock); + when(assetServiceMock.findAssetByTenantIdAndName(any(), any())).thenReturn(asset); + }, + EntityType.CUSTOMER, hasId -> { + var customer = (Customer) hasId; + when(ctxMock.getCustomerService()).thenReturn(customerServiceMock); + when(customerServiceMock.findCustomerByTenantIdAndTitle(any(), any())).thenReturn(Optional.ofNullable(customer)); + }, + EntityType.ENTITY_VIEW, hasId -> { + var entityView = (EntityView) hasId; + when(ctxMock.getEntityViewService()).thenReturn(entityViewServiceMock); + when(entityViewServiceMock.findEntityViewByTenantIdAndName(any(), any())).thenReturn(entityView); + }, + EntityType.EDGE, hasId -> { + var edge = (Edge) hasId; + when(ctxMock.getEdgeService()).thenReturn(edgeServiceMock); + when(edgeServiceMock.findEdgeByTenantIdAndName(any(), any())).thenReturn(edge); + }, + EntityType.USER, hasId -> { + var user = (User) hasId; + when(ctxMock.getUserService()).thenReturn(userServiceMock); + when(userServiceMock.findUserByTenantIdAndEmail(any(), any())).thenReturn(user); + }, + EntityType.DASHBOARD, hasId -> { + var dashboard = (Dashboard) hasId; + when(ctxMock.getDashboardService()).thenReturn(dashboardServiceMock); + when(dashboardServiceMock.findFirstDashboardInfoByTenantIdAndName(any(), any())).thenReturn(dashboard); + }, + EntityType.TENANT, hasId -> { + } + ); + } - when(ctx.getDbCallbackExecutor()).thenReturn(dbExecutor); - when(ctx.getRelationService()).thenReturn(relationService); - when(ctx.getAssetService()).thenReturn(assetService); + private Map verifyEntityServiceCallsCreateEntityIfNotExistsDisabled() { + return Map.of( + EntityType.DEVICE, () -> { + verify(deviceServiceMock).findDeviceByTenantIdAndName(eq(tenantId), eq("EntityName")); + verifyNoMoreInteractions(deviceServiceMock); + }, + EntityType.ASSET, () -> { + verify(assetServiceMock).findAssetByTenantIdAndName(eq(tenantId), eq("EntityName")); + verifyNoMoreInteractions(assetServiceMock); + }, + EntityType.CUSTOMER, () -> { + verify(customerServiceMock).findCustomerByTenantIdAndTitle(eq(tenantId), eq("EntityName")); + verifyNoMoreInteractions(customerServiceMock); + }, + EntityType.ENTITY_VIEW, () -> { + verify(entityViewServiceMock).findEntityViewByTenantIdAndName(eq(tenantId), eq("EntityName")); + verifyNoMoreInteractions(entityViewServiceMock); + }, + EntityType.EDGE, () -> { + verify(edgeServiceMock).findEdgeByTenantIdAndName(eq(tenantId), eq("EntityName")); + verifyNoMoreInteractions(edgeServiceMock); + }, + EntityType.USER, () -> { + verify(userServiceMock).findUserByTenantIdAndEmail(eq(tenantId), eq("EntityName")); + verifyNoMoreInteractions(userServiceMock); + }, + EntityType.DASHBOARD, () -> { + verify(dashboardServiceMock).findFirstDashboardInfoByTenantIdAndName(eq(tenantId), eq("EntityName")); + verifyNoMoreInteractions(dashboardServiceMock); + }, + EntityType.TENANT, () -> { + } + ); + } + + private Map> mockEntityServiceCallsCreateEntityIfNotExistsEnabled() { + return Map.of( + EntityType.DEVICE, (hasId, entityCreatedMsg) -> { + var device = (Device) hasId; + when(ctxMock.getDeviceService()).thenReturn(deviceServiceMock); + when(ctxMock.getClusterService()).thenReturn(clusterServiceMock); + when(deviceServiceMock.findDeviceByTenantIdAndName(any(), any())).thenReturn(null); + when(deviceServiceMock.saveDevice(any())).thenReturn(device); + doAnswer(invocation -> entityCreatedMsg).when(ctxMock).deviceCreatedMsg(any(), any()); + }, + EntityType.ASSET, (hasId, entityCreatedMsg) -> { + var asset = (Asset) hasId; + when(ctxMock.getAssetService()).thenReturn(assetServiceMock); + when(assetServiceMock.findAssetByTenantIdAndName(any(), any())).thenReturn(null); + when(assetServiceMock.saveAsset(any())).thenReturn(asset); + doAnswer(invocation -> entityCreatedMsg).when(ctxMock).assetCreatedMsg(any(), any()); + }, + EntityType.CUSTOMER, (hasId, entityCreatedMsg) -> { + var customer = (Customer) hasId; + when(ctxMock.getCustomerService()).thenReturn(customerServiceMock); + when(customerServiceMock.findCustomerByTenantIdAndTitle(any(), any())).thenReturn(Optional.empty()); + when(customerServiceMock.saveCustomer(any())).thenReturn(customer); + doAnswer(invocation -> entityCreatedMsg).when(ctxMock).customerCreatedMsg(any(), any()); + } + ); + } + + private Map> verifyEntityServiceCallsCreateEntityIfNotExistsEnabled() { + return Map.of( + EntityType.DEVICE, (hasId, entityCreatedMsg) -> { + var device = (Device) hasId; + verify(deviceServiceMock, times(2)).findDeviceByTenantIdAndName(eq(tenantId), eq("EntityName")); + verify(deviceServiceMock).saveDevice(any()); + verify(clusterServiceMock).onDeviceUpdated(eq(device), eq(null)); + verify(ctxMock).enqueue(eq(entityCreatedMsg), any(), any()); + verifyNoMoreInteractions(deviceServiceMock, clusterServiceMock); + }, + EntityType.ASSET, (hasId, entityCreatedMsg) -> { + verify(assetServiceMock, times(2)).findAssetByTenantIdAndName(eq(tenantId), eq("EntityName")); + verify(assetServiceMock).saveAsset(any()); + verify(ctxMock).enqueue(eq(entityCreatedMsg), any(), any()); + verifyNoMoreInteractions(assetServiceMock); + }, + EntityType.CUSTOMER, (hasId, entityCreatedMsg) -> { + verify(customerServiceMock, times(2)).findCustomerByTenantIdAndTitle(eq(tenantId), eq("EntityName")); + verify(customerServiceMock).saveCustomer(any()); + verify(ctxMock).enqueue(eq(entityCreatedMsg), any(), any()); + verifyNoMoreInteractions(customerServiceMock); + } + ); + } + + private Map mockEntityServiceCallsEntityNotFound() { + return Map.of( + EntityType.DEVICE, () -> { + when(ctxMock.getDeviceService()).thenReturn(deviceServiceMock); + when(deviceServiceMock.findDeviceByTenantIdAndName(any(), any())).thenReturn(null); + }, + EntityType.ASSET, () -> { + when(ctxMock.getAssetService()).thenReturn(assetServiceMock); + when(assetServiceMock.findAssetByTenantIdAndName(any(), any())).thenReturn(null); + }, + EntityType.CUSTOMER, () -> { + when(ctxMock.getCustomerService()).thenReturn(customerServiceMock); + when(customerServiceMock.findCustomerByTenantIdAndTitle(any(), any())).thenReturn(Optional.empty()); + }, + EntityType.ENTITY_VIEW, () -> { + when(ctxMock.getEntityViewService()).thenReturn(entityViewServiceMock); + when(entityViewServiceMock.findEntityViewByTenantIdAndName(any(), any())).thenReturn(null); + }, + EntityType.EDGE, () -> { + when(ctxMock.getEdgeService()).thenReturn(edgeServiceMock); + when(edgeServiceMock.findEdgeByTenantIdAndName(any(), any())).thenReturn(null); + }, + EntityType.USER, () -> { + when(ctxMock.getUserService()).thenReturn(userServiceMock); + when(userServiceMock.findUserByTenantIdAndEmail(any(), any())).thenReturn(null); + }, + EntityType.DASHBOARD, () -> { + when(ctxMock.getDashboardService()).thenReturn(dashboardServiceMock); + when(dashboardServiceMock.findFirstDashboardInfoByTenantIdAndName(any(), any())).thenReturn(null); + } + ); + } - node = new TbCreateRelationNode(); - node.init(ctx, nodeConfiguration); + private TbMsg getTbMsg(EntityId originator, TbMsgMetaData metaData) { + return TbMsg.newMsg(TbMsgType.NA, originator, metaData, TbMsg.EMPTY_JSON_OBJECT); } - private TbCreateRelationNodeConfiguration createRelationNodeConfig() { - TbCreateRelationNodeConfiguration configuration = new TbCreateRelationNodeConfiguration(); - configuration.setDirection(EntitySearchDirection.FROM.name()); - configuration.setRelationType(EntityRelation.CONTAINS_TYPE); - configuration.setEntityCacheExpiration(300); - configuration.setEntityType(EntityType.ASSET.name()); - configuration.setEntityNamePattern("${name}"); - configuration.setEntityTypePattern("${type}"); - configuration.setCreateEntityIfNotExists(false); - configuration.setChangeOriginatorToRelatedEntity(false); - configuration.setRemoveCurrentRelations(false); - return configuration; + private TbMsgMetaData getMetadataWithNameTemplate() { + var metaData = new TbMsgMetaData(); + metaData.putValue("name", "EntityName"); + return metaData; } - private TbCreateRelationNodeConfiguration createRelationNodeConfigWithRemoveCurrentRelations() { - TbCreateRelationNodeConfiguration configuration = createRelationNodeConfig(); - configuration.setRemoveCurrentRelations(true); - return configuration; + @Override + protected TbNode getTestNode() { + return node; } - private TbCreateRelationNodeConfiguration createRelationNodeConfigWithChangeOriginator() { - TbCreateRelationNodeConfiguration configuration = createRelationNodeConfig(); - configuration.setChangeOriginatorToRelatedEntity(true); - return configuration; + // Rule nodes upgrade + private static Stream givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() { + return Stream.of( + // version 0 config, FROM direction. + Arguments.of(0, + "{\"direction\":\"FROM\",\"entityType\":\"DEVICE\",\"entityNamePattern\":\"$[name]\"," + + "\"entityTypePattern\":\"$[type]\",\"relationType\":\"Contains\"," + + "\"createEntityIfNotExists\":false,\"removeCurrentRelations\":false," + + "\"changeOriginatorToRelatedEntity\":false,\"entityCacheExpiration\":300}", + true, + "{\"direction\":\"TO\",\"entityType\":\"DEVICE\",\"entityNamePattern\":\"$[name]\"," + + "\"entityTypePattern\":\"$[type]\",\"relationType\":\"Contains\"," + + "\"createEntityIfNotExists\":false,\"removeCurrentRelations\":false," + + "\"changeOriginatorToRelatedEntity\":false}"), + // version 0 config, TO direction. + Arguments.of(0, + "{\"direction\":\"TO\",\"entityType\":\"DEVICE\",\"entityNamePattern\":\"$[name]\"," + + "\"entityTypePattern\":\"$[type]\",\"relationType\":\"Contains\"," + + "\"createEntityIfNotExists\":false,\"removeCurrentRelations\":false," + + "\"changeOriginatorToRelatedEntity\":false,\"entityCacheExpiration\":300}", + true, + "{\"direction\":\"FROM\",\"entityType\":\"DEVICE\",\"entityNamePattern\":\"$[name]\"," + + "\"entityTypePattern\":\"$[type]\",\"relationType\":\"Contains\"," + + "\"createEntityIfNotExists\":false,\"removeCurrentRelations\":false," + + "\"changeOriginatorToRelatedEntity\":false}"), + // config for version 1 with upgrade from version 0 + Arguments.of(0, + "{\"direction\":\"FROM\",\"entityType\":\"DEVICE\",\"entityNamePattern\":\"$[name]\"," + + "\"entityTypePattern\":\"$[type]\",\"relationType\":\"Contains\"," + + "\"createEntityIfNotExists\":false,\"removeCurrentRelations\":false," + + "\"changeOriginatorToRelatedEntity\":false}", + false, + "{\"direction\":\"FROM\",\"entityType\":\"DEVICE\",\"entityNamePattern\":\"$[name]\"," + + "\"entityTypePattern\":\"$[type]\",\"relationType\":\"Contains\"," + + "\"createEntityIfNotExists\":false,\"removeCurrentRelations\":false," + + "\"changeOriginatorToRelatedEntity\":false}") + ); } + } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbDeleteRelationNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbDeleteRelationNodeTest.java new file mode 100644 index 0000000000..e8919af30f --- /dev/null +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbDeleteRelationNodeTest.java @@ -0,0 +1,604 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.rule.engine.action; + +import com.google.common.util.concurrent.Futures; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.ListeningExecutor; +import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest; +import org.thingsboard.rule.engine.TestDbCallbackExecutor; +import org.thingsboard.rule.engine.api.TbContext; +import org.thingsboard.rule.engine.api.TbNode; +import org.thingsboard.rule.engine.api.TbNodeConfiguration; +import org.thingsboard.rule.engine.api.TbNodeException; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.HasId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationTypeGroup; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.dao.asset.AssetService; +import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.dao.dashboard.DashboardService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.edge.EdgeService; +import org.thingsboard.server.dao.entityview.EntityViewService; +import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.dao.user.UserService; + +import java.util.Arrays; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class TbDeleteRelationNodeTest extends AbstractRuleNodeUpgradeTest { + + private static final Set supportedEntityTypes = EnumSet.of(EntityType.TENANT, EntityType.DEVICE, + EntityType.ASSET, EntityType.CUSTOMER, EntityType.ENTITY_VIEW, EntityType.DASHBOARD, EntityType.EDGE, EntityType.USER); + + private static final String supportedEntityTypesStr = supportedEntityTypes.stream().map(Enum::name).collect(Collectors.joining(" ,")); + + private static final Set unsupportedEntityTypes = Arrays.stream(EntityType.values()) + .filter(type -> !supportedEntityTypes.contains(type)).collect(Collectors.toUnmodifiableSet()); + + private static Stream givenSupportedEntityType_whenOnMsg_thenVerifyEntityNotFoundExceptionThrown() { + return supportedEntityTypes.stream().filter(entityType -> !entityType.equals(EntityType.TENANT)).map(Arguments::of); + } + + private static final TenantId tenantId = new TenantId(UUID.fromString("6fdb457d-0910-401c-8880-abc251e6a1e2")); + private static final DeviceId deviceId = new DeviceId(UUID.fromString("4eef91a7-8865-4c3c-837d-ed6f6577508b")); + private static final AssetId assetId = new AssetId(UUID.fromString("f4fd3b10-3f36-4d46-a162-5e62050774cc")); + private static final CustomerId customerId = new CustomerId(UUID.fromString("ab890af2-3622-41e0-ac94-14d50af84348")); + private static final EntityViewId entityViewId = new EntityViewId(UUID.fromString("39ce8d03-52a3-4aa8-b561-267d1d9d68b5")); + private static final EdgeId edgeId = new EdgeId(UUID.fromString("dc4f9809-b6f9-48f9-8057-2737216cfdf7")); + private static final DashboardId dashboardId = new DashboardId(UUID.fromString("fda72baa-c882-4723-9693-25995dc37bc5")); + + private static Stream givenSupportedEntityType_whenOnMsg_thenVerifyConditions() { + return Stream.of( + Arguments.of(new Device(deviceId)), + Arguments.of(new Asset(assetId)), + Arguments.of(new Customer(customerId)), + Arguments.of(new EntityView(entityViewId)), + Arguments.of(new Edge(edgeId)), + Arguments.of(new Dashboard(dashboardId)), + Arguments.of(new Tenant(tenantId)) + ); + } + + private final DeviceId originatorId = new DeviceId(UUID.fromString("574c9840-0885-4d12-be69-f557d7471a78")); + + private final ListeningExecutor dbExecutor = new TestDbCallbackExecutor(); + + @Mock + private TbContext ctxMock; + @Mock + private AssetService assetServiceMock; + @Mock + private DeviceService deviceServiceMock; + @Mock + private EntityViewService entityViewServiceMock; + @Mock + private CustomerService customerServiceMock; + @Mock + private EdgeService edgeServiceMock; + @Mock + private UserService userServiceMock; + @Mock + private DashboardService dashboardServiceMock; + @Mock + private RelationService relationServiceMock; + + + private TbDeleteRelationNode node; + private TbDeleteRelationNodeConfiguration config; + + @BeforeEach + public void setUp() throws TbNodeException { + node = spy(new TbDeleteRelationNode()); + config = new TbDeleteRelationNodeConfiguration().defaultConfiguration(); + } + + @Test + void givenDefaultConfig_whenVerify_thenOK() { + var defaultConfig = new TbDeleteRelationNodeConfiguration().defaultConfiguration(); + assertThat(defaultConfig.getDirection()).isEqualTo(EntitySearchDirection.FROM); + assertThat(defaultConfig.getRelationType()).isEqualTo(EntityRelation.CONTAINS_TYPE); + assertThat(defaultConfig.getEntityNamePattern()).isEqualTo(""); + assertThat(defaultConfig.getEntityTypePattern()).isEqualTo(null); + assertThat(defaultConfig.getEntityType()).isEqualTo(null); + assertThat(defaultConfig.isDeleteForSingleEntity()).isFalse(); + } + + @ParameterizedTest + @EnumSource(EntityType.class) + void givenEntityType_whenInit_thenVerifyExceptionThrownIfTypeIsUnsupported(EntityType entityType) { + // GIVEN + config.setEntityType(entityType); + config.setDeleteForSingleEntity(true); + var nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + + // WHEN-THEN + if (unsupportedEntityTypes.contains(entityType)) { + assertThatThrownBy(() -> node.init(ctxMock, nodeConfiguration)) + .isInstanceOf(TbNodeException.class) + .hasMessage("Unsupported entity type '" + entityType + + "'! Only " + supportedEntityTypesStr + " types are allowed."); + } else { + assertThatCode(() -> node.init(ctxMock, nodeConfiguration)).doesNotThrowAnyException(); + } + verifyNoInteractions(ctxMock); + } + + @ParameterizedTest + @MethodSource("givenSupportedEntityType_whenOnMsg_thenVerifyEntityNotFoundExceptionThrown") + void givenSupportedEntityType_whenOnMsgAndDeleteForSingleEntityIsTrue_thenVerifyEntityNotFoundExceptionThrown(EntityType entityType) throws TbNodeException { + // GIVEN + config.setEntityType(entityType); + config.setEntityNamePattern("${name}"); + config.setEntityTypePattern("${type}"); + config.setDeleteForSingleEntity(true); + + var nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + node.init(ctxMock, nodeConfiguration); + + when(ctxMock.getTenantId()).thenReturn(tenantId); + when(ctxMock.getDbCallbackExecutor()).thenReturn(dbExecutor); + + var mockMethodCallsMap = mockEntityServiceCallsEntityNotFound(); + mockMethodCallsMap.get(entityType).run(); + + var md = getMetadataWithNameTemplate(); + var msg = getTbMsg(originatorId, md); + + // todo fix TestDbCallbackExecutor exception handling. + switch (entityType) { + case CUSTOMER -> { + node.onMsg(ctxMock, msg); + ArgumentCaptor throwableCaptor = ArgumentCaptor.forClass(Throwable.class); + verify(ctxMock).tellFailure(eq(msg), throwableCaptor.capture()); + assertThat(throwableCaptor.getValue()) + .isInstanceOf(NoSuchElementException.class) + .hasMessage(EntityType.CUSTOMER.getNormalName() + " with title 'EntityName' doesn't exist!"); + } + case DEVICE, ASSET -> { + node.onMsg(ctxMock, msg); + ArgumentCaptor throwableCaptor = ArgumentCaptor.forClass(Throwable.class); + verify(ctxMock).tellFailure(eq(msg), throwableCaptor.capture()); + assertThat(throwableCaptor.getValue()) + .isInstanceOf(NoSuchElementException.class) + .hasMessage(entityType.getNormalName() + " with name 'EntityName' doesn't exist!"); + } + default -> assertThatThrownBy(() -> node.onMsg(ctxMock, msg)) + .isInstanceOf(RuntimeException.class).hasCauseInstanceOf(NoSuchElementException.class); + } + } + + @ParameterizedTest + @MethodSource("givenSupportedEntityType_whenOnMsg_thenVerifyConditions") + void givenSupportedEntityType_whenOnMsgAndDeleteForSingleEntityIsTrue_thenVerifyRelationDeletedAndOutMsgSuccess(HasId entity) throws TbNodeException { + // GIVEN + var entityId = (EntityId) entity.getId(); + var entityType = entityId.getEntityType(); + + config.setEntityType(entityType); + config.setEntityNamePattern("${name}"); + config.setEntityTypePattern("${type}"); + config.setDeleteForSingleEntity(true); + + var nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + node.init(ctxMock, nodeConfiguration); + + when(ctxMock.getTenantId()).thenReturn(tenantId); + when(ctxMock.getDbCallbackExecutor()).thenReturn(dbExecutor); + when(ctxMock.getRelationService()).thenReturn(relationServiceMock); + + var mockMethodCallsMap = mockEntityServiceCalls(); + mockMethodCallsMap.get(entityType).accept(entity); + + when(relationServiceMock.checkRelationAsync(any(), any(), any(), any(), any())).thenReturn(Futures.immediateFuture(true)); + when(relationServiceMock.deleteRelationAsync(any(), any(), any(), any(), any())).thenReturn(Futures.immediateFuture(true)); + + var md = getMetadataWithNameTemplate(); + var msg = getTbMsg(originatorId, md); + + // WHEN + node.onMsg(ctxMock, msg); + + // THEN + var verifyMethodCallsMap = verifyEntityServiceCalls(); + verifyMethodCallsMap.get(entityType).accept(entity); + + verify(relationServiceMock).checkRelationAsync(eq(tenantId), eq(originatorId), eq(entityId), eq(EntityRelation.CONTAINS_TYPE), eq(RelationTypeGroup.COMMON)); + verify(relationServiceMock).deleteRelationAsync(eq(tenantId), eq(originatorId), eq(entityId), eq(EntityRelation.CONTAINS_TYPE), eq(RelationTypeGroup.COMMON)); + + verify(ctxMock).tellSuccess(eq(msg)); + verify(ctxMock, never()).tellNext(any(), anyString()); + verify(ctxMock, never()).tellNext(any(), anySet()); + verify(ctxMock, never()).tellFailure(any(), any()); + verify(ctxMock).getDbCallbackExecutor(); + verifyNoMoreInteractions(ctxMock, relationServiceMock); + } + + @ParameterizedTest + @MethodSource("givenSupportedEntityType_whenOnMsg_thenVerifyConditions") + void givenSupportedEntityType_whenOnMsgAndDeleteForSingleEntityIsTrue_thenVerifyRelationFailedToDeleteAndOutMsgFailure(HasId entity) throws TbNodeException { + // GIVEN + var entityId = (EntityId) entity.getId(); + var entityType = entityId.getEntityType(); + + config.setEntityType(entityType); + config.setEntityNamePattern("${name}"); + config.setEntityTypePattern("${type}"); + config.setDeleteForSingleEntity(true); + + var nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + node.init(ctxMock, nodeConfiguration); + + when(ctxMock.getTenantId()).thenReturn(tenantId); + when(ctxMock.getDbCallbackExecutor()).thenReturn(dbExecutor); + when(ctxMock.getRelationService()).thenReturn(relationServiceMock); + + var mockMethodCallsMap = mockEntityServiceCalls(); + mockMethodCallsMap.get(entityType).accept(entity); + + when(relationServiceMock.checkRelationAsync(any(), any(), any(), any(), any())).thenReturn(Futures.immediateFuture(true)); + when(relationServiceMock.deleteRelationAsync(any(), any(), any(), any(), any())).thenReturn(Futures.immediateFuture(false)); + + var md = getMetadataWithNameTemplate(); + var msg = getTbMsg(originatorId, md); + + // WHEN + node.onMsg(ctxMock, msg); + + // THEN + var verifyMethodCallsMap = verifyEntityServiceCalls(); + verifyMethodCallsMap.get(entityType).accept(entity); + + verify(relationServiceMock).checkRelationAsync(eq(tenantId), eq(originatorId), eq(entityId), eq(EntityRelation.CONTAINS_TYPE), eq(RelationTypeGroup.COMMON)); + verify(relationServiceMock).deleteRelationAsync(eq(tenantId), eq(originatorId), eq(entityId), eq(EntityRelation.CONTAINS_TYPE), eq(RelationTypeGroup.COMMON)); + + var throwableCaptor = ArgumentCaptor.forClass(Throwable.class); + verify(ctxMock).tellFailure(eq(msg), throwableCaptor.capture()); + verify(ctxMock, never()).tellNext(any(), anyString()); + verify(ctxMock, never()).tellNext(any(), anySet()); + verify(ctxMock, never()).tellSuccess(any()); + verify(ctxMock).getDbCallbackExecutor(); + verifyNoMoreInteractions(ctxMock, relationServiceMock); + assertThat(throwableCaptor.getValue()).isInstanceOf(RuntimeException.class).hasMessage("Failed to delete relation(s) with originator!"); + } + + @ParameterizedTest + @MethodSource("givenSupportedEntityType_whenOnMsg_thenVerifyConditions") + void givenSupportedEntityType_whenOnMsgAndDeleteForSingleEntityIsTrue_thenVerifyRelationNotFoundAndOutMsgSuccess(HasId entity) throws TbNodeException { + // GIVEN + var entityId = (EntityId) entity.getId(); + var entityType = entityId.getEntityType(); + + config.setEntityType(entityType); + config.setEntityNamePattern("${name}"); + config.setEntityTypePattern("${type}"); + config.setDeleteForSingleEntity(true); + + var nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + node.init(ctxMock, nodeConfiguration); + + when(ctxMock.getTenantId()).thenReturn(tenantId); + when(ctxMock.getDbCallbackExecutor()).thenReturn(dbExecutor); + when(ctxMock.getRelationService()).thenReturn(relationServiceMock); + + var mockMethodCallsMap = mockEntityServiceCalls(); + mockMethodCallsMap.get(entityType).accept(entity); + + when(relationServiceMock.checkRelationAsync(any(), any(), any(), any(), any())).thenReturn(Futures.immediateFuture(false)); + + var md = getMetadataWithNameTemplate(); + var msg = getTbMsg(originatorId, md); + + // WHEN + node.onMsg(ctxMock, msg); + + // THEN + var verifyMethodCallsMap = verifyEntityServiceCalls(); + verifyMethodCallsMap.get(entityType).accept(entity); + + verify(relationServiceMock).checkRelationAsync(eq(tenantId), eq(originatorId), eq(entityId), eq(EntityRelation.CONTAINS_TYPE), eq(RelationTypeGroup.COMMON)); + + verify(ctxMock).tellSuccess(eq(msg)); + verify(ctxMock, never()).tellNext(any(), anyString()); + verify(ctxMock, never()).tellNext(any(), anySet()); + verify(ctxMock, never()).tellFailure(any(), any()); + verify(ctxMock).getDbCallbackExecutor(); + verifyNoMoreInteractions(ctxMock, relationServiceMock); + } + + @Test + void givenSupportedEntityType_whenOnMsgAndDeleteForSingleEntityIsFalse_thenVerifyRelationsDeletedAndOutMsgSuccess() throws TbNodeException { + // GIVEN + + config.setEntityType(EntityType.DEVICE); + config.setEntityNamePattern("${name}"); + config.setEntityTypePattern("${type}"); + + var nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + node.init(ctxMock, nodeConfiguration); + + when(ctxMock.getTenantId()).thenReturn(tenantId); + when(ctxMock.getRelationService()).thenReturn(relationServiceMock); + when(ctxMock.getDbCallbackExecutor()).thenReturn(dbExecutor); + + var relationToDelete = new EntityRelation(); + when(relationServiceMock.findByFromAndTypeAsync(any(), any(), any(), any())).thenReturn(Futures.immediateFuture(List.of(relationToDelete))); + when(relationServiceMock.deleteRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true)); + + var md = getMetadataWithNameTemplate(); + var msg = getTbMsg(originatorId, md); + + // WHEN + node.onMsg(ctxMock, msg); + + verify(relationServiceMock).findByFromAndTypeAsync(eq(tenantId), eq(originatorId), eq(EntityRelation.CONTAINS_TYPE), eq(RelationTypeGroup.COMMON)); + verify(relationServiceMock).deleteRelationAsync(eq(tenantId), eq(relationToDelete)); + + verify(ctxMock).tellSuccess(eq(msg)); + verify(ctxMock, never()).tellNext(any(), anyString()); + verify(ctxMock, never()).tellNext(any(), anySet()); + verify(ctxMock, never()).tellFailure(any(), any()); + verifyNoMoreInteractions(ctxMock, relationServiceMock); + } + + + @Test + void givenSupportedEntityType_whenOnMsgAndDeleteForSingleEntityIsFalse_thenVerifyRelationFailedToDeleteAndOutMsgFailure() throws TbNodeException { + // GIVEN + config.setEntityType(EntityType.DEVICE); + config.setEntityNamePattern("${name}"); + config.setEntityTypePattern("${type}"); + + var nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + node.init(ctxMock, nodeConfiguration); + + when(ctxMock.getTenantId()).thenReturn(tenantId); + when(ctxMock.getRelationService()).thenReturn(relationServiceMock); + when(ctxMock.getDbCallbackExecutor()).thenReturn(dbExecutor); + + var relationToDelete = new EntityRelation(); + when(relationServiceMock.findByFromAndTypeAsync(any(), any(), any(), any())).thenReturn(Futures.immediateFuture(List.of(relationToDelete))); + when(relationServiceMock.deleteRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(false)); + + var md = getMetadataWithNameTemplate(); + var msg = getTbMsg(originatorId, md); + + // WHEN + node.onMsg(ctxMock, msg); + + verify(relationServiceMock).findByFromAndTypeAsync(eq(tenantId), eq(originatorId), eq(EntityRelation.CONTAINS_TYPE), eq(RelationTypeGroup.COMMON)); + verify(relationServiceMock).deleteRelationAsync(eq(tenantId), eq(relationToDelete)); + + var throwableCaptor = ArgumentCaptor.forClass(Throwable.class); + verify(ctxMock).tellFailure(eq(msg), throwableCaptor.capture()); + verify(ctxMock, never()).tellNext(any(), anyString()); + verify(ctxMock, never()).tellNext(any(), anySet()); + verify(ctxMock, never()).tellSuccess(any()); + verifyNoMoreInteractions(ctxMock, relationServiceMock); + assertThat(throwableCaptor.getValue()).isInstanceOf(RuntimeException.class).hasMessage("Failed to delete relation(s) with originator!"); + } + + + private Map mockEntityServiceCallsEntityNotFound() { + return Map.of( + EntityType.DEVICE, () -> { + when(ctxMock.getDeviceService()).thenReturn(deviceServiceMock); + when(deviceServiceMock.findDeviceByTenantIdAndName(any(), any())).thenReturn(null); + }, + EntityType.ASSET, () -> { + when(ctxMock.getAssetService()).thenReturn(assetServiceMock); + when(assetServiceMock.findAssetByTenantIdAndName(any(), any())).thenReturn(null); + }, + EntityType.CUSTOMER, () -> { + when(ctxMock.getCustomerService()).thenReturn(customerServiceMock); + when(customerServiceMock.findCustomerByTenantIdAndTitle(any(), any())).thenReturn(Optional.empty()); + }, + EntityType.ENTITY_VIEW, () -> { + when(ctxMock.getEntityViewService()).thenReturn(entityViewServiceMock); + when(entityViewServiceMock.findEntityViewByTenantIdAndName(any(), any())).thenReturn(null); + }, + EntityType.EDGE, () -> { + when(ctxMock.getEdgeService()).thenReturn(edgeServiceMock); + when(edgeServiceMock.findEdgeByTenantIdAndName(any(), any())).thenReturn(null); + }, + EntityType.USER, () -> { + when(ctxMock.getUserService()).thenReturn(userServiceMock); + when(userServiceMock.findUserByTenantIdAndEmail(any(), any())).thenReturn(null); + }, + EntityType.DASHBOARD, () -> { + when(ctxMock.getDashboardService()).thenReturn(dashboardServiceMock); + when(dashboardServiceMock.findFirstDashboardInfoByTenantIdAndName(any(), any())).thenReturn(null); + } + ); + } + + private Map> mockEntityServiceCalls() { + return Map.of( + EntityType.DEVICE, hasId -> { + var device = (Device) hasId; + when(ctxMock.getDeviceService()).thenReturn(deviceServiceMock); + when(deviceServiceMock.findDeviceByTenantIdAndName(any(), any())).thenReturn(device); + }, + EntityType.ASSET, hasId -> { + var asset = (Asset) hasId; + when(ctxMock.getAssetService()).thenReturn(assetServiceMock); + when(assetServiceMock.findAssetByTenantIdAndName(any(), any())).thenReturn(asset); + }, + EntityType.CUSTOMER, hasId -> { + var customer = (Customer) hasId; + when(ctxMock.getCustomerService()).thenReturn(customerServiceMock); + when(customerServiceMock.findCustomerByTenantIdAndTitle(any(), any())).thenReturn(Optional.ofNullable(customer)); + }, + EntityType.ENTITY_VIEW, hasId -> { + var entityView = (EntityView) hasId; + when(ctxMock.getEntityViewService()).thenReturn(entityViewServiceMock); + when(entityViewServiceMock.findEntityViewByTenantIdAndName(any(), any())).thenReturn(entityView); + }, + EntityType.EDGE, hasId -> { + var edge = (Edge) hasId; + when(ctxMock.getEdgeService()).thenReturn(edgeServiceMock); + when(edgeServiceMock.findEdgeByTenantIdAndName(any(), any())).thenReturn(edge); + }, + EntityType.USER, hasId -> { + var user = (User) hasId; + when(ctxMock.getUserService()).thenReturn(userServiceMock); + when(userServiceMock.findUserByTenantIdAndEmail(any(), any())).thenReturn(user); + }, + EntityType.DASHBOARD, hasId -> { + var dashboard = (Dashboard) hasId; + when(ctxMock.getDashboardService()).thenReturn(dashboardServiceMock); + when(dashboardServiceMock.findFirstDashboardInfoByTenantIdAndName(any(), any())).thenReturn(dashboard); + }, + EntityType.TENANT, hasId -> { + // do nothing. tenantId returned by ctxMock. + } + ); + } + + private Map> verifyEntityServiceCalls() { + return Map.of( + EntityType.DEVICE, hasId -> { + verify(deviceServiceMock).findDeviceByTenantIdAndName(eq(tenantId), eq("EntityName")); + verifyNoMoreInteractions(deviceServiceMock); + }, + EntityType.ASSET, hasId -> { + verify(assetServiceMock).findAssetByTenantIdAndName(eq(tenantId), eq("EntityName")); + verifyNoMoreInteractions(assetServiceMock); + }, + EntityType.CUSTOMER, hasId -> { + verify(customerServiceMock).findCustomerByTenantIdAndTitle(eq(tenantId), eq("EntityName")); + verifyNoMoreInteractions(customerServiceMock); + }, + EntityType.ENTITY_VIEW, hasId -> { + verify(entityViewServiceMock).findEntityViewByTenantIdAndName(eq(tenantId), eq("EntityName")); + verifyNoMoreInteractions(entityViewServiceMock); + }, + EntityType.EDGE, hasId -> { + verify(edgeServiceMock).findEdgeByTenantIdAndName(eq(tenantId), eq("EntityName")); + verifyNoMoreInteractions(edgeServiceMock); + }, + EntityType.USER, hasId -> { + verify(userServiceMock).findUserByTenantIdAndEmail(eq(tenantId), eq("EntityName")); + verifyNoMoreInteractions(userServiceMock); + }, + EntityType.DASHBOARD, hasId -> { + verify(dashboardServiceMock).findFirstDashboardInfoByTenantIdAndName(eq(tenantId), eq("EntityName")); + verifyNoMoreInteractions(dashboardServiceMock); + }, + EntityType.TENANT, hasId -> { + } + ); + } + + private TbMsg getTbMsg(EntityId originator, TbMsgMetaData metaData) { + return TbMsg.newMsg(TbMsgType.NA, originator, metaData, TbMsg.EMPTY_JSON_OBJECT); + } + + private TbMsgMetaData getMetadataWithNameTemplate() { + var metaData = new TbMsgMetaData(); + metaData.putValue("name", "EntityName"); + return metaData; + } + + + @Override + protected TbNode getTestNode() { + return node; + } + + // Rule nodes upgrade + private static Stream givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() { + return Stream.of( + // version 0 config, FROM direction. + Arguments.of(0, + "{\"deleteForSingleEntity\":true,\"direction\":\"FROM\",\"entityType\":\"DEVICE\"," + + "\"entityNamePattern\":\"$[name]\",\"relationType\":\"Contains\",\"entityCacheExpiration\":300}", + true, + "{\"deleteForSingleEntity\":true,\"direction\":\"TO\",\"entityType\":\"DEVICE\"," + + "\"entityNamePattern\":\"$[name]\",\"relationType\":\"Contains\"}"), + // version 0 config, TO direction. + Arguments.of(0, + "{\"deleteForSingleEntity\":true,\"direction\":\"TO\",\"entityType\":\"DEVICE\"," + + "\"entityNamePattern\":\"$[name]\",\"relationType\":\"Contains\",\"entityCacheExpiration\":300}", + true, + "{\"deleteForSingleEntity\":true,\"direction\":\"FROM\",\"entityType\":\"DEVICE\"," + + "\"entityNamePattern\":\"$[name]\",\"relationType\":\"Contains\"}"), + // config for version 1 with upgrade from version 0 + Arguments.of(0, + "{\"deleteForSingleEntity\":true,\"direction\":\"FROM\",\"entityType\":\"DEVICE\"," + + "\"entityNamePattern\":\"$[name]\",\"relationType\":\"Contains\"}", + false, + "{\"deleteForSingleEntity\":true,\"direction\":\"FROM\",\"entityType\":\"DEVICE\"," + + "\"entityNamePattern\":\"$[name]\",\"relationType\":\"Contains\"}") + ); + } + +} diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbUnassignFromCustomerNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbUnassignFromCustomerNodeTest.java new file mode 100644 index 0000000000..68d3c5a232 --- /dev/null +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbUnassignFromCustomerNodeTest.java @@ -0,0 +1,313 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.rule.engine.action; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.ListeningExecutor; +import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest; +import org.thingsboard.rule.engine.TestDbCallbackExecutor; +import org.thingsboard.rule.engine.api.TbContext; +import org.thingsboard.rule.engine.api.TbNode; +import org.thingsboard.rule.engine.api.TbNodeConfiguration; +import org.thingsboard.rule.engine.api.TbNodeException; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.dao.asset.AssetService; +import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.dao.dashboard.DashboardService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.edge.EdgeService; +import org.thingsboard.server.dao.entityview.EntityViewService; + +import java.util.Arrays; +import java.util.EnumSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TbUnassignFromCustomerNodeTest extends AbstractRuleNodeUpgradeTest { + + private static final Set supportedEntityTypes = EnumSet.of(EntityType.DEVICE, EntityType.ASSET, + EntityType.ENTITY_VIEW, EntityType.EDGE, EntityType.DASHBOARD); + + private static final String supportedEntityTypesStr = supportedEntityTypes.stream().map(Enum::name).collect(Collectors.joining(", ")); + + private static final Set unsupportedEntityTypes = Arrays.stream(EntityType.values()) + .filter(type -> !supportedEntityTypes.contains(type)).collect(Collectors.toUnmodifiableSet()); + + private final Device DEVICE = new Device(); + private final Asset ASSET = new Asset(); + private final EntityView ENTITY_VIEW = new EntityView(); + private final Edge EDGE = new Edge(); + private final Dashboard DASHBOARD = new Dashboard(); + + private final TenantId TENANT_ID = new TenantId(UUID.fromString("06fcc15f-2677-436d-a1cb-7754bd0bcccf")); + + private final ListeningExecutor DB_EXECUTOR = new TestDbCallbackExecutor(); + + private static Stream givenUnsupportedOriginatorType_whenOnMsg_thenVerifyExceptionThrown() { + return unsupportedEntityTypes.stream().flatMap(type -> Stream.of(Arguments.of(type))); + } + + private static Stream givenSupportedOriginatorTypeAndCustomerTitle_whenOnMsg_thenVerifySuccessOutMsg() { + return supportedEntityTypes.stream() + .flatMap(type -> Stream.of(Arguments.of(type, StringUtils.randomAlphabetic(5)))); + } + + private TbUnassignFromCustomerNode node; + private TbUnassignFromCustomerNodeConfiguration config; + + @Mock + private TbContext ctxMock; + + @Mock + private CustomerService customerServiceMock; + + @Mock + private DeviceService deviceServiceMock; + + @Mock + private AssetService assetServiceMock; + + @Mock + private EntityViewService entityViewServiceMock; + + @Mock + private EdgeService edgeServiceMock; + + @Mock + private DashboardService dashboardServiceMock; + + @BeforeEach + public void setUp() throws TbNodeException { + node = spy(new TbUnassignFromCustomerNode()); + config = new TbUnassignFromCustomerNodeConfiguration().defaultConfiguration(); + } + + @Override + protected TbNode getTestNode() { + return node; + } + + @Test + void givenDefaultConfig_whenVerify_thenOK() { + var defaultConfig = new TbUnassignFromCustomerNodeConfiguration().defaultConfiguration(); + assertThat(defaultConfig.getCustomerNamePattern()).isEmpty(); + } + + @ParameterizedTest + @MethodSource + void givenUnsupportedOriginatorType_whenOnMsg_thenVerifyExceptionThrown(EntityType originatorType) { + // GIVEN + var originator = toOriginator(originatorType); + var msg = getTbMsg(originator); + + // WHEN + var exception = assertThrows(RuntimeException.class, () -> node.onMsg(ctxMock, msg)); + + // THEN + assertThat(exception.getMessage()).isEqualTo("Unsupported originator type '" + originatorType + + "'! Only " + supportedEntityTypesStr + " types are allowed."); + verifyNoInteractions(ctxMock); + verifyNoInteractions(customerServiceMock); + } + + @ParameterizedTest + @MethodSource("givenSupportedOriginatorTypeAndCustomerTitle_whenOnMsg_thenVerifySuccessOutMsg") + void givenSupportedOriginatorTypeAndCustomerTitle_whenOnMsg_thenVerifySuccessOutMsg(EntityType type, String customerTitle) throws TbNodeException { + // GIVEN + + when(ctxMock.getTenantId()).thenReturn(TENANT_ID); + when(ctxMock.getDbCallbackExecutor()).thenReturn(DB_EXECUTOR); + + config.setCustomerNamePattern(customerTitle); + var nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + node.init(ctxMock, nodeConfiguration); + + var originator = toOriginator(type); + var msg = getTbMsg(originator); + + // we search for the customer only if incoming message originator is dashboard. + if (type.equals(EntityType.DASHBOARD)) { + when(ctxMock.getCustomerService()).thenReturn(customerServiceMock); + var customer = createCustomer(customerTitle); + when(customerServiceMock.findCustomerByTenantIdAndTitle(eq(TENANT_ID), eq(customerTitle))).thenReturn(Optional.of(customer)); + } + Map> entityTypeToEntityIdConsumerMap = mockMethodCallsForSupportedTypes(); + entityTypeToEntityIdConsumerMap.get(type).accept(originator); + + // WHEN + node.onMsg(ctxMock, msg); + + // THEN + verifyMsgSuccess(msg); + verifyNoMoreInteractions(ctxMock); + } + + @ParameterizedTest + @MethodSource("givenSupportedOriginatorTypeAndCustomerTitle_whenOnMsg_thenVerifySuccessOutMsg") + void givenSupportedOriginatorTypeAndCustomerTitle_whenOnMsg_thenVerifyCustomerSearchedAndNotFoundOnlyForDashboardOriginator(EntityType type, String customerTitle) throws TbNodeException { + // GIVEN + + when(ctxMock.getTenantId()).thenReturn(TENANT_ID); + when(ctxMock.getDbCallbackExecutor()).thenReturn(DB_EXECUTOR); + + config.setCustomerNamePattern(customerTitle); + var nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + node.init(ctxMock, nodeConfiguration); + + var originator = toOriginator(type); + var msg = getTbMsg(originator); + + // we search for the customer only if incoming message originator is dashboard. + if (type.equals(EntityType.DASHBOARD)) { + when(ctxMock.getCustomerService()).thenReturn(customerServiceMock); + when(customerServiceMock.findCustomerByTenantIdAndTitle(eq(TENANT_ID), eq(customerTitle))).thenReturn(Optional.empty()); + + // DASHBOARD WHEN + node.onMsg(ctxMock, msg); + + // DASHBOARD THEN + ArgumentCaptor throwableCaptor = ArgumentCaptor.forClass(Throwable.class); + verify(ctxMock).tellFailure(eq(msg), throwableCaptor.capture()); + assertThat(throwableCaptor.getValue()).hasMessage("Customer with title '" + customerTitle + "' doesn't exist!"); + + verifyNoMoreInteractions(customerServiceMock); + verifyNoMoreInteractions(ctxMock); + return; + } + Map> entityTypeToEntityIdConsumerMap = mockMethodCallsForSupportedTypes(); + entityTypeToEntityIdConsumerMap.get(type).accept(originator); + + // OTHER TYPES WHEN + node.onMsg(ctxMock, msg); + + // OTHER TYPES THEN + verifyMsgSuccess(msg); + verifyNoMoreInteractions(ctxMock); + } + + private Map> mockMethodCallsForSupportedTypes() { + return Map.of( + EntityType.DEVICE, id -> { + when(ctxMock.getDeviceService()).thenReturn(deviceServiceMock); + when(deviceServiceMock.unassignDeviceFromCustomer(eq(TENANT_ID), (DeviceId) eq(id))) + .thenReturn(DEVICE); + }, + EntityType.ASSET, id -> { + when(ctxMock.getAssetService()).thenReturn(assetServiceMock); + when(assetServiceMock.unassignAssetFromCustomer(eq(TENANT_ID), (AssetId) eq(id))) + .thenReturn(ASSET); + }, + EntityType.ENTITY_VIEW, id -> { + when(ctxMock.getEntityViewService()).thenReturn(entityViewServiceMock); + when(entityViewServiceMock.unassignEntityViewFromCustomer(eq(TENANT_ID), (EntityViewId) eq(id))) + .thenReturn(ENTITY_VIEW); + }, + EntityType.EDGE, id -> { + when(ctxMock.getEdgeService()).thenReturn(edgeServiceMock); + when(edgeServiceMock.unassignEdgeFromCustomer(eq(TENANT_ID), (EdgeId) eq(id))) + .thenReturn(EDGE); + }, + EntityType.DASHBOARD, id -> { + when(ctxMock.getDashboardService()).thenReturn(dashboardServiceMock); + when(dashboardServiceMock.unassignDashboardFromCustomer(eq(TENANT_ID), (DashboardId) eq(id), any())) + .thenReturn(DASHBOARD); + } + ); + } + + private void verifyMsgSuccess(TbMsg expectedMsg) { + verify(ctxMock).tellSuccess(eq(expectedMsg)); + verify(ctxMock, never()).tellFailure(any(), any()); + } + + private Customer createCustomer(String customerTitle) { + var customer = new Customer(); + customer.setTitle(customerTitle); + customer.setId(new CustomerId(UUID.randomUUID())); + customer.setTenantId(TENANT_ID); + return customer; + } + + private TbMsg getTbMsg(EntityId originator) { + return TbMsg.newMsg(TbMsgType.NA, originator, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); + } + + private static EntityId toOriginator(EntityType type) { + return EntityIdFactory.getByTypeAndId(type.name(), UUID.randomUUID().toString()); + } + + // Rule nodes upgrade + private static Stream givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() { + return Stream.of( + // default config for version 0 + Arguments.of(0, + "{\"customerNamePattern\":\"\",\"customerCacheExpiration\":300}", + true, + "{\"customerNamePattern\":\"\"}"), + // default config for version 1 with upgrade from version 0 + Arguments.of(0, + "{\"customerNamePattern\":\"\"}", + false, + "{\"customerNamePattern\":\"\"}") + ); + } + +} diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/filter/TbCheckRelationNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/filter/TbCheckRelationNodeTest.java index 55372c0181..e445b93455 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/filter/TbCheckRelationNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/filter/TbCheckRelationNodeTest.java @@ -20,10 +20,13 @@ import com.google.common.util.concurrent.Futures; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.provider.Arguments; import org.mockito.ArgumentCaptor; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest; import org.thingsboard.rule.engine.TestDbCallbackExecutor; import org.thingsboard.rule.engine.api.TbContext; +import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.server.common.data.id.AssetId; @@ -42,6 +45,7 @@ import org.thingsboard.server.dao.relation.RelationService; import java.util.Collections; import java.util.List; import java.util.UUID; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -52,16 +56,17 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -class TbCheckRelationNodeTest { +class TbCheckRelationNodeTest extends AbstractRuleNodeUpgradeTest { - private static final TenantId TENANT_ID = new TenantId(UUID.randomUUID()); - private static final DeviceId ORIGINATOR_ID = new DeviceId(UUID.randomUUID()); - private static final TestDbCallbackExecutor DB_EXECUTOR = new TestDbCallbackExecutor(); - private static final TbMsg EMPTY_POST_ATTRIBUTES_MSG = TbMsg.newMsg(TbMsgType.POST_ATTRIBUTES_REQUEST, ORIGINATOR_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); + private final TenantId TENANT_ID = new TenantId(UUID.randomUUID()); + private final DeviceId ORIGINATOR_ID = new DeviceId(UUID.randomUUID()); + private final TestDbCallbackExecutor DB_EXECUTOR = new TestDbCallbackExecutor(); + private final TbMsg EMPTY_POST_ATTRIBUTES_MSG = TbMsg.newMsg(TbMsgType.POST_ATTRIBUTES_REQUEST, ORIGINATOR_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); private TbCheckRelationNode node; @@ -77,7 +82,7 @@ class TbCheckRelationNodeTest { when(ctx.getRelationService()).thenReturn(relationService); when(ctx.getDbCallbackExecutor()).thenReturn(DB_EXECUTOR); - node = new TbCheckRelationNode(); + node = spy(new TbCheckRelationNode()); } @AfterEach @@ -310,4 +315,24 @@ class TbCheckRelationNodeTest { assertEquals(config, JacksonUtil.treeToValue(upgrade.getSecond(), config.getClass())); } + // Rule nodes upgrade + private static Stream givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() { + return Stream.of( + // version 0 config, FROM direction. + Arguments.of(0, + "{\"checkForSingleEntity\":true,\"direction\":\"FROM\",\"entityType\":\"DEVICE\",\"entityId\":\"1943b1eb-2811-4373-846d-6ca2f527bf9e\",\"relationType\":\"Contains\"}", + true, + "{\"checkForSingleEntity\":true,\"direction\":\"TO\",\"entityType\":\"DEVICE\",\"entityId\":\"1943b1eb-2811-4373-846d-6ca2f527bf9e\",\"relationType\":\"Contains\"}"), + // version 0 config, TO direction. + Arguments.of(0, + "{\"checkForSingleEntity\":true,\"direction\":\"TO\",\"entityType\":\"DEVICE\",\"entityId\":\"1943b1eb-2811-4373-846d-6ca2f527bf9e\",\"relationType\":\"Contains\"}", + true, + "{\"checkForSingleEntity\":true,\"direction\":\"FROM\",\"entityType\":\"DEVICE\",\"entityId\":\"1943b1eb-2811-4373-846d-6ca2f527bf9e\",\"relationType\":\"Contains\"}") + ); + } + + @Override + protected TbNode getTestNode() { + return node; + } }