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