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 5740a815f UNOMI-1003: Sort merged segment/scoring lists and fix 
missing limit (#917)
5740a815f is described below

commit 5740a815fb7fe8bfb6dc2a706efed917ee76c63c
Author: Serge Huber <[email protected]>
AuthorDate: Thu Oct 15 21:39:59 2026 +0200

    UNOMI-1003: Sort merged segment/scoring lists and fix missing limit (#917)
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../java/org/apache/unomi/itests/SegmentIT.java    | 102 ++++++++
 manual/src/main/asciidoc/configuration.adoc        |  12 +
 .../asciidoc/migrations/migrate-3.0-to-4.0.adoc    |  57 +++++
 .../ElasticSearchPersistenceServiceImpl.java       |   9 +-
 .../OpenSearchPersistenceServiceImpl.java          |   9 +-
 .../unomi/persistence/spi/PersistenceService.java  |  10 +
 .../impl/segments/MergedMetadataListHelper.java    | 170 +++++++++++++
 .../services/impl/segments/SegmentServiceImpl.java | 213 ++++------------
 .../impl/InMemoryPersistenceServiceImpl.java       |  17 +-
 .../impl/InMemoryPersistenceServiceImplTest.java   |  27 +-
 .../segments/MergedMetadataListHelperTest.java     | 280 +++++++++++++++++++++
 .../impl/segments/SegmentServiceImplTest.java      | 243 ++++++++++++++++++
 12 files changed, 965 insertions(+), 184 deletions(-)

diff --git a/itests/src/test/java/org/apache/unomi/itests/SegmentIT.java 
b/itests/src/test/java/org/apache/unomi/itests/SegmentIT.java
index e4bffff64..1811d65ff 100644
--- a/itests/src/test/java/org/apache/unomi/itests/SegmentIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/SegmentIT.java
@@ -17,8 +17,14 @@
 
 package org.apache.unomi.itests;
 
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.util.EntityUtils;
 import org.apache.unomi.api.Event;
 import org.apache.unomi.api.Metadata;
+import org.apache.unomi.api.PartialList;
 import org.apache.unomi.api.Profile;
 import org.apache.unomi.api.conditions.Condition;
 import org.apache.unomi.api.exceptions.BadSegmentConditionException;
@@ -793,4 +799,100 @@ public class SegmentIT extends BaseIT {
                         updatedProfile.getScores() == null || 
!updatedProfile.getScores().containsKey("relative-date-scoring-test")), 1000,
                 20);
     }
