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
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
</PropertyGroup>

<PropertyGroup>
<Version>1.0.1</Version>
<Version>1.1.0</Version>
<Authors>Volodymyr Dombrovskyi</Authors>
<Copyright>Copyright (c) 2024-2026 Volodymyr Dombrovskyi</Copyright>
<RepositoryUrl>https://github.com/dombrovsky/StrongTypeIdGenerator.git</RepositoryUrl>
Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
StrongTypeIdGenerator is a C# source generator for strongly typed identifiers.
It helps prevent primitive ID mix-ups by generating domain-specific ID types for `string`, `Guid`, and combined (composite) keys.

[![NuGet](https://img.shields.io/nuget/v/StrongTypeIdGenerator.svg)](https://www.nuget.org/packages/StrongTypeIdGenerator/)
[![NuGet Core](https://img.shields.io/nuget/v/StrongTypeIdGenerator.svg?label=NuGet%20Core)](https://www.nuget.org/packages/StrongTypeIdGenerator/)
[![NuGet Json](https://img.shields.io/nuget/v/StrongTypeIdGenerator.Json.svg?label=NuGet%20Json)](https://www.nuget.org/packages/StrongTypeIdGenerator.Json/)
[![NuGet EF Core](https://img.shields.io/nuget/v/StrongTypeIdGenerator.EntityFrameworkCore.svg?label=NuGet%20EF%20Core)](https://www.nuget.org/packages/StrongTypeIdGenerator.EntityFrameworkCore/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

## Why this library
Expand All @@ -19,7 +21,7 @@ Design decisions:
- **Reference types by design.** This project prioritizes invariant safety and controlled construction over minimizing allocations, so invalid IDs are harder to create and propagate.
- **Built-in precondition hooks.** If an ID class defines `CheckValue(...)`, the method is called from the generated constructor and can validate or normalize input.
- **Serializer-agnostic core.** The main package only relies on `System.ComponentModel.TypeConverter` and has no direct dependency on `System.Text.Json`, Newtonsoft.Json, or EF Core converters.
- **netstandard2.0-friendly usage.** IDs can live in `netstandard2.0` libraries without extra serialization dependencies. For `System.Text.Json`, use the optional `StrongTypeIdGenerator.Json` package.
- **netstandard2.0-friendly usage.** IDs can live in `netstandard2.0` libraries without extra serialization dependencies. For `System.Text.Json`, use the optional `StrongTypeIdGenerator.Json` package. For EF Core, use the optional `StrongTypeIdGenerator.EntityFrameworkCore` package.
- **First-class composite identifiers.** `CombinedId` exists for real-world composite business keys, avoiding ad-hoc wrapper implementations.

## Main features
Expand All @@ -30,6 +32,7 @@ Design decisions:
- Optional custom value property name for scalar identifiers.
- Optional constructor privacy (`GenerateConstructorPrivate = true`).
- Optional `System.Text.Json` integration package.
- Optional EF Core integration package.

## Quick start

Expand Down Expand Up @@ -109,7 +112,7 @@ The generator creates immutable reference-type identifiers with:
- implicit conversion operators
- nested `TypeConverter`

## Optional JSON integration
## Optional JSON and EF Core integration

For `System.Text.Json`, install:

Expand All @@ -126,6 +129,8 @@ var options = new JsonSerializerOptions();
options.Converters.Add(new TypeConverterJsonConverterFactory());
```

For EF Core integration, install `StrongTypeIdGenerator.EntityFrameworkCore` and see the [EF Core Integration](docs/ef-core.md) guide.

## Documentation

Detailed docs are in the docs folder:
Expand All @@ -139,6 +144,7 @@ Detailed docs are in the docs folder:
- [Custom Value Property Name](docs/custom-value-property-name.md)
- [Private Constructors and Factories](docs/private-constructors-and-factories.md)
- [TypeConverter and System.Text.Json](docs/typeconverter-and-json.md)
- [EF Core Integration](docs/ef-core.md)
- [Design Decisions](docs/design-decisions.md)
- [FAQ](docs/faq.md)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace StrongTypeIdGenerator.EntityFrameworkCore.Tests
{
internal sealed class CombinedIdEfCoreFixture
{
[TestCase(RegistrationMode.OptionsBuilder)]
[TestCase(RegistrationMode.OnModelCreating)]
public void CanRoundTripCombinedIdAsComplexType(RegistrationMode mode)
{
using var connection = DbTestHelper.CreateOpenConnection();
var options = DbTestHelper.CreateOptions(connection, mode);
DbTestHelper.EnsureCreated(options, mode);

var compositeId = new TestCombinedId(new TestGuidId(Guid.Parse("0D3D8446-9E0D-4E0B-B8E4-4AE07E2A9D4A")), "A1", Guid.Parse("2263F5F1-7D90-48A0-977E-536A96AB4067"), 42);

using (var writeContext = DbTestHelper.CreateContext(options, mode))
{
writeContext.CombinedIds.Add(new CombinedIdEntity
{
Id = 1,
CompositeId = compositeId,
});
writeContext.SaveChanges();
}

using (var readContext = DbTestHelper.CreateContext(options, mode))
{
var loaded = readContext.CombinedIds.Single(x => x.Id == 1);
Assert.That(loaded.CompositeId, Is.EqualTo(compositeId));
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
namespace StrongTypeIdGenerator.EntityFrameworkCore.Tests
{
internal sealed class CombinedIdModelMetadataFixture
{
[TestCase(RegistrationMode.OptionsBuilder)]
[TestCase(RegistrationMode.OnModelCreating)]
public void CombinedIdPropertyIsConfiguredAsComplexTypeWithConverterOnStrongIdComponent(RegistrationMode mode)
{
using var connection = DbTestHelper.CreateOpenConnection();
var options = DbTestHelper.CreateOptions(connection, mode);
using var context = DbTestHelper.CreateContext(options, mode);

var entityType = context.Model.FindEntityType(typeof(CombinedIdEntity));
Assert.That(entityType, Is.Not.Null);

var complexProperty = entityType!.FindComplexProperty(nameof(CombinedIdEntity.CompositeId));
Assert.That(complexProperty, Is.Not.Null);
Assert.That(complexProperty!.ComplexType.ClrType, Is.EqualTo(typeof(TestCombinedId)));

var strongIdComponent = complexProperty.ComplexType.FindProperty(nameof(TestCombinedId.TestGuid));
Assert.That(strongIdComponent, Is.Not.Null);
Assert.That(strongIdComponent!.ClrType, Is.EqualTo(typeof(TestGuidId)));

var converter = strongIdComponent.GetValueConverter();
Assert.That(converter, Is.Not.Null);
Assert.That(converter!.ModelClrType, Is.EqualTo(typeof(TestGuidId)));
Assert.That(converter.ProviderClrType, Is.EqualTo(typeof(Guid)));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
namespace StrongTypeIdGenerator.EntityFrameworkCore.Tests
{
internal sealed class CustomValuePropertyNameEfCoreFixture
{
[TestCase(RegistrationMode.OptionsBuilder)]
[TestCase(RegistrationMode.OnModelCreating)]
public void CanRoundTripGuidIdWithCustomValuePropertyName(RegistrationMode mode)
{
using var connection = DbTestHelper.CreateOpenConnection();
var options = DbTestHelper.CreateOptions(connection, mode);
DbTestHelper.EnsureCreated(options, mode);

var id = new TestGuidIdWithPropertyName(Guid.Parse("008AFC46-C30B-4904-B570-616A6C08EE49"));

using (var writeContext = DbTestHelper.CreateContext(options, mode))
{
writeContext.GuidIdsWithPropertyName.Add(new GuidIdWithPropertyNameEntity
{
Id = id,
Name = "guid-custom-property"
});
writeContext.SaveChanges();
}

using (var readContext = DbTestHelper.CreateContext(options, mode))
{
var loaded = readContext.GuidIdsWithPropertyName.Single(x => x.Id == id);
Assert.That(loaded.Id, Is.EqualTo(id));
Assert.That(loaded.Name, Is.EqualTo("guid-custom-property"));
}
}

[TestCase(RegistrationMode.OptionsBuilder)]
[TestCase(RegistrationMode.OnModelCreating)]
public void CanRoundTripStringIdWithCustomValuePropertyName(RegistrationMode mode)
{
using var connection = DbTestHelper.CreateOpenConnection();
var options = DbTestHelper.CreateOptions(connection, mode);
DbTestHelper.EnsureCreated(options, mode);

var id = new TestStringIdWithPropertyName("customer-xyz");

using (var writeContext = DbTestHelper.CreateContext(options, mode))
{
writeContext.StringIdsWithPropertyName.Add(new StringIdWithPropertyNameEntity
{
Id = id,
Name = "string-custom-property"
});
writeContext.SaveChanges();
}

using (var readContext = DbTestHelper.CreateContext(options, mode))
{
var loaded = readContext.StringIdsWithPropertyName.Single(x => x.Id == id);
Assert.That(loaded.Id, Is.EqualTo(id));
Assert.That(loaded.Name, Is.EqualTo("string-custom-property"));
}
}
}
}
56 changes: 56 additions & 0 deletions StrongTypeIdGenerator.EntityFrameworkCore.Tests/DbTestHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
namespace StrongTypeIdGenerator.EntityFrameworkCore.Tests
{
using StrongTypeIdGenerator.EntityFrameworkCore;

internal enum RegistrationMode
{
OptionsBuilder,
OnModelCreating,
}

internal static class DbTestHelper
{
public static SqliteConnection CreateOpenConnection()
{
var connection = new SqliteConnection("Data Source=:memory:");
connection.Open();
return connection;
}

public static DbContextOptions CreateOptions(SqliteConnection connection, RegistrationMode mode)
{
switch (mode)
{
case RegistrationMode.OptionsBuilder:
var optionsBuilder = new DbContextOptionsBuilder<TestDbContext>();
optionsBuilder.UseSqlite(connection);
optionsBuilder.UseStrongTypeIds();
return optionsBuilder.Options;

case RegistrationMode.OnModelCreating:
var modelBuilderOptions = new DbContextOptionsBuilder<ModelBuilderConfiguredDbContext>();
modelBuilderOptions.UseSqlite(connection);
return modelBuilderOptions.Options;

default:
throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported registration mode.");
}
}

public static TestDbContextBase CreateContext(DbContextOptions options, RegistrationMode mode)
{
return mode switch
{
RegistrationMode.OptionsBuilder => new TestDbContext((DbContextOptions<TestDbContext>)options),
RegistrationMode.OnModelCreating => new ModelBuilderConfiguredDbContext((DbContextOptions<ModelBuilderConfiguredDbContext>)options),
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported registration mode."),
};
}

public static void EnsureCreated(DbContextOptions options, RegistrationMode mode)
{
using var context = CreateContext(options, mode);
context.Database.EnsureCreated();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
global using Microsoft.Data.Sqlite;
global using Microsoft.EntityFrameworkCore;
global using StrongTypeIdGenerator.Tests;
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
namespace StrongTypeIdGenerator.EntityFrameworkCore.Tests
{
internal sealed class GuidIdEfCoreFixture
{
[TestCase(RegistrationMode.OptionsBuilder)]
[TestCase(RegistrationMode.OnModelCreating)]
public void CanRoundTripGuidStrongTypeIdAsPrimaryKey(RegistrationMode mode)
{
using var connection = DbTestHelper.CreateOpenConnection();
var options = DbTestHelper.CreateOptions(connection, mode);
DbTestHelper.EnsureCreated(options, mode);

var id = new TestGuidId(Guid.Parse("D5A5C4D3-4B7E-4B6A-90A5-5A5A08E43A11"));

using (var writeContext = DbTestHelper.CreateContext(options, mode))
{
writeContext.GuidIds.Add(new GuidIdEntity
{
Id = id,
Name = "guid-row"
});
writeContext.SaveChanges();
}

using (var readContext = DbTestHelper.CreateContext(options, mode))
{
var loaded = readContext.GuidIds.Single(x => x.Id == id);
Assert.That(loaded.Id, Is.EqualTo(id));
Assert.That(loaded.Name, Is.EqualTo("guid-row"));
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
namespace StrongTypeIdGenerator.EntityFrameworkCore.Tests
{
internal sealed class MaterializationValidationEfCoreFixture
{
[TestCase(RegistrationMode.OptionsBuilder)]
[TestCase(RegistrationMode.OnModelCreating)]
public void ThrowsWhenGuidCheckValueRejectsStoredValue(RegistrationMode mode)
{
using var connection = DbTestHelper.CreateOpenConnection();
var options = DbTestHelper.CreateOptions(connection, mode);
DbTestHelper.EnsureCreated(options, mode);

InsertRawCheckValueGuidRow(connection, 1, CheckValueGuidId.InvalidValue);

using var readContext = DbTestHelper.CreateContext(options, mode);

Assert.Throws<ArgumentException>(() => readContext.CheckValueGuidIds.Single(x => x.Id == 1));
}

[TestCase(RegistrationMode.OptionsBuilder)]
[TestCase(RegistrationMode.OnModelCreating)]
public void ThrowsWhenStringCheckValueRejectsStoredValue(RegistrationMode mode)
{
using var connection = DbTestHelper.CreateOpenConnection();
var options = DbTestHelper.CreateOptions(connection, mode);
DbTestHelper.EnsureCreated(options, mode);

InsertRawCheckValueStringRow(connection, 1, "string-value-that-is-too-long");

using var readContext = DbTestHelper.CreateContext(options, mode);

Assert.Throws<ArgumentException>(() => readContext.CheckValueStringIds.Single(x => x.Id == 1));
}

private static void InsertRawCheckValueGuidRow(SqliteConnection connection, int id, Guid value)
{
using var command = connection.CreateCommand();
command.CommandText = "INSERT INTO CheckValueGuidIds (Id, StrongId) VALUES ($id, $strongId);";
command.Parameters.AddWithValue("$id", id);
command.Parameters.AddWithValue("$strongId", value);
command.ExecuteNonQuery();
}

private static void InsertRawCheckValueStringRow(SqliteConnection connection, int id, string value)
{
using var command = connection.CreateCommand();
command.CommandText = "INSERT INTO CheckValueStringIds (Id, StrongId) VALUES ($id, $strongId);";
command.Parameters.AddWithValue("$id", id);
command.Parameters.AddWithValue("$strongId", value);
command.ExecuteNonQuery();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace StrongTypeIdGenerator.EntityFrameworkCore.Tests
{
internal sealed class NullableIdEfCoreFixture
{
[TestCase(RegistrationMode.OptionsBuilder)]
[TestCase(RegistrationMode.OnModelCreating)]
public void CanRoundTripNullableGuidStrongTypeId(RegistrationMode mode)
{
using var connection = DbTestHelper.CreateOpenConnection();
var options = DbTestHelper.CreateOptions(connection, mode);
DbTestHelper.EnsureCreated(options, mode);

var nonNullId = new TestGuidId(Guid.Parse("A26974E3-69A2-473A-B9D4-7C1D5E70F435"));

using (var writeContext = DbTestHelper.CreateContext(options, mode))
{
writeContext.NullableGuidIds.Add(new NullableGuidIdEntity { Id = 1, OptionalId = null });
writeContext.NullableGuidIds.Add(new NullableGuidIdEntity { Id = 2, OptionalId = nonNullId });
writeContext.SaveChanges();
}

using (var readContext = DbTestHelper.CreateContext(options, mode))
{
var nullEntity = readContext.NullableGuidIds.Single(x => x.Id == 1);
var nonNullEntity = readContext.NullableGuidIds.Single(x => x.Id == 2);

Assert.That(nullEntity.OptionalId, Is.Null);
Assert.That(nonNullEntity.OptionalId, Is.EqualTo(nonNullId));
}
}
}
}
Loading
Loading