Merge "Support pagination in query across all anchors(ep4)"
authorLuke Gleeson <luke.gleeson@est.tech>
Thu, 3 Aug 2023 13:13:47 +0000 (13:13 +0000)
committerGerrit Code Review <gerrit@onap.org>
Thu, 3 Aug 2023 13:13:47 +0000 (13:13 +0000)
1  2 
cps-rest/docs/openapi/components.yml
cps-rest/src/test/groovy/org/onap/cps/rest/controller/QueryRestControllerSpec.groovy
cps-ri/src/main/java/org/onap/cps/spi/impl/CpsDataPersistenceServiceImpl.java
cps-ri/src/main/java/org/onap/cps/spi/repository/FragmentRepository.java
docs/api/swagger/cps/openapi.yaml
integration-test/src/test/groovy/org/onap/cps/integration/functional/CpsDataServiceIntegrationSpec.groovy

@@@ -263,12 -263,28 +263,28 @@@ components
      descendantsInQuery:
        name: descendants
        in: query
 -      description: Number of descendants to query. Allowed values are 'none', 'all', -1 (for all), 0 (for none) and any positive number.
 +      description: Number of descendants to query. Allowed values are 'none', 'all', 'direct', 1 (for direct), -1 (for all), 0 (for none) and any positive number.
        required: false
        schema:
          type: string
          default: none
          example: 3
+     pageIndexInQuery:
+       name: pageIndex
+       in: query
+       description: page index for pagination over anchors. It must be greater then zero if provided.
+       required: false
+       schema:
+         type: integer
+         example: 1
+     pageSizeInQuery:
+       name: pageSize
+       in: query
+       description: number of records (anchors) per page. It must be greater then zero if provided.
+       required: false
+       schema:
+         type: integer
+         example: 10
  
    responses:
      NotFound:
  
  package org.onap.cps.rest.controller
  
+ import org.onap.cps.spi.PaginationOption
  import org.onap.cps.utils.PrefixResolver
  
 +import static org.onap.cps.spi.FetchDescendantsOption.DIRECT_CHILDREN_ONLY
  import static org.onap.cps.spi.FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS
  import static org.onap.cps.spi.FetchDescendantsOption.OMIT_DESCENDANTS
  import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
