[default-extensions] Introduce patch extension

This commit is contained in:
2025-07-27 13:40:23 +02:00
parent 5678a6bf87
commit 952770c5bc
9 changed files with 407 additions and 6 deletions

View File

@@ -0,0 +1,23 @@
package de.siphalor.tweed5.defaultextensions.patch.api;
import de.siphalor.tweed5.core.api.entry.ConfigEntry;
import de.siphalor.tweed5.core.api.extension.TweedExtension;
import de.siphalor.tweed5.defaultextensions.patch.impl.PatchExtensionImpl;
import de.siphalor.tweed5.patchwork.api.Patchwork;
import org.jspecify.annotations.Nullable;
import java.util.function.Function;
public interface PatchExtension extends TweedExtension {
Class<? extends PatchExtension> DEFAULT = PatchExtensionImpl.class;
String EXTENSION_ID = "patch";
@Override
default String getId() {
return EXTENSION_ID;
}
PatchInfo collectPatchInfo(Patchwork readWriteContextExtensionsData);
<T extends @Nullable Object> T patch(ConfigEntry<T> entry, T targetValue, T patchValue, PatchInfo patchInfo);
}

View File

@@ -0,0 +1,7 @@
package de.siphalor.tweed5.defaultextensions.patch.api;
import de.siphalor.tweed5.core.api.entry.ConfigEntry;
public interface PatchInfo {
boolean containsEntry(ConfigEntry<?> entry);
}

View File

@@ -0,0 +1,4 @@
@NullMarked
package de.siphalor.tweed5.defaultextensions.patch.api;
import org.jspecify.annotations.NullMarked;

View File

@@ -0,0 +1,121 @@
package de.siphalor.tweed5.defaultextensions.patch.impl;
import de.siphalor.tweed5.core.api.entry.CompoundConfigEntry;
import de.siphalor.tweed5.core.api.entry.ConfigEntry;
import de.siphalor.tweed5.core.api.middleware.Middleware;
import de.siphalor.tweed5.data.extension.api.TweedEntryReadException;
import de.siphalor.tweed5.data.extension.api.TweedEntryReader;
import de.siphalor.tweed5.data.extension.api.TweedReadContext;
import de.siphalor.tweed5.data.extension.api.extension.ReadWriteExtensionSetupContext;
import de.siphalor.tweed5.data.extension.api.extension.ReadWriteRelatedExtension;
import de.siphalor.tweed5.dataapi.api.TweedDataReader;
import de.siphalor.tweed5.defaultextensions.patch.api.PatchExtension;
import de.siphalor.tweed5.defaultextensions.patch.api.PatchInfo;
import de.siphalor.tweed5.patchwork.api.Patchwork;
import de.siphalor.tweed5.patchwork.api.PatchworkPartAccess;
import lombok.Data;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
public class PatchExtensionImpl implements PatchExtension, ReadWriteRelatedExtension {
private @Nullable PatchworkPartAccess<ReadWriteContextCustomData> readWriteContextDataAccess;
@Override
public void setupReadWriteExtension(ReadWriteExtensionSetupContext context) {
readWriteContextDataAccess = context.registerReadWriteContextExtensionData(ReadWriteContextCustomData.class);
context.registerReaderMiddleware(new ReaderMiddleware());
}
@Override
public PatchInfo collectPatchInfo(Patchwork readWriteContextExtensionsData) {
ReadWriteContextCustomData customData = getOrCreateCustomData(readWriteContextExtensionsData);
PatchInfoImpl patchInfo = customData.patchInfo();
if (patchInfo == null) {
patchInfo = new PatchInfoImpl();
customData.patchInfo(patchInfo);
}
return patchInfo;
}
private ReadWriteContextCustomData getOrCreateCustomData(Patchwork readWriteContextExtensionsData) {
assert readWriteContextDataAccess != null;
ReadWriteContextCustomData customData = readWriteContextExtensionsData.get(readWriteContextDataAccess);
if (customData == null) {
customData = new ReadWriteContextCustomData();
readWriteContextExtensionsData.set(readWriteContextDataAccess, customData);
}
return customData;
}
@Override
public <T extends @Nullable Object> T patch(ConfigEntry<T> entry, T targetValue, T patchValue, PatchInfo patchInfo) {
if (!patchInfo.containsEntry(entry)) {
return targetValue;
} else if (patchValue == null) {
return null;
}
if (entry instanceof CompoundConfigEntry) {
CompoundConfigEntry<T> compoundEntry = (CompoundConfigEntry<T>) entry;
T targetCompoundValue;
if (targetValue != null) {
targetCompoundValue = targetValue;
} else {
targetCompoundValue = compoundEntry.instantiateCompoundValue();
}
compoundEntry.subEntries().forEach((key, subEntry) -> {
if (!patchInfo.containsEntry(subEntry)) {
return;
}
compoundEntry.set(
targetCompoundValue, key, patch(
subEntry,
compoundEntry.get(targetCompoundValue, key),
compoundEntry.get(patchValue, key),
patchInfo
)
);
});
return targetCompoundValue;
} else {
return patchValue;
}
}
private class ReaderMiddleware implements Middleware<TweedEntryReader<?, ?>> {
@Override
public String id() {
return "patch-info-collector";
}
@Override
public TweedEntryReader<?, ?> process(TweedEntryReader<?, ?> inner) {
assert readWriteContextDataAccess != null;
//noinspection unchecked
TweedEntryReader<Object, ConfigEntry<Object>> innerCasted =
(TweedEntryReader<Object, @NonNull ConfigEntry<Object>>) inner;
return new TweedEntryReader<@Nullable Object, ConfigEntry<Object>>() {
@Override
public @Nullable Object read(
TweedDataReader reader,
ConfigEntry<Object> entry,
TweedReadContext context
) throws TweedEntryReadException {
Object readValue = innerCasted.read(reader, entry, context);
ReadWriteContextCustomData customData = context.extensionsData().get(readWriteContextDataAccess);
if (customData != null && customData.patchInfo() != null) {
customData.patchInfo().addEntry(entry);
}
return readValue;
}
};
}
}
@Data
private static class ReadWriteContextCustomData {
private @Nullable PatchInfoImpl patchInfo;
}
}

