From 24aa3e8fe4fa768706b6e09a82959439da2142bf Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Fri, 23 Oct 2026 12:53:02 +0100 Subject: [PATCH] fix: nullable value types --- CHANGELOG.md | 9 ++ Directory.Packages.props | 2 +- README.md | 3 +- docs/Getting-Started.md | 21 ++- docs/Value-Object-Design.md | 1 + package.json | 2 +- skills/purview-value-objects/SKILL.md | 4 + .../Models/ScalarValueObjectModel.cs | 1 + .../ValueObject/ScalarValueObjectEmitter.cs | 22 +++ .../ScalarValueObjectModelBuilder.cs | 45 +++++- src/src/ValueObjects/Sdk/README.md | 3 +- .../Serialization/ScalarAttribute.cs | 4 +- .../Serialization/ScalarAttributeOfT.cs | 10 +- .../Serialization/ScalarOptionsAttribute.cs | 5 +- .../ScalarAutomaticPropertyTests.cs | 132 +++++++++++++++ .../Generators/ScalarNullFactoryTests.cs | 152 ++++++++++++++++++ 16 files changed, 394 insertions(+), 22 deletions(-) create mode 100644 src/tests/SourceGenerator.UnitTests/Generators/ScalarNullFactoryTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 18698aa..d272a4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,15 @@ newest heading below is the version in development. Earlier releases are listed option in one place instead of silently accepting two sources of truth. `[ValueObjectDefaults]` still allows multiple applications so the build-generated attribute and an author-declared attribute can coexist for the options the author deliberately overrides. +- **`Nullable = true` now applies to value types.** The automatic forms previously ignored the option for a + value type, so `[Scalar(Nullable = true)]` declared a non-nullable `DateTimeOffset Value` + and disagreed with the manual form declaring `DateTimeOffset? Value`. The option now wraps a non-nullable + value type in `Nullable` (an already-nullable type is unchanged), so the two forms generate the same + members for the same model. +- **`Null()` factory for nullable scalars.** A scalar whose underlying value is nullable now exposes + `public static TSelf Null()`, equivalent to `Hydrate(null)`, so the unknown value is explicit and does not + depend on `Empty`. It is not generated when the author declares a member named `Null`, and non-nullable + scalars are unchanged. ## 1.1.0 diff --git a/Directory.Packages.props b/Directory.Packages.props index 2b79a7b..07a0c2c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,7 +7,7 @@ 5.9.0 1.76.0 1.2.0 - 2.3.0 + 2.4.1 diff --git a/README.md b/README.md index 4f2d674..40733f4 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,8 @@ var email = EmailAddress.Create("Demo@Example.com"); `[Scalar]` wraps a single primitive; `[ValueObject]` wraps multiple members. The manual `[Scalar]` form declares the underlying property; the automatic `[Scalar]` / `[Scalar(typeof(T))]` forms have the -generator declare it (and `[Scalar(Nullable = true)]` expresses a nullable reference scalar). See +generator declare it (and `[Scalar(Nullable = true)]` or `[Scalar(Nullable = true)]` +expresses a nullable scalar). See [Getting Started](docs/Getting-Started.md#automatic-vs-manual-underlying-property). ## JSON serialization diff --git a/docs/Getting-Started.md b/docs/Getting-Started.md index aa34527..c0b538b 100644 --- a/docs/Getting-Started.md +++ b/docs/Getting-Started.md @@ -88,22 +88,27 @@ public readonly partial record struct InstallationId } ``` -Use `Nullable` when the automatic property should be a nullable reference type. Nullable reference types +Use `Nullable` when the automatic property should be nullable. For a reference type it adds the nullable +annotation; for a non-nullable value type it wraps the type in `Nullable`. Nullable reference types cannot be generic attribute arguments (`[Scalar]` does not compile — `CS8970`) or `typeof` operands (`typeof(string?)` does not compile — `CS8639`), so express nullability on the attribute: ```csharp [Scalar(Nullable = true)] // emits `public string? Value { get; init; }` [Scalar(typeof(string), Nullable = true)] // same +[Scalar(Nullable = true)] // emits `public DateTimeOffset? Value { get; init; }` public readonly partial record struct Handle { } ``` -Nullable value types are written directly, for example `[Scalar]` or `[Scalar(typeof(int?))]`. +Nullable value types can also be written directly, for example `[Scalar]` or `[Scalar(typeof(int?))]`. +`Nullable = true` is idempotent for an already-nullable value type, so the two spellings are equivalent — and, +for an otherwise identical declaration, the automatic form generates the same members as the manual +`[Scalar]` form with a nullable property. -A nullable scalar round-trips JSON `null`: `[Scalar(Nullable = true)]` and `[Scalar]` -deserialize `null` and serialize back to `null`. A non-nullable reference scalar still rejects `null` with -a `JsonException`. Validation is yours to write, so make it null-tolerant — for a value that must be -`null` or non-whitespace: +A nullable scalar round-trips JSON `null`: `[Scalar(Nullable = true)]`, `[Scalar]`, and +`[Scalar(Nullable = true)]` deserialize `null` and serialize back to `null`. A non-nullable +reference scalar still rejects `null` with a `JsonException`. Validation is yours to write, so make it +null-tolerant — for a value that must be `null` or non-whitespace: ```csharp [Scalar(Nullable = true)] @@ -117,6 +122,10 @@ public readonly partial record struct Nickname } ``` +A nullable scalar also exposes `Null()`, which creates the “unknown” value without validating — +`Nickname.Null()` is equivalent to `Nickname.Hydrate(null)`. Use `Create(null)` instead when `null` is +input that should go through normalization and validation. + The manual form is the more explicit option when you prefer the type and nullability to appear in a property declaration. Both forms are supported, and neither is reported as a diagnostic. Other properties are allowed in either form; only the scalar property (and the interface's `Value` forwarding) is reserved. diff --git a/docs/Value-Object-Design.md b/docs/Value-Object-Design.md index 6309b81..6814bfc 100644 --- a/docs/Value-Object-Design.md +++ b/docs/Value-Object-Design.md @@ -204,6 +204,7 @@ query. | `Create(...)` | Throws on invalid input: the hook's exception, or a `ZodException` when a ZodSharp schema is generated for the type. | | `TryCreate(...)` | Returns `false` instead of throwing. | | `Hydrate(...)` | Never validates. Persistence, replay, and deserialization use this path. | +| `Null()` | Only on a nullable scalar: the unknown (`null`) value, equivalent to `Hydrate(null)`. Never validates. | A ZodSharp `ZodException` carries one or more `ValidationError` entries with a code, message, path, and an optional structured origin, so the same error codes you use in hooks (`ErrorFactory`-style constants) — or diff --git a/package.json b/package.json index 1394678..950f70d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "value-objects", - "version": "1.2.0", + "version": "1.2.1", "license": "MIT", "author": { "name": "Kieron Lanning", diff --git a/skills/purview-value-objects/SKILL.md b/skills/purview-value-objects/SKILL.md index a0e6e2f..abe5bdf 100644 --- a/skills/purview-value-objects/SKILL.md +++ b/skills/purview-value-objects/SKILL.md @@ -71,6 +71,10 @@ public readonly partial record struct EmailAddress - Declare the scalar property yourself (`[Scalar]`), or let the generator own it (`[Scalar]`). With `[Scalar]` you must **not** declare a member of that name — that is `VO1022`. +- `Nullable = true` makes the automatic property nullable for both reference and non-nullable value + types (`[Scalar(Nullable = true)]` emits `DateTimeOffset? Value`). A nullable scalar + round-trips JSON `null` and exposes `Null()`, which is `Hydrate(null)` for the unknown case and never + validates; use `Create(null)` when `null` is input that must be checked. - The default property name is `Value`; `ScalarAttribute.PropertyName` changes it. A `[Scalar]` type with no matching property is `VO1004`. - Prefer `readonly record struct`. Anything else is `VO1006`, because equality, immutability and diff --git a/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs b/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs index cf9a346..11239d1 100644 --- a/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs +++ b/src/src/SourceGenerator/ValueObject/Models/ScalarValueObjectModel.cs @@ -54,6 +54,7 @@ sealed record class ScalarValueObjectModel( bool DeclareOnValidate, bool ImplementsSelfEquatable, bool EmptyExists, + bool NullFactoryExists, string EmptyValueExpression, bool HasPrimitiveToValueObjectConversion, bool HasValueObjectToPrimitiveConversion, diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs index 5873182..dbdf920 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs @@ -29,6 +29,7 @@ static void EmitBody(CodeWriter writer, ScalarValueObjectModel model, bool emitE EmitScalarInterfaceValue(writer, model); EmitFactories(writer, model); EmitEmpty(writer, model); + EmitNullFactory(writer, model); EmitTryCreate(writer, model); EmitComparison(writer, model); EmitConstructor(writer, model); @@ -304,6 +305,27 @@ static void EmitEmpty(CodeWriter writer, ScalarValueObjectModel model) ); } + static void EmitNullFactory(CodeWriter writer, ScalarValueObjectModel model) + { + // Only a nullable scalar has a "null" domain value; a non-nullable value type's default + // (DateTimeOffset.MinValue, 0, ...) is a real value, not an unknown one. + if (!model.ScalarValueIsNullable || model.NullFactoryExists) + return; + + writer + .XmlSummary( + "Creates the value object for the unknown (null) value. Never runs validation; use", + $"{model.TypeModel.Name}.Create(null) when null is input that must be validated." + ) + .MethodExpression( + new("Null", ValueObjectType(model), TypeDeclarationAccessibility.Public) + { + IsStatic = true, + ExpressionBody = "Hydrate(null)", + } + ); + } + static void EmitTryCreate(CodeWriter writer, ScalarValueObjectModel model) { if (model.TryCreateExists) diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs index 368b762..a3909d2 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectModelBuilder.cs @@ -65,6 +65,7 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "Scalar", loca typeSymbol, scalarOptions, scalarAttribute, + compilation, location, diagnosticsList, out var scalarType, @@ -318,6 +319,7 @@ formatProviderType is not null declareOnValidate, ValueObjectSymbolInspector.ImplementsSelfEquatable(typeSymbol), ValueObjectSymbolInspector.HasMemberWithName(typeSymbol, "Empty"), + ValueObjectSymbolInspector.HasMemberWithName(typeSymbol, "Null"), ValueObjectSymbolInspector.GetEmptyValueExpression(scalarType), ValueObjectSymbolInspector.HasConversionOperator(typeSymbol, scalarType, fromPrimitive: true), ValueObjectSymbolInspector.HasConversionOperator(typeSymbol, scalarType, fromPrimitive: false), @@ -510,12 +512,16 @@ List diagnostics /// /// The manual [Scalar] form requires the author to declare the named property (VO1004); /// the automatic [Scalar<T>] form derives the type from the attribute argument and forbids - /// a declared member with that name (VO1022). + /// a declared member with that name (VO1022). Nullable = true only affects the automatic + /// form: it annotates a reference type or wraps a non-nullable value type in , + /// so [Scalar<DateTimeOffset>(Nullable = true)] emits the same property as the manual form + /// declaring a DateTimeOffset? member. /// static bool TryResolveScalarTarget( INamedTypeSymbol typeSymbol, ScalarAttributeData scalarOptions, AttributeData scalarAttribute, + Compilation compilation, Location location, List diagnostics, out ITypeSymbol scalarType, @@ -531,10 +537,11 @@ out IPropertySymbol? scalarProperty if (ScalarAttributeParser.GetValueType(scalarAttribute) is { } declaredType) { - scalarType = - ScalarAttributeParser.GetNullable(scalarAttribute) && declaredType.IsReferenceType - ? declaredType.WithNullableAnnotation(NullableAnnotation.Annotated) - : declaredType; + scalarType = ApplyNullableOption( + declaredType, + ScalarAttributeParser.GetNullable(scalarAttribute), + compilation + ); generateScalarProperty = true; // The generator owns the named property, so a declared member with that name (or with the @@ -587,6 +594,34 @@ out IPropertySymbol? scalarProperty return true; } + /// + /// Applies Nullable = true to the automatic form's declared value type. A reference type gains + /// the nullable annotation; a non-nullable value type is wrapped in , which is + /// how nullability is represented for a value type. An already-nullable value type is left alone so + /// [Scalar<int?>(Nullable = true)] does not double-wrap, and a ref-like struct is left alone + /// because cannot be constructed over a ref struct. + /// + static ITypeSymbol ApplyNullableOption(ITypeSymbol declaredType, bool nullable, Compilation compilation) + { + if (!nullable) + return declaredType; + + if (declaredType.IsReferenceType) + return declaredType.WithNullableAnnotation(NullableAnnotation.Annotated); + + if ( + !declaredType.IsValueType + || declaredType is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } + || declaredType is INamedTypeSymbol { IsRefLikeType: true } + ) + { + return declaredType; + } + + // The type is a non-nullable value type, so wrap it in Nullable to represent nullability. + return compilation.GetSpecialType(SpecialType.System_Nullable_T).Construct(declaredType); + } + /// /// True when the type already declares, in user-authored source, a property or field with the given /// name. Used by the automatic form, where the generator owns the named property: a declared member diff --git a/src/src/ValueObjects/Sdk/README.md b/src/src/ValueObjects/Sdk/README.md index 0546b2c..11ee88b 100644 --- a/src/src/ValueObjects/Sdk/README.md +++ b/src/src/ValueObjects/Sdk/README.md @@ -69,7 +69,8 @@ MSBuild with `ValueObjectsDefaultTrim`, `ValueObjectsDefaultCasing`, and one pro `[Scalar]` wraps a single primitive; `[ValueObject]` wraps multiple members. The manual `[Scalar]` form declares the underlying property; the automatic `[Scalar]` / `[Scalar(typeof(T))]` forms have the -generator declare it (and `[Scalar(Nullable = true)]` expresses a nullable reference scalar). +generator declare it (and `[Scalar(Nullable = true)]` or `[Scalar(Nullable = true)]` +expresses a nullable scalar). ## Formatting and parsing diff --git a/src/src/ValueObjects/Serialization/ScalarAttribute.cs b/src/src/ValueObjects/Serialization/ScalarAttribute.cs index 03598bb..f6edf23 100644 --- a/src/src/ValueObjects/Serialization/ScalarAttribute.cs +++ b/src/src/ValueObjects/Serialization/ScalarAttribute.cs @@ -40,7 +40,9 @@ public ScalarAttribute(string propertyName = "Value") /// /// The underlying value type, for example typeof(string) or typeof(int?). A nullable /// reference type cannot be a typeof operand (CS8639); use - /// for those, for example [Scalar(typeof(string), Nullable = true)]. + /// for those, for example [Scalar(typeof(string), Nullable = true)]. The option also wraps a + /// non-nullable value type, so [Scalar(typeof(int), Nullable = true)] declares an int? + /// property. /// /// The name of the property the generator declares. public ScalarAttribute(Type valueType, string propertyName = "Value") diff --git a/src/src/ValueObjects/Serialization/ScalarAttributeOfT.cs b/src/src/ValueObjects/Serialization/ScalarAttributeOfT.cs index 25cb698..76a1657 100644 --- a/src/src/ValueObjects/Serialization/ScalarAttributeOfT.cs +++ b/src/src/ValueObjects/Serialization/ScalarAttributeOfT.cs @@ -17,10 +17,12 @@ namespace Purview.ValueObjects.Serialization; /// Nullability. A nullable reference type cannot be used as a generic attribute argument (the /// compiler reports CS8970 because nullable annotations are not representable in attribute /// metadata), so [Scalar<string?>] does not compile. Set -/// to have the generator declare the property as a nullable -/// reference type: [Scalar<string>(Nullable = true)] produces -/// public string? Value { get; init; }. This applies to reference types only; nullable value types -/// are written directly, for example [Scalar<int?>]. +/// to have the generator declare the property as nullable: +/// [Scalar<string>(Nullable = true)] produces public string? Value { get; init; }, and +/// [Scalar<DateTimeOffset>(Nullable = true)] produces +/// public DateTimeOffset? Value { get; init; }. Nullable value types can also be written directly, +/// for example [Scalar<int?>]. A nullable scalar exposes an additional +/// Null() factory, equivalent to Hydrate(null). /// /// /// Readability. The automatic form is concise, but the underlying type and nullability are diff --git a/src/src/ValueObjects/Serialization/ScalarOptionsAttribute.cs b/src/src/ValueObjects/Serialization/ScalarOptionsAttribute.cs index 46ae93a..afb83eb 100644 --- a/src/src/ValueObjects/Serialization/ScalarOptionsAttribute.cs +++ b/src/src/ValueObjects/Serialization/ScalarOptionsAttribute.cs @@ -23,12 +23,13 @@ public abstract class ScalarOptionsAttribute(string propertyName) : Attribute public string PropertyName { get; } = propertyName; /// - /// Gets or sets whether the generator declares the underlying property as a nullable reference type. + /// Gets or sets whether the generator declares the underlying property as nullable. /// /// Defaults to . /// /// Applies to the automatic forms ( or - /// ) and to reference types only. Nullable value types are + /// ) only. A reference type gains the nullable annotation; + /// a non-nullable value type is wrapped in . Nullable value types can also be /// expressed directly, for example [Scalar<int?>] or [Scalar(typeof(int?))]. A /// nullable reference type cannot be used as a generic attribute argument or a typeof operand /// (CS8970/CS8639), so Nullable = true is the way to declare one. diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ScalarAutomaticPropertyTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ScalarAutomaticPropertyTests.cs index 20084fc..e0f4ac3 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/ScalarAutomaticPropertyTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/ScalarAutomaticPropertyTests.cs @@ -545,4 +545,136 @@ public readonly partial record struct OverriddenId { } await Assert.That(defaultedEmpty).IsNull(); await Assert.That(overriddenEmpty).IsNotNull(); } + + [Test] + public async Task ScalarGeneration_GivenNullableValueTypeOption_GeneratesNullableProperty( + CancellationToken cancellationToken + ) + { + // Nullable value types were the one shape Nullable did not apply to, so [Scalar( + // Nullable = true)] declared a non-nullable DateTimeOffset property. The option now wraps the value + // type (and is idempotent for a type that is already nullable), matching the direct T? spellings. + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(Nullable = true)] + public readonly partial record struct OptionalTimestamp { } + + [Purview.ValueObjects.Serialization.Scalar(typeof(System.DateTimeOffset), Nullable = true)] + public readonly partial record struct TypeofOptionalTimestamp { } + + [Purview.ValueObjects.Serialization.Scalar(Nullable = true)] + public readonly partial record struct AlreadyNullableTimestamp { } + + public static class Harness + { + public static System.Type GenericPropertyType() => + typeof(OptionalTimestamp).GetProperty("Value")!.PropertyType; + + public static System.Type TypeofPropertyType() => + typeof(TypeofOptionalTimestamp).GetProperty("Value")!.PropertyType; + + public static System.Type AlreadyNullablePropertyType() => + typeof(AlreadyNullableTimestamp).GetProperty("Value")!.PropertyType; + + public static System.DateTimeOffset? CreateNull() => OptionalTimestamp.Create(null).Value; + + public static System.DateTimeOffset? NullValue() => OptionalTimestamp.Null().Value; + + public static string SerializeNull() => + System.Text.Json.JsonSerializer.Serialize(OptionalTimestamp.Null()); + + public static System.DateTimeOffset? DeserializeNull() => + System.Text.Json.JsonSerializer.Deserialize("null").Value; + } + } + """; + + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harness = assembly!.GetType("Testing.Harness")!; + + var genericPropertyType = (Type)harness.GetMethod("GenericPropertyType")!.Invoke(null, null)!; + var typeofPropertyType = (Type)harness.GetMethod("TypeofPropertyType")!.Invoke(null, null)!; + var alreadyNullablePropertyType = (Type)harness.GetMethod("AlreadyNullablePropertyType")!.Invoke(null, null)!; + var createNull = harness.GetMethod("CreateNull")!.Invoke(null, null); + var nullValue = harness.GetMethod("NullValue")!.Invoke(null, null); + var serialized = (string)harness.GetMethod("SerializeNull")!.Invoke(null, null)!; + var deserialized = harness.GetMethod("DeserializeNull")!.Invoke(null, null); + + await Assert.That(genericPropertyType).IsEqualTo(typeof(DateTimeOffset?)); + await Assert.That(typeofPropertyType).IsEqualTo(typeof(DateTimeOffset?)); + await Assert.That(alreadyNullablePropertyType).IsEqualTo(typeof(DateTimeOffset?)); + await Assert.That(createNull).IsNull(); + await Assert.That(nullValue).IsNull(); + await Assert.That(serialized).IsEqualTo("null"); + await Assert.That(deserialized).IsNull(); + } + + [Test] + public async Task ScalarGeneration_GivenNullableValueTypeOption_MatchesManualNullableForm( + CancellationToken cancellationToken + ) + { + // The two declarations differ only in the type name and in who declares the property. The generated + // members must therefore be equivalent: same nullable property, same factories, same JSON contract. + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(Nullable = true)] + public readonly partial record struct OptionalByAttribute { } + + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct OptionalByProperty + { + public System.DateTimeOffset? Value { get; init; } + } + + public static class Harness + { + public static bool SamePropertyType() => + typeof(OptionalByAttribute).GetProperty("Value")!.PropertyType + == typeof(OptionalByProperty).GetProperty("Value")!.PropertyType; + + public static bool BothHaveNullableCreate() => + typeof(OptionalByAttribute).GetMethod("Create", [typeof(System.DateTimeOffset?)]) is not null + && typeof(OptionalByProperty).GetMethod("Create", [typeof(System.DateTimeOffset?)]) is not null; + + public static bool BothHaveNullFactory() => + typeof(OptionalByAttribute).GetMethod("Null", System.Type.EmptyTypes) is not null + && typeof(OptionalByProperty).GetMethod("Null", System.Type.EmptyTypes) is not null; + + public static string SerializeAttributeNull() => + System.Text.Json.JsonSerializer.Serialize(OptionalByAttribute.Null()); + + public static string SerializePropertyNull() => + System.Text.Json.JsonSerializer.Serialize(OptionalByProperty.Null()); + } + } + """; + + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harness = assembly!.GetType("Testing.Harness")!; + + var samePropertyType = (bool)harness.GetMethod("SamePropertyType")!.Invoke(null, null)!; + var bothHaveNullableCreate = (bool)harness.GetMethod("BothHaveNullableCreate")!.Invoke(null, null)!; + var bothHaveNullFactory = (bool)harness.GetMethod("BothHaveNullFactory")!.Invoke(null, null)!; + var attributeJson = (string)harness.GetMethod("SerializeAttributeNull")!.Invoke(null, null)!; + var propertyJson = (string)harness.GetMethod("SerializePropertyNull")!.Invoke(null, null)!; + + await Assert.That(samePropertyType).IsTrue(); + await Assert.That(bothHaveNullableCreate).IsTrue(); + await Assert.That(bothHaveNullFactory).IsTrue(); + await Assert.That(attributeJson).IsEqualTo("null"); + await Assert.That(propertyJson).IsEqualTo(attributeJson); + } } diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ScalarNullFactoryTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ScalarNullFactoryTests.cs new file mode 100644 index 0000000..338894d --- /dev/null +++ b/src/tests/SourceGenerator.UnitTests/Generators/ScalarNullFactoryTests.cs @@ -0,0 +1,152 @@ +namespace Purview.ValueObjects.SourceGenerator.Generators; + +/// +/// Tests for the generated Null() factory. It is the explicit unknown value for a nullable scalar and +/// mirrors Empty: it constructs through Hydrate(null), so it never validates or normalizes. +/// +public sealed class ScalarNullFactoryTests : ValueObjectSourceGeneratorTestBase +{ + [Test] + public async Task NullFactory_GivenNullableValueType_ReturnsNull(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct OptionalQuantity { } + + [Purview.ValueObjects.Serialization.Scalar(Nullable = true)] + public readonly partial record struct OptionalTimestamp { } + + public static class Harness + { + public static bool QuantityIsNull() => OptionalQuantity.Null().Value is null; + + public static bool TimestampIsNull() => OptionalTimestamp.Null().Value is null; + } + } + """; + + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harness = assembly!.GetType("Testing.Harness")!; + + var quantityIsNull = (bool)harness.GetMethod("QuantityIsNull")!.Invoke(null, null)!; + var timestampIsNull = (bool)harness.GetMethod("TimestampIsNull")!.Invoke(null, null)!; + + await Assert.That(quantityIsNull).IsTrue(); + await Assert.That(timestampIsNull).IsTrue(); + } + + [Test] + public async Task NullFactory_GivenNullableReferenceType_ReturnsNull(CancellationToken cancellationToken) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar(Nullable = true)] + public readonly partial record struct Nickname { } + + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct ManualNickname + { + public string? Value { get; init; } + } + + public static class Harness + { + public static bool AutomaticIsNull() => Nickname.Null().Value is null; + + public static bool ManualIsNull() => ManualNickname.Null().Value is null; + } + } + """; + + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + var harness = assembly!.GetType("Testing.Harness")!; + + var automaticIsNull = (bool)harness.GetMethod("AutomaticIsNull")!.Invoke(null, null)!; + var manualIsNull = (bool)harness.GetMethod("ManualIsNull")!.Invoke(null, null)!; + + await Assert.That(automaticIsNull).IsTrue(); + await Assert.That(manualIsNull).IsTrue(); + } + + [Test] + public async Task NullFactory_GivenNonNullableScalar_IsNotGenerated(CancellationToken cancellationToken) + { + // A non-nullable value type's default (Guid.Empty, 0, DateTimeOffset.MinValue) is a real value, not + // the unknown one; a non-nullable reference scalar rejects null. Neither gets a Null factory. + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct InstallationId { } + + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct RequiredName + { + public string Value { get; init; } + } + } + """; + + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); + + await Assert.That(result.Generated().GetRecord("InstallationId", "Testing").HasMethod("Null")).IsFalse(); + await Assert.That(result.Generated().GetRecord("RequiredName", "Testing").HasMethod("Null")).IsFalse(); + } + + [Test] + public async Task NullFactory_GivenAuthorDeclaredNull_IsNotDuplicated(CancellationToken cancellationToken) + { + // The generated partial cannot edit the consumer's copy, so a duplicate member would be CS0111. The + // author's factory wins and the generated one is suppressed. + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct OptionalQuantity + { + public static OptionalQuantity Null() => Hydrate(null); + } + + public static class Harness + { + public static bool IsNull() => OptionalQuantity.Null().Value is null; + } + } + """; + + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); + + var optionalQuantity = assembly!.GetType("Testing.OptionalQuantity")!; + var nullMethods = optionalQuantity + .GetMethods() + .Where(method => method.Name == "Null" && method.GetParameters().Length == 0) + .ToArray(); + var harness = assembly.GetType("Testing.Harness")!; + var isNull = (bool)harness.GetMethod("IsNull")!.Invoke(null, null)!; + + await Assert.That(nullMethods.Length).IsEqualTo(1); + await Assert.That(isNull).IsTrue(); + } +}