@@@ -71,7 -71,7 +72,7 @@@ class QueryRestControllerSpec extends S
  
      def 'Query data node by cps path for the given dataspace and anchor with #scenario.'() {
          given: 'service method returns a list containing a data node'
-              def dataNode1 = new DataNodeBuilder().withXpath('/xpath')
+             def dataNode1 = new DataNodeBuilder().withXpath('/xpath')
                      .withLeaves([leaf: 'value', leafList: ['leaveListElement1', 'leaveListElement2']]).build()
              mockCpsQueryService.queryDataNodes(dataspaceName, anchorName, cpsPath, expectedCpsDataServiceOption) >> [dataNode1, dataNode1]
          and: 'the query endpoint'
              def dataNode1 = new DataNodeBuilder().withXpath('/xpath')
                  .withLeaves([leaf: 'value', leafList: ['leaveListElement1', 'leaveListElement2']]).build()
              mockCpsQueryService.queryDataNodes(dataspaceName, anchorName, cpsPath, { descendantsOption -> {
 -                    assert descendantsOption.depth == 2}}) >> [dataNode1, dataNode1]
 +                assert descendantsOption.depth == expectedDepth}}) >> [dataNode1, dataNode1]
          when: 'query data nodes API is invoked'
              def response =
                  mvc.perform(
                          get(dataNodeEndpointV2)
                                  .param('cps-path', cpsPath)
 -                                .param('descendants', '2'))
 +                                .param('descendants', includeDescendantsOptionString))
                          .andReturn().response
          then: 'the response contains the the datanode in json format'
              assert response.status == HttpStatus.OK.value()
              assert response.getContentAsString().contains('{"xpath":{"leaf":"value","leafList":["leaveListElement1","leaveListElement2"]}}')
 +       where: 'the following options for include descendants are provided in the request'
 +           scenario          | includeDescendantsOptionString || expectedDepth
 +           'direct children' | 'direct'                       || 1
 +           'descendants'     | '2'                            || 2
      }
  
      def 'Query data node by cps path for the given dataspace across all anchors with #scenario.'() {
-         given: 'service method returns a list containing a data node'
+         given: 'service method returns a list containing a data node from different anchors'
              def dataNode1 = new DataNodeBuilder().withXpath('/xpath')
                  .withAnchor('my_anchor')
                  .withLeaves([leaf: 'value', leafList: ['leaveListElement1', 'leaveListElement2']]).build()
              def dataNode2 = new DataNodeBuilder().withXpath('/xpath')
                  .withAnchor('my_anchor_2')
                  .withLeaves([leaf: 'value', leafList: ['leaveListElement3', 'leaveListElement4']]).build()
+         and: 'second data node for the same anchor'
+             def dataNode3 = new DataNodeBuilder().withXpath('/xpath')
+                 .withAnchor('my_anchor_2')
+                 .withLeaves([leaf: 'value', leafList: ['leaveListElement5', 'leaveListElement6']]).build()
+         and: 'the query endpoint'
              def dataspaceName = 'my_dataspace'
              def cpsPath = 'some/cps/path'
-             mockCpsQueryService.queryDataNodesAcrossAnchors(dataspaceName, cpsPath, expectedCpsDataServiceOption) >> [dataNode1, dataNode2]
-         and: 'the query endpoint'
              def dataNodeEndpoint = "$basePath/v2/dataspaces/$dataspaceName/nodes/query"
+             mockCpsQueryService.queryDataNodesAcrossAnchors(dataspaceName, cpsPath,
+                 expectedCpsDataServiceOption, PaginationOption.NO_PAGINATION) >> [dataNode1, dataNode2, dataNode3]
+             mockCpsQueryService.countAnchorsForDataspaceAndCpsPath(dataspaceName, cpsPath) >> 2
          when: 'query data nodes API is invoked'
              def response =
                  mvc.perform(
              response.status == HttpStatus.OK.value()
              response.getContentAsString().contains('{"xpath":{"leaf":"value","leafList":["leaveListElement1","leaveListElement2"]}}')
              response.getContentAsString().contains('{"xpath":{"leaf":"value","leafList":["leaveListElement3","leaveListElement4"]}}')
+             response.getContentAsString().contains('{"xpath":{"leaf":"value","leafList":["leaveListElement5","leaveListElement6"]}}')
          where: 'the following options for include descendants are provided in the request'
              scenario                    | includeDescendantsOptionString || expectedCpsDataServiceOption
              'no descendants by default' | ''                             || OMIT_DESCENDANTS
              'no descendant explicitly'  | 'none'                         || OMIT_DESCENDANTS
              'descendants'               | 'all'                          || INCLUDE_ALL_DESCENDANTS
 +            'direct children'           | 'direct'                       || DIRECT_CHILDREN_ONLY
      }
+     def 'Query data node by cps path for the given dataspace across all anchors with pagination #scenario.'() {
+         given: 'service method returns a list containing a data node from different anchors'
+         def dataNode1 = new DataNodeBuilder().withXpath('/xpath')
+                 .withAnchor('my_anchor')
+                 .withLeaves([leaf: 'value', leafList: ['leaveListElement1', 'leaveListElement2']]).build()
+         def dataNode2 = new DataNodeBuilder().withXpath('/xpath')
+                 .withAnchor('my_anchor_2')
+                 .withLeaves([leaf: 'value', leafList: ['leaveListElement3', 'leaveListElement4']]).build()
+         and: 'the query endpoint'
+             def dataspaceName = 'my_dataspace'
+             def cpsPath = 'some/cps/path'
+             def dataNodeEndpoint = "$basePath/v2/dataspaces/$dataspaceName/nodes/query"
+             mockCpsQueryService.queryDataNodesAcrossAnchors(dataspaceName, cpsPath,
+                 INCLUDE_ALL_DESCENDANTS, new PaginationOption(pageIndex,pageSize)) >> [dataNode1, dataNode2]
+             mockCpsQueryService.countAnchorsForDataspaceAndCpsPath(dataspaceName, cpsPath) >> totalAnchors
+         when: 'query data nodes API is invoked'
+             def response =
+                 mvc.perform(
+                         get(dataNodeEndpoint)
+                                 .param('cps-path', cpsPath)
+                                 .param('descendants', "all")
+                                 .param('pageIndex', String.valueOf(pageIndex))
+                                 .param('pageSize', String.valueOf(pageSize)))
+                         .andReturn().response
+         then: 'the response contains the the datanode in json format'
+             assert response.status == HttpStatus.OK.value()
+             assert Integer.valueOf(response.getHeaderValue("total-pages")) == expectedPageSize
+             assert response.getContentAsString().contains('{"xpath":{"leaf":"value","leafList":["leaveListElement1","leaveListElement2"]}}')
+             assert response.getContentAsString().contains('{"xpath":{"leaf":"value","leafList":["leaveListElement3","leaveListElement4"]}}')
+         where: 'the following options for include descendants are provided in the request'
+             scenario                     | pageIndex | pageSize | totalAnchors || expectedPageSize
+             '1st page with all anchors'  | 1         | 3        | 3            || 1
+             '1st page with less anchors' | 1         | 2        | 3            || 2
+     }
  }
@@@ -23,7 -23,8 +23,8 @@@
  
  package org.onap.cps.spi.impl;
  
- import com.google.common.base.Strings;
+ import static org.onap.cps.spi.PaginationOption.NO_PAGINATION;
  import com.google.common.collect.ImmutableSet;
  import com.google.common.collect.ImmutableSet.Builder;
  import io.micrometer.core.annotation.Timed;
