Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
c690ef8
feat(migrate): wire up analyze command to analyzers
philcarbone Feb 4, 2026
ba4a034
feat(migrate): wire up apply and status commands
philcarbone Feb 4, 2026
182f737
feat(migrate): add filtering and decision file support to apply command
philcarbone Feb 4, 2026
3e6cf3e
feat(migrate): add JSONC support with commented decision file template
philcarbone Feb 4, 2026
cbaf563
fix(migrate): detect [WolverineHandler] classes even without Handle m…
philcarbone Feb 4, 2026
b174827
feat(migrate): remove nested class warnings and add ignore config for…
philcarbone Feb 4, 2026
8300e37
feat(migrate): add MarkerInterfaceTransformer for IEvent/ICommand mig…
philcarbone Feb 4, 2026
6b6e0db
feat(migrate): add GlobalUsingAliasTransformer for Marten type aliases
philcarbone Feb 4, 2026
bc0d92a
fix(migrate): fix missing space bug and false positive detection
philcarbone Feb 4, 2026
831ae6e
feat(migrate): add automatic package reference management
philcarbone Feb 4, 2026
a3531eb
fix(migrate): fix package management for multiple ItemGroups and Wolv…
philcarbone Feb 4, 2026
64b7be1
fix(migrate): use SoftwareExtravaganza.Whizbang package prefix
philcarbone Feb 4, 2026
bdd5178
feat(migrate): add packages section to generated decision file template
philcarbone Feb 4, 2026
fa701ff
fix(migrate): correct package name mappings
philcarbone Feb 4, 2026
d305c79
fix(migrate): add additional packages to removal list
philcarbone Feb 4, 2026
10148ee
feat(transports): add unified transport abstraction for REST and GraphQL
philcarbone Feb 5, 2026
3743b22
fix: Update README badges to use correct workflow file
philcarbone Feb 5, 2026
eeee6e1
fix(generators): add ILRepack.targets for transport generator projects
philcarbone Feb 5, 2026
d145abf
test(middleware): add unit tests for WhizbangScopeMiddleware and Scop…
philcarbone Feb 5, 2026
0df5d25
chore(scripts): add new transport test projects to coverage script
philcarbone Feb 5, 2026
c9b5fa4
refactor(coverage): improve MutationEndpointBase coverage and add edg…
philcarbone Feb 5, 2026
2acddca
fix(ci): exclude source generator projects from SonarCloud coverage
philcarbone Feb 5, 2026
aea59d0
fix(ci): exclude tools/ from SonarCloud coverage analysis
philcarbone Feb 5, 2026
96e125a
feat(migrate): add HotChocolate and FastEndpoints package mappings
philcarbone Feb 5, 2026
14f4fbd
feat(migrate): add HotChocolate transformer for Marten integration mi…
philcarbone Feb 5, 2026
d4e8e8c
feat(migrate): add Wolverine.Http to FastEndpoints transformer
philcarbone Feb 5, 2026
9d419e0
fix(generators): StreamKeyGenerator now checks inherited properties f…
philcarbone Feb 5, 2026
069d2e5
feat(scripts): add Pack-LocalPackages.ps1 for local development
philcarbone Feb 5, 2026
86af0c5
feat(efcore): implement full LINQ support for JSONB columns
philcarbone Feb 6, 2026
340c8d2
docs(efcore): add collection LINQ and GIN index documentation
philcarbone Feb 6, 2026
0c421d9
fix(migrate): detect nested handler classes in WolverineAnalyzer
philcarbone Feb 6, 2026
0dec41e
fix: address SonarCloud quality gate issues
philcarbone Feb 6, 2026
63574b1
Merge origin/develop into feat/migrate-cli-wiring
philcarbone Feb 6, 2026
83a6d96
fix: update WhizbangScopeMiddleware for List<ScopeExtension> Extensions
philcarbone Feb 6, 2026
eac6058
fix: add timeout to Regex patterns to prevent ReDoS
philcarbone Feb 6, 2026
1f068ad
refactor: consolidate enum tests to reduce code duplication
philcarbone Feb 6, 2026
0cfe4d1
refactor: extract BaseUpsertStrategy to eliminate duplicate code
philcarbone Feb 7, 2026
5862dc0
refactor: consolidate attribute tests to reduce duplication
philcarbone Feb 7, 2026
bf464aa
refactor: consolidate TrackedGuidTests to reduce duplication
philcarbone Feb 7, 2026
4bcd738
fix(ci): exclude tests/tools from SonarCloud duplication analysis
philcarbone Feb 7, 2026
4d5885a
refactor: extract shared upsert logic to reduce duplication
philcarbone Feb 7, 2026
1ad74a8
refactor: extract default metadata pattern to reduce duplication
philcarbone Feb 7, 2026
a7a9062
fix(ci): exclude source generators from duplication analysis
philcarbone Feb 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -202,4 +202,4 @@ jobs:
uses: ./.github/workflows/nuget-publish.yml
with:
tag: v${{ needs.version.outputs.semver }}
secrets: inherit
# No secrets needed - workflow uses OIDC trusted publishing
1 change: 1 addition & 0 deletions .github/workflows/reusable-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ jobs:
/d:sonar.token="${SONAR_TOKEN}" \
/d:sonar.host.url="https://sonarcloud.io" \
/d:sonar.exclusions="**/samples/**,**/benchmarks/**,**/*Generated.cs,**/.whizbang-generated/**" \
/d:sonar.cpd.exclusions="**/tests/**,**/tools/**,**/samples/**,**/benchmarks/**,src/Whizbang.Generators/**,src/Whizbang.Generators.Shared/**,src/Whizbang.Data.EFCore.Postgres.Generators/**,src/Whizbang.Transports.HotChocolate.Generators/**,src/Whizbang.Transports.FastEndpoints.Generators/**" \
/d:sonar.coverage.exclusions="**/samples/**,**/benchmarks/**,**/tests/**,**/tools/**,src/Whizbang.Generators/**,src/Whizbang.Generators.Shared/**,src/Whizbang.Data.Schema/**,src/Whizbang.Data.EFCore.Postgres.Generators/**,src/Whizbang.Transports.HotChocolate.Generators/**,src/Whizbang.Transports.FastEndpoints.Generators/**,src/Whizbang.Hosting.Azure.ServiceBus/**,src/Whizbang.Hosting.RabbitMQ/**,src/Whizbang.Data.Dapper.Postgres/**,src/Whizbang.Transports.RabbitMQ/**,src/Whizbang.Data.EFCore.Postgres/**,src/Whizbang.Transports.AzureServiceBus/**,src/Whizbang.Data.Dapper.Sqlite/**,src/Whizbang.Data.Dapper.Custom/**,src/Whizbang.Data.EFCore.Custom/**,src/Whizbang.Data.Postgres/**,src/Whizbang.Testing/**,src/Whizbang.Observability/**,src/Whizbang.SignalR/**" \
/d:sonar.coverageReportPaths="coverage/sonarqube/SonarQube.xml" \
/d:sonar.issue.ignore.multicriteria="e1,e2,e3,e4,e5,e6" \
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/security-secrets.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,6 @@ jobs:
fetch-depth: 0 # Full history for comprehensive scanning

