diff --git a/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/validation/api/ValidationExtension.java b/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/validation/api/ValidationExtension.java index c6dcfc3..ecc12cb 100644 --- a/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/validation/api/ValidationExtension.java +++ b/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/validation/api/ValidationExtension.java @@ -3,11 +3,10 @@ package de.siphalor.tweed5.defaultextensions.validation.api; import de.siphalor.tweed5.core.api.entry.ConfigEntry; import de.siphalor.tweed5.core.api.extension.TweedExtension; import de.siphalor.tweed5.core.api.middleware.Middleware; -import de.siphalor.tweed5.defaultextensions.validation.api.result.ValidationIssue; import de.siphalor.tweed5.defaultextensions.validation.api.result.ValidationIssues; import de.siphalor.tweed5.defaultextensions.validation.api.validators.SimpleValidatorMiddleware; import de.siphalor.tweed5.defaultextensions.validation.impl.ValidationExtensionImpl; -import org.jetbrains.annotations.ApiStatus; +import de.siphalor.tweed5.patchwork.api.Patchwork; import org.jspecify.annotations.Nullable; import java.util.*; @@ -57,5 +56,7 @@ public interface ValidationExtension extends TweedExtension { } void addValidatorMiddleware(ConfigEntry entry, Middleware validator); + ValidationIssues captureValidationIssues(Patchwork readContextExtensionsData); + ValidationIssues validate(ConfigEntry entry, T value); } diff --git a/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/validation/api/validators/NumberRangeValidator.java b/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/validation/api/validators/NumberRangeValidator.java index 393554a..0a18a3c 100644 --- a/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/validation/api/validators/NumberRangeValidator.java +++ b/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/validation/api/validators/NumberRangeValidator.java @@ -5,54 +5,80 @@ import de.siphalor.tweed5.defaultextensions.validation.api.ConfigEntryValidator; import de.siphalor.tweed5.defaultextensions.validation.api.result.ValidationIssue; import de.siphalor.tweed5.defaultextensions.validation.api.result.ValidationIssueLevel; import de.siphalor.tweed5.defaultextensions.validation.api.result.ValidationResult; -import lombok.AllArgsConstructor; -import lombok.Value; -import org.jspecify.annotations.NonNull; +import lombok.*; import org.jspecify.annotations.Nullable; import java.util.Collections; @Value -@AllArgsConstructor -public class NumberRangeValidator implements ConfigEntryValidator { +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class NumberRangeValidator implements ConfigEntryValidator { Class numberClass; @Nullable N minimum; + boolean minimumExclusive; @Nullable N maximum; + boolean maximumExclusive; + String description; + + public static Builder builder(Class numberClass) { + return new Builder<>(numberClass); + } @Override public ValidationResult validate(ConfigEntry configEntry, T value) { if (!(value instanceof Number)) { return ValidationResult.withIssues(value, Collections.singleton( - new ValidationIssue("Value must be numeric", ValidationIssueLevel.ERROR) + new ValidationIssue("Value must be numeric, got" + getClassName(value), ValidationIssueLevel.ERROR) )); } if (value.getClass() != numberClass) { return ValidationResult.withIssues(value, Collections.singleton( new ValidationIssue( - "Value is of wrong type, expected " + numberClass.getSimpleName() + - ", got " + value.getClass().getSimpleName(), + "Value is of wrong type, expected " + numberClass.getName() + + ", got " + getClassName(value), ValidationIssueLevel.ERROR ) )); } Number numberValue = (Number) value; - if (minimum != null && compare(numberValue, minimum) < 0) { - //noinspection unchecked - return ValidationResult.withIssues((T) minimum, Collections.singleton( - new ValidationIssue("Value must be at least " + minimum, ValidationIssueLevel.WARN) - )); + if (minimum != null) { + int minCmp = compare(numberValue, minimum); + if (minimumExclusive ? minCmp <= 0 : minCmp < 0) { + //noinspection unchecked + return ValidationResult.withIssues( + (T) minimum, + Collections.singleton(new ValidationIssue( + description + ", got: " + value, + ValidationIssueLevel.WARN + )) + ); + } } - if (maximum != null && compare(numberValue, maximum) > 0) { - //noinspection unchecked - return ValidationResult.withIssues((T) maximum, Collections.singleton( - new ValidationIssue("Value must be at most " + maximum, ValidationIssueLevel.WARN) - )); + if (maximum != null) { + int maxCmp = compare(numberValue, maximum); + if (maximumExclusive ? maxCmp >= 0 : maxCmp > 0) { + //noinspection unchecked + return ValidationResult.withIssues( + (T) maximum, + Collections.singleton(new ValidationIssue( + description + " Got: " + value, + ValidationIssueLevel.WARN + )) + ); + } } return ValidationResult.ok(value); } + private static String getClassName(@Nullable Object value) { + if (value == null) { + return ""; + } + return value.getClass().getName(); + } + private int compare(Number a, Number b) { if (numberClass == Byte.class) { return Byte.compare(a.byteValue(), b.byteValue()); @@ -71,18 +97,86 @@ public class NumberRangeValidator implements ConfigEn @Override public String description(ConfigEntry configEntry) { - if (minimum == null) { - if (maximum == null) { - return ""; - } else { - return "Must be smaller or equal to " + maximum + "."; + return description; + } + + @RequiredArgsConstructor(access = AccessLevel.PRIVATE) + public static class Builder { + private final Class numberClass; + private @Nullable N minimum; + private boolean minimumExclusive; + private @Nullable N maximum; + private boolean maximumExclusive; + + public Builder greaterThan(N minimum) { + this.minimumExclusive = true; + this.minimum = minimum; + return this; + } + + public Builder greaterThanOrEqualTo(N minimum) { + this.minimumExclusive = false; + this.minimum = minimum; + return this; + } + + public Builder lessThan(N maximum) { + this.maximumExclusive = true; + this.maximum = maximum; + return this; + } + + public Builder lessThanOrEqualTo(N maximum) { + this.maximumExclusive = false; + this.maximum = maximum; + return this; + } + + public NumberRangeValidator build() { + return new NumberRangeValidator<>( + numberClass, + minimum, minimumExclusive, + maximum, maximumExclusive, + createDescription() + ); + } + + private String createDescription() { + if (minimum != null) { + if (maximum != null) { + if (minimumExclusive == maximumExclusive) { + if (minimumExclusive) { + return "Must be exclusively between " + minimum + " and " + maximum + "."; + } else { + return "Must be inclusively between " + minimum + " and " + maximum + "."; + } + } + + StringBuilder description = new StringBuilder(40); + description.append("Must be greater than "); + if (!minimumExclusive) { + description.append("or equal to "); + } + description.append(minimum).append(" and less than "); + if (!maximumExclusive) { + description.append("or equal to "); + } + description.append(maximum).append('.'); + return description.toString(); + } else if (minimumExclusive) { + return "Must be greater than " + minimum + "."; + } else { + return "Must be greater than or equal to " + minimum + "."; + } } - } else { - if (maximum == null) { - return "Must be greater or equal to " + minimum + "."; - } else { - return "Must be inclusively between " + minimum + " and " + maximum + "."; + if (maximum != null) { + if (maximumExclusive) { + return "Must be less than " + maximum + "."; + } else { + return "Must be less than or equal to " + maximum + "."; + } } + return ""; } } } diff --git a/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/validation/impl/ValidationExtensionImpl.java b/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/validation/impl/ValidationExtensionImpl.java index ac1fc2e..2446709 100644 --- a/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/validation/impl/ValidationExtensionImpl.java +++ b/tweed5-default-extensions/src/main/java/de/siphalor/tweed5/defaultextensions/validation/impl/ValidationExtensionImpl.java @@ -27,6 +27,7 @@ import de.siphalor.tweed5.defaultextensions.validation.api.result.ValidationIssu import de.siphalor.tweed5.defaultextensions.validation.api.result.ValidationIssueLevel; import de.siphalor.tweed5.defaultextensions.validation.api.result.ValidationIssues; import de.siphalor.tweed5.defaultextensions.validation.api.result.ValidationResult; +import de.siphalor.tweed5.patchwork.api.Patchwork; import de.siphalor.tweed5.patchwork.api.PatchworkPartAccess; import lombok.*; import org.jspecify.annotations.Nullable; @@ -169,6 +170,11 @@ public class ValidationExtensionImpl implements ReadWriteRelatedExtension, Valid return entryData; } + @Override + public ValidationIssues captureValidationIssues(Patchwork readContextExtensionsData) { + return getOrCreateValidationIssues(readContextExtensionsData); + } + @Override public ValidationIssues validate(ConfigEntry entry, @Nullable T value) { PathTracking pathTracking = new PathTracking(); @@ -215,12 +221,7 @@ public class ValidationExtensionImpl implements ReadWriteRelatedExtension, Valid //noinspection unchecked TweedEntryReader> castedInner = (TweedEntryReader>) inner; return (TweedDataReader reader, ConfigEntry entry, TweedReadContext context) -> { - ValidationIssues validationIssues = context.extensionsData().get(readContextValidationIssuesAccess); - if (validationIssues == null) { - validationIssues = new ValidationIssuesImpl(); - } else { - validationIssues = (ValidationIssues) context.extensionsData(); - } + ValidationIssues validationIssues = getOrCreateValidationIssues(context.extensionsData()); Object value = castedInner.read(reader, entry, context); @@ -251,6 +252,16 @@ public class ValidationExtensionImpl implements ReadWriteRelatedExtension, Valid } } + private ValidationIssues getOrCreateValidationIssues(Patchwork readContextExtensionsData) { + assert readContextValidationIssuesAccess != null; + ValidationIssues validationIssues = readContextExtensionsData.get(readContextValidationIssuesAccess); + if (validationIssues == null) { + validationIssues = new ValidationIssuesImpl(); + readContextExtensionsData.set(readContextValidationIssuesAccess, validationIssues); + } + return validationIssues; + } + @Getter @RequiredArgsConstructor private class ValidatingConfigEntryVisitor implements ConfigEntryValueVisitor { diff --git a/tweed5-default-extensions/src/test/java/de/siphalor/tweed5/defaultextensions/validation/impl/ValidationExtensionImplTest.java b/tweed5-default-extensions/src/test/java/de/siphalor/tweed5/defaultextensions/validation/impl/ValidationExtensionImplTest.java index 7669c0b..b9fa5be 100644 --- a/tweed5-default-extensions/src/test/java/de/siphalor/tweed5/defaultextensions/validation/impl/ValidationExtensionImplTest.java +++ b/tweed5-default-extensions/src/test/java/de/siphalor/tweed5/defaultextensions/validation/impl/ValidationExtensionImplTest.java @@ -6,26 +6,36 @@ import de.siphalor.tweed5.core.api.entry.SimpleConfigEntry; import de.siphalor.tweed5.core.impl.DefaultConfigContainer; 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.comment.api.CommentExtension; import de.siphalor.tweed5.defaultextensions.validation.api.ValidationExtension; -import de.siphalor.tweed5.defaultextensions.validation.api.result.ValidationIssue; import de.siphalor.tweed5.defaultextensions.validation.api.result.ValidationIssueLevel; import de.siphalor.tweed5.defaultextensions.validation.api.result.ValidationIssues; import de.siphalor.tweed5.defaultextensions.validation.api.validators.NumberRangeValidator; +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 java.io.StringReader; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +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.defaultextensions.comment.api.CommentExtension.baseComment; import static de.siphalor.tweed5.defaultextensions.validation.api.ValidationExtension.validators; 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.api.Assertions.*; class ValidationExtensionImplTest { @@ -39,17 +49,26 @@ class ValidationExtensionImplTest { void setUp() { configContainer = new DefaultConfigContainer<>(); - configContainer.registerExtension(CommentExtension.DEFAULT); - configContainer.registerExtension(ValidationExtension.DEFAULT); + configContainer.registerExtension(CommentExtension.class); + configContainer.registerExtension(ReadWriteExtension.class); + configContainer.registerExtension(ValidationExtension.class); configContainer.finishExtensionSetup(); byteEntry = new SimpleConfigEntryImpl<>(configContainer, Byte.class) - .apply(validators(new NumberRangeValidator<>(Byte.class, (byte) 10, (byte) 100))); + .apply(entryReaderWriter(byteReaderWriter())) + .apply(validators( + NumberRangeValidator.builder(Byte.class) + .greaterThanOrEqualTo((byte) 11) + .lessThanOrEqualTo((byte) 100) + .build() + )); intEntry = new SimpleConfigEntryImpl<>(configContainer, Integer.class) - .apply(validators(new NumberRangeValidator<>(Integer.class, null, 123))) + .apply(entryReaderWriter(intReaderWriter())) + .apply(validators(NumberRangeValidator.builder(Integer.class).lessThanOrEqualTo(123).build())) .apply(baseComment("This is the main comment!")); doubleEntry = new SimpleConfigEntryImpl<>(configContainer, Double.class) - .apply(validators(new NumberRangeValidator<>(Double.class, 0.5, null))); + .apply(entryReaderWriter(doubleReaderWriter())) + .apply(validators(NumberRangeValidator.builder(Double.class).greaterThanOrEqualTo(0.5).build())); //noinspection unchecked rootEntry = new StaticMapCompoundConfigEntryImpl<>( @@ -61,7 +80,8 @@ class ValidationExtensionImplTest { entry("int", intEntry), entry("double", doubleEntry) )) - ); + ) + .apply(entryReaderWriter(compoundReaderWriter())); configContainer.attachTree(rootEntry); @@ -98,9 +118,71 @@ class ValidationExtensionImplTest { assertNotNull(result.issuesByPath()); assertAll( - () -> assertValidationIssue(result, ".byte", byteEntry, new ValidationIssue("Value must be at least 10", ValidationIssueLevel.WARN)), - () -> assertValidationIssue(result, ".int", intEntry, new ValidationIssue("Value must be at most 123", ValidationIssueLevel.WARN)), - () -> assertValidationIssue(result, ".double", doubleEntry, new ValidationIssue("Value must be at least 0.5", ValidationIssueLevel.WARN)) + () -> assertValidationIssue( + result, + ".byte", + byteEntry, + ValidationIssueLevel.WARN, + message -> assertThat(message).contains("11", "100", "9") + ), + () -> assertValidationIssue( + result, + ".int", + intEntry, + ValidationIssueLevel.WARN, + message -> assertThat(message).contains("123", "124") + ), + () -> assertValidationIssue( + result, + ".double", + doubleEntry, + ValidationIssueLevel.WARN, + message -> assertThat(message).contains("0.5", "0.2") + ) + ); + } + + @Test + void readInvalid() { + ValidationExtension validationExtension = configContainer.extension(ValidationExtension.class).orElseThrow(); + + var reader = new HjsonReader(new HjsonLexer(new StringReader(""" + { + byte: 9 + int: 124 + double: 0.2 + } + """))); + var validationIssues = new AtomicReference<@Nullable ValidationIssues>(); + Map value = configContainer.rootEntry().call(read( + reader, + extensionsData -> validationIssues.set(validationExtension.captureValidationIssues(extensionsData)) + )); + + assertThat(value).isEqualTo(Map.of("byte", (byte) 11, "int", 123, "double", 0.5)); + //noinspection DataFlowIssue + assertThat(validationIssues.get()).isNotNull().satisfies( + vi -> assertValidationIssue( + vi, + ".byte", + byteEntry, + ValidationIssueLevel.WARN, + message -> assertThat(message).contains("11", "100", "9") + ), + vi -> assertValidationIssue( + vi, + ".int", + intEntry, + ValidationIssueLevel.WARN, + message -> assertThat(message).contains("123", "124") + ), + vi -> assertValidationIssue( + vi, + ".double", + doubleEntry, + ValidationIssueLevel.WARN, + message -> assertThat(message).contains("0.5", "0.2") + ) ); } @@ -108,13 +190,15 @@ class ValidationExtensionImplTest { ValidationIssues issues, String expectedPath, ConfigEntry expectedEntry, - ValidationIssue expectedIssue + ValidationIssueLevel expectedLevel, + Consumer issueMessageConsumer ) { - assertTrue(issues.issuesByPath().containsKey(expectedPath), "Must have issues for path " + expectedPath); - ValidationIssues.EntryIssues entryIssues = issues.issuesByPath().get(expectedPath); - assertSame(expectedEntry, entryIssues.entry(), "Entry must match"); - assertEquals(1, entryIssues.issues().size(), "Entry must have exactly one issue"); - assertEquals(expectedIssue, entryIssues.issues().iterator().next(), "Issue must match"); + assertThat(issues.issuesByPath()).hasEntrySatisfying(expectedPath, entryIssues -> assertThat(entryIssues).satisfies( + eis -> assertThat(eis.entry()).isSameAs(expectedEntry), + eis -> assertThat(eis.issues()).singleElement().satisfies( + ei -> assertThat(ei.level()).isEqualTo(expectedLevel), + ei -> assertThat(ei.message()).satisfies(issueMessageConsumer) + ) + )); } - }