Allow type migrations in JSONStorage (#2784)

Signed-off-by: Jan N. Klug <github@klug.nrw>
This commit is contained in:
J-N-K 2022-04-09 15:49:38 +02:00 committed by GitHub
parent 661fa00e46
commit 6a75130355
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 344 additions and 25 deletions

View File

@ -26,6 +26,7 @@ import java.util.Set;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
@ -34,6 +35,8 @@ import org.openhab.core.config.core.ConfigurationDeserializer;
import org.openhab.core.config.core.OrderingMapSerializer;
import org.openhab.core.config.core.OrderingSetSerializer;
import org.openhab.core.storage.Storage;
import org.openhab.core.storage.json.internal.migration.TypeMigrationException;
import org.openhab.core.storage.json.internal.migration.TypeMigrator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -80,19 +83,21 @@ public class JsonStorage<T> implements Storage<T> {
private final File file;
private final @Nullable ClassLoader classLoader;
private final Map<String, StorageEntry> map = new ConcurrentHashMap<>();
private final Map<String, TypeMigrator> typeMigrators;
private transient Gson internalMapper;
private transient Gson entityMapper;
private final transient Gson internalMapper;
private final transient Gson entityMapper;
private boolean dirty;
public JsonStorage(File file, @Nullable ClassLoader classLoader, int maxBackupFiles, int writeDelay,
int maxDeferredPeriod) {
int maxDeferredPeriod, List<TypeMigrator> typeMigrators) {
this.file = file;
this.classLoader = classLoader;
this.maxBackupFiles = maxBackupFiles;
this.writeDelay = writeDelay;
this.maxDeferredPeriod = maxDeferredPeriod;
this.typeMigrators = typeMigrators.stream().collect(Collectors.toMap(e -> e.getOldType(), e -> e));
this.internalMapper = new GsonBuilder() //
.registerTypeHierarchyAdapter(Map.class, new OrderingMapSerializer())//
@ -156,7 +161,7 @@ public class JsonStorage<T> implements Storage<T> {
if (previousValue == null) {
return null;
}
return deserialize(previousValue);
return deserialize(previousValue, null);
}
@Override
@ -166,7 +171,7 @@ public class JsonStorage<T> implements Storage<T> {
if (removedElement == null) {
return null;
}
return deserialize(removedElement);
return deserialize(removedElement, null);
}
@Override
@ -180,7 +185,7 @@ public class JsonStorage<T> implements Storage<T> {
if (value == null) {
return null;
}
return deserialize(value);
return deserialize(value, key);
}
@Override
@ -201,33 +206,58 @@ public class JsonStorage<T> implements Storage<T> {
* Deserializes and instantiates an object of type {@code T} out of the given
* JSON String. A special classloader (other than the one of the JSON bundle) is
* used in order to load the classes in the context of the calling bundle.
*
* The {@code key} must only be specified if the requested object stays in storage (i.e. only when called from
* {@link #get(String)} action). If specified on other actions, the old or removed value will be persisted.
*
* @param entry the entry that needs deserialization
* @param key the key for this element if storage after type migration is requested
* @return the deserialized type
*/
@SuppressWarnings("unchecked")
private @Nullable T deserialize(@Nullable StorageEntry entry) {
@SuppressWarnings({ "unchecked", "null" })
private @Nullable T deserialize(@Nullable StorageEntry entry, @Nullable String key) {
if (entry == null) {
// nothing to deserialize
return null;
}
try {
// load required class within the given bundle context
Class<T> loadedValueType;
if (classLoader != null) {
loadedValueType = (Class<T>) classLoader.loadClass(entry.getEntityClassName());
} else {
loadedValueType = (Class<T>) Class.forName(entry.getEntityClassName());
String entityClassName = entry.getEntityClassName();
JsonElement entityValue = (JsonElement) entry.getValue();
TypeMigrator migrator = typeMigrators.get(entityClassName);
if (migrator != null) {
entityClassName = migrator.getNewType();
entityValue = migrator.migrate(entityValue);
if (key != null) {
map.put(key, new StorageEntry(entityClassName, entityValue));
deferredCommit();
}
}
T value = entityMapper.fromJson((JsonElement) entry.getValue(), loadedValueType);
// load required class within the given bundle context
Class<T> loadedValueType;
if (classLoader != null) {
loadedValueType = (Class<T>) classLoader.loadClass(entityClassName);
} else {
loadedValueType = (Class<T>) Class.forName(entityClassName);
}
T value = entityMapper.fromJson(entityValue, loadedValueType);
logger.trace("deserialized value '{}' from Json", value);
return value;
} catch (JsonSyntaxException | JsonIOException | ClassNotFoundException e) {
logger.error("Couldn't deserialize value '{}'. Root cause is: {}", entry, e.getMessage());
return null;
} catch (TypeMigrationException e) {
logger.error("Type '{}' needs migration but migration failed: '{}'", entry.getEntityClassName(),
e.getMessage());
return null;
}
}
@SuppressWarnings("unchecked")
@SuppressWarnings({ "unchecked", "null" })
private @Nullable Map<String, StorageEntry> readDatabase(File inputFile) {
if (inputFile.length() == 0) {
logger.warn("Json storage file at '{}' is empty - ignoring corrupt file.", inputFile.getAbsolutePath());
@ -304,9 +334,10 @@ public class JsonStorage<T> implements Storage<T> {
*/
public synchronized void flush() {
// Stop any existing timer
TimerTask commitTimerTask = this.commitTimerTask;
if (commitTimerTask != null) {
commitTimerTask.cancel();
commitTimerTask = null;
this.commitTimerTask = null;
}
if (dirty) {
@ -357,9 +388,10 @@ public class JsonStorage<T> implements Storage<T> {
dirty = true;
// Stop any existing timer
TimerTask commitTimerTask = this.commitTimerTask;
if (commitTimerTask != null) {
commitTimerTask.cancel();
commitTimerTask = null;
this.commitTimerTask = null;
}
// Handle a maximum time for deferring the commit.

View File

@ -16,6 +16,7 @@ import java.io.File;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
@ -24,6 +25,7 @@ import org.openhab.core.OpenHAB;
import org.openhab.core.config.core.ConfigurableService;
import org.openhab.core.storage.Storage;
import org.openhab.core.storage.StorageService;
import org.openhab.core.storage.json.internal.migration.TypeMigrator;
import org.osgi.framework.Constants;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
@ -46,6 +48,11 @@ public class JsonStorageService implements StorageService {
private static final int MAX_FILENAME_LENGTH = 127;
/**
* Contains a map of needed migrations, key is the storage name
*/
private static final Map<String, List<TypeMigrator>> MIGRATORS = Map.of();
private final Logger logger = LoggerFactory.getLogger(JsonStorageService.class);
/** the folder name to store database ({@code jsondb} by default) */
@ -120,9 +127,8 @@ public class JsonStorageService implements StorageService {
@Override
public <T> Storage<T> getStorage(String name, @Nullable ClassLoader classLoader) {
File legacyFile = new File(dbFolderName, name + ".json");
File escapedFile = new File(dbFolderName, urlEscapeUnwantedChars(name) + ".json");
File file = new File(dbFolderName, urlEscapeUnwantedChars(name) + ".json");
File file = escapedFile;
if (legacyFile.exists()) {
file = legacyFile;
}
@ -132,7 +138,8 @@ public class JsonStorageService implements StorageService {
oldStorage.flush();
}
JsonStorage<T> newStorage = new JsonStorage<>(file, classLoader, maxBackupFiles, writeDelay, maxDeferredPeriod);
JsonStorage<T> newStorage = new JsonStorage<>(file, classLoader, maxBackupFiles, writeDelay, maxDeferredPeriod,
MIGRATORS.getOrDefault(name, List.of()));
storageList.put(name, (JsonStorage<Object>) newStorage);
return newStorage;

View File

@ -0,0 +1,42 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.storage.json.internal.migration;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link RenamingTypeMigrator} is an {@link TypeMigrator} for renaming types
*
* @author Jan N. Klug - Initial contribution
*/
@NonNullByDefault
public class RenamingTypeMigrator implements TypeMigrator {
private final String oldType;
private final String newType;
public RenamingTypeMigrator(String oldType, String newType) {
this.oldType = oldType;
this.newType = newType;
}
@Override
public String getOldType() {
return oldType;
}
@Override
public String getNewType() {
return newType;
}
}

View File

@ -0,0 +1,29 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.storage.json.internal.migration;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link TypeMigrationException} is thrown if a migration fails
*
* @author Jan N. Klug - Initial contribution
*/
@NonNullByDefault
public class TypeMigrationException extends Exception {
private static final long serialVersionUID = 1L;
public TypeMigrationException(String message) {
super(message);
}
}

View File

@ -0,0 +1,53 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.storage.json.internal.migration;
import org.eclipse.jdt.annotation.NonNullByDefault;
import com.google.gson.JsonElement;
/**
* The {@link TypeMigrator} interface allows the implementation of JSON storage type migrations
*
* @author Jan N. Klug - Initial contribution
*/
@NonNullByDefault
public interface TypeMigrator {
/**
* Get the name of the old (stored) type
*
* @return Full class name
*/
String getOldType();
/**
* Get the name of the new type
*
* @return Full class name
*/
String getNewType();
/**
* Migrate the old type to the new type
*
* The default implementation can be used if type is renamed only.
*
* @param oldValue The {@link JsonElement} representation of the old type
* @return The corresponding {@link JsonElement} representation of the new type
* @throws TypeMigrationException if an error occurs
*/
default JsonElement migrate(JsonElement oldValue) throws TypeMigrationException {
return oldValue;
}
}

View File

@ -55,13 +55,13 @@ public class JsonStorageTest extends JavaTest {
public void setUp() throws IOException {
tmpFile = File.createTempFile("storage-debug", ".json");
tmpFile.deleteOnExit();
objectStorage = new JsonStorage<>(tmpFile, this.getClass().getClassLoader(), 0, 0, 0);
objectStorage = new JsonStorage<>(tmpFile, this.getClass().getClassLoader(), 0, 0, 0, List.of());
}
private void persistAndReadAgain() {
objectStorage.flush();
waitForAssert(() -> {
objectStorage = new JsonStorage<>(tmpFile, this.getClass().getClassLoader(), 0, 0, 0);
objectStorage = new JsonStorage<>(tmpFile, this.getClass().getClassLoader(), 0, 0, 0, List.of());
DummyObject dummy = objectStorage.get("DummyObject");
assertNotNull(dummy);
assertNotNull(dummy.configuration);
@ -137,7 +137,7 @@ public class JsonStorageTest extends JavaTest {
persistAndReadAgain();
String storageString1 = Files.readString(tmpFile.toPath());
objectStorage = new JsonStorage<>(tmpFile, this.getClass().getClassLoader(), 0, 0, 0);
objectStorage = new JsonStorage<>(tmpFile, this.getClass().getClassLoader(), 0, 0, 0, List.of());
objectStorage.flush();
String storageString2 = Files.readString(tmpFile.toPath());
@ -166,7 +166,7 @@ public class JsonStorageTest extends JavaTest {
assertEquals(storageStringAB, storageStringBA);
{
objectStorage = new JsonStorage<>(tmpFile, this.getClass().getClassLoader(), 0, 0, 0);
objectStorage = new JsonStorage<>(tmpFile, this.getClass().getClassLoader(), 0, 0, 0, List.of());
objectStorage.flush();
}
String storageStringReserialized = Files.readString(tmpFile.toPath());

View File

@ -0,0 +1,156 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.storage.json.internal;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openhab.core.storage.json.internal.migration.RenamingTypeMigrator;
import org.openhab.core.storage.json.internal.migration.TypeMigrationException;
import org.openhab.core.storage.json.internal.migration.TypeMigrator;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
/**
* The {@link MigrationTest} is a
*
* @author Jan N. Klug - Initial contribution
*/
@NonNullByDefault
public class MigrationTest {
private static final String OBJECT_KEY = "foo";
private static final String OBJECT_VALUE = "bar";
private @NonNullByDefault({}) File tmpFile;
@BeforeEach
public void setup() throws IOException {
tmpFile = File.createTempFile("storage-debug", ".json");
tmpFile.deleteOnExit();
// store old class
OldNameClass oldNameInstance = new OldNameClass(OBJECT_VALUE);
JsonStorage<OldNameClass> storage = new JsonStorage<>(tmpFile, this.getClass().getClassLoader(), 0, 0, 0,
List.of());
storage.put(OBJECT_KEY, oldNameInstance);
storage.flush();
}
@Test
public void testRenameClassMigration() throws TypeMigrationException {
TypeMigrator typeMigrator = spy(
new RenamingTypeMigrator(OldNameClass.class.getName(), NewNameClass.class.getName()));
// read new class
JsonStorage<NewNameClass> storage1 = new JsonStorage<>(tmpFile, this.getClass().getClassLoader(), 0, 0, 0,
List.of(typeMigrator));
NewNameClass newNameInstance = storage1.get(OBJECT_KEY);
verify(typeMigrator).getOldType();
verify(typeMigrator).getNewType();
verify(typeMigrator).migrate(any());
Objects.requireNonNull(newNameInstance);
assertThat(OBJECT_VALUE, is(newNameInstance.value));
// ensure type migrations are stored
storage1.flush();
newNameInstance = storage1.get(OBJECT_KEY);
verifyNoMoreInteractions(typeMigrator);
}
@Test
public void testRenameFieldMigration() throws TypeMigrationException {
TypeMigrator typeMigrator = spy(new OldToNewFieldMigrator());
// read new class
JsonStorage<NewFieldClass> storage1 = new JsonStorage<>(tmpFile, this.getClass().getClassLoader(), 0, 0, 0,
List.of(typeMigrator));
NewFieldClass newNameInstance = storage1.get(OBJECT_KEY);
verify(typeMigrator).getOldType();
verify(typeMigrator).getNewType();
verify(typeMigrator).migrate(any());
Objects.requireNonNull(newNameInstance);
assertThat(OBJECT_VALUE, is(newNameInstance.val));
// ensure type migrations are stored
storage1.flush();
newNameInstance = storage1.get(OBJECT_KEY);
verifyNoMoreInteractions(typeMigrator);
}
@SuppressWarnings("unused")
private static class OldNameClass {
public String value;
public OldNameClass(String value) {
this.value = value;
}
}
@SuppressWarnings("unused")
private static class NewNameClass {
public String value;
public NewNameClass(String value) {
this.value = value;
}
}
@SuppressWarnings("unused")
private static class NewFieldClass {
public String val;
public NewFieldClass(String value) {
this.val = value;
}
}
private static class OldToNewFieldMigrator implements TypeMigrator {
@Override
public String getOldType() {
return OldNameClass.class.getName();
}
@Override
public String getNewType() {
return NewFieldClass.class.getName();
}
@Override
public JsonElement migrate(JsonElement oldValue) throws TypeMigrationException {
JsonObject newElement = oldValue.getAsJsonObject();
JsonElement element = newElement.remove("value");
newElement.add("val", element);
return newElement;
}
}
}