diff --git a/src/SKILL.md b/src/SKILL.md new file mode 100644 index 0000000..1398a19 --- /dev/null +++ b/src/SKILL.md @@ -0,0 +1,441 @@ +--- +name: structid +description: > + Helps define, use, and extend StructId — a zero-dependency, strongly-typed ID library for .NET + that uses readonly record structs. Use this skill when working with struct IDs, value-typed + identifiers, IStructId, IStructId, EF Core converters, Dapper handlers, JSON converters, + custom templates ([TStructId]/[TValue]), or INewable factory patterns in a StructId-based project. +--- + +# StructId + +StructId is a zero-dependency, strongly-typed ID library for .NET. Every user-declared ID type is a +`readonly partial record struct` that implements either `IStructId` (string-backed) or +`IStructId` (struct-backed). All code is source-generated directly into the consuming +project — there are **no runtime package references**. + +## Core Interfaces + +```csharp +// String-backed ID +public readonly partial record struct ProductId : IStructId; + +// Struct-backed ID (Guid, int, long, Ulid, or any struct) +public readonly partial record struct UserId : IStructId; +public readonly partial record struct OrderId : IStructId; +``` + +### `IStructId` (string value) + +```csharp +public partial interface IStructId +{ + string Value { get; } +} +``` + +### `IStructId` (struct value) + +```csharp +public partial interface IStructId where TValue : struct +{ + TValue Value { get; } +} +``` + +### `INewable` / `INewable` (factory pattern) + +Static interface members for consistent factory methods: + +```csharp +public interface INewable +{ + static abstract TSelf New(string value); +} + +public interface INewable +{ + static abstract TSelf New(TValue value); +} +``` + +All struct IDs automatically implement these interfaces via generated code. Use them for +generic constraints that require creating new instances: + +```csharp +T CreateId(string value) where T : INewable => T.New(value); +T CreateId(V value) where T : INewable => T.New(value); +``` + +## Declaring Struct IDs + +The minimum declaration is a `readonly partial record struct` implementing one of the core interfaces: + +```csharp +public readonly partial record struct UserId : IStructId; +public readonly partial record struct ProductId : IStructId; // string-backed +public readonly partial record struct OrderId : IStructId; +public readonly partial record struct TraceId : IStructId; // Ulid supported out of the box +``` + +**Key requirements** (enforced by analyzer with code fixes): +- Must be `readonly` +- Must be `partial` +- Must be `record struct` +- If you declare a primary constructor, it must have a single parameter named `Value` + +```csharp +// Custom primary constructor (e.g. to add attributes) +public readonly partial record struct ProductId([property: JsonPropertyName("id")] int Value) : IStructId; +``` + +## What Gets Generated + +For every struct ID, the source generator emits: +- Primary constructor `(TValue Value)` (unless you declared one) +- `Value` property +- `IComparable` + comparison operators (`<`, `<=`, `>`, `>=`) if `TValue : IComparable` +- `IParsable` + `ISpanParsable` if `TValue : IParsable` +- `IFormattable` + `ISpanFormattable` + `IUtf8SpanFormattable` forwarding to `Value` (when applicable) +- Implicit/explicit conversion operators to/from `TValue` +- `INewable` / `INewable` implementation +- `New(TValue value)` static factory method +- `New()` (parameterless) for `Guid`- and `Ulid`-backed IDs, using `Guid.NewGuid()` / `Ulid.NewUlid()` + +## Factory Methods + +```csharp +// Guid-backed: parameterless New() generates a new GUID +var userId = UserId.New(); // new UserId(Guid.NewGuid()) +var userId2 = UserId.New(someGuid); // new UserId(someGuid) + +// Ulid-backed: parameterless New() generates a new ULID +var traceId = TraceId.New(); // new TraceId(Ulid.NewUlid()) + +// String-backed +var productId = ProductId.New("p-123"); // new ProductId("p-123") + +// int-backed (no parameterless New()) +var orderId = OrderId.New(42); // new OrderId(42) +``` + +## EF Core Integration + +Reference `Microsoft.EntityFrameworkCore` — no other configuration needed. The generator emits +value converters and registers them via `UseStructId()`: + +```csharp +var options = new DbContextOptionsBuilder() + .UseSqlite("Data Source=app.db") + .UseStructId() // registers all struct ID value converters + .Options; + +// Or inside OnConfiguring: +protected override void OnConfiguring(DbContextOptionsBuilder builder) => builder.UseStructId(); +``` + +**Value type coverage:** +- Built-in: `Guid`, `int`, `long`, `string`, `bool`, `byte`, `short`, `float`, `double`, `decimal`, `DateTime`, `DateTimeOffset`, `TimeSpan` +- Automatic via `IParsable` + `IFormattable`: `Ulid` and any custom type implementing both +- Custom: any `ValueConverter` subclass in your project is auto-registered + +## Dapper Integration + +Reference `Dapper` — no other configuration needed. The generator emits `SqlMapper.TypeHandler` +implementations and registers them via `UseStructId()`: + +```csharp +using var connection = new SqliteConnection("Data Source=app.db"); +connection.UseStructId(); // registers all struct ID type handlers +connection.Open(); +``` + +**Value type coverage:** +- Built-in: `Guid`, `int`, `long`, `string` +- Automatic via `IParsable` + `IFormattable`: `Ulid` and any custom type implementing both +- Custom: any `SqlMapper.TypeHandler` subclass in your project is auto-registered + +## System.Text.Json Integration + +Activated automatically when the value type implements `IParsable`. Uses `JsonConverter` +that serializes/deserializes via the `Value` property string representation. + +## Newtonsoft.Json Integration + +Reference `Newtonsoft.Json` — the generator emits `JsonConverter` subclasses automatically. + +## Ulid Integration + +Reference the `Ulid` NuGet package. Since `Ulid` implements `IParsable` and `IFormattable`: +- EF Core and Dapper handlers are generated automatically +- A parameterless `New()` factory is generated using `Ulid.NewUlid()` + +```csharp +public readonly partial record struct TraceId : IStructId; + +var id = TraceId.New(); // new TraceId(Ulid.NewUlid()) +``` + +## Custom Templates (`[TStructId]`) + +The template system allows extending all (or a subset of) struct IDs with additional interfaces +or members. Templates are regular C# files in your project. + +### Template Rules + +1. Must be annotated with `[TStructId]` +2. Must be `file partial record struct` (file-scoped to avoid polluting the assembly) +3. Must be named `TSelf` +4. Primary constructor parameter (if present) must be named `Value` — its type controls which + struct IDs the template applies to + +### Template Examples + +**Apply to all struct IDs (any value type):** + +```csharp +[TStructId] +file partial record struct TSelf(TValue Value) +{ + public static implicit operator TValue(TSelf id) => id.Value; + public static explicit operator TSelf(TValue value) => new(value); +} + +file record struct TValue; // empty = match any value type +``` + +**Apply only to string-backed IDs:** + +```csharp +[TStructId] +file partial record struct TSelf(string Value) +{ + public static implicit operator string(TSelf id) => id.Value; + public static explicit operator TSelf(string value) => new(value); +} +``` + +**Apply only to Guid-backed IDs:** + +```csharp +[TStructId] +file partial record struct TSelf(Guid Value) : IMyGuidId +{ + public Guid AsGuid() => Value; +} +``` + +**Apply to IDs whose value type implements a specific interface:** + +```csharp +[TStructId] +file partial record struct TSelf(TValue Value) : IComparable +{ + public int CompareTo(TSelf other) => ((IComparable)Value).CompareTo(other.Value); + + public static bool operator <(TSelf left, TSelf right) => left.Value.CompareTo(right.Value) < 0; + public static bool operator <=(TSelf left, TSelf right) => left.Value.CompareTo(right.Value) <= 0; + public static bool operator >(TSelf left, TSelf right) => left.Value.CompareTo(right.Value) > 0; + public static bool operator >=(TSelf left, TSelf right) => left.Value.CompareTo(right.Value) >= 0; +} + +// Constrain TValue — only applies to IDs whose value type implements IComparable +file record struct TValue : IComparable +{ + public int CompareTo(TValue other) => throw new NotImplementedException(); +} +``` + +**Exclude string from TValue matching:** + +```csharp +// /*!string*/ inline comment excludes string-backed IDs +[TStructId] +file partial record struct TSelf(/*!string*/ TValue Value) +{ + // only applies to non-string value types +} + +file record struct TValue; +``` + +**Add TSelf interface constraint (additional filtering):** + +```csharp +[TStructId] +file partial record struct TSelf(Ulid Value) +{ + public static TSelf New() => new(Ulid.NewUlid()); +} + +// This partial declaration is removed at expansion time; it only constrains matching +file partial record struct TSelf : INewable +{ + public static TSelf New(Ulid value) => throw new NotImplementedException(); +} +``` + +### What Happens at Expansion Time + +For a struct ID `PersonId : IStructId` and a template applying to `Guid`-backed IDs: + +1. `[TStructId]` attribute is removed from the output +2. `TSelf` is replaced with `PersonId` +3. `TValue` is replaced with `Guid` +4. The primary constructor is removed (provided by `ConstructorGenerator`) +5. The `file` modifier is removed from the type declaration +6. The output is wrapped in the same namespace as `PersonId` +7. File-local helper types (like `file record struct TValue`) are removed from output + +### `TValue` Prefixed Identifiers + +To generate unique helper type names per struct ID, use the `TSelf_` or `TValue_` prefix: + +```csharp +[TStructId] +file partial record struct TSelf(TValue Value) +{ + // TSelf_Helper becomes PersonId_Helper, OrderId_Helper, etc. + private sealed class TSelf_Helper { } +} + +file record struct TValue; +``` + +## Custom Value-Type Templates (`[TValue]`) + +For custom Dapper handlers or EF Core converters for a specific value type, use `[TValue]`: + +```csharp +[TValue] +file class TValue_Handler : SqlMapper.TypeHandler +{ + public override void SetValue(IDbDataParameter parameter, TValue value) + => parameter.Value = value.ToString(); + + public override TValue Parse(object value) + => TValue.Parse((string)value, null); +} + +file record struct TValue : IParsable, IFormattable +{ + // Define the value type constraints +} +``` + +These are automatically discovered and registered in the generated `UseStructId` extension. + +## Diagnostics and Code Fixes + +| Diagnostic | Trigger | Auto-Fix Available | +|---|---|---| +| SID001 | Struct ID is not `readonly partial record struct` | ✅ Add missing modifiers | +| SID002 | Primary constructor parameter not named `Value` (or multiple params) | ✅ Rename to `Value` / Remove constructor | +| SID003 | `[TStructId]` type is not `file partial record struct` | ✅ Add `file` modifier | +| SID004 | `[TStructId]` constructor parameter not named `Value` | ✅ Rename to `Value` | +| SID005 | `[TStructId]` type is not named `TSelf` | ✅ Rename type | + +## Installation + +```xml + +``` + +- Install only in the **top-level project** — analyzers and generators propagate transitively to all referencing projects +- The package is `developmentDependency="true"` — no runtime dependency is added to consumers + +## Integration Auto-Activation + +Features activate automatically when the corresponding package is referenced: + +| Package | Generated Feature | +|---|---| +| `Microsoft.EntityFrameworkCore` | `ValueConverter` + `UseStructId(DbContextOptionsBuilder)` | +| `Dapper` | `SqlMapper.TypeHandler` + `UseStructId(IDbConnection)` | +| `Newtonsoft.Json` | `JsonConverter` subclass | +| `Ulid` | `Ulid`-specific handlers + parameterless `New()` factory | + +No attribute, configuration, or code change is needed — just add the NuGet reference and rebuild. + +## Common Patterns + +### Entity with typed ID in EF Core + +```csharp +public readonly partial record struct UserId : IStructId; + +public class User +{ + public UserId Id { get; set; } = UserId.New(); + public string Name { get; set; } = ""; +} + +public class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Users => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity().HasKey(u => u.Id); + } +} + +// Setup +var options = new DbContextOptionsBuilder() + .UseSqlite("Data Source=app.db") + .UseStructId() + .Options; +``` + +### Dapper query with struct ID + +```csharp +public readonly partial record struct ProductId : IStructId; + +using var connection = new SqliteConnection("Data Source=app.db"); +connection.UseStructId(); +connection.Open(); + +var product = connection.QueryFirst( + "SELECT * FROM Products WHERE Id = @Id", + new { Id = new ProductId(42) }); +``` + +### Generic repository using INewable + +```csharp +public class Repository + where TId : struct, IStructId, INewable + where TValue : struct +{ + public TEntity GetById(TValue rawValue) => Get(TId.New(rawValue)); + private TEntity Get(TId id) => /* ... */; +} +``` + +### Custom template for domain-specific interface + +```csharp +// IEntityId.cs — custom interface +public interface IEntityId +{ + Guid AsGuid(); +} + +// EntityIdTemplate.cs — template to implement it for all Guid-backed IDs +[TStructId] +file partial record struct TSelf(Guid Value) : IEntityId +{ + public Guid AsGuid() => Value; +} +``` + +## Conventions + +- Struct IDs must be `readonly partial record struct` +- Always use `IStructId` for struct value types; use `IStructId` for strings +- Templates must be in `file partial record struct TSelf` named files; no specific file naming required +- `TValue` placeholder in templates means "any value type"; add interfaces to constrain it +- Use `TSelf.New()` (parameterless) for `Guid` and `Ulid` IDs; `TSelf.New(value)` for all others +- `UseStructId()` must be called once at startup for EF Core (`DbContextOptionsBuilder`) and Dapper (`IDbConnection`) +- Custom `ValueConverter<,>` and `SqlMapper.TypeHandler` subclasses in the project are auto-registered diff --git a/src/StructId.Package/StructId.Package.msbuildproj b/src/StructId.Package/StructId.Package.msbuildproj index af5035f..11e4746 100644 --- a/src/StructId.Package/StructId.Package.msbuildproj +++ b/src/StructId.Package/StructId.Package.msbuildproj @@ -19,5 +19,6 @@ + \ No newline at end of file diff --git a/src/StructId.Package/StructId.targets b/src/StructId.Package/StructId.targets index 227c215..839485f 100644 --- a/src/StructId.Package/StructId.targets +++ b/src/StructId.Package/StructId.targets @@ -15,6 +15,33 @@ Visible="false"/> + + + + + <_StructIdSkillSourceRoot Include="@(SourceRoot -> WithMetadataValue('SourceControl', 'git'))" /> + + + + <_StructIdSkillRepoRoot>@(_StructIdSkillSourceRoot) + + + + + +