Compare commits

..

12 Commits

Author SHA1 Message Date
Mathias Beaulieu-Duncan 7fc680cd93 Add Svrnty.CQRS.Events.Kafka package
Kafka domain event publisher implementing IDomainEventPublisher,
sibling to Svrnty.CQRS.Events.RabbitMQ. Uses Confluent.Kafka 2.6.1,
targets net10.0 with C# 14.

Features:
- Configurable bootstrap servers, client id, idempotence, acks, retries
- Security protocol + SASL config (Plaintext/SSL/SASL_SSL etc.)
- Topic mapper (default lowercase event-type-name, custom func override)
- IAsyncDisposable producer cleanup
- Two registration overloads via AddKafkaDomainEvents

Project added to solution. Builds with 0 warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:20:49 -04:00
mathias 55f1324286 Merge pull request 'feat/claude-code-harness' (#2) from feat/claude-code-harness into main
Publish NuGets / build (release) Successful in 34s
Reviewed-on: #2
2026-03-12 06:44:11 -04:00
Mathias Beaulieu-Duncan b34bf874b4 Remove Claude harness — replaced by claude-cqrs-plugin
The in-repo .claude/ harness (rules, skills, settings) is superseded by
the standalone claude-cqrs-plugin which provides the same guidance as a
reusable plugin across all Svrnty.CQRS projects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 06:42:50 -04:00
Mathias Beaulieu-Duncan c6de10b98b Move UseSvrntyCqrs() from MinimalApi to core Svrnty.CQRS package
gRPC-only projects couldn't call app.UseSvrntyCqrs() without adding the
MinimalApi package. The method only calls ExecuteMappingCallbacks() which
is already in core — it had no MinimalApi dependency. Adds ASP.NET Core
FrameworkReference to the core package.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 06:38:26 -04:00
Mathias Beaulieu-Duncan 3945c1a158 Add project-init agent for scaffolding new CQRS projects
Scaffolds a complete Svrnty.CQRS project from a natural language
description — creates solution, web project, DAL with PostgreSQL,
entities, Program.cs, first feature, proto file, and .editorconfig.
Defaults to gRPC-only; MinimalApi added only on request.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 06:38:26 -04:00
mathias 7614f68512 Merge pull request 'feat/claude-code-harness' (#1) from feat/claude-code-harness into main
Publish NuGets / build (release) Successful in 32s
Reviewed-on: #1
2026-03-12 03:35:26 -04:00
Mathias Beaulieu-Duncan fdee02c960 Apply dotnet format with new editorconfig rules
Automated formatting: BOM removal, using sort order, final newlines,
whitespace normalization across all projects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 03:30:50 -04:00
Mathias Beaulieu-Duncan a4525bad6a Add Claude Code harness: rules, skills, hooks, and editorconfig
- Add path-specific rules for commands/queries, dynamic queries, validation, and gRPC
- Add /add-command, /add-query, /add-dynamic-query scaffolding skills
- Add project settings with post-edit formatting, proto validation, and build-gate hooks
- Add .editorconfig codifying existing code style conventions
- Trim CLAUDE.md from 414 to 130 lines (domain details moved to rules)
- Add .harness-version tracking for the shared claude-harness repo

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 03:30:27 -04:00
Svrnty 3df094b9e7 docs: sanitise product references, add "Where This Fits" to README
Co-Authored-By: Svrnty Inc. <jp@svrnty.io, mathias@svrnty.io>
2026-02-27 13:08:17 -05:00
david.nguyen 6aece5a769 Handle generic types in proto message name generation
Publish NuGets / build (release) Successful in 39s
Generic types like Translation<T> now produce qualified message names
(e.g. TranslationOfFaqTranslationQueryItem) to avoid duplicate message
definitions in generated .proto files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:56:37 -05:00
david.nguyen b372805c4e Fix string filter values not converting to correct CLR types for DynamicQuery
Publish NuGets / build (release) Successful in 41s
Convert string filter values (e.g. from gRPC transport) to their actual
property types (DateTime, DateTimeOffset) so PoweredSoft.DynamicLinq can
build valid LINQ expressions. Also removes filters with null values and
recurses into composite filters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:28:09 -05:00
david.nguyen 89ccbe990f add AND / OR support when filtering
Publish NuGets / build (release) Successful in 34s
2026-02-02 17:53:43 -05:00
52 changed files with 4057 additions and 3459 deletions
-50
View File
@@ -1,50 +0,0 @@
{
"permissions": {
"allow": [
"Bash(dotnet clean:*)",
"Bash(dotnet run)",
"Bash(dotnet add:*)",
"Bash(timeout 5 dotnet run:*)",
"Bash(dotnet remove:*)",
"Bash(netstat:*)",
"Bash(findstr:*)",
"Bash(cat:*)",
"Bash(taskkill:*)",
"WebSearch",
"Bash(dotnet tool install:*)",
"Bash(protogen:*)",
"Bash(timeout 15 dotnet run:*)",
"Bash(where:*)",
"Bash(timeout 30 dotnet run:*)",
"Bash(timeout 60 dotnet run:*)",
"Bash(timeout 120 dotnet run:*)",
"Bash(git add:*)",
"Bash(curl:*)",
"Bash(timeout 3 cmd:*)",
"Bash(timeout:*)",
"Bash(tasklist:*)",
"Bash(dotnet build:*)",
"Bash(dotnet --list-sdks:*)",
"Bash(dotnet sln:*)",
"Bash(pkill:*)",
"Bash(python3:*)",
"Bash(grpcurl:*)",
"Bash(lsof:*)",
"Bash(xargs kill -9)",
"Bash(dotnet run:*)",
"Bash(find:*)",
"Bash(dotnet pack:*)",
"Bash(unzip:*)",
"WebFetch(domain:andrewlock.net)",
"WebFetch(domain:github.com)",
"WebFetch(domain:stackoverflow.com)",
"WebFetch(domain:www.kenmuse.com)",
"WebFetch(domain:blog.rsuter.com)",
"WebFetch(domain:natemcmaster.com)",
"WebFetch(domain:www.nuget.org)",
"Bash(mkdir:*)"
],
"deny": [],
"ask": []
}
}
+96
View File
@@ -0,0 +1,96 @@
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{csproj,props,targets,xml}]
indent_size = 2
[*.{json,yml,yaml}]
indent_size = 2
[*.proto]
indent_size = 2
[*.cs]
# Namespace
csharp_style_namespace_declarations = file_scoped:warning
# Braces — Allman style
csharp_new_line_before_open_brace = all
# Usings
dotnet_sort_system_directives_first = true
csharp_using_directive_placement = outside_namespace:warning
# var preferences — use var when type is apparent
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = true:suggestion
# Expression bodies — prefer for simple members
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
csharp_style_expression_bodied_constructors = false:suggestion
csharp_style_expression_bodied_operators = when_on_single_line:suggestion
csharp_style_expression_bodied_properties = true:suggestion
csharp_style_expression_bodied_accessors = true:suggestion
csharp_style_expression_bodied_lambdas = true:suggestion
# Pattern matching
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
# Null checking
csharp_style_throw_expression = true:suggestion
csharp_style_conditional_delegate_call = true:suggestion
# Modifier preferences — exclude interface members (netstandard2.1 compat)
dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
# Field naming — _camelCase for private fields
dotnet_naming_rule.private_fields_should_be_camel_case.severity = warning
dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields
dotnet_naming_rule.private_fields_should_be_camel_case.style = camel_case_underscore
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, private_protected
dotnet_naming_symbols.private_fields.required_modifiers =
dotnet_naming_style.camel_case_underscore.required_prefix = _
dotnet_naming_style.camel_case_underscore.capitalization = camel_case
# Constants — PascalCase
dotnet_naming_rule.constants_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants
dotnet_naming_rule.constants_should_be_pascal_case.style = pascal_case
dotnet_naming_symbols.constants.applicable_kinds = field
dotnet_naming_symbols.constants.required_modifiers = const
dotnet_naming_style.pascal_case.capitalization = pascal_case
# Interfaces — I prefix
dotnet_naming_rule.interfaces_should_begin_with_i.severity = warning
dotnet_naming_rule.interfaces_should_begin_with_i.symbols = interfaces
dotnet_naming_rule.interfaces_should_begin_with_i.style = begins_with_i
dotnet_naming_symbols.interfaces.applicable_kinds = interface
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.capitalization = pascal_case
# Async methods — Async suffix
dotnet_naming_rule.async_methods_should_end_with_async.severity = suggestion
dotnet_naming_rule.async_methods_should_end_with_async.symbols = async_methods
dotnet_naming_rule.async_methods_should_end_with_async.style = ends_with_async
dotnet_naming_symbols.async_methods.applicable_kinds = method
dotnet_naming_symbols.async_methods.required_modifiers = async
dotnet_naming_style.ends_with_async.required_suffix = Async
dotnet_naming_style.ends_with_async.capitalization = pascal_case
+1 -1
View File
@@ -1,6 +1,6 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This file provides guidance to AI agents when working with code in this repository.
## Project Overview
+9
View File
@@ -4,6 +4,15 @@
Our implementation of query and command responsibility segregation (CQRS).
## Where This Fits
This is a backend framework of the [Svrnty Agent System](../README.md).
**Layer**: Framework
**Depends on**: Nothing (standalone .NET framework)
**Depended on by**: a-gent-app (backend services), flutter_cqrs_datasource (client)
**Git**: [git.openharbor.io/svrnty/dotnet-cqrs](https://git.openharbor.io/svrnty/dotnet-cqrs)
## Getting Started
> Install nuget package to your awesome project.
@@ -1,4 +1,4 @@
using System;
using System;
namespace Svrnty.CQRS.Abstractions.Attributes;
@@ -1,4 +1,4 @@
using System;
using System;
namespace Svrnty.CQRS.Abstractions.Attributes;
@@ -1,4 +1,4 @@
using System;
using System;
using System.Reflection;
using Svrnty.CQRS.Abstractions.Attributes;
@@ -1,4 +1,4 @@
using System;
using System;
namespace Svrnty.CQRS.Abstractions.Discovery;
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
namespace Svrnty.CQRS.Abstractions.Discovery;
@@ -1,4 +1,4 @@
using System;
using System;
namespace Svrnty.CQRS.Abstractions.Discovery;
@@ -10,4 +10,4 @@ public interface IQueryMeta
Type QueryResultType { get; }
string Category { get; }
string LowerCamelCaseName { get; }
}
}
@@ -1,4 +1,4 @@
using System;
using System;
using System.Reflection;
using Svrnty.CQRS.Abstractions.Attributes;
+2 -2
View File
@@ -1,4 +1,4 @@
using System.Threading;
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.Abstractions;
@@ -13,4 +13,4 @@ public interface ICommandHandler<in TCommand, TCommandResult>
where TCommand : class
{
Task<TCommandResult> HandleAsync(TCommand command, CancellationToken cancellationToken = default);
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
using System.Threading;
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.Abstractions;
@@ -7,4 +7,4 @@ public interface IQueryHandler<in TQuery, TQueryResult>
where TQuery : class
{
Task<TQueryResult> HandleAsync(TQuery query, CancellationToken cancellationToken = default);
}
}
@@ -1,8 +1,8 @@
namespace Svrnty.CQRS.Abstractions.Security;
namespace Svrnty.CQRS.Abstractions.Security;
public enum AuthorizationResult
{
Unauthorized,
Forbidden,
Allowed
}
}
@@ -1,4 +1,4 @@
using System;
using System;
using System.Threading;
using System.Threading.Tasks;
@@ -7,4 +7,4 @@ namespace Svrnty.CQRS.Abstractions.Security;
public interface ICommandAuthorizationService
{
Task<AuthorizationResult> IsAllowedAsync(Type commandType, CancellationToken cancellationToken = default);
}
}
@@ -1,4 +1,4 @@
using System;
using System;
using System.Threading;
using System.Threading.Tasks;
@@ -7,4 +7,4 @@ namespace Svrnty.CQRS.Abstractions.Security;
public interface IQueryAuthorizationService
{
Task<AuthorizationResult> IsAllowedAsync(Type queryType, CancellationToken cancellationToken = default);
}
}
@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Abstractions.Discovery;
@@ -47,4 +47,4 @@ public static class ServiceCollectionExtensions
return services;
}
}
}
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
@@ -1,4 +1,4 @@
using System.Linq;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -13,4 +13,4 @@ public interface IAlterQueryableService<TSource, TDestination, in TParams>
where TParams : class
{
Task<IQueryable<TSource>> AlterQueryableAsync(IQueryable<TSource> query, IDynamicQueryParams<TParams> dynamicQuery, CancellationToken cancellationToken = default);
}
}
@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using PoweredSoft.DynamicQuery.Core;
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
@@ -15,7 +15,7 @@ public interface IDynamicQuery<TSource, TDestination, out TParams> : IDynamicQue
where TDestination : class
where TParams : class
{
}
public interface IDynamicQuery
@@ -26,4 +26,4 @@ public interface IDynamicQuery
List<IAggregate> GetAggregates();
int? GetPage();
int? GetPageSize();
}
}
@@ -1,9 +1,9 @@
using System;
using System;
using System.Collections.Generic;
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
public interface IDynamicQueryInterceptorProvider<TSource, TDestination>
{
IEnumerable<Type> GetInterceptorsTypes();
}
}
@@ -1,4 +1,4 @@
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
public interface IDynamicQueryParams<out TParams>
where TParams : class
@@ -1,4 +1,4 @@
using System.Linq;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -7,4 +7,4 @@ namespace Svrnty.CQRS.DynamicQuery.Abstractions;
public interface IQueryableProvider<TSource>
{
Task<IQueryable<TSource>> GetQueryableAsync(object query, CancellationToken cancellationToken = default);
}
}
@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using PoweredSoft.DynamicQuery.Core;
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.Abstractions.Attributes;
using Svrnty.CQRS.Abstractions.Discovery;
@@ -14,7 +15,6 @@ using Svrnty.CQRS.Abstractions.Security;
using Svrnty.CQRS.DynamicQuery;
using Svrnty.CQRS.DynamicQuery.Abstractions;
using Svrnty.CQRS.DynamicQuery.Discover;
using PoweredSoft.DynamicQuery.Core;
namespace Svrnty.CQRS.DynamicQuery.MinimalApi;
@@ -1,4 +1,4 @@
using System;
using System;
using Pluralize.NET;
using Svrnty.CQRS.Abstractions.Discovery;
@@ -7,7 +7,7 @@ namespace Svrnty.CQRS.DynamicQuery.Discover;
public class DynamicQueryMeta(Type queryType, Type serviceType, Type queryResultType)
: QueryMeta(queryType, serviceType, queryResultType)
{
public Type SourceType => QueryType.GetGenericArguments()[0];
public Type SourceType => QueryType.GetGenericArguments()[0];
public Type DestinationType => QueryType.GetGenericArguments()[1];
public override string Category => "DynamicQuery";
public override string Name
+1 -1
View File
@@ -1,8 +1,8 @@
using System.Collections.Generic;
using System.Linq;
using Svrnty.CQRS.DynamicQuery.Abstractions;
using PoweredSoft.DynamicQuery;
using PoweredSoft.DynamicQuery.Core;
using Svrnty.CQRS.DynamicQuery.Abstractions;
namespace Svrnty.CQRS.DynamicQuery;
@@ -1,6 +1,6 @@
using System;
using PoweredSoft.DynamicQuery;
using PoweredSoft.DynamicQuery.Core;
using System;
namespace Svrnty.CQRS.DynamicQuery;
+10 -10
View File
@@ -1,23 +1,23 @@
using Svrnty.CQRS.DynamicQuery.Abstractions;
using PoweredSoft.DynamicQuery.Core;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using PoweredSoft.DynamicQuery.Core;
using Svrnty.CQRS.DynamicQuery.Abstractions;
namespace Svrnty.CQRS.DynamicQuery;
public class DynamicQueryHandler<TSource, TDestination>
: DynamicQueryHandlerBase<TSource, TDestination>,
: DynamicQueryHandlerBase<TSource, TDestination>,
Svrnty.CQRS.Abstractions.IQueryHandler<IDynamicQuery<TSource, TDestination>, IQueryExecutionResult<TDestination>>
where TSource : class
where TDestination : class
{
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
IEnumerable<IQueryableProvider<TSource>> queryableProviders,
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
IServiceProvider serviceProvider) : base(queryHandlerAsync, queryableProviders, alterQueryableServices, dynamicQueryInterceptorProviders, serviceProvider)
{
}
@@ -29,7 +29,7 @@ public class DynamicQueryHandler<TSource, TDestination>
}
public class DynamicQueryHandler<TSource, TDestination, TParams>
: DynamicQueryHandlerBase<TSource, TDestination>,
: DynamicQueryHandlerBase<TSource, TDestination>,
Svrnty.CQRS.Abstractions.IQueryHandler<IDynamicQuery<TSource, TDestination, TParams>, IQueryExecutionResult<TDestination>>
where TSource : class
where TDestination : class
@@ -37,10 +37,10 @@ public class DynamicQueryHandler<TSource, TDestination, TParams>
{
private readonly IEnumerable<IAlterQueryableService<TSource, TDestination, TParams>> alterQueryableServicesWithParams;
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
IEnumerable<IQueryableProvider<TSource>> queryableProviders,
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
IEnumerable<IAlterQueryableService<TSource, TDestination, TParams>> alterQueryableServicesWithParams,
IEnumerable<IAlterQueryableService<TSource, TDestination, TParams>> alterQueryableServicesWithParams,
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
IServiceProvider serviceProvider) : base(queryHandlerAsync, queryableProviders, alterQueryableServices, dynamicQueryInterceptorProviders, serviceProvider)
{
@@ -49,7 +49,7 @@ public class DynamicQueryHandler<TSource, TDestination, TParams>
protected override async Task<IQueryable<TSource>> AlterSourceAsync(IQueryable<TSource> source, IDynamicQuery query, CancellationToken cancellationToken)
{
source = await base.AlterSourceAsync(source, query, cancellationToken);
source = await base.AlterSourceAsync(source, query, cancellationToken);
if (query is IDynamicQueryParams<TParams> withParams)
{
@@ -1,11 +1,14 @@
using System;
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Svrnty.CQRS.DynamicQuery.Abstractions;
using PoweredSoft.DynamicQuery;
using PoweredSoft.DynamicQuery.Core;
using Svrnty.CQRS.DynamicQuery.Abstractions;
namespace Svrnty.CQRS.DynamicQuery;
@@ -16,10 +19,13 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
private readonly IQueryHandlerAsync _queryHandlerAsync;
private readonly IEnumerable<IQueryableProvider<TSource>> _queryableProviders;
private readonly IEnumerable<IAlterQueryableService<TSource, TDestination>> _alterQueryableServices;
private readonly IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> _dynamicQueryInterceptorProviders;
private readonly IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>>
_dynamicQueryInterceptorProviders;
private readonly IServiceProvider _serviceProvider;
public DynamicQueryHandlerBase(IQueryHandlerAsync queryHandlerAsync,
public DynamicQueryHandlerBase(IQueryHandlerAsync queryHandlerAsync,
IEnumerable<IQueryableProvider<TSource>> queryableProviders,
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
@@ -32,7 +38,8 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
_serviceProvider = serviceProvider;
}
protected virtual Task<IQueryable<TSource>> GetQueryableAsync(IDynamicQuery query, CancellationToken cancellationToken = default)
protected virtual Task<IQueryable<TSource>> GetQueryableAsync(IDynamicQuery query,
CancellationToken cancellationToken = default)
{
if (_queryableProviders.Any())
{
@@ -56,7 +63,8 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
yield return _serviceProvider.GetService(type) as IQueryInterceptor;
}
protected async Task<IQueryExecutionResult<TDestination>> ProcessQueryAsync(IDynamicQuery query, CancellationToken cancellationToken = default)
protected async Task<IQueryExecutionResult<TDestination>> ProcessQueryAsync(IDynamicQuery query,
CancellationToken cancellationToken = default)
{
var source = await GetQueryableAsync(query, cancellationToken);
source = await AlterSourceAsync(source, query, cancellationToken);
@@ -67,11 +75,13 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
_queryHandlerAsync.AddInterceptor(interceptor);
var criteria = CreateCriteriaFromQuery(query);
var result = await _queryHandlerAsync.ExecuteAsync<TSource, TDestination>(source, criteria, options, cancellationToken);
var result =
await _queryHandlerAsync.ExecuteAsync<TSource, TDestination>(source, criteria, options, cancellationToken);
return result;
}
protected virtual async Task<IQueryable<TSource>> AlterSourceAsync(IQueryable<TSource> source, IDynamicQuery query, CancellationToken cancellationToken)
protected virtual async Task<IQueryable<TSource>> AlterSourceAsync(IQueryable<TSource> source, IDynamicQuery query,
CancellationToken cancellationToken)
{
foreach (var t in _alterQueryableServices)
source = await t.AlterQueryableAsync(source, query, cancellationToken);
@@ -81,16 +91,94 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
protected virtual IQueryCriteria CreateCriteriaFromQuery(IDynamicQuery query)
{
var filters = query?.GetFilters() ?? new List<IFilter>();
ConvertFilterValuesToPropertyTypes(filters);
var criteria = new QueryCriteria
{
Page = query?.GetPage(),
PageSize = query?.GetPageSize(),
Filters = query?.GetFilters() ?? new List<IFilter>(),
Filters = filters,
Sorts = query?.GetSorts() ?? new List<ISort>(),
Groups = query?.GetGroups() ?? new List<IGroup>(),
Aggregates = query?.GetAggregates() ?? new List<IAggregate>()
};
return criteria;
}
/// <summary>
/// Converts string filter values to the correct CLR type based on TSource property types.
/// This handles the case where transport layers (e.g. gRPC) pass all values as strings,
/// but PoweredSoft.DynamicLinq needs the actual type to build LINQ expressions.
/// </summary>
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2087",
Justification = "TSource properties are preserved by EF Core and DynamicLinq usage")]
private static void ConvertFilterValuesToPropertyTypes(List<IFilter> filters)
{
for (var i = filters.Count - 1; i >= 0; i--)
{
var filter = filters[i];
if (filter is SimpleFilter simpleFilter)
{
if (simpleFilter.Value == null)
{
filters.RemoveAt(i);
continue;
}
if (simpleFilter.Value is string strValue && !string.IsNullOrEmpty(strValue))
{
var propertyType = ResolvePropertyType(typeof(TSource), simpleFilter.Path);
if (propertyType == null)
continue;
var targetType = Nullable.GetUnderlyingType(propertyType) ?? propertyType;
if (targetType == typeof(DateTime))
{
if (DateTime.TryParse(strValue, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal,
out var dt))
{
simpleFilter.Value = DateTime.SpecifyKind(dt, DateTimeKind.Utc);
}
}
else if (targetType == typeof(DateTimeOffset))
{
if (DateTimeOffset.TryParse(strValue, CultureInfo.InvariantCulture, DateTimeStyles.None,
out var dto))
{
simpleFilter.Value = dto;
}
}
}
}
else if (filter is CompositeFilter compositeFilter && compositeFilter.Filters != null)
{
ConvertFilterValuesToPropertyTypes(compositeFilter.Filters);
}
}
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2070",
Justification = "Property types are preserved by EF Core and DynamicLinq usage")]
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2075",
Justification = "Nested property type resolution is inherently dynamic")]
static Type? ResolvePropertyType(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type, string? path)
{
if (string.IsNullOrEmpty(path))
return null;
var currentType = type;
foreach (var part in path.Split('.'))
{
var property = currentType.GetProperty(part,
BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
if (property == null)
return null;
currentType = property.PropertyType;
}
return currentType;
}
}
}
@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using PoweredSoft.Data.Core;
@@ -91,10 +91,10 @@ public static class ServiceCollectionExtensions
where TParams : class
=> AddDynamicQueryWithParams<TSourceAndDestination, TSourceAndDestination, TParams>(services, name: name);
public static IServiceCollection AddDynamicQueryWithParams<TSource, TDestination, TParams>(this IServiceCollection services, string name = null)
where TSource : class
where TDestination : class
where TParams : class
public static IServiceCollection AddDynamicQueryWithParams<TSource, TDestination, TParams>(this IServiceCollection services, string name = null)
where TSource : class
where TDestination : class
where TParams : class
{
// add query handler.
services.AddTransient<IQueryHandler<IDynamicQuery<TSource, TDestination, TParams>, IQueryExecutionResult<TDestination>>, DynamicQueryHandler<TSource, TDestination, TParams>>();
@@ -133,7 +133,7 @@ public static class ServiceCollectionExtensions
where TParams : class
where TService : class, IAlterQueryableService<TSourceAndTDestination, TSourceAndTDestination, TParams>
{
return services.AddTransient<IAlterQueryableService< TSourceAndTDestination, TSourceAndTDestination, TParams>, TService>();
return services.AddTransient<IAlterQueryableService<TSourceAndTDestination, TSourceAndTDestination, TParams>, TService>();
}
public static IServiceCollection AddAlterQueryableWithParams<TSource, TDestination, TParams, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TService>
@@ -0,0 +1,205 @@
using System.Text;
using System.Text.Json;
using Confluent.Kafka;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Svrnty.CQRS.Events.Abstractions;
namespace Svrnty.CQRS.Events.Kafka;
/// <summary>
/// Apache Kafka implementation of the domain event publisher.
/// </summary>
public class KafkaDomainEventPublisher : IDomainEventPublisher, IAsyncDisposable
{
private readonly KafkaEventOptions _options;
private readonly ILogger<KafkaDomainEventPublisher> _logger;
private readonly IProducer<string, string> _producer;
private bool _disposed;
/// <summary>
/// Creates a new Kafka domain event publisher.
/// </summary>
public KafkaDomainEventPublisher(
IOptions<KafkaEventOptions> options,
ILogger<KafkaDomainEventPublisher> logger)
{
_options = options.Value;
_logger = logger;
var config = BuildProducerConfig();
_producer = new ProducerBuilder<string, string>(config)
.SetErrorHandler((_, e) => _logger.LogError("Kafka producer error: {Reason}", e.Reason))
.SetLogHandler((_, log) => _logger.LogDebug("Kafka: {Message}", log.Message))
.Build();
}
private ProducerConfig BuildProducerConfig()
{
var config = new ProducerConfig
{
BootstrapServers = _options.BootstrapServers,
ClientId = _options.ClientId,
EnableIdempotence = _options.EnableIdempotence,
MessageTimeoutMs = _options.MessageTimeoutMs,
Acks = (Acks)_options.Acks,
MessageSendMaxRetries = _options.Retries
};
// Configure security if specified
if (!string.IsNullOrEmpty(_options.SecurityProtocol) &&
_options.SecurityProtocol != "Plaintext")
{
config.SecurityProtocol = Enum.Parse<SecurityProtocol>(_options.SecurityProtocol);
}
if (!string.IsNullOrEmpty(_options.SaslMechanism))
{
config.SaslMechanism = Enum.Parse<SaslMechanism>(_options.SaslMechanism);
config.SaslUsername = _options.SaslUsername;
config.SaslPassword = _options.SaslPassword;
}
return config;
}
/// <inheritdoc />
public async Task PublishAsync<TEvent>(TEvent @event, CancellationToken cancellationToken = default)
where TEvent : IDomainEvent
{
ObjectDisposedException.ThrowIf(_disposed, this);
var eventTypeName = typeof(TEvent).Name;
var topic = GetTopicName(eventTypeName);
var key = GetEventKey(@event);
var value = JsonSerializer.Serialize(@event);
var message = new Message<string, string>
{
Key = key,
Value = value,
Headers = new Headers
{
{ "event-type", Encoding.UTF8.GetBytes(eventTypeName) },
{ "event-id", Encoding.UTF8.GetBytes(@event.EventId.ToString()) },
{ "occurred-at", Encoding.UTF8.GetBytes(@event.OccurredAt.ToString("O")) },
{ "content-type", Encoding.UTF8.GetBytes("application/json") }
}
};
try
{
var result = await _producer.ProduceAsync(topic, message, cancellationToken);
_logger.LogDebug(
"Published event {EventType} with ID {EventId} to topic {Topic}, partition {Partition}, offset {Offset}",
eventTypeName,
@event.EventId,
topic,
result.Partition.Value,
result.Offset.Value);
}
catch (ProduceException<string, string> ex)
{
_logger.LogError(ex,
"Failed to publish event {EventType} with ID {EventId} to topic {Topic}: {Error}",
eventTypeName,
@event.EventId,
topic,
ex.Error.Reason);
throw;
}
}
private string GetTopicName(string eventTypeName)
{
// Use custom mapper if provided
if (_options.TopicMapper != null)
{
return _options.TopicMapper(eventTypeName);
}
// Default mapping: group events by category
// e.g., RequestCreatedEvent -> {prefix}.requests
// e.g., ContainerSpawnedEvent -> {prefix}.containers
var category = GetEventCategory(eventTypeName);
return $"{_options.TopicPrefix}.{category}";
}
private static string GetEventCategory(string eventTypeName)
{
// Remove "Event" suffix
var name = eventTypeName.Replace("Event", "");
// Map to categories based on prefix
return name switch
{
var n when n.StartsWith("Request") => "requests",
var n when n.StartsWith("Container") => "containers",
var n when n.StartsWith("Plan") => "plans",
var n when n.StartsWith("Project") => "projects",
var n when n.StartsWith("Solution") => "solutions",
var n when n.StartsWith("Claude") || n.StartsWith("Session") => "sessions",
var n when n.StartsWith("Worker") => "workers",
var n when n.StartsWith("Deployment") => "deployments",
_ => "events" // Default catch-all topic
};
}
private static string GetEventKey<TEvent>(TEvent @event) where TEvent : IDomainEvent
{
// Use reflection to find the best partition key
// Priority: RequestId > ProjectId > SolutionId > EventId
var type = typeof(TEvent);
// Try RequestId first (most common for request-scoped events)
var requestIdProp = type.GetProperty("RequestId");
if (requestIdProp?.GetValue(@event) is Guid requestId && requestId != Guid.Empty)
{
return requestId.ToString();
}
// Try ContainerId
var containerIdProp = type.GetProperty("ContainerId");
if (containerIdProp?.GetValue(@event) is Guid containerId && containerId != Guid.Empty)
{
return containerId.ToString();
}
// Try ProjectId
var projectIdProp = type.GetProperty("ProjectId");
if (projectIdProp?.GetValue(@event) is Guid projectId && projectId != Guid.Empty)
{
return projectId.ToString();
}
// Try SolutionId
var solutionIdProp = type.GetProperty("SolutionId");
if (solutionIdProp?.GetValue(@event) is Guid solutionId && solutionId != Guid.Empty)
{
return solutionId.ToString();
}
// Fall back to EventId
return @event.EventId.ToString();
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
if (_disposed)
{
return ValueTask.CompletedTask;
}
_disposed = true;
// Flush pending messages with timeout
_producer.Flush(TimeSpan.FromSeconds(10));
_producer.Dispose();
_logger.LogDebug("Kafka domain event publisher disposed");
return ValueTask.CompletedTask;
}
}
@@ -0,0 +1,72 @@
namespace Svrnty.CQRS.Events.Kafka;
/// <summary>
/// Configuration options for Kafka domain event publishing.
/// </summary>
public class KafkaEventOptions
{
/// <summary>
/// Kafka bootstrap servers. Default: localhost:9092
/// </summary>
public string BootstrapServers { get; set; } = "localhost:9092";
/// <summary>
/// Prefix for Kafka topics. Default: domain
/// Events will be published to topics like {TopicPrefix}.{category}
/// </summary>
public string TopicPrefix { get; set; } = "domain";
/// <summary>
/// Client identifier for Kafka producer. Default: cqrs-events
/// </summary>
public string ClientId { get; set; } = "cqrs-events";
/// <summary>
/// Enable idempotent producer for exactly-once semantics. Default: true
/// </summary>
public bool EnableIdempotence { get; set; } = true;
/// <summary>
/// Message timeout in milliseconds. Default: 30000 (30 seconds)
/// </summary>
public int MessageTimeoutMs { get; set; } = 30000;
/// <summary>
/// Security protocol. Default: Plaintext
/// Options: Plaintext, Ssl, SaslPlaintext, SaslSsl
/// </summary>
public string SecurityProtocol { get; set; } = "Plaintext";
/// <summary>
/// SASL mechanism for authentication. Optional.
/// Options: Plain, ScramSha256, ScramSha512
/// </summary>
public string? SaslMechanism { get; set; }
/// <summary>
/// SASL username for authentication. Optional.
/// </summary>
public string? SaslUsername { get; set; }
/// <summary>
/// SASL password for authentication. Optional.
/// </summary>
public string? SaslPassword { get; set; }
/// <summary>
/// Number of acknowledgements required. Default: All (-1)
/// Options: None (0), Leader (1), All (-1)
/// </summary>
public int Acks { get; set; } = -1;
/// <summary>
/// Maximum number of retries. Default: 3
/// </summary>
public int Retries { get; set; } = 3;
/// <summary>
/// Custom topic mapping function. If not set, default mapping is used.
/// Maps event type name to topic name.
/// </summary>
public Func<string, string>? TopicMapper { get; set; }
}
@@ -0,0 +1,53 @@
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Events.Abstractions;
namespace Svrnty.CQRS.Events.Kafka;
/// <summary>
/// Extension methods for registering Kafka domain event publishing.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds Kafka domain event publishing to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configure">Optional configuration action for Kafka options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddKafkaDomainEvents(
this IServiceCollection services,
Action<KafkaEventOptions>? configure = null)
{
if (configure != null)
{
services.Configure(configure);
}
services.AddSingleton<IDomainEventPublisher, KafkaDomainEventPublisher>();
return services;
}
/// <summary>
/// Adds Kafka domain event publishing with custom topic mapping.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="topicMapper">Custom function to map event type names to topic names.</param>
/// <param name="configure">Optional configuration action for other Kafka options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddKafkaDomainEvents(
this IServiceCollection services,
Func<string, string> topicMapper,
Action<KafkaEventOptions>? configure = null)
{
services.Configure<KafkaEventOptions>(options =>
{
options.TopicMapper = topicMapper;
configure?.Invoke(options);
});
services.AddSingleton<IDomainEventPublisher, KafkaDomainEventPublisher>();
return services;
}
}
@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsAotCompatible>false</IsAotCompatible>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Company>Svrnty</Company>
<Authors>David Lebee, Mathias Beaulieu-Duncan</Authors>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://git.openharbor.io/svrnty/dotnet-cqrs</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<Description>Apache Kafka domain event publishing for Svrnty.CQRS framework</Description>
<DebugType>portable</DebugType>
<DebugSymbols>true</DebugSymbols>
<IncludeSymbols>true</IncludeSymbols>
<IncludeSource>true</IncludeSource>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<None Include="..\icon.png" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Svrnty.CQRS.Events.Abstractions\Svrnty.CQRS.Events.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Confluent.Kafka" Version="2.6.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
</ItemGroup>
</Project>
@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Abstractions;
@@ -39,7 +39,7 @@ public static class ServiceCollectionExtensions
{
services.AddQuery<TQuery, TQueryResult, TQueryHandler>()
.AddFluentValidator<TQuery, TValidator>();
return services;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,102 +1,101 @@
using System.Collections.Generic;
namespace Svrnty.CQRS.Grpc.Generators.Helpers
namespace Svrnty.CQRS.Grpc.Generators.Helpers;
internal static class ProtoTypeMapper
{
internal static class ProtoTypeMapper
private static readonly Dictionary<string, string> TypeMap = new Dictionary<string, string>
{
private static readonly Dictionary<string, string> TypeMap = new Dictionary<string, string>
// Primitives
{ "System.String", "string" },
{ "System.Boolean", "bool" },
{ "System.Int32", "int32" },
{ "System.Int64", "int64" },
{ "System.UInt32", "uint32" },
{ "System.UInt64", "uint64" },
{ "System.Single", "float" },
{ "System.Double", "double" },
{ "System.Byte", "uint32" },
{ "System.SByte", "int32" },
{ "System.Int16", "int32" },
{ "System.UInt16", "uint32" },
{ "System.Decimal", "string" }, // Decimal as string to preserve precision
{ "System.DateTime", "int64" }, // Unix timestamp
{ "System.DateTimeOffset", "int64" }, // Unix timestamp
{ "System.Guid", "string" },
{ "System.TimeSpan", "int64" }, // Ticks
// Nullable variants
{ "System.Boolean?", "bool" },
{ "System.Int32?", "int32" },
{ "System.Int64?", "int64" },
{ "System.UInt32?", "uint32" },
{ "System.UInt64?", "uint64" },
{ "System.Single?", "float" },
{ "System.Double?", "double" },
{ "System.Byte?", "uint32" },
{ "System.SByte?", "int32" },
{ "System.Int16?", "int32" },
{ "System.UInt16?", "uint32" },
{ "System.Decimal?", "string" },
{ "System.DateTime?", "int64" },
{ "System.DateTimeOffset?", "int64" },
{ "System.Guid?", "string" },
{ "System.TimeSpan?", "int64" },
};
public static string MapToProtoType(string csharpType, out bool isRepeated, out bool isOptional)
{
isRepeated = false;
isOptional = false;
// Handle byte[] as bytes proto type (NOT repeated uint32)
if (csharpType == "System.Byte[]" || csharpType == "byte[]" || csharpType == "Byte[]")
{
// Primitives
{ "System.String", "string" },
{ "System.Boolean", "bool" },
{ "System.Int32", "int32" },
{ "System.Int64", "int64" },
{ "System.UInt32", "uint32" },
{ "System.UInt64", "uint64" },
{ "System.Single", "float" },
{ "System.Double", "double" },
{ "System.Byte", "uint32" },
{ "System.SByte", "int32" },
{ "System.Int16", "int32" },
{ "System.UInt16", "uint32" },
{ "System.Decimal", "string" }, // Decimal as string to preserve precision
{ "System.DateTime", "int64" }, // Unix timestamp
{ "System.DateTimeOffset", "int64" }, // Unix timestamp
{ "System.Guid", "string" },
{ "System.TimeSpan", "int64" }, // Ticks
// Nullable variants
{ "System.Boolean?", "bool" },
{ "System.Int32?", "int32" },
{ "System.Int64?", "int64" },
{ "System.UInt32?", "uint32" },
{ "System.UInt64?", "uint64" },
{ "System.Single?", "float" },
{ "System.Double?", "double" },
{ "System.Byte?", "uint32" },
{ "System.SByte?", "int32" },
{ "System.Int16?", "int32" },
{ "System.UInt16?", "uint32" },
{ "System.Decimal?", "string" },
{ "System.DateTime?", "int64" },
{ "System.DateTimeOffset?", "int64" },
{ "System.Guid?", "string" },
{ "System.TimeSpan?", "int64" },
};
public static string MapToProtoType(string csharpType, out bool isRepeated, out bool isOptional)
{
isRepeated = false;
isOptional = false;
// Handle byte[] as bytes proto type (NOT repeated uint32)
if (csharpType == "System.Byte[]" || csharpType == "byte[]" || csharpType == "Byte[]")
{
return "bytes";
}
// Handle arrays
if (csharpType.EndsWith("[]"))
{
isRepeated = true;
var elementType = csharpType.Substring(0, csharpType.Length - 2);
return MapToProtoType(elementType, out _, out _);
}
// Handle generic collections
if (csharpType.StartsWith("System.Collections.Generic.List<") ||
csharpType.StartsWith("System.Collections.Generic.IList<") ||
csharpType.StartsWith("System.Collections.Generic.IEnumerable<") ||
csharpType.StartsWith("System.Collections.Generic.ICollection<"))
{
isRepeated = true;
var startIndex = csharpType.IndexOf('<') + 1;
var endIndex = csharpType.LastIndexOf('>');
var elementType = csharpType.Substring(startIndex, endIndex - startIndex);
return MapToProtoType(elementType, out _, out _);
}
// Handle nullable value types
if (csharpType.EndsWith("?"))
{
isOptional = true;
}
// Check if it's a known primitive type
if (TypeMap.TryGetValue(csharpType, out var protoType))
{
return protoType;
}
// For unknown types, assume it's a custom message type
// Extract just the type name without namespace
var lastDot = csharpType.LastIndexOf('.');
if (lastDot >= 0)
{
return csharpType.Substring(lastDot + 1).Replace("?", "");
}
return csharpType.Replace("?", "");
return "bytes";
}
// Handle arrays
if (csharpType.EndsWith("[]"))
{
isRepeated = true;
var elementType = csharpType.Substring(0, csharpType.Length - 2);
return MapToProtoType(elementType, out _, out _);
}
// Handle generic collections
if (csharpType.StartsWith("System.Collections.Generic.List<") ||
csharpType.StartsWith("System.Collections.Generic.IList<") ||
csharpType.StartsWith("System.Collections.Generic.IEnumerable<") ||
csharpType.StartsWith("System.Collections.Generic.ICollection<"))
{
isRepeated = true;
var startIndex = csharpType.IndexOf('<') + 1;
var endIndex = csharpType.LastIndexOf('>');
var elementType = csharpType.Substring(startIndex, endIndex - startIndex);
return MapToProtoType(elementType, out _, out _);
}
// Handle nullable value types
if (csharpType.EndsWith("?"))
{
isOptional = true;
}
// Check if it's a known primitive type
if (TypeMap.TryGetValue(csharpType, out var protoType))
{
return protoType;
}
// For unknown types, assume it's a custom message type
// Extract just the type name without namespace
var lastDot = csharpType.LastIndexOf('.');
if (lastDot >= 0)
{
return csharpType.Substring(lastDot + 1).Replace("?", "");
}
return csharpType.Replace("?", "");
}
}
@@ -1,83 +1,82 @@
using System.Collections.Generic;
using Microsoft.CodeAnalysis;
namespace Svrnty.CQRS.Grpc.Generators.Models
namespace Svrnty.CQRS.Grpc.Generators.Models;
public class CommandInfo
{
public class CommandInfo
public string Name { get; set; }
public string FullyQualifiedName { get; set; }
public string Namespace { get; set; }
public List<PropertyInfo> Properties { get; set; }
public string? ResultType { get; set; }
public string? ResultFullyQualifiedName { get; set; }
public bool HasResult => ResultType != null;
public string HandlerInterfaceName { get; set; }
public List<PropertyInfo> ResultProperties { get; set; }
public bool IsResultPrimitiveType { get; set; }
public CommandInfo()
{
public string Name { get; set; }
public string FullyQualifiedName { get; set; }
public string Namespace { get; set; }
public List<PropertyInfo> Properties { get; set; }
public string? ResultType { get; set; }
public string? ResultFullyQualifiedName { get; set; }
public bool HasResult => ResultType != null;
public string HandlerInterfaceName { get; set; }
public List<PropertyInfo> ResultProperties { get; set; }
public bool IsResultPrimitiveType { get; set; }
public CommandInfo()
{
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
Properties = new List<PropertyInfo>();
HandlerInterfaceName = string.Empty;
ResultProperties = new List<PropertyInfo>();
IsResultPrimitiveType = false;
}
}
public class PropertyInfo
{
public string Name { get; set; }
public string Type { get; set; }
public string FullyQualifiedType { get; set; }
public string ProtoType { get; set; }
public int FieldNumber { get; set; }
public bool IsComplexType { get; set; }
public List<PropertyInfo> NestedProperties { get; set; }
// Type conversion metadata
public bool IsEnum { get; set; }
public bool IsList { get; set; }
public bool IsNullable { get; set; }
public bool IsDecimal { get; set; }
public bool IsDateTime { get; set; }
public bool IsDateTimeOffset { get; set; }
public bool IsGuid { get; set; }
public bool IsJsonElement { get; set; }
public bool IsBinaryType { get; set; } // Stream, byte[], MemoryStream
public bool IsStream { get; set; } // Specifically Stream types (not byte[])
public bool IsReadOnly { get; set; } // Read-only/computed properties should be skipped
public bool IsValueTypeCollection { get; set; } // Value types that implement IList<T> (like NpgsqlPolygon)
public string? ElementType { get; set; }
public bool IsElementComplexType { get; set; }
public bool IsElementGuid { get; set; }
public List<PropertyInfo>? ElementNestedProperties { get; set; }
public PropertyInfo()
{
Name = string.Empty;
Type = string.Empty;
FullyQualifiedType = string.Empty;
ProtoType = string.Empty;
IsComplexType = false;
NestedProperties = new List<PropertyInfo>();
IsEnum = false;
IsList = false;
IsNullable = false;
IsDecimal = false;
IsDateTime = false;
IsDateTimeOffset = false;
IsGuid = false;
IsJsonElement = false;
IsBinaryType = false;
IsStream = false;
IsReadOnly = false;
IsValueTypeCollection = false;
IsElementComplexType = false;
IsElementGuid = false;
}
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
Properties = new List<PropertyInfo>();
HandlerInterfaceName = string.Empty;
ResultProperties = new List<PropertyInfo>();
IsResultPrimitiveType = false;
}
}
public class PropertyInfo
{
public string Name { get; set; }
public string Type { get; set; }
public string FullyQualifiedType { get; set; }
public string ProtoType { get; set; }
public int FieldNumber { get; set; }
public bool IsComplexType { get; set; }
public List<PropertyInfo> NestedProperties { get; set; }
// Type conversion metadata
public bool IsEnum { get; set; }
public bool IsList { get; set; }
public bool IsNullable { get; set; }
public bool IsDecimal { get; set; }
public bool IsDateTime { get; set; }
public bool IsDateTimeOffset { get; set; }
public bool IsGuid { get; set; }
public bool IsJsonElement { get; set; }
public bool IsBinaryType { get; set; } // Stream, byte[], MemoryStream
public bool IsStream { get; set; } // Specifically Stream types (not byte[])
public bool IsReadOnly { get; set; } // Read-only/computed properties should be skipped
public bool IsValueTypeCollection { get; set; } // Value types that implement IList<T> (like NpgsqlPolygon)
public string? ElementType { get; set; }
public bool IsElementComplexType { get; set; }
public bool IsElementGuid { get; set; }
public List<PropertyInfo>? ElementNestedProperties { get; set; }
public PropertyInfo()
{
Name = string.Empty;
Type = string.Empty;
FullyQualifiedType = string.Empty;
ProtoType = string.Empty;
IsComplexType = false;
NestedProperties = new List<PropertyInfo>();
IsEnum = false;
IsList = false;
IsNullable = false;
IsDecimal = false;
IsDateTime = false;
IsDateTimeOffset = false;
IsGuid = false;
IsJsonElement = false;
IsBinaryType = false;
IsStream = false;
IsReadOnly = false;
IsValueTypeCollection = false;
IsElementComplexType = false;
IsElementGuid = false;
}
}
@@ -1,28 +1,27 @@
namespace Svrnty.CQRS.Grpc.Generators.Models
{
public class DynamicQueryInfo
{
public string Name { get; set; }
public string SourceType { get; set; }
public string SourceTypeFullyQualified { get; set; }
public string DestinationType { get; set; }
public string DestinationTypeFullyQualified { get; set; }
public string? ParamsType { get; set; }
public string? ParamsTypeFullyQualified { get; set; }
public string HandlerInterfaceName { get; set; }
public string QueryInterfaceName { get; set; }
public bool HasParams { get; set; }
namespace Svrnty.CQRS.Grpc.Generators.Models;
public DynamicQueryInfo()
{
Name = string.Empty;
SourceType = string.Empty;
SourceTypeFullyQualified = string.Empty;
DestinationType = string.Empty;
DestinationTypeFullyQualified = string.Empty;
HandlerInterfaceName = string.Empty;
QueryInterfaceName = string.Empty;
HasParams = false;
}
public class DynamicQueryInfo
{
public string Name { get; set; }
public string SourceType { get; set; }
public string SourceTypeFullyQualified { get; set; }
public string DestinationType { get; set; }
public string DestinationTypeFullyQualified { get; set; }
public string? ParamsType { get; set; }
public string? ParamsTypeFullyQualified { get; set; }
public string HandlerInterfaceName { get; set; }
public string QueryInterfaceName { get; set; }
public bool HasParams { get; set; }
public DynamicQueryInfo()
{
Name = string.Empty;
SourceType = string.Empty;
SourceTypeFullyQualified = string.Empty;
DestinationType = string.Empty;
DestinationTypeFullyQualified = string.Empty;
HandlerInterfaceName = string.Empty;
QueryInterfaceName = string.Empty;
HasParams = false;
}
}
@@ -1,50 +1,49 @@
using System.Collections.Generic;
namespace Svrnty.CQRS.Grpc.Generators.Models
namespace Svrnty.CQRS.Grpc.Generators.Models;
/// <summary>
/// Represents a discovered streaming notification type for proto/gRPC generation.
/// </summary>
public class NotificationInfo
{
/// <summary>
/// Represents a discovered streaming notification type for proto/gRPC generation.
/// The notification type name (e.g., "InventoryChangeNotification").
/// </summary>
public class NotificationInfo
public string Name { get; set; }
/// <summary>
/// The fully qualified type name including namespace.
/// </summary>
public string FullyQualifiedName { get; set; }
/// <summary>
/// The namespace of the notification type.
/// </summary>
public string Namespace { get; set; }
/// <summary>
/// The property name used as the subscription key (from [StreamingNotification] attribute).
/// </summary>
public string SubscriptionKeyProperty { get; set; }
/// <summary>
/// The subscription key property info.
/// </summary>
public PropertyInfo SubscriptionKeyInfo { get; set; }
/// <summary>
/// All properties of the notification type.
/// </summary>
public List<PropertyInfo> Properties { get; set; }
public NotificationInfo()
{
/// <summary>
/// The notification type name (e.g., "InventoryChangeNotification").
/// </summary>
public string Name { get; set; }
/// <summary>
/// The fully qualified type name including namespace.
/// </summary>
public string FullyQualifiedName { get; set; }
/// <summary>
/// The namespace of the notification type.
/// </summary>
public string Namespace { get; set; }
/// <summary>
/// The property name used as the subscription key (from [StreamingNotification] attribute).
/// </summary>
public string SubscriptionKeyProperty { get; set; }
/// <summary>
/// The subscription key property info.
/// </summary>
public PropertyInfo SubscriptionKeyInfo { get; set; }
/// <summary>
/// All properties of the notification type.
/// </summary>
public List<PropertyInfo> Properties { get; set; }
public NotificationInfo()
{
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
SubscriptionKeyProperty = string.Empty;
SubscriptionKeyInfo = new PropertyInfo();
Properties = new List<PropertyInfo>();
}
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
SubscriptionKeyProperty = string.Empty;
SubscriptionKeyInfo = new PropertyInfo();
Properties = new List<PropertyInfo>();
}
}
+24 -25
View File
@@ -1,30 +1,29 @@
using System.Collections.Generic;
namespace Svrnty.CQRS.Grpc.Generators.Models
{
public class QueryInfo
{
public string Name { get; set; }
public string FullyQualifiedName { get; set; }
public string Namespace { get; set; }
public List<PropertyInfo> Properties { get; set; }
public string ResultType { get; set; }
public string ResultFullyQualifiedName { get; set; }
public string HandlerInterfaceName { get; set; }
public List<PropertyInfo> ResultProperties { get; set; }
public bool IsResultPrimitiveType { get; set; }
namespace Svrnty.CQRS.Grpc.Generators.Models;
public QueryInfo()
{
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
Properties = new List<PropertyInfo>();
ResultType = string.Empty;
ResultFullyQualifiedName = string.Empty;
HandlerInterfaceName = string.Empty;
ResultProperties = new List<PropertyInfo>();
IsResultPrimitiveType = false;
}
public class QueryInfo
{
public string Name { get; set; }
public string FullyQualifiedName { get; set; }
public string Namespace { get; set; }
public List<PropertyInfo> Properties { get; set; }
public string ResultType { get; set; }
public string ResultFullyQualifiedName { get; set; }
public string HandlerInterfaceName { get; set; }
public List<PropertyInfo> ResultProperties { get; set; }
public bool IsResultPrimitiveType { get; set; }
public QueryInfo()
{
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
Properties = new List<PropertyInfo>();
ResultType = string.Empty;
ResultFullyQualifiedName = string.Empty;
HandlerInterfaceName = string.Empty;
ResultProperties = new List<PropertyInfo>();
IsResultPrimitiveType = false;
}
}
@@ -413,17 +413,21 @@ internal class ProtoFileGenerator
private void GenerateComplexTypeMessage(INamedTypeSymbol? type)
{
if (type == null || _generatedMessages.Contains(type.Name))
if (type == null)
return;
var messageName = ProtoFileTypeMapper.GetProtoMessageName(type);
if (_generatedMessages.Contains(messageName))
return;
// Don't generate messages for system types or primitives
if (type.ContainingNamespace?.ToString().StartsWith("System") == true)
return;
_generatedMessages.Add(type.Name);
_generatedMessages.Add(messageName);
_messagesBuilder.AppendLine($"// {type.Name} entity");
_messagesBuilder.AppendLine($"message {type.Name} {{");
_messagesBuilder.AppendLine($"// {messageName} entity");
_messagesBuilder.AppendLine($"message {messageName} {{");
// Collect nested complex types to generate after closing this message
var nestedComplexTypes = new List<INamedTypeSymbol>();
+16 -1
View File
@@ -1,4 +1,5 @@
using System;
using System.Linq;
using Microsoft.CodeAnalysis;
namespace Svrnty.CQRS.Grpc.Generators;
@@ -151,13 +152,27 @@ internal static class ProtoFileTypeMapper
// Complex types (classes/records) become message types
if (typeSymbol.TypeKind == TypeKind.Class || typeSymbol.TypeKind == TypeKind.Struct)
{
return typeName; // Reference the message type by name
return GetProtoMessageName(typeSymbol); // Reference the message type by name (handles generics)
}
// Fallback
return "string"; // Default to string for unknown types
}
/// <summary>
/// Gets the proto message name for a type, handling generic types by qualifying
/// with type arguments. e.g. Translation&lt;FaqTranslationQueryItem&gt; becomes TranslationOfFaqTranslationQueryItem.
/// </summary>
public static string GetProtoMessageName(ITypeSymbol typeSymbol)
{
if (typeSymbol is INamedTypeSymbol namedType && namedType.IsGenericType && namedType.TypeArguments.Length > 0)
{
var typeArgs = string.Join("And", namedType.TypeArguments.Select(t => GetProtoMessageName(t)));
return $"{namedType.Name}Of{typeArgs}";
}
return typeSymbol.Name;
}
/// <summary>
/// Converts C# PascalCase property name to proto snake_case field name.
/// Uses simple conversion: add underscore before each uppercase letter (except first).
+14
View File
@@ -43,6 +43,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.Abstract
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.RabbitMQ", "Svrnty.CQRS.Events.RabbitMQ\Svrnty.CQRS.Events.RabbitMQ.csproj", "{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.Kafka", "Svrnty.CQRS.Events.Kafka\Svrnty.CQRS.Events.Kafka.csproj", "{D367A93B-2DE7-41AC-9BF1-A49AC4617AFD}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -257,6 +259,18 @@ Global
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x64.Build.0 = Release|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x86.ActiveCfg = Release|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x86.Build.0 = Release|Any CPU
{D367A93B-2DE7-41AC-9BF1-A49AC4617AFD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D367A93B-2DE7-41AC-9BF1-A49AC4617AFD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D367A93B-2DE7-41AC-9BF1-A49AC4617AFD}.Debug|x64.ActiveCfg = Debug|Any CPU
{D367A93B-2DE7-41AC-9BF1-A49AC4617AFD}.Debug|x64.Build.0 = Debug|Any CPU
{D367A93B-2DE7-41AC-9BF1-A49AC4617AFD}.Debug|x86.ActiveCfg = Debug|Any CPU
{D367A93B-2DE7-41AC-9BF1-A49AC4617AFD}.Debug|x86.Build.0 = Debug|Any CPU
{D367A93B-2DE7-41AC-9BF1-A49AC4617AFD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D367A93B-2DE7-41AC-9BF1-A49AC4617AFD}.Release|Any CPU.Build.0 = Release|Any CPU
{D367A93B-2DE7-41AC-9BF1-A49AC4617AFD}.Release|x64.ActiveCfg = Release|Any CPU
{D367A93B-2DE7-41AC-9BF1-A49AC4617AFD}.Release|x64.Build.0 = Release|Any CPU
{D367A93B-2DE7-41AC-9BF1-A49AC4617AFD}.Release|x86.ActiveCfg = Release|Any CPU
{D367A93B-2DE7-41AC-9BF1-A49AC4617AFD}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
+3 -2
View File
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.Discovery;
@@ -43,7 +44,7 @@ public class CqrsBuilder
/// <summary>
/// Adds a command handler to the CQRS pipeline
/// </summary>
public CqrsBuilder AddCommand<TCommand, TCommandHandler>()
public CqrsBuilder AddCommand<TCommand, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TCommandHandler>()
where TCommand : class
where TCommandHandler : class, ICommandHandler<TCommand>
{
@@ -54,7 +55,7 @@ public class CqrsBuilder
/// <summary>
/// Adds a command handler with result to the CQRS pipeline
/// </summary>
public CqrsBuilder AddCommand<TCommand, TResult, TCommandHandler>()
public CqrsBuilder AddCommand<TCommand, TResult, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TCommandHandler>()
where TCommand : class
where TCommandHandler : class, ICommandHandler<TCommand, TResult>
{
+1 -1
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using Svrnty.CQRS.Abstractions.Discovery;
+1 -1
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using Svrnty.CQRS.Abstractions.Discovery;
+4
View File
@@ -26,6 +26,10 @@
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Svrnty.CQRS.Abstractions\Svrnty.CQRS.Abstractions.csproj" />
</ItemGroup>
@@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Configuration;
namespace Svrnty.CQRS.MinimalApi;
namespace Svrnty.CQRS;
public static class WebApplicationExtensions
{
+3 -3
View File
@@ -1,11 +1,11 @@
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Svrnty.CQRS;
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.DynamicQuery;
using Svrnty.CQRS.FluentValidation;
using Svrnty.CQRS.Grpc;
using Svrnty.Sample;
using Svrnty.CQRS.MinimalApi;
using Svrnty.CQRS.DynamicQuery;
using Svrnty.CQRS.Abstractions;
using Svrnty.Sample;
var builder = WebApplication.CreateBuilder(args);
+50
View File
@@ -21,6 +21,13 @@ service QueryService {
}
// DynamicQuery service for CQRS operations
service DynamicQueryService {
// Dynamic query for User
rpc QueryUsers (DynamicQueryUsersRequest) returns (DynamicQueryUsersResponse);
}
// Request message for AddUserCommand
message AddUserCommandRequest {
string name = 1;
@@ -59,3 +66,46 @@ message User {
string email = 3;
}
// Dynamic query filter with AND/OR support
message DynamicQueryFilter {
string path = 1;
int32 type = 2; // PoweredSoft.DynamicQuery.Core.FilterType
string value = 3;
repeated DynamicQueryFilter and = 4;
repeated DynamicQueryFilter or = 5;
}
// Dynamic query sort
message DynamicQuerySort {
string path = 1;
bool ascending = 2;
}
// Dynamic query group
message DynamicQueryGroup {
string path = 1;
}
// Dynamic query aggregate
message DynamicQueryAggregate {
string path = 1;
int32 type = 2; // PoweredSoft.DynamicQuery.Core.AggregateType
}
// Dynamic query request for User
message DynamicQueryUsersRequest {
int32 page = 1;
int32 page_size = 2;
repeated DynamicQueryFilter filters = 3;
repeated DynamicQuerySort sorts = 4;
repeated DynamicQueryGroup groups = 5;
repeated DynamicQueryAggregate aggregates = 6;
}
// Dynamic query response for User
message DynamicQueryUsersResponse {
repeated User data = 1;
int64 total_records = 2;
int32 number_of_pages = 3;
}
+1 -1
View File
@@ -1,5 +1,5 @@
using PoweredSoft.Data.Core;
using System.Linq.Expressions;
using PoweredSoft.Data.Core;
namespace Svrnty.Sample;