subscription-registry node in subscription loader
[cps.git] / cps-ri / src / test / groovy / org / onap / cps / spi / impl / CpsDataPersistenceServiceIntegrationSpec.groovy
1 /*
2  *  ============LICENSE_START=======================================================
3  *  Copyright (C) 2021-2023 Nordix Foundation
4  *  Modifications Copyright (C) 2021 Pantheon.tech
5  *  Modifications Copyright (C) 2021-2022 Bell Canada.
6  *  Modifications Copyright (C) 2022-2023 TechMahindra Ltd.
7  *  ================================================================================
8  *  Licensed under the Apache License, Version 2.0 (the "License");
9  *  you may not use this file except in compliance with the License.
10  *  You may obtain a copy of the License at
11  *
12  *        http://www.apache.org/licenses/LICENSE-2.0
13  *
14  *  Unless required by applicable law or agreed to in writing, software
15  *  distributed under the License is distributed on an "AS IS" BASIS,
16  *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17  *  See the License for the specific language governing permissions and
18  *  limitations under the License.
19  *
20  *  SPDX-License-Identifier: Apache-2.0
21  *  ============LICENSE_END=========================================================
22  */
23
24 package org.onap.cps.spi.impl
25
26 import com.fasterxml.jackson.databind.ObjectMapper
27 import com.google.common.collect.ImmutableSet
28 import org.onap.cps.cpspath.parser.PathParsingException
29 import org.onap.cps.spi.CpsDataPersistenceService
30 import org.onap.cps.spi.entities.FragmentEntity
31 import org.onap.cps.spi.exceptions.AlreadyDefinedExceptionBatch
32 import org.onap.cps.spi.exceptions.AnchorNotFoundException
33 import org.onap.cps.spi.exceptions.CpsAdminException
34 import org.onap.cps.spi.exceptions.CpsPathException
35 import org.onap.cps.spi.exceptions.DataNodeNotFoundException
36 import org.onap.cps.spi.exceptions.DataspaceNotFoundException
37 import org.onap.cps.spi.model.DataNode
38 import org.onap.cps.spi.model.DataNodeBuilder
39 import org.onap.cps.utils.JsonObjectMapper
40 import org.springframework.beans.factory.annotation.Autowired
41 import org.springframework.test.context.jdbc.Sql
42
43 import javax.validation.ConstraintViolationException
44
45 import static org.onap.cps.spi.FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS
46 import static org.onap.cps.spi.FetchDescendantsOption.OMIT_DESCENDANTS
47
48 class CpsDataPersistenceServiceIntegrationSpec extends CpsPersistenceSpecBase {
49
50     @Autowired
51     CpsDataPersistenceService objectUnderTest
52
53     static JsonObjectMapper jsonObjectMapper = new JsonObjectMapper(new ObjectMapper())
54     static DataNodeBuilder dataNodeBuilder = new DataNodeBuilder()
55
56     static final String SET_DATA = '/data/fragment.sql'
57     static long ID_DATA_NODE_WITH_DESCENDANTS = 4001
58     static String XPATH_DATA_NODE_WITH_DESCENDANTS = '/parent-1'
59     static long DATA_NODE_202_FRAGMENT_ID = 4202L
60     static long CHILD_OF_DATA_NODE_202_FRAGMENT_ID = 4203L
61     static long LIST_DATA_NODE_PARENT201_FRAGMENT_ID = 4206L
62     static long LIST_DATA_NODE_PARENT203_FRAGMENT_ID = 4214L
63     static long LIST_DATA_NODE_PARENT202_FRAGMENT_ID = 4211L
64     static long PARENT_3_FRAGMENT_ID = 4003L
65
66     static Collection<DataNode> newDataNodes = [new DataNodeBuilder().build()]
67     static Collection<DataNode> existingDataNodes = [createDataNodeTree(XPATH_DATA_NODE_WITH_DESCENDANTS)]
68     static Collection<DataNode> existingChildDataNodes = [createDataNodeTree('/parent-1/child-1')]
69
70     def static deleteTestParentXPath = '/parent-200'
71     def static deleteTestChildXpath = "${deleteTestParentXPath}/child-with-slash[@key='a/b']"
72     def static deleteTestGrandChildXPath = "${deleteTestChildXpath}/grandChild"
73
74     def expectedLeavesByXpathMap = [
75             '/parent-207'                      : ['parent-leaf': 'parent-leaf value'],
76             '/parent-207/child-001'            : ['first-child-leaf': 'first-child-leaf value'],
77             '/parent-207/child-002'            : ['second-child-leaf': 'second-child-leaf value'],
78             '/parent-207/child-002/grand-child': ['grand-child-leaf': 'grand-child-leaf value']
79     ]
80
81     @Sql([CLEAR_DATA, SET_DATA])
82     def 'Get all datanodes with descendants .'() {
83         when: 'data nodes are retrieved by their xpath'
84             def dataNodes = objectUnderTest.getDataNodesForMultipleXpaths(DATASPACE_NAME, ANCHOR_NAME1, ['/parent-1'], INCLUDE_ALL_DESCENDANTS)
85         then: 'same data nodes are returned by getDataNodesForMultipleXpaths method'
86             assert objectUnderTest.getDataNodes(DATASPACE_NAME, ANCHOR_NAME1, '/parent-1', INCLUDE_ALL_DESCENDANTS) == dataNodes
87         and: 'the dataNodes have no prefix (to be addressed by CPS-1301)'
88             assert dataNodes[0].moduleNamePrefix == null
89     }
90
91     @Sql([CLEAR_DATA, SET_DATA])
92     def 'Storing and Retrieving a new DataNodes with descendants.'() {
93         when: 'a fragment with descendants is stored'
94             def parentXpath = '/parent-new'
95             def childXpath = '/parent-new/child-new'
96             def grandChildXpath = '/parent-new/child-new/grandchild-new'
97             def dataNodes = [createDataNodeTree(parentXpath, childXpath, grandChildXpath)]
98             objectUnderTest.storeDataNodes(DATASPACE_NAME, ANCHOR_NAME1, dataNodes)
99         then: 'it can be retrieved by its xpath'
100             def dataNode = objectUnderTest.getDataNodes(DATASPACE_NAME, ANCHOR_NAME1, parentXpath, INCLUDE_ALL_DESCENDANTS)
101             assert dataNode[0].xpath == parentXpath
102         and: 'it has the correct child'
103             assert dataNode[0].childDataNodes.size() == 1
104             def childDataNode = dataNode[0].childDataNodes[0]
105             assert childDataNode.xpath == childXpath
106         and: 'and its grandchild'
107             assert childDataNode.childDataNodes.size() == 1
108             def grandChildDataNode = childDataNode.childDataNodes[0]
109             assert grandChildDataNode.xpath == grandChildXpath
110     }
111
112     @Sql([CLEAR_DATA, SET_DATA])
113     def 'Store data node for multiple anchors using the same schema.'() {
114         def xpath = '/parent-new'
115         given: 'a fragment is stored for an anchor'
116             objectUnderTest.storeDataNodes(DATASPACE_NAME, ANCHOR_NAME1, [createDataNodeTree(xpath)])
117         when: 'another fragment is stored for an other anchor, using the same schema set'
118             objectUnderTest.storeDataNodes(DATASPACE_NAME, ANCHOR_NAME3, [createDataNodeTree(xpath)])
119         then: 'both fragments can be retrieved by their xpath'
120             def fragment1 = getFragmentByXpath(DATASPACE_NAME, ANCHOR_NAME1, xpath)
121             fragment1.anchor.name == ANCHOR_NAME1
122             fragment1.xpath == xpath
123             def fragment2 = getFragmentByXpath(DATASPACE_NAME, ANCHOR_NAME3, xpath)
124             fragment2.anchor.name == ANCHOR_NAME3
125             fragment2.xpath == xpath
126     }
127
128     @Sql([CLEAR_DATA, SET_DATA])
129     def 'Store datanodes error scenario: #scenario.'() {
130         when: 'attempt to store a data node with #scenario'
131             objectUnderTest.storeDataNodes(dataspaceName, anchorName, dataNodes)
132         then: 'a #expectedException is thrown'
133             thrown(expectedException)
134         where: 'the following data is used'
135             scenario                    | dataspaceName  | anchorName     | dataNodes          || expectedException
136             'dataspace does not exist'  | 'unknown'      | 'not-relevant' | newDataNodes       || DataspaceNotFoundException
137             'schema set does not exist' | DATASPACE_NAME | 'unknown'      | newDataNodes       || AnchorNotFoundException
138             'anchor already exists'     | DATASPACE_NAME | ANCHOR_NAME1   | newDataNodes       || ConstraintViolationException
139             'datanode already exists'   | DATASPACE_NAME | ANCHOR_NAME1   | existingDataNodes  || AlreadyDefinedExceptionBatch
140     }
141
142     @Sql([CLEAR_DATA, SET_DATA])
143     def 'Add children to a Fragment that already has a child.'() {
144         given: 'collection of new child data nodes'
145             def newChild1 = createDataNodeTree('/parent-1/child-2')
146             def newChild2 = createDataNodeTree('/parent-1/child-3')
147             def newChildrenCollection = [newChild1, newChild2]
148         when: 'the child is added to an existing parent with 1 child'
149             objectUnderTest.addChildDataNodes(DATASPACE_NAME, ANCHOR_NAME1, XPATH_DATA_NODE_WITH_DESCENDANTS, newChildrenCollection)
150         then: 'the parent is now has to 3 children'
151             def expectedExistingChildPath = '/parent-1/child-1'
152             def parentFragment = fragmentRepository.findById(ID_DATA_NODE_WITH_DESCENDANTS).orElseThrow()
153             parentFragment.childFragments.size() == 3
154         and: 'it still has the old child'
155             parentFragment.childFragments.find({ it.xpath == expectedExistingChildPath })
156         and: 'it has the new children'
157             parentFragment.childFragments.find({ it.xpath == newChildrenCollection[0].xpath })
158             parentFragment.childFragments.find({ it.xpath == newChildrenCollection[1].xpath })
159     }
160
161     @Sql([CLEAR_DATA, SET_DATA])
162     def 'Add child error scenario: #scenario.'() {
163         when: 'attempt to add a child data node with #scenario'
164             objectUnderTest.addChildDataNodes(DATASPACE_NAME, ANCHOR_NAME1, parentXpath, dataNodes)
165         then: 'a #expectedException is thrown'
166             thrown(expectedException)
167         where: 'the following data is used'
168             scenario                 | parentXpath                      | dataNodes               || expectedException
169             'parent does not exist'  | '/unknown'                       | newDataNodes            || DataNodeNotFoundException
170             'already existing child' | XPATH_DATA_NODE_WITH_DESCENDANTS | existingChildDataNodes  || AlreadyDefinedExceptionBatch
171     }
172
173     @Sql([CLEAR_DATA, SET_DATA])
174     def 'Add collection of multiple new list elements including an element with a child datanode.'() {
175         given: 'two new child list elements for an existing parent'
176             def listElementXpaths = ['/parent-201/child-204[@key="NEW1"]', '/parent-201/child-204[@key="NEW2"]']
177             def listElements = toDataNodes(listElementXpaths)
178         and: 'a (grand)child data node for one of the new list elements'
179             def grandChild = buildDataNode('/parent-201/child-204[@key="NEW1"]/grand-child-204[@key2="NEW1-CHILD"]', [leave:'value'], [])
180             listElements[0].childDataNodes = [grandChild]
181         when: 'the new data node (list elements) are added to an existing parent node'
182             objectUnderTest.addMultipleLists(DATASPACE_NAME, ANCHOR_NAME3, '/parent-201', [listElements])
183         then: 'new entries are successfully persisted, parent node now contains 5 children (2 new + 3 existing before)'
184             def parentFragment = fragmentRepository.getById(LIST_DATA_NODE_PARENT201_FRAGMENT_ID)
185             def allChildXpaths = parentFragment.childFragments.collect { it.xpath }
186             assert allChildXpaths.size() == 5
187             assert allChildXpaths.containsAll(listElementXpaths)
188         and: 'the (grand)child node of the new list entry is also present'
189             def dataspaceEntity = dataspaceRepository.getByName(DATASPACE_NAME)
190             def anchorEntity = anchorRepository.getByDataspaceAndName(dataspaceEntity, ANCHOR_NAME3)
191             def grandChildFragmentEntity = fragmentRepository.findByDataspaceAndAnchorAndXpath(dataspaceEntity, anchorEntity, grandChild.xpath)
192             assert grandChildFragmentEntity.isPresent()
193     }
194
195     @Sql([CLEAR_DATA, SET_DATA])
196     def 'Add multiple list with a mix of existing and new elements'() {
197         given: 'two new child list elements for an existing parent'
198             def existingDataNode = dataNodeBuilder.withXpath('/parent-207/child-001').withLeaves(['id': '001']).build()
199             def newDataNode1 = dataNodeBuilder.withXpath('/parent-207/child-new1').withLeaves(['id': 'new1']).build()
200             def newDataNode2 = dataNodeBuilder.withXpath('/parent-200/child-new2').withLeaves(['id': 'new2']).build()
201             def dataNodeList1 = [existingDataNode, newDataNode1]
202             def dataNodeList2 = [newDataNode2]
203         when: 'duplicate data node is requested to be added'
204             objectUnderTest.addMultipleLists(DATASPACE_NAME, ANCHOR_HAVING_SINGLE_TOP_LEVEL_FRAGMENT, '/', [dataNodeList1, dataNodeList2])
205         then: 'already defined batch exception is thrown'
206             def thrown = thrown(AlreadyDefinedExceptionBatch)
207         and: 'it only contains the xpath(s) of the duplicated elements'
208             assert thrown.alreadyDefinedXpaths.size() == 1
209             assert thrown.alreadyDefinedXpaths.contains('/parent-207/child-001')
210         and: 'it does NOT contains the xpaths of the new element that were not combined with existing elements'
211             assert !thrown.alreadyDefinedXpaths.contains('/parent-207/child-new1')
212             assert !thrown.alreadyDefinedXpaths.contains('/parent-207/child-new1')
213         and: 'the new entity is inserted correctly'
214             def dataspaceEntity = dataspaceRepository.getByName(DATASPACE_NAME)
215             def anchorEntity = anchorRepository.getByDataspaceAndName(dataspaceEntity, ANCHOR_HAVING_SINGLE_TOP_LEVEL_FRAGMENT)
216             fragmentRepository.findByDataspaceAndAnchorAndXpath(dataspaceEntity, anchorEntity, '/parent-200/child-new2').isPresent()
217     }
218
219     @Sql([CLEAR_DATA, SET_DATA])
220     def 'Add list element error scenario: #scenario.'() {
221         given: 'list element as a collection of data nodes'
222             def listElements = toDataNodes(listElementXpaths)
223         when: 'attempt to add list elements to parent node'
224             objectUnderTest.addListElements(DATASPACE_NAME, ANCHOR_NAME3, parentNodeXpath, listElements)
225         then: 'a #expectedException is thrown'
226             thrown(expectedException)
227         where: 'following parameters were used'
228             scenario                        | parentNodeXpath | listElementXpaths                   || expectedException
229             'parent node does not exist'    | '/unknown'      | ['irrelevant']                      || DataNodeNotFoundException
230             'data fragment already exists'  | '/parent-201'   | ["/parent-201/child-204[@key='A']"] || AlreadyDefinedExceptionBatch
231     }
232
233     @Sql([CLEAR_DATA, SET_DATA])
234     def 'Get all data nodes by single xpath without descendants : #scenario'() {
235         when: 'data nodes are requested'
236             def result = objectUnderTest.getDataNodesForMultipleXpaths(DATASPACE_NAME, ANCHOR_WITH_MULTIPLE_TOP_LEVEL_FRAGMENTS,
237                 [inputXPath], OMIT_DESCENDANTS)
238         then: 'data nodes under root are returned'
239             assert result.childDataNodes.size() == 2
240         and: 'no descendants of parent nodes are returned'
241             result.each {assert it.childDataNodes.size() == 0}
242         and: 'same data nodes are returned when V2 of get Data Nodes API is executed'
243             assert objectUnderTest.getDataNodes(DATASPACE_NAME, ANCHOR_WITH_MULTIPLE_TOP_LEVEL_FRAGMENTS,
244                 inputXPath, OMIT_DESCENDANTS) == result
245         where: 'the following xpath is used'
246             scenario      | inputXPath
247             'root xpath'  | '/'
248             'empty xpath' | ''
249     }
250
251     @Sql([CLEAR_DATA, SET_DATA])
252     def 'Cps Path query with syntax error throws a CPS Path Exception.'() {
253         when: 'trying to execute a query with a syntax (parsing) error'
254             objectUnderTest.getDataNodes(DATASPACE_NAME, ANCHOR_FOR_DATA_NODES_WITH_LEAVES, 'invalid-cps-path/child' , OMIT_DESCENDANTS)
255         then: 'exception is thrown'
256             def exceptionThrown = thrown(PathParsingException)
257             assert exceptionThrown.getMessage().contains('failed to parse at line 1 due to extraneous input \'invalid-cps-path\' expecting \'/\'')
258     }
259
260     @Sql([CLEAR_DATA, SET_DATA])
261     def 'Get all data nodes by single xpath with all descendants : #scenario'() {
262         when: 'data nodes are requested with all descendants'
263             def result = objectUnderTest.getDataNodesForMultipleXpaths(DATASPACE_NAME, ANCHOR_WITH_MULTIPLE_TOP_LEVEL_FRAGMENTS,
264                 [inputXPath], INCLUDE_ALL_DESCENDANTS)
265             def mappedResult = multipleTreesToFlatMapByXpath(new HashMap<>(), result)
266         then: 'data nodes are returned with all the descendants populated'
267             assert mappedResult.size() == 8
268             assert result.childDataNodes.size() == 2
269             assert mappedResult.get('/parent-208/child-001').childDataNodes.size() == 0
270             assert mappedResult.get('/parent-208/child-002').childDataNodes.size() == 1
271             assert mappedResult.get('/parent-209/child-001').childDataNodes.size() == 0
272             assert mappedResult.get('/parent-209/child-002').childDataNodes.size() == 1
273         and: 'same data nodes are returned when V2 of Get Data Nodes API is executed'
274             assert objectUnderTest.getDataNodes(DATASPACE_NAME, ANCHOR_WITH_MULTIPLE_TOP_LEVEL_FRAGMENTS,
275                 inputXPath, INCLUDE_ALL_DESCENDANTS) == result
276         where: 'the following data is used'
277             scenario      | inputXPath
278             'root xpath'  | '/'
279             'empty xpath' | ''
280     }
281
282     @Sql([CLEAR_DATA, SET_DATA])
283     def 'Get data nodes error scenario : #scenario.'() {
284         when: 'attempt to get data nodes with #scenario'
285             objectUnderTest.getDataNodes(dataspaceName, anchorName, xpath, OMIT_DESCENDANTS)
286         then: 'an #expectedException is thrown'
287             thrown(expectedException)
288         where: 'the following data is used'
289             scenario             | dataspaceName  | anchorName                        | xpath           || expectedException
290             'non existing xpath' | DATASPACE_NAME | ANCHOR_FOR_DATA_NODES_WITH_LEAVES | '/NO-XPATH'     || DataNodeNotFoundException
291             'invalid Xpath'      | DATASPACE_NAME | ANCHOR_FOR_DATA_NODES_WITH_LEAVES | 'INVALID XPATH' || PathParsingException
292     }
293
294     @Sql([CLEAR_DATA, SET_DATA])
295     def 'Get data nodes for multiple xpaths.'() {
296         when: 'fetch #scenario.'
297             def results = objectUnderTest.getDataNodesForMultipleXpaths(DATASPACE_NAME, ANCHOR_NAME3, inputXpaths, OMIT_DESCENDANTS)
298         then: 'the expected number of data nodes are returned'
299             assert results.size() == expectedResultSize
300         where: 'following parameters were used'
301             scenario                               | inputXpaths                                     || expectedResultSize
302             '0 nodes'                              | []                                              || 0
303             '1 node'                               | ["/parent-200"]                                 || 1
304             '2 unique nodes'                       | ["/parent-200", "/parent-201"]                  || 2
305             '3 unique nodes'                       | ["/parent-200", "/parent-201", "/parent-202"]   || 3
306             '1 unique node with duplicate xpath'   | ["/parent-200", "/parent-200"]                  || 1
307             '2 unique nodes with duplicate xpath'  | ["/parent-200", "/parent-202", "/parent-200"]   || 2
308             'list element with key (single quote)' | ["/parent-201/child-204[@key='A']"]             || 1
309             'list element with key (double quote)' | ['/parent-201/child-204[@key="A"]']             || 1
310             'whole list (not implemented)'         | ["/parent-201/child-204"]                       || 0
311             'non-existing xpath'                   | ["/NO-XPATH"]                                   || 0
312             'existing and non-existing xpaths'     | ["/parent-200", "/NO-XPATH", "/parent-201"]     || 2
313             'invalid xpath'                        | ["INVALID XPATH"]                               || 0
314             'valid and invalid xpaths'             | ["/parent-200", "INVALID XPATH", "/parent-201"] || 2
315             'root xpath'                           | ["/"]                                           || 7
316             'empty (root) xpath'                   | [""]                                            || 7
317             'root and top-level xpaths'            | ["/", "/parent-200", "/parent-201"]             || 7
318             'root and child xpaths'                | ["/", "/parent-200/child-201"]                  || 8
319     }
320
321     @Sql([CLEAR_DATA, SET_DATA])
322     def 'Get data nodes for collection of xpath error scenario : #scenario.'() {
323         when: 'attempt to get data nodes with #scenario'
324             objectUnderTest.getDataNodesForMultipleXpaths(dataspaceName, anchorName, ['/not-relevant'], OMIT_DESCENDANTS)
325         then: 'a #expectedException is thrown'
326             thrown(expectedException)
327         where: 'the following data is used'
328             scenario                 | dataspaceName  | anchorName     || expectedException
329             'non-existing dataspace' | 'NO DATASPACE' | 'not relevant' || DataspaceNotFoundException
330             'non-existing anchor'    | DATASPACE_NAME | 'NO ANCHOR'    || AnchorNotFoundException
331     }
332
333     @Sql([CLEAR_DATA, SET_DATA])
334     def 'Update data node leaves.'() {
335         when: 'update is performed for leaves'
336             objectUnderTest.updateDataLeaves(DATASPACE_NAME, ANCHOR_FOR_DATA_NODES_WITH_LEAVES,
337                     '/parent-200/child-201', ['leaf-value': 'new'])
338         then: 'leaves are updated for selected data node'
339             def updatedFragment = fragmentRepository.getById(DATA_NODE_202_FRAGMENT_ID)
340             def updatedLeaves = getLeavesMap(updatedFragment)
341             assert updatedLeaves.size() == 1
342             assert updatedLeaves.'leaf-value' == 'new'
343         and: 'existing child entry remains as is'
344             def childFragment = updatedFragment.childFragments.iterator().next()
345             def childLeaves = getLeavesMap(childFragment)
346             assert childFragment.id == CHILD_OF_DATA_NODE_202_FRAGMENT_ID
347             assert childLeaves.'leaf-value' == 'original'
348     }
349
350     @Sql([CLEAR_DATA, SET_DATA])
351     def 'Update data leaves error scenario: #scenario.'() {
352         when: 'attempt to update data node for #scenario'
353             objectUnderTest.updateDataLeaves(dataspaceName, anchorName, xpath, ['leaf-name': 'leaf-value'])
354         then: 'a #expectedException is thrown'
355             thrown(expectedException)
356         where: 'the following data is used'
357             scenario                 | dataspaceName  | anchorName                        | xpath                 || expectedException
358             'non-existing dataspace' | 'NO DATASPACE' | 'not relevant'                    | '/not relevant'       || DataspaceNotFoundException
359             'non-existing anchor'    | DATASPACE_NAME | 'NO ANCHOR'                       | '/not relevant'       || AnchorNotFoundException
360             'non-existing xpath'     | DATASPACE_NAME | ANCHOR_FOR_DATA_NODES_WITH_LEAVES | '/NON-EXISTING-XPATH' || DataNodeNotFoundException
361     }
362
363     @Sql([CLEAR_DATA, SET_DATA])
364     def 'Update data node and descendants by removing descendants.'() {
365         given: 'data node object with leaves updated, no children'
366             def submittedDataNode = buildDataNode('/parent-200/child-201', ['leaf-value': 'new'], [])
367         when: 'update data nodes and descendants is performed'
368             objectUnderTest.updateDataNodeAndDescendants(DATASPACE_NAME, ANCHOR_FOR_DATA_NODES_WITH_LEAVES, submittedDataNode)
369         then: 'leaves have been updated for selected data node'
370             def updatedFragment = fragmentRepository.getById(DATA_NODE_202_FRAGMENT_ID)
371             def updatedLeaves = getLeavesMap(updatedFragment)
372             assert updatedLeaves.size() == 1
373             assert updatedLeaves.'leaf-value' == 'new'
374         and: 'updated entry has no children'
375             updatedFragment.childFragments.isEmpty()
376         and: 'previously attached child entry is removed from database'
377             fragmentRepository.findById(CHILD_OF_DATA_NODE_202_FRAGMENT_ID).isEmpty()
378     }
379
380     @Sql([CLEAR_DATA, SET_DATA])
381     def 'Update data node and descendants with new descendants'() {
382         given: 'data node object with leaves updated, having child with old content'
383             def submittedDataNode = buildDataNode('/parent-200/child-201', ['leaf-value': 'new'], [
384                   buildDataNode('/parent-200/child-201/grand-child', ['leaf-value': 'original'], [])
385             ])
386         when: 'update is performed including descendants'
387             objectUnderTest.updateDataNodeAndDescendants(DATASPACE_NAME, ANCHOR_FOR_DATA_NODES_WITH_LEAVES, submittedDataNode)
388         then: 'leaves have been updated for selected data node'
389             def updatedFragment = fragmentRepository.getById(DATA_NODE_202_FRAGMENT_ID)
390             def updatedLeaves = getLeavesMap(updatedFragment)
391             assert updatedLeaves.size() == 1
392             assert updatedLeaves.'leaf-value' == 'new'
393         and: 'existing child entry is not updated as content is same'
394             def childFragment = updatedFragment.childFragments.iterator().next()
395             childFragment.xpath == '/parent-200/child-201/grand-child'
396             def childLeaves = getLeavesMap(childFragment)
397             assert childLeaves.'leaf-value' == 'original'
398     }
399
400     @Sql([CLEAR_DATA, SET_DATA])
401     def 'Update data node and descendants with same descendants but changed leaf value.'() {
402         given: 'data node object with leaves updated, having child with old content'
403             def submittedDataNode = buildDataNode('/parent-200/child-201', ['leaf-value': 'new'], [
404                     buildDataNode('/parent-200/child-201/grand-child', ['leaf-value': 'new'], [])
405             ])
406         when: 'update is performed including descendants'
407             objectUnderTest.updateDataNodeAndDescendants(DATASPACE_NAME, ANCHOR_FOR_DATA_NODES_WITH_LEAVES, submittedDataNode)
408         then: 'leaves have been updated for selected data node'
409             def updatedFragment = fragmentRepository.getById(DATA_NODE_202_FRAGMENT_ID)
410             def updatedLeaves = getLeavesMap(updatedFragment)
411             assert updatedLeaves.size() == 1
412             assert updatedLeaves.'leaf-value' == 'new'
413         and: 'existing child entry is updated with the new content'
414             def childFragment = updatedFragment.childFragments.iterator().next()
415             childFragment.xpath == '/parent-200/child-201/grand-child'
416             def childLeaves = getLeavesMap(childFragment)
417             assert childLeaves.'leaf-value' == 'new'
418     }
419
420     @Sql([CLEAR_DATA, SET_DATA])
421     def 'Update data node and descendants with different descendants xpath'() {
422         given: 'data node object with leaves updated, having child with old content'
423             def submittedDataNode = buildDataNode('/parent-200/child-201', ['leaf-value': 'new'], [
424                     buildDataNode('/parent-200/child-201/grand-child-new', ['leaf-value': 'new'], [])
425             ])
426         when: 'update is performed including descendants'
427             objectUnderTest.updateDataNodeAndDescendants(DATASPACE_NAME, ANCHOR_FOR_DATA_NODES_WITH_LEAVES, submittedDataNode)
428         then: 'leaves have been updated for selected data node'
429             def updatedFragment = fragmentRepository.getById(DATA_NODE_202_FRAGMENT_ID)
430             def updatedLeaves = getLeavesMap(updatedFragment)
431             assert updatedLeaves.size() == 1
432             assert updatedLeaves.'leaf-value' == 'new'
433         and: 'previously attached child entry is removed from database'
434             fragmentRepository.findById(CHILD_OF_DATA_NODE_202_FRAGMENT_ID).isEmpty()
435         and: 'new child entry is persisted'
436             def childFragment = updatedFragment.childFragments.iterator().next()
437             childFragment.xpath == '/parent-200/child-201/grand-child-new'
438             def childLeaves = getLeavesMap(childFragment)
439             assert childLeaves.'leaf-value' == 'new'
440     }
441
442     @Sql([CLEAR_DATA, SET_DATA])
443     def 'Update data node and descendants error scenario: #scenario.'() {
444         given: 'data node object'
445             def submittedDataNode = buildDataNode(xpath, ['leaf-name': 'leaf-value'], [])
446         when: 'attempt to update data node for #scenario'
447             objectUnderTest.updateDataNodeAndDescendants(dataspaceName, anchorName, submittedDataNode)
448         then: 'a #expectedException is thrown'
449             thrown(expectedException)
450         where: 'the following data is used'
451             scenario                 | dataspaceName  | anchorName                        | xpath                 || expectedException
452             'non-existing dataspace' | 'NO DATASPACE' | 'not relevant'                    | '/not relevant'       || DataspaceNotFoundException
453             'non-existing anchor'    | DATASPACE_NAME | 'NO ANCHOR'                       | '/not relevant'       || AnchorNotFoundException
454             'non-existing xpath'     | DATASPACE_NAME | ANCHOR_FOR_DATA_NODES_WITH_LEAVES | '/NON-EXISTING-XPATH' || DataNodeNotFoundException
455             'invalid xpath'          | DATASPACE_NAME | ANCHOR_FOR_DATA_NODES_WITH_LEAVES | 'INVALID XPATH'       || CpsPathException
456     }
457
458     @Sql([CLEAR_DATA, SET_DATA])
459     def 'Update existing list with #scenario.'() {
460         given: 'a parent having a list of data nodes containing: #originalKeys (ech list element has a child too)'
461             def parentXpath = '/parent-3'
462             if (originalKeys.size() > 0) {
463                 def originalListEntriesAsDataNodes = createChildListAllHavingAttributeValue(parentXpath, 'original value', originalKeys, true)
464                 objectUnderTest.addListElements(DATASPACE_NAME, ANCHOR_NAME1, parentXpath, originalListEntriesAsDataNodes)
465             }
466         and: 'each original list element has one child'
467             def originalParentFragment = fragmentRepository.getById(PARENT_3_FRAGMENT_ID)
468             originalParentFragment.childFragments.each {assert it.childFragments.size() == 1 }
469         when: 'it is updated with #scenario'
470             def replacementListEntriesAsDataNodes = createChildListAllHavingAttributeValue(parentXpath, 'new value', replacementKeys, false)
471             objectUnderTest.replaceListContent(DATASPACE_NAME, ANCHOR_NAME1, parentXpath, replacementListEntriesAsDataNodes)
472         then: 'the result list ONLY contains the expected replacement elements'
473             def parentFragment = fragmentRepository.getById(PARENT_3_FRAGMENT_ID)
474             def allChildXpaths = parentFragment.childFragments.collect { it.xpath }
475             def expectedListEntriesAfterUpdateAsXpaths = keysToXpaths(parentXpath, replacementKeys)
476             assert allChildXpaths.size() == replacementKeys.size()
477             assert allChildXpaths.containsAll(expectedListEntriesAfterUpdateAsXpaths)
478         and: 'all the list elements have the new values'
479             assert parentFragment.childFragments.stream().allMatch(childFragment -> childFragment.attributes.contains('new value'))
480         and: 'there are no more grandchildren as none of the replacement list entries had a child'
481             parentFragment.childFragments.each {assert it.childFragments.size() == 0 }
482         where: 'the following replacement lists are applied'
483             scenario                                            | originalKeys | replacementKeys
484             'one existing entry only'                           | []           | ['NEW']
485             'multiple new entries'                              | []           | ['NEW1', 'NEW2']
486             'one new entry only (existing entries are deleted)' | ['A', 'B']   | ['NEW1', 'NEW2']
487             'one existing on new entry'                         | ['A', 'B']   | ['A', 'NEW']
488             'one existing entry only'                           | ['A', 'B']   | ['A']
489     }
490
491     @Sql([CLEAR_DATA, SET_DATA])
492     def 'Replacing existing list element with attributes and (grand)child.'() {
493         given: 'a parent with list elements A and B with attribute and grandchild tagged as "org"'
494             def parentXpath = '/parent-3'
495             def originalListEntriesAsDataNodes = createChildListAllHavingAttributeValue(parentXpath, 'org', ['A','B'], true)
496             objectUnderTest.addListElements(DATASPACE_NAME, ANCHOR_NAME1, parentXpath, originalListEntriesAsDataNodes)
497         when: 'A is replaced with an entry with attribute and grandchild tagged tagged as "new" (B is not in replacement list)'
498             def replacementListEntriesAsDataNodes = createChildListAllHavingAttributeValue(parentXpath, 'new', ['A'], true)
499             objectUnderTest.replaceListContent(DATASPACE_NAME, ANCHOR_NAME1, parentXpath, replacementListEntriesAsDataNodes)
500         then: 'The updated fragment has a child-list with ONLY element "A"'
501             def parentFragment = fragmentRepository.getById(PARENT_3_FRAGMENT_ID)
502             parentFragment.childFragments.size() == 1
503             def childListElementA = parentFragment.childFragments[0]
504             childListElementA.xpath == "/parent-3/child-list[@key='A']"
505         and: 'element "A" has an attribute with the "new" (tag) value'
506             childListElementA.attributes == '{"attr1": "new"}'
507         and: 'element "A" has a only one (grand)child'
508             childListElementA.childFragments.size() == 1
509         and: 'the grandchild is the new grandchild (tag)'
510             def grandChild = childListElementA.childFragments[0]
511             grandChild.xpath == "/parent-3/child-list[@key='A']/new-grand-child"
512         and: 'the grandchild has an attribute with the "new" (tag) value'
513             grandChild.attributes == '{"attr1": "new"}'
514     }
515
516     @Sql([CLEAR_DATA, SET_DATA])
517     def 'Replace list element for a parent (parent-1) with existing one (non-list) child'() {
518         when: 'a list element is added under the parent'
519             def replacementListEntriesAsDataNodes = createChildListAllHavingAttributeValue(XPATH_DATA_NODE_WITH_DESCENDANTS, 'new', ['A','B'], false)
520             objectUnderTest.replaceListContent(DATASPACE_NAME, ANCHOR_NAME1, XPATH_DATA_NODE_WITH_DESCENDANTS, replacementListEntriesAsDataNodes)
521         then: 'the parent will have 3 children after the replacement'
522             def parentFragment = fragmentRepository.getById(ID_DATA_NODE_WITH_DESCENDANTS)
523             parentFragment.childFragments.size() == 3
524             def xpaths = parentFragment.childFragments.collect {it.xpath}
525         and: 'one of the children is the original child fragment'
526             xpaths.contains('/parent-1/child-1')
527         and: 'it has the two new list elements'
528             xpaths.containsAll("/parent-1/child-list[@key='A']", "/parent-1/child-list[@key='B']")
529     }
530
531     @Sql([CLEAR_DATA, SET_DATA])
532     def 'Replace list content using unknown parent'() {
533         given: 'list element as a collection of data nodes'
534             def listElementCollection = toDataNodes(['irrelevant'])
535         when: 'attempt to replace list elements under unknown parent node'
536             objectUnderTest.replaceListContent(DATASPACE_NAME, ANCHOR_NAME3, '/unknown', listElementCollection)
537         then: 'a datanode not found exception is thrown'
538             thrown(DataNodeNotFoundException)
539     }
540
541     @Sql([CLEAR_DATA, SET_DATA])
542     def 'Replace list content with empty collection is not supported'() {
543         when: 'attempt to replace list elements with empty collection'
544             objectUnderTest.replaceListContent(DATASPACE_NAME, ANCHOR_NAME3, '/parent-203', [])
545         then: 'a CPS admin exception is thrown'
546             def thrown = thrown(CpsAdminException)
547             assert thrown.message == 'Invalid list replacement'
548     }
549
550     @Sql([CLEAR_DATA, SET_DATA])
551     def 'Delete list scenario: #scenario.'() {
552         when: 'deleting list is executed for: #scenario.'
553             objectUnderTest.deleteListDataNode(DATASPACE_NAME, ANCHOR_NAME3, targetXpaths)
554         and: 'remaining children are fetched'
555             def parentFragment = fragmentRepository.getById(parentFragmentId)
556             def remainingChildXpaths = parentFragment.childFragments.collect { it.xpath }
557         then: 'only the expected children remain'
558             assert remainingChildXpaths.size() == expectedRemainingChildXpaths.size()
559             assert remainingChildXpaths.containsAll(expectedRemainingChildXpaths)
560         where: 'following parameters were used'
561             scenario                          | targetXpaths                                                 | parentFragmentId                     || expectedRemainingChildXpaths
562             'list element with key'           | '/parent-203/child-204[@key="A"]'                            | LIST_DATA_NODE_PARENT203_FRAGMENT_ID || ["/parent-203/child-203", "/parent-203/child-204[@key='B']"]
563             'list element with combined keys' | '/parent-202/child-205[@key="A" and @key2="B"]'              | LIST_DATA_NODE_PARENT202_FRAGMENT_ID || ["/parent-202/child-206[@key='A']"]
564             'whole list'                      | '/parent-203/child-204'                                      | LIST_DATA_NODE_PARENT203_FRAGMENT_ID || ['/parent-203/child-203']
565             'list element under list element' | '/parent-203/child-204[@key="B"]/grand-child-204[@key2="Y"]' | LIST_DATA_NODE_PARENT203_FRAGMENT_ID || ["/parent-203/child-203", "/parent-203/child-204[@key='A']", "/parent-203/child-204[@key='B']"]
566     }
567
568     @Sql([CLEAR_DATA, SET_DATA])
569     def 'Delete multiple data nodes using scenario: #scenario.'() {
570         when: 'deleting nodes is executed for: #scenario.'
571             objectUnderTest.deleteDataNodes(DATASPACE_NAME, ANCHOR_NAME3, targetXpaths)
572         and: 'remaining children are fetched'
573             def parentFragment = fragmentRepository.getById(LIST_DATA_NODE_PARENT203_FRAGMENT_ID)
574             def remainingChildXpaths = parentFragment.childFragments.collect { it.xpath }
575         then: 'only the expected children remain'
576             assert remainingChildXpaths.size() == expectedRemainingChildXpaths.size()
577             assert remainingChildXpaths.containsAll(expectedRemainingChildXpaths)
578         where: 'following parameters were used'
579             scenario                          | targetXpaths                                                           || expectedRemainingChildXpaths
580             'delete nothing'                  | []                                                                     || ["/parent-203/child-203", "/parent-203/child-204[@key='A']", "/parent-203/child-204[@key='B']"]
581             'datanode'                        | ['/parent-203/child-203']                                              || ["/parent-203/child-204[@key='A']", "/parent-203/child-204[@key='B']"]
582             '1 list element'                  | ['/parent-203/child-204[@key="A"]']                                    || ["/parent-203/child-203", "/parent-203/child-204[@key='B']"]
583             '2 list elements'                 | ['/parent-203/child-204[@key="A"]', '/parent-203/child-204[@key="B"]'] || ["/parent-203/child-203"]
584             'whole list'                      | ['/parent-203/child-204']                                              || ['/parent-203/child-203']
585             'list and element in same list'   | ['/parent-203/child-204', '/parent-203/child-204[@key="A"]']           || ['/parent-203/child-203']
586             'list element under list element' | ['/parent-203/child-204[@key="B"]/grand-child-204[@key2="Y"]']         || ["/parent-203/child-203", "/parent-203/child-204[@key='A']", "/parent-203/child-204[@key='B']"]
587             'invalid xpath'                   | ['INVALID XPATH', '/parent-203/child-204']                             || ['/parent-203/child-203']
588     }
589
590     @Sql([CLEAR_DATA, SET_DATA])
591     def 'Delete multiple data nodes error scenario: #scenario.'() {
592         when: 'deleting nodes is executed for: #scenario.'
593             objectUnderTest.deleteDataNodes(dataspaceName, anchorName, targetXpaths)
594         then: 'a #expectedException is thrown'
595             thrown(expectedException)
596         where: 'the following data is used'
597             scenario                 | dataspaceName  | anchorName     | targetXpaths            || expectedException
598             'non-existing dataspace' | 'NO DATASPACE' | 'not relevant' | ['/not relevant']       || DataspaceNotFoundException
599             'non-existing anchor'    | DATASPACE_NAME | 'NO ANCHOR'    | ['/not relevant']       || AnchorNotFoundException
600             'non-existing datanode'  | DATASPACE_NAME | ANCHOR_NAME3   | ['/NON-EXISTING-XPATH'] || DataNodeNotFoundException
601     }
602
603     @Sql([CLEAR_DATA, SET_DATA])
604     def 'Delete data nodes with "/"-token in list key value: #scenario. (CPS-1409)'() {
605         given: 'a data nodes with list-element child with "/" in index value (and grandchild)'
606             def grandChild = new DataNodeBuilder().withXpath(deleteTestGrandChildXPath).build()
607             def child = new DataNodeBuilder().withXpath(deleteTestChildXpath).withChildDataNodes([grandChild]).build()
608             objectUnderTest.addChildDataNode(DATASPACE_NAME, ANCHOR_NAME3, deleteTestParentXPath, child)
609         and: 'number of children before delete is stored'
610             def numberOfChildrenBeforeDelete = objectUnderTest.getDataNodes(DATASPACE_NAME, ANCHOR_NAME3, pathToParentOfDeletedNode, INCLUDE_ALL_DESCENDANTS)[0].childDataNodes.size()
611         when: 'target node is deleted'
612             objectUnderTest.deleteDataNode(DATASPACE_NAME, ANCHOR_NAME3, deleteTarget)
613         then: 'one child has been deleted'
614             def numberOfChildrenAfterDelete = objectUnderTest.getDataNodes(DATASPACE_NAME, ANCHOR_NAME3, pathToParentOfDeletedNode, INCLUDE_ALL_DESCENDANTS)[0].childDataNodes.size()
615             assert numberOfChildrenAfterDelete == numberOfChildrenBeforeDelete - 1
616         where:
617             scenario                | deleteTarget              | pathToParentOfDeletedNode
618             'list element with /'   | deleteTestChildXpath      | deleteTestParentXPath
619             'child of list element' | deleteTestGrandChildXPath | deleteTestChildXpath
620     }
621
622     @Sql([CLEAR_DATA, SET_DATA])
623     def 'Delete list error scenario: #scenario.'() {
624         when: 'attempting to delete scenario: #scenario.'
625             objectUnderTest.deleteListDataNode(DATASPACE_NAME, ANCHOR_NAME3, targetXpaths)
626         then: 'a DataNodeNotFoundException is thrown'
627             thrown(DataNodeNotFoundException)
628         where: 'following parameters were used'
629             scenario                                   | targetXpaths
630             'whole list, parent node does not exist'   | '/unknown/some-child'
631             'list element, parent node does not exist' | '/unknown/child-204[@key="A"]'
632             'whole list does not exist'                | '/parent-200/unknown'
633             'list element, list does not exist'        | '/parent-200/unknown[@key="C"]'
634             'list element, element does not exist'     | '/parent-203/child-204[@key="C"]'
635             'valid datanode but not a list'            | '/parent-200/child-202'
636     }
637
638     @Sql([CLEAR_DATA, SET_DATA])
639     def 'Delete data node by xpath #scenario.'() {
640         given: 'a valid data node'
641             def dataNode
642         and: 'data nodes are deleted'
643             objectUnderTest.deleteDataNode(DATASPACE_NAME, ANCHOR_NAME3, xpathForDeletion)
644         when: 'verify data nodes are removed'
645             objectUnderTest.getDataNodes(DATASPACE_NAME, ANCHOR_NAME3, xpathForDeletion, INCLUDE_ALL_DESCENDANTS)
646         then:
647             thrown(DataNodeNotFoundException)
648         and: 'some related object is not deleted'
649             if (xpathSurvivor!=null) {
650                 dataNode = objectUnderTest.getDataNodes(DATASPACE_NAME, ANCHOR_NAME3, xpathSurvivor, INCLUDE_ALL_DESCENDANTS)
651                 assert dataNode[0].xpath == xpathSurvivor
652             }
653         where: 'following parameters were used'
654             scenario                               | xpathForDeletion                                  || xpathSurvivor
655             'child data node, parent still exists' | '/parent-206/child-206'                           || '/parent-206'
656             'list element, sibling still exists'   | '/parent-206/child-206/grand-child-206[@key="A"]' || "/parent-206/child-206/grand-child-206[@key='X']"
657             'container node'                       | '/parent-206'                                     || null
658             'container list node'                  | '/parent-206[@key="A"]'                           || "/parent-206[@key='B']"
659             'root node with xpath /'               | '/'                                               || null
660             'root node with xpath passed as blank' | ''                                                || null
661     }
662
663     @Sql([CLEAR_DATA, SET_DATA])
664     def 'Delete data node error scenario: #scenario.'() {
665         when: 'data node is deleted'
666             objectUnderTest.deleteDataNode(DATASPACE_NAME, ANCHOR_NAME3, datanodeXpath)
667         then: 'a #expectedException is thrown'
668             thrown(expectedException)
669         where: 'the following parameters were used'
670             scenario                                        | datanodeXpath                                    | expectedException
671             'valid data node, non existent child node'      | '/parent-203/child-non-existent'                 | DataNodeNotFoundException
672             'invalid list element'                          | '/parent-206/child-206/grand-child-206@key="A"]' | PathParsingException
673     }
674
675     @Sql([CLEAR_DATA, SET_DATA])
676     def 'Delete data node for an anchor.'() {
677         given: 'a data-node exists for an anchor'
678             assert fragmentsExistInDB(1001, 3003)
679         when: 'data nodes are deleted '
680             objectUnderTest.deleteDataNodes(DATASPACE_NAME, ANCHOR_NAME3)
681         then: 'all data-nodes are deleted successfully'
682             assert !fragmentsExistInDB(1001, 3003)
683     }
684
685     @Sql([CLEAR_DATA, SET_DATA])
686     def 'Delete data node for multiple anchors.'() {
687         given: 'a data-node exists for an anchor'
688             assert fragmentsExistInDB(1001, 3001)
689             assert fragmentsExistInDB(1001, 3003)
690         when: 'data nodes are deleted '
691             objectUnderTest.deleteDataNodes(DATASPACE_NAME, ['ANCHOR-001', 'ANCHOR-003'])
692         then: 'all data-nodes are deleted successfully'
693             assert !fragmentsExistInDB(1001, 3001)
694             assert !fragmentsExistInDB(1001, 3003)
695     }
696
697     def fragmentsExistInDB(dataSpaceId, anchorId) {
698         !fragmentRepository.findRootsByDataspaceAndAnchor(dataSpaceId, anchorId).isEmpty()
699     }
700
701     static Collection<DataNode> toDataNodes(xpaths) {
702         return xpaths.collect { new DataNodeBuilder().withXpath(it).build() }
703     }
704
705
706     static DataNode buildDataNode(xpath, leaves, childDataNodes) {
707         return dataNodeBuilder.withXpath(xpath).withLeaves(leaves).withChildDataNodes(childDataNodes).build()
708     }
709
710     static Map<String, Object> getLeavesMap(FragmentEntity fragmentEntity) {
711         return jsonObjectMapper.convertJsonString(fragmentEntity.attributes, Map<String, Object>.class)
712     }
713
714     def static assertLeavesMaps(actualLeavesMap, expectedLeavesMap) {
715         expectedLeavesMap.forEach((key, value) -> {
716             def actualValue = actualLeavesMap[key]
717             if (value instanceof Collection<?> && actualValue instanceof Collection<?>) {
718                 assert value.size() == actualValue.size()
719                 assert value.containsAll(actualValue)
720             } else {
721                 assert value == actualValue
722             }
723         })
724         return true
725     }
726
727     def static treeToFlatMapByXpath(Map<String, DataNode> flatMap, DataNode dataNodeTree) {
728         flatMap.put(dataNodeTree.xpath, dataNodeTree)
729         dataNodeTree.childDataNodes
730                 .forEach(childDataNode -> treeToFlatMapByXpath(flatMap, childDataNode))
731         return flatMap
732     }
733
734     def static multipleTreesToFlatMapByXpath(Map<String, DataNode> flatMap, Collection<DataNode> dataNodeTrees) {
735         for (DataNode dataNodeTree: dataNodeTrees){
736             flatMap.put(dataNodeTree.xpath, dataNodeTree)
737             dataNodeTree.childDataNodes
738                 .forEach(childDataNode -> multipleTreesToFlatMapByXpath(flatMap, [childDataNode]))
739         }
740         return flatMap
741     }
742
743     def keysToXpaths(parent, Collection keys) {
744         return keys.collect { "${parent}/child-list[@key='${it}']".toString() }
745     }
746
747     def static createDataNodeTree(String... xpaths) {
748         def dataNodeBuilder = new DataNodeBuilder().withXpath(xpaths[0])
749         if (xpaths.length > 1) {
750             def xPathsDescendant = Arrays.copyOfRange(xpaths, 1, xpaths.length)
751             def childDataNode = createDataNodeTree(xPathsDescendant)
752             dataNodeBuilder.withChildDataNodes(ImmutableSet.of(childDataNode))
753         }
754         dataNodeBuilder.build()
755     }
756
757     def getFragmentByXpath(dataspaceName, anchorName, xpath) {
758         def dataspace = dataspaceRepository.getByName(dataspaceName)
759         def anchor = anchorRepository.getByDataspaceAndName(dataspace, anchorName)
760         return fragmentRepository.findByDataspaceAndAnchorAndXpath(dataspace, anchor, xpath).orElseThrow()
761     }
762
763
764     def createChildListAllHavingAttributeValue(parentXpath, tag, Collection keys, boolean addGrandChild) {
765         def listElementAsDataNodes = keysToXpaths(parentXpath, keys).collect {
766                 new DataNodeBuilder()
767                     .withXpath(it)
768                     .withLeaves([attr1: tag])
769                     .build()
770         }
771         if (addGrandChild) {
772             listElementAsDataNodes.each {it.childDataNodes = [createGrandChild(it.xpath, tag)]}
773         }
774         return listElementAsDataNodes
775     }
776
777     def createGrandChild(parentXPath, tag) {
778         new DataNodeBuilder()
779             .withXpath("${parentXPath}/${tag}-grand-child")
780             .withLeaves([attr1: tag])
781             .build()
782     }
783
784 }