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
9 changes: 7 additions & 2 deletions src/BbQ.Events/Engine/DefaultProjectionEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@
{

// Check interfaces for explicit implementation
foreach (var iface in type.GetInterfaces())

Check warning on line 227 in src/BbQ.Events/Engine/DefaultProjectionEngine.cs

View workflow job for this annotation

GitHub Actions / build-test

Dereference of a possibly null reference.

Check warning on line 227 in src/BbQ.Events/Engine/DefaultProjectionEngine.cs

View workflow job for this annotation

GitHub Actions / build-test

Dereference of a possibly null reference.
{
var map = type.GetInterfaceMap(iface);
for (int i = 0; i < map.InterfaceMethods.Length; i++)
Expand Down Expand Up @@ -252,7 +252,7 @@
{

// Check interfaces for explicit implementation
foreach (var iface in type.GetInterfaces())

Check warning on line 255 in src/BbQ.Events/Engine/DefaultProjectionEngine.cs

View workflow job for this annotation

GitHub Actions / build-test

Dereference of a possibly null reference.

Check warning on line 255 in src/BbQ.Events/Engine/DefaultProjectionEngine.cs

View workflow job for this annotation

GitHub Actions / build-test

Dereference of a possibly null reference.
{
var map = type.GetInterfaceMap(iface);
for (int i = 0; i < map.InterfaceMethods.Length; i++)
Expand Down Expand Up @@ -377,16 +377,21 @@
// Fall back to reading from attribute
var attribute = concreteType.GetCustomAttribute<ProjectionAttribute>();

return new ProjectionOptions
var options = new ProjectionOptions
{
ProjectionName = concreteType.Name,
MaxDegreeOfParallelism = attribute?.MaxDegreeOfParallelism ?? 1,
CheckpointBatchSize = attribute?.CheckpointBatchSize ?? 100,
StartupMode = attribute?.StartupMode ?? ProjectionStartupMode.Resume,
ChannelCapacity = attribute?.ChannelCapacity ?? 1000,
BackpressureStrategy = attribute?.BackpressureStrategy ?? BackpressureStrategy.Block,
// ErrorHandling is already initialized by property initializer to default values
};

// Use ProjectionNameResolver for consistent name resolution
// Since ProjectionName is not explicitly set in options, the resolver will use the type name
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment "Since ProjectionName is not explicitly set in options, the resolver will use the type name" is slightly misleading. The ProjectionName property has a default value of string.Empty (from ProjectionOptions.cs line 55), not null. The resolver checks string.IsNullOrWhiteSpace which correctly treats empty string as "not set", but the comment could be clearer.

Consider revising to: "Since ProjectionName is empty in the newly created options, the resolver will fall back to the type name"

Suggested change
// Since ProjectionName is not explicitly set in options, the resolver will use the type name
// Since ProjectionName is empty in the newly created options, the resolver will fall back to the type name

Copilot uses AI. Check for mistakes.
options.ProjectionName = ProjectionNameResolver.Resolve(concreteType, options);
Comment on lines +391 to +392
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For clarity and consistency, consider passing null instead of options to ProjectionNameResolver.Resolve here. Since we're creating new options with no ProjectionName set, passing the options object is redundant. The call could be simplified to:

options.ProjectionName = ProjectionNameResolver.Resolve(concreteType, null);

This makes it clearer that we're explicitly choosing to use the type name fallback rather than checking an options object we know has no ProjectionName.

Suggested change
// Since ProjectionName is not explicitly set in options, the resolver will use the type name
options.ProjectionName = ProjectionNameResolver.Resolve(concreteType, options);
// Since ProjectionName is not explicitly set in options, explicitly use the type name fallback
options.ProjectionName = ProjectionNameResolver.Resolve(concreteType, null);

Copilot uses AI. Check for mistakes.

return options;
}

/// <summary>
Expand Down
4 changes: 3 additions & 1 deletion src/BbQ.Events/Engine/DefaultProjectionRebuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,9 @@ public IEnumerable<string> GetRegisteredProjections()
var registration = ProjectionHandlerRegistry.GetHandlerRegistration(eventType, handlerType);
if (registration != null)
{
projectionNames.Add(registration.ConcreteType.Name);
var projectionOptions = ProjectionHandlerRegistry.GetProjectionOptions(registration.ConcreteType.Name);
var resolvedName = ProjectionNameResolver.Resolve(registration.ConcreteType, projectionOptions);
projectionNames.Add(resolvedName);
}
}
}
Expand Down
17 changes: 12 additions & 5 deletions src/BbQ.Events/Engine/DefaultReplayService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ public async Task ReplayAsync(
var registration = ProjectionHandlerRegistry.GetHandlerRegistration(eventType, handlerType);
if (registration != null)
{
registeredProjections.Add(registration.ConcreteType.Name);
var projectionOptions = ProjectionHandlerRegistry.GetProjectionOptions(registration.ConcreteType.Name);
var resolvedName = ProjectionNameResolver.Resolve(registration.ConcreteType, projectionOptions);
registeredProjections.Add(resolvedName);
}
}
}
Expand Down Expand Up @@ -241,13 +243,18 @@ private async Task StreamEventsAndProcessAsync(
foreach (var handlerType in handlers)
{
var registration = ProjectionHandlerRegistry.GetHandlerRegistration(eventType, handlerType);
if (registration != null && registration.ConcreteType.Name == projectionName)
if (registration != null)
{
if (!projectionHandlers.ContainsKey(eventType))
var projectionOptions = ProjectionHandlerRegistry.GetProjectionOptions(registration.ConcreteType.Name);
var resolvedName = ProjectionNameResolver.Resolve(registration.ConcreteType, projectionOptions);
if (resolvedName == projectionName)
{
projectionHandlers[eventType] = new List<Type>();
if (!projectionHandlers.ContainsKey(eventType))
{
projectionHandlers[eventType] = new List<Type>();
}
projectionHandlers[eventType].Add(handlerType);
}
projectionHandlers[eventType].Add(handlerType);
}
}
}
Expand Down
43 changes: 43 additions & 0 deletions src/BbQ.Events/Engine/ProjectionNameResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
namespace BbQ.Events.Engine;