- name: Run TruffleHog
uses: trufflesecurity/trufflehog@main
uses: trufflesecurity/trufflehog@7635b24fd512a2e817dd3e9dd661caaf035a079d # v3.93.1
with:
extra_args: --only-verified
107 changes: 106 additions & 1 deletion ai-docs/efcore-10-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ await context.SaveChangesAsync();

## Querying JSON Properties

EF Core 10 provides rich querying capabilities for JsonB columns.
EF Core 10 provides rich querying capabilities for JsonB columns via `ComplexProperty().ToJson()`.

### Filter by JSON Properties

Expand All @@ -228,6 +228,52 @@ var seattleOrders = await context.Orders
// WHERE "ShippingAddress"->>'City' = 'Seattle'
```

### Collection Operations (Any, Contains, Count)

**EF Core 10's `ComplexProperty().ToJson()` supports full LINQ on collections:**

```csharp
// Model with collection inside JSON
public class Order {
public List<OrderItem> Items { get; set; } = [];
public List<string> Tags { get; set; } = [];
}

// Configuration
builder.ComplexProperty(o => o.Data, d => d.ToJson("data"));

// ✅ Any() with predicate - translates to server-side SQL
var highValueOrders = await context.Orders
.Where(o => o.Items.Any(i => i.Price > 100))
.ToListAsync();

// ✅ Contains() on primitive collections
var taggedOrders = await context.Orders
.Where(o => o.Tags.Contains("priority"))
.ToListAsync();

