diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0c425347..1d892ec7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.github/workflows/reusable-quality.yml b/.github/workflows/reusable-quality.yml index 9240167a..30496f16 100644 --- a/.github/workflows/reusable-quality.yml +++ b/.github/workflows/reusable-quality.yml @@ -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" \ diff --git a/.github/workflows/security-secrets.yml b/.github/workflows/security-secrets.yml index fc7db913..07038800 100644 --- a/.github/workflows/security-secrets.yml +++ b/.github/workflows/security-secrets.yml @@ -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 diff --git a/ai-docs/efcore-10-usage.md b/ai-docs/efcore-10-usage.md index 7dd257a4..c212eebf 100644 --- a/ai-docs/efcore-10-usage.md +++ b/ai-docs/efcore-10-usage.md @@ -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 @@ -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 Items { get; set; } = []; + public List 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 @@ -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 NOT Supported + +EF Core does NOT support `Dictionary` with `ToJson()` (GitHub #29825). + +```csharp +// ❌ WON'T WORK with ToJson() +public class BadModel { + public Dictionary Extensions { get; set; } = new(); +} + +// ✅ USE List of key-value objects instead +public class GoodModel { + public List 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 Points { get; set; } = []; + +// ✅ USE classes instead +public class Point { public int X { get; set; } public int Y { get; set; } } +public List Points { get; set; } = []; +``` + +--- + ## Virtual Generated Columns PostgreSQL 18+ supports **virtual generated columns** (computed columns that aren't stored). diff --git a/scripts/Pack-LocalPackages.ps1 b/scripts/Pack-LocalPackages.ps1 new file mode 100644 index 00000000..56e9986d --- /dev/null +++ b/scripts/Pack-LocalPackages.ps1 @@ -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 +} diff --git a/src/Whizbang.Core/Lenses/PerspectiveMetadata.cs b/src/Whizbang.Core/Lenses/PerspectiveMetadata.cs index 7b41dcce..acf21c9f 100644 --- a/src/Whizbang.Core/Lenses/PerspectiveMetadata.cs +++ b/src/Whizbang.Core/Lenses/PerspectiveMetadata.cs @@ -5,23 +5,36 @@ namespace Whizbang.Core.Lenses; /// Contains information about the event that created/updated this perspective. /// Stored as JSONB/JSON in metadata column. /// +/// +/// +/// EF Core 10 Compatibility: +/// This type is a class (not record) with default values to enable +/// ComplexProperty().ToJson() mapping. Records have generated copy-constructors +/// that can cause NullReferenceException in EF Core query materialization. +/// +/// /// tests/Whizbang.Data.EFCore.Postgres.Tests/OrderPerspectiveTests.cs:OrderPerspective_Update_StoresDefaultMetadataAsync /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresLensQueryTests.cs -public record PerspectiveMetadata { +public class PerspectiveMetadata { + /// + /// Parameterless constructor for EF Core ComplexProperty materialization. + /// + public PerspectiveMetadata() { } + /// /// Fully qualified event type name (e.g., "ECommerce.Contracts.Events.OrderCreatedEvent"). /// Used to filter perspectives by event source. /// /// tests/Whizbang.Data.EFCore.Postgres.Tests/OrderPerspectiveTests.cs:OrderPerspective_Update_StoresDefaultMetadataAsync /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresLensQueryTests.cs - public required string EventType { get; init; } + public string EventType { get; set; } = string.Empty; /// /// Unique identifier for the event. /// /// tests/Whizbang.Data.EFCore.Postgres.Tests/OrderPerspectiveTests.cs:OrderPerspective_Update_StoresDefaultMetadataAsync /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresLensQueryTests.cs - public required string EventId { get; init; } + public string EventId { get; set; } = string.Empty; /// /// When the event occurred. @@ -29,19 +42,19 @@ public record PerspectiveMetadata { /// /// tests/Whizbang.Data.EFCore.Postgres.Tests/OrderPerspectiveTests.cs:OrderPerspective_Update_StoresDefaultMetadataAsync /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresLensQueryTests.cs - public required DateTime Timestamp { get; init; } + public DateTime Timestamp { get; set; } /// /// Correlation ID for distributed tracing. /// Links related events across service boundaries. /// /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresLensQueryTests.cs - public string? CorrelationId { get; init; } + public string? CorrelationId { get; set; } /// /// Causation ID (the event that caused this event). /// Builds event causality chains. /// /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresLensQueryTests.cs - public string? CausationId { get; init; } + public string? CausationId { get; set; } } diff --git a/src/Whizbang.Core/Lenses/PerspectiveRow.cs b/src/Whizbang.Core/Lenses/PerspectiveRow.cs index 28082b7e..dc0739f1 100644 --- a/src/Whizbang.Core/Lenses/PerspectiveRow.cs +++ b/src/Whizbang.Core/Lenses/PerspectiveRow.cs @@ -49,22 +49,30 @@ public class PerspectiveRow where TModel : class { /// Stored as JSONB/JSON. /// Useful for filtering by event source or time range. /// + /// + /// Uses set accessor (not init) for EF Core ComplexProperty materialization compatibility. + /// /// tests/Whizbang.Data.EFCore.Postgres.Tests/OrderPerspectiveTests.cs:OrderPerspective_Update_StoresDefaultMetadataAsync /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresLensQueryTests.cs:Query_CanFilterByMetadataFields_ReturnsMatchingRowsAsync /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresLensQueryTests.cs:Query_CanProjectAcrossColumns_ReturnsAnonymousTypeAsync /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresLensQueryTests.cs:Query_SupportsCombinedFilters_FromAllColumnsAsync - public required PerspectiveMetadata Metadata { get; init; } + public required PerspectiveMetadata Metadata { get; set; } /// /// Multi-tenancy and security scope (tenant ID, user ID, org ID). /// Stored as JSONB/JSON. /// Enables efficient tenant isolation queries. /// + /// + /// Uses set accessor (not init) for EF Core OwnsOne/ComplexProperty materialization compatibility. + /// The required keyword ensures the property is set during initialization while set allows + /// EF Core to populate the instance during query materialization. + /// /// tests/Whizbang.Data.EFCore.Postgres.Tests/OrderPerspectiveTests.cs:OrderPerspective_Update_StoresDefaultScopeAsync /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresLensQueryTests.cs:Query_CanFilterByScopeFields_ReturnsMatchingRowsAsync /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresLensQueryTests.cs:Query_CanProjectAcrossColumns_ReturnsAnonymousTypeAsync /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresLensQueryTests.cs:Query_SupportsCombinedFilters_FromAllColumnsAsync - public required PerspectiveScope Scope { get; init; } + public required PerspectiveScope Scope { get; set; } /// /// When this row was first created. diff --git a/src/Whizbang.Core/Lenses/PerspectiveScope.cs b/src/Whizbang.Core/Lenses/PerspectiveScope.cs index ff02a516..046813e1 100644 --- a/src/Whizbang.Core/Lenses/PerspectiveScope.cs +++ b/src/Whizbang.Core/Lenses/PerspectiveScope.cs @@ -2,9 +2,42 @@ namespace Whizbang.Core.Lenses; +/// +/// Key-value extension for PerspectiveScope. +/// Used instead of Dictionary<string,string?> for EF Core ComplexProperty().ToJson() compatibility. +/// +/// +/// EF Core does NOT support Dictionary with ToJson() (GitHub #29825). +/// Using a list of key-value objects enables full LINQ support via ComplexProperty().ToJson(). +/// +public class ScopeExtension { + /// + /// Parameterless constructor for JSON deserialization. + /// + public ScopeExtension() { } + + /// + /// Creates a new scope extension with key and value. + /// + public ScopeExtension(string key, string? value) { + Key = key; + Value = value; + } + + /// + /// The extension key. + /// + public string Key { get; set; } = string.Empty; + + /// + /// The extension value. + /// + public string? Value { get; set; } +} + /// /// Multi-tenancy and security scope for perspective rows. -/// Stored as JSONB/JSON in scope column, SEPARATE from the data model. +/// Stored as JSONB/JSON in scope column using EF Core ComplexProperty().ToJson(). /// /// core-concepts/scoping#perspective-scope /// Whizbang.Core.Tests/Scoping/PerspectiveScopeTests.cs @@ -18,60 +51,120 @@ namespace Whizbang.Core.Lenses; /// ] /// }; /// -/// // Access via indexer -/// var tenant = scope["TenantId"]; // "tenant-123" -/// var custom = scope["CustomField"]; // from Extensions +/// // Access via GetValue method +/// var tenant = scope.GetValue("TenantId"); // "tenant-123" +/// var custom = scope.GetValue("CustomField"); // from Extensions /// -public record PerspectiveScope { +/// +/// +/// EF Core 10 ComplexProperty().ToJson() Support: +/// This type is designed for full LINQ query support via ComplexProperty().ToJson(): +/// +/// +/// Extensions use List<ScopeExtension> (not Dictionary) for ToJson() compatibility +/// All properties support direct LINQ queries: .Where(r => r.Scope.TenantId == "x") +/// Extension queries: .Where(r => r.Scope.Extensions.Any(e => e.Key == "x")) +/// +/// +/// Using a class (not record) allows EF Core ComplexProperty mapping. +/// +/// +public class PerspectiveScope { + /// + /// Parameterless constructor for JSON deserialization. + /// + public PerspectiveScope() { } + /// /// The tenant identifier for multi-tenancy isolation. /// - public string? TenantId { get; init; } + public string? TenantId { get; set; } /// /// The customer identifier for customer-level isolation. /// - public string? CustomerId { get; init; } + public string? CustomerId { get; set; } /// /// The user identifier for user-level isolation. /// - public string? UserId { get; init; } + public string? UserId { get; set; } /// /// The organization identifier for organization-level isolation. /// - public string? OrganizationId { get; init; } + public string? OrganizationId { get; set; } /// /// Security principals (users, groups, services) that have access to this record. + /// Stored as string values (e.g., "user:alice", "group:sales-team"). /// Enables fine-grained access control: "who can see this record?" /// Query: WHERE AllowedPrincipals OVERLAPS caller.SecurityPrincipals /// /// /// AllowedPrincipals = [ - /// SecurityPrincipalId.Group("sales-team"), - /// SecurityPrincipalId.User("manager-456") + /// SecurityPrincipalId.Group("sales-team"), // Implicitly converts to "group:sales-team" + /// SecurityPrincipalId.User("manager-456") // Implicitly converts to "user:manager-456" /// ] /// - public IReadOnlyList? AllowedPrincipals { get; init; } + /// + /// Uses List<string> which serializes to JSON array. + /// Principal filtering uses PostgreSQL's @> (containment) and ?| (array overlap) + /// operators on the raw JSONB column for efficient GIN-indexed queries. + /// has implicit conversion to/from string, so you can + /// still use the factory methods when populating this list. + /// + public List AllowedPrincipals { get; set; } = []; /// /// Additional scope values as key-value pairs. /// Enables extensibility without schema changes. /// - public IReadOnlyDictionary? Extensions { get; init; } + /// + /// Uses List<ScopeExtension> for EF Core ComplexProperty().ToJson() compatibility. + /// Dictionary is NOT supported with ToJson() (GitHub #29825). + /// Query extensions with LINQ: .Where(r => r.Scope.Extensions.Any(e => e.Key == "region")) + /// + public List Extensions { get; set; } = []; /// - /// Indexer for unified access to standard and extension properties. + /// Gets a scope value by key (searches standard properties then Extensions). /// /// The property name to access. /// The value of the property, or null if not found. - public string? this[string key] => key switch { + /// + /// Implemented as a method instead of indexer for EF Core ComplexProperty compatibility. + /// Indexers are discovered as "Item" properties by EF Core, causing mapping issues. + /// + public string? GetValue(string key) => key switch { nameof(TenantId) => TenantId, nameof(CustomerId) => CustomerId, nameof(UserId) => UserId, nameof(OrganizationId) => OrganizationId, - _ => Extensions?.GetValueOrDefault(key) + _ => Extensions.FirstOrDefault(e => e.Key == key)?.Value }; + + /// + /// Sets an extension value by key. Creates or updates the extension. + /// + /// The extension key. + /// The extension value. + public void SetExtension(string key, string? value) { + var existing = Extensions.FirstOrDefault(e => e.Key == key); + if (existing is not null) { + existing.Value = value; + } else { + Extensions.Add(new ScopeExtension(key, value)); + } + } + + /// + /// Removes an extension by key. + /// + /// The extension key to remove. + /// True if the extension was found and removed. + public bool RemoveExtension(string key) { + var existing = Extensions.FirstOrDefault(e => e.Key == key); + return existing is not null && Extensions.Remove(existing); + } } diff --git a/src/Whizbang.Core/Perspectives/FieldStorageMode.cs b/src/Whizbang.Core/Perspectives/FieldStorageMode.cs new file mode 100644 index 00000000..b2d9b51c --- /dev/null +++ b/src/Whizbang.Core/Perspectives/FieldStorageMode.cs @@ -0,0 +1,30 @@ +namespace Whizbang.Core.Perspectives; + +/// +/// Defines how physical fields are stored relative to JSONB in a perspective. +/// +/// perspectives/physical-fields +/// tests/Whizbang.Core.Tests/Perspectives/FieldStorageModeTests.cs +public enum FieldStorageMode { + /// + /// Default mode. No physical fields; all data stored in JSONB column only. + /// Backwards compatible with existing perspectives. + /// + JsonOnly = 0, + + /// + /// JSONB contains the full model (including fields marked with ). + /// Physical columns are indexed copies for query optimization. + /// Use when: You need fast indexed queries but also want full model in JSONB for flexibility. + /// Trade-off: Slight storage overhead from duplication. + /// + Extracted = 1, + + /// + /// Physical columns hold values; JSONB holds only remaining fields. + /// Avoids data duplication but model reconstruction requires reading both sources. + /// Use when: Storage efficiency is critical or physical columns dominate the model (e.g., vectors). + /// Trade-off: More complex reads, but cleaner separation and no duplication. + /// + Split = 2 +} diff --git a/src/Whizbang.Core/Perspectives/PerspectiveStorageAttribute.cs b/src/Whizbang.Core/Perspectives/PerspectiveStorageAttribute.cs new file mode 100644 index 00000000..cb9b749d --- /dev/null +++ b/src/Whizbang.Core/Perspectives/PerspectiveStorageAttribute.cs @@ -0,0 +1,60 @@ +namespace Whizbang.Core.Perspectives; + +/// +/// Configures how physical fields are stored relative to JSONB for a perspective model. +/// Applied to the model class (not the perspective class). +/// +/// +/// +/// This attribute controls the storage strategy for properties marked with +/// or . +/// +/// +/// Storage Modes: +/// +/// +/// : No physical columns; all data in JSONB (default, backwards compatible) +/// : JSONB contains full model; physical columns are indexed copies +/// : Physical columns contain marked fields; JSONB contains remainder only +/// +/// +/// If this attribute is not present on a model, it defaults to +/// for backwards compatibility. +/// +/// +/// perspectives/physical-fields +/// tests/Whizbang.Core.Tests/Perspectives/PerspectiveStorageAttributeTests.cs +/// +/// +/// // Extracted mode: JSONB contains full model, physical columns are indexed copies +/// [PerspectiveStorage(FieldStorageMode.Extracted)] +/// public record ProductDto { +/// [PhysicalField(Indexed = true)] +/// public decimal Price { get; init; } +/// public string Description { get; init; } +/// } +/// +/// // Split mode: Physical columns contain marked fields, JSONB contains remainder only +/// [PerspectiveStorage(FieldStorageMode.Split)] +/// public record ProductSearchDto { +/// [VectorField(1536)] +/// public float[]? Embedding { get; init; } // Only in physical column +/// public string Name { get; init; } // Only in JSONB +/// } +/// +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class PerspectiveStorageAttribute : Attribute { + /// + /// The storage mode for physical fields in this model. + /// + public FieldStorageMode Mode { get; } + + /// + /// Creates a perspective storage attribute with the specified mode. + /// + /// The storage mode for physical fields. + public PerspectiveStorageAttribute(FieldStorageMode mode) { + Mode = mode; + } +} diff --git a/src/Whizbang.Core/Perspectives/PhysicalFieldAttribute.cs b/src/Whizbang.Core/Perspectives/PhysicalFieldAttribute.cs new file mode 100644 index 00000000..bd67045e --- /dev/null +++ b/src/Whizbang.Core/Perspectives/PhysicalFieldAttribute.cs @@ -0,0 +1,74 @@ +namespace Whizbang.Core.Perspectives; + +/// +/// Marks a property to be stored as a physical database column in addition to or instead of JSONB. +/// Physical columns enable native database indexing, type constraints, and optimized queries. +/// The storage behavior depends on the model's setting. +/// +/// +/// +/// Use this attribute on properties that are frequently queried or filtered. +/// The source generator will create a dedicated database column for each marked property. +/// +/// +/// Storage Modes: +/// +/// +/// : Property exists in both JSONB and physical column (indexed copy) +/// : Property exists only in physical column, excluded from JSONB +/// +/// +/// perspectives/physical-fields +/// tests/Whizbang.Core.Tests/Perspectives/PhysicalFieldAttributeTests.cs +/// +/// +/// [PerspectiveStorage(FieldStorageMode.Extracted)] +/// public record ProductDto { +/// [StreamKey] +/// public Guid ProductId { get; init; } +/// +/// [PhysicalField(Indexed = true)] +/// public Guid CategoryId { get; init; } +/// +/// [PhysicalField(Indexed = true, MaxLength = 100)] +/// public string Sku { get; init; } +/// +/// // Non-physical property stays in JSONB only +/// public string Description { get; init; } +/// } +/// +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] +public sealed class PhysicalFieldAttribute : Attribute { + /// + /// Whether to create a database index on this column. + /// Defaults to false. For composite indexes, use on the model class. + /// + public bool Indexed { get; init; } + + /// + /// Whether this column should have a UNIQUE constraint. + /// Defaults to false. + /// + public bool Unique { get; init; } + + /// + /// Optional custom column name. If not specified, defaults to snake_case of property name. + /// + /// + /// [PhysicalField(ColumnName = "ext_id")] + /// public string ExternalId { get; init; } + /// // Creates column: ext_id instead of external_id + /// + public string? ColumnName { get; init; } + + /// + /// Maximum length for string columns. -1 or 0 means unlimited (TEXT type in PostgreSQL). + /// Only applicable to string properties. + /// + /// + /// Set to a positive integer to create a VARCHAR(N) column. + /// Leave at default (-1) or set to 0 for unlimited TEXT type. + /// + public int MaxLength { get; init; } = -1; +} diff --git a/src/Whizbang.Core/Perspectives/VectorDistanceMetric.cs b/src/Whizbang.Core/Perspectives/VectorDistanceMetric.cs new file mode 100644 index 00000000..6d8aaf4f --- /dev/null +++ b/src/Whizbang.Core/Perspectives/VectorDistanceMetric.cs @@ -0,0 +1,32 @@ +namespace Whizbang.Core.Perspectives; + +/// +/// Distance metrics for vector similarity search using pgvector. +/// Each metric corresponds to a PostgreSQL operator for ordering by similarity. +/// +/// perspectives/vector-fields +/// tests/Whizbang.Core.Tests/Perspectives/VectorDistanceMetricTests.cs +public enum VectorDistanceMetric { + /// + /// L2 (Euclidean) distance. Lower values indicate more similar vectors. + /// PostgreSQL operator: ]]> + /// Formula: sqrt(sum((a[i] - b[i])^2)) + /// + L2 = 0, + + /// + /// Inner product (negative). Higher values indicate more similar vectors. + /// PostgreSQL operator: ]]> + /// Note: For normalized vectors, this equals cosine similarity. + /// The result is negated for ORDER BY to work correctly (lower = more similar). + /// + InnerProduct = 1, + + /// + /// Cosine distance. Lower values indicate more similar vectors. + /// PostgreSQL operator: ]]> + /// Formula: 1 - cosine_similarity(a, b) + /// Value range: 0 (identical) to 2 (opposite). + /// + Cosine = 2 +} diff --git a/src/Whizbang.Core/Perspectives/VectorFieldAttribute.cs b/src/Whizbang.Core/Perspectives/VectorFieldAttribute.cs new file mode 100644 index 00000000..a27d59b8 --- /dev/null +++ b/src/Whizbang.Core/Perspectives/VectorFieldAttribute.cs @@ -0,0 +1,82 @@ +namespace Whizbang.Core.Perspectives; + +/// +/// Marks a float[] property as a vector column for similarity search using pgvector. +/// Enables efficient nearest-neighbor queries using various distance metrics. +/// +/// +/// +/// Requires the pgvector extension in PostgreSQL. The property must be float[] or float[]?. +/// Vector fields are always stored as physical columns (implicit ). +/// +/// +/// For optimal performance with large datasets, enable indexing with either IVFFlat or HNSW. +/// HNSW provides better recall but uses more memory; IVFFlat is faster to build. +/// +/// +/// perspectives/vector-fields +/// tests/Whizbang.Core.Tests/Perspectives/VectorFieldAttributeTests.cs +/// +/// +/// [PerspectiveStorage(FieldStorageMode.Split)] +/// public record ProductSearchDto { +/// [StreamKey] +/// public Guid ProductId { get; init; } +/// +/// // OpenAI embeddings (1536 dimensions) +/// [VectorField(1536)] +/// public float[]? ContentEmbedding { get; init; } +/// +/// // With custom settings +/// [VectorField(768, DistanceMetric = VectorDistanceMetric.Cosine, IndexType = VectorIndexType.HNSW)] +/// public float[]? TitleEmbedding { get; init; } +/// } +/// +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] +public sealed class VectorFieldAttribute : Attribute { + /// + /// The number of dimensions in the vector (e.g., 1536 for OpenAI text-embedding-ada-002). + /// Must be a positive integer. + /// + public int Dimensions { get; } + + /// + /// The distance metric for similarity queries. Defaults to . + /// + public VectorDistanceMetric DistanceMetric { get; init; } = VectorDistanceMetric.Cosine; + + /// + /// Whether to create an index on this vector column for efficient similarity search. + /// Defaults to true. Uses to determine the index algorithm. + /// + public bool Indexed { get; init; } = true; + + /// + /// The index type to use when is true. + /// Defaults to . + /// + public VectorIndexType IndexType { get; init; } = VectorIndexType.IVFFlat; + + /// + /// Number of lists for IVFFlat index. Higher values = faster queries, more memory. + /// Defaults to 100. Only applicable when is . + /// Recommended: sqrt(number of rows) for small datasets, number of rows / 1000 for large datasets. + /// + public int IndexLists { get; init; } = 100; + + /// + /// Optional custom column name. Defaults to snake_case of property name. + /// + public string? ColumnName { get; init; } + + /// + /// Creates a vector field attribute with the specified dimensions. + /// + /// Number of dimensions (e.g., 1536 for OpenAI embeddings, 768 for sentence-transformers). + /// Thrown when dimensions is less than 1. + public VectorFieldAttribute(int dimensions) { + ArgumentOutOfRangeException.ThrowIfLessThan(dimensions, 1); + Dimensions = dimensions; + } +} diff --git a/src/Whizbang.Core/Perspectives/VectorIndexType.cs b/src/Whizbang.Core/Perspectives/VectorIndexType.cs new file mode 100644 index 00000000..7e8b91ed --- /dev/null +++ b/src/Whizbang.Core/Perspectives/VectorIndexType.cs @@ -0,0 +1,31 @@ +namespace Whizbang.Core.Perspectives; + +/// +/// Index types for vector columns in pgvector. +/// Each type offers different trade-offs between build time, memory, and query performance. +/// +/// perspectives/vector-fields +/// tests/Whizbang.Core.Tests/Perspectives/VectorIndexTypeTests.cs +public enum VectorIndexType { + /// + /// No index (exact search). Use for small datasets only. + /// Performs full table scan for each query. + /// + None = 0, + + /// + /// IVFFlat (Inverted File Flat) index. + /// Good balance of build speed and query performance. + /// Requires setting the number of lists (partitions) via IndexLists parameter. + /// Lower recall than HNSW but faster build time and less memory. + /// + IVFFlat = 1, + + /// + /// HNSW (Hierarchical Navigable Small World) index. + /// Better recall and query performance than IVFFlat. + /// Slower build time and higher memory usage. + /// Recommended for production workloads with large datasets. + /// + HNSW = 2 +} diff --git a/src/Whizbang.Core/ValueObjects/TrackedGuid.cs b/src/Whizbang.Core/ValueObjects/TrackedGuid.cs index 7b4de4b6..84148240 100644 --- a/src/Whizbang.Core/ValueObjects/TrackedGuid.cs +++ b/src/Whizbang.Core/ValueObjects/TrackedGuid.cs @@ -34,6 +34,35 @@ private TrackedGuid(Guid value, GuidMetadata metadata) { /// public bool SubMillisecondPrecision => (_metadata & GuidMetadata.SourceMedo) != 0; + /// + /// Gets whether this TrackedGuid has authoritative metadata from creation. + /// True when created via NewMedo(), NewMicrosoftV7(), or NewRandom() - we know exactly how it was generated. + /// False when loaded from external sources (FromExternal, Parse, implicit conversion) where metadata is inferred. + /// + /// + /// + /// Tracking metadata is only useful at GUID creation time. Once a GUID is serialized + /// (to database, JSON, etc.) and deserialized, the tracking information is lost. + /// The deserialized GUID will have = false. + /// + /// + /// Use this property to check if metadata like + /// is authoritative before relying on it. + /// + /// + /// + /// + /// var fresh = TrackedGuid.NewMedo(); + /// Console.WriteLine(fresh.IsTracking); // true + /// Console.WriteLine(fresh.SubMillisecondPrecision); // true (authoritative) + /// + /// var loaded = TrackedGuid.FromExternal(someGuid); + /// Console.WriteLine(loaded.IsTracking); // false + /// Console.WriteLine(loaded.SubMillisecondPrecision); // false (unknown, not authoritative) + /// + /// + public bool IsTracking => (_metadata & (GuidMetadata.SourceMedo | GuidMetadata.SourceMicrosoft)) != 0; + /// /// Extracts the timestamp from a UUIDv7. /// Returns DateTimeOffset.MinValue for non-v7 UUIDs. diff --git a/src/Whizbang.Data.EFCore.Postgres.Generators/EFCorePerspectiveConfigurationGenerator.cs b/src/Whizbang.Data.EFCore.Postgres.Generators/EFCorePerspectiveConfigurationGenerator.cs index 59119dda..f729791b 100644 --- a/src/Whizbang.Data.EFCore.Postgres.Generators/EFCorePerspectiveConfigurationGenerator.cs +++ b/src/Whizbang.Data.EFCore.Postgres.Generators/EFCorePerspectiveConfigurationGenerator.cs @@ -1,10 +1,12 @@ using System; +using System.Collections.Generic; using System.Collections.Immutable; using System.Globalization; using System.Linq; using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; +using Whizbang.Generators.Shared.Models; using Whizbang.Generators.Shared.Utilities; namespace Whizbang.Data.EFCore.Postgres.Generators; @@ -201,9 +203,173 @@ private static string _deriveSchemaFromNamespace(string namespaceName) { var modelType = perspectiveForInterface.TypeArguments[0]; var tableName = "wh_per_" + _toSnakeCase(modelType.Name); + // Extract physical fields from model type + var physicalFields = _extractPhysicalFields(modelType as INamedTypeSymbol); + return new PerspectiveInfo( ModelTypeName: modelType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), - TableName: tableName + TableName: tableName, + PhysicalFields: physicalFields + ); + } + + private const string PHYSICAL_FIELD_ATTRIBUTE = "Whizbang.Core.Perspectives.PhysicalFieldAttribute"; + private const string VECTOR_FIELD_ATTRIBUTE = "Whizbang.Core.Perspectives.VectorFieldAttribute"; + + /// + /// Extracts physical field information from a model type. + /// + private static ImmutableArray _extractPhysicalFields(INamedTypeSymbol? modelType) { + if (modelType is null) { + return ImmutableArray.Empty; + } + + var physicalFields = new List(); + var properties = modelType.GetMembers() + .OfType() + .Where(p => !p.IsStatic); + + foreach (var property in properties) { + var physicalFieldAttr = property.GetAttributes() + .FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == PHYSICAL_FIELD_ATTRIBUTE); + + var vectorFieldAttr = property.GetAttributes() + .FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == VECTOR_FIELD_ATTRIBUTE); + + if (physicalFieldAttr is not null) { + var info = _extractPhysicalFieldInfo(property, physicalFieldAttr); + if (info is not null) { + physicalFields.Add(info); + } + } else if (vectorFieldAttr is not null) { + var info = _extractVectorFieldInfo(property, vectorFieldAttr); + if (info is not null) { + physicalFields.Add(info); + } + } + } + + return physicalFields.ToImmutableArray(); + } + + /// + /// Extracts PhysicalFieldInfo from a [PhysicalField] attribute. + /// + private static PhysicalFieldInfo? _extractPhysicalFieldInfo(IPropertySymbol property, AttributeData attribute) { + var propertyName = property.Name; + var typeName = property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + // Extract named arguments + bool isIndexed = false; + bool isUnique = false; + int? maxLength = null; + string? columnName = null; + + foreach (var namedArg in attribute.NamedArguments) { + switch (namedArg.Key) { + case "Indexed": + isIndexed = namedArg.Value.Value is true; + break; + case "Unique": + isUnique = namedArg.Value.Value is true; + break; + case "MaxLength": + // Handle various numeric types - -1 or 0 means "not set" (unlimited TEXT) + if (namedArg.Value.Kind == TypedConstantKind.Primitive && namedArg.Value.Value != null) { + var maxLengthVal = System.Convert.ToInt32(namedArg.Value.Value, CultureInfo.InvariantCulture); + if (maxLengthVal > 0) { + maxLength = maxLengthVal; + } + } + break; + case "ColumnName": + columnName = namedArg.Value.Value as string; + break; + } + } + + // Default column name is snake_case of property name + var finalColumnName = columnName ?? _toSnakeCase(propertyName); + + return new PhysicalFieldInfo( + PropertyName: propertyName, + ColumnName: finalColumnName, + TypeName: typeName, + IsIndexed: isIndexed, + IsUnique: isUnique, + MaxLength: maxLength, + IsVector: false, + VectorDimensions: null, + VectorDistanceMetric: null, + VectorIndexType: null, + VectorIndexLists: null + ); + } + + /// + /// Extracts PhysicalFieldInfo from a [VectorField] attribute. + /// + private static PhysicalFieldInfo? _extractVectorFieldInfo(IPropertySymbol property, AttributeData attribute) { + var propertyName = property.Name; + var typeName = property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + // Extract constructor argument (dimensions) + int? dimensions = null; + if (attribute.ConstructorArguments.Length > 0) { + dimensions = attribute.ConstructorArguments[0].Value as int?; + } + + // Extract named arguments + var distanceMetric = GeneratorVectorDistanceMetric.Cosine; // Default + var indexType = GeneratorVectorIndexType.IVFFlat; // Default + var isIndexed = true; // Vectors are indexed by default + string? columnName = null; + int? indexLists = null; + + foreach (var namedArg in attribute.NamedArguments) { + switch (namedArg.Key) { + case "DistanceMetric": + if (namedArg.Value.Value is int metricValue) { + distanceMetric = (GeneratorVectorDistanceMetric)metricValue; + } + break; + case "IndexType": + if (namedArg.Value.Value is int indexTypeValue) { + indexType = (GeneratorVectorIndexType)indexTypeValue; + } + break; + case "Indexed": + isIndexed = namedArg.Value.Value is true; + break; + case "ColumnName": + columnName = namedArg.Value.Value as string; + break; + case "IndexLists": + indexLists = namedArg.Value.Value as int?; + break; + } + } + + // Default column name is snake_case of property name + var finalColumnName = columnName ?? _toSnakeCase(propertyName); + + // If not indexed, set index type to None + if (!isIndexed) { + indexType = GeneratorVectorIndexType.None; + } + + return new PhysicalFieldInfo( + PropertyName: propertyName, + ColumnName: finalColumnName, + TypeName: typeName, + IsIndexed: isIndexed, + IsUnique: false, // Vectors are never unique + MaxLength: null, // N/A for vectors + IsVector: true, + VectorDimensions: dimensions, + VectorDistanceMetric: distanceMetric, + VectorIndexType: indexType, + VectorIndexLists: indexLists ); } @@ -233,6 +399,140 @@ private static string _toSnakeCase(string input) { return sb.ToString(); } + /// + /// Generates EF Core shadow property configurations for physical fields. + /// + private static string _generatePhysicalFieldConfigurations( + ImmutableArray physicalFields, + string tableName) { + if (physicalFields.IsEmpty) { + return string.Empty; + } + + var sb = new StringBuilder(); + + foreach (var field in physicalFields) { + // Generate shadow property configuration + var columnType = _getEFCoreColumnType(field); + + sb.AppendLine($" // Physical field: {field.PropertyName}"); + sb.AppendLine($" entity.Property<{_getCSharpType(field)}>(\"{field.ColumnName}\")"); + sb.AppendLine($" .HasColumnName(\"{field.ColumnName}\")"); + sb.AppendLine($" .HasColumnType(\"{columnType}\");"); + sb.AppendLine(); + + // Generate index if configured + if (field.IsIndexed && !field.IsVector) { + var indexName = $"ix_{tableName}_{field.ColumnName}"; + if (field.IsUnique) { + sb.AppendLine($" entity.HasIndex(\"{field.ColumnName}\")"); + sb.AppendLine($" .HasDatabaseName(\"{indexName}\")"); + sb.AppendLine($" .IsUnique();"); + } else { + sb.AppendLine($" entity.HasIndex(\"{field.ColumnName}\")"); + sb.AppendLine($" .HasDatabaseName(\"{indexName}\");"); + } + sb.AppendLine(); + } + + // Generate vector index if configured + if (field.IsVector && field.IsIndexed && field.VectorIndexType != GeneratorVectorIndexType.None) { + var indexName = $"ix_{tableName}_{field.ColumnName}_vec"; + var indexMethod = field.VectorIndexType == GeneratorVectorIndexType.HNSW ? "hnsw" : "ivfflat"; + var opClass = _getVectorOperatorClass(field.VectorDistanceMetric); + + sb.AppendLine($" entity.HasIndex(\"{field.ColumnName}\")"); + sb.AppendLine($" .HasDatabaseName(\"{indexName}\")"); + sb.AppendLine($" .HasMethod(\"{indexMethod}\")"); + sb.AppendLine($" .HasOperators(\"{opClass}\");"); + sb.AppendLine(); + } + } + + return sb.ToString(); + } + + /// + /// Gets the EF Core column type for a physical field. + /// + private static string _getEFCoreColumnType(PhysicalFieldInfo field) { + if (field.IsVector && field.VectorDimensions.HasValue) { + return $"vector({field.VectorDimensions.Value})"; + } + + // Normalize the type name + var typeName = field.TypeName + .Replace("global::", "") + .TrimEnd('?'); + + return typeName switch { + "System.String" or "string" => field.MaxLength.HasValue + ? $"varchar({field.MaxLength.Value})" + : "text", + "System.Int32" or "int" => "integer", + "System.Int64" or "long" => "bigint", + "System.Int16" or "short" => "smallint", + "System.Decimal" or "decimal" => "decimal", + "System.Double" or "double" => "double precision", + "System.Single" or "float" => "real", + "System.Boolean" or "bool" => "boolean", + "System.Guid" => "uuid", + "System.DateTime" => "timestamp", + "System.DateTimeOffset" => "timestamptz", + "System.DateOnly" => "date", + "System.TimeOnly" => "time", + _ => "text" // Default fallback + }; + } + + /// + /// Gets the C# type for a physical field shadow property. + /// + private static string _getCSharpType(PhysicalFieldInfo field) { + if (field.IsVector) { + return "Pgvector.Vector"; + } + + // Return the normalized type + var typeName = field.TypeName + .Replace("global::", ""); + + // Handle nullable types + if (typeName.EndsWith("?", StringComparison.Ordinal)) { + return typeName; + } + + // Non-nullable value types that could be null in database + return typeName switch { + "System.Int32" or "int" => "int?", + "System.Int64" or "long" => "long?", + "System.Int16" or "short" => "short?", + "System.Decimal" or "decimal" => "decimal?", + "System.Double" or "double" => "double?", + "System.Single" or "float" => "float?", + "System.Boolean" or "bool" => "bool?", + "System.Guid" => "System.Guid?", + "System.DateTime" => "System.DateTime?", + "System.DateTimeOffset" => "System.DateTimeOffset?", + "System.DateOnly" => "System.DateOnly?", + "System.TimeOnly" => "System.TimeOnly?", + "System.String" or "string" => "string?", + _ => $"{typeName}?" + }; + } + + /// + /// Gets the pgvector operator class for a distance metric. + /// + private static string _getVectorOperatorClass(GeneratorVectorDistanceMetric? metric) { + return metric switch { + GeneratorVectorDistanceMetric.L2 => "vector_l2_ops", + GeneratorVectorDistanceMetric.InnerProduct => "vector_ip_ops", + GeneratorVectorDistanceMetric.Cosine => "vector_cosine_ops", + _ => "vector_cosine_ops" // Default to cosine + }; + } + /// /// Generates the ModelBuilder extension method with EF Core configuration for all Whizbang entities. /// Includes: discovered PerspectiveRow<TModel> entities + fixed entities (Inbox, Outbox, EventStore). @@ -312,10 +612,15 @@ private static void _generateModelBuilderExtension( // Replace placeholders // Use provided schema, or default to "public" if not specified var effectiveSchema = schema ?? "public"; + + // Generate physical field configurations + var physicalFieldConfigs = _generatePhysicalFieldConfigurations(perspective.PhysicalFields, perspective.TableName); + var config = snippet .Replace("__MODEL_TYPE__", perspective.ModelTypeName) .Replace("__TABLE_NAME__", perspective.TableName) - .Replace("__SCHEMA__", effectiveSchema); + .Replace("__SCHEMA__", effectiveSchema) + .Replace("__PHYSICAL_FIELD_CONFIGS__", physicalFieldConfigs); perspectiveConfigs.AppendLine(TemplateUtilities.IndentCode(config, " ")); perspectiveConfigs.AppendLine(); diff --git a/src/Whizbang.Data.EFCore.Postgres.Generators/EFCoreServiceRegistrationGenerator.cs b/src/Whizbang.Data.EFCore.Postgres.Generators/EFCoreServiceRegistrationGenerator.cs index 844e84fb..3c61cbb0 100644 --- a/src/Whizbang.Data.EFCore.Postgres.Generators/EFCoreServiceRegistrationGenerator.cs +++ b/src/Whizbang.Data.EFCore.Postgres.Generators/EFCoreServiceRegistrationGenerator.cs @@ -923,10 +923,23 @@ string schema sb.AppendLine($");"); sb.AppendLine(); - // Add index on created_at for time-based queries (matches EF Core configuration) + // Add B-tree index on created_at for time-based queries (matches EF Core configuration) sb.AppendLine($"CREATE INDEX IF NOT EXISTS idx_{perspective.TableName.Replace("wh_per_", "")}_created_at"); sb.AppendLine($" ON {schema}.{perspective.TableName} (created_at);"); sb.AppendLine(); + + // Add GIN indexes on JSONB columns for full LINQ query support + // GIN indexes enable efficient containment queries, key/value lookups, and path expressions + var shortName = perspective.TableName.Replace("wh_per_", ""); + sb.AppendLine($"CREATE INDEX IF NOT EXISTS idx_{shortName}_data_gin"); + sb.AppendLine($" ON {schema}.{perspective.TableName} USING gin (data);"); + sb.AppendLine(); + sb.AppendLine($"CREATE INDEX IF NOT EXISTS idx_{shortName}_metadata_gin"); + sb.AppendLine($" ON {schema}.{perspective.TableName} USING gin (metadata);"); + sb.AppendLine(); + sb.AppendLine($"CREATE INDEX IF NOT EXISTS idx_{shortName}_scope_gin"); + sb.AppendLine($" ON {schema}.{perspective.TableName} USING gin (scope);"); + sb.AppendLine(); } // Escape for C# verbatim string diff --git a/src/Whizbang.Data.EFCore.Postgres.Generators/PerspectiveInfo.cs b/src/Whizbang.Data.EFCore.Postgres.Generators/PerspectiveInfo.cs index 14d43ac0..aa9253d3 100644 --- a/src/Whizbang.Data.EFCore.Postgres.Generators/PerspectiveInfo.cs +++ b/src/Whizbang.Data.EFCore.Postgres.Generators/PerspectiveInfo.cs @@ -1,3 +1,6 @@ +using System.Collections.Immutable; +using Whizbang.Generators.Shared.Models; + namespace Whizbang.Data.EFCore.Postgres.Generators; /// @@ -16,7 +19,9 @@ namespace Whizbang.Data.EFCore.Postgres.Generators; /// /// Fully qualified model type name (e.g., "global::MyApp.Orders.OrderSummary") /// PostgreSQL table name for this perspective +/// Array of physical fields discovered on the model internal sealed record PerspectiveInfo( string ModelTypeName, - string TableName + string TableName, + ImmutableArray PhysicalFields ); diff --git a/src/Whizbang.Data.EFCore.Postgres.Generators/Templates/Snippets/EFCoreSnippets.cs b/src/Whizbang.Data.EFCore.Postgres.Generators/Templates/Snippets/EFCoreSnippets.cs index 9774f545..d0b510d7 100644 --- a/src/Whizbang.Data.EFCore.Postgres.Generators/Templates/Snippets/EFCoreSnippets.cs +++ b/src/Whizbang.Data.EFCore.Postgres.Generators/Templates/Snippets/EFCoreSnippets.cs @@ -29,24 +29,21 @@ public void PerspectiveEntityConfiguration(ModelBuilder modelBuilder) { // Primary key entity.Property(e => e.Id).HasColumnName("id"); - // JSONB columns (PostgreSQL with Npgsql) - // Property().HasColumnType("jsonb") enables POCO JSON mapping for custom types - // Requires ConfigureJsonOptions() THEN EnableDynamicJson() on NpgsqlDataSourceBuilder (order matters!) - // JSON serialization uses source-generated converters (WhizbangJsonContext) - entity.Property(e => e.Data) - .HasColumnName("data") - .HasColumnType("jsonb") - .IsRequired(); - - entity.Property(e => e.Metadata) - .HasColumnName("metadata") - .HasColumnType("jsonb") - .IsRequired(); - - entity.Property(e => e.Scope) - .HasColumnName("scope") - .HasColumnType("jsonb") - .IsRequired(); + // JSONB columns - EF Core 10 ComplexProperty().ToJson() for full LINQ support + // + // All columns use ComplexProperty().ToJson() for: + // - Full LINQ query support (Where, OrderBy, Select on nested properties) + // - Collection queries (Any, Contains, Count) + // - String methods (Contains, StartsWith) + // + // Prerequisites (implemented in Whizbang): + // 1. WhizbangId types store Guid directly (not TrackedGuid) for simple EF Core construction + // 2. PerspectiveScope.Extensions uses List instead of Dictionary + // 3. Custom principal filtering translators for AllowedPrincipals queries + // + entity.ComplexProperty(e => e.Data, d => d.ToJson("data")); + entity.ComplexProperty(e => e.Metadata, m => m.ToJson("metadata")); + entity.ComplexProperty(e => e.Scope, s => s.ToJson("scope")); // System fields entity.Property(e => e.CreatedAt).HasColumnName("created_at").IsRequired(); @@ -56,12 +53,16 @@ public void PerspectiveEntityConfiguration(ModelBuilder modelBuilder) { // Indexes entity.HasIndex(e => e.CreatedAt); - // GIN index on scope JSONB for efficient principal filtering - // Uses jsonb_path_ops for optimized @>, ?|, and containment queries - // This enables efficient "AllowedPrincipals ?| ARRAY[...]" queries - entity.HasIndex(e => e.Scope) - .HasMethod("GIN") - .HasOperators("jsonb_path_ops"); + // GIN indexes for JSONB columns are automatically created by EFCoreServiceRegistrationGenerator + // in the generated _generatePerspectiveTablesSchema() method. GIN indexes enable: + // - Efficient containment queries (@>, <@) + // - Key/value lookups on JSONB data + // - Path expression queries (->, ->>) + // EF Core doesn't support HasIndex on ComplexProperty directly (GitHub #28605), + // so we generate the indexes via SQL in the schema creation script. + + // Physical fields (shadow properties for database columns) +__PHYSICAL_FIELD_CONFIGS__ }); #endregion } diff --git a/src/Whizbang.Data.EFCore.Postgres/BaseUpsertStrategy.cs b/src/Whizbang.Data.EFCore.Postgres/BaseUpsertStrategy.cs new file mode 100644 index 00000000..c8b53447 --- /dev/null +++ b/src/Whizbang.Data.EFCore.Postgres/BaseUpsertStrategy.cs @@ -0,0 +1,132 @@ +using Microsoft.EntityFrameworkCore; +using Whizbang.Core.Lenses; +using Whizbang.Core.Perspectives; + +namespace Whizbang.Data.EFCore.Postgres; + +/// +/// Base class for upsert strategies containing shared implementation logic. +/// +public abstract class BaseUpsertStrategy : IDbUpsertStrategy { + /// + /// When true, clears the change tracker after save to prevent entity tracking conflicts. + /// Override in derived classes based on provider requirements. + /// + protected virtual bool ClearChangeTrackerAfterSave => false; + + /// + public Task UpsertPerspectiveRowAsync( + DbContext context, + string tableName, + Guid id, + TModel model, + PerspectiveMetadata metadata, + PerspectiveScope scope, + CancellationToken cancellationToken = default) + where TModel : class => + _upsertCoreAsync(context, id, model, metadata, scope, null, cancellationToken); + + /// + public Task UpsertPerspectiveRowWithPhysicalFieldsAsync( + DbContext context, + string tableName, + Guid id, + TModel model, + PerspectiveMetadata metadata, + PerspectiveScope scope, + IDictionary physicalFieldValues, + CancellationToken cancellationToken = default) + where TModel : class => + _upsertCoreAsync(context, id, model, metadata, scope, physicalFieldValues, cancellationToken); + + private async Task _upsertCoreAsync( + DbContext context, + Guid id, + TModel model, + PerspectiveMetadata metadata, + PerspectiveScope scope, + IDictionary? physicalFieldValues, + CancellationToken cancellationToken) + where TModel : class { + var existingRow = await context.Set>() + .FirstOrDefaultAsync(r => r.Id == id, cancellationToken); + + var now = DateTime.UtcNow; + + var row = existingRow == null + ? _createNewRow(id, model, metadata, scope, now) + : _createUpdatedRow(existingRow, model, metadata, scope, now); + + if (existingRow != null) { + context.Set>().Remove(existingRow); + } + + context.Set>().Add(row); + + if (physicalFieldValues != null) { + var entry = context.Entry(row); + foreach (var (columnName, value) in physicalFieldValues) { + entry.Property(columnName).CurrentValue = value; + } + } + + await context.SaveChangesAsync(cancellationToken); + + if (ClearChangeTrackerAfterSave) { + context.ChangeTracker.Clear(); + } + } + + private static PerspectiveRow _createNewRow( + Guid id, TModel model, PerspectiveMetadata metadata, PerspectiveScope scope, DateTime now) + where TModel : class => + new() { + Id = id, + Data = model, + Metadata = CloneMetadata(metadata), + Scope = CloneScope(scope), + CreatedAt = now, + UpdatedAt = now, + Version = 1 + }; + + private static PerspectiveRow _createUpdatedRow( + PerspectiveRow existing, TModel model, PerspectiveMetadata metadata, PerspectiveScope scope, DateTime now) + where TModel : class => + new() { + Id = existing.Id, + Data = model, + Metadata = CloneMetadata(metadata), + Scope = CloneScope(scope), + CreatedAt = existing.CreatedAt, + UpdatedAt = now, + Version = existing.Version + 1 + }; + + /// + /// Creates a clone of PerspectiveMetadata to avoid EF Core tracking issues. + /// + protected static PerspectiveMetadata CloneMetadata(PerspectiveMetadata metadata) { + return new PerspectiveMetadata { + EventType = metadata.EventType, + EventId = metadata.EventId, + Timestamp = metadata.Timestamp, + CorrelationId = metadata.CorrelationId, + CausationId = metadata.CausationId + }; + } + + /// + /// Creates a clone of PerspectiveScope to avoid EF Core tracking issues. + /// + protected static PerspectiveScope CloneScope(PerspectiveScope scope) { + return new PerspectiveScope { + TenantId = scope.TenantId, + CustomerId = scope.CustomerId, + UserId = scope.UserId, + OrganizationId = scope.OrganizationId, + AllowedPrincipals = [.. scope.AllowedPrincipals], + Extensions = [.. scope.Extensions] + }; + } +} diff --git a/src/Whizbang.Data.EFCore.Postgres/EFCorePostgresPerspectiveStore.cs b/src/Whizbang.Data.EFCore.Postgres/EFCorePostgresPerspectiveStore.cs index c51630c3..cc868dc1 100644 --- a/src/Whizbang.Data.EFCore.Postgres/EFCorePostgresPerspectiveStore.cs +++ b/src/Whizbang.Data.EFCore.Postgres/EFCorePostgresPerspectiveStore.cs @@ -23,6 +23,12 @@ public class EFCorePostgresPerspectiveStore : IPerspectiveStore private readonly string _tableName; private readonly IDbUpsertStrategy _upsertStrategy; + private static PerspectiveMetadata _defaultMetadata => new() { + EventType = "Unknown", + EventId = Guid.NewGuid().ToString(), + Timestamp = DateTime.UtcNow + }; + /// /// Initializes a new instance of . /// @@ -61,26 +67,9 @@ public EFCorePostgresPerspectiveStore( /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresPerspectiveStoreTests.cs:UpsertAsync_WhenRecordExists_UpdatesExistingRecordAsync /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresPerspectiveStoreTests.cs:UpsertAsync_IncrementsVersionNumber_OnEachUpdateAsync /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresPerspectiveStoreTests.cs:UpsertAsync_UpdatesUpdatedAtTimestamp_OnUpdateAsync - public async Task UpsertAsync(Guid streamId, TModel model, CancellationToken cancellationToken = default) { - // Use default metadata for generic upserts - var metadata = new PerspectiveMetadata { - EventType = "Unknown", - EventId = Guid.NewGuid().ToString(), - Timestamp = DateTime.UtcNow - }; - - var scope = new PerspectiveScope(); - - // Delegate to strategy for optimal database-specific implementation - await _upsertStrategy.UpsertPerspectiveRowAsync( - _context, - _tableName, - streamId, - model, - metadata, - scope, - cancellationToken); - } + public Task UpsertAsync(Guid streamId, TModel model, CancellationToken cancellationToken = default) => + _upsertStrategy.UpsertPerspectiveRowAsync( + _context, _tableName, streamId, model, _defaultMetadata, new PerspectiveScope(), cancellationToken); /// /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresPerspectiveStoreTests.cs:GetByPartitionKeyAsync_WhenRecordExists_ReturnsModelAsync @@ -107,34 +96,14 @@ await _upsertStrategy.UpsertPerspectiveRowAsync( /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresPerspectiveStoreTests.cs:UpsertByPartitionKeyAsync_WhenRecordDoesNotExist_CreatesNewRecordAsync /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresPerspectiveStoreTests.cs:UpsertByPartitionKeyAsync_WhenRecordExists_UpdatesExistingRecordAsync /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresPerspectiveStoreTests.cs:UpsertByPartitionKeyAsync_IncrementsVersionNumber_OnEachUpdateAsync - public async Task UpsertByPartitionKeyAsync( + public Task UpsertByPartitionKeyAsync( TPartitionKey partitionKey, TModel model, CancellationToken cancellationToken = default) - where TPartitionKey : notnull { - - // Convert partition key to Guid for storage - var partitionGuid = _convertPartitionKeyToGuid(partitionKey); - - // Use default metadata for generic upserts - var metadata = new PerspectiveMetadata { - EventType = "Unknown", - EventId = Guid.NewGuid().ToString(), - Timestamp = DateTime.UtcNow - }; - - var scope = new PerspectiveScope(); - - // Delegate to strategy for optimal database-specific implementation - await _upsertStrategy.UpsertPerspectiveRowAsync( - _context, - _tableName, - partitionGuid, - model, - metadata, - scope, - cancellationToken); - } + where TPartitionKey : notnull => + _upsertStrategy.UpsertPerspectiveRowAsync( + _context, _tableName, _convertPartitionKeyToGuid(partitionKey), model, + _defaultMetadata, new PerspectiveScope(), cancellationToken); /// /// Converts a partition key of any type to a Guid for storage. @@ -163,6 +132,27 @@ private static Guid _convertPartitionKeyToGuid(TPartitionKey part return new Guid(hashOther); } + /// + /// Insert or update a read model with physical field values. + /// Physical fields are stored in shadow properties configured by the EF Core model. + /// Creates new row if id doesn't exist, updates if it does. + /// Automatically increments version for optimistic concurrency. + /// + /// Stream ID (aggregate ID) to store model for + /// The read model data to store (full model or filtered for Split mode) + /// Dictionary of column name to value for physical fields + /// Cancellation token + /// tests/Whizbang.Data.EFCore.Postgres.Tests/PhysicalFieldUpsertStrategyTests.cs:UpsertWithPhysicalFields_WhenRecordDoesNotExist_CreatesShadowPropertiesAsync + /// tests/Whizbang.Data.EFCore.Postgres.Tests/PhysicalFieldUpsertStrategyTests.cs:UpsertWithPhysicalFields_WhenRecordExists_UpdatesShadowPropertiesAsync + public Task UpsertWithPhysicalFieldsAsync( + Guid streamId, + TModel model, + IDictionary physicalFieldValues, + CancellationToken cancellationToken = default) => + _upsertStrategy.UpsertPerspectiveRowWithPhysicalFieldsAsync( + _context, _tableName, streamId, model, _defaultMetadata, new PerspectiveScope(), + physicalFieldValues, cancellationToken); + /// public async Task FlushAsync(CancellationToken cancellationToken = default) { // For EF Core, ensure all tracked changes are committed to the database diff --git a/src/Whizbang.Data.EFCore.Postgres/IDbUpsertStrategy.cs b/src/Whizbang.Data.EFCore.Postgres/IDbUpsertStrategy.cs index 50eda368..bae657db 100644 --- a/src/Whizbang.Data.EFCore.Postgres/IDbUpsertStrategy.cs +++ b/src/Whizbang.Data.EFCore.Postgres/IDbUpsertStrategy.cs @@ -39,4 +39,32 @@ Task UpsertPerspectiveRowAsync( PerspectiveScope scope, CancellationToken cancellationToken = default) where TModel : class; + + /// + /// Performs an atomic upsert (insert or update) of a perspective row with physical field values. + /// Physical fields are stored in shadow properties configured by the EF Core model. + /// + /// The model type stored in the perspective + /// The EF Core DbContext + /// The table name for the perspective rows + /// The unique identifier for the perspective row + /// The model data to store in the JSONB column + /// Metadata about the event that created/updated this row + /// Multi-tenancy and security scope information + /// Dictionary of column name to value for physical fields + /// Cancellation token + /// Task representing the asynchronous operation + /// tests/Whizbang.Data.EFCore.Postgres.Tests/PhysicalFieldUpsertStrategyTests.cs:UpsertWithPhysicalFields_WhenRecordDoesNotExist_CreatesShadowPropertiesAsync + /// tests/Whizbang.Data.EFCore.Postgres.Tests/PhysicalFieldUpsertStrategyTests.cs:UpsertWithPhysicalFields_WhenRecordExists_UpdatesShadowPropertiesAsync + /// tests/Whizbang.Data.EFCore.Postgres.Tests/PhysicalFieldUpsertStrategyTests.cs:UpsertWithPhysicalFields_PostgresStrategy_SetsShadowPropertiesAsync + Task UpsertPerspectiveRowWithPhysicalFieldsAsync( + DbContext context, + string tableName, + Guid id, + TModel model, + PerspectiveMetadata metadata, + PerspectiveScope scope, + IDictionary physicalFieldValues, + CancellationToken cancellationToken = default) + where TModel : class; } diff --git a/src/Whizbang.Data.EFCore.Postgres/InMemoryUpsertStrategy.cs b/src/Whizbang.Data.EFCore.Postgres/InMemoryUpsertStrategy.cs index 3a59357a..e5659bcb 100644 --- a/src/Whizbang.Data.EFCore.Postgres/InMemoryUpsertStrategy.cs +++ b/src/Whizbang.Data.EFCore.Postgres/InMemoryUpsertStrategy.cs @@ -1,7 +1,3 @@ -using Microsoft.EntityFrameworkCore; -using Whizbang.Core.Lenses; -using Whizbang.Core.Perspectives; - namespace Whizbang.Data.EFCore.Postgres; /// @@ -14,93 +10,7 @@ namespace Whizbang.Data.EFCore.Postgres; /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresPerspectiveStoreTests.cs:UpsertAsync_UpdatesUpdatedAtTimestamp_OnUpdateAsync /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresPerspectiveStoreTests.cs:Constructor_WithNullContext_ThrowsArgumentNullExceptionAsync /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresPerspectiveStoreTests.cs:Constructor_WithNullTableName_ThrowsArgumentNullExceptionAsync -public class InMemoryUpsertStrategy : IDbUpsertStrategy { - - /// - /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresPerspectiveStoreTests.cs:UpsertAsync_WhenRecordDoesNotExist_CreatesNewRecordAsync - /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresPerspectiveStoreTests.cs:UpsertAsync_WhenRecordExists_UpdatesExistingRecordAsync - /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresPerspectiveStoreTests.cs:UpsertAsync_IncrementsVersionNumber_OnEachUpdateAsync - /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresPerspectiveStoreTests.cs:UpsertAsync_UpdatesUpdatedAtTimestamp_OnUpdateAsync - public async Task UpsertPerspectiveRowAsync( - DbContext context, - string tableName, - Guid id, - TModel model, - PerspectiveMetadata metadata, - PerspectiveScope scope, - CancellationToken cancellationToken = default) - where TModel : class { - - var existingRow = await context.Set>() - .FirstOrDefaultAsync(r => r.Id == id, cancellationToken); - - var now = DateTime.UtcNow; - - if (existingRow == null) { - // Insert new record - var newRow = new PerspectiveRow { - Id = id, - Data = model, - Metadata = _cloneMetadata(metadata), - Scope = _cloneScope(scope), - CreatedAt = now, - UpdatedAt = now, - Version = 1 - }; - - context.Set>().Add(newRow); - } else { - // Update existing record - remove and re-add to handle owned types properly - context.Set>().Remove(existingRow); - - var updatedRow = new PerspectiveRow { - Id = existingRow.Id, - Data = model, - Metadata = _cloneMetadata(metadata), - Scope = _cloneScope(scope), - CreatedAt = existingRow.CreatedAt, // Preserve creation time - UpdatedAt = now, - Version = existingRow.Version + 1 - }; - - context.Set>().Add(updatedRow); - } - - await context.SaveChangesAsync(cancellationToken); - } - - /// - /// Creates a clone of PerspectiveMetadata to avoid EF Core tracking issues. - /// - /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresPerspectiveStoreTests.cs:UpsertAsync_WhenRecordDoesNotExist_CreatesNewRecordAsync - /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresPerspectiveStoreTests.cs:UpsertAsync_WhenRecordExists_UpdatesExistingRecordAsync - /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresPerspectiveStoreTests.cs:UpsertAsync_IncrementsVersionNumber_OnEachUpdateAsync - /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresPerspectiveStoreTests.cs:UpsertAsync_UpdatesUpdatedAtTimestamp_OnUpdateAsync - private static PerspectiveMetadata _cloneMetadata(PerspectiveMetadata metadata) { - return new PerspectiveMetadata { - EventType = metadata.EventType, - EventId = metadata.EventId, - Timestamp = metadata.Timestamp, - CorrelationId = metadata.CorrelationId, - CausationId = metadata.CausationId - }; - } - - /// - /// Creates a clone of PerspectiveScope to avoid EF Core tracking issues. - /// - /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresPerspectiveStoreTests.cs:UpsertAsync_WhenRecordDoesNotExist_CreatesNewRecordAsync - /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresPerspectiveStoreTests.cs:UpsertAsync_WhenRecordExists_UpdatesExistingRecordAsync - /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresPerspectiveStoreTests.cs:UpsertAsync_IncrementsVersionNumber_OnEachUpdateAsync - /// tests/Whizbang.Data.EFCore.Postgres.Tests/EFCorePostgresPerspectiveStoreTests.cs:UpsertAsync_UpdatesUpdatedAtTimestamp_OnUpdateAsync - private static PerspectiveScope _cloneScope(PerspectiveScope scope) { - return new PerspectiveScope { - TenantId = scope.TenantId, - CustomerId = scope.CustomerId, - UserId = scope.UserId, - OrganizationId = scope.OrganizationId, - AllowedPrincipals = scope.AllowedPrincipals?.ToList(), - Extensions = scope.Extensions?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) - }; - } +public class InMemoryUpsertStrategy : BaseUpsertStrategy { + // InMemory provider doesn't need change tracker clearing + // All behavior is inherited from BaseUpsertStrategy } diff --git a/src/Whizbang.Data.EFCore.Postgres/PostgresUpsertStrategy.cs b/src/Whizbang.Data.EFCore.Postgres/PostgresUpsertStrategy.cs index 276a97c8..a989bdf4 100644 --- a/src/Whizbang.Data.EFCore.Postgres/PostgresUpsertStrategy.cs +++ b/src/Whizbang.Data.EFCore.Postgres/PostgresUpsertStrategy.cs @@ -1,7 +1,3 @@ -using Microsoft.EntityFrameworkCore; -using Whizbang.Core.Lenses; -using Whizbang.Core.Perspectives; - namespace Whizbang.Data.EFCore.Postgres; /// @@ -32,91 +28,11 @@ namespace Whizbang.Data.EFCore.Postgres; /// those can use native ON CONFLICT for true single-roundtrip upserts. /// /// -/// No tests found -public class PostgresUpsertStrategy : IDbUpsertStrategy { - - /// - /// No tests found - public async Task UpsertPerspectiveRowAsync( - DbContext context, - string tableName, - Guid id, - TModel model, - PerspectiveMetadata metadata, - PerspectiveScope scope, - CancellationToken cancellationToken = default) - where TModel : class { - - var existingRow = await context.Set>() - .FirstOrDefaultAsync(r => r.Id == id, cancellationToken); - - var now = DateTime.UtcNow; - - if (existingRow == null) { - // Insert new record - var newRow = new PerspectiveRow { - Id = id, - Data = model, - Metadata = _cloneMetadata(metadata), - Scope = _cloneScope(scope), - CreatedAt = now, - UpdatedAt = now, - Version = 1 - }; - - context.Set>().Add(newRow); - } else { - // Update existing record - remove and re-add to handle owned types properly - context.Set>().Remove(existingRow); - - var updatedRow = new PerspectiveRow { - Id = existingRow.Id, - Data = model, - Metadata = _cloneMetadata(metadata), - Scope = _cloneScope(scope), - CreatedAt = existingRow.CreatedAt, // Preserve creation time - UpdatedAt = now, - Version = existingRow.Version + 1 - }; - - context.Set>().Add(updatedRow); - } - - await context.SaveChangesAsync(cancellationToken); - - // CRITICAL: Clear change tracker to prevent entity tracking conflicts - // The remove-then-add pattern leaves the deleted entity tracked by EF Core. - // When the same DbContext is reused (scoped per worker loop), subsequent upserts - // with the same ID will fail with tracking conflicts unless we clear the tracker. - context.ChangeTracker.Clear(); - } - - /// - /// Creates a clone of PerspectiveMetadata to avoid EF Core tracking issues. - /// - /// No tests found - private static PerspectiveMetadata _cloneMetadata(PerspectiveMetadata metadata) { - return new PerspectiveMetadata { - EventType = metadata.EventType, - EventId = metadata.EventId, - Timestamp = metadata.Timestamp, - CorrelationId = metadata.CorrelationId, - CausationId = metadata.CausationId - }; - } - +/// tests/Whizbang.Data.EFCore.Postgres.Tests/PhysicalFieldUpsertStrategyTests.cs:UpsertWithPhysicalFields_PostgresStrategy_SetsShadowPropertiesAsync +public class PostgresUpsertStrategy : BaseUpsertStrategy { /// - /// Creates a clone of PerspectiveScope to avoid EF Core tracking issues. + /// PostgreSQL requires clearing the change tracker to prevent entity tracking conflicts + /// when the same DbContext is reused across multiple upsert operations. /// - /// No tests found - private static PerspectiveScope _cloneScope(PerspectiveScope scope) { - return new PerspectiveScope { - TenantId = scope.TenantId, - CustomerId = scope.CustomerId, - UserId = scope.UserId, - OrganizationId = scope.OrganizationId, - AllowedPrincipals = scope.AllowedPrincipals?.ToList(), - Extensions = scope.Extensions?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) - }; - } + protected override bool ClearChangeTrackerAfterSave => true; } diff --git a/src/Whizbang.Data.EFCore.Postgres/PrincipalFilterExtensions.cs b/src/Whizbang.Data.EFCore.Postgres/PrincipalFilterExtensions.cs index 229f659e..4a934a11 100644 --- a/src/Whizbang.Data.EFCore.Postgres/PrincipalFilterExtensions.cs +++ b/src/Whizbang.Data.EFCore.Postgres/PrincipalFilterExtensions.cs @@ -2,48 +2,34 @@ using Microsoft.EntityFrameworkCore; using Whizbang.Core.Lenses; using Whizbang.Core.Security; -using Whizbang.Data.EFCore.Postgres.Functions; namespace Whizbang.Data.EFCore.Postgres; /// /// Extension methods for filtering perspective rows by security principals. -/// Uses PostgreSQL JSONB containment operator (@>) with GIN index optimization. +/// Uses EF Core 10 ComplexProperty().ToJson() with native LINQ support. /// /// core-concepts/security#principal-filtering /// Whizbang.Data.EFCore.Postgres.Tests/PrincipalFilterExtensionsTests.cs /// /// -/// This extension provides principal-based row filtering using EF Core and PostgreSQL JSONB. -/// The AllowedPrincipals field in PerspectiveScope is stored as a JSONB array of strings. +/// This extension provides principal-based row filtering using EF Core's native +/// LINQ support for ComplexProperty().ToJson() columns. AllowedPrincipals is a +/// List<string> within the Scope complex property. /// /// -/// Query Pattern: Uses OR'd @> (containment) checks that PostgreSQL -/// optimizes using GIN index bitmap scans: -/// -/// WHERE scope @> '{"AllowedPrincipals":["user:alice"]}' -/// OR scope @> '{"AllowedPrincipals":["group:sales"]}' -/// -- PostgreSQL uses bitmap index scans with GIN, combining results efficiently -/// +/// Query Pattern: Uses standard LINQ Any/Contains which EF Core 10 +/// translates to efficient PostgreSQL JSONB queries. /// /// /// Performance: A GIN index on the scope column is required for optimal -/// performance. The generated EF Core configuration includes: -/// entity.HasIndex(e => e.Scope).HasMethod("GIN").HasOperators("jsonb_path_ops"); -/// With this index, PostgreSQL performs bitmap index scans for each OR condition and -/// combines them efficiently, making even 100+ principal checks performant. +/// performance. EF Core translates LINQ collection queries to native PostgreSQL JSONB operations. /// /// public static class PrincipalFilterExtensions { - /// - /// Threshold for switching from OR'd containment checks to array overlap. - /// For small sets, OR'd checks have similar performance and simpler SQL. - /// For larger sets, array overlap is significantly more efficient. - /// - private const int ARRAY_OVERLAP_THRESHOLD = 10; /// /// Filters perspective rows where AllowedPrincipals contains any of the caller's principals. - /// Uses PostgreSQL's ?| (array overlap) operator for efficient filtering with GIN index. + /// Uses EF Core 10's native LINQ support for ComplexProperty().ToJson(). /// /// The perspective model type. /// The queryable to filter. @@ -62,10 +48,9 @@ public static class PrincipalFilterExtensions { /// /// /// - /// Uses OR'd @> containment checks for all principal counts. - /// The GIN index with jsonb_path_ops enables efficient bitmap index scans, - /// making even 100+ principal checks performant via bitmap OR operations. - /// For large principal sets (> 10), the query is tagged for diagnostics. + /// Uses LINQ Any() on AllowedPrincipals collection, which EF Core 10 translates + /// to efficient PostgreSQL JSONB queries. A GIN index on the scope column + /// provides optimal query performance. /// public static IQueryable> FilterByPrincipals( this IQueryable> query, @@ -77,73 +62,12 @@ public static IQueryable> FilterByPrincipals( return query.Where(r => false); } - // For small sets, use OR'd containment checks (simpler SQL, similar performance) - // For larger sets, use array overlap operator (much more efficient) - if (callerPrincipals.Count <= ARRAY_OVERLAP_THRESHOLD) { - return _filterByPrincipalsContainment(query, callerPrincipals); - } - - // Use PostgreSQL array overlap: scope->'AllowedPrincipals' ?| ARRAY[...] - // This generates a single efficient index scan instead of N OR'd conditions - return _filterByPrincipalsArrayOverlap(query, callerPrincipals); - } - - /// - /// Internal method using OR'd containment checks for small principal sets. - /// - private static IQueryable> _filterByPrincipalsContainment( - IQueryable> query, - IReadOnlySet callerPrincipals) - where TModel : class { - - // Build OR expression: (AllowedPrincipals contains P1) OR (AllowedPrincipals contains P2) ... - // Each containment check uses EF.Functions.JsonContains which translates to @> - Expression, bool>>? combinedPredicate = null; - - foreach (var principal in callerPrincipals) { - // Create JSON containment check using the full Scope object - // This translates to: scope @> '{"AllowedPrincipals": ["user:alice"]}' - // SecurityPrincipalId values are simple strings (user:xxx, group:xxx, svc:xxx) - // without special characters that need JSON escaping - var containmentJson = _buildContainmentJson(principal.Value); - - Expression, bool>> predicate = r => - EF.Functions.JsonContains(r.Scope, containmentJson); - - combinedPredicate = combinedPredicate == null - ? predicate - : _combineOr(combinedPredicate, predicate); - } - - return query.Where(combinedPredicate!); - } - - /// - /// Internal method for large principal sets. Uses PostgreSQL's ?| array overlap operator. - /// - /// - /// Uses a custom EF Core function translator that maps to PostgreSQL's ?| operator: - /// - /// -- Single efficient query with array overlap - /// WHERE scope->'AllowedPrincipals' ?| ARRAY['user:alice', 'group:sales', ...] - /// - /// This is much more efficient than N OR'd containment checks for large principal sets. - /// Requires to be - /// called during DbContext configuration. - /// - private static IQueryable> _filterByPrincipalsArrayOverlap( - IQueryable> query, - IReadOnlySet callerPrincipals) - where TModel : class { - - // Convert principals to string array for the ?| operator - var principalValues = callerPrincipals.Select(p => p.Value).ToArray(); + // Convert principals to string list for LINQ Contains + var principalValues = callerPrincipals.Select(p => p.Value).ToList(); - // Use custom AllowedPrincipalsContainsAny function which translates to: - // scope->'AllowedPrincipals' ?| ARRAY['user:alice', 'group:sales', ...] - return query - .Where(r => EF.Functions.AllowedPrincipalsContainsAny(r.Scope, principalValues)) - .TagWith("PrincipalFilter:ArrayOverlap"); + // Use EF Core 10's native LINQ support for ComplexProperty().ToJson() + // This translates to efficient PostgreSQL JSONB array operations + return query.Where(r => r.Scope.AllowedPrincipals.Any(p => principalValues.Contains(p))); } /// @@ -164,8 +88,8 @@ private static IQueryable> _filterByPrincipalsArrayOverla /// /// /// - /// Uses the same optimization as : - /// GIN index with jsonb_path_ops enables efficient bitmap index scans for OR'd conditions. + /// Uses EF Core 10's native LINQ support for ComplexProperty().ToJson(). + /// Combines user ownership check with principal matching in a single OR expression. /// public static IQueryable> FilterByUserOrPrincipals( this IQueryable> query, @@ -173,92 +97,27 @@ public static IQueryable> FilterByUserOrPrincipals callerPrincipals) where TModel : class { - // Build: (UserId = currentUser) OR (AllowedPrincipals contains any caller principal) - Expression, bool>>? combinedPredicate = null; - - // Add user match predicate if userId is provided - if (!string.IsNullOrEmpty(userId)) { - Expression, bool>> userPredicate = r => - r.Scope.UserId == userId; - combinedPredicate = userPredicate; - } - - // Add principal predicates using GIN-optimized containment checks - if (callerPrincipals != null && callerPrincipals.Count > 0) { - foreach (var principal in callerPrincipals) { - // Create JSON containment check using the full Scope object - // GIN index with jsonb_path_ops enables efficient bitmap index scans - var containmentJson = _buildContainmentJson(principal.Value); + // Convert principals to string list for LINQ Contains + var principalValues = callerPrincipals?.Select(p => p.Value).ToList() ?? []; - Expression, bool>> predicate = r => - EF.Functions.JsonContains(r.Scope, containmentJson); + // Handle empty inputs + var hasUserId = !string.IsNullOrEmpty(userId); + var hasPrincipals = principalValues.Count > 0; - combinedPredicate = combinedPredicate == null - ? predicate - : _combineOr(combinedPredicate, predicate); - } - } - - // If no predicates, return empty result - if (combinedPredicate == null) { + if (!hasUserId && !hasPrincipals) { + // No filter criteria = no access (return empty result) return query.Where(r => false); } - // Tag query for large principal sets - var result = query.Where(combinedPredicate); - if (callerPrincipals != null && callerPrincipals.Count > ARRAY_OVERLAP_THRESHOLD) { - result = result.TagWith("PrincipalFilter:UserOrPrincipals:LargeSet"); - } - return result; - } - - /// - /// Builds a JSON containment string for AllowedPrincipals check. - /// AOT compatible - no reflection or dynamic code generation. - /// - /// The principal value (e.g., "user:alice") - /// JSON string like {"AllowedPrincipals":["user:alice"]} - private static string _buildContainmentJson(string principalValue) { - // SecurityPrincipalId values follow a simple pattern: type:identifier - // e.g., "user:alice", "group:sales-team", "svc:api-gateway" - // These don't contain JSON-special characters (quotes, backslashes, etc.) - // If the pattern ever changes, this would need proper JSON escaping - return $"{{\"AllowedPrincipals\":[\"{principalValue}\"]}}"; - } - - /// - /// Combines two predicate expressions using OR logic. - /// - private static Expression> _combineOr( - Expression> left, - Expression> right) { - - var parameter = Expression.Parameter(typeof(T), "r"); - - // Replace parameter in both expressions - var leftBody = new ParameterReplacer(left.Parameters[0], parameter).Visit(left.Body); - var rightBody = new ParameterReplacer(right.Parameters[0], parameter).Visit(right.Body); - - // Combine with OR - var body = Expression.OrElse(leftBody, rightBody); - - return Expression.Lambda>(body, parameter); - } - - /// - /// Expression visitor that replaces one parameter with another. - /// - private sealed class ParameterReplacer : ExpressionVisitor { - private readonly ParameterExpression _oldParameter; - private readonly ParameterExpression _newParameter; - - public ParameterReplacer(ParameterExpression oldParameter, ParameterExpression newParameter) { - _oldParameter = oldParameter; - _newParameter = newParameter; - } - - protected override Expression VisitParameter(ParameterExpression node) { - return node == _oldParameter ? _newParameter : base.VisitParameter(node); + // Build OR predicate: (UserId = currentUser) OR (AllowedPrincipals contains any caller principal) + if (hasUserId && hasPrincipals) { + return query.Where(r => + r.Scope.UserId == userId || + r.Scope.AllowedPrincipals.Any(p => principalValues.Contains(p))); + } else if (hasUserId) { + return query.Where(r => r.Scope.UserId == userId); + } else { + return query.Where(r => r.Scope.AllowedPrincipals.Any(p => principalValues.Contains(p))); } } } diff --git a/src/Whizbang.Data.EFCore.Postgres/QueryTranslation/PhysicalFieldExpressionVisitor.cs b/src/Whizbang.Data.EFCore.Postgres/QueryTranslation/PhysicalFieldExpressionVisitor.cs new file mode 100644 index 00000000..496b9c61 --- /dev/null +++ b/src/Whizbang.Data.EFCore.Postgres/QueryTranslation/PhysicalFieldExpressionVisitor.cs @@ -0,0 +1,114 @@ +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reflection; +using Microsoft.EntityFrameworkCore; +using Whizbang.Core.Lenses; + +namespace Whizbang.Data.EFCore.Postgres.QueryTranslation; + +/// +/// Expression visitor that rewrites r.Data.PropertyName to EF.Property(r, "shadow_property") +/// for properties registered as physical fields. +/// +/// +/// +/// This visitor enables unified query syntax by intercepting member access expressions +/// on the Data property of PerspectiveRow<TModel> and redirecting physical field +/// access to the corresponding shadow property. +/// +/// +/// Before transformation: +/// +/// .Where(r => r.Data.Price >= 50.00m) +/// +/// +/// +/// After transformation: +/// +/// .Where(r => EF.Property<decimal>(r, "price") >= 50.00m) +/// +/// +/// +/// perspectives/physical-fields +/// tests/Whizbang.Data.EFCore.Postgres.Tests/UnifiedQuerySyntaxTests.cs +[SuppressMessage("AOT", "IL2060:MakeGenericMethod can break functionality when AOT compiling", Justification = "EF Core data layer inherently uses reflection for query translation")] +[SuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "EF Core data layer inherently uses reflection for query translation")] +public class PhysicalFieldExpressionVisitor : ExpressionVisitor { + // Cache the EF.Property method info + private static readonly MethodInfo _efPropertyMethod = + typeof(EF).GetMethod(nameof(EF.Property))!; + + /// + /// Visits a member access expression and rewrites physical field access. + /// + protected override Expression VisitMember(MemberExpression node) { + // We're looking for: r.Data.PropertyName + // Where r is PerspectiveRow, Data is the JSONB property, PropertyName is on TModel + + // Check if this is a property access + if (node.Member is not PropertyInfo propertyInfo) { + return base.VisitMember(node); + } + + // Check if the expression is accessing a property through .Data + // i.e., node.Expression is also a MemberExpression for "Data" + if (node.Expression is MemberExpression dataAccess && + dataAccess.Member.Name == "Data" && + _isPerspectiveRowType(dataAccess.Expression?.Type)) { + + // Get the model type (TModel from PerspectiveRow) + var modelType = propertyInfo.DeclaringType; + if (modelType == null) { + return base.VisitMember(node); + } + + // Check if this property is registered as a physical field + if (PhysicalFieldRegistry.TryGetMapping(modelType, propertyInfo.Name, out var mapping)) { + // Rewrite to: EF.Property(r, "shadow_property_name") + // Where r is the PerspectiveRow parameter (dataAccess.Expression) + + var entityExpression = dataAccess.Expression; + if (entityExpression == null) { + return base.VisitMember(node); + } + + // Visit the entity expression in case it needs transformation + var visitedEntity = Visit(entityExpression); + + // Create EF.Property(entity, "shadow_property_name") + var efPropertyGeneric = _efPropertyMethod.MakeGenericMethod(propertyInfo.PropertyType); + var columnNameConstant = Expression.Constant(mapping.ShadowPropertyName); + + return Expression.Call(null, efPropertyGeneric, visitedEntity, columnNameConstant); + } + } + + return base.VisitMember(node); + } + + /// + /// Checks if a type is PerspectiveRow<T> or derives from it. + /// + private static bool _isPerspectiveRowType(Type? type) { + if (type == null) { + return false; + } + + // Check if it's a generic type based on PerspectiveRow<> + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(PerspectiveRow<>)) { + return true; + } + + // Check base types + var baseType = type.BaseType; + while (baseType != null) { + if (baseType.IsGenericType && baseType.GetGenericTypeDefinition() == typeof(PerspectiveRow<>)) { + return true; + } + + baseType = baseType.BaseType; + } + + return false; + } +} diff --git a/src/Whizbang.Data.EFCore.Postgres/QueryTranslation/PhysicalFieldQueryInterceptor.cs b/src/Whizbang.Data.EFCore.Postgres/QueryTranslation/PhysicalFieldQueryInterceptor.cs new file mode 100644 index 00000000..c00689e6 --- /dev/null +++ b/src/Whizbang.Data.EFCore.Postgres/QueryTranslation/PhysicalFieldQueryInterceptor.cs @@ -0,0 +1,36 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Whizbang.Data.EFCore.Postgres.QueryTranslation; + +/// +/// Query expression interceptor that transforms r.Data.PropertyName access +/// to shadow property access for registered physical fields. +/// +/// +/// +/// This interceptor integrates into +/// EF Core's query pipeline using the IQueryExpressionInterceptor interface +/// (available in EF Core 7.0+). +/// +/// +/// Register this interceptor when configuring DbContext: +/// +/// optionsBuilder.AddInterceptors(new PhysicalFieldQueryInterceptor()); +/// +/// +/// +/// perspectives/physical-fields +/// tests/Whizbang.Data.EFCore.Postgres.Tests/UnifiedQuerySyntaxTests.cs +public class PhysicalFieldQueryInterceptor : IQueryExpressionInterceptor { + private readonly PhysicalFieldExpressionVisitor _visitor = new(); + + /// + /// Called by EF Core to allow transformation of the query expression tree + /// before compilation. + /// + public Expression QueryCompilationStarting(Expression queryExpression, QueryExpressionEventData eventData) { + // Apply our visitor to transform r.Data.PropertyName to EF.Property(r, "column") + return _visitor.Visit(queryExpression); + } +} diff --git a/src/Whizbang.Data.EFCore.Postgres/QueryTranslation/PhysicalFieldRegistry.cs b/src/Whizbang.Data.EFCore.Postgres/QueryTranslation/PhysicalFieldRegistry.cs new file mode 100644 index 00000000..f9d98601 --- /dev/null +++ b/src/Whizbang.Data.EFCore.Postgres/QueryTranslation/PhysicalFieldRegistry.cs @@ -0,0 +1,118 @@ +using System.Collections.Concurrent; + +namespace Whizbang.Data.EFCore.Postgres.QueryTranslation; + +/// +/// Runtime registry mapping model properties to physical column names. +/// Source generators populate this at startup via Register calls. +/// Used by to redirect +/// r.Data.PropertyName queries to physical columns. +/// +/// +/// +/// This registry enables unified query syntax where users write: +/// +/// .Where(r => r.Data.Price >= 20.00m) // Looks like JSONB access +/// +/// But the query translator redirects to physical column access: +/// +/// WHERE price >= 20.00 // Uses indexed physical column +/// +/// +/// +/// Thread-safe for concurrent registration and lookup. +/// Designed for startup initialization by generated code. +/// +/// +/// perspectives/physical-fields +/// tests/Whizbang.Data.EFCore.Postgres.Tests/QueryTranslation/PhysicalFieldRegistryTests.cs +public static class PhysicalFieldRegistry { + private static readonly ConcurrentDictionary<(Type ModelType, string PropertyName), PhysicalFieldMapping> _mappings = new(); + + /// + /// Registers a physical field mapping for a model property. + /// Called by generated code at startup. + /// + /// The model type containing the property + /// The property name (e.g., "Price") + /// The physical column name (e.g., "price") + /// Optional shadow property name if different from column + public static void Register(string propertyName, string columnName, string? shadowPropertyName = null) { + Register(typeof(TModel), propertyName, columnName, shadowPropertyName); + } + + /// + /// Registers a physical field mapping for a model property. + /// Non-generic version for dynamic registration. + /// + /// The model type containing the property + /// The property name (e.g., "Price") + /// The physical column name (e.g., "price") + /// Optional shadow property name if different from column + public static void Register(Type modelType, string propertyName, string columnName, string? shadowPropertyName = null) { + ArgumentNullException.ThrowIfNull(modelType); + ArgumentException.ThrowIfNullOrWhiteSpace(propertyName); + ArgumentException.ThrowIfNullOrWhiteSpace(columnName); + + var mapping = new PhysicalFieldMapping(columnName, shadowPropertyName ?? columnName); + _mappings[(modelType, propertyName)] = mapping; + } + + /// + /// Attempts to get the column mapping for a model property. + /// + /// The model type + /// The property name + /// The mapping if found + /// True if the property is a registered physical field + public static bool TryGetMapping(Type modelType, string propertyName, out PhysicalFieldMapping mapping) { + return _mappings.TryGetValue((modelType, propertyName), out mapping); + } + + /// + /// Checks if a property is registered as a physical field. + /// + /// The model type + /// The property name + /// True if the property is a physical field + public static bool IsPhysicalField(Type modelType, string propertyName) { + return _mappings.ContainsKey((modelType, propertyName)); + } + + /// + /// Gets all registered mappings for a model type. + /// + /// The model type + /// Dictionary of property name to mapping + public static IReadOnlyDictionary GetMappingsForModel(Type modelType) { + ArgumentNullException.ThrowIfNull(modelType); + + var result = new Dictionary(); + foreach (var ((type, propertyName), mapping) in _mappings) { + if (type == modelType) { + result[propertyName] = mapping; + } + } + + return result; + } + + /// + /// Clears all registered mappings. For testing purposes only. + /// + public static void Clear() { + _mappings.Clear(); + } + + /// + /// Gets the count of registered mappings. For diagnostics. + /// + public static int Count => _mappings.Count; +} + +/// +/// Represents the mapping from a model property to a physical column. +/// +/// The physical database column name +/// The EF Core shadow property name (may differ from column) +public readonly record struct PhysicalFieldMapping(string ColumnName, string ShadowPropertyName); diff --git a/src/Whizbang.Data.EFCore.Postgres/QueryTranslation/WhizbangDbContextOptionsBuilderExtensions.cs b/src/Whizbang.Data.EFCore.Postgres/QueryTranslation/WhizbangDbContextOptionsBuilderExtensions.cs new file mode 100644 index 00000000..f3b9093f --- /dev/null +++ b/src/Whizbang.Data.EFCore.Postgres/QueryTranslation/WhizbangDbContextOptionsBuilderExtensions.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore; + +namespace Whizbang.Data.EFCore.Postgres.QueryTranslation; + +/// +/// Extension methods for configuring Whizbang physical field query translation on DbContext. +/// +public static class WhizbangDbContextOptionsBuilderExtensions { + /// + /// Enables Whizbang physical field query translation. + /// When enabled, queries using r.Data.PropertyName will automatically use + /// physical columns for properties registered in . + /// + /// The options builder + /// The options builder for chaining + /// + /// + /// Usage: + /// + /// var optionsBuilder = new DbContextOptionsBuilder<MyDbContext>(); + /// optionsBuilder + /// .UseNpgsql(connectionString) + /// .UseWhizbangPhysicalFields(); + /// + /// + /// + /// Before using this extension, ensure physical fields are registered: + /// + /// PhysicalFieldRegistry.Register<ProductModel>("Price", "price"); + /// + /// + /// + /// perspectives/physical-fields + /// tests/Whizbang.Data.EFCore.Postgres.Tests/UnifiedQuerySyntaxTests.cs + public static DbContextOptionsBuilder UseWhizbangPhysicalFields( + this DbContextOptionsBuilder optionsBuilder) { + + ArgumentNullException.ThrowIfNull(optionsBuilder); + + // Add the query interceptor that transforms r.Data.PropertyName to EF.Property() + optionsBuilder.AddInterceptors(new PhysicalFieldQueryInterceptor()); + + return optionsBuilder; + } + + /// + /// Enables Whizbang physical field query translation for typed DbContextOptionsBuilder. + /// + /// The DbContext type + /// The options builder + /// The options builder for chaining + public static DbContextOptionsBuilder UseWhizbangPhysicalFields( + this DbContextOptionsBuilder optionsBuilder) + where TContext : DbContext { + + ArgumentNullException.ThrowIfNull(optionsBuilder); + + ((DbContextOptionsBuilder)optionsBuilder).UseWhizbangPhysicalFields(); + + return optionsBuilder; + } +} diff --git a/src/Whizbang.Generators.Shared/Models/PhysicalFieldInfo.cs b/src/Whizbang.Generators.Shared/Models/PhysicalFieldInfo.cs new file mode 100644 index 00000000..8c49a3fc --- /dev/null +++ b/src/Whizbang.Generators.Shared/Models/PhysicalFieldInfo.cs @@ -0,0 +1,63 @@ +namespace Whizbang.Generators.Shared.Models; + +/// +/// Value type containing information about a discovered physical field on a perspective model. +/// This record uses value equality which is critical for incremental generator performance. +/// Physical fields are marked with [PhysicalField] or [VectorField] attributes. +/// +/// Name of the property on the model +/// Database column name (snake_case, or custom from attribute) +/// Fully qualified type name of the property +/// Whether an index should be created +/// Whether a unique constraint should be applied +/// Maximum length for string fields (VARCHAR constraint) +/// Whether this is a vector field (float[]) +/// Dimension count for vector fields +/// Distance metric for vector index (L2=0, InnerProduct=1, Cosine=2) +/// Index type for vectors (None=0, IVFFlat=1, HNSW=2) +/// Number of lists for IVFFlat index +/// perspectives/physical-fields +/// tests/Whizbang.Generators.Tests/Models/PhysicalFieldInfoTests.cs +public sealed record PhysicalFieldInfo( + string PropertyName, + string ColumnName, + string TypeName, + bool IsIndexed, + bool IsUnique, + int? MaxLength, + bool IsVector, + int? VectorDimensions, + GeneratorVectorDistanceMetric? VectorDistanceMetric, + GeneratorVectorIndexType? VectorIndexType, + int? VectorIndexLists +); + +/// +/// Distance metric for pgvector index operations. +/// Mirrors Whizbang.Core.Perspectives.VectorDistanceMetric for generator use. +/// +public enum GeneratorVectorDistanceMetric { + /// L2 (Euclidean) distance - uses <-> operator + L2 = 0, + + /// Inner product (negative) - uses <#> operator + InnerProduct = 1, + + /// Cosine distance - uses <=> operator + Cosine = 2 +} + +/// +/// Index type for pgvector columns. +/// Mirrors Whizbang.Core.Perspectives.VectorIndexType for generator use. +/// +public enum GeneratorVectorIndexType { + /// No index - exact (sequential) search + None = 0, + + /// IVFFlat - good balance of speed and accuracy + IVFFlat = 1, + + /// HNSW - better recall, more memory + HNSW = 2 +} diff --git a/src/Whizbang.Generators/DiagnosticDescriptors.cs b/src/Whizbang.Generators/DiagnosticDescriptors.cs index 8b9b8d17..9d2b6cdc 100644 --- a/src/Whizbang.Generators/DiagnosticDescriptors.cs +++ b/src/Whizbang.Generators/DiagnosticDescriptors.cs @@ -428,4 +428,78 @@ public static class DiagnosticDescriptors { isEnabledByDefault: true, description: "Raw Guid parameters lose metadata about precision and ordering. Consider using IWhizbangId or a strongly-typed ID generated with [WhizbangId]." ); + + // ======================================== + // Physical Field Diagnostics (WHIZ801-809) + // ======================================== + + /// + /// WHIZ801: Error - [VectorField] can only be applied to float[] properties. + /// + /// diagnostics/whiz801 + public static readonly DiagnosticDescriptor VectorFieldInvalidType = new( + id: "WHIZ801", + title: "VectorField Invalid Type", + messageFormat: "[VectorField] on {0}.{1} requires property type float[] or Single[]", + category: CATEGORY, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "The [VectorField] attribute can only be applied to properties of type float[] (System.Single[])." + ); + + /// + /// WHIZ802: Error - [VectorField] dimensions must be positive. + /// + /// diagnostics/whiz802 + public static readonly DiagnosticDescriptor VectorFieldInvalidDimensions = new( + id: "WHIZ802", + title: "VectorField Invalid Dimensions", + messageFormat: "[VectorField] on {0}.{1} has invalid dimensions {2}. Dimensions must be a positive integer.", + category: CATEGORY, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "The [VectorField] attribute requires a positive integer for dimensions." + ); + + /// + /// WHIZ803: Warning - [PhysicalField] on complex type may not benefit from indexing. + /// + /// diagnostics/whiz803 + public static readonly DiagnosticDescriptor PhysicalFieldComplexType = new( + id: "WHIZ803", + title: "PhysicalField Complex Type", + messageFormat: "[PhysicalField] on {0}.{1} with type {2} may not benefit from indexing. Consider using simple types.", + category: CATEGORY, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Physical fields work best with simple types (string, int, decimal, bool, Guid). Complex types may not benefit from database indexing." + ); + + /// + /// WHIZ805: Warning - Split mode with no [PhysicalField] is equivalent to JsonOnly. + /// + /// diagnostics/whiz805 + public static readonly DiagnosticDescriptor SplitModeNoPhysicalFields = new( + id: "WHIZ805", + title: "Split Mode No Physical Fields", + messageFormat: "Perspective '{0}' uses Split mode but has no [PhysicalField] or [VectorField] attributes. This is equivalent to JsonOnly mode.", + category: CATEGORY, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Using Split mode without any physical fields is unnecessary. Consider removing [PerspectiveStorage] or adding physical fields." + ); + + /// + /// WHIZ807: Info - Model has physical field(s) discovered. + /// + /// diagnostics/whiz807 + public static readonly DiagnosticDescriptor PhysicalFieldsDiscovered = new( + id: "WHIZ807", + title: "Physical Fields Discovered", + messageFormat: "Model '{0}' has {1} physical field(s) in {2} mode", + category: CATEGORY, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "Physical fields were discovered on a perspective model and will be included as database columns." + ); } diff --git a/src/Whizbang.Generators/MessageJsonContextGenerator.cs b/src/Whizbang.Generators/MessageJsonContextGenerator.cs index 075d4894..ac8729de 100644 --- a/src/Whizbang.Generators/MessageJsonContextGenerator.cs +++ b/src/Whizbang.Generators/MessageJsonContextGenerator.cs @@ -50,6 +50,7 @@ public class MessageJsonContextGenerator : IIncrementalGenerator { private const string PLACEHOLDER_MESSAGE_ID = "MessageId"; private const string PLACEHOLDER_FULLY_QUALIFIED_NAME = "__FULLY_QUALIFIED_NAME__"; private const string PLACEHOLDER_SIMPLE_NAME = "__SIMPLE_NAME__"; + private const string PLACEHOLDER_SAFE_NAME = "__SAFE_NAME__"; private const string PLACEHOLDER_GLOBAL = "global::"; private const string PLACEHOLDER_INDEX = "__INDEX__"; private const string PLACEHOLDER_PROPERTY_TYPE = "__PROPERTY_TYPE__"; @@ -58,6 +59,19 @@ public class MessageJsonContextGenerator : IIncrementalGenerator { private const string PLACEHOLDER_SETTER = "__SETTER__"; private const string PLACEHOLDER_PARAMETER_NAME = "__PARAMETER_NAME__"; + /// + /// Converts a fully qualified type name to a safe C# identifier for use in method names. + /// This ensures unique method names even when multiple namespaces have types with the same simple name. + /// Example: "global::JDX.Contracts.Job.CreateCommand" → "JDX_Contracts_Job_CreateCommand" + /// Example: "string?" → "string_Nullable" + /// + private static string _toSafeMethodName(string fullyQualifiedName) { + return fullyQualifiedName + .Replace(PLACEHOLDER_GLOBAL, "") + .Replace(".", "_") + .Replace("?", "_Nullable"); + } + public void Initialize(IncrementalGeneratorInitializationContext context) { // Discover message types (commands, events, and types with [WhizbangSerializable]) var messageTypes = context.SyntaxProvider.CreateSyntaxProvider( @@ -381,7 +395,7 @@ private static string _generateLazyFields(Assembly assembly, ImmutableArray t.IsCommand || t.IsEvent)) { var field = envelopeFieldSnippet .Replace(PLACEHOLDER_FULLY_QUALIFIED_NAME, type.FullyQualifiedName) - .Replace(PLACEHOLDER_SIMPLE_NAME, type.SimpleName); + .Replace(PLACEHOLDER_SAFE_NAME, _toSafeMethodName(type.FullyQualifiedName)); sb.AppendLine(field); } @@ -452,7 +466,7 @@ private static string _generateGetTypeInfo(Assembly assembly, ImmutableArray t.IsCommand || t.IsEvent)) { var check = envelopeCheckSnippet .Replace(PLACEHOLDER_FULLY_QUALIFIED_NAME, type.FullyQualifiedName) - .Replace(PLACEHOLDER_SIMPLE_NAME, type.SimpleName); + .Replace(PLACEHOLDER_SAFE_NAME, _toSafeMethodName(type.FullyQualifiedName)); sb.AppendLine(check); sb.AppendLine(); } @@ -473,7 +487,7 @@ private static string _generateGetTypeInfo(Assembly assembly, ImmutableArrayAssembly-qualified type name (e.g., \"YourNamespace.Commands.CreateOrder, YourAssembly\")"); sb.AppendLine("/// JsonSerializerOptions to use for creating JsonTypeInfo"); sb.AppendLine("/// JsonTypeInfo for the type, or null if not found in this assembly"); - sb.AppendLine("[System.Obsolete(\"Use JsonContextRegistry.GetTypeInfoByName() for cross-assembly type resolution with fuzzy matching support.\")]"); + sb.AppendLine("[global::System.Obsolete(\"Use JsonContextRegistry.GetTypeInfoByName() for cross-assembly type resolution with fuzzy matching support.\")]"); sb.AppendLine("public static JsonTypeInfo? GetTypeInfoByName(string assemblyQualifiedTypeName, JsonSerializerOptions options) {"); sb.AppendLine(" if (string.IsNullOrEmpty(assemblyQualifiedTypeName)) return null;"); sb.AppendLine(" if (options == null) return null;"); @@ -598,7 +612,8 @@ private static string _generateMessageTypeFactories(Assembly assembly, Immutable "PARAMETER_INFO_VALUES"); foreach (var message in messages) { - sb.AppendLine($"private JsonTypeInfo<{message.FullyQualifiedName}> Create_{message.SimpleName}(JsonSerializerOptions options) {{"); + var safeName = _toSafeMethodName(message.FullyQualifiedName); + sb.AppendLine($"private JsonTypeInfo<{message.FullyQualifiedName}> Create_{safeName}(JsonSerializerOptions options) {{"); // Generate properties array sb.AppendLine($" var properties = new JsonPropertyInfo[{message.Properties.Length}];"); @@ -606,9 +621,12 @@ private static string _generateMessageTypeFactories(Assembly assembly, Immutable for (int i = 0; i < message.Properties.Length; i++) { var prop = message.Properties[i]; + // Note: No trailing comma - the template snippet adds the comma after __SETTER__ + // Note: No comment for null - a // comment would hide the template's trailing comma + // Note: Use null-forgiving operator (!) to suppress CS8601 warnings - STJ handles null checking var setter = prop.IsInitOnly - ? "null, // Init-only property, STJ will use reflection" - : $"(obj, value) => (({message.FullyQualifiedName})obj).{prop.Name} = value,"; + ? "null" + : $"(obj, value) => (({message.FullyQualifiedName})obj).{prop.Name} = value!"; var propertyCode = propertyCreationSnippet .Replace(PLACEHOLDER_INDEX, i.ToString(CultureInfo.InvariantCulture)) @@ -706,7 +724,8 @@ private static string _generateMessageEnvelopeFactories(Assembly assembly, Immut "PARAMETER_INFO_VALUES"); foreach (var message in messages) { - sb.AppendLine($"private JsonTypeInfo> CreateMessageEnvelope_{message.SimpleName}(JsonSerializerOptions options) {{"); + var safeName = _toSafeMethodName(message.FullyQualifiedName); + sb.AppendLine($"private JsonTypeInfo> CreateMessageEnvelope_{safeName}(JsonSerializerOptions options) {{"); // Generate properties array for MessageEnvelope (MessageId, Payload, Hops) sb.AppendLine(" var properties = new JsonPropertyInfo[3];"); @@ -1018,7 +1037,7 @@ private static string _generateListLazyFields(Assembly assembly, ImmutableArray< foreach (var listType in listTypes) { var field = snippet .Replace("__ELEMENT_TYPE__", listType.ElementTypeName) - .Replace("__ELEMENT_SIMPLE_NAME__", listType.ElementSimpleName); + .Replace("__ELEMENT_SAFE_NAME__", _toSafeMethodName(listType.ElementTypeName)); sb.AppendLine(field); } @@ -1044,7 +1063,7 @@ private static string _generateListFactories(Assembly assembly, ImmutableArray, TModel is at index 0 var modelType = perspectiveInterfaces[0].TypeArguments[0]; - var propertyCount = modelType.GetMembers() + var modelClassName = modelType.Name; + var modelProperties = modelType.GetMembers() .OfType() - .Count(p => !p.IsStatic); + .Where(p => !p.IsStatic) + .ToList(); + var propertyCount = modelProperties.Count; var estimatedSize = _estimateJsonSize(propertyCount); + // Extract storage mode from [PerspectiveStorage] attribute on model + var storageMode = _extractStorageMode(modelType); + + // Discover physical fields on model properties + var physicalFields = _discoverPhysicalFields(modelProperties); + return new PerspectiveSchemaInfo( ClassName: className, FullyQualifiedClassName: classSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + ModelClassName: modelClassName, TableName: tableName, PropertyCount: propertyCount, - EstimatedSizeBytes: estimatedSize + EstimatedSizeBytes: estimatedSize, + StorageMode: storageMode, + PhysicalFields: physicalFields + ); + } + + /// + /// Extracts the FieldStorageMode from [PerspectiveStorage] attribute on the model type. + /// + private static GeneratorFieldStorageMode _extractStorageMode(ITypeSymbol modelType) { + const string PERSPECTIVE_STORAGE_ATTRIBUTE = "Whizbang.Core.Perspectives.PerspectiveStorageAttribute"; + + foreach (var attribute in modelType.GetAttributes()) { + var attrClassName = attribute.AttributeClass?.ToDisplayString(); + if (attrClassName == PERSPECTIVE_STORAGE_ATTRIBUTE && attribute.ConstructorArguments.Length > 0) { + var modeArg = attribute.ConstructorArguments[0]; + if (modeArg.Value is int modeValue) { + return (GeneratorFieldStorageMode)modeValue; + } + } + } + + return GeneratorFieldStorageMode.JsonOnly; + } + + /// + /// Discovers physical fields from [PhysicalField] and [VectorField] attributes on model properties. + /// + private static PhysicalFieldInfo[] _discoverPhysicalFields(System.Collections.Generic.List properties) { + const string PHYSICAL_FIELD_ATTRIBUTE = "Whizbang.Core.Perspectives.PhysicalFieldAttribute"; + const string VECTOR_FIELD_ATTRIBUTE = "Whizbang.Core.Perspectives.VectorFieldAttribute"; + + var physicalFields = new System.Collections.Generic.List(); + + foreach (var property in properties) { + foreach (var attribute in property.GetAttributes()) { + var attrClassName = attribute.AttributeClass?.ToDisplayString(); + + if (attrClassName == PHYSICAL_FIELD_ATTRIBUTE) { + var fieldInfo = _extractPhysicalFieldInfo(property, attribute); + if (fieldInfo != null) { + physicalFields.Add(fieldInfo); + } + } else if (attrClassName == VECTOR_FIELD_ATTRIBUTE) { + var fieldInfo = _extractVectorFieldInfo(property, attribute); + if (fieldInfo != null) { + physicalFields.Add(fieldInfo); + } + } + } + } + + return physicalFields.ToArray(); + } + + /// + /// Extracts PhysicalFieldInfo from a [PhysicalField] attribute. + /// + private static PhysicalFieldInfo? _extractPhysicalFieldInfo(IPropertySymbol property, AttributeData attribute) { + var propertyName = property.Name; + var typeName = property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + // Extract named arguments + bool isIndexed = false; + bool isUnique = false; + int? maxLength = null; + string? columnName = null; + + foreach (var namedArg in attribute.NamedArguments) { + switch (namedArg.Key) { + case "Indexed": + isIndexed = namedArg.Value.Value is true; + break; + case "Unique": + isUnique = namedArg.Value.Value is true; + break; + case "MaxLength": + // Handle various numeric types - TypedConstant may return int, long, short, etc. + // -1 or 0 means "not set" (unlimited TEXT) + if (namedArg.Value.Kind == TypedConstantKind.Primitive && namedArg.Value.Value != null) { + var maxLengthVal = System.Convert.ToInt32(namedArg.Value.Value, CultureInfo.InvariantCulture); + if (maxLengthVal > 0) { + maxLength = maxLengthVal; + } + } + break; + case "ColumnName": + columnName = namedArg.Value.Value as string; + break; + } + } + + // Default column name is snake_case of property name + var finalColumnName = columnName ?? _generateTableName(propertyName); + + return new PhysicalFieldInfo( + PropertyName: propertyName, + ColumnName: finalColumnName, + TypeName: typeName, + IsIndexed: isIndexed, + IsUnique: isUnique, + MaxLength: maxLength, + IsVector: false, + VectorDimensions: null, + VectorDistanceMetric: null, + VectorIndexType: null, + VectorIndexLists: null + ); + } + + /// + /// Extracts PhysicalFieldInfo from a [VectorField] attribute. + /// + private static PhysicalFieldInfo? _extractVectorFieldInfo(IPropertySymbol property, AttributeData attribute) { + var propertyName = property.Name; + var typeName = property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + // Extract constructor argument (dimensions) + int? dimensions = null; + if (attribute.ConstructorArguments.Length > 0) { + dimensions = attribute.ConstructorArguments[0].Value as int?; + } + + // Extract named arguments + var distanceMetric = GeneratorVectorDistanceMetric.Cosine; // Default + var indexType = GeneratorVectorIndexType.IVFFlat; // Default + bool isIndexed = true; // Default + int? indexLists = null; + string? columnName = null; + + foreach (var namedArg in attribute.NamedArguments) { + switch (namedArg.Key) { + case "DistanceMetric": + var metricVal = namedArg.Value.Value; + if (metricVal != null) { + distanceMetric = (GeneratorVectorDistanceMetric)System.Convert.ToInt32(metricVal, CultureInfo.InvariantCulture); + } + break; + case "IndexType": + var typeVal = namedArg.Value.Value; + if (typeVal != null) { + indexType = (GeneratorVectorIndexType)System.Convert.ToInt32(typeVal, CultureInfo.InvariantCulture); + } + break; + case "Indexed": + isIndexed = namedArg.Value.Value is true; + break; + case "IndexLists": + var indexListsVal = namedArg.Value.Value; + if (indexListsVal != null) { + indexLists = System.Convert.ToInt32(indexListsVal, CultureInfo.InvariantCulture); + } + break; + case "ColumnName": + columnName = namedArg.Value.Value as string; + break; + } + } + + // Default column name is snake_case of property name + var finalColumnName = columnName ?? _generateTableName(propertyName); + + // If not indexed, set index type to None + if (!isIndexed) { + indexType = GeneratorVectorIndexType.None; + } + + return new PhysicalFieldInfo( + PropertyName: propertyName, + ColumnName: finalColumnName, + TypeName: typeName, + IsIndexed: isIndexed, + IsUnique: false, // Vectors are never unique + MaxLength: null, // N/A for vectors + IsVector: true, + VectorDimensions: dimensions, + VectorDistanceMetric: distanceMetric, + VectorIndexType: indexType, + VectorIndexLists: indexLists ); } @@ -148,20 +337,51 @@ private static void _generatePerspectiveSchemas( )); } + // Report physical fields discovered + if (perspective.PhysicalFields.Length > 0) { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.PhysicalFieldsDiscovered, + Location.None, + perspective.ModelClassName, + perspective.PhysicalFields.Length.ToString(CultureInfo.InvariantCulture), + perspective.StorageMode.ToString() + )); + } + + // Generate physical column definitions + var physicalColumnsSql = _generatePhysicalColumnsSql(perspective.PhysicalFields); + // Generate CREATE TABLE from snippet var tableCode = createTableSnippet .Replace("__CLASS_NAME__", perspective.ClassName) .Replace("__ESTIMATED_SIZE__", perspective.EstimatedSizeBytes.ToString(CultureInfo.InvariantCulture)) .Replace("__TABLE_NAME__", perspective.TableName); + // Insert physical columns before closing parenthesis of CREATE TABLE + if (!string.IsNullOrEmpty(physicalColumnsSql)) { + // Find the position to insert: before the final ");" + var insertPos = tableCode.LastIndexOf(");", StringComparison.Ordinal); + if (insertPos > 0) { + // Add physical columns with proper comma separation + tableCode = tableCode.Substring(0, insertPos) + ",\n" + physicalColumnsSql + "\n" + tableCode.Substring(insertPos); + } + } + sqlBuilder.AppendLine(tableCode); sqlBuilder.AppendLine(); - // Generate indexes from snippet + // Generate standard indexes from snippet var indexesCode = createIndexesSnippet .Replace("__TABLE_NAME__", perspective.TableName); sqlBuilder.AppendLine(indexesCode); + + // Generate physical field indexes + var physicalIndexesSql = _generatePhysicalIndexesSql(perspective.TableName, perspective.PhysicalFields); + if (!string.IsNullOrEmpty(physicalIndexesSql)) { + sqlBuilder.AppendLine(physicalIndexesSql); + } + sqlBuilder.AppendLine(); } @@ -197,6 +417,130 @@ private static void _generatePerspectiveSchemas( )); } + /// + /// Generates SQL column definitions for physical fields. + /// + private static string _generatePhysicalColumnsSql(PhysicalFieldInfo[] physicalFields) { + if (physicalFields.Length == 0) { + return string.Empty; + } + + var sb = new StringBuilder(); + for (int i = 0; i < physicalFields.Length; i++) { + var field = physicalFields[i]; + var sqlType = _mapToPostgresType(field); + + sb.Append(" "); + sb.Append(field.ColumnName); + sb.Append(' '); + sb.Append(sqlType); + + if (i < physicalFields.Length - 1) { + sb.Append(','); + } + sb.AppendLine(); + } + + return sb.ToString().TrimEnd('\r', '\n'); + } + + /// + /// Maps a physical field to its PostgreSQL column type. + /// + private static string _mapToPostgresType(PhysicalFieldInfo field) { + if (field.IsVector && field.VectorDimensions.HasValue) { + return $"vector({field.VectorDimensions.Value})"; + } + + // Normalize the type name by removing global:: and nullable markers + var typeName = field.TypeName + .Replace("global::", "") + .TrimEnd('?'); + + return typeName switch { + "System.String" or "string" => field.MaxLength.HasValue + ? $"VARCHAR({field.MaxLength.Value})" + : "TEXT", + "System.Int32" or "int" => "INTEGER", + "System.Int64" or "long" => "BIGINT", + "System.Int16" or "short" => "SMALLINT", + "System.Decimal" or "decimal" => "DECIMAL", + "System.Double" or "double" => "DOUBLE PRECISION", + "System.Single" or "float" => "REAL", + "System.Boolean" or "bool" => "BOOLEAN", + "System.Guid" => "UUID", + "System.DateTime" => "TIMESTAMP", + "System.DateTimeOffset" => "TIMESTAMPTZ", + "System.DateOnly" => "DATE", + "System.TimeOnly" => "TIME", + "System.Single[]" or "float[]" => "REAL[]", // fallback for float[] without VectorField + _ => "TEXT" // Default fallback + }; + } + + /// + /// Generates SQL index definitions for physical fields. + /// + private static string _generatePhysicalIndexesSql(string tableName, PhysicalFieldInfo[] physicalFields) { + var sb = new StringBuilder(); + + foreach (var field in physicalFields) { + if (!field.IsIndexed && !field.IsUnique) { + continue; + } + + if (field.IsVector) { + // Generate vector index + var indexSql = _generateVectorIndexSql(tableName, field); + if (!string.IsNullOrEmpty(indexSql)) { + sb.AppendLine(indexSql); + } + } else { + // Generate standard B-tree index + var indexName = $"ix_{tableName}_{field.ColumnName}"; + var uniqueClause = field.IsUnique ? "UNIQUE " : ""; + sb.AppendLine($"CREATE {uniqueClause}INDEX IF NOT EXISTS {indexName} ON {tableName}({field.ColumnName});"); + } + } + + return sb.ToString().TrimEnd('\r', '\n'); + } + + /// + /// Generates a pgvector index SQL statement. + /// + private static string _generateVectorIndexSql(string tableName, PhysicalFieldInfo field) { + if (!field.IsVector || field.VectorIndexType == GeneratorVectorIndexType.None) { + return string.Empty; + } + + var indexName = $"ix_{tableName}_{field.ColumnName}_vec"; + var indexMethod = field.VectorIndexType switch { + GeneratorVectorIndexType.HNSW => "hnsw", + GeneratorVectorIndexType.IVFFlat => "ivfflat", + _ => null + }; + + if (indexMethod == null) { + return string.Empty; + } + + var opsClass = field.VectorDistanceMetric switch { + GeneratorVectorDistanceMetric.L2 => "vector_l2_ops", + GeneratorVectorDistanceMetric.InnerProduct => "vector_ip_ops", + GeneratorVectorDistanceMetric.Cosine => "vector_cosine_ops", + _ => "vector_cosine_ops" // Default to cosine + }; + + // Build WITH clause for index parameters + var withClause = ""; + if (field.VectorIndexType == GeneratorVectorIndexType.IVFFlat && field.VectorIndexLists.HasValue) { + withClause = $" WITH (lists = {field.VectorIndexLists.Value})"; + } + + return $"CREATE INDEX IF NOT EXISTS {indexName} ON {tableName} USING {indexMethod} ({field.ColumnName} {opsClass}){withClause};"; + } + /// /// Generates a snake_case table name from a PascalCase class name. /// Example: "OrderSummaryPerspective" -> "order_summary_perspective" @@ -230,13 +574,34 @@ private static int _estimateJsonSize(int propertyCount) { /// /// Simple class name (e.g., "OrderSummaryPerspective") /// Fully qualified class name +/// Simple class name of the model type /// Generated PostgreSQL table name (e.g., "order_summary_perspective") /// Number of properties for size estimation /// Estimated JSON size in bytes +/// Field storage mode from [PerspectiveStorage] attribute +/// Array of physical fields discovered on the model internal sealed record PerspectiveSchemaInfo( string ClassName, string FullyQualifiedClassName, + string ModelClassName, string TableName, int PropertyCount, - int EstimatedSizeBytes + int EstimatedSizeBytes, + GeneratorFieldStorageMode StorageMode, + PhysicalFieldInfo[] PhysicalFields ); + +/// +/// Field storage mode for physical fields in a perspective. +/// Mirrors Whizbang.Core.Perspectives.FieldStorageMode for generator use. +/// +public enum GeneratorFieldStorageMode { + /// No physical columns - all data in JSONB (default, backwards compatible) + JsonOnly = 0, + + /// JSONB contains full model; physical columns are indexed copies + Extracted = 1, + + /// Physical columns contain marked fields; JSONB contains remainder only + Split = 2 +} diff --git a/src/Whizbang.Generators/StreamKeyGenerator.cs b/src/Whizbang.Generators/StreamKeyGenerator.cs index 8c0d6c20..c57f3c2d 100644 --- a/src/Whizbang.Generators/StreamKeyGenerator.cs +++ b/src/Whizbang.Generators/StreamKeyGenerator.cs @@ -89,23 +89,27 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { return null; } - // Look for [StreamKey] on properties - foreach (var member in typeSymbol.GetMembers()) { - if (member is IPropertySymbol property) { - var hasStreamKeyAttr = property.GetAttributes().Any(a => - a.AttributeClass?.Name == STREAMKEY_ATTRIBUTE_NAME || - a.AttributeClass?.Name == STREAMKEY_SHORT_NAME || - a.AttributeClass?.ToDisplayString() == STREAMKEY_ATTRIBUTE || - a.AttributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == $"global::{STREAMKEY_ATTRIBUTE}"); - - if (hasStreamKeyAttr) { - return new StreamKeyInfo( - EventType: typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), - PropertyName: property.Name, - PropertyType: property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) - ); + // Look for [StreamKey] on properties (including inherited properties) + var currentType = typeSymbol; + while (currentType is not null) { + foreach (var member in currentType.GetMembers()) { + if (member is IPropertySymbol property) { + var hasStreamKeyAttr = property.GetAttributes().Any(a => + a.AttributeClass?.Name == STREAMKEY_ATTRIBUTE_NAME || + a.AttributeClass?.Name == STREAMKEY_SHORT_NAME || + a.AttributeClass?.ToDisplayString() == STREAMKEY_ATTRIBUTE || + a.AttributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == $"global::{STREAMKEY_ATTRIBUTE}"); + + if (hasStreamKeyAttr) { + return new StreamKeyInfo( + EventType: typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + PropertyName: property.Name, + PropertyType: property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + ); + } } } + currentType = currentType.BaseType; } // Look for [StreamKey] on constructor parameters (for records) @@ -160,13 +164,18 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { return null; } - // Check if has [StreamKey] anywhere - var hasStreamKeyOnProperty = typeSymbol.GetMembers().OfType().Any(p => - p.GetAttributes().Any(a => - a.AttributeClass?.Name == STREAMKEY_ATTRIBUTE_NAME || - a.AttributeClass?.Name == STREAMKEY_SHORT_NAME || - a.AttributeClass?.ToDisplayString() == STREAMKEY_ATTRIBUTE || - a.AttributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == $"global::{STREAMKEY_ATTRIBUTE}")); + // Check if has [StreamKey] anywhere (including inherited properties) + var hasStreamKeyOnProperty = false; + var checkType = typeSymbol; + while (checkType is not null && !hasStreamKeyOnProperty) { + hasStreamKeyOnProperty = checkType.GetMembers().OfType().Any(p => + p.GetAttributes().Any(a => + a.AttributeClass?.Name == STREAMKEY_ATTRIBUTE_NAME || + a.AttributeClass?.Name == STREAMKEY_SHORT_NAME || + a.AttributeClass?.ToDisplayString() == STREAMKEY_ATTRIBUTE || + a.AttributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == $"global::{STREAMKEY_ATTRIBUTE}")); + checkType = checkType.BaseType; + } if (hasStreamKeyOnProperty) { return null; diff --git a/src/Whizbang.Generators/Templates/Snippets/JsonContextSnippets.cs b/src/Whizbang.Generators/Templates/Snippets/JsonContextSnippets.cs index 3326c558..ebef28c1 100644 --- a/src/Whizbang.Generators/Templates/Snippets/JsonContextSnippets.cs +++ b/src/Whizbang.Generators/Templates/Snippets/JsonContextSnippets.cs @@ -19,11 +19,11 @@ internal class JsonContextSnippets { #endregion #region LAZY_FIELD_MESSAGE - private JsonTypeInfo<__FULLY_QUALIFIED_NAME__>? ___SIMPLE_NAME__; + private JsonTypeInfo<__FULLY_QUALIFIED_NAME__>? ___SAFE_NAME__; #endregion #region LAZY_FIELD_MESSAGE_ENVELOPE - private JsonTypeInfo>? _MessageEnvelope___SIMPLE_NAME__; + private JsonTypeInfo>? _MessageEnvelope___SAFE_NAME__; #endregion #region GET_TYPE_INFO_VALUE_OBJECT @@ -32,13 +32,13 @@ internal class JsonContextSnippets { #region GET_TYPE_INFO_MESSAGE if (type == typeof(__FULLY_QUALIFIED_NAME__)) { - return Create___SIMPLE_NAME__(options); + return Create___SAFE_NAME__(options); } #endregion #region GET_TYPE_INFO_MESSAGE_ENVELOPE if (type == typeof(MessageEnvelope<__FULLY_QUALIFIED_NAME__>)) { - return CreateMessageEnvelope___SIMPLE_NAME__(options); + return CreateMessageEnvelope___SAFE_NAME__(options); } #endregion @@ -52,17 +52,17 @@ internal class JsonContextSnippets { #endregion #region LAZY_FIELD_LIST -private JsonTypeInfo>? _List___ELEMENT_SIMPLE_NAME__; +private JsonTypeInfo>? _List___ELEMENT_SAFE_NAME__; #endregion #region GET_TYPE_INFO_LIST if (type == typeof(global::System.Collections.Generic.List<__ELEMENT_TYPE__>)) { - return CreateList___ELEMENT_SIMPLE_NAME__(options); + return CreateList___ELEMENT_SAFE_NAME__(options); } #endregion #region LIST_TYPE_FACTORY -private JsonTypeInfo> CreateList___ELEMENT_SIMPLE_NAME__(JsonSerializerOptions options) { +private JsonTypeInfo> CreateList___ELEMENT_SAFE_NAME__(JsonSerializerOptions options) { var elementInfo = GetOrCreateTypeInfo<__ELEMENT_TYPE__>(options); var collectionInfo = new JsonCollectionInfoValues> { ObjectCreator = static () => new global::System.Collections.Generic.List<__ELEMENT_TYPE__>(), @@ -200,7 +200,7 @@ private JsonTypeInfo GetOrCreateTypeInfo(JsonSerializerOptions options) { /// Runs automatically when the assembly is loaded - no explicit call needed. /// Registers WhizbangIdJsonContext and MessageJsonContext with the global JsonContextRegistry. /// -[System.Runtime.CompilerServices.ModuleInitializer] +[global::System.Runtime.CompilerServices.ModuleInitializer] public static void Initialize() { // Register local contexts with the global registry // These will be combined with Core's contexts (InfrastructureJsonContext, etc.) diff --git a/src/Whizbang.Generators/Templates/StreamKeyExtractorsTemplate.cs b/src/Whizbang.Generators/Templates/StreamKeyExtractorsTemplate.cs index 1e86126b..2974e2dd 100644 --- a/src/Whizbang.Generators/Templates/StreamKeyExtractorsTemplate.cs +++ b/src/Whizbang.Generators/Templates/StreamKeyExtractorsTemplate.cs @@ -4,7 +4,7 @@ #endregion #nullable enable -using System; +using global::System; #region NAMESPACE namespace Whizbang.Core.Generated; @@ -20,13 +20,13 @@ public static partial class StreamKeyExtractors { /// Zero-reflection alternative to StreamKeyResolver.Resolve(). /// public static string Resolve(global::Whizbang.Core.IEvent @event) { - System.ArgumentNullException.ThrowIfNull(@event); + global::System.ArgumentNullException.ThrowIfNull(@event); #region RESOLVE_DISPATCH // Type-based dispatch to correct extractor #endregion - throw new System.InvalidOperationException( + throw new global::System.InvalidOperationException( $"No stream key extractor found for event type '{@event.GetType().Name}'. " + "Ensure the event type has a property or parameter marked with [StreamKey]."); } diff --git a/src/Whizbang.Generators/Templates/WhizbangJsonContextFacadeTemplate.cs b/src/Whizbang.Generators/Templates/WhizbangJsonContextFacadeTemplate.cs index 6c9d54a0..8c439240 100644 --- a/src/Whizbang.Generators/Templates/WhizbangJsonContextFacadeTemplate.cs +++ b/src/Whizbang.Generators/Templates/WhizbangJsonContextFacadeTemplate.cs @@ -1,7 +1,7 @@ -using System; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; +using global::System; +using global::System.Text.Json; +using global::System.Text.Json.Serialization; +using global::System.Text.Json.Serialization.Metadata; #region NAMESPACE namespace __NAMESPACE__; @@ -51,8 +51,8 @@ public static JsonSerializerOptions CreateOptions() { }; var options = new JsonSerializerOptions { - TypeInfoResolver = System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine(resolvers), - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + TypeInfoResolver = global::System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine(resolvers), + DefaultIgnoreCondition = global::System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }; __CONVERTER_REGISTRATIONS__ return options; @@ -63,7 +63,7 @@ public static JsonSerializerOptions CreateOptions() { /// This is used when GetTypeInfo is called directly on WhizbangJsonContext. /// private static readonly IJsonTypeInfoResolver _combinedResolver = - System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine( + global::System.Text.Json.Serialization.Metadata.JsonTypeInfoResolver.Combine( global::Whizbang.Core.Generated.WhizbangIdJsonContext.Default, WhizbangIdJsonContext.Default, // Local WhizbangId types MessageJsonContext.Default, diff --git a/src/Whizbang.Generators/Templates/WhizbangJsonContextTemplate.cs b/src/Whizbang.Generators/Templates/WhizbangJsonContextTemplate.cs index 972164a5..aeb6d3dd 100644 --- a/src/Whizbang.Generators/Templates/WhizbangJsonContextTemplate.cs +++ b/src/Whizbang.Generators/Templates/WhizbangJsonContextTemplate.cs @@ -1,14 +1,14 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; -using Whizbang.Core; -using Whizbang.Core.Observability; -using Whizbang.Core.Policies; -using Whizbang.Core.ValueObjects; -using Whizbang.Core.Transports; +using global::System; +using global::System.Collections.Generic; +using global::System.Collections.Immutable; +using global::System.Text.Json; +using global::System.Text.Json.Serialization; +using global::System.Text.Json.Serialization.Metadata; +using global::Whizbang.Core; +using global::Whizbang.Core.Observability; +using global::Whizbang.Core.Policies; +using global::Whizbang.Core.ValueObjects; +using global::Whizbang.Core.Transports; #region NAMESPACE namespace Whizbang.Core.Generated; diff --git a/src/Whizbang.Generators/WhizbangIdGenerator.cs b/src/Whizbang.Generators/WhizbangIdGenerator.cs index 692af3d8..689a6c23 100644 --- a/src/Whizbang.Generators/WhizbangIdGenerator.cs +++ b/src/Whizbang.Generators/WhizbangIdGenerator.cs @@ -462,15 +462,20 @@ private static string _generateValueObject(WhizbangIdInfo id) { sb.AppendLine(); // Struct declaration - implements IWhizbangId, IEquatable, IComparable + // EF Core ComplexProperty().ToJson() compatible - exposes Guid Value property for mapping + // Internally stores TrackedGuid to preserve metadata for freshly created IDs sb.AppendLine($"public readonly partial struct {id.TypeName} : global::Whizbang.Core.IWhizbangId, IEquatable<{id.TypeName}>, IComparable<{id.TypeName}> {{"); - // Private backing field - TrackedGuid instead of Guid + // Private backing field - stores TrackedGuid for metadata tracking + // Fresh IDs via New() have IsTracking=true, deserialized IDs have IsTracking=false sb.AppendLine(" private readonly TrackedGuid _tracked;"); sb.AppendLine(); - // Value property (for backwards compatibility) + // Value property - the ONLY public property for EF Core binding + // Uses init accessor for EF Core ComplexProperty().ToJson() deserialization + // When EF Core deserializes, it sets Value which creates an untracked TrackedGuid sb.AppendLine(" /// Gets the underlying Guid value."); - sb.AppendLine(" public Guid Value => _tracked.Value;"); + sb.AppendLine(" public Guid Value { get => _tracked.Value; init => _tracked = TrackedGuid.FromExternal(value); }"); sb.AppendLine(); // IWhizbangId.ToGuid() implementation @@ -478,33 +483,57 @@ private static string _generateValueObject(WhizbangIdInfo id) { sb.AppendLine(" public Guid ToGuid() => _tracked.Value;"); sb.AppendLine(); - // IWhizbangId.IsTimeOrdered + // IWhizbangId metadata properties - explicit interface implementation + // Explicit implementation hides these from EF Core ComplexProperty so only Value is mapped + // Delegates to TrackedGuid for accurate metadata based on creation source sb.AppendLine(" /// Gets whether this ID is time-ordered (UUIDv7)."); - sb.AppendLine(" public bool IsTimeOrdered => _tracked.IsTimeOrdered;"); + sb.AppendLine(" bool global::Whizbang.Core.IWhizbangId.IsTimeOrdered => _tracked.IsTimeOrdered;"); sb.AppendLine(); - // IWhizbangId.SubMillisecondPrecision sb.AppendLine(" /// Gets whether this ID has sub-millisecond precision (Medo-generated)."); - sb.AppendLine(" public bool SubMillisecondPrecision => _tracked.SubMillisecondPrecision;"); + sb.AppendLine(" bool global::Whizbang.Core.IWhizbangId.SubMillisecondPrecision => _tracked.SubMillisecondPrecision;"); sb.AppendLine(); - // IWhizbangId.Timestamp sb.AppendLine(" /// Gets the timestamp embedded in this ID (for UUIDv7)."); - sb.AppendLine(" public DateTimeOffset Timestamp => _tracked.Timestamp;"); + sb.AppendLine(" DateTimeOffset global::Whizbang.Core.IWhizbangId.Timestamp => _tracked.Timestamp;"); sb.AppendLine(); - // Private constructor from TrackedGuid - sb.AppendLine(" /// Creates an instance from a TrackedGuid."); + // Provide public convenience methods for accessing metadata (won't interfere with EF Core) + sb.AppendLine(" /// Gets whether this ID is time-ordered (UUIDv7)."); + sb.AppendLine(" public bool GetIsTimeOrdered() => _tracked.IsTimeOrdered;"); + sb.AppendLine(); + + sb.AppendLine(" /// "); + sb.AppendLine(" /// Gets whether this ID has sub-millisecond precision."); + sb.AppendLine(" /// True for freshly created IDs via New(), false for deserialized IDs."); + sb.AppendLine(" /// "); + sb.AppendLine(" public bool GetSubMillisecondPrecision() => _tracked.SubMillisecondPrecision;"); + sb.AppendLine(); + + sb.AppendLine(" /// Gets the timestamp embedded in this ID (for UUIDv7)."); + sb.AppendLine(" public DateTimeOffset GetTimestamp() => _tracked.Timestamp;"); + sb.AppendLine(); + + sb.AppendLine(" /// "); + sb.AppendLine(" /// Gets whether this ID has authoritative tracking metadata."); + sb.AppendLine(" /// True for freshly created IDs via New(), false for deserialized IDs."); + sb.AppendLine(" /// "); + sb.AppendLine(" public bool GetIsTracking() => _tracked.IsTracking;"); + sb.AppendLine(); + + // Private constructor for internal use with TrackedGuid (preserves tracking) sb.AppendLine($" private {id.TypeName}(TrackedGuid tracked) => _tracked = tracked;"); sb.AppendLine(); - // Public constructor for EF Core compatibility (wraps in FromExternal) - sb.AppendLine(" /// Creates an instance from a Guid value. Public for EF Core compatibility."); - sb.AppendLine($" public {id.TypeName}(Guid value) => _tracked = TrackedGuid.FromExternal(value);"); + // Public constructor for EF Core ComplexProperty().ToJson() compatibility + // Parameter name MUST match property name (Value) for EF Core constructor binding + // Creates untracked TrackedGuid since we don't know the original source + sb.AppendLine(" /// Creates an instance from a Guid value. Parameter name matches property for EF Core binding."); + sb.AppendLine($" public {id.TypeName}(Guid Value) => _tracked = TrackedGuid.FromExternal(Value);"); sb.AppendLine(); - // From(TrackedGuid) factory method - preserves metadata - sb.AppendLine(" /// Creates an instance from a TrackedGuid, preserving metadata."); + // From(TrackedGuid) factory method - preserves tracking metadata + sb.AppendLine(" /// Creates an instance from a TrackedGuid, preserving tracking metadata."); sb.AppendLine($" public static {id.TypeName} From(TrackedGuid tracked) {{"); sb.AppendLine(" if (!tracked.IsTimeOrdered)"); sb.AppendLine($" throw new ArgumentException(\"{id.TypeName} requires UUIDv7 (time-ordered) but received a non-v7 Guid\", nameof(tracked));"); @@ -512,7 +541,7 @@ private static string _generateValueObject(WhizbangIdInfo id) { sb.AppendLine(" }"); sb.AppendLine(); - // From(Guid) factory method - validates v7 + // From(Guid) factory method - validates v7, creates untracked sb.AppendLine(" /// Creates an instance from a Guid value. Throws if not UUIDv7."); sb.AppendLine($" public static {id.TypeName} From(Guid value) {{"); sb.AppendLine(" if (value.Version != 7)"); @@ -521,14 +550,14 @@ private static string _generateValueObject(WhizbangIdInfo id) { sb.AppendLine(" }"); sb.AppendLine(); - // New factory method using TrackedGuid.NewMedo() - sb.AppendLine(" /// Creates a new instance with sub-millisecond precision using Medo.Uuid7."); + // New factory method using TrackedGuid.NewMedo() - generates new UUIDv7 with tracking + sb.AppendLine(" /// Creates a new instance with sub-millisecond precision using Medo.Uuid7. Preserves tracking metadata."); sb.AppendLine($" public static {id.TypeName} New() => new(TrackedGuid.NewMedo());"); sb.AppendLine(); - // Equality members + // Equality members - compare Guid values directly sb.AppendLine(" /// Determines whether two instances are equal."); - sb.AppendLine($" public bool Equals({id.TypeName} other) => _tracked.Equals(other._tracked);"); + sb.AppendLine($" public bool Equals({id.TypeName} other) => _tracked.Value.Equals(other._tracked.Value);"); sb.AppendLine(); sb.AppendLine(" /// Determines whether this instance equals another IWhizbangId."); @@ -540,12 +569,12 @@ private static string _generateValueObject(WhizbangIdInfo id) { sb.AppendLine(); sb.AppendLine(" /// Returns the hash code for this instance."); - sb.AppendLine(" public override int GetHashCode() => _tracked.GetHashCode();"); + sb.AppendLine(" public override int GetHashCode() => _tracked.Value.GetHashCode();"); sb.AppendLine(); // Comparison sb.AppendLine(" /// Compares this instance to another."); - sb.AppendLine($" public int CompareTo({id.TypeName} other) => _tracked.CompareTo(other._tracked);"); + sb.AppendLine($" public int CompareTo({id.TypeName} other) => _tracked.Value.CompareTo(other._tracked.Value);"); sb.AppendLine(); sb.AppendLine(" /// Compares this instance to another IWhizbangId."); @@ -579,7 +608,7 @@ private static string _generateValueObject(WhizbangIdInfo id) { // ToString sb.AppendLine(" /// Returns the string representation of the underlying Guid."); - sb.AppendLine(" public override string ToString() => _tracked.ToString();"); + sb.AppendLine(" public override string ToString() => _tracked.Value.ToString();"); sb.AppendLine(); // Implicit conversion to Guid diff --git a/src/Whizbang.Transports.HotChocolate/Middleware/WhizbangScopeMiddleware.cs b/src/Whizbang.Transports.HotChocolate/Middleware/WhizbangScopeMiddleware.cs index 64fbe479..45b6cc62 100644 --- a/src/Whizbang.Transports.HotChocolate/Middleware/WhizbangScopeMiddleware.cs +++ b/src/Whizbang.Transports.HotChocolate/Middleware/WhizbangScopeMiddleware.cs @@ -57,18 +57,18 @@ private PerspectiveScope _buildScope(HttpContext context) { var orgId = _extractValue(context, _options.OrganizationIdClaimType, _options.OrganizationIdHeaderName); var customerId = _extractValue(context, _options.CustomerIdClaimType, _options.CustomerIdHeaderName); - var extensions = new Dictionary(); + var extensions = new List(); foreach (var (claimType, extensionKey) in _options.ExtensionClaimMappings) { var value = context.User?.FindFirst(claimType)?.Value; if (!string.IsNullOrEmpty(value)) { - extensions[extensionKey] = value; + extensions.Add(new ScopeExtension { Key = extensionKey, Value = value }); } } foreach (var (headerName, extensionKey) in _options.ExtensionHeaderMappings) { if (context.Request.Headers.TryGetValue(headerName, out var headerValue) && !string.IsNullOrEmpty(headerValue)) { - extensions[extensionKey] = headerValue!; + extensions.Add(new ScopeExtension { Key = extensionKey, Value = headerValue! }); } } @@ -77,7 +77,7 @@ private PerspectiveScope _buildScope(HttpContext context) { UserId = userId, OrganizationId = orgId, CustomerId = customerId, - Extensions = extensions.Count > 0 ? extensions : null + Extensions = extensions }; } diff --git a/tests/Whizbang.Core.Tests/Helpers/AttributeTestHelpers.cs b/tests/Whizbang.Core.Tests/Helpers/AttributeTestHelpers.cs new file mode 100644 index 00000000..e639dba1 --- /dev/null +++ b/tests/Whizbang.Core.Tests/Helpers/AttributeTestHelpers.cs @@ -0,0 +1,26 @@ +namespace Whizbang.Core.Tests.Helpers; + +/// +/// Helper methods for testing attribute metadata. +/// +internal static class AttributeTestHelpers { + /// + /// Gets the AttributeUsageAttribute for a given attribute type. + /// + public static AttributeUsageAttribute? GetAttributeUsage() where TAttribute : Attribute { + return typeof(TAttribute) + .GetCustomAttributes(typeof(AttributeUsageAttribute), false) + .Cast() + .FirstOrDefault(); + } + + /// + /// Gets the AttributeUsageAttribute for a given attribute type. + /// + public static AttributeUsageAttribute? GetAttributeUsage(Type attributeType) { + return attributeType + .GetCustomAttributes(typeof(AttributeUsageAttribute), false) + .Cast() + .FirstOrDefault(); + } +} diff --git a/tests/Whizbang.Core.Tests/Integration/SecurityIntegrationTests.cs b/tests/Whizbang.Core.Tests/Integration/SecurityIntegrationTests.cs index c4f7dc02..03e7a67f 100644 --- a/tests/Whizbang.Core.Tests/Integration/SecurityIntegrationTests.cs +++ b/tests/Whizbang.Core.Tests/Integration/SecurityIntegrationTests.cs @@ -335,18 +335,18 @@ public async Task PerspectiveScope_WithAllowedPrincipals_WorksCorrectly_Async() SecurityPrincipalId.Group("managers"), SecurityPrincipalId.Group("finance-team") ], - Extensions = new Dictionary { - ["department"] = "Engineering", - ["costCenter"] = "CC-123" - } + Extensions = [ + new ScopeExtension("department", "Engineering"), + new ScopeExtension("costCenter", "CC-123") + ] }; - // Act & Assert - Indexer access - await Assert.That(scope["TenantId"]).IsEqualTo("tenant-1"); - await Assert.That(scope["UserId"]).IsEqualTo("user-1"); - await Assert.That(scope["department"]).IsEqualTo("Engineering"); - await Assert.That(scope["costCenter"]).IsEqualTo("CC-123"); - await Assert.That(scope["unknown"]).IsNull(); + // Act & Assert - GetValue access + await Assert.That(scope.GetValue("TenantId")).IsEqualTo("tenant-1"); + await Assert.That(scope.GetValue("UserId")).IsEqualTo("user-1"); + await Assert.That(scope.GetValue("department")).IsEqualTo("Engineering"); + await Assert.That(scope.GetValue("costCenter")).IsEqualTo("CC-123"); + await Assert.That(scope.GetValue("unknown")).IsNull(); // Principal checks await Assert.That(scope.AllowedPrincipals!.Count).IsEqualTo(3); diff --git a/tests/Whizbang.Core.Tests/Perspectives/EnumValueTests.cs b/tests/Whizbang.Core.Tests/Perspectives/EnumValueTests.cs new file mode 100644 index 00000000..26625dc4 --- /dev/null +++ b/tests/Whizbang.Core.Tests/Perspectives/EnumValueTests.cs @@ -0,0 +1,82 @@ +using TUnit.Core; +using Whizbang.Core.Perspectives; + +namespace Whizbang.Core.Tests.Perspectives; + +/// +/// Consolidated tests for perspective-related enums. +/// Validates enum values, defaults, and parsing. +/// +/// perspectives/physical-fields +[Category("Core")] +[Category("Enums")] +public class EnumValueTests { + #region FieldStorageMode Tests + + [Test] + [Arguments(FieldStorageMode.JsonOnly, 0)] + [Arguments(FieldStorageMode.Extracted, 1)] + [Arguments(FieldStorageMode.Split, 2)] + public async Task FieldStorageMode_HasExpectedValueAsync(FieldStorageMode mode, int expected) { + await Assert.That((int)mode).IsEqualTo(expected); + } + + [Test] + public async Task FieldStorageMode_Default_IsJsonOnlyAsync() { + FieldStorageMode defaultValue = default; + await Assert.That(defaultValue).IsEqualTo(FieldStorageMode.JsonOnly); + } + + [Test] + public async Task FieldStorageMode_HasExactlyThreeValuesAsync() { + await Assert.That(Enum.GetValues()).Count().IsEqualTo(3); + } + + #endregion + + #region VectorDistanceMetric Tests + + [Test] + [Arguments(VectorDistanceMetric.L2, 0)] + [Arguments(VectorDistanceMetric.InnerProduct, 1)] + [Arguments(VectorDistanceMetric.Cosine, 2)] + public async Task VectorDistanceMetric_HasExpectedValueAsync(VectorDistanceMetric metric, int expected) { + await Assert.That((int)metric).IsEqualTo(expected); + } + + [Test] + public async Task VectorDistanceMetric_Default_IsL2Async() { + VectorDistanceMetric defaultValue = default; + await Assert.That(defaultValue).IsEqualTo(VectorDistanceMetric.L2); + } + + [Test] + public async Task VectorDistanceMetric_HasExactlyThreeValuesAsync() { + await Assert.That(Enum.GetValues()).Count().IsEqualTo(3); + } + + #endregion + + #region VectorIndexType Tests + + [Test] + [Arguments(VectorIndexType.None, 0)] + [Arguments(VectorIndexType.IVFFlat, 1)] + [Arguments(VectorIndexType.HNSW, 2)] + public async Task VectorIndexType_HasExpectedValueAsync(VectorIndexType indexType, int expected) { + await Assert.That((int)indexType).IsEqualTo(expected); + } + + [Test] + public async Task VectorIndexType_Default_IsNoneAsync() { + VectorIndexType defaultValue = default; + await Assert.That(defaultValue).IsEqualTo(VectorIndexType.None); + } + + [Test] + public async Task VectorIndexType_HasExactlyThreeValuesAsync() { + await Assert.That(Enum.GetValues()).Count().IsEqualTo(3); + } + + #endregion +} diff --git a/tests/Whizbang.Core.Tests/Perspectives/PerspectiveStorageAttributeTests.cs b/tests/Whizbang.Core.Tests/Perspectives/PerspectiveStorageAttributeTests.cs new file mode 100644 index 00000000..a7ee46b1 --- /dev/null +++ b/tests/Whizbang.Core.Tests/Perspectives/PerspectiveStorageAttributeTests.cs @@ -0,0 +1,45 @@ +using TUnit.Core; +using Whizbang.Core.Perspectives; +using Whizbang.Core.Tests.Helpers; + +namespace Whizbang.Core.Tests.Perspectives; + +/// +/// Tests for . +/// Validates attribute behavior, properties, and targeting rules. +/// +/// perspectives/physical-fields +[Category("Core")] +[Category("Attributes")] +[Category("PhysicalFields")] +public class PerspectiveStorageAttributeTests { + [Test] + [Arguments(FieldStorageMode.JsonOnly)] + [Arguments(FieldStorageMode.Extracted)] + [Arguments(FieldStorageMode.Split)] + public async Task PerspectiveStorageAttribute_Constructor_SetsModeAsync(FieldStorageMode mode) { + var attribute = new PerspectiveStorageAttribute(mode); + await Assert.That(attribute.Mode).IsEqualTo(mode); + } + + [Test] + public async Task PerspectiveStorageAttribute_AttributeUsage_AllowsClassAndStructAsync() { + var attributeUsage = AttributeTestHelpers.GetAttributeUsage(); + await Assert.That(attributeUsage).IsNotNull(); + await Assert.That(attributeUsage!.ValidOn.HasFlag(AttributeTargets.Class)).IsTrue(); + await Assert.That(attributeUsage.ValidOn.HasFlag(AttributeTargets.Struct)).IsTrue(); + } + + [Test] + public async Task PerspectiveStorageAttribute_AttributeUsage_DoesNotAllowMultiple_NotInheritedAsync() { + var attributeUsage = AttributeTestHelpers.GetAttributeUsage(); + await Assert.That(attributeUsage).IsNotNull(); + await Assert.That(attributeUsage!.AllowMultiple).IsFalse(); + await Assert.That(attributeUsage.Inherited).IsFalse(); + } + + [Test] + public async Task PerspectiveStorageAttribute_IsSealedAsync() { + await Assert.That(typeof(PerspectiveStorageAttribute).IsSealed).IsTrue(); + } +} diff --git a/tests/Whizbang.Core.Tests/Perspectives/PhysicalFieldAttributeTests.cs b/tests/Whizbang.Core.Tests/Perspectives/PhysicalFieldAttributeTests.cs new file mode 100644 index 00000000..ded1049c --- /dev/null +++ b/tests/Whizbang.Core.Tests/Perspectives/PhysicalFieldAttributeTests.cs @@ -0,0 +1,53 @@ +using TUnit.Core; +using Whizbang.Core.Perspectives; +using Whizbang.Core.Tests.Helpers; + +namespace Whizbang.Core.Tests.Perspectives; + +/// +/// Tests for . +/// Validates attribute behavior, properties, and targeting rules. +/// +/// perspectives/physical-fields +[Category("Core")] +[Category("Attributes")] +[Category("PhysicalFields")] +public class PhysicalFieldAttributeTests { + [Test] + public async Task PhysicalFieldAttribute_DefaultConstructor_HasDefaultValuesAsync() { + var attribute = new PhysicalFieldAttribute(); + await Assert.That(attribute.Indexed).IsFalse(); + await Assert.That(attribute.Unique).IsFalse(); + await Assert.That(attribute.ColumnName).IsNull(); + await Assert.That(attribute.MaxLength).IsEqualTo(-1); + } + + [Test] + public async Task PhysicalFieldAttribute_Properties_CanBeSetAsync() { + var attribute = new PhysicalFieldAttribute { + Indexed = true, + Unique = true, + ColumnName = "custom_column", + MaxLength = 200 + }; + + await Assert.That(attribute.Indexed).IsTrue(); + await Assert.That(attribute.Unique).IsTrue(); + await Assert.That(attribute.ColumnName).IsEqualTo("custom_column"); + await Assert.That(attribute.MaxLength).IsEqualTo(200); + } + + [Test] + public async Task PhysicalFieldAttribute_AttributeUsage_PropertyOnly_AllowsMultiple_IsInheritedAsync() { + var attributeUsage = AttributeTestHelpers.GetAttributeUsage(); + await Assert.That(attributeUsage).IsNotNull(); + await Assert.That(attributeUsage!.ValidOn).IsEqualTo(AttributeTargets.Property); + await Assert.That(attributeUsage.AllowMultiple).IsFalse(); + await Assert.That(attributeUsage.Inherited).IsTrue(); + } + + [Test] + public async Task PhysicalFieldAttribute_IsSealedAsync() { + await Assert.That(typeof(PhysicalFieldAttribute).IsSealed).IsTrue(); + } +} diff --git a/tests/Whizbang.Core.Tests/Perspectives/VectorFieldAttributeTests.cs b/tests/Whizbang.Core.Tests/Perspectives/VectorFieldAttributeTests.cs new file mode 100644 index 00000000..16b54f93 --- /dev/null +++ b/tests/Whizbang.Core.Tests/Perspectives/VectorFieldAttributeTests.cs @@ -0,0 +1,74 @@ +using TUnit.Core; +using Whizbang.Core.Perspectives; +using Whizbang.Core.Tests.Helpers; + +namespace Whizbang.Core.Tests.Perspectives; + +/// +/// Tests for . +/// Validates attribute behavior, properties, and targeting rules. +/// +/// perspectives/vector-fields +[Category("Core")] +[Category("Attributes")] +[Category("PhysicalFields")] +[Category("Vectors")] +public class VectorFieldAttributeTests { + [Test] + [Arguments(1)] + [Arguments(384)] + [Arguments(768)] + [Arguments(1536)] + [Arguments(4096)] + public async Task VectorFieldAttribute_Constructor_AcceptsValidDimensionsAsync(int dimensions) { + var attribute = new VectorFieldAttribute(dimensions); + await Assert.That(attribute.Dimensions).IsEqualTo(dimensions); + } + + [Test] + public async Task VectorFieldAttribute_Constructor_HasDefaultValuesAsync() { + var attribute = new VectorFieldAttribute(768); + await Assert.That(attribute.DistanceMetric).IsEqualTo(VectorDistanceMetric.Cosine); + await Assert.That(attribute.Indexed).IsTrue(); + await Assert.That(attribute.IndexType).IsEqualTo(VectorIndexType.IVFFlat); + await Assert.That(attribute.IndexLists).IsEqualTo(100); + await Assert.That(attribute.ColumnName).IsNull(); + } + + [Test] + public async Task VectorFieldAttribute_Constructor_ThrowsForInvalidDimensionsAsync() { + await Assert.That(() => new VectorFieldAttribute(0)).Throws(); + await Assert.That(() => new VectorFieldAttribute(-1)).Throws(); + } + + [Test] + public async Task VectorFieldAttribute_Properties_CanBeSetAsync() { + var attribute = new VectorFieldAttribute(1536) { + DistanceMetric = VectorDistanceMetric.L2, + Indexed = false, + IndexType = VectorIndexType.HNSW, + IndexLists = 200, + ColumnName = "embedding_vec" + }; + + await Assert.That(attribute.DistanceMetric).IsEqualTo(VectorDistanceMetric.L2); + await Assert.That(attribute.Indexed).IsFalse(); + await Assert.That(attribute.IndexType).IsEqualTo(VectorIndexType.HNSW); + await Assert.That(attribute.IndexLists).IsEqualTo(200); + await Assert.That(attribute.ColumnName).IsEqualTo("embedding_vec"); + } + + [Test] + public async Task VectorFieldAttribute_AttributeUsage_PropertyOnly_NotMultiple_IsInheritedAsync() { + var attributeUsage = AttributeTestHelpers.GetAttributeUsage(); + await Assert.That(attributeUsage).IsNotNull(); + await Assert.That(attributeUsage!.ValidOn).IsEqualTo(AttributeTargets.Property); + await Assert.That(attributeUsage.AllowMultiple).IsFalse(); + await Assert.That(attributeUsage.Inherited).IsTrue(); + } + + [Test] + public async Task VectorFieldAttribute_IsSealedAsync() { + await Assert.That(typeof(VectorFieldAttribute).IsSealed).IsTrue(); + } +} diff --git a/tests/Whizbang.Core.Tests/Scoping/PerspectiveScopeTests.cs b/tests/Whizbang.Core.Tests/Scoping/PerspectiveScopeTests.cs index 627ca78b..c638520b 100644 --- a/tests/Whizbang.Core.Tests/Scoping/PerspectiveScopeTests.cs +++ b/tests/Whizbang.Core.Tests/Scoping/PerspectiveScopeTests.cs @@ -46,113 +46,109 @@ public async Task PerspectiveScope_OrganizationId_ReturnsValueAsync() { await Assert.That(scope.OrganizationId).IsEqualTo("org-abc"); } - // === Indexer Tests === + // === GetValue Tests (replaced indexer for EF Core ComplexProperty compatibility) === [Test] - public async Task PerspectiveScope_Indexer_StandardProperty_TenantId_ReturnsValueAsync() { + public async Task PerspectiveScope_GetValue_StandardProperty_TenantId_ReturnsValueAsync() { // Arrange var scope = new PerspectiveScope { TenantId = "tenant-123" }; // Act - var value = scope["TenantId"]; + var value = scope.GetValue("TenantId"); // Assert await Assert.That(value).IsEqualTo("tenant-123"); } [Test] - public async Task PerspectiveScope_Indexer_StandardProperty_CustomerId_ReturnsValueAsync() { + public async Task PerspectiveScope_GetValue_StandardProperty_CustomerId_ReturnsValueAsync() { // Arrange var scope = new PerspectiveScope { CustomerId = "customer-456" }; // Act - var value = scope["CustomerId"]; + var value = scope.GetValue("CustomerId"); // Assert await Assert.That(value).IsEqualTo("customer-456"); } [Test] - public async Task PerspectiveScope_Indexer_StandardProperty_UserId_ReturnsValueAsync() { + public async Task PerspectiveScope_GetValue_StandardProperty_UserId_ReturnsValueAsync() { // Arrange var scope = new PerspectiveScope { UserId = "user-789" }; // Act - var value = scope["UserId"]; + var value = scope.GetValue("UserId"); // Assert await Assert.That(value).IsEqualTo("user-789"); } [Test] - public async Task PerspectiveScope_Indexer_StandardProperty_OrganizationId_ReturnsValueAsync() { + public async Task PerspectiveScope_GetValue_StandardProperty_OrganizationId_ReturnsValueAsync() { // Arrange var scope = new PerspectiveScope { OrganizationId = "org-abc" }; // Act - var value = scope["OrganizationId"]; + var value = scope.GetValue("OrganizationId"); // Assert await Assert.That(value).IsEqualTo("org-abc"); } [Test] - public async Task PerspectiveScope_Indexer_Extension_ReturnsValueAsync() { + public async Task PerspectiveScope_GetValue_Extension_ReturnsValueAsync() { // Arrange var scope = new PerspectiveScope { - Extensions = new Dictionary { - ["CustomField"] = "custom-value", - ["AnotherField"] = "another-value" - } + Extensions = [ + new ScopeExtension("CustomField", "custom-value"), + new ScopeExtension("AnotherField", "another-value") + ] }; // Act - var value = scope["CustomField"]; + var value = scope.GetValue("CustomField"); // Assert await Assert.That(value).IsEqualTo("custom-value"); } [Test] - public async Task PerspectiveScope_Indexer_Extension_WithNullValue_ReturnsNullAsync() { + public async Task PerspectiveScope_GetValue_Extension_WithNullValue_ReturnsNullAsync() { // Arrange var scope = new PerspectiveScope { - Extensions = new Dictionary { - ["NullableField"] = null - } + Extensions = [new ScopeExtension("NullableField", null)] }; // Act - var value = scope["NullableField"]; + var value = scope.GetValue("NullableField"); // Assert await Assert.That(value).IsNull(); } [Test] - public async Task PerspectiveScope_Indexer_Unknown_ReturnsNullAsync() { + public async Task PerspectiveScope_GetValue_Unknown_ReturnsNullAsync() { // Arrange var scope = new PerspectiveScope { TenantId = "tenant-123", - Extensions = new Dictionary { - ["CustomField"] = "custom-value" - } + Extensions = [new ScopeExtension("CustomField", "custom-value")] }; // Act - var value = scope["UnknownField"]; + var value = scope.GetValue("UnknownField"); // Assert await Assert.That(value).IsNull(); } [Test] - public async Task PerspectiveScope_Indexer_NoExtensions_ReturnsNullForCustomFieldAsync() { + public async Task PerspectiveScope_GetValue_NoExtensions_ReturnsNullForCustomFieldAsync() { // Arrange var scope = new PerspectiveScope { TenantId = "tenant-123" }; // Act - var value = scope["CustomField"]; + var value = scope.GetValue("CustomField"); // Assert await Assert.That(value).IsNull(); @@ -162,10 +158,10 @@ public async Task PerspectiveScope_Indexer_NoExtensions_ReturnsNullForCustomFiel [Test] public async Task PerspectiveScope_AllowedPrincipals_StoresPrincipalsAsync() { - // Arrange - var principals = new List { - SecurityPrincipalId.Group("sales-team"), - SecurityPrincipalId.User("manager-456") + // Arrange - AllowedPrincipals now stores string values directly + var principals = new List { + "group:sales-team", + "user:manager-456" }; var scope = new PerspectiveScope { @@ -176,17 +172,18 @@ public async Task PerspectiveScope_AllowedPrincipals_StoresPrincipalsAsync() { // Assert await Assert.That(scope.AllowedPrincipals).IsNotNull(); await Assert.That(scope.AllowedPrincipals!.Count).IsEqualTo(2); - await Assert.That(scope.AllowedPrincipals).Contains(SecurityPrincipalId.Group("sales-team")); - await Assert.That(scope.AllowedPrincipals).Contains(SecurityPrincipalId.User("manager-456")); + await Assert.That(scope.AllowedPrincipals).Contains("group:sales-team"); + await Assert.That(scope.AllowedPrincipals).Contains("user:manager-456"); } [Test] - public async Task PerspectiveScope_AllowedPrincipals_WhenNull_ReturnsNullAsync() { - // Arrange + public async Task PerspectiveScope_AllowedPrincipals_DefaultsToEmptyListAsync() { + // Arrange - AllowedPrincipals now defaults to empty list (not null) for JSON serialization var scope = new PerspectiveScope { TenantId = "tenant-123" }; // Assert - await Assert.That(scope.AllowedPrincipals).IsNull(); + await Assert.That(scope.AllowedPrincipals).IsNotNull(); + await Assert.That(scope.AllowedPrincipals.Count).IsEqualTo(0); } [Test] @@ -202,10 +199,12 @@ public async Task PerspectiveScope_AllowedPrincipals_EmptyList_ReturnsEmptyListA await Assert.That(scope.AllowedPrincipals!.Count).IsEqualTo(0); } - // === Record Equality Tests === + // === Class Property Equality Tests === + // Note: PerspectiveScope is a class (not record) for EF Core compatibility. + // Classes use reference equality by default, so we test property values directly. [Test] - public async Task PerspectiveScope_Equals_SameProperties_ReturnsTrueAsync() { + public async Task PerspectiveScope_SameProperties_HaveMatchingValuesAsync() { // Arrange var scope1 = new PerspectiveScope { TenantId = "tenant-123", @@ -216,36 +215,22 @@ public async Task PerspectiveScope_Equals_SameProperties_ReturnsTrueAsync() { UserId = "user-456" }; - // Assert - await Assert.That(scope1).IsEqualTo(scope2); + // Assert - compare property values since class uses reference equality + await Assert.That(scope1.TenantId).IsEqualTo(scope2.TenantId); + await Assert.That(scope1.UserId).IsEqualTo(scope2.UserId); } [Test] - public async Task PerspectiveScope_Equals_DifferentProperties_ReturnsFalseAsync() { + public async Task PerspectiveScope_DifferentProperties_HaveDifferentValuesAsync() { // Arrange var scope1 = new PerspectiveScope { TenantId = "tenant-123" }; var scope2 = new PerspectiveScope { TenantId = "tenant-456" }; - // Assert - await Assert.That(scope1).IsNotEqualTo(scope2); + // Assert - compare property values + await Assert.That(scope1.TenantId).IsNotEqualTo(scope2.TenantId); } - // === With Expression Tests === - - [Test] - public async Task PerspectiveScope_With_CreatesNewInstanceWithModifiedPropertyAsync() { - // Arrange - var original = new PerspectiveScope { - TenantId = "tenant-123", - UserId = "user-456" - }; - - // Act - var modified = original with { UserId = "user-789" }; - - // Assert - await Assert.That(modified.TenantId).IsEqualTo("tenant-123"); - await Assert.That(modified.UserId).IsEqualTo("user-789"); - await Assert.That(original.UserId).IsEqualTo("user-456"); // Original unchanged - } + // Note: `with` expression tests removed - PerspectiveScope is a class (not record) + // for EF Core 10 ComplexProperty().ToJson() compatibility. Classes don't support `with`. + // Properties remain init-only for construction-time assignment. } diff --git a/tests/Whizbang.Core.Tests/Security/SecurityPrincipalIdJsonConverterTests.cs b/tests/Whizbang.Core.Tests/Security/SecurityPrincipalIdJsonConverterTests.cs index 228d8c25..4ef0cb27 100644 --- a/tests/Whizbang.Core.Tests/Security/SecurityPrincipalIdJsonConverterTests.cs +++ b/tests/Whizbang.Core.Tests/Security/SecurityPrincipalIdJsonConverterTests.cs @@ -123,7 +123,7 @@ public async Task Deserialize_PerspectiveScopeWithAllowedPrincipals_ReadsCorrect await Assert.That(scope!.TenantId).IsEqualTo("tenant-123"); await Assert.That(scope.AllowedPrincipals).IsNotNull(); await Assert.That(scope.AllowedPrincipals!.Count).IsEqualTo(2); - await Assert.That(scope.AllowedPrincipals[0].Value).IsEqualTo("user:user-456"); - await Assert.That(scope.AllowedPrincipals[1].Value).IsEqualTo("group:team-A"); + await Assert.That(scope.AllowedPrincipals[0]).IsEqualTo("user:user-456"); + await Assert.That(scope.AllowedPrincipals[1]).IsEqualTo("group:team-A"); } } diff --git a/tests/Whizbang.Core.Tests/ValueObjects/IdentityValueObjectTests.cs b/tests/Whizbang.Core.Tests/ValueObjects/IdentityValueObjectTests.cs index d5c7065c..7b9e2e5d 100644 --- a/tests/Whizbang.Core.Tests/ValueObjects/IdentityValueObjectTests.cs +++ b/tests/Whizbang.Core.Tests/ValueObjects/IdentityValueObjectTests.cs @@ -116,18 +116,20 @@ public async Task AllIdTypes_ImplementIWhizbangIdAsync() { } [Test] - public async Task AllIdTypes_HaveSubMillisecondPrecisionAsync() { - // Arrange & Act - Generate one of each ID type + public async Task AllIdTypes_SubMillisecondPrecision_ReturnsTrueForFreshIdsAsync() { + // Arrange & Act - Generate one of each ID type via New() var messageId = MessageId.New(); var correlationId = CorrelationId.New(); var streamId = StreamId.New(); var eventId = EventId.New(); - // Assert - All should have sub-millisecond precision (Medo-generated) - await Assert.That(messageId.SubMillisecondPrecision).IsTrue(); - await Assert.That(correlationId.SubMillisecondPrecision).IsTrue(); - await Assert.That(streamId.SubMillisecondPrecision).IsTrue(); - await Assert.That(eventId.SubMillisecondPrecision).IsTrue(); + // Assert - All freshly created IDs should return true for sub-millisecond precision + // Note: IDs created via New() use TrackedGuid.NewMedo() which has sub-ms precision. + // After serialization/deserialization, this will return false (metadata lost). + await Assert.That(messageId.GetSubMillisecondPrecision()).IsTrue(); + await Assert.That(correlationId.GetSubMillisecondPrecision()).IsTrue(); + await Assert.That(streamId.GetSubMillisecondPrecision()).IsTrue(); + await Assert.That(eventId.GetSubMillisecondPrecision()).IsTrue(); } [Test] @@ -139,10 +141,47 @@ public async Task AllIdTypes_AreTimeOrderedAsync() { var eventId = EventId.New(); // Assert - All should be time-ordered (UUIDv7) - await Assert.That(messageId.IsTimeOrdered).IsTrue(); - await Assert.That(correlationId.IsTimeOrdered).IsTrue(); - await Assert.That(streamId.IsTimeOrdered).IsTrue(); - await Assert.That(eventId.IsTimeOrdered).IsTrue(); + // Use public methods since metadata properties are explicit interface implementations + await Assert.That(messageId.GetIsTimeOrdered()).IsTrue(); + await Assert.That(correlationId.GetIsTimeOrdered()).IsTrue(); + await Assert.That(streamId.GetIsTimeOrdered()).IsTrue(); + await Assert.That(eventId.GetIsTimeOrdered()).IsTrue(); + } + + [Test] + public async Task AllIdTypes_FreshIds_AreTrackingAsync() { + // Arrange & Act - Generate one of each ID type via New() + var messageId = MessageId.New(); + var correlationId = CorrelationId.New(); + var streamId = StreamId.New(); + var eventId = EventId.New(); + + // Assert - All freshly created IDs should have tracking metadata + await Assert.That(messageId.GetIsTracking()).IsTrue(); + await Assert.That(correlationId.GetIsTracking()).IsTrue(); + await Assert.That(streamId.GetIsTracking()).IsTrue(); + await Assert.That(eventId.GetIsTracking()).IsTrue(); + } + + [Test] + public async Task AllIdTypes_DeserializedIds_AreNotTrackingAsync() { + // Arrange - Create IDs and simulate deserialization by reconstructing from Guid + var messageGuid = MessageId.New().Value; + var correlationGuid = CorrelationId.New().Value; + var streamGuid = StreamId.New().Value; + var eventGuid = EventId.New().Value; + + // Act - Reconstruct IDs from Guids (simulates DB/JSON deserialization) + var messageId = MessageId.From(messageGuid); + var correlationId = CorrelationId.From(correlationGuid); + var streamId = StreamId.From(streamGuid); + var eventId = EventId.From(eventGuid); + + // Assert - Deserialized IDs should NOT have tracking metadata + await Assert.That(messageId.GetIsTracking()).IsFalse(); + await Assert.That(correlationId.GetIsTracking()).IsFalse(); + await Assert.That(streamId.GetIsTracking()).IsFalse(); + await Assert.That(eventId.GetIsTracking()).IsFalse(); } #endregion diff --git a/tests/Whizbang.Core.Tests/ValueObjects/TrackedGuidTests.cs b/tests/Whizbang.Core.Tests/ValueObjects/TrackedGuidTests.cs index 3f60c9f5..87f0fc22 100644 --- a/tests/Whizbang.Core.Tests/ValueObjects/TrackedGuidTests.cs +++ b/tests/Whizbang.Core.Tests/ValueObjects/TrackedGuidTests.cs @@ -14,66 +14,15 @@ public class TrackedGuidTests { // ======================================== [Test] - public async Task GuidMetadata_Version7_HasCorrectFlagValueAsync() { - // Arrange & Act - var flag = GuidMetadata.Version7; - - // Assert - bit 1 should be set - await Assert.That((byte)flag).IsEqualTo((byte)(1 << 1)); - } - - [Test] - public async Task GuidMetadata_Version4_HasCorrectFlagValueAsync() { - // Arrange & Act - var flag = GuidMetadata.Version4; - - // Assert - bit 0 should be set - await Assert.That((byte)flag).IsEqualTo((byte)(1 << 0)); - } - - [Test] - public async Task GuidMetadata_SourceMedo_HasCorrectFlagValueAsync() { - // Arrange & Act - var flag = GuidMetadata.SourceMedo; - - // Assert - bit 2 should be set - await Assert.That((byte)flag).IsEqualTo((byte)(1 << 2)); - } - - [Test] - public async Task GuidMetadata_SourceMicrosoft_HasCorrectFlagValueAsync() { - // Arrange & Act - var flag = GuidMetadata.SourceMicrosoft; - - // Assert - bit 3 should be set - await Assert.That((byte)flag).IsEqualTo((byte)(1 << 3)); - } - - [Test] - public async Task GuidMetadata_SourceParsed_HasCorrectFlagValueAsync() { - // Arrange & Act - var flag = GuidMetadata.SourceParsed; - - // Assert - bit 4 should be set - await Assert.That((byte)flag).IsEqualTo((byte)(1 << 4)); - } - - [Test] - public async Task GuidMetadata_SourceExternal_HasCorrectFlagValueAsync() { - // Arrange & Act - var flag = GuidMetadata.SourceExternal; - - // Assert - bit 5 should be set - await Assert.That((byte)flag).IsEqualTo((byte)(1 << 5)); - } - - [Test] - public async Task GuidMetadata_SourceUnknown_HasCorrectFlagValueAsync() { - // Arrange & Act - var flag = GuidMetadata.SourceUnknown; - - // Assert - bit 6 should be set - await Assert.That((byte)flag).IsEqualTo((byte)(1 << 6)); + [Arguments(GuidMetadata.Version4, 0)] + [Arguments(GuidMetadata.Version7, 1)] + [Arguments(GuidMetadata.SourceMedo, 2)] + [Arguments(GuidMetadata.SourceMicrosoft, 3)] + [Arguments(GuidMetadata.SourceParsed, 4)] + [Arguments(GuidMetadata.SourceExternal, 5)] + [Arguments(GuidMetadata.SourceUnknown, 6)] + public async Task GuidMetadata_Flags_HaveCorrectBitPositionsAsync(GuidMetadata flag, int bitPosition) { + await Assert.That((byte)flag).IsEqualTo((byte)(1 << bitPosition)); } [Test] @@ -493,6 +442,29 @@ public async Task TrackedGuid_ToString_ReturnsGuidStringAsync() { await Assert.That(stringValue).IsEqualTo(tracked.Value.ToString()); } + // ======================================== + // IsTracking Property Tests + // ======================================== + + [Test] + public async Task TrackedGuid_IsTracking_OnlyAuthoritativeSourcesReturnTrueAsync() { + // Arrange & Act - Create via different methods + var medo = TrackedGuid.NewMedo(); + var microsoftV7 = TrackedGuid.NewMicrosoftV7(); + var random = TrackedGuid.NewRandom(); + var external = TrackedGuid.FromExternal(Guid.CreateVersion7()); + var parsed = TrackedGuid.Parse(Guid.CreateVersion7().ToString()); + TrackedGuid implicit_ = Guid.CreateVersion7(); + + // Assert - Only generation methods have authoritative metadata + await Assert.That(medo.IsTracking).IsTrue(); + await Assert.That(microsoftV7.IsTracking).IsTrue(); + await Assert.That(random.IsTracking).IsTrue(); + await Assert.That(external.IsTracking).IsFalse(); + await Assert.That(parsed.IsTracking).IsFalse(); + await Assert.That(implicit_.IsTracking).IsFalse(); + } + // ======================================== // Default Value Tests // ======================================== diff --git a/tests/Whizbang.Data.EFCore.Postgres.Tests/ComplexTypeJsonMappingTests.cs b/tests/Whizbang.Data.EFCore.Postgres.Tests/ComplexTypeJsonMappingTests.cs new file mode 100644 index 00000000..2e9f2b06 --- /dev/null +++ b/tests/Whizbang.Data.EFCore.Postgres.Tests/ComplexTypeJsonMappingTests.cs @@ -0,0 +1,361 @@ +using Dapper; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; +using TUnit.Core; +using Whizbang.Core; +using Whizbang.Core.Lenses; +using Whizbang.Core.Perspectives; +using Whizbang.Data.EFCore.Postgres.QueryTranslation; +using Whizbang.Testing.Containers; + +namespace Whizbang.Data.EFCore.Postgres.Tests; + +/// +/// Tests verifying EF Core 10 compatibility with Whizbang's JSONB mapping approach. +/// +/// +/// +/// EF Core 10 Migration Decision: +/// EF Core 10 introduces ComplexProperty().ToJson() as the recommended pattern for JSON mapping. +/// However, this approach requires all nested types to be explicitly configured as complex types, +/// which doesn't work well with our PerspectiveScope and PerspectiveMetadata types +/// that contain complex nested structures like IReadOnlyList<SecurityPrincipalId>. +/// +/// +/// Our Approach: +/// We continue using Property().HasColumnType("jsonb") which: +/// - Works with Npgsql's POCO serialization for any object structure +/// - Doesn't require explicit configuration of nested types +/// - Combined with our PhysicalFieldExpressionVisitor, provides unified query syntax +/// - Is still fully supported in Npgsql 10 +/// +/// +/// These tests verify that our current approach works correctly with EF Core 10. +/// +/// +[Category("Integration")] +[NotInParallel("PostgreSQL")] +public class ComplexTypeJsonMappingTests : IAsyncDisposable { + private static readonly Uuid7IdProvider _idProvider = new(); + + static ComplexTypeJsonMappingTests() { + AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", false); + } + + private string? _testDatabaseName; + private NpgsqlDataSource? _dataSource; + private EFCore10CompatDbContext? _context; + private string _connectionString = null!; + + /// + /// Test model with both physical and JSONB-only fields. + /// + public class ProductModel { + public string Name { get; set; } = string.Empty; + public decimal Price { get; set; } + public string? Description { get; set; } + public List Tags { get; set; } = []; + } + + /// + /// DbContext using current JSONB mapping pattern (compatible with EF Core 10). + /// Uses Property().HasColumnType("jsonb") - the proven pattern for complex types. + /// + private sealed class EFCore10CompatDbContext : DbContext { + public EFCore10CompatDbContext(DbContextOptions options) + : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity>(entity => { + entity.ToTable("wh_per_efcore10_test"); + entity.HasKey(e => e.Id); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.CreatedAt).HasColumnName("created_at"); + entity.Property(e => e.UpdatedAt).HasColumnName("updated_at"); + entity.Property(e => e.Version).HasColumnName("version"); + + // Current pattern: Property().HasColumnType("jsonb") + // This works with any object structure via Npgsql POCO serialization + // Still supported and recommended for complex types in Npgsql 10 + entity.Property(e => e.Data) + .HasColumnName("data") + .HasColumnType("jsonb") + .IsRequired(); + + entity.Property(e => e.Metadata) + .HasColumnName("metadata") + .HasColumnType("jsonb") + .IsRequired(); + + entity.Property(e => e.Scope) + .HasColumnName("scope") + .HasColumnType("jsonb") + .IsRequired(); + + // Physical fields as shadow properties + entity.Property("name").HasColumnName("name").HasMaxLength(200); + entity.Property("price").HasColumnName("price"); + + entity.HasIndex("name"); + entity.HasIndex("price"); + }); + } + } + + [Before(Test)] + public async Task SetupAsync() { + await SharedPostgresContainer.InitializeAsync(); + + _testDatabaseName = $"efcore10_test_{Guid.NewGuid():N}"; + + await using var adminConnection = new NpgsqlConnection(SharedPostgresContainer.ConnectionString); + await adminConnection.OpenAsync(); + await adminConnection.ExecuteAsync($"CREATE DATABASE {_testDatabaseName}"); + + var builder = new NpgsqlConnectionStringBuilder(SharedPostgresContainer.ConnectionString) { + Database = _testDatabaseName, + Timezone = "UTC", + IncludeErrorDetail = true + }; + _connectionString = builder.ConnectionString; + + var dataSourceBuilder = new NpgsqlDataSourceBuilder(_connectionString); + dataSourceBuilder.EnableDynamicJson(); + _dataSource = dataSourceBuilder.Build(); + + // Register physical fields + PhysicalFieldRegistry.Clear(); + PhysicalFieldRegistry.Register("Name", "name"); + PhysicalFieldRegistry.Register("Price", "price"); + + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder + .UseNpgsql(_dataSource) + .UseWhizbangPhysicalFields() + .ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.CoreEventId.ManyServiceProvidersCreatedWarning)); + _context = new EFCore10CompatDbContext(optionsBuilder.Options); + + await _initializeSchemaAsync(); + await _seedTestDataAsync(); + } + + [After(Test)] + public async Task TeardownAsync() { + if (_context != null) { + await _context.DisposeAsync(); + _context = null; + } + + if (_dataSource != null) { + await _dataSource.DisposeAsync(); + _dataSource = null; + } + + if (_testDatabaseName != null) { + try { + await using var adminConnection = new NpgsqlConnection(SharedPostgresContainer.ConnectionString); + await adminConnection.OpenAsync(); + + await adminConnection.ExecuteAsync($@" + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = '{_testDatabaseName}' + AND pid <> pg_backend_pid()"); + + await adminConnection.ExecuteAsync($"DROP DATABASE IF EXISTS {_testDatabaseName}"); + } catch { + // Ignore cleanup errors + } + + _testDatabaseName = null; + } + } + + public async ValueTask DisposeAsync() { + await TeardownAsync(); + GC.SuppressFinalize(this); + } + + private async Task _initializeSchemaAsync() { + await using var connection = new NpgsqlConnection(_connectionString); + await connection.OpenAsync(); + + await connection.ExecuteAsync(""" + CREATE TABLE IF NOT EXISTS wh_per_efcore10_test ( + id UUID PRIMARY KEY, + data JSONB NOT NULL, + metadata JSONB NOT NULL, + scope JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + version INTEGER NOT NULL, + name VARCHAR(200), + price DECIMAL NOT NULL DEFAULT 0 + ); + + CREATE INDEX IF NOT EXISTS idx_efcore10_test_name ON wh_per_efcore10_test(name); + CREATE INDEX IF NOT EXISTS idx_efcore10_test_price ON wh_per_efcore10_test(price); + """); + } + + private async Task _seedTestDataAsync() { + var strategy = new PostgresUpsertStrategy(); + + var testProducts = new[] { + new ProductModel { Name = "Widget A", Price = 15.00m, Description = "A basic widget", Tags = ["widget", "basic"] }, + new ProductModel { Name = "Widget B", Price = 25.00m, Description = "A better widget", Tags = ["widget", "premium"] }, + new ProductModel { Name = "Gadget X", Price = 50.00m, Description = "A useful gadget", Tags = ["gadget"] }, + }; + + var metadata = new PerspectiveMetadata { + EventType = "ProductCreated", + EventId = Guid.NewGuid().ToString(), + Timestamp = DateTime.UtcNow + }; + var scope = new PerspectiveScope(); + + foreach (var product in testProducts) { + var id = _idProvider.NewGuid(); + var physicalFields = new Dictionary { + { "name", product.Name }, + { "price", product.Price } + }; + + await strategy.UpsertPerspectiveRowWithPhysicalFieldsAsync( + _context!, + "wh_per_efcore10_test", + id, + product, + metadata, + scope, + physicalFields); + } + } + + // ==================== EF CORE 10 COMPATIBILITY TESTS ==================== + + /// + /// Verifies current JSONB pattern works in EF Core 10. + /// + [Test] + [Timeout(60000)] + public async Task EFCore10_CurrentJsonbPattern_WorksCorrectlyAsync(CancellationToken cancellationToken) { + // Arrange & Act - read data using current pattern + var results = await _context!.Set>() + .ToListAsync(cancellationToken); + + // Assert + await Assert.That(results).Count().IsEqualTo(3); + + var widgetA = results.FirstOrDefault(r => r.Data.Name == "Widget A"); + await Assert.That(widgetA).IsNotNull(); + await Assert.That(widgetA!.Data.Price).IsEqualTo(15.00m); + await Assert.That(widgetA.Data.Description).IsEqualTo("A basic widget"); + await Assert.That(widgetA.Data.Tags).Contains("widget"); + } + + /// + /// Verifies physical field translator works with EF Core 10. + /// + [Test] + [Timeout(60000)] + public async Task EFCore10_PhysicalFieldTranslator_InterceptsCorrectlyAsync(CancellationToken cancellationToken) { + // Arrange - query using unified syntax + var query = _context!.Set>() + .Where(r => r.Data.Price >= 25.00m); + + // Capture SQL + var sql = query.ToQueryString(); + + // Act + var results = await query.ToListAsync(cancellationToken); + + // Assert - SQL should use physical column, not JSONB extraction + await Assert.That(sql).DoesNotContain("data ->> 'Price'"); + await Assert.That(sql.ToLowerInvariant()).Contains(".price >= "); + + await Assert.That(results).Count().IsEqualTo(2); // Widget B and Gadget X + } + + /// + /// Verifies non-physical fields query via JSONB in EF Core 10. + /// + [Test] + [Timeout(60000)] + public async Task EFCore10_NonPhysicalField_UsesJsonbExtractionAsync(CancellationToken cancellationToken) { + // Arrange - query non-physical field (Description) + var query = _context!.Set>() + .Where(r => r.Data.Description!.Contains("basic")); + + // Act + var results = await query.ToListAsync(cancellationToken); + + // Assert - should find Widget A via JSONB query + await Assert.That(results).Count().IsEqualTo(1); + await Assert.That(results[0].Data.Name).IsEqualTo("Widget A"); + } + + /// + /// Verifies mixed physical and JSONB queries work in EF Core 10. + /// + [Test] + [Timeout(60000)] + public async Task EFCore10_MixedQuery_BothTranslateCorrectlyAsync(CancellationToken cancellationToken) { + // Arrange - combine physical (Price) and JSONB (Description) + var query = _context!.Set>() + .Where(r => r.Data.Price >= 25.00m) + .Where(r => r.Data.Description!.Contains("gadget")); + + // Act + var results = await query.ToListAsync(cancellationToken); + + // Assert - only Gadget X matches both criteria + await Assert.That(results).Count().IsEqualTo(1); + await Assert.That(results[0].Data.Name).IsEqualTo("Gadget X"); + } + + /// + /// Verifies Metadata and Scope complex types work in EF Core 10. + /// + [Test] + [Timeout(60000)] + public async Task EFCore10_MetadataAndScope_PreserveStructureAsync(CancellationToken cancellationToken) { + // Arrange & Act + var results = await _context!.Set>() + .ToListAsync(cancellationToken); + + // Assert - verify Metadata structure preserved + var first = results.First(); + await Assert.That(first.Metadata).IsNotNull(); + await Assert.That(first.Metadata.EventType).IsEqualTo("ProductCreated"); + await Assert.That(first.Metadata.EventId).IsNotNull(); + + // Assert - verify Scope structure preserved + await Assert.That(first.Scope).IsNotNull(); + } + + /// + /// Verifies OrderBy on physical fields works in EF Core 10. + /// + [Test] + [Timeout(60000)] + public async Task EFCore10_OrderByPhysicalField_UsesColumnAsync(CancellationToken cancellationToken) { + // Arrange - order by physical field + var query = _context!.Set>() + .OrderBy(r => r.Data.Price); + + // Act + var results = await query.ToListAsync(cancellationToken); + + // Assert - correct order + await Assert.That(results).Count().IsEqualTo(3); + await Assert.That(results[0].Data.Price).IsEqualTo(15.00m); + await Assert.That(results[1].Data.Price).IsEqualTo(25.00m); + await Assert.That(results[2].Data.Price).IsEqualTo(50.00m); + } +} diff --git a/tests/Whizbang.Data.EFCore.Postgres.Tests/EFCoreFilterableLensQueryTests.cs b/tests/Whizbang.Data.EFCore.Postgres.Tests/EFCoreFilterableLensQueryTests.cs index 5ec278c2..dc61d45a 100644 --- a/tests/Whizbang.Data.EFCore.Postgres.Tests/EFCoreFilterableLensQueryTests.cs +++ b/tests/Whizbang.Data.EFCore.Postgres.Tests/EFCoreFilterableLensQueryTests.cs @@ -26,7 +26,7 @@ private async Task _seedOrderAsync( string? userId = null, string? organizationId = null, string? customerId = null, - IReadOnlyList? allowedPrincipals = null) { + List? allowedPrincipals = null) { var order = new Order { OrderId = TestOrderId.From(orderId), @@ -47,7 +47,7 @@ private async Task _seedOrderAsync( UserId = userId, OrganizationId = organizationId, CustomerId = customerId, - AllowedPrincipals = allowedPrincipals + AllowedPrincipals = allowedPrincipals ?? [] }, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, @@ -215,20 +215,17 @@ public async Task Query_TenantAndPrincipalFilter_ReturnsMatchingPrincipalRowsAsy // Order 1: Shared with sales-team await _seedOrderAsync(context, order1Id, 100m, tenantId: "tenant-1", - allowedPrincipals: [SecurityPrincipalId.Group("sales-team")]); + allowedPrincipals: ["group:sales-team"]); // Order 2: Shared with engineering-team await _seedOrderAsync(context, order2Id, 200m, tenantId: "tenant-1", - allowedPrincipals: [SecurityPrincipalId.Group("engineering-team")]); + allowedPrincipals: ["group:engineering-team"]); // Order 3: Shared with both teams await _seedOrderAsync(context, order3Id, 300m, tenantId: "tenant-1", - allowedPrincipals: [ - SecurityPrincipalId.Group("sales-team"), - SecurityPrincipalId.Group("managers") - ]); + allowedPrincipals: ["group:sales-team", "group:managers"]); var callerPrincipals = new HashSet { SecurityPrincipalId.User("alice"), @@ -262,7 +259,7 @@ public async Task Query_PrincipalFilter_NoMatch_ReturnsEmptyAsync() { var orderId = _idProvider.NewGuid(); await _seedOrderAsync(context, orderId, 100m, tenantId: "tenant-1", - allowedPrincipals: [SecurityPrincipalId.Group("sales-team")]); + allowedPrincipals: ["group:sales-team"]); var callerPrincipals = new HashSet { SecurityPrincipalId.Group("engineering-team") @@ -303,13 +300,13 @@ await _seedOrderAsync(context, order1Id, 100m, await _seedOrderAsync(context, order2Id, 200m, tenantId: "tenant-1", userId: "user-bob", - allowedPrincipals: [SecurityPrincipalId.Group("sales-team")]); + allowedPrincipals: ["group:sales-team"]); // Order 3: Owned by charlie, not shared with alice await _seedOrderAsync(context, order3Id, 300m, tenantId: "tenant-1", userId: "user-charlie", - allowedPrincipals: [SecurityPrincipalId.Group("engineering-team")]); + allowedPrincipals: ["group:engineering-team"]); var callerPrincipals = new HashSet { SecurityPrincipalId.User("alice"), @@ -379,7 +376,7 @@ public async Task Query_UserOrPrincipal_SharedButNotOwned_ReturnsSharedAsync() { await _seedOrderAsync(context, orderId, 100m, tenantId: "tenant-1", userId: "user-bob", - allowedPrincipals: [SecurityPrincipalId.Group("sales-team")]); + allowedPrincipals: ["group:sales-team"]); var callerPrincipals = new HashSet { SecurityPrincipalId.User("alice"), @@ -458,7 +455,7 @@ public async Task GetByIdAsync_WithPrincipalFilter_MatchingPrincipal_ReturnsRowA var orderId = _idProvider.NewGuid(); await _seedOrderAsync(context, orderId, 100m, tenantId: "tenant-1", - allowedPrincipals: [SecurityPrincipalId.Group("sales-team")]); + allowedPrincipals: ["group:sales-team"]); var callerPrincipals = new HashSet { SecurityPrincipalId.Group("sales-team") @@ -488,7 +485,7 @@ public async Task GetByIdAsync_WithPrincipalFilter_NoMatchingPrincipal_ReturnsNu var orderId = _idProvider.NewGuid(); await _seedOrderAsync(context, orderId, 100m, tenantId: "tenant-1", - allowedPrincipals: [SecurityPrincipalId.Group("sales-team")]); + allowedPrincipals: ["group:sales-team"]); var callerPrincipals = new HashSet { SecurityPrincipalId.Group("engineering-team") // No overlap @@ -566,12 +563,12 @@ public async Task Query_TenantPlusPrincipal_EnforcesTenancisolationAsync() { // Order 1: Right tenant, matching principal await _seedOrderAsync(context, order1Id, 100m, tenantId: "tenant-1", - allowedPrincipals: [SecurityPrincipalId.Group("sales-team")]); + allowedPrincipals: ["group:sales-team"]); // Order 2: Wrong tenant, matching principal (should NOT be returned!) await _seedOrderAsync(context, order2Id, 200m, tenantId: "tenant-2", - allowedPrincipals: [SecurityPrincipalId.Group("sales-team")]); + allowedPrincipals: ["group:sales-team"]); var callerPrincipals = new HashSet { SecurityPrincipalId.Group("sales-team") @@ -616,25 +613,17 @@ public async Task Query_With100Principals_HandlesLargePrincipalSetAsync() { // Order that matches one of the 100 principals (group-050) await _seedOrderAsync(context, matchingOrderId, 100m, tenantId: "tenant-1", - allowedPrincipals: [ - SecurityPrincipalId.Group("group-050") - ]); + allowedPrincipals: ["group:group-050"]); // Order that doesn't match any principal await _seedOrderAsync(context, nonMatchingOrderId, 200m, tenantId: "tenant-1", - allowedPrincipals: [ - SecurityPrincipalId.Group("other-group") - ]); + allowedPrincipals: ["group:other-group"]); // Order that matches multiple of the caller's principals await _seedOrderAsync(context, multiMatchOrderId, 300m, tenantId: "tenant-1", - allowedPrincipals: [ - SecurityPrincipalId.User("alice"), - SecurityPrincipalId.Group("group-001"), - SecurityPrincipalId.Group("group-099") - ]); + allowedPrincipals: ["user:alice", "group:group-001", "group:group-099"]); lensQuery.ApplyFilter(new ScopeFilterInfo { Filters = ScopeFilter.Tenant | ScopeFilter.Principal, @@ -670,9 +659,7 @@ public async Task Query_With100Principals_CanLogGeneratedSqlAsync() { var orderId = _idProvider.NewGuid(); await _seedOrderAsync(context, orderId, 100m, tenantId: "tenant-1", - allowedPrincipals: [ - SecurityPrincipalId.Group("group-050") - ]); + allowedPrincipals: ["group:group-050"]); lensQuery.ApplyFilter(new ScopeFilterInfo { Filters = ScopeFilter.Tenant | ScopeFilter.Principal, diff --git a/tests/Whizbang.Data.EFCore.Postgres.Tests/FullLinqSupportTests.cs b/tests/Whizbang.Data.EFCore.Postgres.Tests/FullLinqSupportTests.cs new file mode 100644 index 00000000..adcfa67a --- /dev/null +++ b/tests/Whizbang.Data.EFCore.Postgres.Tests/FullLinqSupportTests.cs @@ -0,0 +1,545 @@ +using Dapper; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; +using TUnit.Core; +using Whizbang.Core; +using Whizbang.Core.Lenses; +using Whizbang.Core.Perspectives; +using Whizbang.Data.EFCore.Postgres.QueryTranslation; +using Whizbang.Testing.Containers; + +namespace Whizbang.Data.EFCore.Postgres.Tests; + +/// +/// Integration tests for full LINQ support on JSONB columns. +/// Verifies that EF Core 10's ComplexProperty().ToJson() works correctly with: +/// - Property access (Where, OrderBy, Select) +/// - Nested property access +/// - Collection operations (Any, Contains, Count) +/// - String functions (Contains, StartsWith) +/// - Scope queries (TenantId, Extensions) +/// +/// +/// These tests validate Phase 6 of the "Full LINQ Support for JSONB Columns" plan. +/// They ensure pure LINQ queries work without raw SQL or EF.Functions calls. +/// +[Category("Integration")] +[NotInParallel("PostgreSQL")] +public class FullLinqSupportTests : IAsyncDisposable { + private static readonly Uuid7IdProvider _idProvider = new(); + + static FullLinqSupportTests() { + AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", false); + } + + private string? _testDatabaseName; + private NpgsqlDataSource? _dataSource; + private LinqTestDbContext? _context; + private string _connectionString = null!; + + /// + /// Address model for testing nested property access. + /// + public class Address { + public string City { get; set; } = string.Empty; + public string State { get; set; } = string.Empty; + public string? ZipCode { get; set; } + } + + /// + /// Order item for testing collection queries. + /// + public class OrderItem { + public string ProductName { get; set; } = string.Empty; + public decimal Price { get; set; } + public int Quantity { get; set; } + public decimal Total => Price * Quantity; + } + + /// + /// Test model with various property types for comprehensive LINQ testing. + /// + public class CustomerOrder { + public string CustomerName { get; set; } = string.Empty; + public decimal TotalAmount { get; set; } + public string Status { get; set; } = string.Empty; + public Address? ShippingAddress { get; set; } + public List Items { get; set; } = []; + public List Tags { get; set; } = []; + public bool IsUrgent { get; set; } + public DateTimeOffset OrderDate { get; set; } + } + + /// + /// DbContext using EF Core 10 ComplexProperty().ToJson() pattern for full LINQ support. + /// + /// + /// ComplexProperty().ToJson() provides: + /// - Full LINQ query support (Where, OrderBy, Select on nested properties) + /// - Collection queries (Any, Contains, Count) translated to server-side SQL + /// - String methods (Contains, StartsWith) + /// + private sealed class LinqTestDbContext : DbContext { + public LinqTestDbContext(DbContextOptions options) + : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity>(entity => { + entity.ToTable("wh_per_linq_test"); + entity.HasKey(e => e.Id); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.CreatedAt).HasColumnName("created_at"); + entity.Property(e => e.UpdatedAt).HasColumnName("updated_at"); + entity.Property(e => e.Version).HasColumnName("version"); + + // JSONB columns - EF Core 10 ComplexProperty().ToJson() for full LINQ support + // This enables server-side translation of collection queries like Any(), Contains() + entity.ComplexProperty(e => e.Data, d => d.ToJson("data")); + entity.ComplexProperty(e => e.Metadata, m => m.ToJson("metadata")); + entity.ComplexProperty(e => e.Scope, s => s.ToJson("scope")); + + // Physical fields as shadow properties for optimized queries + entity.Property("customer_name").HasColumnName("customer_name").HasMaxLength(200); + entity.Property("total_amount").HasColumnName("total_amount"); + entity.Property("status").HasColumnName("status").HasMaxLength(50); + + entity.HasIndex("customer_name"); + entity.HasIndex("total_amount"); + entity.HasIndex("status"); + }); + } + } + + [Before(Test)] + public async Task SetupAsync() { + await SharedPostgresContainer.InitializeAsync(); + + _testDatabaseName = $"linq_test_{Guid.NewGuid():N}"; + + await using var adminConnection = new NpgsqlConnection(SharedPostgresContainer.ConnectionString); + await adminConnection.OpenAsync(); + await adminConnection.ExecuteAsync($"CREATE DATABASE {_testDatabaseName}"); + + var builder = new NpgsqlConnectionStringBuilder(SharedPostgresContainer.ConnectionString) { + Database = _testDatabaseName, + Timezone = "UTC", + IncludeErrorDetail = true + }; + _connectionString = builder.ConnectionString; + + var dataSourceBuilder = new NpgsqlDataSourceBuilder(_connectionString); + dataSourceBuilder.EnableDynamicJson(); + _dataSource = dataSourceBuilder.Build(); + + // Register physical fields + PhysicalFieldRegistry.Clear(); + PhysicalFieldRegistry.Register("CustomerName", "customer_name"); + PhysicalFieldRegistry.Register("TotalAmount", "total_amount"); + PhysicalFieldRegistry.Register("Status", "status"); + + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder + .UseNpgsql(_dataSource) + .UseWhizbangPhysicalFields() + .ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.CoreEventId.ManyServiceProvidersCreatedWarning)); + _context = new LinqTestDbContext(optionsBuilder.Options); + + await _initializeSchemaAsync(); + await _seedTestDataAsync(); + } + + [After(Test)] + public async Task TeardownAsync() { + if (_context != null) { + await _context.DisposeAsync(); + _context = null; + } + + if (_dataSource != null) { + await _dataSource.DisposeAsync(); + _dataSource = null; + } + + if (_testDatabaseName != null) { + try { + await using var adminConnection = new NpgsqlConnection(SharedPostgresContainer.ConnectionString); + await adminConnection.OpenAsync(); + + await adminConnection.ExecuteAsync($@" + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = '{_testDatabaseName}' + AND pid <> pg_backend_pid()"); + + await adminConnection.ExecuteAsync($"DROP DATABASE IF EXISTS {_testDatabaseName}"); + } catch { + // Ignore cleanup errors + } + + _testDatabaseName = null; + } + } + + public async ValueTask DisposeAsync() { + await TeardownAsync(); + GC.SuppressFinalize(this); + } + + private async Task _initializeSchemaAsync() { + await using var connection = new NpgsqlConnection(_connectionString); + await connection.OpenAsync(); + + await connection.ExecuteAsync(""" + CREATE TABLE IF NOT EXISTS wh_per_linq_test ( + id UUID PRIMARY KEY, + data JSONB NOT NULL, + metadata JSONB NOT NULL, + scope JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + version INTEGER NOT NULL, + customer_name VARCHAR(200), + total_amount DECIMAL NOT NULL DEFAULT 0, + status VARCHAR(50) + ); + + CREATE INDEX IF NOT EXISTS idx_linq_test_customer_name ON wh_per_linq_test(customer_name); + CREATE INDEX IF NOT EXISTS idx_linq_test_total_amount ON wh_per_linq_test(total_amount); + CREATE INDEX IF NOT EXISTS idx_linq_test_status ON wh_per_linq_test(status); + + -- GIN indexes for JSONB queries + CREATE INDEX IF NOT EXISTS idx_linq_test_data_gin ON wh_per_linq_test USING gin (data); + CREATE INDEX IF NOT EXISTS idx_linq_test_scope_gin ON wh_per_linq_test USING gin (scope); + """); + } + + private async Task _seedTestDataAsync() { + var strategy = new PostgresUpsertStrategy(); + + var testOrders = new[] { + new CustomerOrder { + CustomerName = "Acme Corp", + TotalAmount = 150.00m, + Status = "completed", + ShippingAddress = new Address { City = "New York", State = "NY", ZipCode = "10001" }, + Items = [ + new OrderItem { ProductName = "Widget A", Price = 50.00m, Quantity = 2 }, + new OrderItem { ProductName = "Widget B", Price = 50.00m, Quantity = 1 } + ], + Tags = ["wholesale", "priority"], + IsUrgent = true, + OrderDate = DateTimeOffset.UtcNow.AddDays(-1) + }, + new CustomerOrder { + CustomerName = "TechStart Inc", + TotalAmount = 75.00m, + Status = "pending", + ShippingAddress = new Address { City = "San Francisco", State = "CA", ZipCode = "94102" }, + Items = [ + new OrderItem { ProductName = "Gadget X", Price = 75.00m, Quantity = 1 } + ], + Tags = ["startup", "tech"], + IsUrgent = false, + OrderDate = DateTimeOffset.UtcNow + }, + new CustomerOrder { + CustomerName = "Global Industries", + TotalAmount = 500.00m, + Status = "completed", + ShippingAddress = new Address { City = "Chicago", State = "IL", ZipCode = "60601" }, + Items = [ + new OrderItem { ProductName = "Premium Package", Price = 250.00m, Quantity = 2 } + ], + Tags = ["enterprise", "wholesale", "vip"], + IsUrgent = false, + OrderDate = DateTimeOffset.UtcNow.AddDays(-7) + } + }; + + var tenants = new[] { "tenant-001", "tenant-002", "tenant-001" }; + var regions = new[] { "us-east", "us-west", "us-midwest" }; + + for (int i = 0; i < testOrders.Length; i++) { + var order = testOrders[i]; + var metadata = new PerspectiveMetadata { + EventType = "OrderCreated", + EventId = Guid.NewGuid().ToString(), + Timestamp = DateTime.UtcNow + }; + var scope = new PerspectiveScope { + TenantId = tenants[i], + Extensions = [new ScopeExtension("region", regions[i])] + }; + + var id = _idProvider.NewGuid(); + var physicalFields = new Dictionary { + { "customer_name", order.CustomerName }, + { "total_amount", order.TotalAmount }, + { "status", order.Status } + }; + + await strategy.UpsertPerspectiveRowWithPhysicalFieldsAsync( + _context!, + "wh_per_linq_test", + id, + order, + metadata, + scope, + physicalFields); + } + } + + // ==================== PROPERTY ACCESS TESTS ==================== + + /// + /// Test simple property access in Where clause. + /// + [Test] + [Timeout(60000)] + public async Task Where_SimpleProperty_FiltersCorrectlyAsync(CancellationToken cancellationToken) { + // Arrange & Act + var results = await _context!.Set>() + .Where(r => r.Data.CustomerName == "Acme Corp") + .ToListAsync(cancellationToken); + + // Assert + await Assert.That(results).Count().IsEqualTo(1); + await Assert.That(results[0].Data.CustomerName).IsEqualTo("Acme Corp"); + } + + /// + /// Test numeric comparison in Where clause. + /// + [Test] + [Timeout(60000)] + public async Task Where_NumericComparison_FiltersCorrectlyAsync(CancellationToken cancellationToken) { + // Arrange & Act - query orders > $100 + var results = await _context!.Set>() + .Where(r => r.Data.TotalAmount > 100.00m) + .ToListAsync(cancellationToken); + + // Assert - Acme Corp ($150) and Global Industries ($500) + await Assert.That(results).Count().IsEqualTo(2); + } + + /// + /// Test boolean property in Where clause. + /// + [Test] + [Timeout(60000)] + public async Task Where_BooleanProperty_FiltersCorrectlyAsync(CancellationToken cancellationToken) { + // Arrange & Act - query urgent orders + var results = await _context!.Set>() + .Where(r => r.Data.IsUrgent) + .ToListAsync(cancellationToken); + + // Assert - only Acme Corp + await Assert.That(results).Count().IsEqualTo(1); + await Assert.That(results[0].Data.CustomerName).IsEqualTo("Acme Corp"); + } + + // ==================== NESTED PROPERTY ACCESS TESTS ==================== + + /// + /// Test nested property access in Where clause. + /// + [Test] + [Timeout(60000)] + public async Task Where_NestedProperty_FiltersCorrectlyAsync(CancellationToken cancellationToken) { + // Arrange & Act - query by city + var results = await _context!.Set>() + .Where(r => r.Data.ShippingAddress!.City == "New York") + .ToListAsync(cancellationToken); + + // Assert + await Assert.That(results).Count().IsEqualTo(1); + await Assert.That(results[0].Data.CustomerName).IsEqualTo("Acme Corp"); + } + + /// + /// Test multiple nested property conditions. + /// + [Test] + [Timeout(60000)] + public async Task Where_MultipleNestedProperties_FiltersCorrectlyAsync(CancellationToken cancellationToken) { + // Arrange & Act - query by state + var results = await _context!.Set>() + .Where(r => r.Data.ShippingAddress!.State == "CA" || r.Data.ShippingAddress!.State == "NY") + .ToListAsync(cancellationToken); + + // Assert - NY (Acme) and CA (TechStart) + await Assert.That(results).Count().IsEqualTo(2); + } + + // ==================== STRING FUNCTION TESTS ==================== + + /// + /// Test string Contains in Where clause. + /// + [Test] + [Timeout(60000)] + public async Task Where_StringContains_FiltersCorrectlyAsync(CancellationToken cancellationToken) { + // Arrange & Act + var results = await _context!.Set>() + .Where(r => r.Data.CustomerName.Contains("Corp")) + .ToListAsync(cancellationToken); + + // Assert + await Assert.That(results).Count().IsEqualTo(1); + await Assert.That(results[0].Data.CustomerName).IsEqualTo("Acme Corp"); + } + + /// + /// Test string StartsWith in Where clause. + /// + [Test] + [Timeout(60000)] + public async Task Where_StringStartsWith_FiltersCorrectlyAsync(CancellationToken cancellationToken) { + // Arrange & Act + var results = await _context!.Set>() + .Where(r => r.Data.CustomerName.StartsWith("Tech")) + .ToListAsync(cancellationToken); + + // Assert + await Assert.That(results).Count().IsEqualTo(1); + await Assert.That(results[0].Data.CustomerName).IsEqualTo("TechStart Inc"); + } + + // ==================== SCOPE QUERY TESTS ==================== + + /// + /// Test Scope.TenantId in Where clause. + /// + [Test] + [Timeout(60000)] + public async Task Where_ScopeTenantId_FiltersCorrectlyAsync(CancellationToken cancellationToken) { + // Arrange & Act + var results = await _context!.Set>() + .Where(r => r.Scope.TenantId == "tenant-001") + .ToListAsync(cancellationToken); + + // Assert - Acme Corp and Global Industries have tenant-001 + await Assert.That(results).Count().IsEqualTo(2); + } + + /// + /// Test Scope.Extensions query using server-side LINQ with ComplexProperty().ToJson(). + /// + /// + /// With ComplexProperty().ToJson(), collection queries like Extensions.Any() are + /// translated to server-side SQL, enabling efficient indexed queries. + /// + [Test] + [Timeout(60000)] + public async Task Where_ScopeExtensionsAny_FiltersCorrectlyAsync(CancellationToken cancellationToken) { + // Arrange & Act - find orders in us-west region + // Server-side evaluation via ComplexProperty().ToJson() + var results = await _context!.Set>() + .Where(r => r.Scope.Extensions.Any(e => e.Key == "region" && e.Value == "us-west")) + .ToListAsync(cancellationToken); + + // Assert - only TechStart in us-west + await Assert.That(results).Count().IsEqualTo(1); + await Assert.That(results[0].Data.CustomerName).IsEqualTo("TechStart Inc"); + } + + // ==================== ORDERBY TESTS ==================== + + /// + /// Test OrderBy on Data property. + /// + [Test] + [Timeout(60000)] + public async Task OrderBy_DataProperty_SortsCorrectlyAsync(CancellationToken cancellationToken) { + // Arrange & Act + var results = await _context!.Set>() + .OrderBy(r => r.Data.TotalAmount) + .ToListAsync(cancellationToken); + + // Assert - ascending order + await Assert.That(results).Count().IsEqualTo(3); + await Assert.That(results[0].Data.TotalAmount).IsEqualTo(75.00m); + await Assert.That(results[1].Data.TotalAmount).IsEqualTo(150.00m); + await Assert.That(results[2].Data.TotalAmount).IsEqualTo(500.00m); + } + + /// + /// Test OrderByDescending on Data property. + /// + [Test] + [Timeout(60000)] + public async Task OrderByDescending_DataProperty_SortsCorrectlyAsync(CancellationToken cancellationToken) { + // Arrange & Act + var results = await _context!.Set>() + .OrderByDescending(r => r.Data.TotalAmount) + .ToListAsync(cancellationToken); + + // Assert - descending order + await Assert.That(results).Count().IsEqualTo(3); + await Assert.That(results[0].Data.TotalAmount).IsEqualTo(500.00m); + await Assert.That(results[1].Data.TotalAmount).IsEqualTo(150.00m); + await Assert.That(results[2].Data.TotalAmount).IsEqualTo(75.00m); + } + + // ==================== SELECT PROJECTION TESTS ==================== + + /// + /// Test Select projection with anonymous type. + /// + [Test] + [Timeout(60000)] + public async Task Select_AnonymousProjection_WorksCorrectlyAsync(CancellationToken cancellationToken) { + // Arrange & Act + var results = await _context!.Set>() + .Select(r => new { r.Data.CustomerName, r.Data.TotalAmount }) + .OrderBy(r => r.TotalAmount) + .ToListAsync(cancellationToken); + + // Assert + await Assert.That(results).Count().IsEqualTo(3); + await Assert.That(results[0].CustomerName).IsEqualTo("TechStart Inc"); + await Assert.That(results[0].TotalAmount).IsEqualTo(75.00m); + } + + // ==================== COMBINED QUERY TESTS ==================== + + /// + /// Test combined Where, OrderBy, and Select. + /// + [Test] + [Timeout(60000)] + public async Task CombinedQuery_WhereOrderBySelect_WorksCorrectlyAsync(CancellationToken cancellationToken) { + // Arrange & Act - get completed orders sorted by amount + var results = await _context!.Set>() + .Where(r => r.Data.Status == "completed") + .OrderByDescending(r => r.Data.TotalAmount) + .Select(r => new { r.Data.CustomerName, r.Data.TotalAmount }) + .ToListAsync(cancellationToken); + + // Assert + await Assert.That(results).Count().IsEqualTo(2); + await Assert.That(results[0].CustomerName).IsEqualTo("Global Industries"); + await Assert.That(results[1].CustomerName).IsEqualTo("Acme Corp"); + } + + /// + /// Test query combining Data and Scope conditions. + /// + [Test] + [Timeout(60000)] + public async Task CombinedQuery_DataAndScope_WorksCorrectlyAsync(CancellationToken cancellationToken) { + // Arrange & Act - completed orders in tenant-001 + var results = await _context!.Set>() + .Where(r => r.Data.Status == "completed") + .Where(r => r.Scope.TenantId == "tenant-001") + .ToListAsync(cancellationToken); + + // Assert - only Global Industries (Acme is completed but tenant-001, Global is completed and tenant-001) + await Assert.That(results).Count().IsEqualTo(2); // Both Acme and Global are tenant-001 and completed + } +} diff --git a/tests/Whizbang.Data.EFCore.Postgres.Tests/PhysicalFieldIntegrationTests.cs b/tests/Whizbang.Data.EFCore.Postgres.Tests/PhysicalFieldIntegrationTests.cs new file mode 100644 index 00000000..a115853b --- /dev/null +++ b/tests/Whizbang.Data.EFCore.Postgres.Tests/PhysicalFieldIntegrationTests.cs @@ -0,0 +1,621 @@ +using System.Text.Json; +using Dapper; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; +using TUnit.Core; +using Whizbang.Core; +using Whizbang.Core.Lenses; +using Whizbang.Core.Perspectives; +using Whizbang.Testing.Containers; + +namespace Whizbang.Data.EFCore.Postgres.Tests; + +/// +/// Integration tests for physical field support with real PostgreSQL. +/// Tests roundtrip persistence, querying by physical columns, and mixed JSONB + physical queries. +/// +/// +/// These tests validate that: +/// 1. Physical field values are correctly persisted to PostgreSQL columns +/// 2. Physical columns can be queried directly (WHERE clause optimization) +/// 3. Mixed queries combining JSONB and physical fields work correctly +/// 4. Update operations correctly update both JSONB and physical columns +/// +/// No tests found +[Category("Integration")] +[NotInParallel("PostgreSQL")] +public class PhysicalFieldIntegrationTests : IAsyncDisposable { + private static readonly Uuid7IdProvider _idProvider = new(); + + static PhysicalFieldIntegrationTests() { + // Configure Npgsql for UTC timestamps + AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", false); + } + + private string? _testDatabaseName; + private NpgsqlDataSource? _dataSource; + private PhysicalFieldIntegrationDbContext? _context; + private string _connectionString = null!; + + /// + /// Test model with physical fields for integration testing. + /// + public class ProductSearchModel { + public string Name { get; init; } = string.Empty; + public decimal Price { get; init; } + public string? Category { get; init; } + public bool IsActive { get; init; } + public string? Description { get; init; } // JSONB only field + public List Tags { get; init; } = []; // JSONB only field + } + + /// + /// DbContext that configures physical fields as shadow properties for PostgreSQL. + /// + private sealed class PhysicalFieldIntegrationDbContext : DbContext { + public PhysicalFieldIntegrationDbContext(DbContextOptions options) + : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity>(entity => { + entity.ToTable("wh_per_product_search"); + entity.HasKey(e => e.Id); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.CreatedAt).HasColumnName("created_at"); + entity.Property(e => e.UpdatedAt).HasColumnName("updated_at"); + entity.Property(e => e.Version).HasColumnName("version"); + + // Data stored as JSONB (full model in Extracted mode) + entity.Property(e => e.Data) + .HasColumnName("data") + .HasColumnType("jsonb"); + + // Metadata as JSONB + entity.Property(e => e.Metadata) + .HasColumnName("metadata") + .HasColumnType("jsonb"); + + // Scope as JSONB + entity.Property(e => e.Scope) + .HasColumnName("scope") + .HasColumnType("jsonb"); + + // Physical fields as shadow properties + // These are indexed copies of model properties for query optimization + entity.Property("name").HasColumnName("name").HasMaxLength(200); + entity.Property("price").HasColumnName("price"); + entity.Property("category").HasColumnName("category").HasMaxLength(100); + entity.Property("is_active").HasColumnName("is_active"); + + // Indexes on physical columns for optimized queries + entity.HasIndex("name"); + entity.HasIndex("price"); + entity.HasIndex("category"); + entity.HasIndex("is_active"); + }); + } + } + + [Before(Test)] + public async Task SetupAsync() { + // Initialize shared container + await SharedPostgresContainer.InitializeAsync(); + + // Create unique database for THIS test + _testDatabaseName = $"test_{Guid.NewGuid():N}"; + + // Connect to main database to create the test database + await using var adminConnection = new NpgsqlConnection(SharedPostgresContainer.ConnectionString); + await adminConnection.OpenAsync(); + await adminConnection.ExecuteAsync($"CREATE DATABASE {_testDatabaseName}"); + + // Build connection string for the test database + var builder = new NpgsqlConnectionStringBuilder(SharedPostgresContainer.ConnectionString) { + Database = _testDatabaseName, + Timezone = "UTC", + IncludeErrorDetail = true + }; + _connectionString = builder.ConnectionString; + + // Configure Npgsql data source with JSON support + var dataSourceBuilder = new NpgsqlDataSourceBuilder(_connectionString); + dataSourceBuilder.EnableDynamicJson(); + _dataSource = dataSourceBuilder.Build(); + + // Configure DbContext + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseNpgsql(_dataSource); + _context = new PhysicalFieldIntegrationDbContext(optionsBuilder.Options); + + // Initialize database schema + await _initializeSchemaAsync(); + } + + [After(Test)] + public async Task TeardownAsync() { + if (_context != null) { + await _context.DisposeAsync(); + _context = null; + } + + if (_dataSource != null) { + await _dataSource.DisposeAsync(); + _dataSource = null; + } + + // Drop the test database + if (_testDatabaseName != null) { + try { + await using var adminConnection = new NpgsqlConnection(SharedPostgresContainer.ConnectionString); + await adminConnection.OpenAsync(); + + // Terminate connections + await adminConnection.ExecuteAsync($@" + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = '{_testDatabaseName}' + AND pid <> pg_backend_pid()"); + + await adminConnection.ExecuteAsync($"DROP DATABASE IF EXISTS {_testDatabaseName}"); + } catch { + // Ignore cleanup errors + } + + _testDatabaseName = null; + } + } + + public async ValueTask DisposeAsync() { + await TeardownAsync(); + GC.SuppressFinalize(this); + } + + private async Task _initializeSchemaAsync() { + await using var connection = new NpgsqlConnection(_connectionString); + await connection.OpenAsync(); + + // Create the table with physical columns + await connection.ExecuteAsync(""" + CREATE TABLE IF NOT EXISTS wh_per_product_search ( + id UUID PRIMARY KEY, + data JSONB NOT NULL, + metadata JSONB NOT NULL, + scope JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + version INTEGER NOT NULL, + -- Physical columns (indexed copies for query optimization) + name VARCHAR(200), + price DECIMAL NOT NULL DEFAULT 0, + category VARCHAR(100), + is_active BOOLEAN NOT NULL DEFAULT FALSE + ); + + CREATE INDEX IF NOT EXISTS idx_wh_per_product_search_name ON wh_per_product_search(name); + CREATE INDEX IF NOT EXISTS idx_wh_per_product_search_price ON wh_per_product_search(price); + CREATE INDEX IF NOT EXISTS idx_wh_per_product_search_category ON wh_per_product_search(category); + CREATE INDEX IF NOT EXISTS idx_wh_per_product_search_is_active ON wh_per_product_search(is_active); + """); + } + + // ==================== Physical Field Persistence Tests ==================== + + [Test] + [Timeout(60000)] + public async Task UpsertWithPhysicalFields_WhenRecordDoesNotExist_PersistsToPostgresAsync(CancellationToken cancellationToken) { + // Arrange + var strategy = new PostgresUpsertStrategy(); + var model = new ProductSearchModel { + Name = "Widget Pro", + Price = 29.99m, + Category = "Electronics", + IsActive = true, + Description = "A premium widget for professionals", + Tags = ["premium", "electronics", "pro"] + }; + var testId = _idProvider.NewGuid(); + var metadata = new PerspectiveMetadata { + EventType = "ProductCreated", + EventId = Guid.NewGuid().ToString(), + Timestamp = DateTime.UtcNow + }; + var scope = new PerspectiveScope { TenantId = "tenant-1" }; + var physicalFieldValues = new Dictionary { + { "name", model.Name }, + { "price", model.Price }, + { "category", model.Category }, + { "is_active", model.IsActive } + }; + + // Act + await strategy.UpsertPerspectiveRowWithPhysicalFieldsAsync( + _context!, + "wh_per_product_search", + testId, + model, + metadata, + scope, + physicalFieldValues, + cancellationToken); + + // Assert - verify via raw SQL that both JSONB and physical columns have correct values + await using var connection = new NpgsqlConnection(_connectionString); + await connection.OpenAsync(cancellationToken); + + var result = await connection.QuerySingleAsync( + "SELECT data, name, price, category, is_active FROM wh_per_product_search WHERE id = @Id", + new { Id = testId }); + + // Verify physical columns + await Assert.That((string?)result.name).IsEqualTo("Widget Pro"); + await Assert.That((decimal)result.price).IsEqualTo(29.99m); + await Assert.That((string?)result.category).IsEqualTo("Electronics"); + await Assert.That((bool)result.is_active).IsTrue(); + + // Verify JSONB contains full model (Extracted mode) + var jsonData = JsonDocument.Parse((string)result.data); + await Assert.That(jsonData.RootElement.GetProperty("Name").GetString()).IsEqualTo("Widget Pro"); + await Assert.That(jsonData.RootElement.GetProperty("Price").GetDecimal()).IsEqualTo(29.99m); + await Assert.That(jsonData.RootElement.GetProperty("Description").GetString()) + .IsEqualTo("A premium widget for professionals"); + await Assert.That(jsonData.RootElement.GetProperty("Tags").GetArrayLength()).IsEqualTo(3); + } + + [Test] + [Timeout(60000)] + public async Task UpsertWithPhysicalFields_WhenRecordExists_UpdatesBothJsonbAndPhysicalColumnsAsync(CancellationToken cancellationToken) { + // Arrange + var strategy = new PostgresUpsertStrategy(); + var testId = _idProvider.NewGuid(); + var metadata = new PerspectiveMetadata { + EventType = "ProductCreated", + EventId = Guid.NewGuid().ToString(), + Timestamp = DateTime.UtcNow + }; + var scope = new PerspectiveScope { TenantId = "tenant-1" }; + + // Create initial record + var initialModel = new ProductSearchModel { + Name = "Basic Widget", + Price = 9.99m, + Category = "Accessories", + IsActive = true, + Description = "A basic widget" + }; + var initialPhysicalValues = new Dictionary { + { "name", initialModel.Name }, + { "price", initialModel.Price }, + { "category", initialModel.Category }, + { "is_active", initialModel.IsActive } + }; + await strategy.UpsertPerspectiveRowWithPhysicalFieldsAsync( + _context!, + "wh_per_product_search", + testId, + initialModel, + metadata, + scope, + initialPhysicalValues, + cancellationToken); + + // Act - update the record + var updatedModel = new ProductSearchModel { + Name = "Premium Widget", + Price = 49.99m, + Category = "Premium", + IsActive = false, // Changed to inactive + Description = "Upgraded to premium", + Tags = ["premium"] + }; + var updatedPhysicalValues = new Dictionary { + { "name", updatedModel.Name }, + { "price", updatedModel.Price }, + { "category", updatedModel.Category }, + { "is_active", updatedModel.IsActive } + }; + await strategy.UpsertPerspectiveRowWithPhysicalFieldsAsync( + _context!, + "wh_per_product_search", + testId, + updatedModel, + metadata, + scope, + updatedPhysicalValues, + cancellationToken); + + // Assert + await using var connection = new NpgsqlConnection(_connectionString); + await connection.OpenAsync(cancellationToken); + + var result = await connection.QuerySingleAsync( + "SELECT data, name, price, category, is_active, version FROM wh_per_product_search WHERE id = @Id", + new { Id = testId }); + + // Verify physical columns updated + await Assert.That((string?)result.name).IsEqualTo("Premium Widget"); + await Assert.That((decimal)result.price).IsEqualTo(49.99m); + await Assert.That((string?)result.category).IsEqualTo("Premium"); + await Assert.That((bool)result.is_active).IsFalse(); + await Assert.That((int)result.version).IsEqualTo(2); + + // Verify JSONB updated + var jsonData = JsonDocument.Parse((string)result.data); + await Assert.That(jsonData.RootElement.GetProperty("Name").GetString()).IsEqualTo("Premium Widget"); + await Assert.That(jsonData.RootElement.GetProperty("Description").GetString()) + .IsEqualTo("Upgraded to premium"); + } + + // ==================== Physical Column Query Tests ==================== + + [Test] + [Timeout(60000)] + public async Task QueryByPhysicalColumn_WhereClauseOnName_UsesIndexAsync(CancellationToken cancellationToken) { + // Arrange - seed multiple products + await _seedProductsAsync(cancellationToken); + + // Act - query by physical column (name) + var results = await _context!.Set>() + .Where(r => EF.Property(r, "name") == "Widget Alpha") + .ToListAsync(cancellationToken); + + // Assert + await Assert.That(results.Count).IsEqualTo(1); + await Assert.That(results[0].Data.Name).IsEqualTo("Widget Alpha"); + await Assert.That(results[0].Data.Price).IsEqualTo(10.00m); + } + + [Test] + [Timeout(60000)] + public async Task QueryByPhysicalColumn_WhereClauseOnPrice_ReturnsFilteredResultsAsync(CancellationToken cancellationToken) { + // Arrange + await _seedProductsAsync(cancellationToken); + + // Act - query by physical column (price range) + var results = await _context!.Set>() + .Where(r => EF.Property(r, "price") >= 20.00m) + .OrderBy(r => EF.Property(r, "price")) + .ToListAsync(cancellationToken); + + // Assert - should return Widget Beta (25.00) and Widget Gamma (50.00) + await Assert.That(results.Count).IsEqualTo(2); + await Assert.That(results[0].Data.Name).IsEqualTo("Widget Beta"); + await Assert.That(results[1].Data.Name).IsEqualTo("Widget Gamma"); + } + + [Test] + [Timeout(60000)] + public async Task QueryByPhysicalColumn_WhereClauseOnCategory_ReturnsMatchingRecordsAsync(CancellationToken cancellationToken) { + // Arrange + await _seedProductsAsync(cancellationToken); + + // Act - query by physical column (category) + var results = await _context!.Set>() + .Where(r => EF.Property(r, "category") == "Electronics") + .ToListAsync(cancellationToken); + + // Assert + await Assert.That(results.Count).IsEqualTo(2); + var names = results.Select(r => r.Data.Name).OrderBy(n => n).ToList(); + await Assert.That(names).IsEquivalentTo(["Widget Alpha", "Widget Beta"]); + } + + [Test] + [Timeout(60000)] + public async Task QueryByPhysicalColumn_WhereClauseOnBool_ReturnsActiveOnlyAsync(CancellationToken cancellationToken) { + // Arrange + await _seedProductsAsync(cancellationToken); + + // Act - query by physical boolean column + var results = await _context!.Set>() + .Where(r => EF.Property(r, "is_active") == true) + .ToListAsync(cancellationToken); + + // Assert - Alpha and Beta are active, Gamma is not + await Assert.That(results.Count).IsEqualTo(2); + var names = results.Select(r => r.Data.Name).OrderBy(n => n).ToList(); + await Assert.That(names).IsEquivalentTo(["Widget Alpha", "Widget Beta"]); + } + + // ==================== Mixed JSONB + Physical Column Tests ==================== + + [Test] + [Timeout(60000)] + public async Task MixedQuery_CombinePhysicalAndJsonbFilters_WorksCorrectlyAsync(CancellationToken cancellationToken) { + // Arrange + await _seedProductsAsync(cancellationToken); + + // Act - combine physical column filter with JSONB path query + // Physical: price >= 20 + // JSONB: Description contains "Professional" (case-sensitive in PostgreSQL) + var results = await _context!.Set>() + .Where(r => EF.Property(r, "price") >= 20.00m) + .Where(r => r.Data.Description != null && r.Data.Description.Contains("Professional")) + .ToListAsync(cancellationToken); + + // Assert - only Widget Beta has price >= 20 AND description containing "Professional" + await Assert.That(results.Count).IsEqualTo(1); + await Assert.That(results[0].Data.Name).IsEqualTo("Widget Beta"); + } + + [Test] + [Timeout(60000)] + public async Task MixedQuery_OrderByPhysicalSelectFullModel_ReturnsCompleteDataAsync(CancellationToken cancellationToken) { + // Arrange + await _seedProductsAsync(cancellationToken); + + // Act - order by physical column, select full Data model + var results = await _context!.Set>() + .Where(r => EF.Property(r, "category") == "Electronics") + .OrderByDescending(r => EF.Property(r, "price")) + .Select(r => r.Data) + .ToListAsync(cancellationToken); + + // Assert - ordered by price descending, with all JSONB-only fields populated + await Assert.That(results.Count).IsEqualTo(2); + await Assert.That(results[0].Name).IsEqualTo("Widget Beta"); // 25.00 + await Assert.That(results[1].Name).IsEqualTo("Widget Alpha"); // 10.00 + + // JSONB-only fields should be populated + await Assert.That(results[0].Description).IsNotNull(); + await Assert.That(results[0].Tags.Count).IsGreaterThan(0); + } + + // ==================== Store Integration Tests ==================== + + [Test] + [Timeout(60000)] + public async Task Store_UpsertWithPhysicalFieldsAsync_RoundtripPersistsCorrectlyAsync(CancellationToken cancellationToken) { + // Arrange + var strategy = new PostgresUpsertStrategy(); + var store = new EFCorePostgresPerspectiveStore( + _context!, + "wh_per_product_search", + strategy); + + var model = new ProductSearchModel { + Name = "Store Test Product", + Price = 99.99m, + Category = "Test", + IsActive = true, + Description = "Testing store integration with PostgreSQL", + Tags = ["test", "integration", "postgres"] + }; + var testId = _idProvider.NewGuid(); + var physicalFieldValues = new Dictionary { + { "name", model.Name }, + { "price", model.Price }, + { "category", model.Category }, + { "is_active", model.IsActive } + }; + + // Act + await store.UpsertWithPhysicalFieldsAsync(testId, model, physicalFieldValues, cancellationToken); + + // Assert - query back via store + var retrieved = await store.GetByStreamIdAsync(testId, cancellationToken); + + await Assert.That(retrieved).IsNotNull(); + await Assert.That(retrieved!.Name).IsEqualTo("Store Test Product"); + await Assert.That(retrieved.Price).IsEqualTo(99.99m); + await Assert.That(retrieved.Category).IsEqualTo("Test"); + await Assert.That(retrieved.IsActive).IsTrue(); + await Assert.That(retrieved.Description).IsEqualTo("Testing store integration with PostgreSQL"); + await Assert.That(retrieved.Tags.Count).IsEqualTo(3); + } + + [Test] + [Timeout(60000)] + public async Task Store_MultipleUpserts_MaintainsDataIntegrityAsync(CancellationToken cancellationToken) { + // Arrange + var strategy = new PostgresUpsertStrategy(); + var store = new EFCorePostgresPerspectiveStore( + _context!, + "wh_per_product_search", + strategy); + var testId = _idProvider.NewGuid(); + + // Act - perform multiple upserts + for (var i = 1; i <= 5; i++) { + var model = new ProductSearchModel { + Name = $"Product v{i}", + Price = i * 10.00m, + Category = i % 2 == 0 ? "Even" : "Odd", + IsActive = i < 5, // Last version is inactive + Description = $"Version {i}" + }; + var physicalFieldValues = new Dictionary { + { "name", model.Name }, + { "price", model.Price }, + { "category", model.Category }, + { "is_active", model.IsActive } + }; + await store.UpsertWithPhysicalFieldsAsync(testId, model, physicalFieldValues, cancellationToken); + } + + // Assert - final version should be persisted + await using var connection = new NpgsqlConnection(_connectionString); + await connection.OpenAsync(cancellationToken); + + var result = await connection.QuerySingleAsync( + "SELECT name, price, category, is_active, version FROM wh_per_product_search WHERE id = @Id", + new { Id = testId }); + + await Assert.That((string?)result.name).IsEqualTo("Product v5"); + await Assert.That((decimal)result.price).IsEqualTo(50.00m); + await Assert.That((string?)result.category).IsEqualTo("Odd"); + await Assert.That((bool)result.is_active).IsFalse(); + await Assert.That((int)result.version).IsEqualTo(5); + } + + // ==================== Helper Methods ==================== + + private async Task _seedProductsAsync(CancellationToken cancellationToken) { + var strategy = new PostgresUpsertStrategy(); + var metadata = new PerspectiveMetadata { + EventType = "ProductCreated", + EventId = Guid.NewGuid().ToString(), + Timestamp = DateTime.UtcNow + }; + var scope = new PerspectiveScope { TenantId = "tenant-1" }; + + var products = new[] { + new { + Id = _idProvider.NewGuid(), + Model = new ProductSearchModel { + Name = "Widget Alpha", + Price = 10.00m, + Category = "Electronics", + IsActive = true, + Description = "Entry-level widget for beginners", + Tags = new List { "basic", "entry" } + } + }, + new { + Id = _idProvider.NewGuid(), + Model = new ProductSearchModel { + Name = "Widget Beta", + Price = 25.00m, + Category = "Electronics", + IsActive = true, + Description = "Professional widget for advanced users", + Tags = new List { "professional", "advanced" } + } + }, + new { + Id = _idProvider.NewGuid(), + Model = new ProductSearchModel { + Name = "Widget Gamma", + Price = 50.00m, + Category = "Premium", + IsActive = false, // Discontinued + Description = "Premium discontinued widget", + Tags = new List { "premium", "discontinued" } + } + } + }; + + foreach (var product in products) { + var physicalFieldValues = new Dictionary { + { "name", product.Model.Name }, + { "price", product.Model.Price }, + { "category", product.Model.Category }, + { "is_active", product.Model.IsActive } + }; + + await strategy.UpsertPerspectiveRowWithPhysicalFieldsAsync( + _context!, + "wh_per_product_search", + product.Id, + product.Model, + metadata, + scope, + physicalFieldValues, + cancellationToken); + } + } +} diff --git a/tests/Whizbang.Data.EFCore.Postgres.Tests/PhysicalFieldUpsertStrategyTests.cs b/tests/Whizbang.Data.EFCore.Postgres.Tests/PhysicalFieldUpsertStrategyTests.cs new file mode 100644 index 00000000..e5092c73 --- /dev/null +++ b/tests/Whizbang.Data.EFCore.Postgres.Tests/PhysicalFieldUpsertStrategyTests.cs @@ -0,0 +1,564 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; +using TUnit.Core; +using Whizbang.Core; +using Whizbang.Core.Lenses; +using Whizbang.Core.Perspectives; +using Whizbang.Data.EFCore.Postgres; + +namespace Whizbang.Data.EFCore.Postgres.Tests; + +/// +/// Tests for . +/// Validates that physical field values are correctly persisted to shadow properties. +/// +public class PhysicalFieldUpsertStrategyTests { + private readonly Uuid7IdProvider _idProvider = new(); + + /// + /// Test model with physical fields for validation. + /// + public class PhysicalFieldTestModel { + public string Name { get; init; } = string.Empty; + public decimal Price { get; init; } + public string? Description { get; init; } + } + + /// + /// Test DbContext that configures shadow properties for physical fields. + /// + private sealed class PhysicalFieldTestDbContext : DbContext { + public PhysicalFieldTestDbContext(DbContextOptions options) + : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) { + base.OnModelCreating(modelBuilder); + + // Configure PerspectiveRow with full entity configuration + modelBuilder.Entity>(entity => { + entity.ToTable("wh_per_physical_field_test_model"); + entity.HasKey(e => e.Id); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.CreatedAt).HasColumnName("created_at"); + entity.Property(e => e.UpdatedAt).HasColumnName("updated_at"); + entity.Property(e => e.Version).HasColumnName("version"); + + // Use owned types for InMemory provider + entity.OwnsOne(e => e.Data, data => { + data.WithOwner(); + }); + + entity.OwnsOne(e => e.Metadata, metadata => { + metadata.WithOwner(); + metadata.Property(m => m.EventType).IsRequired(); + metadata.Property(m => m.EventId).IsRequired(); + metadata.Property(m => m.Timestamp).IsRequired(); + }); + + // Use JSON conversion for Scope + entity.Property(e => e.Scope) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize(v, JsonSerializerOptions.Default)!); + + // Shadow properties for physical fields + entity.Property("name").HasColumnName("name").HasMaxLength(200); + entity.Property("price").HasColumnName("price"); + + // Indexes on shadow properties + entity.HasIndex("name"); + entity.HasIndex("price"); + }); + } + } + + private PhysicalFieldTestDbContext _createInMemoryDbContext() { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: _idProvider.NewGuid().ToString()) + .Options; + + return new PhysicalFieldTestDbContext(options); + } + + [Test] + public async Task UpsertWithPhysicalFields_WhenRecordDoesNotExist_CreatesShadowPropertiesAsync() { + // Arrange + var context = _createInMemoryDbContext(); + var strategy = new InMemoryUpsertStrategy(); + var model = new PhysicalFieldTestModel { + Name = "Widget", + Price = 19.99m, + Description = "A test widget" + }; + var testId = _idProvider.NewGuid(); + var metadata = new PerspectiveMetadata { + EventType = "TestEvent", + EventId = Guid.NewGuid().ToString(), + Timestamp = DateTime.UtcNow + }; + var scope = new PerspectiveScope(); + var physicalFieldValues = new Dictionary { + { "name", model.Name }, + { "price", model.Price } + }; + + // Act + await strategy.UpsertPerspectiveRowWithPhysicalFieldsAsync( + context, + "wh_per_physical_field_test_model", + testId, + model, + metadata, + scope, + physicalFieldValues); + + // Assert - verify shadow property values + var row = await context.Set>() + .FirstOrDefaultAsync(r => r.Id == testId); + + await Assert.That(row).IsNotNull(); + await Assert.That(row!.Data.Name).IsEqualTo("Widget"); + await Assert.That(row.Data.Price).IsEqualTo(19.99m); + + // Verify shadow properties were set + var entry = context.Entry(row); + await Assert.That(entry.Property("name").CurrentValue).IsEqualTo("Widget"); + await Assert.That(entry.Property("price").CurrentValue).IsEqualTo(19.99m); + } + + [Test] + public async Task UpsertWithPhysicalFields_WhenRecordExists_UpdatesShadowPropertiesAsync() { + // Arrange + var context = _createInMemoryDbContext(); + var strategy = new InMemoryUpsertStrategy(); + var testId = _idProvider.NewGuid(); + var metadata = new PerspectiveMetadata { + EventType = "TestEvent", + EventId = Guid.NewGuid().ToString(), + Timestamp = DateTime.UtcNow + }; + var scope = new PerspectiveScope(); + + // Create initial record + var initialModel = new PhysicalFieldTestModel { + Name = "Widget", + Price = 19.99m, + Description = "Original description" + }; + var initialPhysicalValues = new Dictionary { + { "name", initialModel.Name }, + { "price", initialModel.Price } + }; + await strategy.UpsertPerspectiveRowWithPhysicalFieldsAsync( + context, + "wh_per_physical_field_test_model", + testId, + initialModel, + metadata, + scope, + initialPhysicalValues); + + // Act - update the record with new values + var updatedModel = new PhysicalFieldTestModel { + Name = "Super Widget", + Price = 29.99m, + Description = "Updated description" + }; + var updatedPhysicalValues = new Dictionary { + { "name", updatedModel.Name }, + { "price", updatedModel.Price } + }; + await strategy.UpsertPerspectiveRowWithPhysicalFieldsAsync( + context, + "wh_per_physical_field_test_model", + testId, + updatedModel, + metadata, + scope, + updatedPhysicalValues); + + // Assert - verify shadow properties were updated + var row = await context.Set>() + .FirstOrDefaultAsync(r => r.Id == testId); + + await Assert.That(row).IsNotNull(); + await Assert.That(row!.Data.Name).IsEqualTo("Super Widget"); + await Assert.That(row.Data.Price).IsEqualTo(29.99m); + + var entry = context.Entry(row); + await Assert.That(entry.Property("name").CurrentValue).IsEqualTo("Super Widget"); + await Assert.That(entry.Property("price").CurrentValue).IsEqualTo(29.99m); + } + + [Test] + public async Task UpsertWithPhysicalFields_WithNullValues_SetsShadowPropertiesToNullAsync() { + // Arrange + var context = _createInMemoryDbContext(); + var strategy = new InMemoryUpsertStrategy(); + var model = new PhysicalFieldTestModel { + Name = "Widget", + Price = 19.99m, + Description = null + }; + var testId = _idProvider.NewGuid(); + var metadata = new PerspectiveMetadata { + EventType = "TestEvent", + EventId = Guid.NewGuid().ToString(), + Timestamp = DateTime.UtcNow + }; + var scope = new PerspectiveScope(); + + // Physical field with null value + var physicalFieldValues = new Dictionary { + { "name", null }, + { "price", model.Price } + }; + + // Act + await strategy.UpsertPerspectiveRowWithPhysicalFieldsAsync( + context, + "wh_per_physical_field_test_model", + testId, + model, + metadata, + scope, + physicalFieldValues); + + // Assert - null value should be set + var row = await context.Set>() + .FirstOrDefaultAsync(r => r.Id == testId); + + await Assert.That(row).IsNotNull(); + + var entry = context.Entry(row!); + await Assert.That(entry.Property("name").CurrentValue).IsNull(); + await Assert.That(entry.Property("price").CurrentValue).IsEqualTo(19.99m); + } + + [Test] + public async Task UpsertWithPhysicalFields_WithEmptyDictionary_DoesNotFailAsync() { + // Arrange + var context = _createInMemoryDbContext(); + var strategy = new InMemoryUpsertStrategy(); + var model = new PhysicalFieldTestModel { + Name = "Widget", + Price = 19.99m, + Description = "A test widget" + }; + var testId = _idProvider.NewGuid(); + var metadata = new PerspectiveMetadata { + EventType = "TestEvent", + EventId = Guid.NewGuid().ToString(), + Timestamp = DateTime.UtcNow + }; + var scope = new PerspectiveScope(); + var emptyPhysicalFieldValues = new Dictionary(); + + // Act - should not throw with empty dictionary + await strategy.UpsertPerspectiveRowWithPhysicalFieldsAsync( + context, + "wh_per_physical_field_test_model", + testId, + model, + metadata, + scope, + emptyPhysicalFieldValues); + + // Assert - record should be created + var row = await context.Set>() + .FirstOrDefaultAsync(r => r.Id == testId); + + await Assert.That(row).IsNotNull(); + await Assert.That(row!.Data.Name).IsEqualTo("Widget"); + } + + [Test] + public async Task UpsertWithPhysicalFields_PostgresStrategy_SetsShadowPropertiesAsync() { + // Arrange + var context = _createInMemoryDbContext(); + var strategy = new PostgresUpsertStrategy(); + var model = new PhysicalFieldTestModel { + Name = "Premium Widget", + Price = 49.99m, + Description = "High-end widget" + }; + var testId = _idProvider.NewGuid(); + var metadata = new PerspectiveMetadata { + EventType = "TestEvent", + EventId = Guid.NewGuid().ToString(), + Timestamp = DateTime.UtcNow + }; + var scope = new PerspectiveScope(); + var physicalFieldValues = new Dictionary { + { "name", model.Name }, + { "price", model.Price } + }; + + // Act + await strategy.UpsertPerspectiveRowWithPhysicalFieldsAsync( + context, + "wh_per_physical_field_test_model", + testId, + model, + metadata, + scope, + physicalFieldValues); + + // Assert - need to re-query to get shadow property values (change tracker was cleared) + var row = await context.Set>() + .FirstOrDefaultAsync(r => r.Id == testId); + + await Assert.That(row).IsNotNull(); + await Assert.That(row!.Data.Name).IsEqualTo("Premium Widget"); + await Assert.That(row.Data.Price).IsEqualTo(49.99m); + + // Shadow properties should be queryable + var entry = context.Entry(row); + await Assert.That(entry.Property("name").CurrentValue).IsEqualTo("Premium Widget"); + await Assert.That(entry.Property("price").CurrentValue).IsEqualTo(49.99m); + } + + // ==================== Vector Field Tests ==================== + + /// + /// Test model with vector field for validation. + /// + public class VectorFieldTestModel { + public string Name { get; init; } = string.Empty; + public float[]? Embedding { get; init; } + } + + /// + /// Test DbContext that configures shadow properties for vector fields. + /// + private sealed class VectorFieldTestDbContext : DbContext { + public VectorFieldTestDbContext(DbContextOptions options) + : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity>(entity => { + entity.ToTable("wh_per_vector_field_test_model"); + entity.HasKey(e => e.Id); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.CreatedAt).HasColumnName("created_at"); + entity.Property(e => e.UpdatedAt).HasColumnName("updated_at"); + entity.Property(e => e.Version).HasColumnName("version"); + + entity.OwnsOne(e => e.Data, data => { + data.WithOwner(); + }); + + entity.OwnsOne(e => e.Metadata, metadata => { + metadata.WithOwner(); + metadata.Property(m => m.EventType).IsRequired(); + metadata.Property(m => m.EventId).IsRequired(); + metadata.Property(m => m.Timestamp).IsRequired(); + }); + + entity.Property(e => e.Scope) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize(v, JsonSerializerOptions.Default)!); + + // Shadow property for vector field (stored as string in InMemory, actual vector in Postgres) + entity.Property("name").HasColumnName("name").HasMaxLength(200); + entity.Property("embedding").HasColumnName("embedding"); + }); + } + } + + private VectorFieldTestDbContext _createVectorInMemoryDbContext() { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: _idProvider.NewGuid().ToString()) + .Options; + + return new VectorFieldTestDbContext(options); + } + + [Test] + public async Task UpsertWithPhysicalFields_WithVectorField_StoresArrayValueAsync() { + // Arrange + var context = _createVectorInMemoryDbContext(); + var strategy = new InMemoryUpsertStrategy(); + var embedding = new float[] { 0.1f, 0.2f, 0.3f, 0.4f, 0.5f }; + var model = new VectorFieldTestModel { + Name = "Document", + Embedding = embedding + }; + var testId = _idProvider.NewGuid(); + var metadata = new PerspectiveMetadata { + EventType = "TestEvent", + EventId = Guid.NewGuid().ToString(), + Timestamp = DateTime.UtcNow + }; + var scope = new PerspectiveScope(); + + // For InMemory testing, serialize the vector as JSON string + // In real PostgreSQL, this would be a native vector type + var embeddingJson = JsonSerializer.Serialize(embedding); + var physicalFieldValues = new Dictionary { + { "name", model.Name }, + { "embedding", embeddingJson } + }; + + // Act + await strategy.UpsertPerspectiveRowWithPhysicalFieldsAsync( + context, + "wh_per_vector_field_test_model", + testId, + model, + metadata, + scope, + physicalFieldValues); + + // Assert + var row = await context.Set>() + .FirstOrDefaultAsync(r => r.Id == testId); + + await Assert.That(row).IsNotNull(); + await Assert.That(row!.Data.Name).IsEqualTo("Document"); + await Assert.That(row.Data.Embedding).IsNotNull(); + await Assert.That(row.Data.Embedding).IsEquivalentTo(embedding); + + // Verify shadow property was set + var entry = context.Entry(row); + await Assert.That(entry.Property("embedding").CurrentValue).IsEqualTo(embeddingJson); + } + + [Test] + public async Task UpsertWithPhysicalFields_WithNullVector_StoresNullAsync() { + // Arrange + var context = _createVectorInMemoryDbContext(); + var strategy = new InMemoryUpsertStrategy(); + var model = new VectorFieldTestModel { + Name = "NoEmbedding", + Embedding = null + }; + var testId = _idProvider.NewGuid(); + var metadata = new PerspectiveMetadata { + EventType = "TestEvent", + EventId = Guid.NewGuid().ToString(), + Timestamp = DateTime.UtcNow + }; + var scope = new PerspectiveScope(); + var physicalFieldValues = new Dictionary { + { "name", model.Name }, + { "embedding", null } + }; + + // Act + await strategy.UpsertPerspectiveRowWithPhysicalFieldsAsync( + context, + "wh_per_vector_field_test_model", + testId, + model, + metadata, + scope, + physicalFieldValues); + + // Assert + var row = await context.Set>() + .FirstOrDefaultAsync(r => r.Id == testId); + + await Assert.That(row).IsNotNull(); + await Assert.That(row!.Data.Embedding).IsNull(); + + var entry = context.Entry(row); + await Assert.That(entry.Property("embedding").CurrentValue).IsNull(); + } + + // ==================== Store Integration Tests ==================== + + [Test] + public async Task Store_UpsertWithPhysicalFieldsAsync_PersistsShadowPropertiesAsync() { + // Arrange + var context = _createInMemoryDbContext(); + var strategy = new InMemoryUpsertStrategy(); + var store = new EFCorePostgresPerspectiveStore( + context, + "wh_per_physical_field_test_model", + strategy); + + var model = new PhysicalFieldTestModel { + Name = "StoreTest", + Price = 99.99m, + Description = "Testing store integration" + }; + var testId = _idProvider.NewGuid(); + var physicalFieldValues = new Dictionary { + { "name", model.Name }, + { "price", model.Price } + }; + + // Act + await store.UpsertWithPhysicalFieldsAsync(testId, model, physicalFieldValues); + + // Assert - verify data was persisted + var row = await context.Set>() + .FirstOrDefaultAsync(r => r.Id == testId); + + await Assert.That(row).IsNotNull(); + await Assert.That(row!.Data.Name).IsEqualTo("StoreTest"); + await Assert.That(row.Data.Price).IsEqualTo(99.99m); + + // Verify shadow properties + var entry = context.Entry(row); + await Assert.That(entry.Property("name").CurrentValue).IsEqualTo("StoreTest"); + await Assert.That(entry.Property("price").CurrentValue).IsEqualTo(99.99m); + } + + [Test] + public async Task Store_UpsertWithPhysicalFieldsAsync_UpdatesExistingRecordAsync() { + // Arrange + var context = _createInMemoryDbContext(); + var strategy = new InMemoryUpsertStrategy(); + var store = new EFCorePostgresPerspectiveStore( + context, + "wh_per_physical_field_test_model", + strategy); + var testId = _idProvider.NewGuid(); + + // Create initial record + var initialModel = new PhysicalFieldTestModel { + Name = "Initial", + Price = 10.00m, + Description = "First version" + }; + var initialPhysicalValues = new Dictionary { + { "name", initialModel.Name }, + { "price", initialModel.Price } + }; + await store.UpsertWithPhysicalFieldsAsync(testId, initialModel, initialPhysicalValues); + + // Act - update the record + var updatedModel = new PhysicalFieldTestModel { + Name = "Updated", + Price = 20.00m, + Description = "Second version" + }; + var updatedPhysicalValues = new Dictionary { + { "name", updatedModel.Name }, + { "price", updatedModel.Price } + }; + await store.UpsertWithPhysicalFieldsAsync(testId, updatedModel, updatedPhysicalValues); + + // Assert + var row = await context.Set>() + .FirstOrDefaultAsync(r => r.Id == testId); + + await Assert.That(row).IsNotNull(); + await Assert.That(row!.Data.Name).IsEqualTo("Updated"); + await Assert.That(row.Data.Price).IsEqualTo(20.00m); + await Assert.That(row.Version).IsEqualTo(2); + + var entry = context.Entry(row); + await Assert.That(entry.Property("name").CurrentValue).IsEqualTo("Updated"); + await Assert.That(entry.Property("price").CurrentValue).IsEqualTo(20.00m); + } +} diff --git a/tests/Whizbang.Data.EFCore.Postgres.Tests/PrincipalFilterExtensionsTests.cs b/tests/Whizbang.Data.EFCore.Postgres.Tests/PrincipalFilterExtensionsTests.cs index ca1b52c3..62acfa3f 100644 --- a/tests/Whizbang.Data.EFCore.Postgres.Tests/PrincipalFilterExtensionsTests.cs +++ b/tests/Whizbang.Data.EFCore.Postgres.Tests/PrincipalFilterExtensionsTests.cs @@ -29,7 +29,7 @@ private async Task _seedOrderWithPrincipalsAsync( Guid orderId, string tenantId, string? userId, - IReadOnlyList? allowedPrincipals) { + List? allowedPrincipals) { var order = new Order { OrderId = TestOrderId.From(orderId), @@ -48,7 +48,7 @@ private async Task _seedOrderWithPrincipalsAsync( Scope = new PerspectiveScope { TenantId = tenantId, UserId = userId, - AllowedPrincipals = allowedPrincipals + AllowedPrincipals = allowedPrincipals ?? [] }, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, @@ -70,7 +70,7 @@ public async Task FilterByPrincipals_EmptyCallerPrincipals_ReturnsNoRowsAsync() var orderId = _idProvider.NewGuid(); await _seedOrderWithPrincipalsAsync( context, orderId, "tenant-1", "user-1", - [SecurityPrincipalId.User("user-1")]); + ["user:user-1"]); // Act - Empty principals means no access var result = await context.Set>() @@ -89,7 +89,7 @@ public async Task FilterByPrincipals_MatchingUserPrincipal_ReturnsRowAsync() { var orderId = _idProvider.NewGuid(); await _seedOrderWithPrincipalsAsync( context, orderId, "tenant-1", "user-1", - [SecurityPrincipalId.User("alice")]); + ["user:alice"]); var callerPrincipals = new HashSet { SecurityPrincipalId.User("alice") @@ -113,10 +113,7 @@ public async Task FilterByPrincipals_MatchingGroupPrincipal_ReturnsRowAsync() { var orderId = _idProvider.NewGuid(); await _seedOrderWithPrincipalsAsync( context, orderId, "tenant-1", "user-1", - [ - SecurityPrincipalId.Group("sales-team"), - SecurityPrincipalId.Group("managers") - ]); + ["group:sales-team", "group:managers"]); var callerPrincipals = new HashSet { SecurityPrincipalId.User("bob"), @@ -141,10 +138,7 @@ public async Task FilterByPrincipals_NoMatchingPrincipal_ReturnsNoRowsAsync() { var orderId = _idProvider.NewGuid(); await _seedOrderWithPrincipalsAsync( context, orderId, "tenant-1", "user-1", - [ - SecurityPrincipalId.User("alice"), - SecurityPrincipalId.Group("sales-team") - ]); + ["user:alice", "group:sales-team"]); var callerPrincipals = new HashSet { SecurityPrincipalId.User("bob"), @@ -169,22 +163,19 @@ public async Task FilterByPrincipals_MultipleRowsWithOverlap_ReturnsMatchingRows var order1Id = _idProvider.NewGuid(); await _seedOrderWithPrincipalsAsync( context, order1Id, "tenant-1", "user-1", - [SecurityPrincipalId.Group("sales-team")]); + ["group:sales-team"]); // Row 2: Shared with engineering (no overlap with caller) var order2Id = _idProvider.NewGuid(); await _seedOrderWithPrincipalsAsync( context, order2Id, "tenant-1", "user-2", - [SecurityPrincipalId.Group("engineering")]); + ["group:engineering"]); // Row 3: Shared with both sales-team and managers var order3Id = _idProvider.NewGuid(); await _seedOrderWithPrincipalsAsync( context, order3Id, "tenant-1", "user-3", - [ - SecurityPrincipalId.Group("sales-team"), - SecurityPrincipalId.Group("managers") - ]); + ["group:sales-team", "group:managers"]); var callerPrincipals = new HashSet { SecurityPrincipalId.User("bob"), @@ -237,7 +228,7 @@ public async Task FilterByUserOrPrincipals_MatchingUserId_ReturnsRowAsync() { var orderId = _idProvider.NewGuid(); await _seedOrderWithPrincipalsAsync( context, orderId, "tenant-1", "user-alice", - [SecurityPrincipalId.Group("other-team")]); + ["group:other-team"]); var callerPrincipals = new HashSet { SecurityPrincipalId.User("alice"), @@ -262,7 +253,7 @@ public async Task FilterByUserOrPrincipals_MatchingPrincipal_ReturnsRowAsync() { var orderId = _idProvider.NewGuid(); await _seedOrderWithPrincipalsAsync( context, orderId, "tenant-1", "user-bob", // Different user - [SecurityPrincipalId.Group("sales-team")]); + ["group:sales-team"]); var callerPrincipals = new HashSet { SecurityPrincipalId.User("alice"), @@ -294,13 +285,13 @@ await _seedOrderWithPrincipalsAsync( var order2Id = _idProvider.NewGuid(); await _seedOrderWithPrincipalsAsync( context, order2Id, "tenant-1", "user-bob", - [SecurityPrincipalId.Group("alice-team")]); + ["group:alice-team"]); // Row 3: Neither owned nor shared with alice var order3Id = _idProvider.NewGuid(); await _seedOrderWithPrincipalsAsync( context, order3Id, "tenant-1", "user-charlie", - [SecurityPrincipalId.Group("other-team")]); + ["group:other-team"]); var callerPrincipals = new HashSet { SecurityPrincipalId.User("alice"), @@ -328,7 +319,7 @@ public async Task FilterByUserOrPrincipals_NoMatch_ReturnsNoRowsAsync() { var orderId = _idProvider.NewGuid(); await _seedOrderWithPrincipalsAsync( context, orderId, "tenant-1", "user-bob", - [SecurityPrincipalId.Group("bob-team")]); + ["group:bob-team"]); var callerPrincipals = new HashSet { SecurityPrincipalId.User("alice"), @@ -355,13 +346,13 @@ public async Task FilterByPrincipals_LargePrincipalSet_UsesArrayOverlapAndReturn var orderId = _idProvider.NewGuid(); await _seedOrderWithPrincipalsAsync( context, orderId, "tenant-1", "user-1", - [SecurityPrincipalId.Group("target-team")]); + ["group:target-team"]); // Row without the target principal var otherOrderId = _idProvider.NewGuid(); await _seedOrderWithPrincipalsAsync( context, otherOrderId, "tenant-1", "user-2", - [SecurityPrincipalId.Group("other-team")]); + ["group:other-team"]); // Caller has >10 principals (triggers array overlap mode) var callerPrincipals = new HashSet(); @@ -388,7 +379,7 @@ public async Task FilterByPrincipals_LargePrincipalSet_NoMatch_ReturnsNoRowsAsyn var orderId = _idProvider.NewGuid(); await _seedOrderWithPrincipalsAsync( context, orderId, "tenant-1", "user-1", - [SecurityPrincipalId.Group("specific-team")]); + ["group:specific-team"]); // Caller has >10 principals but none overlap var callerPrincipals = new HashSet(); @@ -414,22 +405,19 @@ public async Task FilterByPrincipals_LargePrincipalSet_MultipleRowsWithOverlap_R var order1Id = _idProvider.NewGuid(); await _seedOrderWithPrincipalsAsync( context, order1Id, "tenant-1", "user-1", - [SecurityPrincipalId.Group("target-team")]); + ["group:target-team"]); // Row 2: Shared with other-team (no overlap with caller) var order2Id = _idProvider.NewGuid(); await _seedOrderWithPrincipalsAsync( context, order2Id, "tenant-1", "user-2", - [SecurityPrincipalId.Group("other-team")]); + ["group:other-team"]); // Row 3: Shared with both target-team and managers (should match) var order3Id = _idProvider.NewGuid(); await _seedOrderWithPrincipalsAsync( context, order3Id, "tenant-1", "user-3", - [ - SecurityPrincipalId.Group("target-team"), - SecurityPrincipalId.Group("managers") - ]); + ["group:target-team", "group:managers"]); // Caller has >10 principals including target-team var callerPrincipals = new HashSet(); @@ -484,10 +472,7 @@ public async Task FilterByPrincipals_LargePrincipalSet_MatchingGroupPrincipal_Re var orderId = _idProvider.NewGuid(); await _seedOrderWithPrincipalsAsync( context, orderId, "tenant-1", "user-1", - [ - SecurityPrincipalId.Group("sales-team"), - SecurityPrincipalId.Group("managers") - ]); + ["group:sales-team", "group:managers"]); // Caller has >10 principals, one of which matches var callerPrincipals = new HashSet(); diff --git a/tests/Whizbang.Data.EFCore.Postgres.Tests/QueryTranslation/PhysicalFieldRegistryTests.cs b/tests/Whizbang.Data.EFCore.Postgres.Tests/QueryTranslation/PhysicalFieldRegistryTests.cs new file mode 100644 index 00000000..4b9e6a07 --- /dev/null +++ b/tests/Whizbang.Data.EFCore.Postgres.Tests/QueryTranslation/PhysicalFieldRegistryTests.cs @@ -0,0 +1,170 @@ +using TUnit.Assertions; +using TUnit.Assertions.Extensions; +using TUnit.Core; +using Whizbang.Data.EFCore.Postgres.QueryTranslation; + +namespace Whizbang.Data.EFCore.Postgres.Tests.QueryTranslation; + +/// +/// Unit tests for . +/// +[NotInParallel("PhysicalFieldRegistry")] +public class PhysicalFieldRegistryTests { + // Test model for registration + public class TestModel { + public string Name { get; init; } = string.Empty; + public decimal Price { get; init; } + } + + public class OtherModel { + public string Title { get; init; } = string.Empty; + } + + [Before(Test)] + public void Setup() { + // Clear registry before each test + PhysicalFieldRegistry.Clear(); + } + + [Test] + public async Task Register_WithValidParameters_AddsMapping() { + // Act + PhysicalFieldRegistry.Register("Price", "price"); + + // Assert + await Assert.That(PhysicalFieldRegistry.Count).IsEqualTo(1); + await Assert.That(PhysicalFieldRegistry.IsPhysicalField(typeof(TestModel), "Price")).IsTrue(); + } + + [Test] + public async Task Register_WithShadowPropertyName_StoresBothNames() { + // Act + PhysicalFieldRegistry.Register("IsActive", "is_active", "is_active"); + + // Assert + var found = PhysicalFieldRegistry.TryGetMapping(typeof(TestModel), "IsActive", out var mapping); + await Assert.That(found).IsTrue(); + await Assert.That(mapping.ColumnName).IsEqualTo("is_active"); + await Assert.That(mapping.ShadowPropertyName).IsEqualTo("is_active"); + } + + [Test] + public async Task TryGetMapping_WhenRegistered_ReturnsMapping() { + // Arrange + PhysicalFieldRegistry.Register("Name", "name"); + + // Act + var found = PhysicalFieldRegistry.TryGetMapping(typeof(TestModel), "Name", out var mapping); + + // Assert + await Assert.That(found).IsTrue(); + await Assert.That(mapping.ColumnName).IsEqualTo("name"); + await Assert.That(mapping.ShadowPropertyName).IsEqualTo("name"); + } + + [Test] + public async Task TryGetMapping_WhenNotRegistered_ReturnsFalse() { + // Act + var found = PhysicalFieldRegistry.TryGetMapping(typeof(TestModel), "NotRegistered", out _); + + // Assert + await Assert.That(found).IsFalse(); + } + + [Test] + public async Task IsPhysicalField_WhenRegistered_ReturnsTrue() { + // Arrange + PhysicalFieldRegistry.Register("Price", "price"); + + // Act & Assert + await Assert.That(PhysicalFieldRegistry.IsPhysicalField(typeof(TestModel), "Price")).IsTrue(); + } + + [Test] + public async Task IsPhysicalField_WhenNotRegistered_ReturnsFalse() { + // Act & Assert + await Assert.That(PhysicalFieldRegistry.IsPhysicalField(typeof(TestModel), "Price")).IsFalse(); + } + + [Test] + public async Task IsPhysicalField_DifferentModel_ReturnsFalse() { + // Arrange - register for TestModel + PhysicalFieldRegistry.Register("Name", "name"); + + // Act & Assert - different model should not match + await Assert.That(PhysicalFieldRegistry.IsPhysicalField(typeof(OtherModel), "Name")).IsFalse(); + } + + [Test] + public async Task GetMappingsForModel_ReturnsOnlyModelMappings() { + // Arrange + PhysicalFieldRegistry.Register("Name", "name"); + PhysicalFieldRegistry.Register("Price", "price"); + PhysicalFieldRegistry.Register("Title", "title"); + + // Act + var mappings = PhysicalFieldRegistry.GetMappingsForModel(typeof(TestModel)); + + // Assert + await Assert.That(mappings).Count().IsEqualTo(2); + await Assert.That(mappings.ContainsKey("Name")).IsTrue(); + await Assert.That(mappings.ContainsKey("Price")).IsTrue(); + await Assert.That(mappings.ContainsKey("Title")).IsFalse(); + } + + [Test] + public async Task Clear_RemovesAllMappings() { + // Arrange + PhysicalFieldRegistry.Register("Name", "name"); + PhysicalFieldRegistry.Register("Price", "price"); + + // Act + PhysicalFieldRegistry.Clear(); + + // Assert + await Assert.That(PhysicalFieldRegistry.Count).IsEqualTo(0); + } + + [Test] + public void Register_WithNullModelType_ThrowsArgumentNullException() { + // Act & Assert + Assert.Throws(() => PhysicalFieldRegistry.Register(null!, "Name", "name")); + } + + [Test] + public void Register_WithNullPropertyName_ThrowsArgumentException() { + // Act & Assert + Assert.Throws(() => PhysicalFieldRegistry.Register(null!, "name")); + } + + [Test] + public void Register_WithEmptyColumnName_ThrowsArgumentException() { + // Act & Assert + Assert.Throws(() => PhysicalFieldRegistry.Register("Name", "")); + } + + [Test] + public async Task Register_OverwritesExistingMapping() { + // Arrange + PhysicalFieldRegistry.Register("Name", "old_name"); + + // Act + PhysicalFieldRegistry.Register("Name", "new_name"); + + // Assert + var found = PhysicalFieldRegistry.TryGetMapping(typeof(TestModel), "Name", out var mapping); + await Assert.That(found).IsTrue(); + await Assert.That(mapping.ColumnName).IsEqualTo("new_name"); + } + + [Test] + public async Task NonGenericRegister_WorksCorrectlyAsync() { + // Act - Use non-generic version explicitly for runtime registration scenarios +#pragma warning disable CA2263 // Prefer generic overload - testing non-generic path intentionally + PhysicalFieldRegistry.Register(typeof(TestModel), "Name", "name"); +#pragma warning restore CA2263 + + // Assert + await Assert.That(PhysicalFieldRegistry.IsPhysicalField(typeof(TestModel), "Name")).IsTrue(); + } +} diff --git a/tests/Whizbang.Data.EFCore.Postgres.Tests/ScopedLensFactoryIntegrationTests.cs b/tests/Whizbang.Data.EFCore.Postgres.Tests/ScopedLensFactoryIntegrationTests.cs index 2590a4af..3763bb45 100644 --- a/tests/Whizbang.Data.EFCore.Postgres.Tests/ScopedLensFactoryIntegrationTests.cs +++ b/tests/Whizbang.Data.EFCore.Postgres.Tests/ScopedLensFactoryIntegrationTests.cs @@ -355,7 +355,7 @@ private async Task _seedOrderAsync( decimal amount, string? tenantId = null, string? userId = null, - IReadOnlyList? allowedPrincipals = null) { + List? allowedPrincipals = null) { var order = new Order { OrderId = TestOrderId.From(orderId), @@ -374,7 +374,7 @@ private async Task _seedOrderAsync( Scope = new PerspectiveScope { TenantId = tenantId, UserId = userId, - AllowedPrincipals = allowedPrincipals + AllowedPrincipals = allowedPrincipals ?? [] }, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, diff --git a/tests/Whizbang.Data.EFCore.Postgres.Tests/UnifiedQuerySyntaxTests.cs b/tests/Whizbang.Data.EFCore.Postgres.Tests/UnifiedQuerySyntaxTests.cs new file mode 100644 index 00000000..69fe7d40 --- /dev/null +++ b/tests/Whizbang.Data.EFCore.Postgres.Tests/UnifiedQuerySyntaxTests.cs @@ -0,0 +1,420 @@ +using Dapper; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; +using TUnit.Core; +using Whizbang.Core; +using Whizbang.Core.Lenses; +using Whizbang.Core.Perspectives; +using Whizbang.Data.EFCore.Postgres.QueryTranslation; +using Whizbang.Testing.Containers; + +namespace Whizbang.Data.EFCore.Postgres.Tests; + +/// +/// TDD tests for unified query syntax - verifies that r.Data.PropertyName +/// queries translate to physical column access for [PhysicalField] properties. +/// +/// +/// These tests follow TDD RED-GREEN-REFACTOR: +/// - RED: Tests initially fail because r.Data.PropertyName goes through JSONB +/// - GREEN: After implementing PhysicalFieldMemberTranslator, tests pass +/// - REFACTOR: Clean up and optimize implementation +/// +/// The unified syntax means users write: +/// .Where(r => r.Data.Price >= 20.00m) // Looks like JSONB access +/// But the translator redirects physical fields to column access: +/// WHERE price >= 20.00 // Uses indexed physical column +/// +[Category("Integration")] +[NotInParallel("PostgreSQL")] +public class UnifiedQuerySyntaxTests : IAsyncDisposable { + private static readonly Uuid7IdProvider _idProvider = new(); + + static UnifiedQuerySyntaxTests() { + AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", false); + } + + private string? _testDatabaseName; + private NpgsqlDataSource? _dataSource; + private UnifiedQueryDbContext? _context; + private string _connectionString = null!; + + /// + /// Test model representing a product with physical fields. + /// Physical fields: Name, Price, Category, IsActive + /// JSONB-only fields: Description, Tags + /// + public class ProductModel { + public string Name { get; init; } = string.Empty; + public decimal Price { get; init; } + public string? Category { get; init; } + public bool IsActive { get; init; } + public string? Description { get; init; } // JSONB only + public List Tags { get; init; } = []; // JSONB only + } + + /// + /// DbContext configured with physical fields as shadow properties. + /// + private sealed class UnifiedQueryDbContext : DbContext { + public UnifiedQueryDbContext(DbContextOptions options) + : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity>(entity => { + entity.ToTable("wh_per_unified_test"); + entity.HasKey(e => e.Id); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.CreatedAt).HasColumnName("created_at"); + entity.Property(e => e.UpdatedAt).HasColumnName("updated_at"); + entity.Property(e => e.Version).HasColumnName("version"); + + entity.Property(e => e.Data) + .HasColumnName("data") + .HasColumnType("jsonb"); + + entity.Property(e => e.Metadata) + .HasColumnName("metadata") + .HasColumnType("jsonb"); + + entity.Property(e => e.Scope) + .HasColumnName("scope") + .HasColumnType("jsonb"); + + // Physical fields as shadow properties + entity.Property("name").HasColumnName("name").HasMaxLength(200); + entity.Property("price").HasColumnName("price"); + entity.Property("category").HasColumnName("category").HasMaxLength(100); + entity.Property("is_active").HasColumnName("is_active"); + + entity.HasIndex("name"); + entity.HasIndex("price"); + entity.HasIndex("category"); + entity.HasIndex("is_active"); + }); + } + } + + [Before(Test)] + public async Task SetupAsync() { + await SharedPostgresContainer.InitializeAsync(); + + _testDatabaseName = $"unified_test_{Guid.NewGuid():N}"; + + await using var adminConnection = new NpgsqlConnection(SharedPostgresContainer.ConnectionString); + await adminConnection.OpenAsync(); + await adminConnection.ExecuteAsync($"CREATE DATABASE {_testDatabaseName}"); + + var builder = new NpgsqlConnectionStringBuilder(SharedPostgresContainer.ConnectionString) { + Database = _testDatabaseName, + Timezone = "UTC", + IncludeErrorDetail = true + }; + _connectionString = builder.ConnectionString; + + var dataSourceBuilder = new NpgsqlDataSourceBuilder(_connectionString); + dataSourceBuilder.EnableDynamicJson(); + _dataSource = dataSourceBuilder.Build(); + + // Register physical fields for ProductModel + PhysicalFieldRegistry.Clear(); // Clear any previous registrations + PhysicalFieldRegistry.Register("Name", "name"); + PhysicalFieldRegistry.Register("Price", "price"); + PhysicalFieldRegistry.Register("Category", "category"); + PhysicalFieldRegistry.Register("IsActive", "is_active"); + + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder + .UseNpgsql(_dataSource) + .UseWhizbangPhysicalFields() + .ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.CoreEventId.ManyServiceProvidersCreatedWarning)); + _context = new UnifiedQueryDbContext(optionsBuilder.Options); + + await _initializeSchemaAsync(); + await _seedTestDataAsync(); + } + + [After(Test)] + public async Task TeardownAsync() { + if (_context != null) { + await _context.DisposeAsync(); + _context = null; + } + + if (_dataSource != null) { + await _dataSource.DisposeAsync(); + _dataSource = null; + } + + if (_testDatabaseName != null) { + try { + await using var adminConnection = new NpgsqlConnection(SharedPostgresContainer.ConnectionString); + await adminConnection.OpenAsync(); + + await adminConnection.ExecuteAsync($@" + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = '{_testDatabaseName}' + AND pid <> pg_backend_pid()"); + + await adminConnection.ExecuteAsync($"DROP DATABASE IF EXISTS {_testDatabaseName}"); + } catch { + // Ignore cleanup errors + } + + _testDatabaseName = null; + } + } + + public async ValueTask DisposeAsync() { + await TeardownAsync(); + GC.SuppressFinalize(this); + } + + private async Task _initializeSchemaAsync() { + await using var connection = new NpgsqlConnection(_connectionString); + await connection.OpenAsync(); + + await connection.ExecuteAsync(""" + CREATE TABLE IF NOT EXISTS wh_per_unified_test ( + id UUID PRIMARY KEY, + data JSONB NOT NULL, + metadata JSONB NOT NULL, + scope JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + version INTEGER NOT NULL, + name VARCHAR(200), + price DECIMAL NOT NULL DEFAULT 0, + category VARCHAR(100), + is_active BOOLEAN NOT NULL DEFAULT FALSE + ); + + CREATE INDEX IF NOT EXISTS idx_unified_test_name ON wh_per_unified_test(name); + CREATE INDEX IF NOT EXISTS idx_unified_test_price ON wh_per_unified_test(price); + CREATE INDEX IF NOT EXISTS idx_unified_test_category ON wh_per_unified_test(category); + CREATE INDEX IF NOT EXISTS idx_unified_test_is_active ON wh_per_unified_test(is_active); + """); + } + + private async Task _seedTestDataAsync() { + var strategy = new PostgresUpsertStrategy(); + + var testProducts = new[] { + new ProductModel { Name = "Widget A", Price = 15.00m, Category = "Widgets", IsActive = true, Description = "A basic widget" }, + new ProductModel { Name = "Widget B", Price = 25.00m, Category = "Widgets", IsActive = true, Description = "A better widget" }, + new ProductModel { Name = "Gadget X", Price = 50.00m, Category = "Gadgets", IsActive = true, Description = "A useful gadget" }, + new ProductModel { Name = "Gadget Y", Price = 75.00m, Category = "Gadgets", IsActive = false, Description = "Discontinued gadget" }, + new ProductModel { Name = "Tool Z", Price = 100.00m, Category = "Tools", IsActive = true, Description = "Professional tool" } + }; + + var metadata = new PerspectiveMetadata { + EventType = "ProductCreated", + EventId = Guid.NewGuid().ToString(), + Timestamp = DateTime.UtcNow + }; + var scope = new PerspectiveScope(); + + foreach (var product in testProducts) { + var id = _idProvider.NewGuid(); + var physicalFields = new Dictionary { + { "name", product.Name }, + { "price", product.Price }, + { "category", product.Category }, + { "is_active", product.IsActive } + }; + + await strategy.UpsertPerspectiveRowWithPhysicalFieldsAsync( + _context!, + "wh_per_unified_test", + id, + product, + metadata, + scope, + physicalFields); + } + } + + // ==================== UNIFIED QUERY SYNTAX TESTS ==================== + // These tests verify r.Data.PropertyName translates to physical column access + + /// + /// Test 1: Basic WHERE clause on physical field should use column, not JSONB. + /// Verifies the SQL contains "price" column reference, not JSONB path like data->>'Price'. + /// + [Test] + [Timeout(60000)] + public async Task Query_WhereOnPhysicalField_UsesColumnNotJsonbAsync(CancellationToken cancellationToken) { + // Arrange - query using unified syntax r.Data.Price + var query = _context!.Set>() + .Where(r => r.Data.Price >= 50.00m); + + // Capture the generated SQL + var sql = query.ToQueryString(); + + // Act + var results = await query.ToListAsync(cancellationToken); + + // Assert - SQL must use physical column "w.price", NOT JSONB extraction "data ->> 'Price'" + // Current behavior (before fix): CAST(w.data ->> 'Price' AS numeric) >= 50.0 + // Expected behavior (after fix): w.price >= 50.0 + await Assert.That(sql).DoesNotContain("data ->> 'Price'"); + await Assert.That(sql.ToLowerInvariant()).Contains(".price >= "); + + // Assert - should find Gadget X ($50), Gadget Y ($75), Tool Z ($100) + await Assert.That(results).Count().IsEqualTo(3); + + // Verify data is correctly loaded + var prices = results.Select(r => r.Data.Price).OrderBy(p => p).ToList(); + await Assert.That(prices).Contains(50.00m); + await Assert.That(prices).Contains(75.00m); + await Assert.That(prices).Contains(100.00m); + } + + /// + /// Test 2: Comparison operators on physical field should use column. + /// + [Test] + [Timeout(60000)] + public async Task Query_PhysicalFieldGreaterThan_TranslatesToColumnComparisonAsync(CancellationToken cancellationToken) { + // Arrange - price > 25 should find items priced at 50, 75, 100 + var query = _context!.Set>() + .Where(r => r.Data.Price > 25.00m); + + // Act + var results = await query.ToListAsync(cancellationToken); + + // Assert + await Assert.That(results).Count().IsEqualTo(3); + var allPricesOver25 = results.All(r => r.Data.Price > 25.00m); + await Assert.That(allPricesOver25).IsTrue(); + } + + /// + /// Test 3: String Contains on physical field should use column LIKE. + /// + [Test] + [Timeout(60000)] + public async Task Query_PhysicalFieldStringContains_TranslatesToColumnLikeAsync(CancellationToken cancellationToken) { + // Arrange - find products with "Widget" in name + var query = _context!.Set>() + .Where(r => r.Data.Name.Contains("Widget")); + + // Act + var results = await query.ToListAsync(cancellationToken); + + // Assert - should find Widget A and Widget B + await Assert.That(results).Count().IsEqualTo(2); + var allContainWidget = results.All(r => r.Data.Name.Contains("Widget")); + await Assert.That(allContainWidget).IsTrue(); + } + + /// + /// Test 4: Mixed physical and JSONB fields in same WHERE clause. + /// Physical: r.Data.Price (uses column) + /// JSONB: r.Data.Description (uses data->>'Description') + /// + [Test] + [Timeout(60000)] + public async Task Query_MixedPhysicalAndJsonb_BothTranslateCorrectlyAsync(CancellationToken cancellationToken) { + // Arrange - Price (physical) and Description (JSONB) + var query = _context!.Set>() + .Where(r => r.Data.Price >= 50.00m) + .Where(r => r.Data.Description!.Contains("Professional")); + + // Act + var results = await query.ToListAsync(cancellationToken); + + // Assert - only Tool Z matches both criteria + await Assert.That(results).Count().IsEqualTo(1); + await Assert.That(results[0].Data.Name).IsEqualTo("Tool Z"); + } + + /// + /// Test 5: ORDER BY on physical field should use column. + /// + [Test] + [Timeout(60000)] + public async Task Query_OrderByPhysicalField_UsesColumnNotJsonbAsync(CancellationToken cancellationToken) { + // Arrange - order by price ascending + var query = _context!.Set>() + .OrderBy(r => r.Data.Price); + + // Act + var results = await query.ToListAsync(cancellationToken); + + // Assert - verify correct order + await Assert.That(results).Count().IsEqualTo(5); + await Assert.That(results[0].Data.Price).IsEqualTo(15.00m); // Widget A + await Assert.That(results[1].Data.Price).IsEqualTo(25.00m); // Widget B + await Assert.That(results[2].Data.Price).IsEqualTo(50.00m); // Gadget X + await Assert.That(results[3].Data.Price).IsEqualTo(75.00m); // Gadget Y + await Assert.That(results[4].Data.Price).IsEqualTo(100.00m); // Tool Z + } + + /// + /// Test 6: SELECT projection including physical field. + /// + [Test] + [Timeout(60000)] + public async Task Query_SelectPhysicalField_ReturnsFromColumnAsync(CancellationToken cancellationToken) { + // Arrange - select just Name and Price + var query = _context!.Set>() + .Where(r => r.Data.Category == "Widgets") + .Select(r => new { r.Data.Name, r.Data.Price }); + + // Act + var results = await query.ToListAsync(cancellationToken); + + // Assert + await Assert.That(results).Count().IsEqualTo(2); + var hasWidgetA = results.Any(r => r.Name == "Widget A" && r.Price == 15.00m); + var hasWidgetB = results.Any(r => r.Name == "Widget B" && r.Price == 25.00m); + await Assert.That(hasWidgetA).IsTrue(); + await Assert.That(hasWidgetB).IsTrue(); + } + + /// + /// Test 7: Multiple physical fields in same query. + /// + [Test] + [Timeout(60000)] + public async Task Query_MultiplePhysicalFields_AllUseColumnsAsync(CancellationToken cancellationToken) { + // Arrange - filter on Price, Category, and IsActive + var query = _context!.Set>() + .Where(r => r.Data.Price < 100.00m) + .Where(r => r.Data.Category == "Gadgets") + .Where(r => r.Data.IsActive); + + // Act + var results = await query.ToListAsync(cancellationToken); + + // Assert - only Gadget X matches (Gadget Y is inactive) + await Assert.That(results).Count().IsEqualTo(1); + await Assert.That(results[0].Data.Name).IsEqualTo("Gadget X"); + } + + /// + /// Test 8: Non-physical field (Description) should still use JSONB. + /// This verifies we don't break JSONB queries for non-physical fields. + /// + [Test] + [Timeout(60000)] + public async Task Query_NonPhysicalField_StillUsesJsonbAsync(CancellationToken cancellationToken) { + // Arrange - Description is NOT a physical field, should use JSONB + var query = _context!.Set>() + .Where(r => r.Data.Description!.Contains("basic")); + + // Act + var results = await query.ToListAsync(cancellationToken); + + // Assert - should find Widget A + await Assert.That(results).Count().IsEqualTo(1); + await Assert.That(results[0].Data.Name).IsEqualTo("Widget A"); + } +} diff --git a/tests/Whizbang.Generators.Tests/EFCoreServiceRegistrationGeneratorTests.cs b/tests/Whizbang.Generators.Tests/EFCoreServiceRegistrationGeneratorTests.cs index 3770454d..4c9ce536 100644 --- a/tests/Whizbang.Generators.Tests/EFCoreServiceRegistrationGeneratorTests.cs +++ b/tests/Whizbang.Generators.Tests/EFCoreServiceRegistrationGeneratorTests.cs @@ -786,5 +786,60 @@ public TestDbContext(DbContextOptions options) : base(options) { await Assert.That(errors).IsEmpty(); } + /// + /// Test that schema extensions include GIN indexes for JSONB columns. + /// GIN indexes enable efficient LINQ queries on JSONB data (containment, key lookups, path expressions). + /// + [Test] + public async Task Generator_SchemaExtensions_IncludesGinIndexesForJsonbColumnsAsync() { + // Arrange - use explicit perspective that implements IPerspectiveFor + // The PERSPECTIVE_BOILERPLATE doesn't implement the interface, so we need a full definition + var source = """ + using System.Threading; + using System.Threading.Tasks; + using Microsoft.EntityFrameworkCore; + using Whizbang.Core; + using Whizbang.Core.Perspectives; + using Whizbang.Data.EFCore.Custom; + + namespace TestApp; + + public record TestEvent : IEvent; + public record TestModel { public string Id { get; init; } = ""; } + + // Perspective that implements IPerspectiveFor (required for generator discovery) + public class TestPerspective : IPerspectiveFor { + private readonly IPerspectiveStore _store; + public TestPerspective(IPerspectiveStore store) => _store = store; + public Task UpdateAsync(TestEvent @event, CancellationToken ct = default) => Task.CompletedTask; + } + + [WhizbangDbContext] + public class TestDbContext : DbContext { + public TestDbContext(DbContextOptions options) : base(options) { } + } + """; + + // Act + var result = await GeneratorTestHelpers.RunServiceRegistrationGeneratorAsync(source); + + // Assert + var schemaExtensions = result.GeneratedSources.FirstOrDefault(s => s.HintName.Contains("SchemaExtensions")); + await Assert.That(schemaExtensions).IsNotNull(); + + var sourceText = schemaExtensions!.SourceText.ToString(); + + // Should include GIN indexes for all JSONB columns + // GIN indexes use "USING gin (column)" syntax + await Assert.That(sourceText).Contains("USING gin (data)"); + await Assert.That(sourceText).Contains("USING gin (metadata)"); + await Assert.That(sourceText).Contains("USING gin (scope)"); + + // Should have index names following convention + await Assert.That(sourceText).Contains("_data_gin"); + await Assert.That(sourceText).Contains("_metadata_gin"); + await Assert.That(sourceText).Contains("_scope_gin"); + } + #endregion } diff --git a/tests/Whizbang.Generators.Tests/MessageJsonContextGeneratorTests.cs b/tests/Whizbang.Generators.Tests/MessageJsonContextGeneratorTests.cs index 13ccdf3b..472e32bb 100644 --- a/tests/Whizbang.Generators.Tests/MessageJsonContextGeneratorTests.cs +++ b/tests/Whizbang.Generators.Tests/MessageJsonContextGeneratorTests.cs @@ -155,7 +155,8 @@ public record CreateOrder(string OrderId) : ICommand; await Assert.That(code).IsNotNull(); // Should generate specific factory method for MessageEnvelope - await Assert.That(code!).Contains("CreateMessageEnvelope_CreateOrder"); + // Safe names use fully qualified path to avoid collisions (e.g., MyApp_Commands_CreateOrder) + await Assert.That(code!).Contains("CreateMessageEnvelope_MyApp_Commands_CreateOrder"); await Assert.That(code).Contains("MessageEnvelope"); } @@ -612,7 +613,7 @@ public record CreateOrder(string OrderId, List PublicItems) : ICom await Assert.That(code).IsNotNull(); await Assert.That(code!).Contains("CreateOrder"); await Assert.That(code).Contains("PublicDetail"); - // PublicDetail should have factory method - await Assert.That(code).Contains("Create_PublicDetail"); + // PublicDetail should have factory method with safe name (namespace-qualified) + await Assert.That(code).Contains("Create_MyApp_PublicDetail"); } } diff --git a/tests/Whizbang.Generators.Tests/Models/PhysicalFieldInfoTests.cs b/tests/Whizbang.Generators.Tests/Models/PhysicalFieldInfoTests.cs new file mode 100644 index 00000000..cdea8589 --- /dev/null +++ b/tests/Whizbang.Generators.Tests/Models/PhysicalFieldInfoTests.cs @@ -0,0 +1,207 @@ +extern alias shared; +using GeneratorVectorDistanceMetric = shared::Whizbang.Generators.Shared.Models.GeneratorVectorDistanceMetric; +using GeneratorVectorIndexType = shared::Whizbang.Generators.Shared.Models.GeneratorVectorIndexType; +using PhysicalFieldInfo = shared::Whizbang.Generators.Shared.Models.PhysicalFieldInfo; + +namespace Whizbang.Generators.Tests.Models; + +/// +/// Tests for record. +/// Validates value type equality and property storage for incremental generator caching. +/// +public class PhysicalFieldInfoTests { + [Test] + public async Task PhysicalFieldInfo_Constructor_SetsAllPropertiesAsync() { + // Arrange & Act + var info = new PhysicalFieldInfo( + PropertyName: "Price", + ColumnName: "price", + TypeName: "System.Decimal", + IsIndexed: true, + IsUnique: false, + MaxLength: null, + IsVector: false, + VectorDimensions: null, + VectorDistanceMetric: null, + VectorIndexType: null, + VectorIndexLists: null + ); + + // Assert + await Assert.That(info.PropertyName).IsEqualTo("Price"); + await Assert.That(info.ColumnName).IsEqualTo("price"); + await Assert.That(info.TypeName).IsEqualTo("System.Decimal"); + await Assert.That(info.IsIndexed).IsTrue(); + await Assert.That(info.IsUnique).IsFalse(); + await Assert.That(info.MaxLength).IsNull(); + await Assert.That(info.IsVector).IsFalse(); + await Assert.That(info.VectorDimensions).IsNull(); + await Assert.That(info.VectorDistanceMetric).IsNull(); + await Assert.That(info.VectorIndexType).IsNull(); + await Assert.That(info.VectorIndexLists).IsNull(); + } + + [Test] + public async Task PhysicalFieldInfo_VectorField_SetsVectorPropertiesAsync() { + // Arrange & Act + var info = new PhysicalFieldInfo( + PropertyName: "Embedding", + ColumnName: "embedding", + TypeName: "System.Single[]", + IsIndexed: true, + IsUnique: false, + MaxLength: null, + IsVector: true, + VectorDimensions: 1536, + VectorDistanceMetric: GeneratorVectorDistanceMetric.Cosine, + VectorIndexType: GeneratorVectorIndexType.HNSW, + VectorIndexLists: 100 + ); + + // Assert + await Assert.That(info.IsVector).IsTrue(); + await Assert.That(info.VectorDimensions).IsEqualTo(1536); + await Assert.That(info.VectorDistanceMetric).IsEqualTo(GeneratorVectorDistanceMetric.Cosine); + await Assert.That(info.VectorIndexType).IsEqualTo(GeneratorVectorIndexType.HNSW); + await Assert.That(info.VectorIndexLists).IsEqualTo(100); + } + + [Test] + public async Task PhysicalFieldInfo_ValueEquality_MatchesWhenFieldsEqualAsync() { + // Arrange + var info1 = new PhysicalFieldInfo( + PropertyName: "Name", + ColumnName: "name", + TypeName: "System.String", + IsIndexed: true, + IsUnique: false, + MaxLength: 200, + IsVector: false, + VectorDimensions: null, + VectorDistanceMetric: null, + VectorIndexType: null, + VectorIndexLists: null + ); + + var info2 = new PhysicalFieldInfo( + PropertyName: "Name", + ColumnName: "name", + TypeName: "System.String", + IsIndexed: true, + IsUnique: false, + MaxLength: 200, + IsVector: false, + VectorDimensions: null, + VectorDistanceMetric: null, + VectorIndexType: null, + VectorIndexLists: null + ); + + // Act & Assert - Value equality for incremental caching + await Assert.That(info1).IsEqualTo(info2); + await Assert.That(info1.GetHashCode()).IsEqualTo(info2.GetHashCode()); + } + + [Test] + public async Task PhysicalFieldInfo_ValueEquality_DiffersWhenFieldsDifferAsync() { + // Arrange + var info1 = new PhysicalFieldInfo( + PropertyName: "Name", + ColumnName: "name", + TypeName: "System.String", + IsIndexed: true, + IsUnique: false, + MaxLength: 200, + IsVector: false, + VectorDimensions: null, + VectorDistanceMetric: null, + VectorIndexType: null, + VectorIndexLists: null + ); + + var info2 = new PhysicalFieldInfo( + PropertyName: "Name", + ColumnName: "name", + TypeName: "System.String", + IsIndexed: false, // Different! + IsUnique: false, + MaxLength: 200, + IsVector: false, + VectorDimensions: null, + VectorDistanceMetric: null, + VectorIndexType: null, + VectorIndexLists: null + ); + + // Act & Assert + await Assert.That(info1).IsNotEqualTo(info2); + } + + [Test] + public async Task PhysicalFieldInfo_WithMaxLength_StoresValueAsync() { + // Arrange & Act + var info = new PhysicalFieldInfo( + PropertyName: "Sku", + ColumnName: "sku", + TypeName: "System.String", + IsIndexed: true, + IsUnique: true, + MaxLength: 50, + IsVector: false, + VectorDimensions: null, + VectorDistanceMetric: null, + VectorIndexType: null, + VectorIndexLists: null + ); + + // Assert + await Assert.That(info.MaxLength).IsEqualTo(50); + await Assert.That(info.IsUnique).IsTrue(); + } + + [Test] + public async Task PhysicalFieldInfo_VectorWithIVFFlat_StoresIndexTypeAsync() { + // Arrange & Act + var info = new PhysicalFieldInfo( + PropertyName: "ContentEmbedding", + ColumnName: "content_embedding", + TypeName: "System.Single[]", + IsIndexed: true, + IsUnique: false, + MaxLength: null, + IsVector: true, + VectorDimensions: 768, + VectorDistanceMetric: GeneratorVectorDistanceMetric.L2, + VectorIndexType: GeneratorVectorIndexType.IVFFlat, + VectorIndexLists: 50 + ); + + // Assert + await Assert.That(info.VectorIndexType).IsEqualTo(GeneratorVectorIndexType.IVFFlat); + await Assert.That(info.VectorDistanceMetric).IsEqualTo(GeneratorVectorDistanceMetric.L2); + await Assert.That(info.VectorIndexLists).IsEqualTo(50); + } + + [Test] + public async Task PhysicalFieldInfo_VectorNoIndex_HasNoneIndexTypeAsync() { + // Arrange & Act + var info = new PhysicalFieldInfo( + PropertyName: "TempEmbedding", + ColumnName: "temp_embedding", + TypeName: "System.Single[]", + IsIndexed: false, + IsUnique: false, + MaxLength: null, + IsVector: true, + VectorDimensions: 256, + VectorDistanceMetric: GeneratorVectorDistanceMetric.InnerProduct, + VectorIndexType: GeneratorVectorIndexType.None, + VectorIndexLists: null + ); + + // Assert + await Assert.That(info.IsIndexed).IsFalse(); + await Assert.That(info.VectorIndexType).IsEqualTo(GeneratorVectorIndexType.None); + await Assert.That(info.VectorIndexLists).IsNull(); + } +} diff --git a/tests/Whizbang.Generators.Tests/PhysicalFieldDiscoveryTests.cs b/tests/Whizbang.Generators.Tests/PhysicalFieldDiscoveryTests.cs new file mode 100644 index 00000000..29dbe361 --- /dev/null +++ b/tests/Whizbang.Generators.Tests/PhysicalFieldDiscoveryTests.cs @@ -0,0 +1,450 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.CodeAnalysis; + +namespace Whizbang.Generators.Tests; + +/// +/// Tests for physical field discovery in PerspectiveSchemaGenerator. +/// Validates that [PhysicalField] and [VectorField] attributes are discovered +/// and correctly included in the generated schema. +/// +public class PhysicalFieldDiscoveryTests { + [Test] + [RequiresAssemblyFiles()] + public async Task Generator_WithPhysicalField_DiscoverFieldAsync() { + // Arrange + var source = """ + using System; + using Whizbang.Core; + using Whizbang.Core.Perspectives; + + namespace MyApp.Perspectives; + + [PerspectiveStorage(FieldStorageMode.Extracted)] + public record ProductModel { + [StreamKey] + public Guid ProductId { get; init; } + + [PhysicalField(Indexed = true)] + public decimal Price { get; init; } + + public string Description { get; init; } = string.Empty; + } + + public class ProductPerspective : IPerspectiveFor { + public ProductModel Apply(ProductModel? current, ProductCreated @event) { + return new ProductModel { ProductId = @event.ProductId, Price = @event.Price }; + } + } + + public record ProductCreated([property: StreamKey] Guid ProductId, decimal Price) : IEvent; + """; + + // Act + var result = GeneratorTestHelper.RunGenerator(source); + + // Assert - Should generate schema with physical column + var generatedSource = GeneratorTestHelper.GetGeneratedSource(result, "PerspectiveSchemas.g.sql.cs"); + await Assert.That(generatedSource).IsNotNull(); + await Assert.That(generatedSource!).Contains("price"); + await Assert.That(generatedSource).Contains("DECIMAL"); + } + + [Test] + [RequiresAssemblyFiles()] + public async Task Generator_WithVectorField_DiscoversVectorAsync() { + // Arrange + var source = """ + using System; + using Whizbang.Core; + using Whizbang.Core.Perspectives; + + namespace MyApp.Perspectives; + + [PerspectiveStorage(FieldStorageMode.Split)] + public record ProductSearchModel { + [StreamKey] + public Guid ProductId { get; init; } + + [VectorField(1536, DistanceMetric = VectorDistanceMetric.Cosine)] + public float[]? Embedding { get; init; } + + public string Name { get; init; } = string.Empty; + } + + public class ProductSearchPerspective : IPerspectiveFor { + public ProductSearchModel Apply(ProductSearchModel? current, ProductIndexed @event) { + return new ProductSearchModel { ProductId = @event.ProductId, Embedding = @event.Embedding }; + } + } + + public record ProductIndexed([property: StreamKey] Guid ProductId, float[]? Embedding) : IEvent; + """; + + // Act + var result = GeneratorTestHelper.RunGenerator(source); + + // Assert - Should generate schema with vector column + var generatedSource = GeneratorTestHelper.GetGeneratedSource(result, "PerspectiveSchemas.g.sql.cs"); + await Assert.That(generatedSource).IsNotNull(); + await Assert.That(generatedSource!).Contains("embedding"); + await Assert.That(generatedSource).Contains("vector(1536)"); + } + + [Test] + [RequiresAssemblyFiles()] + public async Task Generator_WithMultiplePhysicalFields_DiscoversAllAsync() { + // Arrange + var source = """ + using System; + using Whizbang.Core; + using Whizbang.Core.Perspectives; + + namespace MyApp.Perspectives; + + [PerspectiveStorage(FieldStorageMode.Extracted)] + public record OrderModel { + [StreamKey] + public Guid OrderId { get; init; } + + [PhysicalField(Indexed = true)] + public string CustomerName { get; init; } = string.Empty; + + [PhysicalField(Indexed = true)] + public decimal TotalAmount { get; init; } + + [PhysicalField(Indexed = false)] + public bool IsActive { get; init; } + + public string Notes { get; init; } = string.Empty; + } + + public class OrderPerspective : IPerspectiveFor { + public OrderModel Apply(OrderModel? current, OrderCreated @event) { + return new OrderModel { OrderId = @event.OrderId }; + } + } + + public record OrderCreated([property: StreamKey] Guid OrderId) : IEvent; + """; + + // Act + var result = GeneratorTestHelper.RunGenerator(source); + + // Assert - Should discover all physical fields + var generatedSource = GeneratorTestHelper.GetGeneratedSource(result, "PerspectiveSchemas.g.sql.cs"); + await Assert.That(generatedSource).IsNotNull(); + await Assert.That(generatedSource!).Contains("customer_name"); + await Assert.That(generatedSource).Contains("total_amount"); + await Assert.That(generatedSource).Contains("is_active"); + } + + [Test] + [RequiresAssemblyFiles()] + public async Task Generator_WithPhysicalFieldMaxLength_GeneratesConstraintAsync() { + // Arrange + var source = """ + using System; + using Whizbang.Core; + using Whizbang.Core.Perspectives; + + namespace MyApp.Perspectives; + + [PerspectiveStorage(FieldStorageMode.Extracted)] + public record ProductModel { + [StreamKey] + public Guid ProductId { get; init; } + + [PhysicalField(Indexed = true, MaxLength = 200)] + public string Sku { get; init; } = string.Empty; + } + + public class ProductPerspective : IPerspectiveFor { + public ProductModel Apply(ProductModel? current, ProductCreated @event) { + return new ProductModel { ProductId = @event.ProductId }; + } + } + + public record ProductCreated([property: StreamKey] Guid ProductId) : IEvent; + """; + + // Act + var result = GeneratorTestHelper.RunGenerator(source); + + // Assert - Should include VARCHAR with max length + var generatedSource = GeneratorTestHelper.GetGeneratedSource(result, "PerspectiveSchemas.g.sql.cs"); + await Assert.That(generatedSource).IsNotNull(); + await Assert.That(generatedSource!).Contains("VARCHAR(200)"); + } + + [Test] + [RequiresAssemblyFiles()] + public async Task Generator_WithVectorHNSWIndex_GeneratesIndexAsync() { + // Arrange + var source = """ + using System; + using Whizbang.Core; + using Whizbang.Core.Perspectives; + + namespace MyApp.Perspectives; + + [PerspectiveStorage(FieldStorageMode.Split)] + public record EmbeddingModel { + [StreamKey] + public Guid ItemId { get; init; } + + [VectorField(768, DistanceMetric = VectorDistanceMetric.Cosine, IndexType = VectorIndexType.HNSW)] + public float[]? ContentEmbedding { get; init; } + } + + public class EmbeddingPerspective : IPerspectiveFor { + public EmbeddingModel Apply(EmbeddingModel? current, ItemEmbedded @event) { + return new EmbeddingModel { ItemId = @event.ItemId, ContentEmbedding = @event.Embedding }; + } + } + + public record ItemEmbedded([property: StreamKey] Guid ItemId, float[]? Embedding) : IEvent; + """; + + // Act + var result = GeneratorTestHelper.RunGenerator(source); + + // Assert - Should include HNSW index + var generatedSource = GeneratorTestHelper.GetGeneratedSource(result, "PerspectiveSchemas.g.sql.cs"); + await Assert.That(generatedSource).IsNotNull(); + await Assert.That(generatedSource!).Contains("USING hnsw"); + await Assert.That(generatedSource).Contains("vector_cosine_ops"); + } + + [Test] + [RequiresAssemblyFiles()] + public async Task Generator_WithNoPhysicalFields_GeneratesStandardSchemaAsync() { + // Arrange - No physical fields, just standard JSONB + var source = """ + using System; + using Whizbang.Core; + using Whizbang.Core.Perspectives; + + namespace MyApp.Perspectives; + + public record SimpleModel { + [StreamKey] + public Guid Id { get; init; } + public string Name { get; init; } = string.Empty; + } + + public class SimplePerspective : IPerspectiveFor { + public SimpleModel Apply(SimpleModel? current, SimpleEvent @event) { + return new SimpleModel { Id = @event.Id }; + } + } + + public record SimpleEvent([property: StreamKey] Guid Id) : IEvent; + """; + + // Act + var result = GeneratorTestHelper.RunGenerator(source); + + // Assert - Should generate standard JSONB-only schema + var generatedSource = GeneratorTestHelper.GetGeneratedSource(result, "PerspectiveSchemas.g.sql.cs"); + await Assert.That(generatedSource).IsNotNull(); + await Assert.That(generatedSource!).Contains("model_data JSONB NOT NULL"); + // Should NOT contain physical column definitions + await Assert.That(generatedSource).DoesNotContain("VARCHAR("); + await Assert.That(generatedSource).DoesNotContain("vector("); + } + + [Test] + [RequiresAssemblyFiles()] + public async Task Generator_WithPhysicalFieldCustomColumnName_UsesCustomNameAsync() { + // Arrange + var source = """ + using System; + using Whizbang.Core; + using Whizbang.Core.Perspectives; + + namespace MyApp.Perspectives; + + [PerspectiveStorage(FieldStorageMode.Extracted)] + public record ProductModel { + [StreamKey] + public Guid ProductId { get; init; } + + [PhysicalField(ColumnName = "product_price", Indexed = true)] + public decimal Price { get; init; } + } + + public class ProductPerspective : IPerspectiveFor { + public ProductModel Apply(ProductModel? current, ProductCreated @event) { + return new ProductModel { ProductId = @event.ProductId }; + } + } + + public record ProductCreated([property: StreamKey] Guid ProductId) : IEvent; + """; + + // Act + var result = GeneratorTestHelper.RunGenerator(source); + + // Assert - Should use custom column name + var generatedSource = GeneratorTestHelper.GetGeneratedSource(result, "PerspectiveSchemas.g.sql.cs"); + await Assert.That(generatedSource).IsNotNull(); + await Assert.That(generatedSource!).Contains("product_price"); + } + + [Test] + [RequiresAssemblyFiles()] + public async Task Generator_WithVectorFieldIVFFlatIndex_GeneratesIVFFlatIndexAsync() { + // Arrange + var source = """ + using System; + using Whizbang.Core; + using Whizbang.Core.Perspectives; + + namespace MyApp.Perspectives; + + [PerspectiveStorage(FieldStorageMode.Split)] + public record SearchModel { + [StreamKey] + public Guid DocId { get; init; } + + [VectorField(512, DistanceMetric = VectorDistanceMetric.L2, IndexType = VectorIndexType.IVFFlat, IndexLists = 50)] + public float[]? DocEmbedding { get; init; } + } + + public class SearchPerspective : IPerspectiveFor { + public SearchModel Apply(SearchModel? current, DocIndexed @event) { + return new SearchModel { DocId = @event.DocId }; + } + } + + public record DocIndexed([property: StreamKey] Guid DocId) : IEvent; + """; + + // Act + var result = GeneratorTestHelper.RunGenerator(source); + + // Assert - Should include IVFFlat index + var generatedSource = GeneratorTestHelper.GetGeneratedSource(result, "PerspectiveSchemas.g.sql.cs"); + await Assert.That(generatedSource).IsNotNull(); + await Assert.That(generatedSource!).Contains("USING ivfflat"); + await Assert.That(generatedSource).Contains("vector_l2_ops"); + } + + [Test] + [RequiresAssemblyFiles()] + public async Task Generator_WithPhysicalFieldUnique_GeneratesUniqueConstraintAsync() { + // Arrange + var source = """ + using System; + using Whizbang.Core; + using Whizbang.Core.Perspectives; + + namespace MyApp.Perspectives; + + [PerspectiveStorage(FieldStorageMode.Extracted)] + public record ProductModel { + [StreamKey] + public Guid ProductId { get; init; } + + [PhysicalField(Indexed = true, Unique = true)] + public string Sku { get; init; } = string.Empty; + } + + public class ProductPerspective : IPerspectiveFor { + public ProductModel Apply(ProductModel? current, ProductCreated @event) { + return new ProductModel { ProductId = @event.ProductId }; + } + } + + public record ProductCreated([property: StreamKey] Guid ProductId) : IEvent; + """; + + // Act + var result = GeneratorTestHelper.RunGenerator(source); + + // Assert - Should include UNIQUE constraint or unique index + var generatedSource = GeneratorTestHelper.GetGeneratedSource(result, "PerspectiveSchemas.g.sql.cs"); + await Assert.That(generatedSource).IsNotNull(); + await Assert.That(generatedSource!).Contains("UNIQUE"); + } + + [Test] + [RequiresAssemblyFiles()] + public async Task Generator_WithInnerProductMetric_GeneratesCorrectOpsAsync() { + // Arrange + var source = """ + using System; + using Whizbang.Core; + using Whizbang.Core.Perspectives; + + namespace MyApp.Perspectives; + + [PerspectiveStorage(FieldStorageMode.Split)] + public record SimilarityModel { + [StreamKey] + public Guid ItemId { get; init; } + + [VectorField(384, DistanceMetric = VectorDistanceMetric.InnerProduct, IndexType = VectorIndexType.HNSW)] + public float[]? ItemEmbedding { get; init; } + } + + public class SimilarityPerspective : IPerspectiveFor { + public SimilarityModel Apply(SimilarityModel? current, ItemProcessed @event) { + return new SimilarityModel { ItemId = @event.ItemId }; + } + } + + public record ItemProcessed([property: StreamKey] Guid ItemId) : IEvent; + """; + + // Act + var result = GeneratorTestHelper.RunGenerator(source); + + // Assert - Should use inner product operator class + var generatedSource = GeneratorTestHelper.GetGeneratedSource(result, "PerspectiveSchemas.g.sql.cs"); + await Assert.That(generatedSource).IsNotNull(); + await Assert.That(generatedSource!).Contains("vector_ip_ops"); + } + + [Test] + [RequiresAssemblyFiles()] + public async Task Generator_ReportsDiagnostic_WhenPhysicalFieldsDiscoveredAsync() { + // Arrange + var source = """ + using System; + using Whizbang.Core; + using Whizbang.Core.Perspectives; + + namespace MyApp.Perspectives; + + [PerspectiveStorage(FieldStorageMode.Extracted)] + public record ProductModel { + [StreamKey] + public Guid ProductId { get; init; } + + [PhysicalField(Indexed = true)] + public decimal Price { get; init; } + + [VectorField(1536)] + public float[]? Embedding { get; init; } + } + + public class ProductPerspective : IPerspectiveFor { + public ProductModel Apply(ProductModel? current, ProductCreated @event) { + return new ProductModel { ProductId = @event.ProductId }; + } + } + + public record ProductCreated([property: StreamKey] Guid ProductId) : IEvent; + """; + + // Act + var result = GeneratorTestHelper.RunGenerator(source); + + // Assert - Should report WHIZ807 diagnostic for physical fields discovered + var physicalFieldsDiagnostic = result.Diagnostics.FirstOrDefault(d => d.Id == "WHIZ807"); + await Assert.That(physicalFieldsDiagnostic).IsNotNull(); + await Assert.That(physicalFieldsDiagnostic!.Severity).IsEqualTo(DiagnosticSeverity.Info); + } +} diff --git a/tests/Whizbang.Generators.Tests/StreamKeyGeneratorTests.cs b/tests/Whizbang.Generators.Tests/StreamKeyGeneratorTests.cs index d990e653..a806d02b 100644 --- a/tests/Whizbang.Generators.Tests/StreamKeyGeneratorTests.cs +++ b/tests/Whizbang.Generators.Tests/StreamKeyGeneratorTests.cs @@ -379,4 +379,86 @@ public class ClassBasedEvent : IEvent { await Assert.That(code!).Contains("ClassBasedEvent"); await Assert.That(code).Contains("EventId"); } + + [Test] + [RequiresAssemblyFiles()] + public async Task StreamKeyGenerator_InheritedStreamKey_GeneratesExtractorAsync() { + // Arrange - Tests inherited [StreamKey] detection from base class + var source = """ +using Whizbang.Core; +using System; + +namespace MyApp.Events; + +// Base class with [StreamKey] on StreamId property +public abstract class BaseEvent : IEvent { + [StreamKey] + public virtual Guid StreamId { get; set; } + public string? CorrelationId { get; set; } +} + +// Derived event - should inherit [StreamKey] from base +public class OrderCreatedEvent : BaseEvent { + public string OrderName { get; set; } = ""; +} + +// Another derived event - also inherits [StreamKey] +public class OrderShippedEvent : BaseEvent { + public string TrackingNumber { get; set; } = ""; +} +"""; + + // Act + var result = GeneratorTestHelper.RunGenerator(source); + + // Assert - Should NOT report WHIZ009 (missing StreamKey) for derived classes + var whiz009Warnings = result.Diagnostics.Where(d => + d.Id == "WHIZ009" && + (d.GetMessage(CultureInfo.InvariantCulture).Contains("OrderCreatedEvent") || + d.GetMessage(CultureInfo.InvariantCulture).Contains("OrderShippedEvent"))); + await Assert.That(whiz009Warnings).IsEmpty(); + + // Assert - Should generate extractors for all three event types + var code = GeneratorTestHelper.GetGeneratedSource(result, "StreamKeyExtractors.g.cs"); + await Assert.That(code).IsNotNull(); + await Assert.That(code!).Contains("BaseEvent"); + await Assert.That(code).Contains("OrderCreatedEvent"); + await Assert.That(code).Contains("OrderShippedEvent"); + await Assert.That(code).Contains("StreamId"); // All use inherited StreamId property + } + + [Test] + [RequiresAssemblyFiles()] + public async Task StreamKeyGenerator_InheritedStreamKey_NoFalsePositiveWarningsAsync() { + // Arrange - Verify derived classes don't trigger WHIZ009 false positives + var source = """ +using Whizbang.Core; +using System; + +namespace MyApp; + +public class BaseJdxEvent : IEvent { + [StreamKey] + public virtual Guid StreamId { get; set; } +} + +public class DerivedEvent : BaseJdxEvent { + public string Data { get; set; } = ""; +} +"""; + + // Act + var result = GeneratorTestHelper.RunGenerator(source); + + // Assert - No WHIZ009 warning for DerivedEvent (it inherits [StreamKey]) + var derivedWarnings = result.Diagnostics.Where(d => + d.Id == "WHIZ009" && + d.GetMessage(CultureInfo.InvariantCulture).Contains("DerivedEvent")); + await Assert.That(derivedWarnings).IsEmpty(); + + // Assert - Extractor generated for derived event + var code = GeneratorTestHelper.GetGeneratedSource(result, "StreamKeyExtractors.g.cs"); + await Assert.That(code).IsNotNull(); + await Assert.That(code!).Contains("DerivedEvent"); + } } diff --git a/tests/Whizbang.Generators.Tests/WhizbangIdGeneratorTests.cs b/tests/Whizbang.Generators.Tests/WhizbangIdGeneratorTests.cs index efb4296d..89493f86 100644 --- a/tests/Whizbang.Generators.Tests/WhizbangIdGeneratorTests.cs +++ b/tests/Whizbang.Generators.Tests/WhizbangIdGeneratorTests.cs @@ -37,9 +37,9 @@ namespace MyApp.Domain; await Assert.That(generatedSource!).Contains("namespace MyApp.Domain"); await Assert.That(generatedSource).Contains("public readonly partial struct ProductId"); - // Assert - Should contain TrackedGuid-backed storage + // Assert - Should contain TrackedGuid-backed storage with Guid Value property for EF Core await Assert.That(generatedSource).Contains("private readonly TrackedGuid _tracked"); - await Assert.That(generatedSource).Contains("public Guid Value => _tracked.Value"); + await Assert.That(generatedSource).Contains("public Guid Value { get => _tracked.Value; init => _tracked = TrackedGuid.FromExternal(value); }"); // Assert - Should contain factory methods await Assert.That(generatedSource).Contains("public static ProductId From(Guid value)"); diff --git a/tests/Whizbang.Generators.Tests/WhizbangIdGeneratorTrackedGuidTests.cs b/tests/Whizbang.Generators.Tests/WhizbangIdGeneratorTrackedGuidTests.cs index bfd03577..d07ae646 100644 --- a/tests/Whizbang.Generators.Tests/WhizbangIdGeneratorTrackedGuidTests.cs +++ b/tests/Whizbang.Generators.Tests/WhizbangIdGeneratorTrackedGuidTests.cs @@ -4,13 +4,15 @@ namespace Whizbang.Generators.Tests; /// -/// Tests for WhizbangIdGenerator TrackedGuid integration. -/// Ensures generated IDs use TrackedGuid backing and implement IWhizbangId. +/// Tests for WhizbangIdGenerator Guid storage. +/// Ensures generated IDs use Guid backing for EF Core ComplexProperty compatibility +/// and implement IWhizbangId. /// [Category("SourceGenerators")] public class WhizbangIdGeneratorTrackedGuidTests { /// - /// Test that generated ID uses TrackedGuid backing field. + /// Test that generated ID uses TrackedGuid backing field for metadata tracking. + /// EF Core sees only the Guid Value property. /// [Test] [RequiresAssemblyFiles()] @@ -32,9 +34,10 @@ namespace MyApp.Domain; var generatedSource = GeneratorTestHelper.GetGeneratedSource(result, "ProductId.g.cs"); await Assert.That(generatedSource).IsNotNull(); - // Should use TrackedGuid backing field instead of Guid + // Should use TrackedGuid backing field for metadata tracking await Assert.That(generatedSource!).Contains("TrackedGuid _tracked"); - await Assert.That(generatedSource).DoesNotContain("Guid _value"); + // EF Core sees only the Guid Value property + await Assert.That(generatedSource).Contains("public Guid Value { get => _tracked.Value; init => _tracked = TrackedGuid.FromExternal(value); }"); } /// @@ -93,7 +96,7 @@ namespace MyApp.Domain; } /// - /// Test that generated ID has IsTimeOrdered property. + /// Test that generated ID has IsTimeOrdered property delegating to TrackedGuid. /// [Test] [RequiresAssemblyFiles()] @@ -115,13 +118,13 @@ namespace MyApp.Domain; var generatedSource = GeneratorTestHelper.GetGeneratedSource(result, "ProductId.g.cs"); await Assert.That(generatedSource).IsNotNull(); - // Should have IsTimeOrdered property that delegates to TrackedGuid - await Assert.That(generatedSource!).Contains("bool IsTimeOrdered"); - await Assert.That(generatedSource).Contains("_tracked.IsTimeOrdered"); + // Should have IsTimeOrdered delegating to _tracked + await Assert.That(generatedSource!).Contains("IsTimeOrdered => _tracked.IsTimeOrdered"); } /// - /// Test that generated ID has SubMillisecondPrecision property. + /// Test that generated ID has SubMillisecondPrecision property that delegates to TrackedGuid. + /// Fresh IDs via New() return true, deserialized IDs return false. /// [Test] [RequiresAssemblyFiles()] @@ -143,13 +146,14 @@ namespace MyApp.Domain; var generatedSource = GeneratorTestHelper.GetGeneratedSource(result, "ProductId.g.cs"); await Assert.That(generatedSource).IsNotNull(); - // Should have SubMillisecondPrecision property that delegates to TrackedGuid - await Assert.That(generatedSource!).Contains("bool SubMillisecondPrecision"); - await Assert.That(generatedSource).Contains("_tracked.SubMillisecondPrecision"); + // Should have SubMillisecondPrecision delegating to _tracked + await Assert.That(generatedSource!).Contains("SubMillisecondPrecision => _tracked.SubMillisecondPrecision"); + // Also has public convenience method delegating to _tracked + await Assert.That(generatedSource).Contains("GetSubMillisecondPrecision() => _tracked.SubMillisecondPrecision"); } /// - /// Test that generated ID has Timestamp property. + /// Test that generated ID has Timestamp property delegating to TrackedGuid. /// [Test] [RequiresAssemblyFiles()] @@ -171,9 +175,9 @@ namespace MyApp.Domain; var generatedSource = GeneratorTestHelper.GetGeneratedSource(result, "ProductId.g.cs"); await Assert.That(generatedSource).IsNotNull(); - // Should have Timestamp property that delegates to TrackedGuid - await Assert.That(generatedSource!).Contains("DateTimeOffset Timestamp"); - await Assert.That(generatedSource).Contains("_tracked.Timestamp"); + // Should have Timestamp delegating to _tracked + await Assert.That(generatedSource!).Contains("DateTimeOffset"); + await Assert.That(generatedSource).Contains("Timestamp => _tracked.Timestamp"); } /// @@ -199,9 +203,9 @@ namespace MyApp.Domain; var generatedSource = GeneratorTestHelper.GetGeneratedSource(result, "ProductId.g.cs"); await Assert.That(generatedSource).IsNotNull(); - // Should have ToGuid method + // Should have ToGuid method that returns _tracked.Value await Assert.That(generatedSource!).Contains("Guid ToGuid()"); - await Assert.That(generatedSource).Contains("_tracked.Value"); + await Assert.That(generatedSource).Contains("ToGuid() => _tracked.Value"); } /// diff --git a/tests/Whizbang.Transports.HotChocolate.Tests/Unit/WhizbangScopeMiddlewareTests.cs b/tests/Whizbang.Transports.HotChocolate.Tests/Unit/WhizbangScopeMiddlewareTests.cs index 479575b1..db56018d 100644 --- a/tests/Whizbang.Transports.HotChocolate.Tests/Unit/WhizbangScopeMiddlewareTests.cs +++ b/tests/Whizbang.Transports.HotChocolate.Tests/Unit/WhizbangScopeMiddlewareTests.cs @@ -232,7 +232,7 @@ public async Task InvokeAsync_WithNoClaims_ScopeFieldsShouldBeNullAsync() { await Assert.That(scope.UserId).IsNull(); await Assert.That(scope.OrganizationId).IsNull(); await Assert.That(scope.CustomerId).IsNull(); - await Assert.That(scope.Extensions).IsNull(); + await Assert.That(scope.Extensions).IsEmpty(); } [Test] @@ -251,7 +251,7 @@ public async Task InvokeAsync_WithNullUser_ScopeFieldsShouldBeNullAsync() { await Assert.That(scope.UserId).IsNull(); await Assert.That(scope.OrganizationId).IsNull(); await Assert.That(scope.CustomerId).IsNull(); - await Assert.That(scope.Extensions).IsNull(); + await Assert.That(scope.Extensions).IsEmpty(); await Assert.That(accessor.Current!.Roles.Count).IsEqualTo(0); await Assert.That(accessor.Current!.Permissions.Count).IsEqualTo(0); await Assert.That(accessor.Current!.SecurityPrincipals.Count).IsEqualTo(0); @@ -271,7 +271,7 @@ public async Task InvokeAsync_WithNullUser_AndExtensionMappings_ShouldHandleGrac await middleware.InvokeAsync(context, accessor); // Assert - await Assert.That(accessor.Current!.Scope.Extensions).IsNull(); + await Assert.That(accessor.Current!.Scope.Extensions).IsEmpty(); } #endregion @@ -290,8 +290,8 @@ public async Task InvokeAsync_WithExtensionClaimMappings_ShouldExtractExtensions await middleware.InvokeAsync(context, accessor); // Assert - await Assert.That(accessor.Current!.Scope.Extensions).IsNotNull(); - await Assert.That(accessor.Current!.Scope.Extensions!["Region"]).IsEqualTo("us-east"); + await Assert.That(accessor.Current!.Scope.Extensions).IsNotEmpty(); + await Assert.That(accessor.Current!.Scope.Extensions.First(e => e.Key == "Region").Value).IsEqualTo("us-east"); } [Test] @@ -307,8 +307,8 @@ public async Task InvokeAsync_WithExtensionHeaderMappings_ShouldExtractExtension await middleware.InvokeAsync(context, accessor); // Assert - await Assert.That(accessor.Current!.Scope.Extensions).IsNotNull(); - await Assert.That(accessor.Current!.Scope.Extensions!["Region"]).IsEqualTo("eu-west"); + await Assert.That(accessor.Current!.Scope.Extensions).IsNotEmpty(); + await Assert.That(accessor.Current!.Scope.Extensions.First(e => e.Key == "Region").Value).IsEqualTo("eu-west"); } [Test] @@ -323,7 +323,7 @@ public async Task InvokeAsync_WithEmptyExtensionClaimValue_ShouldSkipAsync() { await middleware.InvokeAsync(context, accessor); // Assert - await Assert.That(accessor.Current!.Scope.Extensions).IsNull(); + await Assert.That(accessor.Current!.Scope.Extensions).IsEmpty(); } [Test] @@ -339,7 +339,7 @@ public async Task InvokeAsync_WithExtensionClaimMapping_WhenClaimNotPresent_Shou await middleware.InvokeAsync(context, accessor); // Assert - await Assert.That(accessor.Current!.Scope.Extensions).IsNull(); + await Assert.That(accessor.Current!.Scope.Extensions).IsEmpty(); } [Test] @@ -355,7 +355,7 @@ public async Task InvokeAsync_WithEmptyExtensionHeaderValue_ShouldSkipAsync() { await middleware.InvokeAsync(context, accessor); // Assert - await Assert.That(accessor.Current!.Scope.Extensions).IsNull(); + await Assert.That(accessor.Current!.Scope.Extensions).IsEmpty(); } #endregion diff --git a/tools/Whizbang.Migrate/Analysis/TenantContextDetector.cs b/tools/Whizbang.Migrate/Analysis/TenantContextDetector.cs index 76661b7d..a69b2d4d 100644 --- a/tools/Whizbang.Migrate/Analysis/TenantContextDetector.cs +++ b/tools/Whizbang.Migrate/Analysis/TenantContextDetector.cs @@ -23,11 +23,13 @@ public sealed class TenantContextDetector { private static readonly Regex _forTenantPattern = new( @"\.ForTenant\s*\(", - RegexOptions.Compiled); + RegexOptions.Compiled, + TimeSpan.FromSeconds(1)); private static readonly Regex _openSessionWithTenantPattern = new( @"\.OpenSession\s*\(\s*""[^""]+""", - RegexOptions.Compiled); + RegexOptions.Compiled, + TimeSpan.FromSeconds(1)); /// /// Detects tenant context patterns in the given source code. diff --git a/tools/Whizbang.Migrate/Analysis/WolverineAnalyzer.cs b/tools/Whizbang.Migrate/Analysis/WolverineAnalyzer.cs index 5f3a03b2..cf1a7dfd 100644 --- a/tools/Whizbang.Migrate/Analysis/WolverineAnalyzer.cs +++ b/tools/Whizbang.Migrate/Analysis/WolverineAnalyzer.cs @@ -88,6 +88,12 @@ public Task AnalyzeAsync( HandlerKind.IHandleInterface, lineNumber)); + // Check for nested handler class + var nestedWarning = _checkForNestedClass(classDecl, filePath, className, lineNumber); + if (nestedWarning != null) { + warnings.Add(nestedWarning); + } + // Check for custom base class var baseClassWarning = _checkForCustomBaseClass(classDecl, filePath, className, lineNumber); if (baseClassWarning != null) { @@ -121,6 +127,12 @@ public Task AnalyzeAsync( HandlerKind.WolverineAttribute, lineNumber)); + // Check for nested handler class + var nestedWarning = _checkForNestedClass(classDecl, filePath, className, lineNumber); + if (nestedWarning != null) { + warnings.Add(nestedWarning); + } + // Check for custom base class var baseClassWarning = _checkForCustomBaseClass(classDecl, filePath, className, lineNumber); if (baseClassWarning != null) { @@ -141,6 +153,12 @@ public Task AnalyzeAsync( if (conventionHandlers.Count > 0) { handlers.AddRange(conventionHandlers); + // Check for nested handler class + var nestedWarning = _checkForNestedClass(classDecl, filePath, className, lineNumber); + if (nestedWarning != null) { + warnings.Add(nestedWarning); + } + // Check for custom base class var baseClassWarning = _checkForCustomBaseClass(classDecl, filePath, className, lineNumber); if (baseClassWarning != null) { @@ -354,6 +372,28 @@ private static (string MessageType, string? ReturnType)? _findHandleMethod(Class .FirstOrDefault(); } + private static MigrationWarning? _checkForNestedClass( + ClassDeclarationSyntax classDecl, + string filePath, + string className, + int lineNumber) { + // Check if this class is nested inside another type + var parentType = classDecl.Parent as TypeDeclarationSyntax; + if (parentType != null) { + var parentName = parentType.Identifier.Text; + return new MigrationWarning( + filePath, + className, + MigrationWarningKind.NestedHandlerClass, + $"Handler '{className}' is nested inside '{parentName}'. " + + "Consider extracting to a top-level class for better discoverability.", + lineNumber, + parentName); + } + + return null; + } + private static MigrationWarning? _checkForCustomBaseClass( ClassDeclarationSyntax classDecl, string filePath, diff --git a/tools/Whizbang.Migrate/Commands/ApplyCommand.cs b/tools/Whizbang.Migrate/Commands/ApplyCommand.cs index 91ae5891..b6279399 100644 --- a/tools/Whizbang.Migrate/Commands/ApplyCommand.cs +++ b/tools/Whizbang.Migrate/Commands/ApplyCommand.cs @@ -20,6 +20,10 @@ public sealed class ApplyCommand { private readonly DIRegistrationTransformer _diTransformer = new(); private readonly MarkerInterfaceTransformer _markerInterfaceTransformer = new(); private readonly GlobalUsingAliasTransformer _globalUsingAliasTransformer = new(); + private readonly HotChocolateTransformer _hotChocolateTransformer = new(); + private readonly WolverineHttpTransformer _wolverineHttpTransformer = new(); + // JSON transformer is created per-execution based on decision file settings + private NewtonsoftToSystemTextJsonTransformer? _jsonTransformer; /// /// Executes the apply command on the specified directory. @@ -58,11 +62,17 @@ public async Task ExecuteAsync( ct.ThrowIfCancellationRequested(); // Check decision file for skip decisions + // Note: Even if handlers/projections are skipped, we continue if JSON migration is enabled + // since it operates on different patterns (Newtonsoft.Json → System.Text.Json) if (decisionFile != null) { var handlerDecision = decisionFile.GetHandlerDecision(file); var projectionDecision = decisionFile.GetProjectionDecision(file); + var jsonMigrationEnabled = decisionFile.Decisions.JsonMigration.Enabled || + decisionFile.Decisions.JsonMigration.RemoveDeadImports; - if (handlerDecision == DecisionChoice.Skip && projectionDecision == DecisionChoice.Skip) { + if (handlerDecision == DecisionChoice.Skip && + projectionDecision == DecisionChoice.Skip && + !jsonMigrationEnabled) { skippedCount++; continue; } @@ -138,6 +148,29 @@ public async Task ExecuteAsync( allChanges.AddRange(markerResult.Changes); } + // Apply HotChocolate Marten transformations (AddMartenFiltering → AddWhizbangLenses, etc.) + var hotChocolateResult = await _hotChocolateTransformer.TransformAsync(transformedCode, file, ct); + if (hotChocolateResult.Changes.Count > 0) { + transformedCode = hotChocolateResult.TransformedCode; + allChanges.AddRange(hotChocolateResult.Changes); + } + + // Apply Wolverine.Http transformations (flags methods for manual FastEndpoints conversion) + var wolverineHttpResult = await _wolverineHttpTransformer.TransformAsync(transformedCode, file, ct); + if (wolverineHttpResult.Changes.Count > 0) { + transformedCode = wolverineHttpResult.TransformedCode; + allChanges.AddRange(wolverineHttpResult.Changes); + } + + // Apply Newtonsoft.Json → System.Text.Json transformations (optional) + // This always runs to remove dead imports, but full conversion is opt-in + _jsonTransformer ??= _createJsonTransformer(decisionFile); + var jsonResult = await _jsonTransformer.TransformAsync(transformedCode, file, ct); + if (jsonResult.Changes.Count > 0) { + transformedCode = jsonResult.TransformedCode; + allChanges.AddRange(jsonResult.Changes); + } + // Track changes if (allChanges.Count > 0) { fileChanges.Add(new FileChange(file, allChanges.Count, allChanges)); @@ -227,6 +260,18 @@ private static List _filterFiles( .Select(f => Path.Combine(basePath, f.Path)) .ToList(); } + + /// + /// Creates a JSON transformer configured from decision file settings. + /// + private static NewtonsoftToSystemTextJsonTransformer _createJsonTransformer(DecisionFile? decisionFile) { + var settings = decisionFile?.Decisions.JsonMigration; + + return new NewtonsoftToSystemTextJsonTransformer( + enabled: settings?.Enabled ?? false, + removeDeadImports: settings?.RemoveDeadImports ?? true, + addTodoForUnsupported: settings?.AddTodoForUnsupported ?? true); + } } /// diff --git a/tools/Whizbang.Migrate/PackageManagement/PackageManager.cs b/tools/Whizbang.Migrate/PackageManagement/PackageManager.cs index f032585b..5497e7df 100644 --- a/tools/Whizbang.Migrate/PackageManagement/PackageManager.cs +++ b/tools/Whizbang.Migrate/PackageManagement/PackageManager.cs @@ -30,7 +30,14 @@ public sealed class PackageManager { // Kafka -> AzureServiceBus (Whizbang uses ServiceBus for messaging, RabbitMQ for local dev) ["WolverineFx.Kafka"] = "SoftwareExtravaganza.Whizbang.Transports.AzureServiceBus", ["Wolverine.Kafka"] = "SoftwareExtravaganza.Whizbang.Transports.AzureServiceBus", - ["Confluent.Kafka"] = "SoftwareExtravaganza.Whizbang.Transports.AzureServiceBus" + ["Confluent.Kafka"] = "SoftwareExtravaganza.Whizbang.Transports.AzureServiceBus", + + // HTTP transport -> FastEndpoints (Whizbang uses FastEndpoints for HTTP APIs) + ["WolverineFx.Http"] = "SoftwareExtravaganza.Whizbang.Transports.FastEndpoints", + ["Wolverine.Http"] = "SoftwareExtravaganza.Whizbang.Transports.FastEndpoints", + + // HotChocolate Marten integration -> Whizbang HotChocolate transport + ["HotChocolate.Data.Marten"] = "SoftwareExtravaganza.Whizbang.Transports.HotChocolate" }; /// @@ -40,12 +47,8 @@ public sealed class PackageManager { "Marten.CommandLine", "Marten.PLv8", "Marten.NodaTime", - "Wolverine.Http", - "WolverineFx.Http", "Wolverine.FluentValidation", "WolverineFx.FluentValidation", - // HotChocolate Marten integration - no Whizbang equivalent yet - "HotChocolate.Data.Marten", // Kafka/Confluent packages - replaced by AzureServiceBus/RabbitMQ "Aspire.Confluent.Kafka", "Aspire.Hosting.Kafka", diff --git a/tools/Whizbang.Migrate/Transformers/HotChocolateTransformer.cs b/tools/Whizbang.Migrate/Transformers/HotChocolateTransformer.cs new file mode 100644 index 00000000..67f834d8 --- /dev/null +++ b/tools/Whizbang.Migrate/Transformers/HotChocolateTransformer.cs @@ -0,0 +1,289 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Whizbang.Migrate.Transformers; + +/// +/// Transforms HotChocolate.Data.Marten patterns to Whizbang.Transports.HotChocolate equivalents. +/// +/// migration-guide/automated-migration +public sealed class HotChocolateTransformer : ICodeTransformer { + /// + /// Methods that should be replaced with AddWhizbangLenses. + /// + private static readonly HashSet _methodsToReplaceWithLenses = new(StringComparer.Ordinal) { + "AddMartenFiltering" + }; + + /// + /// Methods that should be removed (functionality included in AddWhizbangLenses). + /// + private static readonly HashSet _methodsToRemove = new(StringComparer.Ordinal) { + "AddMartenSorting" + }; + + /// + /// Using directives related to HotChocolate Marten integration. + /// + private static readonly HashSet _martenUsings = new(StringComparer.Ordinal) { + "HotChocolate.Data.Marten" + }; + + /// + public Task TransformAsync( + string sourceCode, + string filePath, + CancellationToken ct = default) { + var changes = new List(); + var warnings = new List(); + + var tree = CSharpSyntaxTree.ParseText(sourceCode, cancellationToken: ct); + var root = tree.GetRoot(ct); + + // Check if there are any HotChocolate Marten patterns to transform + if (!_hasHotChocolateMartenPatterns(root)) { + return Task.FromResult(new TransformationResult( + sourceCode, + sourceCode, + changes, + warnings)); + } + + var newRoot = root; + + // 1. Transform method calls + newRoot = _transformMethodCalls(newRoot, changes, warnings); + + // 2. Transform using directives + newRoot = _transformUsings(newRoot, changes); + + // 3. Transform IMartenQueryable to IQueryable + newRoot = _transformMartenQueryable(newRoot, changes); + + var transformedCode = newRoot.ToFullString(); + + return Task.FromResult(new TransformationResult( + sourceCode, + transformedCode, + changes, + warnings)); + } + + private static bool _hasHotChocolateMartenPatterns(SyntaxNode root) { + // Check for method calls + var invocations = root.DescendantNodes().OfType(); + foreach (var invocation in invocations) { + var methodName = _getMethodName(invocation); + if (methodName != null && + (_methodsToReplaceWithLenses.Contains(methodName) || + _methodsToRemove.Contains(methodName))) { + return true; + } + } + + // Check for using directives + if (root is CompilationUnitSyntax compilationUnit) { + foreach (var usingDirective in compilationUnit.Usings) { + var name = usingDirective.Name?.ToString(); + if (name != null && _martenUsings.Contains(name)) { + return true; + } + } + } + + // Check for IMartenQueryable usage + var genericNames = root.DescendantNodes().OfType(); + foreach (var genericName in genericNames) { + if (genericName.Identifier.Text == "IMartenQueryable") { + return true; + } + } + + return false; + } + + private static string? _getMethodName(InvocationExpressionSyntax invocation) { + return invocation.Expression switch { + MemberAccessExpressionSyntax memberAccess => memberAccess.Name.Identifier.Text, + IdentifierNameSyntax identifier => identifier.Identifier.Text, + _ => null + }; + } + + private static SyntaxNode _transformMethodCalls( + SyntaxNode root, + List changes, + List warnings) { + var rewriter = new HotChocolateMethodCallRewriter(changes, warnings); + return rewriter.Visit(root); + } + + private static SyntaxNode _transformUsings(SyntaxNode root, List changes) { + var compilationUnit = root as CompilationUnitSyntax; + if (compilationUnit == null) { + return root; + } + + var hasMartenUsing = compilationUnit.Usings + .Any(u => u.Name?.ToString() != null && _martenUsings.Contains(u.Name.ToString()!)); + + if (!hasMartenUsing) { + return root; + } + + var newUsings = new List(); + var addedWhizbangHotChocolate = false; + + foreach (var usingDirective in compilationUnit.Usings) { + var name = usingDirective.Name?.ToString(); + + if (name != null && _martenUsings.Contains(name)) { + if (!addedWhizbangHotChocolate) { + // Replace with Whizbang.Transports.HotChocolate + var whizbangUsing = usingDirective + .WithName(SyntaxFactory.ParseName("Whizbang.Transports.HotChocolate") + .WithLeadingTrivia(usingDirective.Name?.GetLeadingTrivia() ?? SyntaxFactory.TriviaList()) + .WithTrailingTrivia(usingDirective.Name?.GetTrailingTrivia() ?? SyntaxFactory.TriviaList())); + newUsings.Add(whizbangUsing); + addedWhizbangHotChocolate = true; + + changes.Add(new CodeChange( + usingDirective.GetLocation().GetLineSpan().StartLinePosition.Line + 1, + ChangeType.UsingRemoved, + $"Replaced 'using {name}' with 'using Whizbang.Transports.HotChocolate'", + $"using {name};", + "using Whizbang.Transports.HotChocolate;")); + } else { + // Skip duplicate + changes.Add(new CodeChange( + usingDirective.GetLocation().GetLineSpan().StartLinePosition.Line + 1, + ChangeType.UsingRemoved, + $"Removed 'using {name}' (consolidated into Whizbang.Transports.HotChocolate)", + $"using {name};", + "")); + } + } else { + newUsings.Add(usingDirective); + } + } + + return compilationUnit.WithUsings(SyntaxFactory.List(newUsings)); + } + + private static SyntaxNode _transformMartenQueryable(SyntaxNode root, List changes) { + var rewriter = new MartenQueryableRewriter(changes); + return rewriter.Visit(root); + } + + /// + /// Rewriter that transforms HotChocolate Marten method calls to Whizbang equivalents. + /// + private sealed class HotChocolateMethodCallRewriter : CSharpSyntaxRewriter { + private readonly List _changes; + private readonly List _warnings; + private bool _addedWhizbangLenses; + + public HotChocolateMethodCallRewriter(List changes, List warnings) { + _changes = changes; + _warnings = warnings; + } + + public override SyntaxNode? VisitInvocationExpression(InvocationExpressionSyntax node) { + var methodName = _getMethodName(node); + + if (methodName == null) { + return base.VisitInvocationExpression(node); + } + + // Handle AddMartenFiltering -> AddWhizbangLenses + if (_methodsToReplaceWithLenses.Contains(methodName)) { + var newMethodName = "AddWhizbangLenses"; + var newNode = _replaceMethodName(node, newMethodName); + _addedWhizbangLenses = true; + + _changes.Add(new CodeChange( + node.GetLocation().GetLineSpan().StartLinePosition.Line + 1, + ChangeType.MethodCallReplacement, + $"Replaced '{methodName}()' with '{newMethodName}()' (includes filtering, sorting, and projections)", + $".{methodName}()", + $".{newMethodName}()")); + + return newNode; + } + + // Handle AddMartenSorting - remove (functionality in AddWhizbangLenses) + if (_methodsToRemove.Contains(methodName)) { + if (node.Expression is MemberAccessExpressionSyntax memberAccess) { + var description = _addedWhizbangLenses + ? $"Removed '{methodName}()' (included in AddWhizbangLenses)" + : $"Removed '{methodName}()' (add AddWhizbangLenses() manually for sorting support)"; + + if (!_addedWhizbangLenses) { + _warnings.Add($"Removed {methodName}() but AddWhizbangLenses() was not added. You may need to add it manually for sorting support."); + } + + _changes.Add(new CodeChange( + node.GetLocation().GetLineSpan().StartLinePosition.Line + 1, + ChangeType.MethodCallReplacement, + description, + $".{methodName}()", + "")); + + // Return just the expression part, removing the method call + return Visit(memberAccess.Expression); + } + } + + return base.VisitInvocationExpression(node); + } + + private static InvocationExpressionSyntax _replaceMethodName( + InvocationExpressionSyntax node, + string newMethodName) { + if (node.Expression is MemberAccessExpressionSyntax memberAccess) { + var newName = SyntaxFactory.IdentifierName(newMethodName) + .WithLeadingTrivia(memberAccess.Name.GetLeadingTrivia()) + .WithTrailingTrivia(memberAccess.Name.GetTrailingTrivia()); + + var newMemberAccess = memberAccess.WithName(newName); + // Clear arguments since AddWhizbangLenses has different signature + return node.WithExpression(newMemberAccess) + .WithArgumentList(SyntaxFactory.ArgumentList()); + } + + return node; + } + } + + /// + /// Rewriter that transforms IMartenQueryable to IQueryable. + /// + private sealed class MartenQueryableRewriter : CSharpSyntaxRewriter { + private readonly List _changes; + + public MartenQueryableRewriter(List changes) { + _changes = changes; + } + + public override SyntaxNode? VisitGenericName(GenericNameSyntax node) { + if (node.Identifier.Text == "IMartenQueryable") { + var newNode = node.WithIdentifier( + SyntaxFactory.Identifier("IQueryable") + .WithLeadingTrivia(node.Identifier.LeadingTrivia) + .WithTrailingTrivia(node.Identifier.TrailingTrivia)); + + _changes.Add(new CodeChange( + node.GetLocation().GetLineSpan().StartLinePosition.Line + 1, + ChangeType.TypeRename, + "Replaced 'IMartenQueryable' with 'IQueryable'", + node.ToString(), + newNode.ToString())); + + return newNode; + } + + return base.VisitGenericName(node); + } + } +} diff --git a/tools/Whizbang.Migrate/Transformers/NewtonsoftToSystemTextJsonTransformer.cs b/tools/Whizbang.Migrate/Transformers/NewtonsoftToSystemTextJsonTransformer.cs new file mode 100644 index 00000000..f259e133 --- /dev/null +++ b/tools/Whizbang.Migrate/Transformers/NewtonsoftToSystemTextJsonTransformer.cs @@ -0,0 +1,469 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Whizbang.Migrate.Transformers; + +/// +/// Transforms Newtonsoft.Json usage to System.Text.Json equivalents. +/// This is an optional migration - controlled by decision file. +/// +/// migration-guide/json-migration +public sealed class NewtonsoftToSystemTextJsonTransformer : ICodeTransformer { + private readonly bool _enabled; + private readonly bool _removeDeadImports; + private readonly bool _addTodoForUnsupported; + + /// + /// Creates a new Newtonsoft to System.Text.Json transformer. + /// + /// Whether to perform transformations (false = only remove dead imports). + /// Whether to remove unused Newtonsoft imports. + /// Whether to add TODO comments for unsupported patterns. + public NewtonsoftToSystemTextJsonTransformer( + bool enabled = true, + bool removeDeadImports = true, + bool addTodoForUnsupported = true) { + _enabled = enabled; + _removeDeadImports = removeDeadImports; + _addTodoForUnsupported = addTodoForUnsupported; + } + + /// + public Task TransformAsync( + string sourceCode, + string filePath, + CancellationToken ct = default) { + var changes = new List(); + var warnings = new List(); + + var tree = CSharpSyntaxTree.ParseText(sourceCode, cancellationToken: ct); + var root = tree.GetRoot(ct); + + if (root is not CompilationUnitSyntax compilationUnit) { + return Task.FromResult(new TransformationResult(sourceCode, sourceCode, changes, warnings)); + } + + // Check if file uses Newtonsoft.Json (any Newtonsoft namespace) + var hasNewtonsoftUsing = compilationUnit.Usings + .Any(u => u.Name?.ToString().StartsWith("Newtonsoft", StringComparison.Ordinal) == true); + + if (!hasNewtonsoftUsing) { + return Task.FromResult(new TransformationResult(sourceCode, sourceCode, changes, warnings)); + } + + // Check if Newtonsoft types are actually used + var usesNewtonsoftTypes = _hasNewtonsoftTypeUsage(root); + + // If only import exists but no types used, remove the dead import + if (!usesNewtonsoftTypes) { + if (_removeDeadImports) { + var newRoot = _removeNewtonsoftUsings(compilationUnit, changes); + return Task.FromResult(new TransformationResult( + sourceCode, + newRoot.ToFullString(), + changes, + warnings)); + } + return Task.FromResult(new TransformationResult(sourceCode, sourceCode, changes, warnings)); + } + + // If not enabled, just report what would be transformed + if (!_enabled) { + warnings.Add("File uses Newtonsoft.Json types. Enable json_migration to transform."); + return Task.FromResult(new TransformationResult(sourceCode, sourceCode, changes, warnings)); + } + + // Perform transformations + var transformed = _transformNewtonsoftUsage(compilationUnit, changes, warnings); + + return Task.FromResult(new TransformationResult( + sourceCode, + transformed.ToFullString(), + changes, + warnings)); + } + + /// + /// Checks if any Newtonsoft.Json types are actually used (not just imported). + /// + private static bool _hasNewtonsoftTypeUsage(SyntaxNode root) { + // Check for JsonProperty attributes + var attributes = root.DescendantNodes().OfType(); + foreach (var attr in attributes) { + var name = _getAttributeName(attr); + if (name is "JsonProperty" or "JsonPropertyAttribute" or + "JsonIgnore" or "JsonIgnoreAttribute" or + "JsonConverter" or "JsonConverterAttribute" or + "JsonObject" or "JsonObjectAttribute" or + "JsonArray" or "JsonArrayAttribute" or + "JsonExtensionData" or "JsonExtensionDataAttribute" or + "JsonConstructor" or "JsonConstructorAttribute") { + return true; + } + } + + // Check for JsonConvert usage + var invocations = root.DescendantNodes().OfType(); + foreach (var invocation in invocations) { + var expr = invocation.Expression.ToString(); + if (expr.Contains("JsonConvert.") || + expr.Contains("JToken.") || + expr.Contains("JObject.") || + expr.Contains("JArray.") || + expr.Contains("JSchemaGenerator")) { + return true; + } + } + + // Check for type declarations using Newtonsoft types + var identifiers = root.DescendantNodes().OfType(); + foreach (var id in identifiers) { + var name = id.Identifier.Text; + if (name is "JObject" or "JArray" or "JToken" or "JValue" or + "JsonSerializer" or "JsonReader" or "JsonWriter" or + "JSchemaGenerator" or "JSchema") { + return true; + } + } + + return false; + } + + /// + /// Gets the name of an attribute (without "Attribute" suffix). + /// + private static string _getAttributeName(AttributeSyntax attr) { + return attr.Name switch { + IdentifierNameSyntax id => id.Identifier.Text, + QualifiedNameSyntax qualified => _getAttributeName(qualified), + _ => attr.Name.ToString() + }; + } + + private static string _getAttributeName(QualifiedNameSyntax qualified) { + return qualified.Right switch { + IdentifierNameSyntax id => id.Identifier.Text, + _ => qualified.Right.ToString() + }; + } + + /// + /// Removes Newtonsoft.Json using directives. + /// + private static CompilationUnitSyntax _removeNewtonsoftUsings( + CompilationUnitSyntax compilationUnit, + List changes) { + var newUsings = new List(); + + foreach (var usingDirective in compilationUnit.Usings) { + var name = usingDirective.Name?.ToString(); + if (name?.StartsWith("Newtonsoft.Json", StringComparison.Ordinal) == true) { + changes.Add(new CodeChange( + usingDirective.GetLocation().GetLineSpan().StartLinePosition.Line + 1, + ChangeType.UsingRemoved, + $"Removed unused 'using {name}'", + $"using {name};", + "")); + } else { + newUsings.Add(usingDirective); + } + } + + return compilationUnit.WithUsings(SyntaxFactory.List(newUsings)); + } + + /// + /// Transforms Newtonsoft.Json usage to System.Text.Json. + /// + private CompilationUnitSyntax _transformNewtonsoftUsage( + CompilationUnitSyntax compilationUnit, + List changes, + List warnings) { + // First, transform the using directives + var (newUsings, addedStjUsing) = _transformUsings(compilationUnit.Usings, changes, warnings); + + // Then transform the code + var rewriter = new NewtonsoftRewriter(changes, warnings, _addTodoForUnsupported); + var newRoot = (CompilationUnitSyntax)rewriter.Visit(compilationUnit); + + // Apply transformed usings + newRoot = newRoot.WithUsings(SyntaxFactory.List(newUsings)); + + // Add System.Text.Json.Serialization using if needed and not already present + if (rewriter.NeedsSerializationUsing && !addedStjUsing) { + var stjUsing = SyntaxFactory.UsingDirective( + SyntaxFactory.ParseName("System.Text.Json.Serialization")) + .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed); + + var usings = newRoot.Usings.ToList(); + // Find a good insertion point (after System.* usings) + var insertIndex = usings.FindLastIndex(u => + u.Name?.ToString().StartsWith("System", StringComparison.Ordinal) == true); + if (insertIndex >= 0) { + usings.Insert(insertIndex + 1, stjUsing); + } else { + usings.Insert(0, stjUsing); + } + newRoot = newRoot.WithUsings(SyntaxFactory.List(usings)); + + changes.Add(new CodeChange( + 1, + ChangeType.UsingAdded, + "Added 'using System.Text.Json.Serialization' for STJ attributes", + "", + "using System.Text.Json.Serialization;")); + } + + return newRoot; + } + + /// + /// Transforms using directives from Newtonsoft to System.Text.Json. + /// + private static (List usings, bool addedStj) _transformUsings( + SyntaxList originalUsings, + List changes, + List warnings) { + var newUsings = new List(); + var addedStjSerialization = false; + + foreach (var usingDirective in originalUsings) { + var name = usingDirective.Name?.ToString(); + + switch (name) { + case "Newtonsoft.Json": + // Replace with System.Text.Json.Serialization + if (!addedStjSerialization) { + var stjUsing = usingDirective + .WithName(SyntaxFactory.ParseName("System.Text.Json.Serialization") + .WithLeadingTrivia(usingDirective.Name?.GetLeadingTrivia() ?? SyntaxFactory.TriviaList()) + .WithTrailingTrivia(usingDirective.Name?.GetTrailingTrivia() ?? SyntaxFactory.TriviaList())); + newUsings.Add(stjUsing); + addedStjSerialization = true; + + changes.Add(new CodeChange( + usingDirective.GetLocation().GetLineSpan().StartLinePosition.Line + 1, + ChangeType.UsingReplaced, + "Replaced 'using Newtonsoft.Json' with 'using System.Text.Json.Serialization'", + "using Newtonsoft.Json;", + "using System.Text.Json.Serialization;")); + } + break; + + case "Newtonsoft.Json.Linq": + // JObject/JArray/JToken - needs manual review + warnings.Add($"JObject/JArray/JToken from Newtonsoft.Json.Linq requires manual migration to JsonDocument/JsonElement"); + // Remove the using + changes.Add(new CodeChange( + usingDirective.GetLocation().GetLineSpan().StartLinePosition.Line + 1, + ChangeType.UsingRemoved, + "Removed 'using Newtonsoft.Json.Linq' - requires manual migration to System.Text.Json", + "using Newtonsoft.Json.Linq;", + "// TODO: Migrate JObject/JArray to JsonDocument/JsonElement")); + break; + + case "Newtonsoft.Json.Schema": + case "Newtonsoft.Json.Schema.Generation": + // JSON Schema generation - no direct STJ equivalent + warnings.Add("JSON Schema generation (Newtonsoft.Json.Schema) has no System.Text.Json equivalent. Consider NJsonSchema package."); + // Keep the using but warn + newUsings.Add(usingDirective); + break; + + case "Newtonsoft.Json.Converters": + // Converters - some may have STJ equivalents, needs review + warnings.Add("Newtonsoft.Json.Converters may need manual migration to System.Text.Json.Serialization converters"); + changes.Add(new CodeChange( + usingDirective.GetLocation().GetLineSpan().StartLinePosition.Line + 1, + ChangeType.UsingRemoved, + "Removed 'using Newtonsoft.Json.Converters' - migrate converters manually", + "using Newtonsoft.Json.Converters;", + "")); + break; + + default: + if (name?.StartsWith("Newtonsoft.Json", StringComparison.Ordinal) == true) { + // Other Newtonsoft namespaces - remove with warning + warnings.Add($"Removed unknown Newtonsoft namespace: {name}"); + changes.Add(new CodeChange( + usingDirective.GetLocation().GetLineSpan().StartLinePosition.Line + 1, + ChangeType.UsingRemoved, + $"Removed 'using {name}'", + $"using {name};", + "")); + } else { + newUsings.Add(usingDirective); + } + break; + } + } + + return (newUsings, addedStjSerialization); + } + + /// + /// Syntax rewriter that transforms Newtonsoft patterns to System.Text.Json. + /// + private sealed class NewtonsoftRewriter : CSharpSyntaxRewriter { + private readonly List _changes; + private readonly List _warnings; + private readonly bool _addTodoForUnsupported; + + public bool NeedsSerializationUsing { get; private set; } + + public NewtonsoftRewriter( + List changes, + List warnings, + bool addTodoForUnsupported) { + _changes = changes; + _warnings = warnings; + _addTodoForUnsupported = addTodoForUnsupported; + } + + public override SyntaxNode? VisitAttribute(AttributeSyntax node) { + var name = _getAttributeName(node); + + switch (name) { + case "JsonProperty": + case "JsonPropertyAttribute": { + // Check if it's just Required = Required.Always + var arguments = node.ArgumentList?.Arguments.ToList() ?? []; + + if (arguments.Count == 1) { + var arg = arguments[0]; + var argText = arg.ToString(); + + // [JsonProperty(Required = Required.Always)] → [JsonRequired] + if (argText.Contains("Required") && argText.Contains("Always")) { + NeedsSerializationUsing = true; + var line = node.GetLocation().GetLineSpan().StartLinePosition.Line + 1; + _changes.Add(new CodeChange( + line, + ChangeType.AttributeReplaced, + "Replaced [JsonProperty(Required = Required.Always)] with [JsonRequired]", + node.ToString(), + "[JsonRequired]")); + + return SyntaxFactory.Attribute(SyntaxFactory.IdentifierName("JsonRequired")) + .WithLeadingTrivia(node.GetLeadingTrivia()) + .WithTrailingTrivia(node.GetTrailingTrivia()); + } + + // [JsonProperty("name")] → [JsonPropertyName("name")] + if (arg.NameEquals == null && arg.Expression is LiteralExpressionSyntax literal) { + NeedsSerializationUsing = true; + var line = node.GetLocation().GetLineSpan().StartLinePosition.Line + 1; + var newAttr = $"[JsonPropertyName({literal})]"; + _changes.Add(new CodeChange( + line, + ChangeType.AttributeReplaced, + $"Replaced [JsonProperty(\"{literal}\")] with [JsonPropertyName(\"{literal}\")]", + node.ToString(), + newAttr)); + + return SyntaxFactory.Attribute( + SyntaxFactory.IdentifierName("JsonPropertyName"), + SyntaxFactory.AttributeArgumentList( + SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.AttributeArgument(literal)))) + .WithLeadingTrivia(node.GetLeadingTrivia()) + .WithTrailingTrivia(node.GetTrailingTrivia()); + } + } + + // Complex JsonProperty - needs manual review + if (_addTodoForUnsupported) { + _warnings.Add($"Complex [JsonProperty] at line {node.GetLocation().GetLineSpan().StartLinePosition.Line + 1} needs manual migration"); + } + break; + } + + case "JsonIgnore": + case "JsonIgnoreAttribute": + // [JsonIgnore] is the same in both + NeedsSerializationUsing = true; + return node; + + case "JsonConverter": + case "JsonConverterAttribute": + // [JsonConverter(typeof(...))] - similar syntax but different converters + _warnings.Add($"[JsonConverter] at line {node.GetLocation().GetLineSpan().StartLinePosition.Line + 1} needs manual migration - converter types differ"); + return node; + + case "JsonExtensionData": + case "JsonExtensionDataAttribute": + // [JsonExtensionData] is the same in both + NeedsSerializationUsing = true; + return node; + + case "JsonConstructor": + case "JsonConstructorAttribute": + // [JsonConstructor] is the same in both + NeedsSerializationUsing = true; + return node; + } + + return base.VisitAttribute(node); + } + + public override SyntaxNode? VisitInvocationExpression(InvocationExpressionSyntax node) { + var expr = node.Expression.ToString(); + + // JsonConvert.SerializeObject → JsonSerializer.Serialize + if (expr == "JsonConvert.SerializeObject") { + var line = node.GetLocation().GetLineSpan().StartLinePosition.Line + 1; + _changes.Add(new CodeChange( + line, + ChangeType.MethodReplaced, + "Replaced JsonConvert.SerializeObject with JsonSerializer.Serialize", + node.ToString(), + $"JsonSerializer.Serialize({node.ArgumentList})")); + + return SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("JsonSerializer"), + SyntaxFactory.IdentifierName("Serialize")), + node.ArgumentList) + .WithLeadingTrivia(node.GetLeadingTrivia()) + .WithTrailingTrivia(node.GetTrailingTrivia()); + } + + // JsonConvert.DeserializeObject → JsonSerializer.Deserialize + if (expr.StartsWith("JsonConvert.DeserializeObject", StringComparison.Ordinal)) { + var line = node.GetLocation().GetLineSpan().StartLinePosition.Line + 1; + + // Handle generic version + if (node.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name is GenericNameSyntax genericName) { + var newExpr = SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("JsonSerializer"), + SyntaxFactory.GenericName("Deserialize") + .WithTypeArgumentList(genericName.TypeArgumentList)), + node.ArgumentList); + + _changes.Add(new CodeChange( + line, + ChangeType.MethodReplaced, + "Replaced JsonConvert.DeserializeObject with JsonSerializer.Deserialize", + node.ToString(), + newExpr.ToString())); + + return newExpr + .WithLeadingTrivia(node.GetLeadingTrivia()) + .WithTrailingTrivia(node.GetTrailingTrivia()); + } + } + + // JObject/JArray/JToken methods - warn for manual migration + if (expr.Contains("JObject.") || expr.Contains("JArray.") || expr.Contains("JToken.")) { + _warnings.Add($"JObject/JArray/JToken usage at line {node.GetLocation().GetLineSpan().StartLinePosition.Line + 1} requires manual migration to JsonDocument/JsonElement"); + } + + return base.VisitInvocationExpression(node); + } + } +} diff --git a/tools/Whizbang.Migrate/Transformers/TransformerTypes.cs b/tools/Whizbang.Migrate/Transformers/TransformerTypes.cs index a8611c58..ad03f1d9 100644 --- a/tools/Whizbang.Migrate/Transformers/TransformerTypes.cs +++ b/tools/Whizbang.Migrate/Transformers/TransformerTypes.cs @@ -47,9 +47,15 @@ public enum ChangeType { /// Using directive was removed. UsingRemoved, + /// Using directive was replaced. + UsingReplaced, + /// Attribute was removed. AttributeRemoved, + /// Attribute was replaced with a different attribute. + AttributeReplaced, + /// Type name was changed. TypeRename, @@ -59,6 +65,9 @@ public enum ChangeType { /// Method call was replaced. MethodCallReplacement, + /// Method was replaced with a different method. + MethodReplaced, + /// Method was transformed (e.g., ShouldDelete to Apply returning ModelAction). MethodTransformed } diff --git a/tools/Whizbang.Migrate/Transformers/WolverineHttpTransformer.cs b/tools/Whizbang.Migrate/Transformers/WolverineHttpTransformer.cs new file mode 100644 index 00000000..fe34bba7 --- /dev/null +++ b/tools/Whizbang.Migrate/Transformers/WolverineHttpTransformer.cs @@ -0,0 +1,297 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Whizbang.Migrate.Transformers; + +/// +/// Transforms Wolverine.Http patterns to FastEndpoints equivalents. +/// Note: Full endpoint conversion requires manual intervention as the patterns are fundamentally different. +/// This transformer handles using statements and flags methods that need manual conversion. +/// +/// migration-guide/automated-migration +public sealed class WolverineHttpTransformer : ICodeTransformer { + /// + /// Wolverine HTTP attributes that indicate methods need conversion. + /// + private static readonly HashSet _wolverineHttpAttributes = new(StringComparer.Ordinal) { + "WolverineGet", + "WolverinePost", + "WolverinePut", + "WolverineDelete", + "WolverinePatch", + "WolverineHead", + "WolverineOptions" + }; + + /// + /// Using directives related to Wolverine HTTP. + /// + private static readonly HashSet _wolverineHttpUsings = new(StringComparer.Ordinal) { + "Wolverine.Http", + "WolverineFx.Http" + }; + + /// + public Task TransformAsync( + string sourceCode, + string filePath, + CancellationToken ct = default) { + var changes = new List(); + var warnings = new List(); + + var tree = CSharpSyntaxTree.ParseText(sourceCode, cancellationToken: ct); + var root = tree.GetRoot(ct); + + // Check if there are any Wolverine HTTP patterns to transform + if (!_hasWolverineHttpPatterns(root)) { + return Task.FromResult(new TransformationResult( + sourceCode, + sourceCode, + changes, + warnings)); + } + + var newRoot = root; + + // 1. Transform using directives + newRoot = _transformUsings(newRoot, changes); + + // 2. Detect and warn about Wolverine HTTP attributes (requires manual conversion) + _detectHttpAttributesAndWarn(newRoot, warnings, changes); + + // 3. Remove Wolverine HTTP attributes (they won't compile without the package) + newRoot = _removeWolverineHttpAttributes(newRoot); + + var transformedCode = newRoot.ToFullString(); + + return Task.FromResult(new TransformationResult( + sourceCode, + transformedCode, + changes, + warnings)); + } + + private static bool _hasWolverineHttpPatterns(SyntaxNode root) { + // Check for using directives + if (root is CompilationUnitSyntax compilationUnit) { + foreach (var usingDirective in compilationUnit.Usings) { + var name = usingDirective.Name?.ToString(); + if (name != null && _wolverineHttpUsings.Contains(name)) { + return true; + } + } + } + + // Check for Wolverine HTTP attributes + var attributes = root.DescendantNodes().OfType(); + foreach (var attr in attributes) { + var attrName = _getAttributeName(attr); + if (attrName != null && _wolverineHttpAttributes.Contains(attrName)) { + return true; + } + } + + return false; + } + + private static string? _getAttributeName(AttributeSyntax attribute) { + return attribute.Name switch { + IdentifierNameSyntax id => id.Identifier.Text, + QualifiedNameSyntax qualified => qualified.Right.Identifier.Text, + _ => attribute.Name.ToString() + }; + } + + private static SyntaxNode _transformUsings(SyntaxNode root, List changes) { + var compilationUnit = root as CompilationUnitSyntax; + if (compilationUnit == null) { + return root; + } + + var hasWolverineHttpUsing = compilationUnit.Usings + .Any(u => u.Name?.ToString() != null && _wolverineHttpUsings.Contains(u.Name.ToString()!)); + + if (!hasWolverineHttpUsing) { + return root; + } + + var newUsings = new List(); + var addedFastEndpoints = false; + var addedWhizbangFastEndpoints = false; + + foreach (var usingDirective in compilationUnit.Usings) { + var name = usingDirective.Name?.ToString(); + + if (name != null && _wolverineHttpUsings.Contains(name)) { + // Add FastEndpoints using if not already added + if (!addedFastEndpoints) { + var fastEndpointsUsing = usingDirective + .WithName(SyntaxFactory.ParseName("FastEndpoints") + .WithLeadingTrivia(usingDirective.Name?.GetLeadingTrivia() ?? SyntaxFactory.TriviaList()) + .WithTrailingTrivia(usingDirective.Name?.GetTrailingTrivia() ?? SyntaxFactory.TriviaList())); + newUsings.Add(fastEndpointsUsing); + addedFastEndpoints = true; + + changes.Add(new CodeChange( + usingDirective.GetLocation().GetLineSpan().StartLinePosition.Line + 1, + ChangeType.UsingRemoved, + $"Replaced 'using {name}' with 'using FastEndpoints'", + $"using {name};", + "using FastEndpoints;")); + } + + // Add Whizbang FastEndpoints using + if (!addedWhizbangFastEndpoints) { + var whizbangUsing = SyntaxFactory.UsingDirective( + SyntaxFactory.ParseName("Whizbang.Transports.FastEndpoints")) + .WithLeadingTrivia(usingDirective.GetLeadingTrivia()) + .WithTrailingTrivia(SyntaxFactory.TriviaList(SyntaxFactory.EndOfLine("\n"))); + newUsings.Add(whizbangUsing); + addedWhizbangFastEndpoints = true; + + changes.Add(new CodeChange( + usingDirective.GetLocation().GetLineSpan().StartLinePosition.Line + 1, + ChangeType.UsingAdded, + "Added 'using Whizbang.Transports.FastEndpoints' for FastEndpoints integration", + "", + "using Whizbang.Transports.FastEndpoints;")); + } + } else { + newUsings.Add(usingDirective); + } + } + + return compilationUnit.WithUsings(SyntaxFactory.List(newUsings)); + } + + private static void _detectHttpAttributesAndWarn( + SyntaxNode root, + List warnings, + List changes) { + var methods = root.DescendantNodes().OfType(); + + foreach (var method in methods) { + _processMethodForHttpAttributes(method, warnings, changes); + } + } + + private static void _processMethodForHttpAttributes( + MethodDeclarationSyntax method, + List warnings, + List changes) { + var attributes = method.AttributeLists.SelectMany(al => al.Attributes); + + foreach (var attr in attributes) { + var attrName = _getAttributeName(attr); + if (attrName == null || !_wolverineHttpAttributes.Contains(attrName)) { + continue; + } + + _addHttpAttributeWarning(method, attr, attrName, warnings, changes); + } + } + + private static void _addHttpAttributeWarning( + MethodDeclarationSyntax method, + AttributeSyntax attr, + string attrName, + List warnings, + List changes) { + var httpMethod = attrName.Replace("Wolverine", "").ToUpperInvariant(); + var route = _extractRouteFromAttribute(attr); + var methodName = method.Identifier.Text; + var className = method.Parent is ClassDeclarationSyntax classDecl + ? classDecl.Identifier.Text + : "Unknown"; + + var lineNumber = method.GetLocation().GetLineSpan().StartLinePosition.Line + 1; + + warnings.Add( + $"MANUAL CONVERSION REQUIRED: {className}.{methodName}() has [{attrName}(\"{route}\")] - " + + $"Convert to FastEndpoints Endpoint class with Configure() and HandleAsync() methods."); + + changes.Add(new CodeChange( + lineNumber, + ChangeType.AttributeRemoved, + $"[{attrName}] requires manual conversion to FastEndpoints pattern - " + + $"Create new Endpoint class with {httpMethod}(\"{route}\") in Configure()", + $"[{attrName}(\"{route}\")]", + "// TODO: Convert to FastEndpoints endpoint class")); + } + + private static string _extractRouteFromAttribute(AttributeSyntax attr) { + if (attr.ArgumentList?.Arguments.Count > 0) { + var firstArg = attr.ArgumentList.Arguments[0]; + if (firstArg.Expression is LiteralExpressionSyntax literal) { + return literal.Token.ValueText; + } + return firstArg.Expression.ToString().Trim('"'); + } + return "/"; + } + + private static SyntaxNode _removeWolverineHttpAttributes(SyntaxNode root) { + var rewriter = new WolverineHttpAttributeRemover(); + return rewriter.Visit(root); + } + + /// + /// Rewriter that removes Wolverine HTTP attributes and adds TODO comments. + /// + private sealed class WolverineHttpAttributeRemover : CSharpSyntaxRewriter { + + public override SyntaxNode? VisitMethodDeclaration(MethodDeclarationSyntax node) { + var hasWolverineHttpAttr = false; + string? attrName = null; + string? route = null; + + foreach (var attrList in node.AttributeLists) { + foreach (var attr in attrList.Attributes) { + var name = _getAttributeName(attr); + if (name != null && _wolverineHttpAttributes.Contains(name)) { + hasWolverineHttpAttr = true; + attrName = name; + route = _extractRouteFromAttribute(attr); + break; + } + } + if (hasWolverineHttpAttr) { + break; + } + } + + if (!hasWolverineHttpAttr) { + return base.VisitMethodDeclaration(node); + } + + // Remove Wolverine HTTP attribute lists + var newAttributeLists = new List(); + foreach (var attrList in node.AttributeLists) { + var newAttributes = attrList.Attributes + .Where(attr => { + var name = _getAttributeName(attr); + return name == null || !_wolverineHttpAttributes.Contains(name); + }) + .ToList(); + + if (newAttributes.Count > 0) { + newAttributeLists.Add(attrList.WithAttributes(SyntaxFactory.SeparatedList(newAttributes))); + } + } + + // Add TODO comment - attrName is guaranteed non-null when hasWolverineHttpAttr is true + var httpMethod = attrName!.Replace("Wolverine", "").ToUpperInvariant(); + var todoComment = SyntaxFactory.Comment( + $"// TODO: Convert to FastEndpoints - Create Endpoint class with {httpMethod}(\"{route}\") in Configure()\n"); + + var leadingTrivia = node.GetLeadingTrivia().Insert(0, todoComment); + + var newNode = node + .WithAttributeLists(SyntaxFactory.List(newAttributeLists)) + .WithLeadingTrivia(leadingTrivia); + + return newNode; + } + } +} diff --git a/tools/Whizbang.Migrate/Wizard/DecisionFile.cs b/tools/Whizbang.Migrate/Wizard/DecisionFile.cs index 4d6ae5e0..3fd8b4c1 100644 --- a/tools/Whizbang.Migrate/Wizard/DecisionFile.cs +++ b/tools/Whizbang.Migrate/Wizard/DecisionFile.cs @@ -20,6 +20,7 @@ namespace Whizbang.Migrate.Wizard; [JsonSerializable(typeof(CustomBaseClassDecisions))] [JsonSerializable(typeof(UnknownInterfaceDecisions))] [JsonSerializable(typeof(PackageDecisions))] +[JsonSerializable(typeof(JsonMigrationDecisions))] internal sealed partial class DecisionFileJsonContext : JsonSerializerContext { } /// @@ -322,6 +323,33 @@ public string ToJsonWithComments() { // Packages to preserve (don't remove even if Marten/Wolverine) "preserve_packages": [] + }, + + // ═══════════════════════════════════════════════════════════════════════════ + // JSON LIBRARY MIGRATION (OPTIONAL) + // ═══════════════════════════════════════════════════════════════════════════ + // Transforms Newtonsoft.Json to System.Text.Json. This is OPTIONAL and opt-in. + // Dead imports are always removed regardless of this setting. + "json_migration": { + // Enable JSON library migration. Default: false (opt-in) + // When true, transforms: + // - [JsonProperty(Required = Required.Always)] → [JsonRequired] + // - [JsonProperty("name")] → [JsonPropertyName("name")] + // - JsonConvert.SerializeObject → JsonSerializer.Serialize + // - JsonConvert.DeserializeObject → JsonSerializer.Deserialize + "enabled": {{Decisions.JsonMigration.Enabled.ToString().ToLowerInvariant()}}, + + // Remove unused Newtonsoft.Json imports (runs even if enabled=false) + "remove_dead_imports": {{Decisions.JsonMigration.RemoveDeadImports.ToString().ToLowerInvariant()}}, + + // Add TODO comments for patterns that can't be auto-converted + "add_todo_for_unsupported": {{Decisions.JsonMigration.AddTodoForUnsupported.ToString().ToLowerInvariant()}} + + // NOTE: These patterns require manual migration: + // - JObject/JArray/JToken → JsonDocument/JsonElement + // - JSchemaGenerator → NJsonSchema package + // - Custom JsonConverters → System.Text.Json.Serialization.JsonConverter + // - JsonSerializerSettings → JsonSerializerOptions } } } @@ -542,6 +570,11 @@ public sealed class MigrationDecisions { /// Package management decisions. /// public PackageDecisions Packages { get; set; } = new(); + + /// + /// JSON library migration decisions (optional Newtonsoft → STJ). + /// + public JsonMigrationDecisions JsonMigration { get; set; } = new(); } /// @@ -977,3 +1010,35 @@ public sealed class PackageDecisions { /// public List PreservePackages { get; set; } = []; } + +/// +/// JSON library migration decisions (Newtonsoft.Json → System.Text.Json). +/// +public sealed class JsonMigrationDecisions { + /// + /// Whether to migrate Newtonsoft.Json to System.Text.Json. Default: false (opt-in). + /// + public bool Enabled { get; set; } + + /// + /// Whether to remove unused Newtonsoft.Json imports. Default: true. + /// This runs even if Enabled is false. + /// + public bool RemoveDeadImports { get; set; } = true; + + /// + /// Whether to add TODO comments for unsupported patterns. Default: true. + /// + public bool AddTodoForUnsupported { get; set; } = true; + + /// + /// Patterns that require manual migration and cannot be auto-converted. + /// + public List UnsupportedPatterns { get; } = + [ + "JObject/JArray/JToken → JsonDocument/JsonElement", + "JSchemaGenerator → NJsonSchema", + "Custom JsonConverters → System.Text.Json.Serialization.JsonConverter", + "JsonSerializerSettings → JsonSerializerOptions" + ]; +}