CPS-265 - updating cps path to support include-descendants option.
[cps.git] / cps-ri / src / test / groovy / org / onap / cps / spi / impl / CpsDataPersistenceServiceSpec.groovy
1 /*
2  * ============LICENSE_START=======================================================
3  *  Copyright (C) 2021 Nordix Foundation
4  *  Modifications Copyright (C) 2021 Pantheon.tech
5  *  ================================================================================
6  *  Licensed under the Apache License, Version 2.0 (the "License");
7  *  you may not use this file except in compliance with the License.
8  *  You may obtain a copy of the License at
9  *
10  *        http://www.apache.org/licenses/LICENSE-2.0
11  *  Unless required by applicable law or agreed to in writing, software
12  *  distributed under the License is distributed on an "AS IS" BASIS,
13  *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14  *  See the License for the specific language governing permissions and
15  *  limitations under the License.
16  *
17  *  SPDX-License-Identifier: Apache-2.0
18  *  ============LICENSE_END=========================================================
19  */
20 package org.onap.cps.spi.impl
21
22 import com.google.common.collect.ImmutableSet
23 import com.google.gson.Gson
24 import com.google.gson.GsonBuilder
25 import org.onap.cps.spi.CpsDataPersistenceService
26 import org.onap.cps.spi.FetchDescendantsOption
27 import org.onap.cps.spi.entities.FragmentEntity
28 import org.onap.cps.spi.exceptions.AnchorNotFoundException
29 import org.onap.cps.spi.exceptions.DataNodeNotFoundException
30 import org.onap.cps.spi.exceptions.DataspaceNotFoundException
31 import org.onap.cps.spi.model.DataNode
32 import org.onap.cps.spi.model.DataNodeBuilder
33 import org.springframework.beans.factory.annotation.Autowired
34 import org.springframework.dao.DataIntegrityViolationException
35 import org.springframework.test.context.jdbc.Sql
36 import spock.lang.Unroll
37
38 import static org.onap.cps.spi.FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS
39 import static org.onap.cps.spi.FetchDescendantsOption.OMIT_DESCENDANTS
40
41 class CpsDataPersistenceServiceSpec extends CpsPersistenceSpecBase {
42
43     @Autowired
44     CpsDataPersistenceService objectUnderTest
45
46     static final Gson GSON = new GsonBuilder().create()
47
48     static final String SET_DATA = '/data/fragment.sql'
49     static final long ID_DATA_NODE_WITH_DESCENDANTS = 4001
50     static final String XPATH_DATA_NODE_WITH_DESCENDANTS = '/parent-1'
51     static final String XPATH_DATA_NODE_WITH_LEAVES = '/parent-100'
52     static final long UPDATE_DATA_NODE_FRAGMENT_ID = 4202L
53     static final long UPDATE_DATA_NODE_SUB_FRAGMENT_ID = 4203L
54
55     static final DataNode newDataNode = new DataNodeBuilder().build()
56     static DataNode existingDataNode
57     static DataNode existingChildDataNode
58
59     def expectedLeavesByXpathMap = [
60             '/parent-100'                      : ['parent-leaf': 'parent-leaf-value'],
61             '/parent-100/child-001'            : ['first-child-leaf': 'first-child-leaf-value'],
62             '/parent-100/child-002'            : ['second-child-leaf': 'second-child-leaf-value'],
63             '/parent-100/child-002/grand-child': ['grand-child-leaf': 'grand-child-leaf-value']
64     ]
65
66     static {
67         existingDataNode = createDataNodeTree(XPATH_DATA_NODE_WITH_DESCENDANTS)
68         existingChildDataNode = createDataNodeTree('/parent-1/child-1')
69     }
70
71     @Sql([CLEAR_DATA, SET_DATA])
72     def 'StoreDataNode with descendants.'() {
73         when: 'a fragment with descendants is stored'
74             def parentXpath = "/parent-new"
75             def childXpath = "/parent-new/child-new"
76             def grandChildXpath = "/parent-new/child-new/grandchild-new"
77             objectUnderTest.storeDataNode(DATASPACE_NAME, ANCHOR_NAME1,
78                     createDataNodeTree(parentXpath, childXpath, grandChildXpath))
79         then: 'it can be retrieved by its xpath'
80             def parentFragment = getFragmentByXpath(DATASPACE_NAME, ANCHOR_NAME1, parentXpath)
81         and: 'it contains the children'
82             parentFragment.childFragments.size() == 1
83             def childFragment = parentFragment.childFragments[0]
84             childFragment.xpath == childXpath
85         and: "and its children's children"
86             childFragment.childFragments.size() == 1
87             def grandchildFragment = childFragment.childFragments[0]
88             grandchildFragment.xpath == grandChildXpath
89     }
90
91     @Unroll
92     @Sql([CLEAR_DATA, SET_DATA])
93     def 'Store datanode error scenario: #scenario.'() {
94         when: 'attempt to store a data node with #scenario'
95             objectUnderTest.storeDataNode(dataspaceName, anchorName, dataNode)
96         then: 'a #expectedException is thrown'
97             thrown(expectedException)
98         where: 'the following data is used'
99             scenario                    | dataspaceName  | anchorName     | dataNode         || expectedException
100             'dataspace does not exist'  | 'unknown'      | 'not-relevant' | newDataNode      || DataspaceNotFoundException
101             'schema set does not exist' | DATASPACE_NAME | 'unknown'      | newDataNode      || AnchorNotFoundException
102             'anchor already exists'     | DATASPACE_NAME | ANCHOR_NAME1   | existingDataNode || DataIntegrityViolationException
103     }
104
105     @Sql([CLEAR_DATA, SET_DATA])
106     def 'Add a child to a Fragment that already has a child.'() {
107         given: ' a new child node'
108             def newChild = createDataNodeTree('xpath for new child')
109         when: 'the child is added to an existing parent with 1 child'
110             objectUnderTest.addChildDataNode(DATASPACE_NAME, ANCHOR_NAME1, XPATH_DATA_NODE_WITH_DESCENDANTS, newChild)
111         then: 'the parent is now has to 2 children'
112             def expectedExistingChildPath = '/parent-1/child-1'
113             def parentFragment = fragmentRepository.findById(ID_DATA_NODE_WITH_DESCENDANTS).orElseThrow()
114             parentFragment.getChildFragments().size() == 2
115         and: 'it still has the old child'
116             parentFragment.getChildFragments().find({ it.xpath == expectedExistingChildPath })
117         and: 'it has the new child'
118             parentFragment.getChildFragments().find({ it.xpath == newChild.xpath })
119     }
120
121     @Unroll
122     @Sql([CLEAR_DATA, SET_DATA])
123     def 'Add child error scenario: #scenario.'() {
124         when: 'attempt to add a child data node with #scenario'
125             objectUnderTest.addChildDataNode(DATASPACE_NAME, ANCHOR_NAME1, parentXpath, dataNode)
126         then: 'a #expectedException is thrown'
127             thrown(expectedException)
128         where: 'the following data is used'
129             scenario                 | parentXpath                      | dataNode              || expectedException
130             'parent does not exist'  | 'unknown'                        | newDataNode           || DataNodeNotFoundException
131             'already existing child' | XPATH_DATA_NODE_WITH_DESCENDANTS | existingChildDataNode || DataIntegrityViolationException
132     }
133
134     static def createDataNodeTree(String... xpaths) {
135         def dataNodeBuilder = new DataNodeBuilder().withXpath(xpaths[0])
136         if (xpaths.length > 1) {
137             def xPathsDescendant = Arrays.copyOfRange(xpaths, 1, xpaths.length)
138             def childDataNode = createDataNodeTree(xPathsDescendant)
139             dataNodeBuilder.withChildDataNodes(ImmutableSet.of(childDataNode))
140         }
141         dataNodeBuilder.build()
142     }
143
144     def getFragmentByXpath(dataspaceName, anchorName, xpath) {
145         def dataspace = dataspaceRepository.getByName(dataspaceName)
146         def anchor = anchorRepository.getByDataspaceAndName(dataspace, anchorName)
147         return fragmentRepository.findByDataspaceAndAnchorAndXpath(dataspace, anchor, xpath).orElseThrow()
148     }
149
150     @Sql([CLEAR_DATA, SET_DATA])
151     def 'Get data node by xpath without descendants.'() {
152         when: 'data node is requested'
153             def result = objectUnderTest.getDataNode(DATASPACE_NAME, ANCHOR_FOR_DATA_NODES_WITH_LEAVES,
154                     XPATH_DATA_NODE_WITH_LEAVES, OMIT_DESCENDANTS)
155         then: 'data node is returned with no descendants'
156             assert result.getXpath() == XPATH_DATA_NODE_WITH_LEAVES
157         and: 'expected leaves'
158             assert result.getChildDataNodes().size() == 0
159             assertLeavesMaps(result.getLeaves(), expectedLeavesByXpathMap[XPATH_DATA_NODE_WITH_LEAVES])
160     }
161
162     @Sql([CLEAR_DATA, SET_DATA])
163     def 'Get data node by xpath with all descendants.'() {
164         when: 'data node is requested with all descendants'
165             def result = objectUnderTest.getDataNode(DATASPACE_NAME, ANCHOR_FOR_DATA_NODES_WITH_LEAVES,
166                     XPATH_DATA_NODE_WITH_LEAVES, INCLUDE_ALL_DESCENDANTS)
167             def mappedResult = treeToFlatMapByXpath(new HashMap<>(), result)
168         then: 'data node is returned with all the descendants populated'
169             assert mappedResult.size() == 4
170             assert result.getChildDataNodes().size() == 2
171             assert mappedResult.get('/parent-100/child-001').getChildDataNodes().size() == 0
172             assert mappedResult.get('/parent-100/child-002').getChildDataNodes().size() == 1
173         and: 'extracted leaves maps are matching expected'
174             mappedResult.forEach(
175                     (xpath, dataNode) ->
176                             assertLeavesMaps(dataNode.getLeaves(), expectedLeavesByXpathMap[xpath])
177             )
178     }
179
180     def static assertLeavesMaps(actualLeavesMap, expectedLeavesMap) {
181         expectedLeavesMap.forEach((key, value) -> {
182             def actualValue = actualLeavesMap[key]
183             if (value instanceof Collection<?> && actualValue instanceof Collection<?>) {
184                 assert value.size() == actualValue.size()
185                 assert value.containsAll(actualValue)
186             } else {
187                 assert value == actualValue
188             }
189         }
190         )
191         return true
192     }
193
194     def static treeToFlatMapByXpath(Map<String, DataNode> flatMap, DataNode dataNodeTree) {
195         flatMap.put(dataNodeTree.getXpath(), dataNodeTree)
196         dataNodeTree.getChildDataNodes()
197                 .forEach(childDataNode -> treeToFlatMapByXpath(flatMap, childDataNode))
198         return flatMap
199     }
200
201     @Unroll
202     @Sql([CLEAR_DATA, SET_DATA])
203     def 'Get data node error scenario: #scenario.'() {
204         when: 'attempt to get data node with #scenario'
205             objectUnderTest.getDataNode(dataspaceName, anchorName, xpath, OMIT_DESCENDANTS)
206         then: 'a #expectedException is thrown'
207             thrown(expectedException)
208         where: 'the following data is used'
209             scenario                 | dataspaceName  | anchorName                        | xpath          || expectedException
210             'non-existing dataspace' | 'NO DATASPACE' | 'not relevant'                    | 'not relevant' || DataspaceNotFoundException
211             'non-existing anchor'    | DATASPACE_NAME | 'NO ANCHOR'                       | 'not relevant' || AnchorNotFoundException
212             'non-existing xpath'     | DATASPACE_NAME | ANCHOR_FOR_DATA_NODES_WITH_LEAVES | 'NO XPATH'     || DataNodeNotFoundException
213     }
214
215     @Sql([CLEAR_DATA, SET_DATA])
216     def 'Update data node leaves.'() {
217         when: 'update is performed for leaves'
218             objectUnderTest.updateDataLeaves(DATASPACE_NAME, ANCHOR_FOR_DATA_NODES_WITH_LEAVES,
219                     "/parent-200/child-201", ['leaf-value': 'new'])
220         then: 'leaves are updated for selected data node'
221             def updatedFragment = fragmentRepository.getOne(UPDATE_DATA_NODE_FRAGMENT_ID)
222             def updatedLeaves = getLeavesMap(updatedFragment)
223             assert updatedLeaves.size() == 1
224             assert updatedLeaves.'leaf-value' == 'new'
225         and: 'existing child entry remains as is'
226             def childFragment = updatedFragment.getChildFragments().iterator().next()
227             def childLeaves = getLeavesMap(childFragment)
228             assert childFragment.getId() == UPDATE_DATA_NODE_SUB_FRAGMENT_ID
229             assert childLeaves.'leaf-value' == 'original'
230     }
231
232     @Unroll
233     @Sql([CLEAR_DATA, SET_DATA])
234     def 'Update data leaves error scenario: #scenario.'() {
235         when: 'attempt to update data node for #scenario'
236             objectUnderTest.updateDataLeaves(dataspaceName, anchorName, xpath, ['leaf-name': 'leaf-value'])
237         then: 'a #expectedException is thrown'
238             thrown(expectedException)
239         where: 'the following data is used'
240             scenario                 | dataspaceName  | anchorName                        | xpath                || expectedException
241             'non-existing dataspace' | 'NO DATASPACE' | 'not relevant'                    | 'not relevant'       || DataspaceNotFoundException
242             'non-existing anchor'    | DATASPACE_NAME | 'NO ANCHOR'                       | 'not relevant'       || AnchorNotFoundException
243             'non-existing xpath'     | DATASPACE_NAME | ANCHOR_FOR_DATA_NODES_WITH_LEAVES | 'NON-EXISTING XPATH' || DataNodeNotFoundException
244     }
245
246     @Sql([CLEAR_DATA, SET_DATA])
247     def 'Replace data node tree with descendants removal.'() {
248         given: 'data node object with leaves updated, no children'
249             def submittedDataNode = buildDataNode("/parent-200/child-201", ['leaf-value': 'new'], [])
250         when: 'replace data node tree is performed'
251             objectUnderTest.replaceDataNodeTree(DATASPACE_NAME, ANCHOR_FOR_DATA_NODES_WITH_LEAVES, submittedDataNode)
252         then: 'leaves have been updated for selected data node'
253             def updatedFragment = fragmentRepository.getOne(UPDATE_DATA_NODE_FRAGMENT_ID)
254             def updatedLeaves = getLeavesMap(updatedFragment)
255             assert updatedLeaves.size() == 1
256             assert updatedLeaves.'leaf-value' == 'new'
257         and: 'updated entry has no children'
258             updatedFragment.getChildFragments().isEmpty()
259         and: 'previously attached child entry is removed from database'
260             fragmentRepository.findById(UPDATE_DATA_NODE_SUB_FRAGMENT_ID).isEmpty()
261     }
262
263     @Sql([CLEAR_DATA, SET_DATA])
264     def 'Replace data node tree with descendants.'() {
265         given: 'data node object with leaves updated, having child with old content'
266             def submittedDataNode = buildDataNode("/parent-200/child-201", ['leaf-value': 'new'], [
267                     buildDataNode("/parent-200/child-201/grand-child", ['leaf-value': 'original'], [])
268             ])
269         when: 'update is performed including descendants'
270             objectUnderTest.replaceDataNodeTree(DATASPACE_NAME, ANCHOR_FOR_DATA_NODES_WITH_LEAVES, submittedDataNode)
271         then: 'leaves have been updated for selected data node'
272             def updatedFragment = fragmentRepository.getOne(UPDATE_DATA_NODE_FRAGMENT_ID)
273             def updatedLeaves = getLeavesMap(updatedFragment)
274             assert updatedLeaves.size() == 1
275             assert updatedLeaves.'leaf-value' == 'new'
276         and: 'previously attached child entry is removed from database'
277             fragmentRepository.findById(UPDATE_DATA_NODE_SUB_FRAGMENT_ID).isEmpty()
278         and: 'new child entry with same content is created'
279             def childFragment = updatedFragment.getChildFragments().iterator().next()
280             def childLeaves = getLeavesMap(childFragment)
281             assert childFragment.getId() != UPDATE_DATA_NODE_SUB_FRAGMENT_ID
282             assert childLeaves.'leaf-value' == 'original'
283     }
284
285     @Unroll
286     @Sql([CLEAR_DATA, SET_DATA])
287     def 'Replace data node tree error scenario: #scenario.'() {
288         given: 'data node object'
289             def submittedDataNode = buildDataNode(xpath, ['leaf-name': 'leaf-value'], [])
290         when: 'attempt to update data node for #scenario'
291             objectUnderTest.replaceDataNodeTree(dataspaceName, anchorName, submittedDataNode)
292         then: 'a #expectedException is thrown'
293             thrown(expectedException)
294         where: 'the following data is used'
295             scenario                 | dataspaceName  | anchorName                        | xpath                || expectedException
296             'non-existing dataspace' | 'NO DATASPACE' | 'not relevant'                    | 'not relevant'       || DataspaceNotFoundException
297             'non-existing anchor'    | DATASPACE_NAME | 'NO ANCHOR'                       | 'not relevant'       || AnchorNotFoundException
298             'non-existing xpath'     | DATASPACE_NAME | ANCHOR_FOR_DATA_NODES_WITH_LEAVES | 'NON-EXISTING XPATH' || DataNodeNotFoundException
299     }
300
301     static DataNode buildDataNode(xpath, leaves, childDataNodes) {
302         return new DataNodeBuilder().withXpath(xpath).withLeaves(leaves).withChildDataNodes(childDataNodes).build()
303     }
304
305     static Map<String, Object> getLeavesMap(FragmentEntity fragmentEntity) {
306         return GSON.fromJson(fragmentEntity.getAttributes(), Map<String, Object>.class)
307     }
308
309     @Unroll
310     @Sql([CLEAR_DATA, SET_DATA])
311     def 'Cps Path query for single leaf value with type: #type.'() {
312         when: 'a query is executed to get a data node by the given cps path'
313             def result = objectUnderTest.queryDataNodes(DATASPACE_NAME, ANCHOR_FOR_DATA_NODES_WITH_LEAVES, cpsPath, includeDescendantsOption)
314         then: 'the correct data is returned'
315             def leaves ='[common-leaf-name:common-leaf-value, common-leaf-name-int:5.0]'
316             DataNode dataNode = result.stream().findFirst().get()
317             dataNode.getLeaves().toString() == leaves
318             dataNode.getChildDataNodes().size() == expectedNumberOfChidlNodes
319         where: 'the following data is used'
320             type                         | cpsPath                                                          | includeDescendantsOption | expectedNumberOfChidlNodes
321             'String and no descendants'  | '/parent-200/child-202[@common-leaf-name=\'common-leaf-value\']' | OMIT_DESCENDANTS         | 0
322             'Integer and descendants'    | '/parent-200/child-202[@common-leaf-name-int=5]'                 | INCLUDE_ALL_DESCENDANTS  | 1
323     }
324
325     @Unroll
326     @Sql([CLEAR_DATA, SET_DATA])
327     def 'Query for attribute by cps path with cps paths that return no data because of #scenario.'() {
328         when: 'a query is executed to get datanodes for the given cps path'
329             def result = objectUnderTest.queryDataNodes(DATASPACE_NAME, ANCHOR_FOR_DATA_NODES_WITH_LEAVES, cpsPath, FetchDescendantsOption.OMIT_DESCENDANTS)
330         then: 'no data is returned'
331             result.isEmpty()
332         where: 'following cps queries are performed'
333             scenario                         | cpsPath
334             'cps path is incomplete'         | '/parent-200[@common-leaf-name-int=5]'
335             'missing / at beginning of path' | 'parent-200/child-202[@common-leaf-name-int=5]'
336             'leaf value does not exist'      | '/parent-200/child-202[@common-leaf-name=\'does not exist\']'
337             'incomplete end of xpath prefix' | '/parent-200/child-20[@common-leaf-name-int=5]'
338     }
339 }