diff --git a/CLAUDE.md b/CLAUDE.md index 766fa14f6..593f290f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -396,6 +396,40 @@ When a change fails and cannot be rolled back: **File**: `core/flamingock-core-api/src/main/java/io/flamingock/api/RecoveryStrategy.java` +### Compile-Time Template Validation + +Templates are validated at compile-time to ensure YAML structure matches the template type: + +**SimpleTemplate** (`AbstractSimpleTemplate`): +- MUST have `apply` field +- MAY have `rollback` field +- MUST NOT have `steps` field + +**SteppableTemplate** (`AbstractSteppableTemplate`): +- MUST have `steps` field +- MUST NOT have `apply` or `rollback` fields at root level +- Each step MUST have `apply` field + +**Configuration:** +```java +@EnableFlamingock( + configFile = "pipeline.yaml", + strictTemplateValidation = true // default +) +``` + +| Flag Value | Behavior | +|------------|----------| +| `true` (default) | Compilation fails with detailed error | +| `false` | Warning logged, compilation continues | + +**Validation Location:** `TemplateValidator` in `core/flamingock-core-commons/.../template/` + +**Key Files:** +- `io.flamingock.internal.common.core.template.TemplateValidator` - validation logic +- `io.flamingock.api.annotations.EnableFlamingock` - strictTemplateValidation flag +- `io.flamingock.api.template.AbstractChangeTemplate` - template base classes + ### Dependency Injection in Templates Template methods (`@Apply`, `@Rollback`) receive dependencies as **method parameters**, not constructor injection: diff --git a/core/flamingock-core-api/src/main/java/io/flamingock/api/annotations/EnableFlamingock.java b/core/flamingock-core-api/src/main/java/io/flamingock/api/annotations/EnableFlamingock.java index 2429835c1..3c0b54ae0 100644 --- a/core/flamingock-core-api/src/main/java/io/flamingock/api/annotations/EnableFlamingock.java +++ b/core/flamingock-core-api/src/main/java/io/flamingock/api/annotations/EnableFlamingock.java @@ -127,4 +127,27 @@ * When false, only a warning is emitted. */ boolean strictStageMapping() default true; + + /** + * If true, the annotation processor validates that all template-based changes + * have YAML structure matching their template type (Simple vs Steppable). + *

+ * SimpleTemplate validation: + *

+ *

+ * SteppableTemplate validation: + *

+ *

