Skip to content

Commit e1a8b10

Browse files
committed
Add support for zero-allocation deserialization with System.Text.Json
1 parent 9d23026 commit e1a8b10

172 files changed

Lines changed: 4111 additions & 55 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/CLAUDE-ATTRIBUTES.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ SmartEnumAttribute()
102102
| `ConversionToKeyMemberType` | `ConversionOperatorsGeneration` | `Implicit` | Generate conversion operator from enum to key type |
103103
| `ConversionFromKeyMemberType` | `ConversionOperatorsGeneration` | `Explicit` | Generate conversion operator from key type to enum |
104104
| `SerializationFrameworks` | `SerializationFrameworks` | `All` | Which serialization frameworks to generate integration code for |
105+
| `DisableSpanBasedJsonConversion` | `bool` | `false` | Disables ReadOnlySpan-based zero-allocation JSON conversion, falling back to string-based conversion (string keys only; NET9+) |
105106
| `SwitchMapStateParameterName` | `string?` | `"state"` | Name of state parameter in Switch/Map methods |
106107

107108
**Note**: `ISpanParsable<T>` inherits from `IParsable<T>`, so setting `SkipISpanParsable = false` will automatically set `SkipIParsable = false` if needed.
@@ -278,7 +279,7 @@ public partial class ApiResponse
278279

279280
### ObjectFactoryAttribute&lt;T&gt;
280281

281-
For types with custom factories for parsing/serialization.
282+
For types with custom factories for parsing/serialization. When `T` is `ReadOnlySpan<char>` and `UseForSerialization = SerializationFrameworks.SystemTextJson`, enables zero-allocation JSON deserialization on NET9+ by transcoding UTF-8 JSON bytes directly to `ReadOnlySpan<char>` instead of allocating a `string`.
282283

283284
**Targets**: `class` or `struct`
284285

.claude/CLAUDE-FEATURE-DEV.md

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ Separate code generator factories for each serialization framework:
106106

107107
**Pattern**: Each factory checks if the corresponding serialization package is referenced, then generates appropriate converter/formatter registration code.
108108

109+
- **`KeyedJsonCodeGenerator`**: Generates JSON converter attributes on the type. On NET9+, when `UseSpanBasedJsonConverter` is true, it emits `#if NET9_0_OR_GREATER` blocks that register `ThinktectureSpanParsableJsonConverterFactory<T, TValidationError>` for zero-allocation deserialization, with a fallback to the regular `ThinktectureJsonConverterFactory<T, TValidationError>` on older target frameworks.
110+
109111
## Type Information System
110112

111113
Rich type metadata is captured and passed through the generation pipeline:
@@ -275,6 +277,103 @@ The generator creates:
275277
- Integration with serializers (JSON, MessagePack, etc.)
276278
- Model binding for ASP.NET Core
277279

