Skip to content

Commit 699a4eb

Browse files
committed
feat: change validator
1 parent a05d92e commit 699a4eb

11 files changed

Lines changed: 1156 additions & 0 deletions
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
/*
2+
* Copyright 2026 Flamingock (https://www.flamingock.io)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.flamingock.support.change;
17+
18+
import io.flamingock.api.RecoveryStrategy;
19+
20+
import java.util.ArrayList;
21+
import java.util.List;
22+
import java.util.Optional;
23+
import java.util.function.Supplier;
24+
import java.util.stream.Collectors;
25+
26+
/**
27+
* Base class for Flamingock change validators.
28+
*
29+
* <p>Provides the soft-assertion engine: assertions are queued via
30+
* {@link #addAssertion(Supplier)} and executed together by {@link #validate()}, which
31+
* collects all failures and throws a single {@link AssertionError} listing every problem.</p>
32+
*
33+
* <p>Shared assertions that apply to both code-based and template-based changes
34+
* ({@code withId}, {@code withAuthor}, {@code withOrder}, {@code withTargetSystem},
35+
* {@code withRecovery}, {@code isTransactional}, {@code isNotTransactional}) are declared
36+
* here so that concrete subclasses inherit them without duplication.</p>
37+
*
38+
* <p>Subclasses must implement the metadata accessors ({@link #getId()},
39+
* {@link #getAuthor()}, etc.) to supply the values that each assertion checks.</p>
40+
*
41+
* <p>Subclasses should return {@code this} from their own assertion methods to allow
42+
* fluent chaining.</p>
43+
*
44+
* @param <SELF> the concrete subclass type, used to preserve the fluent return type
45+
* @see ChangeValidator
46+
*/
47+
public abstract class AbstractChangeValidator<SELF extends AbstractChangeValidator<SELF>> {
48+
49+
/** Display name used in error messages (class simple name or file name). */
50+
protected final String displayName;
51+
52+
/**
53+
* Order extracted from the name at construction time via {@link ChangeNamingConvention}.
54+
* {@code null} when the name does not follow the {@code _ORDER__Name} convention.
55+
*/
56+
protected final String extractedOrder;
57+
58+
private final List<Supplier<Optional<String>>> assertions = new ArrayList<>();
59+
60+
protected AbstractChangeValidator(String displayName, String extractedOrder) {
61+
this.displayName = displayName;
62+
this.extractedOrder = extractedOrder;
63+
}
64+
65+
protected abstract String getId();
66+
67+
protected abstract String getAuthor();
68+
69+
protected abstract boolean isTransactionalValue();
70+
71+
protected abstract String getTargetSystemId();
72+
73+
protected abstract RecoveryStrategy getRecovery();
74+
75+
/**
76+
* Asserts that the change id matches the expected value.
77+
*
78+
* @param expected the expected id
79+
* @return this validator for chaining
80+
*/
81+
public SELF withId(String expected) {
82+
addAssertion(() -> {
83+
String actual = getId();
84+
return actual.equals(expected)
85+
? Optional.empty()
86+
: Optional.of(String.format("withId: expected \"%s\" but was \"%s\"", expected, actual));
87+
});
88+
return self();
89+
}
90+
91+
/**
92+
* Asserts that the change author matches the expected value.
93+
*
94+
* @param expected the expected author
95+
* @return this validator for chaining
96+
*/
97+
public SELF withAuthor(String expected) {
98+
addAssertion(() -> {
99+
String actual = getAuthor();
100+
return actual.equals(expected)
101+
? Optional.empty()
102+
: Optional.of(String.format("withAuthor: expected \"%s\" but was \"%s\"", expected, actual));
103+
});
104+
return self();
105+
}
106+
107+
/**
108+
* Asserts that the order extracted from the name matches the expected value.
109+
*
110+
* <p>Order is derived from the naming convention {@code _ORDER__DescriptiveName}.
111+
* For code-based changes the class simple name is used; for template-based changes
112+
* the file name (without extension) is used.</p>
113+
*
114+
* @param expected the exact expected order string (e.g. {@code "0002"}, {@code "20250101_01"})
115+
* @return this validator for chaining
116+
*/
117+
public SELF withOrder(String expected) {
118+
addAssertion(() -> {
119+
if (extractedOrder == null) {
120+
return Optional.of(String.format(
121+
"withOrder: could not extract order from \"%s\". "
122+
+ "Name must follow the _ORDER__Name convention (e.g. _0001__MyChange).",
123+
displayName));
124+
}
125+
return extractedOrder.equals(expected)
126+
? Optional.empty()
127+
: Optional.of(String.format(
128+
"withOrder: expected \"%s\" but extracted order was \"%s\"",
129+
expected, extractedOrder));
130+
});
131+
return self();
132+
}
133+
134+
/**
135+
* Asserts that a target system is declared with the given id.
136+
*
137+
* @param expectedId the expected target system id
138+
* @return this validator for chaining
139+
*/
140+
public SELF withTargetSystem(String expectedId) {
141+
addAssertion(() -> {
142+
String actual = getTargetSystemId();
143+
if (actual == null) {
144+
return Optional.of(String.format(
145+
"withTargetSystem: expected target system \"%s\" but none is declared",
146+
expectedId));
147+
}
148+
return actual.equals(expectedId)
149+
? Optional.empty()
150+
: Optional.of(String.format(
151+
"withTargetSystem: expected \"%s\" but was \"%s\"", expectedId, actual));
152+
});
153+
return self();
154+
}
155+
156+
/**
157+
* Asserts that the recovery strategy matches the expected value.
158+
*
159+
* <p>When no recovery is explicitly declared, {@link RecoveryStrategy#MANUAL_INTERVENTION}
160+
* is assumed, consistent with the Flamingock runtime default.</p>
161+
*
162+
* @param expected the expected {@link RecoveryStrategy}
163+
* @return this validator for chaining
164+
*/
165+
public SELF withRecovery(RecoveryStrategy expected) {
166+
addAssertion(() -> {
167+
RecoveryStrategy actual = getRecovery();
168+
return actual == expected
169+
? Optional.empty()
170+
: Optional.of(String.format(
171+
"withRecovery: expected %s but was %s", expected.name(), actual.name()));
172+
});
173+
return self();
174+
}
175+
176+
/**
177+
* Asserts that the change is transactional.
178+
*
179+
* @return this validator for chaining
180+
*/
181+
public SELF isTransactional() {
182+
addAssertion(() -> isTransactionalValue()
183+
? Optional.empty()
184+
: Optional.of("isTransactional: expected transactional=true but was false"));
185+
return self();
186+
}
187+
188+
/**
189+
* Asserts that the change is not transactional.
190+
*
191+
* @return this validator for chaining
192+
*/
193+
public SELF isNotTransactional() {
194+
addAssertion(() -> !isTransactionalValue()
195+
? Optional.empty()
196+
: Optional.of("isNotTransactional: expected transactional=false but was true"));
197+
return self();
198+
}
199+
200+
201+
/**
202+
* Queues an assertion to be evaluated when {@link #validate()} is called.
203+
*
204+
* @param assertion a supplier that returns an error message if the assertion fails,
205+
* or {@link Optional#empty()} if it passes
206+
*/
207+
protected final void addAssertion(Supplier<Optional<String>> assertion) {
208+
assertions.add(assertion);
209+
}
210+
211+
/**
212+
* Runs all queued assertions and throws an {@link AssertionError} if any fail.
213+
*
214+
* <p>All assertions are always evaluated; failures are collected and reported together
215+
* so every problem is visible in a single test run.</p>
216+
*
217+
* @throws AssertionError if one or more assertions failed, listing all failure messages
218+
*/
219+
public final void validate() {
220+
List<String> errors = assertions.stream()
221+
.map(Supplier::get)
222+
.filter(Optional::isPresent)
223+
.map(Optional::get)
224+
.collect(Collectors.toList());
225+
226+
if (!errors.isEmpty()) {
227+
throw new AssertionError(
228+
getClass().getSimpleName() + " failed for " + displayName + ":\n - "
229+
+ String.join("\n - ", errors));
230+
}
231+
}
232+
233+
@SuppressWarnings("unchecked")
234+
private SELF self() {
235+
return (SELF) this;
236+
}
237+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Copyright 2026 Flamingock (https://www.flamingock.io)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.flamingock.support.change;
17+
18+
/**
19+
* Shared utility for parsing the Flamingock naming convention that encodes execution order.
20+
*
21+
* <p>Both code-based changes (class names) and template-based changes (file names) follow
22+
* the same pattern:</p>
23+
* <pre>
24+
* _ORDER__DescriptiveName
25+
* </pre>
26+
* <p>Examples:</p>
27+
* <ul>
28+
* <li>{@code _0002__FeedClients} → order {@code "0002"}</li>
29+
* <li>{@code _20250101_01__InitSchema} → order {@code "20250101_01"}</li>
30+
* <li>{@code _V1_2_3__LegacyMigration} → order {@code "V1_2_3"}</li>
31+
* </ul>
32+
*
33+
* <p>This class is package-private and intended for use by validators in this package only.</p>
34+
*/
35+
final class ChangeNamingConvention {
36+
37+
private static final String ORDER_PREFIX = "_";
38+
private static final String ORDER_SEPARATOR = "__";
39+
40+
private ChangeNamingConvention() {
41+
}
42+
43+
/**
44+
* Extracts the order segment from a name (class simple name or file name without extension)
45+
* following the {@code _ORDER__DescriptiveName} convention.
46+
*
47+
* @param name the name to parse (class simple name or file name without extension)
48+
* @return the extracted order string, or {@code null} if the name does not follow the convention
49+
*/
50+
static String extractOrder(String name) {
51+
if (name == null || !name.startsWith(ORDER_PREFIX)) {
52+
return null;
53+
}
54+
int separatorIndex = name.indexOf(ORDER_SEPARATOR);
55+
if (separatorIndex <= 1) {
56+
return null;
57+
}
58+
return name.substring(1, separatorIndex);
59+
}
60+
}

0 commit comments

Comments
 (0)