// ✅ Count() on collections
var bulkOrders = await context.Orders
.Where(o => o.Items.Count > 10)
.ToListAsync();
```

**Important**: These collection operations work **server-side** with `ComplexProperty().ToJson()` pattern. The old `Property().HasColumnType("jsonb")` pattern requires client-side evaluation for collection queries.

### String Functions

```csharp
// String Contains - server-side
var matchingOrders = await context.Orders
.Where(o => o.CustomerName.Contains("Corp"))
.ToListAsync();

// String StartsWith
var prefixOrders = await context.Orders
.Where(o => o.CustomerName.StartsWith("Acme"))
.ToListAsync();
```

### Projection from JSON

```csharp
Expand Down Expand Up @@ -267,6 +313,65 @@ var orders = await context.Orders

---

## GIN Indexes for JsonB

For efficient JsonB queries, add GIN indexes. EF Core doesn't support `HasIndex` on `ComplexProperty` directly (GitHub #28605), so create indexes via SQL.

### ✅ CORRECT - Create GIN indexes via raw SQL

```sql
-- In migration or schema initialization
CREATE INDEX idx_orders_data_gin ON orders USING gin (data);
CREATE INDEX idx_orders_metadata_gin ON orders USING gin (metadata);
CREATE INDEX idx_orders_scope_gin ON orders USING gin (scope);
```

**GIN indexes enable:**
- ✅ Efficient containment queries (`@>`, `<@`)
- ✅ Key/value lookups on JsonB data
- ✅ Path expression queries (`->`, `->>`)

**Whizbang Note**: The `EFCoreServiceRegistrationGenerator` automatically creates GIN indexes in the generated `_generatePerspectiveTablesSchema()` method.

---

## Limitations with ComplexProperty().ToJson()

### ❌ Dictionary<K,V> NOT Supported

