Skip to content

Commit 4e2017a

Browse files
authored
fix: correctly apply change profiles filter at pipeline (#934)
1 parent ca56c3d commit 4e2017a

9 files changed

Lines changed: 629 additions & 6 deletions

File tree

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ plugins {
1919

2020
allprojects {
2121
group = "io.flamingock"
22-
val declaredVersion = "1.4.3-SNAPSHOT"
22+
val declaredVersion = "1.4.4-SNAPSHOT"
2323
version = VersionManager.resolveVersion(declaredVersion, project.hasProperty("release"))
2424

2525
extra["generalUtilVersion"] = "1.5.3"

core/flamingock-core/src/main/java/io/flamingock/internal/core/pipeline/loaded/LoadedPipeline.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,16 @@ public List<AbstractLoadedStage> getStages() {
108108
return loadedStages;
109109
}
110110

111+
/**
112+
* Returns the change filters contributed by plugins and applied at runtime construction
113+
* time. A change is included in the runtime pipeline only if every filter returns
114+
* {@code true} for it; any filter returning {@code false} excludes the change. May be
115+
* empty when no plugin contributed a filter.
116+
*/
117+
public Collection<ChangeFilter> getChangeFilters() {
118+
return changeFilters == null ? Collections.emptyList() : changeFilters;
119+
}
120+
111121
@Override
112122
public Optional<AbstractLoadedChange> getLoadedChange(String changeId) {
113123
return loadedStages.stream()

core/flamingock-core/src/main/java/io/flamingock/internal/core/pipeline/loaded/stage/AbstractLoadedStage.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,31 @@ public Collection<AbstractLoadedChange> getChanges() {
103103
return changes;
104104
}
105105

106+
/**
107+
* Returns a new instance of the same concrete stage type carrying the provided changes
108+
* instead of this stage's current changes. Name, type, and the per-subclass validation
109+
* context are preserved.
110+
*
111+
* <p>Used at runtime construction time to materialize a stage with a filtered subset
112+
* of changes without mutating the original {@code AbstractLoadedStage} (whose
113+
* {@code changes} collection is final and immutable post-construction). The copy is
114+
* produced by reflecting on the concrete subclass to find its public
115+
* {@code (String, StageType, Collection)} constructor — every concrete subclass
116+
* ({@link DefaultLoadedStage}, {@link LegacyLoadedStage}, {@link SystemLoadedStage})
117+
* exposes one.
118+
*/
119+
public AbstractLoadedStage withChanges(Collection<AbstractLoadedChange> newChanges) {
120+
try {
121+
return getClass()
122+
.getConstructor(String.class, StageType.class, Collection.class)
123+
.newInstance(getName(), getType(), newChanges);
124+
} catch (ReflectiveOperationException e) {
125+
throw new IllegalStateException(
126+
"Cannot copy stage [" + getName() + "] of type " + getClass().getName()
127+
+ " with filtered changes", e);
128+
}
129+
}
130+
106131

107132

108133
/**

core/flamingock-core/src/main/java/io/flamingock/internal/core/pipeline/run/PipelineRun.java

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
import io.flamingock.internal.common.core.response.data.PlannerVerdict;
2626
import io.flamingock.internal.common.core.response.data.StageResult;
2727
import io.flamingock.internal.common.core.response.data.StageState;
28+
import io.flamingock.internal.core.change.filter.ChangeFilter;
29+
import io.flamingock.internal.core.change.loaded.AbstractLoadedChange;
2830
import io.flamingock.internal.core.pipeline.execution.StageExecutionException;
2931
import io.flamingock.internal.core.pipeline.loaded.LoadedPipeline;
3032
import io.flamingock.internal.core.pipeline.loaded.stage.AbstractLoadedStage;
@@ -33,6 +35,7 @@
3335
import java.time.Instant;
3436
import java.util.ArrayList;
3537
import java.util.Arrays;
38+
import java.util.Collection;
3639
import java.util.Collections;
3740
import java.util.HashMap;
3841
import java.util.LinkedHashMap;
@@ -52,14 +55,70 @@ public class PipelineRun {
5255
public static PipelineRun of(LoadedPipeline pipeline) {
5356
// Static-structure validation runs as part of construction. Builder-time validation in
5457
// AbstractChangeRunnerBuilder still fails fast on malformed pipelines; this call covers
55-
// the runtime entry point so callers don't have to validate separately.
58+
// the runtime entry point so callers don't have to validate separately. Validation
59+
// operates on the unfiltered pipeline so structural checks (duplicate IDs, empty
60+
// stages, etc.) catch issues regardless of which changes any plugin would exclude.
5661
pipeline.validate();
62+
Collection<ChangeFilter> filters = pipeline.getChangeFilters();
5763
List<AbstractLoadedStage> stages = new ArrayList<>();
58-
pipeline.getSystemStage().ifPresent(stages::add);
59-
stages.addAll(pipeline.getStages());
64+
// Apply the same drop-empty rule to the system stage as to user stages. A stage is
65+
// dropped only when filtering actually removed every change from a stage that had
66+
// some to begin with. A stage that was already empty before filtering is preserved
67+
// unchanged; this matches the pre-existing "empty stages survive" contract used by
68+
// builder-mocked tests and by incremental compile-time flows where a stage may be
69+
// temporarily empty before any @Change has been added to its package.
70+
pipeline.getSystemStage()
71+
.map(stage -> applyFilters(stage, filters))
72+
.filter(result -> !result.filteredToEmpty)
73+
.map(result -> result.stage)
74+
.ifPresent(stages::add);
75+
for (AbstractLoadedStage stage : pipeline.getStages()) {
76+
FilterResult result = applyFilters(stage, filters);
77+
if (!result.filteredToEmpty) {
78+
stages.add(result.stage);
79+
}
80+
}
6081
return of(stages);
6182
}
6283

84+
/**
85+
* Result of running change filters against a single stage. {@code stage} is either the
86+
* original stage (no filter removed any change, OR the original was empty, OR no filters
87+
* were provided) or a new instance with the surviving changes. {@code filteredToEmpty}
88+
* is {@code true} only when the filter actually removed every change from a non-empty
89+
* stage — that's the signal the caller uses to drop the stage from the runtime list.
90+
*/
91+
private static final class FilterResult {
92+
final AbstractLoadedStage stage;
93+
final boolean filteredToEmpty;
94+
95+
FilterResult(AbstractLoadedStage stage, boolean filteredToEmpty) {
96+
this.stage = stage;
97+
this.filteredToEmpty = filteredToEmpty;
98+
}
99+
}
100+
101+
/**
102+
* Applies the given change filters to a stage's changes, returning a {@link FilterResult}
103+
* that preserves the original stage reference when no filter removed any change. Filters
104+
* are AND-composed: a change is kept only if every filter returns {@code true} for it.
105+
*/
106+
private static FilterResult applyFilters(AbstractLoadedStage stage, Collection<ChangeFilter> filters) {
107+
Collection<AbstractLoadedChange> originalChanges = stage.getChanges();
108+
if (filters == null || filters.isEmpty() || originalChanges == null || originalChanges.isEmpty()) {
109+
// No filtering meaningfully applied. Preserve the original stage unchanged — this
110+
// includes the case of a pre-existing empty stage (filteredToEmpty=false).
111+
return new FilterResult(stage, false);
112+
}
113+
Collection<AbstractLoadedChange> surviving = originalChanges.stream()
114+
.filter(change -> filters.stream().allMatch(f -> f.filter(change)))
115+
.collect(Collectors.toList());
116+
if (surviving.size() == originalChanges.size()) {
117+
return new FilterResult(stage, false);
118+
}
119+
return new FilterResult(stage.withChanges(surviving), surviving.isEmpty());
120+
}
121+
63122
public static PipelineRun of(List<AbstractLoadedStage> stages) {
64123
// Partition by StageType in dependency order, skipping empty types (sparse). The resulting
65124
// flat list is the concatenation of blocks in canonical order — a single source of truth

0 commit comments

Comments
 (0)