diff --git a/src/EventTests/Projections/EventProjectionTests.cs b/src/EventTests/Projections/EventProjectionTests.cs index 1774b9d..ad1f680 100644 --- a/src/EventTests/Projections/EventProjectionTests.cs +++ b/src/EventTests/Projections/EventProjectionTests.cs @@ -112,4 +112,121 @@ protected override void storeEntity(FakeOperations ops, T entity) { throw new NotImplementedException(); } -} \ No newline at end of file +} +/// +/// jasperfx#626 — JasperFxEventProjectionBase's constructor never touched Options, so an +/// EventProjection registered NO teardown targets: a rebuild deleted the progression row and then +/// re-projected into a table still holding the previous run's documents, and the ProjectionScenario +/// harness wipe (which reads Options.StorageTypes) did nothing after an event projection. Aggregation +/// projections have always registered their single TDoc; nothing in the API surface signalled the +/// difference, so every event projection author had to know it independently. +/// +public class EventProjectionTeardownTests +{ + private static Type[] cleanupTypes(ProjectionBase projection) + => projection.Options.CleanUps.OfType().Select(x => x.DocumentType).ToArray(); + + [Fact] + public void published_types_become_teardown_targets() + { + var projection = new CreatesDocumentsProjection(); + + // Nothing is registered until assembly -- the source generator emits its + // RegisterPublishedType calls into the subclass constructor, after the base one + projection.Options.CleanUps.ShouldBeEmpty(); + + projection.AssembleAndAssertValidity(); + + cleanupTypes(projection).ShouldBe([typeof(DocOne)]); + projection.Options.StorageTypes.ShouldContain(typeof(DocOne)); + } + + [Fact] + public void every_published_type_is_registered_not_just_the_first() + { + var projection = new CreatesTwoDocumentsProjection(); + projection.AssembleAndAssertValidity(); + + cleanupTypes(projection).ShouldBe([typeof(DocOne), typeof(DocTwo)], ignoreOrder: true); + } + + [Fact] + public void a_projection_that_publishes_nothing_registers_nothing() + { + var projection = new ConventionalEventProjection(); + projection.AssembleAndAssertValidity(); + + projection.Options.CleanUps.ShouldBeEmpty(); + } + + [Fact] + public void the_opt_out_wins_over_the_default() + { + // For a projection writing into storage that must not be truncated on rebuild + var projection = new AppendOnlyProjection(); + projection.AssembleAndAssertValidity(); + + projection.Options.CleanUps.ShouldBeEmpty(); + projection.Options.StorageTypes.ShouldBeEmpty(); + } + + [Fact] + public void a_hand_registered_type_is_not_duplicated() + { + var projection = new CreatesDocumentsProjection(); + projection.Options.DeleteViewTypeOnTeardown(); + + projection.AssembleAndAssertValidity(); + + cleanupTypes(projection).ShouldBe([typeof(DocOne)]); + } + + [Fact] + public void assembling_twice_does_not_duplicate_the_registrations() + { + // ProjectionGraph assembles on more than one path; a second pass must be a no-op + var projection = new CreatesDocumentsProjection(); + projection.AssembleAndAssertValidity(); + projection.AssembleAndAssertValidity(); + + cleanupTypes(projection).ShouldBe([typeof(DocOne)]); + } + + [Fact] + public void an_explicitly_registered_type_survives_the_opt_out() + { + // The documented "some but not all" recipe: opt out, then declare what you do want wiped + var projection = new AppendOnlyProjection(); + projection.Options.DeleteViewTypeOnTeardown(); + + projection.AssembleAndAssertValidity(); + + cleanupTypes(projection).ShouldBe([typeof(DocTwo)]); + } +} + +public class DocOne; + +public class DocTwo; + +public partial class CreatesDocumentsProjection : EventProjection +{ + public DocOne Create(AEvent e) => new(); +} + +public partial class CreatesTwoDocumentsProjection : EventProjection +{ + public DocOne Create(AEvent e) => new(); + + public DocTwo Create(BEvent e) => new(); +} + +public partial class AppendOnlyProjection : EventProjection +{ + public AppendOnlyProjection() + { + DeletePublishedTypesOnTeardown = false; + } + + public DocOne Create(AEvent e) => new(); +} diff --git a/src/JasperFx.Events/Projections/JasperFxEventProjectionBase.cs b/src/JasperFx.Events/Projections/JasperFxEventProjectionBase.cs index bb7ce67..7c78a3d 100644 --- a/src/JasperFx.Events/Projections/JasperFxEventProjectionBase.cs +++ b/src/JasperFx.Events/Projections/JasperFxEventProjectionBase.cs @@ -150,8 +150,45 @@ void IEntityStorage.Store(TOperations ops, T entity) protected abstract void storeEntity(TOperations ops, T entity) where T : notnull; + /// + /// jasperfx#626: whether the document types this projection publishes are automatically + /// registered as teardown targets (), + /// so a rebuild wipes the previous run's documents before re-projecting. True by default, which + /// matches what aggregation projections have always done for their single view type. + /// + /// + /// Set to false when this projection writes into storage that must NOT be truncated on rebuild — + /// an append-only audit table, or documents another projection owns. With it off, nothing is + /// registered automatically and 's teardown rules are + /// entirely yours to declare. It is all-or-nothing: to keep automatic registration for some + /// published types only, turn it off and call Options.DeleteViewTypeOnTeardown<T>() + /// for the ones you do want wiped. + /// + /// + public bool DeletePublishedTypesOnTeardown { get; set; } = true; + + // jasperfx#626: an EventProjection can publish several document types, so the base constructor + // cannot do what JasperFxAggregationProjectionBase does with its single TDoc -- and it could not + // do it there anyway, because the source generator emits its RegisterPublishedType calls into the + // subclass constructor, which runs AFTER this base one. Deferring to AssembleAndAssertValidity + // (registration time, via ProjectionGraph) sees the complete set and lets a subclass constructor + // turn the behavior off before it happens. Idempotent: a type the author already registered by + // hand, or a previous pass, is skipped rather than duplicated. + private void registerPublishedTypesForTeardown() + { + if (!DeletePublishedTypesOnTeardown) return; + + foreach (var publishedType in PublishedTypes().ToArray()) + { + if (Options.StorageTypes.Contains(publishedType)) continue; + Options.DeleteViewTypeOnTeardown(publishedType); + } + } + public sealed override void AssembleAndAssertValidity() { + registerPublishedTypesForTeardown(); + var applyMethod = GetType()!.GetMethod(nameof(ApplyAsync))!; var isOverridden = applyMethod.DeclaringType!.Assembly != typeof(JasperFxEventProjectionBase<,>).Assembly; if (isOverridden)