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