From 699a4ebb89ab418ba567e07761507d3e251cda26 Mon Sep 17 00:00:00 2001 From: davidfrigolet Date: Fri, 20 Feb 2026 16:12:35 +0000 Subject: [PATCH 1/4] feat: change validator --- .../change/AbstractChangeValidator.java | 237 ++++++++++ .../change/ChangeNamingConvention.java | 60 +++ .../support/change/ChangeValidator.java | 150 +++++++ .../change/ChangeNamingConventionTest.java | 96 ++++ .../support/change/ChangeValidatorTest.java | 414 ++++++++++++++++++ .../change/fixtures/NoApplyMethodChange.java | 30 ++ .../fixtures/NoChangeAnnotationClass.java | 27 ++ .../change/fixtures/NoOrderPrefixChange.java | 32 ++ .../fixtures/_0001__FullyAnnotatedChange.java | 43 ++ .../_0002__NonTransactionalChange.java | 35 ++ .../fixtures/_0003__NoTargetSystemChange.java | 32 ++ 11 files changed, 1156 insertions(+) create mode 100644 core/flamingock-test-support/src/main/java/io/flamingock/support/change/AbstractChangeValidator.java create mode 100644 core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeNamingConvention.java create mode 100644 core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeValidator.java create mode 100644 core/flamingock-test-support/src/test/java/io/flamingock/support/change/ChangeNamingConventionTest.java create mode 100644 core/flamingock-test-support/src/test/java/io/flamingock/support/change/ChangeValidatorTest.java create mode 100644 core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/NoApplyMethodChange.java create mode 100644 core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/NoChangeAnnotationClass.java create mode 100644 core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/NoOrderPrefixChange.java create mode 100644 core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/_0001__FullyAnnotatedChange.java create mode 100644 core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/_0002__NonTransactionalChange.java create mode 100644 core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/_0003__NoTargetSystemChange.java diff --git a/core/flamingock-test-support/src/main/java/io/flamingock/support/change/AbstractChangeValidator.java b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/AbstractChangeValidator.java new file mode 100644 index 000000000..cf8a4aa54 --- /dev/null +++ b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/AbstractChangeValidator.java @@ -0,0 +1,237 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.support.change; + +import io.flamingock.api.RecoveryStrategy; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + * Base class for Flamingock change validators. + * + *