View File

@@ -0,0 +1,21 @@
package de.siphalor.tweed5.defaultextensions.patch.impl;
import de.siphalor.tweed5.core.api.entry.ConfigEntry;
import de.siphalor.tweed5.defaultextensions.patch.api.PatchInfo;
import org.jspecify.annotations.Nullable;
import java.util.IdentityHashMap;
import java.util.Map;
public class PatchInfoImpl implements PatchInfo {
private final Map<ConfigEntry<?>, @Nullable Void> subPatchInfos = new IdentityHashMap<>();
@Override
public boolean containsEntry(ConfigEntry<?> entry) {
return subPatchInfos.containsKey(entry);
}
void addEntry(ConfigEntry<?> entry) {
subPatchInfos.put(entry, null);
}
}

View File

@@ -0,0 +1,6 @@
@ApiStatus.Internal
@NullMarked
package de.siphalor.tweed5.defaultextensions.patch.impl;
import org.jetbrains.annotations.ApiStatus;
import org.jspecify.annotations.NullMarked;

View File

@@ -0,0 +1,217 @@
package de.siphalor.tweed5.defaultextensions.patch.impl;
import de.siphalor.tweed5.core.api.container.ConfigContainer;
import de.siphalor.tweed5.core.api.entry.CompoundConfigEntry;
import de.siphalor.tweed5.core.api.entry.ConfigEntry;
import de.siphalor.tweed5.core.impl.DefaultConfigContainer;
import de.siphalor.tweed5.core.impl.entry.CollectionConfigEntryImpl;
import de.siphalor.tweed5.core.impl.entry.SimpleConfigEntryImpl;
import de.siphalor.tweed5.core.impl.entry.StaticMapCompoundConfigEntryImpl;
import de.siphalor.tweed5.data.extension.api.ReadWriteExtension;
import de.siphalor.tweed5.data.hjson.HjsonLexer;
import de.siphalor.tweed5.data.hjson.HjsonReader;
import de.siphalor.tweed5.defaultextensions.patch.api.PatchExtension;
import de.siphalor.tweed5.defaultextensions.patch.api.PatchInfo;
import lombok.SneakyThrows;
import org.jspecify.annotations.NullUnmarked;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.io.StringReader;
import java.util.*;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Stream;
import static de.siphalor.tweed5.data.extension.api.ReadWriteExtension.entryReaderWriter;
import static de.siphalor.tweed5.data.extension.api.ReadWriteExtension.read;
import static de.siphalor.tweed5.data.extension.api.readwrite.TweedEntryReaderWriters.*;
import static de.siphalor.tweed5.testutils.MapTestUtils.sequencedMap;
import static java.util.Map.entry;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.params.provider.Arguments.argumentSet;
import static org.junit.jupiter.params.provider.Arguments.arguments;
@NullUnmarked
class PatchExtensionImplTest {
private ConfigContainer<Map<String, Object>> configContainer;
private CompoundConfigEntry<Map<String, Object>> rootEntry;
private Map<String, ConfigEntry<?>> entries;
@SuppressWarnings({"unchecked", "rawtypes"})
@BeforeEach
void setUp() {
configContainer = new DefaultConfigContainer<>();
configContainer.registerExtension(ReadWriteExtension.class);
configContainer.registerExtension(PatchExtension.class);
configContainer.finishExtensionSetup();
var int1Entry = new SimpleConfigEntryImpl<>(configContainer, Integer.class)
.apply(entryReaderWriter(intReaderWriter()));
var int2Entry = new SimpleConfigEntryImpl<>(configContainer, Integer.class)
.apply(entryReaderWriter(
nullableReader(intReaderWriter()),
nullableWriter(intReaderWriter())
));
var listEntry = new CollectionConfigEntryImpl<>(
configContainer,
(Class<List<Integer>>)(Class) List.class,
ArrayList::new,
new SimpleConfigEntryImpl<>(configContainer, Integer.class)
.apply(entryReaderWriter(intReaderWriter()))
)
.apply(entryReaderWriter(
nullableReader(collectionReaderWriter()),
nullableWriter(collectionReaderWriter())
));
var nestedInt1Entry = new SimpleConfigEntryImpl<>(configContainer, Integer.class)
.apply(entryReaderWriter(intReaderWriter()));
var nestedInt2Entry = new SimpleConfigEntryImpl<>(configContainer, Integer.class)
.apply(entryReaderWriter(intReaderWriter()));
var compoundEntry = new StaticMapCompoundConfigEntryImpl<>(
configContainer,
(Class<Map<String, Object>>)(Class) Map.class,
HashMap::new,
sequencedMap(List.of(
entry("int1", nestedInt1Entry),
entry("int2", nestedInt2Entry)
))
)
.apply(entryReaderWriter(
nullableReader(compoundReaderWriter()),
nullableWriter(compoundReaderWriter())
));
rootEntry = new StaticMapCompoundConfigEntryImpl<>(
configContainer,
(Class<Map<String, Object>>)(Class) Map.class,
HashMap::new,
sequencedMap(List.of(
entry("int1", int1Entry),
entry("int2", int2Entry),
entry("list", listEntry),
entry("compound", compoundEntry)
))
)
.apply(entryReaderWriter(compoundReaderWriter()));
configContainer.attachTree(rootEntry);
configContainer.initialize();
entries = Map.of(
"root", rootEntry,
"int1", int1Entry,
"int2", int2Entry,
"list", listEntry,
"compound", compoundEntry,
"compound.int1", nestedInt1Entry,
"compound.int2", nestedInt2Entry
);
}
@SneakyThrows
@ParameterizedTest
@MethodSource("collectPathInfoParams")
void collectPatchInfo(String patch, Set<String> expectedEntries) {
var reader = new HjsonReader(new HjsonLexer(new StringReader(patch)));
PatchExtension patchExtension = configContainer.extension(PatchExtension.class).orElseThrow();
var patchInfo = new AtomicReference<@Nullable PatchInfo>();
rootEntry.call(read(
reader, extensionsData ->
patchInfo.set(patchExtension.collectPatchInfo(extensionsData))
));
assertThat(patchInfo.get()).isNotNull();
entries.forEach((key, entry) ->
assertThat(Objects.requireNonNull(patchInfo.get()).containsEntry(entry))
.as("PatchInfo should contain entry %s", key)
.isEqualTo(expectedEntries.contains(key))
);
}
static Stream<Arguments> collectPathInfoParams() {
return Stream.of(
arguments("{int1:123}", Set.of("root", "int1")),
arguments("{compound:{int2:123}}", Set.of("root", "compound", "compound.int2"))
);
}
@ParameterizedTest
@MethodSource("patchParams")
void patch(Map<String, Object> baseValue, String patch, Map<String, Object> expectedValue) {
var reader = new HjsonReader(new HjsonLexer(new StringReader(patch)));
PatchExtension patchExtension = configContainer.extension(PatchExtension.class).orElseThrow();
var patchInfo = new AtomicReference<@Nullable PatchInfo>();
Map<String, Object> patchValue = rootEntry.call(read(
reader, extensionsData ->
patchInfo.set(patchExtension.collectPatchInfo(extensionsData))
));
Map<String, Object> resultValue = patchExtension.patch(
rootEntry,
baseValue,
patchValue,
Objects.requireNonNull(patchInfo.get())
);
assertThat(resultValue).isEqualTo(expectedValue).isSameAs(baseValue);
}
static Stream<Arguments> patchParams() {
Map<String, Object> mapForNullCase = new LinkedHashMap<>();
mapForNullCase.put("int2", null);
mapForNullCase.put("list", null);
mapForNullCase.put("compound", null);
return Stream.of(
argumentSet(
"empty patch should not have effect",
new HashMap<>(Map.of("int1", 123, "compound", new HashMap<>(Map.of("int1", 456)))),
"{}",
Map.of("int1", 123, "compound", Map.of("int1", 456))
),
argumentSet(
"overriding only existing values",
new HashMap<>(Map.of("int1", 123, "compound", new HashMap<>(Map.of("int1", 456)))),
"{int1:1230,compound:{int1:4560}}",
Map.of("int1", 1230, "compound", Map.of("int1", 4560))
),
argumentSet(
"overriding lists",
new HashMap<>(Map.of("int1", 123, "list", new ArrayList<>(List.of(12, 34, 56)))),
"{list:[987]}",
Map.of("int1", 123, "list", List.of(987))
),
argumentSet(
"creating new compound on override",
new HashMap<>(Map.of("int1", 123)),
"{compound:{int1:456, int2:789}}",
Map.of("int1", 123, "compound", Map.of("int1", 456, "int2", 789))
),
argumentSet(
"null overrides",
new HashMap<>(Map.of(
"int2", 123,
"list", new ArrayList<>(List.of(12, 34)),
"compound", new HashMap<>(Map.of("int1", 98, "int2", 76))
)),
"{int2:null, list:null, compound:null}",
mapForNullCase
),
argumentSet(
"mixed overrides",
new HashMap<>(Map.of("int1", 123, "compound", new HashMap<>(Map.of("int1", 456)))),
"{int1:1230,compound:{int1:4560,int2:7890}}",
Map.of("int1", 1230, "compound", Map.of("int1", 4560, "int2", 7890))
)
);
}
}

View File

@@ -20,6 +20,7 @@ import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import java.io.StringReader;
@@ -28,14 +29,15 @@ import java.util.Collections;
import static de.siphalor.tweed5.data.extension.api.ReadWriteExtension.*;
import static de.siphalor.tweed5.data.extension.api.readwrite.TweedEntryReaderWriters.*;
import static de.siphalor.tweed5.defaultextensions.validation.api.ValidationExtension.validate;
import static de.siphalor.tweed5.defaultextensions.validation.api.ValidationExtension.validators;
import static de.siphalor.tweed5.defaultextensions.validationfallback.api.ValidationFallbackExtension.validationFallbackValue;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ValidationFallbackExtensionImplTest {
private DefaultConfigContainer<Integer> configContainer;
private SimpleConfigEntry<Integer> intEntry;
private DefaultConfigContainer<@Nullable Integer> configContainer;
private SimpleConfigEntry<@Nullable Integer> intEntry;
@BeforeEach
void setUp() {