From 952770c5bc7cef5b6e91a2dda447250dea26b936 Mon Sep 17 00:00:00 2001 From: Siphalor Date: Sun, 27 Jul 2025 13:40:23 +0200 Subject: [PATCH] [default-extensions] Introduce patch extension --- ...ttributesReadWriteFilterExtensionImpl.java | 6 +- .../patch/api/PatchExtension.java | 23 ++ .../patch/api/PatchInfo.java | 7 + .../patch/api/package-info.java | 4 + .../patch/impl/PatchExtensionImpl.java | 121 ++++++++++ .../patch/impl/PatchInfoImpl.java | 21 ++ .../patch/impl/package-info.java | 6 + .../patch/impl/PatchExtensionImplTest.java | 217 ++++++++++++++++++ .../ValidationFallbackExtensionImplTest.java | 8 +- 9 files changed, 407 insertions(+), 6 deletions(-) create mode 100644 tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/patch/api/PatchExtension.java create mode 100644 tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/patch/api/PatchInfo.java create mode 100644 tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/patch/api/package-info.java create mode 100644 tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/patch/impl/PatchExtensionImpl.java create mode 100644 tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/patch/impl/PatchInfoImpl.java create mode 100644 tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/patch/impl/package-info.java create mode 100644 tweed5-default-extensions/src/test/java/de/siphalor/tweed5/defaultextensions/patch/impl/PatchExtensionImplTest.java diff --git a/tweed5-attributes-extension/src/main/java/de/siphalor/tweed5/attributesextension/impl/serde/filter/AttributesReadWriteFilterExtensionImpl.java b/tweed5-attributes-extension/src/main/java/de/siphalor/tweed5/attributesextension/impl/serde/filter/AttributesReadWriteFilterExtensionImpl.java index 5accf27..2309d48 100644 --- a/tweed5-attributes-extension/src/main/java/de/siphalor/tweed5/attributesextension/impl/serde/filter/AttributesReadWriteFilterExtensionImpl.java +++ b/tweed5-attributes-extension/src/main/java/de/siphalor/tweed5/attributesextension/impl/serde/filter/AttributesReadWriteFilterExtensionImpl.java @@ -35,11 +35,11 @@ import java.util.*; public class AttributesReadWriteFilterExtensionImpl implements AttributesReadWriteFilterExtension, AttributesRelatedExtension, ReadWriteRelatedExtension { - private static final Set MIDDLEWARES_MUST_COME_BEFORE = new HashSet<>(Arrays.asList( - Middleware.DEFAULT_START, + private static final Set MIDDLEWARES_MUST_COME_BEFORE = Collections.emptySet(); + private static final Set MIDDLEWARES_MUST_COME_AFTER = new HashSet<>(Arrays.asList( + Middleware.DEFAULT_END, "validation" )); - private static final Set MIDDLEWARES_MUST_COME_AFTER = Collections.emptySet(); private static final UniqueSymbol TWEED_DATA_NOTHING_VALUE = new UniqueSymbol("nothing (skip value)"); private final ConfigContainer configContainer; diff --git a/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/patch/api/PatchExtension.java b/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/patch/api/PatchExtension.java new file mode 100644 index 0000000..1a5f35c --- /dev/null +++ b/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/patch/api/PatchExtension.java @@ -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 DEFAULT = PatchExtensionImpl.class; + String EXTENSION_ID = "patch"; + + @Override + default String getId() { + return EXTENSION_ID; + } + + PatchInfo collectPatchInfo(Patchwork readWriteContextExtensionsData); + + T patch(ConfigEntry entry, T targetValue, T patchValue, PatchInfo patchInfo); +} diff --git a/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/patch/api/PatchInfo.java b/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/patch/api/PatchInfo.java new file mode 100644 index 0000000..bbd30e6 --- /dev/null +++ b/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/patch/api/PatchInfo.java @@ -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); +} diff --git a/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/patch/api/package-info.java b/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/patch/api/package-info.java new file mode 100644 index 0000000..1f6de7b --- /dev/null +++ b/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/patch/api/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package de.siphalor.tweed5.defaultextensions.patch.api; + +import org.jspecify.annotations.NullMarked; diff --git a/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/patch/impl/PatchExtensionImpl.java b/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/patch/impl/PatchExtensionImpl.java new file mode 100644 index 0000000..99338d3 --- /dev/null +++ b/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/patch/impl/PatchExtensionImpl.java @@ -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 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 patch(ConfigEntry entry, T targetValue, T patchValue, PatchInfo patchInfo) { + if (!patchInfo.containsEntry(entry)) { + return targetValue; + } else if (patchValue == null) { + return null; + } + + if (entry instanceof CompoundConfigEntry) { + CompoundConfigEntry compoundEntry = (CompoundConfigEntry) 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> { + @Override + public String id() { + return "patch-info-collector"; + } + + @Override + public TweedEntryReader process(TweedEntryReader inner) { + assert readWriteContextDataAccess != null; + + //noinspection unchecked + TweedEntryReader> innerCasted = + (TweedEntryReader>) inner; + return new TweedEntryReader<@Nullable Object, ConfigEntry>() { + @Override + public @Nullable Object read( + TweedDataReader reader, + ConfigEntry 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; + } +} diff --git a/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/patch/impl/PatchInfoImpl.java b/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/patch/impl/PatchInfoImpl.java new file mode 100644 index 0000000..6f357ef --- /dev/null +++ b/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/patch/impl/PatchInfoImpl.java @@ -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, @Nullable Void> subPatchInfos = new IdentityHashMap<>(); + + @Override + public boolean containsEntry(ConfigEntry entry) { + return subPatchInfos.containsKey(entry); + } + + void addEntry(ConfigEntry entry) { + subPatchInfos.put(entry, null); + } +} diff --git a/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/patch/impl/package-info.java b/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/patch/impl/package-info.java new file mode 100644 index 0000000..9a82e90 --- /dev/null +++ b/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/patch/impl/package-info.java @@ -0,0 +1,6 @@ +@ApiStatus.Internal +@NullMarked +package de.siphalor.tweed5.defaultextensions.patch.impl; + +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.NullMarked; diff --git a/tweed5-default-extensions/src/test/java/de/siphalor/tweed5/defaultextensions/patch/impl/PatchExtensionImplTest.java b/tweed5-default-extensions/src/test/java/de/siphalor/tweed5/defaultextensions/patch/impl/PatchExtensionImplTest.java new file mode 100644 index 0000000..b411954 --- /dev/null +++ b/tweed5-default-extensions/src/test/java/de/siphalor/tweed5/defaultextensions/patch/impl/PatchExtensionImplTest.java @@ -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> configContainer; + private CompoundConfigEntry> rootEntry; + + private Map> 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>)(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>)(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>)(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 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 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 baseValue, String patch, Map expectedValue) { + var reader = new HjsonReader(new HjsonLexer(new StringReader(patch))); + + PatchExtension patchExtension = configContainer.extension(PatchExtension.class).orElseThrow(); + + var patchInfo = new AtomicReference<@Nullable PatchInfo>(); + Map patchValue = rootEntry.call(read( + reader, extensionsData -> + patchInfo.set(patchExtension.collectPatchInfo(extensionsData)) + )); + + Map resultValue = patchExtension.patch( + rootEntry, + baseValue, + patchValue, + Objects.requireNonNull(patchInfo.get()) + ); + + assertThat(resultValue).isEqualTo(expectedValue).isSameAs(baseValue); + } + + static Stream patchParams() { + Map 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)) + ) + ); + } +} diff --git a/tweed5-default-extensions/src/test/java/de/siphalor/tweed5/defaultextensions/validationfallback/impl/ValidationFallbackExtensionImplTest.java b/tweed5-default-extensions/src/test/java/de/siphalor/tweed5/defaultextensions/validationfallback/impl/ValidationFallbackExtensionImplTest.java index 68d8ae1..5e6c67c 100644 --- a/tweed5-default-extensions/src/test/java/de/siphalor/tweed5/defaultextensions/validationfallback/impl/ValidationFallbackExtensionImplTest.java +++ b/tweed5-default-extensions/src/test/java/de/siphalor/tweed5/defaultextensions/validationfallback/impl/ValidationFallbackExtensionImplTest.java @@ -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 configContainer; - private SimpleConfigEntry intEntry; + private DefaultConfigContainer<@Nullable Integer> configContainer; + private SimpleConfigEntry<@Nullable Integer> intEntry; @BeforeEach void setUp() {