Compare commits
15 Commits
10.1.0-rc2
...
10.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 7614f68512 | |||
| fdee02c960 | |||
| a4525bad6a | |||
| 3df094b9e7 | |||
|
6aece5a769
|
|||
|
b372805c4e
|
|||
|
89ccbe990f
|
|||
|
433b852a43
|
|||
|
03041721ca
|
|||
|
05449b9a28
|
|||
|
dfbef9d161
|
|||
|
377977b080
|
|||
|
20147bfec7
|
|||
|
18f81a28e8
|
|||
|
201768e716
|
@@ -0,0 +1 @@
|
|||||||
|
1.0.0
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "*Command*.cs"
|
||||||
|
- "*Query*.cs"
|
||||||
|
- "*Handler*.cs"
|
||||||
|
- "**/Program.cs"
|
||||||
|
- "**/ServiceCollectionExtensions.cs"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Commands & Queries
|
||||||
|
|
||||||
|
## Commands Are Domain Actions, Not CRUD
|
||||||
|
|
||||||
|
Commands express **user intent**. Name them as the business action being performed.
|
||||||
|
|
||||||
|
- `PlaceOrderCommand` not `CreateOrderCommand`
|
||||||
|
- `ApproveExpenseCommand` not `UpdateExpenseCommand`
|
||||||
|
- `DeactivateAccountCommand` not `DeleteAccountCommand`
|
||||||
|
|
||||||
|
One CRUD "Update" often becomes multiple distinct commands, each with its own validation and side effects.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
Command, handler, and validator live in the **same file**, organized by feature:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// File: Features/Orders/PlaceOrderCommand.cs
|
||||||
|
public record PlaceOrderCommand
|
||||||
|
{
|
||||||
|
public string CustomerId { get; set; } = string.Empty;
|
||||||
|
public List<OrderItem> Items { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PlaceOrderCommandValidator : AbstractValidator<PlaceOrderCommand>
|
||||||
|
{
|
||||||
|
public PlaceOrderCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.CustomerId).NotEmpty();
|
||||||
|
RuleFor(x => x.Items).NotEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PlaceOrderCommandHandler : ICommandHandler<PlaceOrderCommand, int>
|
||||||
|
{
|
||||||
|
public Task<int> HandleAsync(PlaceOrderCommand command, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// implementation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Handler Interfaces
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Command with no result
|
||||||
|
ICommandHandler<TCommand>
|
||||||
|
Task HandleAsync(TCommand command, CancellationToken cancellationToken = default)
|
||||||
|
|
||||||
|
// Command with result
|
||||||
|
ICommandHandler<TCommand, TResult>
|
||||||
|
Task<TResult> HandleAsync(TCommand command, CancellationToken cancellationToken = default)
|
||||||
|
|
||||||
|
// Query (always returns result) — only for single-entity lookups or non-queryable data
|
||||||
|
IQueryHandler<TQuery, TResult>
|
||||||
|
Task<TResult> HandleAsync(TQuery query, CancellationToken cancellationToken = default)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Registration
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Command without result
|
||||||
|
services.AddCommand<DeactivateAccountCommand, DeactivateAccountCommandHandler>();
|
||||||
|
|
||||||
|
// Command with result
|
||||||
|
services.AddCommand<PlaceOrderCommand, int, PlaceOrderCommandHandler>();
|
||||||
|
|
||||||
|
// Command with result + validator (from Svrnty.CQRS.FluentValidation)
|
||||||
|
services.AddCommand<PlaceOrderCommand, int, PlaceOrderCommandHandler, PlaceOrderCommandValidator>();
|
||||||
|
|
||||||
|
// Regular query — ONLY for single-entity lookups or non-queryable results
|
||||||
|
services.AddQuery<FetchOrderByIdQuery, Order, FetchOrderByIdQueryHandler>();
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to Use Regular IQueryHandler vs Dynamic Query
|
||||||
|
|
||||||
|
**Use `IQueryHandler`** (rare):
|
||||||
|
- Single entity by ID: `FetchOrderByIdQuery`
|
||||||
|
- Non-entity results: `GetDashboardStatsQuery`
|
||||||
|
- Complex aggregation not expressible as IQueryable
|
||||||
|
|
||||||
|
**Use Dynamic Query** (default — see dynamic-query.md rule):
|
||||||
|
- Any list/collection query
|
||||||
|
- Anything that needs pagination, filtering, or sorting
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Always use `CancellationToken` — never omit it
|
||||||
|
- Commands/queries are `record` types with `{ get; set; }` properties
|
||||||
|
- Default string properties to `string.Empty`, collections to `[]`
|
||||||
|
- Naming: endpoint name is auto-derived by stripping `Command`/`Query` suffix and converting to lowerCamelCase
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "*QueryableProvider*.cs"
|
||||||
|
- "*AlterQueryable*.cs"
|
||||||
|
- "*DynamicQuery*.cs"
|
||||||
|
- "*Interceptor*.cs"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Dynamic Queries — The Default Query Pattern
|
||||||
|
|
||||||
|
**99% of queries should be dynamic queries.** They provide pagination, filtering, sorting, grouping, and aggregation for free.
|
||||||
|
|
||||||
|
## The Standard Pattern: IQueryableProviderOverride
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// File: Features/Orders/OrderQueryableProvider.cs
|
||||||
|
public class OrderQueryableProvider : IQueryableProviderOverride<Order>
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
public OrderQueryableProvider(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public Task<IQueryable<Order>> GetQueryableAsync(object query, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(_db.Orders.AsQueryable());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Registration — one line:
|
||||||
|
```csharp
|
||||||
|
builder.Services.AddDynamicQueryWithProvider<Order, OrderQueryableProvider>();
|
||||||
|
```
|
||||||
|
|
||||||
|
This automatically creates endpoints with full filtering, sorting, and pagination support.
|
||||||
|
|
||||||
|
## Required Dependencies
|
||||||
|
|
||||||
|
These must be registered before `AddSvrntyCqrs`:
|
||||||
|
```csharp
|
||||||
|
builder.Services.AddTransient<IAsyncQueryableService, SimpleAsyncQueryableService>();
|
||||||
|
builder.Services.AddTransient<IQueryHandlerAsync, QueryHandlerAsync>();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Alter Queryable Services (Security Filters, Tenant Isolation)
|
||||||
|
|
||||||
|
Use `IAlterQueryableService` to modify the queryable before execution — for security filters, tenant isolation, default ordering, etc.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class OrderTenantFilter : IAlterQueryableService<Order, Order>
|
||||||
|
{
|
||||||
|
private readonly ITenantContext _tenant;
|
||||||
|
public OrderTenantFilter(ITenantContext tenant) => _tenant = tenant;
|
||||||
|
|
||||||
|
public Task<IQueryable<Order>> AlterQueryableAsync(
|
||||||
|
IQueryable<Order> query,
|
||||||
|
IDynamicQuery dynamicQuery,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return Task.FromResult(query.Where(o => o.TenantId == _tenant.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Registration:
|
||||||
|
```csharp
|
||||||
|
builder.Services.AddAlterQueryable<Order, Order, OrderTenantFilter>();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Interceptors
|
||||||
|
|
||||||
|
Up to 5 interceptors per query type. These modify the PoweredSoft DynamicQuery criteria at query build time.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Services.AddDynamicQueryInterceptor<Order, Order, OrderInterceptor>();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Source to Destination Mapping
|
||||||
|
|
||||||
|
When the entity type differs from the DTO:
|
||||||
|
```csharp
|
||||||
|
// Provider returns the source entity queryable
|
||||||
|
public class OrderQueryableProvider : IQueryableProviderOverride<Order> { ... }
|
||||||
|
|
||||||
|
// Registration maps source -> destination
|
||||||
|
builder.Services.AddDynamicQueryWithProvider<Order, OrderDto, OrderQueryableProvider>();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Interfaces
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
IQueryableProvider<TSource>
|
||||||
|
Task<IQueryable<TSource>> GetQueryableAsync(object query, CancellationToken ct)
|
||||||
|
|
||||||
|
IQueryableProviderOverride<TSource> : IQueryableProvider<TSource>
|
||||||
|
// Marker interface — same method, signals override registration
|
||||||
|
|
||||||
|
IAlterQueryableService<TSource, TDestination>
|
||||||
|
Task<IQueryable<TSource>> AlterQueryableAsync(IQueryable<TSource> query, IDynamicQuery dynamicQuery, CancellationToken ct)
|
||||||
|
```
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "**/*.proto"
|
||||||
|
- "**/Protos/**"
|
||||||
|
- "*Grpc*"
|
||||||
|
---
|
||||||
|
|
||||||
|
# gRPC Integration
|
||||||
|
|
||||||
|
## Proto File Conventions
|
||||||
|
|
||||||
|
Proto files live in `Protos/` directory. The source generator (`Svrnty.CQRS.Grpc.Generators`) auto-generates service implementations at compile time.
|
||||||
|
|
||||||
|
### Service Structure
|
||||||
|
```protobuf
|
||||||
|
service CommandService {
|
||||||
|
rpc PlaceOrder (PlaceOrderCommandRequest) returns (PlaceOrderCommandResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
service QueryService {
|
||||||
|
rpc FetchUserById (FetchUserByIdQueryRequest) returns (FetchUserByIdQueryResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
service DynamicQueryService {
|
||||||
|
rpc QueryOrders (DynamicQueryOrdersRequest) returns (DynamicQueryOrdersResponse);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naming Rules
|
||||||
|
- RPC method name: command/query name without the `Command`/`Query` suffix
|
||||||
|
- Request message: `{Name}CommandRequest` or `{Name}QueryRequest`
|
||||||
|
- Response message: `{Name}CommandResponse` or `{Name}QueryResponse`
|
||||||
|
- Dynamic query request: `DynamicQuery{PluralEntity}Request`
|
||||||
|
- Dynamic query response: `DynamicQuery{PluralEntity}Response`
|
||||||
|
- Fields use `snake_case` (proto convention), mapped case-insensitively to C# `PascalCase` properties
|
||||||
|
|
||||||
|
### Field Mapping
|
||||||
|
C# property names must match proto field names (case-insensitive):
|
||||||
|
|
||||||
|
| C# Property | Proto Field |
|
||||||
|
|---------------|-----------------|
|
||||||
|
| `UserId` | `user_id` |
|
||||||
|
| `Name` | `name` |
|
||||||
|
| `EmailAddress`| `email_address` |
|
||||||
|
| `OrderItems` | `order_items` |
|
||||||
|
|
||||||
|
### Type Mapping
|
||||||
|
| C# | Proto |
|
||||||
|
|-------------|-------------|
|
||||||
|
| `int` | `int32` |
|
||||||
|
| `long` | `int64` |
|
||||||
|
| `string` | `string` |
|
||||||
|
| `bool` | `bool` |
|
||||||
|
| `double` | `double` |
|
||||||
|
| `float` | `float` |
|
||||||
|
| `List<T>` | `repeated T`|
|
||||||
|
| complex type| `message` |
|
||||||
|
|
||||||
|
### Dynamic Query Messages (standard, reuse across entities)
|
||||||
|
```protobuf
|
||||||
|
message DynamicQueryFilter {
|
||||||
|
string path = 1;
|
||||||
|
int32 type = 2; // PoweredSoft.DynamicQuery.Core.FilterType
|
||||||
|
string value = 3;
|
||||||
|
repeated DynamicQueryFilter and = 4;
|
||||||
|
repeated DynamicQueryFilter or = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DynamicQuerySort {
|
||||||
|
string path = 1;
|
||||||
|
bool ascending = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DynamicQueryGroup {
|
||||||
|
string path = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DynamicQueryAggregate {
|
||||||
|
string path = 1;
|
||||||
|
int32 type = 2; // PoweredSoft.DynamicQuery.Core.AggregateType
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Source Generator Behavior
|
||||||
|
|
||||||
|
The generator in `Svrnty.CQRS.Grpc.Generators` auto-creates:
|
||||||
|
- `CommandServiceImpl` — implements `CommandService.CommandServiceBase`
|
||||||
|
- `QueryServiceImpl` — implements `QueryService.QueryServiceBase`
|
||||||
|
- `DynamicQueryServiceImpl` — implements `DynamicQueryService.DynamicQueryServiceBase`
|
||||||
|
- `GrpcServiceRegistration` — auto-registration code
|
||||||
|
|
||||||
|
Generated implementations handle:
|
||||||
|
1. Request-to-POCO property mapping
|
||||||
|
2. Validator invocation (if registered) with Google Rich Error Model errors
|
||||||
|
3. Handler invocation with proper `CancellationToken`
|
||||||
|
4. DI scope management via `IServiceScopeFactory`
|
||||||
|
|
||||||
|
## Validation in gRPC
|
||||||
|
|
||||||
|
Validation errors return `google.rpc.Status` with `BadRequest` detail containing `FieldViolations`. This is automatic — do not manually validate in handlers.
|
||||||
|
|
||||||
|
## Registration
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Services.AddSvrntyCqrs(cqrs =>
|
||||||
|
{
|
||||||
|
cqrs.AddGrpc(grpc => grpc.EnableReflection()); // reflection for grpcurl/grpcui
|
||||||
|
});
|
||||||
|
```
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "*Validator*.cs"
|
||||||
|
- "*Validation*"
|
||||||
|
---
|
||||||
|
|
||||||
|
# FluentValidation Integration
|
||||||
|
|
||||||
|
## Validator Pattern
|
||||||
|
|
||||||
|
Validators live in the **same file** as their command/query, named `{CommandName}Validator`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class PlaceOrderCommandValidator : AbstractValidator<PlaceOrderCommand>
|
||||||
|
{
|
||||||
|
public PlaceOrderCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.CustomerId)
|
||||||
|
.NotEmpty()
|
||||||
|
.WithMessage("Customer is required");
|
||||||
|
|
||||||
|
RuleFor(x => x.Items)
|
||||||
|
.NotEmpty()
|
||||||
|
.WithMessage("At least one item is required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Registration
|
||||||
|
|
||||||
|
Validators are registered alongside their handler using the FluentValidation overloads:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Command without result + validator
|
||||||
|
services.AddCommand<DeactivateAccountCommand, DeactivateAccountCommandHandler, DeactivateAccountCommandValidator>();
|
||||||
|
|
||||||
|
// Command with result + validator
|
||||||
|
services.AddCommand<PlaceOrderCommand, int, PlaceOrderCommandHandler, PlaceOrderCommandValidator>();
|
||||||
|
|
||||||
|
// Query + validator
|
||||||
|
services.AddQuery<FetchOrderByIdQuery, Order, FetchOrderByIdQueryHandler, FetchOrderByIdQueryValidator>();
|
||||||
|
```
|
||||||
|
|
||||||
|
These come from `Svrnty.CQRS.FluentValidation.ServiceCollectionExtensions` — they call the base `AddCommand`/`AddQuery` then register `IValidator<T>`.
|
||||||
|
|
||||||
|
## Error Formats by Protocol
|
||||||
|
|
||||||
|
The framework handles validation execution automatically. Errors are returned as:
|
||||||
|
|
||||||
|
- **HTTP (Minimal API)**: RFC 7807 Problem Details — `400 Bad Request` with structured error body
|
||||||
|
- **gRPC**: Google Rich Error Model — `google.rpc.Status` with `BadRequest` detail containing `FieldViolations`
|
||||||
|
|
||||||
|
You do NOT need to manually invoke validation in handlers. The endpoint layer (Minimal API or gRPC source-generated service) handles it.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Inherit from `AbstractValidator<T>`, not `IValidator<T>` directly
|
||||||
|
- Define all rules in the constructor
|
||||||
|
- Always include `.WithMessage()` for user-facing error messages
|
||||||
|
- Validator constructor can inject services for async/database validation
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(dotnet build:*)",
|
||||||
|
"Bash(dotnet clean:*)",
|
||||||
|
"Bash(dotnet restore:*)",
|
||||||
|
"Bash(dotnet run:*)",
|
||||||
|
"Bash(dotnet test:*)",
|
||||||
|
"Bash(dotnet format:*)",
|
||||||
|
"Bash(dotnet add:*)",
|
||||||
|
"Bash(dotnet remove:*)",
|
||||||
|
"Bash(dotnet sln:*)",
|
||||||
|
"Bash(dotnet --list-sdks:*)",
|
||||||
|
"Bash(dotnet tool install:*)",
|
||||||
|
"Bash(dotnet ef:*)",
|
||||||
|
"Bash(grpcurl:*)",
|
||||||
|
"Bash(protogen:*)",
|
||||||
|
"Bash(mkdir:*)",
|
||||||
|
"Bash(ls:*)",
|
||||||
|
"Bash(git status:*)",
|
||||||
|
"Bash(git log:*)",
|
||||||
|
"Bash(git diff:*)",
|
||||||
|
"Bash(git branch:*)",
|
||||||
|
"Bash(git stash:*)",
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(git checkout:*)",
|
||||||
|
"WebSearch",
|
||||||
|
"WebFetch(domain:learn.microsoft.com)",
|
||||||
|
"WebFetch(domain:github.com)",
|
||||||
|
"WebFetch(domain:www.nuget.org)",
|
||||||
|
"WebFetch(domain:stackoverflow.com)"
|
||||||
|
],
|
||||||
|
"ask": [
|
||||||
|
"Bash(dotnet publish:*)",
|
||||||
|
"Bash(dotnet pack:*)",
|
||||||
|
"Bash(git push:*)",
|
||||||
|
"Bash(git commit:*)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hooks": {
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Edit|Write",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash -c 'INPUT=$(cat); FILE=$(echo \"$INPUT\" | jq -r \".tool_input.file_path // empty\"); if [[ -n \"$FILE\" && \"$FILE\" == *.cs ]]; then dotnet format --include \"$FILE\" --no-restore --diagnostics IDE0005 IDE0055 IDE0161 2>/dev/null || true; fi'",
|
||||||
|
"timeout": 15,
|
||||||
|
"statusMessage": "Formatting..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Edit|Write",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash -c 'INPUT=$(cat); FILE=$(echo \"$INPUT\" | jq -r \".tool_input.file_path // empty\"); if [[ -n \"$FILE\" && \"$FILE\" == *.proto ]]; then dotnet build --no-restore 2>&1 | grep -E \"(error|warning)\" | head -10; fi'",
|
||||||
|
"timeout": 30,
|
||||||
|
"statusMessage": "Validating proto..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"matcher": "",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash -c 'BUILD=$(dotnet build --no-restore 2>&1); if echo \"$BUILD\" | grep -q \"Build FAILED\"; then echo \"BUILD FAILED — errors below:\" >&2; echo \"$BUILD\" | grep -E \"error CS\" >&2; exit 2; fi'",
|
||||||
|
"timeout": 60,
|
||||||
|
"statusMessage": "Verifying build..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
---
|
||||||
|
name: add-command
|
||||||
|
description: Scaffold a new CQRS command with handler, validator, and DI registration. Use when the user wants to add a new command to a project.
|
||||||
|
argument-hint: <description of the command in natural language>
|
||||||
|
---
|
||||||
|
|
||||||
|
# Add Command
|
||||||
|
|
||||||
|
Scaffold a new command based on: $ARGUMENTS
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
### 1. Determine the command name
|
||||||
|
|
||||||
|
The name must express **domain intent**, not CRUD. If the user's description sounds like CRUD, translate it:
|
||||||
|
- "create a user" → `RegisterUserCommand`
|
||||||
|
- "update the order status" → contextual: `ShipOrderCommand`, `CancelOrderCommand`, `ApproveOrderCommand`
|
||||||
|
- "delete an account" → `DeactivateAccountCommand` or `CloseAccountCommand`
|
||||||
|
|
||||||
|
Ask the user to clarify if the intent is ambiguous (e.g., "update order" could mean many things).
|
||||||
|
|
||||||
|
### 2. Determine the feature folder
|
||||||
|
|
||||||
|
Place the file in the appropriate feature folder: `Features/{DomainArea}/`
|
||||||
|
If the folder doesn't exist, create it. If the project doesn't use `Features/` yet, check the existing structure and follow the same pattern.
|
||||||
|
|
||||||
|
### 3. Create the file
|
||||||
|
|
||||||
|
Create a single file `Features/{DomainArea}/{CommandName}Command.cs` containing all three classes:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using FluentValidation;
|
||||||
|
using Svrnty.CQRS.Abstractions;
|
||||||
|
|
||||||
|
namespace {ProjectNamespace}.Features.{DomainArea};
|
||||||
|
|
||||||
|
// 1. Command POCO — record type, properties with defaults
|
||||||
|
public record {CommandName}Command
|
||||||
|
{
|
||||||
|
// Properties based on user description
|
||||||
|
// Strings default to string.Empty, collections to []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Validator — rules in constructor, always include .WithMessage()
|
||||||
|
public class {CommandName}CommandValidator : AbstractValidator<{CommandName}Command>
|
||||||
|
{
|
||||||
|
public {CommandName}CommandValidator()
|
||||||
|
{
|
||||||
|
// Validation rules based on command properties
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Handler — always async with CancellationToken
|
||||||
|
public class {CommandName}CommandHandler : ICommandHandler<{CommandName}Command, {ResultType}>
|
||||||
|
{
|
||||||
|
public Task<{ResultType}> HandleAsync({CommandName}Command command, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the command has **no return value**, use `ICommandHandler<{CommandName}Command>` (single generic param) with `Task HandleAsync(...)`.
|
||||||
|
|
||||||
|
### 4. Register in DI
|
||||||
|
|
||||||
|
Find the project's service registration (typically `Program.cs` or a dedicated registration method) and add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// With validator (preferred)
|
||||||
|
builder.Services.AddCommand<{CommandName}Command, {ResultType}, {CommandName}CommandHandler, {CommandName}CommandValidator>();
|
||||||
|
|
||||||
|
// Without validator
|
||||||
|
builder.Services.AddCommand<{CommandName}Command, {ResultType}, {CommandName}CommandHandler>();
|
||||||
|
|
||||||
|
// No return value + validator
|
||||||
|
builder.Services.AddCommand<{CommandName}Command, {CommandName}CommandHandler, {CommandName}CommandValidator>();
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensure the `using Svrnty.CQRS.FluentValidation;` namespace is imported if using the validator overload.
|
||||||
|
|
||||||
|
### 5. Proto message (if project uses gRPC)
|
||||||
|
|
||||||
|
If the project has a `Protos/` directory, add the corresponding proto message:
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
// In the CommandService definition
|
||||||
|
rpc {CommandName} ({CommandName}CommandRequest) returns ({CommandName}CommandResponse);
|
||||||
|
|
||||||
|
// Request message
|
||||||
|
message {CommandName}CommandRequest {
|
||||||
|
// fields matching command properties, snake_case, numbered sequentially
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response message
|
||||||
|
message {CommandName}CommandResponse {
|
||||||
|
{result_type} result = 1; // omit if command has no return value
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Summary
|
||||||
|
|
||||||
|
After creating everything, list what was created and where.
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
---
|
||||||
|
name: add-dynamic-query
|
||||||
|
description: Scaffold a dynamic query with IQueryableProviderOverride for automatic pagination, filtering, and sorting. This is the DEFAULT choice for any list/collection query.
|
||||||
|
argument-hint: <entity name and optional description>
|
||||||
|
---
|
||||||
|
|
||||||
|
# Add Dynamic Query
|
||||||
|
|
||||||
|
Scaffold a dynamic query based on: $ARGUMENTS
|
||||||
|
|
||||||
|
**This is the default query pattern.** It gives pagination, filtering, sorting, grouping, and aggregation for free.
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
### 1. Identify the entity
|
||||||
|
|
||||||
|
Determine the source entity type from the user's description. If the project uses a DAL with EF Core, the entity should already exist in the DbContext.
|
||||||
|
|
||||||
|
### 2. Determine the feature folder
|
||||||
|
|
||||||
|
Place the file in: `Features/{DomainArea}/`
|
||||||
|
|
||||||
|
### 3. Create the queryable provider
|
||||||
|
|
||||||
|
Create `Features/{DomainArea}/{Entity}QueryableProvider.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||||
|
|
||||||
|
namespace {ProjectNamespace}.Features.{DomainArea};
|
||||||
|
|
||||||
|
public class {Entity}QueryableProvider : IQueryableProviderOverride<{Entity}>
|
||||||
|
{
|
||||||
|
private readonly {DbContextType} _db;
|
||||||
|
public {Entity}QueryableProvider({DbContextType} db) => _db = db;
|
||||||
|
|
||||||
|
public Task<IQueryable<{Entity}>> GetQueryableAsync(object query, CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(_db.{EntityPlural}.AsQueryable());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the entity needs a DTO projection (different shape for the API), note this for the registration step.
|
||||||
|
|
||||||
|
### 4. Create alter queryable service (if needed)
|
||||||
|
|
||||||
|
If the entity needs security filtering, tenant isolation, or default ordering, create `Features/{DomainArea}/{Entity}AlterQueryable.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||||
|
|
||||||
|
namespace {ProjectNamespace}.Features.{DomainArea};
|
||||||
|
|
||||||
|
public class {Entity}AlterQueryable : IAlterQueryableService<{Entity}, {EntityOrDto}>
|
||||||
|
{
|
||||||
|
public Task<IQueryable<{Entity}>> AlterQueryableAsync(
|
||||||
|
IQueryable<{Entity}> query,
|
||||||
|
IDynamicQuery dynamicQuery,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Add default ordering, security filters, etc.
|
||||||
|
return Task.FromResult(query.OrderByDescending(x => x.CreatedAt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Register in DI
|
||||||
|
|
||||||
|
Find the project's service registration and add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Basic — entity returned as-is
|
||||||
|
builder.Services.AddDynamicQueryWithProvider<{Entity}, {Entity}QueryableProvider>();
|
||||||
|
|
||||||
|
// With DTO projection — entity mapped to DTO
|
||||||
|
builder.Services.AddDynamicQueryWithProvider<{Entity}, {EntityDto}, {Entity}QueryableProvider>();
|
||||||
|
|
||||||
|
// With alter service (add after provider registration)
|
||||||
|
builder.Services.AddAlterQueryable<{Entity}, {EntityOrDto}, {Entity}AlterQueryable>();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Ensure dynamic query dependencies are registered
|
||||||
|
|
||||||
|
Check that these are registered (typically once per project, before `AddSvrntyCqrs`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Services.AddTransient<IAsyncQueryableService, SimpleAsyncQueryableService>();
|
||||||
|
builder.Services.AddTransient<IQueryHandlerAsync, QueryHandlerAsync>();
|
||||||
|
```
|
||||||
|
|
||||||
|
If they're already present, don't add duplicates.
|
||||||
|
|
||||||
|
### 7. Proto message (if project uses gRPC)
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
// In the DynamicQueryService definition
|
||||||
|
rpc Query{EntityPlural} (DynamicQuery{EntityPlural}Request) returns (DynamicQuery{EntityPlural}Response);
|
||||||
|
|
||||||
|
// Entity message (if not already defined)
|
||||||
|
message {Entity} {
|
||||||
|
// fields matching entity properties
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request — uses standard dynamic query fields
|
||||||
|
message DynamicQuery{EntityPlural}Request {
|
||||||
|
int32 page = 1;
|
||||||
|
int32 page_size = 2;
|
||||||
|
repeated DynamicQueryFilter filters = 3;
|
||||||
|
repeated DynamicQuerySort sorts = 4;
|
||||||
|
repeated DynamicQueryGroup groups = 5;
|
||||||
|
repeated DynamicQueryAggregate aggregates = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response
|
||||||
|
message DynamicQuery{EntityPlural}Response {
|
||||||
|
repeated {Entity} data = 1;
|
||||||
|
int64 total_records = 2;
|
||||||
|
int32 number_of_pages = 3;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The standard `DynamicQueryFilter`, `DynamicQuerySort`, `DynamicQueryGroup`, and `DynamicQueryAggregate` messages should already be defined — reuse them.
|
||||||
|
|
||||||
|
### 8. Summary
|
||||||
|
|
||||||
|
After creating everything, list what was created and where.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
---
|
||||||
|
name: add-query
|
||||||
|
description: Scaffold a new regular CQRS query with handler and DI registration. Use ONLY for single-entity lookups or non-queryable results. For list/collection queries, use /add-dynamic-query instead.
|
||||||
|
argument-hint: <description of the query in natural language>
|
||||||
|
---
|
||||||
|
|
||||||
|
# Add Query
|
||||||
|
|
||||||
|
Scaffold a new regular query based on: $ARGUMENTS
|
||||||
|
|
||||||
|
## Important: Is This the Right Skill?
|
||||||
|
|
||||||
|
**This skill is for the rare case.** Most queries should be dynamic queries (`/add-dynamic-query`).
|
||||||
|
|
||||||
|
Only use this for:
|
||||||
|
- Single entity by ID (e.g., `FetchOrderByIdQuery`)
|
||||||
|
- Non-entity results (e.g., `GetDashboardStatsQuery`, `CheckAvailabilityQuery`)
|
||||||
|
- Complex aggregation not expressible as IQueryable
|
||||||
|
|
||||||
|
If the user is asking for a list/collection query with filtering, sorting, or pagination → suggest `/add-dynamic-query` instead.
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
### 1. Determine the query name
|
||||||
|
|
||||||
|
Use descriptive, domain-oriented names:
|
||||||
|
- `FetchOrderByIdQuery` — fetching a single entity
|
||||||
|
- `GetSystemHealthQuery` — non-entity result
|
||||||
|
- `CheckInventoryAvailabilityQuery` — domain-specific check
|
||||||
|
|
||||||
|
### 2. Determine the feature folder
|
||||||
|
|
||||||
|
Place the file in: `Features/{DomainArea}/`
|
||||||
|
|
||||||
|
### 3. Create the file
|
||||||
|
|
||||||
|
Create `Features/{DomainArea}/{QueryName}Query.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Svrnty.CQRS.Abstractions;
|
||||||
|
|
||||||
|
namespace {ProjectNamespace}.Features.{DomainArea};
|
||||||
|
|
||||||
|
// 1. Query POCO
|
||||||
|
public record {QueryName}Query
|
||||||
|
{
|
||||||
|
// Parameters — e.g., Id for lookups
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Handler
|
||||||
|
public class {QueryName}QueryHandler : IQueryHandler<{QueryName}Query, {ResultType}>
|
||||||
|
{
|
||||||
|
public Task<{ResultType}> HandleAsync({QueryName}Query query, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Register in DI
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Services.AddQuery<{QueryName}Query, {ResultType}, {QueryName}QueryHandler>();
|
||||||
|
|
||||||
|
// With validator (if needed)
|
||||||
|
builder.Services.AddQuery<{QueryName}Query, {ResultType}, {QueryName}QueryHandler, {QueryName}QueryValidator>();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Proto message (if project uses gRPC)
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
// In the QueryService definition
|
||||||
|
rpc {QueryName} ({QueryName}QueryRequest) returns ({QueryName}QueryResponse);
|
||||||
|
|
||||||
|
message {QueryName}QueryRequest {
|
||||||
|
// fields matching query properties
|
||||||
|
}
|
||||||
|
|
||||||
|
message {QueryName}QueryResponse {
|
||||||
|
{ResultMessage} result = 1;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Summary
|
||||||
|
|
||||||
|
After creating everything, list what was created and where.
|
||||||
@@ -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,6 +1,9 @@
|
|||||||
# CLAUDE.md
|
# 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.
|
||||||
|
|
||||||
|
For domain-specific patterns (commands, queries, validation, gRPC, dynamic queries), see `.claude/rules/`.
|
||||||
|
For scaffolding commands, see `.claude/skills/` (`/add-command`, `/add-query`, `/add-dynamic-query`).
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
@@ -11,11 +14,10 @@ This is Svrnty.CQRS, a modern implementation of Command Query Responsibility Seg
|
|||||||
- Automatic gRPC endpoint generation with source generators and Google Rich Error Model validation
|
- Automatic gRPC endpoint generation with source generators and Google Rich Error Model validation
|
||||||
- Dynamic query capabilities (filtering, sorting, grouping, aggregation)
|
- Dynamic query capabilities (filtering, sorting, grouping, aggregation)
|
||||||
- FluentValidation support with RFC 7807 Problem Details (HTTP) and Google Rich Error Model (gRPC)
|
- FluentValidation support with RFC 7807 Problem Details (HTTP) and Google Rich Error Model (gRPC)
|
||||||
- AOT (Ahead-of-Time) compilation compatibility for core packages (where dependencies allow)
|
|
||||||
|
|
||||||
## Solution Structure
|
## Solution Structure
|
||||||
|
|
||||||
The solution contains 11 projects organized by responsibility (10 packages + 1 sample project):
|
The solution contains projects organized by responsibility:
|
||||||
|
|
||||||
**Abstractions (interfaces and contracts only):**
|
**Abstractions (interfaces and contracts only):**
|
||||||
- `Svrnty.CQRS.Abstractions` - Core interfaces (ICommandHandler, IQueryHandler, discovery contracts)
|
- `Svrnty.CQRS.Abstractions` - Core interfaces (ICommandHandler, IQueryHandler, discovery contracts)
|
||||||
@@ -24,81 +26,42 @@ The solution contains 11 projects organized by responsibility (10 packages + 1 s
|
|||||||
|
|
||||||
**Implementation:**
|
**Implementation:**
|
||||||
- `Svrnty.CQRS` - Core discovery and registration logic
|
- `Svrnty.CQRS` - Core discovery and registration logic
|
||||||
- `Svrnty.CQRS.MinimalApi` - Minimal API endpoint mapping for commands/queries (recommended for HTTP)
|
- `Svrnty.CQRS.MinimalApi` - Minimal API endpoint mapping for commands/queries
|
||||||
- `Svrnty.CQRS.DynamicQuery` - PoweredSoft.DynamicQuery integration for advanced filtering
|
- `Svrnty.CQRS.DynamicQuery` - PoweredSoft.DynamicQuery integration for advanced filtering
|
||||||
- `Svrnty.CQRS.DynamicQuery.MinimalApi` - Minimal API endpoint mapping for dynamic queries
|
- `Svrnty.CQRS.DynamicQuery.MinimalApi` - Minimal API endpoint mapping for dynamic queries
|
||||||
- `Svrnty.CQRS.FluentValidation` - Validation integration helpers
|
- `Svrnty.CQRS.FluentValidation` - Validation integration helpers
|
||||||
- `Svrnty.CQRS.Grpc` - gRPC service implementation support
|
- `Svrnty.CQRS.Grpc` - gRPC service implementation support
|
||||||
- `Svrnty.CQRS.Grpc.Generators` - Source generator for .proto files and gRPC service implementations
|
- `Svrnty.CQRS.Grpc.Generators` - Source generator for .proto files and gRPC service implementations
|
||||||
|
|
||||||
**Sample Projects:**
|
**Sample:**
|
||||||
- `Svrnty.Sample` - Comprehensive demo project showcasing both HTTP and gRPC endpoints
|
- `Svrnty.Sample` - Demo project showcasing both HTTP and gRPC endpoints
|
||||||
|
|
||||||
**Key Design Principle:** Abstractions projects contain ONLY interfaces/attributes with minimal dependencies. Implementation projects depend on abstractions. This allows consumers to reference abstractions without pulling in heavy implementation dependencies.
|
**Key Design Principle:** Abstractions projects contain ONLY interfaces/attributes with minimal dependencies. Implementation projects depend on abstractions.
|
||||||
|
|
||||||
## Build Commands
|
## Build Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Restore dependencies
|
dotnet restore # Restore dependencies
|
||||||
dotnet restore
|
dotnet build # Build entire solution
|
||||||
|
dotnet build -c Release # Build in Release mode
|
||||||
# Build entire solution
|
dotnet pack -c Release -o ./artifacts -p:Version=1.0.0 # Create NuGet packages
|
||||||
dotnet build
|
|
||||||
|
|
||||||
# Build in Release mode
|
|
||||||
dotnet build -c Release
|
|
||||||
|
|
||||||
# Create NuGet packages (with version)
|
|
||||||
dotnet pack -c Release -o ./artifacts -p:Version=1.0.0
|
|
||||||
|
|
||||||
# Build specific project
|
|
||||||
dotnet build Svrnty.CQRS/Svrnty.CQRS.csproj
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
This repository does not currently contain test projects. When adding tests:
|
No test projects currently exist. When adding tests:
|
||||||
- Place them in a `tests/` directory or alongside source projects
|
- Place them in a `tests/` directory
|
||||||
- Name them with `.Tests` suffix (e.g., `Svrnty.CQRS.Tests`)
|
- Name them with `.Tests` suffix (e.g., `Svrnty.CQRS.Tests`)
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Core CQRS Pattern
|
|
||||||
|
|
||||||
The framework uses handler interfaces that follow this pattern:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Command with no result
|
|
||||||
ICommandHandler<TCommand>
|
|
||||||
Task HandleAsync(TCommand command, CancellationToken cancellationToken = default)
|
|
||||||
|
|
||||||
// Command with result
|
|
||||||
ICommandHandler<TCommand, TResult>
|
|
||||||
Task<TResult> HandleAsync(TCommand command, CancellationToken cancellationToken = default)
|
|
||||||
|
|
||||||
// Query (always returns result)
|
|
||||||
IQueryHandler<TQuery, TResult>
|
|
||||||
Task<TResult> HandleAsync(TQuery query, CancellationToken cancellationToken = default)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Metadata-Driven Discovery
|
### Metadata-Driven Discovery
|
||||||
|
|
||||||
The framework uses a **metadata pattern** for runtime discovery:
|
The framework uses a **metadata pattern** for runtime discovery:
|
||||||
|
|
||||||
1. When you register a handler using `services.AddCommand<TCommand, THandler>()`, it:
|
1. `services.AddCommand<TCommand, THandler>()` registers the handler in DI and creates `ICommandMeta` metadata as a singleton
|
||||||
- Registers the handler in DI as `ICommandHandler<TCommand, THandler>`
|
2. Discovery services (`ICommandDiscovery`, `IQueryDiscovery`) query all registered metadata from DI
|
||||||
- Creates metadata (`ICommandMeta`) describing the command type, handler type, and result type
|
3. Endpoint mapping (HTTP and gRPC) uses discovery to dynamically generate endpoints at startup
|
||||||
- Stores metadata as singleton in DI
|
|
||||||
|
|
||||||
2. Discovery services (`ICommandDiscovery`, `IQueryDiscovery`) implemented in `Svrnty.CQRS`:
|
|
||||||
- Query all registered metadata from DI container
|
|
||||||
- Provide lookup methods: `GetCommand(string name)`, `GetCommands()`, etc.
|
|
||||||
|
|
||||||
3. Endpoint mapping (HTTP and gRPC) uses discovery to:
|
|
||||||
- Enumerate all registered commands/queries
|
|
||||||
- Dynamically generate endpoints at application startup
|
|
||||||
- Apply naming conventions (convert to lowerCamelCase)
|
|
||||||
- Generate gRPC service implementations via source generators
|
|
||||||
|
|
||||||
**Key Files:**
|
**Key Files:**
|
||||||
- `Svrnty.CQRS.Abstractions/Discovery/` - Metadata interfaces
|
- `Svrnty.CQRS.Abstractions/Discovery/` - Metadata interfaces
|
||||||
@@ -107,307 +70,77 @@ The framework uses a **metadata pattern** for runtime discovery:
|
|||||||
- `Svrnty.CQRS.DynamicQuery.MinimalApi/EndpointRouteBuilderExtensions.cs` - Dynamic query endpoint generation
|
- `Svrnty.CQRS.DynamicQuery.MinimalApi/EndpointRouteBuilderExtensions.cs` - Dynamic query endpoint generation
|
||||||
- `Svrnty.CQRS.Grpc.Generators/` - gRPC service generation via source generators
|
- `Svrnty.CQRS.Grpc.Generators/` - gRPC service generation via source generators
|
||||||
|
|
||||||
### Integration Options
|
### Integration
|
||||||
|
|
||||||
There are two primary integration options for exposing commands and queries:
|
Commands and queries can be exposed via HTTP (Minimal API), gRPC, or both simultaneously. The fluent configuration API handles all wiring:
|
||||||
|
|
||||||
#### Option 1: gRPC (Recommended for performance-critical scenarios)
|
|
||||||
|
|
||||||
The **Svrnty.CQRS.Grpc** package with **Svrnty.CQRS.Grpc.Generators** source generator provides high-performance gRPC endpoints:
|
|
||||||
|
|
||||||
**Registration:**
|
|
||||||
```csharp
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
|
||||||
|
|
||||||
// Register CQRS services
|
|
||||||
builder.Services.AddSvrntyCQRS();
|
|
||||||
builder.Services.AddDefaultCommandDiscovery();
|
|
||||||
builder.Services.AddDefaultQueryDiscovery();
|
|
||||||
|
|
||||||
// Add your commands and queries
|
|
||||||
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler>();
|
|
||||||
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
|
|
||||||
|
|
||||||
// Add gRPC support
|
|
||||||
builder.Services.AddGrpc();
|
|
||||||
|
|
||||||
var app = builder.Build();
|
|
||||||
|
|
||||||
// Map auto-generated gRPC service implementations
|
|
||||||
app.MapGrpcService<CommandServiceImpl>();
|
|
||||||
app.MapGrpcService<QueryServiceImpl>();
|
|
||||||
|
|
||||||
// Enable gRPC reflection for tools like grpcurl
|
|
||||||
app.MapGrpcReflectionService();
|
|
||||||
|
|
||||||
app.Run();
|
|
||||||
```
|
|
||||||
|
|
||||||
**How It Works:**
|
|
||||||
1. Define `.proto` files in `Protos/` directory with your commands/queries as messages
|
|
||||||
2. Source generator automatically creates `CommandServiceImpl` and `QueryServiceImpl` implementations
|
|
||||||
3. Property names in C# commands must match proto field names (case-insensitive)
|
|
||||||
4. FluentValidation is automatically integrated with **Google Rich Error Model** for structured validation errors
|
|
||||||
5. Validation errors return `google.rpc.Status` with `BadRequest` containing `FieldViolations`
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- High-performance binary protocol
|
|
||||||
- Automatic service implementation generation at compile time
|
|
||||||
- Google Rich Error Model for structured validation errors
|
|
||||||
- Full FluentValidation integration
|
|
||||||
- gRPC reflection support for development tools
|
|
||||||
- Suitable for microservices, internal APIs, and low-latency scenarios
|
|
||||||
|
|
||||||
**Key Files:**
|
|
||||||
- `Svrnty.CQRS.Grpc/` - Runtime support for gRPC services
|
|
||||||
- `Svrnty.CQRS.Grpc.Generators/` - Source generator for service implementations
|
|
||||||
|
|
||||||
#### Option 2: HTTP via Minimal API (Recommended for web/browser scenarios)
|
|
||||||
|
|
||||||
The **Svrnty.CQRS.MinimalApi** package provides HTTP endpoints for CQRS commands and queries:
|
|
||||||
|
|
||||||
**Registration:**
|
|
||||||
```csharp
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
|
||||||
|
|
||||||
// Register CQRS services
|
|
||||||
builder.Services.AddSvrntyCQRS();
|
|
||||||
builder.Services.AddDefaultCommandDiscovery();
|
|
||||||
builder.Services.AddDefaultQueryDiscovery();
|
|
||||||
|
|
||||||
// Add your commands and queries
|
|
||||||
builder.Services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler>();
|
|
||||||
builder.Services.AddQuery<PersonQuery, IQueryable<Person>, PersonQueryHandler>();
|
|
||||||
|
|
||||||
// Add Swagger (optional)
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
|
||||||
builder.Services.AddSwaggerGen();
|
|
||||||
|
|
||||||
var app = builder.Build();
|
|
||||||
|
|
||||||
// Map endpoints (this creates routes automatically)
|
|
||||||
app.MapSvrntyCommands(); // Maps all commands to POST /api/command/{name}
|
|
||||||
app.MapSvrntyQueries(); // Maps all queries to POST/GET /api/query/{name}
|
|
||||||
|
|
||||||
app.Run();
|
|
||||||
```
|
|
||||||
|
|
||||||
**How It Works:**
|
|
||||||
1. Extension methods iterate through `ICommandDiscovery` and `IQueryDiscovery`
|
|
||||||
2. For each command/query, creates Minimal API endpoints using `MapPost()`/`MapGet()`
|
|
||||||
3. Applies naming conventions (lowerCamelCase)
|
|
||||||
4. Respects `[CommandControllerIgnore]` and `[QueryControllerIgnore]` attributes
|
|
||||||
5. Integrates with `ICommandAuthorizationService` and `IQueryAuthorizationService`
|
|
||||||
6. Supports OpenAPI/Swagger documentation
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Queries support both POST (with JSON body) and GET (with query string parameters)
|
|
||||||
- Commands only support POST with JSON body
|
|
||||||
- Authorization via authorization services (returns 401/403 status codes)
|
|
||||||
- Customizable route prefixes: `MapSvrntyCommands("my-prefix")`
|
|
||||||
- Automatic OpenAPI tags: "Commands" and "Queries"
|
|
||||||
- RFC 7807 Problem Details for validation errors
|
|
||||||
- Full Swagger/OpenAPI support
|
|
||||||
|
|
||||||
**Key Files:**
|
|
||||||
- `Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs` - Main implementation
|
|
||||||
|
|
||||||
#### Option 3: Both gRPC and HTTP (Dual Protocol Support)
|
|
||||||
|
|
||||||
You can enable both protocols simultaneously, allowing clients to choose their preferred protocol:
|
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
builder.Services.AddSvrntyCqrs(cqrs =>
|
||||||
|
{
|
||||||
|
cqrs.AddGrpc(grpc => grpc.EnableReflection());
|
||||||
|
cqrs.AddMinimalApi();
|
||||||
|
});
|
||||||
|
|
||||||
// Register CQRS services
|
app.UseSvrntyCqrs(); // Maps all endpoints
|
||||||
builder.Services.AddSvrntyCQRS();
|
|
||||||
builder.Services.AddDefaultCommandDiscovery();
|
|
||||||
builder.Services.AddDefaultQueryDiscovery();
|
|
||||||
|
|
||||||
// Add commands and queries
|
|
||||||
AddCommands(builder.Services);
|
|
||||||
AddQueries(builder.Services);
|
|
||||||
|
|
||||||
// Add both gRPC and HTTP support
|
|
||||||
builder.Services.AddGrpc();
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
|
||||||
builder.Services.AddSwaggerGen();
|
|
||||||
|
|
||||||
var app = builder.Build();
|
|
||||||
|
|
||||||
// Map both gRPC and HTTP endpoints
|
|
||||||
app.MapGrpcService<CommandServiceImpl>();
|
|
||||||
app.MapGrpcService<QueryServiceImpl>();
|
|
||||||
app.MapGrpcReflectionService();
|
|
||||||
|
|
||||||
app.MapSvrntyCommands();
|
|
||||||
app.MapSvrntyQueries();
|
|
||||||
|
|
||||||
app.Run();
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Benefits:**
|
See `Svrnty.Sample/Program.cs` for a complete working example.
|
||||||
- Single codebase supports multiple protocols
|
|
||||||
- gRPC for high-performance, low-latency scenarios (microservices, internal APIs)
|
|
||||||
- HTTP for web browsers, legacy clients, and public APIs
|
|
||||||
- Same commands, queries, and validation logic for both protocols
|
|
||||||
- Swagger UI available for HTTP endpoints, gRPC reflection for gRPC clients
|
|
||||||
|
|
||||||
### Dynamic Query System
|
|
||||||
|
|
||||||
Dynamic queries provide OData-like filtering capabilities:
|
|
||||||
|
|
||||||
**Core Components:**
|
|
||||||
- `IDynamicQuery<TSource, TDestination>` - Interface with GetFilters(), GetSorts(), GetGroups(), GetAggregates()
|
|
||||||
- `IQueryableProvider<TSource>` - Provides base IQueryable to query against
|
|
||||||
- `IAlterQueryableService<TSource, TDestination>` - Middleware to modify queries (e.g., security filters)
|
|
||||||
- `DynamicQueryHandler<TSource, TDestination>` - Executes queries using PoweredSoft.DynamicQuery
|
|
||||||
|
|
||||||
**Request Flow:**
|
|
||||||
1. HTTP request with filters/sorts/aggregates
|
|
||||||
2. Minimal API endpoint receives request
|
|
||||||
3. DynamicQueryHandler gets base queryable from IQueryableProvider
|
|
||||||
4. Applies alterations from all registered IAlterQueryableService instances
|
|
||||||
5. Builds PoweredSoft query criteria
|
|
||||||
6. Executes and returns IQueryExecutionResult
|
|
||||||
|
|
||||||
**Registration Example:**
|
|
||||||
```csharp
|
|
||||||
// Register dynamic query
|
|
||||||
services.AddDynamicQuery<Person, PersonDto>()
|
|
||||||
.AddDynamicQueryWithProvider<Person, PersonQueryableProvider>()
|
|
||||||
.AddAlterQueryable<Person, PersonDto, SecurityFilter>();
|
|
||||||
|
|
||||||
// Map dynamic query endpoints
|
|
||||||
app.MapSvrntyDynamicQueries(); // Creates POST/GET /api/query/{queryName} endpoints
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Files:**
|
|
||||||
- `Svrnty.CQRS.DynamicQuery/DynamicQueryHandler.cs` - Query execution logic
|
|
||||||
- `Svrnty.CQRS.DynamicQuery.MinimalApi/EndpointRouteBuilderExtensions.cs` - HTTP endpoint mapping
|
|
||||||
|
|
||||||
## Package Configuration
|
## Package Configuration
|
||||||
|
|
||||||
All projects target .NET 10.0 and use C# 14, sharing common configuration:
|
- **Target Framework**: `net10.0` (except DynamicQuery.Abstractions: `netstandard2.1;net10.0`)
|
||||||
|
|
||||||
- **Target Framework**: `net10.0` (except DynamicQuery.Abstractions which multi-targets `netstandard2.1;net10.0`)
|
|
||||||
- **Language Version**: C# 14
|
- **Language Version**: C# 14
|
||||||
- **IsAotCompatible**: Currently set but not enforced (many dependencies are not AOT-compatible yet)
|
|
||||||
- **Symbols**: Portable debug symbols with source, published as `.snupkg`
|
|
||||||
- **NuGet metadata**: Icon, README, license (MIT), and repository URL included in packages
|
|
||||||
- **Authors**: David Lebee, Mathias Beaulieu-Duncan
|
- **Authors**: David Lebee, Mathias Beaulieu-Duncan
|
||||||
- **Repository**: https://git.openharbor.io/svrnty/dotnet-cqrs
|
- **Repository**: https://git.openharbor.io/svrnty/dotnet-cqrs
|
||||||
|
|
||||||
### Package Dependencies
|
### Key Dependencies
|
||||||
|
|
||||||
**Core Dependencies:**
|
|
||||||
- **Microsoft.Extensions.DependencyInjection.Abstractions**: 10.0.0
|
- **Microsoft.Extensions.DependencyInjection.Abstractions**: 10.0.0
|
||||||
- **FluentValidation**: 11.11.0
|
- **FluentValidation**: 11.11.0
|
||||||
- **PoweredSoft.DynamicQuery**: 3.0.1
|
- **PoweredSoft.DynamicQuery**: 3.0.1
|
||||||
- **Pluralize.NET**: 1.0.2
|
- **Grpc.AspNetCore**: 2.68.0+
|
||||||
|
- **Grpc.StatusProto**: 2.71.0+ (Rich Error Model)
|
||||||
**gRPC Dependencies (for Svrnty.CQRS.Grpc):**
|
- **Microsoft.CodeAnalysis.CSharp**: 5.0.0-2.final (source generators, targets netstandard2.0)
|
||||||
- **Grpc.AspNetCore**: 2.68.0 or later
|
|
||||||
- **Grpc.AspNetCore.Server.Reflection**: 2.71.0 or later (optional, for reflection)
|
|
||||||
- **Grpc.StatusProto**: 2.71.0 or later (for Rich Error Model validation)
|
|
||||||
- **Grpc.Tools**: 2.76.0 or later (for .proto compilation)
|
|
||||||
|
|
||||||
**Source Generator Dependencies (for Svrnty.CQRS.Grpc.Generators):**
|
|
||||||
- **Microsoft.CodeAnalysis.CSharp**: 5.0.0-2.final
|
|
||||||
- **Microsoft.CodeAnalysis.Analyzers**: 3.11.0
|
|
||||||
- **Microsoft.Build.Utilities.Core**: 17.0.0
|
|
||||||
- Targets: netstandard2.0 (for Roslyn compatibility)
|
|
||||||
|
|
||||||
## Publishing
|
## Publishing
|
||||||
|
|
||||||
NuGet packages are published automatically via GitHub Actions when a release is created:
|
NuGet packages publish automatically via GitHub Actions (`.github/workflows/publish-nugets.yml`) when a release is created. Tag becomes the version.
|
||||||
|
|
||||||
**Workflow:** `.github/workflows/publish-nugets.yml`
|
|
||||||
1. Triggered on release publication
|
|
||||||
2. Extracts version from release tag
|
|
||||||
3. Runs `dotnet pack -c Release -p:Version={tag}`
|
|
||||||
4. Pushes to NuGet.org using `NUGET_API_KEY` secret
|
|
||||||
|
|
||||||
**Manual publish:**
|
|
||||||
```bash
|
```bash
|
||||||
# Create packages with specific version
|
# Manual publish
|
||||||
dotnet pack -c Release -o ./artifacts -p:Version=1.2.3
|
dotnet pack -c Release -o ./artifacts -p:Version=1.2.3
|
||||||
|
|
||||||
# Push to NuGet
|
|
||||||
dotnet nuget push ./artifacts/*.nupkg --source https://api.nuget.org/v3/index.json --api-key YOUR_KEY
|
dotnet nuget push ./artifacts/*.nupkg --source https://api.nuget.org/v3/index.json --api-key YOUR_KEY
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development Workflow
|
## Development Workflow
|
||||||
|
|
||||||
**Adding a New Command/Query Handler:**
|
**Adding a New Feature to the Framework:**
|
||||||
|
|
||||||
1. Create command/query POCO in consumer project
|
|
||||||
2. Implement handler: `ICommandHandler<TCommand, TResult>`
|
|
||||||
3. Register in DI: `services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler>()`
|
|
||||||
4. (Optional) Add validator: `services.AddTransient<IValidator<CreatePersonCommand>, Validator>()`
|
|
||||||
5. Controller endpoint is automatically generated
|
|
||||||
|
|
||||||
**Adding a New Feature to Framework:**
|
|
||||||
|
|
||||||
1. Add interface to appropriate Abstractions project
|
1. Add interface to appropriate Abstractions project
|
||||||
2. Implement in corresponding implementation project
|
2. Implement in corresponding implementation project
|
||||||
3. Update ServiceCollectionExtensions with registration method
|
3. Update ServiceCollectionExtensions with registration method
|
||||||
4. Ensure all projects maintain AOT compatibility (unless AspNetCore-specific)
|
4. When extending discovery, create corresponding metadata classes (implement ICommandMeta/IQueryMeta)
|
||||||
5. Update package version and release notes
|
5. Update package version and release notes
|
||||||
|
|
||||||
**Naming Conventions:**
|
|
||||||
|
|
||||||
- Commands/Queries: Use `[CommandName]` or `[QueryName]` attribute for custom names
|
|
||||||
- Default naming: Strips "Command"/"Query" suffix, converts to lowerCamelCase
|
|
||||||
- Example: `CreatePersonCommand` -> `createPerson` endpoint
|
|
||||||
|
|
||||||
## C# 14 Language Features
|
## C# 14 Language Features
|
||||||
|
|
||||||
The project now uses C# 14, which introduces several new features. Be aware of these breaking changes:
|
The project uses C# 14. Be aware of these reserved keywords:
|
||||||
|
- **`field`**: Contextual keyword in property accessors for implicit backing fields
|
||||||
**Potential Breaking Changes:**
|
- **`extension`**: Reserved for extension containers; use `@extension` for identifiers
|
||||||
- **`field` keyword**: New contextual keyword in property accessors for implicit backing fields
|
|
||||||
- **`extension` keyword**: Reserved for extension containers; use `@extension` for identifiers
|
|
||||||
- **`partial` return type**: Cannot use `partial` as return type without escaping
|
|
||||||
- **Span<T> overload resolution**: New implicit conversions may select different overloads
|
|
||||||
- **`scoped` as lambda modifier**: Always treated as modifier in lambda parameters
|
|
||||||
|
|
||||||
**New Features Available:**
|
|
||||||
- Extension members (static extension members and extension properties)
|
|
||||||
- Implicit span conversions
|
|
||||||
- Unbound generic types with `nameof`
|
|
||||||
- Lambda parameter modifiers without type specification
|
|
||||||
- Partial instance constructors and events
|
|
||||||
- Null-conditional assignment (`?.=` and `?[]=`)
|
|
||||||
|
|
||||||
The codebase currently compiles without warnings on C# 14.
|
|
||||||
|
|
||||||
## Important Implementation Notes
|
## Important Implementation Notes
|
||||||
|
|
||||||
1. **AOT Compatibility**: Currently not enforced. The `IsAotCompatible` property is set on some projects but many dependencies (including FluentValidation, PoweredSoft.DynamicQuery) are not AOT-compatible. Future work may address this.
|
1. **Async Everywhere**: All handlers are async. Always support CancellationToken.
|
||||||
|
2. **Generic Type Safety**: Framework relies heavily on generics. Maintain strong typing.
|
||||||
2. **Async Everywhere**: All handlers are async. Always support CancellationToken.
|
3. **Endpoint Mapping Timing**: Discovery services must be registered before calling `UseSvrntyCqrs()`.
|
||||||
|
4. **AOT Compatibility**: `IsAotCompatible` is set but not enforced — many dependencies are not AOT-compatible.
|
||||||
3. **Generic Type Safety**: Framework relies heavily on generics for compile-time safety. When adding features, maintain strong typing.
|
|
||||||
|
|
||||||
4. **Metadata Pattern**: When extending discovery, always create corresponding metadata classes (implement ICommandMeta/IQueryMeta).
|
|
||||||
|
|
||||||
5. **Endpoint Mapping Timing**: Endpoints are mapped at application startup. Discovery services must be registered before calling `MapSvrntyCommands()`/`MapSvrntyQueries()` or mapping gRPC services.
|
|
||||||
|
|
||||||
6. **FluentValidation Integration**:
|
|
||||||
- For HTTP: Validation happens automatically in the Minimal API pipeline. Errors return RFC 7807 Problem Details.
|
|
||||||
- For gRPC: Validation happens automatically via source-generated services. Errors return Google Rich Error Model with structured FieldViolations.
|
|
||||||
- The framework REGISTERS validators in DI; actual validation execution is handled by the endpoint implementations.
|
|
||||||
|
|
||||||
7. **DynamicQuery Interceptors**: Support up to 5 interceptors per query type. Interceptors modify PoweredSoft DynamicQuery behavior.
|
|
||||||
|
|
||||||
## Common Code Locations
|
## Common Code Locations
|
||||||
|
|
||||||
- Handler interfaces: `Svrnty.CQRS.Abstractions/ICommandHandler.cs`, `IQueryHandler.cs`
|
- Handler interfaces: `Svrnty.CQRS.Abstractions/ICommandHandler.cs`, `IQueryHandler.cs`
|
||||||
- Discovery implementations: `Svrnty.CQRS/Discovery/`
|
- Discovery: `Svrnty.CQRS/Discovery/`
|
||||||
- Service registration: `*/ServiceCollectionExtensions.cs` in each project
|
- Service registration: `*/ServiceCollectionExtensions.cs` in each project
|
||||||
- HTTP endpoint mapping: `Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs`
|
- HTTP endpoints: `Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs`
|
||||||
- Dynamic query logic: `Svrnty.CQRS.DynamicQuery/DynamicQueryHandler.cs`
|
- Dynamic queries: `Svrnty.CQRS.DynamicQuery/DynamicQueryHandler.cs`
|
||||||
- Dynamic query endpoints: `Svrnty.CQRS.DynamicQuery.MinimalApi/EndpointRouteBuilderExtensions.cs`
|
- gRPC generators: `Svrnty.CQRS.Grpc.Generators/`
|
||||||
- gRPC support: `Svrnty.CQRS.Grpc/` runtime, `Svrnty.CQRS.Grpc.Generators/` source generators
|
- Sample: `Svrnty.Sample/`
|
||||||
- Sample application: `Svrnty.Sample/` - demonstrates both HTTP and gRPC integration
|
|
||||||
|
|||||||
@@ -4,6 +4,15 @@
|
|||||||
|
|
||||||
Our implementation of query and command responsibility segregation (CQRS).
|
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
|
## Getting Started
|
||||||
|
|
||||||
> Install nuget package to your awesome project.
|
> Install nuget package to your awesome project.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace Svrnty.CQRS.Abstractions.Attributes;
|
namespace Svrnty.CQRS.Abstractions.Attributes;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace Svrnty.CQRS.Abstractions.Attributes;
|
namespace Svrnty.CQRS.Abstractions.Attributes;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using Svrnty.CQRS.Abstractions.Attributes;
|
using Svrnty.CQRS.Abstractions.Attributes;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace Svrnty.CQRS.Abstractions.Discovery;
|
namespace Svrnty.CQRS.Abstractions.Discovery;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace Svrnty.CQRS.Abstractions.Discovery;
|
namespace Svrnty.CQRS.Abstractions.Discovery;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace Svrnty.CQRS.Abstractions.Discovery;
|
namespace Svrnty.CQRS.Abstractions.Discovery;
|
||||||
|
|
||||||
@@ -10,4 +10,4 @@ public interface IQueryMeta
|
|||||||
Type QueryResultType { get; }
|
Type QueryResultType { get; }
|
||||||
string Category { get; }
|
string Category { get; }
|
||||||
string LowerCamelCaseName { get; }
|
string LowerCamelCaseName { get; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using Svrnty.CQRS.Abstractions.Attributes;
|
using Svrnty.CQRS.Abstractions.Attributes;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace Svrnty.CQRS.Abstractions;
|
namespace Svrnty.CQRS.Abstractions;
|
||||||
@@ -13,4 +13,4 @@ public interface ICommandHandler<in TCommand, TCommandResult>
|
|||||||
where TCommand : class
|
where TCommand : class
|
||||||
{
|
{
|
||||||
Task<TCommandResult> HandleAsync(TCommand command, CancellationToken cancellationToken = default);
|
Task<TCommandResult> HandleAsync(TCommand command, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace Svrnty.CQRS.Abstractions;
|
namespace Svrnty.CQRS.Abstractions;
|
||||||
@@ -7,4 +7,4 @@ public interface IQueryHandler<in TQuery, TQueryResult>
|
|||||||
where TQuery : class
|
where TQuery : class
|
||||||
{
|
{
|
||||||
Task<TQueryResult> HandleAsync(TQuery query, CancellationToken cancellationToken = default);
|
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
|
public enum AuthorizationResult
|
||||||
{
|
{
|
||||||
Unauthorized,
|
Unauthorized,
|
||||||
Forbidden,
|
Forbidden,
|
||||||
Allowed
|
Allowed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
@@ -7,4 +7,4 @@ namespace Svrnty.CQRS.Abstractions.Security;
|
|||||||
public interface ICommandAuthorizationService
|
public interface ICommandAuthorizationService
|
||||||
{
|
{
|
||||||
Task<AuthorizationResult> IsAllowedAsync(Type commandType, CancellationToken cancellationToken = default);
|
Task<AuthorizationResult> IsAllowedAsync(Type commandType, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
@@ -7,4 +7,4 @@ namespace Svrnty.CQRS.Abstractions.Security;
|
|||||||
public interface IQueryAuthorizationService
|
public interface IQueryAuthorizationService
|
||||||
{
|
{
|
||||||
Task<AuthorizationResult> IsAllowedAsync(Type queryType, CancellationToken cancellationToken = default);
|
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 Microsoft.Extensions.DependencyInjection;
|
||||||
using Svrnty.CQRS.Abstractions.Discovery;
|
using Svrnty.CQRS.Abstractions.Discovery;
|
||||||
|
|
||||||
@@ -47,4 +47,4 @@ public static class ServiceCollectionExtensions
|
|||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
|
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
@@ -13,4 +13,4 @@ public interface IAlterQueryableService<TSource, TDestination, in TParams>
|
|||||||
where TParams : class
|
where TParams : class
|
||||||
{
|
{
|
||||||
Task<IQueryable<TSource>> AlterQueryableAsync(IQueryable<TSource> query, IDynamicQueryParams<TParams> dynamicQuery, CancellationToken cancellationToken = default);
|
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;
|
using PoweredSoft.DynamicQuery.Core;
|
||||||
|
|
||||||
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
|
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||||
@@ -15,7 +15,7 @@ public interface IDynamicQuery<TSource, TDestination, out TParams> : IDynamicQue
|
|||||||
where TDestination : class
|
where TDestination : class
|
||||||
where TParams : class
|
where TParams : class
|
||||||
{
|
{
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IDynamicQuery
|
public interface IDynamicQuery
|
||||||
@@ -26,4 +26,4 @@ public interface IDynamicQuery
|
|||||||
List<IAggregate> GetAggregates();
|
List<IAggregate> GetAggregates();
|
||||||
int? GetPage();
|
int? GetPage();
|
||||||
int? GetPageSize();
|
int? GetPageSize();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
|
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||||
|
|
||||||
public interface IDynamicQueryInterceptorProvider<TSource, TDestination>
|
public interface IDynamicQueryInterceptorProvider<TSource, TDestination>
|
||||||
{
|
{
|
||||||
IEnumerable<Type> GetInterceptorsTypes();
|
IEnumerable<Type> GetInterceptorsTypes();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
|
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||||
|
|
||||||
public interface IDynamicQueryParams<out TParams>
|
public interface IDynamicQueryParams<out TParams>
|
||||||
where TParams : class
|
where TParams : class
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
@@ -7,4 +7,4 @@ namespace Svrnty.CQRS.DynamicQuery.Abstractions;
|
|||||||
public interface IQueryableProvider<TSource>
|
public interface IQueryableProvider<TSource>
|
||||||
{
|
{
|
||||||
Task<IQueryable<TSource>> GetQueryableAsync(object query, CancellationToken cancellationToken = default);
|
Task<IQueryable<TSource>> GetQueryableAsync(object query, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Builder;
|
|||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using PoweredSoft.DynamicQuery.Core;
|
||||||
using Svrnty.CQRS.Abstractions;
|
using Svrnty.CQRS.Abstractions;
|
||||||
using Svrnty.CQRS.Abstractions.Attributes;
|
using Svrnty.CQRS.Abstractions.Attributes;
|
||||||
using Svrnty.CQRS.Abstractions.Discovery;
|
using Svrnty.CQRS.Abstractions.Discovery;
|
||||||
@@ -14,7 +15,6 @@ using Svrnty.CQRS.Abstractions.Security;
|
|||||||
using Svrnty.CQRS.DynamicQuery;
|
using Svrnty.CQRS.DynamicQuery;
|
||||||
using Svrnty.CQRS.DynamicQuery.Abstractions;
|
using Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||||
using Svrnty.CQRS.DynamicQuery.Discover;
|
using Svrnty.CQRS.DynamicQuery.Discover;
|
||||||
using PoweredSoft.DynamicQuery.Core;
|
|
||||||
|
|
||||||
namespace Svrnty.CQRS.DynamicQuery.MinimalApi;
|
namespace Svrnty.CQRS.DynamicQuery.MinimalApi;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using Pluralize.NET;
|
using Pluralize.NET;
|
||||||
using Svrnty.CQRS.Abstractions.Discovery;
|
using Svrnty.CQRS.Abstractions.Discovery;
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ namespace Svrnty.CQRS.DynamicQuery.Discover;
|
|||||||
public class DynamicQueryMeta(Type queryType, Type serviceType, Type queryResultType)
|
public class DynamicQueryMeta(Type queryType, Type serviceType, Type queryResultType)
|
||||||
: QueryMeta(queryType, serviceType, queryResultType)
|
: QueryMeta(queryType, serviceType, queryResultType)
|
||||||
{
|
{
|
||||||
public Type SourceType => QueryType.GetGenericArguments()[0];
|
public Type SourceType => QueryType.GetGenericArguments()[0];
|
||||||
public Type DestinationType => QueryType.GetGenericArguments()[1];
|
public Type DestinationType => QueryType.GetGenericArguments()[1];
|
||||||
public override string Category => "DynamicQuery";
|
public override string Category => "DynamicQuery";
|
||||||
public override string Name
|
public override string Name
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Svrnty.CQRS.DynamicQuery.Abstractions;
|
|
||||||
using PoweredSoft.DynamicQuery;
|
using PoweredSoft.DynamicQuery;
|
||||||
using PoweredSoft.DynamicQuery.Core;
|
using PoweredSoft.DynamicQuery.Core;
|
||||||
|
using Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||||
|
|
||||||
namespace Svrnty.CQRS.DynamicQuery;
|
namespace Svrnty.CQRS.DynamicQuery;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
using System;
|
||||||
using PoweredSoft.DynamicQuery;
|
using PoweredSoft.DynamicQuery;
|
||||||
using PoweredSoft.DynamicQuery.Core;
|
using PoweredSoft.DynamicQuery.Core;
|
||||||
using System;
|
|
||||||
|
|
||||||
namespace Svrnty.CQRS.DynamicQuery;
|
namespace Svrnty.CQRS.DynamicQuery;
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
using Svrnty.CQRS.DynamicQuery.Abstractions;
|
|
||||||
using PoweredSoft.DynamicQuery.Core;
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using PoweredSoft.DynamicQuery.Core;
|
||||||
|
using Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||||
|
|
||||||
namespace Svrnty.CQRS.DynamicQuery;
|
namespace Svrnty.CQRS.DynamicQuery;
|
||||||
|
|
||||||
public class DynamicQueryHandler<TSource, TDestination>
|
public class DynamicQueryHandler<TSource, TDestination>
|
||||||
: DynamicQueryHandlerBase<TSource, TDestination>,
|
: DynamicQueryHandlerBase<TSource, TDestination>,
|
||||||
Svrnty.CQRS.Abstractions.IQueryHandler<IDynamicQuery<TSource, TDestination>, IQueryExecutionResult<TDestination>>
|
Svrnty.CQRS.Abstractions.IQueryHandler<IDynamicQuery<TSource, TDestination>, IQueryExecutionResult<TDestination>>
|
||||||
where TSource : class
|
where TSource : class
|
||||||
where TDestination : class
|
where TDestination : class
|
||||||
{
|
{
|
||||||
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
|
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
|
||||||
IEnumerable<IQueryableProvider<TSource>> queryableProviders,
|
IEnumerable<IQueryableProvider<TSource>> queryableProviders,
|
||||||
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
|
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
|
||||||
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
|
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
|
||||||
IServiceProvider serviceProvider) : base(queryHandlerAsync, queryableProviders, alterQueryableServices, dynamicQueryInterceptorProviders, serviceProvider)
|
IServiceProvider serviceProvider) : base(queryHandlerAsync, queryableProviders, alterQueryableServices, dynamicQueryInterceptorProviders, serviceProvider)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ public class DynamicQueryHandler<TSource, TDestination>
|
|||||||
}
|
}
|
||||||
|
|
||||||
public class DynamicQueryHandler<TSource, TDestination, TParams>
|
public class DynamicQueryHandler<TSource, TDestination, TParams>
|
||||||
: DynamicQueryHandlerBase<TSource, TDestination>,
|
: DynamicQueryHandlerBase<TSource, TDestination>,
|
||||||
Svrnty.CQRS.Abstractions.IQueryHandler<IDynamicQuery<TSource, TDestination, TParams>, IQueryExecutionResult<TDestination>>
|
Svrnty.CQRS.Abstractions.IQueryHandler<IDynamicQuery<TSource, TDestination, TParams>, IQueryExecutionResult<TDestination>>
|
||||||
where TSource : class
|
where TSource : class
|
||||||
where TDestination : class
|
where TDestination : class
|
||||||
@@ -37,10 +37,10 @@ public class DynamicQueryHandler<TSource, TDestination, TParams>
|
|||||||
{
|
{
|
||||||
private readonly IEnumerable<IAlterQueryableService<TSource, TDestination, TParams>> alterQueryableServicesWithParams;
|
private readonly IEnumerable<IAlterQueryableService<TSource, TDestination, TParams>> alterQueryableServicesWithParams;
|
||||||
|
|
||||||
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
|
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
|
||||||
IEnumerable<IQueryableProvider<TSource>> queryableProviders,
|
IEnumerable<IQueryableProvider<TSource>> queryableProviders,
|
||||||
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
|
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
|
||||||
IEnumerable<IAlterQueryableService<TSource, TDestination, TParams>> alterQueryableServicesWithParams,
|
IEnumerable<IAlterQueryableService<TSource, TDestination, TParams>> alterQueryableServicesWithParams,
|
||||||
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
|
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
|
||||||
IServiceProvider serviceProvider) : base(queryHandlerAsync, queryableProviders, alterQueryableServices, dynamicQueryInterceptorProviders, serviceProvider)
|
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)
|
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)
|
if (query is IDynamicQueryParams<TParams> withParams)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Reflection;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Svrnty.CQRS.DynamicQuery.Abstractions;
|
|
||||||
using PoweredSoft.DynamicQuery;
|
using PoweredSoft.DynamicQuery;
|
||||||
using PoweredSoft.DynamicQuery.Core;
|
using PoweredSoft.DynamicQuery.Core;
|
||||||
|
using Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||||
|
|
||||||
namespace Svrnty.CQRS.DynamicQuery;
|
namespace Svrnty.CQRS.DynamicQuery;
|
||||||
|
|
||||||
@@ -16,10 +19,13 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
|
|||||||
private readonly IQueryHandlerAsync _queryHandlerAsync;
|
private readonly IQueryHandlerAsync _queryHandlerAsync;
|
||||||
private readonly IEnumerable<IQueryableProvider<TSource>> _queryableProviders;
|
private readonly IEnumerable<IQueryableProvider<TSource>> _queryableProviders;
|
||||||
private readonly IEnumerable<IAlterQueryableService<TSource, TDestination>> _alterQueryableServices;
|
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;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
|
||||||
public DynamicQueryHandlerBase(IQueryHandlerAsync queryHandlerAsync,
|
public DynamicQueryHandlerBase(IQueryHandlerAsync queryHandlerAsync,
|
||||||
IEnumerable<IQueryableProvider<TSource>> queryableProviders,
|
IEnumerable<IQueryableProvider<TSource>> queryableProviders,
|
||||||
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
|
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
|
||||||
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
|
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
|
||||||
@@ -32,7 +38,8 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
|
|||||||
_serviceProvider = serviceProvider;
|
_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())
|
if (_queryableProviders.Any())
|
||||||
{
|
{
|
||||||
@@ -56,7 +63,8 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
|
|||||||
yield return _serviceProvider.GetService(type) as IQueryInterceptor;
|
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);
|
var source = await GetQueryableAsync(query, cancellationToken);
|
||||||
source = await AlterSourceAsync(source, query, cancellationToken);
|
source = await AlterSourceAsync(source, query, cancellationToken);
|
||||||
@@ -67,11 +75,13 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
|
|||||||
_queryHandlerAsync.AddInterceptor(interceptor);
|
_queryHandlerAsync.AddInterceptor(interceptor);
|
||||||
|
|
||||||
var criteria = CreateCriteriaFromQuery(query);
|
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;
|
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)
|
foreach (var t in _alterQueryableServices)
|
||||||
source = await t.AlterQueryableAsync(source, query, cancellationToken);
|
source = await t.AlterQueryableAsync(source, query, cancellationToken);
|
||||||
@@ -81,16 +91,94 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
|
|||||||
|
|
||||||
protected virtual IQueryCriteria CreateCriteriaFromQuery(IDynamicQuery query)
|
protected virtual IQueryCriteria CreateCriteriaFromQuery(IDynamicQuery query)
|
||||||
{
|
{
|
||||||
|
var filters = query?.GetFilters() ?? new List<IFilter>();
|
||||||
|
ConvertFilterValuesToPropertyTypes(filters);
|
||||||
var criteria = new QueryCriteria
|
var criteria = new QueryCriteria
|
||||||
{
|
{
|
||||||
Page = query?.GetPage(),
|
Page = query?.GetPage(),
|
||||||
PageSize = query?.GetPageSize(),
|
PageSize = query?.GetPageSize(),
|
||||||
Filters = query?.GetFilters() ?? new List<IFilter>(),
|
Filters = filters,
|
||||||
Sorts = query?.GetSorts() ?? new List<ISort>(),
|
Sorts = query?.GetSorts() ?? new List<ISort>(),
|
||||||
Groups = query?.GetGroups() ?? new List<IGroup>(),
|
Groups = query?.GetGroups() ?? new List<IGroup>(),
|
||||||
Aggregates = query?.GetAggregates() ?? new List<IAggregate>()
|
Aggregates = query?.GetAggregates() ?? new List<IAggregate>()
|
||||||
};
|
};
|
||||||
return criteria;
|
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;
|
||||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using PoweredSoft.Data.Core;
|
using PoweredSoft.Data.Core;
|
||||||
@@ -91,10 +91,10 @@ public static class ServiceCollectionExtensions
|
|||||||
where TParams : class
|
where TParams : class
|
||||||
=> AddDynamicQueryWithParams<TSourceAndDestination, TSourceAndDestination, TParams>(services, name: name);
|
=> AddDynamicQueryWithParams<TSourceAndDestination, TSourceAndDestination, TParams>(services, name: name);
|
||||||
|
|
||||||
public static IServiceCollection AddDynamicQueryWithParams<TSource, TDestination, TParams>(this IServiceCollection services, string name = null)
|
public static IServiceCollection AddDynamicQueryWithParams<TSource, TDestination, TParams>(this IServiceCollection services, string name = null)
|
||||||
where TSource : class
|
where TSource : class
|
||||||
where TDestination : class
|
where TDestination : class
|
||||||
where TParams : class
|
where TParams : class
|
||||||
{
|
{
|
||||||
// add query handler.
|
// add query handler.
|
||||||
services.AddTransient<IQueryHandler<IDynamicQuery<TSource, TDestination, TParams>, IQueryExecutionResult<TDestination>>, DynamicQueryHandler<TSource, TDestination, TParams>>();
|
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 TParams : class
|
||||||
where TService : class, IAlterQueryableService<TSourceAndTDestination, TSourceAndTDestination, TParams>
|
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>
|
public static IServiceCollection AddAlterQueryableWithParams<TSource, TDestination, TParams, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TService>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Svrnty.CQRS.Abstractions;
|
using Svrnty.CQRS.Abstractions;
|
||||||
@@ -39,7 +39,7 @@ public static class ServiceCollectionExtensions
|
|||||||
{
|
{
|
||||||
services.AddQuery<TQuery, TQueryResult, TQueryHandler>()
|
services.AddQuery<TQuery, TQueryResult, TQueryHandler>()
|
||||||
.AddFluentValidator<TQuery, TValidator>();
|
.AddFluentValidator<TQuery, TValidator>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
#pragma warning disable RS1035 // Do not use APIs banned for analyzers - This is an MSBuild task, not an analyzer
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using Microsoft.Build.Framework;
|
||||||
|
using Microsoft.Build.Utilities;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
|
||||||
|
namespace Svrnty.CQRS.Grpc.Generators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MSBuild task that generates .proto files by creating its own Roslyn compilation.
|
||||||
|
/// This runs BEFORE CoreCompile to solve the source generator timing issue.
|
||||||
|
/// </summary>
|
||||||
|
public class GenerateProtoFileTask : Task
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The project directory
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public string ProjectDirectory { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The output directory where the proto file should be written (typically Protos/)
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public string OutputDirectory { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The name of the proto file to generate (typically cqrs_services.proto)
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public string ProtoFileName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The C# source files to compile (from @(Compile) ItemGroup)
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public ITaskItem[] SourceFiles { get; set; } = Array.Empty<ITaskItem>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The assembly references (from @(ReferencePath) ItemGroup)
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public ITaskItem[] References { get; set; } = Array.Empty<ITaskItem>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The root namespace of the project
|
||||||
|
/// </summary>
|
||||||
|
public string RootNamespace { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The assembly name of the project
|
||||||
|
/// </summary>
|
||||||
|
public string AssemblyName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public override bool Execute()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Log.LogMessage(MessageImportance.High,
|
||||||
|
"Svrnty.CQRS.Grpc: Generating proto file via MSBuild task...");
|
||||||
|
|
||||||
|
// Determine the namespace for the proto file
|
||||||
|
var projectNamespace = !string.IsNullOrEmpty(RootNamespace) ? RootNamespace
|
||||||
|
: !string.IsNullOrEmpty(AssemblyName) ? AssemblyName
|
||||||
|
: "Generated";
|
||||||
|
var grpcNamespace = $"{projectNamespace}.Grpc";
|
||||||
|
var packageName = "cqrs";
|
||||||
|
|
||||||
|
// Create the compilation
|
||||||
|
var compilation = CreateCompilation();
|
||||||
|
if (compilation == null)
|
||||||
|
{
|
||||||
|
Log.LogWarning("Svrnty.CQRS.Grpc: Could not create compilation. Writing placeholder proto file.");
|
||||||
|
WritePlaceholderProto(grpcNamespace);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for compilation errors that would prevent proper analysis
|
||||||
|
var diagnostics = compilation.GetDiagnostics()
|
||||||
|
.Where(d => d.Severity == DiagnosticSeverity.Error)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (diagnostics.Count > 0)
|
||||||
|
{
|
||||||
|
Log.LogMessage(MessageImportance.Normal,
|
||||||
|
$"Svrnty.CQRS.Grpc: Compilation has {diagnostics.Count} errors. Attempting to generate proto anyway...");
|
||||||
|
|
||||||
|
// Log first few errors for debugging
|
||||||
|
foreach (var diag in diagnostics.Take(5))
|
||||||
|
{
|
||||||
|
Log.LogMessage(MessageImportance.Low, $" {diag.GetMessage()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the ProtoFileGenerator to generate content
|
||||||
|
var generator = new ProtoFileGenerator(compilation);
|
||||||
|
var protoContent = generator.Generate(packageName, grpcNamespace);
|
||||||
|
|
||||||
|
// Check if we got meaningful content
|
||||||
|
if (string.IsNullOrWhiteSpace(protoContent) || !protoContent.Contains("rpc "))
|
||||||
|
{
|
||||||
|
Log.LogMessage(MessageImportance.High,
|
||||||
|
"Svrnty.CQRS.Grpc: No commands/queries/notifications found. Writing minimal proto file.");
|
||||||
|
WritePlaceholderProto(grpcNamespace);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure output directory exists
|
||||||
|
var fullOutputPath = Path.IsPathRooted(OutputDirectory)
|
||||||
|
? OutputDirectory
|
||||||
|
: Path.Combine(ProjectDirectory, OutputDirectory);
|
||||||
|
Directory.CreateDirectory(fullOutputPath);
|
||||||
|
|
||||||
|
// Write the proto file
|
||||||
|
var protoFilePath = Path.Combine(fullOutputPath, ProtoFileName);
|
||||||
|
File.WriteAllText(protoFilePath, protoContent);
|
||||||
|
|
||||||
|
Log.LogMessage(MessageImportance.High,
|
||||||
|
$"Svrnty.CQRS.Grpc: Successfully generated proto file at {protoFilePath}");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.LogErrorFromException(ex, true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private CSharpCompilation? CreateCompilation()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Parse all source files into syntax trees
|
||||||
|
var syntaxTrees = new List<SyntaxTree>();
|
||||||
|
foreach (var sourceFile in SourceFiles)
|
||||||
|
{
|
||||||
|
var filePath = sourceFile.ItemSpec;
|
||||||
|
if (!Path.IsPathRooted(filePath))
|
||||||
|
{
|
||||||
|
filePath = Path.Combine(ProjectDirectory, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
{
|
||||||
|
Log.LogMessage(MessageImportance.Low, $"Source file not found: {filePath}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sourceText = File.ReadAllText(filePath);
|
||||||
|
var syntaxTree = CSharpSyntaxTree.ParseText(
|
||||||
|
sourceText,
|
||||||
|
CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest),
|
||||||
|
path: filePath);
|
||||||
|
syntaxTrees.Add(syntaxTree);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.LogMessage(MessageImportance.Low, $"Failed to parse {filePath}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syntaxTrees.Count == 0)
|
||||||
|
{
|
||||||
|
Log.LogWarning("Svrnty.CQRS.Grpc: No source files could be parsed.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.LogMessage(MessageImportance.Normal,
|
||||||
|
$"Svrnty.CQRS.Grpc: Parsed {syntaxTrees.Count} source files");
|
||||||
|
|
||||||
|
// Create metadata references from the References
|
||||||
|
var metadataReferences = new List<MetadataReference>();
|
||||||
|
foreach (var reference in References)
|
||||||
|
{
|
||||||
|
var refPath = reference.ItemSpec;
|
||||||
|
if (!File.Exists(refPath))
|
||||||
|
{
|
||||||
|
Log.LogMessage(MessageImportance.Low, $"Reference not found: {refPath}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var metadataRef = MetadataReference.CreateFromFile(refPath);
|
||||||
|
metadataReferences.Add(metadataRef);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.LogMessage(MessageImportance.Low, $"Failed to load reference {refPath}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.LogMessage(MessageImportance.Normal,
|
||||||
|
$"Svrnty.CQRS.Grpc: Loaded {metadataReferences.Count} references");
|
||||||
|
|
||||||
|
// Create the compilation
|
||||||
|
var compilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
|
||||||
|
.WithNullableContextOptions(NullableContextOptions.Enable);
|
||||||
|
|
||||||
|
var assemblyName = !string.IsNullOrEmpty(AssemblyName) ? AssemblyName : "TempCompilation";
|
||||||
|
var compilation = CSharpCompilation.Create(
|
||||||
|
assemblyName,
|
||||||
|
syntaxTrees,
|
||||||
|
metadataReferences,
|
||||||
|
compilationOptions);
|
||||||
|
|
||||||
|
return compilation;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.LogMessage(MessageImportance.High,
|
||||||
|
$"Svrnty.CQRS.Grpc: Failed to create compilation: {ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WritePlaceholderProto(string grpcNamespace)
|
||||||
|
{
|
||||||
|
var placeholderProto = $@"syntax = ""proto3"";
|
||||||
|
|
||||||
|
option csharp_namespace = ""{grpcNamespace}"";
|
||||||
|
|
||||||
|
package cqrs;
|
||||||
|
|
||||||
|
// Placeholder proto file - will be regenerated when commands/queries are available
|
||||||
|
// Using namespace: {grpcNamespace}
|
||||||
|
|
||||||
|
// Empty service definitions so Grpc.Tools generates base classes
|
||||||
|
service CommandService {{
|
||||||
|
}}
|
||||||
|
|
||||||
|
service QueryService {{
|
||||||
|
}}
|
||||||
|
|
||||||
|
service DynamicQueryService {{
|
||||||
|
}}
|
||||||
|
";
|
||||||
|
var fullOutputPath = Path.IsPathRooted(OutputDirectory)
|
||||||
|
? OutputDirectory
|
||||||
|
: Path.Combine(ProjectDirectory, OutputDirectory);
|
||||||
|
Directory.CreateDirectory(fullOutputPath);
|
||||||
|
var protoFilePath = Path.Combine(fullOutputPath, ProtoFileName);
|
||||||
|
File.WriteAllText(protoFilePath, placeholderProto);
|
||||||
|
|
||||||
|
Log.LogMessage(MessageImportance.High,
|
||||||
|
$"Svrnty.CQRS.Grpc: Wrote placeholder proto file at {protoFilePath}");
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,102 +1,101 @@
|
|||||||
using System.Collections.Generic;
|
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
|
return "bytes";
|
||||||
{ "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("?", "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 System.Collections.Generic;
|
||||||
using Microsoft.CodeAnalysis;
|
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; }
|
Name = string.Empty;
|
||||||
public string FullyQualifiedName { get; set; }
|
FullyQualifiedName = string.Empty;
|
||||||
public string Namespace { get; set; }
|
Namespace = string.Empty;
|
||||||
public List<PropertyInfo> Properties { get; set; }
|
Properties = new List<PropertyInfo>();
|
||||||
public string? ResultType { get; set; }
|
HandlerInterfaceName = string.Empty;
|
||||||
public string? ResultFullyQualifiedName { get; set; }
|
ResultProperties = new List<PropertyInfo>();
|
||||||
public bool HasResult => ResultType != null;
|
IsResultPrimitiveType = false;
|
||||||
public string HandlerInterfaceName { get; set; }
|
}
|
||||||
public List<PropertyInfo> ResultProperties { get; set; }
|
}
|
||||||
public bool IsResultPrimitiveType { get; set; }
|
|
||||||
|
public class PropertyInfo
|
||||||
public CommandInfo()
|
{
|
||||||
{
|
public string Name { get; set; }
|
||||||
Name = string.Empty;
|
public string Type { get; set; }
|
||||||
FullyQualifiedName = string.Empty;
|
public string FullyQualifiedType { get; set; }
|
||||||
Namespace = string.Empty;
|
public string ProtoType { get; set; }
|
||||||
Properties = new List<PropertyInfo>();
|
public int FieldNumber { get; set; }
|
||||||
HandlerInterfaceName = string.Empty;
|
public bool IsComplexType { get; set; }
|
||||||
ResultProperties = new List<PropertyInfo>();
|
public List<PropertyInfo> NestedProperties { get; set; }
|
||||||
IsResultPrimitiveType = false;
|
|
||||||
}
|
// Type conversion metadata
|
||||||
}
|
public bool IsEnum { get; set; }
|
||||||
|
public bool IsList { get; set; }
|
||||||
public class PropertyInfo
|
public bool IsNullable { get; set; }
|
||||||
{
|
public bool IsDecimal { get; set; }
|
||||||
public string Name { get; set; }
|
public bool IsDateTime { get; set; }
|
||||||
public string Type { get; set; }
|
public bool IsDateTimeOffset { get; set; }
|
||||||
public string FullyQualifiedType { get; set; }
|
public bool IsGuid { get; set; }
|
||||||
public string ProtoType { get; set; }
|
public bool IsJsonElement { get; set; }
|
||||||
public int FieldNumber { get; set; }
|
public bool IsBinaryType { get; set; } // Stream, byte[], MemoryStream
|
||||||
public bool IsComplexType { get; set; }
|
public bool IsStream { get; set; } // Specifically Stream types (not byte[])
|
||||||
public List<PropertyInfo> NestedProperties { get; set; }
|
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)
|
||||||
// Type conversion metadata
|
public string? ElementType { get; set; }
|
||||||
public bool IsEnum { get; set; }
|
public bool IsElementComplexType { get; set; }
|
||||||
public bool IsList { get; set; }
|
public bool IsElementGuid { get; set; }
|
||||||
public bool IsNullable { get; set; }
|
public List<PropertyInfo>? ElementNestedProperties { get; set; }
|
||||||
public bool IsDecimal { get; set; }
|
|
||||||
public bool IsDateTime { get; set; }
|
public PropertyInfo()
|
||||||
public bool IsDateTimeOffset { get; set; }
|
{
|
||||||
public bool IsGuid { get; set; }
|
Name = string.Empty;
|
||||||
public bool IsJsonElement { get; set; }
|
Type = string.Empty;
|
||||||
public bool IsBinaryType { get; set; } // Stream, byte[], MemoryStream
|
FullyQualifiedType = string.Empty;
|
||||||
public bool IsStream { get; set; } // Specifically Stream types (not byte[])
|
ProtoType = string.Empty;
|
||||||
public bool IsReadOnly { get; set; } // Read-only/computed properties should be skipped
|
IsComplexType = false;
|
||||||
public bool IsValueTypeCollection { get; set; } // Value types that implement IList<T> (like NpgsqlPolygon)
|
NestedProperties = new List<PropertyInfo>();
|
||||||
public string? ElementType { get; set; }
|
IsEnum = false;
|
||||||
public bool IsElementComplexType { get; set; }
|
IsList = false;
|
||||||
public bool IsElementGuid { get; set; }
|
IsNullable = false;
|
||||||
public List<PropertyInfo>? ElementNestedProperties { get; set; }
|
IsDecimal = false;
|
||||||
|
IsDateTime = false;
|
||||||
public PropertyInfo()
|
IsDateTimeOffset = false;
|
||||||
{
|
IsGuid = false;
|
||||||
Name = string.Empty;
|
IsJsonElement = false;
|
||||||
Type = string.Empty;
|
IsBinaryType = false;
|
||||||
FullyQualifiedType = string.Empty;
|
IsStream = false;
|
||||||
ProtoType = string.Empty;
|
IsReadOnly = false;
|
||||||
IsComplexType = false;
|
IsValueTypeCollection = false;
|
||||||
NestedProperties = new List<PropertyInfo>();
|
IsElementComplexType = false;
|
||||||
IsEnum = false;
|
IsElementGuid = 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
|
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; }
|
|
||||||
|
|
||||||
public DynamicQueryInfo()
|
public class DynamicQueryInfo
|
||||||
{
|
{
|
||||||
Name = string.Empty;
|
public string Name { get; set; }
|
||||||
SourceType = string.Empty;
|
public string SourceType { get; set; }
|
||||||
SourceTypeFullyQualified = string.Empty;
|
public string SourceTypeFullyQualified { get; set; }
|
||||||
DestinationType = string.Empty;
|
public string DestinationType { get; set; }
|
||||||
DestinationTypeFullyQualified = string.Empty;
|
public string DestinationTypeFullyQualified { get; set; }
|
||||||
HandlerInterfaceName = string.Empty;
|
public string? ParamsType { get; set; }
|
||||||
QueryInterfaceName = string.Empty;
|
public string? ParamsTypeFullyQualified { get; set; }
|
||||||
HasParams = false;
|
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;
|
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>
|
/// <summary>
|
||||||
/// Represents a discovered streaming notification type for proto/gRPC generation.
|
/// The notification type name (e.g., "InventoryChangeNotification").
|
||||||
/// </summary>
|
/// </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>
|
Name = string.Empty;
|
||||||
/// The notification type name (e.g., "InventoryChangeNotification").
|
FullyQualifiedName = string.Empty;
|
||||||
/// </summary>
|
Namespace = string.Empty;
|
||||||
public string Name { get; set; }
|
SubscriptionKeyProperty = string.Empty;
|
||||||
|
SubscriptionKeyInfo = new PropertyInfo();
|
||||||
/// <summary>
|
Properties = new List<PropertyInfo>();
|
||||||
/// 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>();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,29 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace Svrnty.CQRS.Grpc.Generators.Models
|
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; }
|
|
||||||
|
|
||||||
public QueryInfo()
|
public class QueryInfo
|
||||||
{
|
{
|
||||||
Name = string.Empty;
|
public string Name { get; set; }
|
||||||
FullyQualifiedName = string.Empty;
|
public string FullyQualifiedName { get; set; }
|
||||||
Namespace = string.Empty;
|
public string Namespace { get; set; }
|
||||||
Properties = new List<PropertyInfo>();
|
public List<PropertyInfo> Properties { get; set; }
|
||||||
ResultType = string.Empty;
|
public string ResultType { get; set; }
|
||||||
ResultFullyQualifiedName = string.Empty;
|
public string ResultFullyQualifiedName { get; set; }
|
||||||
HandlerInterfaceName = string.Empty;
|
public string HandlerInterfaceName { get; set; }
|
||||||
ResultProperties = new List<PropertyInfo>();
|
public List<PropertyInfo> ResultProperties { get; set; }
|
||||||
IsResultPrimitiveType = false;
|
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)
|
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;
|
return;
|
||||||
|
|
||||||
// Don't generate messages for system types or primitives
|
// Don't generate messages for system types or primitives
|
||||||
if (type.ContainingNamespace?.ToString().StartsWith("System") == true)
|
if (type.ContainingNamespace?.ToString().StartsWith("System") == true)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
_generatedMessages.Add(type.Name);
|
_generatedMessages.Add(messageName);
|
||||||
|
|
||||||
_messagesBuilder.AppendLine($"// {type.Name} entity");
|
_messagesBuilder.AppendLine($"// {messageName} entity");
|
||||||
_messagesBuilder.AppendLine($"message {type.Name} {{");
|
_messagesBuilder.AppendLine($"message {messageName} {{");
|
||||||
|
|
||||||
// Collect nested complex types to generate after closing this message
|
// Collect nested complex types to generate after closing this message
|
||||||
var nestedComplexTypes = new List<INamedTypeSymbol>();
|
var nestedComplexTypes = new List<INamedTypeSymbol>();
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Immutable;
|
|
||||||
using System.Linq;
|
|
||||||
using Microsoft.CodeAnalysis;
|
|
||||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
|
||||||
|
|
||||||
namespace Svrnty.CQRS.Grpc.Generators;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Incremental source generator that generates .proto files from C# commands and queries
|
|
||||||
/// </summary>
|
|
||||||
[Generator]
|
|
||||||
public class ProtoFileSourceGenerator : IIncrementalGenerator
|
|
||||||
{
|
|
||||||
public void Initialize(IncrementalGeneratorInitializationContext context)
|
|
||||||
{
|
|
||||||
// Register a post-initialization output to generate the proto file
|
|
||||||
context.RegisterPostInitializationOutput(ctx =>
|
|
||||||
{
|
|
||||||
// Generate a placeholder - the actual proto will be generated in the source output
|
|
||||||
});
|
|
||||||
|
|
||||||
// Collect type declarations to trigger generation
|
|
||||||
// We use any type declaration as a trigger since ProtoFileGenerator scans all assemblies
|
|
||||||
var typeDeclarations = context.SyntaxProvider
|
|
||||||
.CreateSyntaxProvider(
|
|
||||||
predicate: static (s, _) => s is TypeDeclarationSyntax,
|
|
||||||
transform: static (ctx, _) => GetTypeSymbol(ctx))
|
|
||||||
.Where(static m => m is not null)
|
|
||||||
.Collect();
|
|
||||||
|
|
||||||
// Combine with compilation to have access to it
|
|
||||||
var compilationAndTypes = context.CompilationProvider.Combine(typeDeclarations);
|
|
||||||
|
|
||||||
// Generate proto file when commands/queries change
|
|
||||||
context.RegisterSourceOutput(compilationAndTypes, (spc, source) =>
|
|
||||||
{
|
|
||||||
var (compilation, types) = source;
|
|
||||||
|
|
||||||
// Note: We no longer bail out early since ProtoFileGenerator now scans all referenced assemblies
|
|
||||||
// The types from source are just a trigger - the generator will find types from all assemblies
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Get the root namespace from the compilation - this matches what GrpcGenerator does
|
|
||||||
var rootNamespace = compilation.AssemblyName ?? "Generated";
|
|
||||||
var packageName = "cqrs";
|
|
||||||
var csharpNamespace = $"{rootNamespace}.Grpc";
|
|
||||||
|
|
||||||
// Generate the proto file content
|
|
||||||
var generator = new ProtoFileGenerator(compilation);
|
|
||||||
var protoContent = generator.Generate(packageName, csharpNamespace);
|
|
||||||
|
|
||||||
// Output as an embedded resource that can be extracted
|
|
||||||
var protoFileName = "cqrs_services.proto";
|
|
||||||
|
|
||||||
// Generate a C# class that contains the proto content
|
|
||||||
// This allows build tools to extract it if needed
|
|
||||||
var csContent = $$"""
|
|
||||||
// <auto-generated />
|
|
||||||
#nullable enable
|
|
||||||
|
|
||||||
namespace Svrnty.CQRS.Grpc.Generated
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Contains the auto-generated Protocol Buffer definition
|
|
||||||
/// </summary>
|
|
||||||
internal static class GeneratedProtoFile
|
|
||||||
{
|
|
||||||
public const string FileName = "{{protoFileName}}";
|
|
||||||
|
|
||||||
public const string Content = @"{{protoContent.Replace("\"", "\"\"")}}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
|
|
||||||
spc.AddSource("GeneratedProtoFile.g.cs", csContent);
|
|
||||||
|
|
||||||
// Report that we generated the proto content
|
|
||||||
var descriptor = new DiagnosticDescriptor(
|
|
||||||
"CQRSGRPC002",
|
|
||||||
"Proto file generated",
|
|
||||||
"Generated proto file content in GeneratedProtoFile class",
|
|
||||||
"Svrnty.CQRS.Grpc",
|
|
||||||
DiagnosticSeverity.Info,
|
|
||||||
isEnabledByDefault: true);
|
|
||||||
|
|
||||||
spc.ReportDiagnostic(Diagnostic.Create(descriptor, Location.None));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// Report diagnostic if generation fails
|
|
||||||
var descriptor = new DiagnosticDescriptor(
|
|
||||||
"CQRSGRPC001",
|
|
||||||
"Proto file generation failed",
|
|
||||||
"Failed to generate proto file: {0}",
|
|
||||||
"Svrnty.CQRS.Grpc",
|
|
||||||
DiagnosticSeverity.Warning,
|
|
||||||
isEnabledByDefault: true);
|
|
||||||
|
|
||||||
spc.ReportDiagnostic(Diagnostic.Create(descriptor, Location.None, ex.Message));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static INamedTypeSymbol? GetTypeSymbol(GeneratorSyntaxContext context)
|
|
||||||
{
|
|
||||||
var typeDecl = (TypeDeclarationSyntax)context.Node;
|
|
||||||
var symbol = context.SemanticModel.GetDeclaredSymbol(typeDecl) as INamedTypeSymbol;
|
|
||||||
|
|
||||||
// Skip if it has GrpcIgnore attribute
|
|
||||||
if (symbol?.GetAttributes().Any(a => a.AttributeClass?.Name == "GrpcIgnoreAttribute") == true)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return symbol;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? GetBuildProperty(SourceProductionContext context, string propertyName)
|
|
||||||
{
|
|
||||||
// Try to get build properties from the compilation options
|
|
||||||
// This is a simplified approach - in practice, you might need analyzer config
|
|
||||||
return null; // Will use defaults
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Linq;
|
||||||
using Microsoft.CodeAnalysis;
|
using Microsoft.CodeAnalysis;
|
||||||
|
|
||||||
namespace Svrnty.CQRS.Grpc.Generators;
|
namespace Svrnty.CQRS.Grpc.Generators;
|
||||||
@@ -151,13 +152,27 @@ internal static class ProtoFileTypeMapper
|
|||||||
// Complex types (classes/records) become message types
|
// Complex types (classes/records) become message types
|
||||||
if (typeSymbol.TypeKind == TypeKind.Class || typeSymbol.TypeKind == TypeKind.Struct)
|
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
|
// Fallback
|
||||||
return "string"; // Default to string for unknown types
|
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<FaqTranslationQueryItem> 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>
|
/// <summary>
|
||||||
/// Converts C# PascalCase property name to proto snake_case field name.
|
/// Converts C# PascalCase property name to proto snake_case field name.
|
||||||
/// Uses simple conversion: add underscore before each uppercase letter (except first).
|
/// Uses simple conversion: add underscore before each uppercase letter (except first).
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
#pragma warning disable RS1035 // Do not use APIs banned for analyzers - This is an MSBuild task, not an analyzer
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using Microsoft.Build.Framework;
|
|
||||||
using Microsoft.Build.Utilities;
|
|
||||||
|
|
||||||
namespace Svrnty.CQRS.Grpc.Generators;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// MSBuild task that extracts the auto-generated proto file content from the source generator
|
|
||||||
/// output and writes it to disk so Grpc.Tools can process it
|
|
||||||
/// </summary>
|
|
||||||
public class WriteProtoFileTask : Task
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The project directory where we should look for generated files
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
public string ProjectDirectory { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The intermediate output path (typically obj/Debug/net10.0)
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
public string IntermediateOutputPath { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The output directory where the proto file should be written (typically Protos/)
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
public string OutputDirectory { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The name of the proto file to generate (typically cqrs_services.proto)
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
public string ProtoFileName { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public override bool Execute()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Log.LogMessage(MessageImportance.High,
|
|
||||||
"Svrnty.CQRS.Grpc: Extracting auto-generated proto file...");
|
|
||||||
|
|
||||||
// Look for the generated C# file containing the proto content
|
|
||||||
// Source generators output to obj/Generated, not IntermediateOutputPath/Generated
|
|
||||||
var generatedFilePath = Path.Combine(
|
|
||||||
ProjectDirectory,
|
|
||||||
"obj",
|
|
||||||
"Generated",
|
|
||||||
"Svrnty.CQRS.Grpc.Generators",
|
|
||||||
"Svrnty.CQRS.Grpc.Generators.ProtoFileSourceGenerator",
|
|
||||||
"GeneratedProtoFile.g.cs"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!File.Exists(generatedFilePath))
|
|
||||||
{
|
|
||||||
Log.LogWarning(
|
|
||||||
$"Generated proto file not found at {generatedFilePath}. " +
|
|
||||||
"The proto file may not have been generated yet. This is normal on first build.");
|
|
||||||
|
|
||||||
// Write a minimal placeholder proto file so Grpc.Tools doesn't fail
|
|
||||||
// The real content will be generated on the next build
|
|
||||||
var placeholderProto = @"syntax = ""proto3"";
|
|
||||||
|
|
||||||
option csharp_namespace = ""Generated.Grpc"";
|
|
||||||
|
|
||||||
package cqrs;
|
|
||||||
|
|
||||||
// Placeholder proto file - will be regenerated on next build
|
|
||||||
";
|
|
||||||
var placeholderOutputPath = Path.Combine(ProjectDirectory, OutputDirectory);
|
|
||||||
Directory.CreateDirectory(placeholderOutputPath);
|
|
||||||
var placeholderProtoFilePath = Path.Combine(placeholderOutputPath, ProtoFileName);
|
|
||||||
File.WriteAllText(placeholderProtoFilePath, placeholderProto);
|
|
||||||
|
|
||||||
Log.LogMessage(MessageImportance.High,
|
|
||||||
$"Svrnty.CQRS.Grpc: Wrote placeholder proto file at {placeholderProtoFilePath}. " +
|
|
||||||
"Run build again to generate the actual proto content.");
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read the generated C# file
|
|
||||||
var csContent = File.ReadAllText(generatedFilePath);
|
|
||||||
|
|
||||||
// Extract the proto content using a more robust approach
|
|
||||||
// Looking for: public const string Content = @"...";
|
|
||||||
var startMarker = "public const string Content = @\"";
|
|
||||||
var startIndex = csContent.IndexOf(startMarker);
|
|
||||||
|
|
||||||
if (startIndex < 0)
|
|
||||||
{
|
|
||||||
Log.LogError($"Could not find Content property in {generatedFilePath}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
startIndex += startMarker.Length;
|
|
||||||
|
|
||||||
// Find the closing "; - We need the LAST occurrence because the content contains escaped quotes
|
|
||||||
// The pattern is: Content = @"...content...";
|
|
||||||
// where content has "" for literal quotes
|
|
||||||
var endMarker = "\";";
|
|
||||||
|
|
||||||
// Find where the next field starts or class ends to limit our search
|
|
||||||
var nextFieldOrEnd = csContent.IndexOf("\n }", startIndex); // End of class
|
|
||||||
if (nextFieldOrEnd < 0)
|
|
||||||
{
|
|
||||||
nextFieldOrEnd = csContent.Length;
|
|
||||||
}
|
|
||||||
|
|
||||||
var endIndex = csContent.LastIndexOf(endMarker, nextFieldOrEnd, nextFieldOrEnd - startIndex);
|
|
||||||
|
|
||||||
if (endIndex < 0 || endIndex < startIndex)
|
|
||||||
{
|
|
||||||
Log.LogError($"Could not find end of Content property in {generatedFilePath}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract and unescape doubled quotes
|
|
||||||
var protoContent = csContent.Substring(startIndex, endIndex - startIndex);
|
|
||||||
protoContent = protoContent.Replace("\"\"", "\"");
|
|
||||||
|
|
||||||
Log.LogMessage(MessageImportance.High,
|
|
||||||
$"Extracted proto content length: {protoContent.Length} characters");
|
|
||||||
|
|
||||||
// Ensure output directory exists
|
|
||||||
var fullOutputPath = Path.Combine(ProjectDirectory, OutputDirectory);
|
|
||||||
Directory.CreateDirectory(fullOutputPath);
|
|
||||||
|
|
||||||
// Write the proto file
|
|
||||||
var protoFilePath = Path.Combine(fullOutputPath, ProtoFileName);
|
|
||||||
File.WriteAllText(protoFilePath, protoContent);
|
|
||||||
|
|
||||||
Log.LogMessage(MessageImportance.High,
|
|
||||||
$"Svrnty.CQRS.Grpc: Successfully generated proto file at {protoFilePath}");
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.LogErrorFromException(ex, true);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,29 +9,48 @@
|
|||||||
<!-- Determine the assembly path (different for NuGet package vs project reference) -->
|
<!-- Determine the assembly path (different for NuGet package vs project reference) -->
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<_GeneratorsAssemblyPath Condition="Exists('$(MSBuildThisFileDirectory)\..\analyzers\dotnet\cs\Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)\..\analyzers\dotnet\cs\Svrnty.CQRS.Grpc.Generators.dll</_GeneratorsAssemblyPath>
|
<_GeneratorsAssemblyPath Condition="Exists('$(MSBuildThisFileDirectory)\..\analyzers\dotnet\cs\Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)\..\analyzers\dotnet\cs\Svrnty.CQRS.Grpc.Generators.dll</_GeneratorsAssemblyPath>
|
||||||
|
<_GeneratorsAssemblyPath Condition="'$(_GeneratorsAssemblyPath)' == '' AND Exists('$(MSBuildThisFileDirectory)Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)Svrnty.CQRS.Grpc.Generators.dll</_GeneratorsAssemblyPath>
|
||||||
<_GeneratorsAssemblyPath Condition="'$(_GeneratorsAssemblyPath)' == '' AND Exists('$(MSBuildThisFileDirectory)\..\bin\Debug\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)\..\bin\Debug\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll</_GeneratorsAssemblyPath>
|
<_GeneratorsAssemblyPath Condition="'$(_GeneratorsAssemblyPath)' == '' AND Exists('$(MSBuildThisFileDirectory)\..\bin\Debug\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)\..\bin\Debug\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll</_GeneratorsAssemblyPath>
|
||||||
<_GeneratorsAssemblyPath Condition="'$(_GeneratorsAssemblyPath)' == '' AND Exists('$(MSBuildThisFileDirectory)\..\bin\Release\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)\..\bin\Release\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll</_GeneratorsAssemblyPath>
|
<_GeneratorsAssemblyPath Condition="'$(_GeneratorsAssemblyPath)' == '' AND Exists('$(MSBuildThisFileDirectory)\..\bin\Release\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)\..\bin\Release\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll</_GeneratorsAssemblyPath>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<!-- Load the WriteProtoFileTask from the generator assembly -->
|
<!-- Load the GenerateProtoFileTask from the generator assembly -->
|
||||||
<UsingTask TaskName="Svrnty.CQRS.Grpc.Generators.WriteProtoFileTask"
|
<UsingTask TaskName="Svrnty.CQRS.Grpc.Generators.GenerateProtoFileTask"
|
||||||
AssemblyFile="$(_GeneratorsAssemblyPath)"
|
AssemblyFile="$(_GeneratorsAssemblyPath)"
|
||||||
Condition="'$(_GeneratorsAssemblyPath)' != ''" />
|
Condition="'$(_GeneratorsAssemblyPath)' != ''" />
|
||||||
|
|
||||||
<!-- This target ensures the Protos directory exists before the generator runs -->
|
<!-- This target ensures the Protos directory exists -->
|
||||||
<Target Name="EnsureProtosDirectory" BeforeTargets="CoreCompile">
|
<Target Name="EnsureProtosDirectory" BeforeTargets="SvrntyGenerateProtoFile">
|
||||||
<MakeDir Directories="$(ProtoOutputDirectory)" Condition="!Exists('$(ProtoOutputDirectory)')" />
|
<MakeDir Directories="$(ProtoOutputDirectory)" Condition="!Exists('$(ProtoOutputDirectory)')" />
|
||||||
</Target>
|
</Target>
|
||||||
|
|
||||||
<!-- Extract the proto file from the source generator output BEFORE Grpc.Tools processes protos -->
|
<!--
|
||||||
<!-- Runs before CoreCompile, after source generators have been executed -->
|
Generate the proto file BEFORE Grpc.Tools processes protos and BEFORE CoreCompile.
|
||||||
<Target Name="SvrntyExtractProtoFile" BeforeTargets="CoreCompile" AfterTargets="ResolveProjectReferences" DependsOnTargets="EnsureProtosDirectory" Condition="'$(GenerateProtoFile)' == 'true'">
|
This runs AFTER ResolveAssemblyReferences so we have access to @(ReferencePath).
|
||||||
<Message Text="Svrnty.CQRS.Grpc: Extracting auto-generated proto file to $(ProtoOutputDirectory)\$(GeneratedProtoFileName)" Importance="high" />
|
|
||||||
|
|
||||||
<WriteProtoFileTask
|
Key timing:
|
||||||
|
- AfterTargets="ResolveAssemblyReferences" ensures we have all references resolved
|
||||||
|
- BeforeTargets="_gRPC_GetProtoc;CoreCompile" ensures proto is generated before:
|
||||||
|
1. Grpc.Tools compiles the proto into C# (_gRPC_GetProtoc is Grpc.Tools' entry point)
|
||||||
|
2. CoreCompile compiles the project
|
||||||
|
-->
|
||||||
|
<Target Name="SvrntyGenerateProtoFile"
|
||||||
|
BeforeTargets="_gRPC_GetProtoc;CoreCompile"
|
||||||
|
AfterTargets="ResolveAssemblyReferences"
|
||||||
|
DependsOnTargets="EnsureProtosDirectory"
|
||||||
|
Condition="'$(GenerateProtoFile)' == 'true' AND '$(_GeneratorsAssemblyPath)' != ''">
|
||||||
|
|
||||||
|
<Message Text="Svrnty.CQRS.Grpc: Generating proto file from $(MSBuildProjectName)..." Importance="high" />
|
||||||
|
<Message Text="Svrnty.CQRS.Grpc: Source files count: @(Compile->Count())" Importance="normal" />
|
||||||
|
<Message Text="Svrnty.CQRS.Grpc: References count: @(ReferencePath->Count())" Importance="normal" />
|
||||||
|
|
||||||
|
<GenerateProtoFileTask
|
||||||
ProjectDirectory="$(MSBuildProjectDirectory)"
|
ProjectDirectory="$(MSBuildProjectDirectory)"
|
||||||
IntermediateOutputPath="$(IntermediateOutputPath)"
|
|
||||||
OutputDirectory="$(ProtoOutputDirectory)"
|
OutputDirectory="$(ProtoOutputDirectory)"
|
||||||
ProtoFileName="$(GeneratedProtoFileName)" />
|
ProtoFileName="$(GeneratedProtoFileName)"
|
||||||
|
SourceFiles="@(Compile)"
|
||||||
|
References="@(ReferencePath)"
|
||||||
|
RootNamespace="$(RootNamespace)"
|
||||||
|
AssemblyName="$(AssemblyName)" />
|
||||||
</Target>
|
</Target>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ public static class CqrsBuilderExtensions
|
|||||||
{
|
{
|
||||||
Console.WriteLine("Warning: AddGrpcFromConfiguration not found. gRPC services were not registered.");
|
Console.WriteLine("Warning: AddGrpcFromConfiguration not found. gRPC services were not registered.");
|
||||||
Console.WriteLine("Make sure your project has source generators enabled and references Svrnty.CQRS.Grpc.Generators.");
|
Console.WriteLine("Make sure your project has source generators enabled and references Svrnty.CQRS.Grpc.Generators.");
|
||||||
|
DiagnoseGeneratedCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register mapping callback for automatic endpoint mapping
|
// Register mapping callback for automatic endpoint mapping
|
||||||
@@ -49,6 +50,59 @@ public static class CqrsBuilderExtensions
|
|||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void DiagnoseGeneratedCode()
|
||||||
|
{
|
||||||
|
var entryAsm = Assembly.GetEntryAssembly();
|
||||||
|
if (entryAsm == null)
|
||||||
|
{
|
||||||
|
Console.WriteLine("Diagnostic: Entry assembly is null");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"Diagnostic: Entry assembly = {entryAsm.GetName().Name}");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var allTypes = entryAsm.GetTypes();
|
||||||
|
Console.WriteLine($"Diagnostic: Total types in entry assembly = {allTypes.Length}");
|
||||||
|
|
||||||
|
var grpcTypes = allTypes
|
||||||
|
.Where(t => t.FullName?.Contains("Grpc") == true)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (grpcTypes.Any())
|
||||||
|
{
|
||||||
|
Console.WriteLine("Diagnostic: Found Grpc-related types:");
|
||||||
|
foreach (var t in grpcTypes)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" - {t.FullName} (IsClass={t.IsClass}, IsSealed={t.IsSealed}, IsPublic={t.IsPublic})");
|
||||||
|
|
||||||
|
// Check for our target method
|
||||||
|
var method = t.GetMethod("AddGrpcFromConfiguration", BindingFlags.Static | BindingFlags.Public);
|
||||||
|
if (method != null)
|
||||||
|
Console.WriteLine($" -> HAS AddGrpcFromConfiguration method!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine("Diagnostic: No Grpc-related types found. Source generator did NOT run.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (ReflectionTypeLoadException ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Diagnostic: ReflectionTypeLoadException - {ex.Message}");
|
||||||
|
foreach (var le in ex.LoaderExceptions)
|
||||||
|
{
|
||||||
|
if (le != null)
|
||||||
|
Console.WriteLine($" LoaderException: {le.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Diagnostic: Exception - {ex.GetType().Name}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static MethodInfo? FindExtensionMethod(string methodName, Type parameterType)
|
private static MethodInfo? FindExtensionMethod(string methodName, Type parameterType)
|
||||||
{
|
{
|
||||||
// Search through all loaded assemblies for the extension method
|
// Search through all loaded assemblies for the extension method
|
||||||
|
|||||||
@@ -83,8 +83,7 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
.Produces(200, queryMeta.QueryResultType)
|
.Produces(200, queryMeta.QueryResultType)
|
||||||
.Produces(400)
|
.Produces(400)
|
||||||
.Produces(401)
|
.Produces(401)
|
||||||
.Produces(403)
|
.Produces(403);
|
||||||
.WithAllowAnonymousIfAttributePresent(queryMeta.QueryType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void MapQueryGet(
|
private static void MapQueryGet(
|
||||||
@@ -147,8 +146,7 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
.Produces(200, queryMeta.QueryResultType)
|
.Produces(200, queryMeta.QueryResultType)
|
||||||
.Produces(400)
|
.Produces(400)
|
||||||
.Produces(401)
|
.Produces(401)
|
||||||
.Produces(403)
|
.Produces(403);
|
||||||
.WithAllowAnonymousIfAttributePresent(queryMeta.QueryType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IEndpointRouteBuilder MapSvrntyCommands(this IEndpointRouteBuilder endpoints, string routePrefix = "api/command")
|
public static IEndpointRouteBuilder MapSvrntyCommands(this IEndpointRouteBuilder endpoints, string routePrefix = "api/command")
|
||||||
@@ -215,8 +213,7 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
.Produces(200)
|
.Produces(200)
|
||||||
.Produces(400)
|
.Produces(400)
|
||||||
.Produces(401)
|
.Produces(401)
|
||||||
.Produces(403)
|
.Produces(403);
|
||||||
.WithAllowAnonymousIfAttributePresent(commandMeta.CommandType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void MapCommandWithResult(
|
private static void MapCommandWithResult(
|
||||||
@@ -263,17 +260,6 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
.Produces(200, commandMeta.CommandResultType)
|
.Produces(200, commandMeta.CommandResultType)
|
||||||
.Produces(400)
|
.Produces(400)
|
||||||
.Produces(401)
|
.Produces(401)
|
||||||
.Produces(403)
|
.Produces(403);
|
||||||
.WithAllowAnonymousIfAttributePresent(commandMeta.CommandType);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static RouteHandlerBuilder WithAllowAnonymousIfAttributePresent(this RouteHandlerBuilder builder, Type type)
|
|
||||||
{
|
|
||||||
var allowAnonymousAttribute = type.GetCustomAttribute<Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute>();
|
|
||||||
if (allowAnonymousAttribute != null)
|
|
||||||
{
|
|
||||||
builder.AllowAnonymous();
|
|
||||||
}
|
|
||||||
return builder;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Svrnty.CQRS.Abstractions;
|
using Svrnty.CQRS.Abstractions;
|
||||||
using Svrnty.CQRS.Discovery;
|
using Svrnty.CQRS.Discovery;
|
||||||
@@ -43,7 +44,7 @@ public class CqrsBuilder
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds a command handler to the CQRS pipeline
|
/// Adds a command handler to the CQRS pipeline
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public CqrsBuilder AddCommand<TCommand, TCommandHandler>()
|
public CqrsBuilder AddCommand<TCommand, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TCommandHandler>()
|
||||||
where TCommand : class
|
where TCommand : class
|
||||||
where TCommandHandler : class, ICommandHandler<TCommand>
|
where TCommandHandler : class, ICommandHandler<TCommand>
|
||||||
{
|
{
|
||||||
@@ -54,7 +55,7 @@ public class CqrsBuilder
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds a command handler with result to the CQRS pipeline
|
/// Adds a command handler with result to the CQRS pipeline
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public CqrsBuilder AddCommand<TCommand, TResult, TCommandHandler>()
|
public CqrsBuilder AddCommand<TCommand, TResult, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TCommandHandler>()
|
||||||
where TCommand : class
|
where TCommand : class
|
||||||
where TCommandHandler : class, ICommandHandler<TCommand, TResult>
|
where TCommandHandler : class, ICommandHandler<TCommand, TResult>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Svrnty.CQRS.Abstractions.Discovery;
|
using Svrnty.CQRS.Abstractions.Discovery;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Svrnty.CQRS.Abstractions.Discovery;
|
using Svrnty.CQRS.Abstractions.Discovery;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||||
using Svrnty.CQRS;
|
using Svrnty.CQRS;
|
||||||
|
using Svrnty.CQRS.Abstractions;
|
||||||
|
using Svrnty.CQRS.DynamicQuery;
|
||||||
using Svrnty.CQRS.FluentValidation;
|
using Svrnty.CQRS.FluentValidation;
|
||||||
using Svrnty.CQRS.Grpc;
|
using Svrnty.CQRS.Grpc;
|
||||||
using Svrnty.Sample;
|
|
||||||
using Svrnty.CQRS.MinimalApi;
|
using Svrnty.CQRS.MinimalApi;
|
||||||
using Svrnty.CQRS.DynamicQuery;
|
using Svrnty.Sample;
|
||||||
using Svrnty.CQRS.Abstractions;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using PoweredSoft.Data.Core;
|
|
||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
|
using PoweredSoft.Data.Core;
|
||||||
|
|
||||||
namespace Svrnty.Sample;
|
namespace Svrnty.Sample;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user