diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d040e549..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: @@ -633,11 +641,31 @@ 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. + +> **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: @@ -681,6 +709,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` @@ -698,3 +781,11 @@ 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. +3. **User review** — Present a summary of changes to the user and wait for explicit approval before committing. 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/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.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/MaybeModelBinderTests.cs b/Trellis.Asp/tests/MaybeModelBinderTests.cs index 76a3a0f1..2ef7f567 100644 --- a/Trellis.Asp/tests/MaybeModelBinderTests.cs +++ b/Trellis.Asp/tests/MaybeModelBinderTests.cs @@ -30,6 +30,9 @@ 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 @@ -60,6 +63,9 @@ 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 @@ -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..2c8fd53e 100644 --- a/Trellis.Asp/tests/MaybeScalarValueJsonConverterTests.cs +++ b/Trellis.Asp/tests/MaybeScalarValueJsonConverterTests.cs @@ -45,6 +45,9 @@ 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 @@ -60,6 +63,9 @@ 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 @@ -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..a49420af 100644 --- a/Trellis.Asp/tests/ModelBindingTests.cs +++ b/Trellis.Asp/tests/ModelBindingTests.cs @@ -28,6 +28,9 @@ 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 @@ -58,6 +61,9 @@ 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 @@ -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 new file mode 100644 index 00000000..93944697 --- /dev/null +++ b/Trellis.Asp/tests/NullAndMissingPropertyValidationTests.cs @@ -0,0 +1,524 @@ +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 static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); + } + + 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 static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); + } + + 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 static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); + } + + 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 static Result TryCreate(string? value, string? fieldName = null) => + throw new NotImplementedException(); + } + + 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 +} \ No newline at end of file diff --git a/Trellis.Asp/tests/ScalarValueModelBinderPrimitiveTypesTests.cs b/Trellis.Asp/tests/ScalarValueModelBinderPrimitiveTypesTests.cs index 604eee79..0906a755 100644 --- a/Trellis.Asp/tests/ScalarValueModelBinderPrimitiveTypesTests.cs +++ b/Trellis.Asp/tests/ScalarValueModelBinderPrimitiveTypesTests.cs @@ -33,6 +33,8 @@ 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 @@ -42,6 +44,8 @@ public static Result TryCreate(int value, string? fieldName = 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,6 +55,8 @@ 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 @@ -60,6 +66,8 @@ public static Result TryCreate(decimal value, string? fieldName = nul 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 @@ -69,6 +77,8 @@ 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 @@ -76,6 +86,8 @@ public sealed 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(); } public sealed class DateTimeVO : ScalarValueObject, IScalarValue @@ -85,6 +97,8 @@ public static Result TryCreate(DateTime value, string? fieldName = n 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 @@ -94,6 +108,8 @@ public static Result TryCreate(DateOnly value, string? fieldName = n 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 @@ -101,6 +117,8 @@ public sealed class TimeOnlyVO : ScalarValueObject, IScala 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 @@ -110,6 +128,8 @@ public static Result TryCreate(TimeSpan value, string? fieldName = n 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 @@ -119,6 +139,8 @@ public static Result TryCreate(DateTimeOffset value, string? f 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 @@ -128,6 +150,8 @@ 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 @@ -135,6 +159,8 @@ public sealed class ByteVO : ScalarValueObject, IScalarValue 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 @@ -144,6 +170,8 @@ 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 @@ -151,6 +179,8 @@ 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 @@ -158,6 +188,8 @@ public sealed class UIntVO : ScalarValueObject, IScalarValue 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 @@ -165,6 +197,8 @@ public sealed class ULongVO : ScalarValueObject, IScalarValue
    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 @@ -174,6 +208,8 @@ 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/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..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 @@ -504,7 +506,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 +521,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"); } } 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..2c0d4fc6 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,6 +130,8 @@ 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 @@ -117,6 +139,8 @@ 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 @@ -124,6 +148,8 @@ public class ShortVO : ScalarValueObject, IScalarValue 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 @@ -131,6 +157,8 @@ public class ByteVO : ScalarValueObject, IScalarValue 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 @@ -138,6 +166,8 @@ public class SByteVO : ScalarValueObject, IScalarValue 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 @@ -145,6 +175,8 @@ public class UShortVO : ScalarValueObject, IScalarValue 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 @@ -152,6 +184,8 @@ public class UIntVO : ScalarValueObject, IScalarValue 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 @@ -159,6 +193,8 @@ public class ULongVO : ScalarValueObject, IScalarValue 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/src/ValueObject.cs b/Trellis.DomainDrivenDesign/src/ValueObject.cs index 061da55c..9cd3fb9f 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,17 @@ 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 switch + { + null => 1, + 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/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 73b6ad24..cc0d5acc 100644 --- a/Trellis.DomainDrivenDesign/tests/ValueObjects/ValueObjectTests.cs +++ b/Trellis.DomainDrivenDesign/tests/ValueObjects/ValueObjectTests.cs @@ -370,6 +370,52 @@ 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 + + #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 } /// @@ -391,4 +437,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 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/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.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.EntityFrameworkCore/src/MaybeConvention.cs b/Trellis.EntityFrameworkCore/src/MaybeConvention.cs index a6b8e413..6c16e315 100644 --- a/Trellis.EntityFrameworkCore/src/MaybeConvention.cs +++ b/Trellis.EntityFrameworkCore/src/MaybeConvention.cs @@ -30,6 +30,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., 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). +/// +/// /// User code with the source generator: /// /// @@ -37,6 +43,7 @@ /// { /// public CustomerId Id { get; set; } = null!; /// public partial Maybe<PhoneNumber> Phone { get; set; } +/// public partial Maybe<Money> Discount { get; set; } /// } /// /// @@ -70,6 +77,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 +104,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 new file mode 100644 index 00000000..6948a924 --- /dev/null +++ b/Trellis.EntityFrameworkCore/tests/MaybeMoneyTests.cs @@ -0,0 +1,308 @@ +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() + { + 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 + + [Fact] + public void MaybeMoney_IsMappedAsOwnedNavigation() + { + var entityType = Context.Model.FindEntityType(typeof(PenaltyEntity))!; + + // 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_ColumnNaming_FollowsMoneyConvention() + { + var entityType = Context.Model.FindEntityType(typeof(PenaltyEntity))!; + 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() + .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 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 + { + 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(); + } +} \ No newline at end of file diff --git a/Trellis.Primitives/README.md b/Trellis.Primitives/README.md index 2a70aee6..3e29aecd 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 | @@ -469,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..c0dd4163 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 { /// @@ -231,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; @@ -314,59 +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 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}>, 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, 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, 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. @@ -479,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 @@ -568,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. @@ -658,7 +681,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) @@ -670,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. @@ -726,7 +749,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; @@ -736,10 +759,32 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov }} return validated.Map(_ => new {g.ClassName}(parsedInt)); }}"; - } + } + + // IFormattableScalarValue TryCreate overload for culture-sensitive parsing + result += $@" + + /// + /// 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 += $@" + // Create and Parse are the same regardless of [Range] + result += $@" /// /// Creates a validated instance from an integer. Throws if validation fails. @@ -769,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. @@ -892,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. @@ -948,7 +994,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; @@ -958,10 +1004,32 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov }} return validated.Map(_ => new {g.ClassName}(parsedDecimal)); }}"; - } + } + + // IFormattableScalarValue TryCreate overload for culture-sensitive parsing + result += $@" + + /// + /// 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 += $@" + // Create and Parse are the same regardless of [Range] + result += $@" /// /// Creates a validated instance from a decimal. Throws if validation fails. @@ -991,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. @@ -1081,7 +1150,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) @@ -1093,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. @@ -1149,7 +1218,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; @@ -1159,10 +1228,32 @@ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? prov }} return validated.Map(_ => new {g.ClassName}(parsedLong)); }}"; - } + } + + // IFormattableScalarValue TryCreate overload for culture-sensitive parsing + result += $@" + + /// + /// 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 += $@" + // Create and Parse are the same regardless of [Range] + result += $@" /// /// Creates a validated instance from a long. Throws if validation fails. @@ -1192,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. @@ -1290,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. @@ -1364,6 +1450,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. @@ -1392,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. diff --git a/Trellis.Primitives/src/Primitives/Age.cs b/Trellis.Primitives/src/Primitives/Age.cs index 9a4d7a08..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. @@ -40,21 +40,53 @@ 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, 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); + } + + /// + /// 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; } /// @@ -62,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 new file mode 100644 index 00000000..9f8eccd7 --- /dev/null +++ b/Trellis.Primitives/src/Primitives/MonetaryAmount.cs @@ -0,0 +1,170 @@ +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, IFormattableScalarValue, 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); + } + + /// + /// 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); + } + + /// + /// 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) + { + 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) + { + 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) + { + var r = TryCreate(s, provider); + if (r.IsSuccess) + { + result = r.Value; + return true; + } + + result = default!; + return false; + } + + /// 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); +} \ No newline at end of file diff --git a/Trellis.Primitives/src/Primitives/Percentage.cs b/Trellis.Primitives/src/Primitives/Percentage.cs index c8da56fb..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) { } @@ -126,6 +126,53 @@ 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); + } + + /// + /// 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). /// @@ -164,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; } /// @@ -188,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/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/AgeTests.cs b/Trellis.Primitives/tests/AgeTests.cs index ebcc402c..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] @@ -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/IFormattableScalarValueTests.cs b/Trellis.Primitives/tests/IFormattableScalarValueTests.cs new file mode 100644 index 00000000..76f32bfe --- /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 +} \ No newline at end of file diff --git a/Trellis.Primitives/tests/MonetaryAmountTests.cs b/Trellis.Primitives/tests/MonetaryAmountTests.cs new file mode 100644 index 00000000..81a2e189 --- /dev/null +++ b/Trellis.Primitives/tests/MonetaryAmountTests.cs @@ -0,0 +1,378 @@ +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 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() + { + 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); + } + + [Fact] + public void ExplicitCast_FromDecimal_ReturnsInstance() + { + var amount = (MonetaryAmount)29.99m; + amount.Value.Should().Be(29.99m); + } + + #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(); + } + + [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 + + [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 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] + public void ToString_ReturnsFormattedAmount() + { + var amount = MonetaryAmount.Create(1234.56m); + amount.ToString().Should().Be("1234.56"); + } + + #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 +} \ No newline at end of file diff --git a/Trellis.Primitives/tests/PercentageTests.cs b/Trellis.Primitives/tests/PercentageTests.cs index c052bb78..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] @@ -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/IFormattableScalarValue.cs b/Trellis.Results/src/IFormattableScalarValue.cs new file mode 100644 index 00000000..e419603b --- /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); +} \ 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. /// /// 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.Testing/src/ServiceCollectionDbProviderExtensions.cs b/Trellis.Testing/src/ServiceCollectionDbProviderExtensions.cs new file mode 100644 index 00000000..da276cac --- /dev/null +++ b/Trellis.Testing/src/ServiceCollectionDbProviderExtensions.cs @@ -0,0 +1,65 @@ +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 + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configureOptions); + + 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; + } +} \ No newline at end of file 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..9cb90a27 --- /dev/null +++ b/Trellis.Testing/tests/ServiceCollectionDbProviderExtensionsTests.cs @@ -0,0 +1,164 @@ +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"); + } + + [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 + + 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 +} \ No newline at end of file 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 @@ + + diff --git a/docs/docfx_project/articles/integration-ef.md b/docs/docfx_project/articles/integration-ef.md index 9c7ea77a..2a2b6d4b 100644 --- a/docs/docfx_project/articles/integration-ef.md +++ b/docs/docfx_project/articles/integration-ef.md @@ -826,6 +826,36 @@ 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: + +```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 +877,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/docs/docfx_project/articles/primitives.md b/docs/docfx_project/articles/primitives.md index fae36455..22291967 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); @@ -161,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 1042f8f3..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>`. @@ -1138,8 +1148,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 +1702,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 @@ -1685,6 +1716,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 +1740,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 +1933,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) 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