Skip to content

Commit b64f75b

Browse files
authored
Merge branch 'master' into feature/template_transaction_inference
2 parents 8bc767b + 9a6efb7 commit b64f75b

9 files changed

Lines changed: 843 additions & 1 deletion

File tree

core/flamingock-test-support/src/main/java/io/flamingock/support/change/ChangeValidator.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import io.flamingock.api.RecoveryStrategy;
1919
import org.jetbrains.annotations.NotNull;
2020

21+
import java.nio.file.Path;
2122
import java.util.ArrayList;
2223
import java.util.List;
2324
import java.util.Optional;
@@ -62,6 +63,21 @@ public static CodeBasedChangeValidator of(Class<?> changeClass) {
6263
return new CodeBasedChangeValidator(changeClass);
6364
}
6465

66+
/**
67+
* Creates a {@code ChangeValidator} for the given template-based change YAML file.
68+
*
69+
* <p>Validates eagerly that the file exists, the {@code id} and {@code template} fields are
70+
* present and non-empty, and that either an {@code apply} field or a {@code steps} list is
71+
* present.</p>
72+
*
73+
* @param yamlPath path to the YAML change file; must not be {@code null}
74+
* @return a new validator ready for assertion chaining
75+
* @throws IllegalArgumentException if the file does not exist or required fields are missing
76+
*/
77+
public static TemplateBasedChangeValidator of(Path yamlPath) {
78+
return new TemplateBasedChangeValidator(yamlPath);
79+
}
80+
6581
/** Display name used in error messages (class simple name or file name). */
6682
protected final String displayName;
6783

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
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+
import io.flamingock.internal.common.core.task.RecoveryDescriptor;
20+
import io.flamingock.internal.common.core.template.ChangeTemplateFileContent;
21+
import io.flamingock.internal.util.FileUtil;
22+
23+
import java.io.File;
24+
import java.nio.file.Path;
25+
import java.util.List;
26+
import java.util.Map;
27+
28+
/**
29+
* Fluent assertion utility for validating that a template-based change YAML file is correctly
30+
* structured.
31+
*
32+
* <p>Parses the YAML file using the same {@link ChangeTemplateFileContent} model that the
33+
* Flamingock runtime uses, then exposes assertions for the fields that are meaningful for
34+
* template changes: id, author, template name, transactionality, target system, recovery
35+
* strategy, step count, and rollback presence.</p>
36+
*
37+
* <p>All assertions use a <strong>soft-assertion pattern</strong>: each chained call queues an
38+
* assertion, and {@link #validate()} executes them all together, collecting every failure into a
39+
* single {@link AssertionError}. This means you see all problems at once rather than stopping at
40+
* the first mismatch.</p>
41+
*
42+
* <h2>Implicit validation at construction</h2>
43+
* <p>{@link ChangeValidator#of(Path)} checks eagerly that:
44+
* <ul>
45+
* <li>The file exists</li>
46+
* <li>The {@code id} field is present and non-empty</li>
47+
* <li>The {@code template} field is present and non-empty</li>
48+
* <li>Either an {@code apply} field or a {@code steps} list is present</li>
49+
* </ul>
50+
*
51+
* <h2>Usage example — simple template</h2>
52+
* <pre>{@code
53+
* ChangeValidator.of(Paths.get("src/test/java/.../changes/_0001__create_users_collection.yaml"))
54+
* .withId("create-users-collection")
55+
* .withOrder("0001")
56+
* .withTemplateName("MongoChangeTemplate")
57+
* .isNotTransactional()
58+
* .hasRollback()
59+
* .validate();
60+
* }</pre>
61+
*
62+
* <h2>Usage example — multi-step template</h2>
63+
* <pre>{@code
64+
* ChangeValidator.of(Paths.get("src/test/java/.../changes/_0005__step_based_change.yaml"))
65+
* .withId("step-based-change")
66+
* .withOrder("0005")
67+
* .withStepCount(3)
68+
* .hasRollbackForStep(0)
69+
* .validate();
70+
* }</pre>
71+
*
72+
* @see ChangeValidator
73+
*/
74+
public final class TemplateBasedChangeValidator extends ChangeValidator<TemplateBasedChangeValidator> {
75+
76+
private final ChangeTemplateFileContent content;
77+
78+
TemplateBasedChangeValidator(Path yamlPath) {
79+
super(
80+
nameWithoutExtension(yamlPath),
81+
ChangeNamingConvention.extractOrder(nameWithoutExtension(yamlPath))
82+
);
83+
File file = yamlPath.toFile();
84+
if (!file.exists()) {
85+
throw new IllegalArgumentException(
86+
String.format("YAML file does not exist: %s", yamlPath.toAbsolutePath()));
87+
}
88+
this.content = FileUtil.getFromYamlFile(file, ChangeTemplateFileContent.class);
89+
90+
if (content.getId() == null || content.getId().isEmpty()) {
91+
throw new IllegalArgumentException(
92+
String.format("YAML file [%s] must have a non-empty 'id' field", displayName));
93+
}
94+
if (content.getTemplate() == null || content.getTemplate().isEmpty()) {
95+
throw new IllegalArgumentException(
96+
String.format("YAML file [%s] must have a non-empty 'template' field", displayName));
97+
}
98+
if (content.getApply() == null && !(content.getSteps() instanceof List)) {
99+
throw new IllegalArgumentException(
100+
String.format("YAML file [%s] must have either an 'apply' field or a 'steps' list", displayName));
101+
}
102+
}
103+
104+
private static String nameWithoutExtension(Path yamlPath) {
105+
String fileName = yamlPath.getFileName().toString();
106+
int dotIndex = fileName.lastIndexOf('.');
107+
return dotIndex > 0 ? fileName.substring(0, dotIndex) : fileName;
108+
}
109+
110+
@Override
111+
protected String getId() {
112+
return content.getId();
113+
}
114+
115+
@Override
116+
protected String getAuthor() {
117+
return content.getAuthor();
118+
}
119+
120+
/**
121+
* Asserts that the author field in the YAML matches the expected value.
122+
*
123+
* <p>Overrides the base implementation to handle the case where no {@code author} field is
124+
* present in the YAML (in which case {@link #getAuthor()} returns {@code null}). Calling this
125+
* method with a non-null {@code expected} when the YAML has no author reports a clear failure
126+
* message rather than throwing a {@link NullPointerException}.</p>
127+
*
128+
* @param expected the expected author string, or {@code null} to assert no author is set
129+
* @return this validator for chaining
130+
*/
131+
@Override
132+
public TemplateBasedChangeValidator withAuthor(String expected) {
133+
addAssertion(() -> {
134+
String actual = getAuthor();
135+
if (expected == null && actual == null) {
136+
return ChangeValidatorResult.OK();
137+
}
138+
if (actual == null) {
139+
return ChangeValidatorResult.error(String.format(
140+
"withAuthor: expected \"%s\" but no 'author' field is set in the YAML", expected));
141+
}
142+
return actual.equals(expected)
143+
? ChangeValidatorResult.OK()
144+
: ChangeValidatorResult.error(String.format(
145+
"withAuthor: expected \"%s\" but was \"%s\"", expected, actual));
146+
});
147+
return this;
148+
}
149+
150+
@Override
151+
protected boolean isTransactionalValue() {
152+
return content.getTransactional() == null || content.getTransactional();
153+
}
154+
155+
@Override
156+
protected String getTargetSystemId() {
157+
return content.getTargetSystem() != null ? content.getTargetSystem().getId() : null;
158+
}
159+
160+
@Override
161+
protected RecoveryStrategy getRecovery() {
162+
RecoveryDescriptor recovery = content.getRecovery();
163+
return recovery != null ? recovery.getStrategy() : RecoveryStrategy.MANUAL_INTERVENTION;
164+
}
165+
166+
private boolean isMultiStep() {
167+
return content.getSteps() instanceof List;
168+
}
169+
170+
/**
171+
* Asserts that the {@code template} field in the YAML matches the expected template name.
172+
*
173+
* @param expected the expected template simple name (e.g. {@code "MongoChangeTemplate"})
174+
* @return this validator for chaining
175+
*/
176+
public TemplateBasedChangeValidator withTemplateName(String expected) {
177+
addAssertion(() -> {
178+
String actual = content.getTemplate();
179+
return actual.equals(expected)
180+
? ChangeValidatorResult.OK()
181+
: ChangeValidatorResult.error(String.format(
182+
"withTemplateName: expected \"%s\" but was \"%s\"", expected, actual));
183+
});
184+
return this;
185+
}
186+
187+
/**
188+
* Asserts that the template has the given number of steps.
189+
*
190+
* <p>Reports a descriptive error if this is a simple (non-multi-step) template, i.e. the
191+
* YAML has an {@code apply} field rather than a {@code steps} list.</p>
192+
*
193+
* @param expected the expected step count
194+
* @return this validator for chaining
195+
*/
196+
public TemplateBasedChangeValidator withStepCount(int expected) {
197+
addAssertion(() -> {
198+
if (!isMultiStep()) {
199+
return ChangeValidatorResult.error(
200+
"withStepCount: this is a simple template (no 'steps' list found); "
201+
+ "withStepCount is only applicable to multi-step templates");
202+
}
203+
List<?> steps = (List<?>) content.getSteps();
204+
int actual = steps.size();
205+
return actual == expected
206+
? ChangeValidatorResult.OK()
207+
: ChangeValidatorResult.error(String.format(
208+
"withStepCount: expected %d steps but found %d", expected, actual));
209+
});
210+
return this;
211+
}
212+
213+
/**
214+
* Asserts that a rollback is defined for the change.
215+
*
216+
* <ul>
217+
* <li>For <strong>simple templates</strong>: the top-level {@code rollback} field must be
218+
* non-null.</li>
219+
* <li>For <strong>multi-step templates</strong>: every step must contain a {@code rollback}
220+
* field. If any step is missing a rollback the assertion reports which step index is at
221+
* fault.</li>
222+
* </ul>
223+
*
224+
* @return this validator for chaining
225+
*/
226+
public TemplateBasedChangeValidator hasRollback() {
227+
addAssertion(() -> {
228+
if (isMultiStep()) {
229+
List<?> steps = (List<?>) content.getSteps();
230+
for (int i = 0; i < steps.size(); i++) {
231+
Object step = steps.get(i);
232+
if (!(step instanceof Map) || ((Map<?, ?>) step).get("rollback") == null) {
233+
return ChangeValidatorResult.error(String.format(
234+
"hasRollback: step %d is missing a 'rollback' field", i));
235+
}
236+
}
237+
return ChangeValidatorResult.OK();
238+
} else {
239+
return content.getRollback() != null
240+
? ChangeValidatorResult.OK()
241+
: ChangeValidatorResult.error("hasRollback: no top-level 'rollback' field found");
242+
}
243+
});
244+
return this;
245+
}
246+
247+
/**
248+
* Asserts that the step at the given 0-based index has a {@code rollback} field defined.
249+
*
250+
* <p>Reports a descriptive error if this is a simple (non-multi-step) template, or if the
251+
* index is out of bounds.</p>
252+
*
253+
* @param stepIndex 0-based index of the step to check
254+
* @return this validator for chaining
255+
*/
256+
public TemplateBasedChangeValidator hasRollbackForStep(int stepIndex) {
257+
addAssertion(() -> {
258+
if (!isMultiStep()) {
259+
return ChangeValidatorResult.error(
260+
"hasRollbackForStep: this is a simple template (no 'steps' list found); "
261+
+ "hasRollbackForStep is only applicable to multi-step templates");
262+
}
263+
List<?> steps = (List<?>) content.getSteps();
264+
if (stepIndex < 0 || stepIndex >= steps.size()) {
265+
return ChangeValidatorResult.error(String.format(
266+
"hasRollbackForStep: step index %d is out of bounds (template has %d steps)",
267+
stepIndex, steps.size()));
268+
}
269+
Object step = steps.get(stepIndex);
270+
if (step instanceof Map && ((Map<?, ?>) step).get("rollback") != null) {
271+
return ChangeValidatorResult.OK();
272+
}
273+
return ChangeValidatorResult.error(String.format(
274+
"hasRollbackForStep: step %d is missing a 'rollback' field", stepIndex));
275+
});
276+
return this;
277+
}
278+
}

core/flamingock-test-support/src/test/java/io/flamingock/support/change/ChangeValidatorTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ void shouldThrowWhenNoApplyMethod() {
5555
@Test
5656
@DisplayName("Should throw NullPointerException when changeClass is null")
5757
void shouldThrowWhenChangeClassIsNull() {
58-
assertThrows(NullPointerException.class, () -> ChangeValidator.of(null));
58+
assertThrows(NullPointerException.class, () -> ChangeValidator.of((Class<?>) null));
5959
}
6060

6161
@Test

0 commit comments

Comments
 (0)