Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<DateTimeOffset>(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<T>` (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

Expand Down
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<RoslynVersion>5.9.0</RoslynVersion>
<TUnitVersion>1.76.0</TUnitVersion>
<PurviewSGFVersion>1.2.0</PurviewSGFVersion>
<PurviewZodSharpVersion>2.3.0</PurviewZodSharpVersion>
<PurviewZodSharpVersion>2.4.1</PurviewZodSharpVersion>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Purview.SourceGeneratorFramework" Version="$(PurviewSGFVersion)" />
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>]` / `[Scalar(typeof(T))]` forms have the
generator declare it (and `[Scalar<string>(Nullable = true)]` expresses a nullable reference scalar). See
generator declare it (and `[Scalar<string>(Nullable = true)]` or `[Scalar<DateTimeOffset>(Nullable = true)]`
expresses a nullable scalar). See
[Getting Started](docs/Getting-Started.md#automatic-vs-manual-underlying-property).

## JSON serialization
Expand Down
21 changes: 15 additions & 6 deletions docs/Getting-Started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>`. Nullable reference types
cannot be generic attribute arguments (`[Scalar<string?>]` does not compile — `CS8970`) or `typeof`
operands (`typeof(string?)` does not compile — `CS8639`), so express nullability on the attribute:

```csharp
[Scalar<string>(Nullable = true)] // emits `public string? Value { get; init; }`
[Scalar(typeof(string), Nullable = true)] // same
[Scalar<DateTimeOffset>(Nullable = true)] // emits `public DateTimeOffset? Value { get; init; }`
public readonly partial record struct Handle { }
```

Nullable value types are written directly, for example `[Scalar<int?>]` or `[Scalar(typeof(int?))]`.
Nullable value types can also be written directly, for example `[Scalar<int?>]` 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<string>(Nullable = true)]` and `[Scalar<int?>]`
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<string>(Nullable = true)]`, `[Scalar<int?>]`, and
`[Scalar<DateTimeOffset>(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<string>(Nullable = true)]
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/Value-Object-Design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "value-objects",
"version": "1.2.0",
"version": "1.2.1",
"license": "MIT",
"author": {
"name": "Kieron Lanning",
Expand Down
4 changes: 4 additions & 0 deletions skills/purview-value-objects/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ public readonly partial record struct EmailAddress

- Declare the scalar property yourself (`[Scalar]`), or let the generator own it (`[Scalar<string>]`).
With `[Scalar<T>]` 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<DateTimeOffset>(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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ sealed record class ScalarValueObjectModel(
bool DeclareOnValidate,
bool ImplementsSelfEquatable,
bool EmptyExists,
bool NullFactoryExists,
string EmptyValueExpression,
bool HasPrimitiveToValueObjectConversion,
bool HasValueObjectToPrimitiveConversion,
Expand Down
22 changes: 22 additions & 0 deletions src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ .. ValueObjectSymbolInspector.ValidateValueObjectType(typeSymbol, "Scalar", loca
typeSymbol,
scalarOptions,
scalarAttribute,
compilation,
location,
diagnosticsList,
out var scalarType,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -510,12 +512,16 @@ List<ReportableDiagnostic> diagnostics
/// <remarks>
/// The manual <c>[Scalar]</c> form requires the author to declare the named property (<c>VO1004</c>);
/// the automatic <c>[Scalar&lt;T&gt;]</c> form derives the type from the attribute argument and forbids
/// a declared member with that name (<c>VO1022</c>).
/// a declared member with that name (<c>VO1022</c>). <c>Nullable = true</c> only affects the automatic
/// form: it annotates a reference type or wraps a non-nullable value type in <see cref="Nullable{T}"/>,
/// so <c>[Scalar&lt;DateTimeOffset&gt;(Nullable = true)]</c> emits the same property as the manual form
/// declaring a <c>DateTimeOffset?</c> member.
/// </remarks>
static bool TryResolveScalarTarget(
INamedTypeSymbol typeSymbol,
ScalarAttributeData scalarOptions,
AttributeData scalarAttribute,
Compilation compilation,
Location location,
List<ReportableDiagnostic> diagnostics,
out ITypeSymbol scalarType,
Expand All @@ -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
Expand Down Expand Up @@ -587,6 +594,34 @@ out IPropertySymbol? scalarProperty
return true;
}

/// <summary>
/// Applies <c>Nullable = true</c> to the automatic form's declared value type. A reference type gains
/// the nullable annotation; a non-nullable value type is wrapped in <see cref="Nullable{T}"/>, which is
/// how nullability is represented for a value type. An already-nullable value type is left alone so
/// <c>[Scalar&lt;int?&gt;(Nullable = true)]</c> does not double-wrap, and a ref-like struct is left alone
/// because <see cref="Nullable{T}"/> cannot be constructed over a <c>ref struct</c>.
/// </summary>
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<T> to represent nullability.
return compilation.GetSpecialType(SpecialType.System_Nullable_T).Construct(declaredType);
}

/// <summary>
/// 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
Expand Down
3 changes: 2 additions & 1 deletion src/src/ValueObjects/Sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>]` / `[Scalar(typeof(T))]` forms have the
generator declare it (and `[Scalar<string>(Nullable = true)]` expresses a nullable reference scalar).
generator declare it (and `[Scalar<string>(Nullable = true)]` or `[Scalar<DateTimeOffset>(Nullable = true)]`
expresses a nullable scalar).

## Formatting and parsing

Expand Down
4 changes: 3 additions & 1 deletion src/src/ValueObjects/Serialization/ScalarAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ public ScalarAttribute(string propertyName = "Value")
/// <param name="valueType">
/// The underlying value type, for example <c>typeof(string)</c> or <c>typeof(int?)</c>. A nullable
/// reference type cannot be a <c>typeof</c> operand (CS8639); use <see cref="ScalarOptionsAttribute.Nullable"/>
/// for those, for example <c>[Scalar(typeof(string), Nullable = true)]</c>.
/// for those, for example <c>[Scalar(typeof(string), Nullable = true)]</c>. The option also wraps a
/// non-nullable value type, so <c>[Scalar(typeof(int), Nullable = true)]</c> declares an <c>int?</c>
/// property.
/// </param>
/// <param name="propertyName">The name of the property the generator declares.</param>
public ScalarAttribute(Type valueType, string propertyName = "Value")
Expand Down
10 changes: 6 additions & 4 deletions src/src/ValueObjects/Serialization/ScalarAttributeOfT.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ namespace Purview.ValueObjects.Serialization;
/// <b>Nullability.</b> A nullable reference type cannot be used as a generic attribute argument (the
/// compiler reports <c>CS8970</c> because nullable annotations are not representable in attribute
/// metadata), so <c>[Scalar&lt;string?&gt;]</c> does not compile. Set
/// <see cref="ScalarOptionsAttribute.Nullable"/> to have the generator declare the property as a nullable
/// reference type: <c>[Scalar&lt;string&gt;(Nullable = true)]</c> produces
/// <c>public string? Value { get; init; }</c>. This applies to reference types only; nullable value types
/// are written directly, for example <c>[Scalar&lt;int?&gt;]</c>.
/// <see cref="ScalarOptionsAttribute.Nullable"/> to have the generator declare the property as nullable:
/// <c>[Scalar&lt;string&gt;(Nullable = true)]</c> produces <c>public string? Value { get; init; }</c>, and
/// <c>[Scalar&lt;DateTimeOffset&gt;(Nullable = true)]</c> produces
/// <c>public DateTimeOffset? Value { get; init; }</c>. Nullable value types can also be written directly,
/// for example <c>[Scalar&lt;int?&gt;]</c>. A nullable scalar exposes an additional
/// <c>Null()</c> factory, equivalent to <c>Hydrate(null)</c>.
/// </para>
/// <para>
/// <b>Readability.</b> The automatic form is concise, but the underlying type and nullability are
Expand Down
5 changes: 3 additions & 2 deletions src/src/ValueObjects/Serialization/ScalarOptionsAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,13 @@ public abstract class ScalarOptionsAttribute(string propertyName) : Attribute
public string PropertyName { get; } = propertyName;

/// <summary>
/// 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.
/// </summary>
/// <value>Defaults to <see langword="false"/>.</value>
/// <remarks>
/// Applies to the automatic forms (<see cref="ScalarAttribute{TValue}"/> or
/// <see cref="ScalarAttribute(Type, string)"/>) and to reference types only. Nullable value types are
/// <see cref="ScalarAttribute(Type, string)"/>) only. A reference type gains the nullable annotation;
/// a non-nullable value type is wrapped in <see cref="Nullable{T}"/>. Nullable value types can also be
/// expressed directly, for example <c>[Scalar&lt;int?&gt;]</c> or <c>[Scalar(typeof(int?))]</c>. A
/// nullable reference type cannot be used as a generic attribute argument or a <c>typeof</c> operand
/// (CS8970/CS8639), so <c>Nullable = true</c> is the way to declare one.
Expand Down
Loading
Loading