@@@ -48,6 -49,7 +49,7 @@@ import org.onap.cps.cpspath.parser.CpsP
  import org.onap.cps.cpspath.parser.PathParsingException;
  import org.onap.cps.spi.CpsDataPersistenceService;
  import org.onap.cps.spi.FetchDescendantsOption;
+ import org.onap.cps.spi.PaginationOption;
  import org.onap.cps.spi.entities.AnchorEntity;
  import org.onap.cps.spi.entities.DataspaceEntity;
  import org.onap.cps.spi.entities.FragmentEntity;
@@@ -79,8 -81,6 +81,6 @@@ public class CpsDataPersistenceServiceI
      private final SessionManager sessionManager;
  
      private static final String REG_EX_FOR_OPTIONAL_LIST_INDEX = "(\\[@.+?])?)";
-     private static final String QUERY_ACROSS_ANCHORS = null;
-     private static final AnchorEntity ALL_ANCHORS = null;
  
      @Override
      public void addChildDataNodes(final String dataspaceName, final String anchorName,
          newChildAsFragmentEntity.setParentId(parentFragmentEntity.getId());
          try {
              fragmentRepository.save(newChildAsFragmentEntity);
 -        } catch (final DataIntegrityViolationException e) {
 +        } catch (final DataIntegrityViolationException dataIntegrityViolationException) {
              throw AlreadyDefinedException.forDataNodes(Collections.singletonList(newChild.getXpath()),
                      anchorEntity.getName());
          }
                  fragmentEntities.add(newChildAsFragmentEntity);
              }
              fragmentRepository.saveAll(fragmentEntities);
 -        } catch (final DataIntegrityViolationException e) {
 +        } catch (final DataIntegrityViolationException dataIntegrityViolationException) {
              log.warn("Exception occurred : {} , While saving : {} children, retrying using individual save operations",
 -                    e, fragmentEntities.size());
 +                    dataIntegrityViolationException, fragmentEntities.size());
              retrySavingEachChildIndividually(anchorEntity, parentNodeXpath, newChildren);
          }
      }
          for (final DataNode newChild : newChildren) {
              try {
                  addNewChildDataNode(anchorEntity, parentNodeXpath, newChild);
 -            } catch (final AlreadyDefinedException e) {
 +            } catch (final AlreadyDefinedException alreadyDefinedException) {
                  failedXpaths.add(newChild.getXpath());
              }
          }
              try {
                  final FragmentEntity fragmentEntity = convertToFragmentWithAllDescendants(anchorEntity, dataNode);
                  fragmentRepository.save(fragmentEntity);
 -            } catch (final DataIntegrityViolationException e) {
 +            } catch (final DataIntegrityViolationException dataIntegrityViolationException) {
                  failedXpaths.add(dataNode.getXpath());
              }
          }
  
      private Collection<FragmentEntity> getFragmentEntities(final AnchorEntity anchorEntity,
                                                             final Collection<String> xpaths) {
 -        final Collection<String> nonRootXpaths = new HashSet<>(xpaths);
 -        final boolean haveRootXpath = nonRootXpaths.removeIf(CpsDataPersistenceServiceImpl::isRootXpath);
 +        final Collection<String> normalizedXpaths = getNormalizedXpaths(xpaths);
  
 -        final Collection<String> normalizedXpaths = new HashSet<>(nonRootXpaths.size());
 -        for (final String xpath : nonRootXpaths) {
 -            try {
 -                normalizedXpaths.add(CpsPathUtil.getNormalizedXpath(xpath));
 -            } catch (final PathParsingException e) {
 -                log.warn("Error parsing xpath \"{}\": {}", xpath, e.getMessage());
 +        final boolean haveRootXpath = normalizedXpaths.removeIf(CpsDataPersistenceServiceImpl::isRootXpath);
 +
 +        final List<FragmentEntity> fragmentEntities = fragmentRepository.findByAnchorAndXpathIn(anchorEntity,
 +                normalizedXpaths);
 +
 +        for (final FragmentEntity fragmentEntity : fragmentEntities) {
 +            normalizedXpaths.remove(fragmentEntity.getXpath());
 +        }
 +
 +        for (final String xpath : normalizedXpaths) {
 +            if (!CpsPathUtil.isPathToListElement(xpath)) {
 +                fragmentEntities.addAll(fragmentRepository.findListByAnchorAndXpath(anchorEntity, xpath));
              }
          }
 +
          if (haveRootXpath) {
 -            normalizedXpaths.addAll(fragmentRepository.findAllXpathByAnchorAndParentIdIsNull(anchorEntity));
 +            fragmentEntities.addAll(fragmentRepository.findRootsByAnchorId(anchorEntity.getId()));
          }
  
 -        return fragmentRepository.findByAnchorAndXpathIn(anchorEntity, normalizedXpaths);
 +        return fragmentEntities;
      }
  
      private FragmentEntity getFragmentEntity(final AnchorEntity anchorEntity, final String xpath) {
      public List<DataNode> queryDataNodes(final String dataspaceName, final String anchorName, final String cpsPath,
                                           final FetchDescendantsOption fetchDescendantsOption) {
          final DataspaceEntity dataspaceEntity = dataspaceRepository.getByName(dataspaceName);
-         final AnchorEntity anchorEntity = Strings.isNullOrEmpty(anchorName) ? ALL_ANCHORS
-             : anchorRepository.getByDataspaceAndName(dataspaceEntity, anchorName);
+         final AnchorEntity anchorEntity = anchorRepository.getByDataspaceAndName(dataspaceEntity, anchorName);
          final CpsPathQuery cpsPathQuery;
          try {
              cpsPathQuery = CpsPathUtil.getCpsPathQuery(cpsPath);
 -        } catch (final PathParsingException e) {
 -            throw new CpsPathException(e.getMessage());
 +        } catch (final PathParsingException pathParsingException) {
 +            throw new CpsPathException(pathParsingException.getMessage());
          }
  
          Collection<FragmentEntity> fragmentEntities;
-         if (anchorEntity == ALL_ANCHORS) {
-             fragmentEntities = fragmentRepository.findByDataspaceAndCpsPath(dataspaceEntity, cpsPathQuery);
+         fragmentEntities = fragmentRepository.findByAnchorAndCpsPath(anchorEntity, cpsPathQuery);
+         if (cpsPathQuery.hasAncestorAxis()) {
+             final Collection<String> ancestorXpaths = processAncestorXpath(fragmentEntities, cpsPathQuery);
+             fragmentEntities = fragmentRepository.findByAnchorAndXpathIn(anchorEntity, ancestorXpaths);
+         }
+         fragmentEntities = fragmentRepository.prefetchDescendantsOfFragmentEntities(fetchDescendantsOption,
+                 fragmentEntities);
+         return createDataNodesFromFragmentEntities(fetchDescendantsOption, fragmentEntities);
+     }
+     @Override
+     @Timed(value = "cps.data.persistence.service.datanode.query.anchors",
+             description = "Time taken to query data nodes across all anchors or list of anchors")
+     public List<DataNode> queryDataNodesAcrossAnchors(final String dataspaceName, final String cpsPath,
+                                                       final FetchDescendantsOption fetchDescendantsOption,
+                                                       final PaginationOption paginationOption) {
+         final DataspaceEntity dataspaceEntity = dataspaceRepository.getByName(dataspaceName);
+         final CpsPathQuery cpsPathQuery;
+         try {
+             cpsPathQuery = CpsPathUtil.getCpsPathQuery(cpsPath);
+         } catch (final PathParsingException e) {
+             throw new CpsPathException(e.getMessage());
+         }
+         final List<Long> anchorIds;
+         if (paginationOption == NO_PAGINATION) {
+             anchorIds = Collections.EMPTY_LIST;
          } else {
-             fragmentEntities = fragmentRepository.findByAnchorAndCpsPath(anchorEntity, cpsPathQuery);
+             anchorIds = getAnchorIdsForPagination(dataspaceEntity, cpsPathQuery, paginationOption);
+             if (anchorIds.isEmpty()) {
+                 return Collections.emptyList();
+             }
          }
+         Collection<FragmentEntity> fragmentEntities =
+             fragmentRepository.findByDataspaceAndCpsPath(dataspaceEntity, cpsPathQuery, anchorIds);
          if (cpsPathQuery.hasAncestorAxis()) {
              final Collection<String> ancestorXpaths = processAncestorXpath(fragmentEntities, cpsPathQuery);
-             if (anchorEntity == ALL_ANCHORS) {
+             if (anchorIds.isEmpty()) {
                  fragmentEntities = fragmentRepository.findByDataspaceAndXpathIn(dataspaceEntity, ancestorXpaths);
              } else {
-                 fragmentEntities = fragmentRepository.findByAnchorAndXpathIn(anchorEntity, ancestorXpaths);
+                 fragmentEntities = fragmentRepository.findByAnchorIdsAndXpathIn(
+                         anchorIds.toArray(new Long[0]), ancestorXpaths.toArray(new String[0]));
              }
          }
          fragmentEntities = fragmentRepository.prefetchDescendantsOfFragmentEntities(fetchDescendantsOption,
                  fragmentEntities);
          return createDataNodesFromFragmentEntities(fetchDescendantsOption, fragmentEntities);
      }
  
-     @Override
-     public List<DataNode> queryDataNodesAcrossAnchors(final String dataspaceName, final String cpsPath,
-                                                       final FetchDescendantsOption fetchDescendantsOption) {
-         return queryDataNodes(dataspaceName, QUERY_ACROSS_ANCHORS, cpsPath, fetchDescendantsOption);
+     private List<Long> getAnchorIdsForPagination(final DataspaceEntity dataspaceEntity, final CpsPathQuery cpsPathQuery,
+                                                  final PaginationOption paginationOption) {
+         return fragmentRepository.findAnchorIdsForPagination(dataspaceEntity, cpsPathQuery, paginationOption);
      }
  
      private List<DataNode> createDataNodesFromFragmentEntities(final FetchDescendantsOption fetchDescendantsOption,
          }
          try {
              return CpsPathUtil.getNormalizedXpath(xpathSource);
 -        } catch (final PathParsingException e) {
 -            throw new CpsPathException(e.getMessage());
 +        } catch (final PathParsingException pathParsingException) {
 +            throw new CpsPathException(pathParsingException.getMessage());
 +        }
 +    }
 +
 +    private static Collection<String> getNormalizedXpaths(final Collection<String> xpaths) {
 +        final Collection<String> normalizedXpaths = new HashSet<>(xpaths.size());
 +        for (final String xpath : xpaths) {
 +            try {
 +                normalizedXpaths.add(getNormalizedXpath(xpath));
 +            } catch (final CpsPathException cpsPathException) {
 +                log.warn("Error parsing xpath \"{}\": {}", xpath, cpsPathException.getMessage());
 +            }
          }
 +        return normalizedXpaths;
      }
  
      @Override
          sessionManager.lockAnchor(sessionId, dataspaceName, anchorName, timeoutInMilliseconds);
      }
  
+     @Override
+     public Integer countAnchorsForDataspaceAndCpsPath(final String dataspaceName, final String cpsPath) {
+         final DataspaceEntity dataspaceEntity = dataspaceRepository.getByName(dataspaceName);
+         final CpsPathQuery cpsPathQuery;
+         try {
+             cpsPathQuery = CpsPathUtil.getCpsPathQuery(cpsPath);
+         } catch (final PathParsingException e) {
+             throw new CpsPathException(e.getMessage());
+         }
+         final List<Long> anchorIdList = getAnchorIdsForPagination(dataspaceEntity, cpsPathQuery, NO_PAGINATION);
+         return anchorIdList.size();
+     }
      private static Set<String> processAncestorXpath(final Collection<FragmentEntity> fragmentEntities,
                                                      final CpsPathQuery cpsPathQuery) {
          final Set<String> ancestorXpath = new HashSet<>();
          for (final FragmentEntity dataNodeFragment : fragmentEntities) {
              try {
                  fragmentRepository.save(dataNodeFragment);
 -            } catch (final StaleStateException e) {
 +            } catch (final StaleStateException staleStateException) {
                  failedXpaths.add(dataNodeFragment.getXpath());
              }
          }
  
          final AnchorEntity anchorEntity = getAnchorEntity(dataspaceName, anchorName);
  
 -        final Collection<String> deleteChecklist = new HashSet<>(xpathsToDelete.size());
 -        for (final String xpath : xpathsToDelete) {
 -            try {
 -                deleteChecklist.add(CpsPathUtil.getNormalizedXpath(xpath));
 -            } catch (final PathParsingException e) {
 -                log.warn("Error parsing xpath \"{}\": {}", xpath, e.getMessage());
 -            }
 -        }
 -
 +        final Collection<String> deleteChecklist = getNormalizedXpaths(xpathsToDelete);
          final Collection<String> xpathsToExistingContainers =
              fragmentRepository.findAllXpathByAnchorAndXpathIn(anchorEntity, deleteChecklist);
          if (onlySupportListDeletion) {
@@@ -58,17 -58,6 +58,17 @@@ public interface FragmentRepository ext
          return findByAnchorIdAndXpathIn(anchorEntity.getId(), xpaths.toArray(new String[0]));\r
      }\r
  \r
 +    @Query(value = "SELECT * FROM fragment WHERE anchor_id = :anchorId \n"\r
 +            + "AND xpath LIKE :escapedXpath||'[@%]' AND xpath NOT LIKE :escapedXpath||'[@%]/%[@%]'",\r
 +            nativeQuery = true)\r
 +    List<FragmentEntity> findListByAnchorIdAndEscapedXpath(@Param("anchorId") long anchorId,\r
 +                                                           @Param("escapedXpath") String escapedXpath);\r
 +\r
 +    default List<FragmentEntity> findListByAnchorAndXpath(final AnchorEntity anchorEntity, final String xpath) {\r
 +        final String escapedXpath = EscapeUtils.escapeForSqlLike(xpath);\r
 +        return findListByAnchorIdAndEscapedXpath(anchorEntity.getId(), escapedXpath);\r
 +    }\r
 +\r
      @Query(value = "SELECT fragment.* FROM fragment JOIN anchor ON anchor.id = fragment.anchor_id "\r
          + "WHERE dataspace_id = :dataspaceId AND xpath = ANY (:xpaths)", nativeQuery = true)\r
      List<FragmentEntity> findByDataspaceIdAndXpathIn(@Param("dataspaceId") int dataspaceId,\r
          return findByDataspaceIdAndXpathIn(dataspaceEntity.getId(), xpaths.toArray(new String[0]));\r
      }\r
  \r
+     @Query(value = "SELECT * FROM fragment WHERE anchor_id IN (:anchorIds)"\r
+             + " AND xpath = ANY (:xpaths)", nativeQuery = true)\r
+     List<FragmentEntity> findByAnchorIdsAndXpathIn(@Param("anchorIds") Long[] anchorIds,\r
+                                                    @Param("xpaths") String[] xpaths);\r
\r
      @Query(value = "SELECT * FROM fragment WHERE anchor_id = :anchorId LIMIT 1", nativeQuery = true)\r
      Optional<FragmentEntity> findOneByAnchorId(@Param("anchorId") long anchorId);\r
  \r
  \r
      boolean existsByAnchorAndXpathStartsWith(AnchorEntity anchorEntity, String xpath);\r
  \r
 -    @Query("SELECT xpath FROM FragmentEntity WHERE anchor = :anchor AND parentId IS NULL")\r
 -    List<String> findAllXpathByAnchorAndParentIdIsNull(@Param("anchor") AnchorEntity anchorEntity);\r
 +    @Query(value = "SELECT * FROM fragment WHERE anchor_id = :anchorId AND parent_id IS NULL", nativeQuery = true)\r
 +    List<FragmentEntity> findRootsByAnchorId(@Param("anchorId") long anchorId);\r
  \r
  }\r
@@@ -1316,8 -1316,8 +1316,8 @@@ paths
            schema:
              default: /
              type: string
 -        - description: "Number of descendants to query. Allowed values are 'none', 'all',\
 -          \ -1 (for all), 0 (for none) and any positive number."
 +        - description: "Number of descendants to query. Allowed values are 'none', 'all', 'direct',\
 +          \ 1 (for direct), -1 (for all), 0 (for none) and any positive number."
            in: query
            name: descendants
            required: false
            schema:
              default: /
              type: string
 -        - description: "Number of descendants to query. Allowed values are 'none', 'all',\
 -          \ -1 (for all), 0 (for none) and any positive number."
 +        - description: "Number of descendants to query. Allowed values are 'none', 'all', 'direct',\
 +          \ 1 (for direct), -1 (for all), 0 (for none) and any positive number."
            in: query
            name: descendants
            required: false
            schema:
              default: /
              type: string
 -        - description: "Number of descendants to query. Allowed values are 'none', 'all',\
 -          \ -1 (for all), 0 (for none) and any positive number."
 +        - description: "Number of descendants to query. Allowed values are 'none', 'all', 'direct',\
 +          \ 1 (for direct), -1 (for all), 0 (for none) and any positive number."
            in: query
            name: descendants
            required: false
              default: none
              example: "3"
              type: string
+         - description: "page index for pagination over anchors"
+           name: pageIndex
+           in: query
+           required: false
+           schema:
+             type: integer
+             minimum: 1
+         - description: "number of records (anchors) to query per page"
+           name: pageSize
+           in: query
+           required: false
+           schema:
+             type: integer
+             minimum: 1
        responses:
          "200":
            content:
                schema:
                  type: object
            description: OK
+           headers:
+             total-pages:
+               schema:
+                 type: integer
+               description: Total number of pages for given page size
          "400":
            content:
              application/json:
@@@ -2532,8 -2551,8 +2551,8 @@@ components
          example: false
          type: boolean
      descendantsInQuery:
 -      description: "Number of descendants to query. Allowed values are 'none', 'all',\
 -        \ -1 (for all), 0 (for none) and any positive number."
 +      description: "Number of descendants to query. Allowed values are 'none', 'all', 'direct',\
 +          \ 1 (for direct), -1 (for all), 0 (for none) and any positive number."
        in: query
        name: descendants
        required: false
    securitySchemes:
      basicAuth:
        scheme: basic
-       type: http
+       type: http
@@@ -58,7 -58,7 +58,7 @@@ class CpsDataServiceIntegrationSpec ext
          then: 'the tree consist ouf of #expectNumberOfDataNodes data nodes'
              assert countDataNodesInTree(result) == expectNumberOfDataNodes
          and: 'the top level data node has the expected attribute and value'
-             assert result.leaves['bookstore-name'] == ['Easons']
+             assert result.leaves['bookstore-name'] == ['Easons-1']
          and: 'they are from the correct dataspace'
              assert result.dataspace == [FUNCTIONAL_TEST_DATASPACE_1]
          and: 'they are from the correct anchor'
@@@ -74,9 -74,9 +74,9 @@@
      def 'Read bookstore top-level container(s) using "root" path variations.'() {
          when: 'get data nodes for bookstore container'
              def result = objectUnderTest.getDataNodes(FUNCTIONAL_TEST_DATASPACE_1, BOOKSTORE_ANCHOR_1, root, OMIT_DESCENDANTS)
-         then: 'the tree consist ouf of one data node'
+         then: 'the tree consist correct number of data nodes'
              assert countDataNodesInTree(result) == 2
-         and: 'the top level data node has the expected attribute and value'
+         and: 'the top level data node has the expected number of leaves'
              assert result.leaves.size() == 2
          where: 'the following variations of "root" are used'
              root << [ '/', '' ]
              restoreBookstoreDataAnchor(1)
      }
  
 +    def 'Get whole list data' () {
 +            def xpathForWholeList = "/bookstore/categories"
 +        when: 'get data nodes for bookstore container'
 +            def dataNodes = objectUnderTest.getDataNodes(FUNCTIONAL_TEST_DATASPACE_1, BOOKSTORE_ANCHOR_1, xpathForWholeList, FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS)
 +        then: 'the tree consist ouf of #expectNumberOfDataNodes data nodes'
 +            assert dataNodes.size() == 5
 +        and: 'each datanode contains the list node xpath partially in its xpath'
 +            dataNodes.each {dataNode ->
 +                assert dataNode.xpath.contains(xpathForWholeList)
 +            }
 +    }
 +
 +    def 'Read (multiple) data nodes with #scenario' () {
 +        when: 'attempt to get data nodes using multiple valid xpaths'
 +            def dataNodes = objectUnderTest.getDataNodesForMultipleXpaths(FUNCTIONAL_TEST_DATASPACE_1, BOOKSTORE_ANCHOR_1, xpath, OMIT_DESCENDANTS)
 +        then: 'expected numer of data nodes are returned'
 +            dataNodes.size() == expectedNumberOfDataNodes
 +        where: 'the following data was used'
 +                    scenario                    |                       xpath                                       |   expectedNumberOfDataNodes
 +            'container-node xpath'              | ['/bookstore']                                                    |               1
 +            'list-item'                         | ['/bookstore/categories[@code=1]']                                |               1
 +            'parent-list xpath'                 | ['/bookstore/categories']                                         |               5
 +            'child-list xpath'                  | ['/bookstore/categories[@code=1]/books']                          |               2
 +            'both parent and child list xpath'  | ['/bookstore/categories', '/bookstore/categories[@code=1]/books'] |               7
 +    }
 +
      def 'Add and Delete a (container) data node using #scenario.'() {
 -        when: 'the new datanode is saved'
 -            objectUnderTest.saveData(FUNCTIONAL_TEST_DATASPACE_1, BOOKSTORE_ANCHOR_1 , parentXpath, json, now)
 -        then: 'it can be retrieved by its normalized xpath'
 -            def result = objectUnderTest.getDataNodes(FUNCTIONAL_TEST_DATASPACE_1, BOOKSTORE_ANCHOR_1, normalizedXpathToNode, DIRECT_CHILDREN_ONLY)
 -            assert result.size() == 1
 -            assert result[0].xpath == normalizedXpathToNode
 -        and: 'there is now one extra datanode'
 -            assert originalCountBookstoreChildNodes + 1 == countDataNodesInBookstore()
 -        when: 'the new datanode is deleted'
 -            objectUnderTest.deleteDataNode(FUNCTIONAL_TEST_DATASPACE_1, BOOKSTORE_ANCHOR_1, normalizedXpathToNode, now)
 -        then: 'the original number of data nodes is restored'
 -            assert originalCountBookstoreChildNodes == countDataNodesInBookstore()
 -        where:
 -            scenario                      | parentXpath                         | json                                                                                        || normalizedXpathToNode
 -            'normalized parent xpath'     | '/bookstore'                        | '{"webinfo": {"domain-name":"ourbookstore.com", "contact-email":"info@ourbookstore.com" }}' || "/bookstore/webinfo"
 -            'non-normalized parent xpath' | '/bookstore/categories[ @code="1"]' | '{"books": {"title":"new" }}'                                                               || "/bookstore/categories[@code='1']/books[@title='new']"
 +            when: 'the new datanode is saved'
 +                objectUnderTest.saveData(FUNCTIONAL_TEST_DATASPACE_1, BOOKSTORE_ANCHOR_1 , parentXpath, json, now)
 +            then: 'it can be retrieved by its normalized xpath'
 +                def result = objectUnderTest.getDataNodes(FUNCTIONAL_TEST_DATASPACE_1, BOOKSTORE_ANCHOR_1, normalizedXpathToNode, DIRECT_CHILDREN_ONLY)
 +                assert result.size() == 1
 +                assert result[0].xpath == normalizedXpathToNode
 +            and: 'there is now one extra datanode'
 +                assert originalCountBookstoreChildNodes + 1 == countDataNodesInBookstore()
 +            when: 'the new datanode is deleted'
 +                objectUnderTest.deleteDataNode(FUNCTIONAL_TEST_DATASPACE_1, BOOKSTORE_ANCHOR_1, normalizedXpathToNode, now)
 +            then: 'the original number of data nodes is restored'
 +                assert originalCountBookstoreChildNodes == countDataNodesInBookstore()
 +            where:
 +                scenario                      | parentXpath                         | json                                                                                        || normalizedXpathToNode
 +                'normalized parent xpath'     | '/bookstore'                        | '{"webinfo": {"domain-name":"ourbookstore.com", "contact-email":"info@ourbookstore.com" }}' || "/bookstore/webinfo"
 +                'non-normalized parent xpath' | '/bookstore/categories[ @code="1"]' | '{"books": {"title":"new" }}'                                                               || "/bookstore/categories[@code='1']/books[@title='new']"
      }
  
      def 'Attempt to create a top level data node using root.'() {
  
      def 'Add and Delete top-level list (element) data nodes with root node.'() {
          given: 'a new (multiple-data-tree:invoice) datanodes'
 -            def json = '{"multiple-data-tree:invoice": [{"ProductID": "2","ProductName": "Mango","price": "150","stock": true}]}'
 +            def json = '{"bookstore-address":[{"bookstore-name":"Scholastic","address":"Bangalore,India","postal-code":"560043"}]}'
          when: 'the new list elements are saved'
              objectUnderTest.saveListElements(FUNCTIONAL_TEST_DATASPACE_1, BOOKSTORE_ANCHOR_1 , '/', json, now)
          then: 'they can be retrieved by their xpaths'
 -            objectUnderTest.getDataNodes(FUNCTIONAL_TEST_DATASPACE_1, BOOKSTORE_ANCHOR_1 , '/invoice[@ProductID ="2"]', INCLUDE_ALL_DESCENDANTS)
 +            objectUnderTest.getDataNodes(FUNCTIONAL_TEST_DATASPACE_1, BOOKSTORE_ANCHOR_1 , '/bookstore-address[@bookstore-name="Easons"]', INCLUDE_ALL_DESCENDANTS)
          and: 'there is one extra datanode'
              assert originalCountBookstoreTopLevelListNodes + 1 == countTopLevelListDataNodesInBookstore()
          when: 'the new elements are deleted'
 -            objectUnderTest.deleteDataNode(FUNCTIONAL_TEST_DATASPACE_1, BOOKSTORE_ANCHOR_1 , '/invoice[@ProductID ="2"]', now)
 +            objectUnderTest.deleteDataNode(FUNCTIONAL_TEST_DATASPACE_1, BOOKSTORE_ANCHOR_1 , '/bookstore-address[@bookstore-name="Easons"]', now)
          then: 'the original number of datanodes is restored'
              assert originalCountBookstoreTopLevelListNodes == countTopLevelListDataNodesInBookstore()
      }
              'new code, new child'           | 'new'        | ', "books" : [ { "title": "New Book" } ]' || 2
      }
  
-     def 'Update multiple data node leaves.'() {
-         given: 'Updated json for bookstore data'
-             def jsonData =  "{'book-store:books':{'lang':'English/French','price':100,'title':'Matilda'}}"
-         when: 'update is performed for leaves'
-             objectUnderTest.updateNodeLeaves(FUNCTIONAL_TEST_DATASPACE_1, BOOKSTORE_ANCHOR_2, "/bookstore/categories[@code='1']", jsonData, now)
-         then: 'the updated data nodes are retrieved'
-             def result = cpsDataService.getDataNodes(FUNCTIONAL_TEST_DATASPACE_1, BOOKSTORE_ANCHOR_2, "/bookstore/categories[@code=1]/books[@title='Matilda']", INCLUDE_ALL_DESCENDANTS)
-         and: 'the leaf values are updated as expected'
-             assert result.leaves['lang'] == ['English/French']
-             assert result.leaves['price'] == [100]
-         cleanup:
-             restoreBookstoreDataAnchor(2)
-     }
      def 'Update data node leaves for node that has no leaves (yet).'() {
          given: 'new (webinfo) datanode without leaves'
              def json = '{"webinfo": {} }'
              restoreBookstoreDataAnchor(1)
      }
  
+     def 'Update multiple data node leaves.'() {
+         given: 'Updated json for bookstore data'
+             def jsonData =  "{'book-store:books':{'lang':'English/French','price':100,'title':'Matilda'}}"
+         when: 'update is performed for leaves'
+             objectUnderTest.updateNodeLeaves(FUNCTIONAL_TEST_DATASPACE_1, BOOKSTORE_ANCHOR_2, "/bookstore/categories[@code='1']", jsonData, now)
+         then: 'the updated data nodes are retrieved'
+             def result = cpsDataService.getDataNodes(FUNCTIONAL_TEST_DATASPACE_1, BOOKSTORE_ANCHOR_2, "/bookstore/categories[@code=1]/books[@title='Matilda']", INCLUDE_ALL_DESCENDANTS)
+         and: 'the leaf values are updated as expected'
+             assert result.leaves['lang'] == ['English/French']
+             assert result.leaves['price'] == [100]
+         cleanup:
+             restoreBookstoreDataAnchor(2)
+     }
      def countDataNodesInBookstore() {
          return countDataNodesInTree(objectUnderTest.getDataNodes(FUNCTIONAL_TEST_DATASPACE_1, BOOKSTORE_ANCHOR_1, '/bookstore', INCLUDE_ALL_DESCENDANTS))
      }