EF Core does NOT support `Dictionary<TKey, TValue>` with `ToJson()` (GitHub #29825).

```csharp
// ❌ WON'T WORK with ToJson()
public class BadModel {
public Dictionary<string, string> Extensions { get; set; } = new();
}

// ✅ USE List of key-value objects instead
public class GoodModel {
public List<KeyValuePair> Extensions { get; set; } = [];
}

public class KeyValuePair {
public string Key { get; set; } = string.Empty;
public string? Value { get; set; }
}
```

### ❌ Collections of Structs NOT Supported

```csharp
// ❌ WON'T WORK - struct in collection
public struct Point { public int X; public int Y; }
public List<Point> Points { get; set; } = [];

// ✅ USE classes instead
public class Point { public int X { get; set; } public int Y { get; set; } }
public List<Point> Points { get; set; } = [];
```

---

## Virtual Generated Columns

PostgreSQL 18+ supports **virtual generated columns** (computed columns that aren't stored).
Expand Down
142 changes: 142 additions & 0 deletions scripts/Pack-LocalPackages.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Packs all Whizbang NuGet packages to local-packages directory.

.DESCRIPTION
Builds and packs all src/Whizbang.* projects to the local-packages directory
for local development and testing.

.PARAMETER Configuration
Build configuration (Debug or Release). Default: Debug

.PARAMETER Clean
Clean the local-packages directory before packing.

.EXAMPLE
./scripts/Pack-LocalPackages.ps1
Packs all packages in Debug configuration.

.EXAMPLE
./scripts/Pack-LocalPackages.ps1 -Configuration Release -Clean
Cleans local-packages and packs all packages in Release configuration.
#>

param(
[ValidateSet("Debug", "Release")]
[string]$Configuration = "Debug",

[switch]$Clean
)

$ErrorActionPreference = "Stop"

# Find repo root
$scriptDir = $PSScriptRoot
if (-not $scriptDir) {
$scriptDir = Get-Location
}
$repoRoot = Split-Path $scriptDir -Parent

$localPackagesDir = Join-Path $repoRoot "local-packages"
$srcDir = Join-Path $repoRoot "src"

Write-Host "Whizbang Local Package Builder" -ForegroundColor Cyan
Write-Host "===============================" -ForegroundColor Cyan
Write-Host ""
Write-Host "Repository: $repoRoot"
Write-Host "Output: $localPackagesDir"
Write-Host "Config: $Configuration"
Write-Host ""

# Clean if requested
if ($Clean) {
Write-Host "Cleaning local-packages directory..." -ForegroundColor Yellow
if (Test-Path $localPackagesDir) {
Remove-Item "$localPackagesDir/*.nupkg" -Force -ErrorAction SilentlyContinue
Remove-Item "$localPackagesDir/*.snupkg" -Force -ErrorAction SilentlyContinue
}
}

# Ensure output directory exists
if (-not (Test-Path $localPackagesDir)) {
New-Item -ItemType Directory -Path $localPackagesDir | Out-Null
}

# Find all Whizbang projects
$projects = Get-ChildItem -Path $srcDir -Filter "Whizbang.*.csproj" -Recurse |
Where-Object { $_.FullName -notmatch "\\obj\\" -and $_.FullName -notmatch "\\bin\\" }

Write-Host "Found $($projects.Count) projects to pack:" -ForegroundColor Green
$projects | ForEach-Object { Write-Host " - $($_.BaseName)" -ForegroundColor Gray }
Write-Host ""

$successCount = 0
$failCount = 0
$results = @()

foreach ($project in $projects) {
$projectName = $project.BaseName
Write-Host "Packing $projectName..." -ForegroundColor Cyan -NoNewline

$output = dotnet pack $project.FullName -o $localPackagesDir -c $Configuration 2>&1
$exitCode = $LASTEXITCODE

# Check for success (package created)
$packageCreated = $output | Select-String "Successfully created package"

if ($packageCreated) {
Write-Host " OK" -ForegroundColor Green
$successCount++
$results += [PSCustomObject]@{
Project = $projectName
Status = "Success"
}
} else {
# Check if it's just the NU5017 error (no content) but package was still created
$nu5017 = $output | Select-String "NU5017"
if ($nu5017 -and ($output | Select-String "Successfully created package")) {
Write-Host " OK (analyzer package)" -ForegroundColor Green
$successCount++
$results += [PSCustomObject]@{
Project = $projectName
Status = "Success (analyzer)"
}
} elseif ($exitCode -ne 0) {
Write-Host " FAILED" -ForegroundColor Red
$failCount++
$results += [PSCustomObject]@{
Project = $projectName
Status = "Failed"
}
# Show error details
$output | Where-Object { $_ -match "error" } | ForEach-Object {
Write-Host " $_" -ForegroundColor Red
}
} else {
Write-Host " OK" -ForegroundColor Green
$successCount++
$results += [PSCustomObject]@{
Project = $projectName
Status = "Success"
}
}
}
}

Write-Host ""
Write-Host "===============================" -ForegroundColor Cyan
Write-Host "Results: $successCount succeeded, $failCount failed" -ForegroundColor $(if ($failCount -gt 0) { "Yellow" } else { "Green" })
Write-Host ""

# List created packages
$packages = Get-ChildItem -Path $localPackagesDir -Filter "*.nupkg" | Sort-Object LastWriteTime -Descending
Write-Host "Packages in $localPackagesDir`:" -ForegroundColor Cyan
$packages | ForEach-Object {
$size = [math]::Round($_.Length / 1KB, 1)
Write-Host " $($_.Name) ($size KB)" -ForegroundColor Gray
}

if ($failCount -gt 0) {
exit 1
}
25 changes: 19 additions & 6 deletions src/Whizbang.Core/Lenses/PerspectiveMetadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,43 +5,56 @@ namespace Whizbang.Core.Lenses;
/// Contains information about the event that created/updated this perspective.
/// Stored as JSONB/JSON in metadata column.
/// </summary>
/// <remarks>
/// <para>
/// <strong>EF Core 10 Compatibility:</strong>
/// This type is a <c>class</c> (not <c>record</c>) with default values to enable
/// <c>ComplexProperty().ToJson()</c> mapping. Records have generated copy-constructors
/// that can cause NullReferenceException in EF Core query materialization.
/// </para>
/// </remarks>
/// <tests>tests/Whizbang.Data.EFCore.Postgres.Tests/OrderPerspectiveTests.cs:OrderPerspective_Update_StoresDefaultMetadataAsync</tests>
/// <tests>tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresLensQueryTests.cs</tests>
public record PerspectiveMetadata {
public class PerspectiveMetadata {
/// <summary>
/// Parameterless constructor for EF Core ComplexProperty materialization.
/// </summary>
public PerspectiveMetadata() { }

/// <summary>
/// Fully qualified event type name (e.g., "ECommerce.Contracts.Events.OrderCreatedEvent").
/// Used to filter perspectives by event source.
/// </summary>
/// <tests>tests/Whizbang.Data.EFCore.Postgres.Tests/OrderPerspectiveTests.cs:OrderPerspective_Update_StoresDefaultMetadataAsync</tests>
/// <tests>tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresLensQueryTests.cs</tests>
public required string EventType { get; init; }
public string EventType { get; set; } = string.Empty;

/// <summary>
/// Unique identifier for the event.
/// </summary>
/// <tests>tests/Whizbang.Data.EFCore.Postgres.Tests/OrderPerspectiveTests.cs:OrderPerspective_Update_StoresDefaultMetadataAsync</tests>
/// <tests>tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresLensQueryTests.cs</tests>
public required string EventId { get; init; }
public string EventId { get; set; } = string.Empty;

/// <summary>
/// When the event occurred.
/// Useful for time-range queries (e.g., orders created in last 30 days).
/// </summary>
/// <tests>tests/Whizbang.Data.EFCore.Postgres.Tests/OrderPerspectiveTests.cs:OrderPerspective_Update_StoresDefaultMetadataAsync</tests>
/// <tests>tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresLensQueryTests.cs</tests>
public required DateTime Timestamp { get; init; }
public DateTime Timestamp { get; set; }

/// <summary>
/// Correlation ID for distributed tracing.
/// Links related events across service boundaries.
/// </summary>
/// <tests>tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresLensQueryTests.cs</tests>
public string? CorrelationId { get; init; }
public string? CorrelationId { get; set; }

/// <summary>
/// Causation ID (the event that caused this event).
/// Builds event causality chains.
/// </summary>
/// <tests>tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresLensQueryTests.cs</tests>
public string? CausationId { get; init; }
public string? CausationId { get; set; }
}
12 changes: 10 additions & 2 deletions src/Whizbang.Core/Lenses/PerspectiveRow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,22 +49,30 @@ public class PerspectiveRow<TModel> where TModel : class {
/// Stored as JSONB/JSON.
/// Useful for filtering by event source or time range.
/// </summary>
/// <remarks>
/// Uses <c>set</c> accessor (not <c>init</c>) for EF Core ComplexProperty materialization compatibility.
/// </remarks>
/// <tests>tests/Whizbang.Data.EFCore.Postgres.Tests/OrderPerspectiveTests.cs:OrderPerspective_Update_StoresDefaultMetadataAsync</tests>
/// <tests>tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresLensQueryTests.cs:Query_CanFilterByMetadataFields_ReturnsMatchingRowsAsync</tests>
/// <tests>tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresLensQueryTests.cs:Query_CanProjectAcrossColumns_ReturnsAnonymousTypeAsync</tests>
/// <tests>tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresLensQueryTests.cs:Query_SupportsCombinedFilters_FromAllColumnsAsync</tests>
public required PerspectiveMetadata Metadata { get; init; }
public required PerspectiveMetadata Metadata { get; set; }

/// <summary>
/// Multi-tenancy and security scope (tenant ID, user ID, org ID).
/// Stored as JSONB/JSON.
/// Enables efficient tenant isolation queries.
/// </summary>
/// <remarks>
/// Uses <c>set</c> accessor (not <c>init</c>) for EF Core OwnsOne/ComplexProperty materialization compatibility.
/// The <c>required</c> keyword ensures the property is set during initialization while <c>set</c> allows
/// EF Core to populate the instance during query materialization.
/// </remarks>
/// <tests>tests/Whizbang.Data.EFCore.Postgres.Tests/OrderPerspectiveTests.cs:OrderPerspective_Update_StoresDefaultScopeAsync</tests>
/// <tests>tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresLensQueryTests.cs:Query_CanFilterByScopeFields_ReturnsMatchingRowsAsync</tests>
/// <tests>tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresLensQueryTests.cs:Query_CanProjectAcrossColumns_ReturnsAnonymousTypeAsync</tests>
/// <tests>tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresLensQueryTests.cs:Query_SupportsCombinedFilters_FromAllColumnsAsync</tests>
public required PerspectiveScope Scope { get; init; }
public required PerspectiveScope Scope { get; set; }

/// <summary>
/// When this row was first created.
Expand Down
Loading
Loading