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
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<Project>
<PropertyGroup>
<JasperFxVersion>2.14.0</JasperFxVersion>
<JasperFxVersion>2.14.1</JasperFxVersion>
<LangVersion>13</LangVersion>
<NoWarn>1570;1571;1572;1573;1574;1587;1591;1701;1702;1711;1735;0618</NoWarn>
<Authors>Jeremy D. Miller;Jaedyn Tonee</Authors>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,53 @@ public partial class AllSync : SingleStreamProjection<MyAggregate, Guid>
generated.ShouldContain("typeof(global::Test.MyEvent)");
}

[Fact]
public void di_activated_projection_without_parameterless_ctor_compiles()
{
// #4185 / CS7036 regression: a projection registered via AddProjectionWithServices has only a
// constructor with injected dependencies (no parameterless ctor). The evolver's private shadow
// instance must not be created with `new T()` — that won't compile. It is instantiated without
// running the constructor instead (RuntimeHelpers.GetUninitializedObject).
var source = @"
using System;
using JasperFx.Events;
using JasperFx.Events.Aggregation;
using JasperFx.Events.Projections;

namespace Test;

public class MyAggregate { public int Count { get; set; } }
public class MyEvent { }
public class CreateEvent { }
public interface ISecondaryStore { }

public abstract class SingleStreamProjection<TDoc, TId> : JasperFxSingleStreamProjectionBase<TDoc, TId, object, object>
where TDoc : notnull where TId : notnull
{
protected SingleStreamProjection() : base(AggregationScope.SingleStream) { }
}

public partial class DiActivated : SingleStreamProjection<MyAggregate, Guid>
{
private readonly ISecondaryStore _secondaryStore;
public DiActivated(ISecondaryStore secondaryStore) { _secondaryStore = secondaryStore; }

public MyAggregate Create(CreateEvent e) => new MyAggregate();
public void Apply(MyEvent e, MyAggregate agg) { agg.Count++; }
}
";
var (_, generatedSources) = RunGenerator(source);
var generated = string.Join("\n", generatedSources);

// Shadow instance is created without invoking the (parameterized) constructor.
generated.ShouldContain("GetUninitializedObject(typeof(global::Test.DiActivated))");
generated.ShouldNotContain("new global::Test.DiActivated()");

// The generated evolver must compile — no CS7036 "no argument for required parameter".
var diagnostics = CompileWithGenerator(source);
diagnostics.ShouldNotContain(d => d.Id == "CS7036");
}

[Fact]
public void partial_projection_for_required_member_aggregate_suppresses_snapshot_fallback()
{
Expand Down
27 changes: 26 additions & 1 deletion src/JasperFx.Events.SourceGenerator/EvolverCodeEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ public static string EmitPartialProjection(CandidateInfo info)
if (NeedsProjectionInstance(info))
{
sb.AppendLine($" private {projectionFullName}? _projectionInstance;");
sb.AppendLine($" private {projectionFullName} _projection => _projectionInstance ??= new {projectionFullName}();");
sb.AppendLine($" private {projectionFullName} _projection => _projectionInstance ??= {BuildProjectionInstanceExpression(info.ClassSymbol, projectionFullName)};");
sb.AppendLine();
}

Expand Down Expand Up @@ -137,6 +137,31 @@ private static bool NeedsProjectionInstance(CandidateInfo info)
return info.Methods.Any(m => !m.IsStatic && !m.IsOnAggregate);
}

/// <summary>
/// Builds the expression that creates the evolver's private "shadow" projection instance
/// (used only to invoke the projection's stateless instance Apply/Create/ShouldDelete event
/// methods). When the projection has a public parameterless constructor we just <c>new</c> it.
/// A DI-activated projection (registered via <c>AddProjectionWithServices</c>) has only a
/// constructor with injected dependencies and therefore no parameterless ctor — <c>new T()</c>
/// would not compile (#4185 / CS7036). In that case instantiate without running the constructor
/// (mirrors the aggregate no-parameterless-ctor fallback in
/// <see cref="BuildAggregateConstructorExpression"/>). Injected services are null on this shadow
/// instance, which is correct for the inline evolve path: event Apply/Create methods that
/// dereference injected services are not supported there.
/// </summary>
private static string BuildProjectionInstanceExpression(INamedTypeSymbol projectionType, string projectionFullName)
{
var hasPublicParameterless = projectionType.InstanceConstructors.Any(c =>
c.Parameters.Length == 0 && c.DeclaredAccessibility == Accessibility.Public);

if (hasPublicParameterless)
{
return $"new {projectionFullName}()";
}

return $"({projectionFullName})global::System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(typeof({projectionFullName}))";
}

/// <summary>
/// The IGeneratedAsyncEvolver / IGeneratedAsyncDetermineAction contracts type the session as
/// <c>object</c> so the runtime stays free of the session generic parameter. The dispatch body
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,15 +164,27 @@ private bool tryUseAssemblyRegisteredEvolver()
continue;
}

// Aggregate-only (self-aggregating) evolver: acceptable fallback, but keep scanning in case
// a projection-specific registration for this exact projection appears later.
selected ??= attr;
// Aggregate-only (self-aggregating) evolver: acceptable fallback, but ONLY when its evolver
// implements a <TDoc, TId> generated contract for THIS identity type. When the same aggregate
// is registered against multiple identity types (#297 — e.g. AggregateStream<CountOfLetters>
// with both Guid and string ids) the generator emits one evolver per TId, all keyed on the
// aggregate type with a null ProjectionType. Selecting purely by aggregate type could pick the
// wrong-TId evolver, whose strongly-typed interface checks below would all fail, leaving _evolve
// unwired and tripping the "no source-generated dispatcher" backstop. Keep scanning in case a
// projection-specific registration for this exact projection appears later.
if (evolverImplementsIdentityContract(attr.EvolverType))
{
selected ??= attr;
}
}

if (selected != null)
{
var evolverType = selected.EvolverType;

// (selection above already guaranteed this for aggregate-only matches; a
// projection-specific match is bound to a single TId by construction.)

// Check for IGeneratedSyncEvolver<TDoc, TId>. Skip this branch when
// the projection has ShouldDelete methods — a plain SyncEvolver
// only knows about Apply/Create on the aggregate type itself, so
Expand Down Expand Up @@ -307,6 +319,20 @@ private bool tryUseAssemblyRegisteredEvolver()
return false;
}

/// <summary>
/// Whether <paramref name="evolverType"/> implements one of the generated <c>&lt;TDoc, TId&gt;</c>
/// dispatcher contracts for THIS projection's identity type. Used to disambiguate the self-aggregating
/// fallback when one aggregate is registered against multiple identity types (#297) — only the evolver
/// emitted for the matching TId can actually be wired below.
/// </summary>
private static bool evolverImplementsIdentityContract(Type evolverType)
{
return typeof(IGeneratedSyncEvolver<TDoc, TId>).IsAssignableFrom(evolverType)
|| typeof(IGeneratedSyncDetermineAction<TDoc, TId>).IsAssignableFrom(evolverType)
|| typeof(IGeneratedAsyncDetermineAction<TDoc, TId>).IsAssignableFrom(evolverType)
|| typeof(IGeneratedAsyncEvolver<TDoc, TId>).IsAssignableFrom(evolverType);
}

/// <summary>
/// Gathers <see cref="GeneratedEvolverAttribute"/> registrations from the aggregate's assembly and,
/// when different, the concrete projection's own assembly. A partial projection whose aggregate is
Expand Down
Loading