Provides the soft-assertion engine: assertions are queued via + * {@link #addAssertion(Supplier)} and executed together by {@link #validate()}, which + * collects all failures and throws a single {@link AssertionError} listing every problem.

+ * + *

Shared assertions that apply to both code-based and template-based changes + * ({@code withId}, {@code withAuthor}, {@code withOrder}, {@code withTargetSystem}, + * {@code withRecovery}, {@code isTransactional}, {@code isNotTransactional}) are declared + * here so that concrete subclasses inherit them without duplication.

+ * + *

Subclasses must implement the metadata accessors ({@link #getId()}, + * {@link #getAuthor()}, etc.) to supply the values that each assertion checks.

+ * + *

Subclasses should return {@code this} from their own assertion methods to allow + * fluent chaining.

+ * + * @param the concrete subclass type, used to preserve the fluent return type + * @see ChangeValidator + */ +public abstract class AbstractChangeValidator> { + + /** Display name used in error messages (class simple name or file name). */ + protected final String displayName; + + /** + * Order extracted from the name at construction time via {@link ChangeNamingConvention}. + * {@code null} when the name does not follow the {@code _ORDER__Name} convention. + */ + protected final String extractedOrder; + + private final List>> assertions = new ArrayList<>(); + + protected AbstractChangeValidator(String displayName, String extractedOrder) { + this.displayName = displayName; + this.extractedOrder = extractedOrder; + } + + protected abstract String getId(); + + protected abstract String getAuthor(); + + protected abstract boolean isTransactionalValue(); + + protected abstract String getTargetSystemId(); + + protected abstract RecoveryStrategy getRecovery(); + + /** + * Asserts that the change id matches the expected value. + * + * @param expected the expected id + * @return this validator for chaining + */ + public SELF withId(String expected) { + addAssertion(() -> { + String actual = getId(); + return actual.equals(expected) + ? Optional.empty() + : Optional.of(String.format("withId: expected \"%s\" but was \"%s\"", expected, actual)); + }); + return self(); + } + + /** + * Asserts that the change author matches the expected value. + * + * @param expected the expected author + * @return this validator for chaining + */ + public SELF withAuthor(String expected) { + addAssertion(() -> { + String actual = getAuthor(); + return actual.equals(expected) + ? Optional.empty() + : Optional.of(String.format("withAuthor: expected \"%s\" but was \"%s\"", expected, actual)); + }); + return self(); + } + + /** + * Asserts that the order extracted from the name matches the expected value. + * + *

Order is derived from the naming convention {@code _ORDER__DescriptiveName}. + * For code-based changes the class simple name is used; for template-based changes + * the file name (without extension) is used.

+ * + * @param expected the exact expected order string (e.g. {@code "0002"}, {@code "20250101_01"}) + * @return this validator for chaining + */ + public SELF withOrder(String expected) { + addAssertion(() -> { + if (extractedOrder == null) { + return Optional.of(String.format( + "withOrder: could not extract order from \"%s\". " + + "Name must follow the _ORDER__Name convention (e.g. _0001__MyChange).", + displayName)); + } + return extractedOrder.equals(expected) + ? Optional.empty() + : Optional.of(String.format( + "withOrder: expected \"%s\" but extracted order was \"%s\"", + expected, extractedOrder)); + }); + return self(); + } + + /** + * Asserts that a target system is declared with the given id. + * + * @param expectedId the expected target system id + * @return this validator for chaining + */ + public SELF withTargetSystem(String expectedId) { + addAssertion(() -> { + String actual = getTargetSystemId(); + if (actual == null) { + return Optional.of(String.format( + "withTargetSystem: expected target system \"%s\" but none is declared", + expectedId)); + } + return actual.equals(expectedId) + ? Optional.empty() + : Optional.of(String.format( + "withTargetSystem: expected \"%s\" but was \"%s\"", expectedId, actual)); + }); + return self(); + } + + /** + * Asserts that the recovery strategy matches the expected value. + * + *

When no recovery is explicitly declared, {@link RecoveryStrategy#MANUAL_INTERVENTION} + * is assumed, consistent with the Flamingock runtime default.

+ * + * @param expected the expected {@link RecoveryStrategy} + * @return this validator for chaining + */ + public SELF withRecovery(RecoveryStrategy expected) { + addAssertion(() -> { + RecoveryStrategy actual = getRecovery(); + return actual == expected + ? Optional.empty() + : Optional.of(String.format( + "withRecovery: expected %s but was %s", expected.name(), actual.name())); + }); + return self(); + } + + /** + * Asserts that the change is transactional. + * + * @return this validator for chaining + */ + public SELF isTransactional() { + addAssertion(() -> isTransactionalValue() + ? Optional.empty() + : Optional.of("isTransactional: expected transactional=true but was false")); + return self(); + } + + /** + * Asserts that the change is not transactional. + * + * @return this validator for chaining + */ + public SELF isNotTransactional() { + addAssertion(() -> !isTransactionalValue() + ? Optional.empty() + : Optional.of("isNotTransactional: expected transactional=false but was true")); + return self(); + } + + + /** + * Queues an assertion to be evaluated when {@link #validate()} is called. + * + * @param assertion a supplier that returns an error message if the assertion fails, + * or {@link Optional#empty()} if it passes + */ + protected final void addAssertion(Supplier> assertion) { + assertions.add(assertion); + } + + /** + * Runs all queued assertions and throws an {@link AssertionError} if any fail. + * + *

All assertions are always evaluated; failures are collected and reported together + * so every problem is visible in a single test run.

+ * + * @throws AssertionError if one or more assertions failed, listing all failure messages + */ + public final void validate() { + List errors = assertions.stream() + .map(Supplier::get) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toList()); + + if (!errors.isEmpty()) { + throw new AssertionError( + getClass().getSimpleName() + " failed for " + displayName + ":\n - " + + String.join("\n - ", errors)); + } + } + + @SuppressWarnings("unchecked") + private SELF self() { + return (SELF) this; + } +} diff --git a/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeNamingConvention.java b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeNamingConvention.java new file mode 100644 index 000000000..cd2a114ee --- /dev/null +++ b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeNamingConvention.java @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.support.change; + +/** + * Shared utility for parsing the Flamingock naming convention that encodes execution order. + * + *

Both code-based changes (class names) and template-based changes (file names) follow + * the same pattern:

+ *
+ *   _ORDER__DescriptiveName
+ * 
+ *

Examples:

+ *
    + *
  • {@code _0002__FeedClients} → order {@code "0002"}
  • + *
  • {@code _20250101_01__InitSchema} → order {@code "20250101_01"}
  • + *
  • {@code _V1_2_3__LegacyMigration} → order {@code "V1_2_3"}
  • + *
+ * + *

This class is package-private and intended for use by validators in this package only.

+ */ +final class ChangeNamingConvention { + + private static final String ORDER_PREFIX = "_"; + private static final String ORDER_SEPARATOR = "__"; + + private ChangeNamingConvention() { + } + + /** + * Extracts the order segment from a name (class simple name or file name without extension) + * following the {@code _ORDER__DescriptiveName} convention. + * + * @param name the name to parse (class simple name or file name without extension) + * @return the extracted order string, or {@code null} if the name does not follow the convention + */ + static String extractOrder(String name) { + if (name == null || !name.startsWith(ORDER_PREFIX)) { + return null; + } + int separatorIndex = name.indexOf(ORDER_SEPARATOR); + if (separatorIndex <= 1) { + return null; + } + return name.substring(1, separatorIndex); + } +} diff --git a/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeValidator.java b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeValidator.java new file mode 100644 index 000000000..ca7badbdd --- /dev/null +++ b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeValidator.java @@ -0,0 +1,150 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.support.change; + +import io.flamingock.api.RecoveryStrategy; +import io.flamingock.api.annotations.Apply; +import io.flamingock.api.annotations.Change; +import io.flamingock.api.annotations.Recovery; +import io.flamingock.api.annotations.Rollback; +import io.flamingock.api.annotations.TargetSystem; + +import java.util.Arrays; +import java.util.Objects; +import java.util.Optional; + +/** + * Fluent assertion utility for validating that a code-based change class is correctly annotated. + * + *

Reads metadata from {@code @Change}, {@code @TargetSystem}, {@code @Recovery}, + * {@code @Apply}, and {@code @Rollback} via reflection and asserts that the values match + * expectations. All assertions are soft: they are queued on each chained call and executed + * together by {@link #validate()}, which collects every failure into a single + * {@link AssertionError}.

+ * + *

Implicit validation at construction

+ *

{@link #of(Class)} checks eagerly that: + *

    + *
  • The class is annotated with {@code @Change}
  • + *
  • The class declares at least one method annotated with {@code @Apply}
  • + *
+ * + *

Usage example

+ *
{@code
+ * ChangeValidator.of(_0002__FeedClients.class)
+ *     .withId("feed-clients")
+ *     .withAuthor("john.doe")
+ *     .withOrder("0002")
+ *     .isTransactional()
+ *     .withTargetSystem("mongodb")
+ *     .hasRollbackMethod()
+ *     .validate();
+ * }
+ * + * @see AbstractChangeValidator + * @see io.flamingock.api.annotations.Change + * @see io.flamingock.api.annotations.Apply + * @see io.flamingock.api.annotations.Rollback + */ +public final class ChangeValidator extends AbstractChangeValidator { + + private final Class changeClass; + private final Change changeAnnotation; + + /** + * Creates a {@code ChangeValidator} for the given change class. + * + *

Validates eagerly that the class is annotated with {@code @Change} and declares + * at least one method annotated with {@code @Apply}.

+ * + * @param changeClass the change class to validate; must not be {@code null} + * @return a new validator ready for assertion chaining + * @throws NullPointerException if {@code changeClass} is {@code null} + * @throws IllegalArgumentException if {@code @Change} or {@code @Apply} is absent + */ + public static ChangeValidator of(Class changeClass) { + return new ChangeValidator(changeClass); + } + + private ChangeValidator(Class changeClass) { + super( + Objects.requireNonNull(changeClass, "changeClass must not be null").getSimpleName(), + ChangeNamingConvention.extractOrder(changeClass.getSimpleName()) + ); + this.changeClass = changeClass; + + this.changeAnnotation = changeClass.getAnnotation(Change.class); + if (changeAnnotation == null) { + throw new IllegalArgumentException( + String.format("Class [%s] must be annotated with @Change", changeClass.getName())); + } + + boolean hasApplyMethod = Arrays.stream(changeClass.getDeclaredMethods()) + .anyMatch(m -> m.isAnnotationPresent(Apply.class)); + if (!hasApplyMethod) { + throw new IllegalArgumentException( + String.format("Class [%s] must declare a method annotated with @Apply", changeClass.getName())); + } + } + + @Override + protected String getId() { + return changeAnnotation.id(); + } + + @Override + protected String getAuthor() { + return changeAnnotation.author(); + } + + @Override + protected boolean isTransactionalValue() { + return changeAnnotation.transactional(); + } + + @Override + protected String getTargetSystemId() { + TargetSystem ts = changeClass.getAnnotation(TargetSystem.class); + return ts != null ? ts.id() : null; + } + + @Override + protected RecoveryStrategy getRecovery() { + Recovery rec = changeClass.getAnnotation(Recovery.class); + return rec != null ? rec.strategy() : RecoveryStrategy.MANUAL_INTERVENTION; + } + + /** + * Asserts that the class declares a method annotated with {@code @Rollback}. + * + *

Only methods directly declared in the class are considered; inherited methods + * are not scanned.

+ * + * @return this validator for chaining + */ + public ChangeValidator hasRollbackMethod() { + addAssertion(() -> { + boolean found = Arrays.stream(changeClass.getDeclaredMethods()) + .anyMatch(m -> m.isAnnotationPresent(Rollback.class)); + return found + ? Optional.empty() + : Optional.of(String.format( + "hasRollbackMethod: no method annotated with @Rollback found in %s", + changeClass.getSimpleName())); + }); + return this; + } +} diff --git a/core/flamingock-test-support/src/test/java/io/flamingock/support/change/ChangeNamingConventionTest.java b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/ChangeNamingConventionTest.java new file mode 100644 index 000000000..4ce29641b --- /dev/null +++ b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/ChangeNamingConventionTest.java @@ -0,0 +1,96 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.support.change; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class ChangeNamingConventionTest { + + @Nested + @DisplayName("Valid names — order extracted correctly") + class ValidNames { + + @Test + @DisplayName("Should extract numeric order from class simple name") + void shouldExtractNumericOrder() { + assertEquals("0002", ChangeNamingConvention.extractOrder("_0002__FeedClients")); + } + + @Test + @DisplayName("Should extract date-based order") + void shouldExtractDateBasedOrder() { + assertEquals("20250101_01", ChangeNamingConvention.extractOrder("_20250101_01__InitSchema")); + } + + @Test + @DisplayName("Should extract version-style order") + void shouldExtractVersionStyleOrder() { + assertEquals("V1_2_3", ChangeNamingConvention.extractOrder("_V1_2_3__LegacyMigration")); + } + + @Test + @DisplayName("Should extract single-digit order") + void shouldExtractSingleDigitOrder() { + assertEquals("1", ChangeNamingConvention.extractOrder("_1__MyChange")); + } + + @Test + @DisplayName("Should work identically for file names without extension") + void shouldWorkForFileNames() { + assertEquals("0003", ChangeNamingConvention.extractOrder("_0003__create_users")); + } + } + + @Nested + @DisplayName("Invalid names — null returned") + class InvalidNames { + + @Test + @DisplayName("Should return null when name is null") + void shouldReturnNullForNull() { + assertNull(ChangeNamingConvention.extractOrder(null)); + } + + @Test + @DisplayName("Should return null when name does not start with underscore") + void shouldReturnNullWhenNoLeadingUnderscore() { + assertNull(ChangeNamingConvention.extractOrder("FeedClients")); + } + + @Test + @DisplayName("Should return null when name starts with underscore but has no double-underscore separator") + void shouldReturnNullWhenNoDoubleSeparator() { + assertNull(ChangeNamingConvention.extractOrder("_FeedClients")); + } + + @Test + @DisplayName("Should return null when double-underscore is at the very start") + void shouldReturnNullWhenSeparatorAtStart() { + assertNull(ChangeNamingConvention.extractOrder("__FeedClients")); + } + + @Test + @DisplayName("Should return null for empty string") + void shouldReturnNullForEmptyString() { + assertNull(ChangeNamingConvention.extractOrder("")); + } + } +} diff --git a/core/flamingock-test-support/src/test/java/io/flamingock/support/change/ChangeValidatorTest.java b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/ChangeValidatorTest.java new file mode 100644 index 000000000..c15f288ec --- /dev/null +++ b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/ChangeValidatorTest.java @@ -0,0 +1,414 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.support.change; + +import io.flamingock.api.RecoveryStrategy; +import io.flamingock.support.change.fixtures.NoApplyMethodChange; +import io.flamingock.support.change.fixtures.NoChangeAnnotationClass; +import io.flamingock.support.change.fixtures.NoOrderPrefixChange; +import io.flamingock.support.change.fixtures._0001__FullyAnnotatedChange; +import io.flamingock.support.change.fixtures._0002__NonTransactionalChange; +import io.flamingock.support.change.fixtures._0003__NoTargetSystemChange; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ChangeValidatorTest { + + @Nested + @DisplayName("Construction") + class ConstructionTests { + + @Test + @DisplayName("Should throw IllegalArgumentException when class has no @Change annotation") + void shouldThrowWhenNoChangeAnnotation() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> ChangeValidator.of(NoChangeAnnotationClass.class)); + + assertTrue(ex.getMessage().contains(NoChangeAnnotationClass.class.getName())); + } + + @Test + @DisplayName("Should throw IllegalArgumentException when class has no @Apply method") + void shouldThrowWhenNoApplyMethod() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> ChangeValidator.of(NoApplyMethodChange.class)); + + assertTrue(ex.getMessage().contains(NoApplyMethodChange.class.getName())); + } + + @Test + @DisplayName("Should throw NullPointerException when changeClass is null") + void shouldThrowWhenChangeClassIsNull() { + assertThrows(NullPointerException.class, () -> ChangeValidator.of(null)); + } + + @Test + @DisplayName("Should construct successfully for a valid change class") + void shouldConstructSuccessfully() { + assertDoesNotThrow(() -> ChangeValidator.of(_0001__FullyAnnotatedChange.class)); + } + + @Test + @DisplayName("Should pass validate() with no assertions added") + void shouldPassValidateWithNoAssertions() { + ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class); + + assertDoesNotThrow(validator::validate); + } + } + + @Nested + @DisplayName("withId") + class WithIdTests { + + @Test + @DisplayName("Should pass when id matches") + void shouldPassWhenIdMatches() { + ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .withId("fully-annotated"); + + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail when id does not match") + void shouldFailWhenIdDoesNotMatch() { + ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .withId("wrong-id"); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("withId")); + assertTrue(error.getMessage().contains("wrong-id")); + assertTrue(error.getMessage().contains("fully-annotated")); + } + } + + @Nested + @DisplayName("withAuthor") + class WithAuthorTests { + + @Test + @DisplayName("Should pass when author matches") + void shouldPassWhenAuthorMatches() { + ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .withAuthor("test-author"); + + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail when author does not match") + void shouldFailWhenAuthorDoesNotMatch() { + ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .withAuthor("wrong-author"); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("withAuthor")); + assertTrue(error.getMessage().contains("wrong-author")); + assertTrue(error.getMessage().contains("test-author")); + } + } + + @Nested + @DisplayName("withOrder") + class WithOrderTests { + + @Test + @DisplayName("Should pass when order matches the class name prefix") + void shouldPassWhenOrderMatches() { + ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .withOrder("0001"); + + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should pass for a different valid order prefix") + void shouldPassForDifferentValidOrderPrefix() { + ChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) + .withOrder("0002"); + + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail when order does not match") + void shouldFailWhenOrderDoesNotMatch() { + ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .withOrder("9999"); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("withOrder")); + assertTrue(error.getMessage().contains("9999")); + assertTrue(error.getMessage().contains("0001")); + } + + @Test + @DisplayName("Should fail with descriptive message when class name has no order prefix") + void shouldFailWithDescriptiveMessageWhenNoOrderPrefix() { + ChangeValidator validator = ChangeValidator.of(NoOrderPrefixChange.class) + .withOrder("0001"); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("withOrder")); + assertTrue(error.getMessage().contains("NoOrderPrefixChange")); + assertTrue(error.getMessage().contains("_ORDER__Name")); + } + } + + @Nested + @DisplayName("isTransactional") + class IsTransactionalTests { + + @Test + @DisplayName("Should pass when change is transactional via explicit annotation value") + void shouldPassWhenTransactional() { + ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .isTransactional(); + + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should pass when change uses default transactional=true") + void shouldPassForDefaultTransactional() { + ChangeValidator validator = ChangeValidator.of(_0003__NoTargetSystemChange.class) + .isTransactional(); + + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail when change is not transactional") + void shouldFailWhenNotTransactional() { + ChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) + .isTransactional(); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("isTransactional")); + } + } + + @Nested + @DisplayName("isNotTransactional") + class IsNotTransactionalTests { + + @Test + @DisplayName("Should pass when change is not transactional") + void shouldPassWhenNotTransactional() { + ChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) + .isNotTransactional(); + + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail when change is transactional") + void shouldFailWhenTransactional() { + ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .isNotTransactional(); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("isNotTransactional")); + } + } + + @Nested + @DisplayName("withTargetSystem") + class WithTargetSystemTests { + + @Test + @DisplayName("Should pass when target system id matches") + void shouldPassWhenTargetSystemMatches() { + ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .withTargetSystem("mongodb"); + + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail when target system id does not match") + void shouldFailWhenTargetSystemDoesNotMatch() { + ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .withTargetSystem("postgresql"); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("withTargetSystem")); + assertTrue(error.getMessage().contains("postgresql")); + assertTrue(error.getMessage().contains("mongodb")); + } + + @Test + @DisplayName("Should fail when @TargetSystem annotation is not present") + void shouldFailWhenTargetSystemAnnotationAbsent() { + ChangeValidator validator = ChangeValidator.of(_0003__NoTargetSystemChange.class) + .withTargetSystem("mongodb"); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("withTargetSystem")); + assertTrue(error.getMessage().contains("none is declared")); + } + } + + @Nested + @DisplayName("withRecovery") + class WithRecoveryTests { + + @Test + @DisplayName("Should pass for ALWAYS_RETRY when @Recovery is present with ALWAYS_RETRY") + void shouldPassForAlwaysRetryWhenAnnotationPresent() { + ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .withRecovery(RecoveryStrategy.ALWAYS_RETRY); + + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should pass for MANUAL_INTERVENTION when @Recovery annotation is absent") + void shouldPassForDefaultWhenAnnotationAbsent() { + ChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) + .withRecovery(RecoveryStrategy.MANUAL_INTERVENTION); + + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail when recovery strategy does not match") + void shouldFailWhenRecoveryDoesNotMatch() { + ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .withRecovery(RecoveryStrategy.MANUAL_INTERVENTION); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("withRecovery")); + assertTrue(error.getMessage().contains("MANUAL_INTERVENTION")); + assertTrue(error.getMessage().contains("ALWAYS_RETRY")); + } + + @Test + @DisplayName("Should fail when ALWAYS_RETRY expected but default MANUAL_INTERVENTION is in effect") + void shouldFailWhenAlwaysRetryExpectedButDefaultApplies() { + ChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) + .withRecovery(RecoveryStrategy.ALWAYS_RETRY); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("withRecovery")); + assertTrue(error.getMessage().contains("ALWAYS_RETRY")); + assertTrue(error.getMessage().contains("MANUAL_INTERVENTION")); + } + } + + @Nested + @DisplayName("hasRollbackMethod") + class HasRollbackMethodTests { + + @Test + @DisplayName("Should pass when @Rollback method is present") + void shouldPassWhenRollbackPresent() { + ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .hasRollbackMethod(); + + assertDoesNotThrow(validator::validate); + } + + @Test + @DisplayName("Should fail when no @Rollback method is present") + void shouldFailWhenRollbackAbsent() { + ChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) + .hasRollbackMethod(); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("hasRollbackMethod")); + assertTrue(error.getMessage().contains(_0002__NonTransactionalChange.class.getSimpleName())); + } + } + + @Nested + @DisplayName("Aggregated Failures") + class AggregatedFailuresTests { + + @Test + @DisplayName("Should report all failures in a single AssertionError") + void shouldReportAllFailuresTogether() { + ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .withId("wrong-id") + .withAuthor("wrong-author") + .withOrder("9999"); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("withId")); + assertTrue(error.getMessage().contains("withAuthor")); + assertTrue(error.getMessage().contains("withOrder")); + } + + @Test + @DisplayName("Should only report failed assertions, not passing ones") + void shouldOnlyReportFailedAssertions() { + ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .withId("fully-annotated") + .withAuthor("wrong-author"); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("withAuthor")); + assertFalse(error.getMessage().contains("withId")); + } + + @Test + @DisplayName("Should include the change class simple name in the error header") + void shouldIncludeClassNameInErrorHeader() { + ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + .withId("wrong-id"); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains(_0001__FullyAnnotatedChange.class.getSimpleName())); + } + + @Test + @DisplayName("Should combine assertions across all assertion types") + void shouldCombineAssertionsAcrossAllTypes() { + ChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) + .withId("wrong-id") + .isTransactional() + .withTargetSystem("wrong-system") + .withRecovery(RecoveryStrategy.ALWAYS_RETRY) + .hasRollbackMethod(); + + AssertionError error = assertThrows(AssertionError.class, validator::validate); + + assertTrue(error.getMessage().contains("withId")); + assertTrue(error.getMessage().contains("isTransactional")); + assertTrue(error.getMessage().contains("withTargetSystem")); + assertTrue(error.getMessage().contains("withRecovery")); + assertTrue(error.getMessage().contains("hasRollbackMethod")); + } + } +} diff --git a/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/NoApplyMethodChange.java b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/NoApplyMethodChange.java new file mode 100644 index 000000000..866ebd3f2 --- /dev/null +++ b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/NoApplyMethodChange.java @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.support.change.fixtures; + +import io.flamingock.api.annotations.Change; + +/** + * Fixture for ChangeValidatorTest. + * Has @Change but no @Apply method — used to verify that ChangeValidator.of() throws + * IllegalArgumentException when the mandatory apply method is missing. + */ +@Change(id = "no-apply", author = "test-author") +public class NoApplyMethodChange { + + public void doSomething() { + } +} diff --git a/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/NoChangeAnnotationClass.java b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/NoChangeAnnotationClass.java new file mode 100644 index 000000000..853fcc7c5 --- /dev/null +++ b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/NoChangeAnnotationClass.java @@ -0,0 +1,27 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.support.change.fixtures; + +/** + * Fixture for ChangeValidatorTest. + * Intentionally missing @Change — used to verify that ChangeValidator.of() throws + * IllegalArgumentException when the class is not a change. + */ +public class NoChangeAnnotationClass { + + public void apply() { + } +} diff --git a/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/NoOrderPrefixChange.java b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/NoOrderPrefixChange.java new file mode 100644 index 000000000..73d48ca73 --- /dev/null +++ b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/NoOrderPrefixChange.java @@ -0,0 +1,32 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.support.change.fixtures; + +import io.flamingock.api.annotations.Apply; +import io.flamingock.api.annotations.Change; + +/** + * Fixture for ChangeValidatorTest. + * Valid @Change and @Apply, but the class name does NOT follow the _ORDER__Name convention. + * Used to verify that withOrder() produces a descriptive error when order cannot be extracted. + */ +@Change(id = "no-order-prefix", author = "test-author") +public class NoOrderPrefixChange { + + @Apply + public void apply() { + } +} diff --git a/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/_0001__FullyAnnotatedChange.java b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/_0001__FullyAnnotatedChange.java new file mode 100644 index 000000000..3cf27c8ce --- /dev/null +++ b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/_0001__FullyAnnotatedChange.java @@ -0,0 +1,43 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.support.change.fixtures; + +import io.flamingock.api.RecoveryStrategy; +import io.flamingock.api.annotations.Apply; +import io.flamingock.api.annotations.Change; +import io.flamingock.api.annotations.Recovery; +import io.flamingock.api.annotations.Rollback; +import io.flamingock.api.annotations.TargetSystem; + +/** + * Fixture change for ChangeValidatorTest. + * All optional annotations present: @TargetSystem, @Recovery(ALWAYS_RETRY), @Rollback. + * transactional=true (explicit). + * order = "0001" (from class name prefix). + */ +@Change(id = "fully-annotated", author = "test-author", transactional = true) +@TargetSystem(id = "mongodb") +@Recovery(strategy = RecoveryStrategy.ALWAYS_RETRY) +public class _0001__FullyAnnotatedChange { + + @Apply + public void apply() { + } + + @Rollback + public void rollback() { + } +} diff --git a/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/_0002__NonTransactionalChange.java b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/_0002__NonTransactionalChange.java new file mode 100644 index 000000000..78e3c69c5 --- /dev/null +++ b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/_0002__NonTransactionalChange.java @@ -0,0 +1,35 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.support.change.fixtures; + +import io.flamingock.api.annotations.Apply; +import io.flamingock.api.annotations.Change; +import io.flamingock.api.annotations.TargetSystem; + +/** + * Fixture change for ChangeValidatorTest. + * transactional=false, @TargetSystem present, no @Recovery (default = MANUAL_INTERVENTION), + * no @Rollback. + * order = "0002" (from class name prefix). + */ +@Change(id = "non-transactional", author = "test-author", transactional = false) +@TargetSystem(id = "kafka") +public class _0002__NonTransactionalChange { + + @Apply + public void apply() { + } +} diff --git a/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/_0003__NoTargetSystemChange.java b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/_0003__NoTargetSystemChange.java new file mode 100644 index 000000000..22aa23dd1 --- /dev/null +++ b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/fixtures/_0003__NoTargetSystemChange.java @@ -0,0 +1,32 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.support.change.fixtures; + +import io.flamingock.api.annotations.Apply; +import io.flamingock.api.annotations.Change; + +/** + * Fixture change for ChangeValidatorTest. + * No @TargetSystem, no @Recovery, no @Rollback. transactional=true (default). + * order = "0003" (from class name prefix). + */ +@Change(id = "no-target-system", author = "test-author") +public class _0003__NoTargetSystemChange { + + @Apply + public void apply() { + } +} From bed0d59cfc33b91ba9a984ab9d52a7808db15835 Mon Sep 17 00:00:00 2001 From: Antonio Perez Dieppa Date: Mon, 23 Feb 2026 14:26:39 +0000 Subject: [PATCH 2/4] refactor: ChangeValidatorResult --- .../change/AbstractChangeValidator.java | 55 +++++++++++-------- .../support/change/ChangeValidator.java | 52 ++++++++++-------- .../support/change/ChangeValidatorResult.java | 37 +++++++++++++ 3 files changed, 99 insertions(+), 45 deletions(-) create mode 100644 core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeValidatorResult.java diff --git a/core/flamingock-test-support/src/main/java/io/flamingock/support/change/AbstractChangeValidator.java b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/AbstractChangeValidator.java index cf8a4aa54..62ad4ae8a 100644 --- a/core/flamingock-test-support/src/main/java/io/flamingock/support/change/AbstractChangeValidator.java +++ b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/AbstractChangeValidator.java @@ -16,6 +16,7 @@ package io.flamingock.support.change; import io.flamingock.api.RecoveryStrategy; +import org.jetbrains.annotations.NotNull; import java.util.ArrayList; import java.util.List; @@ -55,7 +56,7 @@ public abstract class AbstractChangeValidator>> assertions = new ArrayList<>(); + private final List> assertions = new ArrayList<>(); protected AbstractChangeValidator(String displayName, String extractedOrder) { this.displayName = displayName; @@ -82,8 +83,8 @@ public SELF withId(String expected) { addAssertion(() -> { String actual = getId(); return actual.equals(expected) - ? Optional.empty() - : Optional.of(String.format("withId: expected \"%s\" but was \"%s\"", expected, actual)); + ? ChangeValidatorResult.OK() + : ChangeValidatorResult.error(String.format("withId: expected \"%s\" but was \"%s\"", expected, actual)); }); return self(); } @@ -98,8 +99,8 @@ public SELF withAuthor(String expected) { addAssertion(() -> { String actual = getAuthor(); return actual.equals(expected) - ? Optional.empty() - : Optional.of(String.format("withAuthor: expected \"%s\" but was \"%s\"", expected, actual)); + ? ChangeValidatorResult.OK() + : ChangeValidatorResult.error(String.format("withAuthor: expected \"%s\" but was \"%s\"", expected, actual)); }); return self(); } @@ -117,14 +118,14 @@ public SELF withAuthor(String expected) { public SELF withOrder(String expected) { addAssertion(() -> { if (extractedOrder == null) { - return Optional.of(String.format( + return ChangeValidatorResult.error(String.format( "withOrder: could not extract order from \"%s\". " + "Name must follow the _ORDER__Name convention (e.g. _0001__MyChange).", displayName)); } return extractedOrder.equals(expected) - ? Optional.empty() - : Optional.of(String.format( + ? ChangeValidatorResult.OK() + : ChangeValidatorResult.error(String.format( "withOrder: expected \"%s\" but extracted order was \"%s\"", expected, extractedOrder)); }); @@ -141,13 +142,13 @@ public SELF withTargetSystem(String expectedId) { addAssertion(() -> { String actual = getTargetSystemId(); if (actual == null) { - return Optional.of(String.format( + return ChangeValidatorResult.error(String.format( "withTargetSystem: expected target system \"%s\" but none is declared", expectedId)); } return actual.equals(expectedId) - ? Optional.empty() - : Optional.of(String.format( + ? ChangeValidatorResult.OK() + : ChangeValidatorResult.error(String.format( "withTargetSystem: expected \"%s\" but was \"%s\"", expectedId, actual)); }); return self(); @@ -165,10 +166,11 @@ public SELF withTargetSystem(String expectedId) { public SELF withRecovery(RecoveryStrategy expected) { addAssertion(() -> { RecoveryStrategy actual = getRecovery(); + String actualName = actual != null ? actual.name() : null; return actual == expected - ? Optional.empty() - : Optional.of(String.format( - "withRecovery: expected %s but was %s", expected.name(), actual.name())); + ? ChangeValidatorResult.OK() + : ChangeValidatorResult.error(String.format( + "withRecovery: expected %s but was %s", expected.name(), actualName)); }); return self(); } @@ -180,8 +182,8 @@ public SELF withRecovery(RecoveryStrategy expected) { */ public SELF isTransactional() { addAssertion(() -> isTransactionalValue() - ? Optional.empty() - : Optional.of("isTransactional: expected transactional=true but was false")); + ? ChangeValidatorResult.OK() + : ChangeValidatorResult.error("isTransactional: expected transactional=true but was false")); return self(); } @@ -192,8 +194,8 @@ public SELF isTransactional() { */ public SELF isNotTransactional() { addAssertion(() -> !isTransactionalValue() - ? Optional.empty() - : Optional.of("isNotTransactional: expected transactional=false but was true")); + ? ChangeValidatorResult.OK() + : ChangeValidatorResult.error("isNotTransactional: expected transactional=false but was true")); return self(); } @@ -204,7 +206,7 @@ public SELF isNotTransactional() { * @param assertion a supplier that returns an error message if the assertion fails, * or {@link Optional#empty()} if it passes */ - protected final void addAssertion(Supplier> assertion) { + protected final void addAssertion(Supplier assertion) { assertions.add(assertion); } @@ -217,10 +219,8 @@ protected final void addAssertion(Supplier> assertion) { * @throws AssertionError if one or more assertions failed, listing all failure messages */ public final void validate() { - List errors = assertions.stream() - .map(Supplier::get) - .filter(Optional::isPresent) - .map(Optional::get) + List errors = getErrors().stream() + .map(ChangeValidatorResult.Error::getMessage) .collect(Collectors.toList()); if (!errors.isEmpty()) { @@ -230,6 +230,15 @@ public final void validate() { } } + @NotNull + private List getErrors() { + return assertions.stream() + .map(Supplier::get) + .filter(ChangeValidatorResult::isError) + .map(ChangeValidatorResult.Error.class::cast) + .collect(Collectors.toList()); + } + @SuppressWarnings("unchecked") private SELF self() { return (SELF) this; diff --git a/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeValidator.java b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeValidator.java index ca7badbdd..1cc338108 100644 --- a/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeValidator.java +++ b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeValidator.java @@ -24,7 +24,6 @@ import java.util.Arrays; import java.util.Objects; -import java.util.Optional; /** * Fluent assertion utility for validating that a code-based change class is correctly annotated. @@ -64,21 +63,6 @@ public final class ChangeValidator extends AbstractChangeValidator changeClass; private final Change changeAnnotation; - /** - * Creates a {@code ChangeValidator} for the given change class. - * - *

Validates eagerly that the class is annotated with {@code @Change} and declares - * at least one method annotated with {@code @Apply}.

- * - * @param changeClass the change class to validate; must not be {@code null} - * @return a new validator ready for assertion chaining - * @throws NullPointerException if {@code changeClass} is {@code null} - * @throws IllegalArgumentException if {@code @Change} or {@code @Apply} is absent - */ - public static ChangeValidator of(Class changeClass) { - return new ChangeValidator(changeClass); - } - private ChangeValidator(Class changeClass) { super( Objects.requireNonNull(changeClass, "changeClass must not be null").getSimpleName(), @@ -100,6 +84,21 @@ private ChangeValidator(Class changeClass) { } } + /** + * Creates a {@code ChangeValidator} for the given change class. + * + *

Validates eagerly that the class is annotated with {@code @Change} and declares + * at least one method annotated with {@code @Apply}.

+ * + * @param changeClass the change class to validate; must not be {@code null} + * @return a new validator ready for assertion chaining + * @throws NullPointerException if {@code changeClass} is {@code null} + * @throws IllegalArgumentException if {@code @Change} or {@code @Apply} is absent + */ + public static ChangeValidator of(Class changeClass) { + return new ChangeValidator(changeClass); + } + @Override protected String getId() { return changeAnnotation.id(); @@ -123,8 +122,17 @@ protected String getTargetSystemId() { @Override protected RecoveryStrategy getRecovery() { - Recovery rec = changeClass.getAnnotation(Recovery.class); - return rec != null ? rec.strategy() : RecoveryStrategy.MANUAL_INTERVENTION; + if (changeClass.isAnnotationPresent(Recovery.class)) { + Recovery rec = changeClass.getAnnotation(Recovery.class); + return rec != null ? rec.strategy() : null; + } else { + try { + return (RecoveryStrategy) Recovery.class.getMethod("strategy").getDefaultValue(); + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + } /** @@ -140,10 +148,10 @@ public ChangeValidator hasRollbackMethod() { boolean found = Arrays.stream(changeClass.getDeclaredMethods()) .anyMatch(m -> m.isAnnotationPresent(Rollback.class)); return found - ? Optional.empty() - : Optional.of(String.format( - "hasRollbackMethod: no method annotated with @Rollback found in %s", - changeClass.getSimpleName())); + ? ChangeValidatorResult.OK() + : ChangeValidatorResult.error(String.format( + "hasRollbackMethod: no method annotated with @Rollback found in %s", + changeClass.getSimpleName())); }); return this; } diff --git a/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeValidatorResult.java b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeValidatorResult.java new file mode 100644 index 000000000..7f8772cac --- /dev/null +++ b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeValidatorResult.java @@ -0,0 +1,37 @@ +package io.flamingock.support.change; + +public abstract class ChangeValidatorResult { + + public static Ok OK_INSTANCE; + + public static ChangeValidatorResult.Ok OK() { + if (OK_INSTANCE == null) { + OK_INSTANCE = new ChangeValidatorResult.Ok(); + } + return OK_INSTANCE; + } + + public static ChangeValidatorResult.Error error(String message) { + return new ChangeValidatorResult.Error(message); + } + + public static class Ok extends ChangeValidatorResult { + + } + + public static class Error extends ChangeValidatorResult { + private final String message; + + public Error(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } + } + + public boolean isError() { + return this instanceof ChangeValidatorResult.Error; + } +} From 2d1682f173b76f4fc635d785677e48f60954ef50 Mon Sep 17 00:00:00 2001 From: Antonio Perez Dieppa Date: Mon, 23 Feb 2026 14:32:28 +0000 Subject: [PATCH 3/4] refactor: Move static method 'of' to parent class towards to future additions --- .../change/AbstractChangeValidator.java | 246 --------------- .../support/change/ChangeValidator.java | 295 ++++++++++++------ .../change/CodeBasedChangeValidator.java | 143 +++++++++ .../support/change/ChangeValidatorTest.java | 54 ++-- 4 files changed, 369 insertions(+), 369 deletions(-) delete mode 100644 core/flamingock-test-support/src/main/java/io/flamingock/support/change/AbstractChangeValidator.java create mode 100644 core/flamingock-test-support/src/main/java/io/flamingock/support/change/CodeBasedChangeValidator.java diff --git a/core/flamingock-test-support/src/main/java/io/flamingock/support/change/AbstractChangeValidator.java b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/AbstractChangeValidator.java deleted file mode 100644 index 62ad4ae8a..000000000 --- a/core/flamingock-test-support/src/main/java/io/flamingock/support/change/AbstractChangeValidator.java +++ /dev/null @@ -1,246 +0,0 @@ -/* - * Copyright 2026 Flamingock (https://www.flamingock.io) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.flamingock.support.change; - -import io.flamingock.api.RecoveryStrategy; -import org.jetbrains.annotations.NotNull; - -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.function.Supplier; -import java.util.stream.Collectors; - -/** - * Base class for Flamingock change validators. - * - *

Provides the soft-assertion engine: assertions are queued via - * {@link #addAssertion(Supplier)} and executed together by {@link #validate()}, which - * collects all failures and throws a single {@link AssertionError} listing every problem.

- * - *

Shared assertions that apply to both code-based and template-based changes - * ({@code withId}, {@code withAuthor}, {@code withOrder}, {@code withTargetSystem}, - * {@code withRecovery}, {@code isTransactional}, {@code isNotTransactional}) are declared - * here so that concrete subclasses inherit them without duplication.

- * - *

Subclasses must implement the metadata accessors ({@link #getId()}, - * {@link #getAuthor()}, etc.) to supply the values that each assertion checks.

- * - *

Subclasses should return {@code this} from their own assertion methods to allow - * fluent chaining.

- * - * @param the concrete subclass type, used to preserve the fluent return type - * @see ChangeValidator - */ -public abstract class AbstractChangeValidator> { - - /** Display name used in error messages (class simple name or file name). */ - protected final String displayName; - - /** - * Order extracted from the name at construction time via {@link ChangeNamingConvention}. - * {@code null} when the name does not follow the {@code _ORDER__Name} convention. - */ - protected final String extractedOrder; - - private final List> assertions = new ArrayList<>(); - - protected AbstractChangeValidator(String displayName, String extractedOrder) { - this.displayName = displayName; - this.extractedOrder = extractedOrder; - } - - protected abstract String getId(); - - protected abstract String getAuthor(); - - protected abstract boolean isTransactionalValue(); - - protected abstract String getTargetSystemId(); - - protected abstract RecoveryStrategy getRecovery(); - - /** - * Asserts that the change id matches the expected value. - * - * @param expected the expected id - * @return this validator for chaining - */ - public SELF withId(String expected) { - addAssertion(() -> { - String actual = getId(); - return actual.equals(expected) - ? ChangeValidatorResult.OK() - : ChangeValidatorResult.error(String.format("withId: expected \"%s\" but was \"%s\"", expected, actual)); - }); - return self(); - } - - /** - * Asserts that the change author matches the expected value. - * - * @param expected the expected author - * @return this validator for chaining - */ - public SELF withAuthor(String expected) { - addAssertion(() -> { - String actual = getAuthor(); - return actual.equals(expected) - ? ChangeValidatorResult.OK() - : ChangeValidatorResult.error(String.format("withAuthor: expected \"%s\" but was \"%s\"", expected, actual)); - }); - return self(); - } - - /** - * Asserts that the order extracted from the name matches the expected value. - * - *

Order is derived from the naming convention {@code _ORDER__DescriptiveName}. - * For code-based changes the class simple name is used; for template-based changes - * the file name (without extension) is used.

- * - * @param expected the exact expected order string (e.g. {@code "0002"}, {@code "20250101_01"}) - * @return this validator for chaining - */ - public SELF withOrder(String expected) { - addAssertion(() -> { - if (extractedOrder == null) { - return ChangeValidatorResult.error(String.format( - "withOrder: could not extract order from \"%s\". " - + "Name must follow the _ORDER__Name convention (e.g. _0001__MyChange).", - displayName)); - } - return extractedOrder.equals(expected) - ? ChangeValidatorResult.OK() - : ChangeValidatorResult.error(String.format( - "withOrder: expected \"%s\" but extracted order was \"%s\"", - expected, extractedOrder)); - }); - return self(); - } - - /** - * Asserts that a target system is declared with the given id. - * - * @param expectedId the expected target system id - * @return this validator for chaining - */ - public SELF withTargetSystem(String expectedId) { - addAssertion(() -> { - String actual = getTargetSystemId(); - if (actual == null) { - return ChangeValidatorResult.error(String.format( - "withTargetSystem: expected target system \"%s\" but none is declared", - expectedId)); - } - return actual.equals(expectedId) - ? ChangeValidatorResult.OK() - : ChangeValidatorResult.error(String.format( - "withTargetSystem: expected \"%s\" but was \"%s\"", expectedId, actual)); - }); - return self(); - } - - /** - * Asserts that the recovery strategy matches the expected value. - * - *

When no recovery is explicitly declared, {@link RecoveryStrategy#MANUAL_INTERVENTION} - * is assumed, consistent with the Flamingock runtime default.

- * - * @param expected the expected {@link RecoveryStrategy} - * @return this validator for chaining - */ - public SELF withRecovery(RecoveryStrategy expected) { - addAssertion(() -> { - RecoveryStrategy actual = getRecovery(); - String actualName = actual != null ? actual.name() : null; - return actual == expected - ? ChangeValidatorResult.OK() - : ChangeValidatorResult.error(String.format( - "withRecovery: expected %s but was %s", expected.name(), actualName)); - }); - return self(); - } - - /** - * Asserts that the change is transactional. - * - * @return this validator for chaining - */ - public SELF isTransactional() { - addAssertion(() -> isTransactionalValue() - ? ChangeValidatorResult.OK() - : ChangeValidatorResult.error("isTransactional: expected transactional=true but was false")); - return self(); - } - - /** - * Asserts that the change is not transactional. - * - * @return this validator for chaining - */ - public SELF isNotTransactional() { - addAssertion(() -> !isTransactionalValue() - ? ChangeValidatorResult.OK() - : ChangeValidatorResult.error("isNotTransactional: expected transactional=false but was true")); - return self(); - } - - - /** - * Queues an assertion to be evaluated when {@link #validate()} is called. - * - * @param assertion a supplier that returns an error message if the assertion fails, - * or {@link Optional#empty()} if it passes - */ - protected final void addAssertion(Supplier assertion) { - assertions.add(assertion); - } - - /** - * Runs all queued assertions and throws an {@link AssertionError} if any fail. - * - *

All assertions are always evaluated; failures are collected and reported together - * so every problem is visible in a single test run.

- * - * @throws AssertionError if one or more assertions failed, listing all failure messages - */ - public final void validate() { - List errors = getErrors().stream() - .map(ChangeValidatorResult.Error::getMessage) - .collect(Collectors.toList()); - - if (!errors.isEmpty()) { - throw new AssertionError( - getClass().getSimpleName() + " failed for " + displayName + ":\n - " - + String.join("\n - ", errors)); - } - } - - @NotNull - private List getErrors() { - return assertions.stream() - .map(Supplier::get) - .filter(ChangeValidatorResult::isError) - .map(ChangeValidatorResult.Error.class::cast) - .collect(Collectors.toList()); - } - - @SuppressWarnings("unchecked") - private SELF self() { - return (SELF) this; - } -} diff --git a/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeValidator.java b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeValidator.java index 1cc338108..a907494c7 100644 --- a/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeValidator.java +++ b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeValidator.java @@ -16,73 +16,36 @@ package io.flamingock.support.change; import io.flamingock.api.RecoveryStrategy; -import io.flamingock.api.annotations.Apply; -import io.flamingock.api.annotations.Change; -import io.flamingock.api.annotations.Recovery; -import io.flamingock.api.annotations.Rollback; -import io.flamingock.api.annotations.TargetSystem; +import org.jetbrains.annotations.NotNull; -import java.util.Arrays; -import java.util.Objects; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Supplier; +import java.util.stream.Collectors; /** - * Fluent assertion utility for validating that a code-based change class is correctly annotated. + * Base class for Flamingock change validators. * - *

Reads metadata from {@code @Change}, {@code @TargetSystem}, {@code @Recovery}, - * {@code @Apply}, and {@code @Rollback} via reflection and asserts that the values match - * expectations. All assertions are soft: they are queued on each chained call and executed - * together by {@link #validate()}, which collects every failure into a single - * {@link AssertionError}.

+ *

Provides the soft-assertion engine: assertions are queued via + * {@link #addAssertion(Supplier)} and executed together by {@link #validate()}, which + * collects all failures and throws a single {@link AssertionError} listing every problem.

* - *

Implicit validation at construction

- *

{@link #of(Class)} checks eagerly that: - *

    - *
  • The class is annotated with {@code @Change}
  • - *
  • The class declares at least one method annotated with {@code @Apply}
  • - *
+ *

Shared assertions that apply to both code-based and template-based changes + * ({@code withId}, {@code withAuthor}, {@code withOrder}, {@code withTargetSystem}, + * {@code withRecovery}, {@code isTransactional}, {@code isNotTransactional}) are declared + * here so that concrete subclasses inherit them without duplication.

* - *

Usage example

- *
{@code
- * ChangeValidator.of(_0002__FeedClients.class)
- *     .withId("feed-clients")
- *     .withAuthor("john.doe")
- *     .withOrder("0002")
- *     .isTransactional()
- *     .withTargetSystem("mongodb")
- *     .hasRollbackMethod()
- *     .validate();
- * }
+ *

Subclasses must implement the metadata accessors ({@link #getId()}, + * {@link #getAuthor()}, etc.) to supply the values that each assertion checks.

* - * @see AbstractChangeValidator - * @see io.flamingock.api.annotations.Change - * @see io.flamingock.api.annotations.Apply - * @see io.flamingock.api.annotations.Rollback + *

Subclasses should return {@code this} from their own assertion methods to allow + * fluent chaining.

+ * + * @param the concrete subclass type, used to preserve the fluent return type + * @see CodeBasedChangeValidator */ -public final class ChangeValidator extends AbstractChangeValidator { - - private final Class changeClass; - private final Change changeAnnotation; - - private ChangeValidator(Class changeClass) { - super( - Objects.requireNonNull(changeClass, "changeClass must not be null").getSimpleName(), - ChangeNamingConvention.extractOrder(changeClass.getSimpleName()) - ); - this.changeClass = changeClass; - - this.changeAnnotation = changeClass.getAnnotation(Change.class); - if (changeAnnotation == null) { - throw new IllegalArgumentException( - String.format("Class [%s] must be annotated with @Change", changeClass.getName())); - } - - boolean hasApplyMethod = Arrays.stream(changeClass.getDeclaredMethods()) - .anyMatch(m -> m.isAnnotationPresent(Apply.class)); - if (!hasApplyMethod) { - throw new IllegalArgumentException( - String.format("Class [%s] must declare a method annotated with @Apply", changeClass.getName())); - } - } +public abstract class ChangeValidator> { /** * Creates a {@code ChangeValidator} for the given change class. @@ -95,64 +58,204 @@ private ChangeValidator(Class changeClass) { * @throws NullPointerException if {@code changeClass} is {@code null} * @throws IllegalArgumentException if {@code @Change} or {@code @Apply} is absent */ - public static ChangeValidator of(Class changeClass) { - return new ChangeValidator(changeClass); + public static CodeBasedChangeValidator of(Class changeClass) { + return new CodeBasedChangeValidator(changeClass); } - @Override - protected String getId() { - return changeAnnotation.id(); - } + /** Display name used in error messages (class simple name or file name). */ + protected final String displayName; + + /** + * Order extracted from the name at construction time via {@link ChangeNamingConvention}. + * {@code null} when the name does not follow the {@code _ORDER__Name} convention. + */ + protected final String extractedOrder; + + private final List> assertions = new ArrayList<>(); - @Override - protected String getAuthor() { - return changeAnnotation.author(); + protected ChangeValidator(String displayName, String extractedOrder) { + this.displayName = displayName; + this.extractedOrder = extractedOrder; } - @Override - protected boolean isTransactionalValue() { - return changeAnnotation.transactional(); + protected abstract String getId(); + + protected abstract String getAuthor(); + + protected abstract boolean isTransactionalValue(); + + protected abstract String getTargetSystemId(); + + protected abstract RecoveryStrategy getRecovery(); + + /** + * Asserts that the change id matches the expected value. + * + * @param expected the expected id + * @return this validator for chaining + */ + public SELF withId(String expected) { + addAssertion(() -> { + String actual = getId(); + return actual.equals(expected) + ? ChangeValidatorResult.OK() + : ChangeValidatorResult.error(String.format("withId: expected \"%s\" but was \"%s\"", expected, actual)); + }); + return self(); } - @Override - protected String getTargetSystemId() { - TargetSystem ts = changeClass.getAnnotation(TargetSystem.class); - return ts != null ? ts.id() : null; + /** + * Asserts that the change author matches the expected value. + * + * @param expected the expected author + * @return this validator for chaining + */ + public SELF withAuthor(String expected) { + addAssertion(() -> { + String actual = getAuthor(); + return actual.equals(expected) + ? ChangeValidatorResult.OK() + : ChangeValidatorResult.error(String.format("withAuthor: expected \"%s\" but was \"%s\"", expected, actual)); + }); + return self(); } - @Override - protected RecoveryStrategy getRecovery() { - if (changeClass.isAnnotationPresent(Recovery.class)) { - Recovery rec = changeClass.getAnnotation(Recovery.class); - return rec != null ? rec.strategy() : null; - } else { - try { - return (RecoveryStrategy) Recovery.class.getMethod("strategy").getDefaultValue(); - } catch (NoSuchMethodException e) { - throw new RuntimeException(e); + /** + * Asserts that the order extracted from the name matches the expected value. + * + *

Order is derived from the naming convention {@code _ORDER__DescriptiveName}. + * For code-based changes the class simple name is used; for template-based changes + * the file name (without extension) is used.

+ * + * @param expected the exact expected order string (e.g. {@code "0002"}, {@code "20250101_01"}) + * @return this validator for chaining + */ + public SELF withOrder(String expected) { + addAssertion(() -> { + if (extractedOrder == null) { + return ChangeValidatorResult.error(String.format( + "withOrder: could not extract order from \"%s\". " + + "Name must follow the _ORDER__Name convention (e.g. _0001__MyChange).", + displayName)); } - } + return extractedOrder.equals(expected) + ? ChangeValidatorResult.OK() + : ChangeValidatorResult.error(String.format( + "withOrder: expected \"%s\" but extracted order was \"%s\"", + expected, extractedOrder)); + }); + return self(); + } + /** + * Asserts that a target system is declared with the given id. + * + * @param expectedId the expected target system id + * @return this validator for chaining + */ + public SELF withTargetSystem(String expectedId) { + addAssertion(() -> { + String actual = getTargetSystemId(); + if (actual == null) { + return ChangeValidatorResult.error(String.format( + "withTargetSystem: expected target system \"%s\" but none is declared", + expectedId)); + } + return actual.equals(expectedId) + ? ChangeValidatorResult.OK() + : ChangeValidatorResult.error(String.format( + "withTargetSystem: expected \"%s\" but was \"%s\"", expectedId, actual)); + }); + return self(); } /** - * Asserts that the class declares a method annotated with {@code @Rollback}. + * Asserts that the recovery strategy matches the expected value. * - *

Only methods directly declared in the class are considered; inherited methods - * are not scanned.

+ *

When no recovery is explicitly declared, {@link RecoveryStrategy#MANUAL_INTERVENTION} + * is assumed, consistent with the Flamingock runtime default.

* + * @param expected the expected {@link RecoveryStrategy} * @return this validator for chaining */ - public ChangeValidator hasRollbackMethod() { + public SELF withRecovery(RecoveryStrategy expected) { addAssertion(() -> { - boolean found = Arrays.stream(changeClass.getDeclaredMethods()) - .anyMatch(m -> m.isAnnotationPresent(Rollback.class)); - return found + RecoveryStrategy actual = getRecovery(); + String actualName = actual != null ? actual.name() : null; + return actual == expected ? ChangeValidatorResult.OK() : ChangeValidatorResult.error(String.format( - "hasRollbackMethod: no method annotated with @Rollback found in %s", - changeClass.getSimpleName())); + "withRecovery: expected %s but was %s", expected.name(), actualName)); }); - return this; + return self(); + } + + /** + * Asserts that the change is transactional. + * + * @return this validator for chaining + */ + public SELF isTransactional() { + addAssertion(() -> isTransactionalValue() + ? ChangeValidatorResult.OK() + : ChangeValidatorResult.error("isTransactional: expected transactional=true but was false")); + return self(); + } + + /** + * Asserts that the change is not transactional. + * + * @return this validator for chaining + */ + public SELF isNotTransactional() { + addAssertion(() -> !isTransactionalValue() + ? ChangeValidatorResult.OK() + : ChangeValidatorResult.error("isNotTransactional: expected transactional=false but was true")); + return self(); + } + + + /** + * Queues an assertion to be evaluated when {@link #validate()} is called. + * + * @param assertion a supplier that returns an error message if the assertion fails, + * or {@link Optional#empty()} if it passes + */ + protected final void addAssertion(Supplier assertion) { + assertions.add(assertion); + } + + /** + * Runs all queued assertions and throws an {@link AssertionError} if any fail. + * + *

All assertions are always evaluated; failures are collected and reported together + * so every problem is visible in a single test run.

+ * + * @throws AssertionError if one or more assertions failed, listing all failure messages + */ + public final void validate() { + List errors = getErrors().stream() + .map(ChangeValidatorResult.Error::getMessage) + .collect(Collectors.toList()); + + if (!errors.isEmpty()) { + throw new AssertionError( + getClass().getSimpleName() + " failed for " + displayName + ":\n - " + + String.join("\n - ", errors)); + } + } + + @NotNull + private List getErrors() { + return assertions.stream() + .map(Supplier::get) + .filter(ChangeValidatorResult::isError) + .map(ChangeValidatorResult.Error.class::cast) + .collect(Collectors.toList()); + } + + @SuppressWarnings("unchecked") + private SELF self() { + return (SELF) this; } } diff --git a/core/flamingock-test-support/src/main/java/io/flamingock/support/change/CodeBasedChangeValidator.java b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/CodeBasedChangeValidator.java new file mode 100644 index 000000000..45c72d746 --- /dev/null +++ b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/CodeBasedChangeValidator.java @@ -0,0 +1,143 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.support.change; + +import io.flamingock.api.RecoveryStrategy; +import io.flamingock.api.annotations.Apply; +import io.flamingock.api.annotations.Change; +import io.flamingock.api.annotations.Recovery; +import io.flamingock.api.annotations.Rollback; +import io.flamingock.api.annotations.TargetSystem; + +import java.util.Arrays; +import java.util.Objects; + +/** + * Fluent assertion utility for validating that a code-based change class is correctly annotated. + * + *

Reads metadata from {@code @Change}, {@code @TargetSystem}, {@code @Recovery}, + * {@code @Apply}, and {@code @Rollback} via reflection and asserts that the values match + * expectations. All assertions are soft: they are queued on each chained call and executed + * together by {@link #validate()}, which collects every failure into a single + * {@link AssertionError}.

+ * + *

Implicit validation at construction

+ *

{@link #of(Class)} checks eagerly that: + *

    + *
  • The class is annotated with {@code @Change}
  • + *
  • The class declares at least one method annotated with {@code @Apply}
  • + *
+ * + *

Usage example

+ *
{@code
+ * ChangeValidator.of(_0002__FeedClients.class)
+ *     .withId("feed-clients")
+ *     .withAuthor("john.doe")
+ *     .withOrder("0002")
+ *     .isTransactional()
+ *     .withTargetSystem("mongodb")
+ *     .hasRollbackMethod()
+ *     .validate();
+ * }
+ * + * @see ChangeValidator + * @see io.flamingock.api.annotations.Change + * @see io.flamingock.api.annotations.Apply + * @see io.flamingock.api.annotations.Rollback + */ +public final class CodeBasedChangeValidator extends ChangeValidator { + + private final Class changeClass; + private final Change changeAnnotation; + + CodeBasedChangeValidator(Class changeClass) { + super( + Objects.requireNonNull(changeClass, "changeClass must not be null").getSimpleName(), + ChangeNamingConvention.extractOrder(changeClass.getSimpleName()) + ); + this.changeClass = changeClass; + + this.changeAnnotation = changeClass.getAnnotation(Change.class); + if (changeAnnotation == null) { + throw new IllegalArgumentException( + String.format("Class [%s] must be annotated with @Change", changeClass.getName())); + } + + boolean hasApplyMethod = Arrays.stream(changeClass.getDeclaredMethods()) + .anyMatch(m -> m.isAnnotationPresent(Apply.class)); + if (!hasApplyMethod) { + throw new IllegalArgumentException( + String.format("Class [%s] must declare a method annotated with @Apply", changeClass.getName())); + } + } + + @Override + protected String getId() { + return changeAnnotation.id(); + } + + @Override + protected String getAuthor() { + return changeAnnotation.author(); + } + + @Override + protected boolean isTransactionalValue() { + return changeAnnotation.transactional(); + } + + @Override + protected String getTargetSystemId() { + TargetSystem ts = changeClass.getAnnotation(TargetSystem.class); + return ts != null ? ts.id() : null; + } + + @Override + protected RecoveryStrategy getRecovery() { + if (changeClass.isAnnotationPresent(Recovery.class)) { + Recovery rec = changeClass.getAnnotation(Recovery.class); + return rec != null ? rec.strategy() : null; + } else { + try { + return (RecoveryStrategy) Recovery.class.getMethod("strategy").getDefaultValue(); + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + + } + + /** + * Asserts that the class declares a method annotated with {@code @Rollback}. + * + *

Only methods directly declared in the class are considered; inherited methods + * are not scanned.

+ * + * @return this validator for chaining + */ + public CodeBasedChangeValidator hasRollbackMethod() { + addAssertion(() -> { + boolean found = Arrays.stream(changeClass.getDeclaredMethods()) + .anyMatch(m -> m.isAnnotationPresent(Rollback.class)); + return found + ? ChangeValidatorResult.OK() + : ChangeValidatorResult.error(String.format( + "hasRollbackMethod: no method annotated with @Rollback found in %s", + changeClass.getSimpleName())); + }); + return this; + } +} diff --git a/core/flamingock-test-support/src/test/java/io/flamingock/support/change/ChangeValidatorTest.java b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/ChangeValidatorTest.java index c15f288ec..efd88f9b9 100644 --- a/core/flamingock-test-support/src/test/java/io/flamingock/support/change/ChangeValidatorTest.java +++ b/core/flamingock-test-support/src/test/java/io/flamingock/support/change/ChangeValidatorTest.java @@ -67,7 +67,7 @@ void shouldConstructSuccessfully() { @Test @DisplayName("Should pass validate() with no assertions added") void shouldPassValidateWithNoAssertions() { - ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class); + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class); assertDoesNotThrow(validator::validate); } @@ -80,7 +80,7 @@ class WithIdTests { @Test @DisplayName("Should pass when id matches") void shouldPassWhenIdMatches() { - ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) .withId("fully-annotated"); assertDoesNotThrow(validator::validate); @@ -89,7 +89,7 @@ void shouldPassWhenIdMatches() { @Test @DisplayName("Should fail when id does not match") void shouldFailWhenIdDoesNotMatch() { - ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) .withId("wrong-id"); AssertionError error = assertThrows(AssertionError.class, validator::validate); @@ -107,7 +107,7 @@ class WithAuthorTests { @Test @DisplayName("Should pass when author matches") void shouldPassWhenAuthorMatches() { - ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) .withAuthor("test-author"); assertDoesNotThrow(validator::validate); @@ -116,7 +116,7 @@ void shouldPassWhenAuthorMatches() { @Test @DisplayName("Should fail when author does not match") void shouldFailWhenAuthorDoesNotMatch() { - ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) .withAuthor("wrong-author"); AssertionError error = assertThrows(AssertionError.class, validator::validate); @@ -134,7 +134,7 @@ class WithOrderTests { @Test @DisplayName("Should pass when order matches the class name prefix") void shouldPassWhenOrderMatches() { - ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) .withOrder("0001"); assertDoesNotThrow(validator::validate); @@ -143,7 +143,7 @@ void shouldPassWhenOrderMatches() { @Test @DisplayName("Should pass for a different valid order prefix") void shouldPassForDifferentValidOrderPrefix() { - ChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) + CodeBasedChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) .withOrder("0002"); assertDoesNotThrow(validator::validate); @@ -152,7 +152,7 @@ void shouldPassForDifferentValidOrderPrefix() { @Test @DisplayName("Should fail when order does not match") void shouldFailWhenOrderDoesNotMatch() { - ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) .withOrder("9999"); AssertionError error = assertThrows(AssertionError.class, validator::validate); @@ -165,7 +165,7 @@ void shouldFailWhenOrderDoesNotMatch() { @Test @DisplayName("Should fail with descriptive message when class name has no order prefix") void shouldFailWithDescriptiveMessageWhenNoOrderPrefix() { - ChangeValidator validator = ChangeValidator.of(NoOrderPrefixChange.class) + CodeBasedChangeValidator validator = ChangeValidator.of(NoOrderPrefixChange.class) .withOrder("0001"); AssertionError error = assertThrows(AssertionError.class, validator::validate); @@ -183,7 +183,7 @@ class IsTransactionalTests { @Test @DisplayName("Should pass when change is transactional via explicit annotation value") void shouldPassWhenTransactional() { - ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) .isTransactional(); assertDoesNotThrow(validator::validate); @@ -192,7 +192,7 @@ void shouldPassWhenTransactional() { @Test @DisplayName("Should pass when change uses default transactional=true") void shouldPassForDefaultTransactional() { - ChangeValidator validator = ChangeValidator.of(_0003__NoTargetSystemChange.class) + CodeBasedChangeValidator validator = ChangeValidator.of(_0003__NoTargetSystemChange.class) .isTransactional(); assertDoesNotThrow(validator::validate); @@ -201,7 +201,7 @@ void shouldPassForDefaultTransactional() { @Test @DisplayName("Should fail when change is not transactional") void shouldFailWhenNotTransactional() { - ChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) + CodeBasedChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) .isTransactional(); AssertionError error = assertThrows(AssertionError.class, validator::validate); @@ -217,7 +217,7 @@ class IsNotTransactionalTests { @Test @DisplayName("Should pass when change is not transactional") void shouldPassWhenNotTransactional() { - ChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) + CodeBasedChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) .isNotTransactional(); assertDoesNotThrow(validator::validate); @@ -226,7 +226,7 @@ void shouldPassWhenNotTransactional() { @Test @DisplayName("Should fail when change is transactional") void shouldFailWhenTransactional() { - ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) .isNotTransactional(); AssertionError error = assertThrows(AssertionError.class, validator::validate); @@ -242,7 +242,7 @@ class WithTargetSystemTests { @Test @DisplayName("Should pass when target system id matches") void shouldPassWhenTargetSystemMatches() { - ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) .withTargetSystem("mongodb"); assertDoesNotThrow(validator::validate); @@ -251,7 +251,7 @@ void shouldPassWhenTargetSystemMatches() { @Test @DisplayName("Should fail when target system id does not match") void shouldFailWhenTargetSystemDoesNotMatch() { - ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) .withTargetSystem("postgresql"); AssertionError error = assertThrows(AssertionError.class, validator::validate); @@ -264,7 +264,7 @@ void shouldFailWhenTargetSystemDoesNotMatch() { @Test @DisplayName("Should fail when @TargetSystem annotation is not present") void shouldFailWhenTargetSystemAnnotationAbsent() { - ChangeValidator validator = ChangeValidator.of(_0003__NoTargetSystemChange.class) + CodeBasedChangeValidator validator = ChangeValidator.of(_0003__NoTargetSystemChange.class) .withTargetSystem("mongodb"); AssertionError error = assertThrows(AssertionError.class, validator::validate); @@ -281,7 +281,7 @@ class WithRecoveryTests { @Test @DisplayName("Should pass for ALWAYS_RETRY when @Recovery is present with ALWAYS_RETRY") void shouldPassForAlwaysRetryWhenAnnotationPresent() { - ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) .withRecovery(RecoveryStrategy.ALWAYS_RETRY); assertDoesNotThrow(validator::validate); @@ -290,7 +290,7 @@ void shouldPassForAlwaysRetryWhenAnnotationPresent() { @Test @DisplayName("Should pass for MANUAL_INTERVENTION when @Recovery annotation is absent") void shouldPassForDefaultWhenAnnotationAbsent() { - ChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) + CodeBasedChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) .withRecovery(RecoveryStrategy.MANUAL_INTERVENTION); assertDoesNotThrow(validator::validate); @@ -299,7 +299,7 @@ void shouldPassForDefaultWhenAnnotationAbsent() { @Test @DisplayName("Should fail when recovery strategy does not match") void shouldFailWhenRecoveryDoesNotMatch() { - ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) .withRecovery(RecoveryStrategy.MANUAL_INTERVENTION); AssertionError error = assertThrows(AssertionError.class, validator::validate); @@ -312,7 +312,7 @@ void shouldFailWhenRecoveryDoesNotMatch() { @Test @DisplayName("Should fail when ALWAYS_RETRY expected but default MANUAL_INTERVENTION is in effect") void shouldFailWhenAlwaysRetryExpectedButDefaultApplies() { - ChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) + CodeBasedChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) .withRecovery(RecoveryStrategy.ALWAYS_RETRY); AssertionError error = assertThrows(AssertionError.class, validator::validate); @@ -330,7 +330,7 @@ class HasRollbackMethodTests { @Test @DisplayName("Should pass when @Rollback method is present") void shouldPassWhenRollbackPresent() { - ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) .hasRollbackMethod(); assertDoesNotThrow(validator::validate); @@ -339,7 +339,7 @@ void shouldPassWhenRollbackPresent() { @Test @DisplayName("Should fail when no @Rollback method is present") void shouldFailWhenRollbackAbsent() { - ChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) + CodeBasedChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) .hasRollbackMethod(); AssertionError error = assertThrows(AssertionError.class, validator::validate); @@ -356,7 +356,7 @@ class AggregatedFailuresTests { @Test @DisplayName("Should report all failures in a single AssertionError") void shouldReportAllFailuresTogether() { - ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) .withId("wrong-id") .withAuthor("wrong-author") .withOrder("9999"); @@ -371,7 +371,7 @@ void shouldReportAllFailuresTogether() { @Test @DisplayName("Should only report failed assertions, not passing ones") void shouldOnlyReportFailedAssertions() { - ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) .withId("fully-annotated") .withAuthor("wrong-author"); @@ -384,7 +384,7 @@ void shouldOnlyReportFailedAssertions() { @Test @DisplayName("Should include the change class simple name in the error header") void shouldIncludeClassNameInErrorHeader() { - ChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) + CodeBasedChangeValidator validator = ChangeValidator.of(_0001__FullyAnnotatedChange.class) .withId("wrong-id"); AssertionError error = assertThrows(AssertionError.class, validator::validate); @@ -395,7 +395,7 @@ void shouldIncludeClassNameInErrorHeader() { @Test @DisplayName("Should combine assertions across all assertion types") void shouldCombineAssertionsAcrossAllTypes() { - ChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) + CodeBasedChangeValidator validator = ChangeValidator.of(_0002__NonTransactionalChange.class) .withId("wrong-id") .isTransactional() .withTargetSystem("wrong-system") From 8bf39ef765f59a835438412668fd7eb5954df4d5 Mon Sep 17 00:00:00 2001 From: Antonio Perez Dieppa Date: Mon, 23 Feb 2026 14:40:57 +0000 Subject: [PATCH 4/4] chore: licence header --- .../support/change/ChangeValidatorResult.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeValidatorResult.java b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeValidatorResult.java index 7f8772cac..b84452d37 100644 --- a/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeValidatorResult.java +++ b/core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeValidatorResult.java @@ -1,3 +1,18 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package io.flamingock.support.change; public abstract class ChangeValidatorResult {