+ * When validation fails and this flag is {@code true} (default), a RuntimeException + * is thrown at compilation time. When {@code false}, only a warning is emitted. + */ + boolean strictTemplateValidation() default true; } diff --git a/core/flamingock-core-api/src/main/java/io/flamingock/api/template/AbstractChangeTemplate.java b/core/flamingock-core-api/src/main/java/io/flamingock/api/template/AbstractChangeTemplate.java index 708223a70..a148d3a4e 100644 --- a/core/flamingock-core-api/src/main/java/io/flamingock/api/template/AbstractChangeTemplate.java +++ b/core/flamingock-core-api/src/main/java/io/flamingock/api/template/AbstractChangeTemplate.java @@ -44,12 +44,13 @@ public abstract class AbstractChangeTemplate> reflectiveClasses; + private final Set> additionalReflectiveClasses; @SuppressWarnings("unchecked") public AbstractChangeTemplate(Class... additionalReflectiveClass) { - reflectiveClasses = new HashSet<>(Arrays.asList(additionalReflectiveClass)); + // Store additional classes - reflective classes set is built on-demand in getReflectiveClasses() + this.additionalReflectiveClasses = new HashSet<>(Arrays.asList(additionalReflectiveClass)); try { Class[] typeArgs = ReflectionUtil.resolveTypeArgumentsAsClasses(this.getClass(), AbstractChangeTemplate.class); @@ -61,11 +62,6 @@ public AbstractChangeTemplate(Class... additionalReflectiveClass) { this.configurationClass = (Class) typeArgs[0]; this.applyPayloadClass = (Class) typeArgs[1]; this.rollbackPayloadClass = (Class) typeArgs[2]; - - reflectiveClasses.add(configurationClass); - reflectiveClasses.add(applyPayloadClass); - reflectiveClasses.add(rollbackPayloadClass); - reflectiveClasses.add(TemplateStep.class); } catch (ClassCastException e) { throw new IllegalStateException("Generic type arguments for a Template must be concrete types (classes, interfaces, or primitive wrappers like String, Integer, etc.): " + e.getMessage(), e); } catch (Exception e) { @@ -73,8 +69,30 @@ public AbstractChangeTemplate(Class... additionalReflectiveClass) { } } + /** + * Returns the collection of classes that need reflection registration for GraalVM native images. + *

+ * This method builds the reflective classes set on-demand, including: + *

+ *

+ * This method is only called by GraalVM's {@code RegistrationFeature} at build-time, + * so there is no performance concern from building the set on each call. + * + * @return collection of classes requiring reflection registration + */ @Override public final Collection> getReflectiveClasses() { + Set> reflectiveClasses = new HashSet<>(additionalReflectiveClasses); + reflectiveClasses.add(configurationClass); + reflectiveClasses.add(applyPayloadClass); + reflectiveClasses.add(rollbackPayloadClass); + reflectiveClasses.add(TemplateStep.class); return reflectiveClasses; } diff --git a/core/flamingock-core-api/src/test/java/io/flamingock/api/template/AbstractChangeTemplateReflectiveClassesTest.java b/core/flamingock-core-api/src/test/java/io/flamingock/api/template/AbstractChangeTemplateReflectiveClassesTest.java new file mode 100644 index 000000000..5b38e9564 --- /dev/null +++ b/core/flamingock-core-api/src/test/java/io/flamingock/api/template/AbstractChangeTemplateReflectiveClassesTest.java @@ -0,0 +1,202 @@ +/* + * 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.api.template; + +import io.flamingock.api.annotations.Apply; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Collection; + +import static org.junit.jupiter.api.Assertions.*; + +class AbstractChangeTemplateReflectiveClassesTest { + + // Simple test configuration class + public static class TestConfig { + public String configValue; + } + + // Simple test apply payload class + public static class TestApplyPayload { + public String applyData; + } + + // Simple test rollback payload class + public static class TestRollbackPayload { + public String rollbackData; + } + + // Additional class for reflection + public static class AdditionalClass { + public String additionalData; + } + + // Another additional class for reflection + public static class AnotherAdditionalClass { + public String moreData; + } + + // Test template with custom generic types + public static class TestTemplateWithCustomTypes + extends AbstractSimpleTemplate { + + public TestTemplateWithCustomTypes() { + super(); + } + + @Apply + public void apply() { + // Test implementation + } + } + + // Test template with additional reflective classes + public static class TestTemplateWithAdditionalClasses + extends AbstractSimpleTemplate { + + public TestTemplateWithAdditionalClasses() { + super(AdditionalClass.class, AnotherAdditionalClass.class); + } + + @Apply + public void apply() { + // Test implementation + } + } + + // Test template with Void configuration + public static class TestTemplateWithVoidConfig + extends AbstractSimpleTemplate { + + public TestTemplateWithVoidConfig() { + super(); + } + + @Apply + public void apply() { + // Test implementation + } + } + + @Test + @DisplayName("getReflectiveClasses should return set containing configuration class") + void getReflectiveClassesShouldContainConfigurationClass() { + TestTemplateWithCustomTypes template = new TestTemplateWithCustomTypes(); + + Collection> reflectiveClasses = template.getReflectiveClasses(); + + assertTrue(reflectiveClasses.contains(TestConfig.class), + "Should contain configuration class TestConfig"); + } + + @Test + @DisplayName("getReflectiveClasses should return set containing apply payload class") + void getReflectiveClassesShouldContainApplyPayloadClass() { + TestTemplateWithCustomTypes template = new TestTemplateWithCustomTypes(); + + Collection> reflectiveClasses = template.getReflectiveClasses(); + + assertTrue(reflectiveClasses.contains(TestApplyPayload.class), + "Should contain apply payload class TestApplyPayload"); + } + + @Test + @DisplayName("getReflectiveClasses should return set containing rollback payload class") + void getReflectiveClassesShouldContainRollbackPayloadClass() { + TestTemplateWithCustomTypes template = new TestTemplateWithCustomTypes(); + + Collection> reflectiveClasses = template.getReflectiveClasses(); + + assertTrue(reflectiveClasses.contains(TestRollbackPayload.class), + "Should contain rollback payload class TestRollbackPayload"); + } + + @Test + @DisplayName("getReflectiveClasses should return set containing TemplateStep class") + void getReflectiveClassesShouldContainTemplateStepClass() { + TestTemplateWithCustomTypes template = new TestTemplateWithCustomTypes(); + + Collection> reflectiveClasses = template.getReflectiveClasses(); + + assertTrue(reflectiveClasses.contains(TemplateStep.class), + "Should contain TemplateStep class"); + } + + @Test + @DisplayName("getReflectiveClasses should include additional reflective classes passed to constructor") + void getReflectiveClassesShouldIncludeAdditionalClasses() { + TestTemplateWithAdditionalClasses template = new TestTemplateWithAdditionalClasses(); + + Collection> reflectiveClasses = template.getReflectiveClasses(); + + assertTrue(reflectiveClasses.contains(AdditionalClass.class), + "Should contain AdditionalClass"); + assertTrue(reflectiveClasses.contains(AnotherAdditionalClass.class), + "Should contain AnotherAdditionalClass"); + } + + @Test + @DisplayName("Multiple calls to getReflectiveClasses should return equivalent sets") + void multipleCallsShouldReturnEquivalentSets() { + TestTemplateWithCustomTypes template = new TestTemplateWithCustomTypes(); + + Collection> firstCall = template.getReflectiveClasses(); + Collection> secondCall = template.getReflectiveClasses(); + + assertEquals(firstCall.size(), secondCall.size(), + "Both calls should return sets of the same size"); + assertTrue(firstCall.containsAll(secondCall), + "First call should contain all elements of second call"); + assertTrue(secondCall.containsAll(firstCall), + "Second call should contain all elements of first call"); + } + + @Test + @DisplayName("getReflectiveClasses with Void configuration should include Void class") + void getReflectiveClassesWithVoidConfigShouldIncludeVoidClass() { + TestTemplateWithVoidConfig template = new TestTemplateWithVoidConfig(); + + Collection> reflectiveClasses = template.getReflectiveClasses(); + + assertTrue(reflectiveClasses.contains(Void.class), + "Should contain Void class for configuration"); + assertTrue(reflectiveClasses.contains(String.class), + "Should contain String class for apply/rollback payloads"); + } + + @Test + @DisplayName("getReflectiveClasses should return at least 4 classes (config, apply, rollback, TemplateStep)") + void getReflectiveClassesShouldReturnAtLeast4Classes() { + TestTemplateWithCustomTypes template = new TestTemplateWithCustomTypes(); + + Collection> reflectiveClasses = template.getReflectiveClasses(); + + assertTrue(reflectiveClasses.size() >= 4, + "Should return at least 4 classes (config, apply, rollback, TemplateStep)"); + } + + @Test + @DisplayName("getReflectiveClasses with additional classes should return more than 4 classes") + void getReflectiveClassesWithAdditionalClassesShouldReturnMoreThan4() { + TestTemplateWithAdditionalClasses template = new TestTemplateWithAdditionalClasses(); + + Collection> reflectiveClasses = template.getReflectiveClasses(); + + assertTrue(reflectiveClasses.size() >= 6, + "Should return at least 6 classes (config, apply, rollback, TemplateStep, + 2 additional)"); + } +} diff --git a/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/template/TemplateValidator.java b/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/template/TemplateValidator.java new file mode 100644 index 000000000..667f23064 --- /dev/null +++ b/core/flamingock-core-commons/src/main/java/io/flamingock/internal/common/core/template/TemplateValidator.java @@ -0,0 +1,217 @@ +/* + * 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.internal.common.core.template; + +import io.flamingock.api.template.AbstractSimpleTemplate; +import io.flamingock.api.template.AbstractSteppableTemplate; +import io.flamingock.api.template.ChangeTemplate; +import io.flamingock.internal.common.core.error.validation.ValidationError; +import io.flamingock.internal.common.core.error.validation.ValidationResult; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Validates that template-based YAML changes have the correct structure for their template type. + * + *

This validator ensures: + *

+ * + *

The validator is used during compile-time by the annotation processor to catch structural + * mismatches early. Behavior is controlled by the {@code strictTemplateValidation} flag in + * {@code @EnableFlamingock}: + *

+ */ +public class TemplateValidator { + + /** + * Enumeration of template types for validation purposes. + */ + public enum TemplateType { + /** + * Template extends AbstractSimpleTemplate - uses apply/rollback fields. + */ + SIMPLE, + /** + * Template extends AbstractSteppableTemplate - uses steps field. + */ + STEPPABLE, + /** + * Template type could not be determined. + */ + UNKNOWN + } + + private static final String ENTITY_TYPE = "template-change"; + + /** + * Creates a new TemplateValidator and ensures templates are loaded. + */ + public TemplateValidator() { + ChangeTemplateManager.loadTemplates(); + } + + /** + * Validates the YAML content structure against the template type. + * + * @param content the parsed YAML content + * @return ValidationResult containing any validation errors found + */ + public ValidationResult validate(ChangeTemplateFileContent content) { + ValidationResult result = new ValidationResult("Template structure validation"); + + String templateName = content.getTemplate(); + String changeId = content.getId() != null ? content.getId() : "unknown"; + + if (templateName == null || templateName.trim().isEmpty()) { + result.add(new ValidationError("Template name is required", changeId, ENTITY_TYPE)); + return result; + } + + Optional>> templateClassOpt = ChangeTemplateManager.getTemplate(templateName); + + if (!templateClassOpt.isPresent()) { + result.add(new ValidationError( + "Template '" + templateName + "' not found. Ensure the template is registered via SPI.", + changeId, + ENTITY_TYPE + )); + return result; + } + + Class> templateClass = templateClassOpt.get(); + TemplateType type = getTemplateType(templateClass); + + switch (type) { + case SIMPLE: + validateSimpleTemplate(content, changeId, result); + break; + case STEPPABLE: + validateSteppableTemplate(content, changeId, result); + break; + case UNKNOWN: + // Unknown types are valid - they may have custom structure + break; + } + + return result; + } + + /** + * Determines the template type based on the class hierarchy. + * + * @param templateClass the template class to check + * @return the TemplateType (SIMPLE, STEPPABLE, or UNKNOWN) + */ + public TemplateType getTemplateType(Class> templateClass) { + if (AbstractSimpleTemplate.class.isAssignableFrom(templateClass)) { + return TemplateType.SIMPLE; + } else if (AbstractSteppableTemplate.class.isAssignableFrom(templateClass)) { + return TemplateType.STEPPABLE; + } + return TemplateType.UNKNOWN; + } + + /** + * Validates a SimpleTemplate structure: + * - Must have apply + * - May have rollback + * - Must NOT have steps + */ + private void validateSimpleTemplate(ChangeTemplateFileContent content, String changeId, ValidationResult result) { + // Validate: apply is required + if (content.getApply() == null) { + result.add(new ValidationError( + "SimpleTemplate requires 'apply' field", + changeId, + ENTITY_TYPE + )); + } + + // Validate: steps must NOT be present + if (content.getSteps() != null) { + result.add(new ValidationError( + "SimpleTemplate must not have 'steps' field. Use 'apply' and 'rollback' instead.", + changeId, + ENTITY_TYPE + )); + } + } + + /** + * Validates a SteppableTemplate structure: + * - Must have steps + * - Must NOT have apply at root level + * - Must NOT have rollback at root level + * - Each step must have apply + */ + @SuppressWarnings("unchecked") + private void validateSteppableTemplate(ChangeTemplateFileContent content, String changeId, ValidationResult result) { + // Validate: apply must NOT be present at root level + if (content.getApply() != null) { + result.add(new ValidationError( + "SteppableTemplate must not have 'apply' at root level. Define 'apply' within each step.", + changeId, + ENTITY_TYPE + )); + } + + // Validate: rollback must NOT be present at root level + if (content.getRollback() != null) { + result.add(new ValidationError( + "SteppableTemplate must not have 'rollback' at root level. Define 'rollback' within each step.", + changeId, + ENTITY_TYPE + )); + } + + // Validate: steps is required + Object steps = content.getSteps(); + if (steps == null) { + result.add(new ValidationError( + "SteppableTemplate requires 'steps' field", + changeId, + ENTITY_TYPE + )); + return; + } + + // Validate each step has apply + if (steps instanceof List) { + List stepList = (List) steps; + for (int i = 0; i < stepList.size(); i++) { + Object step = stepList.get(i); + if (step instanceof Map) { + Map stepMap = (Map) step; + if (!stepMap.containsKey("apply") || stepMap.get("apply") == null) { + result.add(new ValidationError( + "Step " + (i + 1) + " is missing required 'apply' field", + changeId, + ENTITY_TYPE + )); + } + } + } + } + } +} diff --git a/core/flamingock-core-commons/src/test/java/io/flamingock/internal/common/core/template/TemplateValidatorTest.java b/core/flamingock-core-commons/src/test/java/io/flamingock/internal/common/core/template/TemplateValidatorTest.java new file mode 100644 index 000000000..82975e039 --- /dev/null +++ b/core/flamingock-core-commons/src/test/java/io/flamingock/internal/common/core/template/TemplateValidatorTest.java @@ -0,0 +1,296 @@ +/* + * 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.internal.common.core.template; + +import io.flamingock.api.annotations.Apply; +import io.flamingock.api.template.AbstractSimpleTemplate; +import io.flamingock.api.template.AbstractSteppableTemplate; +import io.flamingock.internal.common.core.error.validation.ValidationResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class TemplateValidatorTest { + + private TemplateValidator validator; + + // Test template extending AbstractSimpleTemplate + public static class TestSimpleTemplate extends AbstractSimpleTemplate { + public TestSimpleTemplate() { + super(); + } + + @Apply + public void apply() { + // Test implementation + } + } + + // Test template extending AbstractSteppableTemplate + public static class TestSteppableTemplate extends AbstractSteppableTemplate { + public TestSteppableTemplate() { + super(); + } + + @Apply + public void apply() { + // Test implementation + } + } + + @BeforeEach + void setUp() { + // Register test templates + ChangeTemplateManager.addTemplate("TestSimpleTemplate", TestSimpleTemplate.class); + ChangeTemplateManager.addTemplate("TestSteppableTemplate", TestSteppableTemplate.class); + validator = new TemplateValidator(); + } + + @Nested + @DisplayName("getTemplateType tests") + class GetTemplateTypeTests { + + @Test + @DisplayName("Should return SIMPLE for AbstractSimpleTemplate subclass") + void shouldReturnSimpleForAbstractSimpleTemplateSubclass() { + TemplateValidator.TemplateType type = validator.getTemplateType(TestSimpleTemplate.class); + assertEquals(TemplateValidator.TemplateType.SIMPLE, type); + } + + @Test + @DisplayName("Should return STEPPABLE for AbstractSteppableTemplate subclass") + void shouldReturnSteppableForAbstractSteppableTemplateSubclass() { + TemplateValidator.TemplateType type = validator.getTemplateType(TestSteppableTemplate.class); + assertEquals(TemplateValidator.TemplateType.STEPPABLE, type); + } + } + + @Nested + @DisplayName("SimpleTemplate validation tests") + class SimpleTemplateValidationTests { + + @Test + @DisplayName("SimpleTemplate with apply only should pass validation") + void simpleTemplateWithApplyOnlyPasses() { + ChangeTemplateFileContent content = new ChangeTemplateFileContent(); + content.setId("test-change-1"); + content.setTemplate("TestSimpleTemplate"); + content.setApply("CREATE TABLE users"); + + ValidationResult result = validator.validate(content); + + assertFalse(result.hasErrors(), "Should have no errors: " + result.formatMessage()); + } + + @Test + @DisplayName("SimpleTemplate with apply and rollback should pass validation") + void simpleTemplateWithApplyAndRollbackPasses() { + ChangeTemplateFileContent content = new ChangeTemplateFileContent(); + content.setId("test-change-2"); + content.setTemplate("TestSimpleTemplate"); + content.setApply("CREATE TABLE users"); + content.setRollback("DROP TABLE users"); + + ValidationResult result = validator.validate(content); + + assertFalse(result.hasErrors(), "Should have no errors: " + result.formatMessage()); + } + + @Test + @DisplayName("SimpleTemplate with steps should fail validation") + void simpleTemplateWithStepsFails() { + ChangeTemplateFileContent content = new ChangeTemplateFileContent(); + content.setId("test-change-3"); + content.setTemplate("TestSimpleTemplate"); + content.setApply("CREATE TABLE users"); + content.setSteps(Arrays.asList(createStep("step1", null))); + + ValidationResult result = validator.validate(content); + + assertTrue(result.hasErrors()); + assertTrue(result.formatMessage().contains("SimpleTemplate must not have 'steps' field")); + } + + @Test + @DisplayName("SimpleTemplate missing apply should fail validation") + void simpleTemplateMissingApplyFails() { + ChangeTemplateFileContent content = new ChangeTemplateFileContent(); + content.setId("test-change-4"); + content.setTemplate("TestSimpleTemplate"); + content.setRollback("DROP TABLE users"); + // apply is NOT set + + ValidationResult result = validator.validate(content); + + assertTrue(result.hasErrors()); + assertTrue(result.formatMessage().contains("SimpleTemplate requires 'apply' field")); + } + } + + @Nested + @DisplayName("SteppableTemplate validation tests") + class SteppableTemplateValidationTests { + + @Test + @DisplayName("SteppableTemplate with valid steps should pass validation") + void steppableTemplateWithValidStepsPasses() { + ChangeTemplateFileContent content = new ChangeTemplateFileContent(); + content.setId("test-change-5"); + content.setTemplate("TestSteppableTemplate"); + content.setSteps(Arrays.asList( + createStep("CREATE TABLE users", null), + createStep("CREATE TABLE orders", "DROP TABLE orders") + )); + + ValidationResult result = validator.validate(content); + + assertFalse(result.hasErrors(), "Should have no errors: " + result.formatMessage()); + } + + @Test + @DisplayName("SteppableTemplate with steps having apply and rollback should pass validation") + void steppableTemplateWithStepsHavingApplyAndRollbackPasses() { + ChangeTemplateFileContent content = new ChangeTemplateFileContent(); + content.setId("test-change-6"); + content.setTemplate("TestSteppableTemplate"); + content.setSteps(Arrays.asList( + createStep("CREATE TABLE users", "DROP TABLE users"), + createStep("CREATE TABLE orders", "DROP TABLE orders") + )); + + ValidationResult result = validator.validate(content); + + assertFalse(result.hasErrors(), "Should have no errors: " + result.formatMessage()); + } + + @Test + @DisplayName("SteppableTemplate with apply at root should fail validation") + void steppableTemplateWithApplyAtRootFails() { + ChangeTemplateFileContent content = new ChangeTemplateFileContent(); + content.setId("test-change-7"); + content.setTemplate("TestSteppableTemplate"); + content.setApply("CREATE TABLE users"); // Should not be at root level + content.setSteps(Arrays.asList(createStep("step1", null))); + + ValidationResult result = validator.validate(content); + + assertTrue(result.hasErrors()); + assertTrue(result.formatMessage().contains("SteppableTemplate must not have 'apply' at root level")); + } + + @Test + @DisplayName("SteppableTemplate with rollback at root should fail validation") + void steppableTemplateWithRollbackAtRootFails() { + ChangeTemplateFileContent content = new ChangeTemplateFileContent(); + content.setId("test-change-8"); + content.setTemplate("TestSteppableTemplate"); + content.setRollback("DROP TABLE users"); // Should not be at root level + content.setSteps(Arrays.asList(createStep("step1", null))); + + ValidationResult result = validator.validate(content); + + assertTrue(result.hasErrors()); + assertTrue(result.formatMessage().contains("SteppableTemplate must not have 'rollback' at root level")); + } + + @Test + @DisplayName("SteppableTemplate missing steps should fail validation") + void steppableTemplateMissingStepsFails() { + ChangeTemplateFileContent content = new ChangeTemplateFileContent(); + content.setId("test-change-9"); + content.setTemplate("TestSteppableTemplate"); + // steps is NOT set + + ValidationResult result = validator.validate(content); + + assertTrue(result.hasErrors()); + assertTrue(result.formatMessage().contains("SteppableTemplate requires 'steps' field")); + } + + @Test + @DisplayName("SteppableTemplate with step missing apply should fail validation") + void steppableTemplateWithStepMissingApplyFails() { + ChangeTemplateFileContent content = new ChangeTemplateFileContent(); + content.setId("test-change-10"); + content.setTemplate("TestSteppableTemplate"); + + List> steps = new ArrayList<>(); + Map step1 = new HashMap<>(); + step1.put("rollback", "DROP TABLE users"); // apply is missing + steps.add(step1); + content.setSteps(steps); + + ValidationResult result = validator.validate(content); + + assertTrue(result.hasErrors()); + assertTrue(result.formatMessage().contains("Step 1 is missing required 'apply' field")); + } + } + + @Nested + @DisplayName("Template not found tests") + class TemplateNotFoundTests { + + @Test + @DisplayName("Unknown template name should fail with template not found error") + void unknownTemplateNameFails() { + ChangeTemplateFileContent content = new ChangeTemplateFileContent(); + content.setId("test-change-11"); + content.setTemplate("UnknownTemplate"); + content.setApply("some operation"); + + ValidationResult result = validator.validate(content); + + assertTrue(result.hasErrors()); + assertTrue(result.formatMessage().contains("Template 'UnknownTemplate' not found")); + } + + @Test + @DisplayName("Missing template name should fail validation") + void missingTemplateNameFails() { + ChangeTemplateFileContent content = new ChangeTemplateFileContent(); + content.setId("test-change-12"); + // template is NOT set + content.setApply("some operation"); + + ValidationResult result = validator.validate(content); + + assertTrue(result.hasErrors()); + assertTrue(result.formatMessage().contains("Template name is required")); + } + } + + /** + * Helper method to create a step map with apply and optional rollback. + */ + private Map createStep(String apply, String rollback) { + Map step = new HashMap<>(); + step.put("apply", apply); + if (rollback != null) { + step.put("rollback", rollback); + } + return step; + } +} diff --git a/core/flamingock-processor/src/main/java/io/flamingock/core/processor/FlamingockAnnotationProcessor.java b/core/flamingock-processor/src/main/java/io/flamingock/core/processor/FlamingockAnnotationProcessor.java index 83646c775..69a416d00 100644 --- a/core/flamingock-processor/src/main/java/io/flamingock/core/processor/FlamingockAnnotationProcessor.java +++ b/core/flamingock-processor/src/main/java/io/flamingock/core/processor/FlamingockAnnotationProcessor.java @@ -26,11 +26,17 @@ import io.flamingock.internal.common.core.metadata.BuilderProviderInfo; import io.flamingock.internal.common.core.metadata.FlamingockMetadata; import io.flamingock.internal.common.core.pipeline.PipelineHelper; +import io.flamingock.internal.common.core.error.validation.ValidationError; +import io.flamingock.internal.common.core.error.validation.ValidationResult; +import io.flamingock.internal.common.core.preview.AbstractPreviewTask; import io.flamingock.internal.common.core.preview.CodePreviewChange; import io.flamingock.internal.common.core.preview.PreviewPipeline; import io.flamingock.internal.common.core.preview.PreviewStage; import io.flamingock.internal.common.core.preview.SystemPreviewStage; +import io.flamingock.internal.common.core.preview.TemplatePreviewChange; import io.flamingock.internal.common.core.task.TaskDescriptor; +import io.flamingock.internal.common.core.template.ChangeTemplateFileContent; +import io.flamingock.internal.common.core.template.TemplateValidator; import io.flamingock.internal.common.core.util.LoggerPreProcessor; import io.flamingock.internal.common.core.util.Serializer; import org.jetbrains.annotations.NotNull; @@ -227,6 +233,7 @@ public boolean process(Set annotations, RoundEnvironment ); validateAllChangesAreMappedToStages(standardChangesMapByPackage, pipeline, flamingockAnnotation.strictStageMapping()); + validateTemplateStructures(pipeline, flamingockAnnotation.strictTemplateValidation()); Serializer serializer = new Serializer(processingEnv, logger); String configFile = flamingockAnnotation.configFile(); @@ -769,4 +776,97 @@ private void validateAllChangesAreMappedToStages(Map allErrors = new ArrayList<>(); + + // Collect errors from system stage + if (pipeline.getSystemStage() != null && pipeline.getSystemStage().getTasks() != null) { + collectTemplateValidationErrors(validator, pipeline.getSystemStage().getTasks(), allErrors); + } + + // Collect errors from regular stages + if (pipeline.getStages() != null) { + for (PreviewStage stage : pipeline.getStages()) { + if (stage.getTasks() != null) { + collectTemplateValidationErrors(validator, stage.getTasks(), allErrors); + } + } + } + + if (!allErrors.isEmpty()) { + String message = formatTemplateValidationErrors(allErrors); + if (Boolean.TRUE.equals(strictTemplateValidation)) { + throw new RuntimeException(message); + } else { + logger.warn(message); + } + } + } + + /** + * Collects validation errors from template-based tasks. + * + * @param validator the template validator + * @param tasks the collection of preview tasks to check + * @param errors the list to add validation errors to + */ + private void collectTemplateValidationErrors( + TemplateValidator validator, + Collection tasks, + List errors) { + + for (AbstractPreviewTask task : tasks) { + if (task instanceof TemplatePreviewChange) { + TemplatePreviewChange templateTask = (TemplatePreviewChange) task; + + // Build ChangeTemplateFileContent from preview for validation + ChangeTemplateFileContent content = toFileContent(templateTask); + + ValidationResult result = validator.validate(content); + if (result.hasErrors()) { + errors.addAll(result.getErrors()); + } + } + } + } + + /** + * Converts a TemplatePreviewChange to ChangeTemplateFileContent for validation. + * + * @param preview the template preview change + * @return the file content representation + */ + private ChangeTemplateFileContent toFileContent(TemplatePreviewChange preview) { + ChangeTemplateFileContent content = new ChangeTemplateFileContent(); + content.setId(preview.getId()); + content.setTemplate(preview.getTemplateName()); + content.setApply(preview.getApply()); + content.setRollback(preview.getRollback()); + content.setSteps(preview.getSteps()); + return content; + } + + /** + * Formats template validation errors into a readable message. + * + * @param errors the list of validation errors + * @return formatted error message + */ + private String formatTemplateValidationErrors(List errors) { + StringBuilder sb = new StringBuilder("Template structure validation errors:\n"); + for (ValidationError error : errors) { + sb.append(" - ").append(error.getFormattedMessage()).append("\n"); + } + return sb.toString(); + } } diff --git a/core/flamingock-processor/src/test/java/io/flamingock/core/processor/PipelinePreProcessorTest.java b/core/flamingock-processor/src/test/java/io/flamingock/core/processor/PipelinePreProcessorTest.java index 7a2b0d8bd..acfe10051 100644 --- a/core/flamingock-processor/src/test/java/io/flamingock/core/processor/PipelinePreProcessorTest.java +++ b/core/flamingock-processor/src/test/java/io/flamingock/core/processor/PipelinePreProcessorTest.java @@ -513,6 +513,11 @@ public boolean strictStageMapping() { return true; } + @Override + public boolean strictTemplateValidation() { + return true; + } + @Override public Class annotationType() { return EnableFlamingock.class; } }; }