YAML configuration: add basic version management (#4667)

Each YAML element provider can now define which YAML file versions it is applicable to and whether it is deprecated or not.

Related to #3666

Signed-off-by: Laurent Garnier <lg.hc@free.fr>
This commit is contained in:
lolodomo
2025-04-04 11:23:41 +02:00
committed by GitHub
parent 089961ac43
commit 7b6e4c0718
4 changed files with 170 additions and 8 deletions
@@ -18,12 +18,13 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link YamlModelListener} interface is responsible for managing a particular model type
* with data processed from YAML configuration files.
* with data processed from YAML configuration files having a version that is supported by this manager.
* <p />
* Implementors are notified whenever a YAML model changed that contains elements of the given type. They MUST declare
* at least {@link YamlModelListener} as service to automatically register them with the repository.
*
* @author Laurent Garnier - Initial contribution
* @author Laurent Garnier - Added basic version management
*/
@NonNullByDefault
public interface YamlModelListener<T extends YamlElement> {
@@ -62,4 +63,19 @@ public interface YamlModelListener<T extends YamlElement> {
* @return the DTO element class
*/
Class<T> getElementClass();
/**
* Indicates whether a YAML file version is supported.
*
* @param version the YAML file version to check
* @return true if the version is supported
*/
boolean isVersionSupported(int version);
/**
* Indicates whether this manager is deprecated.
*
* @return true if this manager is deprecated
*/
boolean isDeprecated();
}
@@ -70,6 +70,7 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLParser;
* @author Laurent Garnier - Initial contribution
* @author Jan N. Klug - Refactored for multiple types per file and add modifying possibility
* @author Laurent Garnier - Introduce version 2 using map instead of table
* @author Laurent Garnier - Added basic version management
*/
@NonNullByDefault
@Component(immediate = true)
@@ -157,11 +158,12 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener,
if (removedModel == null) {
return;
}
int version = removedModel.getVersion();
for (Map.Entry<String, List<JsonNode>> modelEntry : removedModel.getNodesV1().entrySet()) {
String elementName = modelEntry.getKey();
List<JsonNode> removedNodes = modelEntry.getValue();
if (!removedNodes.isEmpty()) {
getElementListeners(elementName).forEach(listener -> {
getElementListeners(elementName, version).forEach(listener -> {
List removedElements = parseJsonNodesV1(removedNodes, listener.getElementClass());
listener.removedModel(modelName, removedElements);
});
@@ -171,7 +173,7 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener,
String elementName = modelEntry.getKey();
JsonNode removedMapNode = modelEntry.getValue();
if (removedMapNode != null) {
getElementListeners(elementName).forEach(listener -> {
getElementListeners(elementName, version).forEach(listener -> {
List removedElements = parseJsonMapNode(removedMapNode, listener.getElementClass());
listener.removedModel(modelName, removedElements);
});
@@ -234,7 +236,7 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener,
List<JsonNode> oldNodeV1Elements = model.getNodesV1().getOrDefault(elementName, List.of());
JsonNode oldNodeElements = model.getNodes().get(elementName);
for (YamlModelListener<?> elementListener : getElementListeners(elementName)) {
for (YamlModelListener<?> elementListener : getElementListeners(elementName, modelVersion)) {
Class<? extends YamlElement> elementClass = elementListener.getElementClass();
Map<String, ? extends YamlElement> oldElements = listToMap(
@@ -250,6 +252,13 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener,
e -> oldElements.containsKey(e.getId()) && !e.equals(oldElements.get(e.getId())))
.toList();
if (elementListener.isDeprecated()
&& (!addedElements.isEmpty() || !updatedElements.isEmpty())) {
logger.warn(
"Element {} in model {} version {} is still supported but deprecated, please consider migrating your model to a more recent version",
elementName, modelName, modelVersion);
}
if (!addedElements.isEmpty()) {
elementListener.addedModel(modelName, addedElements);
}
@@ -291,6 +300,15 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener,
// iterate over all models and notify he new listener of already existing models with this type
for (Map.Entry<String, YamlModelWrapper> model : modelCache.entrySet()) {
String modelName = model.getKey();
int modelVersion = model.getValue().getVersion();
if (!listener.isVersionSupported(modelVersion)) {
continue;
}
if (listener.isDeprecated()) {
logger.warn(
"Element {} in model {} version {} is still supported but deprecated, please consider migrating your model to a more recent version",
elementName, modelName, modelVersion);
}
List<JsonNode> modelNodes = model.getValue().getNodesV1().getOrDefault(elementName, List.of());
JsonNode modelMapNode = model.getValue().getNodes().get(elementName);
if (modelNodes.isEmpty() && modelMapNode == null) {
@@ -343,7 +361,7 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener,
}
}
// notify listeners
for (YamlModelListener<?> l : getElementListeners(elementName)) {
for (YamlModelListener<?> l : getElementListeners(elementName, model.getVersion())) {
List newElements = parseJsonNodes(addedNodes, mapAddedNode, l.getElementClass());
if (!newElements.isEmpty()) {
l.addedModel(modelName, newElements);
@@ -398,7 +416,7 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener,
mapRemovedNode = objectMapper.valueToTree(Map.of(id, element.cloneWithoutId()));
}
// notify listeners
for (YamlModelListener<?> l : getElementListeners(elementName)) {
for (YamlModelListener<?> l : getElementListeners(elementName, model.getVersion())) {
List oldElements = parseJsonNodes(removedNodes, mapRemovedNode, l.getElementClass());
if (!oldElements.isEmpty()) {
l.removedModel(modelName, oldElements);
@@ -455,7 +473,7 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener,
mapUpdatedNode = objectMapper.valueToTree(Map.of(id, element.cloneWithoutId()));
}
// notify listeners
for (YamlModelListener<?> l : getElementListeners(elementName)) {
for (YamlModelListener<?> l : getElementListeners(elementName, model.getVersion())) {
List newElements = parseJsonNodes(updatedNodes, mapUpdatedNode, l.getElementClass());
if (!newElements.isEmpty()) {
l.updatedModel(modelName, newElements);
@@ -516,6 +534,10 @@ public class YamlModelRepositoryImpl implements WatchService.WatchEventListener,
return Objects.requireNonNull(elementListeners.computeIfAbsent(elementName, k -> new CopyOnWriteArrayList<>()));
}
private List<YamlModelListener<?>> getElementListeners(String elementName, int version) {
return getElementListeners(elementName).stream().filter(l -> l.isVersionSupported(version)).toList();
}
// To be used only for version 1
private <T extends YamlElement> @Nullable JsonNode findNodeById(List<JsonNode> nodes, Class<T> elementClass,
String id) {
@@ -37,6 +37,7 @@ import org.slf4j.LoggerFactory;
* These semantic tags are automatically exposed to the {@link org.openhab.core.semantics.SemanticTagRegistry}.
*
* @author Laurent Garnier - Initial contribution
* @author Laurent Garnier - Added basic version management
*/
@NonNullByDefault
@Component(immediate = true, service = { SemanticTagProvider.class, YamlSemanticTagProvider.class,
@@ -107,4 +108,14 @@ public class YamlSemanticTagProvider extends AbstractProvider<SemanticTag>
private SemanticTag mapSemanticTag(YamlSemanticTagDTO tagDTO) {
return new SemanticTagImpl(tagDTO.uid, tagDTO.label, tagDTO.description, tagDTO.synonyms);
}
@Override
public boolean isVersionSupported(int version) {
return version >= 1;
}
@Override
public boolean isDeprecated() {
return false;
}
}
@@ -15,8 +15,8 @@ package org.openhab.core.model.yaml.internal;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;
import static org.hamcrest.Matchers.contains;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
import java.io.IOException;
@@ -49,6 +49,7 @@ import org.yaml.snakeyaml.Yaml;
*
* @author Jan N. Klug - Initial contribution
* @author Laurent Garnier - Extended tests to cover version 2
* @author Laurent Garnier - Added one test for version management
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@@ -80,8 +81,14 @@ public class YamlModelRepositoryImplTest {
when(watchServiceMock.getWatchPath()).thenReturn(watchPath);
when(firstTypeListener.getElementClass()).thenReturn(FirstTypeDTO.class);
when(firstTypeListener.isVersionSupported(anyInt())).thenReturn(true);
when(firstTypeListener.isDeprecated()).thenReturn(false);
when(secondTypeListener1.getElementClass()).thenReturn(SecondTypeDTO.class);
when(secondTypeListener1.isVersionSupported(anyInt())).thenReturn(true);
when(secondTypeListener1.isDeprecated()).thenReturn(false);
when(secondTypeListener2.getElementClass()).thenReturn(SecondTypeDTO.class);
when(secondTypeListener2.isVersionSupported(anyInt())).thenReturn(true);
when(secondTypeListener2.isDeprecated()).thenReturn(false);
}
@Test
@@ -480,4 +487,110 @@ public class YamlModelRepositoryImplTest {
assertThat(yaml.load(actualFileContent), equalTo(yaml.load(expectedFileContent)));
}
@Test
public void testExistingProviderForVersion() throws IOException {
// Provider supporting only version 1
when(firstTypeListener.isVersionSupported(eq(1))).thenReturn(true);
when(firstTypeListener.isVersionSupported(eq(2))).thenReturn(false);
when(firstTypeListener.isDeprecated()).thenReturn(false);
Files.copy(SOURCE_PATH.resolve("modelFileAddedOrRemoved.yaml"), fullModelPath);
YamlModelRepositoryImpl modelRepository = new YamlModelRepositoryImpl(watchServiceMock);
modelRepository.addYamlModelListener(firstTypeListener);
modelRepository.processWatchEvent(WatchService.Kind.CREATE, MODEL_PATH);
verify(firstTypeListener).addedModel(eq(MODEL_NAME), firstTypeCaptor.capture());
verify(firstTypeListener, never()).updatedModel(any(), any());
verify(firstTypeListener, never()).removedModel(any(), any());
Collection<FirstTypeDTO> firstTypeElements = firstTypeCaptor.getValue();
assertThat(firstTypeElements, hasSize(2));
assertThat(firstTypeElements,
containsInAnyOrder(new FirstTypeDTO("First1", "Description1"), new FirstTypeDTO("First2", null)));
}
@Test
public void testExistingDeprecatedProviderForVersion() throws IOException {
// Provider supporting only version 1 and deprecated
when(firstTypeListener.isVersionSupported(eq(1))).thenReturn(true);
when(firstTypeListener.isVersionSupported(eq(2))).thenReturn(false);
when(firstTypeListener.isDeprecated()).thenReturn(true);
Files.copy(SOURCE_PATH.resolve("modelFileAddedOrRemoved.yaml"), fullModelPath);
YamlModelRepositoryImpl modelRepository = new YamlModelRepositoryImpl(watchServiceMock);
modelRepository.addYamlModelListener(firstTypeListener);
modelRepository.processWatchEvent(WatchService.Kind.CREATE, MODEL_PATH);
verify(firstTypeListener).addedModel(eq(MODEL_NAME), firstTypeCaptor.capture());
verify(firstTypeListener, never()).updatedModel(any(), any());
verify(firstTypeListener, never()).removedModel(any(), any());
Collection<FirstTypeDTO> firstTypeElements = firstTypeCaptor.getValue();
assertThat(firstTypeElements, hasSize(2));
assertThat(firstTypeElements,
containsInAnyOrder(new FirstTypeDTO("First1", "Description1"), new FirstTypeDTO("First2", null)));
}
@Test
public void testNoProviderForVersion() throws IOException {
// Provider supporting only version 2
when(firstTypeListener.isVersionSupported(eq(1))).thenReturn(false);
when(firstTypeListener.isVersionSupported(eq(2))).thenReturn(true);
when(firstTypeListener.isDeprecated()).thenReturn(false);
Files.copy(SOURCE_PATH.resolve("modelFileAddedOrRemoved.yaml"), fullModelPath);
YamlModelRepositoryImpl modelRepository = new YamlModelRepositoryImpl(watchServiceMock);
modelRepository.addYamlModelListener(firstTypeListener);
modelRepository.processWatchEvent(WatchService.Kind.CREATE, MODEL_PATH);
verify(firstTypeListener, never()).addedModel(any(), any());
verify(firstTypeListener, never()).updatedModel(any(), any());
verify(firstTypeListener, never()).removedModel(any(), any());
}
@Test
public void testDifferentProvidersDependingOnVersion() throws IOException {
// secondTypeListener1 supports version 1 only
when(secondTypeListener1.isVersionSupported(eq(1))).thenReturn(true);
when(secondTypeListener1.isVersionSupported(eq(2))).thenReturn(false);
when(secondTypeListener1.isDeprecated()).thenReturn(false);
// secondTypeListener2 supports version 2 only
when(secondTypeListener2.isVersionSupported(eq(1))).thenReturn(false);
when(secondTypeListener2.isVersionSupported(eq(2))).thenReturn(true);
when(secondTypeListener2.isDeprecated()).thenReturn(false);
Files.copy(SOURCE_PATH.resolve("modelFileAddedOrRemoved.yaml"), fullModelPath);
Files.copy(SOURCE_PATH.resolve("modelV2FileAddedOrRemoved.yaml"), fullModel2Path);
YamlModelRepositoryImpl modelRepository = new YamlModelRepositoryImpl(watchServiceMock);
modelRepository.addYamlModelListener(secondTypeListener1);
modelRepository.addYamlModelListener(secondTypeListener2);
modelRepository.processWatchEvent(WatchService.Kind.CREATE, MODEL_PATH);
modelRepository.processWatchEvent(WatchService.Kind.CREATE, MODEL2_PATH);
verify(secondTypeListener1).addedModel(eq(MODEL_NAME), secondTypeCaptor1.capture());
verify(secondTypeListener1, never()).addedModel(eq(MODEL2_NAME), any());
verify(secondTypeListener1, never()).updatedModel(any(), any());
verify(secondTypeListener1, never()).removedModel(any(), any());
verify(secondTypeListener2).addedModel(eq(MODEL2_NAME), secondTypeCaptor2.capture());
verify(secondTypeListener2, never()).addedModel(eq(MODEL_NAME), any());
verify(secondTypeListener2, never()).updatedModel(any(), any());
verify(secondTypeListener2, never()).removedModel(any(), any());
Collection<SecondTypeDTO> secondTypeElements = secondTypeCaptor1.getValue();
assertThat(secondTypeElements, hasSize(1));
assertThat(secondTypeElements, contains(new SecondTypeDTO("Second1", "Label1")));
secondTypeElements = secondTypeCaptor2.getValue();
assertThat(secondTypeElements, hasSize(1));
assertThat(secondTypeElements, contains(new SecondTypeDTO("Second1", "Label1")));
}
}