/// <summary>
/// Provides consistent projection name resolution across all projection components.
/// </summary>
/// <remarks>
/// This resolver ensures that projection names are resolved using the same logic everywhere:
/// - DefaultProjectionEngine (runtime processing)
/// - DefaultReplayService (replay operations)
/// - DefaultProjectionRebuilder (rebuild operations)
///
/// Resolution logic:
/// 1. If ProjectionOptions.ProjectionName is set and not empty → use it
/// 2. Otherwise → use the projection type name
///
/// This consistency prevents mismatched projection identifiers between runtime processing
/// and replay/rebuild operations.
/// </remarks>
public static class ProjectionNameResolver
{
/// <summary>
/// Resolves the projection name for a given projection type and options.
/// </summary>
/// <param name="projectionType">The concrete type of the projection handler.</param>
/// <param name="options">Optional projection options that may contain an explicit projection name.</param>
/// <returns>The resolved projection name.</returns>
public static string Resolve(Type projectionType, ProjectionOptions? options)
{
if (projectionType == null)
{
throw new ArgumentNullException(nameof(projectionType));
}

// If ProjectionOptions.ProjectionName is explicitly set, use it
if (!string.IsNullOrWhiteSpace(options?.ProjectionName))
{
return options.ProjectionName;
}

// Otherwise, fall back to the projection type name
return projectionType.Name;
}
}
140 changes: 140 additions & 0 deletions tests/BbQ.Cqrs.Tests/ProjectionNameResolverTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
using BbQ.Events.Engine;
using NUnit.Framework;