+
+    @Test
+    public void testQuerySegmentsWithNoLimitReturnsFullList() throws Exception 
{
+        Metadata segmentOneMetadata = new Metadata("query-no-limit-segment-1");
+        segmentOneMetadata.setName("Query No Limit Segment 1");
+        Segment segmentOne = new Segment(segmentOneMetadata);
+        Condition segmentOneCondition = new 
Condition(definitionsService.getConditionType("profilePropertyCondition"));
+        segmentOneCondition.setParameter("propertyName", "properties.email");
+        segmentOneCondition.setParameter("comparisonOperator", "exists");
+        segmentOne.setCondition(segmentOneCondition);
+        segmentService.setSegmentDefinition(segmentOne);
+
+        Metadata segmentTwoMetadata = new Metadata("query-no-limit-segment-2");
+        segmentTwoMetadata.setName("Query No Limit Segment 2");
+        Segment segmentTwo = new Segment(segmentTwoMetadata);
+        Condition segmentTwoCondition = new 
Condition(definitionsService.getConditionType("profilePropertyCondition"));
+        segmentTwoCondition.setParameter("propertyName", 
"properties.firstName");
+        segmentTwoCondition.setParameter("comparisonOperator", "exists");
+        segmentTwo.setCondition(segmentTwoCondition);
+        segmentService.setSegmentDefinition(segmentTwo);
+
+        keepTrying("Segments should be listed",
+                () -> segmentService.getSegmentMetadatas(0, 50, 
null).getList(),
+                list -> list.stream().anyMatch(m -> 
"query-no-limit-segment-1".equals(m.getId()))
+                        && list.stream().anyMatch(m -> 
"query-no-limit-segment-2".equals(m.getId())),
+                DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+        PartialList<?> noLimit = postMetadataQuery("/cxs/segments/query", 
"{}");
+        // Like the persistence services, a query without a limit returns 
defaultQueryLimit items (10 unless configured)
+        Assert.assertTrue("totalSize should cover the full list", 
noLimit.getTotalSize() >= 2);
+        Assert.assertEquals("Query without limit should return the default 
page", Math.min(noLimit.getTotalSize(), 10), noLimit.getList().size());
+
+        // 2.x/3.0 clients send -1 for "all" and persistence-style sort 
criteria
+        List<String> sortedIds = 
metadataQueryIds(postMetadataQuery("/cxs/segments/query", 
"{\"limit\":-1,\"sortby\":\"metadata.name:desc\"}"));
+        Assert.assertTrue("Should return both created segments", 
sortedIds.containsAll(Arrays.asList("query-no-limit-segment-1", 
"query-no-limit-segment-2")));
+        Assert.assertTrue("Segments should be sorted by name descending", 
sortedIds.indexOf("query-no-limit-segment-2") < 
sortedIds.indexOf("query-no-limit-segment-1"));
+
+        // Unknown sort fields are ignored, as they were by the persistence 
services
+        Assert.assertTrue("Unknown sort field should be ignored",
+                metadataQueryIds(postMetadataQuery("/cxs/segments/query", 
"{\"limit\":-1,\"sortby\":\"notAField:asc\"}")).contains("query-no-limit-segment-1"));
+    }
+
+    @Test
+    public void testQueryScoringsWithNoLimitReturnsFullList() throws Exception 
{
+        Metadata scoringOneMetadata = new Metadata("query-no-limit-scoring-1");
+        scoringOneMetadata.setName("Query No Limit Scoring 1");
+        Scoring scoringOne = new Scoring(scoringOneMetadata);
+        scoringOne.setElements(new ArrayList<>());
+        segmentService.setScoringDefinition(scoringOne);
+
+        Metadata scoringTwoMetadata = new Metadata("query-no-limit-scoring-2");
+        scoringTwoMetadata.setName("Query No Limit Scoring 2");
+        Scoring scoringTwo = new Scoring(scoringTwoMetadata);
+        scoringTwo.setElements(new ArrayList<>());
+        segmentService.setScoringDefinition(scoringTwo);
+
+        keepTrying("Scorings should be listed",
+                () -> segmentService.getScoringMetadatas(0, 50, 
null).getList(),
+                list -> list.stream().anyMatch(m -> 
"query-no-limit-scoring-1".equals(m.getId()))
+                        && list.stream().anyMatch(m -> 
"query-no-limit-scoring-2".equals(m.getId())),
+                DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+        PartialList<?> noLimit = postMetadataQuery("/cxs/scoring/query", "{}");
+        // Like the persistence services, a query without a limit returns 
defaultQueryLimit items (10 unless configured)
+        Assert.assertTrue("totalSize should cover the full list", 
noLimit.getTotalSize() >= 2);
+        Assert.assertEquals("Query without limit should return the default 
page", Math.min(noLimit.getTotalSize(), 10), noLimit.getList().size());
+
+        // 2.x/3.0 clients send -1 for "all" and persistence-style sort 
criteria
+        List<String> sortedIds = 
metadataQueryIds(postMetadataQuery("/cxs/scoring/query", 
"{\"limit\":-1,\"sortby\":\"metadata.name:desc\"}"));
+        Assert.assertTrue("Should return both created scorings", 
sortedIds.containsAll(Arrays.asList("query-no-limit-scoring-1", 
"query-no-limit-scoring-2")));
+        Assert.assertTrue("Scorings should be sorted by name descending", 
sortedIds.indexOf("query-no-limit-scoring-2") < 
sortedIds.indexOf("query-no-limit-scoring-1"));
+
+        // Unknown sort fields are ignored, as they were by the persistence 
services
+        Assert.assertTrue("Unknown sort field should be ignored",
+                metadataQueryIds(postMetadataQuery("/cxs/scoring/query", 
"{\"limit\":-1,\"sortby\":\"notAField:asc\"}")).contains("query-no-limit-scoring-1"));
+    }
+
+    private PartialList<?> postMetadataQuery(String path, String body) throws 
Exception {
+        HttpPost request = new HttpPost(getFullUrl(path));
+        request.setEntity(new StringEntity(body, 
ContentType.APPLICATION_JSON));
+        try (CloseableHttpResponse response = executeHttpRequest(request)) {
+            Assert.assertEquals("Query " + body + " on " + path + " should 
return 200", 200, response.getStatusLine().getStatusCode());
+            PartialList<?> result = 
getObjectMapper().readValue(EntityUtils.toString(response.getEntity()), 
PartialList.class);
+            Assert.assertNotNull(result);
+            Assert.assertNotNull(result.getList());
+            return result;
+        }
+    }
+
+    private List<String> metadataQueryIds(PartialList<?> result) {
+        List<String> ids = new ArrayList<>();
+        for (Object item : result.getList()) {
+            ids.add((String) ((Map<?, ?>) item).get("id"));
+        }
+        return ids;
+    }
 }
diff --git a/manual/src/main/asciidoc/configuration.adoc 
b/manual/src/main/asciidoc/configuration.adoc
index c3b037ce1..3ba40441e 100644
--- a/manual/src/main/asciidoc/configuration.adoc
+++ b/manual/src/main/asciidoc/configuration.adoc
@@ -105,6 +105,18 @@ 
org.apache.unomi.opensearch.password=${env:OPENSEARCH_INITIAL_ADMIN_PASSWORD:-ad
 org.apache.unomi.opensearch.sslTrustAllCertificates=true
 ----
 
+A query that does not give a size (for example a `/cxs/segments/query` body 
without `limit`) returns
+`defaultQueryLimit` results, 10 by default. A size of `-1` returns every 
result. The setting applies to every
+search, including the segment and scoring lists that merge system and tenant 
items:
+
+[source]
+----
+# env UNOMI_ELASTICSEARCH_DEFAULTQUERYLIMIT
+org.apache.unomi.elasticsearch.defaultQueryLimit=10
+# env UNOMI_OPENSEARCH_DEFAULTQUERYLIMIT
+org.apache.unomi.opensearch.defaultQueryLimit=10
+----
+
 To select which search engine to use, you can:
 1. Use the appropriate configuration properties above
 2. When building from source, use the appropriate Maven profile:
diff --git a/manual/src/main/asciidoc/migrations/migrate-3.0-to-4.0.adoc 
b/manual/src/main/asciidoc/migrations/migrate-3.0-to-4.0.adoc
index 5444d8cf6..4f7ae942a 100644
--- a/manual/src/main/asciidoc/migrations/migrate-3.0-to-4.0.adoc
+++ b/manual/src/main/asciidoc/migrations/migrate-3.0-to-4.0.adoc
@@ -266,6 +266,63 @@ This affects every endpoint that takes a `Query`, among 
them `/cxs/profiles/sear
 * A client that omits the `condition` field entirely still searches over 
everything, and it needs no change.
 * A client whose condition names a type that the server does not know now 
reads an empty list where it used to read every item. Check the server log for 
the condition type it names, and deploy the plugin that defines it.
 
+[#_segment_and_scoring_lists]
+==== Segment and scoring lists merge system and tenant items (4.0)
+
+A tenant sees its own segments and scoring plans together with those of the 
system tenant, and a tenant item
+replaces a system item with the same id. Because the two sets are merged after 
they are read, 4.0 sorts and pages
+the segment and scoring lists itself instead of leaving it to the search 
engine. This applies to
+`GET /cxs/segments`, `POST /cxs/segments/query`, `GET /cxs/scoring`, `POST 
/cxs/scoring/query` and the GraphQL
+`findSegments` query.
+
+Paging and sorting follow the 3.0 rules:
+
+* `offset` and `limit` page the merged list. A missing `limit` returns 
`defaultQueryLimit` items, 10 unless
+  configured (see 
<<changing_the_default_configuration_using_property_files,the search engine 
configuration>>), and `-1` returns every item.
+  The response echoes the requested `offset` and `limit`.
+* `sortby` takes the same syntax: comma-separated `field` or `field:desc` 
criteria. Any criterion other than an
+  exact `:desc` suffix sorts ascending.
+* A field with no value sorts last in both directions. A field with several 
values, such as `metadata.tags`,
+  sorts on its smallest value ascending and its largest value descending.
+* A sort field the server does not know is ignored, as it was when the search 
engine ignored an unmapped field.
+
+The sortable fields are `metadata.id`, `metadata.name`, 
`metadata.description`, `metadata.scope`,
+`metadata.tags`, `metadata.systemTags`, `metadata.enabled`, 
`metadata.missingPlugins`, `metadata.hidden`,
+`metadata.readOnly`, `itemId`, `tenantId`, `createdBy`, `lastModifiedBy`, 
`creationDate`,
+`lastModificationDate` and `lastSyncDate`. Each `metadata.*` field can also be 
named without its prefix, for
+example `name`.
+
+.What changed
+[cols="1,2,2", options="header"]
+|===
+| Request | 3.0 | 4.0
+
+| `limit` is `-1`
+| Every item, with `totalSize` reported as `0`.
+| Every item, with `totalSize` reported as the number of items.
+
+| Several items have the same value for the sort field
+| Their order was left to the search engine.
+| They are ordered by `itemId`, so pages are stable.
+
+| `sortby` names a field without its `metadata.` prefix, or an audit field 
such as `creationDate`
+| Ignored.
+| Sorted on.
+
+| A text value longer than 256 characters, in a sort
+| Sorted as a missing value, because the search engine does not index it for 
sorting.
+| Sorted on its full value.
+
+| A negative `offset`, or `offset` plus `limit` above 10,000
+| Rejected by the search engine.
+| A negative offset counts as `0`, and large pages are returned.
+|===
+
+===== Migrating a client
+
+* A client that pages with `limit` and `offset`, or reads every item with 
`-1`, needs no change.
+* A client that read `totalSize` after asking for `-1` now gets the real count 
instead of `0`.
+
 === Migrating your existing data
 
 ==== Multi-Tenancy Impact
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 fea126b58..46cdad5ba 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
@@ -130,7 +130,7 @@ public class ElasticSearchPersistenceServiceImpl implements 
PersistenceService,
     private ConditionESQueryBuilderDispatcher 
conditionESQueryBuilderDispatcher;
     private Map<String, String> routingByType;
 
-    private Integer defaultQueryLimit = 10;
+    private int defaultQueryLimit = 10;
     private final Integer removeByQueryTimeoutInMinutes = 10;
     private Integer taskWaitingTimeout = 3600000;
     private Integer taskWaitingPollingInterval = 1000;
@@ -243,10 +243,15 @@ public class ElasticSearchPersistenceServiceImpl 
implements PersistenceService,
         this.indexMaxDocValueFieldsSearch = indexMaxDocValueFieldsSearch;
     }
 
-    public void setDefaultQueryLimit(Integer defaultQueryLimit) {
+    public void setDefaultQueryLimit(int defaultQueryLimit) {
         this.defaultQueryLimit = defaultQueryLimit;
     }
 
+    @Override
+    public int getDefaultQueryLimit() {
+        return defaultQueryLimit;
+    }
+
     public void setRoutingByType(Map<String, String> routingByType) {
         this.routingByType = routingByType;
     }
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 5b4270f57..a83d6a7b6 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
@@ -127,7 +127,7 @@ public class OpenSearchPersistenceServiceImpl implements 
PersistenceService, Syn
     private ConditionOSQueryBuilderDispatcher 
conditionOSQueryBuilderDispatcher;
     private Map<String, String> routingByType;
 
-    private Integer defaultQueryLimit = 10;
+    private int defaultQueryLimit = 10;
     private Integer removeByQueryTimeoutInMinutes = 10;
     private Integer taskWaitingTimeout = 3600000;
     private Integer taskWaitingPollingInterval = 1000;
@@ -249,10 +249,15 @@ public class OpenSearchPersistenceServiceImpl implements 
PersistenceService, Syn
         this.indexMaxDocValueFieldsSearch = indexMaxDocValueFieldsSearch;
     }
 
-    public void setDefaultQueryLimit(Integer defaultQueryLimit) {
+    public void setDefaultQueryLimit(int defaultQueryLimit) {
         this.defaultQueryLimit = defaultQueryLimit;
     }
 
+    @Override
+    public int getDefaultQueryLimit() {
+        return defaultQueryLimit;
+    }
+
     public void setRoutingByType(Map<String, String> routingByType) {
         this.routingByType = routingByType;
     }
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 5289917e0..2c64c1b01 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
@@ -698,6 +698,16 @@ public interface PersistenceService {
      */
     Map<String, Long> aggregateWithOptimizedQuery(Condition filter, 
BaseAggregate aggregate, String itemType, int size);
 
+    /**
+     * Returns the number of results a query returns when it is not given a 
size (a negative size other than
+     * {@code -1}, which returns all results). Services that page results in 
memory use it to match the queries.
+     *
+     * @return the configured default query limit
+     */
+    default int getDefaultQueryLimit() {
+        return 10;
+    }
+
     /**
      * Refreshes persistence engine indices when required by the backend.
      */
diff --git 
a/services/src/main/java/org/apache/unomi/services/impl/segments/MergedMetadataListHelper.java
 
b/services/src/main/java/org/apache/unomi/services/impl/segments/MergedMetadataListHelper.java
new file mode 100644
index 000000000..1df7b90e1
--- /dev/null
+++ 
b/services/src/main/java/org/apache/unomi/services/impl/segments/MergedMetadataListHelper.java
@@ -0,0 +1,170 @@
+/*
+ * 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.services.impl.segments;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.unomi.api.Metadata;
+import org.apache.unomi.api.MetadataItem;
+import org.apache.unomi.api.PartialList;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+
+/**
+ * Sorts and pages merged system+tenant {@link MetadataItem} lists for segment 
and scoring queries.
+ * <p>
+ * Before multi-tenancy these lists were sorted and paged by the search 
engine, so this helper reproduces the
+ * persistence semantics that Unomi 2.x and 3.0 clients rely on:
+ * <ul>
+ *     <li>a size of {@code -1} returns every item, an unset ({@link 
Integer#MIN_VALUE}) or other negative size
+ *     returns the persistence default query limit, and the requested size is 
echoed back as the page size;</li>
+ *     <li>sort criteria use the persistence syntax ({@code 
field[:asc|:desc]}, comma separated), and criteria
+ *     on unknown fields are ignored rather than rejected;</li>
+ *     <li>missing values sort last in both directions, and multi-valued 
fields sort on their smallest value
+ *     ascending and largest value descending.</li>
+ * </ul>
+ * Only a fixed set of fields can be sorted on; values are read through plain 
getters, never by reflection on a
+ * caller-supplied path.
+ */
+final class MergedMetadataListHelper {
+
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(MergedMetadataListHelper.class);
+
+    private static final Map<String, Function<MetadataItem, Object>> 
SORT_FIELDS = new HashMap<>();
+
+    static {
+        addMetadataField("id", Metadata::getId);
+        addMetadataField("name", Metadata::getName);
+        addMetadataField("description", Metadata::getDescription);
+        addMetadataField("scope", Metadata::getScope);
+        addMetadataField("tags", Metadata::getTags);
+        addMetadataField("systemTags", Metadata::getSystemTags);
+        addMetadataField("enabled", Metadata::isEnabled);
+        addMetadataField("missingPlugins", Metadata::isMissingPlugins);
+        addMetadataField("hidden", Metadata::isHidden);
+        addMetadataField("readOnly", Metadata::isReadOnly);
+        SORT_FIELDS.put("itemId", MetadataItem::getItemId);
+        SORT_FIELDS.put("tenantId", MetadataItem::getTenantId);
+        SORT_FIELDS.put("createdBy", MetadataItem::getCreatedBy);
+        SORT_FIELDS.put("lastModifiedBy", MetadataItem::getLastModifiedBy);
+        SORT_FIELDS.put("creationDate", MetadataItem::getCreationDate);
+        SORT_FIELDS.put("lastModificationDate", 
MetadataItem::getLastModificationDate);
+        SORT_FIELDS.put("lastSyncDate", MetadataItem::getLastSyncDate);
+    }
+
+    private static final Comparator<MetadataItem> TIE_BREAKER =
+            Comparator.comparing(MetadataItem::getItemId, 
Comparator.nullsLast(Comparator.naturalOrder()));
+
+    private MergedMetadataListHelper() {
+    }
+
+    /**
+     * Registers a metadata field under both its persistence path ({@code 
metadata.name}) and its short alias
+     * ({@code name}).
+     */
+    private static void addMetadataField(String name, Function<Metadata, 
Object> getter) {
+        Function<MetadataItem, Object> extractor = item -> item.getMetadata() 
!= null ? getter.apply(item.getMetadata()) : null;
+        SORT_FIELDS.put("metadata." + name, extractor);
+        SORT_FIELDS.put(name, extractor);
+    }
+
+    /**
+     * @param defaultQueryLimit the number of items returned when no size is 
given, normally
+     *                          {@link 
org.apache.unomi.persistence.spi.PersistenceService#getDefaultQueryLimit()}
+     */
+    static <T extends MetadataItem> PartialList<Metadata> 
sortAndPage(Collection<T> mergedItems, int offset, int size, String sortBy,
+                                                                    int 
defaultQueryLimit) {
+        List<T> list = new ArrayList<>(mergedItems);
+        sort(list, sortBy);
+
+        int totalSize = list.size();
+        int fromIndex = Math.max(offset, 0);
+        int pageSize = size == -1 ? totalSize : (size < 0 ? defaultQueryLimit 
: size);
+        int toIndex = (int) Math.min((long) fromIndex + pageSize, totalSize);
+
+        List<Metadata> page = new ArrayList<>();
+        if (fromIndex < toIndex) {
+            for (T item : list.subList(fromIndex, toIndex)) {
+                page.add(item.getMetadata());
+            }
+        }
+        return new PartialList<>(page, offset, size, totalSize, 
PartialList.Relation.EQUAL);
+    }
+
+    static void sort(List<? extends MetadataItem> items, String sortBy) {
+        Comparator<MetadataItem> comparator = buildComparator(sortBy);
+        if (comparator != null) {
+            items.sort(comparator.thenComparing(TIE_BREAKER));
+        }
+    }
+
+    private static Comparator<MetadataItem> buildComparator(String sortBy) {
+        if (StringUtils.isBlank(sortBy)) {
+            return null;
+        }
+        Comparator<MetadataItem> comparator = null;
+        for (String criterion : sortBy.split(",")) {
+            // Same parsing as the persistence services: the field is 
everything before the last ':' and the
+            // order is descending only for a ":desc" suffix.
+            String field = StringUtils.substringBeforeLast(criterion, 
":").trim();
+            Function<MetadataItem, Object> extractor = SORT_FIELDS.get(field);
+            if (extractor == null) {
+                LOGGER.debug("Ignoring unsupported sort field '{}' in '{}'", 
field, sortBy);
+                continue;
+            }
+            boolean descending = criterion.trim().endsWith(":desc");
+            Comparator<MetadataItem> fieldComparator = (left, right) ->
+                    compareValues(sortValue(extractor.apply(left), 
descending), sortValue(extractor.apply(right), descending), descending);
+            comparator = comparator == null ? fieldComparator : 
comparator.thenComparing(fieldComparator);
+        }
+        return comparator;
+    }
+
+    /**
+     * Reduces a multi-valued field to the value the search engine sorts on: 
the smallest one ascending, the largest
+     * one descending. An empty collection counts as a missing value.
+     */
+    @SuppressWarnings({"unchecked", "rawtypes"})
+    private static Object sortValue(Object value, boolean descending) {
+        if (!(value instanceof Collection)) {
+            return value;
+        }
+        Collection<Comparable> values = (Collection<Comparable>) value;
+        if (values.isEmpty()) {
+            return null;
+        }
+        return descending ? Collections.max(values) : Collections.min(values);
+    }
+
+    @SuppressWarnings({"unchecked", "rawtypes"})
+    private static int compareValues(Object left, Object right, boolean 
descending) {
+        if (left == null || right == null) {
+            // Missing values go last whatever the order.
+            return left == null ? (right == null ? 0 : 1) : -1;
+        }
+        int comparison = ((Comparable) left).compareTo(right);
+        return descending ? -comparison : comparison;
+    }
+}
diff --git 
a/services/src/main/java/org/apache/unomi/services/impl/segments/SegmentServiceImpl.java
 
b/services/src/main/java/org/apache/unomi/services/impl/segments/SegmentServiceImpl.java
index ed1b71911..d91fd9e94 100644
--- 
a/services/src/main/java/org/apache/unomi/services/impl/segments/SegmentServiceImpl.java
+++ 
b/services/src/main/java/org/apache/unomi/services/impl/segments/SegmentServiceImpl.java
@@ -279,91 +279,49 @@ public class SegmentServiceImpl extends 
AbstractMultiTypeCachingService implemen
 
     public PartialList<Metadata> getSegmentMetadatas(String scope, int offset, 
int size, String sortBy) {
         String currentTenantId = 
contextManager.getCurrentContext().getTenantId();
-        List<Metadata> details = new LinkedList<>();
+        Map<String, Segment> merged = new LinkedHashMap<>();
 
         // Get system tenant segments first
         if (!TenantService.SYSTEM_TENANT.equals(currentTenantId)) {
             contextManager.executeAsSystem(() -> {
-                Condition systemTenantCondition = new 
Condition(definitionsService.getConditionType("booleanCondition"));
-                systemTenantCondition.setParameter("operator", "and");
-                List<Condition> systemConditions = new ArrayList<>();
-
-                Condition systemTenantCheck = new 
Condition(definitionsService.getConditionType("sessionPropertyCondition"));
-                systemTenantCheck.setParameter("propertyName", "tenantId");
-                systemTenantCheck.setParameter("comparisonOperator", "equals");
-                systemTenantCheck.setParameter("propertyValue", 
TenantService.SYSTEM_TENANT);
-                systemConditions.add(systemTenantCheck);
-
-                if (scope != null) {
-                    Condition systemScopeCheck = new 
Condition(definitionsService.getConditionType("sessionPropertyCondition"));
-                    systemScopeCheck.setParameter("propertyName", 
"metadata.scope");
-                    systemScopeCheck.setParameter("comparisonOperator", 
"equals");
-                    systemScopeCheck.setParameter("propertyValue", scope);
-                    systemConditions.add(systemScopeCheck);
-                }
-
-                systemTenantCondition.setParameter("subConditions", 
systemConditions);
-
-                PartialList<Segment> systemSegments = 
persistenceService.query(systemTenantCondition, sortBy, Segment.class, 0, -1);
+                Condition systemTenantCondition = 
buildTenantScopeCondition(TenantService.SYSTEM_TENANT, scope);
+                PartialList<Segment> systemSegments = 
persistenceService.query(systemTenantCondition, null, Segment.class, 0, -1);
                 for (Segment definition : systemSegments.getList()) {
-                    details.add(definition.getMetadata());
+                    merged.put(definition.getMetadata().getId(), definition);
                 }
                 return null;
             });
         }
 
         // Get current tenant segments (will override system segments with 
same ID)
-        Condition tenantCondition = new 
Condition(definitionsService.getConditionType("booleanCondition"));
-        tenantCondition.setParameter("operator", "and");
-        List<Condition> conditions = new ArrayList<>();
+        Condition tenantCondition = buildTenantScopeCondition(currentTenantId, 
scope);
+        PartialList<Segment> segments = 
persistenceService.query(tenantCondition, null, Segment.class, 0, -1);
+        for (Segment definition : segments.getList()) {
+            merged.put(definition.getMetadata().getId(), definition);
+        }
 
+        return MergedMetadataListHelper.sortAndPage(merged.values(), offset, 
size, sortBy, persistenceService.getDefaultQueryLimit());
+    }
+
+    private Condition buildTenantScopeCondition(String tenantId, String scope) 
{
         Condition tenantCheck = new 
Condition(definitionsService.getConditionType("sessionPropertyCondition"));
         tenantCheck.setParameter("propertyName", "tenantId");
         tenantCheck.setParameter("comparisonOperator", "equals");
-        tenantCheck.setParameter("propertyValue", currentTenantId);
-        conditions.add(tenantCheck);
-
-        if (scope != null) {
-            Condition scopeCheck = new 
Condition(definitionsService.getConditionType("sessionPropertyCondition"));
-            scopeCheck.setParameter("propertyName", "metadata.scope");
-            scopeCheck.setParameter("comparisonOperator", "equals");
-            scopeCheck.setParameter("propertyValue", scope);
-            conditions.add(scopeCheck);
-        }
-
-        tenantCondition.setParameter("subConditions", conditions);
-
-        PartialList<Segment> segments = 
persistenceService.query(tenantCondition, sortBy, Segment.class, 0, -1);
-        Map<String, Metadata> mergedDetails = new HashMap<>();
+        tenantCheck.setParameter("propertyValue", tenantId);
 
-        // Add system tenant segments first
-        for (Metadata metadata : details) {
-            mergedDetails.put(metadata.getId(), metadata);
+        if (scope == null) {
+            return tenantCheck;
         }
 
-        // Override with current tenant segments
-        for (Segment definition : segments.getList()) {
-            mergedDetails.put(definition.getMetadata().getId(), 
definition.getMetadata());
-        }
-
-        // Convert to list and apply pagination
-        List<Metadata> finalDetails = new ArrayList<>(mergedDetails.values());
-        if (sortBy != null) {
-            // TODO: Implement sorting of merged results
-        }
+        Condition scopeCheck = new 
Condition(definitionsService.getConditionType("sessionPropertyCondition"));
+        scopeCheck.setParameter("propertyName", "metadata.scope");
+        scopeCheck.setParameter("comparisonOperator", "equals");
+        scopeCheck.setParameter("propertyValue", scope);
 
-        int totalSize = finalDetails.size();
-        int fromIndex = offset;
-        int toIndex = offset + size;
-        if (fromIndex >= totalSize) {
-            return new PartialList<Metadata>(new ArrayList<>(), offset, size, 
totalSize, PartialList.Relation.EQUAL);
-        }
-        if (toIndex > totalSize) {
-            toIndex = totalSize;
-        }
-        finalDetails = finalDetails.subList(fromIndex, toIndex);
-
-        return new PartialList<Metadata>(finalDetails, offset, size, 
totalSize, PartialList.Relation.EQUAL);
+        Condition combined = new 
Condition(definitionsService.getConditionType("booleanCondition"));
+        combined.setParameter("operator", "and");
+        combined.setParameter("subConditions", Arrays.asList(tenantCheck, 
scopeCheck));
+        return combined;
     }
 
     public PartialList<Metadata> getSegmentMetadatas(Query query) {
@@ -1598,62 +1556,34 @@ public class SegmentServiceImpl extends 
AbstractMultiTypeCachingService implemen
 
     protected <T extends MetadataItem> PartialList<Metadata> getMetadatas(int 
offset, int size, String sortBy, Class<T> clazz) {
         String currentTenantId = 
contextManager.getCurrentContext().getTenantId();
-        List<Metadata> details = new LinkedList<>();
+        Map<String, T> merged = new LinkedHashMap<>();
 
         // Get system tenant items first
         if (!TenantService.SYSTEM_TENANT.equals(currentTenantId)) {
             contextManager.executeAsSystem(() -> {
-                Condition systemTenantCondition = new 
Condition(definitionsService.getConditionType("sessionPropertyCondition"));
-                systemTenantCondition.setParameter("propertyName", "tenantId");
-                systemTenantCondition.setParameter("comparisonOperator", 
"equals");
-                systemTenantCondition.setParameter("propertyValue", 
TenantService.SYSTEM_TENANT);
-                PartialList<T> systemItems = 
persistenceService.query(systemTenantCondition, sortBy, clazz, 0, -1);
+                Condition systemTenantCondition = 
buildTenantScopeCondition(TenantService.SYSTEM_TENANT, null);
+                PartialList<T> systemItems = 
persistenceService.query(systemTenantCondition, null, clazz, 0, -1);
                 for (T definition : systemItems.getList()) {
-                    details.add(definition.getMetadata());
+                    merged.put(definition.getMetadata().getId(), definition);
                 }
                 return null;
             });
         }
 
         // Get current tenant items (will override system items with same ID)
-        Condition tenantCondition = new 
Condition(definitionsService.getConditionType("sessionPropertyCondition"));
-        tenantCondition.setParameter("propertyName", "tenantId");
-        tenantCondition.setParameter("comparisonOperator", "equals");
-        tenantCondition.setParameter("propertyValue", currentTenantId);
-        PartialList<T> items = persistenceService.query(tenantCondition, 
sortBy, clazz, 0, -1);
-        Map<String, Metadata> mergedDetails = new HashMap<>();
-
-        // Add system tenant items first
-        for (Metadata metadata : details) {
-            mergedDetails.put(metadata.getId(), metadata);
-        }
-
-        // Override with current tenant items
+        Condition tenantCondition = buildTenantScopeCondition(currentTenantId, 
null);
+        PartialList<T> items = persistenceService.query(tenantCondition, null, 
clazz, 0, -1);
         for (T definition : items.getList()) {
-            mergedDetails.put(definition.getMetadata().getId(), 
definition.getMetadata());
+            merged.put(definition.getMetadata().getId(), definition);
         }
 
-        // Convert to list and apply pagination
-        List<Metadata> finalDetails = new ArrayList<>(mergedDetails.values());
-        if (sortBy != null) {
-            // TODO: Implement sorting of merged results
-        }
-
-        int totalSize = finalDetails.size();
-        int fromIndex = offset;
-        int toIndex = offset + size;
-        if (fromIndex >= totalSize) {
-            return new PartialList<Metadata>(new ArrayList<>(), offset, size, 
totalSize, PartialList.Relation.EQUAL);
-        }
-        if (toIndex > totalSize) {
-            toIndex = totalSize;
-        }
-        finalDetails = finalDetails.subList(fromIndex, toIndex);
-
-        return new PartialList<Metadata>(finalDetails, offset, size, 
totalSize, PartialList.Relation.EQUAL);
+        return MergedMetadataListHelper.sortAndPage(merged.values(), offset, 
size, sortBy, persistenceService.getDefaultQueryLimit());
     }
 
     protected <T extends MetadataItem> PartialList<Metadata> 
getMetadatas(Query query, Class<T> clazz) {
+        if (query.isForceRefresh()) {
+            persistenceService.refreshIndex(clazz);
+        }
         if (query.getCondition() != null) {
             
definitionsService.getConditionValidationService().validate(query.getCondition());
         }
@@ -1663,77 +1593,40 @@ public class SegmentServiceImpl extends 
AbstractMultiTypeCachingService implemen
             return new PartialList<>();
         }
 
-        List<Metadata> details = new LinkedList<>();
+        Map<String, T> merged = new LinkedHashMap<>();
 
         // Get system tenant items first
         if (!TenantService.SYSTEM_TENANT.equals(currentTenantId)) {
             contextManager.executeAsSystem(() -> {
-                Condition systemTenantCondition = new 
Condition(definitionsService.getConditionType("booleanCondition"));
-                systemTenantCondition.setParameter("operator", "and");
-                List<Condition> systemConditions = new ArrayList<>();
-
-                Condition systemTenantCheck = new 
Condition(definitionsService.getConditionType("sessionPropertyCondition"));
-                systemTenantCheck.setParameter("propertyName", "tenantId");
-                systemTenantCheck.setParameter("comparisonOperator", "equals");
-                systemTenantCheck.setParameter("propertyValue", 
TenantService.SYSTEM_TENANT);
-                systemConditions.add(systemTenantCheck);
-
-                systemConditions.add(query.getCondition());
-                systemTenantCondition.setParameter("subConditions", 
systemConditions);
-
-                PartialList<T> systemItems = 
persistenceService.query(systemTenantCondition, query.getSortby(), clazz, 0, 
-1);
+                Condition systemTenantCondition = 
buildTenantQueryCondition(TenantService.SYSTEM_TENANT, query.getCondition());
+                PartialList<T> systemItems = 
persistenceService.query(systemTenantCondition, null, clazz, 0, -1);
                 for (T definition : systemItems.getList()) {
-                    details.add(definition.getMetadata());
+                    merged.put(definition.getMetadata().getId(), definition);
                 }
                 return null;
             });
         }
 
         // Get current tenant items (will override system items with same ID)
-        Condition tenantCondition = new 
Condition(definitionsService.getConditionType("booleanCondition"));
-        tenantCondition.setParameter("operator", "and");
-        List<Condition> conditions = new ArrayList<>();
-
-        Condition tenantCheck = new 
Condition(definitionsService.getConditionType("sessionPropertyCondition"));
-        tenantCheck.setParameter("propertyName", "tenantId");
-        tenantCheck.setParameter("comparisonOperator", "equals");
-        tenantCheck.setParameter("propertyValue", currentTenantId);
-        conditions.add(tenantCheck);
-
-        conditions.add(query.getCondition());
-        tenantCondition.setParameter("subConditions", conditions);
-
-        PartialList<T> items = persistenceService.query(tenantCondition, 
query.getSortby(), clazz, 0, -1);
-        Map<String, Metadata> mergedDetails = new HashMap<>();
-
-        // Add system tenant items first
-        for (Metadata metadata : details) {
-            mergedDetails.put(metadata.getId(), metadata);
-        }
-
-        // Override with current tenant items
+        Condition tenantCondition = buildTenantQueryCondition(currentTenantId, 
query.getCondition());
+        PartialList<T> items = persistenceService.query(tenantCondition, null, 
clazz, 0, -1);
         for (T definition : items.getList()) {
-            mergedDetails.put(definition.getMetadata().getId(), 
definition.getMetadata());
+            merged.put(definition.getMetadata().getId(), definition);
         }
 
-        // Convert to list and apply pagination
-        List<Metadata> finalDetails = new ArrayList<>(mergedDetails.values());
-        if (query.getSortby() != null) {
-            // TODO: Implement sorting of merged results
-        }
+        return MergedMetadataListHelper.sortAndPage(merged.values(), 
query.getOffset(), query.getLimit(), query.getSortby(),
+                persistenceService.getDefaultQueryLimit());
+    }
 
-        int totalSize = finalDetails.size();
-        int fromIndex = query.getOffset();
-        int toIndex = fromIndex + query.getLimit();
-        if (fromIndex >= totalSize) {
-            return new PartialList<Metadata>(new ArrayList<>(), 
query.getOffset(), query.getLimit(), totalSize, PartialList.Relation.EQUAL);
+    private Condition buildTenantQueryCondition(String tenantId, Condition 
queryCondition) {
+        Condition tenantCheck = buildTenantScopeCondition(tenantId, null);
+        if (queryCondition == null) {
+            return tenantCheck;
         }
-        if (toIndex > totalSize) {
-            toIndex = totalSize;
-        }
-        finalDetails = finalDetails.subList(fromIndex, toIndex);
-
-        return new PartialList<Metadata>(finalDetails, query.getOffset(), 
query.getLimit(), totalSize, PartialList.Relation.EQUAL);
+        Condition combined = new 
Condition(definitionsService.getConditionType("booleanCondition"));
+        combined.setParameter("operator", "and");
+        combined.setParameter("subConditions", Arrays.asList(tenantCheck, 
queryCondition));
+        return combined;
     }
 
 
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 210f6913c..2f71e8ce6 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
@@ -93,7 +93,7 @@ public class InMemoryPersistenceServiceImpl implements 
PersistenceService {
     private final Map<String, RefreshPolicy> itemTypeToRefreshPolicy = new 
ConcurrentHashMap<>();
 
     // Default query limit (simulates Elasticsearch/OpenSearch default query 
limit)
-    private Integer defaultQueryLimit = 10;
+    private int defaultQueryLimit = 10;
 
     // Tenant transformation listeners (simulates Elasticsearch/OpenSearch 
tenant transformations)
     private final List<TenantTransformationListener> transformationListeners = 
new ArrayList<>();
@@ -1050,8 +1050,8 @@ public class InMemoryPersistenceServiceImpl implements 
PersistenceService {
         List<T> matchingItems = query(condition, sortBy, clazz);
         int totalSize = matchingItems.size();
 
-        // Apply default query limit when size < 0 (simulates 
Elasticsearch/OpenSearch behavior)
-        int effectiveSize = size < 0 ? defaultQueryLimit : size;
+        // Apply default query limit when size < 0, except -1 which returns 
everything (simulates Elasticsearch/OpenSearch behavior)
+        int effectiveSize = size == -1 ? totalSize : (size < 0 ? 
defaultQueryLimit : size);
 
         // If size is negative and no scroll, use default limit
         if (size < 0 && scrollTimeValidity == null) {
@@ -2203,8 +2203,8 @@ public class InMemoryPersistenceServiceImpl implements 
PersistenceService {
 
     private <T extends Item> PartialList<T> createPartialList(List<T> items, 
int offset, int size) {
         int totalSize = items.size();
-        // Apply default query limit when size < 0 (simulates 
Elasticsearch/OpenSearch behavior)
-        int effectiveSize = size < 0 ? defaultQueryLimit : size;
+        // Apply default query limit when size < 0, except -1 which returns 
everything (simulates Elasticsearch/OpenSearch behavior)
+        int effectiveSize = size == -1 ? totalSize : (size < 0 ? 
defaultQueryLimit : size);
         int fromIndex = Math.min(offset, totalSize);
         int toIndex = Math.min(offset + effectiveSize, totalSize);
         List<T> pageItems = items.subList(fromIndex, toIndex);
@@ -2919,7 +2919,8 @@ public class InMemoryPersistenceServiceImpl implements 
PersistenceService {
      *
      * @return the default query limit
      */
-    public Integer getDefaultQueryLimit() {
+    @Override
+    public int getDefaultQueryLimit() {
         return defaultQueryLimit;
     }
 
@@ -2928,8 +2929,8 @@ public class InMemoryPersistenceServiceImpl implements 
PersistenceService {
      *
      * @param defaultQueryLimit the default query limit to set
      */
-    public void setDefaultQueryLimit(Integer defaultQueryLimit) {
-        this.defaultQueryLimit = defaultQueryLimit != null && 
defaultQueryLimit > 0 ? defaultQueryLimit : 10;
+    public void setDefaultQueryLimit(int defaultQueryLimit) {
+        this.defaultQueryLimit = defaultQueryLimit > 0 ? defaultQueryLimit : 
10;
     }
 
     /**
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 3d730133d..b998ae172 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
@@ -6363,7 +6363,7 @@ public class InMemoryPersistenceServiceImplTest {
     @Nested
     class DefaultQueryLimitTests {
         @Test
-        void shouldApplyDefaultLimitWhenSizeIsNegative() {
+        void shouldApplyDefaultLimitOnlyWhenSizeIsUnset() {
             // Create service with default limit of 10
             InMemoryPersistenceServiceImpl service = new 
InMemoryPersistenceServiceImpl(
                     executionContextManager, conditionEvaluatorDispatcher);
@@ -6379,11 +6379,14 @@ public class InMemoryPersistenceServiceImplTest {
             // The totalSize is calculated from items that are currently 
queryable (refreshed)
             // So we need to ensure all items are refreshed before verifying 
totalSize
             PartialList<TestMetadataItem> result = TestHelper.retryUntil(
-                () -> service.query(null, null, TestMetadataItem.class, 0, -1),
+                () -> service.query(null, null, TestMetadataItem.class, 0, 
Integer.MIN_VALUE),
                 r -> r != null && r.getTotalSize() == 20 && r.getList().size() 
== 10
             );
-            assertEquals(10, result.getList().size(), "Query with size=-1 
should return default limit of 10");
+            assertEquals(10, result.getList().size(), "Query with an unset 
size should return default limit of 10");
             assertEquals(20, result.getTotalSize(), "Total size should be 20");
+
+            PartialList<TestMetadataItem> all = service.query(null, null, 
TestMetadataItem.class, 0, -1);
+            assertEquals(20, all.getList().size(), "Query with size=-1 should 
return all items, like Elasticsearch/OpenSearch");
         }
 
         @Test
@@ -6403,10 +6406,10 @@ public class InMemoryPersistenceServiceImplTest {
             // The totalSize is calculated from items that are currently 
queryable (refreshed)
             // So we need to ensure all items are refreshed before verifying 
totalSize
             PartialList<TestMetadataItem> result = TestHelper.retryUntil(
-                () -> service.query(null, null, TestMetadataItem.class, 0, -1, 
null),
+                () -> service.query(null, null, TestMetadataItem.class, 0, 
Integer.MIN_VALUE, null),
                 r -> r != null && r.getTotalSize() == 20 && r.getList().size() 
== 10
             );
-            assertEquals(10, result.getList().size(), "Query with size=-1 
should return default limit of 10");
+            assertEquals(10, result.getList().size(), "Query with an unset 
size should return default limit of 10");
             assertEquals(20, result.getTotalSize(), "Total size should be 20");
         }
 
@@ -6449,10 +6452,10 @@ public class InMemoryPersistenceServiceImplTest {
 
             // Retry until both list size and totalSize are correct — same 
race as shouldRespectExplicitSizeWhenProvided.
             PartialList<TestMetadataItem> result = TestHelper.retryUntil(
-                () -> service.query(null, null, TestMetadataItem.class, 0, -1),
+                () -> service.query(null, null, TestMetadataItem.class, 0, 
Integer.MIN_VALUE),
                 r -> r != null && r.getTotalSize() == 20 && r.getList().size() 
== 5
             );
-            assertEquals(5, result.getList().size(), "Query with size=-1 
should return custom default limit of 5");
+            assertEquals(5, result.getList().size(), "Query with an unset size 
should return custom default limit of 5");
             assertEquals(20, result.getTotalSize(), "Total size should be 20");
         }
 
@@ -6470,12 +6473,12 @@ public class InMemoryPersistenceServiceImplTest {
                 service.save(item);
             }
 
-            // Query with field matching and size = -1 - retry until items are 
available
+            // Query with field matching and an unset size - retry until items 
are available
             PartialList<TestMetadataItem> result = 
TestHelper.retryQueryUntilAvailable(
-                () -> service.query("name", "test", null, 
TestMetadataItem.class, 0, -1),
+                () -> service.query("name", "test", null, 
TestMetadataItem.class, 0, Integer.MIN_VALUE),
                 10
             );
-            assertEquals(10, result.getList().size(), "Query with size=-1 
should return default limit of 10");
+            assertEquals(10, result.getList().size(), "Query with an unset 
size should return default limit of 10");
         }
 
         @Test
@@ -6496,10 +6499,10 @@ public class InMemoryPersistenceServiceImplTest {
             // The totalSize is calculated from items that are currently 
queryable (refreshed)
             // So we need to ensure all items are refreshed before verifying 
totalSize
             PartialList<TestMetadataItem> result = TestHelper.retryUntil(
-                () -> service.rangeQuery("numericValue", "0", "20", null, 
TestMetadataItem.class, 0, -1),
+                () -> service.rangeQuery("numericValue", "0", "20", null, 
TestMetadataItem.class, 0, Integer.MIN_VALUE),
                 r -> r != null && r.getTotalSize() == 20 && r.getList().size() 
== 10
             );
-            assertEquals(10, result.getList().size(), "Range query with 
size=-1 should return default limit of 10");
+            assertEquals(10, result.getList().size(), "Range query with an 
unset size should return default limit of 10");
             assertEquals(20, result.getTotalSize(), "Total size should be 20");
         }
 
diff --git 
a/services/src/test/java/org/apache/unomi/services/impl/segments/MergedMetadataListHelperTest.java
 
b/services/src/test/java/org/apache/unomi/services/impl/segments/MergedMetadataListHelperTest.java
new file mode 100644
index 000000000..15df89f3d
--- /dev/null
+++ 
b/services/src/test/java/org/apache/unomi/services/impl/segments/MergedMetadataListHelperTest.java
@@ -0,0 +1,280 @@
+/*
+ * 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.services.impl.segments;
+
+import org.apache.unomi.api.Metadata;
+import org.apache.unomi.api.PartialList;
+import org.apache.unomi.api.segments.Segment;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class MergedMetadataListHelperTest {
+
+    private static final int DEFAULT_QUERY_LIMIT = 10;
+
+    @Test
+    public void testSortByNameAscendingAndDescending() {
+        List<Segment> items = threeSegments();
+
+        MergedMetadataListHelper.sort(items, "name:asc");
+        assertEquals(Arrays.asList("a", "b", "c"), ids(items));
+
+        MergedMetadataListHelper.sort(items, "metadata.name:desc");
+        assertEquals(Arrays.asList("c", "b", "a"), ids(items));
+
+        MergedMetadataListHelper.sort(items, "metadata.name");
+        assertEquals(Arrays.asList("a", "b", "c"), ids(items));
+    }
+
+    @Test
+    public void testSortByCreationDateAscendingAndDescending() {
+        List<Segment> items = threeSegments();
+
+        MergedMetadataListHelper.sort(items, "creationDate:asc");
+        assertEquals(Arrays.asList("c", "b", "a"), ids(items));
+
+        MergedMetadataListHelper.sort(items, "creationDate:desc");
+        assertEquals(Arrays.asList("a", "b", "c"), ids(items));
+    }
+
+    @Test
+    public void testMultipleCriteriaAndBooleanField() {
+        Segment disabledB = segment("b", "Same", daysAgo(1));
+        disabledB.getMetadata().setEnabled(false);
+        Segment enabledC = segment("c", "Same", daysAgo(2));
+        Segment enabledA = segment("a", "Other", daysAgo(3));
+        List<Segment> items = new ArrayList<>(Arrays.asList(disabledB, 
enabledC, enabledA));
+
+        MergedMetadataListHelper.sort(items, 
"metadata.enabled:desc,metadata.name:asc");
+        assertEquals(Arrays.asList("a", "c", "b"), ids(items));
+    }
+
+    @Test
+    public void testFieldsSortableInPreviousVersionsAreSupported() {
+        Segment readOnly = segment("a", "Alpha", daysAgo(1));
+        readOnly.getMetadata().setReadOnly(true);
+        List<Segment> items = new ArrayList<>(Arrays.asList(readOnly, 
segment("b", "Bravo", daysAgo(2))));
+
+        MergedMetadataListHelper.sort(items, "metadata.readOnly:asc");
+        assertEquals(Arrays.asList("b", "a"), ids(items));
+
+        for (String field : Arrays.asList("metadata.hidden", 
"metadata.missingPlugins", "metadata.tags",
+                "metadata.systemTags", "metadata.description", 
"metadata.scope", "metadata.id", "itemId",
+                "tenantId", "createdBy", "lastModifiedBy", 
"lastModificationDate", "lastSyncDate")) {
+            assertDoesNotThrow(() -> MergedMetadataListHelper.sort(items, 
field + ":desc"), field);
+        }
+    }
+
+    @Test
+    public void 
testMultiValuedFieldSortsOnSmallestAscendingAndLargestDescending() {
+        Segment a = segment("a", "Alpha", daysAgo(1));
+        a.getMetadata().setTags(new LinkedHashSet<>(Arrays.asList("m", "z")));
+        Segment b = segment("b", "Bravo", daysAgo(2));
+        b.getMetadata().setTags(new LinkedHashSet<>(Arrays.asList("b", "n")));
+        Segment untagged = segment("c", "Charlie", daysAgo(3));
+        List<Segment> items = new ArrayList<>(Arrays.asList(untagged, a, b));
+
+        MergedMetadataListHelper.sort(items, "metadata.tags:asc");
+        assertEquals(Arrays.asList("b", "a", "c"), ids(items));
+
+        MergedMetadataListHelper.sort(items, "metadata.tags:desc");
+        assertEquals(Arrays.asList("a", "b", "c"), ids(items));
+    }
+
+    @Test
+    public void testMissingValuesSortLastInBothDirections() {
+        Segment noDate = segment("x", "Xray", null);
+        List<Segment> items = new ArrayList<>(Arrays.asList(noDate, 
segment("a", "Alpha", daysAgo(1)), segment("b", "Bravo", daysAgo(2))));
+
+        MergedMetadataListHelper.sort(items, "creationDate:asc");
+        assertEquals(Arrays.asList("b", "a", "x"), ids(items));
+
+        MergedMetadataListHelper.sort(items, "creationDate:desc");
+        assertEquals(Arrays.asList("a", "b", "x"), ids(items));
+    }
+
+    @Test
+    public void testTiesAreBrokenByItemId() {
+        List<Segment> items = new ArrayList<>(Arrays.asList(
+                segment("c", "Same", daysAgo(1)),
+                segment("a", "Same", daysAgo(2)),
+                segment("b", "Same", daysAgo(3))
+        ));
+
+        MergedMetadataListHelper.sort(items, "name:desc");
+        assertEquals(Arrays.asList("a", "b", "c"), ids(items));
+    }
+
+    @Test
+    public void testUnknownOrMalformedCriteriaAreIgnored() {
+        List<Segment> items = threeSegments();
+        List<String> original = ids(items);
+
+        MergedMetadataListHelper.sort(items, "unknownField:asc");
+        assertEquals(original, ids(items));
+
+        MergedMetadataListHelper.sort(items, ",unknownField:desc,");
+        assertEquals(original, ids(items));
+
+        MergedMetadataListHelper.sort(items, "unknownField:desc,name:asc");
+        assertEquals(Arrays.asList("a", "b", "c"), ids(items));
+    }
+
+    @Test
+    public void testOnlyExactDescSuffixSortsDescending() {
+        List<Segment> items = threeSegments();
+
+        MergedMetadataListHelper.sort(items, "name:descending");
+        assertEquals(Arrays.asList("a", "b", "c"), ids(items));
+
+        MergedMetadataListHelper.sort(items, "name:DESC");
+        assertEquals(Arrays.asList("a", "b", "c"), ids(items));
+    }
+
+    @Test
+    public void testNoSortKeepsMergedOrder() {
+        List<Segment> items = threeSegments();
+        List<String> original = ids(items);
+
+        MergedMetadataListHelper.sort(items, null);
+        assertEquals(original, ids(items));
+
+        PartialList<Metadata> page = 
MergedMetadataListHelper.sortAndPage(items, 0, -1, "  ", DEFAULT_QUERY_LIMIT);
+        assertEquals(original, metadataIds(page));
+    }
+
+    @Test
+    public void testLimitSemanticsMatchPersistence() {
+        List<Segment> items = new ArrayList<>();
+        for (int i = 0; i < DEFAULT_QUERY_LIMIT + 5; i++) {
+            items.add(segment(String.format("s%02d", i), 
String.format("Segment %02d", i), daysAgo(i)));
+        }
+        int total = items.size();
+
+        PartialList<Metadata> unset = 
MergedMetadataListHelper.sortAndPage(items, 0, Integer.MIN_VALUE, "name:asc", 
DEFAULT_QUERY_LIMIT);
+        assertEquals(DEFAULT_QUERY_LIMIT, unset.getList().size());
+        assertEquals(total, unset.getTotalSize());
+        assertEquals(Integer.MIN_VALUE, unset.getPageSize());
+
+        PartialList<Metadata> otherNegative = 
MergedMetadataListHelper.sortAndPage(items, 0, -5, "name:asc", 
DEFAULT_QUERY_LIMIT);
+        assertEquals(DEFAULT_QUERY_LIMIT, otherNegative.getList().size());
+
+        PartialList<Metadata> all = 
MergedMetadataListHelper.sortAndPage(items, 0, -1, "name:asc", 
DEFAULT_QUERY_LIMIT);
+        assertEquals(total, all.getList().size());
+        assertEquals(-1, all.getPageSize());
+
+        PartialList<Metadata> allFromOffset = 
MergedMetadataListHelper.sortAndPage(items, 10, -1, "name:asc", 
DEFAULT_QUERY_LIMIT);
+        assertEquals(total - 10, allFromOffset.getList().size());
+
+        PartialList<Metadata> zero = 
MergedMetadataListHelper.sortAndPage(items, 0, 0, "name:asc", 
DEFAULT_QUERY_LIMIT);
+        assertEquals(0, zero.getList().size());
+        assertEquals(total, zero.getTotalSize());
+
+        PartialList<Metadata> largerThanTotal = 
MergedMetadataListHelper.sortAndPage(items, 0, Integer.MAX_VALUE, "name:asc", 
DEFAULT_QUERY_LIMIT);
+        assertEquals(total, largerThanTotal.getList().size());
+
+        PartialList<Metadata> pastEnd = 
MergedMetadataListHelper.sortAndPage(items, 100, 2, "name:asc", 
DEFAULT_QUERY_LIMIT);
+        assertEquals(0, pastEnd.getList().size());
+        assertEquals(total, pastEnd.getTotalSize());
+
+        PartialList<Metadata> secondPage = 
MergedMetadataListHelper.sortAndPage(items, 2, 3, "name:asc", 
DEFAULT_QUERY_LIMIT);
+        assertEquals(Arrays.asList("s02", "s03", "s04"), 
metadataIds(secondPage));
+        assertEquals(2, secondPage.getOffset());
+        assertEquals(3, secondPage.getPageSize());
+        assertEquals(total, secondPage.getTotalSize());
+        assertEquals(PartialList.Relation.EQUAL, 
secondPage.getTotalSizeRelation());
+
+        PartialList<Metadata> unsetFromOffset = 
MergedMetadataListHelper.sortAndPage(items, 12, Integer.MIN_VALUE, "name:asc", 
DEFAULT_QUERY_LIMIT);
+        assertEquals(Arrays.asList("s12", "s13", "s14"), 
metadataIds(unsetFromOffset));
+        assertEquals(12, unsetFromOffset.getOffset());
+
+        PartialList<Metadata> negativeOffset 
=MergedMetadataListHelper.sortAndPage(items, -3, 2, "name:asc", 
DEFAULT_QUERY_LIMIT);
+        assertEquals(Arrays.asList("s00", "s01"), metadataIds(negativeOffset));
+    }
+
+    @Test
+    public void testConfiguredDefaultQueryLimitIsApplied() {
+        List<Segment> items = threeSegments();
+
+        PartialList<Metadata> unset = 
MergedMetadataListHelper.sortAndPage(items, 0, Integer.MIN_VALUE, "name:asc", 
2);
+        assertEquals(Arrays.asList("a", "b"), metadataIds(unset));
+        assertEquals(3, unset.getTotalSize());
+
+        PartialList<Metadata> all = 
MergedMetadataListHelper.sortAndPage(items, 0, -1, "name:asc", 2);
+        assertEquals(Arrays.asList("a", "b", "c"), metadataIds(all));
+    }
+
+    @Test
+    public void testPagingThroughAllPagesReturnsEachItemOnce() {
+        List<Segment> items = Arrays.asList(
+                segment("a", "Alpha", daysAgo(1)),
+                segment("b", "Bravo", daysAgo(2)),
+                segment("c", "Charlie", daysAgo(3)),
+                segment("d", "Delta", daysAgo(4)),
+                segment("e", "Echo", daysAgo(5))
+        );
+
+        List<String> collected = new ArrayList<>();
+        int pageSize = 2;
+        for (int offset = 0; offset < items.size(); offset += pageSize) {
+            PartialList<Metadata> page = 
MergedMetadataListHelper.sortAndPage(items, offset, pageSize, "name:asc", 
DEFAULT_QUERY_LIMIT);
+            collected.addAll(metadataIds(page));
+        }
+        assertEquals(Arrays.asList("a", "b", "c", "d", "e"), collected);
+    }
+
+    private static List<Segment> threeSegments() {
+        return new ArrayList<>(Arrays.asList(
+                segment("c", "Charlie", daysAgo(3)),
+                segment("a", "Alpha", daysAgo(1)),
+                segment("b", "Bravo", daysAgo(2))
+        ));
+    }
+
+    private static Segment segment(String id, String name, Date creationDate) {
+        Segment segment = new Segment();
+        segment.setItemId(id);
+        Metadata metadata = new Metadata();
+        metadata.setId(id);
+        metadata.setName(name);
+        metadata.setScope("systemscope");
+        segment.setMetadata(metadata);
+        segment.setCreationDate(creationDate);
+        segment.setLastModificationDate(creationDate);
+        return segment;
+    }
+
+    private static Date daysAgo(int days) {
+        return new Date(System.currentTimeMillis() - days * 24L * 60L * 60L * 
1000L);
+    }
+
+    private static List<String> ids(List<Segment> items) {
+        return 
items.stream().map(Segment::getItemId).collect(Collectors.toList());
+    }
+
+    private static List<String> metadataIds(PartialList<Metadata> page) {
+        return 
page.getList().stream().map(Metadata::getId).collect(Collectors.toList());
+    }
+}
diff --git 
a/services/src/test/java/org/apache/unomi/services/impl/segments/SegmentServiceImplTest.java
 
b/services/src/test/java/org/apache/unomi/services/impl/segments/SegmentServiceImplTest.java
index c7564fa9d..a127beed7 100644
--- 
a/services/src/test/java/org/apache/unomi/services/impl/segments/SegmentServiceImplTest.java
+++ 
b/services/src/test/java/org/apache/unomi/services/impl/segments/SegmentServiceImplTest.java
@@ -24,7 +24,9 @@ import org.apache.unomi.api.actions.Action;
 import org.apache.unomi.api.conditions.Condition;
 import org.apache.unomi.api.conditions.ConditionType;
 import org.apache.unomi.api.exceptions.BadSegmentConditionException;
+import org.apache.unomi.api.query.Query;
 import org.apache.unomi.api.rules.Rule;
+import org.apache.unomi.api.segments.Scoring;
 import org.apache.unomi.api.segments.Segment;
 import org.apache.unomi.api.segments.SegmentsAndScores;
 import org.apache.unomi.api.services.EventService;
@@ -239,6 +241,33 @@ public class SegmentServiceImplTest {
         return segment;
     }
 
+    private Scoring createTestScoring(String scoringId, String name) {
+        Scoring scoring = new Scoring();
+        scoring.setItemId(scoringId);
+        
scoring.setTenantId(executionContextManager.getCurrentContext().getTenantId());
+
+        Metadata metadata = new Metadata();
+        metadata.setId(scoringId);
+        metadata.setName(name);
+        metadata.setScope("systemscope");
+        metadata.setEnabled(false);
+        scoring.setMetadata(metadata);
+        scoring.setElements(Collections.emptyList());
+        return scoring;
+    }
+
+    private Date daysAgo(int days) {
+        return new Date(System.currentTimeMillis() - days * 24L * 60L * 60L * 
1000L);
+    }
+
+    private List<String> metadataIds(PartialList<Metadata> page) {
+        List<String> ids = new ArrayList<>();
+        for (Metadata metadata : page.getList()) {
+            ids.add(metadata.getId());
+        }
+        return ids;
+    }
+
     private Segment createPastEventSegment(String segmentId, String name, 
String eventType, int numberOfDays) {
         Segment segment = new Segment();
         segment.setItemId(segmentId);
@@ -1160,4 +1189,218 @@ public class SegmentServiceImplTest {
             return null;
         });
     }
+
+    @Test
+    public void testMergedSegmentListSortByNameAndDate() {
+        executionContextManager.executeAsSystem(() -> {
+            Segment zebra = createTestSegment("sys-zebra", "Zebra");
+            zebra.setCreationDate(daysAgo(5));
+            Segment middle = createTestSegment("sys-middle", "Middle");
+            middle.setCreationDate(daysAgo(3));
+            segmentService.setSegmentDefinition(zebra);
+            segmentService.setSegmentDefinition(middle);
+            return null;
+        });
+
+        executionContextManager.executeAsTenant(TENANT_1, () -> {
+            Segment alpha = createTestSegment("tenant-alpha", "Alpha");
+            alpha.setCreationDate(daysAgo(1));
+            segmentService.setSegmentDefinition(alpha);
+            persistenceService.refresh();
+
+            PartialList<Metadata> byNameAsc = 
TestHelper.retryQueryUntilAvailable(
+                () -> segmentService.getSegmentMetadatas(0, 10, "name:asc"),
+                3
+            );
+            assertEquals(Arrays.asList("tenant-alpha", "sys-middle", 
"sys-zebra"), metadataIds(byNameAsc));
+
+            PartialList<Metadata> byNameDesc = 
segmentService.getSegmentMetadatas(0, 10, "name:desc");
+            assertEquals(Arrays.asList("sys-zebra", "sys-middle", 
"tenant-alpha"), metadataIds(byNameDesc));
+
+            PartialList<Metadata> byDateAsc = 
segmentService.getSegmentMetadatas(0, 10, "creationDate:asc");
+            assertEquals(Arrays.asList("sys-zebra", "sys-middle", 
"tenant-alpha"), metadataIds(byDateAsc));
+
+            PartialList<Metadata> byDateDesc = 
segmentService.getSegmentMetadatas(0, 10, "creationDate:desc");
+            assertEquals(Arrays.asList("tenant-alpha", "sys-middle", 
"sys-zebra"), metadataIds(byDateDesc));
+            return null;
+        });
+    }
+
+    @Test
+    public void testMergedSegmentListPagingAndQueryLimits() {
+        executionContextManager.executeAsSystem(() -> {
+            segmentService.setSegmentDefinition(createTestSegment("sys-a", 
"A-System"));
+            segmentService.setSegmentDefinition(createTestSegment("sys-b", 
"B-System"));
+            return null;
+        });
+
+        executionContextManager.executeAsTenant(TENANT_1, () -> {
+            segmentService.setSegmentDefinition(createTestSegment("ten-c", 
"C-Tenant"));
+            segmentService.setSegmentDefinition(createTestSegment("ten-d", 
"D-Tenant"));
+            segmentService.setSegmentDefinition(createTestSegment("ten-e", 
"E-Tenant"));
+            persistenceService.refresh();
+
+            TestHelper.retryQueryUntilAvailable(() -> 
segmentService.getSegmentMetadatas(0, 10, "name:asc"), 5);
+
+            PartialList<Metadata> page1 = 
segmentService.getSegmentMetadatas(0, 2, "name:asc");
+            PartialList<Metadata> page2 = 
segmentService.getSegmentMetadatas(2, 2, "name:asc");
+            PartialList<Metadata> page3 = 
segmentService.getSegmentMetadatas(4, 2, "name:asc");
+            List<String> allPages = new ArrayList<>();
+            allPages.addAll(metadataIds(page1));
+            allPages.addAll(metadataIds(page2));
+            allPages.addAll(metadataIds(page3));
+            assertEquals(Arrays.asList("sys-a", "sys-b", "ten-c", "ten-d", 
"ten-e"), allPages);
+            assertEquals(5, page1.getTotalSize());
+
+            Query noLimit = new Query();
+            PartialList<Metadata> noLimitResult = 
segmentService.getSegmentMetadatas(noLimit);
+            assertEquals(5, noLimitResult.getList().size());
+            assertEquals(5, noLimitResult.getTotalSize());
+
+            Query limitMinusOne = new Query();
+            limitMinusOne.setLimit(-1);
+            assertEquals(5, 
segmentService.getSegmentMetadatas(limitMinusOne).getList().size());
+
+            Query limitZero = new Query();
+            limitZero.setLimit(0);
+            assertEquals(0, 
segmentService.getSegmentMetadatas(limitZero).getList().size());
+
+            Query largerThanTotal = new Query();
+            largerThanTotal.setLimit(100);
+            largerThanTotal.setSortby("name:asc");
+            assertEquals(5, 
segmentService.getSegmentMetadatas(largerThanTotal).getList().size());
+
+            Query pastEnd = new Query();
+            pastEnd.setOffset(20);
+            pastEnd.setLimit(5);
+            assertEquals(0, 
segmentService.getSegmentMetadatas(pastEnd).getList().size());
+            assertEquals(5, 
segmentService.getSegmentMetadatas(pastEnd).getTotalSize());
+
+            // Like the persistence services, unknown sort fields are ignored 
rather than rejected
+            PartialList<Metadata> unknownSort = 
segmentService.getSegmentMetadatas(0, 10, "notARealField:asc");
+            assertEquals(5, unknownSort.getList().size());
+            assertEquals(5, unknownSort.getTotalSize());
+            return null;
+        });
+    }
+
+    @Test
+    public void testMergedListsUseConfiguredDefaultQueryLimit() {
+        ((InMemoryPersistenceServiceImpl) 
persistenceService).setDefaultQueryLimit(2);
+
+        executionContextManager.executeAsSystem(() -> {
+            segmentService.setSegmentDefinition(createTestSegment("sys-a", 
"A-System"));
+            
segmentService.setScoringDefinition(createTestScoring("sys-score-a", 
"A-Score"));
+            return null;
+        });
+
+        executionContextManager.executeAsTenant(TENANT_1, () -> {
+            segmentService.setSegmentDefinition(createTestSegment("ten-b", 
"B-Tenant"));
+            segmentService.setSegmentDefinition(createTestSegment("ten-c", 
"C-Tenant"));
+            segmentService.setSegmentDefinition(createTestSegment("ten-d", 
"D-Tenant"));
+            
segmentService.setScoringDefinition(createTestScoring("ten-score-b", 
"B-Score"));
+            
segmentService.setScoringDefinition(createTestScoring("ten-score-c", 
"C-Score"));
+            
segmentService.setScoringDefinition(createTestScoring("ten-score-d", 
"D-Score"));
+            persistenceService.refresh();
+
+            // More items per tenant than the limit: the limit must only apply 
to the merged page
+            Query all = new Query();
+            all.setLimit(-1);
+            TestHelper.retryQueryUntilAvailable(() -> 
segmentService.getSegmentMetadatas(all), 4);
+            TestHelper.retryQueryUntilAvailable(() -> 
segmentService.getScoringMetadatas(all), 4);
+
+            Query noLimit = new Query();
+            noLimit.setSortby("metadata.name:asc");
+            PartialList<Metadata> segments = 
segmentService.getSegmentMetadatas(noLimit);
+            assertEquals(Arrays.asList("sys-a", "ten-b"), 
metadataIds(segments));
+            assertEquals(4, segments.getTotalSize());
+
+            PartialList<Metadata> scorings = 
segmentService.getScoringMetadatas(noLimit);
+            assertEquals(Arrays.asList("sys-score-a", "ten-score-b"), 
metadataIds(scorings));
+            assertEquals(4, scorings.getTotalSize());
+
+            assertEquals(2, segmentService.getSegmentMetadatas(0, 
Integer.MIN_VALUE, "name:asc").getList().size());
+            assertEquals(4, 
segmentService.getSegmentMetadatas(all).getList().size());
+            return null;
+        });
+    }
+
+    @Test
+    public void testMergedSegmentListTenantOverridesSystemItem() {
+        executionContextManager.executeAsSystem(() -> {
+            segmentService.setSegmentDefinition(createTestSegment("shared", 
"System Version"));
+            segmentService.setSegmentDefinition(createTestSegment("sys-only", 
"System Only"));
+            return null;
+        });
+
+        executionContextManager.executeAsTenant(TENANT_1, () -> {
+            segmentService.setSegmentDefinition(createTestSegment("shared", 
"Tenant Version"));
+            persistenceService.refresh();
+
+            PartialList<Metadata> result = TestHelper.retryQueryUntilAvailable(
+                () -> segmentService.getSegmentMetadatas(0, 10, "name:asc"),
+                2
+            );
+            assertEquals(Arrays.asList("sys-only", "shared"), 
metadataIds(result));
+            assertEquals(2, result.getTotalSize());
+            assertEquals("Tenant Version", result.getList().get(1).getName());
+            return null;
+        });
+    }
+
+    @Test
+    public void testMergedScoringListSortPagingAndQueryLimits() {
+        executionContextManager.executeAsSystem(() -> {
+            Scoring late = createTestScoring("sys-late", "Late");
+            late.setCreationDate(daysAgo(4));
+            Scoring early = createTestScoring("sys-early", "Early");
+            early.setCreationDate(daysAgo(1));
+            segmentService.setScoringDefinition(late);
+            segmentService.setScoringDefinition(early);
+            return null;
+        });
+
+        executionContextManager.executeAsTenant(TENANT_1, () -> {
+            Scoring mid = createTestScoring("ten-mid", "Mid");
+            mid.setCreationDate(daysAgo(2));
+            segmentService.setScoringDefinition(mid);
+            persistenceService.refresh();
+
+            PartialList<Metadata> byNameAsc = 
TestHelper.retryQueryUntilAvailable(
+                () -> segmentService.getScoringMetadatas(0, 10, "name:asc"),
+                3
+            );
+            assertEquals(Arrays.asList("sys-early", "sys-late", "ten-mid"), 
metadataIds(byNameAsc));
+
+            PartialList<Metadata> byDateDesc = 
segmentService.getScoringMetadatas(0, 10, "creationDate:desc");
+            assertEquals(Arrays.asList("sys-early", "ten-mid", "sys-late"), 
metadataIds(byDateDesc));
+
+            PartialList<Metadata> page1 = 
segmentService.getScoringMetadatas(0, 2, "name:asc");
+            PartialList<Metadata> page2 = 
segmentService.getScoringMetadatas(2, 2, "name:asc");
+            List<String> allPages = new ArrayList<>();
+            allPages.addAll(metadataIds(page1));
+            allPages.addAll(metadataIds(page2));
+            assertEquals(Arrays.asList("sys-early", "sys-late", "ten-mid"), 
allPages);
+
+            Query noLimit = new Query();
+            assertEquals(3, 
segmentService.getScoringMetadatas(noLimit).getList().size());
+
+            Query limitMinusOne = new Query();
+            limitMinusOne.setLimit(-1);
+            assertEquals(3, 
segmentService.getScoringMetadatas(limitMinusOne).getList().size());
+
+            Query limitZero = new Query();
+            limitZero.setLimit(0);
+            assertEquals(0, 
segmentService.getScoringMetadatas(limitZero).getList().size());
+
+            Query pastEnd = new Query();
+            pastEnd.setOffset(50);
+            pastEnd.setLimit(10);
+            assertEquals(0, 
segmentService.getScoringMetadatas(pastEnd).getList().size());
+
+            assertEquals(3, segmentService.getScoringMetadatas(0, 10, 
"properties.score:asc").getList().size());
+            return null;
+        });
+    }
 }
+

Reply via email to