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