280+
### Zero-Allocation JSON via ObjectFactory (NET9+)
281+
282+
When `[ObjectFactory<ReadOnlySpan<char>>]` is present with `UseForSerialization = SerializationFrameworks.SystemTextJson`, the source generator sets `UseSpanBasedJsonConverter = true` for the type. This causes the generated JSON converter attribute to use `ThinktectureSpanParsableJsonConverterFactory` on NET9+, enabling zero-allocation deserialization by transcoding UTF-8 JSON bytes directly to `ReadOnlySpan<char>` without allocating a `string`. This is the opt-in mechanism for Value Objects (unlike Smart Enums with string keys, where it is automatic).
283+
284+
## Span-Based Zero-Allocation JSON Deserialization (NET9+)
285+
286+
This feature enables zero-allocation JSON deserialization for string-keyed Smart Enums and Value Objects with `ReadOnlySpan<char>` object factories, by converting UTF-8 JSON bytes directly to `ReadOnlySpan<char>` instead of allocating an intermediate `string`.
287+
288+
### Architecture
289+
290+
The feature spans three layers:
291+
292+
1. **`Utf8JsonReaderHelper`** (internal, `Thinktecture.Internal` namespace, NET9+ only): The low-level engine that converts UTF-8 JSON bytes to `ReadOnlySpan<char>` without string allocation.
293+
- **Fast path**: When the JSON value is contiguous in the buffer and unescaped, it transcodes the raw UTF-8 bytes directly via `Encoding.UTF8.GetChars(ReadOnlySpan<byte>, Span<char>)`.
294+
- **Slow path**: When the value is escaped or fragmented across buffer segments, it uses `Utf8JsonReader.CopyString(Span<byte>)` to get unescaped UTF-8 bytes, then transcodes.
295+
- **Memory strategy**: Uses `stackalloc` for values up to 128 chars; rents from `ArrayPool<char>.Shared` for larger values and returns the buffer after use.
296+
297+
2. **`ThinktectureSpanParsableJsonConverter<T, TValidationError>`** (NET9+ only): A `System.Text.Json` converter that uses `Utf8JsonReaderHelper` to obtain a `ReadOnlySpan<char>` from the JSON reader, then calls `IConvertible<ReadOnlySpan<char>>.ToValue()` on the target type to perform zero-allocation conversion.
298+
- **Type constraints**: `T : IObjectFactory<T, ReadOnlySpan<char>, TValidationError>, IConvertible<ReadOnlySpan<char>>`
299+
- Paired with `ThinktectureSpanParsableJsonConverterFactory<T, TValidationError>` for registration.
300+
301+
3. **`ThinktectureJsonConverterFactory`** (runtime detection): Updated with a NET9+-only constructor accepting `Func<Type, bool>? skipSpanBasedDeserialization`. At runtime, it inspects metadata via its internal `CanUseSpanParsableConverter()` method to decide whether to return the span-based converter or the regular converter for a given type.
302+
303+
### Runtime Converter Selection (`CanUseSpanParsableConverter`)
304+
305+
The `ThinktectureJsonConverterFactory` checks two paths to determine if a type supports span-based deserialization:
306+
307+
- **String-keyed Smart Enums**: Checks `Metadata.Keyed.SmartEnum.DisableSpanBasedJsonConversion`. If `false` (the default), the span-based converter is used.
308+
- **Other types (Value Objects, etc.)**: Checks the type's `ObjectFactories` metadata for an entry whose `ValueType` is `ReadOnlySpan<char>` and whose `SerializationFrameworks` includes `SystemTextJson`.
309+
310+
The optional `skipSpanBasedDeserialization` delegate (passed via the constructor) provides an additional external override for consumers who need to disable span-based deserialization for specific types at the application level.
311+
312+
### How It Works for Smart Enums
313+
314+
**Automatic for string-keyed enums**: When a Smart Enum has a `string` key type, the source generator automatically enables span-based JSON deserialization (no user action needed).
315+
316+
**Opt-out**: Set `DisableSpanBasedJsonConversion = true` on the `[SmartEnum<string>]` attribute:
317+
318+
```csharp
319+
[SmartEnum<string>(DisableSpanBasedJsonConversion = true)]
320+
public partial class MyEnum
321+
{
322+
public static readonly MyEnum Item1 = new("value1");
323+
}
324+
```
325+
326+
**Non-string keys**: Not applicable. The feature is only effective for `string`-keyed Smart Enums because `IConvertible<ReadOnlySpan<char>>` maps naturally to string-based key lookup.
327+
328+
### How It Works for Value Objects
329+
330+
**Opt-in via ObjectFactory**: Value Objects must explicitly declare `[ObjectFactory<ReadOnlySpan<char>>]` with `UseForSerialization = SerializationFrameworks.SystemTextJson`:
331+
332+
```csharp
333+
[ValueObject<string>]
334+
[ObjectFactory<ReadOnlySpan<char>>(UseForSerialization = SerializationFrameworks.SystemTextJson)]
335+
public partial class ProductName
336+
{
337+
// The generated IObjectFactory<ProductName, ReadOnlySpan<char>, ValidationError>
338+
// implementation enables zero-allocation JSON deserialization
339+
}
340+
```
341+
342+
The `ObjectFactorySourceGenerator` detects the `ReadOnlySpan<char>` object factory and sets `UseSpanBasedJsonConverter = true` in the serializer generator state.
343+
344+
### Source Generator Behavior
345+
346+
**State tracking**: `KeyedSerializerGeneratorState` has a `UseSpanBasedJsonConverter` boolean property that is included in equality comparison and hash code computation (ensuring incremental generation correctness).
347+
348+
**Per-generator logic for setting `UseSpanBasedJsonConverter`**:
349+
350+
- **`SmartEnumSourceGenerator`**: Sets to `true` when `!state.Settings.DisableSpanBasedJsonConversion && state.KeyMember?.SpecialType == SpecialType.System_String`
351+
- **`ValueObjectSourceGenerator`**: Always passes `false` (Value Objects require an explicit `[ObjectFactory<ReadOnlySpan<char>>]`)
352+
- **`ObjectFactorySourceGenerator`**: Sets to `true` when `state.AttributeInfo.ObjectFactories.Any(f => f.IsReadOnlySpanOfChar)`
353+
354+
**Generated code pattern** (`KeyedJsonCodeGenerator`):
355+
356+
```csharp
357+
// When UseSpanBasedJsonConverter = true
358+
#if NET9_0_OR_GREATER
359+
[System.Text.Json.Serialization.JsonConverter(typeof(
360+
global::Thinktecture.Text.Json.Serialization.ThinktectureSpanParsableJsonConverterFactory<MyType, ValidationError>))]
361+
#else
362+
[System.Text.Json.Serialization.JsonConverter(typeof(
363+
global::Thinktecture.Text.Json.Serialization.ThinktectureJsonConverterFactory<MyType, string, ValidationError>))]
364+
#endif
365+
partial class MyType { }
366+
```
367+
368+
**Settings and constants**:
369+
370+
- `SmartEnumAttribute<TKey>.DisableSpanBasedJsonConversion`: New attribute property (default `false`)
371+
- `AllEnumSettings` and `SmartEnumSettings`: Track the `DisableSpanBasedJsonConversion` value
372+
- `Constants.DISABLE_SPAN_BASED_JSON_CONVERSION`: String constant for attribute property lookup
373+
- `AttributeDataExtensions.FindDisableSpanBasedJsonConversion()`: Extension method to extract the setting from attribute data
374+
375+
**`IConvertible<T>` update**: On NET9+, the interface uses `allows ref struct` constraint to support `ReadOnlySpan<char>` as the type parameter.
376+
278377
## Runtime Metadata System
279378

