From e893564e38e1895e9276dd320a2269b22d6ae1c4 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Fri, 31 Jul 2026 14:28:01 -0700 Subject: [PATCH 1/8] Add basket shipping address support; improve request handling - Added Address value object and ShippingAddress to basket domain, contract, and persistence models. - Implemented AddressMapper and AddressValidator. - Added PUT /api/baskets/{basketId}/shipping-address endpoint. - Updated checkout logic to require shipping address. - Persist shipping address as JSON; updated schema, EF model, seed data, and tests. - Improved CoreEx.AspNetCore request body handling and validation. - Made ref-data code comparison case-sensitive by default. - Added generic JSON EF converter. - Updated DbEx packages, codegen templates, and tests for new behaviors. --- Directory.Packages.props | 4 +- .../Controllers/BasketController.cs | 8 ++- .../BasketService.cs | 15 +++- .../Interfaces/IBasketService.cs | 7 +- .../Mapping/AddressMapper.cs | 22 ++++++ .../Mapping/BasketMapper.cs | 3 +- .../Validators/AddressValidator.cs | 12 ++++ .../src/Contoso.Shopping.Contracts/Address.cs | 11 +++ .../src/Contoso.Shopping.Contracts/Basket.cs | 4 +- ...622-194111-alter-shopping-basket-table.sql | 8 +++ .../src/Contoso.Shopping.Database/dbex.yaml | 6 +- samples/src/Contoso.Shopping.Domain/Basket.cs | 35 +++++++--- .../Contoso.Shopping.Domain/GlobalUsing.cs | 3 +- .../ValueObjects/Address.cs | 10 +++ .../Mapping/AddressMapper.cs | 22 ++++++ .../Mapping/BasketIntoMapper.cs | 3 +- .../Mapping/BasketMapper.cs | 3 +- .../Persistence/Address.cs | 10 +++ .../Persistence/Basket.g.cs | 3 + .../Repositories/ShoppingDbContext.g.cs | 3 +- .../MovementMutateTests.Reserve.cs | 4 +- .../ProductMutateTests.Create.cs | 14 ++-- .../ProductMutateTests.Delete.cs | 4 +- .../ReadTests.MovementQuery.cs | 4 +- .../ReadTests.ProductQuery.cs | 6 +- .../MutateTests.Basket.cs | 70 +++++++++++++++++-- .../MutateTests.BasketItem.cs | 4 +- .../Resources/Basket_Get_Found.res.json | 8 ++- .../Data/mutate-data.seed.yaml | 4 +- .../Data/read-data.seed.yaml | 2 +- .../Domains/BasketTests.cs | 63 ++++++++++++++++- .../Validators/AddressValidatorTests.cs | 61 ++++++++++++++++ .../Abstractions/IWebApiRequestOptions.cs | 11 ++- src/CoreEx.AspNetCore/Abstractions/WebApi.cs | 12 ++-- .../Abstractions/WebApiBase.cs | 17 ++++- src/CoreEx.AspNetCore/WebApiRequestOptions.cs | 31 ++++++-- .../WebApiRequestResponseOptions.cs | 30 ++++++-- .../Templates/EfModelBuilder_cs.hbs | 66 ++++++++--------- .../JsonElementStringEfConverter.cs | 2 +- .../Converters/TypeToJsonStringEfConverter.cs | 13 ++++ .../ReferenceDataCollectionCore.cs | 6 +- .../ReferenceDataCollectionT.cs | 4 +- .../ReferenceDataCollectionT2.cs | 4 +- .../CoreEx.Core/_Directory.Packages.props | 6 +- .../UnitTestExExtensions.Assert.cs | 10 +-- .../Rules/ReferenceDataRule.cs | 2 +- src/CoreEx.Validation/ValidatorT.cs | 2 +- .../Converters/TypeToJsonStringConverter.cs | 43 ++++++++++++ src/CoreEx/Metadata/RuntimeMetadata.Clean.cs | 4 +- .../PersonApi_MutateTestsBase.cs | 9 ++- .../PersonApi_QueryTestsBase.cs | 6 +- .../ReferenceDataValidationTests.cs | 15 +++- 52 files changed, 596 insertions(+), 133 deletions(-) create mode 100644 samples/src/Contoso.Shopping.Application/Mapping/AddressMapper.cs create mode 100644 samples/src/Contoso.Shopping.Application/Validators/AddressValidator.cs create mode 100644 samples/src/Contoso.Shopping.Contracts/Address.cs create mode 100644 samples/src/Contoso.Shopping.Database/Migrations/20260622-194111-alter-shopping-basket-table.sql create mode 100644 samples/src/Contoso.Shopping.Domain/ValueObjects/Address.cs create mode 100644 samples/src/Contoso.Shopping.Infrastructure/Mapping/AddressMapper.cs create mode 100644 samples/src/Contoso.Shopping.Infrastructure/Persistence/Address.cs create mode 100644 samples/tests/Contoso.Shopping.Test.Unit/Validators/AddressValidatorTests.cs create mode 100644 src/CoreEx.EntityFrameworkCore/Converters/TypeToJsonStringEfConverter.cs create mode 100644 src/CoreEx/Mapping/Converters/TypeToJsonStringConverter.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 84dfb269..353275d1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -68,8 +68,8 @@ - - + + diff --git a/samples/src/Contoso.Shopping.Api/Controllers/BasketController.cs b/samples/src/Contoso.Shopping.Api/Controllers/BasketController.cs index 9b5ef232..8f7163af 100644 --- a/samples/src/Contoso.Shopping.Api/Controllers/BasketController.cs +++ b/samples/src/Contoso.Shopping.Api/Controllers/BasketController.cs @@ -23,6 +23,12 @@ public Task CreateAsync(string customerId, CancellationToken canc public Task ApplyDiscountAsync(string basketId, string coupon, CancellationToken cancellationToken = default) => _webApi.PutWithResultAsync(Request, (_, ct) => _service.ApplyDiscountAsync(basketId.Required(), coupon.Required(), ct), cancellationToken: cancellationToken); + [HttpPut("{basketId}/shipping-address")] + [ProducesResponseType(typeof(Basket), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public Task UpdateShippingAddressAsync(string basketId, CancellationToken cancellationToken = default) => _webApi.PutWithResultAsync(Request, (ro, ct) + => _service.UpdateShippingAddressAsync(basketId.Required(), ro.ValueOrDefault, ct), cancellationToken: cancellationToken); + [HttpPost("{basketId}/checkout")] [ProducesResponseType(typeof(Basket), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] @@ -49,4 +55,4 @@ public Task ItemUpdateAsync(string basketId, string basketItemId, [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public Task ItemDeleteAsync(string basketId, string basketItemId, CancellationToken cancellationToken = default) => _webApi.DeleteWithResultAsync(Request, (_, ct) => _service.ItemDeleteAsync(basketId.Required(), basketItemId.Required(), ct), cancellationToken: cancellationToken); -} \ No newline at end of file +} diff --git a/samples/src/Contoso.Shopping.Application/BasketService.cs b/samples/src/Contoso.Shopping.Application/BasketService.cs index 9491f313..f79ae4d9 100644 --- a/samples/src/Contoso.Shopping.Application/BasketService.cs +++ b/samples/src/Contoso.Shopping.Application/BasketService.cs @@ -43,6 +43,19 @@ public Task> CreateAsync(string customerId, CancellationToken ct return await OrchestrateUpdateAsync(basketId, basket => basket.ApplyDiscount(discountCoupon), ct, EventAction.Updated).ConfigureAwait(false); } + /// + public async Task> UpdateShippingAddressAsync(string basketId, Address? shippingAddress, CancellationToken ct = default) + { + if (shippingAddress is not null) + { + var vr = await AddressValidator.Default.ValidateWithResultAsync(shippingAddress, cancellationToken: ct).ConfigureAwait(false); + if (vr.IsFailure) + return vr.AsResult(); + } + + return await OrchestrateUpdateAsync(basketId, basket => basket.UpdateShippingAddress(AddressMapper.From.Map(shippingAddress)), ct, EventAction.Updated).ConfigureAwait(false); + } + /// public async Task> ItemAddAsync(string basketId, BasketItemAddRequest item, CancellationToken ct = default) { @@ -167,4 +180,4 @@ private Task> UpdateAsync(Domain.Basket basket, EventAction actio throw; } }); -} \ No newline at end of file +} diff --git a/samples/src/Contoso.Shopping.Application/Interfaces/IBasketService.cs b/samples/src/Contoso.Shopping.Application/Interfaces/IBasketService.cs index 6f3cb943..88f35202 100644 --- a/samples/src/Contoso.Shopping.Application/Interfaces/IBasketService.cs +++ b/samples/src/Contoso.Shopping.Application/Interfaces/IBasketService.cs @@ -12,6 +12,11 @@ public interface IBasketService /// Task> ApplyDiscountAsync(string basketId, DiscountCoupon discountCoupon, CancellationToken ct = default); + /// + /// Updates the for the specified . + /// + Task> UpdateShippingAddressAsync(string basketId, Address? shippingAddress, CancellationToken ct = default); + /// /// Checkout the specified . /// @@ -31,4 +36,4 @@ public interface IBasketService /// Deletes an existing from the specified . /// Task> ItemDeleteAsync(string basketId, string basketItemId, CancellationToken ct = default); -} \ No newline at end of file +} diff --git a/samples/src/Contoso.Shopping.Application/Mapping/AddressMapper.cs b/samples/src/Contoso.Shopping.Application/Mapping/AddressMapper.cs new file mode 100644 index 00000000..e95d60d2 --- /dev/null +++ b/samples/src/Contoso.Shopping.Application/Mapping/AddressMapper.cs @@ -0,0 +1,22 @@ +namespace Contoso.Shopping.Application.Mapping; + +public class AddressMapper : BiDirectionMapper +{ + protected override Contracts.Address OnMap(Domain.ValueObjects.Address source) => new() + { + Street1 = source.Street1, + Street2 = source.Street2, + City = source.City, + PostCode = source.PostCode, + State = source.State + }; + + protected override Domain.ValueObjects.Address OnMap(Contracts.Address source) => new() + { + Street1 = source.Street1, + Street2 = source.Street2, + City = source.City!, + PostCode = source.PostCode!, + State = source.State! + }; +} diff --git a/samples/src/Contoso.Shopping.Application/Mapping/BasketMapper.cs b/samples/src/Contoso.Shopping.Application/Mapping/BasketMapper.cs index 84c585b5..e0f1ef9a 100644 --- a/samples/src/Contoso.Shopping.Application/Mapping/BasketMapper.cs +++ b/samples/src/Contoso.Shopping.Application/Mapping/BasketMapper.cs @@ -15,6 +15,7 @@ public class BasketMapper : Mapper BasketItemMapper.Map(i))] }; @@ -32,4 +33,4 @@ private class BasketItemMapper : Mapper +{ + public AddressValidator() + { + RuleFor(x => x.Street1).NotEmpty(); + RuleFor(x => x.City).NotEmpty(); + RuleFor(x => x.PostCode).NotEmpty(); + RuleFor(x => x.State).NotEmpty(); + } +} diff --git a/samples/src/Contoso.Shopping.Contracts/Address.cs b/samples/src/Contoso.Shopping.Contracts/Address.cs new file mode 100644 index 00000000..4e6e7921 --- /dev/null +++ b/samples/src/Contoso.Shopping.Contracts/Address.cs @@ -0,0 +1,11 @@ +namespace Contoso.Shopping.Contracts; + +[Contract] +public partial class Address +{ + public string? Street1 { get; set; } + public string? Street2 { get; set; } + public string? City { get; set; } + public string? PostCode { get; set; } + public string? State { get; set; } +} diff --git a/samples/src/Contoso.Shopping.Contracts/Basket.cs b/samples/src/Contoso.Shopping.Contracts/Basket.cs index 3f8e54cf..45dfb248 100644 --- a/samples/src/Contoso.Shopping.Contracts/Basket.cs +++ b/samples/src/Contoso.Shopping.Contracts/Basket.cs @@ -16,9 +16,11 @@ public partial class Basket : IIdentifier, IChangeLog, IETag public BasketPricing? Pricing { get; set; } + public Address? ShippingAddress { get; set; } + [ReadOnly(true)] public ChangeLog? ChangeLog { get; set; } [ReadOnly(true)] public string? ETag { get; set; } -} \ No newline at end of file +} diff --git a/samples/src/Contoso.Shopping.Database/Migrations/20260622-194111-alter-shopping-basket-table.sql b/samples/src/Contoso.Shopping.Database/Migrations/20260622-194111-alter-shopping-basket-table.sql new file mode 100644 index 00000000..6aee9cad --- /dev/null +++ b/samples/src/Contoso.Shopping.Database/Migrations/20260622-194111-alter-shopping-basket-table.sql @@ -0,0 +1,8 @@ +-- Alter table: [shopping].[basket] + +BEGIN TRANSACTION + +ALTER TABLE [shopping].[basket] + ADD [ShippingAddressJson] NVARCHAR(MAX) NULL + +COMMIT TRANSACTION diff --git a/samples/src/Contoso.Shopping.Database/dbex.yaml b/samples/src/Contoso.Shopping.Database/dbex.yaml index 9de34f66..13c5e753 100644 --- a/samples/src/Contoso.Shopping.Database/dbex.yaml +++ b/samples/src/Contoso.Shopping.Database/dbex.yaml @@ -8,5 +8,9 @@ tables: # Transactional-data - name: Basket + columns: + - name: ShippingAddressJson + property: ShippingAddress + type: Persistence.Address? - name: BasketItem -- name: Product \ No newline at end of file +- name: Product diff --git a/samples/src/Contoso.Shopping.Domain/Basket.cs b/samples/src/Contoso.Shopping.Domain/Basket.cs index 6c781bea..b20e22c3 100644 --- a/samples/src/Contoso.Shopping.Domain/Basket.cs +++ b/samples/src/Contoso.Shopping.Domain/Basket.cs @@ -7,14 +7,15 @@ public sealed class Basket : Aggregate public static Basket CreateNew(string customerId) => new Basket(Runtime.NewId()) { CustomerId = customerId, - Status = BasketStatus.Empty + Status = Contracts.BasketStatus.Empty }.AsNew(); - public static Basket CreateFrom(string id, string customerId, BasketStatus status, DiscountCoupon? discountCoupon, IEnumerable? items, ChangeLog? changeLog, string? etag) => new Basket(id) + public static Basket CreateFrom(string id, string customerId, Contracts.BasketStatus status, Contracts.DiscountCoupon? discountCoupon, Address? shippingAddress, IEnumerable? items, ChangeLog? changeLog, string? etag) => new Basket(id) { CustomerId = customerId, Status = status, DiscountCoupon = discountCoupon, + ShippingAddress = shippingAddress, _items = items is null ? [] : [.. items.Select(i => i.Clone(PersistenceState.NotModified))], ChangeLog = changeLog, ETag = etag @@ -24,9 +25,11 @@ private Basket(string id) : base(id) { } public string CustomerId { get; private set => field = value.ThrowIfNullOrEmpty(); } = null!; - public BasketStatus Status { get; private set => field = value.ThrowIfNull().ThrowIfInactive(); } = null!; + public Contracts.BasketStatus Status { get; private set => field = value.ThrowIfNull().ThrowIfInactive(); } = null!; - public DiscountCoupon? DiscountCoupon { get; private set => field = value?.ThrowIfInvalid(); } + public Contracts.DiscountCoupon? DiscountCoupon { get; private set => field = value?.ThrowIfInvalid(); } + + public Address? ShippingAddress { get; private set; } public IReadOnlyList Items => _items; @@ -52,13 +55,13 @@ protected override void OnMutate() { // Automatically update the status based on the items in the basket (where it can be mutated). if (Status.CanBeMutated) - Status = _items.Any(i => i.PersistenceState.IsNotRemoved) ? BasketStatus.Active : BasketStatus.Empty; + Status = _items.Any(i => i.PersistenceState.IsNotRemoved) ? Contracts.BasketStatus.Active : Contracts.BasketStatus.Empty; } /// /// Applies the discount coupon to the basket (where not already applied). /// - public Result ApplyDiscount(DiscountCoupon discountCoupon) + public Result ApplyDiscount(Contracts.DiscountCoupon discountCoupon) { discountCoupon.ThrowIfNull().ThrowIfInactive(); if (discountCoupon != DiscountCoupon) @@ -67,6 +70,17 @@ public Result ApplyDiscount(DiscountCoupon discountCoupon) return Result.Success; } + /// + /// Updates (overrides) the shipping address for the basket. + /// + public Result UpdateShippingAddress(Address? shippingAddress) + { + if (shippingAddress != ShippingAddress) + Modify(() => ShippingAddress = shippingAddress); + + return Result.Success; + } + /// /// Adds new or merges into an existing item in the basket. /// @@ -117,12 +131,15 @@ public Result ItemDelete(string basketItemId) /// public Result Checkout() { - if (Status == BasketStatus.Empty) + if (Status == Contracts.BasketStatus.Empty) return Result.BusinessError("An empty basket can not be checked out.", c => c.WithKey(Id).WithErrorCode("empty-basket")); if (_items.Sum(i => i.Pricing.Quantity) == 0) return Result.BusinessError("A basket must have at least one item with a quantity greater than zero to be checked out.", c => c.WithKey(Id).WithErrorCode("zero-quantity-basket")); + if (ShippingAddress is null) + return Result.BusinessError("A basket must have a shipping address to be checked out.", c => c.WithKey(Id).WithErrorCode("missing-shipping-address")); + if (HasChanges) throw new InvalidOperationException("A basket can not be checked out where changes have not been committed."); @@ -131,9 +148,9 @@ public Result Checkout() foreach (var item in _items.Where(i => i.Pricing.Quantity == 0)) item.Delete(); - Status = BasketStatus.CheckedOut; + Status = Contracts.BasketStatus.CheckedOut; }); return Result.Success; } -} \ No newline at end of file +} diff --git a/samples/src/Contoso.Shopping.Domain/GlobalUsing.cs b/samples/src/Contoso.Shopping.Domain/GlobalUsing.cs index e330ae9a..9767ea11 100644 --- a/samples/src/Contoso.Shopping.Domain/GlobalUsing.cs +++ b/samples/src/Contoso.Shopping.Domain/GlobalUsing.cs @@ -1,7 +1,6 @@ -global using Contoso.Shopping.Contracts; global using Contoso.Shopping.Domain.ValueObjects; global using CoreEx; global using CoreEx.DomainDriven; global using CoreEx.Entities; global using CoreEx.Results; -global using CoreEx.Validation; \ No newline at end of file +global using CoreEx.Validation; diff --git a/samples/src/Contoso.Shopping.Domain/ValueObjects/Address.cs b/samples/src/Contoso.Shopping.Domain/ValueObjects/Address.cs new file mode 100644 index 00000000..59e61254 --- /dev/null +++ b/samples/src/Contoso.Shopping.Domain/ValueObjects/Address.cs @@ -0,0 +1,10 @@ +namespace Contoso.Shopping.Domain.ValueObjects; + +public record class Address +{ + public required string? Street1 { get; init => field = value.ThrowIfNullOrEmpty(); } + public string? Street2 { get; init => field = value.ThrowIfEmpty(); } + public required string City { get; init => field = value.ThrowIfNullOrEmpty(); } + public required string PostCode { get; init => field = value.ThrowIfNullOrEmpty(); } + public required string State { get; init => field = value.ThrowIfNullOrEmpty(); } +} diff --git a/samples/src/Contoso.Shopping.Infrastructure/Mapping/AddressMapper.cs b/samples/src/Contoso.Shopping.Infrastructure/Mapping/AddressMapper.cs new file mode 100644 index 00000000..b0221919 --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/Mapping/AddressMapper.cs @@ -0,0 +1,22 @@ +namespace Contoso.Shopping.Infrastructure.Mapping; + +public class AddressMapper : BiDirectionMapper +{ + protected override Domain.ValueObjects.Address OnMap(Persistence.Address source) => new() + { + Street1 = source.Street1!, + Street2 = source.Street2, + City = source.City!, + PostCode = source.PostCode!, + State = source.State! + }; + + protected override Persistence.Address OnMap(Domain.ValueObjects.Address source) => new() + { + Street1 = source.Street1, + Street2 = source.Street2, + City = source.City, + PostCode = source.PostCode, + State = source.State + }; +} diff --git a/samples/src/Contoso.Shopping.Infrastructure/Mapping/BasketIntoMapper.cs b/samples/src/Contoso.Shopping.Infrastructure/Mapping/BasketIntoMapper.cs index 7ff27ac4..29b1bdcf 100644 --- a/samples/src/Contoso.Shopping.Infrastructure/Mapping/BasketIntoMapper.cs +++ b/samples/src/Contoso.Shopping.Infrastructure/Mapping/BasketIntoMapper.cs @@ -11,5 +11,6 @@ protected override void OnMapInto(Domain.Basket source, Persistence.Basket desti destination.DiscountCouponCode = source.DiscountCoupon?.Code; destination.DiscountAmount = source.DiscountAmount; destination.Total = source.Total; + destination.ShippingAddress = AddressMapper.From.Map(source.ShippingAddress); } -} \ No newline at end of file +} diff --git a/samples/src/Contoso.Shopping.Infrastructure/Mapping/BasketMapper.cs b/samples/src/Contoso.Shopping.Infrastructure/Mapping/BasketMapper.cs index ed845743..0238f2f6 100644 --- a/samples/src/Contoso.Shopping.Infrastructure/Mapping/BasketMapper.cs +++ b/samples/src/Contoso.Shopping.Infrastructure/Mapping/BasketMapper.cs @@ -22,8 +22,9 @@ protected override Domain.Basket OnMap(Persistence.Basket source) source.CustomerId, source.BasketStatusCode, source.DiscountCouponCode, + AddressMapper.To.Map(source.ShippingAddress), items, ChangeLog.CreateFrom(source), source.ETag); } -} \ No newline at end of file +} diff --git a/samples/src/Contoso.Shopping.Infrastructure/Persistence/Address.cs b/samples/src/Contoso.Shopping.Infrastructure/Persistence/Address.cs new file mode 100644 index 00000000..b56863af --- /dev/null +++ b/samples/src/Contoso.Shopping.Infrastructure/Persistence/Address.cs @@ -0,0 +1,10 @@ +namespace Contoso.Shopping.Infrastructure.Persistence; + +public class Address +{ + public string? Street1 { get; set; } + public string? Street2 { get; set; } + public string? City { get; set; } + public string? PostCode { get; set; } + public string? State { get; set; } +} diff --git a/samples/src/Contoso.Shopping.Infrastructure/Persistence/Basket.g.cs b/samples/src/Contoso.Shopping.Infrastructure/Persistence/Basket.g.cs index 6a5a6f09..f121b6fc 100644 --- a/samples/src/Contoso.Shopping.Infrastructure/Persistence/Basket.g.cs +++ b/samples/src/Contoso.Shopping.Infrastructure/Persistence/Basket.g.cs @@ -29,6 +29,9 @@ public partial class Basket : ModelBase /// Gets or sets the value of the 'Total' column (type 'DECIMAL(18, 2)'). public decimal Total { get; set; } + + /// Gets or sets the value of the 'ShippingAddressJson' column (type 'NVARCHAR(MAX) NULL'). + public Persistence.Address? ShippingAddress { get; set; } } #nullable restore \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.g.cs b/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.g.cs index 24c43afe..678abe2e 100644 --- a/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.g.cs +++ b/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.g.cs @@ -84,6 +84,7 @@ partial void AddGeneratedModels(Microsoft.EntityFrameworkCore.ModelBuilder model e.Property(p => p.DiscountCouponCode).HasColumnName("DiscountCouponCode").HasColumnType("NVARCHAR(50)"); e.Property(p => p.DiscountAmount).HasColumnName("DiscountAmount").HasColumnType("DECIMAL(18, 2)"); e.Property(p => p.Total).HasColumnName("Total").HasColumnType("DECIMAL(18, 2)"); + e.Property(p => p.ShippingAddress).HasColumnName("ShippingAddressJson").HasColumnType("NVARCHAR(MAX)").HasConversion(TypeToJsonStringEfConverter.Default); e.Property(p => p.CreatedBy).HasColumnName("CreatedBy").HasColumnType("NVARCHAR(250)"); e.Property(p => p.CreatedOn).HasColumnName("CreatedOn").HasColumnType("DATETIMEOFFSET"); e.Property(p => p.UpdatedBy).HasColumnName("UpdatedBy").HasColumnType("NVARCHAR(250)"); @@ -125,4 +126,4 @@ partial void AddGeneratedModels(Microsoft.EntityFrameworkCore.ModelBuilder model } } -#nullable restore \ No newline at end of file +#nullable restore diff --git a/samples/tests/Contoso.Products.Test.Api/MovementMutateTests.Reserve.cs b/samples/tests/Contoso.Products.Test.Api/MovementMutateTests.Reserve.cs index ab73dbfa..4ec23996 100644 --- a/samples/tests/Contoso.Products.Test.Api/MovementMutateTests.Reserve.cs +++ b/samples/tests/Contoso.Products.Test.Api/MovementMutateTests.Reserve.cs @@ -39,7 +39,7 @@ public void Reserve_InsufficientStock() Test.Http() .Run(HttpMethod.Post, "/api/inventory/reserve", req) .AssertBadRequest() - .AssertProblemDetailsTitle($"Product '{p2}' does not have sufficient quantity on hand."); + .AssertProblemDetails(p => p.Title.Should().Be($"Product '{p2}' does not have sufficient quantity on hand.")); } [Test] @@ -88,4 +88,4 @@ public void Reserve_Success() .AssertOK() .Value.Should().Be(q2 - 1); } -} \ No newline at end of file +} diff --git a/samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Create.cs b/samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Create.cs index 5a1e31bb..85398428 100644 --- a/samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Create.cs +++ b/samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Create.cs @@ -37,8 +37,8 @@ public void Create_Duplicate() Text = "Yeti ASR C2", Price = 5800M, SubCategoryCode = "XC", - UnitOfMeasureCode = "ea", - BrandCode = "yeti" + UnitOfMeasureCode = "EA", + BrandCode = "YETI" }; // Act/Assert. @@ -57,8 +57,8 @@ public void Create_Success() Text = "New Product", Price = 1000M, SubCategoryCode = "XC", - UnitOfMeasureCode = "ea", - BrandCode = "yeti" + UnitOfMeasureCode = "EA", + BrandCode = "YETI" }; // Act/Assert. @@ -90,8 +90,8 @@ public void Create_IdempotencyKey() Text = "Another New Product", Price = 1200M, SubCategoryCode = "XC", - UnitOfMeasureCode = "ea", - BrandCode = "yeti" + UnitOfMeasureCode = "EA", + BrandCode = "YETI" }; var ik = Guid.NewGuid().ToString(); @@ -115,4 +115,4 @@ public void Create_IdempotencyKey() // Assert: both results are the same. ObjectComparer.Assert(v1, v2); } -} \ No newline at end of file +} diff --git a/samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Delete.cs b/samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Delete.cs index 74a96258..bda04cda 100644 --- a/samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Delete.cs +++ b/samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Delete.cs @@ -27,7 +27,7 @@ public void Delete_IsActive() Test.Http() .Run(HttpMethod.Delete, $"/api/products/{12.ToGuid()}") .AssertBadRequest() - .AssertProblemDetailsTitle("A product must first be deactivated before it can be deleted."); + .AssertProblemDetails(p => p.Title.Should().Be("A product must first be deactivated before it can be deleted.")); } [Test] @@ -56,4 +56,4 @@ public void Delete_Success() .Run(HttpMethod.Get, $"/api/products/{id}") .AssertNotFound(); } -} \ No newline at end of file +} diff --git a/samples/tests/Contoso.Products.Test.Api/ReadTests.MovementQuery.cs b/samples/tests/Contoso.Products.Test.Api/ReadTests.MovementQuery.cs index 7d918c81..b7821578 100644 --- a/samples/tests/Contoso.Products.Test.Api/ReadTests.MovementQuery.cs +++ b/samples/tests/Contoso.Products.Test.Api/ReadTests.MovementQuery.cs @@ -18,8 +18,8 @@ public void Movement_Query_All() public void Movement_Query_Filter() { var r = Test.Http() - .Run(HttpMethod.Get, $"/api/inventory/movements?$filter=referenceid eq '{1000.ToGuid()}' and productid ne '{6.ToGuid()}' and kind eq 'I' and status eq 'p'") + .Run(HttpMethod.Get, $"/api/inventory/movements?$filter=referenceid eq '{1000.ToGuid()}' and productid ne '{6.ToGuid()}' and kind eq 'I' and status eq 'P'") .AssertOK() .AssertJsonFromResource("Movement_Query_Filter.res.json", ["changelog", "etag"]); } -} \ No newline at end of file +} diff --git a/samples/tests/Contoso.Products.Test.Api/ReadTests.ProductQuery.cs b/samples/tests/Contoso.Products.Test.Api/ReadTests.ProductQuery.cs index f3118210..dfaa3273 100644 --- a/samples/tests/Contoso.Products.Test.Api/ReadTests.ProductQuery.cs +++ b/samples/tests/Contoso.Products.Test.Api/ReadTests.ProductQuery.cs @@ -78,7 +78,7 @@ public void Product_Query_FilterBySku_IncludeInactive() public void Product_Query_FilterByCategory() { var r = Test.Http() - .Run(HttpMethod.Get, "/api/products?$filter=category eq 'm'") + .Run(HttpMethod.Get, "/api/products?$filter=category eq 'M'") .AssertOK() .Value; @@ -91,10 +91,10 @@ public void Product_Query_FilterByCategory() public void Product_Query_FilterByBrandAndSubCategory() { var r = Test.Http() - .Run(HttpMethod.Get, "/api/products?$filter=subcategory eq 'xc' and brand in ('yeti', 'canyon')") + .Run(HttpMethod.Get, "/api/products?$filter=subcategory eq 'XC' and brand in ('YETI', 'CANYON')") .AssertOK() .Value; r.Should().NotBeNull().And.HaveCount(2); } -} \ No newline at end of file +} diff --git a/samples/tests/Contoso.Shopping.Test.Api/MutateTests.Basket.cs b/samples/tests/Contoso.Shopping.Test.Api/MutateTests.Basket.cs index ad9ae8b3..19bac2af 100644 --- a/samples/tests/Contoso.Shopping.Test.Api/MutateTests.Basket.cs +++ b/samples/tests/Contoso.Shopping.Test.Api/MutateTests.Basket.cs @@ -26,7 +26,7 @@ public void Basket_Create() public void Basket_ApplyDiscount_NotFound() { Test.Http() - .Run(HttpMethod.Put, $"/api/baskets/{404.ToGuid()}/apply-discount/save10") + .Run(HttpMethod.Put, $"/api/baskets/{404.ToGuid()}/apply-discount/SAVE10") .AssertNotFound(); } @@ -34,9 +34,9 @@ public void Basket_ApplyDiscount_NotFound() public void Basket_ApplyDiscount_Invalid() { Test.Http() - .Run(HttpMethod.Put, $"/api/baskets/{404.ToGuid()}/apply-discount/save100") + .Run(HttpMethod.Put, $"/api/baskets/{404.ToGuid()}/apply-discount/SAVE100") .AssertBadRequest() - .AssertProblemDetailsTitle("Discount coupon either does not exist or is no longer active."); + .AssertProblemDetails(p => p.Title.Should().Be("Discount coupon either does not exist or is no longer active.")); } @@ -46,7 +46,7 @@ public void Basket_ApplyDiscount_Inactive() Test.Http() .Run(HttpMethod.Put, $"/api/baskets/{404.ToGuid()}/apply-discount/XMAS2025") .AssertBadRequest() - .AssertProblemDetailsTitle("Discount coupon either does not exist or is no longer active."); + .AssertProblemDetails(p => p.Title.Should().Be("Discount coupon either does not exist or is no longer active.")); } [Test] @@ -65,7 +65,7 @@ public void Basket_ApplyDiscount_Success() v = Test.Http() .ExpectChangeLogUpdated() .ExpectSqlServerOutboxEvents(e => e.AssertWithValue("contoso", "contoso.shopping.basket.updated.v1")) - .Run(HttpMethod.Put, $"/api/baskets/{v.Id}/apply-discount/save10") + .Run(HttpMethod.Put, $"/api/baskets/{v.Id}/apply-discount/SAVE10") .AssertOK() .Value!; @@ -80,6 +80,64 @@ public void Basket_ApplyDiscount_Success() .AssertValue(v); } + [Test] + public void Basket_Update_ShippingAddress() + { + var v = Test.Http() + .ExpectSqlServerOutboxEvents() + .Run(HttpMethod.Post, $"/api/customers/{1004.ToGuid()}/baskets") + .AssertCreated() + .Value!; + + v.ShippingAddress.Should().BeNull(); + + var address = new Address + { + Street1= "123 Main St", + City = "Anytown", + State = "CA", + PostCode = "12345" + }; + + v = Test.Http() + .ExpectChangeLogUpdated() + .ExpectSqlServerOutboxEvents(e => e.AssertWithValue("contoso", "contoso.shopping.basket.updated.v1")) + .Run(HttpMethod.Put, $"/api/baskets/{v.Id}/shipping-address", address) + .AssertOK() + .Value!; + + v.ShippingAddress.Should().NotBeNull(); + v.ShippingAddress.Should().BeEquivalentTo(address); + + Test.Http() + .Run(HttpMethod.Get, $"/api/baskets/{v.Id}") + .AssertOK() + .AssertValue(v); + + /* Reset address back to null. */ + v = Test.Http() + .ExpectChangeLogUpdated() + .ExpectSqlServerOutboxEvents(e => e.AssertWithValue("contoso", "contoso.shopping.basket.updated.v1")) + .Run(HttpMethod.Put, $"/api/baskets/{v.Id}/shipping-address") + .AssertOK() + .Value!; + + v.ShippingAddress.Should().BeNull(); + } + + [Test] + public void Basket_Checkout_NoShippingAddress() + { + Test.Http() + .Run(HttpMethod.Post, $"/api/baskets/{3006.ToGuid()}/checkout") + .AssertBadRequest() + .AssertProblemDetails(p => + { + p.Title.Should().Be("A basket must have a shipping address to be checked out."); + p.ErrorCode.Should().Be("missing-shipping-address"); + }); + } + [Test] public void Basket_Checkout_Success() { @@ -176,4 +234,4 @@ public void Basket_Checkout_Save_Failure() v.StatusCode.Should().Be(BasketStatus.Active); } -} \ No newline at end of file +} diff --git a/samples/tests/Contoso.Shopping.Test.Api/MutateTests.BasketItem.cs b/samples/tests/Contoso.Shopping.Test.Api/MutateTests.BasketItem.cs index 09f3e8e4..d4277cce 100644 --- a/samples/tests/Contoso.Shopping.Test.Api/MutateTests.BasketItem.cs +++ b/samples/tests/Contoso.Shopping.Test.Api/MutateTests.BasketItem.cs @@ -61,7 +61,7 @@ public void Basket_Item_Update_Scale_Error() .Run(HttpMethod.Put, $"/api/baskets/{3005.ToGuid()}/items/{4008.ToGuid()}", item) .AssertBadRequest() .AssertContentTypeProblemJson() - .AssertProblemDetailsTitle("Quantity decimal places exceed the specified unit-of-measure (Pair) configuration of 0."); + .AssertProblemDetails(p => p.Title.Should().Be("Quantity decimal places exceed the specified unit-of-measure (Pair) configuration of 0.")); } [Test] @@ -130,4 +130,4 @@ public void Basket_Item_Delete() .AssertOK() .AssertValue(v2); } -} \ No newline at end of file +} diff --git a/samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Get_Found.res.json b/samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Get_Found.res.json index 531be9c3..d67fbd37 100644 --- a/samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Get_Found.res.json +++ b/samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Get_Found.res.json @@ -33,9 +33,15 @@ "discountAmount": 1492.35, "total": 8456.6500 }, + "shippingAddress": { + "street1": "123 Main St", + "city": "Anytown", + "postCode": "12345", + "state": "CA" + }, "changeLog": { "createdBy": "DOMAIN-CORP\\eric.sibly", "createdOn": "2026-02-21T17:53:32.8347127\u002B00:00" }, "etag": "AAAAAAAACDA=" -} \ No newline at end of file +} diff --git a/samples/tests/Contoso.Shopping.Test.Common/Data/mutate-data.seed.yaml b/samples/tests/Contoso.Shopping.Test.Common/Data/mutate-data.seed.yaml index 7aa90a8d..1850548d 100644 --- a/samples/tests/Contoso.Shopping.Test.Common/Data/mutate-data.seed.yaml +++ b/samples/tests/Contoso.Shopping.Test.Common/Data/mutate-data.seed.yaml @@ -38,8 +38,8 @@ Shopping: - { BasketId: ^3004, CustomerId: ^1003, BasketStatusCode: B, SubTotal: 98, Total: 98 } - { BasketId: ^3005, CustomerId: ^1003, BasketStatusCode: A, SubTotal: 98, Total: 98 } - { BasketId: ^3006, CustomerId: ^1003, BasketStatusCode: A, SubTotal: 98, Total: 98 } - - { BasketId: ^3007, CustomerId: ^1003, BasketStatusCode: A, SubTotal: 98, Total: 98 } - - { BasketId: ^3008, CustomerId: ^1003, BasketStatusCode: A, SubTotal: 98, Total: 98 } + - { BasketId: ^3007, CustomerId: ^1003, BasketStatusCode: A, SubTotal: 98, Total: 98, ShippingAddressJson: { street1: "123 Main St", city: "Anytown", state: "CA", postCode: "12345" } } + - { BasketId: ^3008, CustomerId: ^1003, BasketStatusCode: A, SubTotal: 98, Total: 98, ShippingAddressJson: { street1: "123 Main St", city: "Anytown", state: "CA", postCode: "12345" } } - BasketItem: - { BasketItemId: ^4001, BasketId: ^3006, ProductId: ^27, Sku: ONEUP-COMPOSITE-PEDALS, Text: OneUp Composite Pedals, UnitOfMeasureCode: PR, Quantity: 2, UnitPrice: 49 } - { BasketItemId: ^4002, BasketId: ^3001, ProductId: ^1, Sku: YETI-ASR-C2-2025, Text: Yeti ASR C2, UnitOfMeasureCode: EA, Quantity: 2, UnitPrice: 129 } diff --git a/samples/tests/Contoso.Shopping.Test.Common/Data/read-data.seed.yaml b/samples/tests/Contoso.Shopping.Test.Common/Data/read-data.seed.yaml index 7aa90a8d..7c66157e 100644 --- a/samples/tests/Contoso.Shopping.Test.Common/Data/read-data.seed.yaml +++ b/samples/tests/Contoso.Shopping.Test.Common/Data/read-data.seed.yaml @@ -33,7 +33,7 @@ Shopping: - { ProductId: ^32, Sku: LABOR, Text: Labor, UnitOfMeasureCode: HR, Price: 80.00, IsNonStocked: true } - Basket: - { BasketId: ^3001, CustomerId: ^1001, BasketStatusCode: E, SubTotal: 0, Total: 0 } - - { BasketId: ^3002, CustomerId: ^1002, BasketStatusCode: C, SubTotal: 9679, DiscountCouponCode: XMAS2025, DiscountAmount: 145.19, Total: 9533.81 } + - { BasketId: ^3002, CustomerId: ^1002, BasketStatusCode: C, SubTotal: 9679, DiscountCouponCode: XMAS2025, DiscountAmount: 145.19, Total: 9533.81, ShippingAddressJson: { street1: "123 Main St", city: "Anytown", state: "CA", postCode: "12345" } } - { BasketId: ^3003, CustomerId: ^1001, BasketStatusCode: A, SubTotal: 9999, Total: 9999 } - { BasketId: ^3004, CustomerId: ^1003, BasketStatusCode: B, SubTotal: 98, Total: 98 } - { BasketId: ^3005, CustomerId: ^1003, BasketStatusCode: A, SubTotal: 98, Total: 98 } diff --git a/samples/tests/Contoso.Shopping.Test.Unit/Domains/BasketTests.cs b/samples/tests/Contoso.Shopping.Test.Unit/Domains/BasketTests.cs index 40e40f35..52a355e2 100644 --- a/samples/tests/Contoso.Shopping.Test.Unit/Domains/BasketTests.cs +++ b/samples/tests/Contoso.Shopping.Test.Unit/Domains/BasketTests.cs @@ -6,7 +6,7 @@ public class BasketTests : WithGenericTester public void Basket_ApplyDiscount_Success() => Test.Scoped(test => { // Arrange: Create a basket with an item. - var basket = Domain.Basket.CreateFrom("basket-id", "customer-id", BasketStatus.Active, null, + var basket = Domain.Basket.CreateFrom("basket-id", "customer-id", BasketStatus.Active, null, null, [Domain.BasketItem.CreateFrom("item-id", "product-id", "sku", "text", new Domain.ValueObjects.ItemPricing { UnitOfMeasure = "EA", Quantity = 1, UnitPrice = 100m }, null)], null, null); @@ -22,7 +22,7 @@ public void Basket_ApplyDiscount_Success() => Test.Scoped(test => public void Basket_ApplyDiscount_Invalid_Status() => Test.Scoped(test => { // Arrange: Create a basket with an item. - var basket = Domain.Basket.CreateFrom("basket-id", "customer-id", BasketStatus.CheckedOut, null, + var basket = Domain.Basket.CreateFrom("basket-id", "customer-id", BasketStatus.CheckedOut, null, null, [Domain.BasketItem.CreateFrom("item-id", "product-id", "sku", "text", new Domain.ValueObjects.ItemPricing { UnitOfMeasure = "EA", Quantity = 1, UnitPrice = 100m }, null)], null, null); @@ -35,4 +35,63 @@ public void Basket_ApplyDiscount_Invalid_Status() => Test.Scoped(test => basket.DiscountAmount.Should().Be(0m); basket.Total.Should().Be(100m); }); + + [Test] + public void Basket_UpdateShippingAddress_Success() => Test.Scoped(test => + { + // Arrange: Create a basket with no shipping address. + var basket = Domain.Basket.CreateFrom("basket-id", "customer-id", BasketStatus.Active, null, null, [], null, null); + var address = new Domain.ValueObjects.Address { Street1 = "1 Main St", City = "Sydney", PostCode = "2000", State = "NSW" }; + + // Act: Set a shipping address. + basket.UpdateShippingAddress(address); + + // Assert: Verify the address is set and the basket is marked as modified. + basket.ShippingAddress.Should().Be(address); + basket.HasChanges.Should().BeTrue(); + }); + + [Test] + public void Basket_UpdateShippingAddress_Clear() => Test.Scoped(test => + { + // Arrange: Create a basket with an existing shipping address. + var existingAddress = new Domain.ValueObjects.Address { Street1 = "1 Main St", City = "Sydney", PostCode = "2000", State = "NSW" }; + var basket = Domain.Basket.CreateFrom("basket-id", "customer-id", BasketStatus.Active, null, existingAddress, [], null, null); + + // Act: Clear the shipping address. + basket.UpdateShippingAddress(null); + + // Assert: Verify the address is cleared and the basket is marked as modified. + basket.ShippingAddress.Should().BeNull(); + basket.HasChanges.Should().BeTrue(); + }); + + [Test] + public void Basket_UpdateShippingAddress_NoChange() => Test.Scoped(test => + { + // Arrange: Create a basket with an existing shipping address. + var address = new Domain.ValueObjects.Address { Street1 = "1 Main St", City = "Sydney", PostCode = "2000", State = "NSW" }; + var basket = Domain.Basket.CreateFrom("basket-id", "customer-id", BasketStatus.Active, null, address, [], null, null); + + // Act: Update with a structurally equal address β€” record value equality prevents mutation. + basket.UpdateShippingAddress(new Domain.ValueObjects.Address { Street1 = "1 Main St", City = "Sydney", PostCode = "2000", State = "NSW" }); + + // Assert: Verify no modification occurred. + basket.ShippingAddress.Should().Be(address); + basket.HasChanges.Should().BeFalse(); + }); + + [Test] + public void Basket_UpdateShippingAddress_Invalid_Status() => Test.Scoped(test => + { + // Arrange: Create a checked-out basket. + var basket = Domain.Basket.CreateFrom("basket-id", "customer-id", BasketStatus.CheckedOut, null, null, [], null, null); + + // Act: Attempt to set a shipping address. + Action act = () => basket.UpdateShippingAddress(new Domain.ValueObjects.Address { Street1 = "1 Main St", City = "Sydney", PostCode = "2000", State = "NSW" }); + + // Assert: Verify that the update is rejected and the address remains unset. + act.Should().Throw().WithMessage("Basket has a status of 'Checked-out' and as such cannot be modified."); + basket.ShippingAddress.Should().BeNull(); + }); } diff --git a/samples/tests/Contoso.Shopping.Test.Unit/Validators/AddressValidatorTests.cs b/samples/tests/Contoso.Shopping.Test.Unit/Validators/AddressValidatorTests.cs new file mode 100644 index 00000000..e1fc8607 --- /dev/null +++ b/samples/tests/Contoso.Shopping.Test.Unit/Validators/AddressValidatorTests.cs @@ -0,0 +1,61 @@ +namespace Contoso.Shopping.Test.Unit.Validators; + +public class AddressValidatorTests : WithGenericTester +{ + [Test] + public void Address_Validate_Empty_AllRequired() => Test.Scoped(test => + { + var a = new Address(); + AddressValidator.Default.AssertErrors(a, + ("street1", "Street1 is required."), + ("city", "City is required."), + ("postCode", "Post code is required."), + ("state", "State is required.")); + }); + + [Test] + public void Address_Validate_Street1_Required() => Test.Scoped(test => + { + var a = new Address { City = "Sydney", PostCode = "2000", State = "NSW" }; + AddressValidator.Default.AssertErrors(a, + ("street1", "Street1 is required.")); + }); + + [Test] + public void Address_Validate_City_Required() => Test.Scoped(test => + { + var a = new Address { Street1 = "1 Main St", PostCode = "2000", State = "NSW" }; + AddressValidator.Default.AssertErrors(a, + ("city", "City is required.")); + }); + + [Test] + public void Address_Validate_PostCode_Required() => Test.Scoped(test => + { + var a = new Address { Street1 = "1 Main St", City = "Sydney", State = "NSW" }; + AddressValidator.Default.AssertErrors(a, + ("postCode", "Post code is required.")); + }); + + [Test] + public void Address_Validate_State_Required() => Test.Scoped(test => + { + var a = new Address { Street1 = "1 Main St", City = "Sydney", PostCode = "2000" }; + AddressValidator.Default.AssertErrors(a, + ("state", "State is required.")); + }); + + [Test] + public void Address_Validate_Street2_Optional() => Test.Scoped(test => + { + var a = new Address { Street1 = "1 Main St", City = "Sydney", PostCode = "2000", State = "NSW" }; + AddressValidator.Default.AssertSuccess(a); + }); + + [Test] + public void Address_Validate_Success() => Test.Scoped(test => + { + var a = new Address { Street1 = "1 Main St", Street2 = "Unit 5", City = "Sydney", PostCode = "2000", State = "NSW" }; + AddressValidator.Default.AssertSuccess(a); + }); +} diff --git a/src/CoreEx.AspNetCore/Abstractions/IWebApiRequestOptions.cs b/src/CoreEx.AspNetCore/Abstractions/IWebApiRequestOptions.cs index 141debd7..36f9adbd 100644 --- a/src/CoreEx.AspNetCore/Abstractions/IWebApiRequestOptions.cs +++ b/src/CoreEx.AspNetCore/Abstractions/IWebApiRequestOptions.cs @@ -7,13 +7,18 @@ namespace CoreEx.AspNetCore.Abstractions; public interface IWebApiRequestOptions { /// - /// Gets the request value or . + /// Indicates whether to automatically the request body value on first access. + /// + bool AutoCleanValue { get; set; } + + /// + /// Gets the request value (from body) or . /// TRequest? ValueOrDefault { get; } /// - /// Gets the request value where not ; otherwise, results in a corresponding (see ). + /// Gets the request value (from body) where not ; otherwise, results in a corresponding . /// [NotNull] TRequest Value { get; } -} \ No newline at end of file +} diff --git a/src/CoreEx.AspNetCore/Abstractions/WebApi.cs b/src/CoreEx.AspNetCore/Abstractions/WebApi.cs index a10cf847..8e6cc14d 100644 --- a/src/CoreEx.AspNetCore/Abstractions/WebApi.cs +++ b/src/CoreEx.AspNetCore/Abstractions/WebApi.cs @@ -12,10 +12,6 @@ namespace CoreEx.AspNetCore.Abstractions; public abstract partial class WebApi(WebApiInvoker invoker, JsonSerializerOptions? jsonSerializerOptions = null, ILogger>? logger = null, ExecutionContext? executionContext = null) : WebApiBase(jsonSerializerOptions, logger, executionContext) { - private const string _requestBodyErrorType = "request-body"; - private static readonly LText _requestBodyRequiredText = new("CoreEx.AspNetCore.WebApi.RequestBodyRequired", "Request body is required."); - private static readonly LText _requestBodyInvalidText = new("CoreEx.AspNetCore.WebApi.RequestBodyInvalid", "Request body is invalid: {0}"); - private readonly WebApiInvoker _invoker = invoker.ThrowIfNull(); /// @@ -157,8 +153,8 @@ private WebApiResult CreateContentForValue(WebApiOptionsBase options /// The corresponding . protected async Task> GetRequestValueAsync(HttpRequest request, CancellationToken cancellationToken) { - if (request.ContentLength == 0) - return new ValidationException(_requestBodyRequiredText).WithErrorType(_requestBodyErrorType); + if (request.ContentLength is null || request.ContentLength == 0) + return Result.Ok(default); try { @@ -166,7 +162,7 @@ private WebApiResult CreateContentForValue(WebApiOptionsBase options } catch (Exception ex) { - return new ValidationException(_requestBodyInvalidText.WithArgs(ex.Message)).WithErrorType(_requestBodyErrorType); + return new ValidationException(RequestBodyInvalidText.WithArgs(ex.Message)).WithErrorType(RequestBodyErrorType); } } @@ -217,4 +213,4 @@ private WebApiResult CreateContentForValue(WebApiOptionsBase options _ => new UnexpectedInternalException { StatusCode = statusCode }, }; } -} \ No newline at end of file +} diff --git a/src/CoreEx.AspNetCore/Abstractions/WebApiBase.cs b/src/CoreEx.AspNetCore/Abstractions/WebApiBase.cs index c5e81c09..1244cbd7 100644 --- a/src/CoreEx.AspNetCore/Abstractions/WebApiBase.cs +++ b/src/CoreEx.AspNetCore/Abstractions/WebApiBase.cs @@ -10,6 +10,21 @@ public abstract class WebApiBase(JsonSerializerOptions? jsonSerializerOptions = { private JsonMergePatch? _jsonMergePatch; + /// + /// Gets the request body error type. + /// + internal const string RequestBodyErrorType = "request-body"; + + /// + /// Gets the request body required text. + /// + internal static readonly LText RequestBodyRequiredText = new("CoreEx.AspNetCore.WebApi.RequestBodyRequired", "Request body is required."); + + /// + /// Gets the request body invalid text. + /// + internal static readonly LText RequestBodyInvalidText = new("CoreEx.AspNetCore.WebApi.RequestBodyInvalid", "Request body is invalid: {0}"); + /// /// The configuration name to indicate whether to include exception details in the . /// @@ -71,4 +86,4 @@ protected static void CheckRequest([NotNull] HttpRequest request, string[] expec throw new ArgumentException($"HttpRequest.Method is '{request.Method}'; must be '{string.Join(", ", expectedmethods)}' to use {memberName ?? "??"}.", nameof(request)); } -} \ No newline at end of file +} diff --git a/src/CoreEx.AspNetCore/WebApiRequestOptions.cs b/src/CoreEx.AspNetCore/WebApiRequestOptions.cs index beecdf4e..b6df85d1 100644 --- a/src/CoreEx.AspNetCore/WebApiRequestOptions.cs +++ b/src/CoreEx.AspNetCore/WebApiRequestOptions.cs @@ -8,6 +8,9 @@ public sealed class WebApiRequestOptions : WebApiOptionsBase, IWebApiR { private static readonly LText _concurrencyMessage = new($"{typeof(WebApiOptionsBase).FullName}.IfMatchRequired" , "A concurrency error occurred; an ETag is required either as an IF-MATCH header (preferred) or specified within the request body (where supported)."); + private TRequest? _valueOrDefault; + private bool _hasBeenCleaned; + /// /// Initializes a new instance of the class. /// @@ -15,7 +18,7 @@ public sealed class WebApiRequestOptions : WebApiOptionsBase, IWebApiR /// The deserialized request value. public WebApiRequestOptions(HttpRequest httpRequest, TRequest? value) : base(httpRequest) { - ValueOrDefault = value; + _valueOrDefault = value; // Override the ETag where specified as a request IF-MATCH header. if (value is not null && ETag is not null && value is IETag etag) @@ -29,7 +32,7 @@ public WebApiRequestOptions(HttpRequest httpRequest, TRequest? value) : base(htt /// The deserialized request value. public WebApiRequestOptions(WebApiOptionsBase options, TRequest? value) : base(options) { - ValueOrDefault = value; + _valueOrDefault = value; // Override the ETag where specified as a request IF-MATCH header. if (value is not null && ETag is not null && value is IETag etag) @@ -37,11 +40,29 @@ public WebApiRequestOptions(WebApiOptionsBase options, TRequest? value) : base(o } /// - public TRequest? ValueOrDefault { get; } + /// Defaults to . + public bool AutoCleanValue { get; set; } = true; + + /// + public TRequest? ValueOrDefault + { + get + { + if (AutoCleanValue && !_hasBeenCleaned) + { + _valueOrDefault = Metadata.RuntimeMetadata.Clean(_valueOrDefault); + _hasBeenCleaned = true; + } + + return _valueOrDefault; + } + } /// [NotNull] - public TRequest Value => ValueOrDefault.Required(); + public TRequest Value => (Comparer.Default.Compare(ValueOrDefault, default!) == 0) + ? throw new ValidationException(WebApiBase.RequestBodyRequiredText).WithErrorType(WebApiBase.RequestBodyErrorType) + : ValueOrDefault!; /// protected internal override Result Verify() => VerifyRequest(this, ValueOrDefault).Then(() => base.Verify()); @@ -63,4 +84,4 @@ internal static Result VerifyRequest(TOptions options, TRequest? value return Result.Success; } -} \ No newline at end of file +} diff --git a/src/CoreEx.AspNetCore/WebApiRequestResponseOptions.cs b/src/CoreEx.AspNetCore/WebApiRequestResponseOptions.cs index f2d8a775..35a16c77 100644 --- a/src/CoreEx.AspNetCore/WebApiRequestResponseOptions.cs +++ b/src/CoreEx.AspNetCore/WebApiRequestResponseOptions.cs @@ -7,6 +7,8 @@ namespace CoreEx.AspNetCore; /// The response . public sealed class WebApiRequestResponseOptions : WebApiOptionsBase, IWebApiRequestOptions, IWebApiResponseOptions { + private TRequest? _valueOrDefault; + private bool _hasBeenCleaned; private Func? _locationUri; /// @@ -16,7 +18,7 @@ public sealed class WebApiRequestResponseOptions : WebApiOp /// The deserialized request value. public WebApiRequestResponseOptions(HttpRequest httpRequest, TRequest? value) : base(httpRequest) { - ValueOrDefault = value; + _valueOrDefault = value; // Override the ETag where specified as a request IF-MATCH header. if (value is not null && ETag is not null && value is IETag etag) @@ -30,7 +32,7 @@ public WebApiRequestResponseOptions(HttpRequest httpRequest, TRequest? value) : /// The deserialized request value. public WebApiRequestResponseOptions(WebApiOptionsBase options, TRequest? value) : base(options) { - ValueOrDefault = value; + _valueOrDefault = value; // Override the ETag where specified as a request IF-MATCH header. if (value is not null && ETag is not null && value is IETag etag) @@ -42,11 +44,29 @@ public WebApiRequestResponseOptions(WebApiOptionsBase options, TRequest? value) } /// - public TRequest? ValueOrDefault { get; } + /// Defaults to . + public bool AutoCleanValue { get; set; } = true; + + /// + public TRequest? ValueOrDefault + { + get + { + if (AutoCleanValue && !_hasBeenCleaned) + { + _valueOrDefault = Metadata.RuntimeMetadata.Clean(_valueOrDefault); + _hasBeenCleaned = true; + } + + return _valueOrDefault; + } + } /// [NotNull] - public TRequest Value => ValueOrDefault.Required(); + public TRequest Value => (Comparer.Default.Compare(ValueOrDefault, default!) == 0) + ? throw new ValidationException(WebApiBase.RequestBodyRequiredText).WithErrorType(WebApiBase.RequestBodyErrorType) + : ValueOrDefault!; /// Func? IWebApiResponseOptions.LocationUri => _locationUri; @@ -81,4 +101,4 @@ public WebApiRequestResponseOptions WithLocationUri(Func protected internal override Result Verify() => WebApiRequestOptions.VerifyRequest(this, ValueOrDefault).Then(() => base.Verify()); -} \ No newline at end of file +} diff --git a/src/CoreEx.Database/Templates/EfModelBuilder_cs.hbs b/src/CoreEx.Database/Templates/EfModelBuilder_cs.hbs index 8385c24b..4b89147e 100644 --- a/src/CoreEx.Database/Templates/EfModelBuilder_cs.hbs +++ b/src/CoreEx.Database/Templates/EfModelBuilder_cs.hbs @@ -25,51 +25,51 @@ public partial class {{Domain}}DbContext {{#if HasPrimaryKeyIdentifier}} e.HasKey(p => p.Id); e.Property(p => p.Id).HasColumnName("{{PrimaryKeyIdentifierColumn.Name}}").HasColumnType("{{PrimaryKeyIdentifierColumn.DbColumn.SqlType2}}"){{#if PrimaryKeyIdentifierColumn.DbColumn.IsComputed}}.ValueGeneratedOnAddOrUpdate(){{/if}}; - {{#each StandardColumns}} - e.Property(p => p.{{Property}}).HasColumnName("{{Name}}").HasColumnType("{{DbColumn.SqlType2}}"){{#if DbColumn.IsComputed}}.ValueGeneratedOnAddOrUpdate(){{/if}}{{#if DbColumn.IsRowVersionColumn}}.IsRowVersion(){{/if}}{{#if DbColumn.IsCreatedAudit}}.ValueGeneratedOnUpdate(){{/if}}{{#if DbColumn.IsUpdatedAudit}}.ValueGeneratedOnAdd(){{/if}}{{#if ValueConverter}}.HasConversion({{ValueConverter}}){{/if}}; + {{else}} + {{#ifne PrimaryKeyColumns.Count 0}} + e.HasKey({{#ifeq PrimaryKeyColumns.Count 1}}p => p.{{#each PrimaryKeyColumns}}{{Property}}{{/each}}{{else}}{{#each PrimaryKeyColumns}}"{{Property}}"{{#unless @last}}, {{/unless}}{{/each}}{{/ifeq}}); + {{/ifne}} + {{#each Columns}} + e.Property(p => p.{{Property}}).HasColumnName("{{Name}}").HasColumnType("{{DbColumn.SqlType2}}"){{#if DbColumn.IsComputed}}.ValueGeneratedOnAddOrUpdate(){{/if}}{{#if DbColumn.IsRowVersionColumn}}.IsRowVersion(){{/if}}{{#if DbColumn.IsCreatedAudit}}.ValueGeneratedOnUpdate(){{/if}}{{#if DbColumn.IsUpdatedAudit}}.ValueGeneratedOnAdd(){{/if}}{{#if ValueConverter}}.HasConversion({{ValueConverter}}){{else}}{{#if DbColumn.IsJsonContent}}{{#ifne Type 'string' 'string?'}}.HasConversion(TypeToJsonStringEfConverter<{{Type}}>.Default){{/ifne}}{{/if}}{{/if}}; {{/each}} - {{#if HasColumnCreatedBy}} + {{/if}} + {{#each StandardColumns}} + e.Property(p => p.{{Property}}).HasColumnName("{{Name}}").HasColumnType("{{DbColumn.SqlType2}}"){{#if DbColumn.IsComputed}}.ValueGeneratedOnAddOrUpdate(){{/if}}{{#if DbColumn.IsRowVersionColumn}}.IsRowVersion(){{/if}}{{#if DbColumn.IsCreatedAudit}}.ValueGeneratedOnUpdate(){{/if}}{{#if DbColumn.IsUpdatedAudit}}.ValueGeneratedOnAdd(){{/if}}{{#if ValueConverter}}.HasConversion({{ValueConverter}}){{else}}{{#if DbColumn.IsJsonContent}}{{#ifne Type 'string' 'string?'}}.HasConversion(TypeToJsonStringEfConverter<{{Type}}>.Default){{/ifne}}{{/if}}{{/if}}; + {{/each}} + {{#if HasColumnCreatedBy}} e.Property(p => p.CreatedBy).HasColumnName("{{ColumnCreatedBy.Name}}").HasColumnType("{{ColumnCreatedBy.DbColumn.SqlType2}}"); - {{/if}} - {{#if HasColumnCreatedOn}} + {{/if}} + {{#if HasColumnCreatedOn}} e.Property(p => p.CreatedOn).HasColumnName("{{ColumnCreatedOn.Name}}").HasColumnType("{{ColumnCreatedOn.DbColumn.SqlType2}}"); - {{/if}} - {{#if HasColumnUpdatedBy}} + {{/if}} + {{#if HasColumnUpdatedBy}} e.Property(p => p.UpdatedBy).HasColumnName("{{ColumnUpdatedBy.Name}}").HasColumnType("{{ColumnUpdatedBy.DbColumn.SqlType2}}"); - {{/if}} - {{#if HasColumnUpdatedOn}} + {{/if}} + {{#if HasColumnUpdatedOn}} e.Property(p => p.UpdatedOn).HasColumnName("{{ColumnUpdatedOn.Name}}").HasColumnType("{{ColumnUpdatedOn.DbColumn.SqlType2}}"); - {{/if}} - {{#if HasColumnRowVersion}} + {{/if}} + {{#if HasColumnRowVersion}} e.Property(p => p.ETag).HasColumnName("{{ColumnRowVersion.Name}}").HasColumnType("{{ColumnRowVersion.DbColumn.SqlType2}}").IsRowVersion().HasConversion(ValueConverterBridge.Create(BaseDatabase.RowVersionConverter)); - {{/if}} - {{#if HasColumnTenantId}} + {{/if}} + {{#if HasColumnTenantId}} e.Property(p => p.TenantId).HasColumnName("{{ColumnTenantId.Name}}").HasColumnType("{{ColumnTenantId.DbColumn.SqlType2}}"); - {{/if}} - {{#if HasColumnIsDeleted}} + {{/if}} + {{#if HasColumnIsDeleted}} e.Property(p => p.IsDeleted).HasColumnName("{{ColumnIsDeleted.Name}}").HasColumnType("{{ColumnIsDeleted.DbColumn.SqlType2}}"); - {{/if}} - {{#if DbTable.IsRefData}} - {{#ifval RefData.TextProperty RefData.DescriptionProperty RefData.SortOrderProperty RefData.IsActiveProperty RefData.StartsOnProperty RefData.EndsOnProperty}} - {{else}} + {{/if}} + {{#if DbTable.IsRefData}} + {{#ifval RefData.TextProperty RefData.DescriptionProperty RefData.SortOrderProperty RefData.IsActiveProperty RefData.StartsOnProperty RefData.EndsOnProperty}} + {{else}} e{{#ifnull RefData.TextProperty}}.Ignore(p => p.Text){{/ifnull}}{{#ifnull RefData.DescriptionProperty}}.Ignore(p => p.Description){{/ifnull}}{{#ifnull RefData.SortOrderProperty}}.Ignore(p => p.SortOrder){{/ifnull}}{{#ifnull RefData.IsActiveProperty}}.Ignore(p => p.IsActive){{/ifnull}}{{#ifnull RefData.StartsOnProperty}}.Ignore(p => p.StartsOn){{/ifnull}}{{#ifnull RefData.EndsOnProperty}}.Ignore(p => p.EndsOn){{/ifnull}}; - {{/ifval}} - {{/if}} - {{#ifval ColumnCreatedBy ColumnCreatedOn ColumnUpdatedBy ColumnUpdatedOn ColumnRowVersion}} - {{else}} - e{{#unless HasColumnCreatedBy}}.Ignore(p => p.CreatedBy){{/unless}}{{#unless HasColumnCreatedOn}}.Ignore(p => p.CreatedOn){{/unless}}{{#unless HasColumnUpdatedBy}}.Ignore(p => p.UpdatedBy){{/unless}}{{#unless HasColumnUpdatedOn}}.Ignore(p => p.UpdatedOn){{/unless}}{{#unless HasColumnRowVersion}}.Ignore(p => p.ETag){{/unless}}; - {{/ifval}} - {{else}} - {{#ifne PrimaryKeyColumns.Count 0}} - e.HasKey({{#ifeq PrimaryKeyColumns.Count 1}}p => p.{{#each PrimaryKeyColumns}}{{Property}}{{/each}}{{else}}{{#each PrimaryKeyColumns}}"{{Property}}"{{#unless @last}}, {{/unless}}{{/each}}{{/ifeq}}); - {{/ifne}} - {{#each Columns}} - e.Property(p => p.{{Property}}).HasColumnName("{{Name}}").HasColumnType("{{DbColumn.SqlType2}}"){{#if DbColumn.IsComputed}}.ValueGeneratedOnAddOrUpdate(){{/if}}{{#if DbColumn.IsRowVersionColumn}}.IsRowVersion(){{/if}}{{#if DbColumn.IsCreatedAudit}}.ValueGeneratedOnUpdate(){{/if}}{{#if DbColumn.IsUpdatedAudit}}.ValueGeneratedOnAdd(){{/if}}{{#if ValueConverter}}.HasConversion({{ValueConverter}}){{/if}}; - {{/each}} + {{/ifval}} {{/if}} + {{#ifval ColumnCreatedBy ColumnCreatedOn ColumnUpdatedBy ColumnUpdatedOn ColumnRowVersion}} + {{else}} + e{{#unless HasColumnCreatedBy}}.Ignore(p => p.CreatedBy){{/unless}}{{#unless HasColumnCreatedOn}}.Ignore(p => p.CreatedOn){{/unless}}{{#unless HasColumnUpdatedBy}}.Ignore(p => p.UpdatedBy){{/unless}}{{#unless HasColumnUpdatedOn}}.Ignore(p => p.UpdatedOn){{/unless}}{{#unless HasColumnRowVersion}}.Ignore(p => p.ETag){{/unless}}; + {{/ifval}} }); {{/each}} } } -#nullable restore \ No newline at end of file +#nullable restore diff --git a/src/CoreEx.EntityFrameworkCore/Converters/JsonElementStringEfConverter.cs b/src/CoreEx.EntityFrameworkCore/Converters/JsonElementStringEfConverter.cs index 0538c628..9854a116 100644 --- a/src/CoreEx.EntityFrameworkCore/Converters/JsonElementStringEfConverter.cs +++ b/src/CoreEx.EntityFrameworkCore/Converters/JsonElementStringEfConverter.cs @@ -9,4 +9,4 @@ public sealed class JsonElementStringEfConverter() : ValueConverterBridge. /// public static JsonElementStringEfConverter Default { get; } = new(); -} \ No newline at end of file +} diff --git a/src/CoreEx.EntityFrameworkCore/Converters/TypeToJsonStringEfConverter.cs b/src/CoreEx.EntityFrameworkCore/Converters/TypeToJsonStringEfConverter.cs new file mode 100644 index 00000000..2f48c82d --- /dev/null +++ b/src/CoreEx.EntityFrameworkCore/Converters/TypeToJsonStringEfConverter.cs @@ -0,0 +1,13 @@ +namespace CoreEx.EntityFrameworkCore.Converters; + +/// +/// Provides a and JSON entity-framework (EF) . +/// +/// +public sealed class TypeToJsonStringEfConverter() : ValueConverterBridge(Mapping.Converters.TypeToJsonStringConverter.Default) +{ + /// + /// Gets the default . + /// + public static TypeToJsonStringEfConverter Default { get; } = new(); +} diff --git a/src/CoreEx.RefData/Abstractions/ReferenceDataCollectionCore.cs b/src/CoreEx.RefData/Abstractions/ReferenceDataCollectionCore.cs index 10a27954..6c4ad7e0 100644 --- a/src/CoreEx.RefData/Abstractions/ReferenceDataCollectionCore.cs +++ b/src/CoreEx.RefData/Abstractions/ReferenceDataCollectionCore.cs @@ -23,11 +23,11 @@ public abstract class ReferenceDataCollectionCore : IReferenceDataCol /// Initializes a new instance of the class. /// /// The default for the collection. Defaults to . - /// The for comparisons. Defaults to . + /// The for comparisons. Defaults to as casing often matters. internal ReferenceDataCollectionCore(ReferenceDataSortOrder sortOrder = ReferenceDataSortOrder.SortOrder, StringComparer? codeComparer = null) { SortOrder = sortOrder; - _rdcCode = new ConcurrentDictionary(codeComparer ?? StringComparer.OrdinalIgnoreCase); + _rdcCode = new ConcurrentDictionary(codeComparer ?? StringComparer.Ordinal); } /// @@ -322,4 +322,4 @@ public IEnumerator GetEnumerator() public void CopyTo(Array array, int index) => throw new NotSupportedException(); #endregion -} \ No newline at end of file +} diff --git a/src/CoreEx.RefData/ReferenceDataCollectionT.cs b/src/CoreEx.RefData/ReferenceDataCollectionT.cs index dad219e7..2d083f6b 100644 --- a/src/CoreEx.RefData/ReferenceDataCollectionT.cs +++ b/src/CoreEx.RefData/ReferenceDataCollectionT.cs @@ -12,11 +12,11 @@ public class ReferenceDataCollection : ReferenceDataCollectionCore class. /// /// The . Defaults to . - /// The for comparisons. Defaults to . + /// The for comparisons. Defaults to as casing often matters. public ReferenceDataCollection(ReferenceDataSortOrder sortOrder = ReferenceDataSortOrder.SortOrder, StringComparer? codeComparer = null) : base(sortOrder, codeComparer) => OnInitialization(); /// /// Provides an opportunity to extend initialization when the object is constructed. /// protected virtual void OnInitialization() { } -} \ No newline at end of file +} diff --git a/src/CoreEx.RefData/ReferenceDataCollectionT2.cs b/src/CoreEx.RefData/ReferenceDataCollectionT2.cs index b3a2b810..2cf1e493 100644 --- a/src/CoreEx.RefData/ReferenceDataCollectionT2.cs +++ b/src/CoreEx.RefData/ReferenceDataCollectionT2.cs @@ -13,11 +13,11 @@ public class ReferenceDataCollection : ReferenceDataCollectionCore class. /// /// The . Defaults to . - /// The for comparisons. Defaults to . + /// The for comparisons. Defaults to as casing often matters. public ReferenceDataCollection(ReferenceDataSortOrder sortOrder = ReferenceDataSortOrder.SortOrder, StringComparer? codeComparer = null) : base(sortOrder, codeComparer) => OnInitialization(); /// /// Provides an opportunity to extend initialization when the object is constructed. /// protected virtual void OnInitialization() { } -} \ No newline at end of file +} diff --git a/src/CoreEx.Template/content/CoreEx.Core/_Directory.Packages.props b/src/CoreEx.Template/content/CoreEx.Core/_Directory.Packages.props index c10630b6..74cdc11e 100644 --- a/src/CoreEx.Template/content/CoreEx.Core/_Directory.Packages.props +++ b/src/CoreEx.Template/content/CoreEx.Core/_Directory.Packages.props @@ -70,10 +70,10 @@ - + - + @@ -87,4 +87,4 @@ - \ No newline at end of file + diff --git a/src/CoreEx.UnitTesting/UnitTestExExtensions.Assert.cs b/src/CoreEx.UnitTesting/UnitTestExExtensions.Assert.cs index ae9c6f3c..15961f00 100644 --- a/src/CoreEx.UnitTesting/UnitTestExExtensions.Assert.cs +++ b/src/CoreEx.UnitTesting/UnitTestExExtensions.Assert.cs @@ -5,19 +5,19 @@ namespace UnitTestEx; public static partial class UnitTestExExtensions { /// - /// Asserts that the response is a and that the matches the expected . + /// Asserts that the response is a and allows for further assertions to be performed on the instance. /// /// The . /// The . - /// The expected . + /// An optional action to perform additional assertions on the instance. /// The instance to support fluent-style method-chaining. - public static TSelf AssertProblemDetailsTitle(this TSelf assertor, string title) where TSelf : HttpResponseMessageAssertorBase + public static TSelf AssertProblemDetails(this TSelf assertor, Action? assertAction = null) where TSelf : HttpResponseMessageAssertorBase { var problemDetails = assertor.GetValue(null); if (problemDetails is null) assertor.Owner.Implementor.AssertFail("Expected ProblemDetails response to be present but nothing was returned."); - assertor.Owner.Implementor.AssertAreEqual(title, problemDetails!.Title, "ProblemDetails Title does not match expected value."); + assertAction?.Invoke(problemDetails!); return assertor; } -} \ No newline at end of file +} diff --git a/src/CoreEx.Validation/Rules/ReferenceDataRule.cs b/src/CoreEx.Validation/Rules/ReferenceDataRule.cs index bb90cf1e..a9da3592 100644 --- a/src/CoreEx.Validation/Rules/ReferenceDataRule.cs +++ b/src/CoreEx.Validation/Rules/ReferenceDataRule.cs @@ -16,4 +16,4 @@ protected override Task OnValidateAsync(PropertyContext cont return Task.CompletedTask; } -} \ No newline at end of file +} diff --git a/src/CoreEx.Validation/ValidatorT.cs b/src/CoreEx.Validation/ValidatorT.cs index 8ac8187b..4fe0c58a 100644 --- a/src/CoreEx.Validation/ValidatorT.cs +++ b/src/CoreEx.Validation/ValidatorT.cs @@ -99,4 +99,4 @@ public async Task> ValidateWithResultAsync(TEntity value, Valida var vc = await ValidateAsync(value, args, cancellationToken).ConfigureAwait(false); return vc.HasErrors ? vc.ToResult() : value; } -} \ No newline at end of file +} diff --git a/src/CoreEx/Mapping/Converters/TypeToJsonStringConverter.cs b/src/CoreEx/Mapping/Converters/TypeToJsonStringConverter.cs new file mode 100644 index 00000000..14a1b30c --- /dev/null +++ b/src/CoreEx/Mapping/Converters/TypeToJsonStringConverter.cs @@ -0,0 +1,43 @@ +namespace CoreEx.Mapping.Converters; + +/// +/// Represents a to JSON converter (uses with ). +/// +/// +public readonly struct TypeToJsonStringConverter : IConverter +{ + private static readonly ValueConverter _convertToDestination = new(s => s is null ? null : JsonSerializer.Serialize(s, JsonDefaults.SerializerOptions)); + private static readonly ValueConverter _convertToSource = new(s => s is null ? default! : JsonSerializer.Deserialize(s, JsonDefaults.SerializerOptions)!); + + /// + /// Gets or sets the default (singleton) instance. + /// + public static TypeToJsonStringConverter Default { get; set; } = new(); + + /// + /// Initializes a new instance of the struct. + /// + public TypeToJsonStringConverter() { } + + /// + /// Gets the source to destination . + /// + public IValueConverter ToDestination => _convertToDestination; + + /// + /// Gets the destination to source . + /// + public IValueConverter ToSource => _convertToSource; + + /// + public readonly object? ConvertToDestination(object? source) => ConvertToDestination((string?)source); + + /// + public readonly object? ConvertToSource(object? destination) => ConvertToSource((byte[]?)destination); + + /// + public readonly string? ConvertToDestination(T source) => ToDestination.Convert(source); + + /// + public readonly T ConvertToSource(string? destination) => ToSource.Convert(destination); +} diff --git a/src/CoreEx/Metadata/RuntimeMetadata.Clean.cs b/src/CoreEx/Metadata/RuntimeMetadata.Clean.cs index 097e6879..4c9dfbc6 100644 --- a/src/CoreEx/Metadata/RuntimeMetadata.Clean.cs +++ b/src/CoreEx/Metadata/RuntimeMetadata.Clean.cs @@ -10,7 +10,7 @@ public static partial class RuntimeMetadata /// The cleaned . /// This will walk the fully object graph, including arrays, collections, and dictionaries cleaning all mutable properties. Note that where the entry for an array, collection, or dictionary is a value type /// this is unable to be cleaned/replaced. An empty array, collection, or dictionary will be set to . - public static T? Clean(T value) + public static T? Clean(T? value) { if (value is string str) return Internal.Cast(Cleaner.Clean(str, Cleaner.DefaultStringTrim, Cleaner.DefaultStringTransform, Cleaner.DefaultStringCase)!); @@ -79,4 +79,4 @@ public static partial class RuntimeMetadata return RuntimeMetadata.IsDefault(value) ? default : value; } -} \ No newline at end of file +} diff --git a/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_MutateTestsBase.cs b/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_MutateTestsBase.cs index ba1878da..3b31cd04 100644 --- a/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_MutateTestsBase.cs +++ b/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_MutateTestsBase.cs @@ -21,7 +21,12 @@ public void Create_NoValue() Test.Http() .Run(HttpMethod.Post, $"{Route}") .AssertBadRequest() - .AssertProblemDetailsTitle("Request body is invalid: Unable to read the request as JSON because the request content type '' is not a known JSON content type."); + .AssertContentTypeProblemJson() + .AssertProblemDetails(p => + { + p.Title.Should().Be("Request body is required."); + p.ErrorType.Should().Be("request-body"); + }); } [Test] @@ -213,4 +218,4 @@ public void Delete_NotFound() .Run(HttpMethod.Delete, $"{Route}/0") .AssertNoContent(); } -} \ No newline at end of file +} diff --git a/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_QueryTestsBase.cs b/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_QueryTestsBase.cs index 641bea9c..b32d14c7 100644 --- a/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_QueryTestsBase.cs +++ b/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_QueryTestsBase.cs @@ -146,7 +146,7 @@ public void GetByQuery_Filter_EndsWith() public void GetByQuery_Filter_Gender() { var v = Test.Http() - .Run(HttpMethod.Get, Route, r => r.WithQuery("gender in ('m')")) + .Run(HttpMethod.Get, Route, r => r.WithQuery("gender in ('M')")) .AssertOK() .Value; @@ -160,7 +160,7 @@ public void GetByQuery_Filter_Gender() public void GetByQuery_OrderBy_FirstName() { var v = Test.Http() - .Run(HttpMethod.Get, Route, r => r.WithQuery(filter: "gender in ('m')", orderBy: "firstname")) + .Run(HttpMethod.Get, Route, r => r.WithQuery(filter: "gender in ('M')", orderBy: "firstname")) .AssertOK() .Value; @@ -189,4 +189,4 @@ public void GetByQuery_OrderBy_Error() .AssertContentType("application/problem+json") .AssertJsonFromResource("Person_GetByQuery_OrderByError.json", "traceid"); } -} \ No newline at end of file +} diff --git a/tests/CoreEx.RefData.Test.Unit/ReferenceDataValidationTests.cs b/tests/CoreEx.RefData.Test.Unit/ReferenceDataValidationTests.cs index 543bb02d..4f8caedb 100644 --- a/tests/CoreEx.RefData.Test.Unit/ReferenceDataValidationTests.cs +++ b/tests/CoreEx.RefData.Test.Unit/ReferenceDataValidationTests.cs @@ -15,6 +15,9 @@ public async Task Validation() vr = await ((DummyRefData)"A").Validator(c => c.IsValid()).ValidateAsync(); vr.HasErrors.Should().BeFalse(); + vr = await ((DummyRefData)"a").Validator(c => c.IsValid()).ValidateAsync(); // Configured to ignore case. + vr.HasErrors.Should().BeFalse(); + vr = await ((DummyRefData)"Z").Validator(c => c.IsValid()).ValidateAsync(); vr.HasErrors.Should().BeTrue(); vr.Messages.Should().ContainSingle().Which.Text.ToString().Should().EndWith("is invalid."); @@ -27,6 +30,16 @@ public async Task Validation() vr.HasErrors.Should().BeFalse(); } + [Test] + public async Task Validation_Casing_Matters() + { + var vr = await ((DummyRefData2)"A").Validator(c => c.IsValid()).ValidateAsync(); + vr.HasErrors.Should().BeFalse(); + vr = await ((DummyRefData2)"a").Validator(c => c.IsValid()).ValidateAsync(); + vr.HasErrors.Should().BeTrue(); + vr.Messages.Should().ContainSingle().Which.Text.ToString().Should().EndWith("is invalid."); + } + [Test] public async Task Validation_Code() { @@ -96,4 +109,4 @@ internal partial class Entity [ReferenceDataCodeCollection] public partial List? DummySids { get; set; } } -} \ No newline at end of file +} From 9ce145e7df0cb8244b50a812f91f9e0831e67448 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Fri, 31 Jul 2026 15:23:21 -0700 Subject: [PATCH 2/8] Handle cycles in RuntimeMetadata; fix Clean property filter Added thread-static visited sets to AreEqual, Clean, GetHashCode, and IsDefault for safe cycle detection in object graphs. Fixed Clean to only process writable properties. Added unit tests for circular references and IsReadOnly filter, ensuring no stack overflows and correct behavior. --- .../Metadata/RuntimeMetadata.AreEqual.cs | 75 +++++++++--- src/CoreEx/Metadata/RuntimeMetadata.Clean.cs | 94 ++++++++------- .../Metadata/RuntimeMetadata.GetHashCode.cs | 51 ++++++-- .../Metadata/RuntimeMetadata.IsDefault.cs | 43 ++++++- .../Runtime/RuntimeMetadataTests.cs | 110 ++++++++++++++++++ 5 files changed, 303 insertions(+), 70 deletions(-) diff --git a/src/CoreEx/Metadata/RuntimeMetadata.AreEqual.cs b/src/CoreEx/Metadata/RuntimeMetadata.AreEqual.cs index 65895d80..f3d717b2 100644 --- a/src/CoreEx/Metadata/RuntimeMetadata.AreEqual.cs +++ b/src/CoreEx/Metadata/RuntimeMetadata.AreEqual.cs @@ -2,6 +2,12 @@ namespace CoreEx.Metadata; public static partial class RuntimeMetadata { + // Identity hash-code pairs used to detect cycles; stored as (int, int) to avoid boxing. + // Collision probability per pair is ~1/2^64 (two independent 32-bit identity codes must both collide), + // which is negligible for typical object graphs. + [ThreadStatic] + private static HashSet<(int Left, int Right)>? _visitingForAreEqual; + /// /// Compare two values for equality. /// @@ -10,8 +16,8 @@ public static partial class RuntimeMetadata /// The right-side value. /// indicates they are equal; otherwise, . /// This improves upon the standard which for a generally only performs a reference equality. The following additional checks - /// are performed: comparison, comparison, per item and comparisons, - /// item comparisons, and nested and comparisons. This is to achieve a best attempt deep-equals where a contract-style + /// are performed: comparison, comparison, per item and comparisons, + /// item comparisons, and nested and comparisons. This is to achieve a best attempt deep-equals where a contract-style /// class (such as a ) constrains itself to simple and known types such as those described above, and/or overrides accordingly. public static bool AreEqual(T? left, T? right) { @@ -25,15 +31,33 @@ public static bool AreEqual(T? left, T? right) // Where metadata, then matchy-matchy each property one-by-one. if (left is IRuntimeMetadataCore lrm) { - var epl = lrm.GetPropertyRuntimeMetadata().GetEnumerator(); - var epr = ((IRuntimeMetadataCore)right).GetPropertyRuntimeMetadata().GetEnumerator(); - while (epl.MoveNext()) + var set = _visitingForAreEqual ??= []; + var isRoot = set.Count == 0; + try { - if (!epr.MoveNext() || !AreEqual(epl.Current.GetValue(left), epr.Current.GetValue(right))) - return false; + var pair = (RuntimeHelpers.GetHashCode((object)left!), RuntimeHelpers.GetHashCode((object)right!)); + if (!set.Add(pair)) + return true; // cycle detected β€” assume structurally equal + + var epl = lrm.GetPropertyRuntimeMetadata().GetEnumerator(); + var epr = ((IRuntimeMetadataCore)right).GetPropertyRuntimeMetadata().GetEnumerator(); + while (epl.MoveNext()) + { + if (!epr.MoveNext() || !AreEqual(epl.Current.GetValue(left), epr.Current.GetValue(right))) + { + set.Remove(pair); + return false; + } + } + + set.Remove(pair); + return true; + } + finally + { + if (isRoot) + set.Clear(); } - - return true; } // Fast-path explicit equality implementation. @@ -68,14 +92,33 @@ public static bool AreEqual(T? left, T? right) return Equals(left, right); // Must be a class so use reflection-based runtime-metadata to compare each property. - foreach (var p in GetCachedProperties(type).Values) { - if (!AreEqual(p.GetValue(left), p.GetValue(right))) - return false; + var set = _visitingForAreEqual ??= []; + var isRoot = set.Count == 0; + try + { + var pair = (RuntimeHelpers.GetHashCode((object)left!), RuntimeHelpers.GetHashCode((object)right!)); + if (!set.Add(pair)) + return true; // cycle detected β€” assume structurally equal + + foreach (var p in GetCachedProperties(type).Values) + { + if (!AreEqual(p.GetValue(left), p.GetValue(right))) + { + set.Remove(pair); + return false; + } + } + + set.Remove(pair); + // Well, if we got this far, then they must be equal - good job (https://www.youtube.com/watch?v=BSmliwh7D30). + return true; + } + finally + { + if (isRoot) set.Clear(); + } } - - // Well, if we got this far, then they must be equal - good job (https://www.youtube.com/watch?v=BSmliwh7D30). - return true; } /// @@ -142,4 +185,4 @@ private static bool IDictionaryAreEqual(IDictionary left, IDictionary right) return true; } -} \ No newline at end of file +} diff --git a/src/CoreEx/Metadata/RuntimeMetadata.Clean.cs b/src/CoreEx/Metadata/RuntimeMetadata.Clean.cs index 4c9dfbc6..e1dc82c6 100644 --- a/src/CoreEx/Metadata/RuntimeMetadata.Clean.cs +++ b/src/CoreEx/Metadata/RuntimeMetadata.Clean.cs @@ -2,6 +2,9 @@ namespace CoreEx.Metadata; public static partial class RuntimeMetadata { + [ThreadStatic] + private static HashSet? _visitingForClean; + /// /// Cleans (deep) the mutable properties of the . /// @@ -21,62 +24,73 @@ public static partial class RuntimeMetadata if (value is DateTime dt) return Internal.Cast(Cleaner.Clean(dt, Cleaner.DefaultDateTimeTransform)); - if (value is IRuntimeMetadataCore rm) + // All reference-type branches below can form cycles β€” allocate the visited set once per thread and reuse it. + var set = _visitingForClean ??= new HashSet(ReferenceEqualityComparer.Instance); + var isRoot = set.Count == 0; + try { - foreach (var p in rm.GetPropertyRuntimeMetadata().Where(x => !x.IsReadOnly)) + if (value is IRuntimeMetadataCore rm) { - p.Clean(value); - } + if (!set.Add(value)) + return value; // cycle detected β€” return as-is - return RuntimeMetadata.IsDefault(value) ? default : value; - } - - // Zero-length collections are nulled out. - if (value is ICollection ic && ic.Count == 0) - return default; + foreach (var p in rm.GetPropertyRuntimeMetadata().Where(x => !x.IsReadOnly)) + p.Clean(value); - // Clean each dictionary item (does not replace/null entry, only contents thereof); key remains unchanged. - if (value is IDictionary d) - { - foreach (DictionaryEntry de in d) - { - Clean(de.Value); + set.Remove(value); // allow re-visit from a different path (DAG support) + return RuntimeMetadata.IsDefault(value) ? default : value; } - return value; - } + // Zero-length collections are nulled out. + if (value is ICollection ic && ic.Count == 0) + return default; - // Clean each enumerable item (does not replace/null entry, only contents thereof). - if (value is IEnumerable e) - { - // Fast-path common/hot types to avoid boxing - can't clean anyway! - if (value is ICollection || value is ICollection || value is ICollection - || value is ICollection || value is ICollection || value is ICollection || value is ICollection) - return value; + // Clean each dictionary item (does not replace/null entry, only contents thereof); key remains unchanged. + if (value is IDictionary d) + { + foreach (DictionaryEntry de in d) + Clean(de.Value); - // Get the element type to determine if boxing will occur and bail if so - can't clean anyway! - var elementType = GetEnumerableElementType(value); - if (elementType is not null && elementType.IsValueType) return value; + } - foreach (var item in e) + // Clean each enumerable item (does not replace/null entry, only contents thereof). + if (value is IEnumerable e) { - Clean(item); + // Fast-path common/hot types to avoid boxing - can't clean anyway! + if (value is ICollection || value is ICollection || value is ICollection + || value is ICollection || value is ICollection || value is ICollection || value is ICollection) + return value; + + // Get the element type to determine if boxing will occur and bail if so - can't clean anyway! + var elementType = GetEnumerableElementType(value); + if (elementType is not null && elementType.IsValueType) + return value; + + foreach (var item in e) + Clean(item); + + return value; } - return value; - } + // Handle value or class types. + var type = value.GetType(); + if (type.IsValueType) + return value; // value types (other than string/DateTime, handled above) have no cleaning to perform + + if (!set.Add(value)) + return value; // cycle detected β€” return as-is - // Handle value or class types. - var type = value.GetType(); - if (type.IsValueType) - return Cleaner.Clean(value); + foreach (var p in GetCachedProperties(type).Values.Where(x => !x.IsReadOnly)) + p.Clean(value); - foreach (var p in GetCachedProperties(value.GetType()).Values.Where(x => x.IsReadOnly)) + set.Remove(value); + return RuntimeMetadata.IsDefault(value) ? default : value; + } + finally { - p.Clean(value); + if (isRoot) + set.Clear(); } - - return RuntimeMetadata.IsDefault(value) ? default : value; } } diff --git a/src/CoreEx/Metadata/RuntimeMetadata.GetHashCode.cs b/src/CoreEx/Metadata/RuntimeMetadata.GetHashCode.cs index 4e898423..d2b185a2 100644 --- a/src/CoreEx/Metadata/RuntimeMetadata.GetHashCode.cs +++ b/src/CoreEx/Metadata/RuntimeMetadata.GetHashCode.cs @@ -2,6 +2,10 @@ namespace CoreEx.Metadata; public static partial class RuntimeMetadata { + // Identity hash codes used to detect cycles during hash computation. + [ThreadStatic] + private static HashSet? _visitingForGetHashCode; + /// /// Gets the hash code for the . /// @@ -20,13 +24,25 @@ public static int GetHashCode(T? value) case IRuntimeMetadataCore rm: { - var hash = new HashCode(); - foreach (var p in rm.GetPropertyRuntimeMetadata()) + var set = _visitingForGetHashCode ??= []; + var isRoot = set.Count == 0; + try { - hash.Add(GetHashCode(p.GetValue(value))); - } + var id = RuntimeHelpers.GetHashCode((object)value!); + if (!set.Add(id)) + return 0; // cycle detected β€” return a neutral sentinel - return hash.ToHashCode(); + var hash = new HashCode(); + foreach (var p in rm.GetPropertyRuntimeMetadata()) + hash.Add(GetHashCode(p.GetValue(value))); + + set.Remove(id); + return hash.ToHashCode(); + } + finally + { + if (isRoot) set.Clear(); + } } case IDictionary d: @@ -58,14 +74,27 @@ public static int GetHashCode(T? value) return value.GetHashCode(); // Must be a class so use reflection-based runtime-metadata. - var hash = new HashCode(); - foreach (var p in GetCachedProperties(type).Values) + var set = _visitingForGetHashCode ??= []; + var isRoot = set.Count == 0; + try { - hash.Add(GetHashCode(p.GetValue(value))); - } + var id = RuntimeHelpers.GetHashCode((object)value!); + if (!set.Add(id)) + return 0; // cycle detected β€” return a neutral sentinel - return hash.ToHashCode(); + var hash = new HashCode(); + foreach (var p in GetCachedProperties(type).Values) + hash.Add(GetHashCode(p.GetValue(value))); + + set.Remove(id); + return hash.ToHashCode(); + } + finally + { + if (isRoot) + set.Clear(); + } } } } -} \ No newline at end of file +} diff --git a/src/CoreEx/Metadata/RuntimeMetadata.IsDefault.cs b/src/CoreEx/Metadata/RuntimeMetadata.IsDefault.cs index 29530835..6c71a891 100644 --- a/src/CoreEx/Metadata/RuntimeMetadata.IsDefault.cs +++ b/src/CoreEx/Metadata/RuntimeMetadata.IsDefault.cs @@ -2,6 +2,9 @@ namespace CoreEx.Metadata; public static partial class RuntimeMetadata { + [ThreadStatic] + private static HashSet? _visitingForIsDefault; + /// /// Indicates whether the is in its default state. /// @@ -28,7 +31,24 @@ internal static bool IsDefault(T value, T @default) return AreEqual(str, Internal.Cast(@default)); if (value is IRuntimeMetadataCore rm) - return !rm.GetPropertyRuntimeMetadata().Any(x => !x.IsDefault(value)); + { + var set = _visitingForIsDefault ??= new HashSet(ReferenceEqualityComparer.Instance); + var isRoot = set.Count == 0; + try + { + if (!set.Add(value)) + return false; // cycle detected β€” a self-referential object is not considered default + + bool result = !rm.GetPropertyRuntimeMetadata().Any(x => !x.IsDefault(value)); + set.Remove(value); + return result; + } + finally + { + if (isRoot) + set.Clear(); + } + } if (value is ICollection ic && ic.Count == 0) return true; @@ -37,6 +57,23 @@ internal static bool IsDefault(T value, T @default) if (type.IsValueType) return AreEqual(value, @default); - return !GetPropertyRuntimeMetadata(value.GetType()).Any(x => !x.IsDefault(value)); + { + var set = _visitingForIsDefault ??= new HashSet(ReferenceEqualityComparer.Instance); + var isRoot = set.Count == 0; + try + { + if (!set.Add(value)) + return false; // cycle detected + + bool result = !GetPropertyRuntimeMetadata(type).Any(x => !x.IsDefault(value)); + set.Remove(value); + return result; + } + finally + { + if (isRoot) + set.Clear(); + } + } } -} \ No newline at end of file +} diff --git a/tests/CoreEx.Test.Unit/Runtime/RuntimeMetadataTests.cs b/tests/CoreEx.Test.Unit/Runtime/RuntimeMetadataTests.cs index 065c1eb7..64b3f9ae 100644 --- a/tests/CoreEx.Test.Unit/Runtime/RuntimeMetadataTests.cs +++ b/tests/CoreEx.Test.Unit/Runtime/RuntimeMetadataTests.cs @@ -431,4 +431,114 @@ public class EntityE(int id) public int Id { get; } = id; public string? Name { get; set; } } + + /// A contract type that can form reference cycles via its property. + [Contract] + private partial class NodeC + { + public string? Name { get; set; } + public NodeC? Next { get; set; } + } + + // ------------------------------------------------------------------------- + // Circular-reference safety tests (verifies the ThreadStatic visited-set fix) + // ------------------------------------------------------------------------- + + [Test] + public void Clean_CircularReference_DoesNotOverflow() + { + var a = new NodeC { Name = "A" }; + var b = new NodeC { Name = "B", Next = a }; + a.Next = b; // a β†’ b β†’ a + + var act = () => { Cleaner.Clean(a); }; + act.Should().NotThrow(); + + a.Name.Should().Be("A"); + b.Name.Should().Be("B"); + } + + [Test] + public void AreEqual_CircularReference_DoesNotOverflow() + { + var a1 = new NodeC { Name = "A" }; + var b1 = new NodeC { Name = "B", Next = a1 }; + a1.Next = b1; + + var a2 = new NodeC { Name = "A" }; + var b2 = new NodeC { Name = "B", Next = a2 }; + a2.Next = b2; + + bool result = default; + var act = () => { result = RuntimeMetadata.AreEqual(a1, a2); }; + act.Should().NotThrow(); + result.Should().BeTrue(); // structurally equivalent cycles are equal + } + + [Test] + public void AreEqual_CircularReference_NotEqual_DoesNotOverflow() + { + var a1 = new NodeC { Name = "A" }; + var b1 = new NodeC { Name = "B", Next = a1 }; + a1.Next = b1; + + var a2 = new NodeC { Name = "X" }; // different name + var b2 = new NodeC { Name = "B", Next = a2 }; + a2.Next = b2; + + bool result = true; + var act = () => { result = RuntimeMetadata.AreEqual(a1, a2); }; + act.Should().NotThrow(); + result.Should().BeFalse(); + } + + [Test] + public void GetHashCode_CircularReference_DoesNotOverflow() + { + var a = new NodeC { Name = "A" }; + var b = new NodeC { Name = "B", Next = a }; + a.Next = b; + + var act = () => RuntimeMetadata.GetHashCode(a); + act.Should().NotThrow(); + } + + [Test] + public void IsDefault_CircularReference_DoesNotOverflow() + { + var a = new NodeC { Name = "A" }; + var b = new NodeC { Name = "B", Next = a }; + a.Next = b; + + bool result = default; + var act = () => { result = RuntimeMetadata.IsDefault(a); }; + act.Should().NotThrow(); + result.Should().BeFalse(); // a.Name is non-default + } + + // ------------------------------------------------------------------------- + // Fix 2: inverted IsReadOnly filter in plain-class Clean() path + // ------------------------------------------------------------------------- + + [Test] + public void Clean_PlainClass_WritablePropertyNullCollapsed() + { + // EntityE is a plain class (not IContract). With the IsReadOnly fix applied, + // writable properties are now correctly visited and null-collapsed. + var e = new EntityE(1) { Name = "" }; + Cleaner.Clean(e); + e.Name.Should().BeNull(); // "" cleaned to null (CleanAndDefault null-collapse) + e.Id.Should().Be(1); // read-only β€” unchanged + } + + [Test] + public void Clean_DecimalProperty_DoesNotOverflow() + { + // Decimal (and other non-DateTime value types) previously caused a StackOverflowException: + // RuntimeMetadata.Clean β†’ Cleaner.Clean (line 196: => RuntimeMetadata.Clean) β†’ infinite loop. + var b = new EntityB { Amount = 1.23m }; + Action act = () => Cleaner.Clean(b); + act.Should().NotThrow(); + b.Amount.Should().Be(1.23m); + } } \ No newline at end of file From b55fd02e2b67deb3c8510a47ea4337c9b9ff9d26 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Fri, 31 Jul 2026 16:57:34 -0700 Subject: [PATCH 3/8] Add JSON-backed Tags property to Products domain - Added `Tags` (`List?`) to `Product` entities and mapped to new `tags_json` JSONB column in PostgreSQL. - Updated `dbex.yaml`, migration script, persistence model, and EF Core model builder for JSON support. - Enhanced `ProductMapper` and seed data to handle `Tags`. - Added/updated integration tests for round-trip JSON serialization. - Improved docs and instructions for JSON column conventions and mapping. - Minor fixes to address/mapping classes for nullability and consistency. - Updated Shopping basket scenario for address updates with JSON handling. --- .../coreex-repositories.instructions.md | 20 +++- .../coreex-tooling.instructions.md | 33 ++++++ .github/skills/coreex-db-migration/SKILL.md | 15 +++ .../references/workflow.md | 100 ++++++++++++++++-- .../coreex-repository/references/workflow.md | 2 + .../Contoso.Products.Contracts/ProductBase.cs | 2 + ...alter-products-product-add-tags-json.pgsql | 7 ++ .../src/Contoso.Products.Database/dbex.yaml | 6 +- .../Mapping/ProductMapper.cs | 6 +- .../Persistence/Product.g.cs | 3 + .../Repositories/ProductsDbContext.g.cs | 1 + .../Mapping/AddressMapper.cs | 2 +- .../ValueObjects/Address.cs | 2 +- .../Mapping/AddressMapper.cs | 8 +- .../Persistence/Address.cs | 8 +- .../Scenarios/ShoppingBasketScenario.cs | 20 +++- .../ProductMutateTests.Create.cs | 35 ++++++ .../Create_WithTags.res.json | 11 ++ .../ReadTests/Product_Get_Found.res.json | 1 + .../Data/mutate-data.seed.yaml | 2 +- .../Data/read-data.seed.yaml | 2 +- 21 files changed, 261 insertions(+), 25 deletions(-) create mode 100644 samples/src/Contoso.Products.Database/Migrations/20260731-120000-alter-products-product-add-tags-json.pgsql create mode 100644 samples/tests/Contoso.Products.Test.Api/Resources/ProductMutateTests/Create_WithTags.res.json diff --git a/.github/instructions/coreex-repositories.instructions.md b/.github/instructions/coreex-repositories.instructions.md index b52dba74..29a05bab 100644 --- a/.github/instructions/coreex-repositories.instructions.md +++ b/.github/instructions/coreex-repositories.instructions.md @@ -42,7 +42,7 @@ The Infrastructure project is organised into focused sub-folders. The table belo | `Mapping/` | Bidirectional mappers (`BiDirectionMapper`) between Contract types and Persistence model types. | | `Adapters/` | Implementations of `IXxxAdapter` interfaces defined in `Application/Adapters/`. Registered with `[ScopedService]`. | | `Clients/` | Typed HTTP client wrappers β€” one class per external service. Registered via `AddTypedHttpClient()` in `Program.cs`. | -| `Persistence/` | EF entity/model classes. These are **generated** (`*.g.cs`) by the `*.Database` tooling project β€” do not create or edit manually. | +| `Persistence/` | EF entity/model classes. The `*.g.cs` files are **generated** by the `*.Database` tooling β€” do not edit them. Hand-authored POCOs for JSON-column storage (plain classes, no base class, no attributes) also live here alongside the generated files β€” see [Mapping](#mapping). | Repository and adapter implementations follow the same primary-constructor + guard pattern: @@ -337,6 +337,24 @@ protected override Contracts.Employee OnMap(Persistence.Employee source) => new( Infrastructure-level mapping covers either **Contract ↔ Persistence** (CRUD domains) or **Domain ↔ Persistence** (domains with a Domain layer, where the aggregate is mapped to/from the persistence model). Application-level mapping (Domain aggregate ↔ Contract) lives in `Application/Mapping/` and uses `Mapper`. Do not conflate the two. +**JSON-typed properties** (backed by a `*Json` / `*_json` database column) are mapped in `OnMap` exactly like any other typed property β€” direct assignment, no `JsonSerializer.Serialize/Deserialize`: + +```csharp +// Simple collection β€” the CLR type is the same on both sides; assign directly +Tags = source.Tags, + +// Complex POCO β€” map fields from the hand-authored Persistence.Address POCO +ShippingAddress = source.ShippingAddress is null ? null : new Persistence.Address +{ + Street1 = source.ShippingAddress.Street1, + City = source.ShippingAddress.City, + PostCode = source.ShippingAddress.PostCode, + State = source.ShippingAddress.State, +} +``` + +`TypeToJsonStringEfConverter` (auto-wired in the generated `*DbContext.g.cs`) handles serialisation transparently at the EF layer. For complex object types, the hand-authored POCO lives in `Infrastructure/Persistence/` alongside the generated `*.g.cs` files β€” it is a plain class, no base class, no attributes; use `required`/non-nullable for mandatory fields. See the [`coreex-db-migration`](/.github/skills/coreex-db-migration/SKILL.md#json-columns) skill for the `dbex.yaml` columns entry and full POCO conventions. + ## External Clients and Adapter Implementations When a domain calls another domain's API over HTTP, split the concern across two focused classes: diff --git a/.github/instructions/coreex-tooling.instructions.md b/.github/instructions/coreex-tooling.instructions.md index 98c83aa2..23900b15 100644 --- a/.github/instructions/coreex-tooling.instructions.md +++ b/.github/instructions/coreex-tooling.instructions.md @@ -277,6 +277,36 @@ tables: (Add `columns:`, `efModel`, `efModelName`, `includeColumns`/`excludeColumns`, or `columnName*` overrides only when a specific need arises.) +### JSON columns in `dbex.yaml` + +A column whose name ends with `Json` (SQL Server `PascalCase`) or `_json` (PostgreSQL `snake_case`) stores a serialised JSON representation of a .NET type. DbEx surfaces this in `Inspect` output as `Json: Yes`. **A `columns:` entry is required** β€” without it, DbEx generates `string?` with no converter. + +```yaml +# SQL Server β€” PascalCase +- name: Basket + columns: + - name: ShippingAddressJson # DB column name β€” must include the Json suffix + property: ShippingAddress # C# property name β€” suffix stripped + type: Persistence.Address? # CLR type: persistence POCO, List?, Dictionary?, etc. + +# PostgreSQL β€” snake_case +- name: product + columns: + - name: tags_json + property: Tags + type: List? +``` + +The `type:` field drives code generation: +- **Non-string type** β†’ DbEx auto-wires `TypeToJsonStringEfConverter` in the generated `*DbContext.g.cs`. Do not add `.HasConversion(...)` by hand. +- **`string` or `string?`** β†’ the JSON is stored as-is with no converter (raw passthrough). + +When `type:` is a complex object (not `string`, `List`, `Dictionary`, or another natively-serialisable type), a **hand-authored POCO** is required in `Infrastructure/Persistence/`. It is a plain class β€” no base class, no `[Contract]` or other attributes, no validation logic. Use `required` / non-nullable for mandatory fields and nullable only where genuinely optional. For natively-serialisable types (`List?`, `Dictionary?`, etc.) no separate class is needed. + +Default column types: `NVARCHAR(MAX)` (SQL Server) / `JSONB` (PostgreSQL) β€” unless the user specifies a bounded size. + +For the full worked examples, DDD aggregate vs CRUD service guidance, and mapper conventions see the [`coreex-db-migration`](/.github/skills/coreex-db-migration/SKILL.md#json-columns) skill. + ### `CodeGen` phase β€” generated Infrastructure C# The `CodeGen` command generates `.g.cs` files into the Infrastructure project: @@ -422,6 +452,9 @@ When authoring a migration script for an entity that has a corresponding .NET co | `bool` | `BIT` | `BOOLEAN` | | `DateTime` | `DATETIME2` | `TIMESTAMP` | | `DateTimeOffset` | `DATETIMEOFFSET` | `TIMESTAMPTZ` | +| `DateOnly` | `DATE` | `date` | +| `TimeOnly` | `TIME` | `time` | +| Complex type (class/record) | `NVARCHAR(MAX)` β€” JSON suffix convention (see [JSON columns](#json-columns-in-dbex-yaml)) | `JSONB` β€” JSON suffix convention (see [JSON columns](#json-columns-in-dbex-yaml)) | > **Creation procedure β†’ skill.** Authoring/applying a migration script is driven by the > [`coreex-db-migration`](/.github/skills/coreex-db-migration/SKILL.md) skill (inspect β†’ script β†’ fill columns β†’ diff --git a/.github/skills/coreex-db-migration/SKILL.md b/.github/skills/coreex-db-migration/SKILL.md index 8d4714ee..32c923d1 100644 --- a/.github/skills/coreex-db-migration/SKILL.md +++ b/.github/skills/coreex-db-migration/SKILL.md @@ -69,6 +69,21 @@ Check the project's `*.Database/Program.cs` or `appsettings.json` to confirm the For the full step-by-step decision tree, SQL column templates, and guardrails see [`references/workflow.md`](references/workflow.md). +## JSON Columns + +A column whose name ends with `Json` (SQL Server) or `_json` (PostgreSQL) stores a serialised .NET type as JSON text. DbEx surfaces this in `Inspect` output as `Json: Yes`. + +Three things are required: + +1. **A `columns:` entry in `dbex.yaml`** β€” without it, DbEx generates `string?` with no converter. + `name:` (DB column name including the suffix), `property:` (C# name without suffix), `type:` (CLR type, e.g. `Persistence.Address?` or `List?`). +2. **A hand-authored persistence POCO** in `Infrastructure/Persistence/` when the stored type is a complex object. For natively-serialisable types (`List?`, `Dictionary?`, etc.) use the .NET type directly β€” no extra class needed. +3. **No manual `.HasConversion(...)` call** β€” `TypeToJsonStringEfConverter` is auto-wired in the generated `*DbContext.g.cs` when `type:` is non-string. + +Default column types (unless the user specifies otherwise): `NVARCHAR(MAX)` (SQL Server) / `JSONB` (PostgreSQL). + +For the full workflow, example YAML, DDD aggregate vs CRUD service guidance, and POCO class conventions see [`references/workflow.md` β€” JSON columns](references/workflow.md#json-columns). + ## Key References - [`/.github/instructions/coreex-tooling.instructions.md`](/.github/instructions/coreex-tooling.instructions.md) β€” DbEx command reference, `dbex.yaml` structure, SQL conventions, outbox provisioning diff --git a/.github/skills/coreex-db-migration/references/workflow.md b/.github/skills/coreex-db-migration/references/workflow.md index eeac4634..2245e432 100644 --- a/.github/skills/coreex-db-migration/references/workflow.md +++ b/.github/skills/coreex-db-migration/references/workflow.md @@ -109,21 +109,109 @@ Remove any `DEFAULT (NEWSEQUENTIALID())`, `IDENTITY`, or `SERIAL` unless the use | `DateTime` | `DATETIME2` | `TIMESTAMP` | | `DateOnly` | `DATE` | `date` | | `TimeOnly` | `TIME` | `time` | +| Complex type (class/record) | `NVARCHAR(MAX)` β€” JSON suffix convention (see below) | `JSONB` β€” JSON suffix convention (see below) | `DateOnly`/`TimeOnly` map natively β€” no `HasConversion(...)` value converter is required on either provider (EF Core SqlServer since EF8, Npgsql since v6). See `tests/CoreEx.Database.SqlServer.Test.Unit/Repository/TestDbContext.cs` and `tests/CoreEx.Database.Postgres.Test.Unit/Repository/TestDbContext.cs` for confirmed working `HasColumnType`-only configuration. ### JSON columns -When a column name ends with `Json` (SQL Server `PascalCase`) or `json` (PostgreSQL `snake_case`), **confirm with the developer** that the intent is serialized JSON storage. If confirmed: +A column whose name ends with `Json` (SQL Server `PascalCase`) or `_json` (PostgreSQL `snake_case`) stores a serialised JSON representation of a .NET type. DbEx surfaces this in `Inspect` output as `Json: Yes`. -| Provider | Default column type | Override allowed | +**Confirm with the developer** that the intent is JSON storage, then choose the column type: + +| Provider | Default column type | Override | |---|---|---| -| SQL Server | `NVARCHAR(MAX)` | Yes β€” e.g. `NVARCHAR(4000)` if size is bounded | -| PostgreSQL | `jsonb` | Yes β€” `json` (text) if binary operators are explicitly not wanted | +| SQL Server | `NVARCHAR(MAX)` | e.g. `NVARCHAR(4000)` if size is bounded | +| PostgreSQL | `JSONB` | `TEXT` if native JSON operators are not needed | + +Unless the user specifies otherwise, use the maximum-length type (`NVARCHAR(MAX)` / `JSONB`). This is a NoSQL-within-SQL pattern: complex nested data is stored as a blob when no database-level operations against the JSON content (filtering, indexing on sub-fields) are needed. If the developer expects to query within the JSON, flag that β€” `JSONB` (PostgreSQL) supports operators, but the design decision should be explicit. + +#### `dbex.yaml` `columns:` entry (required) + +**A `columns:` entry is required for every JSON column** β€” without it, DbEx generates a plain `string?` property with no converter. Add it under the table entry in `dbex.yaml`: + +```yaml +# SQL Server example (PascalCase) +- name: Basket + columns: + - name: ShippingAddressJson # DB column name β€” must include the Json suffix + property: ShippingAddress # C# property name β€” without the suffix + type: Persistence.Address? # CLR type: persistence POCO or any serialisable .NET type + +# PostgreSQL example (snake_case) +- name: product + columns: + - name: tags_json + property: Tags + type: List? +``` + +The `name:` + `property:` pair strips the suffix from the generated C# property. The `type:` field drives code generation: +- A **non-string type** causes DbEx to auto-wire `TypeToJsonStringEfConverter` in the generated `*DbContext.g.cs`. +- `string` or `string?` stores the JSON as-is with no converter (raw JSON passthrough). + +#### What DbEx auto-generates + +**Persistence model** (`Infrastructure/Persistence/.g.cs`): +```csharp +public Persistence.Address? ShippingAddress { get; set; } // typed POCO, not string +``` + +**EF model builder** (`Infrastructure/Repositories/*DbContext.g.cs`): +```csharp +e.Property(p => p.ShippingAddress) + .HasColumnName("ShippingAddressJson") + .HasColumnType("NVARCHAR(MAX)") // or "JSONB" for PostgreSQL + .HasConversion(TypeToJsonStringEfConverter.Default); +``` + +`TypeToJsonStringEfConverter` (from `CoreEx.EntityFrameworkCore.Converters`) is auto-applied by the code generator β€” **never add `.HasConversion(...)` by hand** for a JSON column. + +#### Hand-authored persistence POCO (complex types only) + +When `type:` is a complex object (not `string`, `string?`, `List`, `Dictionary`, or another natively-serialisable type), a hand-authored POCO is required in `Infrastructure/Persistence/`. This is **not generated** β€” create it manually alongside the `*.g.cs` files: + +```csharp +// Infrastructure/Persistence/Address.cs +namespace Contoso.Shopping.Infrastructure.Persistence; + +public class Address // plain class β€” NOT ModelBase, NOT [Contract], NOT partial +{ + public required string Street1 { get; set; } + public string? Street2 { get; set; } + public required string City { get; set; } + public required string PostCode { get; set; } + public required string State { get; set; } +} +``` + +Properties should reflect actual data requirements β€” `required`/non-nullable for mandatory fields, nullable only where genuinely optional. No validation logic (domain validation enforces constraints before persistence). The class must be constructible without a parameterised constructor (required for `System.Text.Json` deserialization). + +For natively-serialisable types (`List?`, `Dictionary?`, etc.), no separate POCO class is needed β€” use the .NET type directly in `type:`. + +#### Mapping JSON properties + +In `BiDirectionMapper.OnMap`, JSON-backed properties are mapped exactly like any other typed property β€” direct assignment, no `JsonSerializer.Serialize/Deserialize`. `TypeToJsonStringEfConverter` handles the DB serialisation transparently at the EF layer: + +```csharp +// Simple collection β€” direct assignment (no POCO conversion needed) +Tags = source.Tags + +// Complex POCO β€” map fields directly +ShippingAddress = source.ShippingAddress is null ? null : new Persistence.Address +{ + Street1 = source.ShippingAddress.Street1, + City = source.ShippingAddress.City, + PostCode = source.ShippingAddress.PostCode, + State = source.ShippingAddress.State, +} +``` + +#### DDD aggregate vs CRUD service -The `.NET` type for a JSON column is typically a **class or record**, not a string β€” the column stores the serialized form of that type. This is a NoSQL-within-SQL pattern: complex nested data is stored as a blob to avoid composite columns or child tables when no specific database-level operations (filtering, indexing on subfields, etc.) are needed against the content. If the developer expects to query within the JSON, flag that β€” `jsonb` supports operators but the design decision should be explicit. +**DDD aggregate (e.g. Shopping/Basket):** Three distinct types + two mappers exist across layer boundaries. The persistence POCO (`Persistence.Address`) is hand-authored; the domain value object (`Domain.ValueObjects.Address`) is a separate record with validation; the contract DTO (`Contracts.Address`) is a `[Contract]` class. Application-layer and Infrastructure-layer mappers each handle one boundary. -Do not add JSON column handling to the `dbex.yaml` `columns:` entry β€” DbEx will infer the CLR type via a registered value converter. Confirm the Infrastructure mapper (or `*.Database` CodeGen config) handles the JSON serialization; this is a hand-written concern, not auto-generated. +**CRUD service (e.g. Products/Tags):** Only two types β€” the persistence property (`List?`) and the contract property (`List?`) are the same CLR type, so the mapper simply assigns `Tags = source.Tags`. No dedicated POCO class or extra mapper class is needed. ### Reference-data relationships diff --git a/.github/skills/coreex-repository/references/workflow.md b/.github/skills/coreex-repository/references/workflow.md index 506f4f8f..5ac79816 100644 --- a/.github/skills/coreex-repository/references/workflow.md +++ b/.github/skills/coreex-repository/references/workflow.md @@ -103,6 +103,8 @@ public class {Name}Mapper : BiDirectionMapper` handles the DB serialisation transparently at the EF layer. For complex object types, a hand-authored persistence POCO lives in `Infrastructure/Persistence/` alongside the generated `*.g.cs` files. See [`coreex-db-migration`](../coreex-db-migration/references/workflow.md#json-columns) for the full column setup, `dbex.yaml` `columns:` entry, and POCO conventions. + Ensure `global using {Solution}.Infrastructure.Mapping;` is in `GlobalUsing.cs` so other classes can reference `{Name}Mapper.Default` without a fully-qualified name. ### A4 β€” DbContext (new domain only) diff --git a/samples/src/Contoso.Products.Contracts/ProductBase.cs b/samples/src/Contoso.Products.Contracts/ProductBase.cs index 5f624f18..65ab1a73 100644 --- a/samples/src/Contoso.Products.Contracts/ProductBase.cs +++ b/samples/src/Contoso.Products.Contracts/ProductBase.cs @@ -29,6 +29,8 @@ public abstract partial class ProductBase : IIdentifier public bool IsNonStocked { get; set; } + public List? Tags { get; set; } + [ReadOnly(true)] public bool IsInactive { get; set; } } \ No newline at end of file diff --git a/samples/src/Contoso.Products.Database/Migrations/20260731-120000-alter-products-product-add-tags-json.pgsql b/samples/src/Contoso.Products.Database/Migrations/20260731-120000-alter-products-product-add-tags-json.pgsql new file mode 100644 index 00000000..23e2849b --- /dev/null +++ b/samples/src/Contoso.Products.Database/Migrations/20260731-120000-alter-products-product-add-tags-json.pgsql @@ -0,0 +1,7 @@ +-- Migration Script. + +BEGIN TRANSACTION; + +ALTER TABLE "products"."product" ADD "tags_json" JSONB NULL; + +COMMIT TRANSACTION; diff --git a/samples/src/Contoso.Products.Database/dbex.yaml b/samples/src/Contoso.Products.Database/dbex.yaml index 6715e5b8..b9141a45 100644 --- a/samples/src/Contoso.Products.Database/dbex.yaml +++ b/samples/src/Contoso.Products.Database/dbex.yaml @@ -14,4 +14,8 @@ tables: # Transactional-data - name: inventory - name: movement -- name: product \ No newline at end of file +- name: product + columns: + - name: tags_json + property: Tags + type: List? \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/Mapping/ProductMapper.cs b/samples/src/Contoso.Products.Infrastructure/Mapping/ProductMapper.cs index 20e1158f..23fc2ff9 100644 --- a/samples/src/Contoso.Products.Infrastructure/Mapping/ProductMapper.cs +++ b/samples/src/Contoso.Products.Infrastructure/Mapping/ProductMapper.cs @@ -12,7 +12,8 @@ public class ProductMapper : BiDirectionMapper new Contracts.Product() @@ -25,6 +26,7 @@ public class ProductMapper : BiDirectionMapper p.CategoryCode = p.SubCategory?.CategoryCode); } \ No newline at end of file diff --git a/samples/src/Contoso.Products.Infrastructure/Persistence/Product.g.cs b/samples/src/Contoso.Products.Infrastructure/Persistence/Product.g.cs index 7e373a33..0b66e7e7 100644 --- a/samples/src/Contoso.Products.Infrastructure/Persistence/Product.g.cs +++ b/samples/src/Contoso.Products.Infrastructure/Persistence/Product.g.cs @@ -36,6 +36,9 @@ public partial class Product : ModelBase, ILogicallyDeleted /// Gets or sets the value of the 'is_non_stocked' column (type 'BOOLEAN'). public bool IsNonStocked { get; set; } + /// Gets or sets the value of the 'tags_json' column (type 'JSONB NULL'). + public List? Tags { get; set; } + /// Gets or sets the value of the 'is_deleted' column (type 'BOOLEAN'); see . public bool IsDeleted { get; set; } } diff --git a/samples/src/Contoso.Products.Infrastructure/Repositories/ProductsDbContext.g.cs b/samples/src/Contoso.Products.Infrastructure/Repositories/ProductsDbContext.g.cs index 1b61f1fd..2e068c04 100644 --- a/samples/src/Contoso.Products.Infrastructure/Repositories/ProductsDbContext.g.cs +++ b/samples/src/Contoso.Products.Infrastructure/Repositories/ProductsDbContext.g.cs @@ -173,6 +173,7 @@ partial void AddGeneratedModels(Microsoft.EntityFrameworkCore.ModelBuilder model e.Property(p => p.UpdatedBy).HasColumnName("updated_by").HasColumnType("CHARACTER VARYING(250)"); e.Property(p => p.UpdatedOn).HasColumnName("updated_on").HasColumnType("TIMESTAMP WITH TIME ZONE"); e.Property(p => p.ETag).HasColumnName("xmin").HasColumnType("XID").IsRowVersion().HasConversion(ValueConverterBridge.Create(BaseDatabase.RowVersionConverter)); + e.Property(p => p.Tags).HasColumnName("tags_json").HasColumnType("JSONB").HasConversion(TypeToJsonStringEfConverter?>.Default); e.Property(p => p.IsDeleted).HasColumnName("is_deleted").HasColumnType("BOOLEAN"); }); } diff --git a/samples/src/Contoso.Shopping.Application/Mapping/AddressMapper.cs b/samples/src/Contoso.Shopping.Application/Mapping/AddressMapper.cs index e95d60d2..d2ede937 100644 --- a/samples/src/Contoso.Shopping.Application/Mapping/AddressMapper.cs +++ b/samples/src/Contoso.Shopping.Application/Mapping/AddressMapper.cs @@ -13,7 +13,7 @@ public class AddressMapper : BiDirectionMapper new() { - Street1 = source.Street1, + Street1 = source.Street1!, Street2 = source.Street2, City = source.City!, PostCode = source.PostCode!, diff --git a/samples/src/Contoso.Shopping.Domain/ValueObjects/Address.cs b/samples/src/Contoso.Shopping.Domain/ValueObjects/Address.cs index 59e61254..2b344acd 100644 --- a/samples/src/Contoso.Shopping.Domain/ValueObjects/Address.cs +++ b/samples/src/Contoso.Shopping.Domain/ValueObjects/Address.cs @@ -2,7 +2,7 @@ namespace Contoso.Shopping.Domain.ValueObjects; public record class Address { - public required string? Street1 { get; init => field = value.ThrowIfNullOrEmpty(); } + public required string Street1 { get; init => field = value.ThrowIfNullOrEmpty(); } public string? Street2 { get; init => field = value.ThrowIfEmpty(); } public required string City { get; init => field = value.ThrowIfNullOrEmpty(); } public required string PostCode { get; init => field = value.ThrowIfNullOrEmpty(); } diff --git a/samples/src/Contoso.Shopping.Infrastructure/Mapping/AddressMapper.cs b/samples/src/Contoso.Shopping.Infrastructure/Mapping/AddressMapper.cs index b0221919..fcc691a6 100644 --- a/samples/src/Contoso.Shopping.Infrastructure/Mapping/AddressMapper.cs +++ b/samples/src/Contoso.Shopping.Infrastructure/Mapping/AddressMapper.cs @@ -4,11 +4,11 @@ public class AddressMapper : BiDirectionMapper new() { - Street1 = source.Street1!, + Street1 = source.Street1, Street2 = source.Street2, - City = source.City!, - PostCode = source.PostCode!, - State = source.State! + City = source.City, + PostCode = source.PostCode, + State = source.State }; protected override Persistence.Address OnMap(Domain.ValueObjects.Address source) => new() diff --git a/samples/src/Contoso.Shopping.Infrastructure/Persistence/Address.cs b/samples/src/Contoso.Shopping.Infrastructure/Persistence/Address.cs index b56863af..7c3db784 100644 --- a/samples/src/Contoso.Shopping.Infrastructure/Persistence/Address.cs +++ b/samples/src/Contoso.Shopping.Infrastructure/Persistence/Address.cs @@ -2,9 +2,9 @@ namespace Contoso.Shopping.Infrastructure.Persistence; public class Address { - public string? Street1 { get; set; } + public string Street1 { get; set; } = default!; public string? Street2 { get; set; } - public string? City { get; set; } - public string? PostCode { get; set; } - public string? State { get; set; } + public string City { get; set; } = default!; + public string PostCode { get; set; } = default!; + public string State { get; set; } = default!; } diff --git a/samples/tests/Contoso.E2E.Runner/Scenarios/ShoppingBasketScenario.cs b/samples/tests/Contoso.E2E.Runner/Scenarios/ShoppingBasketScenario.cs index ca85d07d..aa56fc8f 100644 --- a/samples/tests/Contoso.E2E.Runner/Scenarios/ShoppingBasketScenario.cs +++ b/samples/tests/Contoso.E2E.Runner/Scenarios/ShoppingBasketScenario.cs @@ -74,7 +74,21 @@ public async Task RunAsync(ScenarioContext context) await ScenarioContext.RandomizedDelayAsync(context); - // Step 5: Checkout the basket + // Step 5: Update the shipping address. + basket = await context.StepAsync("Update shipping address.", async () => + { + var address = new Address + { + Street1 = "123 Main St", + City = "Anytown", + State = "CA", + PostCode = "12345", + }; + var response = await context.TestContext.ShoppingHttpClient.PutAsJsonAsync($"/api/baskets/{basket!.Id}/shipping-address", address, JsonDefaults.SerializerOptions); + return await response.GetValueAsync(); + }, b => $"Shipping address updated."); + + // Step 6: Checkout the basket basket = await context.StepAsync("Checkout basket.", async () => { var response = await context.TestContext.ShoppingHttpClient.PostAsync($"/api/baskets/{basket!.Id}/checkout", null); @@ -83,11 +97,11 @@ public async Task RunAsync(ScenarioContext context) await ScenarioContext.RandomizedDelayAsync(context); - // Step 6: Get the basket. + // Step 7: Get the basket. basket = await context.StepAsync("Get checked-out basket.", async () => { var response = await context.TestContext.ShoppingHttpClient.GetAsync($"/api/baskets/{basket!.Id}"); return await response.GetValueAsync() ?? throw new NotFoundException(); }, b => $"Basket retrieved."); } -} \ No newline at end of file +} diff --git a/samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Create.cs b/samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Create.cs index 85398428..013e26c2 100644 --- a/samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Create.cs +++ b/samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Create.cs @@ -80,6 +80,41 @@ public void Create_Success() .AssertValue(r); } + [Test] + public void Create_WithTags() + { + // Arrange β€” create a product with tags to verify the JSONB column round-trips correctly. + var p = new Product + { + Sku = "TAGGED-SKU-001", + Text = "Tagged Product", + Price = 1500M, + SubCategoryCode = "XC", + UnitOfMeasureCode = "EA", + BrandCode = "YETI", + Tags = ["cross-country", "carbon", "race"] + }; + + // Act/Assert β€” create and verify the response includes tags. + var r = Test.Http() + .ExpectIdentifier() + .ExpectETag() + .ExpectChangeLogCreated() + .ExpectPostgresOutboxEvents(e => e.AssertWithValue("contoso", "contoso.products.product.created.v1")) + .Run(HttpMethod.Post, "/api/products", p) + .AssertCreated() + .AssertLocationHeader(r => new Uri($"/api/products/{r!.Id}", UriKind.Relative)) + .AssertJsonFromResource("ProductMutateTests.Create_WithTags.res.json", "id", "etag", "changeLog") + .Value!; + + // Assert tags survive a Get round-trip. + r.Tags.Should().BeEquivalentTo(["cross-country", "carbon", "race"]); + Test.Http() + .Run(HttpMethod.Get, $"/api/products/{r.Id}") + .AssertOK() + .AssertValue(r); + } + [Test] public void Create_IdempotencyKey() { diff --git a/samples/tests/Contoso.Products.Test.Api/Resources/ProductMutateTests/Create_WithTags.res.json b/samples/tests/Contoso.Products.Test.Api/Resources/ProductMutateTests/Create_WithTags.res.json new file mode 100644 index 00000000..8f58d79c --- /dev/null +++ b/samples/tests/Contoso.Products.Test.Api/Resources/ProductMutateTests/Create_WithTags.res.json @@ -0,0 +1,11 @@ +{ + "sku": "TAGGED-SKU-001", + "text": "Tagged Product", + "category": "B", + "subCategory": "XC", + "unitOfMeasure": "EA", + "brand": "YETI", + "price": 1500, + "tags": ["cross-country", "carbon", "race"], + "isInactive": true +} diff --git a/samples/tests/Contoso.Products.Test.Api/Resources/ReadTests/Product_Get_Found.res.json b/samples/tests/Contoso.Products.Test.Api/Resources/ReadTests/Product_Get_Found.res.json index cc6f4f24..25054d0f 100644 --- a/samples/tests/Contoso.Products.Test.Api/Resources/ReadTests/Product_Get_Found.res.json +++ b/samples/tests/Contoso.Products.Test.Api/Resources/ReadTests/Product_Get_Found.res.json @@ -7,6 +7,7 @@ "unitOfMeasure": "EA", "brand": "YETI", "price": 5800.00, + "tags": ["cross-country", "full-suspension"], "etag": "AAAAAAAAB9E=", "changeLog": { "createdBy": "METACORTEX\\thomas.anderson", diff --git a/samples/tests/Contoso.Products.Test.Common/Data/mutate-data.seed.yaml b/samples/tests/Contoso.Products.Test.Common/Data/mutate-data.seed.yaml index e78e56ef..8d3ac582 100644 --- a/samples/tests/Contoso.Products.Test.Common/Data/mutate-data.seed.yaml +++ b/samples/tests/Contoso.Products.Test.Common/Data/mutate-data.seed.yaml @@ -1,6 +1,6 @@ products: - product: - - { product_id: ^1, sku: YETI-ASR-C2-2025, text: Yeti ASR C2, sub_category_code: XC, unit_of_measure_code: EA, price: 5800, brand_code: YETI } + - { product_id: ^1, sku: YETI-ASR-C2-2025, text: Yeti ASR C2, sub_category_code: XC, unit_of_measure_code: EA, price: 5800, brand_code: YETI, tags_json: '["cross-country","full-suspension"]' } - { product_id: ^2, sku: YETI-SB120-C2, text: Yeti SB120 C2, sub_category_code: TR, unit_of_measure_code: EA, price: 6200, brand_code: YETI } - { product_id: ^3, sku: YETI-SB140-LR-C2, text: Yeti SB140 LR C2, sub_category_code: TR, unit_of_measure_code: EA, price: 6600, brand_code: YETI } - { product_id: ^4, sku: YETI-SB165-C3, text: Yeti SB165 C3, sub_category_code: EN, unit_of_measure_code: EA, price: 7200, brand_code: YETI } diff --git a/samples/tests/Contoso.Products.Test.Common/Data/read-data.seed.yaml b/samples/tests/Contoso.Products.Test.Common/Data/read-data.seed.yaml index e78e56ef..8d3ac582 100644 --- a/samples/tests/Contoso.Products.Test.Common/Data/read-data.seed.yaml +++ b/samples/tests/Contoso.Products.Test.Common/Data/read-data.seed.yaml @@ -1,6 +1,6 @@ products: - product: - - { product_id: ^1, sku: YETI-ASR-C2-2025, text: Yeti ASR C2, sub_category_code: XC, unit_of_measure_code: EA, price: 5800, brand_code: YETI } + - { product_id: ^1, sku: YETI-ASR-C2-2025, text: Yeti ASR C2, sub_category_code: XC, unit_of_measure_code: EA, price: 5800, brand_code: YETI, tags_json: '["cross-country","full-suspension"]' } - { product_id: ^2, sku: YETI-SB120-C2, text: Yeti SB120 C2, sub_category_code: TR, unit_of_measure_code: EA, price: 6200, brand_code: YETI } - { product_id: ^3, sku: YETI-SB140-LR-C2, text: Yeti SB140 LR C2, sub_category_code: TR, unit_of_measure_code: EA, price: 6600, brand_code: YETI } - { product_id: ^4, sku: YETI-SB165-C3, text: Yeti SB165 C3, sub_category_code: EN, unit_of_measure_code: EA, price: 7200, brand_code: YETI } From 51eb50c2908da38e00e04671d29316b0805b18b4 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Fri, 31 Jul 2026 17:19:10 -0700 Subject: [PATCH 4/8] Improve HTTP body detection in GetRequestValueAsync GetRequestValueAsync now checks for the "Transfer-Encoding" header in addition to ContentLength to determine if the request has a body. This ensures correct handling of chunked or encoded requests that lack a Content-Length header, returning a default result only when neither is present. --- src/CoreEx.AspNetCore/Abstractions/WebApi.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/CoreEx.AspNetCore/Abstractions/WebApi.cs b/src/CoreEx.AspNetCore/Abstractions/WebApi.cs index 8e6cc14d..53daa226 100644 --- a/src/CoreEx.AspNetCore/Abstractions/WebApi.cs +++ b/src/CoreEx.AspNetCore/Abstractions/WebApi.cs @@ -153,7 +153,8 @@ private WebApiResult CreateContentForValue(WebApiOptionsBase options /// The corresponding . protected async Task> GetRequestValueAsync(HttpRequest request, CancellationToken cancellationToken) { - if (request.ContentLength is null || request.ContentLength == 0) + var hasBody = request.ContentLength > 0 || request.Headers.ContainsKey("Transfer-Encoding"); + if (!hasBody) return Result.Ok(default); try From 26af9ebcecfd0c76bea72bb71af8167294f1dd60 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Fri, 31 Jul 2026 18:11:24 -0700 Subject: [PATCH 5/8] Round out JSON database column support: correct defaults, close DDD/mapper docs gaps, fix converter bug Corrects and completes the JSON-column feature so it consistently proves the end-to-end pattern of converting a JSON database column to/from a non-string .NET type, with correct guidance for teams building on it: - Default JSON columns to bounded text (e.g. NVARCHAR(n)/VARCHAR(n)) matching the rest of the repo's string-column convention, reserving unbounded MAX / native JSON types as an explicit, documented override (Products' tags_json/JSONB is kept and annotated as that override example; Shopping's ShippingAddressJson is corrected to the bounded default). - Fix a real bug in TypeToJsonStringConverter where the non-generic IConverter overloads cast to the wrong side's type, in one case causing infinite recursion regardless of T. Add regression tests. - Document the full contract/persistence/mapper pairing for JSON-backed values, including the three-type, two-mapper chain (Contract <-> Domain value object <-> Persistence model) required when a DDD aggregate owns the JSON-backed property. - Extend the AI instructions/skills (tooling, domain, application-services, db-migration, aggregate, app-service) so future JSON-column work follows this naming convention (Json/_json suffix), default column typing, and contract/persistence/mapper pattern without re-deriving it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...oreex-application-services.instructions.md | 30 ++++++++ .../coreex-domain.instructions.md | 37 +++++++++ .../coreex-tooling.instructions.md | 5 +- .github/skills/coreex-aggregate/SKILL.md | 3 +- .../coreex-aggregate/references/workflow.md | 33 ++++++++ .github/skills/coreex-app-service/SKILL.md | 1 + .../coreex-app-service/references/workflow.md | 2 + .github/skills/coreex-db-migration/SKILL.md | 2 +- .../references/workflow.md | 14 ++-- ...alter-products-product-add-tags-json.pgsql | 2 + .../src/Contoso.Products.Database/dbex.yaml | 2 +- ...622-194111-alter-shopping-basket-table.sql | 2 +- .../Persistence/Basket.g.cs | 2 +- .../Repositories/ShoppingDbContext.g.cs | 2 +- .../Converters/TypeToJsonStringConverter.cs | 4 +- .../TypeToJsonStringConverterTests.cs | 75 +++++++++++++++++++ 16 files changed, 200 insertions(+), 16 deletions(-) create mode 100644 tests/CoreEx.Test.Unit/Mapping/Converters/TypeToJsonStringConverterTests.cs diff --git a/.github/instructions/coreex-application-services.instructions.md b/.github/instructions/coreex-application-services.instructions.md index 925eba95..0c1ce455 100644 --- a/.github/instructions/coreex-application-services.instructions.md +++ b/.github/instructions/coreex-application-services.instructions.md @@ -448,6 +448,36 @@ var contract = BasketMapper.Map(aggregate); Infrastructure-level mapping (Contract ↔ Persistence model) uses `BiDirectionMapper` and lives in `Infrastructure/Mapping/`. Do not conflate the two layers. +### JSON-backed value object mapping + +A Domain value object that is persisted via a JSON column (see [`coreex-domain.instructions.md#value-objects-backed-by-a-json-column`](/.github/instructions/coreex-domain.instructions.md#value-objects-backed-by-a-json-column)) still needs an Application-layer mapper β€” but because the value can flow **both** directions (read back out to the contract *and* accepted as input on an update operation), use `BiDirectionMapper` here instead of the uni-directional `Mapper` used for the root aggregate: + +```csharp +// Application/Mapping/AddressMapper.cs +public class AddressMapper : BiDirectionMapper +{ + protected override Contracts.Address OnMap(Domain.ValueObjects.Address source) => new() + { + Street1 = source.Street1, + Street2 = source.Street2, + City = source.City, + PostCode = source.PostCode, + State = source.State + }; + + protected override Domain.ValueObjects.Address OnMap(Contracts.Address source) => new() + { + Street1 = source.Street1!, + Street2 = source.Street2, + City = source.City!, + PostCode = source.PostCode!, + State = source.State! + }; +} +``` + +Call `AddressMapper.To.Map(...)` / `AddressMapper.From.Map(...)` at the point of use β€” e.g. the root `BasketMapper` composes it for the outbound direction (`ShippingAddress = AddressMapper.To.Map(source.ShippingAddress)`), and the service composes it for the inbound direction when accepting an update (`basket.UpdateShippingAddress(AddressMapper.From.Map(shippingAddress))`). This mapper only ever sees the Domain ↔ Contract boundary β€” the separate Infrastructure-layer mapper (Domain ↔ Persistence, in `Infrastructure/Mapping/`) handles the other boundary; never conflate the two or skip straight from `Contracts.{ValueObject}` to `Persistence.{ValueObject}`. + ## DI Registration Principle Only register a type in DI when there is a current, concrete intent to mock or replace it. Applying YAGNI, the following Application-layer types are **not** DI-registered β€” they are called or instantiated directly at the point of use: diff --git a/.github/instructions/coreex-domain.instructions.md b/.github/instructions/coreex-domain.instructions.md index e886c582..0432d5e5 100644 --- a/.github/instructions/coreex-domain.instructions.md +++ b/.github/instructions/coreex-domain.instructions.md @@ -193,6 +193,43 @@ public sealed record class ItemPricing Place value objects in a `ValueObjects/` sub-folder within the Domain project. +### Value Objects Backed by a JSON Column + +Some value objects are persisted as a single serialised JSON column rather than as separate scalar columns on the owning table (e.g. `Basket.ShippingAddress` β†’ `basket.ShippingAddressJson`). This does not change how the value object itself is authored β€” it is still a plain `sealed record`/`record class` with invariant-enforcing `init` setters, as above β€” but it does introduce an extra type at each layer boundary: + +```csharp +// Domain/ValueObjects/Address.cs β€” same conventions as any other value object +public record class Address +{ + public required string Street1 { get; init => field = value.ThrowIfNullOrEmpty(); } + public string? Street2 { get; init => field = value.ThrowIfEmpty(); } + public required string City { get; init => field = value.ThrowIfNullOrEmpty(); } + public required string PostCode { get; init => field = value.ThrowIfNullOrEmpty(); } + public required string State { get; init => field = value.ThrowIfNullOrEmpty(); } +} +``` + +The Domain value object sits in the **middle** of a three-type chain: `Contracts.Address` (DTO) ↔ `Domain.ValueObjects.Address` (this type, with invariants) ↔ `Persistence.Address` (hand-authored POCO, no invariants β€” see [`coreex-tooling.instructions.md`](/.github/instructions/coreex-tooling.instructions.md#json-columns-in-dbex-yaml) and the [`coreex-db-migration`](/.github/skills/coreex-db-migration/SKILL.md#json-columns) skill for the `dbex.yaml` column setup and Persistence POCO conventions). Two separate `BiDirectionMapper`s bridge the chain β€” one per layer boundary: + +- **Application layer** (Domain ↔ Contract) β€” see [`coreex-application-services.instructions.md`](/.github/instructions/coreex-application-services.instructions.md#json-backed-value-object-mapping). +- **Infrastructure layer** (Domain ↔ Persistence) β€” see [`coreex-repositories.instructions.md`](/.github/instructions/coreex-repositories.instructions.md). + +Wire the value object into the aggregate exactly like any other property β€” a private setter guarded by `Modify(...)` via a dedicated update method: + +```csharp +public Address? ShippingAddress { get; private set; } + +public Result UpdateShippingAddress(Address? shippingAddress) +{ + if (shippingAddress != ShippingAddress) + Modify(() => ShippingAddress = shippingAddress); + + return Result.Success; +} +``` + +The aggregate has no awareness that `ShippingAddress` is persisted as JSON β€” that is entirely an Infrastructure-layer (EF `ValueConverter`) concern via `TypeToJsonStringEfConverter`. + ## When to Introduce the Domain Layer Only introduce a Domain layer when the domain genuinely has: diff --git a/.github/instructions/coreex-tooling.instructions.md b/.github/instructions/coreex-tooling.instructions.md index 23900b15..e3327ab2 100644 --- a/.github/instructions/coreex-tooling.instructions.md +++ b/.github/instructions/coreex-tooling.instructions.md @@ -303,7 +303,7 @@ The `type:` field drives code generation: When `type:` is a complex object (not `string`, `List`, `Dictionary`, or another natively-serialisable type), a **hand-authored POCO** is required in `Infrastructure/Persistence/`. It is a plain class β€” no base class, no `[Contract]` or other attributes, no validation logic. Use `required` / non-nullable for mandatory fields and nullable only where genuinely optional. For natively-serialisable types (`List?`, `Dictionary?`, etc.) no separate class is needed. -Default column types: `NVARCHAR(MAX)` (SQL Server) / `JSONB` (PostgreSQL) β€” unless the user specifies a bounded size. +**Default column type: bounded text, matching the DB's normal string-column convention** β€” `NVARCHAR(n)` (SQL Server) / `VARCHAR(n)` (PostgreSQL) with an explicit maximum length (e.g. `NVARCHAR(2000)`/`VARCHAR(2000)` as a reasonable starting point), the same way any other text column in the database is sized. Do **not** default to unbounded `NVARCHAR(MAX)` / `TEXT` or the native `JSONB`/`JSON` type β€” those are an explicit **override**, used only when the developer deliberately wants unbounded storage or in-database JSON querying/indexing. See `samples/src/Contoso.Products.Database` (`product.tags_json` β†’ native `JSONB`, an intentional override) vs. `samples/src/Contoso.Shopping.Database` (`basket.ShippingAddressJson` β†’ bounded `NVARCHAR(2000)`, the default) for both patterns side-by-side. For the full worked examples, DDD aggregate vs CRUD service guidance, and mapper conventions see the [`coreex-db-migration`](/.github/skills/coreex-db-migration/SKILL.md#json-columns) skill. @@ -347,6 +347,7 @@ Reading the output: - Branch on the `## SCHEMA.TABLE - Exists: Yes|No` header first. `No` means the table is absent and must be created. - Use the **Qualified Name** bullet (e.g. `"public"."contact"` or `[Test].[Contact]`) for DDL casing and quoting β€” not the uppercased header text. - Honour the **Reference Data: Yes|No** flag for routing decisions (a reference data table is maintained via `ref-data.yaml` + CodeGen, not by hand). +- Honour the **Json: Yes|No** flag per column β€” it means the column is (or should be modelled as) serialised JSON content, either because it uses a native JSON database type or by the `Json`/`_json` naming convention (see [JSON columns](#json-columns-in-dbex-yaml)). A required `dbex.yaml` `columns:` entry follows from this. - PostgreSQL reports canonical type names (`CHARACTER VARYING(50)`, `TIMESTAMP WITH TIME ZONE`); treat these as equivalent to the `VARCHAR(50)` / `TIMESTAMPTZ` forms you would author in a script. - Per the disclaimer in the output, the live database remains the ultimate truth; the report is derived from system catalogs and may not capture every nuance. @@ -454,7 +455,7 @@ When authoring a migration script for an entity that has a corresponding .NET co | `DateTimeOffset` | `DATETIMEOFFSET` | `TIMESTAMPTZ` | | `DateOnly` | `DATE` | `date` | | `TimeOnly` | `TIME` | `time` | -| Complex type (class/record) | `NVARCHAR(MAX)` β€” JSON suffix convention (see [JSON columns](#json-columns-in-dbex-yaml)) | `JSONB` β€” JSON suffix convention (see [JSON columns](#json-columns-in-dbex-yaml)) | +| Complex type (class/record), or collection/dictionary (`List`, `Dictionary`) | `NVARCHAR(n)` bounded by default β€” JSON suffix convention (see [JSON columns](#json-columns-in-dbex-yaml)); `NVARCHAR(MAX)`/native `JSON` only as an explicit override | `VARCHAR(n)` bounded by default β€” JSON suffix convention (see [JSON columns](#json-columns-in-dbex-yaml)); native `JSONB` only as an explicit override | > **Creation procedure β†’ skill.** Authoring/applying a migration script is driven by the > [`coreex-db-migration`](/.github/skills/coreex-db-migration/SKILL.md) skill (inspect β†’ script β†’ fill columns β†’ diff --git a/.github/skills/coreex-aggregate/SKILL.md b/.github/skills/coreex-aggregate/SKILL.md index bd394cd4..f2fd6cf5 100644 --- a/.github/skills/coreex-aggregate/SKILL.md +++ b/.github/skills/coreex-aggregate/SKILL.md @@ -54,6 +54,7 @@ then follows the corresponding pattern from `CoreEx.DomainDriven`. - No async I/O in domain classes β€” ever. Async belongs in Application services or Policies. - No native domain-event dispatch (MediatR-style) β€” only integration events via `Aggregate.Events`, forwarded through `IUnitOfWork.Events` in the Application layer - Aggregates are the best unit-test target in the codebase (no injected dependencies) β€” cover every mutation method's happy path and rejection path with `WithGenericTester` + `Test.Scoped(...)`, calling the aggregate directly (no repository, no Application service); assert `OnCheckCanMutate()` guard failures as thrown exceptions, and failed-`Result` returns as `Result` assertions +- A value object backed by a JSON database column (e.g. `Address` stored as `basket.ShippingAddressJson`) is authored **exactly like any other value object** β€” a plain `sealed record`/`record class` with invariant-enforcing `init` setters. It sits as the middle type in a three-type chain (`Contracts.Xxx` ↔ `Domain.ValueObjects.Xxx` ↔ `Persistence.Xxx`) bridged by two separate mappers (Application: Domain↔Contract; Infrastructure: Domain↔Persistence) β€” see [`coreex-domain.instructions.md#value-objects-backed-by-a-json-column`](/.github/instructions/coreex-domain.instructions.md#value-objects-backed-by-a-json-column) and the [`coreex-db-migration`](/.github/skills/coreex-db-migration/SKILL.md#json-columns) skill for the column/mapper setup For full workflow and code examples see [`references/workflow.md`](references/workflow.md). @@ -62,7 +63,7 @@ For full workflow and code examples see [`references/workflow.md`](references/wo - [`/.github/instructions/coreex-domain.instructions.md`](/.github/instructions/coreex-domain.instructions.md) β€” full Domain layer conventions (aggregates, entities, value objects, `PersistenceState`) - [`/.github/instructions/coreex-application-services.instructions.md`](/.github/instructions/coreex-application-services.instructions.md) β€” how Application services construct, mutate, and map aggregates (`Domain.Xxx.CreateNew(...)`, `Application/Mapping/` `Mapper`) - [`/.github/instructions/coreex-tests.instructions.md`](/.github/instructions/coreex-tests.instructions.md) β€” `*.Test.Unit` conventions (`WithGenericTester`, `Test.Scoped(...)`) reused for aggregate unit tests -- Related skills: [`coreex-contract`](../coreex-contract/SKILL.md) (maps aggregate ↔ contract), [`coreex-repository`](../coreex-repository/SKILL.md) (persists aggregates via `PersistenceState`), [`coreex-app-service`](../coreex-app-service/SKILL.md) (constructs/mutates aggregates, forwards `Events`) +- Related skills: [`coreex-contract`](../coreex-contract/SKILL.md) (maps aggregate ↔ contract), [`coreex-repository`](../coreex-repository/SKILL.md) (persists aggregates via `PersistenceState`), [`coreex-app-service`](../coreex-app-service/SKILL.md) (constructs/mutates aggregates, forwards `Events`), [`coreex-db-migration`](../coreex-db-migration/SKILL.md#json-columns) (JSON column setup for JSON-backed value objects) - Deep-dive (after `/coreex-docs-sync`) β€” the primary consumer-resolvable pointers into `CoreEx.DomainDriven`: - [`/.github/docs/coreex/agents/CoreEx.DomainDriven.md`](/.github/docs/coreex/agents/CoreEx.DomainDriven.md) β€” package AI usage guide (key types, the "Domain Events β€” Intentionally Not Supported" rationale) - [`/.github/docs/coreex/domain-layer.md`](/.github/docs/coreex/domain-layer.md) β€” Domain layer sample architecture doc diff --git a/.github/skills/coreex-aggregate/references/workflow.md b/.github/skills/coreex-aggregate/references/workflow.md index 3631f348..6487d60e 100644 --- a/.github/skills/coreex-aggregate/references/workflow.md +++ b/.github/skills/coreex-aggregate/references/workflow.md @@ -212,6 +212,39 @@ public sealed record class {ValueObject} directly with object initializer syntax; there is no `PersistenceState` to track because it has no independent identity. +### Persisted as a JSON column + +When the owning aggregate/entity stores this value object as serialised JSON (naming convention: a +`{Property}Json`/`{property}_json` database column β€” see the [`coreex-db-migration`](/.github/skills/coreex-db-migration/SKILL.md#json-columns) +skill), the value object itself is authored with **no changes** to the rules above β€” it still knows +nothing about persistence or JSON. Three additional things fall out of this choice: + +1. **Three types instead of two.** The Domain value object (this type) sits between `Contracts.{ValueObject}` + (plain DTO) and `Persistence.{ValueObject}` (hand-authored POCO, no invariants, no base class β€” created + manually alongside the generated `.g.cs` files, since DbEx cannot generate a type with validation logic). +2. **Two mappers instead of one.** `BiDirectionMapper` + in `Application/Mapping/` bridges Domain ↔ Contract; `BiDirectionMapper` + in `Infrastructure/Mapping/` bridges Persistence ↔ Domain. Each mapper only ever sees its own boundary β€” never + skip straight from `Persistence.{ValueObject}` to `Contracts.{ValueObject}`. +3. **Wire it into the aggregate like any other property** β€” a private setter plus a dedicated + `Update{Property}(...)` method guarded by `Modify(...)`: + +```csharp +public {ValueObject}? {Property} { get; private set; } + +public Result Update{Property}({ValueObject}? {property}) +{ + if ({property} != {Property}) + Modify(() => {Property} = {property}); + + return Result.Success; +} +``` + +For the worked example (`Basket.ShippingAddress` / `Domain.ValueObjects.Address`) see +[`coreex-domain.instructions.md#value-objects-backed-by-a-json-column`](/.github/instructions/coreex-domain.instructions.md#value-objects-backed-by-a-json-column). +For the Application-layer mapper see [`coreex-application-services.instructions.md#json-backed-value-object-mapping`](/.github/instructions/coreex-application-services.instructions.md#json-backed-value-object-mapping). + --- ## Unit Testing Aggregates diff --git a/.github/skills/coreex-app-service/SKILL.md b/.github/skills/coreex-app-service/SKILL.md index 15d0d950..83fb66d2 100644 --- a/.github/skills/coreex-app-service/SKILL.md +++ b/.github/skills/coreex-app-service/SKILL.md @@ -59,6 +59,7 @@ Guides you through creating or modifying a CoreEx Application-layer service in ` - All interface methods include `CancellationToken cancellationToken = default` as the last parameter; pass through to every async call and `TransactionAsync(async ct => ..., cancellationToken)` - CQRS: mutations + `GetAsync` β†’ `{Name}Service`; queries + `GetAsync` β†’ `{Name}ReadService` (both have `GetAsync`) - Always `.ConfigureAwait(false)` on every `await` +- A Domain value object persisted via a JSON column (e.g. `Basket.ShippingAddress`) is mapped with a `BiDirectionMapper` in `Application/Mapping/` (not the uni-directional `Mapper` used for the root aggregate) β€” see [`coreex-application-services.instructions.md#json-backed-value-object-mapping`](/.github/instructions/coreex-application-services.instructions.md#json-backed-value-object-mapping) For full workflow and code examples see [`references/workflow.md`](references/workflow.md). diff --git a/.github/skills/coreex-app-service/references/workflow.md b/.github/skills/coreex-app-service/references/workflow.md index 461a2e34..19dce01d 100644 --- a/.github/skills/coreex-app-service/references/workflow.md +++ b/.github/skills/coreex-app-service/references/workflow.md @@ -163,6 +163,8 @@ public async Task {Action}Async(string id, CancellationToken c Use when the project has elected the ROP style. Method signatures return `Result`. `TransactionAsync` returns `Task>` when its delegate returns `Result`. Compose with `Result.GoAsync` / `.ThenAs` / `.ThenAsAsync`. > **Domain aggregate case:** When a Domain layer is present, repositories return `Result` directly (not `DataResult`). Map to contract via a `Mapper` in `Application/Mapping/` β€” call via `{Name}Mapper.Map(aggregate)`. See Shopping samples in Key References. +> +> **JSON-backed value object case:** If the aggregate holds a value object persisted via a JSON column (e.g. `Basket.ShippingAddress`), that value object needs its own `BiDirectionMapper` in `Application/Mapping/` β€” not the root aggregate's uni-directional `Mapper`, because the value flows both out (to the read contract) and in (as update input, e.g. `UpdateShippingAddressAsync`). Compose it in both directions: `{ValueObject}Mapper.To.Map(...)` in the root mapper's `OnMap`, and `{ValueObject}Mapper.From.Map(...)` when accepting the value as a service parameter before calling the aggregate's `UpdateXxx(...)` method. See [`coreex-application-services.instructions.md#json-backed-value-object-mapping`](/.github/instructions/coreex-application-services.instructions.md#json-backed-value-object-mapping). ### B1 β€” Interface and scaffold diff --git a/.github/skills/coreex-db-migration/SKILL.md b/.github/skills/coreex-db-migration/SKILL.md index 32c923d1..9d656497 100644 --- a/.github/skills/coreex-db-migration/SKILL.md +++ b/.github/skills/coreex-db-migration/SKILL.md @@ -80,7 +80,7 @@ Three things are required: 2. **A hand-authored persistence POCO** in `Infrastructure/Persistence/` when the stored type is a complex object. For natively-serialisable types (`List?`, `Dictionary?`, etc.) use the .NET type directly β€” no extra class needed. 3. **No manual `.HasConversion(...)` call** β€” `TypeToJsonStringEfConverter` is auto-wired in the generated `*DbContext.g.cs` when `type:` is non-string. -Default column types (unless the user specifies otherwise): `NVARCHAR(MAX)` (SQL Server) / `JSONB` (PostgreSQL). +Default column types (unless the user explicitly opts into unbounded/native JSON storage): bounded text matching the DB's normal string-column convention β€” `NVARCHAR(n)` (SQL Server) / `VARCHAR(n)` (PostgreSQL), e.g. `NVARCHAR(2000)`/`VARCHAR(2000)` as a reasonable starting size. `NVARCHAR(MAX)` / native `JSONB`/`JSON` are an **override** for when unbounded storage or in-database JSON querying/indexing is deliberately wanted β€” see `samples/src/Contoso.Products.Database` (`tags_json` β†’ native `JSONB`, an intentional override) vs. `samples/src/Contoso.Shopping.Database` (`ShippingAddressJson` β†’ bounded `NVARCHAR(2000)`, the default) for both side-by-side. For the full workflow, example YAML, DDD aggregate vs CRUD service guidance, and POCO class conventions see [`references/workflow.md` β€” JSON columns](references/workflow.md#json-columns). diff --git a/.github/skills/coreex-db-migration/references/workflow.md b/.github/skills/coreex-db-migration/references/workflow.md index 2245e432..ad798cfb 100644 --- a/.github/skills/coreex-db-migration/references/workflow.md +++ b/.github/skills/coreex-db-migration/references/workflow.md @@ -109,7 +109,7 @@ Remove any `DEFAULT (NEWSEQUENTIALID())`, `IDENTITY`, or `SERIAL` unless the use | `DateTime` | `DATETIME2` | `TIMESTAMP` | | `DateOnly` | `DATE` | `date` | | `TimeOnly` | `TIME` | `time` | -| Complex type (class/record) | `NVARCHAR(MAX)` β€” JSON suffix convention (see below) | `JSONB` β€” JSON suffix convention (see below) | +| Complex type (class/record), or collection/dictionary (`List`, `Dictionary`) | `NVARCHAR(n)` bounded by default β€” JSON suffix convention (see below); `NVARCHAR(MAX)`/native `JSON` only as an explicit override | `VARCHAR(n)` bounded by default β€” JSON suffix convention (see below); native `JSONB` only as an explicit override | `DateOnly`/`TimeOnly` map natively β€” no `HasConversion(...)` value converter is required on either provider (EF Core SqlServer since EF8, Npgsql since v6). See `tests/CoreEx.Database.SqlServer.Test.Unit/Repository/TestDbContext.cs` and `tests/CoreEx.Database.Postgres.Test.Unit/Repository/TestDbContext.cs` for confirmed working `HasColumnType`-only configuration. @@ -121,10 +121,12 @@ A column whose name ends with `Json` (SQL Server `PascalCase`) or `_json` (Postg | Provider | Default column type | Override | |---|---|---| -| SQL Server | `NVARCHAR(MAX)` | e.g. `NVARCHAR(4000)` if size is bounded | -| PostgreSQL | `JSONB` | `TEXT` if native JSON operators are not needed | +| SQL Server | `NVARCHAR(n)` β€” bounded, e.g. `NVARCHAR(2000)` | `NVARCHAR(MAX)` if unbounded storage is deliberately wanted | +| PostgreSQL | `VARCHAR(n)` β€” bounded, e.g. `VARCHAR(2000)` | native `JSONB` if in-database JSON operators/indexing are deliberately wanted (`JSON` if operators aren't needed but native typing still is) | -Unless the user specifies otherwise, use the maximum-length type (`NVARCHAR(MAX)` / `JSONB`). This is a NoSQL-within-SQL pattern: complex nested data is stored as a blob when no database-level operations against the JSON content (filtering, indexing on sub-fields) are needed. If the developer expects to query within the JSON, flag that β€” `JSONB` (PostgreSQL) supports operators, but the design decision should be explicit. +**Default to a bounded text type, matching how every other text column in the database is sized** β€” the same `NVARCHAR(n)` / `VARCHAR(n)` convention used for any other string property (see the contract-type mapping table above). Unless the user explicitly opts into unbounded storage (`NVARCHAR(MAX)`) or a native JSON type (`JSONB`/`JSON`) β€” typically because they want in-database JSON querying, filtering, or indexing on sub-fields β€” do not default to the maximum-length type. Native JSON types are a deliberate, explicit design decision, not the default. + +Both patterns are demonstrated side-by-side in the samples: `samples/src/Contoso.Shopping.Database` (`basket.ShippingAddressJson` β†’ bounded `NVARCHAR(2000)`, the default) and `samples/src/Contoso.Products.Database` (`product.tags_json` β†’ native `JSONB`, an intentional override for a Postgres-idiomatic collection column). #### `dbex.yaml` `columns:` entry (required) @@ -161,7 +163,7 @@ public Persistence.Address? ShippingAddress { get; set; } // typed POCO, not s ```csharp e.Property(p => p.ShippingAddress) .HasColumnName("ShippingAddressJson") - .HasColumnType("NVARCHAR(MAX)") // or "JSONB" for PostgreSQL + .HasColumnType("NVARCHAR(2000)") // bounded default; "JSONB"/"NVARCHAR(MAX)" only as an explicit override .HasConversion(TypeToJsonStringEfConverter.Default); ``` @@ -209,7 +211,7 @@ ShippingAddress = source.ShippingAddress is null ? null : new Persistence.Addres #### DDD aggregate vs CRUD service -**DDD aggregate (e.g. Shopping/Basket):** Three distinct types + two mappers exist across layer boundaries. The persistence POCO (`Persistence.Address`) is hand-authored; the domain value object (`Domain.ValueObjects.Address`) is a separate record with validation; the contract DTO (`Contracts.Address`) is a `[Contract]` class. Application-layer and Infrastructure-layer mappers each handle one boundary. +**DDD aggregate (e.g. Shopping/Basket):** Three distinct types + two mappers exist across layer boundaries. The persistence POCO (`Persistence.Address`) is hand-authored; the domain value object (`Domain.ValueObjects.Address`) is a separate record with validation; the contract DTO (`Contracts.Address`) is a `[Contract]` class. Application-layer and Infrastructure-layer mappers each handle one boundary β€” see [`coreex-domain.instructions.md`](/.github/instructions/coreex-domain.instructions.md) for creating the value object and wiring it into the aggregate, and [`coreex-application-services.instructions.md`](/.github/instructions/coreex-application-services.instructions.md) for the Application-layer mapper (Domain ↔ Contract). This Infrastructure-layer half of the pattern (Domain ↔ Persistence) is covered above and in [`coreex-repositories.instructions.md`](/.github/instructions/coreex-repositories.instructions.md). **CRUD service (e.g. Products/Tags):** Only two types β€” the persistence property (`List?`) and the contract property (`List?`) are the same CLR type, so the mapper simply assigns `Tags = source.Tags`. No dedicated POCO class or extra mapper class is needed. diff --git a/samples/src/Contoso.Products.Database/Migrations/20260731-120000-alter-products-product-add-tags-json.pgsql b/samples/src/Contoso.Products.Database/Migrations/20260731-120000-alter-products-product-add-tags-json.pgsql index 23e2849b..a6a8fb5f 100644 --- a/samples/src/Contoso.Products.Database/Migrations/20260731-120000-alter-products-product-add-tags-json.pgsql +++ b/samples/src/Contoso.Products.Database/Migrations/20260731-120000-alter-products-product-add-tags-json.pgsql @@ -1,4 +1,6 @@ -- Migration Script. +-- NOTE: native JSONB is a deliberate override of the bounded-text JSON-column default (see coreex-db-migration skill), +-- chosen here to demonstrate Postgres-idiomatic JSON storage with in-database query/index support. BEGIN TRANSACTION; diff --git a/samples/src/Contoso.Products.Database/dbex.yaml b/samples/src/Contoso.Products.Database/dbex.yaml index b9141a45..59da7437 100644 --- a/samples/src/Contoso.Products.Database/dbex.yaml +++ b/samples/src/Contoso.Products.Database/dbex.yaml @@ -16,6 +16,6 @@ tables: - name: movement - name: product columns: - - name: tags_json + - name: tags_json # native JSONB (Postgres) β€” deliberate override of the bounded-text default, since Postgres JSON operators/indexing are wanted here property: Tags type: List? \ No newline at end of file diff --git a/samples/src/Contoso.Shopping.Database/Migrations/20260622-194111-alter-shopping-basket-table.sql b/samples/src/Contoso.Shopping.Database/Migrations/20260622-194111-alter-shopping-basket-table.sql index 6aee9cad..9047a5e0 100644 --- a/samples/src/Contoso.Shopping.Database/Migrations/20260622-194111-alter-shopping-basket-table.sql +++ b/samples/src/Contoso.Shopping.Database/Migrations/20260622-194111-alter-shopping-basket-table.sql @@ -3,6 +3,6 @@ BEGIN TRANSACTION ALTER TABLE [shopping].[basket] - ADD [ShippingAddressJson] NVARCHAR(MAX) NULL + ADD [ShippingAddressJson] NVARCHAR(2000) NULL COMMIT TRANSACTION diff --git a/samples/src/Contoso.Shopping.Infrastructure/Persistence/Basket.g.cs b/samples/src/Contoso.Shopping.Infrastructure/Persistence/Basket.g.cs index f121b6fc..662facc8 100644 --- a/samples/src/Contoso.Shopping.Infrastructure/Persistence/Basket.g.cs +++ b/samples/src/Contoso.Shopping.Infrastructure/Persistence/Basket.g.cs @@ -30,7 +30,7 @@ public partial class Basket : ModelBase /// Gets or sets the value of the 'Total' column (type 'DECIMAL(18, 2)'). public decimal Total { get; set; } - /// Gets or sets the value of the 'ShippingAddressJson' column (type 'NVARCHAR(MAX) NULL'). + /// Gets or sets the value of the 'ShippingAddressJson' column (type 'NVARCHAR(2000) NULL'). public Persistence.Address? ShippingAddress { get; set; } } diff --git a/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.g.cs b/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.g.cs index 678abe2e..3c1560f6 100644 --- a/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.g.cs +++ b/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.g.cs @@ -84,7 +84,7 @@ partial void AddGeneratedModels(Microsoft.EntityFrameworkCore.ModelBuilder model e.Property(p => p.DiscountCouponCode).HasColumnName("DiscountCouponCode").HasColumnType("NVARCHAR(50)"); e.Property(p => p.DiscountAmount).HasColumnName("DiscountAmount").HasColumnType("DECIMAL(18, 2)"); e.Property(p => p.Total).HasColumnName("Total").HasColumnType("DECIMAL(18, 2)"); - e.Property(p => p.ShippingAddress).HasColumnName("ShippingAddressJson").HasColumnType("NVARCHAR(MAX)").HasConversion(TypeToJsonStringEfConverter.Default); + e.Property(p => p.ShippingAddress).HasColumnName("ShippingAddressJson").HasColumnType("NVARCHAR(2000)").HasConversion(TypeToJsonStringEfConverter.Default); e.Property(p => p.CreatedBy).HasColumnName("CreatedBy").HasColumnType("NVARCHAR(250)"); e.Property(p => p.CreatedOn).HasColumnName("CreatedOn").HasColumnType("DATETIMEOFFSET"); e.Property(p => p.UpdatedBy).HasColumnName("UpdatedBy").HasColumnType("NVARCHAR(250)"); diff --git a/src/CoreEx/Mapping/Converters/TypeToJsonStringConverter.cs b/src/CoreEx/Mapping/Converters/TypeToJsonStringConverter.cs index 14a1b30c..1d8b23c6 100644 --- a/src/CoreEx/Mapping/Converters/TypeToJsonStringConverter.cs +++ b/src/CoreEx/Mapping/Converters/TypeToJsonStringConverter.cs @@ -30,10 +30,10 @@ public TypeToJsonStringConverter() { } public IValueConverter ToSource => _convertToSource; /// - public readonly object? ConvertToDestination(object? source) => ConvertToDestination((string?)source); + public readonly object? ConvertToDestination(object? source) => ConvertToDestination((T)source!); /// - public readonly object? ConvertToSource(object? destination) => ConvertToSource((byte[]?)destination); + public readonly object? ConvertToSource(object? destination) => ConvertToSource((string?)destination); /// public readonly string? ConvertToDestination(T source) => ToDestination.Convert(source); diff --git a/tests/CoreEx.Test.Unit/Mapping/Converters/TypeToJsonStringConverterTests.cs b/tests/CoreEx.Test.Unit/Mapping/Converters/TypeToJsonStringConverterTests.cs new file mode 100644 index 00000000..615c39dc --- /dev/null +++ b/tests/CoreEx.Test.Unit/Mapping/Converters/TypeToJsonStringConverterTests.cs @@ -0,0 +1,75 @@ +using CoreEx.Mapping.Converters; + +namespace CoreEx.Test.Unit.Mapping.Converters; + +[TestFixture] +public class TypeToJsonStringConverterTests +{ + private sealed record TestValue(string Name, int Number); + + private readonly TypeToJsonStringConverter _converter = TypeToJsonStringConverter.Default; + + [Test] + public void ConvertToDestination_Value_ReturnsJson() + { + var value = new TestValue("Bob", 42); + var result = _converter.ConvertToDestination(value); + + result.Should().Be("""{"name":"Bob","number":42}"""); + } + + [Test] + public void ConvertToDestination_Null_ReturnsNull() + { + // T is unconstrained (no nullable annotation), but the underlying ValueConverter explicitly handles a null source at runtime. + _converter.ConvertToDestination(default(TestValue)!).Should().BeNull(); + } + + [Test] + public void ConvertToSource_Json_ReturnsValue() + { + var result = _converter.ConvertToSource("""{"name":"Bob","number":42}"""); + + result.Should().Be(new TestValue("Bob", 42)); + } + + [Test] + public void ConvertToSource_Null_ReturnsDefault() + { + _converter.ConvertToSource((string?)null).Should().BeNull(); + } + + [Test] + public void RoundTrip_ValueToJsonAndBack() + { + var value = new TestValue("Alice", 7); + var json = _converter.ConvertToDestination(value); + var roundTrip = _converter.ConvertToSource(json); + + roundTrip.Should().Be(value); + } + + // The following two tests exercise the non-generic IConverter object-based overloads directly, which is + // where a copy/paste bug previously caused either an InvalidCastException or infinite recursion (StackOverflowException) + // because the casts were against the wrong side's type (TDestination instead of TSource, and vice versa). + [Test] + public void IConverter_ConvertToDestination_Object_ReturnsJson() + { + IConverter converter = _converter; + var value = new TestValue("Bob", 42); + + var result = converter.ConvertToDestination(value); + + result.Should().Be("""{"name":"Bob","number":42}"""); + } + + [Test] + public void IConverter_ConvertToSource_Object_ReturnsValue() + { + IConverter converter = _converter; + + var result = converter.ConvertToSource("""{"name":"Bob","number":42}"""); + + result.Should().Be(new TestValue("Bob", 42)); + } +} From 30ed8cf51fbb16a39a09053aa1fd08e6d4c47d0d Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Mon, 3 Aug 2026 08:48:02 -0700 Subject: [PATCH 6/8] Refactor default checks & rename Json to KvpJson Replaced Comparer.Default.Compare(x, default!) with !EqualityComparer.Default.Equals(x, default!) for more accurate default value checks. Renamed Test.Table column and all related code from Json to KvpJson, including SQL, entities, mappings, DTOs, test data, and assertions. Updated data.yaml and all mapping/test logic accordingly. Minor code cleanups: added missing closing braces. --- src/CoreEx.AspNetCore/WebApiRequestOptions.cs | 2 +- .../WebApiRequestResponseOptions.cs | 2 +- .../PostgresExtensions.Parameters.cs | 6 +++--- .../SqlServerExtensions.Parameters.cs | 6 +++--- .../DatabaseExtensions.Parameters.cs | 12 ++++++------ .../EventsExtensions.CloudEvent.cs | 4 ++-- .../UnitTestExExpectations.Identifier.cs | 4 ++-- .../Abstractions/ValidatorBase.Fluent.cs | 4 ++-- .../Abstractions/ValidatorBase.cs | 6 +++--- src/CoreEx.Validation/Rules/MandatoryRule.cs | 4 ++-- .../Rules/NullNoneEmptyRule.cs | 4 ++-- .../ValidationExtensions.WhenClause.cs | 4 ++-- src/CoreEx.Validation/ValidationExtensions.cs | 4 ++-- src/CoreEx/Data/DataExtensions.Where.cs | 4 ++-- .../Entities/EntitiesExtensions.IEnumerable.cs | 4 ++-- .../Validation/ValidatorExtensions.Requires.cs | 6 +++--- .../Data/data.yaml | 18 +++++++++--------- .../Migrations/002-create-test-table.sql | 4 ++-- .../Contracts/TestTableDtoMapper.cs | 6 +++--- .../DatabaseTests.cs | 6 +++--- .../EntityFrameworkCrudTests.Create.cs | 6 +++--- .../EntityFrameworkCrudTests.Get.cs | 4 ++-- .../Models/TestTable.cs | 4 ++-- .../Models/TestTableMapper.cs | 4 ++-- .../Repository/TestDbContext.cs | 4 ++-- 25 files changed, 66 insertions(+), 66 deletions(-) diff --git a/src/CoreEx.AspNetCore/WebApiRequestOptions.cs b/src/CoreEx.AspNetCore/WebApiRequestOptions.cs index b6df85d1..08250886 100644 --- a/src/CoreEx.AspNetCore/WebApiRequestOptions.cs +++ b/src/CoreEx.AspNetCore/WebApiRequestOptions.cs @@ -60,7 +60,7 @@ public TRequest? ValueOrDefault /// [NotNull] - public TRequest Value => (Comparer.Default.Compare(ValueOrDefault, default!) == 0) + public TRequest Value => (EqualityComparer.Default.Equals(ValueOrDefault, default!)) ? throw new ValidationException(WebApiBase.RequestBodyRequiredText).WithErrorType(WebApiBase.RequestBodyErrorType) : ValueOrDefault!; diff --git a/src/CoreEx.AspNetCore/WebApiRequestResponseOptions.cs b/src/CoreEx.AspNetCore/WebApiRequestResponseOptions.cs index 35a16c77..89137d67 100644 --- a/src/CoreEx.AspNetCore/WebApiRequestResponseOptions.cs +++ b/src/CoreEx.AspNetCore/WebApiRequestResponseOptions.cs @@ -64,7 +64,7 @@ public TRequest? ValueOrDefault /// [NotNull] - public TRequest Value => (Comparer.Default.Compare(ValueOrDefault, default!) == 0) + public TRequest Value => (EqualityComparer.Default.Equals(ValueOrDefault, default!)) ? throw new ValidationException(WebApiBase.RequestBodyRequiredText).WithErrorType(WebApiBase.RequestBodyErrorType) : ValueOrDefault!; diff --git a/src/CoreEx.Database.Postgres/PostgresExtensions.Parameters.cs b/src/CoreEx.Database.Postgres/PostgresExtensions.Parameters.cs index 972863e6..c7ad7aed 100644 --- a/src/CoreEx.Database.Postgres/PostgresExtensions.Parameters.cs +++ b/src/CoreEx.Database.Postgres/PostgresExtensions.Parameters.cs @@ -76,7 +76,7 @@ public static TSelf ParamWhen(this IDatabaseParameters paramete /// The (default to ). /// The current instance to support chaining (fluent interface). public static TSelf ParamWith(this IDatabaseParameters parameters, object? with, string name, Func value, NpgsqlDbType npgsqlDbType, ParameterDirection direction = ParameterDirection.Input) - => ParamWhen(parameters, with is not null && Comparer.Default.Compare((T)with, default!) != 0, name, value, npgsqlDbType, direction); + => ParamWhen(parameters, with is not null && !EqualityComparer.Default.Equals((T)with, default!), name, value, npgsqlDbType, direction); /// /// Adds a named parameter when invoked a non-default value. @@ -91,5 +91,5 @@ public static TSelf ParamWith(this IDatabaseParameters paramete /// The (default to ). /// The current instance to support chaining (fluent interface). public static TSelf ParamWith(this IDatabaseParameters parameters, T? with, string name, Func? value, NpgsqlDbType npgsqlDbType, ParameterDirection direction = ParameterDirection.Input) - => ParamWhen(parameters, with is not null && Comparer.Default.Compare(with, default!) != 0, name, value ?? (() => with!), npgsqlDbType, direction); -} \ No newline at end of file + => ParamWhen(parameters, with is not null && !EqualityComparer.Default.Equals(with, default!), name, value ?? (() => with!), npgsqlDbType, direction); +} diff --git a/src/CoreEx.Database.SqlServer/SqlServerExtensions.Parameters.cs b/src/CoreEx.Database.SqlServer/SqlServerExtensions.Parameters.cs index 86e252c1..5c045566 100644 --- a/src/CoreEx.Database.SqlServer/SqlServerExtensions.Parameters.cs +++ b/src/CoreEx.Database.SqlServer/SqlServerExtensions.Parameters.cs @@ -76,7 +76,7 @@ public static TSelf ParamWhen(this IDatabaseParameters paramete /// The (default to ). /// The current instance to support chaining (fluent interface). public static TSelf ParamWith(this IDatabaseParameters parameters, object? with, string name, Func value, SqlDbType sqlDbType, ParameterDirection direction = ParameterDirection.Input) - => ParamWhen(parameters, with is not null && Comparer.Default.Compare((T)with, default!) != 0, name, value, sqlDbType, direction); + => ParamWhen(parameters, with is not null && !EqualityComparer.Default.Equals((T)with, default!), name, value, sqlDbType, direction); /// /// Adds a named parameter when invoked a non-default value. @@ -91,5 +91,5 @@ public static TSelf ParamWith(this IDatabaseParameters paramete /// The (default to ). /// The current instance to support chaining (fluent interface). public static TSelf ParamWith(this IDatabaseParameters parameters, T? with, string name, Func? value, SqlDbType sqlDbType, ParameterDirection direction = ParameterDirection.Input) - => ParamWhen(parameters, with is not null && Comparer.Default.Compare(with, default!) != 0, name, value ?? (() => with!), sqlDbType, direction); -} \ No newline at end of file + => ParamWhen(parameters, with is not null && !EqualityComparer.Default.Equals(with, default!), name, value ?? (() => with!), sqlDbType, direction); +} diff --git a/src/CoreEx.Database/DatabaseExtensions.Parameters.cs b/src/CoreEx.Database/DatabaseExtensions.Parameters.cs index dae3e807..8d7bd120 100644 --- a/src/CoreEx.Database/DatabaseExtensions.Parameters.cs +++ b/src/CoreEx.Database/DatabaseExtensions.Parameters.cs @@ -188,7 +188,7 @@ public static TSelf WildcardParamWhen(this IDatabaseParameters par /// The (default to ). /// The to support fluent-style method-chaining. public static TSelf ParamWith(this IDatabaseParameters parameters, T? with, string name, DbType? dbType = null, ParameterDirection direction = ParameterDirection.Input) - => ParamWhen(parameters, with is not null && Comparer.Default.Compare(with, default!) != 0, name, () => with!, dbType, direction); + => ParamWhen(parameters, with is not null && !EqualityComparer.Default.Equals(with, default!), name, () => with!, dbType, direction); /// /// Adds a named parameter when invoked a non-default value. @@ -204,7 +204,7 @@ public static TSelf ParamWith(this IDatabaseParameters paramete /// The (default to ). /// The to support fluent-style method-chaining. public static TSelf ParamWith(this IDatabaseParameters parameters, TWith? with, string name, Func value, DbType? dbType = null, ParameterDirection direction = ParameterDirection.Input) - => ParamWhen(parameters, with is not null && Comparer.Default.Compare(with, default!) != 0, name, value, dbType, direction); + => ParamWhen(parameters, with is not null && !EqualityComparer.Default.Equals(with, default!), name, value, dbType, direction); /// /// Adds a named parameter when invoked a non-default value serialized as a JSON . @@ -216,7 +216,7 @@ public static TSelf ParamWith(this IDatabaseParametersThe parameter name. /// The to support fluent-style method-chaining. public static TSelf JsonParamWith(this IDatabaseParameters parameters, T? with, string name) - => JsonParamWhen(parameters, with is not null && Comparer.Default.Compare(with, default!) != 0, name, () => with); + => JsonParamWhen(parameters, with is not null && !EqualityComparer.Default.Equals(with, default!), name, () => with); /// /// Adds a named parameter when invoked a non-default value serialized as a JSON . @@ -230,7 +230,7 @@ public static TSelf JsonParamWith(this IDatabaseParameters para /// The parameter value. /// The to support fluent-style method-chaining. public static TSelf JsonParamWith(this IDatabaseParameters parameters, TWith? with, string name, Func value) - => JsonParamWhen(parameters, with is not null && Comparer.Default.Compare(with, default!) != 0, name, value); + => JsonParamWhen(parameters, with is not null && !EqualityComparer.Default.Equals(with, default!), name, value); /// /// Adds a named parameter when invoked with a non-default (converted for the database). @@ -254,7 +254,7 @@ public static TSelf WildcardParamWith(this IDatabaseParameters par /// The parameter name. /// The to support fluent-style method-chaining. public static TSelf WildcardParamWith(this IDatabaseParameters parameters, TWith? with, string name, Func wildcard) - => WildcardParamWhen(parameters, with is not null && Comparer.Default.Compare(with, default!) != 0, name, wildcard); + => WildcardParamWhen(parameters, with is not null && !EqualityComparer.Default.Equals(with, default!), name, wildcard); #endregion @@ -383,4 +383,4 @@ public static DbParameter SetDirectionWhenOperationType(this DbParameter paramet return parameter; } -} \ No newline at end of file +} diff --git a/src/CoreEx.Events/EventsExtensions.CloudEvent.cs b/src/CoreEx.Events/EventsExtensions.CloudEvent.cs index 8f906c8f..97b4de2c 100644 --- a/src/CoreEx.Events/EventsExtensions.CloudEvent.cs +++ b/src/CoreEx.Events/EventsExtensions.CloudEvent.cs @@ -147,7 +147,7 @@ public override void DecodeBinaryModeEventData(ReadOnlyMemory body, CloudE /// The attribute value. public static void SetExtensionAttribute(this CloudEvent ce, string name, T value) { - if (Comparer.Default.Compare(value, default!) == 0) + if (EqualityComparer.Default.Equals(value, default!)) return; ce[name] = value; @@ -172,4 +172,4 @@ public static bool TryGetExtensionAttribute(this CloudEvent ce, string name, value = (T)val; return true; } -} \ No newline at end of file +} diff --git a/src/CoreEx.UnitTesting/UnitTestExExpectations.Identifier.cs b/src/CoreEx.UnitTesting/UnitTestExExpectations.Identifier.cs index 76dd9c89..31d24623 100644 --- a/src/CoreEx.UnitTesting/UnitTestExExpectations.Identifier.cs +++ b/src/CoreEx.UnitTesting/UnitTestExExpectations.Identifier.cs @@ -26,7 +26,7 @@ Task extension(AssertArgs args) if (identifier is null) { - if (System.Collections.Comparer.Default.Compare(id!.Id, id!.GetType().IsClass ? null! : Activator.CreateInstance(id!.GetType())) == 0) + if (System.Collections.Generic.EqualityComparer.Default.Equals(id!.Id, id!.GetType().IsClass ? null! : Activator.CreateInstance(id!.GetType()))) args.Tester.Implementor.AssertFail($"Expected {pn} to have a non-default value."); } else @@ -46,4 +46,4 @@ Task extension(AssertArgs args) /// The tester. /// The instance to support fluent-style method-chaining. public static TSelf IgnoreIdentifier(this IValueExpectations tester) where TSelf : IValueExpectations => IgnorePaths(tester, nameof(IIdentifierCore.Id)); -} \ No newline at end of file +} diff --git a/src/CoreEx.Validation/Abstractions/ValidatorBase.Fluent.cs b/src/CoreEx.Validation/Abstractions/ValidatorBase.Fluent.cs index 8b297d95..aff8779f 100644 --- a/src/CoreEx.Validation/Abstractions/ValidatorBase.Fluent.cs +++ b/src/CoreEx.Validation/Abstractions/ValidatorBase.Fluent.cs @@ -22,9 +22,9 @@ protected IRootPropertyRule RuleFor(Expression(metadata, e => metadata.GetValue(e).GetValueOrDefault(), - e => Comparer.Default.Compare(metadata.GetValue(e).GetValueOrDefault(), default) == 0); + e => EqualityComparer.Default.Equals(metadata.GetValue(e).GetValueOrDefault(), default)); Rules.Add(rule); return rule; } -} \ No newline at end of file +} diff --git a/src/CoreEx.Validation/Abstractions/ValidatorBase.cs b/src/CoreEx.Validation/Abstractions/ValidatorBase.cs index 00addc91..9d06ff2c 100644 --- a/src/CoreEx.Validation/Abstractions/ValidatorBase.cs +++ b/src/CoreEx.Validation/Abstractions/ValidatorBase.cs @@ -32,7 +32,7 @@ protected IRootPropertyRule Property(Expression(metadata, e => metadata.GetValue(e).GetValueOrDefault(), - e => Comparer.Default.Compare(metadata.GetValue(e).GetValueOrDefault(), default) == 0); + e => EqualityComparer.Default.Equals(metadata.GetValue(e).GetValueOrDefault(), default)); Rules.Add(rule); return rule; @@ -70,7 +70,7 @@ public TSelf HasProperty(Expression> proper var metadata = RuntimeMetadata.GetForExpression(propertyExpression.ThrowIfNull()); return HasPropertyInternal(metadata, configure, e => metadata.GetValue(e).GetValueOrDefault(), - e => Comparer.Default.Compare(metadata.GetValue(e).GetValueOrDefault(), default) == 0); + e => EqualityComparer.Default.Equals(metadata.GetValue(e).GetValueOrDefault(), default)); } /// @@ -174,4 +174,4 @@ Task IValidatorEx.ValidateAsync(IValidationContext context, Ca /// The . /// The . internal abstract Task ValidateAsync(IValidationContext context, CancellationToken cancellationToken); -} \ No newline at end of file +} diff --git a/src/CoreEx.Validation/Rules/MandatoryRule.cs b/src/CoreEx.Validation/Rules/MandatoryRule.cs index 4ec4fdf4..19b2e891 100644 --- a/src/CoreEx.Validation/Rules/MandatoryRule.cs +++ b/src/CoreEx.Validation/Rules/MandatoryRule.cs @@ -33,7 +33,7 @@ protected override Task OnValidateAsync(PropertyContext cont if (mustNotBeDefault && context.IsValueNullable && context.IsNullableValueDefault()) return AddError(context); - if (mustNotBeDefault && !context.IsValueNullable && Comparer.Default.Compare(context.Value, default) == 0) + if (mustNotBeDefault && !context.IsValueNullable && EqualityComparer.Default.Equals(context.Value, default)) return AddError(context); if (!mustNotBeEmpty) @@ -66,4 +66,4 @@ private Task AddError(PropertyContext context) context.AddError(ErrorText ?? ValidatorStrings.MandatoryFormat); return Task.CompletedTask; } -} \ No newline at end of file +} diff --git a/src/CoreEx.Validation/Rules/NullNoneEmptyRule.cs b/src/CoreEx.Validation/Rules/NullNoneEmptyRule.cs index f7e9ec93..27551b59 100644 --- a/src/CoreEx.Validation/Rules/NullNoneEmptyRule.cs +++ b/src/CoreEx.Validation/Rules/NullNoneEmptyRule.cs @@ -32,7 +32,7 @@ protected override Task OnValidateAsync(PropertyContext cont if (mustBeDefault && context.IsValueNullable && !context.IsNullableValueDefault()) return AddError(context); - if (mustBeDefault && !context.IsValueNullable && Comparer.Default.Compare(context.Value, default) != 0) + if (mustBeDefault && !context.IsValueNullable && !EqualityComparer.Default.Equals(context.Value, default)) return AddError(context); if (!mustBeEmpty) @@ -67,4 +67,4 @@ private Task AddError(PropertyContext context) context.AddError(ErrorText ?? ValidatorStrings.NoneFormat); return Task.CompletedTask; } -} \ No newline at end of file +} diff --git a/src/CoreEx.Validation/ValidationExtensions.WhenClause.cs b/src/CoreEx.Validation/ValidationExtensions.WhenClause.cs index 7cdd0970..9a282e0c 100644 --- a/src/CoreEx.Validation/ValidationExtensions.WhenClause.cs +++ b/src/CoreEx.Validation/ValidationExtensions.WhenClause.cs @@ -76,5 +76,5 @@ public static IPropertyRule When(this IP /// The being extended. /// The to support fluent-style method-chaining. public static IPropertyRule WhenHasValue(this IPropertyRule rule) where TEntity : class - => AddClause(rule, new WhenClause((c, _) => Task.FromResult(Comparer.Default.Compare(c.Value, default!) != 0))); -} \ No newline at end of file + => AddClause(rule, new WhenClause((c, _) => Task.FromResult(!EqualityComparer.Default.Equals(c.Value, default!)))); +} diff --git a/src/CoreEx.Validation/ValidationExtensions.cs b/src/CoreEx.Validation/ValidationExtensions.cs index f2ce67a9..3f2e38de 100644 --- a/src/CoreEx.Validation/ValidationExtensions.cs +++ b/src/CoreEx.Validation/ValidationExtensions.cs @@ -150,5 +150,5 @@ public static IValueValidator Validator(this T? value, ActionThe should be used to further configure the validation rules, clauses, etc. /// Finally, the or , should be invoked to execute the underlying validation. public static IValueValidator Validator(this T? value, Action, T?>>? configure, [CallerArgumentExpression(nameof(value))] string? name = null, LText? text = null, string? jsonName = null) where T : struct - => new ValueValidator(value, name ?? Validation.ValueName, jsonName, text, configure, e => e.Value.GetValueOrDefault(), e => Comparer.Default.Compare(e.Value.GetValueOrDefault(), default) == 0); -} \ No newline at end of file + => new ValueValidator(value, name ?? Validation.ValueName, jsonName, text, configure, e => e.Value.GetValueOrDefault(), e => EqualityComparer.Default.Equals(e.Value.GetValueOrDefault(), default)); +} diff --git a/src/CoreEx/Data/DataExtensions.Where.cs b/src/CoreEx/Data/DataExtensions.Where.cs index 8ba5c935..58c7874d 100644 --- a/src/CoreEx/Data/DataExtensions.Where.cs +++ b/src/CoreEx/Data/DataExtensions.Where.cs @@ -24,7 +24,7 @@ public static partial class DataExtensions /// Where the is an it will also ensure there is at least a single item. public static IQueryable WhereWith(this IQueryable source, TWith with, Expression> predicate) { - if (Comparer.Default.Compare(with, default!) != 0) + if (!EqualityComparer.Default.Equals(with, default!)) { if (with is not string && with is IEnumerable ie && !ie.GetEnumerator().MoveNext()) return source; @@ -90,4 +90,4 @@ public static IQueryable WhereWildcard(this IQueryable>(exp, selector.Parameters)); } -} \ No newline at end of file +} diff --git a/src/CoreEx/Entities/EntitiesExtensions.IEnumerable.cs b/src/CoreEx/Entities/EntitiesExtensions.IEnumerable.cs index 6de5f34b..999ec3cc 100644 --- a/src/CoreEx/Entities/EntitiesExtensions.IEnumerable.cs +++ b/src/CoreEx/Entities/EntitiesExtensions.IEnumerable.cs @@ -27,7 +27,7 @@ public static partial class EntitiesExtensions /// Where the is an it will also ensure there is at least a single item. public static IEnumerable WhereWith(this IEnumerable source, TWith with, Func predicate) { - if (Comparer.Default.Compare(with, default!) != 0) + if (!EqualityComparer.Default.Equals(with, default!)) { if (with is not string && with is IEnumerable ie && !ie.GetEnumerator().MoveNext()) return source; @@ -120,4 +120,4 @@ public static IEnumerable WithPaging(this IEnumerable source, PagingArg paging ??= PagingArgs.Create(); return source.Skip(paging.Skip).Take(paging.Take); } -} \ No newline at end of file +} diff --git a/src/CoreEx/Validation/ValidatorExtensions.Requires.cs b/src/CoreEx/Validation/ValidatorExtensions.Requires.cs index 55319725..989d336e 100644 --- a/src/CoreEx/Validation/ValidatorExtensions.Requires.cs +++ b/src/CoreEx/Validation/ValidatorExtensions.Requires.cs @@ -13,7 +13,7 @@ public static partial class ValidatorExtensions /// Thrown where the value is default. [return: NotNull()] public static T Required(this T value, [CallerArgumentExpression(nameof(value))] string? name = null, LText? text = null) - => (Comparer.Default.Compare(value, default!) == 0) ? throw Validation.CreateRequiredValueResult(name, text).Error : value!; + => EqualityComparer.Default.Equals(value, default!) ? throw Validation.CreateRequiredValueResult(name, text).Error : value!; /// /// Requires (validates) that the is non-default and continues; otherwise, will return the with a corresponding . @@ -30,7 +30,7 @@ public static T Required(this T value, [CallerArgumentExpression(nameof(value value.ThrowIfNull(); name.ThrowIfNullOrEmpty(); - return result.IsSuccess && Comparer.Default.Compare(value(), default!) == 0 ? Validation.CreateRequiredValueResult(name, text) : result; + return result.IsSuccess && EqualityComparer.Default.Equals(value(), default!) ? Validation.CreateRequiredValueResult(name, text) : result; } /// @@ -45,4 +45,4 @@ public static T Required(this T value, [CallerArgumentExpression(nameof(value /// The resulting public static TResult Requires(this TResult result, T value, [CallerArgumentExpression(nameof(value))] string? name = null, LText? text = null) where TResult : IResult, new() => result.Requires(() => value, name ?? Validation.ValueName, text); -} \ No newline at end of file +} diff --git a/tests/CoreEx.Database.SqlServer.Test.Console/Data/data.yaml b/tests/CoreEx.Database.SqlServer.Test.Console/Data/data.yaml index 3670a24b..ac5dff5e 100644 --- a/tests/CoreEx.Database.SqlServer.Test.Console/Data/data.yaml +++ b/tests/CoreEx.Database.SqlServer.Test.Console/Data/data.yaml @@ -1,12 +1,12 @@ Test: - Table: - { TableId: 1 } - - { TableId: 2, Text: Abc, Number: 123, Amount: 45.67, Flag: true, Date: 2024-06-20, Time: 14:30:59, TenantId: A, Json: {"Key":"Value"} } - - { TableId: 3, Text: Ace, Number: 567, Amount: 45.67, Flag: true, Date: 2024-03-13, Time: 14:30:59, TenantId: A, Json: {"Key":"Value"} } - - { TableId: 4, Text: Bdf, Number: 901, Amount: 45.67, Flag: false, Date: 2025-01-08, Time: 14:30:59, TenantId: B, IsDeleted: 1, Json: {"Key":"Value"} } - - { TableId: 5, Text: Qrs, Number: 765, Amount: 45.67, Flag: true, Date: 2024-06-20, Time: 14:30:59, TenantId: B, Json: {"Key":"Value"} } - - { TableId: 6, Text: Jkl, Number: 765, Amount: 56.24, Flag: true, Date: 2024-06-20, Time: 14:30:59, TenantId: A, Json: {"Key":"Value"} } - - { TableId: 7, Text: Blq, Number: 765, Amount: 45.67, Flag: false, Date: 2024-06-20, Time: 14:30:59, TenantId: A, Json: {"Key":"Value"} } - - { TableId: 8, Text: Zyi, Number: 765, Amount: 99.23, Flag: true, Date: 2024-06-20, Time: 14:30:59, TenantId: A, Json: {"Key":"Value"} } - - { TableId: 9, Text: Abz, Number: 765, Amount: 45.67, Flag: true, Date: 2024-06-20, Time: 14:30:59, TenantId: A, Json: {"Key":"Value"} } - - { TableId: 10, Text: Qpo, Number: 881, Amount: 12.67, Flag: true, Date: 2024-06-20, Time: 14:30:59, TenantId: A, IsDeleted: 1, Json: {"Key":"Value"} } \ No newline at end of file + - { TableId: 2, Text: Abc, Number: 123, Amount: 45.67, Flag: true, Date: 2024-06-20, Time: 14:30:59, TenantId: A, KvpJson: {"Key":"Value"} } + - { TableId: 3, Text: Ace, Number: 567, Amount: 45.67, Flag: true, Date: 2024-03-13, Time: 14:30:59, TenantId: A, KvpJson: {"Key":"Value"} } + - { TableId: 4, Text: Bdf, Number: 901, Amount: 45.67, Flag: false, Date: 2025-01-08, Time: 14:30:59, TenantId: B, IsDeleted: 1, KvpJson: {"Key":"Value"} } + - { TableId: 5, Text: Qrs, Number: 765, Amount: 45.67, Flag: true, Date: 2024-06-20, Time: 14:30:59, TenantId: B, KvpJson: {"Key":"Value"} } + - { TableId: 6, Text: Jkl, Number: 765, Amount: 56.24, Flag: true, Date: 2024-06-20, Time: 14:30:59, TenantId: A, KvpJson: {"Key":"Value"} } + - { TableId: 7, Text: Blq, Number: 765, Amount: 45.67, Flag: false, Date: 2024-06-20, Time: 14:30:59, TenantId: A, KvpJson: {"Key":"Value"} } + - { TableId: 8, Text: Zyi, Number: 765, Amount: 99.23, Flag: true, Date: 2024-06-20, Time: 14:30:59, TenantId: A, KvpJson: {"Key":"Value"} } + - { TableId: 9, Text: Abz, Number: 765, Amount: 45.67, Flag: true, Date: 2024-06-20, Time: 14:30:59, TenantId: A, KvpJson: {"Key":"Value"} } + - { TableId: 10, Text: Qpo, Number: 881, Amount: 12.67, Flag: true, Date: 2024-06-20, Time: 14:30:59, TenantId: A, IsDeleted: 1, KvpJson: {"Key":"Value"} } diff --git a/tests/CoreEx.Database.SqlServer.Test.Console/Migrations/002-create-test-table.sql b/tests/CoreEx.Database.SqlServer.Test.Console/Migrations/002-create-test-table.sql index d773a458..e10c5f13 100644 --- a/tests/CoreEx.Database.SqlServer.Test.Console/Migrations/002-create-test-table.sql +++ b/tests/CoreEx.Database.SqlServer.Test.Console/Migrations/002-create-test-table.sql @@ -6,7 +6,7 @@ CREATE TABLE [Test].[Table] ( [Flag] BIT NULL, [Date] DATE NULL, [Time] TIME NULL, - [Json] NVARCHAR (500) NULL, + [KvpJson] NVARCHAR (500) NULL, [TenantId] NVARCHAR(20) NULL, [RowVersion] TIMESTAMP NOT NULL, [CreatedBy] NVARCHAR(250) NULL, @@ -14,4 +14,4 @@ CREATE TABLE [Test].[Table] ( [UpdatedBy] NVARCHAR(250) NULL, [UpdatedOn] DATETIMEOFFSET NULL, [IsDeleted] BIT DEFAULT 0 NOT NULL -) \ No newline at end of file +) diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/Contracts/TestTableDtoMapper.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/Contracts/TestTableDtoMapper.cs index 6ed5846d..d439561a 100644 --- a/tests/CoreEx.Database.SqlServer.Test.Unit/Contracts/TestTableDtoMapper.cs +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/Contracts/TestTableDtoMapper.cs @@ -15,7 +15,7 @@ public class TestTableDtoMapper : BiDirectionMapper(JsonDefaults.SerializerOptions) + Key = source.KvpJson?.Deserialize(JsonDefaults.SerializerOptions) }.MapStandardFrom(source); -} \ No newline at end of file +} diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/DatabaseTests.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/DatabaseTests.cs index 48749335..1b32773f 100644 --- a/tests/CoreEx.Database.SqlServer.Test.Unit/DatabaseTests.cs +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/DatabaseTests.cs @@ -19,7 +19,7 @@ public void SelectAndGetNullValues() => Test.ScopedType(test tt.Flag.Should().BeNull(); tt.Date.Should().BeNull(); tt.Time.Should().BeNull(); - tt.Json.Should().BeNull(); + tt.KvpJson.Should().BeNull(); tt.ETag.Should().NotBeNull(); tt.CreatedBy.Should().NotBeNull(); tt.CreatedOn.Should().NotBeNull(); @@ -44,7 +44,7 @@ public void SelectAndGetValues() => Test.ScopedType(test => tt.Date.Should().Be(new DateOnly(2024, 6, 20)); tt.Time.Should().Be(new TimeOnly(14, 30, 59)); tt.ETag.Should().NotBeNull(); - tt.Json.Should().NotBeNull().And.Subject.ToString().Should().Be("{\"Key\": \"Value\"}"); + tt.KvpJson.Should().NotBeNull().And.Subject.ToString().Should().Be("{\"Key\": \"Value\"}"); tt.CreatedBy.Should().NotBeNull(); tt.CreatedOn.Should().NotBeNull(); tt.UpdatedBy.Should().BeNull(); @@ -188,4 +188,4 @@ public void NonQueryAsync() => Test.ScopedType(test => number.Should().Be(89); }).AssertSuccess(); }); -} \ No newline at end of file +} diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTests.Create.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTests.Create.cs index 1a72f018..8e2be22d 100644 --- a/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTests.Create.cs +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTests.Create.cs @@ -79,7 +79,7 @@ public void Create_Success() => Test.ScopedType(test => test.R Flag = true, Date = new DateOnly(2024, 7, 1), Time = new TimeOnly(10, 20, 30), - Json = jd.RootElement.Clone() + KvpJson = jd.RootElement.Clone() }; var created = await ef.Table.CreateAsync(m).ConfigureAwait(false); @@ -92,7 +92,7 @@ public void Create_Success() => Test.ScopedType(test => test.R created.Value.Flag.Should().BeTrue(); created.Value.Date.Should().Be(new DateOnly(2024, 7, 1)); created.Value.Time.Should().Be(new TimeOnly(10, 20, 30)); - created.Value.Json.Should().NotBeNull().And.Subject.ToString().Should().Be("{\"Key\": \"Value\"}"); + created.Value.KvpJson.Should().NotBeNull().And.Subject.ToString().Should().Be("{\"Key\": \"Value\"}"); created.Value.ETag.Should().NotBeNull(); created.Value.TenantId.Should().Be("A"); created.Value.CreatedBy.Should().NotBeNull(); @@ -143,4 +143,4 @@ public void Create_Mapped_Success() => Test.ScopedType(test => v = await ef.TableDto.GetAsync(id).ConfigureAwait(false); ObjectComparer.Assert(created.Value, v); }).AssertSuccess()); -} \ No newline at end of file +} diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTests.Get.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTests.Get.cs index e5a50a00..5d4e5a5d 100644 --- a/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTests.Get.cs +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkCrudTests.Get.cs @@ -51,7 +51,7 @@ public void Get_Found() => Test.ScopedType(test => test.Run(as tt.Flag.Should().BeTrue(); tt.Date.Should().Be(new DateOnly(2024, 6, 20)); tt.Time.Should().Be(new TimeOnly(14, 30, 59)); - tt.Json.Should().NotBeNull().And.Subject.ToString().Should().Be("{\"Key\": \"Value\"}"); + tt.KvpJson.Should().NotBeNull().And.Subject.ToString().Should().Be("{\"Key\": \"Value\"}"); tt.CreatedBy.Should().NotBeNull(); tt.CreatedOn.Should().NotBeNull(); tt.UpdatedBy.Should().BeNull(); @@ -79,4 +79,4 @@ public void Get_Mapped_Found() => Test.ScopedType(test => test dto.ChangeLog.UpdatedBy.Should().BeNull(); dto.ChangeLog.UpdatedOn.Should().BeNull(); }).AssertSuccess()); -} \ No newline at end of file +} diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/Models/TestTable.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/Models/TestTable.cs index 7c5c4eb0..5245a243 100644 --- a/tests/CoreEx.Database.SqlServer.Test.Unit/Models/TestTable.cs +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/Models/TestTable.cs @@ -13,7 +13,7 @@ public class TestTable : IIdentifier, IETag, IChangeLogEx, ITenantId, ILog public bool? Flag { get; set; } public DateOnly? Date { get; set; } public TimeOnly? Time { get; set; } - public JsonElement? Json { get; set; } + public JsonElement? KvpJson { get; set; } public string? ETag { get; set; } public string? CreatedBy { get; set; } public DateTimeOffset? CreatedOn { get; set; } @@ -21,4 +21,4 @@ public class TestTable : IIdentifier, IETag, IChangeLogEx, ITenantId, ILog public DateTimeOffset? UpdatedOn { get; set; } public string? TenantId { get; set; } public bool IsDeleted { get; set; } -} \ No newline at end of file +} diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/Models/TestTableMapper.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/Models/TestTableMapper.cs index 2a054331..7337426f 100644 --- a/tests/CoreEx.Database.SqlServer.Test.Unit/Models/TestTableMapper.cs +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/Models/TestTableMapper.cs @@ -14,6 +14,6 @@ public class TestTableMapper : DatabaseMapper Flag = r.GetValue("Flag"), Date = r.GetValue("Date"), Time = r.GetValue("Time"), - Json = r.GetValueFromJson("Json") + KvpJson = r.GetValueFromJson("KvpJson") }.MapStandardFromDb(r); -} \ No newline at end of file +} diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/Repository/TestDbContext.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/Repository/TestDbContext.cs index 0343ee39..e459d2b0 100644 --- a/tests/CoreEx.Database.SqlServer.Test.Unit/Repository/TestDbContext.cs +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/Repository/TestDbContext.cs @@ -34,7 +34,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) e.Property(p => p.Flag).HasColumnName("Flag").HasColumnType("BIT"); e.Property(p => p.Date).HasColumnName("Date").HasColumnType("DATE"); e.Property(p => p.Time).HasColumnName("Time").HasColumnType("TIME"); - e.Property(p => p.Json).HasColumnName("Json").HasColumnType("NVARCHAR(500)").HasConversion(JsonElementStringEfConverter.Default); + e.Property(p => p.KvpJson).HasColumnName("KvpJson").HasColumnType("NVARCHAR(500)").HasConversion(JsonElementStringEfConverter.Default); e.Property(p => p.TenantId).HasColumnName("TenantId").HasColumnType("NVARCHAR(20)"); e.Property(p => p.ETag).HasColumnName("RowVersion").HasColumnType("TIMESTAMP").IsRowVersion().HasConversion(ValueConverterBridge.Create(BaseDatabase.RowVersionConverter)); e.Property(p => p.CreatedBy).HasColumnName("CreatedBy").HasColumnType("NVARCHAR(250)").ValueGeneratedOnUpdate(); @@ -44,4 +44,4 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) e.Property(p => p.IsDeleted).HasColumnName("IsDeleted").HasColumnType("BIT").HasDefaultValue(false); }); } -} \ No newline at end of file +} From 4b3c1294d22a3d1695a2f8c6523e133169c0e723 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Mon, 3 Aug 2026 09:22:04 -0700 Subject: [PATCH 7/8] Clarify codegen ownership; update ShippingAddressJson type - Expanded AGENTS.md pattern catalog with explicit rules on generated code ownership (never edit `*.g.cs`, `*.g.sql`, or `*.g.pgsql` directly) and clarified house rules (all `using` in `GlobalUsings.cs`, use `AwesomeAssertions`, explicit mapping, `.ConfigureAwait(false)`, file-scoped namespaces, etc.). - Re-executed code-generation as the AI-assistant previously updated the generated files directly. --- AGENTS.md | 21 +++++++++++++++++++ .../Repositories/ProductsDbContext.g.cs | 4 ++-- .../Persistence/Basket.g.cs | 2 +- .../Repositories/ShoppingDbContext.g.cs | 2 +- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8d338185..199138e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,27 @@ If you are contributing to the CoreEx framework itself, see commands, coding conventions, and the full contributor instruction set. That file is injected automatically by GitHub Copilot when working in this repo. +### Generated Code + +Never create or edit `*.g.cs`, `*.g.sql`, or `*.g.pgsql` files directly. Each generator owns its outputs: + +| File pattern | Generator | Change instead | +|---|---|---| +| `*.g.cs` (contracts, ref-data) | Roslyn source generator (`CoreEx.Generator`) | The `[Contract]`- or `[ReferenceData]`-decorated partial class | +| `*.g.cs` (ref-data layer β€” controller, service, repository, mapper) | `*.CodeGen` project (CoreEx.CodeGen + `ref-data.yaml`) | `ref-data.yaml` config or the Handlebars templates in `CoreEx.CodeGen/RefData/Templates/` | +| `*.g.sql`, `*.g.pgsql`, `*DbContext.g.cs`, `Persistence/*.g.cs` | `*.Database` project (DbEx) | DbEx YAML config or SQL migration scripts | + +### House Rules + +Rules that are easy to violate and cause real breakage or wrong choices: + +- **`GlobalUsings.cs`** β€” every project has a single `GlobalUsings.cs` at the project root; all `using` statements go there, never in individual source files. The Roslyn code generator emits no `using` statements and depends on this. +- **`AwesomeAssertions` not FluentAssertions** β€” tests use the `AwesomeAssertions` NuGet package. Do not reach for FluentAssertions. +- **Polyglot data** β€” Products uses PostgreSQL (`CoreEx.Database.Postgres`); Shopping uses SQL Server (`CoreEx.Database.SqlServer`). Do not assume SQL Server when working on Products, and do not mix outbox/publisher helpers across domains. +- **No AutoMapper** β€” do not introduce AutoMapper. All mapping is explicit via `Mapper<>` (application layer) or `BiDirectionMapper<>` (infrastructure layer). +- **`.ConfigureAwait(false)`** β€” always use it in service and repository code. +- **File-scoped namespaces** β€” `namespace Foo.Bar;` only; never block-scoped `namespace Foo.Bar { }`. + --- ## Using CoreEx in a Consumer Project (Cold Start) diff --git a/samples/src/Contoso.Products.Infrastructure/Repositories/ProductsDbContext.g.cs b/samples/src/Contoso.Products.Infrastructure/Repositories/ProductsDbContext.g.cs index 2e068c04..9a838f82 100644 --- a/samples/src/Contoso.Products.Infrastructure/Repositories/ProductsDbContext.g.cs +++ b/samples/src/Contoso.Products.Infrastructure/Repositories/ProductsDbContext.g.cs @@ -168,15 +168,15 @@ partial void AddGeneratedModels(Microsoft.EntityFrameworkCore.ModelBuilder model e.Property(p => p.Price).HasColumnName("price").HasColumnType("NUMERIC(18, 2)"); e.Property(p => p.IsInactive).HasColumnName("is_inactive").HasColumnType("BOOLEAN"); e.Property(p => p.IsNonStocked).HasColumnName("is_non_stocked").HasColumnType("BOOLEAN"); + e.Property(p => p.Tags).HasColumnName("tags_json").HasColumnType("JSONB").HasConversion(TypeToJsonStringEfConverter?>.Default); e.Property(p => p.CreatedBy).HasColumnName("created_by").HasColumnType("CHARACTER VARYING(250)"); e.Property(p => p.CreatedOn).HasColumnName("created_on").HasColumnType("TIMESTAMP WITH TIME ZONE"); e.Property(p => p.UpdatedBy).HasColumnName("updated_by").HasColumnType("CHARACTER VARYING(250)"); e.Property(p => p.UpdatedOn).HasColumnName("updated_on").HasColumnType("TIMESTAMP WITH TIME ZONE"); e.Property(p => p.ETag).HasColumnName("xmin").HasColumnType("XID").IsRowVersion().HasConversion(ValueConverterBridge.Create(BaseDatabase.RowVersionConverter)); - e.Property(p => p.Tags).HasColumnName("tags_json").HasColumnType("JSONB").HasConversion(TypeToJsonStringEfConverter?>.Default); e.Property(p => p.IsDeleted).HasColumnName("is_deleted").HasColumnType("BOOLEAN"); }); } } -#nullable restore \ No newline at end of file +#nullable restore diff --git a/samples/src/Contoso.Shopping.Infrastructure/Persistence/Basket.g.cs b/samples/src/Contoso.Shopping.Infrastructure/Persistence/Basket.g.cs index 662facc8..f121b6fc 100644 --- a/samples/src/Contoso.Shopping.Infrastructure/Persistence/Basket.g.cs +++ b/samples/src/Contoso.Shopping.Infrastructure/Persistence/Basket.g.cs @@ -30,7 +30,7 @@ public partial class Basket : ModelBase /// Gets or sets the value of the 'Total' column (type 'DECIMAL(18, 2)'). public decimal Total { get; set; } - /// Gets or sets the value of the 'ShippingAddressJson' column (type 'NVARCHAR(2000) NULL'). + /// Gets or sets the value of the 'ShippingAddressJson' column (type 'NVARCHAR(MAX) NULL'). public Persistence.Address? ShippingAddress { get; set; } } diff --git a/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.g.cs b/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.g.cs index 3c1560f6..678abe2e 100644 --- a/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.g.cs +++ b/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.g.cs @@ -84,7 +84,7 @@ partial void AddGeneratedModels(Microsoft.EntityFrameworkCore.ModelBuilder model e.Property(p => p.DiscountCouponCode).HasColumnName("DiscountCouponCode").HasColumnType("NVARCHAR(50)"); e.Property(p => p.DiscountAmount).HasColumnName("DiscountAmount").HasColumnType("DECIMAL(18, 2)"); e.Property(p => p.Total).HasColumnName("Total").HasColumnType("DECIMAL(18, 2)"); - e.Property(p => p.ShippingAddress).HasColumnName("ShippingAddressJson").HasColumnType("NVARCHAR(2000)").HasConversion(TypeToJsonStringEfConverter.Default); + e.Property(p => p.ShippingAddress).HasColumnName("ShippingAddressJson").HasColumnType("NVARCHAR(MAX)").HasConversion(TypeToJsonStringEfConverter.Default); e.Property(p => p.CreatedBy).HasColumnName("CreatedBy").HasColumnType("NVARCHAR(250)"); e.Property(p => p.CreatedOn).HasColumnName("CreatedOn").HasColumnType("DATETIMEOFFSET"); e.Property(p => p.UpdatedBy).HasColumnName("UpdatedBy").HasColumnType("NVARCHAR(250)"); From 0e2460a6b93423ccaee85812e4c253b6b2319e41 Mon Sep 17 00:00:00 2001 From: Eric Sibly Date: Mon, 3 Aug 2026 09:36:09 -0700 Subject: [PATCH 8/8] Re-generated from new DB as AI had also updated what should have been an immutable migration script. --- .../src/Contoso.Shopping.Infrastructure/Persistence/Basket.g.cs | 2 +- .../Repositories/ShoppingDbContext.g.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/samples/src/Contoso.Shopping.Infrastructure/Persistence/Basket.g.cs b/samples/src/Contoso.Shopping.Infrastructure/Persistence/Basket.g.cs index f121b6fc..662facc8 100644 --- a/samples/src/Contoso.Shopping.Infrastructure/Persistence/Basket.g.cs +++ b/samples/src/Contoso.Shopping.Infrastructure/Persistence/Basket.g.cs @@ -30,7 +30,7 @@ public partial class Basket : ModelBase /// Gets or sets the value of the 'Total' column (type 'DECIMAL(18, 2)'). public decimal Total { get; set; } - /// Gets or sets the value of the 'ShippingAddressJson' column (type 'NVARCHAR(MAX) NULL'). + /// Gets or sets the value of the 'ShippingAddressJson' column (type 'NVARCHAR(2000) NULL'). public Persistence.Address? ShippingAddress { get; set; } } diff --git a/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.g.cs b/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.g.cs index 678abe2e..3c1560f6 100644 --- a/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.g.cs +++ b/samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.g.cs @@ -84,7 +84,7 @@ partial void AddGeneratedModels(Microsoft.EntityFrameworkCore.ModelBuilder model e.Property(p => p.DiscountCouponCode).HasColumnName("DiscountCouponCode").HasColumnType("NVARCHAR(50)"); e.Property(p => p.DiscountAmount).HasColumnName("DiscountAmount").HasColumnType("DECIMAL(18, 2)"); e.Property(p => p.Total).HasColumnName("Total").HasColumnType("DECIMAL(18, 2)"); - e.Property(p => p.ShippingAddress).HasColumnName("ShippingAddressJson").HasColumnType("NVARCHAR(MAX)").HasConversion(TypeToJsonStringEfConverter.Default); + e.Property(p => p.ShippingAddress).HasColumnName("ShippingAddressJson").HasColumnType("NVARCHAR(2000)").HasConversion(TypeToJsonStringEfConverter.Default); e.Property(p => p.CreatedBy).HasColumnName("CreatedBy").HasColumnType("NVARCHAR(250)"); e.Property(p => p.CreatedOn).HasColumnName("CreatedOn").HasColumnType("DATETIMEOFFSET"); e.Property(p => p.UpdatedBy).HasColumnName("UpdatedBy").HasColumnType("NVARCHAR(250)");