Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
39d5eaa
Support string raw and prefixed typed IDs
Dragemil Dec 17, 2025
9bf5c22
Add language annotations for C# in strings for source generation code
Dragemil Dec 17, 2025
dc637b7
Rename MaxValuePartLength to MaxValueLength and update related logic
Dragemil Dec 17, 2025
714043b
Update ID docs
Dragemil Dec 17, 2025
d204aa2
Improve MaxValueLength documentation
Dragemil Dec 17, 2025
3ba9c5e
Fix GetValuePart to handle Empty instance
Dragemil Dec 17, 2025
dd1f33b
Add MaxValueLength validation to FromValuePart and constructor
Dragemil Dec 17, 2025
4f9a860
Improve RawString parse error message
Dragemil Dec 17, 2025
5dcd7e0
Add language annotations for C# in strings for source generation code
Dragemil Dec 18, 2025
1cec43b
Refactor typed ID structures to implement IConstSizeTypedId and IMaxL…
Dragemil Dec 18, 2025
0f2f8ed
Add EF Core support for string-typed IDs with max length validation
Dragemil Dec 18, 2025
aa97bab
Add integration tests for PrefixedUlidId
Dragemil Dec 18, 2025
54ec58b
Enhance typed ID property builders to include comparers for int, long…
Dragemil Dec 18, 2025
1730e1f
Update documentation for IDs to correct MaxLength property name
Dragemil Dec 19, 2025
ff35af7
Add IHasEmptyId interface and refactor typed ID EF builders max lengt…
Dragemil Dec 19, 2025
6fc5c3d
Refactor string-typed ID implementation to unify max length handling …
Dragemil Dec 22, 2025
839fe29
Add tests for raw and prefixed string ID max value length requirement
Dragemil Dec 22, 2025
d8438b1
Address string typed IDs in changelog and fix their attribute XML docs
Dragemil Dec 22, 2025
cf1598a
Include strongly typed IDs in integration tests
Dragemil Dec 22, 2025
fa4074e
Bring back `AreFixedLength()` ID column configurations for backcomp
Dragemil Dec 22, 2025
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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ but this project DOES NOT adhere to [Semantic Versioning](http://semver.org/).
* Upgrade to .NET 10
* Response serialization moved from `CQRSMiddleware` to individual CQRS middlewares to support output caching
* Add CQRS output caching support with new `LeanCode.CQRS.OutputCaching` package
* Test infrastructure moved to Microsoft Testing Platform v2 and xunit v3
* Migrate Test infrastructure to Microsoft Testing Platform v2 and xunit v3
* Add `RawString` and `PrefixedString` source generated typed IDs support

## 9.0

Expand Down
25 changes: 22 additions & 3 deletions docs/domain/id/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,15 @@ The format of the ID can be configured using:
- `RawInt` - uses `int` as the underlying type; works as a wrapper over `int`; does not support generating new IDs at runtime by default.
- `RawLong` - uses `long` as the underlying type; works as a wrapper over `long`; does not support generating new IDs at runtime by default.
- `RawGuid` - uses `Guid` as the underlying type; works as a wrapper over `Guid`; can generate new ID at runtime using `Guid.NewGuid`.
- `PrefixedGuid` - uses `string` as the underlying type; it is represented as a `(prefix)_(guid)` string that can be generated at runtime; by default `(prefix)` is a lowercase class name with `id` at the end removed.
- `RawString` - uses `string` as the underlying type; works as a wrapper over arbitrary strings; does not support generating new IDs at runtime.
- `PrefixedGuid` - uses `string` as the underlying type; it is represented as a `(prefix)_(guid)` string that can be generated at runtime; by default `(prefix)` is a lowercase class name with `Id` suffix removed.
- `PrefixedUlid` - uses `string` as the underlying type; it is represented as a `(prefix)_(ulid)` string that can be generated at runtime; by default `(prefix)` is a lowercase class name with `Id` suffix removed.
- `PrefixedString` - uses `string` as the underlying type; it is represented as a `(prefix)_(value)` string where `(value)` is an arbitrary string; does not support generating new IDs at runtime.
- `CustomPrefix` - for `Prefixed*` formats, you can configure what prefix it uses (if you e.g. want to use a shorter one).
- `SkipRandomGenerator` - setting this to `true` will skip generating `New` factory method (for `Prefixed` types only).
- `SkipRandomGenerator` - setting this to `true` will skip generating `New` factory method (for formats that support generation).
- `MaxValueLength` - optional maximum length constraint for the value part. For `RawString`, this is the entire string length. For `PrefixedString`, this excludes the prefix and separator. When set, validation is performed in `Parse`/`IsValid` methods. Consider SQL Server's 900-byte key limit (~450 nvarchar chars) when choosing this value.

Example:
Examples:

```cs
[TypedId(TypedIdFormat.PrefixedGuid, CustomPrefix = "employee")]
Expand All @@ -87,6 +91,21 @@ public readonly partial record struct VeryLongEmployeeId;
// The `VeryLongEmployeeId` will have format `employee_(guid)`, with `New` using `Guid.NewGuid` as random source.
```

```cs
[TypedId(TypedIdFormat.RawString, MaxValueLength = 100)]
public readonly partial record struct ExternalId;

// The `ExternalId` wraps any string up to 100 characters. Exposes `MaxLength` static property.
```

```cs
[TypedId(TypedIdFormat.PrefixedString, CustomPrefix = "ext", MaxValueLength = 50)]
public readonly partial record struct ExternalRefId;

// The `ExternalRefId` has format `ext_(value)` where value can be up to 50 characters.
// Exposes `MaxValueLength` (50) and `MaxLength` (54 = 3 + 1 + 50) static properties.
```

## Generic type wrappers

The domain part of the library supports a set of generic IDs:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,34 @@ this PropertiesConfigurationBuilder<TId?> builder
return builder.AreRawTypedId<Guid, TId>();
}