280379
The runtime metadata system provides the bridge between compile-time source generation and runtime framework integration. This system is critical for serialization, model binding, type conversion, and other runtime operations.
@@ -338,6 +437,7 @@ public partial class MyEnum
338437

339438
**Concrete implementations**:
340439
- `SmartEnumMetadata`: For Smart Enums
440+
- Includes `Metadata.Keyed.SmartEnum.DisableSpanBasedJsonConversion` (`bool`, `init`): When `true`, prevents the runtime `ThinktectureJsonConverterFactory` from selecting the span-based JSON converter for this Smart Enum. Default `false`. Only meaningful for string-keyed enums. Set via `SmartEnumAttribute<TKey>.DisableSpanBasedJsonConversion` and emitted by `SmartEnumCodeGenerator` in metadata initialization.
341441
- `KeyedValueObjectMetadata`: For simple Value Objects with single key
342442
- `ComplexValueObjectMetadata`: For complex Value Objects with multiple members
343443

.claude/CLAUDE-REVIEW.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ static partial void ValidateFactoryArguments(ref string value, ref ValidationErr
123123
- [ ] Manual registration code correct if not using automatic integration
124124
- [ ] Serialization tested with roundtrip tests
125125
- [ ] Null handling correct for nullable types
126+
- [ ] Span-based JSON converter attribute generated correctly with `#if NET9_0_OR_GREATER` blocks (for string-keyed Smart Enums)
127+
- [ ] `DisableSpanBasedJsonConversion` setting respected when set to `true`
126128

127129
### Framework Integration
128130

@@ -159,6 +161,7 @@ static partial void ValidateFactoryArguments(ref string value, ref ValidationErr
159161
- [ ] Generated code follows expected patterns
160162
- [ ] No duplicate members generated
161163
- [ ] Conditional compilation (`#if NET9_0_OR_GREATER`) used correctly
164+
- [ ] `#if NET9_0_OR_GREATER` blocks present for span-based JSON converter on string-keyed Smart Enums
162165
- [ ] Generated XML documentation present
163166

164167
### Test Coverage
@@ -175,6 +178,8 @@ static partial void ValidateFactoryArguments(ref string value, ref ValidationErr
175178

176179
- [ ] No unnecessary allocations in hot paths
177180
- [ ] Span-based APIs used when available (NET9+)
181+
- [ ] Zero-allocation span-based JSON deserialization used for string-keyed Smart Enums (NET9+)
182+
- [ ] `[ObjectFactory<ReadOnlySpan<char>>]` with `UseForSerialization` used for value objects needing span-based JSON deserialization
178183
- [ ] StringBuilder used for string concatenation in loops
179184
- [ ] No LINQ in performance-critical code (if avoidable)
180185

.claude/CLAUDE-TESTING.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ The test suite is organized into multiple projects, each with a specific purpose
138138
public partial struct IntBasedStructValueObject;
139139
```
140140

141+
**Notable test types for span-based JSON deserialization**:
142+
- `TestEnums/SmartEnum_StringBased_WithDisabledSpanBasedJsonConversion.cs`: Compilation test verifying that `DisableSpanBasedJsonConversion = true` on a string-based Smart Enum produces compilable generated code that falls back to string-based JSON deserialization
143+
- `TestValueObjects/ComplexValueObjectWithReadOnlySpanBasedObjectFactoryForJson.cs`: Compilation test verifying that a complex value object with `[ObjectFactory<ReadOnlySpan<char>>]` produces compilable generated code supporting span-based JSON deserialization
144+
141145
**Best practices**:
142146
- Create separate types for each meaningful feature variation or configuration
143147
- Name types descriptively to indicate what feature/configuration they test
@@ -307,6 +311,20 @@ public void Should_serialize_and_deserialize_smart_enum()
307311
}
308312
```
309313

314+
#### Zero-Allocation Span-Based JSON Deserialization
315+
316+
String-based Smart Enums and Value Objects support zero-allocation JSON deserialization via `ThinktectureSpanParsableJsonConverter`. This converter avoids allocating a `string` during deserialization by reading the UTF-8 JSON payload directly into a `ReadOnlySpan<char>` and calling `ISpanParsable<T>.Parse`.
317+
318+
**Key test areas**:
319+
320+
- **`ThinktectureSpanParsableJsonConverter` tests** (`SpanParsableJsonConverterTests.cs`): Verify that types implementing `ISpanParsable<T>` are deserialized through the span-based path. Test both `ThinktectureSpanParsableJsonConverterFactory` (standalone) and the integration through `ThinktectureJsonConverterFactory`.
321+
- **`Utf8JsonReaderHelper` tests** (`Utf8JsonReaderHelperTests.cs`): Verify correct UTF-8 byte to `ReadOnlySpan<char>` conversion, including multi-byte characters, empty strings, and edge cases. This helper is the core of the zero-allocation path.
322+
- **Opt-out via `DisableSpanBasedJsonConversion`**: Smart Enums can disable span-based JSON conversion by setting `DisableSpanBasedJsonConversion = true` on the `[SmartEnum<T>]` attribute. Test that the generated JSON converter falls back to string-based deserialization. The compilation test type `SmartEnum_StringBased_WithDisabledSpanBasedJsonConversion` verifies the generated code compiles correctly.
323+
- **Value objects with `[ObjectFactory<ReadOnlySpan<char>>]`**: Value objects that declare a `ReadOnlySpan<char>`-based object factory also get span-based JSON deserialization. The compilation test type `ComplexValueObjectWithReadOnlySpanBasedObjectFactoryForJson` verifies this path. Snapshot tests in `JsonObjectFactoryCodeGeneratorFactoryTests` verify the generated converter code.
324+
- **Source generator snapshot tests**: `Generate.Snapshot_StringKeyWithSpanJsonConverter.verified.txt` verifies the generated JSON converter includes the span-based deserialization code path.
325+
326+
**Benchmarks**: `SmartEnumJsonDeserializationBenchmarks` and `ValueObjectJsonDeserializationBenchmarks` (in the `Benchmarking` sample project) measure the allocation reduction. These are not unit tests but are useful for validating performance characteristics.
327+
310328
### MessagePack Serialization
311329

312330
```csharp

.claude/CLAUDE.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ This is a .NET library providing **Smart Enums**, **Value Objects**, and **Discr
135135
- Can be generic types
136136
- Supports `IParsable<T>`, `ISpanParsable<T>` (NET9+, zero-allocation), `IComparable<T>`, `IFormattable`
137137
- Configurable operators, conversion, Switch/Map generation
138+
- **Span-based JSON deserialization** (NET9+, string keys only): Zero-allocation JSON deserialization enabled by default; transcodes UTF-8 JSON bytes directly to `ReadOnlySpan<char>` instead of allocating a `string`. Opt out per enum via `DisableSpanBasedJsonConversion = true`
138139

139140
#### Value Objects
140141

@@ -196,7 +197,7 @@ The `MetadataLookup` class provides cached metadata discovery via reflection. Ob
196197

197198
### Framework Integration Quick Reference
198199

199-
**Serialization**: System.Text.Json, MessagePack, Newtonsoft.Json, ProtoBuf - reference package for auto-generation or manually register converters
200+
**Serialization**: System.Text.Json, MessagePack, Newtonsoft.Json, ProtoBuf - reference package for auto-generation or manually register converters. On NET9+, string-based Smart Enums and types with `[ObjectFactory<ReadOnlySpan<char>>(UseForSerialization = SerializationFrameworks.SystemTextJson)]` automatically use zero-allocation span-based JSON deserialization (via `Utf8JsonReaderHelper` and `ThinktectureSpanParsableJsonConverter`)
200201

201202
**Entity Framework Core**: Version-specific packages (8/9/10) - call `.UseThinktectureValueConverters()` on DbContextOptionsBuilder
202203

@@ -214,6 +215,7 @@ The `MetadataLookup` class provides cached metadata discovery via reflection. Ob
214215
4. **Serialization not working**: Ensure integration package is referenced, or manually register converters/formatters
215216
5. **EF Core not converting**: Call `.UseThinktectureValueConverters()` on DbContextOptionsBuilder
216217
6. **ISpanParsable not available**: Requires NET9+; ensure project targets `net9.0` or later and key type implements `ISpanParsable<TKey>`
218+
7. **Span-based JSON deserialization causing issues**: Disable per Smart Enum with `[SmartEnum<string>(DisableSpanBasedJsonConversion = true)]`. For `ThinktectureJsonConverterFactory`, pass `skipSpanBasedDeserialization` callback to the constructor (NET9+ overload) to opt out at runtime
217219

218220
### Best Practices
219221

@@ -225,6 +227,7 @@ The `MetadataLookup` class provides cached metadata discovery via reflection. Ob
225227
6. **Partial keyword**: Types must be marked `partial` for source generators to work
226228
7. **Culture-specific parsing**: Always pass appropriate `IFormatProvider` when parsing/formatting culture-sensitive types
227229
8. **Arithmetic operators**: Use unchecked arithmetic context - overflow/underflow wraps around
230+
9. **Span-based JSON for value objects**: To enable zero-allocation JSON deserialization for value objects on NET9+, add `[ObjectFactory<ReadOnlySpan<char>>(UseForSerialization = SerializationFrameworks.SystemTextJson)]` to the type. Without this attribute, value objects use regular key-based JSON conversion
228231

229232
## Project Structure
230233

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
**/packages/**
66
**/test-results/**
77
.local-memory/**
8-
.serena/cache/**
98

109
*\.sln\.DotSettings\.user
1110
*\.csproj\.user
@@ -14,3 +13,6 @@
1413
# Verify
1514
test/**/*.received.*
1615
test/**/*.received/
16+
17+
# Benchmarking
18+
**/BenchmarkDotNet.Artifacts/**

docs

Submodule docs updated from b83366b to 41ce5f9
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using System.Text.Json;
2+
using BenchmarkDotNet.Attributes;
3+
using Thinktecture.Database;
4+
5+
namespace Thinktecture.Benchmarks;
6+
7+
public class SmartEnumJsonDeserializationBenchmarks
8+
{
9+
private readonly byte[] _stringBasedJson = "\"Value1\""u8.ToArray();
10+
private readonly byte[] _intBasedJson = "1"u8.ToArray();
11+
12+
private readonly JsonSerializerOptions _options = new();
13+
14+
[Benchmark]
15+
public TestSmartEnum_Class_StringBased? StringBased_with_ISpanParsable()
16+
{
17+
return JsonSerializer.Deserialize<TestSmartEnum_Class_StringBased>(_stringBasedJson, _options);
18+
}
19+
20+
[Benchmark(Baseline = true)]
21+
public TestSmartEnum_Class_StringBased_Without_SpanParsableConverter? StringBased_without_ISpanParsable()
22+
{
23+
return JsonSerializer.Deserialize<TestSmartEnum_Class_StringBased_Without_SpanParsableConverter>(_stringBasedJson, _options);
24+
}
25+
26+
// [Benchmark]
27+
// public TestSmartEnum_Class_IntBased? IntBased()
28+
// {
29+
// return JsonSerializer.Deserialize<TestSmartEnum_Class_IntBased>(_intBasedJson, _options);
30+
// }
31+
}

0 commit comments

Comments
 (0)