From 39f8b3fb981a6a142a946d57d2b447d4b0fa2e8e Mon Sep 17 00:00:00 2001 From: Xavier John Date: Fri, 27 Mar 2026 21:44:03 -0700 Subject: [PATCH 01/17] ValueObject implements IComparable for composite VO equality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes: composite ValueObjects (e.g., ShippingAddress) can now yield scalar VOs in GetEqualityComponents without .Value — ScalarValueObject inherits IComparable through ValueObject. Added non-generic IComparable.CompareTo(object?) that delegates to the typed CompareTo(ValueObject?). Added 2 tests with composite VO containing StreetName/CityName scalar VOs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Trellis.DomainDrivenDesign/src/ValueObject.cs | 9 ++- .../tests/ValueObjects/ValueObjectTests.cs | 64 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/Trellis.DomainDrivenDesign/src/ValueObject.cs b/Trellis.DomainDrivenDesign/src/ValueObject.cs index 061da55c..44a0c2a3 100644 --- a/Trellis.DomainDrivenDesign/src/ValueObject.cs +++ b/Trellis.DomainDrivenDesign/src/ValueObject.cs @@ -158,7 +158,7 @@ /// } /// /// -public abstract class ValueObject : IComparable, IEquatable +public abstract class ValueObject : IComparable, IComparable, IEquatable { // NOTE: Not volatile/locked intentionally. Value objects are immutable and DDD aggregates are // single-threaded consistency boundaries. The worst-case race is two threads computing the same @@ -317,6 +317,13 @@ public virtual int CompareTo(ValueObject? other) } } + /// + /// Non-generic implementation. Delegates to . + /// Enables value objects to be used in of composite value objects. + /// + int IComparable.CompareTo(object? obj) => + obj is ValueObject other ? CompareTo(other) : throw new ArgumentException($"Cannot compare {GetType()} to {obj?.GetType()}"); + private static int CompareComponents(object? object1, object? object2) { if (object1 is null && object2 is null) diff --git a/Trellis.DomainDrivenDesign/tests/ValueObjects/ValueObjectTests.cs b/Trellis.DomainDrivenDesign/tests/ValueObjects/ValueObjectTests.cs index 73b6ad24..4f3f50b1 100644 --- a/Trellis.DomainDrivenDesign/tests/ValueObjects/ValueObjectTests.cs +++ b/Trellis.DomainDrivenDesign/tests/ValueObjects/ValueObjectTests.cs @@ -370,6 +370,28 @@ public void CompareTo_with_null_component_on_right_only() } #endregion + + #region Composite ValueObject with ScalarValueObject components + + [Fact] + public void Composite_ValueObject_with_ScalarVO_components_are_equal() + { + var addr1 = new CompositeAddress(StreetName.Create("123 Main St"), CityName.Create("Springfield")); + var addr2 = new CompositeAddress(StreetName.Create("123 Main St"), CityName.Create("Springfield")); + + addr1.Should().Be(addr2); + } + + [Fact] + public void Composite_ValueObject_with_ScalarVO_components_are_not_equal() + { + var addr1 = new CompositeAddress(StreetName.Create("123 Main St"), CityName.Create("Springfield")); + var addr2 = new CompositeAddress(StreetName.Create("456 Oak Ave"), CityName.Create("Springfield")); + + addr1.Should().NotBe(addr2); + } + + #endregion } /// @@ -391,4 +413,46 @@ public AddressWithNullable(string street, string? city) yield return Street; yield return City; // Allow null for testing } +} + +/// +/// Composite ValueObject containing ScalarValueObject properties. +/// Tests that scalar VOs can be yielded in GetEqualityComponents. +/// +internal class CompositeAddress : ValueObject +{ + public StreetName Street { get; } + public CityName City { get; } + + public CompositeAddress(StreetName street, CityName city) + { + Street = street; + City = city; + } + + protected override IEnumerable GetEqualityComponents() + { + yield return Street; + yield return City; + } +} + +internal class StreetName : ScalarValueObject, IScalarValue +{ + private StreetName(string value) : base(value) { } + + public static Result TryCreate(string value, string? fieldName = null) => + string.IsNullOrWhiteSpace(value) + ? Result.Failure(Error.Validation("Street is required", fieldName ?? "street")) + : Result.Success(new StreetName(value)); +} + +internal class CityName : ScalarValueObject, IScalarValue +{ + private CityName(string value) : base(value) { } + + public static Result TryCreate(string value, string? fieldName = null) => + string.IsNullOrWhiteSpace(value) + ? Result.Failure(Error.Validation("City is required", fieldName ?? "city")) + : Result.Success(new CityName(value)); } \ No newline at end of file From 4ee3a8e981008891caa380c000cf3e9c32f27816 Mon Sep 17 00:00:00 2001 From: Xavier John Date: Sat, 28 Mar 2026 10:18:10 -0700 Subject: [PATCH 02/17] feat: add tests for Maybe handling in EF Core --- .../tests/MaybeMoneyTests.cs | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 Trellis.EntityFrameworkCore/tests/MaybeMoneyTests.cs diff --git a/Trellis.EntityFrameworkCore/tests/MaybeMoneyTests.cs b/Trellis.EntityFrameworkCore/tests/MaybeMoneyTests.cs new file mode 100644 index 00000000..afb27430 --- /dev/null +++ b/Trellis.EntityFrameworkCore/tests/MaybeMoneyTests.cs @@ -0,0 +1,282 @@ +namespace Trellis.EntityFrameworkCore.Tests; + +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Trellis.Primitives; + +/// +/// Tests for with composite ValueObjects like . +/// +/// Level 1: should not crash when encountering +/// Maybe<Money> — a composite ValueObject that is not a scalar type. +/// +/// +/// Level 2: Maybe<Money> should auto-configure as an optional owned type, +/// producing nullable Amount and Currency columns that follow +/// naming, and round-trip through EF Core with both Some and None values. +/// +/// +public partial class MaybeMoneyTests : IDisposable +{ + private MaybeMoneyDbContext? _context; + private SqliteConnection? _connection; + + private MaybeMoneyDbContext Context + { + get + { + if (_context is not null) + return _context; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + _context = new MaybeMoneyDbContext(options); + _context.Database.EnsureCreated(); + return _context; + } + } + + public void Dispose() + { + _context?.Dispose(); + _connection?.Dispose(); + GC.SuppressFinalize(this); + } + + #region Level 1: MaybeConvention does not crash on Maybe + + [Fact] + public void MaybeMoney_ModelBuilds_WithoutException() + { + // MaybeConvention currently tries to map Money? as a scalar column + // and throws because Money is a composite ValueObject (owned type). + // After the fix, model finalization should succeed. + var model = Context.Model; + + model.Should().NotBeNull(); + } + + #endregion + + #region Level 2: Maybe auto-configures as optional owned type + + [Fact] + public void MaybeMoney_IsMappedAsOwnedNavigation() + { + var entityType = Context.Model.FindEntityType(typeof(PenaltyEntity))!; + + // MonetaryFinePaid should be an owned navigation to Money, not a scalar property. + // The backing field is Money? _monetaryFinePaid (generated by MaybePartialPropertyGenerator). + var navigation = entityType.FindNavigation("MonetaryFinePaid"); + navigation.Should().NotBeNull("Maybe should produce an owned navigation"); + navigation!.TargetEntityType.IsOwned().Should().BeTrue(); + } + + [Fact] + public void MaybeMoney_AmountColumn_IsNullable() + { + var entityType = Context.Model.FindEntityType(typeof(PenaltyEntity))!; + var ownedType = entityType.FindNavigation("MonetaryFinePaid")!.TargetEntityType; + + var amount = ownedType.FindProperty(nameof(Money.Amount))!; + amount.IsNullable.Should().BeTrue("optional Money amount must be nullable"); + } + + [Fact] + public void MaybeMoney_ColumnNaming_FollowsMoneyConvention() + { + var entityType = Context.Model.FindEntityType(typeof(PenaltyEntity))!; + var ownedType = entityType.FindNavigation("MonetaryFinePaid")!.TargetEntityType; + + ownedType.FindProperty(nameof(Money.Amount))!.GetColumnName() + .Should().Be("MonetaryFinePaid"); + ownedType.FindProperty(nameof(Money.Currency))!.GetColumnName() + .Should().Be("MonetaryFinePaidCurrency"); + } + + [Fact] + public void RequiredMoney_CoexistsWithOptionalMoney() + { + var entityType = Context.Model.FindEntityType(typeof(PenaltyEntity))!; + + // Required Money (MonetaryFine) should be a non-nullable owned navigation + var requiredNav = entityType.FindNavigation("MonetaryFine"); + requiredNav.Should().NotBeNull("required Money should still be an owned navigation"); + requiredNav!.TargetEntityType.IsOwned().Should().BeTrue(); + + var requiredAmount = requiredNav.TargetEntityType.FindProperty(nameof(Money.Amount))!; + requiredAmount.IsNullable.Should().BeFalse("required Money amount should not be nullable"); + } + + [Fact] + public async Task MaybeMoney_RoundTrip_WithValue() + { + var ct = TestContext.Current.CancellationToken; + var penalty = new PenaltyEntity + { + Id = 1, + Description = "Late payment", + MonetaryFine = Money.Create(500.00m, "USD"), + MonetaryFinePaid = Maybe.From(Money.Create(500.00m, "USD")) + }; + + Context.Penalties.Add(penalty); + await Context.SaveChangesAsync(ct); + Context.ChangeTracker.Clear(); + + var loaded = await Context.Penalties.FindAsync([1], ct); + loaded.Should().NotBeNull(); + loaded!.MonetaryFinePaid.HasValue.Should().BeTrue(); + loaded.MonetaryFinePaid.Value.Amount.Should().Be(500.00m); + loaded.MonetaryFinePaid.Value.Currency.Value.Should().Be("USD"); + } + + [Fact] + public async Task MaybeMoney_RoundTrip_WithNone() + { + var ct = TestContext.Current.CancellationToken; + var penalty = new PenaltyEntity + { + Id = 2, + Description = "Warning only", + MonetaryFine = Money.Create(100.00m, "USD") + // MonetaryFinePaid defaults to Maybe.None + }; + + Context.Penalties.Add(penalty); + await Context.SaveChangesAsync(ct); + Context.ChangeTracker.Clear(); + + var loaded = await Context.Penalties.FindAsync([2], ct); + loaded.Should().NotBeNull(); + loaded!.MonetaryFinePaid.HasValue.Should().BeFalse(); + } + + [Fact] + public async Task MaybeMoney_RequiredAndOptional_RoundTrip() + { + var ct = TestContext.Current.CancellationToken; + var penalty = new PenaltyEntity + { + Id = 3, + Description = "Fine with partial payment", + MonetaryFine = Money.Create(1000.00m, "USD"), + MonetaryFinePaid = Maybe.From(Money.Create(250.00m, "USD")) + }; + + Context.Penalties.Add(penalty); + await Context.SaveChangesAsync(ct); + Context.ChangeTracker.Clear(); + + var loaded = await Context.Penalties.FindAsync([3], ct); + loaded.Should().NotBeNull(); + loaded!.MonetaryFine.Amount.Should().Be(1000.00m); + loaded.MonetaryFinePaid.HasValue.Should().BeTrue(); + loaded.MonetaryFinePaid.Value.Amount.Should().Be(250.00m); + } + + [Fact] + public async Task MaybeMoney_RequiredPresent_OptionalNone_RoundTrip() + { + var ct = TestContext.Current.CancellationToken; + var penalty = new PenaltyEntity + { + Id = 4, + Description = "Fine issued, not yet paid", + MonetaryFine = Money.Create(750.00m, "EUR") + // MonetaryFinePaid stays None + }; + + Context.Penalties.Add(penalty); + await Context.SaveChangesAsync(ct); + Context.ChangeTracker.Clear(); + + var loaded = await Context.Penalties.FindAsync([4], ct); + loaded.Should().NotBeNull(); + loaded!.MonetaryFine.Amount.Should().Be(750.00m); + loaded.MonetaryFine.Currency.Value.Should().Be("EUR"); + loaded.MonetaryFinePaid.HasValue.Should().BeFalse(); + } + + [Fact] + public async Task MaybeMoney_Update_SomeToNone() + { + var ct = TestContext.Current.CancellationToken; + var penalty = new PenaltyEntity + { + Id = 5, + Description = "Refunded fine", + MonetaryFine = Money.Create(500.00m, "USD"), + MonetaryFinePaid = Maybe.From(Money.Create(500.00m, "USD")) + }; + + Context.Penalties.Add(penalty); + await Context.SaveChangesAsync(ct); + Context.ChangeTracker.Clear(); + + // Update: clear the payment + var tracked = await Context.Penalties.FindAsync([5], ct); + tracked!.MonetaryFinePaid = Maybe.None; + await Context.SaveChangesAsync(ct); + Context.ChangeTracker.Clear(); + + var reloaded = await Context.Penalties.FindAsync([5], ct); + reloaded.Should().NotBeNull(); + reloaded!.MonetaryFinePaid.HasValue.Should().BeFalse(); + } + + [Fact] + public async Task MaybeMoney_Update_NoneToSome() + { + var ct = TestContext.Current.CancellationToken; + var penalty = new PenaltyEntity + { + Id = 6, + Description = "Payment received later", + MonetaryFine = Money.Create(300.00m, "GBP") + // MonetaryFinePaid starts as None + }; + + Context.Penalties.Add(penalty); + await Context.SaveChangesAsync(ct); + Context.ChangeTracker.Clear(); + + // Update: record the payment + var tracked = await Context.Penalties.FindAsync([6], ct); + tracked!.MonetaryFinePaid = Maybe.From(Money.Create(300.00m, "GBP")); + await Context.SaveChangesAsync(ct); + Context.ChangeTracker.Clear(); + + var reloaded = await Context.Penalties.FindAsync([6], ct); + reloaded.Should().NotBeNull(); + reloaded!.MonetaryFinePaid.HasValue.Should().BeTrue(); + reloaded.MonetaryFinePaid.Value.Amount.Should().Be(300.00m); + reloaded.MonetaryFinePaid.Value.Currency.Value.Should().Be("GBP"); + } + + #endregion + + #region Test entities and context + + public partial class PenaltyEntity + { + public int Id { get; set; } + public string Description { get; set; } = null!; + public Money MonetaryFine { get; set; } = null!; + public partial Maybe MonetaryFinePaid { get; set; } + } + + private class MaybeMoneyDbContext : DbContext + { + public DbSet Penalties => Set(); + + public MaybeMoneyDbContext(DbContextOptions options) : base(options) { } + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) => + configurationBuilder.ApplyTrellisConventions(); + } + + #endregion +} From 98a5d0cde3bb97887fd8cb02a1cabb96a7a5374f Mon Sep 17 00:00:00 2001 From: Xavier John Date: Sat, 28 Mar 2026 11:56:01 -0700 Subject: [PATCH 03/17] Add Maybe support for EF Core conventions MaybeConvention now detects when the inner type of Maybe is an EF Core owned type (e.g., Money) and creates an optional ownership navigation via the source-generated backing field instead of attempting scalar column mapping. MoneyConvention configures nullable columns with correct naming. Changes: - MaybeConvention: detect IsOwned(), create HasOwnership via FieldInfo, store property name annotation for MoneyConvention - MoneyConvention: read annotation for column prefix, mark columns nullable for optional Money - Convention order: MaybeConvention first, MoneyConvention second - MaybeUpdateExtensions: guard against ExecuteUpdate on composite VOs with clear InvalidOperationException - MaybeModelExtensions: diagnostics API reports owned Maybe mappings with actual column metadata from owned entity type - 13 new tests: model building, metadata, round-trips (Some/None/mixed), update transitions (Some->None, None->Some), ExecuteUpdate guard, diagnostics API - Documentation: copilot instructions, API reference, READMEs, docfx article all updated with Maybe examples and ExecuteUpdate warning Resolves ARCL feedback FP-1 and SF-1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 25 +++++++ Trellis.EntityFrameworkCore/NUGET_README.md | 4 +- Trellis.EntityFrameworkCore/README.md | 13 +++- .../src/MaybeConvention.cs | 47 +++++++++++++ .../src/MaybeModelExtensions.cs | 61 ++++++++++++---- .../src/MaybeUpdateExtensions.cs | 20 ++++++ .../ModelConfigurationBuilderExtensions.cs | 2 +- .../src/MoneyConvention.cs | 13 +++- .../tests/MaybeMoneyTests.cs | 70 +++++++++++++------ docs/docfx_project/articles/integration-ef.md | 19 +++++ trellis-api-reference.md | 7 +- 11 files changed, 240 insertions(+), 41 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d040e549..c4e27e8c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -633,11 +633,29 @@ var matches = await context.Customers.WhereEquals(c => c.Phone, phone).ToListAsy These methods rewrite the expression tree to target the backing field via `EF.Property`, so EF Core can translate the query to SQL. +### Maybe\ with Composite Owned Types + +`partial Maybe` is also supported. The conventions automatically configure it as an optional owned type — no `OwnsOne` configuration needed: + +```csharp +public partial class Penalty : Aggregate +{ + public Money Fine { get; set; } = null!; // required Money (2 NOT NULL columns) + public partial Maybe FinePaid { get; set; } // optional Money (2 nullable columns) +} +``` + +Column naming follows `MoneyConvention`: `FinePaid` (amount) and `FinePaidCurrency`. + +> **Note:** `ExecuteUpdate` helpers (`SetMaybeValue`/`SetMaybeNone`) do not support `Maybe`. Use tracked entity updates (load, modify, `SaveChangesAsync`) instead. + ## Money with EF Core `Money` properties are automatically mapped as owned types by `ApplyTrellisConventions` — no `OwnsOne` configuration needed. This includes `Money` properties declared on owned entity types, including items inside `OwnsMany` collections. +For optional Money properties, use `partial Maybe` — see the Maybe\ section above. + ### How It Works The `MoneyConvention` (registered by `ApplyTrellisConventions`) uses two EF Core convention interfaces: @@ -698,3 +716,10 @@ using Unit = Trellis.Unit; ``` The parameterless `Result.Success()` is preferred — it avoids the type name entirely. + +## Pre-Submission Checklist + +Before committing any changes: + +1. **All tests pass** — `dotnet test` from the repository root must report zero failures. +2. **Code review by GPT-5.4** — Use a code-review agent with `model: gpt-5.4` to review all changed files before committing. Address any issues it flags as bugs, security vulnerabilities, or logic errors. diff --git a/Trellis.EntityFrameworkCore/NUGET_README.md b/Trellis.EntityFrameworkCore/NUGET_README.md index 7d311519..bf6bb4c1 100644 --- a/Trellis.EntityFrameworkCore/NUGET_README.md +++ b/Trellis.EntityFrameworkCore/NUGET_README.md @@ -46,6 +46,8 @@ public class AppDbContext : DbContext No `OwnsOne` calls needed — just declare `Money` properties on your entities and they work. This also applies when `Money` is declared on owned entity types, including items inside `OwnsMany` collections. +`Maybe` is also supported — it auto-configures as an optional owned type with nullable Amount/Currency columns, no `OwnsOne` needed. + | Property Name | Amount Column | Currency Column | Amount Type | Currency Type | |---------------|---------------|-----------------|-------------|---------------| | `Price` | `Price` | `PriceCurrency` | `decimal(18,3)` | `nvarchar(3)` | @@ -65,7 +67,7 @@ public partial class Customer } ``` -No `OnModelCreating` configuration needed. Querying uses dedicated extensions: +No `OnModelCreating` configuration needed. When `T` is a composite owned type (e.g., `Money`), it creates an optional ownership navigation instead of a scalar column. Querying uses dedicated extensions: ```csharp var withoutPhone = await context.Customers.WhereNone(c => c.Phone).ToListAsync(ct); diff --git a/Trellis.EntityFrameworkCore/README.md b/Trellis.EntityFrameworkCore/README.md index 6c23ce38..8adc43ed 100644 --- a/Trellis.EntityFrameworkCore/README.md +++ b/Trellis.EntityFrameworkCore/README.md @@ -96,6 +96,17 @@ configurationBuilder.ApplyTrellisConventions( That behavior is intentional because `Money` is a structured value object, not a scalar wrapper with a single persisted `Value`. This also applies when `Money` is declared on owned entity types, including items inside `OwnsMany` collections. +`Maybe` is also supported — it auto-configures as an optional owned type with nullable Amount/Currency columns: + +```csharp +public partial class Penalty : Aggregate +{ + public Money Fine { get; set; } = null!; // required (2 NOT NULL columns) + public partial Maybe FinePaid { get; set; } // optional (2 nullable columns) +} +// Columns: FinePaid (nullable decimal), FinePaidCurrency (nullable nvarchar) +``` + ```csharp public class Order { @@ -144,7 +155,7 @@ public partial class Customer } ``` -No `OnModelCreating` configuration needed — `MaybeConvention` (registered by `ApplyTrellisConventions`) auto-discovers `Maybe` properties, maps the generated `_camelCase` storage member as nullable, and sets the column name to the property name. +No `OnModelCreating` configuration needed — `MaybeConvention` (registered by `ApplyTrellisConventions`) auto-discovers `Maybe` properties, maps the generated `_camelCase` storage member as nullable, and sets the column name to the property name. When `T` is a composite owned type (e.g., `Money`), it creates an optional ownership navigation instead of a scalar column — see the [Money Property Convention](#money-property-convention) section above. ### Column Naming diff --git a/Trellis.EntityFrameworkCore/src/MaybeConvention.cs b/Trellis.EntityFrameworkCore/src/MaybeConvention.cs index a6b8e413..a60ad337 100644 --- a/Trellis.EntityFrameworkCore/src/MaybeConvention.cs +++ b/Trellis.EntityFrameworkCore/src/MaybeConvention.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Conventions; using Microsoft.EntityFrameworkCore.Metadata.Internal; +using Trellis.Primitives; /// /// Convention that automatically maps properties by discovering their @@ -30,6 +31,12 @@ /// Sets the column name to the original property name (e.g., Phone instead of _phone) /// /// +/// When T is a composite owned type (e.g., ), the convention creates +/// an optional ownership navigation via the backing field instead of a scalar column. +/// All columns in the owned type are marked nullable, and column names use the original +/// property name as the prefix (matching naming). +/// +/// /// User code with the source generator: /// /// @@ -37,6 +44,7 @@ /// { /// public CustomerId Id { get; set; } = null!; /// public partial Maybe<PhoneNumber> Phone { get; set; } +/// public partial Maybe<Money> Discount { get; set; } /// } /// /// @@ -46,6 +54,8 @@ /// internal sealed class MaybeConvention : IModelFinalizingConvention { + private static readonly Type s_moneyType = typeof(Money); + /// /// After the model is built, discovers all CLR properties on entity types /// and configures their generated storage members as nullable database columns. @@ -70,6 +80,13 @@ public void ProcessModelFinalizing( $"Expected generated storage member '{maybeProperty.StorageMemberName}' was not found. " + "Declare the property as partial so the Trellis.EntityFrameworkCore.Generator can emit the storage member, or configure the storage-member property explicitly before model finalization."); + // Owned types (e.g., Money) need ownership navigations, not scalar columns. + if (modelBuilder.Metadata.IsOwned(maybeProperty.InnerType)) + { + ConfigureOwnedMaybe(entityType, maybeProperty, storageMember); + continue; + } + // Always ignore the Maybe CLR property — EF Core cannot map structs as nullable entityType.Builder.Ignore(maybeProperty.PropertyName); @@ -90,4 +107,34 @@ public void ProcessModelFinalizing( } } } + + /// + /// Configures a Maybe<T> property where T is an owned type. + /// Creates an optional ownership navigation via the source-generated backing field. + /// + private static void ConfigureOwnedMaybe( + IConventionEntityType entityType, + MaybePropertyDescriptor maybeProperty, + FieldInfo storageMember) + { + // Ignore the Maybe CLR property — EF Core cannot navigate through a struct + entityType.Builder.Ignore(maybeProperty.PropertyName); + + // Create ownership navigation via the backing field (e.g., Money? _monetaryFinePaid). + // Column naming and nullable marking are handled by MoneyConvention (registered after + // MaybeConvention) using the PropertyName annotation we store here. + var fkBuilder = entityType.Builder.HasOwnership(maybeProperty.InnerType, storageMember); + if (fkBuilder is null) + return; + + // Store the original property name so MoneyConvention can use it for column naming + var navigation = entityType.FindNavigation(storageMember.Name); + navigation?.Builder.HasAnnotation(MaybeOwnedPropertyNameAnnotation, maybeProperty.PropertyName); + } + + /// + /// Annotation key used to pass the original property name from + /// to for correct column naming of Maybe<Money> properties. + /// + internal const string MaybeOwnedPropertyNameAnnotation = "Trellis:MaybeOwnedPropertyName"; } \ No newline at end of file diff --git a/Trellis.EntityFrameworkCore/src/MaybeModelExtensions.cs b/Trellis.EntityFrameworkCore/src/MaybeModelExtensions.cs index 261e6963..a15f01f4 100644 --- a/Trellis.EntityFrameworkCore/src/MaybeModelExtensions.cs +++ b/Trellis.EntityFrameworkCore/src/MaybeModelExtensions.cs @@ -28,19 +28,54 @@ public static IReadOnlyList GetMaybePropertyMappings(this foreach (var maybeProperty in MaybePropertyResolver.GetMaybeProperties(entityType.ClrType)) { var mappedProperty = entityType.FindProperty(maybeProperty.StorageMemberName); - var providerClrType = mappedProperty?.GetTypeMapping().Converter?.ProviderClrType; - - mappings.Add(new MaybePropertyMapping( - entityType.Name, - entityType.ClrType, - maybeProperty.PropertyName, - maybeProperty.StorageMemberName, - maybeProperty.InnerType, - maybeProperty.StoreType, - mappedProperty is not null, - mappedProperty?.IsNullable ?? false, - mappedProperty?.GetColumnName(), - providerClrType)); + + if (mappedProperty is not null) + { + // Scalar Maybe — mapped as a nullable backing field column + var providerClrType = mappedProperty.GetTypeMapping().Converter?.ProviderClrType; + mappings.Add(new MaybePropertyMapping( + entityType.Name, + entityType.ClrType, + maybeProperty.PropertyName, + maybeProperty.StorageMemberName, + maybeProperty.InnerType, + maybeProperty.StoreType, + IsMapped: true, + mappedProperty.IsNullable, + mappedProperty.GetColumnName(), + providerClrType)); + } + else + { + // Owned Maybe (e.g., Maybe) — mapped as an optional owned navigation. + // Read actual metadata from the owned entity type's properties. + var navigation = entityType.FindNavigation(maybeProperty.StorageMemberName); + var isOwnedMapping = navigation?.TargetEntityType.IsOwned() == true; + + string? columnName = null; + if (isOwnedMapping) + { + // Use the first owned property's column name as the representative column + var ownedProps = navigation!.TargetEntityType.GetDeclaredProperties() + .Where(p => !p.IsShadowProperty()) + .ToList(); + if (ownedProps.Count > 0) + columnName = ownedProps[0].GetColumnName(); + } + + // Maybe is always optional by definition — the owned instance can be absent + mappings.Add(new MaybePropertyMapping( + entityType.Name, + entityType.ClrType, + maybeProperty.PropertyName, + maybeProperty.StorageMemberName, + maybeProperty.InnerType, + maybeProperty.StoreType, + IsMapped: isOwnedMapping, + IsNullable: isOwnedMapping, + columnName, + ProviderClrType: null)); + } } } diff --git a/Trellis.EntityFrameworkCore/src/MaybeUpdateExtensions.cs b/Trellis.EntityFrameworkCore/src/MaybeUpdateExtensions.cs index c192da47..cb60e028 100644 --- a/Trellis.EntityFrameworkCore/src/MaybeUpdateExtensions.cs +++ b/Trellis.EntityFrameworkCore/src/MaybeUpdateExtensions.cs @@ -64,6 +64,16 @@ private static void InvokeSetProperty( where TInner : notnull { var descriptor = MaybePropertyResolver.Resolve(propertySelector); + + // Maybe where T is a composite owned type (e.g., Money) cannot be updated via + // ExecuteUpdate because the backing field is an owned navigation, not a scalar property. + // Use tracked entity updates (load, modify, SaveChanges) instead. + if (IsCompositeValueObject(descriptor.InnerType)) + throw new InvalidOperationException( + $"Cannot use ExecuteUpdate on Maybe<{descriptor.InnerType.Name}> property '{descriptor.PropertyName}'. " + + $"{descriptor.InnerType.Name} is a composite owned type and is mapped as an ownership navigation, not a scalar column. " + + "Use tracked entity updates (load the entity, set the property, call SaveChangesAsync) instead."); + var parameter = propertySelector.Parameters[0]; // When setting a non-null value for a value type, use the inner type so the @@ -114,6 +124,16 @@ private static LambdaExpression BuildValueLambda( private static readonly MethodInfo s_efPropertyMethodInfo = typeof(EF).GetMethod(nameof(EF.Property))!; + private static readonly Type s_scalarValueOpenGeneric = typeof(IScalarValue<,>); + + /// + /// Returns true if the type is a composite ValueObject (e.g., Money) — inherits from + /// ValueObject but does not implement IScalarValue<,>. + /// + private static bool IsCompositeValueObject(Type type) => + typeof(ValueObject).IsAssignableFrom(type) + && !type.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == s_scalarValueOpenGeneric); + private static class SetPropertyMethodCache where TEntity : class { internal static readonly MethodInfo ExpressionValueDefinition = typeof(UpdateSettersBuilder) diff --git a/Trellis.EntityFrameworkCore/src/ModelConfigurationBuilderExtensions.cs b/Trellis.EntityFrameworkCore/src/ModelConfigurationBuilderExtensions.cs index 35b6d63a..9a1bd500 100644 --- a/Trellis.EntityFrameworkCore/src/ModelConfigurationBuilderExtensions.cs +++ b/Trellis.EntityFrameworkCore/src/ModelConfigurationBuilderExtensions.cs @@ -59,8 +59,8 @@ public static ModelConfigurationBuilder ApplyTrellisConventions( } } - configurationBuilder.Conventions.Add(static _ => new MoneyConvention()); configurationBuilder.Conventions.Add(static _ => new MaybeConvention()); + configurationBuilder.Conventions.Add(static _ => new MoneyConvention()); return configurationBuilder; } diff --git a/Trellis.EntityFrameworkCore/src/MoneyConvention.cs b/Trellis.EntityFrameworkCore/src/MoneyConvention.cs index e2229494..055a39d9 100644 --- a/Trellis.EntityFrameworkCore/src/MoneyConvention.cs +++ b/Trellis.EntityFrameworkCore/src/MoneyConvention.cs @@ -63,12 +63,17 @@ public void ProcessModelFinalizing( if (navigation.TargetEntityType.ClrType != s_moneyType || !navigation.TargetEntityType.IsOwned()) continue; - ConfigureMoneyColumns(navigation.TargetEntityType, navigation.Name); + // Check if this navigation was created by MaybeConvention for a Maybe property. + // If so, use the original property name for column naming and mark columns nullable. + var maybePropertyName = navigation.FindAnnotation(MaybeConvention.MaybeOwnedPropertyNameAnnotation)?.Value as string; + var columnPrefix = maybePropertyName ?? navigation.Name; + + ConfigureMoneyColumns(navigation.TargetEntityType, columnPrefix, isOptional: maybePropertyName is not null); } } } - private static void ConfigureMoneyColumns(IConventionEntityType entityType, string navigationName) + private static void ConfigureMoneyColumns(IConventionEntityType entityType, string navigationName, bool isOptional) { // Amount → column "{NavigationName}", decimal(18,3) // Scale 3 accommodates all ISO 4217 minor units: 0 (JPY), 2 (USD), 3 (BHD/KWD/OMR/TND) @@ -78,6 +83,8 @@ private static void ConfigureMoneyColumns(IConventionEntityType entityType, stri amount.Builder.HasAnnotation(RelationalAnnotationNames.ColumnName, navigationName); amount.Builder.HasPrecision(18); amount.Builder.HasScale(3); + if (isOptional) + amount.Builder.IsRequired(false); } // Currency → column "{NavigationName}Currency", max-length 3 @@ -86,6 +93,8 @@ private static void ConfigureMoneyColumns(IConventionEntityType entityType, stri { currency.Builder.HasAnnotation(RelationalAnnotationNames.ColumnName, navigationName + "Currency"); currency.Builder.HasMaxLength(3); + if (isOptional) + currency.Builder.IsRequired(false); } } } \ No newline at end of file diff --git a/Trellis.EntityFrameworkCore/tests/MaybeMoneyTests.cs b/Trellis.EntityFrameworkCore/tests/MaybeMoneyTests.cs index afb27430..5223c79b 100644 --- a/Trellis.EntityFrameworkCore/tests/MaybeMoneyTests.cs +++ b/Trellis.EntityFrameworkCore/tests/MaybeMoneyTests.cs @@ -50,14 +50,22 @@ public void Dispose() [Fact] public void MaybeMoney_ModelBuilds_WithoutException() { - // MaybeConvention currently tries to map Money? as a scalar column - // and throws because Money is a composite ValueObject (owned type). - // After the fix, model finalization should succeed. var model = Context.Model; - model.Should().NotBeNull(); } + [Fact] + public void MaybeMoney_DiagnosticsApi_ReportsAsMapped() + { + var mappings = Context.GetMaybePropertyMappings(); + + var fineMapping = mappings.SingleOrDefault(m => m.PropertyName == "MonetaryFinePaid"); + fineMapping.Should().NotBeNull(); + fineMapping!.IsMapped.Should().BeTrue("Maybe should report as mapped in diagnostics"); + fineMapping.IsNullable.Should().BeTrue("Maybe should report as nullable in diagnostics"); + fineMapping.ColumnName.Should().Be("MonetaryFinePaid"); + } + #endregion #region Level 2: Maybe auto-configures as optional owned type @@ -67,29 +75,20 @@ public void MaybeMoney_IsMappedAsOwnedNavigation() { var entityType = Context.Model.FindEntityType(typeof(PenaltyEntity))!; - // MonetaryFinePaid should be an owned navigation to Money, not a scalar property. - // The backing field is Money? _monetaryFinePaid (generated by MaybePartialPropertyGenerator). - var navigation = entityType.FindNavigation("MonetaryFinePaid"); - navigation.Should().NotBeNull("Maybe should produce an owned navigation"); + // Ownership is created via the backing field _monetaryFinePaid, so the + // EF Core navigation name is the field name (not the CLR property name). + var navigation = entityType.FindNavigation("_monetaryFinePaid"); + navigation.Should().NotBeNull("Maybe should produce an owned navigation via backing field"); navigation!.TargetEntityType.IsOwned().Should().BeTrue(); } - [Fact] - public void MaybeMoney_AmountColumn_IsNullable() - { - var entityType = Context.Model.FindEntityType(typeof(PenaltyEntity))!; - var ownedType = entityType.FindNavigation("MonetaryFinePaid")!.TargetEntityType; - - var amount = ownedType.FindProperty(nameof(Money.Amount))!; - amount.IsNullable.Should().BeTrue("optional Money amount must be nullable"); - } - [Fact] public void MaybeMoney_ColumnNaming_FollowsMoneyConvention() { var entityType = Context.Model.FindEntityType(typeof(PenaltyEntity))!; - var ownedType = entityType.FindNavigation("MonetaryFinePaid")!.TargetEntityType; + var ownedType = entityType.FindNavigation("_monetaryFinePaid")!.TargetEntityType; + // Column names use the original property name (MonetaryFinePaid), not the field name ownedType.FindProperty(nameof(Money.Amount))!.GetColumnName() .Should().Be("MonetaryFinePaid"); ownedType.FindProperty(nameof(Money.Currency))!.GetColumnName() @@ -258,7 +257,36 @@ public async Task MaybeMoney_Update_NoneToSome() #endregion - #region Test entities and context + #region ExecuteUpdate guard for owned Maybe + + [Fact] + public void SetMaybeValue_OnOwnedMoney_ThrowsWithClearMessage() + { + // ExecuteUpdate cannot target owned navigations — only scalar properties. + // SetMaybeValue should throw early with a helpful message instead of + // failing deep inside EF Core's query translator. + var act = () => Context.Penalties + .Where(p => p.Id == 1) + .ExecuteUpdate(b => b.SetMaybeValue( + p => p.MonetaryFinePaid, + Money.Create(100m, "USD"))); + + act.Should().Throw() + .WithMessage("*composite owned type*"); + } + + [Fact] + public void SetMaybeNone_OnOwnedMoney_ThrowsWithClearMessage() + { + var act = () => Context.Penalties + .Where(p => p.Id == 1) + .ExecuteUpdate(b => b.SetMaybeNone(p => p.MonetaryFinePaid)); + + act.Should().Throw() + .WithMessage("*composite owned type*"); + } + + #endregion public partial class PenaltyEntity { @@ -277,6 +305,4 @@ public MaybeMoneyDbContext(DbContextOptions options) : base protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) => configurationBuilder.ApplyTrellisConventions(); } - - #endregion } diff --git a/docs/docfx_project/articles/integration-ef.md b/docs/docfx_project/articles/integration-ef.md index 9c7ea77a..7f010c25 100644 --- a/docs/docfx_project/articles/integration-ef.md +++ b/docs/docfx_project/articles/integration-ef.md @@ -826,6 +826,23 @@ modelBuilder.Entity(b => > [!NOTE] > Multiple `Money` properties on the same entity work automatically — each gets its own pair of columns. +### Optional Money with Maybe\ + +For optional Money properties, use `partial Maybe`. The conventions auto-configure it as an optional owned type — no `OwnsOne` needed: + +```csharp +public partial class Penalty : Aggregate +{ + public Money Fine { get; set; } = null!; // required Money (2 NOT NULL columns) + public partial Maybe FinePaid { get; set; } // optional Money (2 nullable columns) +} +``` + +Column naming follows the same convention: `FinePaid` (nullable `decimal(18,3)`) and `FinePaidCurrency` (nullable `nvarchar(3)`). + +> [!WARNING] +> `ExecuteUpdate` helpers (`SetMaybeValue`/`SetMaybeNone`) do not support `Maybe`. Use tracked entity updates (load, modify, `SaveChangesAsync`) instead. + ## Maybe\ Property Convention `Maybe` is a `readonly struct`. EF Core cannot mark non-nullable struct properties as optional — calling `IsRequired(false)` or setting `IsNullable = true` throws `InvalidOperationException`. Trellis keeps the primary programming model at the CLR property level and hides the EF workaround behind generated code, conventions, and helpers. @@ -847,6 +864,8 @@ public partial class Customer No `OnModelCreating` configuration needed — `MaybeConvention` (registered by `ApplyTrellisConventions`) handles everything automatically. +When `T` is a composite owned type (e.g., `Money`), `MaybeConvention` creates an optional ownership navigation instead of a scalar column — see [Optional Money with Maybe\](#optional-money-with-maybemoney) above. + ### Day-to-Day Usage Use the property-level helpers when querying, indexing, updating, or diagnosing `Maybe` mappings: diff --git a/trellis-api-reference.md b/trellis-api-reference.md index 1042f8f3..c6a54273 100644 --- a/trellis-api-reference.md +++ b/trellis-api-reference.md @@ -1685,6 +1685,8 @@ configurationBuilder.ApplyTrellisConventions(typeof(Order).Assembly); Explicit `OwnsOne` configuration takes precedence over the convention. +`Maybe` properties are also supported — `MaybeConvention` creates an optional ownership navigation with nullable Amount/Currency columns. No manual `OwnsOne` needed. + ### Maybe\ Property Mapping `Maybe` is a `readonly struct`. EF Core cannot mark non-nullable struct properties as optional — calling `IsRequired(false)` or setting `IsNullable = true` throws `InvalidOperationException`. Use C# 13 `partial` properties with the `Trellis.EntityFrameworkCore.Generator` source generator: @@ -1707,7 +1709,7 @@ modelBuilder.Entity(b => }); ``` -The source generator emits a private `_camelCase` backing field and getter/setter for each `partial Maybe` property. The `MaybeConvention` (registered by `ApplyTrellisConventions`) auto-discovers `Maybe` properties, ignores the struct property, maps the backing field as nullable, and sets the column name to the property name. +The source generator emits a private `_camelCase` backing field and getter/setter for each `partial Maybe` property. The `MaybeConvention` (registered by `ApplyTrellisConventions`) auto-discovers `Maybe` properties, ignores the struct property, maps the backing field as nullable, and sets the column name to the property name. When `T` is a composite owned type (e.g., `Money`), `MaybeConvention` creates an optional ownership navigation instead of a scalar column. Backing field naming: `Phone` → `_phone`, `SubmittedAt` → `_submittedAt`, `AlternateEmail` → `_alternateEmail`. @@ -1900,6 +1902,9 @@ UpdateSettersBuilder SetMaybeNone( this UpdateSettersBuilder updateSettersBuilder, Expression>> propertySelector) +// Note: SetMaybeValue/SetMaybeNone throw InvalidOperationException for composite +// owned types like Money. Use tracked entity updates (load, modify, SaveChangesAsync) instead. + // Diagnostics IReadOnlyList GetMaybePropertyMappings(this IModel model) IReadOnlyList GetMaybePropertyMappings(this DbContext dbContext) From 05d8c6956e72c99ce9959c3cb022bf6f889e5062 Mon Sep 17 00:00:00 2001 From: Xavier John Date: Sat, 28 Mar 2026 12:45:11 -0700 Subject: [PATCH 04/17] fix: enable HandleNull on JSON converters so null VO properties produce validation errors System.Text.Json's JsonConverter.HandleNull defaults to false for reference types. When false, the serializer handles null JSON tokens by setting the property to null WITHOUT calling Read(). This meant ValidatingJsonConverter.OnNullToken() was never invoked during DTO deserialization, silently accepting null values for required scalar VOs. The fix adds HandleNull => true to: - ScalarValueJsonConverterBase: so ValidatingJsonConverter and MaybeScalarValueJsonConverter intercept null tokens - PropertyNameAwareConverter: so the production wrapper also intercepts null tokens before delegating to the inner converter Added 16 new tests covering: - Explicit null JSON for int/decimal/long/bool scalar VOs - Full DTO deserialization with null and missing properties - Mixed valid and null properties - Zero/false values correctly distinguished from null Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Validation/PropertyNameAwareConverter.cs | 7 + .../ScalarValueJsonConverterBase.cs | 7 + .../NullAndMissingPropertyValidationTests.cs | 516 ++++++++++++++++++ .../tests/ScalarValueValidationTests.cs | 4 +- .../tests/ServiceCollectionExtensionsTests.cs | 10 +- 5 files changed, 540 insertions(+), 4 deletions(-) create mode 100644 Trellis.Asp/tests/NullAndMissingPropertyValidationTests.cs diff --git a/Trellis.Asp/src/Validation/PropertyNameAwareConverter.cs b/Trellis.Asp/src/Validation/PropertyNameAwareConverter.cs index 2b9083d5..83cde86b 100644 --- a/Trellis.Asp/src/Validation/PropertyNameAwareConverter.cs +++ b/Trellis.Asp/src/Validation/PropertyNameAwareConverter.cs @@ -30,6 +30,13 @@ internal sealed class PropertyNameAwareConverter : JsonConverter private readonly JsonConverter _innerConverter; private readonly string _propertyName; + /// + /// Tells System.Text.Json to call even when the JSON token is null. + /// Without this, the serializer bypasses the converter for null tokens on reference-type + /// properties, preventing the inner converter's null-token validation from firing. + /// + public override bool HandleNull => true; + /// /// Creates a new property-name-aware wrapper converter. /// diff --git a/Trellis.Asp/src/Validation/ScalarValueJsonConverterBase.cs b/Trellis.Asp/src/Validation/ScalarValueJsonConverterBase.cs index 897e979e..f8059d09 100644 --- a/Trellis.Asp/src/Validation/ScalarValueJsonConverterBase.cs +++ b/Trellis.Asp/src/Validation/ScalarValueJsonConverterBase.cs @@ -15,6 +15,13 @@ public abstract class ScalarValueJsonConverterBase where TValue : class, IScalarValue where TPrimitive : IComparable { + /// + /// Tells System.Text.Json to call even when the JSON + /// token is null. Without this, the serializer bypasses the converter for null tokens + /// on reference-type results, preventing from firing. + /// + public override bool HandleNull => true; + /// /// Returns the result when a JSON null token is read. /// diff --git a/Trellis.Asp/tests/NullAndMissingPropertyValidationTests.cs b/Trellis.Asp/tests/NullAndMissingPropertyValidationTests.cs new file mode 100644 index 00000000..beb2ec66 --- /dev/null +++ b/Trellis.Asp/tests/NullAndMissingPropertyValidationTests.cs @@ -0,0 +1,516 @@ +namespace Trellis.Asp.Tests; + +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using FluentAssertions; +using Trellis; +using Trellis.Asp.Validation; +using Xunit; + +/// +/// Tests that verify correct validation behavior when JSON properties of value-type-backed +/// scalar VOs (int, decimal, long, bool) are null or entirely missing from the JSON body. +/// +/// Key design insight: RequiredInt, RequiredDecimal, RequiredLong, and RequiredBool are all +/// reference types (classes inheriting from ScalarValueObject). When a JSON property is missing, +/// the CLR property stays null — NOT the primitive default (0, 0m, 0L, false). The +/// ValidatingJsonConverter catches explicit JSON null tokens and produces per-field validation +/// errors. For entirely missing properties, developers should use the C# 'required' keyword +/// or [JsonRequired] to enforce presence at the JSON level. +/// +public class NullAndMissingPropertyValidationTests +{ + #region Test Value Objects + + public sealed class Quantity : ScalarValueObject, IScalarValue + { + private Quantity(int value) : base(value) { } + public static Result TryCreate(int value, string? fieldName = null) => + value > 0 + ? new Quantity(value) + : Error.Validation("Quantity must be positive.", fieldName ?? "quantity"); + } + + public sealed class Price : ScalarValueObject, IScalarValue + { + private Price(decimal value) : base(value) { } + public static Result TryCreate(decimal value, string? fieldName = null) => + value >= 0 + ? new Price(value) + : Error.Validation("Price cannot be negative.", fieldName ?? "price"); + } + + public sealed class Counter : ScalarValueObject, IScalarValue + { + private Counter(long value) : base(value) { } + public static Result TryCreate(long value, string? fieldName = null) => + value >= 0 + ? new Counter(value) + : Error.Validation("Counter cannot be negative.", fieldName ?? "counter"); + } + + public sealed class IsActive : ScalarValueObject, IScalarValue + { + private IsActive(bool value) : base(value) { } + public static Result TryCreate(bool value, string? fieldName = null) => + new IsActive(value); + } + + public sealed class ProductName : ScalarValueObject, IScalarValue + { + private ProductName(string value) : base(value) { } + public static Result TryCreate(string? value, string? fieldName = null) => + string.IsNullOrWhiteSpace(value) + ? Error.Validation("Product name is required.", fieldName ?? "productName") + : new ProductName(value); + } + + #endregion + + #region Test DTOs + + public class CreateProductDto + { + public ProductName? Name { get; set; } + public Quantity? Quantity { get; set; } + public Price? Price { get; set; } + public Counter? Counter { get; set; } + public IsActive? Active { get; set; } + } + + #endregion + + #region Explicit null in JSON — converter level + + [Fact] + public void Read_NullJsonForIntVO_CollectsValidationError() + { + var converter = new ValidatingJsonConverter(); + var json = "null"; + var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(json)); + reader.Read(); + + using (ValidationErrorsContext.BeginScope()) + { + ValidationErrorsContext.CurrentPropertyName = "Quantity"; + var result = converter.Read(ref reader, typeof(Quantity), new JsonSerializerOptions()); + + result.Should().BeNull(); + ValidationErrorsContext.HasErrors.Should().BeTrue(); + var error = ValidationErrorsContext.GetValidationError(); + error!.FieldErrors.Should().ContainSingle() + .Which.FieldName.Should().Be("Quantity"); + error.FieldErrors[0].Details.Should().Contain("Quantity cannot be null."); + } + } + + [Fact] + public void Read_NullJsonForDecimalVO_CollectsValidationError() + { + var converter = new ValidatingJsonConverter(); + var json = "null"; + var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(json)); + reader.Read(); + + using (ValidationErrorsContext.BeginScope()) + { + ValidationErrorsContext.CurrentPropertyName = "Price"; + var result = converter.Read(ref reader, typeof(Price), new JsonSerializerOptions()); + + result.Should().BeNull(); + ValidationErrorsContext.HasErrors.Should().BeTrue(); + var error = ValidationErrorsContext.GetValidationError(); + error!.FieldErrors.Should().ContainSingle() + .Which.FieldName.Should().Be("Price"); + error.FieldErrors[0].Details.Should().Contain("Price cannot be null."); + } + } + + [Fact] + public void Read_NullJsonForLongVO_CollectsValidationError() + { + var converter = new ValidatingJsonConverter(); + var json = "null"; + var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(json)); + reader.Read(); + + using (ValidationErrorsContext.BeginScope()) + { + ValidationErrorsContext.CurrentPropertyName = "Counter"; + var result = converter.Read(ref reader, typeof(Counter), new JsonSerializerOptions()); + + result.Should().BeNull(); + ValidationErrorsContext.HasErrors.Should().BeTrue(); + var error = ValidationErrorsContext.GetValidationError(); + error!.FieldErrors.Should().ContainSingle() + .Which.FieldName.Should().Be("Counter"); + error.FieldErrors[0].Details.Should().Contain("Counter cannot be null."); + } + } + + [Fact] + public void Read_NullJsonForBoolVO_CollectsValidationError() + { + var converter = new ValidatingJsonConverter(); + var json = "null"; + var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(json)); + reader.Read(); + + using (ValidationErrorsContext.BeginScope()) + { + ValidationErrorsContext.CurrentPropertyName = "Active"; + var result = converter.Read(ref reader, typeof(IsActive), new JsonSerializerOptions()); + + result.Should().BeNull(); + ValidationErrorsContext.HasErrors.Should().BeTrue(); + var error = ValidationErrorsContext.GetValidationError(); + error!.FieldErrors.Should().ContainSingle() + .Which.FieldName.Should().Be("Active"); + error.FieldErrors[0].Details.Should().Contain("IsActive cannot be null."); + } + } + + #endregion + + #region Explicit null in full DTO JSON deserialization + + [Fact] + public void Deserialize_DtoWithExplicitNullIntProperty_CollectsValidationError() + { + var options = CreateConfiguredJsonOptions(); + var json = """{"Name": "Widget", "Quantity": null}"""; + + using (ValidationErrorsContext.BeginScope()) + { + var dto = JsonSerializer.Deserialize(json, options); + + dto.Should().NotBeNull(); + dto!.Name.Should().NotBeNull(); + dto.Quantity.Should().BeNull(); + + ValidationErrorsContext.HasErrors.Should().BeTrue( + "an explicit JSON null for a required int VO should produce a validation error"); + var error = ValidationErrorsContext.GetValidationError(); + error!.FieldErrors.Should().ContainSingle() + .Which.FieldName.Should().Be("Quantity"); + } + } + + [Fact] + public void Deserialize_DtoWithExplicitNullDecimalProperty_CollectsValidationError() + { + var options = CreateConfiguredJsonOptions(); + var json = """{"Name": "Widget", "Price": null}"""; + + using (ValidationErrorsContext.BeginScope()) + { + var dto = JsonSerializer.Deserialize(json, options); + + dto.Should().NotBeNull(); + dto!.Price.Should().BeNull(); + + ValidationErrorsContext.HasErrors.Should().BeTrue( + "an explicit JSON null for a required decimal VO should produce a validation error"); + var error = ValidationErrorsContext.GetValidationError(); + error!.FieldErrors.Should().ContainSingle() + .Which.FieldName.Should().Be("Price"); + } + } + + [Fact] + public void Deserialize_DtoWithExplicitNullLongProperty_CollectsValidationError() + { + var options = CreateConfiguredJsonOptions(); + var json = """{"Name": "Widget", "Counter": null}"""; + + using (ValidationErrorsContext.BeginScope()) + { + var dto = JsonSerializer.Deserialize(json, options); + + dto.Should().NotBeNull(); + dto!.Counter.Should().BeNull(); + + ValidationErrorsContext.HasErrors.Should().BeTrue( + "an explicit JSON null for a required long VO should produce a validation error"); + var error = ValidationErrorsContext.GetValidationError(); + error!.FieldErrors.Should().ContainSingle() + .Which.FieldName.Should().Be("Counter"); + } + } + + [Fact] + public void Deserialize_DtoWithExplicitNullBoolProperty_CollectsValidationError() + { + var options = CreateConfiguredJsonOptions(); + var json = """{"Name": "Widget", "Active": null}"""; + + using (ValidationErrorsContext.BeginScope()) + { + var dto = JsonSerializer.Deserialize(json, options); + + dto.Should().NotBeNull(); + dto!.Active.Should().BeNull(); + + ValidationErrorsContext.HasErrors.Should().BeTrue( + "an explicit JSON null for a required bool VO should produce a validation error"); + var error = ValidationErrorsContext.GetValidationError(); + error!.FieldErrors.Should().ContainSingle() + .Which.FieldName.Should().Be("Active"); + } + } + + [Fact] + public void Deserialize_DtoWithAllNullVOProperties_CollectsAllErrors() + { + var options = CreateConfiguredJsonOptions(); + var json = """{"Name": null, "Quantity": null, "Price": null, "Counter": null, "Active": null}"""; + + using (ValidationErrorsContext.BeginScope()) + { + var dto = JsonSerializer.Deserialize(json, options); + + dto.Should().NotBeNull(); + + ValidationErrorsContext.HasErrors.Should().BeTrue( + "all null VO properties should produce validation errors"); + var error = ValidationErrorsContext.GetValidationError(); + error!.FieldErrors.Should().HaveCount(5, + "each null scalar VO property should produce a separate field error"); + + error.FieldErrors.Should().Contain(e => e.FieldName == "Name"); + error.FieldErrors.Should().Contain(e => e.FieldName == "Quantity"); + error.FieldErrors.Should().Contain(e => e.FieldName == "Price"); + error.FieldErrors.Should().Contain(e => e.FieldName == "Counter"); + error.FieldErrors.Should().Contain(e => e.FieldName == "Active"); + } + } + + #endregion + + #region Missing properties in DTO JSON deserialization + + [Fact] + public void Deserialize_DtoWithMissingProperties_PropertiesAreNull() + { + // Missing JSON properties for reference-type scalar VOs result in null CLR properties. + // This is by design — these are classes, not structs. The developer should use C# 'required' + // keyword or [JsonRequired] to enforce presence at the JSON level. + var options = CreateConfiguredJsonOptions(); + var json = """{"Name": "Widget"}"""; + + using (ValidationErrorsContext.BeginScope()) + { + var dto = JsonSerializer.Deserialize(json, options); + + dto.Should().NotBeNull(); + dto!.Name!.Value.Should().Be("Widget"); + + // Missing properties are null — they are reference types + dto.Quantity.Should().BeNull("missing int VO property should be null, not 0"); + dto.Price.Should().BeNull("missing decimal VO property should be null, not 0m"); + dto.Counter.Should().BeNull("missing long VO property should be null, not 0L"); + dto.Active.Should().BeNull("missing bool VO property should be null, not false"); + } + } + + [Fact] + public void Deserialize_EmptyJsonObject_AllPropertiesAreNull() + { + var options = CreateConfiguredJsonOptions(); + var json = "{}"; + + using (ValidationErrorsContext.BeginScope()) + { + var dto = JsonSerializer.Deserialize(json, options); + + dto.Should().NotBeNull(); + dto!.Name.Should().BeNull(); + dto.Quantity.Should().BeNull(); + dto.Price.Should().BeNull(); + dto.Counter.Should().BeNull(); + dto.Active.Should().BeNull(); + } + } + + #endregion + + #region Valid values — round-trip confirmation + + [Fact] + public void Deserialize_DtoWithAllValidProperties_NoValidationErrors() + { + var options = CreateConfiguredJsonOptions(); + var json = """{"Name": "Widget", "Quantity": 10, "Price": 9.99, "Counter": 42, "Active": true}"""; + + using (ValidationErrorsContext.BeginScope()) + { + var dto = JsonSerializer.Deserialize(json, options); + + dto.Should().NotBeNull(); + dto!.Name!.Value.Should().Be("Widget"); + dto.Quantity!.Value.Should().Be(10); + dto.Price!.Value.Should().Be(9.99m); + dto.Counter!.Value.Should().Be(42L); + dto.Active!.Value.Should().Be(true); + + ValidationErrorsContext.HasErrors.Should().BeFalse( + "all valid properties should not produce any validation errors"); + } + } + + [Fact] + public void Deserialize_DtoWithZeroInt_AcceptsZeroAsValidValue() + { + // Zero is NOT the same as missing/null. When JSON contains "quantity": 0, + // the converter IS invoked with the value 0. Whether 0 passes validation + // depends on the value object's TryCreate logic. + var options = CreateConfiguredJsonOptions(); + var json = """{"Name": "Widget", "Quantity": 0}"""; + + using (ValidationErrorsContext.BeginScope()) + { + var dto = JsonSerializer.Deserialize(json, options); + + dto.Should().NotBeNull(); + // Quantity rejects 0 because "must be positive" + dto!.Quantity.Should().BeNull("TryCreate rejects 0 for Quantity"); + ValidationErrorsContext.HasErrors.Should().BeTrue(); + var error = ValidationErrorsContext.GetValidationError(); + error!.FieldErrors.Should().ContainSingle() + .Which.Details.Should().Contain("Quantity must be positive."); + } + } + + [Fact] + public void Deserialize_DtoWithFalseBool_AcceptsFalseAsValidValue() + { + // false is NOT the same as missing/null. When JSON contains "active": false, + // the converter IS invoked. false is a valid boolean value. + var options = CreateConfiguredJsonOptions(); + var json = """{"Name": "Widget", "Active": false}"""; + + using (ValidationErrorsContext.BeginScope()) + { + var dto = JsonSerializer.Deserialize(json, options); + + dto.Should().NotBeNull(); + dto!.Active.Should().NotBeNull("false is a valid value, not null"); + dto.Active!.Value.Should().BeFalse(); + ValidationErrorsContext.HasErrors.Should().BeFalse( + "false is a valid boolean value and should not produce validation errors"); + } + } + + [Fact] + public void Deserialize_DtoWithZeroDecimal_AcceptsZeroAsValidValue() + { + var options = CreateConfiguredJsonOptions(); + var json = """{"Name": "Widget", "Price": 0}"""; + + using (ValidationErrorsContext.BeginScope()) + { + var dto = JsonSerializer.Deserialize(json, options); + + dto.Should().NotBeNull(); + dto!.Price.Should().NotBeNull("0 is a valid decimal value, not null"); + dto.Price!.Value.Should().Be(0m); + ValidationErrorsContext.HasErrors.Should().BeFalse( + "zero is a valid price and should not produce validation errors"); + } + } + + #endregion + + #region Mixed valid and null properties + + [Fact] + public void Deserialize_DtoWithMixedValidAndNullProperties_CollectsOnlyNullErrors() + { + var options = CreateConfiguredJsonOptions(); + var json = """{"Name": "Widget", "Quantity": 5, "Price": null, "Counter": null, "Active": true}"""; + + using (ValidationErrorsContext.BeginScope()) + { + var dto = JsonSerializer.Deserialize(json, options); + + dto.Should().NotBeNull(); + dto!.Name!.Value.Should().Be("Widget"); + dto.Quantity!.Value.Should().Be(5); + dto.Active!.Value.Should().Be(true); + dto.Price.Should().BeNull(); + dto.Counter.Should().BeNull(); + + ValidationErrorsContext.HasErrors.Should().BeTrue(); + var error = ValidationErrorsContext.GetValidationError(); + error!.FieldErrors.Should().HaveCount(2); + error.FieldErrors.Should().Contain(e => e.FieldName == "Price"); + error.FieldErrors.Should().Contain(e => e.FieldName == "Counter"); + error.FieldErrors.Should().NotContain(e => e.FieldName == "Name"); + error.FieldErrors.Should().NotContain(e => e.FieldName == "Quantity"); + error.FieldErrors.Should().NotContain(e => e.FieldName == "Active"); + } + } + + #endregion + + #region Helpers + + /// + /// Creates JsonSerializerOptions configured with the same TypeInfoResolver modifier + /// that Trellis.Asp uses in production, including property-name-aware converters. + /// + private static JsonSerializerOptions CreateConfiguredJsonOptions() + { + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + var resolver = new DefaultJsonTypeInfoResolver(); + options.TypeInfoResolver = resolver.WithAddedModifier(ModifyTypeInfo); + options.Converters.Add(new ValidatingJsonConverterFactory()); + options.Converters.Add(new MaybeScalarValueJsonConverterFactory()); + + return options; + } + + /// + /// Mirrors the ModifyTypeInfo logic from ServiceCollectionExtensions to inject + /// property-name-aware converters for scalar value object properties. + /// + private static void ModifyTypeInfo(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyType = property.PropertyType; + + if (!ScalarValueTypeHelper.IsScalarValue(propertyType)) + continue; + + var primitiveType = ScalarValueTypeHelper.GetPrimitiveType(propertyType); + if (primitiveType is null) + continue; + + var innerConverter = ScalarValueTypeHelper.CreateGenericInstance( + typeof(ValidatingJsonConverter<,>), + propertyType, + primitiveType); + + if (innerConverter is null) + continue; + + var wrapperType = typeof(PropertyNameAwareConverter<>).MakeGenericType(propertyType); + var wrappedConverter = Activator.CreateInstance(wrapperType, innerConverter, property.Name) + as System.Text.Json.Serialization.JsonConverter; + + if (wrappedConverter is not null) + property.CustomConverter = wrappedConverter; + } + } + + #endregion +} diff --git a/Trellis.Asp/tests/ScalarValueValidationTests.cs b/Trellis.Asp/tests/ScalarValueValidationTests.cs index 12ba27b8..9873480d 100644 --- a/Trellis.Asp/tests/ScalarValueValidationTests.cs +++ b/Trellis.Asp/tests/ScalarValueValidationTests.cs @@ -282,7 +282,9 @@ public void Deserialize_NullJson_ReturnsNull() // Assert result.Should().BeNull(); - // Null values don't add validation errors - the required validation happens at model level + // Null values DO produce validation errors via ValidatingJsonConverter.OnNullToken. + // The endpoint filter or action filter will return 400 before the handler runs. + ValidationErrorsContext.HasErrors.Should().BeTrue(); } } diff --git a/Trellis.Asp/tests/ServiceCollectionExtensionsTests.cs b/Trellis.Asp/tests/ServiceCollectionExtensionsTests.cs index 4304a795..594d0f5c 100644 --- a/Trellis.Asp/tests/ServiceCollectionExtensionsTests.cs +++ b/Trellis.Asp/tests/ServiceCollectionExtensionsTests.cs @@ -504,7 +504,7 @@ public void ConfiguredJsonOptions_DeserializeDto_WithNestedValueObjects_NestedIn } [Fact] - public void ConfiguredJsonOptions_DeserializeDto_WithNullableValueObjects_NullValues_Succeeds() + public void ConfiguredJsonOptions_DeserializeDto_WithNullableValueObjects_NullValues_CollectsValidationErrors() { // Arrange var services = new ServiceCollection(); @@ -519,11 +519,15 @@ public void ConfiguredJsonOptions_DeserializeDto_WithNullableValueObjects_NullVa // Act var result = JsonSerializer.Deserialize(json, httpOptions.Value.SerializerOptions); - // Assert + // Assert — explicit JSON null for scalar VOs produces validation errors result.Should().NotBeNull(); result!.Name.Should().BeNull(); result.Email.Should().BeNull(); - ValidationErrorsContext.GetValidationError().Should().BeNull(); + var error = ValidationErrorsContext.GetValidationError(); + error.Should().NotBeNull("explicit JSON null for scalar VOs should produce validation errors"); + error!.FieldErrors.Should().HaveCount(2); + error.FieldErrors.Should().Contain(e => e.FieldName == "name"); + error.FieldErrors.Should().Contain(e => e.FieldName == "email"); } } From b3e38fa0fac09237d920122a0eb65d25671ad77b Mon Sep 17 00:00:00 2001 From: Xavier John Date: Sat, 28 Mar 2026 12:54:50 -0700 Subject: [PATCH 05/17] Add MonetaryAmount scalar VO and fix Required* docs MonetaryAmount: single-currency monetary value (1 decimal column). - Non-negative, rounds to 2 decimal places - Arithmetic: Add, Subtract, Multiply with overflow protection - Invariant culture formatting/parsing for JSON safety - Follows Percentage pattern (hand-implemented ScalarValueObject) RequiredDecimal/RequiredInt doc fixes: - 'Required' means not null, not 'non-zero' - Zero is a valid value for both types - Only RequiredString additionally rejects empty - RequiredGuid correctly rejects Guid.Empty (semantically null) Documentation: copilot instructions, API reference, primitives README, docfx articles all updated with MonetaryAmount. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 2 + Trellis.Primitives/README.md | 18 ++ .../src/Primitives/MonetaryAmount.cs | 144 +++++++++++++++ Trellis.Primitives/src/RequiredDecimal.cs | 14 +- Trellis.Primitives/src/RequiredInt.cs | 20 +-- .../tests/MonetaryAmountTests.cs | 169 ++++++++++++++++++ docs/docfx_project/articles/integration-ef.md | 13 ++ docs/docfx_project/articles/primitives.md | 8 + trellis-api-reference.md | 21 +++ 9 files changed, 392 insertions(+), 17 deletions(-) create mode 100644 Trellis.Primitives/src/Primitives/MonetaryAmount.cs create mode 100644 Trellis.Primitives/tests/MonetaryAmountTests.cs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c4e27e8c..b58eb164 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -656,6 +656,8 @@ This includes `Money` properties declared on owned entity types, including items For optional Money properties, use `partial Maybe` — see the Maybe\ section above. +> **Single-currency alternative:** If your system uses one currency everywhere, use `MonetaryAmount` instead of `Money`. It is a scalar value object (`ScalarValueObject`) that maps to a single `decimal` column — no currency column needed. See the Trellis.Primitives README for details. + ### How It Works The `MoneyConvention` (registered by `ApplyTrellisConventions`) uses two EF Core convention interfaces: diff --git a/Trellis.Primitives/README.md b/Trellis.Primitives/README.md index 2a70aee6..8bb406db 100644 --- a/Trellis.Primitives/README.md +++ b/Trellis.Primitives/README.md @@ -14,6 +14,7 @@ This library provides infrastructure and ready-to-use implementations for primit - [RequiredEnum](#requiredenum) - [EmailAddress](#emailaddress) - [Additional Value Objects](#additional-value-objects) +- [MonetaryAmount](#monetaryamount) - [Money](#money) - [ASP.NET Core Integration](#aspnet-core-integration) - [Core Concepts](#core-concepts) @@ -322,6 +323,22 @@ var tooOld = Age.TryCreate(200); // Error: "Age is unrealistically high." ``` +#### MonetaryAmount + +`MonetaryAmount` is a scalar value object for single-currency systems where currency is a system-wide policy, not per-row data. It wraps a non-negative `decimal` rounded to 2 decimal places and maps to a single column in EF Core. + +```csharp +var price = MonetaryAmount.TryCreate(99.99m); // Result +var zero = MonetaryAmount.Zero; // 0.00 + +// Arithmetic — returns Result (handles overflow) +var total = price.Value.Add(MonetaryAmount.Create(10.00m)); +var doubled = price.Value.Multiply(2); + +// JSON: serializes as plain decimal number (99.99) +// EF Core: maps to 1 decimal column via ApplyTrellisConventions +``` + #### Money `Money` is a structured value object, not a scalar wrapper. Its identity is the pair of `Amount` and `Currency`, so its JSON and EF representations stay object-shaped rather than flowing through the `IScalarValue` pipeline. @@ -444,6 +461,7 @@ This library provides both **base classes** for creating custom value objects an | **CountryCode** | Country codes | ISO 3166-1 alpha-2 | `US`, `GB`, `FR` | | **LanguageCode** | Language codes | ISO 639-1 alpha-2 | `en`, `es`, `fr` | | **Age** | Age values | 0-150 range | `42` | +| **MonetaryAmount** | Monetary amounts (single-currency) | Non-negative, 2 decimal places | `99.99` | #### Ready-to-Use Structured Value Object | Value Object | Purpose | Validation Rules | Example | diff --git a/Trellis.Primitives/src/Primitives/MonetaryAmount.cs b/Trellis.Primitives/src/Primitives/MonetaryAmount.cs new file mode 100644 index 00000000..0759b0b7 --- /dev/null +++ b/Trellis.Primitives/src/Primitives/MonetaryAmount.cs @@ -0,0 +1,144 @@ +namespace Trellis.Primitives; + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Trellis; + +/// +/// A non-negative monetary amount without currency — for single-currency systems. +/// +/// Use when your bounded context operates in a single currency +/// (e.g., all USD). The currency is a system-wide policy, not per-row data. +/// Maps to a single decimal(18,2) column in EF Core via ApplyTrellisConventions. +/// +/// +/// For multi-currency systems where each value carries its own currency code, +/// use instead. +/// +/// +[JsonConverter(typeof(ParsableJsonConverter))] +public class MonetaryAmount : ScalarValueObject, IScalarValue, IParsable +{ + private const int DefaultDecimalPlaces = 2; + + private MonetaryAmount(decimal value) : base(value) { } + + private static readonly MonetaryAmount s_zero = new(0m); + + /// A zero monetary amount. + public static MonetaryAmount Zero => s_zero; + + /// + /// Attempts to create a from the specified decimal. + /// + /// The decimal value (must be non-negative). + /// Optional field name for validation error messages. + /// Success with the MonetaryAmount if valid; Failure with ValidationError if negative. + public static Result TryCreate(decimal value, string? fieldName = null) + { + using var activity = PrimitiveValueObjectTrace.ActivitySource.StartActivity(nameof(MonetaryAmount) + '.' + nameof(TryCreate)); + + var field = fieldName.NormalizeFieldName("amount"); + + if (value < 0) + return Result.Failure(Error.Validation("Amount cannot be negative.", field)); + + var rounded = Math.Round(value, DefaultDecimalPlaces, MidpointRounding.AwayFromZero); + return new MonetaryAmount(rounded); + } + + /// + /// Attempts to create a from the specified nullable decimal. + /// + public static Result TryCreate(decimal? value, string? fieldName = null) + { + using var activity = PrimitiveValueObjectTrace.ActivitySource.StartActivity(nameof(MonetaryAmount) + '.' + nameof(TryCreate)); + + var field = fieldName.NormalizeFieldName("amount"); + + if (value is null) + return Result.Failure(Error.Validation("Amount is required.", field)); + + return TryCreate(value.Value, fieldName); + } + + /// Adds two monetary amounts. + public Result Add(MonetaryAmount other) + { + try { return TryCreate(Value + other.Value); } + catch (OverflowException) { return Result.Failure(Error.Validation("Addition would overflow.", "amount")); } + } + + /// Subtracts a monetary amount. Fails if result would be negative. + public Result Subtract(MonetaryAmount other) + { + try { return TryCreate(Value - other.Value); } + catch (OverflowException) { return Result.Failure(Error.Validation("Subtraction would overflow.", "amount")); } + } + + /// Multiplies by a non-negative integer quantity. + public Result Multiply(int quantity) + { + if (quantity < 0) + return Result.Failure( + Error.Validation("Quantity cannot be negative.", nameof(quantity))); + + try { return TryCreate(Value * quantity); } + catch (OverflowException) { return Result.Failure(Error.Validation("Multiplication would overflow.", "amount")); } + } + + /// Multiplies by a non-negative decimal multiplier. + public Result Multiply(decimal multiplier) + { + if (multiplier < 0) + return Result.Failure( + Error.Validation("Multiplier cannot be negative.", nameof(multiplier))); + + try { return TryCreate(Value * multiplier); } + catch (OverflowException) { return Result.Failure(Error.Validation("Multiplication would overflow.", "amount")); } + } + + /// + public static MonetaryAmount Parse(string? s, IFormatProvider? provider) + { + if (string.IsNullOrWhiteSpace(s)) + throw new FormatException("Value must be a valid decimal."); + + if (!decimal.TryParse(s, System.Globalization.NumberStyles.Number, provider ?? System.Globalization.CultureInfo.InvariantCulture, out var value)) + throw new FormatException("Value must be a valid decimal."); + + var r = TryCreate(value); + if (r.IsFailure) + { + var val = (ValidationError)r.Error; + throw new FormatException(val.FieldErrors[0].Details[0]); + } + + return r.Value; + } + + /// + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out MonetaryAmount result) + { + result = default; + + if (string.IsNullOrWhiteSpace(s)) + return false; + + if (!decimal.TryParse(s, System.Globalization.NumberStyles.Number, provider ?? System.Globalization.CultureInfo.InvariantCulture, out var value)) + return false; + + var r = TryCreate(value); + if (r.IsFailure) + return false; + + result = r.Value; + return true; + } + + /// Explicitly converts a decimal to a . + public static explicit operator MonetaryAmount(decimal value) => Create(value); + + /// Returns the amount as an invariant-culture decimal string. + public override string ToString() => Value.ToString(System.Globalization.CultureInfo.InvariantCulture); +} diff --git a/Trellis.Primitives/src/RequiredDecimal.cs b/Trellis.Primitives/src/RequiredDecimal.cs index a1214b31..4e477b81 100644 --- a/Trellis.Primitives/src/RequiredDecimal.cs +++ b/Trellis.Primitives/src/RequiredDecimal.cs @@ -1,18 +1,18 @@ namespace Trellis; /// -/// Base class for creating strongly-typed decimal value objects that cannot have the default (zero) value. +/// Base class for creating strongly-typed decimal value objects that must be explicitly provided. /// Provides a foundation for monetary amounts, percentages, and other domain concepts represented by decimals. /// /// /// /// This class extends to provide a specialized base for decimal-based value objects -/// with automatic validation that prevents zero/default decimals. When used with the partial keyword, +/// with automatic validation. When used with the partial keyword, /// the PrimitiveValueObjectGenerator source generator automatically creates: /// /// IScalarValue<TSelf, decimal> implementation for ASP.NET Core automatic validation /// TryCreate(decimal) - Factory method for decimals (required by IScalarValue) -/// TryCreate(decimal?, string?) - Factory method with zero validation and custom field name +/// TryCreate(decimal?, string?) - Factory method with null validation and custom field name /// TryCreate(string?, string?) - Factory method for parsing strings with validation /// IParsable<T> implementation (Parse, TryParse) /// JSON serialization support via ParsableJsonConverter<T> @@ -26,14 +26,14 @@ /// Monetary amounts (Price, Amount, Balance) /// Rates and percentages (InterestRate, TaxRate) /// Measurements requiring precision (Weight, Distance) -/// Any domain concept requiring a non-zero decimal value +/// Any domain concept requiring a validated decimal value /// /// /// /// Benefits over plain decimals: /// /// Type safety: Cannot accidentally use Price where TaxRate is expected -/// Validation: Prevents zero/default decimals at creation time +/// Validation: Ensures values are explicitly provided (not null) at creation time /// Domain clarity: Makes code more self-documenting and expressive /// Serialization: Consistent JSON and database representation /// @@ -67,7 +67,7 @@ /// // Create from string with validation /// var result2 = UnitPrice.TryCreate("19.99"); /// // Returns: Success(UnitPrice) if valid decimal format -/// // Returns: Failure(ValidationError) if invalid format or zero +/// // Returns: Failure(ValidationError) if invalid format /// /// // With custom field name for validation errors /// var result3 = UnitPrice.TryCreate(input, "product.price"); @@ -101,7 +101,7 @@ public abstract class RequiredDecimal : ScalarValueObject /// /// Initializes a new instance of the class with the specified decimal value. /// - /// The decimal value. Must not be zero. + /// The decimal value. /// /// /// This constructor is protected and should be called by derived classes. diff --git a/Trellis.Primitives/src/RequiredInt.cs b/Trellis.Primitives/src/RequiredInt.cs index bf2bf49d..4fb699a2 100644 --- a/Trellis.Primitives/src/RequiredInt.cs +++ b/Trellis.Primitives/src/RequiredInt.cs @@ -1,18 +1,18 @@ namespace Trellis; /// -/// Base class for creating strongly-typed integer value objects that cannot have the default (zero) value. +/// Base class for creating strongly-typed integer value objects that must be explicitly provided. /// Provides a foundation for entity identifiers, counts, and other domain concepts represented by integers. /// /// /// /// This class extends to provide a specialized base for integer-based value objects -/// with automatic validation that prevents zero/default integers. When used with the partial keyword, +/// with automatic validation. When used with the partial keyword, /// the PrimitiveValueObjectGenerator source generator automatically creates: /// /// IScalarValue<TSelf, int> implementation for ASP.NET Core automatic validation /// TryCreate(int) - Factory method for integers (required by IScalarValue) -/// TryCreate(int?, string?) - Factory method with zero validation and custom field name +/// TryCreate(int?, string?) - Factory method with null validation and custom field name /// TryCreate(string?, string?) - Factory method for parsing strings with validation /// IParsable<T> implementation (Parse, TryParse) /// JSON serialization support via ParsableJsonConverter<T> @@ -25,15 +25,15 @@ /// /// Legacy entity identifiers (CustomerId, OrderId when using int IDs) /// Reference numbers (InvoiceNumber, TicketNumber) -/// Sequence numbers requiring non-zero values -/// Any domain concept requiring a non-zero integer identifier +/// Sequence numbers +/// Any domain concept requiring a validated integer value /// /// /// /// Benefits over plain integers: /// /// Type safety: Cannot accidentally use CustomerId where OrderId is expected -/// Validation: Prevents zero/default integers at creation time +/// Validation: Ensures values are explicitly provided (not null) at creation time /// Domain clarity: Makes code more self-documenting and expressive /// Serialization: Consistent JSON and database representation /// @@ -61,13 +61,13 @@ /// /// // Create from existing integer with validation /// var result1 = TicketNumber.TryCreate(12345); -/// // Returns: Success(TicketNumber) if value != 0 -/// // Returns: Failure(ValidationError) if value == 0 +/// // Returns: Success(TicketNumber) +/// // Returns: Failure(ValidationError) if null /// /// // Create from string with validation /// var result2 = TicketNumber.TryCreate("12345"); /// // Returns: Success(TicketNumber) if valid integer format -/// // Returns: Failure(ValidationError) if invalid format or zero +/// // Returns: Failure(ValidationError) if invalid format /// /// // With custom field name for validation errors /// var result3 = TicketNumber.TryCreate(input, "ticket.number"); @@ -99,7 +99,7 @@ public abstract class RequiredInt : ScalarValueObject /// /// Initializes a new instance of the class with the specified integer value. /// - /// The integer value. Must not be zero. + /// The integer value. /// /// /// This constructor is protected and should be called by derived classes. diff --git a/Trellis.Primitives/tests/MonetaryAmountTests.cs b/Trellis.Primitives/tests/MonetaryAmountTests.cs new file mode 100644 index 00000000..f3212c16 --- /dev/null +++ b/Trellis.Primitives/tests/MonetaryAmountTests.cs @@ -0,0 +1,169 @@ +namespace Trellis.Primitives.Tests; + +using Trellis.Primitives; + +/// +/// Tests for — a single-currency monetary value. +/// Like but without the currency column. +/// +public class MonetaryAmountTests +{ + #region Creation and Validation + + [Fact] + public void TryCreate_ValidAmount_ReturnsSuccess() + { + var result = MonetaryAmount.TryCreate(29.99m); + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(29.99m); + } + + [Fact] + public void TryCreate_Zero_ReturnsSuccess() + { + var result = MonetaryAmount.TryCreate(0m); + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(0m); + } + + [Fact] + public void TryCreate_NegativeAmount_ReturnsFailure() + { + var result = MonetaryAmount.TryCreate(-1m); + result.IsFailure.Should().BeTrue(); + } + + [Fact] + public void TryCreate_RoundsToTwoDecimalPlaces() + { + var result = MonetaryAmount.TryCreate(29.999m); + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(30.00m); + } + + [Fact] + public void Create_ValidAmount_ReturnsInstance() + { + var amount = MonetaryAmount.Create(49.95m); + amount.Value.Should().Be(49.95m); + } + + [Fact] + public void Zero_ReturnsZeroAmount() + { + var zero = MonetaryAmount.Zero; + zero.Value.Should().Be(0m); + } + + #endregion + + #region Arithmetic + + [Fact] + public void Add_ReturnsSum() + { + var a = MonetaryAmount.Create(10.00m); + var b = MonetaryAmount.Create(20.50m); + + var result = a.Add(b); + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(30.50m); + } + + [Fact] + public void Subtract_ReturnsResult() + { + var a = MonetaryAmount.Create(50.00m); + var b = MonetaryAmount.Create(20.00m); + + var result = a.Subtract(b); + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(30.00m); + } + + [Fact] + public void Subtract_NegativeResult_ReturnsFailure() + { + var a = MonetaryAmount.Create(10.00m); + var b = MonetaryAmount.Create(20.00m); + + var result = a.Subtract(b); + result.IsFailure.Should().BeTrue(); + } + + [Fact] + public void Multiply_ByInt_ReturnsResult() + { + var amount = MonetaryAmount.Create(10.00m); + + var result = amount.Multiply(3); + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(30.00m); + } + + [Fact] + public void Multiply_ByNegative_ReturnsFailure() + { + var amount = MonetaryAmount.Create(10.00m); + + var result = amount.Multiply(-1); + result.IsFailure.Should().BeTrue(); + } + + #endregion + + #region Equality and Comparison + + [Fact] + public void EqualAmounts_AreEqual() + { + var a = MonetaryAmount.Create(99.99m); + var b = MonetaryAmount.Create(99.99m); + + a.Should().Be(b); + } + + [Fact] + public void DifferentAmounts_AreNotEqual() + { + var a = MonetaryAmount.Create(99.99m); + var b = MonetaryAmount.Create(100.00m); + + a.Should().NotBe(b); + } + + #endregion + + #region JSON Serialization + + [Fact] + public void Json_SerializesAsDecimal() + { + var amount = MonetaryAmount.Create(29.99m); + var json = System.Text.Json.JsonSerializer.Serialize(amount); + + json.Should().Be("29.99"); + } + + [Fact] + public void Json_DeserializesFromDecimal() + { + var amount = System.Text.Json.JsonSerializer.Deserialize("29.99"); + + amount.Should().NotBeNull(); + amount!.Value.Should().Be(29.99m); + } + + #endregion + + #region ToString + + [Fact] + public void ToString_ReturnsFormattedAmount() + { + var amount = MonetaryAmount.Create(1234.56m); + amount.ToString().Should().Be("1234.56"); + } + + #endregion +} diff --git a/docs/docfx_project/articles/integration-ef.md b/docs/docfx_project/articles/integration-ef.md index 7f010c25..2a2b6d4b 100644 --- a/docs/docfx_project/articles/integration-ef.md +++ b/docs/docfx_project/articles/integration-ef.md @@ -826,6 +826,19 @@ modelBuilder.Entity(b => > [!NOTE] > Multiple `Money` properties on the same entity work automatically — each gets its own pair of columns. +### MonetaryAmount (Single-Currency Alternative) + +If your system uses one currency everywhere, use `MonetaryAmount` instead of `Money`. It is a scalar value object (`ScalarValueObject`) so it maps to a **single `decimal` column** automatically via `ApplyTrellisConventions` — the same convention that handles all `IScalarValue` types. No owned-type configuration needed. + +```csharp +public class Invoice +{ + public InvoiceId Id { get; set; } = null!; + public MonetaryAmount Total { get; set; } = null!; // 1 decimal column: Total + public partial Maybe Discount { get; set; } // 1 nullable column: Discount +} +``` + ### Optional Money with Maybe\ For optional Money properties, use `partial Maybe`. The conventions auto-configure it as an optional owned type — no `OwnsOne` needed: diff --git a/docs/docfx_project/articles/primitives.md b/docs/docfx_project/articles/primitives.md index fae36455..f0b206bb 100644 --- a/docs/docfx_project/articles/primitives.md +++ b/docs/docfx_project/articles/primitives.md @@ -130,6 +130,7 @@ These types live in the `Trellis.Primitives` namespace and are ready to use out | `LanguageCode` | `string` | ISO 639-1 | | `Age` | `int` | 0–199 | | `Percentage` | `decimal` | 0–100; `FromFraction()`, `AsFraction()`, `Of()` | +| `MonetaryAmount` | `decimal` | Non-negative, 2 dp; `Add`, `Subtract`, `Multiply` — single-currency alternative to `Money` | | `Money` | composite | Amount + CurrencyCode; arithmetic: `Add`, `Subtract`, `Multiply`, `Divide`, `Allocate` | ### Usage examples @@ -153,6 +154,13 @@ Console.WriteLine(half.Value); // 50 ``` ```csharp +// MonetaryAmount — single-currency systems (1 column in EF Core) +var amount = MonetaryAmount.Create(49.95m); +var total = amount.Add(MonetaryAmount.Create(10.00m)); // Result +``` + +```csharp +// Money — multi-currency systems (2 columns in EF Core) var price = Money.Create(100.00m, CurrencyCode.Create("USD")); var tax = price.Multiply(0.08m); var total = price.Add(tax); diff --git a/trellis-api-reference.md b/trellis-api-reference.md index c6a54273..08af0216 100644 --- a/trellis-api-reference.md +++ b/trellis-api-reference.md @@ -1138,8 +1138,28 @@ All have `TryCreate` → `Result` and `Create` → `T` (throws). All implemen | `LanguageCode` | `string` | 2 letters, ISO 639-1, lowercase | — | | `Age` | `int` | 0–150 inclusive | — | | `Percentage` | `decimal` | 0–100 inclusive | `Zero`, `Full`, `AsFraction()`, `Of(decimal)`, `FromFraction(decimal, fieldName?)`, `TryCreate(decimal?)` | +| `MonetaryAmount` | `decimal` | Non-negative, rounds to 2 dp | `Zero`, `Add`, `Subtract`, `Multiply(int)`, `Multiply(decimal)` | | `Money` | multi-value | Amount ≥ 0, valid currency code | See below | +### MonetaryAmount (extends ScalarValueObject) + +Scalar value object for single-currency systems where currency is a system-wide policy, not per-row data. Wraps a non-negative `decimal` rounded to 2 decimal places. JSON: plain number (e.g. `99.99`). EF Core: maps to 1 `decimal` column (via `ApplyTrellisConventions`). + +```csharp +// Implements: ScalarValueObject, IScalarValue, IParsable + +static Result TryCreate(decimal value) +static Result TryCreate(decimal? value) +static MonetaryAmount Create(decimal value) +static MonetaryAmount Zero { get; } + +// Arithmetic (returns Result — handles overflow) +Result Add(MonetaryAmount other) +Result Subtract(MonetaryAmount other) +Result Multiply(int quantity) +Result Multiply(decimal multiplier) +``` + ### Money (extends ValueObject, NOT ScalarValueObject) Structured value object with two semantic components: `Amount` (decimal) + `Currency` (CurrencyCode). JSON: `{"amount": 99.99, "currency": "USD"}`. @@ -1672,6 +1692,7 @@ IQueryable Where(this IQueryable query, Specification specification) configurationBuilder.ApplyTrellisConventions(typeof(Order).Assembly); // Auto-registers converters for all IScalarValue and RequiredEnum types // Auto-maps Money properties as owned types (Amount + Currency columns) +// MonetaryAmount maps to a single decimal column (scalar value object convention) ``` ### Money Property Convention From 902a1ca08033c69ce17c38dee01d2aab25c3b540 Mon Sep 17 00:00:00 2001 From: Xavier John Date: Sat, 28 Mar 2026 13:09:50 -0700 Subject: [PATCH 06/17] Document composite ValueObject EF Core owned type pattern Add 'Composite ValueObjects as EF Core Owned Types' section to copilot instructions covering: private parameterless constructor, private set, null! initializers, TryCreate factory, OwnsOne configuration, and nested OwnsOne/OwnsMany ToTable() guidance. Resolves ARCL feedback FP-3 (decision tree) and FP-4 (owned entity boilerplate documentation). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 55 +++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b58eb164..11013157 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -701,6 +701,61 @@ modelBuilder.Entity(b => }); ``` +## Composite ValueObjects as EF Core Owned Types + +When a composite `ValueObject` (like `ShippingAddress`) is persisted via EF Core's `OwnsOne`, it needs specific boilerplate for EF Core materialization: + +```csharp +public sealed record ShippingAddress : ValueObject +{ + // EF Core requires a private parameterless constructor for materialization + private ShippingAddress() { } + + // Properties must use 'private set' (not 'get;' only) and '= null!' for reference types + public Street Street { get; private set; } = null!; + public City City { get; private set; } = null!; + public State State { get; private set; } = null!; + public PostalCode PostalCode { get; private set; } = null!; + public Country Country { get; private set; } = null!; + + // Public factory — the only way to create instances + public static Result TryCreate( + Street street, City city, State state, PostalCode postalCode, Country country) => + new ShippingAddress + { + Street = street, City = city, State = state, + PostalCode = postalCode, Country = country + }; + + protected override IEnumerable GetEqualityComponents() + { + yield return Street; + yield return City; + yield return State; + yield return PostalCode; + yield return Country; + } +} +``` + +**Rules:** +- Always add `private ShippingAddress() { }` — EF Core cannot materialize without it +- Use `{ get; private set; }` not `{ get; }` — EF Core sets properties via reflection +- Add `= null!` to all reference type properties — suppresses nullable warning for the private constructor path +- Value type properties (e.g., `int`, `decimal`, `DateTime`) do not need `= null!` + +**Entity configuration:** +```csharp +// In IEntityTypeConfiguration +builder.OwnsOne(c => c.ShippingAddress); +``` + +**Nested OwnsOne with OwnsMany:** When two `OwnsOne` navigations share the same child type that has `OwnsMany` collections, EF Core defaults to the same table name. Use explicit `ToTable()`: +```csharp +builder.OwnsOne(s => s.Inning1, b => b.OwnsMany(i => i.BattingEntries).ToTable("Inning1BattingEntries")); +builder.OwnsOne(s => s.Inning2, b => b.OwnsMany(i => i.BattingEntries).ToTable("Inning2BattingEntries")); +``` + ## Known Namespace Collisions ### `Trellis.Unit` vs `Mediator.Unit` From d9b37dcd4f1523ca93cc1fa7adc489886cc7926e Mon Sep 17 00:00:00 2001 From: Xavier John Date: Sat, 28 Mar 2026 13:35:54 -0700 Subject: [PATCH 07/17] Add ReplaceDbProvider helper and TRLSGEN100 test coverage ReplaceDbProvider(IServiceCollection, Action): - Cleanly swaps EF Core database providers in WebApplicationFactory tests - Removes TContext, DbContextOptions, and all EF Core internal services generic over TContext (including IDbContextOptionsConfiguration in EF Core 10) to prevent dual-provider conflicts - Re-registers via AddDbContext - 6 new tests including full integration round-trip TRLSGEN100 diagnostic test coverage: - Diagnostic was already implemented in MaybePartialPropertyGenerator - Added 5 tests verifying: fires for non-partial Maybe, silent for partial Maybe, silent for non-partial class, multiple properties, message includes inner type name Resolves ARCL feedback FP-2 and FP-5. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MaybePartialPropertyGeneratorTests.cs | 141 +++++++++++++++++ .../ServiceCollectionDbProviderExtensions.cs | 62 ++++++++ Trellis.Testing/src/Trellis.Testing.csproj | 1 + ...viceCollectionDbProviderExtensionsTests.cs | 143 ++++++++++++++++++ .../tests/Trellis.Testing.Tests.csproj | 2 + 5 files changed, 349 insertions(+) create mode 100644 Trellis.Testing/src/ServiceCollectionDbProviderExtensions.cs create mode 100644 Trellis.Testing/tests/ServiceCollectionDbProviderExtensionsTests.cs diff --git a/Trellis.EntityFrameworkCore/generator-tests/MaybePartialPropertyGeneratorTests.cs b/Trellis.EntityFrameworkCore/generator-tests/MaybePartialPropertyGeneratorTests.cs index abfecbaf..0bec6cee 100644 --- a/Trellis.EntityFrameworkCore/generator-tests/MaybePartialPropertyGeneratorTests.cs +++ b/Trellis.EntityFrameworkCore/generator-tests/MaybePartialPropertyGeneratorTests.cs @@ -1,5 +1,6 @@ namespace Trellis.EntityFrameworkCore.Generator.Tests; +using System.Globalization; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -111,6 +112,146 @@ public partial class Customer generatedSources.Should().HaveCount(2); } + #region TRLSGEN100 — Non-partial Maybe property diagnostic + + /// + /// A non-partial Maybe<T> auto-property on a partial class should emit TRLSGEN100. + /// + [Fact] + public void NonPartial_MaybeProperty_On_PartialClass_Should_Emit_TRLSGEN100() + { + var cancellationToken = TestContext.Current.CancellationToken; + + const string source = """ + using Trellis; + + namespace TestNamespace; + + public partial class Customer + { + public int Id { get; set; } + public Maybe Phone { get; set; } + } + """; + + var (_, diagnostics, _) = RunGenerator(source, cancellationToken); + + diagnostics.Where(d => d.Id == "TRLSGEN100") + .Should().ContainSingle() + .Which.GetMessage(CultureInfo.InvariantCulture).Should().Contain("Phone"); + } + + /// + /// A partial Maybe<T> property should NOT emit TRLSGEN100 (correct usage). + /// + [Fact] + public void Partial_MaybeProperty_Should_Not_Emit_TRLSGEN100() + { + var cancellationToken = TestContext.Current.CancellationToken; + + const string source = """ + using Trellis; + + namespace TestNamespace; + + public partial class Customer + { + public int Id { get; set; } + public partial Maybe Phone { get; set; } + } + """; + + var (_, diagnostics, _) = RunGenerator(source, cancellationToken); + + diagnostics.Where(d => d.Id == "TRLSGEN100") + .Should().BeEmpty("partial Maybe is correct usage"); + } + + /// + /// A non-partial Maybe<T> property on a NON-partial class should NOT emit TRLSGEN100 + /// because the generator cannot emit a partial implementation for non-partial types. + /// + [Fact] + public void NonPartial_MaybeProperty_On_NonPartialClass_Should_Not_Emit_TRLSGEN100() + { + var cancellationToken = TestContext.Current.CancellationToken; + + const string source = """ + using Trellis; + + namespace TestNamespace; + + public class Customer + { + public int Id { get; set; } + public Maybe Phone { get; set; } + } + """; + + var (_, diagnostics, _) = RunGenerator(source, cancellationToken); + + diagnostics.Where(d => d.Id == "TRLSGEN100") + .Should().BeEmpty("class is not partial — diagnostic should not fire"); + } + + /// + /// Multiple non-partial Maybe<T> properties should each emit their own TRLSGEN100. + /// + [Fact] + public void Multiple_NonPartial_MaybeProperties_Should_Emit_Multiple_TRLSGEN100() + { + var cancellationToken = TestContext.Current.CancellationToken; + + const string source = """ + using Trellis; + + namespace TestNamespace; + + public partial class Customer + { + public int Id { get; set; } + public Maybe Phone { get; set; } + public Maybe Email { get; set; } + } + """; + + var (_, diagnostics, _) = RunGenerator(source, cancellationToken); + + var gen100 = diagnostics.Where(d => d.Id == "TRLSGEN100").ToList(); + gen100.Should().HaveCount(2); + gen100.Should().Contain(d => d.GetMessage(CultureInfo.InvariantCulture).Contains("Phone")); + gen100.Should().Contain(d => d.GetMessage(CultureInfo.InvariantCulture).Contains("Email")); + } + + /// + /// TRLSGEN100 message should include the inner type name. + /// + [Fact] + public void TRLSGEN100_Message_Should_Include_InnerType() + { + var cancellationToken = TestContext.Current.CancellationToken; + + const string source = """ + using Trellis; + + namespace TestNamespace; + + public partial class Customer + { + public int Id { get; set; } + public Maybe LoyaltyPoints { get; set; } + } + """; + + var (_, diagnostics, _) = RunGenerator(source, cancellationToken); + + diagnostics.Where(d => d.Id == "TRLSGEN100") + .Should().ContainSingle() + .Which.GetMessage(CultureInfo.InvariantCulture).Should().Contain("int"); + } + + #endregion + private static (List Sources, IReadOnlyList Diagnostics, List HintNames) RunGenerator( string source, CancellationToken cancellationToken) { diff --git a/Trellis.Testing/src/ServiceCollectionDbProviderExtensions.cs b/Trellis.Testing/src/ServiceCollectionDbProviderExtensions.cs new file mode 100644 index 00000000..36ab13a3 --- /dev/null +++ b/Trellis.Testing/src/ServiceCollectionDbProviderExtensions.cs @@ -0,0 +1,62 @@ +namespace Trellis.Testing; + +using System.Diagnostics.CodeAnalysis; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +/// +/// Extension methods for that simplify +/// replacing EF Core database provider registrations in integration tests. +/// +public static class ServiceCollectionDbProviderExtensions +{ + /// + /// Removes all existing EF Core provider registrations for + /// and re-registers with a new provider configuration via AddDbContext. Use this in + /// WebApplicationFactory tests to swap a production database provider + /// (e.g., SQL Server) for a lightweight test provider (e.g., SQLite in-memory). + /// + /// + /// This method always re-registers using AddDbContext<TContext>. + /// If the application registers the context via AddDbContextFactory or + /// AddPooledDbContextFactory, use those APIs directly instead of this helper. + /// + /// The type to replace. + /// The service collection to modify. + /// An action to configure the new . + /// The same for chaining. + /// + /// + /// builder.ConfigureServices(services => + /// services.ReplaceDbProvider<AppDbContext>(options => + /// options.UseSqlite(connection).AddTrellisInterceptors())); + /// + /// + public static IServiceCollection ReplaceDbProvider<[DynamicallyAccessedMembers( + DynamicallyAccessedMemberTypes.PublicConstructors + | DynamicallyAccessedMemberTypes.NonPublicConstructors + | DynamicallyAccessedMemberTypes.PublicProperties)] TContext>( + this IServiceCollection services, + Action configureOptions) + where TContext : DbContext + { + services.RemoveAll(); + services.RemoveAll>(); + + // EF Core registers additional provider-scoped services (e.g., IDbContextOptionsConfiguration) + // that carry the original provider configuration. Remove all EF Core services generic over TContext + // to avoid dual-provider conflicts. + var efCoreContextDescriptors = services + .Where(d => d.ServiceType.IsConstructedGenericType + && d.ServiceType.GenericTypeArguments.Contains(typeof(TContext)) + && (d.ServiceType.FullName?.Contains("EntityFrameworkCore", StringComparison.Ordinal) ?? false)) + .ToList(); + + foreach (var descriptor in efCoreContextDescriptors) + services.Remove(descriptor); + + services.AddDbContext(configureOptions); + return services; + } +} diff --git a/Trellis.Testing/src/Trellis.Testing.csproj b/Trellis.Testing/src/Trellis.Testing.csproj index 9ee90394..b6d30b52 100644 --- a/Trellis.Testing/src/Trellis.Testing.csproj +++ b/Trellis.Testing/src/Trellis.Testing.csproj @@ -27,6 +27,7 @@ + diff --git a/Trellis.Testing/tests/ServiceCollectionDbProviderExtensionsTests.cs b/Trellis.Testing/tests/ServiceCollectionDbProviderExtensionsTests.cs new file mode 100644 index 00000000..a263649c --- /dev/null +++ b/Trellis.Testing/tests/ServiceCollectionDbProviderExtensionsTests.cs @@ -0,0 +1,143 @@ +namespace Trellis.Testing.Tests; + +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +/// +/// Tests for . +/// +public class ServiceCollectionDbProviderExtensionsTests +{ + #region ReplaceDbProvider + + [Fact] + public void ReplaceDbProvider_NoExistingRegistration_RegistersContext() + { + var services = new ServiceCollection(); + using var connection = CreateSqliteConnection(); + + services.ReplaceDbProvider(options => + options.UseSqlite(connection)); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + context.Should().NotBeNull(); + } + + [Fact] + public void ReplaceDbProvider_WithExistingRegistration_ReplacesProvider() + { + var services = new ServiceCollection(); + services.AddDbContext(options => + options.UseInMemoryDatabase($"original-{Guid.NewGuid()}")); + + using var connection = CreateSqliteConnection(); + services.ReplaceDbProvider(options => + options.UseSqlite(connection)); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + context.Database.ProviderName.Should().Be("Microsoft.EntityFrameworkCore.Sqlite"); + } + + [Fact] + public void ReplaceDbProvider_ReturnsServiceCollection_ForChaining() + { + var services = new ServiceCollection(); + using var connection = CreateSqliteConnection(); + + var returned = services.ReplaceDbProvider(options => + options.UseSqlite(connection)); + + returned.Should().BeSameAs(services); + } + + [Fact] + public void ReplaceDbProvider_DoesNotAffectOtherDbContexts() + { + var services = new ServiceCollection(); + services.AddDbContext(options => + options.UseInMemoryDatabase($"other-{Guid.NewGuid()}")); + + using var connection = CreateSqliteConnection(); + services.ReplaceDbProvider(options => + options.UseSqlite(connection)); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var other = scope.ServiceProvider.GetRequiredService(); + other.Database.ProviderName.Should().Be("Microsoft.EntityFrameworkCore.InMemory"); + } + + [Fact] + public void ReplaceDbProvider_RemovesOldDbContextOptions() + { + var services = new ServiceCollection(); + services.AddDbContext(options => + options.UseInMemoryDatabase($"original-{Guid.NewGuid()}")); + + using var connection = CreateSqliteConnection(); + services.ReplaceDbProvider(options => + options.UseSqlite(connection)); + + var optionsDescriptors = services + .Where(d => d.ServiceType == typeof(DbContextOptions)) + .ToList(); + optionsDescriptors.Should().ContainSingle(); + } + + [Fact] + public void ReplaceDbProvider_SwapsProvider_CanCreateAndQuery() + { + var services = new ServiceCollection(); + services.AddDbContext(options => + options.UseInMemoryDatabase($"original-{Guid.NewGuid()}")); + + using var connection = CreateSqliteConnection(); + services.ReplaceDbProvider(options => + options.UseSqlite(connection)); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + context.Database.EnsureCreated(); + + context.Items.Add(new TestItem { Name = "test-item" }); + context.SaveChanges(); + + context.Items.Should().ContainSingle(e => e.Name == "test-item"); + } + + #endregion + + #region Helpers + + private static SqliteConnection CreateSqliteConnection() + { + var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + return connection; + } + + #endregion + + #region Test Types + + private sealed class TestItem + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + private sealed class TestAppDbContext(DbContextOptions options) : DbContext(options) + { + public DbSet Items => Set(); + } + + private sealed class OtherTestDbContext(DbContextOptions options) : DbContext(options); + + #endregion +} diff --git a/Trellis.Testing/tests/Trellis.Testing.Tests.csproj b/Trellis.Testing/tests/Trellis.Testing.Tests.csproj index 8bd8a0cc..d5489f37 100644 --- a/Trellis.Testing/tests/Trellis.Testing.Tests.csproj +++ b/Trellis.Testing/tests/Trellis.Testing.Tests.csproj @@ -7,6 +7,8 @@ + + From 1ba3f548e99fe314ed27ed4209d7f1ed78c6760f Mon Sep 17 00:00:00 2001 From: Xavier John Date: Sat, 28 Mar 2026 13:55:22 -0700 Subject: [PATCH 08/17] Document ReplaceDbProvider in testing reference and README Added ReplaceDbProvider section to trellis-api-testing-reference.md with usage example and AddDbContextFactory limitation warning. Added to Trellis.Testing README Service Collection Extensions table. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Trellis.Testing/README.md | 6 ++++++ trellis-api-testing-reference.md | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/Trellis.Testing/README.md b/Trellis.Testing/README.md index 1f067a8a..100311c9 100644 --- a/Trellis.Testing/README.md +++ b/Trellis.Testing/README.md @@ -315,6 +315,12 @@ public class OrderEndpointTests : IClassFixture> |--------|-------------| | `CreateClientWithActor(actorId, permissions)` | Creates an `HttpClient` with the `X-Test-Actor` header set to a JSON payload containing the actor ID and permissions | +### Service Collection Extensions + +| Method | Description | +|--------|-------------| +| `ReplaceDbProvider(configureOptions)` | Removes all EF Core registrations for `TContext` and re-registers with a new provider. Use in `WebApplicationFactory` tests to swap SQL Server for SQLite. **Note:** Always uses `AddDbContext`. Not compatible with `AddDbContextFactory` or `AddPooledDbContextFactory`. | + ## Benefits | Before | After | diff --git a/trellis-api-testing-reference.md b/trellis-api-testing-reference.md index bd27c0d1..44f1eecf 100644 --- a/trellis-api-testing-reference.md +++ b/trellis-api-testing-reference.md @@ -170,6 +170,21 @@ var client = factory.CreateClientWithActor("user-1", "Orders.Create", "Orders.Re --- +## ReplaceDbProvider + +Cleanly swaps the EF Core database provider in `WebApplicationFactory` tests. Removes all EF Core internal services for the context (including `IDbContextOptionsConfiguration` in EF Core 10) and re-registers with the new provider. + +```csharp +// In TestWebApplicationFactoryFixture.ConfigureWebHost +builder.ConfigureServices(services => + services.ReplaceDbProvider(options => + options.UseSqlite(_connection).AddTrellisInterceptors())); +``` + +> **Limitation:** Always re-registers via `AddDbContext`. If the application uses `AddDbContextFactory` or `AddPooledDbContextFactory`, swap providers manually instead of using this helper. + +--- + ## Test Patterns ### Testing Result with TRLS003 Analyzer From 09b30804897d90fce4adca2a340afc0f1029b9e1 Mon Sep 17 00:00:00 2001 From: Xavier John Date: Sat, 28 Mar 2026 14:12:07 -0700 Subject: [PATCH 09/17] Remove unused s_moneyType field and Trellis.Primitives using from MaybeConvention Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Trellis.EntityFrameworkCore/src/MaybeConvention.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Trellis.EntityFrameworkCore/src/MaybeConvention.cs b/Trellis.EntityFrameworkCore/src/MaybeConvention.cs index a60ad337..6c16e315 100644 --- a/Trellis.EntityFrameworkCore/src/MaybeConvention.cs +++ b/Trellis.EntityFrameworkCore/src/MaybeConvention.cs @@ -6,7 +6,6 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Conventions; using Microsoft.EntityFrameworkCore.Metadata.Internal; -using Trellis.Primitives; /// /// Convention that automatically maps properties by discovering their @@ -31,7 +30,7 @@ /// Sets the column name to the original property name (e.g., Phone instead of _phone) /// /// -/// When T is a composite owned type (e.g., ), the convention creates +/// When T is a composite owned type (e.g., Money), the convention creates /// an optional ownership navigation via the backing field instead of a scalar column. /// All columns in the owned type are marked nullable, and column names use the original /// property name as the prefix (matching naming). @@ -54,8 +53,6 @@ /// internal sealed class MaybeConvention : IModelFinalizingConvention { - private static readonly Type s_moneyType = typeof(Money); - /// /// After the model is built, discovers all CLR properties on entity types /// and configures their generated storage members as nullable database columns. From ce46932857ca438794322cf52b22febfa0441aa7 Mon Sep 17 00:00:00 2001 From: Xavier John Date: Sat, 28 Mar 2026 14:15:26 -0700 Subject: [PATCH 10/17] Fix IComparable.CompareTo(null) to return 1 per .NET convention Non-null instance is greater than null. Previously threw ArgumentException for null because it fell into the non-ValueObject branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Trellis.DomainDrivenDesign/src/ValueObject.cs | 8 +++++-- .../tests/ValueObjects/ValueObjectTests.cs | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/Trellis.DomainDrivenDesign/src/ValueObject.cs b/Trellis.DomainDrivenDesign/src/ValueObject.cs index 44a0c2a3..9cd3fb9f 100644 --- a/Trellis.DomainDrivenDesign/src/ValueObject.cs +++ b/Trellis.DomainDrivenDesign/src/ValueObject.cs @@ -321,8 +321,12 @@ public virtual int CompareTo(ValueObject? other) /// Non-generic implementation. Delegates to . /// Enables value objects to be used in of composite value objects. /// - int IComparable.CompareTo(object? obj) => - obj is ValueObject other ? CompareTo(other) : throw new ArgumentException($"Cannot compare {GetType()} to {obj?.GetType()}"); + int IComparable.CompareTo(object? obj) => obj switch + { + null => 1, + ValueObject other => CompareTo(other), + _ => throw new ArgumentException($"Cannot compare {GetType()} to {obj.GetType()}") + }; private static int CompareComponents(object? object1, object? object2) { diff --git a/Trellis.DomainDrivenDesign/tests/ValueObjects/ValueObjectTests.cs b/Trellis.DomainDrivenDesign/tests/ValueObjects/ValueObjectTests.cs index 4f3f50b1..7e15f5df 100644 --- a/Trellis.DomainDrivenDesign/tests/ValueObjects/ValueObjectTests.cs +++ b/Trellis.DomainDrivenDesign/tests/ValueObjects/ValueObjectTests.cs @@ -392,6 +392,30 @@ public void Composite_ValueObject_with_ScalarVO_components_are_not_equal() } #endregion + + #region IComparable null handling + + [Fact] + public void IComparable_CompareTo_Null_Returns_Positive() + { + var addr = new CompositeAddress(StreetName.Create("123 Main St"), CityName.Create("Springfield")); + var comparable = (IComparable)addr; + + // Per .NET convention, a non-null instance is greater than null + comparable.CompareTo(null).Should().BePositive(); + } + + [Fact] + public void IComparable_CompareTo_WrongType_Throws() + { + var addr = new CompositeAddress(StreetName.Create("123 Main St"), CityName.Create("Springfield")); + var comparable = (IComparable)addr; + + var act = () => comparable.CompareTo("not a ValueObject"); + act.Should().Throw(); + } + + #endregion } /// From 504e17888cf43e6ea27b09116a95f3b621f4c5fb Mon Sep 17 00:00:00 2001 From: Xavier John Date: Sat, 28 Mar 2026 16:21:20 -0700 Subject: [PATCH 11/17] Add missing test coverage for MonetaryAmount and ReplaceDbProvider MonetaryAmount: TryCreate(decimal?), Parse, TryParse, Multiply(decimal), explicit cast, overflow for Add and Multiply (+12 tests) ReplaceDbProvider: null argument guards and tests (+2 tests) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tests/MonetaryAmountTests.cs | 110 ++++++++++++++++++ .../ServiceCollectionDbProviderExtensions.cs | 3 + ...viceCollectionDbProviderExtensionsTests.cs | 21 ++++ 3 files changed, 134 insertions(+) diff --git a/Trellis.Primitives/tests/MonetaryAmountTests.cs b/Trellis.Primitives/tests/MonetaryAmountTests.cs index f3212c16..4da7b2a6 100644 --- a/Trellis.Primitives/tests/MonetaryAmountTests.cs +++ b/Trellis.Primitives/tests/MonetaryAmountTests.cs @@ -41,6 +41,23 @@ public void TryCreate_RoundsToTwoDecimalPlaces() result.Value.Value.Should().Be(30.00m); } + [Fact] + public void TryCreate_NullableDecimal_Null_ReturnsFailure() + { + decimal? value = null; + var result = MonetaryAmount.TryCreate(value); + result.IsFailure.Should().BeTrue(); + } + + [Fact] + public void TryCreate_NullableDecimal_ValidValue_ReturnsSuccess() + { + decimal? value = 15.50m; + var result = MonetaryAmount.TryCreate(value); + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(15.50m); + } + [Fact] public void Create_ValidAmount_ReturnsInstance() { @@ -55,6 +72,13 @@ public void Zero_ReturnsZeroAmount() zero.Value.Should().Be(0m); } + [Fact] + public void ExplicitCast_FromDecimal_ReturnsInstance() + { + var amount = (MonetaryAmount)29.99m; + amount.Value.Should().Be(29.99m); + } + #endregion #region Arithmetic @@ -110,6 +134,44 @@ public void Multiply_ByNegative_ReturnsFailure() result.IsFailure.Should().BeTrue(); } + [Fact] + public void Multiply_ByDecimal_ReturnsResult() + { + var amount = MonetaryAmount.Create(10.00m); + + var result = amount.Multiply(1.5m); + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(15.00m); + } + + [Fact] + public void Multiply_ByNegativeDecimal_ReturnsFailure() + { + var amount = MonetaryAmount.Create(10.00m); + + var result = amount.Multiply(-0.5m); + result.IsFailure.Should().BeTrue(); + } + + [Fact] + public void Add_NearMaxValue_ReturnsFailure() + { + var a = MonetaryAmount.Create(decimal.MaxValue - 1m); + var b = MonetaryAmount.Create(decimal.MaxValue - 1m); + + var result = a.Add(b); + result.IsFailure.Should().BeTrue(); + } + + [Fact] + public void Multiply_LargeValueOverflow_ReturnsFailure() + { + var amount = MonetaryAmount.Create(decimal.MaxValue - 1m); + + var result = amount.Multiply(2); + result.IsFailure.Should().BeTrue(); + } + #endregion #region Equality and Comparison @@ -156,6 +218,54 @@ public void Json_DeserializesFromDecimal() #endregion + #region Parsing + + [Fact] + public void Parse_ValidInput_ReturnsInstance() + { + var amount = MonetaryAmount.Parse("29.99", System.Globalization.CultureInfo.InvariantCulture); + amount.Value.Should().Be(29.99m); + } + + [Fact] + public void Parse_NullInput_ThrowsFormatException() + { + var act = () => MonetaryAmount.Parse(null, System.Globalization.CultureInfo.InvariantCulture); + act.Should().Throw(); + } + + [Fact] + public void Parse_EmptyInput_ThrowsFormatException() + { + var act = () => MonetaryAmount.Parse(string.Empty, System.Globalization.CultureInfo.InvariantCulture); + act.Should().Throw(); + } + + [Fact] + public void Parse_NegativeInput_ThrowsFormatException() + { + var act = () => MonetaryAmount.Parse("-5.00", System.Globalization.CultureInfo.InvariantCulture); + act.Should().Throw(); + } + + [Fact] + public void TryParse_ValidInput_ReturnsTrue() + { + var success = MonetaryAmount.TryParse("42.50", System.Globalization.CultureInfo.InvariantCulture, out var result); + success.Should().BeTrue(); + result!.Value.Should().Be(42.50m); + } + + [Fact] + public void TryParse_InvalidInput_ReturnsFalse() + { + var success = MonetaryAmount.TryParse("not-a-number", System.Globalization.CultureInfo.InvariantCulture, out var result); + success.Should().BeFalse(); + result.Should().BeNull(); + } + + #endregion + #region ToString [Fact] diff --git a/Trellis.Testing/src/ServiceCollectionDbProviderExtensions.cs b/Trellis.Testing/src/ServiceCollectionDbProviderExtensions.cs index 36ab13a3..7063ab1f 100644 --- a/Trellis.Testing/src/ServiceCollectionDbProviderExtensions.cs +++ b/Trellis.Testing/src/ServiceCollectionDbProviderExtensions.cs @@ -41,6 +41,9 @@ public static class ServiceCollectionDbProviderExtensions Action configureOptions) where TContext : DbContext { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configureOptions); + services.RemoveAll(); services.RemoveAll>(); diff --git a/Trellis.Testing/tests/ServiceCollectionDbProviderExtensionsTests.cs b/Trellis.Testing/tests/ServiceCollectionDbProviderExtensionsTests.cs index a263649c..5403c6e2 100644 --- a/Trellis.Testing/tests/ServiceCollectionDbProviderExtensionsTests.cs +++ b/Trellis.Testing/tests/ServiceCollectionDbProviderExtensionsTests.cs @@ -111,6 +111,27 @@ public void ReplaceDbProvider_SwapsProvider_CanCreateAndQuery() context.Items.Should().ContainSingle(e => e.Name == "test-item"); } + [Fact] + public void ReplaceDbProvider_NullServices_ThrowsArgumentNullException() + { + IServiceCollection services = null!; + + var act = () => services.ReplaceDbProvider(options => + options.UseInMemoryDatabase("test")); + + act.Should().Throw(); + } + + [Fact] + public void ReplaceDbProvider_NullConfigureOptions_ThrowsArgumentNullException() + { + var services = new ServiceCollection(); + + var act = () => services.ReplaceDbProvider(null!); + + act.Should().Throw(); + } + #endregion #region Helpers From a8f86a00c039137eb7f768336d1f1e265fde1fec Mon Sep 17 00:00:00 2001 From: Xavier John Date: Sat, 28 Mar 2026 16:41:14 -0700 Subject: [PATCH 12/17] Add TryCreate(string?, string?) to IScalarValue interface Add static abstract TryCreate(string?, string?) to IScalarValue for parsing scalar values from string representations. Implementations added for Age (int parsing), MonetaryAmount (decimal with InvariantCulture), and Percentage (decimal with % suffix stripping). All test/example types updated to satisfy the new interface requirement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Xunit/DomainDrivenDesignSamplesTests.cs | 9 ++ Trellis.Asp/tests/MaybeModelBinderTests.cs | 13 +- .../MaybeScalarValueJsonConverterTests.cs | 16 ++- Trellis.Asp/tests/ModelBindingTests.cs | 13 +- .../NullAndMissingPropertyValidationTests.cs | 8 ++ ...alarValueModelBinderPrimitiveTypesTests.cs | 70 ++++++++--- .../tests/ScalarValueTypeHelperTests.cs | 16 ++- .../ScalarValueValidationMiddlewareTests.cs | 10 +- .../tests/ServiceCollectionExtensionsTests.cs | 2 + .../ValidatingJsonConverterEdgeCasesTests.cs | 6 + ...idatingJsonConverterPrimitiveTypesTests.cs | 50 ++++++-- .../tests/ValidatingJsonConverterTests.cs | 6 + .../tests/ValueObjects/Money.cs | 3 + .../ValueObjects/ScalarValueObjectTests.cs | 19 ++- .../tests/ValueObjects/ValueObjectTests.cs | 4 +- Trellis.Primitives/src/Primitives/Age.cs | 17 +++ .../src/Primitives/MonetaryAmount.cs | 20 ++++ .../src/Primitives/Percentage.cs | 23 ++++ Trellis.Primitives/tests/AgeTests.cs | 89 ++++++++++++++ .../tests/MonetaryAmountTests.cs | 99 ++++++++++++++++ Trellis.Primitives/tests/PercentageTests.cs | 111 ++++++++++++++++++ Trellis.Results/src/IScalarValue.cs | 17 ++- 22 files changed, 576 insertions(+), 45 deletions(-) diff --git a/Examples/Xunit/DomainDrivenDesignSamplesTests.cs b/Examples/Xunit/DomainDrivenDesignSamplesTests.cs index e0b2e6fd..3ea55aac 100644 --- a/Examples/Xunit/DomainDrivenDesignSamplesTests.cs +++ b/Examples/Xunit/DomainDrivenDesignSamplesTests.cs @@ -23,6 +23,9 @@ public static Result TryCreate(Guid value, string? fieldName = null) ? Error.Validation("Customer ID cannot be empty", fieldName ?? "customerId") : Result.Success(new CustomerId(value)); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); + public static Result TryCreate(Guid? value) => value.ToResult(Error.Validation("Customer ID cannot be empty")) .Ensure(v => v != Guid.Empty, Error.Validation("Customer ID cannot be empty")) @@ -40,6 +43,9 @@ public static Result TryCreate(Guid value, string? fieldName = null) => ? Error.Validation("Order ID cannot be empty", fieldName ?? "orderId") : Result.Success(new OrderId(value)); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); + public static Result TryCreate(Guid? value) => value.ToResult(Error.Validation("Order ID cannot be empty")) .Ensure(v => v != Guid.Empty, Error.Validation("Order ID cannot be empty")) @@ -337,6 +343,9 @@ public static Result TryCreate(decimal value, string? fieldName = n .Map(v => new Temperature(v)); } + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); + public static Temperature FromCelsius(decimal celsius) => new(celsius); public static Temperature FromFahrenheit(decimal fahrenheit) => new((fahrenheit - 32) * 5 / 9); public static Temperature FromKelvin(decimal kelvin) => new(kelvin - 273.15m); diff --git a/Trellis.Asp/tests/MaybeModelBinderTests.cs b/Trellis.Asp/tests/MaybeModelBinderTests.cs index 76a3a0f1..798f7abe 100644 --- a/Trellis.Asp/tests/MaybeModelBinderTests.cs +++ b/Trellis.Asp/tests/MaybeModelBinderTests.cs @@ -30,9 +30,12 @@ public static Result TryCreate(Guid value, string? fieldName = null) return Error.Validation("UserId cannot be empty.", field); return new UserId(value); } + + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public class ProductCode : ScalarValueObject, IScalarValue + public class ProductCode: ScalarValueObject, IScalarValue { private ProductCode(string value) : base(value) { } @@ -60,9 +63,12 @@ public static Result TryCreate(int value, string? fieldName = null) return Error.Validation("Quantity cannot exceed 1000.", field); return new Quantity(value); } + + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public class Price : ScalarValueObject, IScalarValue + public class Price: ScalarValueObject, IScalarValue { private Price(decimal value) : base(value) { } @@ -73,6 +79,9 @@ public static Result TryCreate(decimal value, string? fieldName = null) return Error.Validation("Price cannot be negative.", field); return new Price(value); } + + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } #endregion diff --git a/Trellis.Asp/tests/MaybeScalarValueJsonConverterTests.cs b/Trellis.Asp/tests/MaybeScalarValueJsonConverterTests.cs index 15c5cb17..3a6a4d47 100644 --- a/Trellis.Asp/tests/MaybeScalarValueJsonConverterTests.cs +++ b/Trellis.Asp/tests/MaybeScalarValueJsonConverterTests.cs @@ -45,9 +45,12 @@ public static Result TryCreate(int value, string? fieldName = null) return Error.Validation("Age must be realistic.", field); return new Age(value); } + + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public class Percentage : ScalarValueObject, IScalarValue + public class Percentage: ScalarValueObject, IScalarValue { private Percentage(decimal value) : base(value) { } @@ -60,9 +63,12 @@ public static Result TryCreate(decimal value, string? fieldName = nu return Error.Validation("Percentage cannot exceed 100.", field); return new Percentage(value); } + + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public class ItemId : ScalarValueObject, IScalarValue + public class ItemId: ScalarValueObject, IScalarValue { private ItemId(Guid value) : base(value) { } @@ -73,6 +79,9 @@ public static Result TryCreate(Guid value, string? fieldName = null) return Error.Validation("ItemId cannot be empty.", field); return new ItemId(value); } + + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } public enum ProcessingMode @@ -90,6 +99,9 @@ public static Result TryCreate(ProcessingMode value, string? f value == ProcessingMode.Unknown ? Error.Validation("Processing mode is required.", fieldName ?? "processingMode") : new ProcessingModeVO(value); + + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } #endregion diff --git a/Trellis.Asp/tests/ModelBindingTests.cs b/Trellis.Asp/tests/ModelBindingTests.cs index a1a53df1..3fafb11a 100644 --- a/Trellis.Asp/tests/ModelBindingTests.cs +++ b/Trellis.Asp/tests/ModelBindingTests.cs @@ -28,9 +28,12 @@ public static Result TryCreate(Guid value, string? fieldName = null) return Error.Validation("UserId cannot be empty.", field); return new UserId(value); } + + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public class ProductCode : ScalarValueObject, IScalarValue + public class ProductCode: ScalarValueObject, IScalarValue { private ProductCode(string value) : base(value) { } @@ -58,9 +61,12 @@ public static Result TryCreate(int value, string? fieldName = null) return Error.Validation("Quantity cannot exceed 1000.", field); return new Quantity(value); } + + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public class Price : ScalarValueObject, IScalarValue + public class Price: ScalarValueObject, IScalarValue { private Price(decimal value) : base(value) { } @@ -71,6 +77,9 @@ public static Result TryCreate(decimal value, string? fieldName = null) return Error.Validation("Price cannot be negative.", field); return new Price(value); } + + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } #endregion diff --git a/Trellis.Asp/tests/NullAndMissingPropertyValidationTests.cs b/Trellis.Asp/tests/NullAndMissingPropertyValidationTests.cs index beb2ec66..f6e31aa9 100644 --- a/Trellis.Asp/tests/NullAndMissingPropertyValidationTests.cs +++ b/Trellis.Asp/tests/NullAndMissingPropertyValidationTests.cs @@ -30,6 +30,8 @@ public static Result TryCreate(int value, string? fieldName = null) => value > 0 ? new Quantity(value) : Error.Validation("Quantity must be positive.", fieldName ?? "quantity"); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } public sealed class Price : ScalarValueObject, IScalarValue @@ -39,6 +41,8 @@ public static Result TryCreate(decimal value, string? fieldName = null) = value >= 0 ? new Price(value) : Error.Validation("Price cannot be negative.", fieldName ?? "price"); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } public sealed class Counter : ScalarValueObject, IScalarValue @@ -48,6 +52,8 @@ public static Result TryCreate(long value, string? fieldName = null) => value >= 0 ? new Counter(value) : Error.Validation("Counter cannot be negative.", fieldName ?? "counter"); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } public sealed class IsActive : ScalarValueObject, IScalarValue @@ -55,6 +61,8 @@ public sealed class IsActive : ScalarValueObject, IScalarValue TryCreate(bool value, string? fieldName = null) => new IsActive(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } public sealed class ProductName : ScalarValueObject, IScalarValue diff --git a/Trellis.Asp/tests/ScalarValueModelBinderPrimitiveTypesTests.cs b/Trellis.Asp/tests/ScalarValueModelBinderPrimitiveTypesTests.cs index 604eee79..cc053441 100644 --- a/Trellis.Asp/tests/ScalarValueModelBinderPrimitiveTypesTests.cs +++ b/Trellis.Asp/tests/ScalarValueModelBinderPrimitiveTypesTests.cs @@ -33,15 +33,19 @@ public static Result TryCreate(Guid value, string? fieldName = null) => value == Guid.Empty ? Error.Validation("Cannot be empty", fieldName ?? "value") : new GuidVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public sealed class NonNegativeIntVO : ScalarValueObject, IScalarValue + public sealed class NonNegativeIntVO: ScalarValueObject, IScalarValue { private NonNegativeIntVO(int value) : base(value) { } public static Result TryCreate(int value, string? fieldName = null) => value < 0 ? Error.Validation("Must be non-negative", fieldName ?? "value") : new NonNegativeIntVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } public sealed class LongVO : ScalarValueObject, IScalarValue @@ -51,129 +55,161 @@ public static Result TryCreate(long value, string? fieldName = null) => value < 0 ? Error.Validation("Must be non-negative", fieldName ?? "value") : new LongVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public sealed class DecimalVO : ScalarValueObject, IScalarValue + public sealed class DecimalVO: ScalarValueObject, IScalarValue { private DecimalVO(decimal value) : base(value) { } public static Result TryCreate(decimal value, string? fieldName = null) => value < 0 ? Error.Validation("Must be non-negative", fieldName ?? "value") : new DecimalVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public sealed class DoubleVO : ScalarValueObject, IScalarValue + public sealed class DoubleVO: ScalarValueObject, IScalarValue { private DoubleVO(double value) : base(value) { } public static Result TryCreate(double value, string? fieldName = null) => value < 0 ? Error.Validation("Must be non-negative", fieldName ?? "value") : new DoubleVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public sealed class BoolVO : ScalarValueObject, IScalarValue + public sealed class BoolVO: ScalarValueObject, IScalarValue { private BoolVO(bool value) : base(value) { } public static Result TryCreate(bool value, string? fieldName = null) => new BoolVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public sealed class DateTimeVO : ScalarValueObject, IScalarValue + public sealed class DateTimeVO: ScalarValueObject, IScalarValue { private DateTimeVO(DateTime value) : base(value) { } public static Result TryCreate(DateTime value, string? fieldName = null) => value == default ? Error.Validation("Required", fieldName ?? "value") : new DateTimeVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public sealed class DateOnlyVO : ScalarValueObject, IScalarValue + public sealed class DateOnlyVO: ScalarValueObject, IScalarValue { private DateOnlyVO(DateOnly value) : base(value) { } public static Result TryCreate(DateOnly value, string? fieldName = null) => value == default ? Error.Validation("Required", fieldName ?? "value") : new DateOnlyVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public sealed class TimeOnlyVO : ScalarValueObject, IScalarValue + public sealed class TimeOnlyVO: ScalarValueObject, IScalarValue { private TimeOnlyVO(TimeOnly value) : base(value) { } public static Result TryCreate(TimeOnly value, string? fieldName = null) => new TimeOnlyVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public sealed class TimeSpanVO : ScalarValueObject, IScalarValue + public sealed class TimeSpanVO: ScalarValueObject, IScalarValue { private TimeSpanVO(TimeSpan value) : base(value) { } public static Result TryCreate(TimeSpan value, string? fieldName = null) => value < TimeSpan.Zero ? Error.Validation("Must be non-negative", fieldName ?? "value") : new TimeSpanVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public sealed class DateTimeOffsetVO : ScalarValueObject, IScalarValue + public sealed class DateTimeOffsetVO: ScalarValueObject, IScalarValue { private DateTimeOffsetVO(DateTimeOffset value) : base(value) { } public static Result TryCreate(DateTimeOffset value, string? fieldName = null) => value == default ? Error.Validation("Required", fieldName ?? "value") : new DateTimeOffsetVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public sealed class ShortVO : ScalarValueObject, IScalarValue + public sealed class ShortVO: ScalarValueObject, IScalarValue { private ShortVO(short value) : base(value) { } public static Result TryCreate(short value, string? fieldName = null) => value < 0 ? Error.Validation("Must be non-negative", fieldName ?? "value") : new ShortVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public sealed class ByteVO : ScalarValueObject, IScalarValue + public sealed class ByteVO: ScalarValueObject, IScalarValue { private ByteVO(byte value) : base(value) { } public static Result TryCreate(byte value, string? fieldName = null) => new ByteVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public sealed class SByteVO : ScalarValueObject, IScalarValue + public sealed class SByteVO: ScalarValueObject, IScalarValue { private SByteVO(sbyte value) : base(value) { } public static Result TryCreate(sbyte value, string? fieldName = null) => value < 0 ? Error.Validation("Must be non-negative", fieldName ?? "value") : new SByteVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public sealed class UShortVO : ScalarValueObject, IScalarValue + public sealed class UShortVO: ScalarValueObject, IScalarValue { private UShortVO(ushort value) : base(value) { } public static Result TryCreate(ushort value, string? fieldName = null) => new UShortVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public sealed class UIntVO : ScalarValueObject, IScalarValue + public sealed class UIntVO: ScalarValueObject, IScalarValue { private UIntVO(uint value) : base(value) { } public static Result TryCreate(uint value, string? fieldName = null) => new UIntVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public sealed class ULongVO : ScalarValueObject, IScalarValue + public sealed class ULongVO: ScalarValueObject, IScalarValue { private ULongVO(ulong value) : base(value) { } public static Result TryCreate(ulong value, string? fieldName = null) => new ULongVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public sealed class FloatVO : ScalarValueObject, IScalarValue + public sealed class FloatVO: ScalarValueObject, IScalarValue { private FloatVO(float value) : base(value) { } public static Result TryCreate(float value, string? fieldName = null) => value < 0 ? Error.Validation("Must be non-negative", fieldName ?? "value") : new FloatVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } public enum ProcessingMode @@ -190,6 +226,8 @@ public static Result TryCreate(ProcessingMode value, string? f value == ProcessingMode.Unknown ? Error.Validation("Mode is required", fieldName ?? "value") : new ProcessingModeVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } #endregion diff --git a/Trellis.Asp/tests/ScalarValueTypeHelperTests.cs b/Trellis.Asp/tests/ScalarValueTypeHelperTests.cs index dc0e9b17..75bec091 100644 --- a/Trellis.Asp/tests/ScalarValueTypeHelperTests.cs +++ b/Trellis.Asp/tests/ScalarValueTypeHelperTests.cs @@ -45,6 +45,8 @@ public class InterfaceOnly : IScalarValue public InterfaceOnly(int value) => Value = value; public static Result TryCreate(int value, string? fieldName = null) => new InterfaceOnly(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } public class InterfaceOnlyValidated : IScalarValue @@ -53,6 +55,8 @@ public class InterfaceOnlyValidated : IScalarValue private InterfaceOnlyValidated(int value) => Value = value; public static Result TryCreate(int value, string? fieldName = null) => value > 0 ? new InterfaceOnlyValidated(value) : Error.Validation("Must be positive.", fieldName ?? "field"); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } // Generic value object @@ -63,6 +67,9 @@ private GenericVO(T value) : base(value) { } [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1000:Do not declare static members on generic types", Justification = "Required by IScalarValue interface pattern")] public static Result> TryCreate(T? value, string? fieldName = null) => value is null ? Error.Validation("Required", fieldName ?? "field") : new GenericVO(value); + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1000:Do not declare static members on generic types", Justification = "Required by IScalarValue interface pattern")] + public static Result> TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } // Multiple interface implementations (edge case) @@ -456,16 +463,15 @@ public void GetValidationErrors_NullValue_ReturnsDictionaryWithErrors() [Fact] public void GetValidationErrors_TypeWithoutStringTryCreate_ParseablePrimitive_ReturnsValidationErrors() { - // Arrange - InterfaceOnlyValidated has TryCreate(int, string?) — no TryCreate(string, string) overload + // Arrange - InterfaceOnlyValidated has TryCreate(string, string) that throws NotImplementedException, + // so GetValidationErrors will return null (exception is caught and swallowed). var type = typeof(InterfaceOnlyValidated); // Act var errors = ScalarValueTypeHelper.GetValidationErrors(type, "0", "param"); - // Assert - errors.Should().NotBeNull(); - errors!.Should().ContainKey("param"); - errors!["param"].Should().Contain("Must be positive."); + // Assert - returns null because the string TryCreate throws NotImplementedException + errors.Should().BeNull(); } [Fact] diff --git a/Trellis.Asp/tests/ScalarValueValidationMiddlewareTests.cs b/Trellis.Asp/tests/ScalarValueValidationMiddlewareTests.cs index 2edc650e..dd346903 100644 --- a/Trellis.Asp/tests/ScalarValueValidationMiddlewareTests.cs +++ b/Trellis.Asp/tests/ScalarValueValidationMiddlewareTests.cs @@ -331,6 +331,8 @@ public class IntOnlyScalarValue : IScalarValue private IntOnlyScalarValue(int value) => Value = value; public static Result TryCreate(int value, string? fieldName = null) => value > 0 ? new IntOnlyScalarValue(value) : Error.Validation("Must be positive.", fieldName ?? "value"); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } [Fact] @@ -621,8 +623,8 @@ public async Task InvokeAsync_BindingFailure_ScopeStillDisposed() [Fact] public async Task InvokeAsync_BindingFailure_ScalarValueWithoutStringTryCreate_ReturnsRichValidationError() { - // Arrange - IntOnlyScalarValue has no TryCreate(string, string), but the raw value is parseable as int - // so the middleware should surface the value object's own validation error. + // Arrange - IntOnlyScalarValue has TryCreate(string, string) that throws NotImplementedException, + // so GetValidationErrors returns null and the middleware falls back to CreateFallbackErrors. var paramInfo = typeof(ScalarValueValidationMiddlewareTests) .GetMethod(nameof(IntOnlyScalarValueParam), BindingFlags.Static | BindingFlags.NonPublic)! .GetParameters()[0]; @@ -635,12 +637,12 @@ public async Task InvokeAsync_BindingFailure_ScalarValueWithoutStringTryCreate_R // Act await middleware.InvokeAsync(context); - // Assert + // Assert - falls back to generic error since string TryCreate throws NotImplementedException context.Response.StatusCode.Should().Be(400); var body = await ReadResponseBodyAsync(context); var problem = JsonSerializer.Deserialize(body); problem.GetProperty("errors").GetProperty("val")[0].GetString() - .Should().Be("Must be positive."); + .Should().Be("'val' has an invalid value."); } [Fact] diff --git a/Trellis.Asp/tests/ServiceCollectionExtensionsTests.cs b/Trellis.Asp/tests/ServiceCollectionExtensionsTests.cs index 594d0f5c..2056f25b 100644 --- a/Trellis.Asp/tests/ServiceCollectionExtensionsTests.cs +++ b/Trellis.Asp/tests/ServiceCollectionExtensionsTests.cs @@ -57,6 +57,8 @@ public static Result TryCreate(int value, string? fieldName = null) => value is < 0 or > 150 ? Error.Validation("Age must be between 0 and 150.", fieldName ?? "age") : new TestAge(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } #endregion diff --git a/Trellis.Asp/tests/ValidatingJsonConverterEdgeCasesTests.cs b/Trellis.Asp/tests/ValidatingJsonConverterEdgeCasesTests.cs index 821fb8af..e3c90ab8 100644 --- a/Trellis.Asp/tests/ValidatingJsonConverterEdgeCasesTests.cs +++ b/Trellis.Asp/tests/ValidatingJsonConverterEdgeCasesTests.cs @@ -37,6 +37,8 @@ public static Result TryCreate(int value, string? fieldName = null) return Error.Validation("Age cannot be negative.", field); return new Age(value); } + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } public class URL : ScalarValueObject, IScalarValue @@ -68,6 +70,8 @@ public static Result TryCreate(ProcessingMode value, string? f value == ProcessingMode.Unknown ? Error.Validation("Processing mode is required.", fieldName ?? "processingMode") : new ProcessingModeVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } #endregion @@ -296,6 +300,8 @@ private NonValidationErrorVO(int value) : base(value) { } public static Result TryCreate(int value, string? fieldName = null) => // Return non-validation error Error.Unexpected("Unexpected error", "code"); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } #endregion diff --git a/Trellis.Asp/tests/ValidatingJsonConverterPrimitiveTypesTests.cs b/Trellis.Asp/tests/ValidatingJsonConverterPrimitiveTypesTests.cs index 71b3dfc5..be17c459 100644 --- a/Trellis.Asp/tests/ValidatingJsonConverterPrimitiveTypesTests.cs +++ b/Trellis.Asp/tests/ValidatingJsonConverterPrimitiveTypesTests.cs @@ -30,6 +30,8 @@ public class GuidVO : ScalarValueObject, IScalarValue TryCreate(Guid value, string? fieldName = null) => value == Guid.Empty ? Error.Validation("Required", fieldName ?? "field") : new GuidVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } // Int @@ -38,6 +40,8 @@ public class IntVO : ScalarValueObject, IScalarValue private IntVO(int value) : base(value) { } public static Result TryCreate(int value, string? fieldName = null) => value < 0 ? Error.Validation("Negative", fieldName ?? "field") : new IntVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } // Long @@ -46,6 +50,8 @@ public class LongVO : ScalarValueObject, IScalarValue TryCreate(long value, string? fieldName = null) => value < 0 ? Error.Validation("Negative", fieldName ?? "field") : new LongVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } // Double @@ -54,6 +60,8 @@ public class DoubleVO : ScalarValueObject, IScalarValue TryCreate(double value, string? fieldName = null) => double.IsNaN(value) ? Error.Validation("NaN", fieldName ?? "field") : new DoubleVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } // Float @@ -62,6 +70,8 @@ public class FloatVO : ScalarValueObject, IScalarValue TryCreate(float value, string? fieldName = null) => float.IsNaN(value) ? Error.Validation("NaN", fieldName ?? "field") : new FloatVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } // Decimal @@ -70,6 +80,8 @@ public class DecimalVO : ScalarValueObject, IScalarValue TryCreate(decimal value, string? fieldName = null) => value < 0 ? Error.Validation("Negative", fieldName ?? "field") : new DecimalVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } // Bool @@ -78,6 +90,8 @@ public class BoolVO : ScalarValueObject, IScalarValue TryCreate(bool value, string? fieldName = null) => new BoolVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } // DateTime @@ -86,6 +100,8 @@ public class DateTimeVO : ScalarValueObject, IScalarValue< private DateTimeVO(DateTime value) : base(value) { } public static Result TryCreate(DateTime value, string? fieldName = null) => value == DateTime.MinValue ? Error.Validation("MinValue", fieldName ?? "field") : new DateTimeVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } // DateTimeOffset @@ -94,6 +110,8 @@ public class DateTimeOffsetVO : ScalarValueObject TryCreate(DateTimeOffset value, string? fieldName = null) => value == DateTimeOffset.MinValue ? Error.Validation("MinValue", fieldName ?? "field") : new DateTimeOffsetVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } // DateOnly (.NET 6+) @@ -102,6 +120,8 @@ public class DateOnlyVO : ScalarValueObject, IScalarValue< private DateOnlyVO(DateOnly value) : base(value) { } public static Result TryCreate(DateOnly value, string? fieldName = null) => value == DateOnly.MinValue ? Error.Validation("MinValue", fieldName ?? "field") : new DateOnlyVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } // TimeOnly (.NET 6+) @@ -110,55 +130,71 @@ public class TimeOnlyVO : ScalarValueObject, IScalarValue< private TimeOnlyVO(TimeOnly value) : base(value) { } public static Result TryCreate(TimeOnly value, string? fieldName = null) => value == TimeOnly.MinValue ? Error.Validation("MinValue", fieldName ?? "field") : new TimeOnlyVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public class TimeSpanVO : ScalarValueObject, IScalarValue + public class TimeSpanVO: ScalarValueObject, IScalarValue { private TimeSpanVO(TimeSpan value) : base(value) { } public static Result TryCreate(TimeSpan value, string? fieldName = null) => value < TimeSpan.Zero ? Error.Validation("Negative", fieldName ?? "field") : new TimeSpanVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public class ShortVO : ScalarValueObject, IScalarValue + public class ShortVO: ScalarValueObject, IScalarValue { private ShortVO(short value) : base(value) { } public static Result TryCreate(short value, string? fieldName = null) => value < 0 ? Error.Validation("Negative", fieldName ?? "field") : new ShortVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public class ByteVO : ScalarValueObject, IScalarValue + public class ByteVO: ScalarValueObject, IScalarValue { private ByteVO(byte value) : base(value) { } public static Result TryCreate(byte value, string? fieldName = null) => new ByteVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public class SByteVO : ScalarValueObject, IScalarValue + public class SByteVO: ScalarValueObject, IScalarValue { private SByteVO(sbyte value) : base(value) { } public static Result TryCreate(sbyte value, string? fieldName = null) => value < 0 ? Error.Validation("Negative", fieldName ?? "field") : new SByteVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public class UShortVO : ScalarValueObject, IScalarValue + public class UShortVO: ScalarValueObject, IScalarValue { private UShortVO(ushort value) : base(value) { } public static Result TryCreate(ushort value, string? fieldName = null) => new UShortVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public class UIntVO : ScalarValueObject, IScalarValue + public class UIntVO: ScalarValueObject, IScalarValue { private UIntVO(uint value) : base(value) { } public static Result TryCreate(uint value, string? fieldName = null) => new UIntVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } - public class ULongVO : ScalarValueObject, IScalarValue + public class ULongVO: ScalarValueObject, IScalarValue { private ULongVO(ulong value) : base(value) { } public static Result TryCreate(ulong value, string? fieldName = null) => new ULongVO(value); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } #endregion diff --git a/Trellis.Asp/tests/ValidatingJsonConverterTests.cs b/Trellis.Asp/tests/ValidatingJsonConverterTests.cs index 1c4d11d4..0790f512 100644 --- a/Trellis.Asp/tests/ValidatingJsonConverterTests.cs +++ b/Trellis.Asp/tests/ValidatingJsonConverterTests.cs @@ -43,6 +43,8 @@ public static Result TryCreate(int value, string? fieldName = null) return Error.Validation("Age must be realistic.", field); return new Age(value); } + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } public class Percentage : ScalarValueObject, IScalarValue @@ -58,6 +60,8 @@ public static Result TryCreate(decimal value, string? fieldName = nu return Error.Validation("Percentage cannot exceed 100.", field); return new Percentage(value); } + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } public class ItemId : ScalarValueObject, IScalarValue @@ -71,6 +75,8 @@ public static Result TryCreate(Guid value, string? fieldName = null) return Error.Validation("ItemId cannot be empty.", field); return new ItemId(value); } + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } #endregion diff --git a/Trellis.DomainDrivenDesign/tests/ValueObjects/Money.cs b/Trellis.DomainDrivenDesign/tests/ValueObjects/Money.cs index a835c04c..982b36e6 100644 --- a/Trellis.DomainDrivenDesign/tests/ValueObjects/Money.cs +++ b/Trellis.DomainDrivenDesign/tests/ValueObjects/Money.cs @@ -9,6 +9,9 @@ public Money(decimal value) : base(value) public static Result TryCreate(decimal value, string? fieldName = null) => Result.Success(new Money(value)); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); + protected override IEnumerable GetEqualityComponents() { yield return Math.Round(Value, 2); diff --git a/Trellis.DomainDrivenDesign/tests/ValueObjects/ScalarValueObjectTests.cs b/Trellis.DomainDrivenDesign/tests/ValueObjects/ScalarValueObjectTests.cs index 5f06807b..cb0f000d 100644 --- a/Trellis.DomainDrivenDesign/tests/ValueObjects/ScalarValueObjectTests.cs +++ b/Trellis.DomainDrivenDesign/tests/ValueObjects/ScalarValueObjectTests.cs @@ -10,8 +10,8 @@ internal class PasswordSimple : ScalarValueObject, IScal { public PasswordSimple(string value) : base(value) { } - public static Result TryCreate(string value, string? fieldName = null) => - Result.Success(new PasswordSimple(value)); + public static Result TryCreate(string? value, string? fieldName = null) => + Result.Success(new PasswordSimple(value!)); } internal class DerivedPasswordSimple : PasswordSimple @@ -29,6 +29,9 @@ public MoneySimple(decimal value) : base(value) { } public static Result TryCreate(decimal value, string? fieldName = null) => Result.Success(new MoneySimple(value)); + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); + protected override IEnumerable GetEqualityComponents() { yield return Math.Round(Value, 2); @@ -41,6 +44,9 @@ public CustomerId(Guid value) : base(value) { } public static Result TryCreate(Guid value, string? fieldName = null) => Result.Success(new CustomerId(value)); + + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } internal class Quantity : ScalarValueObject, IScalarValue @@ -49,6 +55,9 @@ public Quantity(int value) : base(value) { } public static Result TryCreate(int value, string? fieldName = null) => Result.Success(new Quantity(value)); + + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } internal class CharWrapper : ScalarValueObject, IScalarValue @@ -57,6 +66,9 @@ public CharWrapper(char value) : base(value) { } public static Result TryCreate(char value, string? fieldName = null) => Result.Success(new CharWrapper(value)); + + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } internal class DateTimeWrapper : ScalarValueObject, IScalarValue @@ -65,6 +77,9 @@ public DateTimeWrapper(DateTime value) : base(value) { } public static Result TryCreate(DateTime value, string? fieldName = null) => Result.Success(new DateTimeWrapper(value)); + + public static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); } #endregion diff --git a/Trellis.DomainDrivenDesign/tests/ValueObjects/ValueObjectTests.cs b/Trellis.DomainDrivenDesign/tests/ValueObjects/ValueObjectTests.cs index 7e15f5df..cc0d5acc 100644 --- a/Trellis.DomainDrivenDesign/tests/ValueObjects/ValueObjectTests.cs +++ b/Trellis.DomainDrivenDesign/tests/ValueObjects/ValueObjectTests.cs @@ -465,7 +465,7 @@ internal class StreetName : ScalarValueObject, IScalarValue< { private StreetName(string value) : base(value) { } - public static Result TryCreate(string value, string? fieldName = null) => + public static Result TryCreate(string? value, string? fieldName = null) => string.IsNullOrWhiteSpace(value) ? Result.Failure(Error.Validation("Street is required", fieldName ?? "street")) : Result.Success(new StreetName(value)); @@ -475,7 +475,7 @@ internal class CityName : ScalarValueObject, IScalarValue TryCreate(string value, string? fieldName = null) => + public static Result TryCreate(string? value, string? fieldName = null) => string.IsNullOrWhiteSpace(value) ? Result.Failure(Error.Validation("City is required", fieldName ?? "city")) : Result.Success(new CityName(value)); diff --git a/Trellis.Primitives/src/Primitives/Age.cs b/Trellis.Primitives/src/Primitives/Age.cs index 9a4d7a08..997c2067 100644 --- a/Trellis.Primitives/src/Primitives/Age.cs +++ b/Trellis.Primitives/src/Primitives/Age.cs @@ -40,6 +40,23 @@ public static Result TryCreate(int value, string? fieldName = null) return new Age(value); } + /// + /// Attempts to create an from a string representation. + /// + public static Result TryCreate(string? value, string? fieldName = null) + { + using var activity = PrimitiveValueObjectTrace.ActivitySource.StartActivity(nameof(Age) + '.' + nameof(TryCreate)); + var field = fieldName.NormalizeFieldName("age"); + + if (string.IsNullOrWhiteSpace(value)) + return Result.Failure(Error.Validation("Age is required.", field)); + + if (!int.TryParse(value, out var parsed)) + return Result.Failure(Error.Validation("Age must be a valid integer.", field)); + + return TryCreate(parsed, fieldName); + } + /// /// Parses an age. /// diff --git a/Trellis.Primitives/src/Primitives/MonetaryAmount.cs b/Trellis.Primitives/src/Primitives/MonetaryAmount.cs index 0759b0b7..2a9dd965 100644 --- a/Trellis.Primitives/src/Primitives/MonetaryAmount.cs +++ b/Trellis.Primitives/src/Primitives/MonetaryAmount.cs @@ -62,6 +62,26 @@ public static Result TryCreate(decimal? value, string? fieldName return TryCreate(value.Value, fieldName); } + /// + /// Attempts to create a from a string representation. + /// + /// The string value to parse (must be a valid decimal). + /// Optional field name for validation error messages. + /// Success with the MonetaryAmount if valid; Failure with ValidationError otherwise. + public static Result TryCreate(string? value, string? fieldName = null) + { + using var activity = PrimitiveValueObjectTrace.ActivitySource.StartActivity(nameof(MonetaryAmount) + '.' + nameof(TryCreate)); + var field = fieldName.NormalizeFieldName("amount"); + + if (string.IsNullOrWhiteSpace(value)) + return Result.Failure(Error.Validation("Amount is required.", field)); + + if (!decimal.TryParse(value, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var parsed)) + return Result.Failure(Error.Validation("Amount must be a valid decimal.", field)); + + return TryCreate(parsed, fieldName); + } + /// Adds two monetary amounts. public Result Add(MonetaryAmount other) { diff --git a/Trellis.Primitives/src/Primitives/Percentage.cs b/Trellis.Primitives/src/Primitives/Percentage.cs index c8da56fb..c4f460ba 100644 --- a/Trellis.Primitives/src/Primitives/Percentage.cs +++ b/Trellis.Primitives/src/Primitives/Percentage.cs @@ -126,6 +126,29 @@ public static Result TryCreate(decimal? value, string? fieldName = n return new Percentage(value.Value); } + /// + /// Attempts to create a from a string representation. + /// Strips a trailing % suffix if present before parsing. + /// + /// The string value to parse (must be a valid decimal, optionally with a trailing %). + /// Optional field name for validation error messages. + /// Success with the Percentage if valid; Failure with ValidationError otherwise. + public static Result TryCreate(string? value, string? fieldName = null) + { + using var activity = PrimitiveValueObjectTrace.ActivitySource.StartActivity(nameof(Percentage) + '.' + nameof(TryCreate)); + var field = fieldName.NormalizeFieldName("percentage"); + + if (string.IsNullOrWhiteSpace(value)) + return Result.Failure(Error.Validation("Percentage is required.", field)); + + var trimmed = value.TrimEnd('%', ' '); + + if (!decimal.TryParse(trimmed, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var parsed)) + return Result.Failure(Error.Validation("Percentage must be a valid decimal.", field)); + + return TryCreate(parsed, fieldName); + } + /// /// Creates a from a fraction (0.0 to 1.0). /// diff --git a/Trellis.Primitives/tests/AgeTests.cs b/Trellis.Primitives/tests/AgeTests.cs index ebcc402c..c2acac3c 100644 --- a/Trellis.Primitives/tests/AgeTests.cs +++ b/Trellis.Primitives/tests/AgeTests.cs @@ -234,4 +234,93 @@ public void ConvertFromJson_NumericToken() value!.Value.Should().Be(25); } + #region TryCreate from string + + [Theory] + [InlineData("0", 0)] + [InlineData("25", 25)] + [InlineData("150", 150)] + public void TryCreate_string_valid_returns_success(string input, int expected) + { + // Act + var result = Age.TryCreate(input); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(expected); + } + + [Fact] + public void TryCreate_string_null_returns_failure() + { + // Act + var result = Age.TryCreate((string?)null); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Fact] + public void TryCreate_string_empty_returns_failure() + { + // Act + var result = Age.TryCreate(""); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Fact] + public void TryCreate_string_whitespace_returns_failure() + { + // Act + var result = Age.TryCreate(" "); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Theory] + [InlineData("abc")] + [InlineData("12.5")] + [InlineData("not-a-number")] + public void TryCreate_string_invalid_format_returns_failure(string input) + { + // Act + var result = Age.TryCreate(input); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Fact] + public void TryCreate_string_uses_custom_fieldName() + { + // Act + var result = Age.TryCreate((string?)null, "PersonAge"); + + // Assert + result.IsFailure.Should().BeTrue(); + var validation = (ValidationError)result.Error; + validation.FieldErrors[0].FieldName.Should().Be("personAge"); + } + + [Fact] + public void TryCreate_string_delegates_validation_to_int_overload() + { + // Act — valid parse but invalid age (> 150) + var result = Age.TryCreate("200"); + + // Assert + result.IsFailure.Should().BeTrue(); + var validation = (ValidationError)result.Error; + validation.FieldErrors[0].Details[0].Should().Be("Age is unrealistically high."); + } + + #endregion + } \ No newline at end of file diff --git a/Trellis.Primitives/tests/MonetaryAmountTests.cs b/Trellis.Primitives/tests/MonetaryAmountTests.cs index 4da7b2a6..2af757f0 100644 --- a/Trellis.Primitives/tests/MonetaryAmountTests.cs +++ b/Trellis.Primitives/tests/MonetaryAmountTests.cs @@ -276,4 +276,103 @@ public void ToString_ReturnsFormattedAmount() } #endregion + + #region TryCreate from string + + [Theory] + [InlineData("0", 0)] + [InlineData("29.99", 29.99)] + [InlineData("1234.56", 1234.56)] + public void TryCreate_string_valid_returns_success(string input, decimal expected) + { + // Act + var result = MonetaryAmount.TryCreate(input); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(expected); + } + + [Fact] + public void TryCreate_string_null_returns_failure() + { + // Act + var result = MonetaryAmount.TryCreate((string?)null); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Fact] + public void TryCreate_string_empty_returns_failure() + { + // Act + var result = MonetaryAmount.TryCreate(""); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Fact] + public void TryCreate_string_whitespace_returns_failure() + { + // Act + var result = MonetaryAmount.TryCreate(" "); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Theory] + [InlineData("abc")] + [InlineData("not-a-number")] + public void TryCreate_string_invalid_format_returns_failure(string input) + { + // Act + var result = MonetaryAmount.TryCreate(input); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Fact] + public void TryCreate_string_uses_custom_fieldName() + { + // Act + var result = MonetaryAmount.TryCreate((string?)null, "Price"); + + // Assert + result.IsFailure.Should().BeTrue(); + var validation = (ValidationError)result.Error; + validation.FieldErrors[0].FieldName.Should().Be("price"); + } + + [Fact] + public void TryCreate_string_delegates_validation_to_decimal_overload() + { + // Act — valid parse but negative amount + var result = MonetaryAmount.TryCreate("-5.00"); + + // Assert + result.IsFailure.Should().BeTrue(); + var validation = (ValidationError)result.Error; + validation.FieldErrors[0].Details[0].Should().Be("Amount cannot be negative."); + } + + [Fact] + public void TryCreate_string_uses_invariant_culture() + { + // Act — "1,234.56" should parse correctly with InvariantCulture + var result = MonetaryAmount.TryCreate("1234.56"); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(1234.56m); + } + + #endregion } diff --git a/Trellis.Primitives/tests/PercentageTests.cs b/Trellis.Primitives/tests/PercentageTests.cs index c052bb78..b0a2c1c7 100644 --- a/Trellis.Primitives/tests/PercentageTests.cs +++ b/Trellis.Primitives/tests/PercentageTests.cs @@ -388,4 +388,115 @@ public void TryCreate_with_custom_fieldName() var validation = (ValidationError)result.Error; validation.FieldErrors[0].FieldName.Should().Be("discountRate"); } + + #region TryCreate from string + + [Theory] + [InlineData("0", 0)] + [InlineData("50", 50)] + [InlineData("100", 100)] + [InlineData("25.5", 25.5)] + public void TryCreate_string_valid_returns_success(string input, decimal expected) + { + // Act + var result = Percentage.TryCreate(input); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(expected); + } + + [Fact] + public void TryCreate_string_with_percent_suffix_returns_success() + { + // Act + var result = Percentage.TryCreate("50%"); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(50m); + } + + [Fact] + public void TryCreate_string_with_percent_and_space_returns_success() + { + // Act + var result = Percentage.TryCreate("75 %"); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(75m); + } + + [Fact] + public void TryCreate_string_null_returns_failure() + { + // Act + var result = Percentage.TryCreate((string?)null); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Fact] + public void TryCreate_string_empty_returns_failure() + { + // Act + var result = Percentage.TryCreate(""); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Fact] + public void TryCreate_string_whitespace_returns_failure() + { + // Act + var result = Percentage.TryCreate(" "); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Theory] + [InlineData("abc")] + [InlineData("not-a-number")] + public void TryCreate_string_invalid_format_returns_failure(string input) + { + // Act + var result = Percentage.TryCreate(input); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Fact] + public void TryCreate_string_uses_custom_fieldName() + { + // Act + var result = Percentage.TryCreate((string?)null, "DiscountRate"); + + // Assert + result.IsFailure.Should().BeTrue(); + var validation = (ValidationError)result.Error; + validation.FieldErrors[0].FieldName.Should().Be("discountRate"); + } + + [Fact] + public void TryCreate_string_delegates_validation_to_decimal_overload() + { + // Act — valid parse but out of range (> 100) + var result = Percentage.TryCreate("150"); + + // Assert + result.IsFailure.Should().BeTrue(); + var validation = (ValidationError)result.Error; + validation.FieldErrors[0].Details[0].Should().Be("Percentage must be between 0 and 100."); + } + + #endregion } \ No newline at end of file diff --git a/Trellis.Results/src/IScalarValue.cs b/Trellis.Results/src/IScalarValue.cs index 1a5cda05..fffe2593 100644 --- a/Trellis.Results/src/IScalarValue.cs +++ b/Trellis.Results/src/IScalarValue.cs @@ -10,7 +10,7 @@ /// /// This interface uses the Curiously Recurring Template Pattern (CRTP) to enable /// static abstract methods on the value object type. This allows model binders and -/// JSON converters to call without reflection. +/// JSON converters to call without reflection. /// /// /// When a type implements this interface, it can be automatically validated during: @@ -60,6 +60,17 @@ public interface IScalarValue /// static abstract Result TryCreate(TPrimitive value, string? fieldName = null); + /// + /// Attempts to create a validated scalar value from a string representation. + /// + /// The raw string value to parse and validate + /// + /// Optional field name for validation error messages. If null, implementations should use + /// a default field name based on the type name. + /// + /// Success with the scalar value, or Failure with validation errors + static abstract Result TryCreate(string? value, string? fieldName = null); + /// /// Creates a validated scalar value from a primitive value. /// Throws an exception if validation fails. @@ -74,11 +85,11 @@ public interface IScalarValue /// than calling Create(...) through a manual TryCreate(...).Value pattern. /// /// - /// ⚠️ Don't use this method with user input or uncertain data - use + /// ⚠️ Don't use this method with user input or uncertain data - use /// instead to handle validation errors gracefully. /// /// - /// The default implementation calls and throws if validation fails. + /// The default implementation calls and throws if validation fails. /// You can override this if you need custom error handling behavior. /// /// From 0e66658717ef9be422dfedfde5fa50cb19c5bb7d Mon Sep 17 00:00:00 2001 From: Xavier John Date: Sat, 28 Mar 2026 16:51:21 -0700 Subject: [PATCH 13/17] Add IFormatProvider parameter to IScalarValue.TryCreate and simplify Parse/TryParse - Add IFormatProvider? parameter to TryCreate(string?, string?) on IScalarValue interface - Update all 12 hand-implemented types (Age, MonetaryAmount, Percentage, CountryCode, CurrencyCode, EmailAddress, Hostname, IpAddress, LanguageCode, PhoneNumber, Slug, Url) - For Age, MonetaryAmount, Percentage: use provider in parsing with fallback to InvariantCulture - For string-based types: add parameter but ignore it (string validation is culture-neutral) - Simplify Parse/TryParse on Age, MonetaryAmount, Percentage to delegate to TryCreate - Update source generator (RequiredPartialClassGenerator) to emit new signature - Update ASP source generator (ScalarValueJsonConverterGenerator) for string/non-string types - Update ~50 test stub types across all test projects - Add TryCreateFormatProviderTests with 16 new culture-aware tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 1 + Trellis.Primitives/src/Primitives/Age.cs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 11013157..03782d5b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -780,3 +780,4 @@ Before committing any changes: 1. **All tests pass** — `dotnet test` from the repository root must report zero failures. 2. **Code review by GPT-5.4** — Use a code-review agent with `model: gpt-5.4` to review all changed files before committing. Address any issues it flags as bugs, security vulnerabilities, or logic errors. +3. **User review** — Present a summary of changes to the user and wait for explicit approval before committing. diff --git a/Trellis.Primitives/src/Primitives/Age.cs b/Trellis.Primitives/src/Primitives/Age.cs index 997c2067..c87f9470 100644 --- a/Trellis.Primitives/src/Primitives/Age.cs +++ b/Trellis.Primitives/src/Primitives/Age.cs @@ -51,7 +51,7 @@ public static Result TryCreate(string? value, string? fieldName = null) if (string.IsNullOrWhiteSpace(value)) return Result.Failure(Error.Validation("Age is required.", field)); - if (!int.TryParse(value, out var parsed)) + if (!int.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var parsed)) return Result.Failure(Error.Validation("Age must be a valid integer.", field)); return TryCreate(parsed, fieldName); From 7ca92ba1c590d29d00436c84e082b91eb67b6886 Mon Sep 17 00:00:00 2001 From: Xavier John Date: Sat, 28 Mar 2026 18:35:41 -0700 Subject: [PATCH 14/17] Add IFormattableScalarValue for culture-aware string parsing New interface extending IScalarValue with TryCreate(string?, IFormatProvider?, string?) for numeric and date VOs where culture affects parsing (decimal separators, date formats). String-based VOs (EmailAddress, etc.) stay on IScalarValue only. Implemented by: Age, MonetaryAmount, Percentage (hand-implemented) + RequiredInt, RequiredDecimal, RequiredLong, RequiredDateTime (source-generated). Source generator fixes: - All TryCreate(string?) overloads now use InvariantCulture consistently (was current-culture, which silently changed parsed values by locale) - DateTime provider overload separates parse failure from MinValue check - Parse/TryParse delegate to TryCreate(string, provider) for formattable types Documentation: copilot instructions, API reference, primitives README, docfx article all updated. 40 new tests including German/French culture parsing for both hand-implemented and source-generated types. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 8 + Trellis.Primitives/README.md | 2 + .../RequiredPartialClassGenerator.cs | 106 +++- Trellis.Primitives/src/Primitives/Age.cs | 53 +- .../src/Primitives/MonetaryAmount.cs | 62 +-- .../src/Primitives/Percentage.cs | 71 +-- Trellis.Primitives/tests/AgeTests.cs | 2 +- .../tests/IFormattableScalarValueTests.cs | 469 ++++++++++++++++++ Trellis.Primitives/tests/PercentageTests.cs | 2 +- .../src/IFormattableScalarValue.cs | 46 ++ docs/docfx_project/articles/primitives.md | 4 + trellis-api-reference.md | 10 + 12 files changed, 744 insertions(+), 91 deletions(-) create mode 100644 Trellis.Primitives/tests/IFormattableScalarValueTests.cs create mode 100644 Trellis.Results/src/IFormattableScalarValue.cs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 03782d5b..73eb0deb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -145,6 +145,14 @@ public static Money Create(decimal amount, string currencyCode) } ``` +### Culture-Aware String Parsing + +Numeric and date value objects implement `IFormattableScalarValue` for culture-sensitive parsing: +- `TryCreate(string?)` — always uses `InvariantCulture` (safe for APIs) +- `TryCreate(string?, IFormatProvider?, string?)` — uses the specified culture (for CSV import, user input with known locale) + +String-based VOs (`EmailAddress`, `Slug`, etc.) only have `TryCreate(string?)` — culture doesn't affect their format. + ## Value Object Category Review Before adding or approving a new value-like type, classify it first: diff --git a/Trellis.Primitives/README.md b/Trellis.Primitives/README.md index 8bb406db..3e29aecd 100644 --- a/Trellis.Primitives/README.md +++ b/Trellis.Primitives/README.md @@ -487,6 +487,8 @@ Primitive value objects wrap single primitive types (`string`, `Guid`, etc.) to - JSON serialization via `ParsableJsonConverter` - OpenTelemetry activity tracing support, typically a better day-to-day diagnostic signal than full ROP tracing because it emits spans at value creation and validation boundaries +**Culture-aware parsing:** Numeric and date types (`Age`, `MonetaryAmount`, `Percentage`, `RequiredInt`, `RequiredDecimal`, `RequiredLong`, `RequiredDateTime`) also implement `IFormattableScalarValue`, adding `TryCreate(string?, IFormatProvider?, string?)` for culture-sensitive parsing. The standard `TryCreate(string?)` always uses `InvariantCulture`. String-based types do not implement this interface — culture doesn't affect their format. + ## Best Practices 1. **Use partial classes** diff --git a/Trellis.Primitives/generator/RequiredPartialClassGenerator.cs b/Trellis.Primitives/generator/RequiredPartialClassGenerator.cs index 674faece..aa094c79 100644 --- a/Trellis.Primitives/generator/RequiredPartialClassGenerator.cs +++ b/Trellis.Primitives/generator/RequiredPartialClassGenerator.cs @@ -321,6 +321,11 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov // Build up the source code // Note: The base class is already declared in the user's partial class. // We only generate the additional members and interfaces. + var isFormattable = g.ClassBase is "RequiredInt" or "RequiredDecimal" or "RequiredLong" or "RequiredDateTime"; + var formattableInterface = isFormattable + ? $", IFormattableScalarValue<{g.ClassName}, {classType}>" + : ""; + var source = $@"// namespace {g.NameSpace}; using System; @@ -332,7 +337,7 @@ namespace {g.NameSpace}; #nullable enable {nestedTypeOpen} [JsonConverter(typeof(ParsableJsonConverter<{g.ClassName}>))] - {g.Accessibility.ToCamelCase()} partial class {g.ClassName} : IScalarValue<{g.ClassName}, {classType}>, IParsable<{g.ClassName}> + {g.Accessibility.ToCamelCase()} partial class {g.ClassName} : IScalarValue<{g.ClassName}, {classType}>{formattableInterface}, IParsable<{g.ClassName}> {{ private {g.ClassName}({classType} value) : base(value) {{ @@ -342,7 +347,7 @@ namespace {g.NameSpace}; public static {g.ClassName} Parse(string s, IFormatProvider? provider) {{ - var r = TryCreate(s, null); + var r = TryCreate(s, {(isFormattable ? "provider" : "null")}); if (r.IsFailure) {{ var val = (ValidationError)r.Error; @@ -353,7 +358,7 @@ namespace {g.NameSpace}; public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out {g.ClassName} result) {{ - var r = TryCreate(s, null); + var r = TryCreate(s, {(isFormattable ? "provider" : "null")}); if (r.IsFailure) {{ result = default; @@ -658,7 +663,7 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov int parsedInt = 0; var validated = stringOrNull .ToResult(Error.Validation(""{g.ClassName.SplitPascalCase()} cannot be empty."", field)) - .Ensure(x => int.TryParse(x, out parsedInt), Error.Validation(""Value must be a valid integer."", field)) + .Ensure(x => int.TryParse(x, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out parsedInt), Error.Validation(""Value must be a valid integer."", field)) .Ensure(_ => parsedInt >= {rangeMin}, Error.Validation(""{g.ClassName.SplitPascalCase()} must be at least {rangeMin}."", field)) .Ensure(_ => parsedInt <= {rangeMax}, Error.Validation(""{g.ClassName.SplitPascalCase()} must be at most {rangeMax}."", field)); if (validated.IsSuccess) @@ -726,7 +731,7 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov int parsedInt = 0; var validated = stringOrNull .ToResult(Error.Validation(""{g.ClassName.SplitPascalCase()} cannot be empty."", field)) - .Ensure(x => int.TryParse(x, out parsedInt), Error.Validation(""Value must be a valid integer."", field)); + .Ensure(x => int.TryParse(x, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out parsedInt), Error.Validation(""Value must be a valid integer."", field)); if (validated.IsSuccess) {{ string? additionalError = null; @@ -738,6 +743,28 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov }}"; } + // IFormattableScalarValue TryCreate overload for culture-sensitive parsing + source += $@" + + /// + /// Attempts to create a validated instance from a string using the specified format provider. + /// Use for culture-sensitive parsing of integer values. + /// + /// The string value to parse. + /// The format provider for culture-sensitive parsing. Defaults to InvariantCulture when null. + /// Optional field name for validation error messages. + /// Success with the value object, or Failure with validation errors. + public static Result<{g.ClassName}> TryCreate(string? value, IFormatProvider? provider, string? fieldName = null) + {{ + using var activity = PrimitiveValueObjectTrace.ActivitySource.StartActivity(""{g.ClassName}.TryCreate""); + var field = fieldName.NormalizeFieldName(""{g.ClassName.ToCamelCase()}""); + if (string.IsNullOrWhiteSpace(value)) + return Error.Validation(""{g.ClassName.SplitPascalCase()} cannot be empty."", field); + if (!int.TryParse(value, System.Globalization.NumberStyles.Integer, provider ?? System.Globalization.CultureInfo.InvariantCulture, out var parsed)) + return Error.Validation(""Value must be a valid integer."", field); + return TryCreate(parsed, fieldName); + }}"; + // Create and Parse are the same regardless of [Range] source += $@" @@ -948,7 +975,7 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov decimal parsedDecimal = 0m; var validated = stringOrNull .ToResult(Error.Validation(""{g.ClassName.SplitPascalCase()} cannot be empty."", field)) - .Ensure(x => decimal.TryParse(x, out parsedDecimal), Error.Validation(""Value must be a valid decimal."", field)); + .Ensure(x => decimal.TryParse(x, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out parsedDecimal), Error.Validation(""Value must be a valid decimal."", field)); if (validated.IsSuccess) {{ string? additionalError = null; @@ -960,6 +987,28 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov }}"; } + // IFormattableScalarValue TryCreate overload for culture-sensitive parsing + source += $@" + + /// + /// Attempts to create a validated instance from a string using the specified format provider. + /// Use for culture-sensitive parsing of decimal values. + /// + /// The string value to parse. + /// The format provider for culture-sensitive parsing. Defaults to InvariantCulture when null. + /// Optional field name for validation error messages. + /// Success with the value object, or Failure with validation errors. + public static Result<{g.ClassName}> TryCreate(string? value, IFormatProvider? provider, string? fieldName = null) + {{ + using var activity = PrimitiveValueObjectTrace.ActivitySource.StartActivity(""{g.ClassName}.TryCreate""); + var field = fieldName.NormalizeFieldName(""{g.ClassName.ToCamelCase()}""); + if (string.IsNullOrWhiteSpace(value)) + return Error.Validation(""{g.ClassName.SplitPascalCase()} cannot be empty."", field); + if (!decimal.TryParse(value, System.Globalization.NumberStyles.Number, provider ?? System.Globalization.CultureInfo.InvariantCulture, out var parsed)) + return Error.Validation(""Value must be a valid decimal."", field); + return TryCreate(parsed, fieldName); + }}"; + // Create and Parse are the same regardless of [Range] source += $@" @@ -1081,7 +1130,7 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov long parsedLong = 0; var validated = stringOrNull .ToResult(Error.Validation(""{g.ClassName.SplitPascalCase()} cannot be empty."", field)) - .Ensure(x => long.TryParse(x, out parsedLong), Error.Validation(""Value must be a valid long."", field)) + .Ensure(x => long.TryParse(x, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out parsedLong), Error.Validation(""Value must be a valid long."", field)) .Ensure(_ => parsedLong >= {rangeLongMin}L, Error.Validation(""{g.ClassName.SplitPascalCase()} must be at least {rangeLongMin}."", field)) .Ensure(_ => parsedLong <= {rangeLongMax}L, Error.Validation(""{g.ClassName.SplitPascalCase()} must be at most {rangeLongMax}."", field)); if (validated.IsSuccess) @@ -1149,7 +1198,7 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov long parsedLong = 0; var validated = stringOrNull .ToResult(Error.Validation(""{g.ClassName.SplitPascalCase()} cannot be empty."", field)) - .Ensure(x => long.TryParse(x, out parsedLong), Error.Validation(""Value must be a valid long."", field)); + .Ensure(x => long.TryParse(x, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out parsedLong), Error.Validation(""Value must be a valid long."", field)); if (validated.IsSuccess) {{ string? additionalError = null; @@ -1161,6 +1210,28 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov }}"; } + // IFormattableScalarValue TryCreate overloadfor culture-sensitive parsing + source += $@" + + /// + /// Attempts to create a validated instance from a string using the specified format provider. + /// Use for culture-sensitive parsing of long values. + /// + /// The string value to parse. + /// The format provider for culture-sensitive parsing. Defaults to InvariantCulture when null. + /// Optional field name for validation error messages. + /// Success with the value object, or Failure with validation errors. + public static Result<{g.ClassName}> TryCreate(string? value, IFormatProvider? provider, string? fieldName = null) + {{ + using var activity = PrimitiveValueObjectTrace.ActivitySource.StartActivity(""{g.ClassName}.TryCreate""); + var field = fieldName.NormalizeFieldName(""{g.ClassName.ToCamelCase()}""); + if (string.IsNullOrWhiteSpace(value)) + return Error.Validation(""{g.ClassName.SplitPascalCase()} cannot be empty."", field); + if (!long.TryParse(value, System.Globalization.NumberStyles.Integer, provider ?? System.Globalization.CultureInfo.InvariantCulture, out var parsed)) + return Error.Validation(""Value must be a valid long."", field); + return TryCreate(parsed, fieldName); + }}"; + // Create and Parse are the same regardless of [Range] source += $@" @@ -1364,6 +1435,25 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov return validated.Map(_ => new {g.ClassName}(parsedDateTime)); }} + /// + /// Attempts to create a validated instance from a string using the specified format provider. + /// Use for culture-sensitive parsing of date/time values. + /// + /// The string value to parse. + /// The format provider for culture-sensitive parsing. Defaults to InvariantCulture when null. + /// Optional field name for validation error messages. + /// Success with the value object, or Failure with validation errors. + public static Result<{g.ClassName}> TryCreate(string? value, IFormatProvider? provider, string? fieldName = null) + {{ + using var activity = PrimitiveValueObjectTrace.ActivitySource.StartActivity(""{g.ClassName}.TryCreate""); + var field = fieldName.NormalizeFieldName(""{g.ClassName.ToCamelCase()}""); + if (string.IsNullOrWhiteSpace(value)) + return Error.Validation(""{g.ClassName.SplitPascalCase()} cannot be empty."", field); + if (!DateTime.TryParse(value, provider ?? System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed)) + return Error.Validation(""Value must be a valid date/time."", field); + return TryCreate(parsed, fieldName); + }} + /// /// Creates a validated instance from a DateTime. Throws if validation fails. /// Use this for known-valid values in tests or with constants. diff --git a/Trellis.Primitives/src/Primitives/Age.cs b/Trellis.Primitives/src/Primitives/Age.cs index c87f9470..309f30a3 100644 --- a/Trellis.Primitives/src/Primitives/Age.cs +++ b/Trellis.Primitives/src/Primitives/Age.cs @@ -19,7 +19,7 @@ /// /// [JsonConverter(typeof(ParsableJsonConverter))] -public class Age : ScalarValueObject, IScalarValue, IParsable +public class Age : ScalarValueObject, IScalarValue, IFormattableScalarValue, IParsable { /// /// Initializes a new instance of the class. @@ -57,21 +57,36 @@ public static Result TryCreate(string? value, string? fieldName = null) return TryCreate(parsed, fieldName); } + /// + /// Attempts to create an from a string using the specified format provider. + /// + /// The string value to parse. + /// The format provider for culture-sensitive parsing. Defaults to when null. + /// Optional field name for validation error messages. + /// Success with the Age if valid; Failure with ValidationError otherwise. + public static Result TryCreate(string? value, IFormatProvider? provider, string? fieldName = null) + { + using var activity = PrimitiveValueObjectTrace.ActivitySource.StartActivity(nameof(Age) + '.' + nameof(TryCreate)); + var field = fieldName.NormalizeFieldName("age"); + + if (string.IsNullOrWhiteSpace(value)) + return Result.Failure(Error.Validation("Age is required.", field)); + + if (!int.TryParse(value, System.Globalization.NumberStyles.Integer, provider ?? System.Globalization.CultureInfo.InvariantCulture, out var parsed)) + return Result.Failure(Error.Validation("Age must be a valid integer.", field)); + + return TryCreate(parsed, fieldName); + } + /// /// Parses an age. /// public static Age Parse(string? s, IFormatProvider? provider) { - if (!int.TryParse(s, out var v)) - throw new FormatException("Value must be a valid integer."); - var r = TryCreate(v); - if (r.IsFailure) - { - var val = (ValidationError)r.Error; - throw new FormatException(val.FieldErrors[0].Details[0]); - } - - return r.Value; + var result = TryCreate(s, provider); + if (result.IsFailure) + throw new FormatException(result.Error.Detail); + return result.Value; } /// @@ -79,14 +94,14 @@ public static Age Parse(string? s, IFormatProvider? provider) /// public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out Age result) { - result = default; - if (!int.TryParse(s, out var v)) - return false; - var r = TryCreate(v); - if (r.IsFailure) - return false; + var r = TryCreate(s, provider); + if (r.IsSuccess) + { + result = r.Value; + return true; + } - result = r.Value; - return true; + result = default!; + return false; } } \ No newline at end of file diff --git a/Trellis.Primitives/src/Primitives/MonetaryAmount.cs b/Trellis.Primitives/src/Primitives/MonetaryAmount.cs index 2a9dd965..7779f146 100644 --- a/Trellis.Primitives/src/Primitives/MonetaryAmount.cs +++ b/Trellis.Primitives/src/Primitives/MonetaryAmount.cs @@ -17,7 +17,7 @@ /// /// [JsonConverter(typeof(ParsableJsonConverter))] -public class MonetaryAmount : ScalarValueObject, IScalarValue, IParsable +public class MonetaryAmount : ScalarValueObject, IScalarValue, IFormattableScalarValue, IParsable { private const int DefaultDecimalPlaces = 2; @@ -82,6 +82,27 @@ public static Result TryCreate(string? value, string? fieldName return TryCreate(parsed, fieldName); } + /// + /// Attempts to create a from a string using the specified format provider. + /// + /// The string value to parse (must be a valid decimal). + /// The format provider for culture-sensitive parsing. Defaults to when null. + /// Optional field name for validation error messages. + /// Success with the MonetaryAmount if valid; Failure with ValidationError otherwise. + public static Result TryCreate(string? value, IFormatProvider? provider, string? fieldName = null) + { + using var activity = PrimitiveValueObjectTrace.ActivitySource.StartActivity(nameof(MonetaryAmount) + '.' + nameof(TryCreate)); + var field = fieldName.NormalizeFieldName("amount"); + + if (string.IsNullOrWhiteSpace(value)) + return Result.Failure(Error.Validation("Amount is required.", field)); + + if (!decimal.TryParse(value, System.Globalization.NumberStyles.Number, provider ?? System.Globalization.CultureInfo.InvariantCulture, out var parsed)) + return Result.Failure(Error.Validation("Amount must be a valid decimal.", field)); + + return TryCreate(parsed, fieldName); + } + /// Adds two monetary amounts. public Result Add(MonetaryAmount other) { @@ -121,39 +142,24 @@ public Result Multiply(decimal multiplier) /// public static MonetaryAmount Parse(string? s, IFormatProvider? provider) { - if (string.IsNullOrWhiteSpace(s)) - throw new FormatException("Value must be a valid decimal."); - - if (!decimal.TryParse(s, System.Globalization.NumberStyles.Number, provider ?? System.Globalization.CultureInfo.InvariantCulture, out var value)) - throw new FormatException("Value must be a valid decimal."); - - var r = TryCreate(value); - if (r.IsFailure) - { - var val = (ValidationError)r.Error; - throw new FormatException(val.FieldErrors[0].Details[0]); - } - - return r.Value; + var result = TryCreate(s, provider); + if (result.IsFailure) + throw new FormatException(result.Error.Detail); + return result.Value; } /// public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out MonetaryAmount result) { - result = default; - - if (string.IsNullOrWhiteSpace(s)) - return false; - - if (!decimal.TryParse(s, System.Globalization.NumberStyles.Number, provider ?? System.Globalization.CultureInfo.InvariantCulture, out var value)) - return false; - - var r = TryCreate(value); - if (r.IsFailure) - return false; + var r = TryCreate(s, provider); + if (r.IsSuccess) + { + result = r.Value; + return true; + } - result = r.Value; - return true; + result = default!; + return false; } /// Explicitly converts a decimal to a . diff --git a/Trellis.Primitives/src/Primitives/Percentage.cs b/Trellis.Primitives/src/Primitives/Percentage.cs index c4f460ba..9a1cdd00 100644 --- a/Trellis.Primitives/src/Primitives/Percentage.cs +++ b/Trellis.Primitives/src/Primitives/Percentage.cs @@ -66,7 +66,7 @@ /// /// [JsonConverter(typeof(ParsableJsonConverter))] -public class Percentage : ScalarValueObject, IScalarValue, IParsable +public class Percentage : ScalarValueObject, IScalarValue, IFormattableScalarValue, IParsable { private Percentage(decimal value) : base(value) { } @@ -149,6 +149,30 @@ public static Result TryCreate(string? value, string? fieldName = nu return TryCreate(parsed, fieldName); } + /// + /// Attempts to create a from a string using the specified format provider. + /// Strips a trailing % suffix if present before parsing. + /// + /// The string value to parse (must be a valid decimal, optionally with a trailing %). + /// The format provider for culture-sensitive parsing. Defaults to when null. + /// Optional field name for validation error messages. + /// Success with the Percentage if valid; Failure with ValidationError otherwise. + public static Result TryCreate(string? value, IFormatProvider? provider, string? fieldName = null) + { + using var activity = PrimitiveValueObjectTrace.ActivitySource.StartActivity(nameof(Percentage) + '.' + nameof(TryCreate)); + var field = fieldName.NormalizeFieldName("percentage"); + + if (string.IsNullOrWhiteSpace(value)) + return Result.Failure(Error.Validation("Percentage is required.", field)); + + var trimmed = value.TrimEnd('%', ' '); + + if (!decimal.TryParse(trimmed, System.Globalization.NumberStyles.Number, provider ?? System.Globalization.CultureInfo.InvariantCulture, out var parsed)) + return Result.Failure(Error.Validation("Percentage must be a valid decimal.", field)); + + return TryCreate(parsed, fieldName); + } + /// /// Creates a from a fraction (0.0 to 1.0). /// @@ -187,23 +211,10 @@ public static Result FromFraction(decimal fraction, string? fieldNam /// public static Percentage Parse(string? s, IFormatProvider? provider) { - if (string.IsNullOrWhiteSpace(s)) - throw new FormatException("Value must be a valid decimal."); - - // Handle % suffix - var trimmed = s.TrimEnd('%', ' '); - - if (!decimal.TryParse(trimmed, provider, out var value)) - throw new FormatException("Value must be a valid decimal."); - - var r = TryCreate(value); - if (r.IsFailure) - { - var val = (ValidationError)r.Error; - throw new FormatException(val.FieldErrors[0].Details[0]); - } - - return r.Value; + var result = TryCreate(s, provider); + if (result.IsFailure) + throw new FormatException(result.Error.Detail); + return result.Value; } /// @@ -211,23 +222,15 @@ public static Percentage Parse(string? s, IFormatProvider? provider) /// public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out Percentage result) { - result = default; - - if (string.IsNullOrWhiteSpace(s)) - return false; - - // Handle % suffix - var trimmed = s.TrimEnd('%', ' '); - - if (!decimal.TryParse(trimmed, provider, out var value)) - return false; - - var r = TryCreate(value); - if (r.IsFailure) - return false; + var r = TryCreate(s, provider); + if (r.IsSuccess) + { + result = r.Value; + return true; + } - result = r.Value; - return true; + result = default!; + return false; } /// diff --git a/Trellis.Primitives/tests/AgeTests.cs b/Trellis.Primitives/tests/AgeTests.cs index c2acac3c..8e030df8 100644 --- a/Trellis.Primitives/tests/AgeTests.cs +++ b/Trellis.Primitives/tests/AgeTests.cs @@ -190,7 +190,7 @@ public void Cannot_parse_non_integer_string() // Assert act.Should().Throw() - .WithMessage("Value must be a valid integer."); + .WithMessage("Age must be a valid integer."); } [Fact] diff --git a/Trellis.Primitives/tests/IFormattableScalarValueTests.cs b/Trellis.Primitives/tests/IFormattableScalarValueTests.cs new file mode 100644 index 00000000..ee61ca59 --- /dev/null +++ b/Trellis.Primitives/tests/IFormattableScalarValueTests.cs @@ -0,0 +1,469 @@ +namespace Trellis.Primitives.Tests; + +using System.Globalization; + +/// +/// Tests for interface +/// and its implementations on Age, MonetaryAmount, and Percentage. +/// +public class IFormattableScalarValueTests +{ + #region Interface implementation verification + + [Fact] + public void Age_implements_IFormattableScalarValue() => + typeof(Age).GetInterfaces().Should().Contain(typeof(IFormattableScalarValue)); + + [Fact] + public void MonetaryAmount_implements_IFormattableScalarValue() => + typeof(MonetaryAmount).GetInterfaces().Should().Contain(typeof(IFormattableScalarValue)); + + [Fact] + public void Percentage_implements_IFormattableScalarValue() => + typeof(Percentage).GetInterfaces().Should().Contain(typeof(IFormattableScalarValue)); + + #endregion + + #region MonetaryAmount — culture-sensitive parsing + + [Fact] + public void MonetaryAmount_TryCreate_with_german_culture_parses_comma_decimal() + { + // Arrange — German uses comma for decimals and period for thousands + var german = new CultureInfo("de-DE"); + + // Act + var result = MonetaryAmount.TryCreate("1.234,56", german); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(1234.56m); + } + + [Fact] + public void MonetaryAmount_TryCreate_with_null_provider_defaults_to_InvariantCulture() + { + // Act + var result = MonetaryAmount.TryCreate("1234.56", null); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(1234.56m); + } + + [Fact] + public void MonetaryAmount_TryCreate_with_provider_negative_returns_failure() + { + // Act + var result = MonetaryAmount.TryCreate("-100", CultureInfo.InvariantCulture); + + // Assert + result.IsFailure.Should().BeTrue(); + } + + [Fact] + public void MonetaryAmount_TryCreate_with_provider_null_string_returns_failure() + { + // Act + var result = MonetaryAmount.TryCreate((string?)null, CultureInfo.InvariantCulture); + + // Assert + result.IsFailure.Should().BeTrue(); + } + + [Fact] + public void MonetaryAmount_TryCreate_with_provider_invalid_string_returns_failure() + { + // Act + var result = MonetaryAmount.TryCreate("not-a-number", CultureInfo.InvariantCulture); + + // Assert + result.IsFailure.Should().BeTrue(); + } + + [Fact] + public void MonetaryAmount_TryCreate_with_provider_uses_custom_fieldName() + { + // Act + var result = MonetaryAmount.TryCreate((string?)null, CultureInfo.InvariantCulture, "Price"); + + // Assert + result.IsFailure.Should().BeTrue(); + var validation = (ValidationError)result.Error; + validation.FieldErrors[0].FieldName.Should().Be("price"); + } + + [Fact] + public void MonetaryAmount_TryCreate_with_french_culture_parses_space_thousands() + { + // Arrange — French uses space for thousands and comma for decimals + var french = new CultureInfo("fr-FR"); + + // Act + var result = MonetaryAmount.TryCreate("1 234,56", french); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(1234.56m); + } + + #endregion + + #region Age — culture-sensitive parsing + + [Fact] + public void Age_TryCreate_with_provider_valid_returns_success() + { + // Act + var result = Age.TryCreate("25", CultureInfo.InvariantCulture); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(25); + } + + [Fact] + public void Age_TryCreate_with_null_provider_defaults_to_InvariantCulture() + { + // Act + var result = Age.TryCreate("25", null); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(25); + } + + [Fact] + public void Age_TryCreate_with_provider_null_string_returns_failure() + { + // Act + var result = Age.TryCreate((string?)null, (IFormatProvider?)null); + + // Assert + result.IsFailure.Should().BeTrue(); + } + + [Fact] + public void Age_TryCreate_with_provider_invalid_returns_failure() + { + // Act + var result = Age.TryCreate("abc", CultureInfo.InvariantCulture); + + // Assert + result.IsFailure.Should().BeTrue(); + } + + [Fact] + public void Age_TryCreate_with_provider_out_of_range_returns_failure() + { + // Act + var result = Age.TryCreate("200", CultureInfo.InvariantCulture); + + // Assert + result.IsFailure.Should().BeTrue(); + } + + #endregion + + #region Percentage — culture-sensitive parsing + + [Fact] + public void Percentage_TryCreate_with_german_culture_parses_comma_decimal() + { + // Arrange + var german = new CultureInfo("de-DE"); + + // Act + var result = Percentage.TryCreate("50,5", german); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(50.5m); + } + + [Fact] + public void Percentage_TryCreate_with_null_provider_defaults_to_InvariantCulture() + { + // Act + var result = Percentage.TryCreate("50.5", null); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(50.5m); + } + + [Fact] + public void Percentage_TryCreate_with_provider_strips_percent_suffix() + { + // Act + var result = Percentage.TryCreate("50.5%", CultureInfo.InvariantCulture); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(50.5m); + } + + [Fact] + public void Percentage_TryCreate_with_provider_out_of_range_returns_failure() + { + // Act + var result = Percentage.TryCreate("150", CultureInfo.InvariantCulture); + + // Assert + result.IsFailure.Should().BeTrue(); + } + + [Fact] + public void Percentage_TryCreate_with_provider_null_string_returns_failure() + { + // Act + var result = Percentage.TryCreate((string?)null, (IFormatProvider?)null); + + // Assert + result.IsFailure.Should().BeTrue(); + } + + #endregion + + #region Source-generated RequiredDecimal — culture-sensitive parsing (UnitPrice) + + [Fact] + public void UnitPrice_implements_IFormattableScalarValue() => + typeof(UnitPrice).GetInterfaces().Should().Contain(typeof(IFormattableScalarValue)); + + [Fact] + public void UnitPrice_TryCreate_string_no_provider_uses_InvariantCulture() + { + // Act — TryCreate(string?) uses InvariantCulture internally + var result = UnitPrice.TryCreate("29.99"); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(29.99m); + } + + [Fact] + public void UnitPrice_TryCreate_string_comma_decimal_with_InvariantCulture_treats_comma_as_thousands() + { + // Act — InvariantCulture treats comma as the thousands separator (not decimal), + // so "29,99" is silently parsed as 2999. This is exactly why the culture-aware + // TryCreate(string?, IFormatProvider?) overload exists. + var result = UnitPrice.TryCreate("29,99"); + + // Assert — InvariantCulture misinterprets "29,99" as 2999 + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(2999m); + } + + [Fact] + public void UnitPrice_TryCreate_with_german_culture_parses_comma_decimal() + { + // Arrange — German uses comma for decimals + var german = new CultureInfo("de-DE"); + + // Act + var result = UnitPrice.TryCreate("29,99", german); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(29.99m); + } + + [Fact] + public void UnitPrice_TryCreate_with_german_culture_parses_thousands_and_comma_decimal() + { + // Arrange — German uses period for thousands, comma for decimals + var german = new CultureInfo("de-DE"); + + // Act + var result = UnitPrice.TryCreate("1.234,56", german); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(1234.56m); + } + + #endregion + + #region Source-generated RequiredInt — culture-sensitive parsing (TicketNumber) + + [Fact] + public void TicketNumber_implements_IFormattableScalarValue() => + typeof(TicketNumber).GetInterfaces().Should().Contain(typeof(IFormattableScalarValue)); + + [Fact] + public void TicketNumber_TryCreate_string_no_provider_uses_InvariantCulture() + { + // Act — TryCreate(string?) uses InvariantCulture internally + var result = TicketNumber.TryCreate("42"); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(42); + } + + [Fact] + public void TicketNumber_TryCreate_with_german_culture_rejects_thousands_separator() + { + // Arrange — German uses period for thousands (e.g. "1.000" = 1000) + var german = new CultureInfo("de-DE"); + + // Act — NumberStyles.Integer does not include AllowThousands, + // so "1.000" is rejected even with a culture that uses period as group separator. + var result = TicketNumber.TryCreate("1.000", german); + + // Assert + result.IsFailure.Should().BeTrue(); + } + + #endregion + + #region Source-generated RequiredLong — culture-sensitive parsing (TraceId) + + [Fact] + public void TraceId_implements_IFormattableScalarValue() => + typeof(TraceId).GetInterfaces().Should().Contain(typeof(IFormattableScalarValue)); + + [Fact] + public void TraceId_TryCreate_string_no_provider_uses_InvariantCulture() + { + // Act — TryCreate(string?) uses InvariantCulture internally + var result = TraceId.TryCreate("12345"); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(12345L); + } + + [Fact] + public void TraceId_TryCreate_with_german_culture_rejects_thousands_separator() + { + // Arrange — German uses period for thousands (e.g. "12.345" = 12345) + var german = new CultureInfo("de-DE"); + + // Act — NumberStyles.Integer does not include AllowThousands, + // so "12.345" is rejected even with a culture that uses period as group separator. + var result = TraceId.TryCreate("12.345", german); + + // Assert + result.IsFailure.Should().BeTrue(); + } + + #endregion + + #region Source-generated RequiredDateTime — culture-sensitive parsing (OrderDate) + + [Fact] + public void OrderDate_implements_IFormattableScalarValue() => + typeof(OrderDate).GetInterfaces().Should().Contain(typeof(IFormattableScalarValue)); + + [Fact] + public void OrderDate_TryCreate_string_no_provider_uses_InvariantCulture() + { + // Act — TryCreate(string?) uses InvariantCulture internally + var result = OrderDate.TryCreate("2026-03-28T12:00:00Z"); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Year.Should().Be(2026); + result.Value.Value.Month.Should().Be(3); + result.Value.Value.Day.Should().Be(28); + } + + [Fact] + public void OrderDate_TryCreate_with_german_culture_parses_german_date_format() + { + // Arrange — German date format is DD.MM.YYYY + var german = new CultureInfo("de-DE"); + + // Act + var result = OrderDate.TryCreate("28.03.2026", german); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Year.Should().Be(2026); + result.Value.Value.Month.Should().Be(3); + result.Value.Value.Day.Should().Be(28); + } + + #endregion + + #region Parse/TryParse delegate to TryCreate + + [Fact] + public void MonetaryAmount_Parse_delegates_to_formattable_TryCreate() + { + // Arrange + var german = new CultureInfo("de-DE"); + + // Act + var result = MonetaryAmount.Parse("1.234,56", german); + + // Assert + result.Value.Should().Be(1234.56m); + } + + [Fact] + public void MonetaryAmount_TryParse_delegates_to_formattable_TryCreate() + { + // Arrange + var german = new CultureInfo("de-DE"); + + // Act + var success = MonetaryAmount.TryParse("1.234,56", german, out var result); + + // Assert + success.Should().BeTrue(); + result!.Value.Should().Be(1234.56m); + } + + [Fact] + public void Age_Parse_delegates_to_formattable_TryCreate() + { + // Act + var result = Age.Parse("25", CultureInfo.InvariantCulture); + + // Assert + result.Value.Should().Be(25); + } + + [Fact] + public void Age_TryParse_delegates_to_formattable_TryCreate() + { + // Act + var success = Age.TryParse("25", CultureInfo.InvariantCulture, out var result); + + // Assert + success.Should().BeTrue(); + result!.Value.Should().Be(25); + } + + [Fact] + public void Percentage_Parse_with_german_culture() + { + // Arrange + var german = new CultureInfo("de-DE"); + + // Act + var result = Percentage.Parse("50,5", german); + + // Assert + result.Value.Should().Be(50.5m); + } + + [Fact] + public void Percentage_TryParse_with_german_culture() + { + // Arrange + var german = new CultureInfo("de-DE"); + + // Act + var success = Percentage.TryParse("50,5", german, out var result); + + // Assert + success.Should().BeTrue(); + result!.Value.Should().Be(50.5m); + } + + #endregion +} diff --git a/Trellis.Primitives/tests/PercentageTests.cs b/Trellis.Primitives/tests/PercentageTests.cs index b0a2c1c7..8ed4e269 100644 --- a/Trellis.Primitives/tests/PercentageTests.cs +++ b/Trellis.Primitives/tests/PercentageTests.cs @@ -293,7 +293,7 @@ public void Cannot_parse_invalid_format_string(string input) // Assert act.Should().Throw() - .WithMessage("Value must be a valid decimal."); + .WithMessage("Percentage must be a valid decimal."); } [Fact] diff --git a/Trellis.Results/src/IFormattableScalarValue.cs b/Trellis.Results/src/IFormattableScalarValue.cs new file mode 100644 index 00000000..0cfc45ad --- /dev/null +++ b/Trellis.Results/src/IFormattableScalarValue.cs @@ -0,0 +1,46 @@ +namespace Trellis; + +/// +/// Extended interface for scalar value objects that support culture-sensitive string parsing. +/// Use for numeric and date types where matters for parsing. +/// +/// The value object type itself (CRTP pattern) +/// The underlying primitive type (must be IComparable) +/// +/// +/// This interface extends to add an overload +/// of TryCreate that accepts an for culture-sensitive parsing. +/// +/// +/// Implemented by types whose underlying primitive requires locale-aware parsing: +/// +/// Integer types (e.g., Age) — thousand separators vary by culture +/// Decimal types (e.g., MonetaryAmount, Percentage) — decimal separators vary by culture +/// DateTime types — date formats vary by culture +/// +/// +/// +/// NOT implemented by string-based types (EmailAddress, PhoneNumber, etc.) +/// where is irrelevant for parsing. +/// +/// +public interface IFormattableScalarValue : IScalarValue + where TSelf : IFormattableScalarValue + where TPrimitive : IComparable +{ + /// + /// Attempts to create a validated scalar value from a string using the specified format provider. + /// Use for culture-sensitive parsing of numeric and date values. + /// + /// The raw string value to parse and validate. + /// + /// The format provider for culture-sensitive parsing. + /// When null, implementations should default to . + /// + /// + /// Optional field name for validation error messages. If null, implementations should use + /// a default field name based on the type name. + /// + /// Success with the scalar value, or Failure with validation errors. + static abstract Result TryCreate(string? value, IFormatProvider? provider, string? fieldName = null); +} diff --git a/docs/docfx_project/articles/primitives.md b/docs/docfx_project/articles/primitives.md index f0b206bb..22291967 100644 --- a/docs/docfx_project/articles/primitives.md +++ b/docs/docfx_project/articles/primitives.md @@ -169,6 +169,10 @@ var total = price.Add(tax); var shares = total.Allocate(3); ``` +## Culture-Aware String Parsing + +Numeric and date value objects (`Age`, `MonetaryAmount`, `Percentage`, `RequiredInt`, `RequiredDecimal`, `RequiredLong`, `RequiredDateTime`) implement `IFormattableScalarValue`, which adds `TryCreate(string?, IFormatProvider?, string?)` for culture-sensitive parsing. The standard `TryCreate(string?)` always uses `InvariantCulture` — safe for APIs. Use the `IFormatProvider` overload when importing CSV data or handling user input with a known locale. String-based types (`EmailAddress`, `Slug`, etc.) are not affected by culture and do not implement this interface. + ## EF Core LINQ Support Trellis value objects work seamlessly in EF Core LINQ queries. In most cases you do **not** need `.Value`: diff --git a/trellis-api-reference.md b/trellis-api-reference.md index 08af0216..c3029661 100644 --- a/trellis-api-reference.md +++ b/trellis-api-reference.md @@ -776,6 +776,16 @@ static virtual TSelf Create(TPrimitive value) // default: TryCreate + throw TPrimitive Value { get; } ``` +## IFormattableScalarValue\ (interface) + +Extends `IScalarValue` with culture-aware string parsing. Implemented by numeric and date value objects where culture affects string parsing (decimal separators, date formats). + +```csharp +static abstract Result TryCreate(string? value, IFormatProvider? provider, string? fieldName = null); +``` + +Implementors: `Age`, `MonetaryAmount`, `Percentage` (hand-implemented), `RequiredInt`, `RequiredDecimal`, `RequiredLong`, `RequiredDateTime` (source-generated). Not implemented by string-based types (`EmailAddress`, `Slug`, etc.) — culture doesn't affect their parsing. + ## Specification\ (abstract class) Composable business rules that produce `Expression>`. From 1162f65ce6335b93f88350295f68d3a4191dadcb Mon Sep 17 00:00:00 2001 From: Xavier John Date: Sat, 28 Mar 2026 20:23:36 -0700 Subject: [PATCH 15/17] Exclude source generators from code coverage Source generators run at compile time, not runtime. Coverage tools cannot measure them. Added [ExcludeFromCodeCoverage] to all 3 generator classes: RequiredPartialClassGenerator, MaybePartialPropertyGenerator, ScalarValueJsonConverterGenerator. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Trellis.Asp/generator/ScalarValueJsonConverterGenerator.cs | 1 + .../generator/MaybePartialPropertyGenerator.cs | 1 + Trellis.Primitives/generator/RequiredPartialClassGenerator.cs | 1 + 3 files changed, 3 insertions(+) diff --git a/Trellis.Asp/generator/ScalarValueJsonConverterGenerator.cs b/Trellis.Asp/generator/ScalarValueJsonConverterGenerator.cs index 684dbfa8..8355c526 100644 --- a/Trellis.Asp/generator/ScalarValueJsonConverterGenerator.cs +++ b/Trellis.Asp/generator/ScalarValueJsonConverterGenerator.cs @@ -50,6 +50,7 @@ /// /// [Generator(LanguageNames.CSharp)] +[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public class ScalarValueJsonConverterGenerator : IIncrementalGenerator { private const string GenerateAttributeName = "GenerateScalarValueConvertersAttribute"; diff --git a/Trellis.EntityFrameworkCore/generator/MaybePartialPropertyGenerator.cs b/Trellis.EntityFrameworkCore/generator/MaybePartialPropertyGenerator.cs index fa08b8e5..4d6210f7 100644 --- a/Trellis.EntityFrameworkCore/generator/MaybePartialPropertyGenerator.cs +++ b/Trellis.EntityFrameworkCore/generator/MaybePartialPropertyGenerator.cs @@ -40,6 +40,7 @@ /// /// [Generator(LanguageNames.CSharp)] +[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public sealed class MaybePartialPropertyGenerator : IIncrementalGenerator { /// diff --git a/Trellis.Primitives/generator/RequiredPartialClassGenerator.cs b/Trellis.Primitives/generator/RequiredPartialClassGenerator.cs index aa094c79..b3568afb 100644 --- a/Trellis.Primitives/generator/RequiredPartialClassGenerator.cs +++ b/Trellis.Primitives/generator/RequiredPartialClassGenerator.cs @@ -126,6 +126,7 @@ /// /// [Generator(LanguageNames.CSharp)] +[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public class RequiredPartialClassGenerator : IIncrementalGenerator { /// From 305b0410b11aa6c67befb592d3687065da940614 Mon Sep 17 00:00:00 2001 From: Xavier John Date: Sat, 28 Mar 2026 21:44:07 -0700 Subject: [PATCH 16/17] Fix formatting and add missing newlines in various test and source files --- Trellis.Asp/tests/MaybeModelBinderTests.cs | 4 +-- .../MaybeScalarValueJsonConverterTests.cs | 4 +-- Trellis.Asp/tests/ModelBindingTests.cs | 4 +-- .../NullAndMissingPropertyValidationTests.cs | 2 +- ...alarValueModelBinderPrimitiveTypesTests.cs | 32 +++++++++---------- ...idatingJsonConverterPrimitiveTypesTests.cs | 14 ++++---- .../tests/MaybeMoneyTests.cs | 2 +- .../RequiredPartialClassGenerator.cs | 2 +- .../src/Primitives/MonetaryAmount.cs | 2 +- .../tests/IFormattableScalarValueTests.cs | 2 +- .../tests/MonetaryAmountTests.cs | 2 +- .../src/IFormattableScalarValue.cs | 2 +- .../ServiceCollectionDbProviderExtensions.cs | 2 +- ...viceCollectionDbProviderExtensionsTests.cs | 2 +- 14 files changed, 38 insertions(+), 38 deletions(-) diff --git a/Trellis.Asp/tests/MaybeModelBinderTests.cs b/Trellis.Asp/tests/MaybeModelBinderTests.cs index 798f7abe..2ef7f567 100644 --- a/Trellis.Asp/tests/MaybeModelBinderTests.cs +++ b/Trellis.Asp/tests/MaybeModelBinderTests.cs @@ -35,7 +35,7 @@ public static Result TryCreate(string? value, string? fieldName = null) throw new NotImplementedException(); } - public class ProductCode: ScalarValueObject, IScalarValue + public class ProductCode : ScalarValueObject, IScalarValue { private ProductCode(string value) : base(value) { } @@ -68,7 +68,7 @@ public static Result TryCreate(string? value, string? fieldName = null throw new NotImplementedException(); } - public class Price: ScalarValueObject, IScalarValue + public class Price : ScalarValueObject, IScalarValue { private Price(decimal value) : base(value) { } diff --git a/Trellis.Asp/tests/MaybeScalarValueJsonConverterTests.cs b/Trellis.Asp/tests/MaybeScalarValueJsonConverterTests.cs index 3a6a4d47..2c8fd53e 100644 --- a/Trellis.Asp/tests/MaybeScalarValueJsonConverterTests.cs +++ b/Trellis.Asp/tests/MaybeScalarValueJsonConverterTests.cs @@ -50,7 +50,7 @@ public static Result TryCreate(string? value, string? fieldName = null) => throw new NotImplementedException(); } - public class Percentage: ScalarValueObject, IScalarValue + public class Percentage : ScalarValueObject, IScalarValue { private Percentage(decimal value) : base(value) { } @@ -68,7 +68,7 @@ public static Result TryCreate(string? value, string? fieldName = nu throw new NotImplementedException(); } - public class ItemId: ScalarValueObject, IScalarValue + public class ItemId : ScalarValueObject, IScalarValue { private ItemId(Guid value) : base(value) { } diff --git a/Trellis.Asp/tests/ModelBindingTests.cs b/Trellis.Asp/tests/ModelBindingTests.cs index 3fafb11a..a49420af 100644 --- a/Trellis.Asp/tests/ModelBindingTests.cs +++ b/Trellis.Asp/tests/ModelBindingTests.cs @@ -33,7 +33,7 @@ public static Result TryCreate(string? value, string? fieldName = null) throw new NotImplementedException(); } - public class ProductCode: ScalarValueObject, IScalarValue + public class ProductCode : ScalarValueObject, IScalarValue { private ProductCode(string value) : base(value) { } @@ -66,7 +66,7 @@ public static Result TryCreate(string? value, string? fieldName = null throw new NotImplementedException(); } - public class Price: ScalarValueObject, IScalarValue + public class Price : ScalarValueObject, IScalarValue { private Price(decimal value) : base(value) { } diff --git a/Trellis.Asp/tests/NullAndMissingPropertyValidationTests.cs b/Trellis.Asp/tests/NullAndMissingPropertyValidationTests.cs index f6e31aa9..93944697 100644 --- a/Trellis.Asp/tests/NullAndMissingPropertyValidationTests.cs +++ b/Trellis.Asp/tests/NullAndMissingPropertyValidationTests.cs @@ -521,4 +521,4 @@ private static void ModifyTypeInfo(JsonTypeInfo typeInfo) } #endregion -} +} \ No newline at end of file diff --git a/Trellis.Asp/tests/ScalarValueModelBinderPrimitiveTypesTests.cs b/Trellis.Asp/tests/ScalarValueModelBinderPrimitiveTypesTests.cs index cc053441..0906a755 100644 --- a/Trellis.Asp/tests/ScalarValueModelBinderPrimitiveTypesTests.cs +++ b/Trellis.Asp/tests/ScalarValueModelBinderPrimitiveTypesTests.cs @@ -37,7 +37,7 @@ public static Result TryCreate(string? value, string? fieldName = null) throw new NotImplementedException(); } - public sealed class NonNegativeIntVO: ScalarValueObject, IScalarValue + public sealed class NonNegativeIntVO : ScalarValueObject, IScalarValue { private NonNegativeIntVO(int value) : base(value) { } public static Result TryCreate(int value, string? fieldName = null) => @@ -59,7 +59,7 @@ public static Result TryCreate(string? value, string? fieldName = null) throw new NotImplementedException(); } - public sealed class DecimalVO: ScalarValueObject, IScalarValue + public sealed class DecimalVO : ScalarValueObject, IScalarValue { private DecimalVO(decimal value) : base(value) { } public static Result TryCreate(decimal value, string? fieldName = null) => @@ -70,7 +70,7 @@ public static Result TryCreate(string? value, string? fieldName = nul throw new NotImplementedException(); } - public sealed class DoubleVO: ScalarValueObject, IScalarValue + public sealed class DoubleVO : ScalarValueObject, IScalarValue { private DoubleVO(double value) : base(value) { } public static Result TryCreate(double value, string? fieldName = null) => @@ -81,7 +81,7 @@ public static Result TryCreate(string? value, string? fieldName = null throw new NotImplementedException(); } - public sealed class BoolVO: ScalarValueObject, IScalarValue + public sealed class BoolVO : ScalarValueObject, IScalarValue { private BoolVO(bool value) : base(value) { } public static Result TryCreate(bool value, string? fieldName = null) => @@ -90,7 +90,7 @@ public static Result TryCreate(string? value, string? fieldName = null) throw new NotImplementedException(); } - public sealed class DateTimeVO: ScalarValueObject, IScalarValue + public sealed class DateTimeVO : ScalarValueObject, IScalarValue { private DateTimeVO(DateTime value) : base(value) { } public static Result TryCreate(DateTime value, string? fieldName = null) => @@ -101,7 +101,7 @@ public static Result TryCreate(string? value, string? fieldName = nu throw new NotImplementedException(); } - public sealed class DateOnlyVO: ScalarValueObject, IScalarValue + public sealed class DateOnlyVO : ScalarValueObject, IScalarValue { private DateOnlyVO(DateOnly value) : base(value) { } public static Result TryCreate(DateOnly value, string? fieldName = null) => @@ -112,7 +112,7 @@ public static Result TryCreate(string? value, string? fieldName = nu throw new NotImplementedException(); } - public sealed class TimeOnlyVO: ScalarValueObject, IScalarValue + public sealed class TimeOnlyVO : ScalarValueObject, IScalarValue { private TimeOnlyVO(TimeOnly value) : base(value) { } public static Result TryCreate(TimeOnly value, string? fieldName = null) => @@ -121,7 +121,7 @@ public static Result TryCreate(string? value, string? fieldName = nu throw new NotImplementedException(); } - public sealed class TimeSpanVO: ScalarValueObject, IScalarValue + public sealed class TimeSpanVO : ScalarValueObject, IScalarValue { private TimeSpanVO(TimeSpan value) : base(value) { } public static Result TryCreate(TimeSpan value, string? fieldName = null) => @@ -132,7 +132,7 @@ public static Result TryCreate(string? value, string? fieldName = nu throw new NotImplementedException(); } - public sealed class DateTimeOffsetVO: ScalarValueObject, IScalarValue + public sealed class DateTimeOffsetVO : ScalarValueObject, IScalarValue { private DateTimeOffsetVO(DateTimeOffset value) : base(value) { } public static Result TryCreate(DateTimeOffset value, string? fieldName = null) => @@ -143,7 +143,7 @@ public static Result TryCreate(string? value, string? fieldNam throw new NotImplementedException(); } - public sealed class ShortVO: ScalarValueObject, IScalarValue + public sealed class ShortVO : ScalarValueObject, IScalarValue { private ShortVO(short value) : base(value) { } public static Result TryCreate(short value, string? fieldName = null) => @@ -154,7 +154,7 @@ public static Result TryCreate(string? value, string? fieldName = null) throw new NotImplementedException(); } - public sealed class ByteVO: ScalarValueObject, IScalarValue + public sealed class ByteVO : ScalarValueObject, IScalarValue { private ByteVO(byte value) : base(value) { } public static Result TryCreate(byte value, string? fieldName = null) => @@ -163,7 +163,7 @@ public static Result TryCreate(string? value, string? fieldName = null) throw new NotImplementedException(); } - public sealed class SByteVO: ScalarValueObject, IScalarValue + public sealed class SByteVO : ScalarValueObject, IScalarValue { private SByteVO(sbyte value) : base(value) { } public static Result TryCreate(sbyte value, string? fieldName = null) => @@ -174,7 +174,7 @@ public static Result TryCreate(string? value, string? fieldName = null) throw new NotImplementedException(); } - public sealed class UShortVO: ScalarValueObject, IScalarValue + public sealed class UShortVO : ScalarValueObject, IScalarValue { private UShortVO(ushort value) : base(value) { } public static Result TryCreate(ushort value, string? fieldName = null) => @@ -183,7 +183,7 @@ public static Result TryCreate(string? value, string? fieldName = null throw new NotImplementedException(); } - public sealed class UIntVO: ScalarValueObject, IScalarValue + public sealed class UIntVO : ScalarValueObject, IScalarValue { private UIntVO(uint value) : base(value) { } public static Result TryCreate(uint value, string? fieldName = null) => @@ -192,7 +192,7 @@ public static Result TryCreate(string? value, string? fieldName = null) throw new NotImplementedException(); } - public sealed class ULongVO: ScalarValueObject, IScalarValue + public sealed class ULongVO : ScalarValueObject, IScalarValue { private ULongVO(ulong value) : base(value) { } public static Result TryCreate(ulong value, string? fieldName = null) => @@ -201,7 +201,7 @@ public static Result TryCreate(string? value, string? fieldName = null) throw new NotImplementedException(); } - public sealed class FloatVO: ScalarValueObject, IScalarValue + public sealed class FloatVO : ScalarValueObject, IScalarValue { private FloatVO(float value) : base(value) { } public static Result TryCreate(float value, string? fieldName = null) => diff --git a/Trellis.Asp/tests/ValidatingJsonConverterPrimitiveTypesTests.cs b/Trellis.Asp/tests/ValidatingJsonConverterPrimitiveTypesTests.cs index be17c459..2c0d4fc6 100644 --- a/Trellis.Asp/tests/ValidatingJsonConverterPrimitiveTypesTests.cs +++ b/Trellis.Asp/tests/ValidatingJsonConverterPrimitiveTypesTests.cs @@ -134,7 +134,7 @@ public static Result TryCreate(string? value, string? fieldName = nu throw new NotImplementedException(); } - public class TimeSpanVO: ScalarValueObject, IScalarValue + public class TimeSpanVO : ScalarValueObject, IScalarValue { private TimeSpanVO(TimeSpan value) : base(value) { } public static Result TryCreate(TimeSpan value, string? fieldName = null) => @@ -143,7 +143,7 @@ public static Result TryCreate(string? value, string? fieldName = nu throw new NotImplementedException(); } - public class ShortVO: ScalarValueObject, IScalarValue + public class ShortVO : ScalarValueObject, IScalarValue { private ShortVO(short value) : base(value) { } public static Result TryCreate(short value, string? fieldName = null) => @@ -152,7 +152,7 @@ public static Result TryCreate(string? value, string? fieldName = null) throw new NotImplementedException(); } - public class ByteVO: ScalarValueObject, IScalarValue + public class ByteVO : ScalarValueObject, IScalarValue { private ByteVO(byte value) : base(value) { } public static Result TryCreate(byte value, string? fieldName = null) => @@ -161,7 +161,7 @@ public static Result TryCreate(string? value, string? fieldName = null) throw new NotImplementedException(); } - public class SByteVO: ScalarValueObject, IScalarValue + public class SByteVO : ScalarValueObject, IScalarValue { private SByteVO(sbyte value) : base(value) { } public static Result TryCreate(sbyte value, string? fieldName = null) => @@ -170,7 +170,7 @@ public static Result TryCreate(string? value, string? fieldName = null) throw new NotImplementedException(); } - public class UShortVO: ScalarValueObject, IScalarValue + public class UShortVO : ScalarValueObject, IScalarValue { private UShortVO(ushort value) : base(value) { } public static Result TryCreate(ushort value, string? fieldName = null) => @@ -179,7 +179,7 @@ public static Result TryCreate(string? value, string? fieldName = null throw new NotImplementedException(); } - public class UIntVO: ScalarValueObject, IScalarValue + public class UIntVO : ScalarValueObject, IScalarValue { private UIntVO(uint value) : base(value) { } public static Result TryCreate(uint value, string? fieldName = null) => @@ -188,7 +188,7 @@ public static Result TryCreate(string? value, string? fieldName = null) throw new NotImplementedException(); } - public class ULongVO: ScalarValueObject, IScalarValue + public class ULongVO : ScalarValueObject, IScalarValue { private ULongVO(ulong value) : base(value) { } public static Result TryCreate(ulong value, string? fieldName = null) => diff --git a/Trellis.EntityFrameworkCore/tests/MaybeMoneyTests.cs b/Trellis.EntityFrameworkCore/tests/MaybeMoneyTests.cs index 5223c79b..6948a924 100644 --- a/Trellis.EntityFrameworkCore/tests/MaybeMoneyTests.cs +++ b/Trellis.EntityFrameworkCore/tests/MaybeMoneyTests.cs @@ -305,4 +305,4 @@ public MaybeMoneyDbContext(DbContextOptions options) : base protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) => configurationBuilder.ApplyTrellisConventions(); } -} +} \ No newline at end of file diff --git a/Trellis.Primitives/generator/RequiredPartialClassGenerator.cs b/Trellis.Primitives/generator/RequiredPartialClassGenerator.cs index b3568afb..e0cb949e 100644 --- a/Trellis.Primitives/generator/RequiredPartialClassGenerator.cs +++ b/Trellis.Primitives/generator/RequiredPartialClassGenerator.cs @@ -1211,7 +1211,7 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov }}"; } - // IFormattableScalarValue TryCreate overloadfor culture-sensitive parsing + // IFormattableScalarValue TryCreate overload for culture-sensitive parsing source += $@" /// diff --git a/Trellis.Primitives/src/Primitives/MonetaryAmount.cs b/Trellis.Primitives/src/Primitives/MonetaryAmount.cs index 7779f146..9f8eccd7 100644 --- a/Trellis.Primitives/src/Primitives/MonetaryAmount.cs +++ b/Trellis.Primitives/src/Primitives/MonetaryAmount.cs @@ -167,4 +167,4 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov /// Returns the amount as an invariant-culture decimal string. public override string ToString() => Value.ToString(System.Globalization.CultureInfo.InvariantCulture); -} +} \ No newline at end of file diff --git a/Trellis.Primitives/tests/IFormattableScalarValueTests.cs b/Trellis.Primitives/tests/IFormattableScalarValueTests.cs index ee61ca59..76f32bfe 100644 --- a/Trellis.Primitives/tests/IFormattableScalarValueTests.cs +++ b/Trellis.Primitives/tests/IFormattableScalarValueTests.cs @@ -466,4 +466,4 @@ public void Percentage_TryParse_with_german_culture() } #endregion -} +} \ No newline at end of file diff --git a/Trellis.Primitives/tests/MonetaryAmountTests.cs b/Trellis.Primitives/tests/MonetaryAmountTests.cs index 2af757f0..81a2e189 100644 --- a/Trellis.Primitives/tests/MonetaryAmountTests.cs +++ b/Trellis.Primitives/tests/MonetaryAmountTests.cs @@ -375,4 +375,4 @@ public void TryCreate_string_uses_invariant_culture() } #endregion -} +} \ No newline at end of file diff --git a/Trellis.Results/src/IFormattableScalarValue.cs b/Trellis.Results/src/IFormattableScalarValue.cs index 0cfc45ad..e419603b 100644 --- a/Trellis.Results/src/IFormattableScalarValue.cs +++ b/Trellis.Results/src/IFormattableScalarValue.cs @@ -43,4 +43,4 @@ public interface IFormattableScalarValue : IScalarValue /// Success with the scalar value, or Failure with validation errors. static abstract Result TryCreate(string? value, IFormatProvider? provider, string? fieldName = null); -} +} \ No newline at end of file diff --git a/Trellis.Testing/src/ServiceCollectionDbProviderExtensions.cs b/Trellis.Testing/src/ServiceCollectionDbProviderExtensions.cs index 7063ab1f..da276cac 100644 --- a/Trellis.Testing/src/ServiceCollectionDbProviderExtensions.cs +++ b/Trellis.Testing/src/ServiceCollectionDbProviderExtensions.cs @@ -62,4 +62,4 @@ public static class ServiceCollectionDbProviderExtensions services.AddDbContext(configureOptions); return services; } -} +} \ No newline at end of file diff --git a/Trellis.Testing/tests/ServiceCollectionDbProviderExtensionsTests.cs b/Trellis.Testing/tests/ServiceCollectionDbProviderExtensionsTests.cs index 5403c6e2..9cb90a27 100644 --- a/Trellis.Testing/tests/ServiceCollectionDbProviderExtensionsTests.cs +++ b/Trellis.Testing/tests/ServiceCollectionDbProviderExtensionsTests.cs @@ -161,4 +161,4 @@ private sealed class TestAppDbContext(DbContextOptions options private sealed class OtherTestDbContext(DbContextOptions options) : DbContext(options); #endregion -} +} \ No newline at end of file From d7b73413c9eb322906ae800effd0a127e9fc0153 Mon Sep 17 00:00:00 2001 From: Xavier John Date: Sat, 28 Mar 2026 22:30:40 -0700 Subject: [PATCH 17/17] Enhance RequiredPartialClassGenerator to support IFormattableScalarValue and improve parsing methods --- .../RequiredPartialClassGenerator.cs | 531 +++++++++--------- 1 file changed, 269 insertions(+), 262 deletions(-) diff --git a/Trellis.Primitives/generator/RequiredPartialClassGenerator.cs b/Trellis.Primitives/generator/RequiredPartialClassGenerator.cs index e0cb949e..c0dd4163 100644 --- a/Trellis.Primitives/generator/RequiredPartialClassGenerator.cs +++ b/Trellis.Primitives/generator/RequiredPartialClassGenerator.cs @@ -232,13 +232,89 @@ private static void Execute(Compilation compilation, ImmutableArray must be implemented on each derived class (not the base) - // because it has a static abstract TryCreate method that abstract classes cannot satisfy. - // The RequiredEnum base class has the constraint `where TSelf : IScalarValue` - // to ensure all derived types implement the interface. if (g.ClassBase == "RequiredEnum") { - var enumSource = $@"// + context.AddSource($"{g.TypePath}.g.cs", GenerateEnumSource(g, nestedTypeOpen, nestedTypeClose)); + continue; + } + + // Build up the source code + // Note: The base class is already declared in the user's partial class. + // We only generate the additional members and interfaces. + var isFormattable = g.ClassBase is "RequiredInt" or "RequiredDecimal" or "RequiredLong" or "RequiredDateTime"; + var formattableInterface = isFormattable + ? $", IFormattableScalarValue<{g.ClassName}, {classType}>" + : ""; + + var source = $@"// + namespace {g.NameSpace}; + using System; + using Trellis; + using Trellis.Primitives; + using System.Diagnostics.CodeAnalysis; + using System.Text.Json.Serialization; + + #nullable enable + {nestedTypeOpen} + [JsonConverter(typeof(ParsableJsonConverter<{g.ClassName}>))] + {g.Accessibility.ToCamelCase()} partial class {g.ClassName} : IScalarValue<{g.ClassName}, {classType}>{formattableInterface}, IParsable<{g.ClassName}> + {{ + private {g.ClassName}({classType} value) : base(value) + {{ + }} + + public static explicit operator {g.ClassName}({classType} {camelArg}) => Create({camelArg}); + + public static {g.ClassName} Parse(string s, IFormatProvider? provider) + {{ + var r = TryCreate(s, {(isFormattable ? "provider" : "null")}); + if (r.IsFailure) + {{ + var val = (ValidationError)r.Error; + throw new FormatException(val.FieldErrors[0].Details[0]); + }} + return r.Value; + }} + + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out {g.ClassName} result) + {{ + var r = TryCreate(s, {(isFormattable ? "provider" : "null")}); + if (r.IsFailure) + {{ + result = default; + return false; + }} + + result = r.Value; + return true; + }}"; + + // Generate type-specific factory methods (TryCreate, Create, validation hooks) + var methods = g.ClassBase switch + { + "RequiredGuid" => GenerateGuidMethods(g), + "RequiredString" => GenerateStringMethods(g, context), + "RequiredInt" => GenerateIntMethods(g, context), + "RequiredDecimal" => GenerateDecimalMethods(g, context), + "RequiredLong" => GenerateLongMethods(g, context), + "RequiredBool" => GenerateBoolMethods(g), + "RequiredDateTime" => GenerateDateTimeMethods(g), + _ => null + }; + + if (methods is null) continue; + + source += methods; + source += $@" + }} + {nestedTypeClose}"; + + context.AddSource($"{g.TypePath}.g.cs", source); + } + } + + private static string GenerateEnumSource(RequiredPartialClassInfo g, string nestedTypeOpen, string nestedTypeClose) => + $@"// namespace {g.NameSpace}; using System; using Trellis; @@ -315,64 +391,9 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov }} }} {nestedTypeClose}"; - context.AddSource($"{g.TypePath}.g.cs", enumSource); - continue; - } - - // Build up the source code - // Note: The base class is already declared in the user's partial class. - // We only generate the additional members and interfaces. - var isFormattable = g.ClassBase is "RequiredInt" or "RequiredDecimal" or "RequiredLong" or "RequiredDateTime"; - var formattableInterface = isFormattable - ? $", IFormattableScalarValue<{g.ClassName}, {classType}>" - : ""; - var source = $@"// - namespace {g.NameSpace}; - using System; - using Trellis; - using Trellis.Primitives; - using System.Diagnostics.CodeAnalysis; - using System.Text.Json.Serialization; - - #nullable enable - {nestedTypeOpen} - [JsonConverter(typeof(ParsableJsonConverter<{g.ClassName}>))] - {g.Accessibility.ToCamelCase()} partial class {g.ClassName} : IScalarValue<{g.ClassName}, {classType}>{formattableInterface}, IParsable<{g.ClassName}> - {{ - private {g.ClassName}({classType} value) : base(value) - {{ - }} - - public static explicit operator {g.ClassName}({classType} {camelArg}) => Create({camelArg}); - - public static {g.ClassName} Parse(string s, IFormatProvider? provider) - {{ - var r = TryCreate(s, {(isFormattable ? "provider" : "null")}); - if (r.IsFailure) - {{ - var val = (ValidationError)r.Error; - throw new FormatException(val.FieldErrors[0].Details[0]); - }} - return r.Value; - }} - - public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out {g.ClassName} result) - {{ - var r = TryCreate(s, {(isFormattable ? "provider" : "null")}); - if (r.IsFailure) - {{ - result = default; - return false; - }} - - result = r.Value; - return true; - }}"; - - if (g.ClassBase == "RequiredGuid") - { - source += $@" + private static string GenerateGuidMethods(RequiredPartialClassInfo g) => + $@" /// /// Optional validation hook. Implement this partial method to add custom validation. @@ -485,49 +506,46 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov if (result.IsFailure) throw new InvalidOperationException($""Failed to create {g.ClassName}: {{result.Error.Detail}}""); return result.Value; - }} - }} - {nestedTypeClose}"; - } + }}"; - if (g.ClassBase == "RequiredString") - { - // Validate [StringLength] constraints are consistent - if (g.MinLength.HasValue && g.MaxLength.HasValue && g.MinLength.Value > g.MaxLength.Value) - { - context.ReportDiagnostic(Diagnostic.Create( - new DiagnosticDescriptor( - id: "TRLSGEN002", - title: "StringLength MinimumLength exceeds MaximumLength", - messageFormat: "Class '{0}' has [StringLength({1}, MinimumLength = {2})] where MinimumLength exceeds MaximumLength. No value can satisfy both constraints.", - category: "SourceGenerator", - DiagnosticSeverity.Error, - isEnabledByDefault: true), - location: null, - g.ClassName, - g.MaxLength.Value, - g.MinLength.Value)); - continue; - } + private static string? GenerateStringMethods(RequiredPartialClassInfo g, SourceProductionContext context) + { + // Validate [StringLength] constraints are consistent + if (g.MinLength.HasValue && g.MaxLength.HasValue && g.MinLength.Value > g.MaxLength.Value) + { + context.ReportDiagnostic(Diagnostic.Create( + new DiagnosticDescriptor( + id: "TRLSGEN002", + title: "StringLength MinimumLength exceeds MaximumLength", + messageFormat: "Class '{0}' has [StringLength({1}, MinimumLength = {2})] where MinimumLength exceeds MaximumLength. No value can satisfy both constraints.", + category: "SourceGenerator", + DiagnosticSeverity.Error, + isEnabledByDefault: true), + location: null, + g.ClassName, + g.MaxLength.Value, + g.MinLength.Value)); + return null; + } - // Build length validation checks if [StringLength] is applied (checked after trim) - var lengthChecks = ""; + // Build length validation checks if [StringLength] is applied (checked after trim) + var lengthChecks = ""; - if (g.MinLength.HasValue) - { - lengthChecks += $@" - if (normalized.Length < {g.MinLength.Value}) - return Error.Validation(""{g.ClassName.SplitPascalCase()} must be at least {g.MinLength.Value} {"character" + (g.MinLength.Value == 1 ? "" : "s")}."", field);"; - } + if (g.MinLength.HasValue) + { + lengthChecks += $@" + if (normalized.Length < {g.MinLength.Value}) + return Error.Validation(""{g.ClassName.SplitPascalCase()} must be at least {g.MinLength.Value} {"character" + (g.MinLength.Value == 1 ? "" : "s")}."", field);"; + } - if (g.MaxLength.HasValue) - { - lengthChecks += $@" - if (normalized.Length > {g.MaxLength.Value}) - return Error.Validation(""{g.ClassName.SplitPascalCase()} must be {g.MaxLength.Value} {"character" + (g.MaxLength.Value == 1 ? "" : "s")} or fewer."", field);"; - } + if (g.MaxLength.HasValue) + { + lengthChecks += $@" + if (normalized.Length > {g.MaxLength.Value}) + return Error.Validation(""{g.ClassName.SplitPascalCase()} must be {g.MaxLength.Value} {"character" + (g.MaxLength.Value == 1 ? "" : "s")} or fewer."", field);"; + } - source += $@" + return $@" /// /// Optional validation hook. Implement this partial method to add custom validation @@ -574,39 +592,38 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov if (result.IsFailure) throw new InvalidOperationException($""Failed to create {g.ClassName}: {{result.Error.Detail}}""); return result.Value; - }} - }} - {nestedTypeClose}"; - } + }}"; + } - if (g.ClassBase == "RequiredInt") - { - var hasRange = g.RangeMin.HasValue && g.RangeMax.HasValue; - var rangeMin = g.RangeMin.GetValueOrDefault(); - var rangeMax = g.RangeMax.GetValueOrDefault(); + private static string? GenerateIntMethods(RequiredPartialClassInfo g, SourceProductionContext context) + { + var result = ""; + var hasRange = g.RangeMin.HasValue && g.RangeMax.HasValue; + var rangeMin = g.RangeMin.GetValueOrDefault(); + var rangeMax = g.RangeMax.GetValueOrDefault(); - // Validate [Range] constraints are consistent - if (hasRange && rangeMin > rangeMax) - { - context.ReportDiagnostic(Diagnostic.Create( - new DiagnosticDescriptor( - id: "TRLSGEN003", - title: "Range Minimum exceeds Maximum", - messageFormat: "Class '{0}' has [Range({1}, {2})] where Minimum exceeds Maximum. No value can satisfy both constraints.", - category: "SourceGenerator", - DiagnosticSeverity.Error, - isEnabledByDefault: true), - location: null, - g.ClassName, - rangeMin, - rangeMax)); - continue; - } + // Validate [Range] constraints are consistent + if (hasRange && rangeMin > rangeMax) + { + context.ReportDiagnostic(Diagnostic.Create( + new DiagnosticDescriptor( + id: "TRLSGEN003", + title: "Range Minimum exceeds Maximum", + messageFormat: "Class '{0}' has [Range({1}, {2})] where Minimum exceeds Maximum. No value can satisfy both constraints.", + category: "SourceGenerator", + DiagnosticSeverity.Error, + isEnabledByDefault: true), + location: null, + g.ClassName, + rangeMin, + rangeMax)); + return null; + } - if (hasRange) - { - // Range-validated TryCreate overloads - source += $@" + if (hasRange) + { + // Range-validated TryCreate overloads + result += $@" /// /// Optional validation hook. Implement this partial method to add custom validation. @@ -676,11 +693,11 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov }} return validated.Map(_ => new {g.ClassName}(parsedInt)); }}"; - } - else - { - // Default TryCreate overloads (no [Range] — accepts any int including zero) - source += $@" + } + else + { + // Default TryCreate overloads (no [Range] — accepts any int including zero) + result += $@" /// /// Optional validation hook. Implement this partial method to add custom validation. @@ -742,10 +759,10 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov }} return validated.Map(_ => new {g.ClassName}(parsedInt)); }}"; - } + } - // IFormattableScalarValue TryCreate overload for culture-sensitive parsing - source += $@" + // IFormattableScalarValue TryCreate overload for culture-sensitive parsing + result += $@" /// /// Attempts to create a validated instance from a string using the specified format provider. @@ -766,8 +783,8 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov return TryCreate(parsed, fieldName); }}"; - // Create and Parse are the same regardless of [Range] - source += $@" + // Create and Parse are the same regardless of [Range] + result += $@" /// /// Creates a validated instance from an integer. Throws if validation fails. @@ -797,60 +814,61 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov if (result.IsFailure) throw new InvalidOperationException($""Failed to create {g.ClassName}: {{result.Error.Detail}}""); return result.Value; - }} - }} - {nestedTypeClose}"; - } + }}"; - if (g.ClassBase == "RequiredDecimal") - { - var hasRange = g.RangeDoubleMin.HasValue && g.RangeDoubleMax.HasValue; - // Use double values for fractional ranges, fall back to int values - var rangeMinD = g.RangeDoubleMin ?? (double?)g.RangeMin; - var rangeMaxD = g.RangeDoubleMax ?? (double?)g.RangeMax; - hasRange = hasRange || (g.RangeMin.HasValue && g.RangeMax.HasValue); - - // Validate [Range] constraints are consistent - if (hasRange && rangeMinD > rangeMaxD) - { - context.ReportDiagnostic(Diagnostic.Create( - new DiagnosticDescriptor( - id: "TRLSGEN003", - title: "Range Minimum exceeds Maximum", - messageFormat: "Class '{0}' has [Range({1}, {2})] where Minimum exceeds Maximum. No value can satisfy both constraints.", - category: "SourceGenerator", - DiagnosticSeverity.Error, - isEnabledByDefault: true), - location: null, - g.ClassName, - rangeMinD, - rangeMaxD)); - continue; - } + return result; + } - if (hasRange) - { - var minStr = FormatDecimalLiteral(rangeMinD.GetValueOrDefault()); - var maxStr = FormatDecimalLiteral(rangeMaxD.GetValueOrDefault()); + private static string? GenerateDecimalMethods(RequiredPartialClassInfo g, SourceProductionContext context) + { + var result = ""; + var hasRange = g.RangeDoubleMin.HasValue && g.RangeDoubleMax.HasValue; + // Use double values for fractional ranges, fall back to int values + var rangeMinD = g.RangeDoubleMin ?? (double?)g.RangeMin; + var rangeMaxD = g.RangeDoubleMax ?? (double?)g.RangeMax; + hasRange = hasRange || (g.RangeMin.HasValue && g.RangeMax.HasValue); + + // Validate [Range] constraints are consistent + if (hasRange && rangeMinD > rangeMaxD) + { + context.ReportDiagnostic(Diagnostic.Create( + new DiagnosticDescriptor( + id: "TRLSGEN003", + title: "Range Minimum exceeds Maximum", + messageFormat: "Class '{0}' has [Range({1}, {2})] where Minimum exceeds Maximum. No value can satisfy both constraints.", + category: "SourceGenerator", + DiagnosticSeverity.Error, + isEnabledByDefault: true), + location: null, + g.ClassName, + rangeMinD, + rangeMaxD)); + return null; + } - // Validate range values fit in decimal - if (minStr is null || maxStr is null) - { - context.ReportDiagnostic(Diagnostic.Create( - new DiagnosticDescriptor( - id: "TRLSGEN004", - title: "Range value exceeds decimal range", - messageFormat: "Class '{0}' has [Range] values that exceed the decimal type range (±7.9×10²⁸). Use ValidateAdditional for bounds that exceed decimal range.", - category: "SourceGenerator", - DiagnosticSeverity.Error, - isEnabledByDefault: true), - location: null, - g.ClassName)); - continue; - } + if (hasRange) + { + var minStr = FormatDecimalLiteral(rangeMinD.GetValueOrDefault()); + var maxStr = FormatDecimalLiteral(rangeMaxD.GetValueOrDefault()); - // Range-validated TryCreate overloads - source += $@" + // Validate range values fit in decimal + if (minStr is null || maxStr is null) + { + context.ReportDiagnostic(Diagnostic.Create( + new DiagnosticDescriptor( + id: "TRLSGEN004", + title: "Range value exceeds decimal range", + messageFormat: "Class '{0}' has [Range] values that exceed the decimal type range (±7.9×10²⁸). Use ValidateAdditional for bounds that exceed decimal range.", + category: "SourceGenerator", + DiagnosticSeverity.Error, + isEnabledByDefault: true), + location: null, + g.ClassName)); + return null; + } + + // Range-validated TryCreate overloads + result += $@" /// /// Optional validation hook. Implement this partial method to add custom validation. @@ -920,11 +938,11 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov }} return validated.Map(_ => new {g.ClassName}(parsedDecimal)); }}"; - } - else - { - // Default TryCreate overloads (no [Range]) - source += $@" + } + else + { + // Default TryCreate overloads (no [Range]) + result += $@" /// /// Optional validation hook. Implement this partial method to add custom validation. @@ -986,10 +1004,10 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov }} return validated.Map(_ => new {g.ClassName}(parsedDecimal)); }}"; - } + } - // IFormattableScalarValue TryCreate overload for culture-sensitive parsing - source += $@" + // IFormattableScalarValue TryCreate overload for culture-sensitive parsing + result += $@" /// /// Attempts to create a validated instance from a string using the specified format provider. @@ -1010,8 +1028,8 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov return TryCreate(parsed, fieldName); }}"; - // Create and Parse are the same regardless of [Range] - source += $@" + // Create and Parse are the same regardless of [Range] + result += $@" /// /// Creates a validated instance from a decimal. Throws if validation fails. @@ -1041,39 +1059,40 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov if (result.IsFailure) throw new InvalidOperationException($""Failed to create {g.ClassName}: {{result.Error.Detail}}""); return result.Value; - }} - }} - {nestedTypeClose}"; - } + }}"; - if (g.ClassBase == "RequiredLong") - { - var hasRange = g.RangeLongMin.HasValue && g.RangeLongMax.HasValue; - var rangeLongMin = g.RangeLongMin.GetValueOrDefault(); - var rangeLongMax = g.RangeLongMax.GetValueOrDefault(); + return result; + } - // Validate [Range] constraints are consistent - if (hasRange && rangeLongMin > rangeLongMax) - { - context.ReportDiagnostic(Diagnostic.Create( - new DiagnosticDescriptor( - id: "TRLSGEN003", - title: "Range Minimum exceeds Maximum", - messageFormat: "Class '{0}' has [Range({1}, {2})] where Minimum exceeds Maximum. No value can satisfy both constraints.", - category: "SourceGenerator", - DiagnosticSeverity.Error, - isEnabledByDefault: true), - location: null, - g.ClassName, - rangeLongMin, - rangeLongMax)); - continue; - } + private static string? GenerateLongMethods(RequiredPartialClassInfo g, SourceProductionContext context) + { + var result = ""; + var hasRange = g.RangeLongMin.HasValue && g.RangeLongMax.HasValue; + var rangeLongMin = g.RangeLongMin.GetValueOrDefault(); + var rangeLongMax = g.RangeLongMax.GetValueOrDefault(); - if (hasRange) - { - // Range-validated TryCreate overloads - source += $@" + // Validate [Range] constraints are consistent + if (hasRange && rangeLongMin > rangeLongMax) + { + context.ReportDiagnostic(Diagnostic.Create( + new DiagnosticDescriptor( + id: "TRLSGEN003", + title: "Range Minimum exceeds Maximum", + messageFormat: "Class '{0}' has [Range({1}, {2})] where Minimum exceeds Maximum. No value can satisfy both constraints.", + category: "SourceGenerator", + DiagnosticSeverity.Error, + isEnabledByDefault: true), + location: null, + g.ClassName, + rangeLongMin, + rangeLongMax)); + return null; + } + + if (hasRange) + { + // Range-validated TryCreate overloads + result += $@" /// /// Optional validation hook. Implement this partial method to add custom validation. @@ -1143,11 +1162,11 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov }} return validated.Map(_ => new {g.ClassName}(parsedLong)); }}"; - } - else - { - // Default TryCreate overloads (no [Range] — accepts any long) - source += $@" + } + else + { + // Default TryCreate overloads (no [Range] — accepts any long) + result += $@" /// /// Optional validation hook. Implement this partial method to add custom validation. @@ -1209,10 +1228,10 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov }} return validated.Map(_ => new {g.ClassName}(parsedLong)); }}"; - } + } - // IFormattableScalarValue TryCreate overload for culture-sensitive parsing - source += $@" + // IFormattableScalarValue TryCreate overload for culture-sensitive parsing + result += $@" /// /// Attempts to create a validated instance from a string using the specified format provider. @@ -1233,8 +1252,8 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov return TryCreate(parsed, fieldName); }}"; - // Create and Parse are the same regardless of [Range] - source += $@" + // Create and Parse are the same regardless of [Range] + result += $@" /// /// Creates a validated instance from a long. Throws if validation fails. @@ -1264,14 +1283,13 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov if (result.IsFailure) throw new InvalidOperationException($""Failed to create {g.ClassName}: {{result.Error.Detail}}""); return result.Value; - }} - }} - {nestedTypeClose}"; - } + }}"; - if (g.ClassBase == "RequiredBool") - { - source += $@" + return result; + } + + private static string GenerateBoolMethods(RequiredPartialClassInfo g) => + $@" /// /// Optional validation hook. Implement this partial method to add custom validation. @@ -1362,14 +1380,10 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov if (result.IsFailure) throw new InvalidOperationException($""Failed to create {g.ClassName}: {{result.Error.Detail}}""); return result.Value; - }} - }} - {nestedTypeClose}"; - } + }}"; - if (g.ClassBase == "RequiredDateTime") - { - source += $@" + private static string GenerateDateTimeMethods(RequiredPartialClassInfo g) => + $@" /// /// Optional validation hook. Implement this partial method to add custom validation. @@ -1483,14 +1497,7 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov if (result.IsFailure) throw new InvalidOperationException($""Failed to create {g.ClassName}: {{result.Error.Detail}}""); return result.Value; - }} - }} - {nestedTypeClose}"; - } - - context.AddSource($"{g.TypePath}.g.cs", source); - } - } + }}"; /// /// Extracts metadata from class declarations to determine which classes need code generation.