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
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.
18 * SPDX-License-Identifier: Apache-2.0
19 * ============LICENSE_END=========================================================
21 package org.onap.cps.spi.impl
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
40 import javax.validation.ConstraintViolationException
42 import static org.onap.cps.spi.FetchDescendantsOption.INCLUDE_ALL_DESCENDANTS
43 import static org.onap.cps.spi.FetchDescendantsOption.OMIT_DESCENDANTS
45 class CpsDataPersistenceServiceSpec extends CpsPersistenceSpecBase {
48 CpsDataPersistenceService objectUnderTest
50 static final Gson GSON = new GsonBuilder().create()
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
59 static final DataNode newDataNode = new DataNodeBuilder().build()
60 static DataNode existingDataNode
61 static DataNode existingChildDataNode
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']
71 existingDataNode = createDataNodeTree(XPATH_DATA_NODE_WITH_DESCENDANTS)
72 existingChildDataNode = createDataNodeTree('/parent-1/child-1')
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
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
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
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 })
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
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))
162 dataNodeBuilder.build()
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()
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])
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(
197 assertLeavesMaps(dataNode.getLeaves(), expectedLeavesByXpathMap[xpath])
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)
208 assert value == actualValue
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))
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
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'
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
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()
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'], [])
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'
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
322 static DataNode buildDataNode(xpath, leaves, childDataNodes) {
323 return new DataNodeBuilder().withXpath(xpath).withLeaves(leaves).withChildDataNodes(childDataNodes).build()
326 static Map<String, Object> getLeavesMap(FragmentEntity fragmentEntity) {
327 return GSON.fromJson(fragmentEntity.getAttributes(), Map<String, Object>.class)
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
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'
353 where: 'following cps queries are performed'
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 'empty cps path of type ends with' | '///'
362 @Sql([CLEAR_DATA, SET_DATA])
363 def 'Cps Path query with and without descendants using #type.'() {
364 when: 'a query is executed to get a data node by the given cps path'
365 def cpsPath = '///child-202'
366 def result = objectUnderTest.queryDataNodes(DATASPACE_NAME, ANCHOR_FOR_DATA_NODES_WITH_LEAVES, cpsPath, includeDescendantsOption)
367 then: 'the data node has the correct number of children'
368 DataNode dataNode = result.stream().findFirst().get()
369 dataNode.getChildDataNodes().size() == expectedNumberOfChildNodes
370 where: 'the following data is used'
371 type | includeDescendantsOption | expectedNumberOfChildNodes
372 'ends with and omit descendants' | OMIT_DESCENDANTS | 0
373 'ends with and include all descendants' | INCLUDE_ALL_DESCENDANTS | 1
377 @Sql([CLEAR_DATA, SET_DATA])
378 def 'Cps Path query using ends with variations of #type.'() {
379 when: 'a query is executed to get a data node by the given cps path'
380 def result = objectUnderTest.queryDataNodes(DATASPACE_NAME, ANCHOR_FOR_DATA_NODES_WITH_LEAVES, cpsPath, OMIT_DESCENDANTS)
381 then: 'the correct number of data nodes is returned'
382 result.size() == expectedNumberOfDataNodes
383 where: 'the following data is used'
384 type | cpsPath | expectedNumberOfDataNodes
385 'single match with / prefix' | '///child-202' | 1
386 'single match without / prefix' | '//grand-child-202' | 1
387 'multiple matches' | '//202' | 2