namespace BbQ.Cqrs.Tests;

/// <summary>
/// Tests for ProjectionNameResolver to ensure consistent projection name resolution.
/// </summary>
[TestFixture]
public class ProjectionNameResolverTests
{
// Test projection class
private class TestProjection { }

[Test]
public void Resolve_WithNullProjectionType_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
ProjectionNameResolver.Resolve(null!, null));
}

[Test]
public void Resolve_WithNullOptions_ReturnsTypeName()
{
// Arrange
var projectionType = typeof(TestProjection);

// Act
var result = ProjectionNameResolver.Resolve(projectionType, null);

// Assert
Assert.That(result, Is.EqualTo("TestProjection"));
}

[Test]
public void Resolve_WithOptionsButNullProjectionName_ReturnsTypeName()
{
// Arrange
var projectionType = typeof(TestProjection);
var options = new ProjectionOptions
{
ProjectionName = null!
};

// Act
var result = ProjectionNameResolver.Resolve(projectionType, options);

// Assert
Assert.That(result, Is.EqualTo("TestProjection"));
}

[Test]
public void Resolve_WithOptionsAndEmptyProjectionName_ReturnsTypeName()
{
// Arrange
var projectionType = typeof(TestProjection);
var options = new ProjectionOptions
{
ProjectionName = string.Empty
};

// Act
var result = ProjectionNameResolver.Resolve(projectionType, options);

// Assert
Assert.That(result, Is.EqualTo("TestProjection"));
}

[Test]
public void Resolve_WithOptionsAndWhitespaceProjectionName_ReturnsTypeName()
{
// Arrange
var projectionType = typeof(TestProjection);
var options = new ProjectionOptions
{
ProjectionName = " "
};

// Act
var result = ProjectionNameResolver.Resolve(projectionType, options);

// Assert
Assert.That(result, Is.EqualTo("TestProjection"));
}

[Test]
public void Resolve_WithOptionsAndCustomProjectionName_ReturnsCustomName()
{
// Arrange
var projectionType = typeof(TestProjection);
var customName = "CustomProjectionName";
var options = new ProjectionOptions
{
ProjectionName = customName
};

// Act
var result = ProjectionNameResolver.Resolve(projectionType, options);

// Assert
Assert.That(result, Is.EqualTo(customName));
}

[Test]
public void Resolve_WithDifferentProjectionTypes_ReturnsCorrectTypeNames()
{
// Arrange & Act & Assert
Assert.That(
ProjectionNameResolver.Resolve(typeof(TestProjection), null),
Is.EqualTo("TestProjection"));

Assert.That(
ProjectionNameResolver.Resolve(typeof(ProjectionNameResolverTests), null),
Is.EqualTo("ProjectionNameResolverTests"));

Assert.That(
ProjectionNameResolver.Resolve(typeof(string), null),
Is.EqualTo("String"));
}

[Test]
public void Resolve_ConsistencyBetweenCalls_ReturnsSameResult()
{
// Arrange
var projectionType = typeof(TestProjection);
var options = new ProjectionOptions
{
ProjectionName = "ConsistentName"
};

// Act
var result1 = ProjectionNameResolver.Resolve(projectionType, options);
var result2 = ProjectionNameResolver.Resolve(projectionType, options);

// Assert
Assert.That(result1, Is.EqualTo(result2));
Assert.That(result1, Is.EqualTo("ConsistentName"));
}
}
Comment on lines +1 to +140
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests comprehensively cover the ProjectionNameResolver itself, but there are no integration tests that verify the fix works end-to-end. Consider adding a test that:

  1. Registers a projection with a custom ProjectionName via AddProjection configureOptions
  2. Verifies that DefaultReplayService.GetRegisteredProjections() returns the custom name (not the type name)
  3. Verifies that replay operations target the correct projection with the custom name

This would ensure the fix solves the original issue described in the PR.

Copilot uses AI. Check for mistakes.
Loading