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
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,39 @@ newest heading below is the version in development. Earlier releases are listed
`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.
- **`HasValue` for null-capable scalars.** A nullable scalar, and any reference-backed scalar (whose
`Empty`/`Hydrate(null!)` value is null even when the domain contract says non-null), exposes
`public bool HasValue`. It reports whether the underlying value is null without validating or
normalizing, is not generated for a non-nullable value-type scalar, and is skipped when the author
declares `HasValue`.
- **Nullable scalars map to nullable Entity Framework Core columns.** The generated registry unwraps
`Nullable<T>` before matching the converter, so a `NullableString?` property uses the generated
`ValueConverter<NullableString, string>` with no hand-written converter, and both `T` and `T?`
properties are mapped. `ScalarMapping` now carries the scalar's nullability; the registry marks a
mapped property optional where EF Core accepts it, never overrides an explicit `.IsRequired(...)` or
`[Required]`, and never marks a key nullable. EF Core cannot mark a non-nullable struct property
optional (`CannotBeNullable`), so a nullable scalar used as a non-nullable entity property stays a
required column; the new `VO1031` warning reports that at compile time. The Entity Framework guide
documents the `T?` shape and the two-scalar (`NonNullableString?` + nullable DTO scalar) pattern,
and `src/src/Sample` demonstrates it for `string`, `DateTimeOffset`, and `int`.
- **Nullability conflicts are reported specifically.** A `[Scalar<T>(Nullable = true)]` combined with a
declared `Value` is `VO1028` (with a code fix offering three repairs: remove the property, convert to
the manual `[Scalar]` form, or remove only `Nullable = true`); explicit `Nullable = false` with a
nullable `Value` is `VO1029`; a declared `Value` whose type does not match the underlying type is
`VO1030`. The fully manual `[Scalar]` form with a nullable property remains valid.
- **`VO1031`** — a nullable scalar used as a non-nullable property on an entity or complex-type-mapped
`[ValueObject]`. Scoped to types the compilation can see being mapped, so DTO properties of nullable
scalars are not reported.

### Changed

- **Extraction is explicit for nullable scalars only.** A nullable scalar emits
`explicit operator TValue(TSelf)`, so `value == null`, `value != null`, `null == value`, and
comparisons with the underlying value are unambiguous against the record's synthesized
`(TSelf, TSelf)` equality, and they read the underlying value directly — no normalization or
validation. Extraction for a nullable scalar is `(TValue)value` or `value.Value`. A **non-nullable**
scalar keeps the implicit conversion, so `string raw = value;` and passing the value object where its
underlying primitive is expected continue to work.

## 1.1.0

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Adds F#-style single-case types to C#. Mark a `partial` struct or class with `[S
incremental source generator produces:

- `Create` / `Hydrate` / `TryCreate` factories with `OnNormalize` normalization and `OnValidate` validation
- `Empty` instances, equality, comparison, `CompareTo`, `ToString` (including the underlying value's `IFormattable` and format overloads), and implicit conversions
- `Empty` instances, `HasValue` for null-capable scalars, equality, comparison, `CompareTo`, `ToString` (including the underlying value's `IFormattable` and format overloads), implicit conversions (with explicit extraction for nullable scalars)
- The underlying value's standard interfaces — `IEquatable<T>`, `ISpanFormattable`, `IUtf8SpanFormattable`, `IParsable<T>`, `ISpanParsable<T>`, and `IUtf8SpanParsable<T>` — mirrored per type
- JSON converters (scalar value objects serialize as their underlying value)
- Contextual creation via `IContextualValueObject<,>` + `ValueObjectContext<T>`
Expand Down
10 changes: 10 additions & 0 deletions docs/Diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ These govern the declaration shape a value object must have for the generator to
| `VO1008` | Error | `[Scalar]` and `[ValueObject]` are both applied to the same type. | Pick one. A scalar wraps a single primitive; a value object composes members. |
| `VO1016` | Warning | A value object member is mutable. | Make the member `readonly`/`init`-only. A mutable member breaks immutability and makes equality unstable. |
| `VO1022` | Error | A `[Scalar<T>]` type declares a member with the name of the property the generator owns. | Remove the declaration, or use `[Scalar]` (without a type argument) to own the property yourself. |
| `VO1028` | Error | `[Scalar<T>(Nullable = true)]` is combined with a declared `Value` property, so nullability has two sources. | Remove the declared `Value`; convert to the manual `[Scalar]` form; or remove `Nullable = true`. The code fix offers all three. |
| `VO1029` | Error | Explicit `Nullable = false` contradicts a declared nullable `Value` property. | Remove the declared `Value`, or use the manual `[Scalar]` form and own the property. |
| `VO1030` | Error | The automatic form's declared `Value` has a type that does not match the scalar's underlying type. | Remove the declaration, or use the manual `[Scalar]` form with a matching type. |

## ZodSharp validation rules

Expand Down Expand Up @@ -61,6 +64,13 @@ See [Entity Framework integration](Entity-Framework.md) for the mapping model.
| `VO1018` | Warning | EF complex mapping cannot convert one of the members. | Simplify the member, or map it explicitly. |
| `VO1019` | Warning | EF complex-type mapping is requested but the project resolves to EF Core 7 or earlier. | Target EF Core 8+, or use a different `EntityFrameworkMapping`. |
| `VO1021` | Warning | EF key value generation is unavailable for the value object. | Assign keys explicitly instead of relying on store generation. |
| `VO1031` | Warning | A nullable scalar is used as a non-nullable property on an entity or complex-type-mapped `[ValueObject]`. EF Core maps that to a required column and cannot persist the scalar's `Null()`. | Declare the property as `T?`, or use a non-nullable scalar behind a nullable property. See [Entity Framework](Entity-Framework.md#nullable-scalars-and-nullable-columns). |

`VO1031` is scoped to types the compilation can see being mapped (a `DbSet<T>` on a `DbContext`, an
`IEntityTypeConfiguration<T>` target, or a complex-type-mapped `[ValueObject]`). Plain DTOs are not
reported: a DTO property of a nullable scalar is the supported way to represent the scalar's `Null()`
value. Entity types configured only in a referenced assembly, or built entirely at runtime, are not
discovered; the rule stays a warning and can be suppressed when the non-nullable shape is deliberate.

## Retired identifiers

Expand Down
113 changes: 113 additions & 0 deletions docs/Entity-Framework.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,113 @@ What the mapping does:
- Complex value objects with `[ValueObject(EFMapping = EntityFrameworkMapping.Json)]` map to a single JSON column using the
generated JSON converter.

## Nullable scalars and nullable columns

A scalar can represent null (`[Scalar<string>(Nullable = true)]`, `[Scalar<int?>]`, or a manual
`[Scalar]` with a nullable `Value` property). Entity Framework Core derives a **column's** nullability
from the CLR property type, so the registry:

- unwraps `Nullable<T>` before matching the converter, so a `T?` property uses the generated `T`
converter — no hand-written `ValueConverter` is needed;
- marks a mapped property optional when the scalar is nullable (or the property is `T?`) **where EF
Core permits it**, never overrides an explicit `.IsRequired(...)`/`[Required]`, and never marks a key
nullable.

| Entity property | Column | What a database `NULL` means |
| --- | --- | --- |
| `NullableString` (a non-nullable struct) | required | impossible; `VO1031` reports it |
| `NullableString?` | nullable | both a CLR `null` and `NullableString.Null()` |
| `NonNullableString?` (two-scalar pattern) | nullable | absence only; no value can collapse into it |

`HasValue` is generated for a nullable scalar and for any reference-backed scalar, so
`dto.Email.HasValue` inspects the value without constructing one through `Create`.

EF Core cannot mark a non-nullable struct property optional: `Property.SetIsNullable(true)` throws
`CannotBeNullable`. A nullable scalar on the non-nullable shape therefore stays a required column (and
`NullableString.Null()` cannot be persisted through it); `VO1031` reports that at compile time. The
fix is one of:

1. declare the entity property as `T?`; or
2. keep a non-nullable scalar in the entity (`NonNullableString?`) and map the nullable DTO scalar at
the boundary.

### Keeping "absent" distinct from "null"

The two-scalar pattern is the stricter option: the entity stores a non-nullable scalar behind a
nullable property, so a database `NULL` can only mean "no value", and the domain's `Null()` value lives
in the DTO:

```csharp
// Persistence scalar: never null, and Create canonicalises input.
[Scalar<string>(Trim = true, Casing = StringCasing.LowerInvariant)]
public readonly partial record struct NonNullableString { }

// Domain/DTO scalar: null is a value.
[Scalar<string>(Nullable = true)]
public readonly partial record struct NullableString { }

public sealed class Customer
{
public NonNullableString? Email { get; set; } // nullable column, generated T converter
}

public sealed record CustomerDto
{
public NullableString Email { get; init; } // Null() = explicitly no value
}
```

Map at the boundary; `Hydrate` is the replay path (no re-validation), `Create` applies the
non-nullable scalar's normalization and validation:

```csharp
static CustomerDto ToDto(Customer entity) => new()
{
Email = entity.Email is null
? NullableString.Null()
: NullableString.Hydrate(entity.Email.Value.Value),
};

static Customer ToEntity(CustomerDto dto) => new()
{
Email = dto.Email.HasValue
? NonNullableString.Create(dto.Email.Value!)
: (NonNullableString?)null,
};
```

Because `HasValue` is a computed property, the compiler does not narrow `Value`; use `Value!` (or
`GetValueOrDefault()` for a value-type underlying) after the check.

> **The cast on the null branch matters for a reference-backed scalar.** A bare `null` is convertible
> to `NonNullableString` through the generated implicit creation operator, so the conditional would
> produce a *populated* scalar whose `Value` is null instead of a null `Nullable<T>`. Write
> `(NonNullableString?)null` (or branch with an `if`). A value-type-backed scalar (`int`,
> `DateTimeOffset`) does not have this problem because `null` is not convertible to its underlying
> type.

The conversion can also be expressed with explicit operators declared in one scalar's partial:

```csharp
[Scalar<string>(Nullable = true)]
public readonly partial record struct NullableString
{
public static explicit operator NullableString(NonNullableString? value) =>
value is null ? Null() : Hydrate(value.Value.Value);

public static explicit operator NonNullableString?(NullableString value) =>
value.Value is null ? (NonNullableString?)null : NonNullableString.Create(value.Value);
}

dto.Email = (NullableString)entity.Email;
entity.Email = (NonNullableString?)dto.Email;
```

The generator does not emit cross-scalar conversions: the pairing and the `Create`/`Hydrate` policy
belong to the boundary. The runnable [sample](../src/src/Sample) demonstrates the pattern for
`string`, `DateTimeOffset`, and `int`; the tests are in
`src/tests/ValueObjects.EFCompatibility.IntegrationTests`.

## Queries — no `.Value` required

Because scalar value objects convert to their underlying primitive column, queries compare the value object
Expand Down Expand Up @@ -342,6 +449,9 @@ modelBuilder.Entity<Tenant>().HasQueryFilter(tenant => tenant.Id == Platform.Sys
- A scalar value object is a **single column** of its provider primitive, so `dotnet ef migrations add`
sees the primitive: renaming the value object's property does not change the schema, and changing the
underlying type is a column type change you review like any other.
- A nullable scalar is a **nullable column only when the entity property is nullable** (`T?`): EF Core
derives a column's nullability from the CLR property type, not from the scalar's own ability to hold
null. A non-nullable struct property is a required column (`VO1031`), even for a nullable scalar.
- A complex value object mapped as a complex type is a **set of columns** named after its members, and one
mapped with `EFMapping = Json` is a **single JSON column**. Moving a value object between those shapes is
a schema change: add a migration and consider the data path (a JSON column usually needs a data migration
Expand Down Expand Up @@ -438,6 +548,9 @@ value-object provider assemblies referenced by EF consumers:
property manually.
- `VO1021` — `GenerateEFValueGenerator` was requested for a value object that is not Guid-backed, or whose
Entity Framework converter is disabled; no value generator is emitted.
- `VO1031` — a nullable scalar is used as a non-nullable property on an entity or complex type. EF Core
maps that to a required column and cannot persist the scalar's `Null()`; declare the property as `T?`
(or use the two-scalar pattern above).

## Notes

Expand Down
73 changes: 69 additions & 4 deletions docs/Getting-Started.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ The generator adds:
- `EmailAddress.Hydrate(string)` – constructs without re-validating (for persisted data).
- `EmailAddress.TryCreate(string, out EmailAddress)` – returns `false` instead of throwing.
- `EmailAddress.Empty` – a default instance.
- Equality, comparison, `CompareTo`, `ToString`, implicit conversions, and a JSON converter.
- Equality, comparison, `CompareTo`, `ToString`, implicit conversions (explicit extraction for nullable
scalars), and a JSON converter.
- The underlying value's standard interfaces (see [Formatting](#formatting) and [Standard interfaces](#standard-interfaces)) when it implements them.

```csharp
Expand Down Expand Up @@ -73,7 +74,8 @@ public readonly partial record struct EmailAddress { }

`[Scalar]` is the **manual** form: you declare the property. `[Scalar<T>]` and `[Scalar(typeof(T))]` are
the **automatic** form: the generator declares it. They are mutually exclusive for a given type —
declaring the property with an automatic form is an error (`VO1022`).
declaring the property with an automatic form is an error (`VO1022`, or the more specific
`VO1028`–`VO1030` when the declaration also asserts nullability).

```csharp
// Automatic: the generator emits `public Guid Value { get; init; }`.
Expand Down Expand Up @@ -126,9 +128,68 @@ A nullable scalar also exposes `Null()`, which creates the “unknown” value w
`Nickname.Null()` is equivalent to `Nickname.Hydrate(null)`. Use `Create(null)` instead when `null` is
input that should go through normalization and validation.

A nullable scalar — and any reference-backed scalar, whose `Empty`/`Hydrate(null!)` value is null even
when the domain contract says non-null — exposes `HasValue`, which reports whether the underlying value
is null without validating or normalizing: `Nickname.Null().HasValue` is `false`,
`Nickname.Create("x").HasValue` is `true`, and `Nickname.Empty.HasValue` is also `false`. A
non-nullable value-type scalar (for example `[Scalar<Guid>]`) has no `HasValue`; it is always present.

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.
property declaration. Both forms are supported on their own; they cannot be combined. Declaring the
generator-owned property is reported as an error, with a specific rule for each nullability conflict:

| Rule | When |
| --- | --- |
| `VO1022` | An automatic form declares a member with the generator-owned name (the plain duplicate). |
| `VO1028` | `Nullable = true` is combined with a declared `Value`: nullability has two sources. |
| `VO1029` | `Nullable = false` is explicit but the declared `Value` is nullable. |
| `VO1030` | The declared `Value` type does not match the scalar's underlying type. |

`VO1028` has a code fix offering three repairs: remove the declared property; convert the attribute to
the manual `[Scalar]` form; or remove only `Nullable = true`. Other properties are allowed in either
form; only the scalar property (and the interface's `Value` forwarding) is reserved.

When a nullable scalar is used as an Entity Framework Core property, declare the property as `T?`; see
[Entity Framework](Entity-Framework.md#nullable-scalars-and-nullable-columns). Using it as a
non-nullable entity property is reported as `VO1031`.

### Comparing with `null` and the underlying value

Equality comparisons work against the underlying value and against `null`, in either operand order:

```csharp
NullableString value = NullableString.Null();

_ = value == null; // true
_ = null == value; // true
_ = value != null; // false

NullableString name = NullableString.Create("value");
_ = name == "value"; // true
_ = "value" == name; // true
_ = name == null; // false
```

`default(T)`, `Empty`, `Null()`, and `Create(null)` all compare equal to `null`, symmetrically. The
comparison reads the underlying value directly — it never calls `Create`, so it neither normalizes nor
validates.

`is null` does not work for a non-nullable struct (CS0037); use `== null`/`!= null`. A **nullable** or
reference-backed scalar also exposes `HasValue`, which inspects the underlying value without constructing
one (and therefore without normalizing or validating), and is the cheap null check. Extraction is
**explicit for a nullable scalar**, so write `.Value` or a cast; a **non-nullable** scalar keeps the
implicit conversion, so `string raw = email;` continues to work:

```csharp
string rawNullable = (string)name; // or name.Value
string rawRequired = email; // implicit for a non-nullable scalar
```

The conversion **from** the underlying value remains implicit for both, so
`NullableString value = "value";` still creates through `Create`. A non-nullable scalar does not
support `== null`: a value-type scalar has no conversion from `null`, and a non-nullable
reference-backed scalar is ambiguous between its primitive operator and the synthesized equality. Check
`!value.HasValue` when you need to inspect the wrapped value.

### Formatting

Expand Down Expand Up @@ -272,6 +333,10 @@ Use `[ValueObjectDefaults]` to set generic defaults for the whole assembly. Ever
Assembly defaults apply to every value object in the assembly; an option explicitly set on an individual
`[Scalar]`/`[ValueObject]` attribute always overrides it.

`GenerateImplicitToPrimitive` keeps its name for compatibility. It emits an explicit extraction operator
for a nullable scalar (write `(TValue)value` or `.Value`) and an implicit one for a non-nullable scalar;
`GenerateImplicitFromPrimitive` controls the implicit creation conversion.

### Setting defaults from MSBuild

Every `[ValueObjectDefaults]` option also has a matching `ValueObjectsDefault<Property>` MSBuild property, so a
Expand Down
Loading
Loading