public static PropertiesConfigurationBuilder<TId> AreStringTypedId<TId>(
this PropertiesConfigurationBuilder<TId> builder
)
where TId : struct, IRawStringTypedId<TId>
{
return builder
.HaveConversion<RawStringTypedIdConverter<TId>, RawStringTypedIdComparer<TId>>()
.ConfigureMaxLength();
}

public static PropertiesConfigurationBuilder<TId?> AreStringTypedId<TId>(
this PropertiesConfigurationBuilder<TId?> builder
)
where TId : struct, IRawStringTypedId<TId>
{
return builder
.HaveConversion<RawStringTypedIdConverter<TId>, RawStringTypedIdComparer<TId>>()
.ConfigureMaxLength();
}

public static PropertiesConfigurationBuilder<TId> ArePrefixedTypedId<TId>(
this PropertiesConfigurationBuilder<TId> builder
)
where TId : struct, IPrefixedTypedId<TId>
{
return builder
.HaveConversion<PrefixedTypedIdConverter<TId>, PrefixedTypedIdComparer<TId>>()
.HaveMaxLength(TId.RawLength)
.AreFixedLength();
.ConfigureMaxLength();
}

public static PropertiesConfigurationBuilder<TId?> ArePrefixedTypedId<TId>(
Expand All @@ -72,8 +91,23 @@ this PropertiesConfigurationBuilder<TId?> builder
{
return builder
.HaveConversion<PrefixedTypedIdConverter<TId>, PrefixedTypedIdComparer<TId>>()
.HaveMaxLength(TId.RawLength)
.AreFixedLength();
.ConfigureMaxLength();
}

private static PropertiesConfigurationBuilder<TId> ConfigureMaxLength<TId>(
this PropertiesConfigurationBuilder<TId> builder
)
where TId : struct, IMaxLengthTypedId
{
return builder.HaveMaxLength(TId.MaxLength).AreFixedLength();
}

private static PropertiesConfigurationBuilder<TId?> ConfigureMaxLength<TId>(
this PropertiesConfigurationBuilder<TId?> builder
)
where TId : struct, IMaxLengthTypedId
{
return builder.HaveMaxLength(TId.MaxLength).AreFixedLength();
}

private static PropertiesConfigurationBuilder<TId> AreRawTypedId<TBacking, TId>(
Expand Down
54 changes: 40 additions & 14 deletions src/Domain/LeanCode.DomainModels.EF/PropertyBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,56 +74,82 @@ public static PropertyBuilder<SId<T>> IsTypedId<T>(this PropertyBuilder<SId<T>>
public static PropertyBuilder<TId> IsIntTypedId<TId>(this PropertyBuilder<TId> builder)
where TId : struct, IRawTypedId<int, TId>
{
return builder.HasConversion(RawTypedIdConverter<int, TId>.Instance);
return builder.HasConversion(RawTypedIdConverter<int, TId>.Instance, RawTypedIdComparer<int, TId>.Instance);
}

public static PropertyBuilder<TId?> IsIntTypedId<TId>(this PropertyBuilder<TId?> builder)
where TId : struct, IRawTypedId<int, TId>
{
return builder.HasConversion(RawTypedIdConverter<int, TId>.Instance);
return builder.HasConversion(RawTypedIdConverter<int, TId>.Instance, RawTypedIdComparer<int, TId>.Instance);
}

public static PropertyBuilder<TId> IsLongTypedId<TId>(this PropertyBuilder<TId> builder)
where TId : struct, IRawTypedId<long, TId>
{
return builder.HasConversion(RawTypedIdConverter<long, TId>.Instance);
return builder.HasConversion(RawTypedIdConverter<long, TId>.Instance, RawTypedIdComparer<long, TId>.Instance);
}

public static PropertyBuilder<TId?> IsLongTypedId<TId>(this PropertyBuilder<TId?> builder)
where TId : struct, IRawTypedId<long, TId>
{
return builder.HasConversion(RawTypedIdConverter<long, TId>.Instance);
return builder.HasConversion(RawTypedIdConverter<long, TId>.Instance, RawTypedIdComparer<long, TId>.Instance);
}

public static PropertyBuilder<TId> IsGuidTypedId<TId>(this PropertyBuilder<TId> builder)
where TId : struct, IRawTypedId<Guid, TId>
{
return builder.HasConversion(RawTypedIdConverter<Guid, TId>.Instance);
return builder.HasConversion(RawTypedIdConverter<Guid, TId>.Instance, RawTypedIdComparer<Guid, TId>.Instance);
}

public static PropertyBuilder<TId?> IsGuidTypedId<TId>(this PropertyBuilder<TId?> builder)
where TId : struct, IRawTypedId<Guid, TId>
{
return builder.HasConversion(RawTypedIdConverter<Guid, TId>.Instance);
return builder.HasConversion(RawTypedIdConverter<Guid, TId>.Instance, RawTypedIdComparer<Guid, TId>.Instance);
}

public static PropertyBuilder<TId> IsStringTypedId<TId>(this PropertyBuilder<TId> builder)
where TId : struct, IRawStringTypedId<TId>
{
return builder
.HasConversion(RawStringTypedIdConverter<TId>.Instance, RawStringTypedIdComparer<TId>.Instance)
.ConfigureMaxLength();
}

public static PropertyBuilder<TId?> IsStringTypedId<TId>(this PropertyBuilder<TId?> builder)
where TId : struct, IRawStringTypedId<TId>
{
return builder
.HasConversion(RawStringTypedIdConverter<TId>.Instance, RawStringTypedIdComparer<TId>.Instance)
.ConfigureMaxLength();
}

public static PropertyBuilder<TId> IsPrefixedTypedId<TId>(this PropertyBuilder<TId> builder)
where TId : struct, IPrefixedTypedId<TId>
{
return builder
.HasConversion(PrefixedTypedIdConverter<TId>.Instance)
.HasMaxLength(TId.RawLength)
.IsFixedLength()
.ValueGeneratedNever();
.HasConversion(PrefixedTypedIdConverter<TId>.Instance, PrefixedTypedIdComparer<TId>.Instance)
.ValueGeneratedNever()
.ConfigureMaxLength();
}

public static PropertyBuilder<TId?> IsPrefixedTypedId<TId>(this PropertyBuilder<TId?> builder)
where TId : struct, IPrefixedTypedId<TId>
{
return builder
.HasConversion(PrefixedTypedIdConverter<TId>.Instance)
.HasMaxLength(TId.RawLength)
.IsFixedLength()
.ValueGeneratedNever();
.HasConversion(PrefixedTypedIdConverter<TId>.Instance, PrefixedTypedIdComparer<TId>.Instance)
.ValueGeneratedNever()
.ConfigureMaxLength();
}

private static PropertyBuilder<TId> ConfigureMaxLength<TId>(this PropertyBuilder<TId> builder)
where TId : struct, IMaxLengthTypedId
{
return builder.HasMaxLength(TId.MaxLength).IsFixedLength();
}

private static PropertyBuilder<TId?> ConfigureMaxLength<TId>(this PropertyBuilder<TId?> builder)
where TId : struct, IMaxLengthTypedId
{
return builder.HasMaxLength(TId.MaxLength).IsFixedLength();
}
}
24 changes: 24 additions & 0 deletions src/Domain/LeanCode.DomainModels.EF/TypedIdConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,22 @@ public RawTypedIdConverter()
: base(d => d.Value, TId.FromDatabase, mappingHints: null) { }
}

[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
public class RawStringTypedIdConverter<TId> : ValueConverter<TId, string>
where TId : struct, IRawStringTypedId<TId>
{
public static readonly RawStringTypedIdConverter<TId> Instance = new();

public RawStringTypedIdConverter()
: base(d => d.Value, TId.FromDatabase, mappingHints: null) { }
}

[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
public class PrefixedTypedIdComparer<TId> : ValueComparer<TId>
where TId : struct, IPrefixedTypedId<TId>
{
public static readonly PrefixedTypedIdComparer<TId> Instance = new();

public PrefixedTypedIdComparer()
: base(TId.DatabaseEquals, d => d.GetHashCode()) { }
}
Expand All @@ -38,6 +50,18 @@ public class RawTypedIdComparer<TBacking, TId> : ValueComparer<TId>
where TBacking : struct
where TId : struct, IRawTypedId<TBacking, TId>
{
public static readonly RawTypedIdComparer<TBacking, TId> Instance = new();

public RawTypedIdComparer()
: base(TId.DatabaseEquals, d => d.GetHashCode()) { }
}

[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
public class RawStringTypedIdComparer<TId> : ValueComparer<TId>
where TId : struct, IRawStringTypedId<TId>
{
public static readonly RawStringTypedIdComparer<TId> Instance = new();

public RawStringTypedIdComparer()
: base(TId.DatabaseEquals, d => d.GetHashCode()) { }
}
Loading