Add support for JSON database columns backed by non-string .NET types - #176
Conversation
- 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.
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.
- Added `Tags` (`List<string>?`) 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.
GetRequestValueAsync<TRequest> 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.
…apper 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<T> 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>
There was a problem hiding this comment.
Pull request overview
This PR extends CoreEx/DbEx/EF Core to support JSON-backed database columns mapped to non-string CLR types via a new TypeToJsonStringEfConverter<T> (and underlying TypeToJsonStringConverter<T>), demonstrates the end-to-end pattern in the Products (CRUD) and Shopping (DDD aggregate) samples, and updates skills/instructions to document the approach. It also includes RuntimeMetadata cycle-safety improvements and request-body handling changes in WebApiRequestOptions.
Changes:
- Add typed JSON↔string converters and wire them into DbEx EF model generation for JSON columns.
- Demonstrate JSON-backed properties in samples: Products
Tags(List<string>?via PostgresJSONB) and ShoppingBasket.ShippingAddress(DDD value object via SQL ServerNVARCHAR(2000)JSON). - Improve deep graph safety in
RuntimeMetadata(cycle detection) and update tests; adjust web API request-body validation/assertion helpers.
Reviewed changes
Copilot reviewed 76 out of 79 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/CoreEx.Test.Unit/Runtime/RuntimeMetadataTests.cs | Adds regression tests for cycles/cleaning. |
| tests/CoreEx.Test.Unit/Mapping/Converters/TypeToJsonStringConverterTests.cs | New unit tests for JSON converter. |
| tests/CoreEx.RefData.Test.Unit/ReferenceDataValidationTests.cs | Adds case-sensitivity validation coverage. |
| tests/CoreEx.AspNetCore.Test.Unit/PersonApi_QueryTestsBase.cs | Updates query tests for casing changes. |
| tests/CoreEx.AspNetCore.Test.Unit/PersonApi_MutateTestsBase.cs | Updates missing-body error assertions. |
| src/CoreEx/Metadata/RuntimeMetadata.IsDefault.cs | Adds cycle detection to IsDefault. |
| src/CoreEx/Metadata/RuntimeMetadata.GetHashCode.cs | Adds cycle detection to GetHashCode. |
| src/CoreEx/Metadata/RuntimeMetadata.Clean.cs | Adds cycle detection + value-type handling. |
| src/CoreEx/Metadata/RuntimeMetadata.AreEqual.cs | Adds cycle detection to deep equals. |
| src/CoreEx/Mapping/Converters/TypeToJsonStringConverter.cs | Introduces typed JSON string converter. |
| src/CoreEx.Validation/ValidatorT.cs | Formatting-only change. |
| src/CoreEx.Validation/Rules/ReferenceDataRule.cs | Formatting-only change. |
| src/CoreEx.UnitTesting/UnitTestExExtensions.Assert.cs | Replaces title-only ProblemDetails assert with callback-based assert. |
| src/CoreEx.Template/content/CoreEx.Core/_Directory.Packages.props | Updates DbEx package versions in template. |
| src/CoreEx.RefData/ReferenceDataCollectionT2.cs | Documents new default code comparer behavior. |
| src/CoreEx.RefData/ReferenceDataCollectionT.cs | Documents new default code comparer behavior. |
| src/CoreEx.RefData/Abstractions/ReferenceDataCollectionCore.cs | Changes default refdata code comparer to Ordinal. |
| src/CoreEx.EntityFrameworkCore/Converters/TypeToJsonStringEfConverter.cs | Adds EF ValueConverter bridge for typed JSON. |
| src/CoreEx.EntityFrameworkCore/Converters/JsonElementStringEfConverter.cs | Formatting-only change. |
| src/CoreEx.Database/Templates/EfModelBuilder_cs.hbs | Auto-wires typed JSON conversion for JSON columns. |
| src/CoreEx.AspNetCore/WebApiRequestResponseOptions.cs | Adds auto-cleaning + new required-body behavior. |
| src/CoreEx.AspNetCore/WebApiRequestOptions.cs | Adds auto-cleaning + new required-body behavior. |
| src/CoreEx.AspNetCore/Abstractions/WebApiBase.cs | Centralizes request-body ProblemDetails constants. |
| src/CoreEx.AspNetCore/Abstractions/WebApi.cs | Adjusts request-body detection and invalid-body error. |
| src/CoreEx.AspNetCore/Abstractions/IWebApiRequestOptions.cs | Adds AutoCleanValue to request options contract. |
| samples/tests/Contoso.Shopping.Test.Unit/Validators/AddressValidatorTests.cs | Adds validator unit tests for Address. |
| samples/tests/Contoso.Shopping.Test.Unit/Domains/BasketTests.cs | Adds domain tests for shipping address behavior. |
| samples/tests/Contoso.Shopping.Test.Common/Data/read-data.seed.yaml | Seeds shopping baskets with ShippingAddressJson. |
| samples/tests/Contoso.Shopping.Test.Common/Data/mutate-data.seed.yaml | Seeds mutate baskets with ShippingAddressJson. |
| samples/tests/Contoso.Shopping.Test.Api/Resources/Basket_Get_Found.res.json | Adds shippingAddress to expected basket response. |
| samples/tests/Contoso.Shopping.Test.Api/MutateTests.BasketItem.cs | Updates ProblemDetails assertion style. |
| samples/tests/Contoso.Shopping.Test.Api/MutateTests.Basket.cs | Adds shipping-address endpoint tests + checkout guard test. |
| samples/tests/Contoso.Products.Test.Common/Data/read-data.seed.yaml | Seeds products with tags_json JSON. |
| samples/tests/Contoso.Products.Test.Common/Data/mutate-data.seed.yaml | Seeds products with tags_json JSON. |
| samples/tests/Contoso.Products.Test.Api/Resources/ReadTests/Product_Get_Found.res.json | Adds tags to expected product response. |
| samples/tests/Contoso.Products.Test.Api/Resources/ProductMutateTests/Create_WithTags.res.json | New expected JSON for tagged product create. |
| samples/tests/Contoso.Products.Test.Api/ReadTests.ProductQuery.cs | Updates filters for casing changes. |
| samples/tests/Contoso.Products.Test.Api/ReadTests.MovementQuery.cs | Updates filters for casing changes. |
| samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Delete.cs | Updates ProblemDetails assertion style. |
| samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Create.cs | Adds create-with-tags API test + casing updates. |
| samples/tests/Contoso.Products.Test.Api/MovementMutateTests.Reserve.cs | Updates ProblemDetails assertion style. |
| samples/tests/Contoso.E2E.Runner/Scenarios/ShoppingBasketScenario.cs | Adds shipping-address step to E2E scenario. |
| samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.g.cs | Generated EF mapping for ShippingAddress JSON column. |
| samples/src/Contoso.Shopping.Infrastructure/Persistence/Basket.g.cs | Generated persistence property for ShippingAddress. |
| samples/src/Contoso.Shopping.Infrastructure/Persistence/Address.cs | Adds persistence POCO for JSON storage. |
| samples/src/Contoso.Shopping.Infrastructure/Mapping/BasketMapper.cs | Maps ShippingAddress Persistence→Domain. |
| samples/src/Contoso.Shopping.Infrastructure/Mapping/BasketIntoMapper.cs | Maps ShippingAddress Domain→Persistence. |
| samples/src/Contoso.Shopping.Infrastructure/Mapping/AddressMapper.cs | Adds persistence↔domain mapper for Address. |
| samples/src/Contoso.Shopping.Domain/ValueObjects/Address.cs | Adds Address domain value object. |
| samples/src/Contoso.Shopping.Domain/GlobalUsing.cs | Adjusts global usings for domain project. |
| samples/src/Contoso.Shopping.Domain/Basket.cs | Adds ShippingAddress + checkout guard + mutation method. |
| samples/src/Contoso.Shopping.Database/Migrations/20260622-194111-alter-shopping-basket-table.sql | Adds ShippingAddressJson column (SQL Server). |
| samples/src/Contoso.Shopping.Database/dbex.yaml | Configures typed JSON column mapping for basket. |
| samples/src/Contoso.Shopping.Contracts/Basket.cs | Adds ShippingAddress to contract. |
| samples/src/Contoso.Shopping.Contracts/Address.cs | Adds Address contract DTO. |
| samples/src/Contoso.Shopping.Application/Validators/AddressValidator.cs | Adds Address validator. |
| samples/src/Contoso.Shopping.Application/Mapping/BasketMapper.cs | Maps ShippingAddress Domain→Contract. |
| samples/src/Contoso.Shopping.Application/Mapping/AddressMapper.cs | Adds domain↔contract mapper for Address. |
| samples/src/Contoso.Shopping.Application/Interfaces/IBasketService.cs | Adds UpdateShippingAddressAsync contract. |
| samples/src/Contoso.Shopping.Application/BasketService.cs | Implements UpdateShippingAddressAsync with validation + mapping. |
| samples/src/Contoso.Shopping.Api/Controllers/BasketController.cs | Adds PUT /shipping-address endpoint. |
| samples/src/Contoso.Products.Infrastructure/Repositories/ProductsDbContext.g.cs | Generated EF mapping for tags_json column. |
| samples/src/Contoso.Products.Infrastructure/Persistence/Product.g.cs | Generated persistence property for Tags. |
| samples/src/Contoso.Products.Infrastructure/Mapping/ProductMapper.cs | Maps Tags contract↔persistence. |
| samples/src/Contoso.Products.Database/Migrations/20260731-120000-alter-products-product-add-tags-json.pgsql | Adds tags_json column (Postgres JSONB). |
| samples/src/Contoso.Products.Database/dbex.yaml | Configures typed JSON column mapping for product. |
| samples/src/Contoso.Products.Contracts/ProductBase.cs | Adds Tags to product contract base. |
| Directory.Packages.props | Updates DbEx package versions. |
| .github/skills/coreex-repository/references/workflow.md | Documents repository mapping rules for JSON columns. |
| .github/skills/coreex-db-migration/SKILL.md | Adds JSON columns guidance. |
| .github/skills/coreex-db-migration/references/workflow.md | Adds detailed JSON column workflow and examples. |
| .github/skills/coreex-app-service/SKILL.md | Documents JSON-backed value object mapping guidance. |
| .github/skills/coreex-app-service/references/workflow.md | Documents JSON-backed value object mapping workflow. |
| .github/skills/coreex-aggregate/SKILL.md | Documents JSON-backed value object conventions. |
| .github/skills/coreex-aggregate/references/workflow.md | Adds JSON-backed value object persistence guidance. |
| .github/instructions/coreex-tooling.instructions.md | Adds dbex.yaml JSON column conventions + Inspect flag note. |
| .github/instructions/coreex-repositories.instructions.md | Documents JSON-typed property mapping conventions. |
| .github/instructions/coreex-domain.instructions.md | Documents JSON-backed value object conventions. |
| .github/instructions/coreex-application-services.instructions.md | Documents application-layer mapping for JSON-backed value objects. |
Replaced Comparer<T>.Default.Compare(x, default!) with !EqualityComparer<T>.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.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 99 out of 102 changed files in this pull request and generated no new comments.
Suppressed comments (6)
src/CoreEx/Metadata/RuntimeMetadata.GetHashCode.cs:45
- If a property getter throws while computing a hash, the current implementation can leave the thread-static visited set polluted (because
set.Remove(id)is not in afinally). That can cause subsequentGetHashCodecalls on the same thread to incorrectly treat unrelated objects as part of a cycle. Also, using the object reference rather than an identity hash avoids false positives due to hash collisions.
src/CoreEx/Metadata/RuntimeMetadata.GetHashCode.cs:96 - Same visited-set leak/collision concerns as the
IRuntimeMetadataCorebranch above: the thread-static visited set should be cleaned up in afinally, and cycle detection should use object references (reference equality) rather than identity hash-code integers to avoid collisions.
src/CoreEx/Metadata/RuntimeMetadata.IsDefault.cs:76 - Same visited-set cleanup issue as the
IRuntimeMetadataCorepath above: if reflection-based property access throws,set.Remove(value)might not run, leaving the thread-static set in a bad state. Ensure removal happens in afinallywhen this frame actually added the object.
src/CoreEx/Metadata/RuntimeMetadata.GetHashCode.cs:7 GetHashCodecycle detection tracksRuntimeHelpers.GetHashCodeintegers. These identity hash codes are not guaranteed unique, so collisions can produce false cycle detection and incorrect hash codes. Tracking object references withReferenceEqualityCompareravoids collisions and matches the approach used elsewhere (e.g.IsDefault).
This issue also appears in the following locations of the same file:
- line 27
- line 77
src/CoreEx/Metadata/RuntimeMetadata.IsDefault.cs:50
- If a property getter throws during
IsDefault, the current implementation can leave the thread-static visited set polluted becauseset.Remove(value)is not guaranteed to run. That can make subsequentIsDefaultcalls on the same thread incorrectly report cycles. Moving the removal into thefinally(when this frame actually added the value) makes the guard robust.
This issue also appears on line 61 of the same file.
src/CoreEx.AspNetCore/Abstractions/WebApi.cs:158
hasBodydetection relies onTransfer-Encoding, which is not used by HTTP/2+ and can result in a request with a body but noContent-Lengthbeing treated as body-less (silently skipping deserialization). UsingContentLength == 0as the only fast-path avoids false negatives for unknown-length bodies while preserving the intended “no body” behavior for the common case.
- 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.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 100 out of 103 changed files in this pull request and generated no new comments.
Suppressed comments (7)
src/CoreEx/Metadata/RuntimeMetadata.IsDefault.cs:65
- Same issue as the
IRuntimeMetadataCorepath: the reflection-basedThreadStaticvisited set can leak entries when an exception occurs beforeset.Remove(value), causing false cycle detections later on the same thread.
src/CoreEx/Metadata/RuntimeMetadata.GetHashCode.cs:83 - Same issue as the
IRuntimeMetadataCorebranch: the reflection-basedThreadStaticset can leakidentries on exceptions beforeset.Remove(id), leading to false cycle detection on subsequent calls on the same thread.
src/CoreEx/Metadata/RuntimeMetadata.IsDefault.cs:50 - The cycle-detection
HashSetisThreadStatic, butset.Remove(value)is not protected by afinally. If any property getter/metadata evaluation throws inside thetry, and this call is not the root invocation, the set can retain stale entries and break subsequentIsDefaultcalls on the same thread.
This issue also appears on line 61 of the same file.
samples/tests/Contoso.Products.Test.Api/ProductMutateTests.Create.cs:107
ExpectIdentifier(),ExpectETag(), andExpectChangeLogCreated()already assert presence and auto-exclude those fields from the JSON resource compare. Passing "id", "etag", and "changeLog" exclusions toAssertJsonFromResourceis redundant and conflicts with the established test pattern for volatile fields.
src/CoreEx.UnitTesting/UnitTestExExtensions.Assert.cs:15- Renaming
AssertProblemDetailsTitletoAssertProblemDetailsis a breaking change for any downstream test suites usingCoreEx.UnitTesting. Consider keepingAssertProblemDetailsTitleas a backward-compatible shim (optionally[Obsolete]) that forwards to the new API.
src/CoreEx/Metadata/RuntimeMetadata.GetHashCode.cs:33 - The
ThreadStaticcycle-detection set removesidonly on the success path. If a property getter/metadata evaluation throws afterset.Add(id), and this call is not the root invocation, the set can retain stale ids and cause incorrect hash computation in later calls on the same thread.
This issue also appears on line 77 of the same file.
samples/src/Contoso.Shopping.Infrastructure/Repositories/ShoppingDbContext.g.cs:88
- The EF model config declares
ShippingAddressJsonasNVARCHAR(MAX), but the migration adds it asNVARCHAR(2000). This mismatch can lead to confusing drift (and potentially runtime failures if larger JSON is written via EF but the DB column is bounded). The generated model/migration/dbex config should agree on the intended size.
… an immutable migration script.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 100 out of 103 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
src/CoreEx/Metadata/RuntimeMetadata.GetHashCode.cs:40
- Within the
IRuntimeMetadataCorehashing path, prefer tracking visited object references (withReferenceEqualityComparer) instead of identity hash codes to avoid collisions causing incorrect early termination.
src/CoreEx/Metadata/RuntimeMetadata.GetHashCode.cs:90 - Same issue as the
IRuntimeMetadataCorebranch: the reflection-based hashing path should use a visited set of object references rather thanRuntimeHelpers.GetHashCodeintegers to avoid collisions causing incorrect cycle detection.
src/CoreEx.UnitTesting/UnitTestExExtensions.Assert.cs:15 - This change removes/renames
AssertProblemDetailsTitle(...), which is a breaking public API change for CoreEx.UnitTesting consumers. Since existing call sites likely exist outside this repo, consider keepingAssertProblemDetailsTitleas a wrapper (optionally obsolete) and addingAssertProblemDetails(Action<ProblemDetails>?)as the new flexible API.
src/CoreEx/Metadata/RuntimeMetadata.GetHashCode.cs:7 - Cycle detection uses
HashSet<int>ofRuntimeHelpers.GetHashCode(object).RuntimeHelpers.GetHashCodeis not guaranteed unique, so collisions can cause false cycle detection and return the sentinel hash (0) for non-cyclic graphs, producing unstable/incorrect hash codes.
This issue also appears in the following locations of the same file:
- line 27
- line 77
Summary
Adds first-class support for JSON-typed database columns that convert to/from a .NET type other than the default
stringmapping — proving the pattern end-to-end across both a simple CRUD domain and a DDD aggregate domain, and documenting it so it's repeatable.What's included
Json(SQL Server, PascalCase) /_json(Postgres, snake_case) suffix, or by using the database's native JSON type.dbex.yamlwires the column to a typed CLR property viaTypeToJsonStringEfConverter<T>, which serializes/deserializes throughSystem.Text.Jsonat the EF Core layer.NVARCHAR(n)/VARCHAR(n)) consistent with every other string column in the schema. Unbounded (MAX) or native database JSON types (e.g. PostgresJSONB) are supported as an explicit, opt-in override — demonstrated in the Products sample.Basket.ShippingAddressin the Shopping sample.List<string>?tags collection stored as nativeJSONB.ShippingAddressvalue object stored as bounded JSON text.InspectoutputJson: Yes|Noflag), Domain layer conventions for JSON-backed value objects, Application-layer mapping conventions for the Domain↔Contract mapper half, and the correspondingcoreex-db-migration,coreex-aggregate, andcoreex-app-serviceskills.Validation
dotnet build CoreEx.sln— succeeds with 0 errors (net8.0/net9.0/net10.0).CoreEx.Test.Unit— full suite passes (719/719) across all target frameworks.