Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 118 additions & 1 deletion src/EventTests/Projections/EventProjectionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,121 @@ protected override void storeEntity<T>(FakeOperations ops, T entity)
{
throw new NotImplementedException();
}
}
}
/// <summary>
/// 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.
/// </summary>
public class EventProjectionTeardownTests
{
private static Type[] cleanupTypes(ProjectionBase projection)
=> projection.Options.CleanUps.OfType<DeleteDocuments>().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<DocOne>();

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<DocTwo>();

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();
}
37 changes: 37 additions & 0 deletions src/JasperFx.Events/Projections/JasperFxEventProjectionBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,45 @@ void IEntityStorage<TOperations>.Store<T>(TOperations ops, T entity)

protected abstract void storeEntity<T>(TOperations ops, T entity) where T : notnull;

/// <summary>
/// jasperfx#626: whether the document types this projection publishes are automatically
/// registered as teardown targets (<see cref="AsyncOptions.DeleteViewTypeOnTeardown(Type)" />),
/// 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.
///
/// <para>
/// 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 <see cref="ProjectionBase.Options" />'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 <c>Options.DeleteViewTypeOnTeardown&lt;T&gt;()</c>
/// for the ones you do want wiped.
/// </para>
/// </summary>
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)
Expand Down
Loading