This is an automated email from the ASF dual-hosted git repository.
sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git
The following commit(s) were added to refs/heads/master by this push:
new ad5fefbb2 UNOMI-999: Keep scope purge limited to one scope (#907)
ad5fefbb2 is described below
commit ad5fefbb21630bb919cab7001a07caf87de28c81
Author: Serge Huber <[email protected]>
AuthorDate: Tue Oct 6 15:19:41 2026 +0200
UNOMI-999: Keep scope purge limited to one scope (#907)
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../apache/unomi/itests/PersistenceServiceIT.java | 130 ++++++++++++++++++++-
.../ElasticSearchPersistenceServiceImpl.java | 26 ++++-
.../ElasticSearchScopePurgeQueryTest.java | 107 +++++++++++++++++
.../OpenSearchPersistenceServiceImpl.java | 37 +++---
.../opensearch/OpenSearchScopePurgeQueryTest.java | 74 ++++++++++++
.../unomi/persistence/spi/PersistenceService.java | 5 +-
.../impl/InMemoryPersistenceServiceImpl.java | 4 +-
.../impl/InMemoryPersistenceServiceImplTest.java | 5 +-
8 files changed, 358 insertions(+), 30 deletions(-)
diff --git
a/itests/src/test/java/org/apache/unomi/itests/PersistenceServiceIT.java
b/itests/src/test/java/org/apache/unomi/itests/PersistenceServiceIT.java
index a2c9e581e..05d00aef4 100644
--- a/itests/src/test/java/org/apache/unomi/itests/PersistenceServiceIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/PersistenceServiceIT.java
@@ -16,8 +16,10 @@
*/
package org.apache.unomi.itests;
+import org.apache.unomi.api.Event;
import org.apache.unomi.api.PartialList;
import org.apache.unomi.api.Profile;
+import org.apache.unomi.api.tenants.Tenant;
import org.junit.After;
import org.junit.Assert;
import org.junit.Test;
@@ -27,13 +29,14 @@ import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy;
import org.ops4j.pax.exam.spi.reactors.PerSuite;
import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
import java.util.List;
import java.util.UUID;
/**
- * Integration tests for {@link
org.apache.unomi.persistence.spi.PersistenceService} query APIs against the live
- * search backend (Elasticsearch or OpenSearch). Initial coverage focuses on
{@code rangeQuery}; additional methods
- * should be covered in follow-up work (UNOMI-956).
+ * Integration tests for {@link
org.apache.unomi.persistence.spi.PersistenceService} against the live
+ * search backend (Elasticsearch or OpenSearch).
*/
@RunWith(PaxExam.class)
@ExamReactorStrategy(PerSuite.class)
@@ -42,14 +45,19 @@ public class PersistenceServiceIT extends BaseIT {
private static final String AGE_PROPERTY = "properties.age";
private final List<String> profileIds = new ArrayList<>();
+ private final List<String> eventIds = new ArrayList<>();
@After
public void tearDown() throws InterruptedException {
+ for (String eventId : eventIds) {
+ persistenceService.remove(eventId, Event.class);
+ }
+ eventIds.clear();
for (String profileId : profileIds) {
persistenceService.remove(profileId, Profile.class);
}
profileIds.clear();
- refreshPersistence(Profile.class);
+ refreshPersistence(Event.class, Profile.class);
}
@Test
@@ -105,6 +113,120 @@ public class PersistenceServiceIT extends BaseIT {
Assert.assertEquals(5, lastPage.getList().get(0).getProperty("age"));
}
+ @Test
+ public void testPurgeByScopeKeepsOtherScopesAndProfiles() throws
InterruptedException {
+ String suffix = UUID.randomUUID().toString();
+ String dropScope = "purge-drop-" + suffix;
+ String keepScope = "purge-keep-" + suffix;
+ Profile profile = saveProfile("purge-profile-" + suffix);
+ Event dropEvent = saveViewEvent("purge-drop-event-" + suffix, profile,
dropScope);
+ Event keepEvent = saveViewEvent("purge-keep-event-" + suffix, profile,
keepScope);
+ refreshPersistence(Event.class, Profile.class);
+
+ persistenceService.purge(dropScope);
+ refreshPersistence(Event.class, Profile.class);
+
+ Assert.assertNull("Events of the purged scope should be gone",
persistenceService.load(dropEvent.getItemId(), Event.class));
+ Assert.assertNotNull("Events of another scope should stay",
persistenceService.load(keepEvent.getItemId(), Event.class));
+ Assert.assertNotNull("Profiles have no scope and must stay",
persistenceService.load(profile.getItemId(), Profile.class));
+ }
+
+ @Test
+ public void testPurgeByScopeDoesNotRemoveAnotherTenantsEvents() throws
Exception {
+ String suffix = UUID.randomUUID().toString();
+ String scope = "purge-shared-scope-" + suffix;
+ // Items of the other tenant are not tracked for tearDown, which runs
as the current tenant and would not find them
+ String otherProfileId = "purge-other-profile-" + suffix;
+ String otherEventId = "purge-other-event-" + suffix;
+ Tenant otherTenant = tenantService.createTenant("purge-other-" +
suffix.substring(0, 8), Collections.emptyMap());
+ try {
+ Event localEvent = saveViewEvent("purge-local-event-" + suffix,
saveProfile("purge-local-profile-" + suffix), scope);
+ executionContextManager.executeAsTenant(otherTenant.getItemId(),
() -> {
+ Profile otherProfile = new Profile(otherProfileId);
+ persistenceService.save(otherProfile);
+ persistenceService.save(new Event(otherEventId, "view", null,
otherProfile, scope, null, null, new Date()));
+ });
+ refreshPersistence(Event.class, Profile.class);
+
+ persistenceService.purge(scope);
+ refreshPersistence(Event.class, Profile.class);
+
+ Assert.assertNull("Current tenant events in the scope should be
gone",
+ persistenceService.load(localEvent.getItemId(),
Event.class));
+ executionContextManager.executeAsTenant(otherTenant.getItemId(),
() -> {
+ Assert.assertNotNull("Other tenant events in a scope with the
same name should stay",
+ persistenceService.load(otherEventId, Event.class));
+ });
+ } finally {
+ try {
+
executionContextManager.executeAsTenant(otherTenant.getItemId(), () -> {
+ persistenceService.remove(otherEventId, Event.class);
+ persistenceService.remove(otherProfileId, Profile.class);
+ });
+ } finally {
+ tenantService.deleteTenant(otherTenant.getItemId());
+ }
+ }
+ }
+
+ @Test
+ public void testPurgeByScopeRemovesMoreThanOnePageOfItems() throws
InterruptedException {
+ String suffix = UUID.randomUUID().toString();
+ String scope = "purge-many-" + suffix;
+ Profile profile = saveProfile("purge-many-profile-" + suffix);
+ // purge reads matching items 100 at a time
+ int eventCount = 250;
+ for (int i = 0; i < eventCount; i++) {
+ saveViewEvent("purge-many-event-" + i + "-" + suffix, profile,
scope);
+ }
+ refreshPersistence(Event.class, Profile.class);
+
+ persistenceService.purge(scope);
+ refreshPersistence(Event.class, Profile.class);
+
+ for (int i = 0; i < eventCount; i++) {
+ Assert.assertNull("Event " + i + " of the purged scope should be
gone",
+ persistenceService.load("purge-many-event-" + i + "-" +
suffix, Event.class));
+ }
+ }
+
+ @Test
+ public void testPurgeByScopeMatchesMixedCaseAndAccentedScope() throws
InterruptedException {
+ String suffix = UUID.randomUUID().toString();
+ String scope = "Purge-Mixed-Scopé-" + suffix;
+ Event event = saveViewEvent("purge-mixed-event-" + suffix,
saveProfile("purge-mixed-profile-" + suffix), scope);
+ refreshPersistence(Event.class, Profile.class);
+
+ persistenceService.purge(scope);
+ refreshPersistence(Event.class, Profile.class);
+
+ Assert.assertNull("Events of the purged scope should be gone",
persistenceService.load(event.getItemId(), Event.class));
+ }
+
+ @Test
+ public void testPurgeByScopeRejectsBlankScope() {
+ try {
+ persistenceService.purge(" ");
+ Assert.fail("A blank scope should be refused");
+ } catch (IllegalArgumentException expected) {
+ Assert.assertTrue(expected.getMessage().contains("required"));
+ }
+ }
+
+ private Profile saveProfile(String itemId) {
+ Profile profile = new Profile(itemId);
+ persistenceService.save(profile);
+ profileIds.add(itemId);
+ return profile;
+ }
+
+ private Event saveViewEvent(String itemId, Profile profile, String scope) {
+ Event event = new Event(itemId, "view", null, profile, scope, null,
null, new Date());
+ persistenceService.save(event);
+ eventIds.add(itemId);
+ return event;
+ }
+
private void saveProfileWithAge(String idSuffix, int age) {
Profile profile = new Profile();
profile.setItemId(idSuffix + "-" + UUID.randomUUID());
diff --git
a/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java
b/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java
index f6d66d37f..fea126b58 100644
---
a/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java
+++
b/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java
@@ -2726,13 +2726,12 @@ public class ElasticSearchPersistenceServiceImpl
implements PersistenceService,
}
@Override public void purge(final String scope) {
- LOGGER.debug("Purge scope {}", scope);
String finalTenantId =
validateTenantAndGetId(SecurityServiceConfiguration.PERMISSION_PURGE);
+ Query query = buildScopePurgeQuery(scope, finalTenantId);
+ LOGGER.debug("Purge scope {}", scope);
new InClassLoaderExecute<Void>(metricsService,
this.getClass().getName() + ".purgeWithScope", this.bundleContext,
this.fatalIllegalStateErrors, throwExceptions) {
@Override protected Void execute(Object... args) throws
IOException {
- Query query = TermQuery.of(builder ->
builder.field("scope").value(scope).field("tenantId").value(ConditionContextHelper.foldToASCII(finalTenantId)))._toQuery();
-
List<BulkOperation> operations = new ArrayList<>();
Time keepAlive = Time.of(t -> t.time("1h"));
@@ -2745,7 +2744,7 @@ public class ElasticSearchPersistenceServiceImpl
implements PersistenceService,
String scrollId = searchResponse.scrollId();
// Scroll until no more hits are returned
while (!hits.isEmpty()) {
- for (Hit<JsonData> hit : searchResponse.hits().hits()) {
+ for (Hit<JsonData> hit : hits) {
// add hit to bulk delete
operations.add(BulkOperation.of(builder ->
builder.delete(d -> d.index(hit.index()).id(hit.id()))));
}
@@ -2777,6 +2776,25 @@ public class ElasticSearchPersistenceServiceImpl
implements PersistenceService,
}.catchingExecuteInClassLoader(true);
}
+ /**
+ * Builds the search used by {@link #purge(String)}.
+ * A {@code TermQuery} has a single field, so both filters must be bool
must clauses.
+ * Both values are folded, as the fields are indexed with the folding
analyzer.
+ * Keep in step with the OpenSearch implementation.
+ */
+ static Query buildScopePurgeQuery(String scope, String tenantId) {
+ if (scope == null || scope.isBlank()) {
+ throw new IllegalArgumentException("A scope is required to purge
by scope");
+ }
+ if (tenantId == null || tenantId.isBlank()) {
+ throw new IllegalArgumentException("A tenant is required to purge
by scope");
+ }
+ return Query.of(q -> q.bool(b -> b
+ .must(m -> m.term(t ->
t.field("scope").value(ConditionContextHelper.foldToASCII(scope))))
+ .must(m -> m.term(t -> t.field("tenantId")
+
.value(ConditionContextHelper.foldToASCII(tenantId))))));
+ }
+
@Override public Map<String, Double> getSingleValuesMetrics(final
Condition condition, final String[] metrics, final String field,
final String itemType) {
return new InClassLoaderExecute<Map<String, Double>>(metricsService,
this.getClass().getName() + ".getSingleValuesMetrics",
diff --git
a/persistence-elasticsearch/core/src/test/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchScopePurgeQueryTest.java
b/persistence-elasticsearch/core/src/test/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchScopePurgeQueryTest.java
new file mode 100644
index 000000000..83beef4c3
--- /dev/null
+++
b/persistence-elasticsearch/core/src/test/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchScopePurgeQueryTest.java
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.unomi.persistence.elasticsearch;
+
+import co.elastic.clients.elasticsearch._types.query_dsl.Query;
+import co.elastic.clients.json.jackson.JacksonJsonpMapper;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.json.stream.JsonGenerator;
+import org.junit.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class ElasticSearchScopePurgeQueryTest {
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ @Test
+ public void buildScopePurgeQueryRequiresBothScopeAndTenantTerms() throws
Exception {
+ Query query =
ElasticSearchPersistenceServiceImpl.buildScopePurgeQuery("drop_scope",
"tenant_a");
+ JsonNode json = OBJECT_MAPPER.readTree(toJson(query));
+
+ JsonNode must = json.path("bool").path("must");
+ assertTrue("Purge query must be a bool query with must clauses: " +
json, must.isArray());
+ assertEquals(2, must.size());
+
+ Set<String> fields = new HashSet<>();
+ for (JsonNode clause : must) {
+ JsonNode term = clause.path("term");
+ assertTrue("Each must clause should be a term query: " + json,
term.isObject() && term.size() == 1);
+ fields.add(term.fieldNames().next());
+ }
+ assertEquals(Set.of("scope", "tenantId"), fields);
+ assertEquals("drop_scope", termValue(must, "scope"));
+ assertEquals("tenant_a", termValue(must, "tenantId"));
+ }
+
+ @Test
+ public void buildScopePurgeQueryFoldsScopeAndTenant() throws Exception {
+ Query query =
ElasticSearchPersistenceServiceImpl.buildScopePurgeQuery("Drop_Scopé",
"Tenant_A");
+ JsonNode must =
OBJECT_MAPPER.readTree(toJson(query)).path("bool").path("must");
+
+ assertEquals("drop_scope", termValue(must, "scope"));
+ assertEquals("tenant_a", termValue(must, "tenantId"));
+ }
+
+ @Test
+ public void buildScopePurgeQueryRejectsBlankScopeOrTenant() {
+ assertIllegalArgument(() ->
ElasticSearchPersistenceServiceImpl.buildScopePurgeQuery(" ", "tenant_a"));
+ assertIllegalArgument(() ->
ElasticSearchPersistenceServiceImpl.buildScopePurgeQuery(null, "tenant_a"));
+ assertIllegalArgument(() ->
ElasticSearchPersistenceServiceImpl.buildScopePurgeQuery("drop_scope", " "));
+ assertIllegalArgument(() ->
ElasticSearchPersistenceServiceImpl.buildScopePurgeQuery("drop_scope", null));
+ }
+
+ private static String termValue(JsonNode must, String field) {
+ for (JsonNode clause : must) {
+ JsonNode term = clause.path("term").path(field);
+ if (term.has("value")) {
+ return term.get("value").asText();
+ }
+ if (term.isTextual()) {
+ return term.asText();
+ }
+ }
+ fail("No term for field " + field);
+ return null;
+ }
+
+ private static String toJson(Query query) {
+ JacksonJsonpMapper mapper = new JacksonJsonpMapper();
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ JsonGenerator generator = mapper.jsonProvider().createGenerator(out);
+ query.serialize(generator, mapper);
+ generator.close();
+ return out.toString(StandardCharsets.UTF_8);
+ }
+
+ private static void assertIllegalArgument(Runnable action) {
+ try {
+ action.run();
+ fail("Expected IllegalArgumentException");
+ } catch (IllegalArgumentException expected) {
+ assertTrue(expected.getMessage().contains("required"));
+ }
+ }
+}
diff --git
a/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java
b/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java
index 4afc072ca..5b4270f57 100644
---
a/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java
+++
b/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java
@@ -2667,6 +2667,7 @@ public class OpenSearchPersistenceServiceImpl implements
PersistenceService, Syn
@Override
public void purge(final String scope) {
String finalTenantId =
validateTenantAndGetId(SecurityServiceConfiguration.PERMISSION_PURGE);
+ Query query = buildScopePurgeQuery(scope, finalTenantId);
LOGGER.debug("Purge scope {}", scope);
new InClassLoaderExecute<Void>(metricsService,
this.getClass().getName() + ".purgeWithScope", this.bundleContext,
this.fatalIllegalStateErrors, throwExceptions) {
@@ -2674,22 +2675,7 @@ public class OpenSearchPersistenceServiceImpl implements
PersistenceService, Syn
protected Void execute(Object... args) throws IOException {
SearchResponse<Item> response = client.search(s -> s
- .query(q -> q
- .bool(b -> b
- .must(m -> m
- .term(t -> t
- .field("scope")
- .value(v ->
v.stringValue(scope))
- )
- )
- .must(m -> m
- .term(t -> t
- .field("tenantId")
- .value(v ->
v.stringValue(ConditionContextHelper.foldToASCII(finalTenantId)))
- )
- )
- )
- )
+ .query(query)
.size(100)
.scroll(scr -> scr
.time("1h")
@@ -2730,6 +2716,25 @@ public class OpenSearchPersistenceServiceImpl implements
PersistenceService, Syn
}.catchingExecuteInClassLoader(true);
}
+ /**
+ * Builds the search used by {@link #purge(String)}.
+ * Both values are folded, as the fields are indexed with the folding
analyzer.
+ * Keep in step with the Elasticsearch implementation.
+ */
+ static Query buildScopePurgeQuery(String scope, String tenantId) {
+ if (scope == null || scope.isBlank()) {
+ throw new IllegalArgumentException("A scope is required to purge
by scope");
+ }
+ if (tenantId == null || tenantId.isBlank()) {
+ throw new IllegalArgumentException("A tenant is required to purge
by scope");
+ }
+ return Query.of(q -> q.bool(b -> b
+ .must(m -> m.term(t -> t.field("scope")
+ .value(v ->
v.stringValue(ConditionContextHelper.foldToASCII(scope)))))
+ .must(m -> m.term(t -> t.field("tenantId")
+ .value(v ->
v.stringValue(ConditionContextHelper.foldToASCII(tenantId)))))));
+ }
+
@Override
public Map<String, Double> getSingleValuesMetrics(final Condition
condition, final String[] metrics, final String field, final String itemType) {
return new InClassLoaderExecute<Map<String, Double>>(metricsService,
this.getClass().getName() + ".getSingleValuesMetrics", this.bundleContext,
this.fatalIllegalStateErrors, throwExceptions) {
diff --git
a/persistence-opensearch/core/src/test/java/org/apache/unomi/persistence/opensearch/OpenSearchScopePurgeQueryTest.java
b/persistence-opensearch/core/src/test/java/org/apache/unomi/persistence/opensearch/OpenSearchScopePurgeQueryTest.java
new file mode 100644
index 000000000..d7d5b18f6
--- /dev/null
+++
b/persistence-opensearch/core/src/test/java/org/apache/unomi/persistence/opensearch/OpenSearchScopePurgeQueryTest.java
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.unomi.persistence.opensearch;
+
+import org.junit.jupiter.api.Test;
+import org.opensearch.client.opensearch._types.query_dsl.Query;
+import org.opensearch.client.opensearch._types.query_dsl.TermQuery;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class OpenSearchScopePurgeQueryTest {
+
+ @Test
+ public void buildScopePurgeQueryRequiresBothScopeAndTenantTerms() {
+ Query query =
OpenSearchPersistenceServiceImpl.buildScopePurgeQuery("drop_scope", "tenant_a");
+
+ assertEquals(Map.of("scope", "drop_scope", "tenantId", "tenant_a"),
mustTerms(query));
+ }
+
+ @Test
+ public void buildScopePurgeQueryFoldsScopeAndTenant() {
+ Query query =
OpenSearchPersistenceServiceImpl.buildScopePurgeQuery("Drop_Scopé", "Tenant_A");
+
+ assertEquals(Map.of("scope", "drop_scope", "tenantId", "tenant_a"),
mustTerms(query));
+ }
+
+ @Test
+ public void buildScopePurgeQueryRejectsBlankScopeOrTenant() {
+ assertRequired("scope", () ->
OpenSearchPersistenceServiceImpl.buildScopePurgeQuery(" ", "tenant_a"));
+ assertRequired("scope", () ->
OpenSearchPersistenceServiceImpl.buildScopePurgeQuery(null, "tenant_a"));
+ assertRequired("tenant", () ->
OpenSearchPersistenceServiceImpl.buildScopePurgeQuery("drop_scope", " "));
+ assertRequired("tenant", () ->
OpenSearchPersistenceServiceImpl.buildScopePurgeQuery("drop_scope", null));
+ }
+
+ private static Map<String, String> mustTerms(Query query) {
+ assertTrue(query.isBool(), "Purge query must be a bool query: " +
query);
+ List<Query> must = query.bool().must();
+ assertEquals(2, must.size());
+ assertTrue(query.bool().should().isEmpty() &&
query.bool().mustNot().isEmpty());
+
+ Map<String, String> terms = new HashMap<>();
+ for (Query clause : must) {
+ assertTrue(clause.isTerm(), "Each must clause should be a term
query: " + query);
+ TermQuery term = clause.term();
+ terms.put(term.field(), term.value().stringValue());
+ }
+ return terms;
+ }
+
+ private static void assertRequired(String what, Runnable action) {
+ IllegalArgumentException e =
assertThrows(IllegalArgumentException.class, action::run);
+ assertTrue(e.getMessage().contains(what) &&
e.getMessage().contains("required"), e.getMessage());
+ }
+}
diff --git
a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/PersistenceService.java
b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/PersistenceService.java
index b572ab129..5289917e0 100644
---
a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/PersistenceService.java
+++
b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/PersistenceService.java
@@ -790,9 +790,10 @@ public interface PersistenceService {
boolean removeIndex(final String itemType);
/**
- * Removes all data associated with the provided scope.
+ * Removes all data associated with the provided scope, for the current
tenant only.
*
- * @param scope the scope for which we want to remove data
+ * @param scope the scope for which we want to remove data, must not be
null or blank
+ * @throws IllegalArgumentException if the scope is null or blank
*/
void purge(final String scope);
diff --git
a/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImpl.java
b/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImpl.java
index 38b7fb136..210f6913c 100644
---
a/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImpl.java
+++
b/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImpl.java
@@ -1193,8 +1193,8 @@ public class InMemoryPersistenceServiceImpl implements
PersistenceService {
@Override
public void purge(String scope) {
- if (scope == null) {
- return;
+ if (scope == null || scope.isBlank()) {
+ throw new IllegalArgumentException("A scope is required to purge
by scope");
}
String currentTenantId =
executionContextManager.getCurrentContext().getTenantId();
diff --git
a/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImplTest.java
b/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImplTest.java
index 9d1fc916b..3d730133d 100644
---
a/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImplTest.java
+++
b/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImplTest.java
@@ -3944,8 +3944,9 @@ public class InMemoryPersistenceServiceImplTest {
assertNull(persistenceService.load("purge-null-test",
TestMetadataItem.class),
"purge(null) should purge all items (used for test isolation
reset)");
- // Test purge with null scope — also a no-op
- assertDoesNotThrow(() -> persistenceService.purge((String) null));
+ // A null or blank scope is refused, as in the Elasticsearch and
OpenSearch implementations
+ assertThrows(IllegalArgumentException.class, () ->
persistenceService.purge((String) null));
+ assertThrows(IllegalArgumentException.class, () ->
persistenceService.purge(" "));
// Test refresh index with null class
assertDoesNotThrow(() -> persistenceService.refreshIndex(null,
null));