diff --git a/build.gradle.kts b/build.gradle.kts index 61c0de75c..f917e4165 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -19,7 +19,7 @@ plugins { allprojects { group = "io.flamingock" - val declaredVersion = "1.4.3-SNAPSHOT" + val declaredVersion = "1.4.4-SNAPSHOT" version = VersionManager.resolveVersion(declaredVersion, project.hasProperty("release")) extra["generalUtilVersion"] = "1.5.3" diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/pipeline/loaded/LoadedPipeline.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/pipeline/loaded/LoadedPipeline.java index a77fb0119..8ee11877c 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/pipeline/loaded/LoadedPipeline.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/pipeline/loaded/LoadedPipeline.java @@ -108,6 +108,16 @@ public List getStages() { return loadedStages; } + /** + * Returns the change filters contributed by plugins and applied at runtime construction + * time. A change is included in the runtime pipeline only if every filter returns + * {@code true} for it; any filter returning {@code false} excludes the change. May be + * empty when no plugin contributed a filter. + */ + public Collection getChangeFilters() { + return changeFilters == null ? Collections.emptyList() : changeFilters; + } + @Override public Optional getLoadedChange(String changeId) { return loadedStages.stream() diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/pipeline/loaded/stage/AbstractLoadedStage.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/pipeline/loaded/stage/AbstractLoadedStage.java index 8c05e54b4..66c4f5a53 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/pipeline/loaded/stage/AbstractLoadedStage.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/pipeline/loaded/stage/AbstractLoadedStage.java @@ -103,6 +103,31 @@ public Collection getChanges() { return changes; } + /** + * Returns a new instance of the same concrete stage type carrying the provided changes + * instead of this stage's current changes. Name, type, and the per-subclass validation + * context are preserved. + * + *

Used at runtime construction time to materialize a stage with a filtered subset + * of changes without mutating the original {@code AbstractLoadedStage} (whose + * {@code changes} collection is final and immutable post-construction). The copy is + * produced by reflecting on the concrete subclass to find its public + * {@code (String, StageType, Collection)} constructor — every concrete subclass + * ({@link DefaultLoadedStage}, {@link LegacyLoadedStage}, {@link SystemLoadedStage}) + * exposes one. + */ + public AbstractLoadedStage withChanges(Collection newChanges) { + try { + return getClass() + .getConstructor(String.class, StageType.class, Collection.class) + .newInstance(getName(), getType(), newChanges); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException( + "Cannot copy stage [" + getName() + "] of type " + getClass().getName() + + " with filtered changes", e); + } + } + /** diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/pipeline/run/PipelineRun.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/pipeline/run/PipelineRun.java index 46f57a4ae..28c0b1b2b 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/pipeline/run/PipelineRun.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/pipeline/run/PipelineRun.java @@ -25,6 +25,8 @@ import io.flamingock.internal.common.core.response.data.PlannerVerdict; import io.flamingock.internal.common.core.response.data.StageResult; import io.flamingock.internal.common.core.response.data.StageState; +import io.flamingock.internal.core.change.filter.ChangeFilter; +import io.flamingock.internal.core.change.loaded.AbstractLoadedChange; import io.flamingock.internal.core.pipeline.execution.StageExecutionException; import io.flamingock.internal.core.pipeline.loaded.LoadedPipeline; import io.flamingock.internal.core.pipeline.loaded.stage.AbstractLoadedStage; @@ -33,6 +35,7 @@ import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; @@ -52,14 +55,70 @@ public class PipelineRun { public static PipelineRun of(LoadedPipeline pipeline) { // Static-structure validation runs as part of construction. Builder-time validation in // AbstractChangeRunnerBuilder still fails fast on malformed pipelines; this call covers - // the runtime entry point so callers don't have to validate separately. + // the runtime entry point so callers don't have to validate separately. Validation + // operates on the unfiltered pipeline so structural checks (duplicate IDs, empty + // stages, etc.) catch issues regardless of which changes any plugin would exclude. pipeline.validate(); + Collection filters = pipeline.getChangeFilters(); List stages = new ArrayList<>(); - pipeline.getSystemStage().ifPresent(stages::add); - stages.addAll(pipeline.getStages()); + // Apply the same drop-empty rule to the system stage as to user stages. A stage is + // dropped only when filtering actually removed every change from a stage that had + // some to begin with. A stage that was already empty before filtering is preserved + // unchanged; this matches the pre-existing "empty stages survive" contract used by + // builder-mocked tests and by incremental compile-time flows where a stage may be + // temporarily empty before any @Change has been added to its package. + pipeline.getSystemStage() + .map(stage -> applyFilters(stage, filters)) + .filter(result -> !result.filteredToEmpty) + .map(result -> result.stage) + .ifPresent(stages::add); + for (AbstractLoadedStage stage : pipeline.getStages()) { + FilterResult result = applyFilters(stage, filters); + if (!result.filteredToEmpty) { + stages.add(result.stage); + } + } return of(stages); } + /** + * Result of running change filters against a single stage. {@code stage} is either the + * original stage (no filter removed any change, OR the original was empty, OR no filters + * were provided) or a new instance with the surviving changes. {@code filteredToEmpty} + * is {@code true} only when the filter actually removed every change from a non-empty + * stage — that's the signal the caller uses to drop the stage from the runtime list. + */ + private static final class FilterResult { + final AbstractLoadedStage stage; + final boolean filteredToEmpty; + + FilterResult(AbstractLoadedStage stage, boolean filteredToEmpty) { + this.stage = stage; + this.filteredToEmpty = filteredToEmpty; + } + } + + /** + * Applies the given change filters to a stage's changes, returning a {@link FilterResult} + * that preserves the original stage reference when no filter removed any change. Filters + * are AND-composed: a change is kept only if every filter returns {@code true} for it. + */ + private static FilterResult applyFilters(AbstractLoadedStage stage, Collection filters) { + Collection originalChanges = stage.getChanges(); + if (filters == null || filters.isEmpty() || originalChanges == null || originalChanges.isEmpty()) { + // No filtering meaningfully applied. Preserve the original stage unchanged — this + // includes the case of a pre-existing empty stage (filteredToEmpty=false). + return new FilterResult(stage, false); + } + Collection surviving = originalChanges.stream() + .filter(change -> filters.stream().allMatch(f -> f.filter(change))) + .collect(Collectors.toList()); + if (surviving.size() == originalChanges.size()) { + return new FilterResult(stage, false); + } + return new FilterResult(stage.withChanges(surviving), surviving.isEmpty()); + } + public static PipelineRun of(List stages) { // Partition by StageType in dependency order, skipping empty types (sparse). The resulting // flat list is the concatenation of blocks in canonical order — a single source of truth diff --git a/core/flamingock-core/src/test/java/io/flamingock/internal/core/pipeline/run/PipelineRunOfLoadedPipelineTest.java b/core/flamingock-core/src/test/java/io/flamingock/internal/core/pipeline/run/PipelineRunOfLoadedPipelineTest.java new file mode 100644 index 000000000..51f1a7a1e --- /dev/null +++ b/core/flamingock-core/src/test/java/io/flamingock/internal/core/pipeline/run/PipelineRunOfLoadedPipelineTest.java @@ -0,0 +1,321 @@ +/* + * 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.core.pipeline.run; + +import io.flamingock.api.StageType; +import io.flamingock.internal.core.change.filter.ChangeFilter; +import io.flamingock.internal.core.change.loaded.AbstractLoadedChange; +import io.flamingock.internal.core.pipeline.loaded.LoadedPipeline; +import io.flamingock.internal.core.pipeline.loaded.stage.AbstractLoadedStage; +import io.flamingock.internal.core.pipeline.loaded.stage.DefaultLoadedStage; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link PipelineRun#of(LoadedPipeline)} focusing on the runtime application + * of the {@link ChangeFilter}s carried by the {@link LoadedPipeline}. + * + *

These tests pin down the contract introduced by issue #933: change filters must be + * applied at runtime construction time, so excluded changes never appear in the resulting + * pipeline. If a filter removes all changes from a stage, the stage is dropped (sparse + * block semantics), and block ordering (SYSTEM -> LEGACY -> DEFAULT) is preserved. + */ +class PipelineRunOfLoadedPipelineTest { + + // --------------------------------------------------------------------------------------- + // Empty / no-filter baseline + // --------------------------------------------------------------------------------------- + + @Test + @DisplayName("SHOULD keep all changes WHEN no filters are present") + void noFiltersKeepsAllChangesInAllStages() { + DefaultLoadedStage userStage = newStage("user", StageType.DEFAULT, "u1", "u2"); + LoadedPipeline pipeline = mockPipeline(Optional.empty(), Arrays.asList(userStage), Collections.emptyList()); + + PipelineRun run = PipelineRun.of(pipeline); + + assertEquals(1, run.getStageCount()); + assertEquals(2L, run.getTotalChangeCount()); + } + + @Test + @DisplayName("SHOULD run validation on the unfiltered pipeline (existing behavior preserved)") + void validationRunsOnTheUnfilteredPipeline() { + // The validate() call must happen before filtering. We assert it is invoked exactly + // once regardless of whether filters are present. This pins down the contract that + // validation operates on the unfiltered pipeline (consistent with builder-time checks). + DefaultLoadedStage userStage = newStage("user", StageType.DEFAULT, "u1"); + LoadedPipeline pipeline = mock(LoadedPipeline.class); + when(pipeline.getSystemStage()).thenReturn(Optional.empty()); + when(pipeline.getStages()).thenReturn(Collections.singletonList(userStage)); + when(pipeline.getChangeFilters()).thenReturn(Collections.emptyList()); + // Mockito's default for void methods is no-op, so validate() is essentially a no-op + // here. We still call the real method (doNothing) and rely on the test passing through + // the rest of the pipeline. + doNothing().when(pipeline).validate(); + + PipelineRun run = PipelineRun.of(pipeline); + + assertNotNull(run); + assertEquals(1, run.getStageCount()); + } + + // --------------------------------------------------------------------------------------- + // Filter applies to individual changes + // --------------------------------------------------------------------------------------- + + @Test + @DisplayName("SHOULD exclude a change WHEN a filter rejects it") + void singleFilterExcludesTheRejectedChange() { + DefaultLoadedStage userStage = newStage("user", StageType.DEFAULT, "u1", "u2", "u3"); + ChangeFilter rejectU2 = descriptor -> !"u2".equals(descriptor.getId()); + LoadedPipeline pipeline = mockPipeline(Optional.empty(), Arrays.asList(userStage), Arrays.asList(rejectU2)); + + PipelineRun run = PipelineRun.of(pipeline); + + List survivingIds = collectChangeIds(run); + assertEquals(2L, run.getTotalChangeCount()); + assertTrue(survivingIds.contains("u1")); + assertFalse(survivingIds.contains("u2")); + assertTrue(survivingIds.contains("u3")); + } + + @Test + @DisplayName("SHOULD keep a change WHEN all filters accept it (AND semantics across filters)") + void multipleFiltersAreAnded() { + DefaultLoadedStage userStage = newStage("user", StageType.DEFAULT, "u1", "u2"); + // Filter A: drop u1. Filter B: drop u2. Both must drop together, but a change is + // kept only if BOTH filters keep it. So neither u1 nor u2 survives. + ChangeFilter dropU1 = descriptor -> !"u1".equals(descriptor.getId()); + ChangeFilter dropU2 = descriptor -> !"u2".equals(descriptor.getId()); + LoadedPipeline pipeline = mockPipeline(Optional.empty(), Arrays.asList(userStage), Arrays.asList(dropU1, dropU2)); + + PipelineRun run = PipelineRun.of(pipeline); + + // Both changes rejected by at least one filter -> stage is dropped (sparse). + assertEquals(0, run.getStageCount()); + assertEquals(0L, run.getTotalChangeCount()); + } + + @Test + @DisplayName("SHOULD keep a change WHEN one filter keeps it and another also keeps it (intersection)") + void multipleFiltersAllowOverlap() { + DefaultLoadedStage userStage = newStage("user", StageType.DEFAULT, "shared", "onlyA", "onlyB"); + // Filter A keeps "shared" and "onlyA"; Filter B keeps "shared" and "onlyB". + // Intersection = "shared". + ChangeFilter filterA = descriptor -> { + String id = descriptor.getId(); + return id.equals("shared") || id.equals("onlyA"); + }; + ChangeFilter filterB = descriptor -> { + String id = descriptor.getId(); + return id.equals("shared") || id.equals("onlyB"); + }; + LoadedPipeline pipeline = mockPipeline(Optional.empty(), Arrays.asList(userStage), Arrays.asList(filterA, filterB)); + + PipelineRun run = PipelineRun.of(pipeline); + + List survivingIds = collectChangeIds(run); + assertEquals(1L, run.getTotalChangeCount()); + assertTrue(survivingIds.contains("shared")); + assertFalse(survivingIds.contains("onlyA")); + assertFalse(survivingIds.contains("onlyB")); + } + + // --------------------------------------------------------------------------------------- + // Empty stages after filtering — sparse semantics + // --------------------------------------------------------------------------------------- + + @Test + @DisplayName("SHOULD drop a stage WHEN filtering removes all its changes") + void emptyStageAfterFilteringIsDropped() { + DefaultLoadedStage alpha = newStage("alpha", StageType.DEFAULT, "a1", "a2"); + DefaultLoadedStage beta = newStage("beta", StageType.DEFAULT, "b1", "b2"); + // Filter rejects every change in 'alpha' but lets 'beta' through. + ChangeFilter dropAllAlpha = descriptor -> !descriptor.getId().startsWith("a"); + LoadedPipeline pipeline = mockPipeline(Optional.empty(), Arrays.asList(alpha, beta), Arrays.asList(dropAllAlpha)); + + PipelineRun run = PipelineRun.of(pipeline); + + assertEquals(1, run.getStageCount(), "alpha must be dropped when empty after filtering"); + assertEquals("beta", run.getStageRuns().get(0).getName()); + assertEquals(2L, run.getTotalChangeCount()); + } + + @Test + @DisplayName("SHOULD drop a stage WHEN filtering removes every change in it, even mid-block") + void emptyStageInTheMiddleIsDropped() { + DefaultLoadedStage first = newStage("first", StageType.DEFAULT, "f1"); + DefaultLoadedStage doomed = newStage("doomed", StageType.DEFAULT, "d1", "d2"); + DefaultLoadedStage last = newStage("last", StageType.DEFAULT, "l1"); + ChangeFilter dropDoomed = descriptor -> !descriptor.getId().startsWith("d"); + LoadedPipeline pipeline = mockPipeline( + Optional.empty(), + Arrays.asList(first, doomed, last), + Arrays.asList(dropDoomed)); + + PipelineRun run = PipelineRun.of(pipeline); + + List survivingStageNames = run.getStageRuns().stream() + .map(StageRun::getName) + .collect(Collectors.toList()); + assertEquals(Arrays.asList("first", "last"), survivingStageNames, + "doomed must be dropped; first and last must remain in order"); + } + + @Test + @DisplayName("SHOULD yield an empty run WHEN every stage is filtered out") + void allStagesFilteredOutYieldsEmptyRun() { + DefaultLoadedStage alpha = newStage("alpha", StageType.DEFAULT, "a1"); + DefaultLoadedStage beta = newStage("beta", StageType.DEFAULT, "b1"); + ChangeFilter rejectAll = descriptor -> false; + LoadedPipeline pipeline = mockPipeline(Optional.empty(), Arrays.asList(alpha, beta), Arrays.asList(rejectAll)); + + PipelineRun run = PipelineRun.of(pipeline); + + assertEquals(0, run.getStageCount()); + assertEquals(0L, run.getTotalChangeCount()); + } + + // --------------------------------------------------------------------------------------- + // Block ordering preserved (SYSTEM -> LEGACY -> DEFAULT) after filtering + // --------------------------------------------------------------------------------------- + + @Test + @DisplayName("SHOULD preserve SYSTEM -> LEGACY -> DEFAULT block ordering after filtering") + void blockOrderingPreservedAfterFiltering() { + DefaultLoadedStage systemStage = newStage("system", StageType.SYSTEM, "s1", "s2"); + DefaultLoadedStage legacyStage = newStage("legacy", StageType.LEGACY, "l1"); + DefaultLoadedStage userStage = newStage("user", StageType.DEFAULT, "u1", "u2", "u3"); + // Drop s2 (SYSTEM), keep l1 (LEGACY), drop u2 (DEFAULT). + ChangeFilter customFilter = descriptor -> { + String id = descriptor.getId(); + return !id.equals("s2") && !id.equals("u2"); + }; + LoadedPipeline pipeline = mockPipeline( + Optional.of(systemStage), + Arrays.asList(legacyStage, userStage), + Arrays.asList(customFilter)); + + PipelineRun run = PipelineRun.of(pipeline); + + // Flat list is canonicalised: SYSTEM block first, then LEGACY, then DEFAULT, regardless + // of input order. Filtering must not perturb this. + List flatStageNames = run.getStageRuns().stream() + .map(StageRun::getName) + .collect(Collectors.toList()); + assertEquals(Arrays.asList("system", "legacy", "user"), flatStageNames); + + // Block view: one block per type, in dependency order. + List blocks = run.getStageBlocks(); + assertEquals(3, blocks.size()); + assertEquals(StageType.SYSTEM, blocks.get(0).getType()); + assertEquals(StageType.LEGACY, blocks.get(1).getType()); + assertEquals(StageType.DEFAULT, blocks.get(2).getType()); + + // Surviving change IDs. + List survivingIds = collectChangeIds(run); + assertEquals(4L, run.getTotalChangeCount()); + assertTrue(survivingIds.contains("s1")); + assertFalse(survivingIds.contains("s2")); + assertTrue(survivingIds.contains("l1")); + assertTrue(survivingIds.contains("u1")); + assertFalse(survivingIds.contains("u2")); + assertTrue(survivingIds.contains("u3")); + } + + @Test + @DisplayName("SHOULD drop a SYSTEM block WHEN the system stage becomes empty after filtering") + void emptySystemStageIsDroppedAndLegacyTakesFoundationRole() { + // Filtering removes every change in the system stage. The SYSTEM block should be + // dropped (sparse), and the LEGACY block becomes the foundation in the flat view. + DefaultLoadedStage systemStage = newStage("system", StageType.SYSTEM, "s1", "s2"); + DefaultLoadedStage legacyStage = newStage("legacy", StageType.LEGACY, "l1"); + DefaultLoadedStage userStage = newStage("user", StageType.DEFAULT, "u1"); + ChangeFilter dropAllSystem = descriptor -> !descriptor.getId().startsWith("s"); + LoadedPipeline pipeline = mockPipeline( + Optional.of(systemStage), + Arrays.asList(legacyStage, userStage), + Arrays.asList(dropAllSystem)); + + PipelineRun run = PipelineRun.of(pipeline); + + List flatStageNames = run.getStageRuns().stream() + .map(StageRun::getName) + .collect(Collectors.toList()); + assertEquals(Arrays.asList("legacy", "user"), flatStageNames); + + List blocks = run.getStageBlocks(); + assertEquals(2, blocks.size(), "empty SYSTEM block must be omitted"); + assertEquals(StageType.LEGACY, blocks.get(0).getType()); + assertEquals(StageType.DEFAULT, blocks.get(1).getType()); + } + + // --------------------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------------------- + + private static LoadedPipeline mockPipeline(Optional systemStage, + List userStages, + Collection filters) { + LoadedPipeline pipeline = mock(LoadedPipeline.class); + when(pipeline.getSystemStage()).thenReturn(systemStage); + when(pipeline.getStages()).thenReturn(userStages); + when(pipeline.getChangeFilters()).thenReturn(filters); + doNothing().when(pipeline).validate(); + return pipeline; + } + + private static DefaultLoadedStage newStage(String name, StageType type, String... changeIds) { + List changes = new ArrayList<>(changeIds.length); + for (String id : changeIds) { + changes.add(mockChange(id)); + } + return new DefaultLoadedStage(name, type, changes); + } + + private static AbstractLoadedChange mockChange(String id) { + AbstractLoadedChange change = mock(AbstractLoadedChange.class); + when(change.getId()).thenReturn(id); + return change; + } + + private static List collectChangeIds(PipelineRun run) { + List ids = new ArrayList<>(); + for (StageRun stageRun : run.getStageRuns()) { + for (AbstractLoadedChange change : stageRun.getLoadedStage().getChanges()) { + ids.add(change.getId()); + } + } + return ids; + } +} diff --git a/core/flamingock-graalvm/src/main/java/io/flamingock/graalvm/RegistrationFeature.java b/core/flamingock-graalvm/src/main/java/io/flamingock/graalvm/RegistrationFeature.java index 9f82742e0..4daa5525f 100644 --- a/core/flamingock-graalvm/src/main/java/io/flamingock/graalvm/RegistrationFeature.java +++ b/core/flamingock-graalvm/src/main/java/io/flamingock/graalvm/RegistrationFeature.java @@ -90,6 +90,9 @@ private static void registerInternalClasses() { //Loaded registerClassForReflection("io.flamingock.internal.core.pipeline.loaded.LoadedPipeline"); registerClassForReflection("io.flamingock.internal.core.pipeline.loaded.stage.AbstractLoadedStage"); + registerClassForReflection("io.flamingock.internal.core.pipeline.loaded.stage.DefaultLoadedStage"); + registerClassForReflection("io.flamingock.internal.core.pipeline.loaded.stage.LegacyLoadedStage"); + registerClassForReflection("io.flamingock.internal.core.pipeline.loaded.stage.SystemLoadedStage"); registerClassForReflection("io.flamingock.internal.core.change.loaded.AbstractLoadedChange"); registerClassForReflection("io.flamingock.internal.core.change.loaded.AbstractReflectionLoadedChange"); registerClassForReflection("io.flamingock.internal.core.change.loaded.CodeLoadedChange"); diff --git a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/SpringbootProfileFilter.java b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/SpringbootProfileFilter.java index e6843af26..88a64f95f 100644 --- a/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/SpringbootProfileFilter.java +++ b/platform-plugins/flamingock-springboot-integration/src/main/java/io/flamingock/springboot/SpringbootProfileFilter.java @@ -22,6 +22,7 @@ import io.flamingock.internal.core.change.loaded.CodeLoadedChange; import org.springframework.context.annotation.Profile; +import java.lang.reflect.Method; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -70,8 +71,28 @@ private boolean filterTemplateChange(AbstractTemplateLoadedChange reflectionDesc } - private boolean filterCodeChange(CodeLoadedChange reflectionDescriptor) { - Class sourceClass = reflectionDescriptor.getImplementationClass(); + private boolean filterCodeChange(CodeLoadedChange change) { + // Legacy Mongock @ChangeSet changes: each @ChangeSet method is its own atomic change, + // so method-level @Profile is the natural gate. We detect the annotation by FQCN. + // + // This is intentionally narrow: only methods annotated with the legacy Mongock + // @ChangeSet annotation (com.github.cloudyrock.mongock.ChangeSet) qualify. + // Mongock @ChangeUnit / @Execution / @BeforeExecution flows are excluded. + // Native Flamingock @Apply method-level @Profile is also excluded. + if (hasChangeSetAnnotation(change.getApplyMethod())) { + Method applyMethod = change.getApplyMethod(); + if (applyMethod != null && applyMethod.isAnnotationPresent(Profile.class)) { + return filterProfiles(Arrays.asList(applyMethod.getAnnotation(Profile.class).value())); + } + } + + // Native Flamingock changes (and legacy fallback): profiles are declared at the change + // (class) level only. The change is the atomic unit, so its profile gate lives on the + // @Change-annotated class. Method-level @Profile (on @Apply or @Rollback) is + // intentionally NOT honored: a per-method gate is incoherent for a change-level inclusion + // decision and would risk applying a change while silently skipping its rollback under a + // different active profile, breaking recovery semantics. + Class sourceClass = change.getImplementationClass(); if (!sourceClass.isAnnotationPresent(Profile.class)) { return true; // no-profiled changeset always matches } @@ -79,6 +100,22 @@ private boolean filterCodeChange(CodeLoadedChange reflectionDescriptor) { return filterProfiles(changeProfile); } + /** + * Checks whether the given method carries the legacy Mongock {@code @ChangeSet} annotation, + * using its fully qualified name to avoid a compile-time dependency on the legacy module. + */ + private static boolean hasChangeSetAnnotation(Method method) { + if (method == null) { + return false; + } + for (java.lang.annotation.Annotation ann : method.getDeclaredAnnotations()) { + if ("com.github.cloudyrock.mongock.ChangeSet".equals(ann.annotationType().getName())) { + return true; + } + } + return false; + } + private boolean filterProfiles(List changeProfile) { boolean changeHasAtLeastOneProfileApplied = false; for (String profile : changeProfile) { diff --git a/platform-plugins/flamingock-springboot-integration/src/test/java/com/github/cloudyrock/mongock/ChangeSet.java b/platform-plugins/flamingock-springboot-integration/src/test/java/com/github/cloudyrock/mongock/ChangeSet.java new file mode 100644 index 000000000..45222543d --- /dev/null +++ b/platform-plugins/flamingock-springboot-integration/src/test/java/com/github/cloudyrock/mongock/ChangeSet.java @@ -0,0 +1,39 @@ +/* + * Copyright 2025 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 com.github.cloudyrock.mongock; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Test-only minimal replica of the legacy Mongock {@code @ChangeSet} annotation. + *

Replaces the {@code :legacy:mongock-support} test dependency so that + * {@link io.flamingock.springboot.SpringbootProfileFilter} FQCN-based detection + * of {@code com.github.cloudyrock.mongock.ChangeSet} can be verified without + * pulling the full legacy module into the test classpath.

+ */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ChangeSet { + + String author(); + + String id(); + + String order(); +} diff --git a/platform-plugins/flamingock-springboot-integration/src/test/java/io/flamingock/springboot/SpringProfileFilterCodeChangeTest.java b/platform-plugins/flamingock-springboot-integration/src/test/java/io/flamingock/springboot/SpringProfileFilterCodeChangeTest.java index f26a20745..ebe475046 100644 --- a/platform-plugins/flamingock-springboot-integration/src/test/java/io/flamingock/springboot/SpringProfileFilterCodeChangeTest.java +++ b/platform-plugins/flamingock-springboot-integration/src/test/java/io/flamingock/springboot/SpringProfileFilterCodeChangeTest.java @@ -15,6 +15,7 @@ */ package io.flamingock.springboot; +import com.github.cloudyrock.mongock.ChangeSet; import io.flamingock.api.annotations.Apply; import io.flamingock.api.annotations.Change; import io.flamingock.internal.core.change.loaded.CodeLoadedChange; @@ -92,6 +93,92 @@ private CodeLoadedChange getCodeLoadedChange(Class sourceClass) { return LoadedChangeBuilder.getCodeBuilderInstance(sourceClass).build(); } + private CodeLoadedChange getLegacyCodeLoadedChange(Class sourceClass) { + return LoadedChangeBuilder.getCodeBuilderInstance(sourceClass).setLegacy(true).build(); + } + + // ----------------------------------------------------------------------- + // Legacy method @Profile tests — @ChangeSet vs ChangeUnit distinction + // ----------------------------------------------------------------------- + + // _010__LegacyWithMethodProfile: legacy change setLegacy(true), method has @Profile but + // does NOT carry @ChangeSet (simulates a ChangeUnit/@Execution-style change). + // Method-level @Profile must be IGNORED — fall through to class-level (no class @Profile → unfiltered). + + @Test + @DisplayName("SHOULD return true WHEN legacy non-@ChangeSet method has @Profile(P1) and activeProfiles=[P1] — method @Profile is IGNORED") + void legacyMethodProfileIgnoredWhenActiveProfileMatches() { + assertTrue(new SpringbootProfileFilter("P1").filter(getLegacyCodeLoadedChange(_010__LegacyWithMethodProfile.class))); + } + + @Test + @DisplayName("SHOULD return true WHEN legacy non-@ChangeSet method has @Profile(P1) and activeProfiles=[P2] — method @Profile is IGNORED") + void legacyMethodProfileIgnoredWhenActiveProfileDoesNotMatch() { + // Method-level @Profile is not honored for non-@ChangeSet methods (ChangeUnit-style), + // so fall through to class-level: no class @Profile → unfiltered → true. + assertTrue(new SpringbootProfileFilter("P2").filter(getLegacyCodeLoadedChange(_010__LegacyWithMethodProfile.class))); + } + + @Test + @DisplayName("SHOULD return true WHEN legacy class has @Profile(P1) and method has no @Profile, activeProfiles=[P1]") + void legacyClassProfileFallbackWhenNoMethodProfile() { + assertTrue(new SpringbootProfileFilter("P1").filter(getLegacyCodeLoadedChange(_011__LegacyClassProfileNoMethod.class))); + } + + @Test + @DisplayName("SHOULD return false WHEN legacy class has @Profile(P1) and method has no @Profile, activeProfiles=[P2]") + void legacyClassProfileFallbackWhenNoMethodProfileNotMatching() { + assertFalse(new SpringbootProfileFilter("P2").filter(getLegacyCodeLoadedChange(_011__LegacyClassProfileNoMethod.class))); + } + + // ----------------------------------------------------------------------- + // Legacy @ChangeSet method @Profile tests — @Profile must be HONORED + // ----------------------------------------------------------------------- + + @Test + @DisplayName("SHOULD return true WHEN legacy @ChangeSet method has @Profile(P1) and activeProfiles=[P1]") + void changeSetMethodProfileHonoredWhenMatches() { + assertTrue(new SpringbootProfileFilter("P1").filter(getLegacyCodeLoadedChange(_013__ChangeSetWithMethodProfile.class))); + } + + @Test + @DisplayName("SHOULD return false WHEN legacy @ChangeSet method has @Profile(P1) and activeProfiles=[P2]") + void changeSetMethodProfileNotHonoredWhenNotMatches() { + assertFalse(new SpringbootProfileFilter("P2").filter(getLegacyCodeLoadedChange(_013__ChangeSetWithMethodProfile.class))); + } + + // ----------------------------------------------------------------------- + // Native method @Profile tests — method-level @Profile must be IGNORED + // ----------------------------------------------------------------------- + + @Test + @DisplayName("SHOULD return true WHEN native @Apply method has @Profile(P1) but no class @Profile, activeProfiles=[]") + void nativeMethodProfileNotHonoredWhenActiveProfileEmpty() { + // No class-level @Profile means the change is always unfiltered. + // Method-level @Profile on native @Apply is NOT honored. + assertTrue(new SpringbootProfileFilter().filter(getCodeLoadedChange(_012__NativeWithMethodProfile.class))); + } + + @Test + @DisplayName("SHOULD return true WHEN native @Apply method has @Profile(P1) but no class @Profile, activeProfiles=[P1]") + void nativeMethodProfileNotHonoredWhenActiveProfileMatches() { + // Even when active profiles happen to match, the method-level @Profile is + // not checked — it must not gate execution for native changes. + assertTrue(new SpringbootProfileFilter("P1").filter(getCodeLoadedChange(_012__NativeWithMethodProfile.class))); + } + + @Test + @DisplayName("SHOULD return true WHEN native @Apply method has @Profile(P1) but no class @Profile, activeProfiles=[P2]") + void nativeMethodProfileNotHonoredWhenActiveProfileDoesNotMatch() { + // Method-level @Profile must be entirely ignored for native changes. + // Without class-level @Profile the change is unfiltered even when P2 is active. + assertTrue(new SpringbootProfileFilter("P2").filter(getCodeLoadedChange(_012__NativeWithMethodProfile.class))); + } + + // ======================================================================= + // Test change classes + // ======================================================================= + @Change(id="not-annotated", author = "aperezdieppa") public static class _000__NotAnnotated { @Apply @@ -126,4 +213,46 @@ public void apply() { // testing purpose } } + + // Legacy-style change: method-level @Profile on the apply method + @Change(id="legacy-method-profile", author = "test") + public static class _010__LegacyWithMethodProfile { + @Apply + @Profile("P1") + public void apply() { + // testing purpose + } + } + + // Legacy-style change: only class-level @Profile, method has none + @Profile("P1") + @Change(id="legacy-class-fallback", author = "test") + public static class _011__LegacyClassProfileNoMethod { + @Apply + public void apply() { + // testing purpose + } + } + + // Native Flamingock change: method-level @Profile should be IGNORED by the filter + @Change(id="native-method-profile", author = "test") + public static class _012__NativeWithMethodProfile { + @Apply + @Profile("P1") + public void apply() { + // testing purpose + } + } + + // Legacy @ChangeSet change: method-level @Profile should be HONORED by the filter + @SuppressWarnings("deprecation") + @Change(id="legacy-changeset-method-profile", author = "test") + public static class _013__ChangeSetWithMethodProfile { + @Apply + @ChangeSet(author = "test", id = "test-id", order = "1") + @Profile("P1") + public void apply() { + // testing purpose + } + } } \ No newline at end of file