diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 7e64f9d..4c5021c 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -28,7 +28,7 @@ samples/ # Sample applications
## Technology Stack
- **Language**: C# on .NET 10
-- **Test framework**: xUnit with AutoFixture, FluentAssertions, and Moq
+- **Test framework**: xUnit with AutoFixture, Shouldly, and Moq
- **Serialization**: Newtonsoft.Json
- **Versioning**: Nerdbank.GitVersioning (`version.json`)
- **Package management**: Central package management via `Directory.Packages.props`
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 318f773..9e04c49 100755
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -5,14 +5,14 @@
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
@@ -37,11 +37,12 @@
-
-
+
+
+
@@ -53,4 +54,4 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
-
\ No newline at end of file
+
diff --git a/Papst.EventStore.slnx b/Papst.EventStore.slnx
index 0c05f62..f95c392 100644
--- a/Papst.EventStore.slnx
+++ b/Papst.EventStore.slnx
@@ -11,6 +11,7 @@
+
diff --git a/README.md b/README.md
index 4dcd047..b833988 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,60 @@ Please refer to the documentation in the relevant implementation sources:
* [Azure Cosmos](./src/Papst.EventStore.AzureCosmos/README.md)
* [Entity Framework Core](./src/Papst.EventStore.EntityFrameworkCore/README.md)
+## Event Catalog
+
+The **Event Catalog** provides a queryable registry of all events associated with a given entity type, including metadata (description, constraints) and a compile-time generated JSON Schema. This is useful for documentation, API discovery, and runtime introspection.
+
+### Registering Events for the Catalog
+
+Use the generic `EventNameAttribute` to associate an event with an entity:
+
+```csharp
+[EventName("UserCreated", Description = "Raised when a new user is created", Constraints = new[] { "Create" })]
+public record UserCreatedEvent(string Name, string Email);
+
+[EventName("UserRenamed", Description = "Raised when a user changes their name", Constraints = new[] { "Update" })]
+public record UserRenamedEvent(string NewName);
+```
+
+Events can also be discovered automatically via aggregator registrations (`EventAggregatorBase` / `IEventAggregator`).
+
+The same event name may exist for different entity types — the catalog tracks them independently per entity.
+
+### Code Generation
+
+When the `Papst.EventStore.CodeGeneration` package is referenced, the source generator automatically emits an `AddCodeGeneratedEventCatalog()` extension method alongside the existing `AddCodeGeneratedEvents()`:
+
+```csharp
+var services = new ServiceCollection();
+services.AddCodeGeneratedEvents();
+services.AddCodeGeneratedEventCatalog();
+```
+
+### Querying the Catalog
+
+Resolve `IEventCatalog` from DI and use the async API:
+
+```csharp
+var catalog = serviceProvider.GetRequiredService();
+
+// List all events for an entity
+IReadOnlyList events = await catalog.ListEvents();
+
+// Filter by name and/or constraints
+IReadOnlyList filtered = await catalog.ListEvents(
+ name: "UserCreated",
+ constraints: new[] { "Create" }
+);
+
+// Get event details including JSON Schema (global lookup)
+EventCatalogEventDetails? details = await catalog.GetEventDetails("UserCreated");
+
+// Get event details scoped to a specific entity (for duplicate event names across entities)
+EventCatalogEventDetails? scoped = await catalog.GetEventDetails("UserCreated");
+```
+
+A full working sample is available at [`samples/SampleEventCatalog/`](./samples/SampleEventCatalog/).
# Changelog
diff --git a/samples/SampleEventCatalog/Events.cs b/samples/SampleEventCatalog/Events.cs
new file mode 100644
index 0000000..f00d905
--- /dev/null
+++ b/samples/SampleEventCatalog/Events.cs
@@ -0,0 +1,37 @@
+using Papst.EventStore.Aggregation.EventRegistration;
+
+namespace SampleEventCatalog;
+
+// Entity types
+public record User(Guid Id, string Name, string Email, bool IsActive);
+public record Order(Guid Id, Guid UserId, decimal Total, OrderStatus Status, List Items);
+public record OrderItem(string ProductName, int Quantity, decimal Price);
+
+public enum OrderStatus
+{
+ Pending,
+ Confirmed,
+ Shipped,
+ Delivered,
+ Cancelled
+}
+
+// User events
+[EventName("UserCreated", Description = "Raised when a new user is created", Constraints = new[] { "Create" })]
+public record UserCreatedEvent(string Name, string Email);
+
+[EventName("UserRenamed", Description = "Raised when a user changes their name", Constraints = new[] { "Update" })]
+public record UserRenamedEvent(string NewName);
+
+[EventName("UserDeactivated", Description = "Raised when a user is deactivated", Constraints = new[] { "Update", "Admin" })]
+public record UserDeactivatedEvent(string Reason);
+
+// Order events
+[EventName("OrderPlaced", Description = "Raised when a new order is placed", Constraints = new[] { "Create" })]
+public record OrderPlacedEvent(Guid UserId, List Items, decimal Total);
+
+[EventName("OrderStatusChanged", Description = "Raised when order status changes", Constraints = new[] { "Update" })]
+public record OrderStatusChangedEvent(OrderStatus NewStatus);
+
+[EventName("OrderCancelled", Description = "Raised when an order is cancelled", Constraints = new[] { "Delete" })]
+public record OrderCancelledEvent(string CancellationReason, DateTime CancelledAt);
diff --git a/samples/SampleEventCatalog/Program.cs b/samples/SampleEventCatalog/Program.cs
new file mode 100644
index 0000000..dac5c1e
--- /dev/null
+++ b/samples/SampleEventCatalog/Program.cs
@@ -0,0 +1,222 @@
+using Json.Schema;
+using Microsoft.Extensions.DependencyInjection;
+using Papst.EventStore.EventCatalog;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace SampleEventCatalog;
+
+public class Program
+{
+ public static async Task Main(string[] args)
+ {
+ var services = new ServiceCollection();
+
+ // Register both generated events and catalog
+ services.AddCodeGeneratedEvents();
+ services.AddCodeGeneratedEventCatalog();
+
+ var serviceProvider = services.BuildServiceProvider();
+ var catalog = serviceProvider.GetRequiredService();
+
+ Console.WriteLine("=== Event Catalog Sample ===");
+ Console.WriteLine();
+
+ // List all events for User entity
+ Console.WriteLine("--- Events for User entity ---");
+ var userEvents = await catalog.ListEvents();
+ foreach (var entry in userEvents)
+ {
+ Console.WriteLine($" Event: {entry.EventName}");
+ Console.WriteLine($" Description: {entry.Description ?? "(none)"}");
+ Console.WriteLine($" Constraints: {(entry.Constraints != null ? string.Join(", ", entry.Constraints) : "(none)")}");
+ Console.WriteLine();
+ }
+
+ // List all events for Order entity
+ Console.WriteLine("--- Events for Order entity ---");
+ var orderEvents = await catalog.ListEvents();
+ foreach (var entry in orderEvents)
+ {
+ Console.WriteLine($" Event: {entry.EventName}");
+ Console.WriteLine($" Description: {entry.Description ?? "(none)"}");
+ Console.WriteLine($" Constraints: {(entry.Constraints != null ? string.Join(", ", entry.Constraints) : "(none)")}");
+ Console.WriteLine();
+ }
+
+ // Filter events by constraint
+ Console.WriteLine("--- User events with 'Update' constraint ---");
+ var updateEvents = await catalog.ListEvents(constraints: new[] { "Update" });
+ foreach (var entry in updateEvents)
+ {
+ Console.WriteLine($" Event: {entry.EventName} ({entry.Description})");
+ }
+ Console.WriteLine();
+
+ // Filter events by name
+ Console.WriteLine("--- Looking up 'UserCreated' event ---");
+ var filtered = await catalog.ListEvents(name: "UserCreated");
+ foreach (var entry in filtered)
+ {
+ Console.WriteLine($" Found: {entry.EventName} - {entry.Description}");
+ }
+ Console.WriteLine();
+
+ // Get event details with JSON schema (global lookup)
+ Console.WriteLine("--- Event Details with JSON Schema ---");
+ var details = await catalog.GetEventDetails("OrderPlaced");
+ if (details != null)
+ {
+ Console.WriteLine($" Event: {details.EventName}");
+ Console.WriteLine($" Description: {details.Description}");
+ Console.WriteLine($" Constraints: {(details.Constraints != null ? string.Join(", ", details.Constraints) : "(none)")}");
+ Console.WriteLine($" JSON Schema: {details.JsonSchema}");
+ }
+ Console.WriteLine();
+
+ // Get event details scoped to entity
+ Console.WriteLine("--- Entity-scoped Event Details for User/UserCreated ---");
+ var userCreatedDetails = await catalog.GetEventDetails("UserCreated");
+ if (userCreatedDetails != null)
+ {
+ Console.WriteLine($" Event: {userCreatedDetails.EventName}");
+ Console.WriteLine($" Description: {userCreatedDetails.Description}");
+ Console.WriteLine($" JSON Schema: {userCreatedDetails.JsonSchema}");
+ }
+
+ Console.WriteLine();
+
+ // --- JSON Schema: build a JSON object and validate it ---
+ await DemonstrateSchemaValidationAsync(catalog);
+ }
+
+ private static async Task DemonstrateSchemaValidationAsync(IEventCatalog catalog)
+ {
+ Console.WriteLine("=== JSON Schema Validation Demo ===");
+ Console.WriteLine();
+
+ var details = await catalog.GetEventDetails("UserCreated");
+ if (details is null)
+ {
+ Console.WriteLine(" Event 'UserCreated' not found in catalog.");
+ return;
+ }
+
+ Console.WriteLine($" Schema for '{details.EventName}':");
+ Console.WriteLine($" {FormatJson(details.JsonSchema)}");
+ Console.WriteLine();
+
+ var schema = JsonSchema.FromText(details.JsonSchema);
+
+ // --- Valid object ---
+ var validEvent = new JsonObject
+ {
+ ["name"] = "Alice",
+ ["email"] = "alice@example.com"
+ };
+
+ var validResult = schema.Evaluate(ToJsonElement(validEvent), new EvaluationOptions { OutputFormat = OutputFormat.List });
+
+ Console.WriteLine(" Valid event object:");
+ Console.WriteLine($" {validEvent.ToJsonString()}");
+ Console.WriteLine($" Validation: {(validResult.IsValid ? "✓ PASSED" : "✗ FAILED")}");
+ Console.WriteLine();
+
+ // --- Invalid: wrong type for 'name' ---
+ var invalidTypeEvent = new JsonObject
+ {
+ ["name"] = 42,
+ ["email"] = "bob@example.com"
+ };
+
+ var invalidTypeResult = schema.Evaluate(ToJsonElement(invalidTypeEvent), new EvaluationOptions { OutputFormat = OutputFormat.List });
+
+ Console.WriteLine(" Invalid event object (wrong type for 'name'):");
+ Console.WriteLine($" {invalidTypeEvent.ToJsonString()}");
+ Console.WriteLine($" Validation: {(invalidTypeResult.IsValid ? "✓ PASSED" : "✗ FAILED")}");
+ if (!invalidTypeResult.IsValid)
+ {
+ foreach (var error in (invalidTypeResult.Details ?? []).Where(d => !d.IsValid && d.Errors is not null))
+ {
+ foreach (var (key, message) in error.Errors!)
+ {
+ Console.WriteLine($" - [{error.InstanceLocation}] {key}: {message}");
+ }
+ }
+ }
+ Console.WriteLine();
+
+ // --- Invalid: extra strictness demo using OrderPlaced ---
+ var orderDetails = await catalog.GetEventDetails("OrderPlaced");
+ if (orderDetails is null) return;
+
+ Console.WriteLine($" Schema for 'OrderPlaced':");
+ Console.WriteLine($" {FormatJson(orderDetails.JsonSchema)}");
+ Console.WriteLine();
+
+ var orderSchema = JsonSchema.FromText(orderDetails.JsonSchema);
+
+ // Valid OrderPlaced object
+ var validOrder = new JsonObject
+ {
+ ["userId"] = Guid.NewGuid().ToString(),
+ ["items"] = new JsonArray
+ {
+ new JsonObject
+ {
+ ["productName"] = "Widget",
+ ["quantity"] = 2,
+ ["price"] = 9.99
+ }
+ },
+ ["total"] = 19.98
+ };
+
+ var validOrderResult = orderSchema.Evaluate(ToJsonElement(validOrder), new EvaluationOptions { OutputFormat = OutputFormat.List });
+
+ Console.WriteLine(" Valid OrderPlaced object:");
+ Console.WriteLine($" {validOrder.ToJsonString()}");
+ Console.WriteLine($" Validation: {(validOrderResult.IsValid ? "✓ PASSED" : "✗ FAILED")}");
+ Console.WriteLine();
+
+ // Invalid: total is a string instead of number
+ var invalidOrder = new JsonObject
+ {
+ ["userId"] = Guid.NewGuid().ToString(),
+ ["items"] = new JsonArray(),
+ ["total"] = "not-a-number"
+ };
+
+ var invalidOrderResult = orderSchema.Evaluate(ToJsonElement(invalidOrder), new EvaluationOptions { OutputFormat = OutputFormat.List });
+
+ Console.WriteLine(" Invalid OrderPlaced object ('total' is a string):");
+ Console.WriteLine($" {invalidOrder.ToJsonString()}");
+ Console.WriteLine($" Validation: {(invalidOrderResult.IsValid ? "✓ PASSED" : "✗ FAILED")}");
+ if (!invalidOrderResult.IsValid)
+ {
+ foreach (var error in (invalidOrderResult.Details ?? []).Where(d => !d.IsValid && d.Errors is not null))
+ {
+ foreach (var (key, message) in error.Errors!)
+ {
+ Console.WriteLine($" - [{error.InstanceLocation}] {key}: {message}");
+ }
+ }
+ }
+ }
+
+ private static JsonElement ToJsonElement(JsonNode node)
+ => JsonSerializer.Deserialize(node.ToJsonString());
+
+ private static string FormatJson(string json)
+ {
+ try
+ {
+ var doc = JsonDocument.Parse(json);
+ return JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true });
+ }
+ catch
+ {
+ return json;
+ }
+ }
+}
diff --git a/samples/SampleEventCatalog/SampleEventCatalog.csproj b/samples/SampleEventCatalog/SampleEventCatalog.csproj
new file mode 100644
index 0000000..13d1db0
--- /dev/null
+++ b/samples/SampleEventCatalog/SampleEventCatalog.csproj
@@ -0,0 +1,18 @@
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Papst.EventStore.Aggregation.EventRegistration/EventNameAttribute.cs b/src/Papst.EventStore.Aggregation.EventRegistration/EventNameAttribute.cs
index a5cd11a..b073ee0 100644
--- a/src/Papst.EventStore.Aggregation.EventRegistration/EventNameAttribute.cs
+++ b/src/Papst.EventStore.Aggregation.EventRegistration/EventNameAttribute.cs
@@ -19,6 +19,16 @@ public sealed class EventNameAttribute : Attribute
///
public bool IsWriteName { get; }
+ ///
+ /// Optional description of the Event
+ ///
+ public string? Description { get; set; }
+
+ ///
+ /// Optional constraints associated with the Event
+ ///
+ public string[]? Constraints { get; set; }
+
///
/// Mark the Event with Read/Write information
///
@@ -37,3 +47,50 @@ public EventNameAttribute(string name, bool isWriteName)
public EventNameAttribute(string name) : this(name, true)
{ }
}
+
+///
+/// Marks an Event Sourcing Event associated with entity
+/// If is true, the Property is used to write Events
+///
+/// The Entity Type this Event is associated with
+[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)]
+public sealed class EventNameAttribute : Attribute
+{
+ ///
+ /// Name of the Event
+ ///
+ public string Name { get; }
+
+ ///
+ /// Flag whether the Attribute is used for Writing the Name
+ ///
+ public bool IsWriteName { get; }
+
+ ///
+ /// Optional description of the Event
+ ///
+ public string? Description { get; set; }
+
+ ///
+ /// Optional constraints associated with the Event
+ ///
+ public string[]? Constraints { get; set; }
+
+ ///
+ /// Mark the Event with Read/Write information
+ ///
+ ///
+ ///
+ public EventNameAttribute(string name, bool isWriteName)
+ {
+ Name = name;
+ IsWriteName = isWriteName;
+ }
+
+ ///
+ /// Mark the Event with the as Write Attribute
+ ///
+ ///
+ public EventNameAttribute(string name) : this(name, true)
+ { }
+}
diff --git a/src/Papst.EventStore.CodeGeneration/EventRegistrationIncrementalCodeGenerator.cs b/src/Papst.EventStore.CodeGeneration/EventRegistrationIncrementalCodeGenerator.cs
index 7303bb5..154cf0d 100644
--- a/src/Papst.EventStore.CodeGeneration/EventRegistrationIncrementalCodeGenerator.cs
+++ b/src/Papst.EventStore.CodeGeneration/EventRegistrationIncrementalCodeGenerator.cs
@@ -18,6 +18,39 @@ public class EventRegistrationIncrementalCodeGenerator : IIncrementalGenerator
private const string EventAggregatorInterfaceName = "IEventAggregator";
private const string ClassName = "EventStoreEventAggregator";
+ private class EventAttributeInfo
+ {
+ public string Name { get; set; }
+ public bool IsWrite { get; set; }
+ public string Description { get; set; }
+ public List Constraints { get; set; }
+ public string EntityTypeName { get; set; }
+ public string EntityTypeNamespace { get; set; }
+ public bool IsGeneric { get; set; }
+ }
+
+ private class EventInfo
+ {
+ public string ClassName { get; set; }
+ public string Namespace { get; set; }
+ public List Attributes { get; set; } = new List();
+ }
+
+ private class AggregatorTypeArg
+ {
+ public string Entity { get; set; }
+ public string EntityNamespace { get; set; }
+ public string Event { get; set; }
+ public string EventNamespace { get; set; }
+ }
+
+ private class AggregatorInfo
+ {
+ public string Class { get; set; }
+ public string Namespace { get; set; }
+ public List TypeArguments { get; set; } = new List();
+ }
+
public void Initialize(IncrementalGeneratorInitializationContext context)
{
context.RegisterSourceOutput(context.CompilationProvider, (productionContext, compilation) =>
@@ -49,7 +82,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
.ToArray();
var events = allClasses
- .Select(c => FindEvents(compilation, c))
+ .Select(c => FindEvents(compilation, c, allClasses))
.Where(c => c != null)
.ToList();
@@ -62,7 +95,7 @@ bt.Type is GenericNameSyntax &&
((GenericNameSyntax)bt.Type).Identifier.ValueText == EventAggregatorInterfaceName)
)
)
- .Select(c => new
+ .Select(c => new AggregatorInfo
{
Class = c.Identifier.ValueText,
Namespace = FindNamespace(c),
@@ -76,13 +109,14 @@ bt.Type is GenericNameSyntax &&
Entity = ((IdentifierNameSyntax)((GenericNameSyntax)bt.Type).TypeArgumentList.Arguments[0]).Identifier.ValueText,
Event = ((IdentifierNameSyntax)((GenericNameSyntax)bt.Type).TypeArgumentList.Arguments[1]).Identifier.ValueText
})
- .Select(bt => new
+ .Select(bt => new AggregatorTypeArg
{
Entity = bt.Entity,
EntityNamespace = FindNamespace(bt.Entity, allClasses),
Event = bt.Event,
EventNamespace = FindNamespace(bt.Event, allClasses)
})
+ .ToList()
})
.ToList();
@@ -99,6 +133,14 @@ bt.Type is GenericNameSyntax &&
.AppendLine($"namespace {baseNamespace};")
.AppendLine($"public static class {ClassName}")
.AppendLine("{")
+ ;
+
+ // --- Generate schema fields for catalog ---
+ var catalogEntries = BuildCatalogEntries(events, aggregators, compilation, allClasses);
+ var schemaFields = GenerateSchemaFields(catalogEntries, compilation, allClasses, builder);
+
+ // --- AddCodeGeneratedEvents (existing behavior) ---
+ builder
.AppendLine(" public static IServiceCollection AddCodeGeneratedEvents(this IServiceCollection services)")
.AppendLine(" {")
;
@@ -108,7 +150,7 @@ bt.Type is GenericNameSyntax &&
builder.AppendLine(" var registration = new Papst.EventStore.EventRegistration.EventDescriptionEventRegistration();");
foreach (var evt in events)
{
- builder.AppendLine($" registration.AddEvent<{evt.Value.NameSpace}.{evt.Value.Name}>({string.Join(", ", evt.Value.Attributes.Select(attr => $"new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"{attr.Name}\", {(attr.IsWrite ? bool.TrueString.ToLower() : bool.FalseString.ToLower())})"))});");
+ builder.AppendLine($" registration.AddEvent<{evt.Namespace}.{evt.ClassName}>({string.Join(", ", evt.Attributes.Select(attr => $"new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"{attr.Name}\", {(attr.IsWrite ? bool.TrueString.ToLower() : bool.FalseString.ToLower())})"))});");
}
builder.AppendLine(" services.AddSingleton(registration);");
}
@@ -132,10 +174,15 @@ bt.Type is GenericNameSyntax &&
;
builder.AppendLine(" return services;");
- builder
- .AppendLine(" }")
- .AppendLine("}")
- ;
+ builder.AppendLine(" }");
+
+ // --- AddCodeGeneratedEventCatalog (new) ---
+ if (catalogEntries.Count > 0)
+ {
+ GenerateCatalogMethod(builder, catalogEntries, schemaFields);
+ }
+
+ builder.AppendLine("}");
productionContext.AddSource("EventRegistration.g.cs", builder.ToString());
}
@@ -167,15 +214,330 @@ bt.Type is GenericNameSyntax &&
});
}
+ #region Catalog Generation
+
+ private class CatalogEntry
+ {
+ public string EntityNamespace { get; set; }
+ public string EntityName { get; set; }
+ public string EventName { get; set; }
+ public string Description { get; set; }
+ public List Constraints { get; set; }
+ public string EventClassName { get; set; }
+ public string EventClassNamespace { get; set; }
+ }
+
+ private static List BuildCatalogEntries(
+ List events,
+ List aggregators,
+ Compilation compilation,
+ TypeDeclarationSyntax[] allClasses)
+ {
+ var entries = new List();
+ var seen = new HashSet();
+
+ // From generic EventNameAttribute
+ foreach (var evt in events)
+ {
+ foreach (var attr in evt.Attributes.Where(a => a.IsGeneric))
+ {
+ string key = $"{attr.EntityTypeNamespace}.{attr.EntityTypeName}|{attr.Name}";
+ if (seen.Add(key))
+ {
+ entries.Add(new CatalogEntry
+ {
+ EntityNamespace = attr.EntityTypeNamespace,
+ EntityName = attr.EntityTypeName,
+ EventName = attr.Name,
+ Description = attr.Description,
+ Constraints = attr.Constraints,
+ EventClassName = evt.ClassName,
+ EventClassNamespace = evt.Namespace
+ });
+ }
+ }
+ }
+
+ // From aggregators
+ foreach (var aggregator in aggregators)
+ {
+ foreach (var impl in aggregator.TypeArguments)
+ {
+ string entityNs = impl.EntityNamespace;
+ string entityName = impl.Entity;
+ string eventNs = impl.EventNamespace;
+ string eventName = impl.Event;
+
+ // Find the event info to get attribute metadata
+ var eventInfo = events.FirstOrDefault(e => e.ClassName == eventName && e.Namespace == eventNs);
+ if (eventInfo != null)
+ {
+ foreach (var attr in eventInfo.Attributes)
+ {
+ string key = $"{entityNs}.{entityName}|{attr.Name}";
+ if (seen.Add(key))
+ {
+ entries.Add(new CatalogEntry
+ {
+ EntityNamespace = entityNs,
+ EntityName = entityName,
+ EventName = attr.Name,
+ Description = attr.Description,
+ Constraints = attr.Constraints,
+ EventClassName = eventInfo.ClassName,
+ EventClassNamespace = eventInfo.Namespace
+ });
+ }
+ }
+ }
+ }
+ }
+
+ return entries;
+ }
+
+ private static Dictionary GenerateSchemaFields(
+ List catalogEntries,
+ Compilation compilation,
+ TypeDeclarationSyntax[] allClasses,
+ StringBuilder builder)
+ {
+ var schemaFields = new Dictionary();
+
+ foreach (var entry in catalogEntries)
+ {
+ string typeKey = $"{entry.EventClassNamespace}.{entry.EventClassName}";
+ if (schemaFields.ContainsKey(typeKey))
+ {
+ continue;
+ }
+
+ string fieldName = $"_schema_{typeKey.Replace(".", "_")}";
+ schemaFields[typeKey] = fieldName;
+
+ var typeDecl = allClasses.FirstOrDefault(c =>
+ c.Identifier.ValueText == entry.EventClassName &&
+ FindNamespace(c) == entry.EventClassNamespace);
+
+ string schemaJson = "{}";
+ if (typeDecl != null)
+ {
+ schemaJson = GenerateJsonSchema(compilation, typeDecl);
+ }
+
+ string verbatimLiteral = ToVerbatimStringLiteral(schemaJson);
+ builder.AppendLine($" private static readonly System.Lazy {fieldName} = new System.Lazy(() => {verbatimLiteral});");
+ }
+
+ return schemaFields;
+ }
+
+ private static void GenerateCatalogMethod(
+ StringBuilder builder,
+ List catalogEntries,
+ Dictionary schemaFields)
+ {
+ builder
+ .AppendLine(" public static IServiceCollection AddCodeGeneratedEventCatalog(this IServiceCollection services)")
+ .AppendLine(" {")
+ .AppendLine(" var catalog = new Papst.EventStore.EventCatalog.EventCatalogRegistration();")
+ ;
+
+ foreach (var entry in catalogEntries)
+ {
+ string typeKey = $"{entry.EventClassNamespace}.{entry.EventClassName}";
+ string fieldName = schemaFields[typeKey];
+
+ string descArg = entry.Description != null
+ ? $"\"{EscapeString(entry.Description)}\""
+ : "null";
+
+ string constraintsArg;
+ if (entry.Constraints != null && entry.Constraints.Count > 0)
+ {
+ constraintsArg = $"new string[] {{ {string.Join(", ", entry.Constraints.Select(c => $"\"{EscapeString(c)}\""))} }}";
+ }
+ else
+ {
+ constraintsArg = "null";
+ }
+
+ builder.AppendLine($" catalog.RegisterEvent<{entry.EntityNamespace}.{entry.EntityName}>(\"{EscapeString(entry.EventName)}\", {descArg}, {constraintsArg}, {fieldName});");
+ }
+
+ builder
+ .AppendLine(" services.AddSingleton(catalog);")
+ .AppendLine(" if (!services.Any(descriptor => descriptor.ServiceType == typeof(Papst.EventStore.EventCatalog.IEventCatalog)))")
+ .AppendLine(" {")
+ .AppendLine(" services.AddSingleton();")
+ .AppendLine(" }")
+ .AppendLine(" return services;")
+ .AppendLine(" }")
+ ;
+ }
+
+ #endregion
+
+ #region JSON Schema Generation
+
+ private static string GenerateJsonSchema(Compilation compilation, TypeDeclarationSyntax typeDeclaration)
+ {
+ var semanticModel = compilation.GetSemanticModel(typeDeclaration.SyntaxTree);
+ var typeSymbol = semanticModel.GetDeclaredSymbol(typeDeclaration) as INamedTypeSymbol;
+ if (typeSymbol == null)
+ {
+ return "{}";
+ }
+ return GenerateSchemaForType(typeSymbol, new HashSet());
+ }
+
+ private static string GenerateSchemaForType(ITypeSymbol type, HashSet visited)
+ {
+ // Unwrap Nullable
+ if (type.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T && type is INamedTypeSymbol nullable)
+ {
+ return GenerateSchemaForType(nullable.TypeArguments[0], visited);
+ }
+
+ // Handle special/primitive types
+ switch (type.SpecialType)
+ {
+ case SpecialType.System_String:
+ return "{\"type\":\"string\"}";
+ case SpecialType.System_Int16:
+ case SpecialType.System_Int32:
+ case SpecialType.System_Int64:
+ case SpecialType.System_Byte:
+ case SpecialType.System_UInt16:
+ case SpecialType.System_UInt32:
+ case SpecialType.System_UInt64:
+ case SpecialType.System_SByte:
+ return "{\"type\":\"integer\"}";
+ case SpecialType.System_Single:
+ case SpecialType.System_Double:
+ case SpecialType.System_Decimal:
+ return "{\"type\":\"number\"}";
+ case SpecialType.System_Boolean:
+ return "{\"type\":\"boolean\"}";
+ case SpecialType.System_DateTime:
+ return "{\"type\":\"string\",\"format\":\"date-time\"}";
+ case SpecialType.System_Object:
+ return "{}";
+ }
+
+ // Handle well-known types by display name
+ string fullName = type.ToDisplayString();
+ if (fullName == "System.DateTimeOffset")
+ return "{\"type\":\"string\",\"format\":\"date-time\"}";
+ if (fullName == "System.Guid")
+ return "{\"type\":\"string\",\"format\":\"uuid\"}";
+ if (fullName == "System.TimeSpan")
+ return "{\"type\":\"string\",\"format\":\"duration\"}";
+ if (fullName == "System.Uri")
+ return "{\"type\":\"string\",\"format\":\"uri\"}";
+
+ // Handle enums
+ if (type.TypeKind == TypeKind.Enum && type is INamedTypeSymbol enumType)
+ {
+ var members = enumType.GetMembers().OfType().Where(f => f.HasConstantValue).ToList();
+ var values = string.Join(",", members.Select(m => $"\"{m.Name}\""));
+ return $"{{\"type\":\"string\",\"enum\":[{values}]}}";
+ }
+
+ // Handle arrays
+ if (type is IArrayTypeSymbol arrayType)
+ {
+ string itemSchema = GenerateSchemaForType(arrayType.ElementType, visited);
+ return $"{{\"type\":\"array\",\"items\":{itemSchema}}}";
+ }
+
+ // Handle generic collections
+ if (type is INamedTypeSymbol namedType && namedType.IsGenericType)
+ {
+ // Check for IEnumerable
+ var enumerableInterface = namedType.AllInterfaces
+ .FirstOrDefault(i => i.IsGenericType &&
+ i.OriginalDefinition.ToDisplayString() == "System.Collections.Generic.IEnumerable");
+
+ string origDisplay = namedType.OriginalDefinition.ToDisplayString();
+ if (enumerableInterface != null || origDisplay == "System.Collections.Generic.IEnumerable")
+ {
+ var elementType = enumerableInterface != null
+ ? enumerableInterface.TypeArguments[0]
+ : namedType.TypeArguments[0];
+ string itemSchema = GenerateSchemaForType(elementType, visited);
+ return $"{{\"type\":\"array\",\"items\":{itemSchema}}}";
+ }
+
+ // Check for Dictionary
+ if (origDisplay.StartsWith("System.Collections.Generic.Dictionary") ||
+ origDisplay.StartsWith("System.Collections.Generic.IDictionary"))
+ {
+ string valueSchema = GenerateSchemaForType(namedType.TypeArguments[1], visited);
+ return $"{{\"type\":\"object\",\"additionalProperties\":{valueSchema}}}";
+ }
+ }
+
+ // Handle object types (class/record/struct)
+ if (type is INamedTypeSymbol objectType &&
+ (type.TypeKind == TypeKind.Class || type.TypeKind == TypeKind.Struct))
+ {
+ string typeKey = objectType.ToDisplayString();
+ if (visited.Contains(typeKey))
+ {
+ return "{\"type\":\"object\"}";
+ }
+ visited.Add(typeKey);
+
+ var props = objectType.GetMembers()
+ .OfType()
+ .Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic && p.GetMethod != null)
+ .ToList();
+
+ var sb = new StringBuilder();
+ sb.Append("{\"type\":\"object\",\"properties\":{");
+ bool first = true;
+ foreach (var prop in props)
+ {
+ if (!first) sb.Append(",");
+ first = false;
+ string propName = char.ToLowerInvariant(prop.Name[0]) + prop.Name.Substring(1);
+ string propSchema = GenerateSchemaForType(prop.Type, visited);
+ sb.Append($"\"{propName}\":{propSchema}");
+ }
+ sb.Append("}}");
+
+ visited.Remove(typeKey);
+ return sb.ToString();
+ }
+
+ // Fallback
+ return "{\"type\":\"object\"}";
+ }
+
+ private static string ToVerbatimStringLiteral(string value)
+ {
+ return "@\"" + value.Replace("\"", "\"\"") + "\"";
+ }
+
+ private static string EscapeString(string value)
+ {
+ return value.Replace("\\", "\\\\").Replace("\"", "\\\"");
+ }
+
+ #endregion
+
+ #region Attribute Parsing
+
///
/// Checks if the is attributed with an EventNameAttribute
- /// and parses the values
+ /// (generic or non-generic) and parses the values
///
- private static (List<(string Name, bool IsWrite)> Attributes, string Name, string NameSpace)? FindEvents(Compilation compilation, TypeDeclarationSyntax typeDeclaration)
+ private static EventInfo FindEvents(Compilation compilation, TypeDeclarationSyntax typeDeclaration, TypeDeclarationSyntax[] allClasses)
{
var attributes = typeDeclaration.AttributeLists
.SelectMany(x => x.Attributes)
- .Where(attr => attr.Name.ToString() == "EventNameAttribute" || attr.Name.ToString() == "EventName")
+ .Where(attr => IsEventNameAttribute(attr.Name))
.ToList();
if (attributes.Count == 0)
@@ -187,47 +549,226 @@ private static (List<(string Name, bool IsWrite)> Attributes, string Name, strin
var className = typeDeclaration.Identifier.ValueText;
string nsName = FindNamespace(typeDeclaration);
- List<(string Name, bool IsWrite)> setAttributes = new List<(string Name, bool IsWrite)>();
+ var eventInfo = new EventInfo { ClassName = className, Namespace = nsName };
+
foreach (var attr in attributes)
{
- string name = null;
- bool isWrite = true;
- var expr = semanticModel.GetConstantValue(attr.ArgumentList.Arguments[0].Expression).Value;
-
- if (expr is string name2)
+ var attrInfo = ParseEventNameAttribute(attr, semanticModel, allClasses);
+ if (attrInfo != null)
{
- name = name2;
+ eventInfo.Attributes.Add(attrInfo);
}
- else if (expr is bool isWrite2)
+ }
+
+ return eventInfo.Attributes.Count > 0 ? eventInfo : null;
+ }
+
+ private static bool IsEventNameAttribute(NameSyntax name)
+ {
+ string simpleName = GetSimpleName(name);
+ return simpleName == "EventName" || simpleName == "EventNameAttribute";
+ }
+
+ private static string GetSimpleName(NameSyntax name)
+ {
+ if (name is IdentifierNameSyntax ins)
+ return ins.Identifier.ValueText;
+ if (name is GenericNameSyntax gns)
+ return gns.Identifier.ValueText;
+ if (name is AliasQualifiedNameSyntax aqns)
+ return GetSimpleName(aqns.Name);
+ if (name is QualifiedNameSyntax qns)
+ return GetSimpleName(qns.Right);
+ return "";
+ }
+
+ private static GenericNameSyntax GetGenericName(NameSyntax name)
+ {
+ if (name is GenericNameSyntax gns)
+ return gns;
+ if (name is AliasQualifiedNameSyntax aqns)
+ return GetGenericName(aqns.Name);
+ if (name is QualifiedNameSyntax qns)
+ return GetGenericName(qns.Right);
+ return null;
+ }
+
+ private static EventAttributeInfo ParseEventNameAttribute(
+ AttributeSyntax attr,
+ SemanticModel semanticModel,
+ TypeDeclarationSyntax[] allClasses)
+ {
+ string name = null;
+ bool isWrite = true;
+ string description = null;
+ List constraints = null;
+ bool isGeneric = false;
+ string entityTypeName = null;
+ string entityTypeNamespace = null;
+
+ // Check if this is a generic attribute EventName
+ GenericNameSyntax gns = GetGenericName(attr.Name);
+ if (gns != null && gns.TypeArgumentList.Arguments.Count == 1)
+ {
+ isGeneric = true;
+ var entityTypeSyntax = gns.TypeArgumentList.Arguments[0];
+ var typeInfo = semanticModel.GetTypeInfo(entityTypeSyntax);
+ if (typeInfo.Type != null && typeInfo.Type.TypeKind != TypeKind.Error)
{
- isWrite = isWrite2;
+ entityTypeName = typeInfo.Type.Name;
+ entityTypeNamespace = typeInfo.Type.ContainingNamespace?.ToDisplayString();
}
-
- if (attr.ArgumentList.Arguments.Count > 1)
+ else
{
- expr = semanticModel.GetConstantValue(attr.ArgumentList.Arguments[1].Expression).Value;
- if (expr is string name3)
+ // Fallback: try to find from syntax
+ string entityName = entityTypeSyntax.ToString();
+ entityTypeName = entityName;
+ try
+ {
+ entityTypeNamespace = FindNamespace(entityName, allClasses);
+ }
+ catch
{
- name = name3;
+ entityTypeNamespace = entityName;
}
- else if (expr is bool isWrite2)
+ }
+ }
+
+ if (attr.ArgumentList == null || attr.ArgumentList.Arguments.Count == 0)
+ {
+ return null;
+ }
+
+ // Parse arguments (handles positional, named with =, and named with :)
+ foreach (var arg in attr.ArgumentList.Arguments)
+ {
+ if (arg.NameEquals != null)
+ {
+ string argName = arg.NameEquals.Name.Identifier.ValueText;
+ ParseNamedArgument(argName, arg.Expression, semanticModel, ref name, ref isWrite, ref description, ref constraints);
+ }
+ else if (arg.NameColon != null)
+ {
+ string argName = arg.NameColon.Name.Identifier.ValueText;
+ ParseNamedArgument(argName, arg.Expression, semanticModel, ref name, ref isWrite, ref description, ref constraints);
+ }
+ else
+ {
+ // Positional argument - use type-based heuristic
+ var expr = semanticModel.GetConstantValue(arg.Expression);
+ if (expr.HasValue)
{
- isWrite = isWrite2;
+ if (expr.Value is string s)
+ name = s;
+ else if (expr.Value is bool b)
+ isWrite = b;
}
}
- if (name != null)
+ }
+
+ if (name == null)
+ {
+ return null;
+ }
+
+ return new EventAttributeInfo
+ {
+ Name = name,
+ IsWrite = isWrite,
+ Description = description,
+ Constraints = constraints,
+ IsGeneric = isGeneric,
+ EntityTypeName = entityTypeName,
+ EntityTypeNamespace = entityTypeNamespace
+ };
+ }
+
+ private static void ParseNamedArgument(
+ string argName,
+ ExpressionSyntax expression,
+ SemanticModel semanticModel,
+ ref string name,
+ ref bool isWrite,
+ ref string description,
+ ref List constraints)
+ {
+ // Handle both property names and constructor parameter names (case-insensitive first char)
+ string normalizedName = argName.Length > 0
+ ? char.ToUpperInvariant(argName[0]) + argName.Substring(1)
+ : argName;
+
+ if (normalizedName == "Name")
+ {
+ name = semanticModel.GetConstantValue(expression).Value as string ?? name;
+ }
+ else if (normalizedName == "IsWriteName" || normalizedName == "IsWrite")
+ {
+ var val = semanticModel.GetConstantValue(expression);
+ if (val.HasValue && val.Value is bool b)
+ isWrite = b;
+ }
+ else if (normalizedName == "Description")
+ {
+ description = semanticModel.GetConstantValue(expression).Value as string;
+ }
+ else if (normalizedName == "Constraints")
+ {
+ constraints = ParseStringArray(semanticModel, expression);
+ }
+ }
+
+ private static List ParseStringArray(SemanticModel semanticModel, ExpressionSyntax expression)
+ {
+ var result = new List();
+
+ InitializerExpressionSyntax initializer = null;
+
+ if (expression is ImplicitArrayCreationExpressionSyntax implicitArray)
+ {
+ initializer = implicitArray.Initializer;
+ }
+ else if (expression is ArrayCreationExpressionSyntax arrayCreation)
+ {
+ initializer = arrayCreation.Initializer;
+ }
+
+ if (initializer != null)
+ {
+ foreach (var elem in initializer.Expressions)
{
- setAttributes.Add((name, isWrite));
+ var val = semanticModel.GetConstantValue(elem);
+ if (val.HasValue && val.Value is string s)
+ {
+ result.Add(s);
+ }
}
+ return result.Count > 0 ? result : null;
}
- if (setAttributes.Count > 0)
+ // Handle collection expression syntax ["a", "b"]
+ if (expression is CollectionExpressionSyntax collExpr)
{
- return (setAttributes, className, nsName);
+ foreach (var elem in collExpr.Elements)
+ {
+ if (elem is ExpressionElementSyntax exprElem)
+ {
+ var val = semanticModel.GetConstantValue(exprElem.Expression);
+ if (val.HasValue && val.Value is string s)
+ {
+ result.Add(s);
+ }
+ }
+ }
+ return result.Count > 0 ? result : null;
}
+
return null;
}
+ #endregion
+
+ #region Namespace Helpers
+
private static string FindNamespace(TypeDeclarationSyntax typeDeclaration)
{
if (typeDeclaration.Parent is BaseNamespaceDeclarationSyntax nsDeclBase)
@@ -269,5 +810,7 @@ private static string FindNamespace(string className, IEnumerable
+/// Lightweight description of an Event registered in the catalog
+///
+/// The name of the Event
+/// Optional description of the Event
+/// Optional constraints associated with the Event
+public record EventCatalogEntry(string EventName, string? Description, string[]? Constraints);
diff --git a/src/Papst.EventStore/EventCatalog/EventCatalogEventDetails.cs b/src/Papst.EventStore/EventCatalog/EventCatalogEventDetails.cs
new file mode 100644
index 0000000..9c2f177
--- /dev/null
+++ b/src/Papst.EventStore/EventCatalog/EventCatalogEventDetails.cs
@@ -0,0 +1,10 @@
+namespace Papst.EventStore.EventCatalog;
+
+///
+/// Detailed description of an Event including its JSON Schema
+///
+/// The name of the Event
+/// Optional description of the Event
+/// Optional constraints associated with the Event
+/// JSON Schema of the Event payload
+public record EventCatalogEventDetails(string EventName, string? Description, string[]? Constraints, string JsonSchema);
diff --git a/src/Papst.EventStore/EventCatalog/EventCatalogProvider.cs b/src/Papst.EventStore/EventCatalog/EventCatalogProvider.cs
new file mode 100644
index 0000000..77ffff6
--- /dev/null
+++ b/src/Papst.EventStore/EventCatalog/EventCatalogProvider.cs
@@ -0,0 +1,59 @@
+using System.Linq;
+using Papst.EventStore.Exceptions;
+
+namespace Papst.EventStore.EventCatalog;
+
+///
+/// Default implementation of that delegates to registered instances
+///
+public sealed class EventCatalogProvider : IEventCatalog
+{
+ private readonly IEnumerable _registrations;
+
+ public EventCatalogProvider(IEnumerable registrations)
+ {
+ _registrations = registrations;
+ }
+
+ ///
+ public ValueTask> ListEvents(string? name = null, string[]? constraints = null)
+ {
+ Type entityType = typeof(TEntity);
+
+ IReadOnlyList result = _registrations
+ .SelectMany(r => r.GetEntries(entityType, name, constraints))
+ .ToList()
+ .AsReadOnly();
+
+ return new ValueTask>(result);
+ }
+
+ ///
+ public ValueTask GetEventDetails(string eventName)
+ {
+ List matches = _registrations
+ .Select(r => r.GetDetails(eventName))
+ .Where(d => d is not null)
+ .Cast()
+ .ToList();
+
+ if (matches.Count > 1)
+ {
+ throw new EventCatalogAmbiguousEventException(eventName, matches.Count);
+ }
+
+ return new ValueTask(matches.FirstOrDefault());
+ }
+
+ ///
+ public ValueTask GetEventDetails(string eventName)
+ {
+ Type entityType = typeof(TEntity);
+
+ EventCatalogEventDetails? result = _registrations
+ .Select(r => r.GetDetails(entityType, eventName))
+ .FirstOrDefault(d => d is not null);
+
+ return new ValueTask(result);
+ }
+}
diff --git a/src/Papst.EventStore/EventCatalog/EventCatalogRegistration.cs b/src/Papst.EventStore/EventCatalog/EventCatalogRegistration.cs
new file mode 100644
index 0000000..2c5d77f
--- /dev/null
+++ b/src/Papst.EventStore/EventCatalog/EventCatalogRegistration.cs
@@ -0,0 +1,100 @@
+using System.Linq;
+using Papst.EventStore.Exceptions;
+
+namespace Papst.EventStore.EventCatalog;
+
+///
+/// In-memory implementation of
+///
+public sealed class EventCatalogRegistration : IEventCatalogRegistration
+{
+ private readonly record struct CatalogItem(string EventName, string? Description, string[]? Constraints, Lazy SchemaJson);
+
+ private readonly Dictionary> _entityEvents = new();
+
+ ///
+ public void RegisterEvent(string eventName, string? description, string[]? constraints, Lazy schemaJson)
+ {
+ Type entityType = typeof(TEntity);
+ CatalogItem item = new(eventName, description, constraints, schemaJson);
+
+ if (!_entityEvents.TryGetValue(entityType, out List? items))
+ {
+ items = new List();
+ _entityEvents[entityType] = items;
+ }
+ items.Add(item);
+ }
+
+ ///
+ public IReadOnlyList GetEntries(Type entityType, string? name, string[]? constraints)
+ {
+ if (!_entityEvents.TryGetValue(entityType, out List? items))
+ {
+ return Array.Empty();
+ }
+
+ IEnumerable filtered = items;
+
+ if (name is not null)
+ {
+ filtered = filtered.Where(i => i.EventName == name);
+ }
+
+ if (constraints is { Length: > 0 })
+ {
+ filtered = filtered.Where(i =>
+ i.Constraints is not null &&
+ i.Constraints.Any(c => constraints.Contains(c)));
+ }
+
+ return filtered
+ .Select(i => new EventCatalogEntry(i.EventName, i.Description, i.Constraints))
+ .ToList()
+ .AsReadOnly();
+ }
+
+ ///
+ public EventCatalogEventDetails? GetDetails(string eventName)
+ {
+ List<(Type EntityType, CatalogItem Item)> matches = _entityEvents
+ .SelectMany(pair => pair.Value
+ .Where(item => item.EventName == eventName)
+ .Select(item => (pair.Key, item)))
+ .ToList();
+
+ if (matches.Count == 0)
+ {
+ return null;
+ }
+
+ if (matches.Count > 1)
+ {
+ throw new EventCatalogAmbiguousEventException(eventName, matches.Select(match => match.EntityType), matches.Count);
+ }
+
+ return CreateDetails(matches[0].Item);
+ }
+
+ ///
+ public EventCatalogEventDetails? GetDetails(Type entityType, string eventName)
+ {
+ if (!_entityEvents.TryGetValue(entityType, out List? items))
+ {
+ return null;
+ }
+
+ CatalogItem? found = items.Cast().FirstOrDefault(i => i!.Value.EventName == eventName);
+ if (!found.HasValue)
+ {
+ return null;
+ }
+
+ return CreateDetails(found.Value);
+ }
+
+ private static EventCatalogEventDetails CreateDetails(CatalogItem item)
+ {
+ return new EventCatalogEventDetails(item.EventName, item.Description, item.Constraints, item.SchemaJson.Value);
+ }
+}
diff --git a/src/Papst.EventStore/EventCatalog/IEventCatalog.cs b/src/Papst.EventStore/EventCatalog/IEventCatalog.cs
new file mode 100644
index 0000000..ac78a57
--- /dev/null
+++ b/src/Papst.EventStore/EventCatalog/IEventCatalog.cs
@@ -0,0 +1,34 @@
+namespace Papst.EventStore.EventCatalog;
+
+///
+/// Provides read access to the Event Catalog, allowing queries for registered events per entity
+///
+public interface IEventCatalog
+{
+ ///
+ /// List events registered for entity , optionally filtered by name and/or constraints
+ ///
+ /// The Entity to list events for
+ /// Optional event name filter (exact match)
+ /// Optional constraints filter (events matching any of the given constraints)
+ /// A list of matching instances
+ ValueTask> ListEvents(string? name = null, string[]? constraints = null);
+
+ ///
+ /// Get detailed information (including JSON Schema) for a specific event by name.
+ /// Throws when the same event name exists for multiple registrations.
+ ///
+ /// The event name to look up
+ /// The or null if the event is not registered
+ /// Thrown when the event name matches more than one registration.
+ ValueTask GetEventDetails(string eventName);
+
+ ///
+ /// Get detailed information (including JSON Schema) for a specific event by name, scoped to entity .
+ /// Use this overload when the same event name may exist for different entities.
+ ///
+ /// The entity type to scope the lookup to
+ /// The event name to look up
+ /// The or null if the event is not registered for this entity
+ ValueTask GetEventDetails(string eventName);
+}
diff --git a/src/Papst.EventStore/EventCatalog/IEventCatalogRegistration.cs b/src/Papst.EventStore/EventCatalog/IEventCatalogRegistration.cs
new file mode 100644
index 0000000..f107fe4
--- /dev/null
+++ b/src/Papst.EventStore/EventCatalog/IEventCatalogRegistration.cs
@@ -0,0 +1,33 @@
+namespace Papst.EventStore.EventCatalog;
+
+///
+/// Registration interface used by generated code to populate the Event Catalog
+///
+public interface IEventCatalogRegistration
+{
+ ///
+ /// Register an event for a specific entity type
+ ///
+ /// The entity type this event is associated with
+ /// The name of the event
+ /// Optional description
+ /// Optional constraints
+ /// Lazily evaluated JSON schema string
+ void RegisterEvent(string eventName, string? description, string[]? constraints, Lazy schemaJson);
+
+ ///
+ /// Get catalog entries for a given entity type, optionally filtered
+ ///
+ IReadOnlyList GetEntries(Type entityType, string? name, string[]? constraints);
+
+ ///
+ /// Get detailed event information by name.
+ /// Throws when more than one event with the same name is registered.
+ ///
+ EventCatalogEventDetails? GetDetails(string eventName);
+
+ ///
+ /// Get detailed event information by name, scoped to a specific entity type
+ ///
+ EventCatalogEventDetails? GetDetails(Type entityType, string eventName);
+}
diff --git a/src/Papst.EventStore/Exceptions/EventCatalogAmbiguousEventException.cs b/src/Papst.EventStore/Exceptions/EventCatalogAmbiguousEventException.cs
new file mode 100644
index 0000000..903a63b
--- /dev/null
+++ b/src/Papst.EventStore/Exceptions/EventCatalogAmbiguousEventException.cs
@@ -0,0 +1,54 @@
+using System.Linq;
+
+namespace Papst.EventStore.Exceptions;
+
+///
+/// Exception that is thrown when an event catalog lookup by event name matches more than one registration.
+///
+public class EventCatalogAmbiguousEventException : Exception
+{
+ public EventCatalogAmbiguousEventException(string eventName, IEnumerable matchingEntityTypes)
+ : this(eventName, matchingEntityTypes, null)
+ { }
+
+ public EventCatalogAmbiguousEventException(string eventName, int matchCount)
+ : this(eventName, null, matchCount)
+ { }
+
+ public EventCatalogAmbiguousEventException(string eventName, IEnumerable? matchingEntityTypes, int? matchCount = null)
+ : base(CreateMessage(eventName, matchingEntityTypes, matchCount))
+ {
+ EventName = eventName;
+ MatchingEntityTypes = matchingEntityTypes?.Distinct().ToArray() ?? Array.Empty();
+ MatchCount = matchCount ?? MatchingEntityTypes.Count;
+ }
+
+ ///
+ /// The ambiguous event name.
+ ///
+ public string EventName { get; }
+
+ ///
+ /// The entity types that matched the event name, when known.
+ ///
+ public IReadOnlyList MatchingEntityTypes { get; }
+
+ ///
+ /// The number of matches that made the lookup ambiguous.
+ ///
+ public int MatchCount { get; }
+
+ private static string CreateMessage(string eventName, IEnumerable? matchingEntityTypes, int? matchCount)
+ {
+ Type[] entityTypes = matchingEntityTypes?.Distinct().ToArray() ?? Array.Empty();
+ int resolvedMatchCount = matchCount ?? entityTypes.Length;
+
+ if (entityTypes.Length == 0)
+ {
+ return $"Event catalog lookup for '{eventName}' is ambiguous because {resolvedMatchCount} matching registrations were found.";
+ }
+
+ string typeNames = string.Join(", ", entityTypes.Select(t => t.FullName ?? t.Name));
+ return $"Event catalog lookup for '{eventName}' is ambiguous because it matches {resolvedMatchCount} registrations for entity types: {typeNames}.";
+ }
+}
diff --git a/tests/Papst.EventStore.AzureCosmos.Tests/IntegrationTests/CosmosEventStoreTests.cs b/tests/Papst.EventStore.AzureCosmos.Tests/IntegrationTests/CosmosEventStoreTests.cs
index ce3fa2f..603bb33 100755
--- a/tests/Papst.EventStore.AzureCosmos.Tests/IntegrationTests/CosmosEventStoreTests.cs
+++ b/tests/Papst.EventStore.AzureCosmos.Tests/IntegrationTests/CosmosEventStoreTests.cs
@@ -1,10 +1,10 @@
using AutoFixture.Xunit2;
-using FluentAssertions;
using Microsoft.Azure.Cosmos;
using Microsoft.Azure.Cosmos.Linq;
using Microsoft.Extensions.DependencyInjection;
using Papst.EventStore.AzureCosmos.Database;
using Papst.EventStore.Exceptions;
+using Shouldly;
using Xunit;
namespace Papst.EventStore.AzureCosmos.Tests.IntegrationTests;
@@ -31,8 +31,8 @@ public async Task CreateAsync_ShouldCreateIndexDocument(Guid streamId)
CosmosDbIntegrationTestFixture.CosmosContainerId);
var iterator = container.GetItemLinqQueryable().ToFeedIterator();
var batch = await iterator.ReadNextAsync();
- batch.Count.Should().Be(1);
- batch.Resource.First().StreamId.Should().Be(streamId);
+ batch.Count.ShouldBe(1);
+ batch.Resource.First().StreamId.ShouldBe(streamId);
}
[Theory, AutoData]
@@ -46,7 +46,7 @@ public async Task GetAsync_ShouldThrow_WhenNotFound(Guid streamId)
Func act = () => eventStore.GetAsync(streamId, CancellationToken.None);
// assert
- await act.Should().ThrowAsync();
+ await Should.ThrowAsync(act);
}
[Theory, AutoData]
@@ -63,7 +63,7 @@ public async Task GetAsync_ShouldReturnStream(EventStreamIndexEntity index)
var stream = await eventStore.GetAsync(index.StreamId, CancellationToken.None);
// assert
- stream.Should().NotBeNull();
- stream.StreamId.Should().Be(index.StreamId);
+ stream.ShouldNotBeNull();
+ stream.StreamId.ShouldBe(index.StreamId);
}
}
diff --git a/tests/Papst.EventStore.AzureCosmos.Tests/IntegrationTests/CosmosEventStreamTests.cs b/tests/Papst.EventStore.AzureCosmos.Tests/IntegrationTests/CosmosEventStreamTests.cs
index 6f58973..cf2a69d 100755
--- a/tests/Papst.EventStore.AzureCosmos.Tests/IntegrationTests/CosmosEventStreamTests.cs
+++ b/tests/Papst.EventStore.AzureCosmos.Tests/IntegrationTests/CosmosEventStreamTests.cs
@@ -1,9 +1,9 @@
using AutoFixture.Xunit2;
-using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Papst.EventStore.Aggregation;
using Papst.EventStore.AzureCosmos.Tests.IntegrationTests.Models;
using Papst.EventStore.Documents;
+using Shouldly;
using Xunit;
namespace Papst.EventStore.AzureCosmos.Tests.IntegrationTests;
@@ -28,6 +28,26 @@ private async Task CreateStreamAsync(IEventStore store, Guid strea
return stream;
}
+ private static void ShouldMatch(EventStreamMetaData actual, EventStreamMetaData expected)
+ {
+ actual.UserId.ShouldBe(expected.UserId);
+ actual.UserName.ShouldBe(expected.UserName);
+ actual.TenantId.ShouldBe(expected.TenantId);
+ actual.Comment.ShouldBe(expected.Comment);
+ if (expected.Additional is null)
+ {
+ actual.Additional.ShouldBeNull();
+ return;
+ }
+
+ actual.Additional.ShouldNotBeNull();
+ actual.Additional.Count.ShouldBe(expected.Additional.Count);
+ foreach (var entry in expected.Additional)
+ {
+ actual.Additional[entry.Key].ShouldBe(entry.Value);
+ }
+ }
+
[Theory, AutoData]
public async Task AppendAsync_ShouldAppendEvent(Guid streamId, Guid documentId)
{
@@ -40,9 +60,9 @@ public async Task AppendAsync_ShouldAppendEvent(Guid streamId, Guid documentId)
await stream.AppendAsync(documentId, new TestAppendedEvent());
// assert
- stream.Version.Should().Be(1);
+ stream.Version.ShouldBe(1UL);
var events = await stream.ListAsync().ToListAsync();
- events.Should().Contain(d => d.Id == documentId);
+ events.ShouldContain(d => d.Id == documentId);
}
[Theory, AutoData]
@@ -63,7 +83,7 @@ public async Task AppendTransactionAsync_ShouldAppendEvents(Guid streamId)
// assert
var events = await stream.ListAsync(0).ToListAsync();
- events.Should().HaveCount(5);
+ events.Count.ShouldBe(5);
}
@@ -85,9 +105,10 @@ public async Task AppenSnapshotAsync_ShouldAppendSnapshot(Guid streamId, Guid do
};
// assert
- await act.Should().NotThrowAsync();
- entity.Should().NotBeNull();
- stream.LatestSnapshotVersion.Should().HaveValue().And.Be(entity!.Version);
+ await Should.NotThrowAsync(act);
+ entity.ShouldNotBeNull();
+ stream.LatestSnapshotVersion.HasValue.ShouldBeTrue();
+ stream.LatestSnapshotVersion.ShouldBe(entity.Version);
}
[Theory, AutoData]
@@ -103,7 +124,7 @@ public async Task ListAsync_ShouldListEvents(Guid streamId)
var events = await list.ToListAsync();
// assert
- events.Count.Should().Be(4, "3 TestAppendedEvent + 1 TestCreatedEvent");
+ events.Count.ShouldBe(4);
}
[Theory, AutoData]
@@ -118,7 +139,8 @@ public async Task UpdateMetadata_ShouldUpdate(Guid streamId, EventStreamMetaData
await stream.UpdateStreamMetaData(metaData, default);
// assert
- stream.MetaData.Should().NotBeNull().And.BeEquivalentTo(metaData);
+ stream.MetaData.ShouldNotBeNull();
+ ShouldMatch(stream.MetaData, metaData);
}
[Theory, AutoData]
@@ -135,6 +157,7 @@ public async Task UpdateMetadata_ShouldPersist(Guid streamId, EventStreamMetaDat
stream = await store.GetAsync(streamId, default);
// assert
- stream.MetaData.Should().NotBeNull().And.BeEquivalentTo(metaData);
+ stream.MetaData.ShouldNotBeNull();
+ ShouldMatch(stream.MetaData, metaData);
}
}
diff --git a/tests/Papst.EventStore.AzureCosmos.Tests/Papst.EventStore.AzureCosmos.Tests.csproj b/tests/Papst.EventStore.AzureCosmos.Tests/Papst.EventStore.AzureCosmos.Tests.csproj
index 136b1da..3a1d6e7 100755
--- a/tests/Papst.EventStore.AzureCosmos.Tests/Papst.EventStore.AzureCosmos.Tests.csproj
+++ b/tests/Papst.EventStore.AzureCosmos.Tests/Papst.EventStore.AzureCosmos.Tests.csproj
@@ -11,7 +11,7 @@
-
+
diff --git a/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorCatalogTests.cs b/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorCatalogTests.cs
new file mode 100644
index 0000000..9912019
--- /dev/null
+++ b/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorCatalogTests.cs
@@ -0,0 +1,210 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using System.Linq;
+using Shouldly;
+using Xunit;
+
+namespace Papst.EventStore.CodeGeneration.Tests;
+
+public class CodeGeneratorCatalogTests : CodeGeneratorTestBase
+{
+ [Fact]
+ public void TestGenericEventNameAttributeGeneratesCatalog()
+ {
+ var generator = new EventRegistrationIncrementalCodeGenerator();
+ GeneratorDriver driver = CSharpGeneratorDriver.Create(generator);
+ Compilation inputCompilation = CreateCompilation(@"
+namespace MyCode
+{
+ public class Program { public static void Main(string[] args) {} }
+ public class UserEntity {}
+ [EventName(""UserCreated"", Description = ""A user was created"", Constraints = new[] { ""Create"" })]
+ public record UserCreatedEvent(string Name);
+}
+");
+ driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics);
+ diagnostics.ShouldBeEmpty();
+ var runResult = driver.GetRunResult();
+ runResult.Diagnostics.ShouldBeEmpty();
+ var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString();
+ source.ShouldContain("AddCodeGeneratedEventCatalog");
+ source.ShouldContain("catalog.RegisterEvent(");
+ source.ShouldContain("\"UserCreated\"");
+ source.ShouldContain("\"A user was created\"");
+ source.ShouldContain("new string[] { \"Create\" }");
+ source.ShouldContain("\"\"name\"\"");
+ }
+
+ [Fact]
+ public void TestAggregatorBasedCatalogGeneration()
+ {
+ var generator = new EventRegistrationIncrementalCodeGenerator();
+ GeneratorDriver driver = CSharpGeneratorDriver.Create(generator);
+ Compilation inputCompilation = CreateCompilation(@"
+namespace MyCode
+{
+ public class Program { public static void Main(string[] args) {} }
+ [EventName(""FooEvent"")]
+ public record FooEvent(int Value);
+ public record FooEntity();
+ public class FooAggregator : EventAggregatorBase
+ {
+ public override Task ApplyAsync(FooEvent evt, FooEntity entity, IAggregatorStreamContext ctx)
+ { throw new NotImplementedException(); }
+ }
+}
+");
+ driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics);
+ diagnostics.ShouldBeEmpty();
+ var runResult = driver.GetRunResult();
+ runResult.Diagnostics.ShouldBeEmpty();
+ var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString();
+ source.ShouldContain("AddCodeGeneratedEventCatalog");
+ source.ShouldContain("catalog.RegisterEvent(");
+ source.ShouldContain("\"FooEvent\"");
+ source.ShouldContain("\"\"value\"\"");
+ source.ShouldContain("\"\"integer\"\"");
+ }
+
+ [Fact]
+ public void TestCatalogJsonSchemaGeneration()
+ {
+ var generator = new EventRegistrationIncrementalCodeGenerator();
+ GeneratorDriver driver = CSharpGeneratorDriver.Create(generator);
+ Compilation inputCompilation = CreateCompilation(@"
+namespace MyCode
+{
+ public class Program { public static void Main(string[] args) {} }
+ public class MyEntity {}
+ public enum Status { Active, Inactive }
+ [EventName(""ComplexEvent"")]
+ public record ComplexEvent(string Name, int Count, bool IsActive, Status Status, string[] Tags);
+}
+");
+ driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics);
+ diagnostics.ShouldBeEmpty();
+ var runResult = driver.GetRunResult();
+ runResult.Diagnostics.ShouldBeEmpty();
+ var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString();
+ source.ShouldContain("\"\"name\"\":{\"\"type\"\":\"\"string\"\"}");
+ source.ShouldContain("\"\"count\"\":{\"\"type\"\":\"\"integer\"\"}");
+ source.ShouldContain("\"\"isActive\"\":{\"\"type\"\":\"\"boolean\"\"}");
+ source.ShouldContain("\"\"status\"\":{\"\"type\"\":\"\"string\"\",\"\"enum\"\":[\"\"Active\"\",\"\"Inactive\"\"]}");
+ source.ShouldContain("\"\"tags\"\":{\"\"type\"\":\"\"array\"\",\"\"items\"\":{\"\"type\"\":\"\"string\"\"}}");
+ }
+
+ [Fact]
+ public void TestDescriptionAndConstraintsOnNonGenericAttribute()
+ {
+ var generator = new EventRegistrationIncrementalCodeGenerator();
+ GeneratorDriver driver = CSharpGeneratorDriver.Create(generator);
+ Compilation inputCompilation = CreateCompilation(@"
+namespace MyCode
+{
+ public class Program { public static void Main(string[] args) {} }
+ [EventName(""BarEvent"", Description = ""Bar happened"", Constraints = new[] { ""Read"", ""Write"" })]
+ public record BarEvent(string Data);
+ public record BarEntity();
+ public class BarAggregator : EventAggregatorBase
+ {
+ public override Task ApplyAsync(BarEvent evt, BarEntity entity, IAggregatorStreamContext ctx)
+ { throw new NotImplementedException(); }
+ }
+}
+");
+ driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics);
+ diagnostics.ShouldBeEmpty();
+ var runResult = driver.GetRunResult();
+ runResult.Diagnostics.ShouldBeEmpty();
+ var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString();
+ source.ShouldContain("catalog.RegisterEvent(\"BarEvent\", \"Bar happened\", new string[] { \"Read\", \"Write\" },");
+ }
+
+ [Fact]
+ public void TestNoCatalogWithoutEntityAssociation()
+ {
+ var generator = new EventRegistrationIncrementalCodeGenerator();
+ GeneratorDriver driver = CSharpGeneratorDriver.Create(generator);
+ Compilation inputCompilation = CreateCompilation(@"
+namespace MyCode
+{
+ public class Program { public static void Main(string[] args) {} }
+ [EventName(""OrphanEvent"")]
+ public record OrphanEvent(string Value);
+}
+");
+ driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics);
+ diagnostics.ShouldBeEmpty();
+ var runResult = driver.GetRunResult();
+ runResult.Diagnostics.ShouldBeEmpty();
+ var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString();
+ source.ShouldNotContain("AddCodeGeneratedEventCatalog");
+ source.ShouldContain("AddCodeGeneratedEvents");
+ }
+
+ [Fact]
+ public void TestCatalogWithNullDescriptionAndConstraints()
+ {
+ var generator = new EventRegistrationIncrementalCodeGenerator();
+ GeneratorDriver driver = CSharpGeneratorDriver.Create(generator);
+ Compilation inputCompilation = CreateCompilation(@"
+namespace MyCode
+{
+ public class Program { public static void Main(string[] args) {} }
+ public class MyEntity {}
+ [EventName(""SimpleEvent"")]
+ public record SimpleEvent(string Value);
+}
+");
+ driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics);
+ diagnostics.ShouldBeEmpty();
+ var runResult = driver.GetRunResult();
+ runResult.Diagnostics.ShouldBeEmpty();
+ var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString();
+ source.ShouldContain("catalog.RegisterEvent(\"SimpleEvent\", null, null,");
+ }
+
+ [Fact]
+ public void TestQualifiedGenericEventNameAttributeGeneratesCatalog()
+ {
+ var generator = new EventRegistrationIncrementalCodeGenerator();
+ GeneratorDriver driver = CSharpGeneratorDriver.Create(generator);
+ Compilation inputCompilation = CreateCompilation(@"
+namespace MyCode
+{
+ public class Program { public static void Main(string[] args) {} }
+ public class OrderEntity {}
+ [Papst.EventStore.Aggregation.EventRegistration.EventName(""OrderCreated"")]
+ public record OrderCreatedEvent(string Number);
+}
+");
+ driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics);
+ diagnostics.ShouldBeEmpty();
+ var runResult = driver.GetRunResult();
+ runResult.Diagnostics.ShouldBeEmpty();
+ var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString();
+ source.ShouldContain("catalog.RegisterEvent(\"OrderCreated\", null, null,");
+ }
+
+ [Fact]
+ public void TestGlobalQualifiedGenericEventNameAttributeGeneratesCatalog()
+ {
+ var generator = new EventRegistrationIncrementalCodeGenerator();
+ GeneratorDriver driver = CSharpGeneratorDriver.Create(generator);
+ Compilation inputCompilation = CreateCompilation(@"
+namespace MyCode
+{
+ public class Program { public static void Main(string[] args) {} }
+ public class InvoiceEntity {}
+ [global::Papst.EventStore.Aggregation.EventRegistration.EventName(""InvoiceIssued"")]
+ public record InvoiceIssuedEvent(string Number);
+}
+");
+ driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics);
+ diagnostics.ShouldBeEmpty();
+ var runResult = driver.GetRunResult();
+ runResult.Diagnostics.ShouldBeEmpty();
+ var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString();
+ source.ShouldContain("catalog.RegisterEvent(\"InvoiceIssued\", null, null,");
+ }
+}
diff --git a/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorTestBase.cs b/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorTestBase.cs
new file mode 100644
index 0000000..0bc3622
--- /dev/null
+++ b/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorTestBase.cs
@@ -0,0 +1,16 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using System.Reflection;
+
+namespace Papst.EventStore.CodeGeneration.Tests;
+
+public abstract class CodeGeneratorTestBase
+{
+ protected static Compilation CreateCompilation(string source)
+ {
+ return CSharpCompilation.Create("compilation",
+ new[] { CSharpSyntaxTree.ParseText(source) },
+ new[] { MetadataReference.CreateFromFile(typeof(Binder).GetTypeInfo().Assembly.Location) },
+ new CSharpCompilationOptions(OutputKind.ConsoleApplication));
+ }
+}
diff --git a/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorTests.cs b/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorTests.cs
index e172d49..f1b0895 100644
--- a/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorTests.cs
+++ b/tests/Papst.EventStore.CodeGeneration.Tests/CodeGeneratorTests.cs
@@ -1,14 +1,14 @@
-using FluentAssertions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using System.Linq;
using System.Reflection;
+using Shouldly;
using Xunit;
namespace Papst.EventStore.CodeGeneration.Tests;
-public class CodeGeneratorTests
+public class CodeGeneratorTests : CodeGeneratorTestBase
{
[Fact]
public void TestFindsNamespaceWithEntryPoint()
@@ -32,16 +32,16 @@ public record Foo {}
driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics);
- diagnostics.Should().BeEmpty();
- outputCompilation.SyntaxTrees.Should().HaveCount(2);
+ diagnostics.ShouldBeEmpty();
+ outputCompilation.SyntaxTrees.Count().ShouldBe(2);
var runResult = driver.GetRunResult();
- runResult.GeneratedTrees.Should().HaveCount(1);
- runResult.Diagnostics.Should().BeEmpty();
+ runResult.GeneratedTrees.Length.ShouldBe(1);
+ runResult.Diagnostics.ShouldBeEmpty();
var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString();
- source.Should().Contain("namespace MyCode");
+ source.ShouldContain("namespace MyCode");
}
[Fact]
@@ -60,17 +60,17 @@ public record Foo {}
driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics);
- diagnostics.Should().BeEmpty();
- outputCompilation.SyntaxTrees.Should().HaveCount(2);
+ diagnostics.ShouldBeEmpty();
+ outputCompilation.SyntaxTrees.Count().ShouldBe(2);
var runResult = driver.GetRunResult();
- runResult.GeneratedTrees.Should().HaveCount(1);
- runResult.Diagnostics.Should().BeEmpty();
+ runResult.GeneratedTrees.Length.ShouldBe(1);
+ runResult.Diagnostics.ShouldBeEmpty();
var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString();
//source.Should().Contain("namespace MyCode");
- source.Should().Contain("namespace compilation", "This does not return MyCode, because the compiling process is called compilation!");
+ source.ShouldContain("namespace compilation");
}
[Fact]
@@ -98,16 +98,16 @@ public class TestEventFoo
driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics);
- diagnostics.Should().BeEmpty();
- outputCompilation.SyntaxTrees.Should().HaveCount(2);
+ diagnostics.ShouldBeEmpty();
+ outputCompilation.SyntaxTrees.Count().ShouldBe(2);
var runResult = driver.GetRunResult();
- runResult.GeneratedTrees.Should().HaveCount(1);
- runResult.Diagnostics.Should().BeEmpty();
+ runResult.GeneratedTrees.Length.ShouldBe(1);
+ runResult.Diagnostics.ShouldBeEmpty();
var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString();
- source.Should().Contain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));");
+ source.ShouldContain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));");
}
[Fact]
@@ -135,16 +135,16 @@ public class TestEventFoo
driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics);
- diagnostics.Should().BeEmpty();
- outputCompilation.SyntaxTrees.Should().HaveCount(2);
+ diagnostics.ShouldBeEmpty();
+ outputCompilation.SyntaxTrees.Count().ShouldBe(2);
var runResult = driver.GetRunResult();
- runResult.GeneratedTrees.Should().HaveCount(1);
- runResult.Diagnostics.Should().BeEmpty();
+ runResult.GeneratedTrees.Length.ShouldBe(1);
+ runResult.Diagnostics.ShouldBeEmpty();
var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString();
- source.Should().Contain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));");
+ source.ShouldContain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));");
}
@@ -171,16 +171,16 @@ public class TestEventFoo
driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics);
- diagnostics.Should().BeEmpty();
- outputCompilation.SyntaxTrees.Should().HaveCount(2);
+ diagnostics.ShouldBeEmpty();
+ outputCompilation.SyntaxTrees.Count().ShouldBe(2);
var runResult = driver.GetRunResult();
- runResult.GeneratedTrees.Should().HaveCount(1);
- runResult.Diagnostics.Should().BeEmpty();
+ runResult.GeneratedTrees.Length.ShouldBe(1);
+ runResult.Diagnostics.ShouldBeEmpty();
var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString();
- source.Should().Contain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));");
+ source.ShouldContain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));");
}
@@ -211,16 +211,16 @@ public class TestEventFoo
driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics);
- diagnostics.Should().BeEmpty();
- outputCompilation.SyntaxTrees.Should().HaveCount(2);
+ diagnostics.ShouldBeEmpty();
+ outputCompilation.SyntaxTrees.Count().ShouldBe(2);
var runResult = driver.GetRunResult();
- runResult.GeneratedTrees.Should().HaveCount(1);
- runResult.Diagnostics.Should().BeEmpty();
+ runResult.GeneratedTrees.Length.ShouldBe(1);
+ runResult.Diagnostics.ShouldBeEmpty();
var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString();
- source.Should().Contain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"FooOld\", false), new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));");
+ source.ShouldContain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"FooOld\", false), new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));");
}
@@ -251,16 +251,16 @@ public class TestEventFoo
driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics);
- diagnostics.Should().BeEmpty();
- outputCompilation.SyntaxTrees.Should().HaveCount(2);
+ diagnostics.ShouldBeEmpty();
+ outputCompilation.SyntaxTrees.Count().ShouldBe(2);
var runResult = driver.GetRunResult();
- runResult.GeneratedTrees.Should().HaveCount(1);
- runResult.Diagnostics.Should().BeEmpty();
+ runResult.GeneratedTrees.Length.ShouldBe(1);
+ runResult.Diagnostics.ShouldBeEmpty();
var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString();
- source.Should().Contain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"FooOld\", false), new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));");
+ source.ShouldContain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"FooOld\", false), new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));");
}
@@ -289,16 +289,16 @@ public record TestEventFoo
driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics);
- diagnostics.Should().BeEmpty();
- outputCompilation.SyntaxTrees.Should().HaveCount(2);
+ diagnostics.ShouldBeEmpty();
+ outputCompilation.SyntaxTrees.Count().ShouldBe(2);
var runResult = driver.GetRunResult();
- runResult.GeneratedTrees.Should().HaveCount(1);
- runResult.Diagnostics.Should().BeEmpty();
+ runResult.GeneratedTrees.Length.ShouldBe(1);
+ runResult.Diagnostics.ShouldBeEmpty();
var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString();
- source.Should().Contain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));");
+ source.ShouldContain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));");
}
@@ -336,17 +336,17 @@ public class TestEventFooAgg : EventAggregatorBase
driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics);
- diagnostics.Should().BeEmpty();
- outputCompilation.SyntaxTrees.Should().HaveCount(2);
+ diagnostics.ShouldBeEmpty();
+ outputCompilation.SyntaxTrees.Count().ShouldBe(2);
var runResult = driver.GetRunResult();
- runResult.GeneratedTrees.Should().HaveCount(1);
- runResult.Diagnostics.Should().BeEmpty();
+ runResult.GeneratedTrees.Length.ShouldBe(1);
+ runResult.Diagnostics.ShouldBeEmpty();
var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString();
- source.Should().Contain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));");
- source.Should().Contain("services.AddTransient, MyCode.TestEventFooAgg");
+ source.ShouldContain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));");
+ source.ShouldContain("services.AddTransient, MyCode.TestEventFooAgg");
}
[Fact]
@@ -389,18 +389,18 @@ public class TestEventFooAgg : EventAggregatorBase, Eve
driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics);
- diagnostics.Should().BeEmpty();
- outputCompilation.SyntaxTrees.Should().HaveCount(2);
+ diagnostics.ShouldBeEmpty();
+ outputCompilation.SyntaxTrees.Count().ShouldBe(2);
var runResult = driver.GetRunResult();
- runResult.GeneratedTrees.Should().HaveCount(1);
- runResult.Diagnostics.Should().BeEmpty();
+ runResult.GeneratedTrees.Length.ShouldBe(1);
+ runResult.Diagnostics.ShouldBeEmpty();
var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString();
- source.Should().Contain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));");
- source.Should().Contain("services.AddTransient, MyCode.TestEventFooAgg");
- source.Should().Contain("services.AddTransient, MyCode.TestEventFooAgg");
+ source.ShouldContain("registration.AddEvent(new Papst.EventStore.EventRegistration.EventAttributeDescriptor(\"Foo\", true));");
+ source.ShouldContain("services.AddTransient, MyCode.TestEventFooAgg");
+ source.ShouldContain("services.AddTransient, MyCode.TestEventFooAgg");
}
[Fact]
@@ -437,16 +437,16 @@ internal class TestEventFooAgg : EventAggregatorBase
driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics);
- diagnostics.Should().BeEmpty();
- outputCompilation.SyntaxTrees.Should().HaveCount(2);
+ diagnostics.ShouldBeEmpty();
+ outputCompilation.SyntaxTrees.Count().ShouldBe(2);
var runResult = driver.GetRunResult();
- runResult.GeneratedTrees.Should().HaveCount(1);
- runResult.Diagnostics.Should().BeEmpty();
+ runResult.GeneratedTrees.Length.ShouldBe(1);
+ runResult.Diagnostics.ShouldBeEmpty();
var source = runResult.Results[0].GeneratedSources[0].SourceText.ToString();
- source.Should().Contain("services.AddTransient, MyCode.TestEventFooAgg");
+ source.ShouldContain("services.AddTransient, MyCode.TestEventFooAgg");
}
[Fact]
@@ -467,16 +467,7 @@ public static void Main(string[] args)
}");
driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics);
- diagnostics.Should().HaveCount(1);
- diagnostics.First().Id.Should().Be("EVTSRC0002");
+ diagnostics.Length.ShouldBe(1);
+ diagnostics.First().Id.ShouldBe("EVTSRC0002");
}
-
- private Compilation CreateCompilation(string source)
- {
- return CSharpCompilation.Create("compilation",
- new[] { CSharpSyntaxTree.ParseText(source) },
- new[] { MetadataReference.CreateFromFile(typeof(System.Reflection.Binder).GetTypeInfo().Assembly.Location) },
- new CSharpCompilationOptions(OutputKind.ConsoleApplication));
- }
-
}
diff --git a/tests/Papst.EventStore.CodeGeneration.Tests/Papst.EventStore.CodeGeneration.Tests.csproj b/tests/Papst.EventStore.CodeGeneration.Tests/Papst.EventStore.CodeGeneration.Tests.csproj
index 5749584..2973571 100644
--- a/tests/Papst.EventStore.CodeGeneration.Tests/Papst.EventStore.CodeGeneration.Tests.csproj
+++ b/tests/Papst.EventStore.CodeGeneration.Tests/Papst.EventStore.CodeGeneration.Tests.csproj
@@ -13,7 +13,7 @@
-
+
@@ -38,4 +38,4 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
-
\ No newline at end of file
+
diff --git a/tests/Papst.EventStore.MongoDB.Tests/IntegrationTests/MongoDBEventStoreTests.cs b/tests/Papst.EventStore.MongoDB.Tests/IntegrationTests/MongoDBEventStoreTests.cs
index 5b24def..83a9043 100644
--- a/tests/Papst.EventStore.MongoDB.Tests/IntegrationTests/MongoDBEventStoreTests.cs
+++ b/tests/Papst.EventStore.MongoDB.Tests/IntegrationTests/MongoDBEventStoreTests.cs
@@ -2,9 +2,9 @@
using System.Threading;
using System.Threading.Tasks;
using AutoFixture.Xunit2;
-using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Papst.EventStore.Exceptions;
+using Shouldly;
using Xunit;
namespace Papst.EventStore.MongoDB.Tests.IntegrationTests;
@@ -27,8 +27,8 @@ public async Task CreateAsync_ShouldCreateStream(Guid streamId)
// assert
var stream = await eventStore.GetAsync(streamId, CancellationToken.None);
- stream.StreamId.Should().Be(streamId);
- stream.Version.Should().Be(0);
+ stream.StreamId.ShouldBe(streamId);
+ stream.Version.ShouldBe(0UL);
}
[Theory, AutoData]
@@ -43,12 +43,12 @@ public async Task CreateAsync_WithMetadata_ShouldCreateStreamWithMetadata(Guid s
// assert
var stream = await eventStore.GetAsync(streamId, CancellationToken.None);
- stream.StreamId.Should().Be(streamId);
- stream.Version.Should().Be(0);
- stream.MetaData.TenantId.Should().Be(tenantId);
- stream.MetaData.UserId.Should().Be(userId);
- stream.MetaData.UserName.Should().Be(username);
- stream.MetaData.Comment.Should().Be(comment);
+ stream.StreamId.ShouldBe(streamId);
+ stream.Version.ShouldBe(0UL);
+ stream.MetaData.TenantId.ShouldBe(tenantId);
+ stream.MetaData.UserId.ShouldBe(userId);
+ stream.MetaData.UserName.ShouldBe(username);
+ stream.MetaData.Comment.ShouldBe(comment);
}
[Theory, AutoData]
diff --git a/tests/Papst.EventStore.MongoDB.Tests/IntegrationTests/MongoDBEventStreamTests.cs b/tests/Papst.EventStore.MongoDB.Tests/IntegrationTests/MongoDBEventStreamTests.cs
index 89dcab6..ce15776 100644
--- a/tests/Papst.EventStore.MongoDB.Tests/IntegrationTests/MongoDBEventStreamTests.cs
+++ b/tests/Papst.EventStore.MongoDB.Tests/IntegrationTests/MongoDBEventStreamTests.cs
@@ -4,9 +4,9 @@
using System.Threading;
using System.Threading.Tasks;
using AutoFixture.Xunit2;
-using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Papst.EventStore.MongoDB.Tests.IntegrationTests.Events;
+using Shouldly;
using Xunit;
namespace Papst.EventStore.MongoDB.Tests.IntegrationTests;
@@ -29,7 +29,7 @@ public async Task ListAsync_ShouldReturnEmpty(Guid streamId)
var events = await stream.ListAsync(0, CancellationToken.None).ToListAsync(CancellationToken.None);
// assert
- events.Should().BeEmpty();
+ events.ShouldBeEmpty();
}
[Theory, AutoData]
@@ -45,8 +45,8 @@ public async Task AppendAsync_ShouldAppendEvent(Guid streamId, TestEvent testEve
// assert
var events = await stream.ListAsync(0, CancellationToken.None).ToListAsync(CancellationToken.None);
- events.Should().HaveCount(1);
- events[0].Data.ToObject().Should().BeEquivalentTo(testEvent);
+ events.Count.ShouldBe(1);
+ events[0].Data.ToObject().ShouldBe(testEvent);
}
[Theory, AutoData]
@@ -66,8 +66,12 @@ public async Task ListAsync_ShouldReturnEntries(List testEvents, Guid
var result = await stream.ListAsync(0, CancellationToken.None).ToListAsync(CancellationToken.None);
// assert
- result.Should().HaveCount(testEvents.Count);
- result.Select(e => e.Data.ToObject()).Should().BeEquivalentTo(testEvents);
+ result.Count.ShouldBe(testEvents.Count);
+ result.Select(e => e.Data.ToObject())
+ .Where(e => e is not null)
+ .Select(e => e!)
+ .ToList()
+ .ShouldBe(testEvents);
}
[Theory, AutoData]
@@ -87,8 +91,9 @@ public async Task ListDescendingAsync_ShouldReturnEntriesInDescendingOrder(List<
var result = await stream.ListDescendingAsync((ulong)testEvents.Count, CancellationToken.None).ToListAsync(CancellationToken.None);
// assert
- result.Should().HaveCount(testEvents.Count);
- result.Select(e => e.Version).Should().BeInDescendingOrder();
+ result.Count.ShouldBe(testEvents.Count);
+ var versions = result.Select(e => e.Version).ToList();
+ versions.ShouldBe(versions.OrderByDescending(version => version).ToList());
}
[Theory, AutoData]
@@ -104,8 +109,8 @@ public async Task AppendSnapshotAsync_ShouldAppendSnapshot(Guid streamId, TestEv
// assert
var latestSnapshot = await stream.GetLatestSnapshot(CancellationToken.None);
- latestSnapshot.Should().NotBeNull();
- latestSnapshot!.Data.ToObject().Should().BeEquivalentTo(snapshot);
+ latestSnapshot.ShouldNotBeNull();
+ latestSnapshot!.Data.ToObject().ShouldBe(snapshot);
}
[Theory, AutoData]
@@ -126,6 +131,6 @@ public async Task CreateTransactionalBatchAsync_ShouldCommitMultipleEvents(Guid
// assert
var result = await stream.ListAsync(0, CancellationToken.None).ToListAsync(CancellationToken.None);
- result.Should().HaveCount(testEvents.Count);
+ result.Count.ShouldBe(testEvents.Count);
}
}
diff --git a/tests/Papst.EventStore.MongoDB.Tests/Papst.EventStore.MongoDB.Tests.csproj b/tests/Papst.EventStore.MongoDB.Tests/Papst.EventStore.MongoDB.Tests.csproj
index 97f7171..b77b2d1 100644
--- a/tests/Papst.EventStore.MongoDB.Tests/Papst.EventStore.MongoDB.Tests.csproj
+++ b/tests/Papst.EventStore.MongoDB.Tests/Papst.EventStore.MongoDB.Tests.csproj
@@ -14,7 +14,7 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
diff --git a/tests/Papst.EventStore.Tests/AggregationContextDataTests.cs b/tests/Papst.EventStore.Tests/AggregationContextDataTests.cs
index f4b3446..a3e5226 100644
--- a/tests/Papst.EventStore.Tests/AggregationContextDataTests.cs
+++ b/tests/Papst.EventStore.Tests/AggregationContextDataTests.cs
@@ -1,6 +1,6 @@
using System;
using AutoFixture.Xunit2;
-using FluentAssertions;
+using Shouldly;
using Xunit;
namespace Papst.EventStore.Tests;
@@ -22,11 +22,11 @@ public void TestAggregationContextDataInitialization(string key, ulong version,
{
var data = new AggregationContextData(key, version, validUntil, value);
- data.Should().NotBeNull();
- data.Key.Should().Be(key);
- data.Version.Should().Be(version);
- data.ValidUntilVersion.Should().Be(validUntil);
- data.Value.Should().Be(value);
+ data.ShouldNotBeNull();
+ data.Key.ShouldBe(key);
+ data.Version.ShouldBe(version);
+ data.ValidUntilVersion.ShouldBe(validUntil);
+ data.Value.ShouldBe(value);
}
[Fact]
@@ -35,7 +35,7 @@ public void TestAggregationContextDataEquality()
var d1 = new AggregationContextData("key", 1UL, 5UL, "value");
var d2 = new AggregationContextData("key", 1UL, 5UL, "value");
- d1.Should().Be(d2);
+ d1.ShouldBe(d2);
}
[Fact]
@@ -44,7 +44,7 @@ public void TestAggregationContextDataInequalityDifferentKey()
var d1 = new AggregationContextData("k1", 1UL, 5UL, "value");
var d2 = new AggregationContextData("k2", 1UL, 5UL, "value");
- d1.Should().NotBe(d2);
+ d1.ShouldNotBe(d2);
}
[Fact]
@@ -53,7 +53,7 @@ public void TestAggregationContextDataInequalityDifferentVersion()
var d1 = new AggregationContextData("key", 1UL, 5UL, "value");
var d2 = new AggregationContextData("key", 2UL, 5UL, "value");
- d1.Should().NotBe(d2);
+ d1.ShouldNotBe(d2);
}
[Fact]
@@ -65,11 +65,11 @@ public void SetAndGetAggregationData_WhenValid_ReturnsData()
var data = ctx.GetAggregationData("key1");
- data.Should().NotBeNull();
- data!.Key.Should().Be("key1");
- data.Value.Should().Be("value1");
- data.Version.Should().Be(4UL);
- data.ValidUntilVersion.Should().BeNull();
+ data.ShouldNotBeNull();
+ data!.Key.ShouldBe("key1");
+ data.Value.ShouldBe("value1");
+ data.Version.ShouldBe(4UL);
+ data.ValidUntilVersion.ShouldBeNull();
}
[Fact]
@@ -81,7 +81,7 @@ public void GetAggregationData_WithVersionGreaterThanCurrent_ReturnsNull()
var data = ctx.GetAggregationData("k");
- data.Should().BeNull();
+ data.ShouldBeNull();
}
[Fact]
@@ -93,7 +93,7 @@ public void GetAggregationData_ExpiredByValidUntil_ReturnsNull()
var data = ctx.GetAggregationData("k2");
- data.Should().BeNull();
+ data.ShouldBeNull();
}
[Fact]
@@ -109,7 +109,7 @@ public void GetAggregationData_IgnoreValidity_ReturnsDataEvenIfExpiredOrNew()
var newer = ctx.GetAggregationData("newer", ignoreValidity: true);
var expired = ctx.GetAggregationData("expired", ignoreValidity: true);
- newer.Should().NotBeNull();
- expired.Should().NotBeNull();
+ newer.ShouldNotBeNull();
+ expired.ShouldNotBeNull();
}
}
diff --git a/tests/Papst.EventStore.Tests/EventAggregatorBaseTests.cs b/tests/Papst.EventStore.Tests/EventAggregatorBaseTests.cs
index 77cdfc8..8be9c84 100644
--- a/tests/Papst.EventStore.Tests/EventAggregatorBaseTests.cs
+++ b/tests/Papst.EventStore.Tests/EventAggregatorBaseTests.cs
@@ -1,8 +1,8 @@
#nullable enable
using System;
using System.Threading.Tasks;
-using FluentAssertions;
using Papst.EventStore.Aggregation;
+using Shouldly;
using Xunit;
namespace Papst.EventStore.Tests;
@@ -19,7 +19,7 @@ public void SetIfNotNull_ShouldInvokeSetter_WhenNullableStructHasValue()
_sut.CallSetIfNotNull(v => result = v, value);
- result.Should().Be(42);
+ result.ShouldBe(42);
}
[Fact]
@@ -30,7 +30,7 @@ public void SetIfNotNull_ShouldNotInvokeSetter_WhenNullableStructIsNull()
_sut.CallSetIfNotNull(v => result = v, value);
- result.Should().Be(0);
+ result.ShouldBe(0);
}
[Fact]
@@ -41,7 +41,7 @@ public void SetIfNotNull_ShouldInvokeSetter_WhenNullableDateTimeHasValue()
_sut.CallSetIfNotNull(v => result = v, value);
- result.Should().Be(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc));
+ result.ShouldBe(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc));
}
[Fact]
@@ -52,7 +52,7 @@ public void SetIfNotNull_ShouldNotInvokeSetter_WhenNullableDateTimeIsNull()
_sut.CallSetIfNotNull(v => result = v, value);
- result.Should().Be(default(DateTime));
+ result.ShouldBe(default(DateTime));
}
[Fact]
@@ -64,7 +64,7 @@ public void SetIfNotNull_ShouldInvokeSetter_WhenNullableGuidHasValue()
_sut.CallSetIfNotNull(v => result = v, value);
- result.Should().Be(expected);
+ result.ShouldBe(expected);
}
[Fact]
@@ -75,7 +75,7 @@ public void SetIfNotNull_ShouldNotInvokeSetter_WhenNullableGuidIsNull()
_sut.CallSetIfNotNull(v => result = v, value);
- result.Should().Be(Guid.Empty);
+ result.ShouldBe(Guid.Empty);
}
[Fact]
@@ -86,7 +86,7 @@ public void Update_ShouldInvokeSetter_WhenNullableStructHasValue()
_sut.CallUpdate(value, v => result = v);
- result.Should().Be(42);
+ result.ShouldBe(42);
}
[Fact]
@@ -97,7 +97,7 @@ public void Update_ShouldNotInvokeSetter_WhenNullableStructIsNull()
_sut.CallUpdate(value, v => result = v);
- result.Should().Be(0);
+ result.ShouldBe(0);
}
[Fact]
@@ -108,7 +108,7 @@ public void Update_ShouldInvokeSetter_WhenNullableDateTimeHasValue()
_sut.CallUpdate(value, v => result = v);
- result.Should().Be(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc));
+ result.ShouldBe(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc));
}
[Fact]
@@ -119,7 +119,7 @@ public void Update_ShouldNotInvokeSetter_WhenNullableDateTimeIsNull()
_sut.CallUpdate(value, v => result = v);
- result.Should().Be(default(DateTime));
+ result.ShouldBe(default(DateTime));
}
[Fact]
@@ -131,7 +131,7 @@ public void Update_ShouldInvokeSetter_WhenNullableGuidHasValue()
_sut.CallUpdate(value, v => result = v);
- result.Should().Be(expected);
+ result.ShouldBe(expected);
}
[Fact]
@@ -142,7 +142,7 @@ public void Update_ShouldNotInvokeSetter_WhenNullableGuidIsNull()
_sut.CallUpdate(value, v => result = v);
- result.Should().Be(Guid.Empty);
+ result.ShouldBe(Guid.Empty);
}
private class TestEntity
diff --git a/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs b/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs
new file mode 100644
index 0000000..8f58b84
--- /dev/null
+++ b/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs
@@ -0,0 +1,258 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Papst.EventStore.EventCatalog;
+using Papst.EventStore.Exceptions;
+using Shouldly;
+using Xunit;
+
+namespace Papst.EventStore.Tests.EventCatalog;
+
+public class EventCatalogProviderTests
+{
+ private class TestEntity { }
+ private class OtherEntity { }
+
+ private static (EventCatalogRegistration registration, EventCatalogProvider provider) CreateCatalog()
+ {
+ var registration = new EventCatalogRegistration();
+ var provider = new EventCatalogProvider(new[] { registration });
+ return (registration, provider);
+ }
+
+ // --- EventCatalogRegistration tests (via provider) ---
+
+ [Fact]
+ public async Task RegisterEvent_ShouldStoreEventForEntity()
+ {
+ var (registration, provider) = CreateCatalog();
+
+ registration.RegisterEvent("TestEvent", "A test event", null, new Lazy(() => "{}"));
+
+ var entries = await provider.ListEvents();
+
+ entries.Count().ShouldBe(1);
+ entries.Single().EventName.ShouldBe("TestEvent");
+ }
+
+ [Fact]
+ public async Task GetEntries_FilterByName_ReturnsMatching()
+ {
+ var (registration, provider) = CreateCatalog();
+
+ registration.RegisterEvent("EventA", null, null, new Lazy(() => "{}"));
+ registration.RegisterEvent("EventB", null, null, new Lazy(() => "{}"));
+
+ var entries = await provider.ListEvents(name: "EventA");
+
+ entries.Count().ShouldBe(1);
+ entries.Single().EventName.ShouldBe("EventA");
+ }
+
+ [Fact]
+ public async Task GetEntries_FilterByConstraints_ReturnsMatching()
+ {
+ var (registration, provider) = CreateCatalog();
+
+ registration.RegisterEvent("EventX", null, new[] { "admin" }, new Lazy(() => "{}"));
+ registration.RegisterEvent("EventY", null, new[] { "user" }, new Lazy(() => "{}"));
+
+ var entries = await provider.ListEvents(constraints: new[] { "admin" });
+
+ entries.Count().ShouldBe(1);
+ entries.Single().EventName.ShouldBe("EventX");
+ }
+
+ [Fact]
+ public async Task GetEntries_FilterByNameAndConstraints_ReturnsMatching()
+ {
+ var (registration, provider) = CreateCatalog();
+
+ registration.RegisterEvent("EventA", null, new[] { "admin" }, new Lazy(() => "{}"));
+ registration.RegisterEvent("EventA", null, new[] { "user" }, new Lazy(() => "{}"));
+ registration.RegisterEvent("EventB", null, new[] { "admin" }, new Lazy(() => "{}"));
+
+ var entries = await provider.ListEvents(name: "EventA", constraints: new[] { "admin" });
+
+ entries.Count().ShouldBe(1);
+ entries.Single().EventName.ShouldBe("EventA");
+ }
+
+ [Fact]
+ public async Task GetEntries_ForUnknownEntity_ReturnsEmpty()
+ {
+ var (registration, provider) = CreateCatalog();
+
+ registration.RegisterEvent("EventA", null, null, new Lazy(() => "{}"));
+
+ var entries = await provider.ListEvents();
+
+ entries.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task GetDetails_ReturnsSchemaAndDescription()
+ {
+ var (registration, provider) = CreateCatalog();
+ const string schema = """{"type":"object","properties":{"id":{"type":"string"}}}""";
+
+ registration.RegisterEvent("DetailedEvent", "Has a schema", new[] { "v1" }, new Lazy(() => schema));
+
+ var details = await provider.GetEventDetails("DetailedEvent");
+
+ details.ShouldNotBeNull();
+ details!.EventName.ShouldBe("DetailedEvent");
+ details.Description.ShouldBe("Has a schema");
+ details.Constraints.ShouldBe(new[] { "v1" });
+ details.JsonSchema.ShouldBe(schema);
+ }
+
+ [Fact]
+ public async Task GetDetails_UnknownEvent_ReturnsNull()
+ {
+ var (_, provider) = CreateCatalog();
+
+ var details = await provider.GetEventDetails("NonExistent");
+
+ details.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task GetDetails_LazySchemaEvaluatedOnAccess()
+ {
+ var (registration, provider) = CreateCatalog();
+ bool evaluated = false;
+ var lazySchema = new Lazy(() =>
+ {
+ evaluated = true;
+ return """{"type":"object"}""";
+ });
+
+ registration.RegisterEvent("LazyEvent", null, null, lazySchema);
+
+ evaluated.ShouldBeFalse();
+
+ var details = await provider.GetEventDetails("LazyEvent");
+
+ evaluated.ShouldBeTrue();
+ details.ShouldNotBeNull();
+ details.JsonSchema.ShouldBe("""{"type":"object"}""");
+ }
+
+ // --- EventCatalogProvider delegation tests ---
+
+ [Fact]
+ public async Task ListEvents_DelegatesToRegistrations()
+ {
+ var registration = new EventCatalogRegistration();
+ registration.RegisterEvent("Evt1", "desc", null, new Lazy(() => "{}"));
+ var provider = new EventCatalogProvider(new[] { registration });
+
+ var entries = await provider.ListEvents();
+
+ entries.Count().ShouldBe(1);
+ var entry = entries.Single();
+ entry.EventName.ShouldBe("Evt1");
+ entry.Description.ShouldBe("desc");
+ entry.Constraints.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task GetEventDetails_DelegatesToRegistrations()
+ {
+ var registration = new EventCatalogRegistration();
+ registration.RegisterEvent("Evt2", "desc2", new[] { "c1" }, new Lazy(() => """{"schema":true}"""));
+ var provider = new EventCatalogProvider(new[] { registration });
+
+ var details = await provider.GetEventDetails("Evt2");
+
+ details.ShouldNotBeNull();
+ details!.EventName.ShouldBe("Evt2");
+ details.Description.ShouldBe("desc2");
+ details.Constraints.ShouldBe(new[] { "c1" });
+ details.JsonSchema.ShouldBe("""{"schema":true}""");
+ }
+
+ [Fact]
+ public async Task ListEvents_CombinesMultipleRegistrations()
+ {
+ var reg1 = new EventCatalogRegistration();
+ reg1.RegisterEvent("FromReg1", null, null, new Lazy(() => "{}"));
+
+ var reg2 = new EventCatalogRegistration();
+ reg2.RegisterEvent("FromReg2", null, null, new Lazy(() => "{}"));
+
+ var provider = new EventCatalogProvider(new IEventCatalogRegistration[] { reg1, reg2 });
+
+ var entries = await provider.ListEvents();
+
+ entries.Count().ShouldBe(2);
+ entries.Select(e => e.EventName).OrderBy(name => name).ToArray().ShouldBe(new[] { "FromReg1", "FromReg2" });
+ }
+
+ // --- Entity-scoped GetEventDetails tests ---
+
+ [Fact]
+ public async Task GetEventDetails_EntityScoped_ReturnsDetailsForEntity()
+ {
+ var (registration, provider) = CreateCatalog();
+
+ registration.RegisterEvent("SharedEvent", "Test version", new[] { "test" }, new Lazy(() => """{"entity":"test"}"""));
+ registration.RegisterEvent("SharedEvent", "Other version", new[] { "other" }, new Lazy(() => """{"entity":"other"}"""));
+
+ var testDetails = await provider.GetEventDetails("SharedEvent");
+ var otherDetails = await provider.GetEventDetails("SharedEvent");
+
+ testDetails.ShouldNotBeNull();
+ testDetails!.Description.ShouldBe("Test version");
+ testDetails.JsonSchema.ShouldBe("""{"entity":"test"}""");
+
+ otherDetails.ShouldNotBeNull();
+ otherDetails!.Description.ShouldBe("Other version");
+ otherDetails.JsonSchema.ShouldBe("""{"entity":"other"}""");
+ }
+
+ [Fact]
+ public async Task GetEventDetails_EntityScoped_UnknownEntity_ReturnsNull()
+ {
+ var (registration, provider) = CreateCatalog();
+
+ registration.RegisterEvent("SomeEvent", null, null, new Lazy(() => "{}"));
+
+ var details = await provider.GetEventDetails("SomeEvent");
+
+ details.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task GetEventDetails_DuplicateEventNamesAcrossEntities_GlobalThrowsAmbiguousException()
+ {
+ var (registration, provider) = CreateCatalog();
+
+ registration.RegisterEvent("DuplicateName", "First", null, new Lazy(() => """{"first":true}"""));
+ registration.RegisterEvent("DuplicateName", "Second", null, new Lazy(() => """{"second":true}"""));
+
+ Func act = async () => _ = await provider.GetEventDetails("DuplicateName");
+
+ var exception = await Should.ThrowAsync(act);
+ exception.Message.ShouldContain("DuplicateName");
+ }
+
+ [Fact]
+ public async Task GetEventDetails_DuplicateEventNamesAcrossRegistrations_GlobalThrowsAmbiguousException()
+ {
+ var reg1 = new EventCatalogRegistration();
+ reg1.RegisterEvent("DuplicateName", "First", null, new Lazy(() => """{"first":true}"""));
+
+ var reg2 = new EventCatalogRegistration();
+ reg2.RegisterEvent("DuplicateName", "Second", null, new Lazy(() => """{"second":true}"""));
+
+ var provider = new EventCatalogProvider(new IEventCatalogRegistration[] { reg1, reg2 });
+
+ Func act = async () => _ = await provider.GetEventDetails("DuplicateName");
+
+ var exception = await Should.ThrowAsync(act);
+ exception.Message.ShouldContain("DuplicateName");
+ }
+}
diff --git a/tests/Papst.EventStore.Tests/EventRegistrationTests.cs b/tests/Papst.EventStore.Tests/EventRegistrationTests.cs
index f6735c8..d6e8653 100644
--- a/tests/Papst.EventStore.Tests/EventRegistrationTests.cs
+++ b/tests/Papst.EventStore.Tests/EventRegistrationTests.cs
@@ -1,8 +1,8 @@
using AutoFixture.Xunit2;
-using FluentAssertions;
using Microsoft.Extensions.Logging;
using Moq;
using Papst.EventStore.EventRegistration;
+using Shouldly;
using Xunit;
namespace Papst.EventStore.Tests;
@@ -20,8 +20,8 @@ public void RegistrationShouldReturnWriteName(string readName, string writeName)
var eventreg = new EventRegistrationTypeProvider(loggerMock.Object, new[] { registration });
var resolved = eventreg.ResolveType(typeof(FooEvent));
- resolved.Should().NotBeNull();
- resolved.Should().Be(writeName);
+ resolved.ShouldNotBeNull();
+ resolved.ShouldBe(writeName);
}
[Theory, AutoData]
@@ -35,8 +35,8 @@ public void RegistrationShouldResolveReadName(string readName, string writeName)
var eventreg = new EventRegistrationTypeProvider(loggerMock.Object, new[] { registration });
var resolved = eventreg.ResolveIdentifier(readName);
- resolved.Should().NotBeNull();
- resolved.Should().Be(typeof(FooEvent));
+ resolved.ShouldNotBeNull();
+ resolved.ShouldBe(typeof(FooEvent));
}
private record FooEvent();
diff --git a/tests/Papst.EventStore.Tests/EventStreamMetadataTests.cs b/tests/Papst.EventStore.Tests/EventStreamMetadataTests.cs
index 28afe31..70e43af 100644
--- a/tests/Papst.EventStore.Tests/EventStreamMetadataTests.cs
+++ b/tests/Papst.EventStore.Tests/EventStreamMetadataTests.cs
@@ -1,8 +1,8 @@
using System.Collections.Generic;
using AutoFixture.Xunit2;
-using FluentAssertions;
using Newtonsoft.Json;
using Papst.EventStore.Documents;
+using Shouldly;
using Xunit;
namespace Papst.EventStore.Tests;
@@ -27,32 +27,42 @@ Dictionary add
Additional = add
};
- meta.Should().NotBeNull();
- meta.UserId.Should().Be(id);
- meta.UserName.Should().Be(name);
- meta.TenantId.Should().Be(tenant);
- meta.Comment.Should().Be(comment);
- meta.Additional.Should().BeEquivalentTo(add);
+ meta.ShouldNotBeNull();
+ meta.UserId.ShouldBe(id);
+ meta.UserName.ShouldBe(name);
+ meta.TenantId.ShouldBe(tenant);
+ meta.Comment.ShouldBe(comment);
+ meta.Additional.ShouldNotBeNull();
+ meta.Additional.Count.ShouldBe(add.Count);
+ foreach (var entry in add)
+ {
+ meta.Additional[entry.Key].ShouldBe(entry.Value);
+ }
}
[Theory, AutoData]
public void TestEventMetaDataSerialization(EventStreamMetaData doc)
{
- doc.Should().NotBeNull();
+ doc.ShouldNotBeNull();
string serialized = JsonConvert.SerializeObject(doc);
- serialized.Should().NotBeNull().And.NotBeEmpty().And.StartWith("{");
+ serialized.ShouldNotBeNullOrEmpty();
+ serialized.ShouldStartWith("{");
}
[Theory, AutoData]
public void TestEventMetaDataDeserialization(EventStreamMetaData doc)
{
- doc.Should().NotBeNull();
+ doc.ShouldNotBeNull();
+
+ var deserialized = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(doc));
- JsonConvert.DeserializeObject(JsonConvert.SerializeObject(doc))
- .Should()
- .NotBeNull()
- .And.BeEquivalentTo(doc);
+ deserialized.ShouldNotBeNull();
+ deserialized.UserId.ShouldBe(doc.UserId);
+ deserialized.UserName.ShouldBe(doc.UserName);
+ deserialized.TenantId.ShouldBe(doc.TenantId);
+ deserialized.Comment.ShouldBe(doc.Comment);
+ deserialized.Additional.ShouldBe(doc.Additional);
}
}
diff --git a/tests/Papst.EventStore.Tests/Papst.EventStore.Tests.csproj b/tests/Papst.EventStore.Tests/Papst.EventStore.Tests.csproj
index 720283e..9e9f0fb 100644
--- a/tests/Papst.EventStore.Tests/Papst.EventStore.Tests.csproj
+++ b/tests/Papst.EventStore.Tests/Papst.EventStore.Tests.csproj
@@ -10,7 +10,7 @@
-
+
@@ -38,4 +38,4 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
-
\ No newline at end of file
+
diff --git a/tests/Papst.EventsStore.InMemory.Tests/IntegrationTests/InMemoryEventStoreTests.cs b/tests/Papst.EventsStore.InMemory.Tests/IntegrationTests/InMemoryEventStoreTests.cs
index 49d8af2..c559ffc 100644
--- a/tests/Papst.EventsStore.InMemory.Tests/IntegrationTests/InMemoryEventStoreTests.cs
+++ b/tests/Papst.EventsStore.InMemory.Tests/IntegrationTests/InMemoryEventStoreTests.cs
@@ -1,7 +1,7 @@
using AutoFixture.Xunit2;
-using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Papst.EventStore;
+using Shouldly;
namespace Papst.EventsStore.InMemory.Tests.IntegrationTests;
@@ -22,8 +22,8 @@ public async Task CreateAsync_ShouldCreateStream(Guid streamId)
// assert
var stream = await eventStore.GetAsync(streamId, CancellationToken.None);
- stream.StreamId.Should().Be(streamId);
- stream.Version.Should().Be(0);
+ stream.StreamId.ShouldBe(streamId);
+ stream.Version.ShouldBe(0UL);
}
diff --git a/tests/Papst.EventsStore.InMemory.Tests/IntegrationTests/InMemoryEventStreamTests.cs b/tests/Papst.EventsStore.InMemory.Tests/IntegrationTests/InMemoryEventStreamTests.cs
index 660dac1..69267f8 100644
--- a/tests/Papst.EventsStore.InMemory.Tests/IntegrationTests/InMemoryEventStreamTests.cs
+++ b/tests/Papst.EventsStore.InMemory.Tests/IntegrationTests/InMemoryEventStreamTests.cs
@@ -1,8 +1,8 @@
using AutoFixture.Xunit2;
-using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Papst.EventsStore.InMemory.Tests.IntegrationTests.Events;
using Papst.EventStore;
+using Shouldly;
namespace Papst.EventsStore.InMemory.Tests.IntegrationTests;
@@ -24,7 +24,7 @@ public async Task ListAsync_ShouldReturnEmpty(Guid streamId)
var events = await stream.ListAsync(0, CancellationToken.None).ToListAsync(CancellationToken.None);
// assert
- events.Should().BeEmpty();
+ events.ShouldBeEmpty();
}
[Theory, AutoData]
@@ -43,8 +43,12 @@ public async Task ListAsync_ShouldReturnEntries(List events, Guid str
var result = await stream.ListAsync(0, CancellationToken.None).ToListAsync(CancellationToken.None);
// assert
- result.Should().HaveCount(events.Count);
- result.Select(e => e.Data.ToObject()).Should().BeEquivalentTo(events);
+ result.Count.ShouldBe(events.Count);
+ result.Select(e => e.Data.ToObject())
+ .Where(e => e is not null)
+ .Select(e => e!)
+ .ToList()
+ .ShouldBe(events);
}
}
diff --git a/tests/Papst.EventsStore.InMemory.Tests/Papst.EventsStore.InMemory.Tests.csproj b/tests/Papst.EventsStore.InMemory.Tests/Papst.EventsStore.InMemory.Tests.csproj
index cdabaa5..95080eb 100644
--- a/tests/Papst.EventsStore.InMemory.Tests/Papst.EventsStore.InMemory.Tests.csproj
+++ b/tests/Papst.EventsStore.InMemory.Tests/Papst.EventsStore.InMemory.Tests.csproj
@@ -15,7 +15,7 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
diff --git a/version.json b/version.json
index eabde83..8a5474d 100644
--- a/version.json
+++ b/version.json
@@ -1,5 +1,5 @@
{
- "version": "5.4",
+ "version": "6.0",
"publicReleaseRefSpec": [
"^refs/heads/main"
],