# ============LICENSE_START=======================================================
# Copyright (c) 2021-2022 Bell Canada.
# Modifications Copyright (C) 2021-2022 Nordix Foundation
-# Modifications Copyright (C) 2022-2024 TechMahindra Ltd.
+# Modifications Copyright (C) 2022-2025 TechMahindra Ltd.
# Modifications Copyright (C) 2022 Deutsche Telekom AG
# ================================================================================
# Licensed under the Apache License, Version 2.0 (the "License");
responses:
'200':
$ref: 'components.yml#/components/responses/Ok'
+ '201':
+ $ref: 'components.yml#/components/responses/Created'
'400':
$ref: 'components.yml#/components/responses/BadRequest'
'403':
if (Boolean.TRUE.equals(dryRunEnabled)) {
cpsDataService.validateData(dataspaceName, anchorName, parentNodeXpath, nodeData, contentType);
return ResponseEntity.ok().build();
- } else {
- cpsDataService.updateDataNodeAndDescendants(dataspaceName, anchorName, parentNodeXpath,
- nodeData, toOffsetDateTime(observedTimestamp), contentType);
}
- return ResponseEntity.status(HttpStatus.OK).build();
+ final boolean hasNewDataNodes = cpsDataService.updateDataNodeAndDescendants(dataspaceName, anchorName,
+ parentNodeXpath, nodeData, toOffsetDateTime(observedTimestamp), contentType);
+ final HttpStatus httpStatus = hasNewDataNodes ? HttpStatus.CREATED : HttpStatus.OK;
+ return ResponseEntity.status(httpStatus).build();
}
@Override
'XML content: some xpath by parent' | '/some/xpath' | MediaType.APPLICATION_XML || '/some/xpath' | requestBodyXml | expectedXmlData | ContentType.XML
}
+ def 'Replace data node tree returns #hasNewNodes for #scenario.'() {
+ given: 'endpoint to replace node'
+ def endpoint = "$dataNodeBaseEndpointV2/anchors/$anchorName/nodes"
+ when: 'put request is performed'
+ def response =
+ mvc.perform(
+ put(endpoint)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(requestBodyJson)
+ .param('xpath', ''))
+ .andReturn().response
+ then: 'the cps data service method is invoked with expected parameters'
+ 1 * mockCpsDataService.updateDataNodeAndDescendants(dataspaceName, anchorName, '/', expectedJsonData, noTimestamp, ContentType.JSON) >> hasNewNodes
+ and: 'response status indicates success or creation'
+ assert response.status == expectedStatus
+ where:
+ scenario | hasNewNodes || expectedStatus
+ 'JSON content: root node updated only' | false || HttpStatus.OK.value()
+ 'JSON content: root node with new list items' | true || HttpStatus.CREATED.value()
+ }
+
def 'Validate data using Replace data node API.'() {
given: 'endpoint to replace node'
def endpoint = "$dataNodeBaseEndpointV1/anchors/$anchorName/nodes"
* Copyright (C) 2021-2025 OpenInfra Foundation Europe. All rights reserved.
* Modifications Copyright (C) 2021 Pantheon.tech
* Modifications Copyright (C) 2020-2022 Bell Canada.
- * Modifications Copyright (C) 2022-2023 TechMahindra Ltd.
+ * Modifications Copyright (C) 2022-2025 TechMahindra Ltd.
* ================================================================================
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
}
@Override
- public void updateDataNodesAndDescendants(final String dataspaceName, final String anchorName,
+ public boolean updateDataNodesAndDescendants(final String dataspaceName, final String anchorName,
final Collection<DataNode> updatedDataNodes) {
final AnchorEntity anchorEntity = getAnchorEntity(dataspaceName, anchorName);
-
final Map<String, DataNode> xpathToUpdatedDataNode = updatedDataNodes.stream()
.collect(Collectors.toMap(DataNode::getXpath, dataNode -> dataNode));
-
final Collection<String> xpaths = xpathToUpdatedDataNode.keySet();
Collection<FragmentEntity> existingFragmentEntities = getFragmentEntities(anchorEntity, xpaths);
-
- logMissingXPaths(xpaths, existingFragmentEntities);
-
+ final List<DataNode> newDataNodes = identifyNewDataNodes(updatedDataNodes, existingFragmentEntities);
existingFragmentEntities = fragmentRepository.prefetchDescendantsOfFragmentEntities(
FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS, existingFragmentEntities);
+ updateExistingFragments(existingFragmentEntities, xpathToUpdatedDataNode);
+ addFragmentEntitiesForNewDataNodes(anchorEntity, newDataNodes, existingFragmentEntities);
+ persistFragmentEntitiesWithRetry(anchorEntity, existingFragmentEntities);
+ return !newDataNodes.isEmpty();
+ }
+ private void updateExistingFragments(final Collection<FragmentEntity> existingFragmentEntities,
+ final Map<String, DataNode> xpathToUpdatedDataNode) {
for (final FragmentEntity existingFragmentEntity : existingFragmentEntities) {
final DataNode updatedDataNode = xpathToUpdatedDataNode.get(existingFragmentEntity.getXpath());
updateFragmentEntityAndDescendantsWithDataNode(existingFragmentEntity, updatedDataNode);
}
+ }
+ private void addFragmentEntitiesForNewDataNodes(final AnchorEntity anchorEntity, final List<DataNode> newNodes,
+ final Collection<FragmentEntity> existingFragmentEntities) {
+ for (final DataNode newNode : newNodes) {
+ final FragmentEntity newFragmentEntity = convertToFragmentWithAllDescendants(anchorEntity, newNode);
+ existingFragmentEntities.add(newFragmentEntity);
+ }
+ }
+
+ private void persistFragmentEntitiesWithRetry(final AnchorEntity anchorEntity,
+ final Collection<FragmentEntity> existingFragmentEntities) {
try {
fragmentRepository.saveAll(existingFragmentEntities);
} catch (final StaleStateException staleStateException) {
}
}
+ private List<DataNode> identifyNewDataNodes(final Collection<DataNode> updatedDataNodes,
+ final Collection<FragmentEntity> existingFragmentEntities) {
+ final Set<String> existingXpaths = existingFragmentEntities.stream()
+ .map(FragmentEntity::getXpath)
+ .collect(Collectors.toSet());
+ return updatedDataNodes.stream()
+ .filter(dataNode -> !existingXpaths.contains(dataNode.getXpath()))
+ .collect(Collectors.toList());
+ }
+
private void retryUpdateDataNodesIndividually(final AnchorEntity anchorEntity,
final Collection<FragmentEntity> fragmentEntities) {
final Collection<String> failedXpaths = new HashSet<>();
* ============LICENSE_START=======================================================
* Copyright (c) 2021 Bell Canada.
* Modifications Copyright (C) 2021-2023 Nordix Foundation
- * Modifications Copyright (C) 2022-2023 TechMahindra Ltd.
+ * Modifications Copyright (C) 2022-2025 TechMahindra Ltd.
* ================================================================================
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
}})
}
+ def 'Replace data nodes and add new list item'() {
+ given: 'The fragment repository returns existing fragment for known XPath'
+ def existingFragmentEntity = new FragmentEntity(id: 1, xpath: '/bookstore/categories[@code=1]/books[@title="Matilda"]', attributes: '{"title":"Matilda","lang":"English"}', anchor: anchorEntity, childFragments: [] as Set)
+ mockFragmentRepository.findByAnchorAndXpathIn(_, _ as Set) >> [existingFragmentEntity]
+ and: 'a data node that represents replacing an attribute from an existing one'
+ def dataNode1 = new DataNode(xpath: '/bookstore/categories[@code=1]/books[@title="Matilda"]', leaves: ['title': 'Matilda', 'lang': 'French'])
+ and: 'a data node that represents adding a new list item'
+ def dataNode2 = new DataNode(xpath: '/bookstore/categories[@code=1]/books[@title="Book 2"]', leaves: ['title': 'Book 2', 'lang': 'English'])
+ when: 'the fragment entities are updated by the data nodes'
+ def result = objectUnderTest.updateDataNodesAndDescendants('dataspace', 'anchor', [dataNode1, dataNode2])
+ then: 'fragment repository saves the updated new fragments'
+ 1 * mockFragmentRepository.saveAll({ fragmentEntities ->
+ {
+ assert fragmentEntities.size() == 2
+ def fragmentEntityPerXpath = fragmentEntities.collectEntries { [it.xpath, it] }
+ assert fragmentEntityPerXpath.get('/bookstore/categories[@code=1]/books[@title="Matilda"]').attributes.contains('"lang":"French"')
+ assert fragmentEntityPerXpath.get('/bookstore/categories[@code=1]/books[@title="Book 2"]').attributes.contains('"lang":"English"')
+ }})
+ and: 'result confirms new nodes were inserted'
+ assert result
+ }
+
def createDataNodeAndMockRepositoryMethodSupportingIt(xpath, scenario) {
def dataNode = new DataNodeBuilder().withXpath(xpath).build()
def fragmentEntity = new FragmentEntity(xpath: xpath, childFragments: [])
* @param nodeData node data
* @param observedTimestamp observedTimestamp
* @param contentType JSON/XML content type
+ * @return true if new data nodes were created during the update;
+ * false if only existing nodes were updated or no changes were applied
*/
- void updateDataNodeAndDescendants(String dataspaceName, String anchorName, String parentNodeXpath, String nodeData,
- OffsetDateTime observedTimestamp, ContentType contentType);
+ boolean updateDataNodeAndDescendants(String dataspaceName, String anchorName, String parentNodeXpath,
+ String nodeData, OffsetDateTime observedTimestamp, ContentType contentType);
/**
* Replaces multiple existing data nodes' content including descendants in a batch operation.
@Override
@Timed(value = "cps.data.service.datanode.descendants.update",
description = "Time taken to update a data node and descendants")
- public void updateDataNodeAndDescendants(final String dataspaceName, final String anchorName,
+ public boolean updateDataNodeAndDescendants(final String dataspaceName, final String anchorName,
final String parentNodeXpath, final String nodeData,
final OffsetDateTime observedTimestamp, final ContentType contentType) {
cpsValidator.validateNameCharacters(dataspaceName, anchorName);
final Anchor anchor = cpsAnchorService.getAnchor(dataspaceName, anchorName);
final Collection<DataNode> dataNodes = dataNodeFactory
.createDataNodesWithAnchorParentXpathAndNodeData(anchor, parentNodeXpath, nodeData, contentType);
- cpsDataPersistenceService.updateDataNodesAndDescendants(dataspaceName, anchorName, dataNodes);
+ final boolean hasNewDataNodes = cpsDataPersistenceService
+ .updateDataNodesAndDescendants(dataspaceName, anchorName, dataNodes);
sendDataUpdatedEvent(anchor, parentNodeXpath, Operation.UPDATE, observedTimestamp);
+ return hasNewDataNodes;
}
@Override
* Copyright (C) 2020-2025 OpenInfra Foundation Europe. All rights reserved.
* Modifications Copyright (C) 2021 Pantheon.tech
* Modifications Copyright (C) 2022 Bell Canada
- * Modifications Copyright (C) 2022-2023 TechMahindra Ltd.
+ * Modifications Copyright (C) 2022-2025 TechMahindra Ltd.
* ================================================================================
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* @param dataspaceName dataspace name
* @param anchorName anchor name
* @param dataNodes data nodes
+ * @return true if new data nodes were created during the update;
+ * false if only existing nodes were updated or no changes were applied
+ *
*/
- void updateDataNodesAndDescendants(String dataspaceName, String anchorName, final Collection<DataNode> dataNodes);
+ boolean updateDataNodesAndDescendants(String dataspaceName, String anchorName, final Collection<DataNode>
+ dataNodes);
/**
* Replaces list content by removing all existing elements and inserting the given new elements
schema:
type: object
description: OK
+ "201":
+ content:
+ application/json:
+ schema:
+ example: my-resource
+ type: string
+ description: Created
"400":
content:
application/json: