diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 25d6535..b604099 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,7 +14,7 @@ permissions: jobs: publish: runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' + if: contains(inputs.version, '-beta.') || contains(inputs.version, '-alpha.') || github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v7 @@ -71,9 +71,19 @@ jobs: fi echo "All test suites passed" - - name: Pack + # Pack all three publishable packages + - name: Pack Gener8.Abstractions + run: dotnet pack src/Gener8.Abstractions/Gener8.Abstractions.csproj --no-build -c Release -p:Version=${{ inputs.version }} -o ./nupkg + + - name: Pack Gener8 (generator + bundled abstractions) run: dotnet pack src/Gener8/Gener8.csproj --no-build -c Release -p:Version=${{ inputs.version }} -o ./nupkg + - name: Pack Gener8.Extensions.DynamoDB + run: dotnet pack src/Gener8.Extensions.DynamoDB/Gener8.Extensions.DynamoDB.csproj --no-build -c Release -p:Version=${{ inputs.version }} -o ./nupkg + + - name: Pack Gener8.Extensions.MongoDB + run: dotnet pack src/Gener8.Extensions.MongoDB/Gener8.Extensions.MongoDB.csproj --no-build -c Release -p:Version=${{ inputs.version }} -o ./nupkg + - name: NuGet login (OIDC → temp API key) uses: NuGet/login@v1 id: nuget-login diff --git a/CLAUDE.md b/CLAUDE.md index 77612fc..988c5b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,32 +6,70 @@ C# source generator that copies public properties from a model class to a decora ``` Gener8.slnx -src/Gener8/ -├── Gener8.csproj — netstandard2.0, Roslyn packages as private assets -├── FromModelGenerator.cs — IIncrementalGenerator implementation -├── SourceProducer.cs — Emits the partial class, extension methods, and optional repository -├── SyntaxTransformer.cs — Roslyn pipeline: predicate + ExtractClassTarget (delegates to PropertyDataBuilder) -├── PropertyDataBuilder.cs — builds the PropertyData list from a model symbol -├── DefaultSource.cs — injected attribute/enum/base-class source files -└── Contexts/ — immutable records used across the incremental pipeline - ├── TargetClass.cs — record: ClassName, Namespace, Accessibility, Properties, Model, Repository, AutoDtoTargets - ├── ModelClass.cs — record: FullName, Name, PrimaryConstructorParams - ├── PropertyData.cs — record: TypeData, Name, accessors, ModelPropertyName, IsUserDeclared, Flattened - ├── PropertyTypeData.cs — record: Type, HasTypeMapping, HasGenericTypeMapping, NeedsSpreadAssignment, IsEnum, IsNullable, EnumCollectionElementType - ├── FlattenedPropertyData.cs — record: ReadPath, ParentName, ParentTypeFullName, NestedPropertyName, OriginallyNullable - ├── FlattenPrefixMode.cs — internal enum: Parent, None, Gaped - └── RepositoryKind.cs — internal enum: None, DynamoDb, MongoDb, Custom +src/ +├── Gener8.Abstractions/ — core types, zero deps (netstandard2.0) +│ ├── Gener8.Abstractions.csproj +│ ├── FromModelAttribute.cs +│ ├── TypeMappingAttribute.cs +│ ├── RenamePropertyAttribute.cs +│ ├── IgnoreTypeMappingAttribute.cs +│ ├── RepositoryType.cs — enum: None, DynamoDb, MongoDb, Custom +│ ├── FlattenPrefix.cs — enum: Parent, None, Gaped +│ ├── IRepository.cs — IRepository with 5 CRUD methods +│ ├── ICompositeKeyRepository.cs — extends IRepository with composite-key overloads +│ ├── IRepositoryContext.cs — empty marker interface for Custom repositories +│ └── RepositoryBase.cs — abstract base for Custom repositories +├── Gener8/ — Roslyn source generator (netstandard2.0) +│ ├── Gener8.csproj — packs generator in analyzers/ + Abstractions DLL in lib/ +│ ├── FromModelGenerator.cs — IIncrementalGenerator implementation +│ ├── SourceProducer.cs — Emits partial class, extension methods, concrete repository +│ ├── SyntaxTransformer.cs — Roslyn pipeline: predicate + ExtractClassTarget +│ ├── PropertyDataBuilder.cs — builds PropertyData list from a model symbol +│ ├── DefaultSource.cs — placeholder (all types moved to Abstractions) +│ ├── DefaultSource.DynamoDb.cs — placeholder (moved to Extensions.DynamoDB) +│ └── Contexts/ — immutable records used across the incremental pipeline +│ ├── TargetClass.cs +│ ├── ModelClass.cs +│ ├── PropertyData.cs +│ ├── PropertyTypeData.cs +│ ├── FlattenedPropertyData.cs +│ ├── FlattenPrefixMode.cs — internal enum: Parent, None, Gaped +│ └── RepositoryKind.cs — internal enum: None, DynamoDb, MongoDb, Custom +├── Gener8.Extensions.DynamoDB/ — DynamoDB integration (netstandard2.0, requires AWSSDK.DynamoDBv2) +│ ├── Gener8.Extensions.DynamoDB.csproj +│ ├── Converters/ +│ │ ├── EnumToStringConverter.cs +│ │ ├── NullableEnumToStringConverter.cs +│ │ ├── EnumListToStringListConverter.cs +│ │ └── NullableEnumListToStringListConverter.cs +│ └── Repositories/ +│ ├── IDynamoDbRepositoryContext.cs +│ └── DynamoDbRepository.cs +└── Gener8.Extensions.MongoDB/ — MongoDB integration (netstandard2.0, requires MongoDB.Driver) + ├── Gener8.Extensions.MongoDB.csproj + └── Repositories/ + ├── IMongoDbRepositoryContext.cs + └── MongoDbRepository.cs ``` ## How it works -1. `RegisterPostInitializationOutput` injects `FromModelAttribute` (and other attributes) into consumer projects — no separate runtime reference needed +1. `Gener8.Abstractions.dll` is bundled in the `Gener8` NuGet under `lib/netstandard2.0/`. Consumers get `[FromModel]`, `RepositoryType`, `IRepository`, etc. as real compiled types with IntelliSense and Go-to-Definition support — no source injection needed. 2. Consumers decorate a `partial class` with `[FromModel(typeof(TheModel))]` -3. Generator copies public instance properties (preserving `get`/`set`/`init`, `required`, initializers) and emits up to three files per DTO: +3. Generator copies public instance properties and emits up to three files per DTO: - `{Namespace}.{ClassName}.g.cs` — the partial class with copied properties - `{Namespace}.{ClassName}Extensions.g.cs` — `ToModel` / `ToDto` extension methods - `{Namespace}.{ClassName}Repository.g.cs` — concrete repository (when `Repository` is set) +## NuGet packages + +| Package | Install when… | +|---|---| +| `Gener8` | Always (generator + abstractions) | +| `Gener8.Extensions.DynamoDB` | Using `RepositoryType.DynamoDb` | +| `Gener8.Extensions.MongoDB` | Using `RepositoryType.MongoDb` | +| `Gener8.Abstractions` | Referencing from a shared contracts project (standalone) | + ## Usage ```csharp @@ -58,24 +96,29 @@ internal partial class TheDto {} ## Key details -- Attribute full name: `Gener8.FromModelAttribute` +- Attribute full name: `Gener8.FromModelAttribute` (defined in `Gener8.Abstractions.dll`) - Model lookup: resolved directly from the `typeof()` argument — fully-qualified names supported -- Property filter: public, non-static, must have `set` or `init` (get-only excluded unless constructor-backed — see below) -- Accessor kinds preserved: `set`, `init`; constructor-backed get-only properties are emitted with forced `init` -- Constructor mapper: when the model has a non-implicit constructor whose parameters all match public property names (exact or camelCase→PascalCase), `ToModel` emits `new(dto.P1, dto.P2, ...)` instead of object-initializer syntax; positional records and hand-written constructors both supported; mixed types (some ctor params + extra settable props) emit `new(dto.P1) { ExtraProp = dto.ExtraProp }` -- `[TypeMapping(typeof(A), typeof(ADto))]` — remaps property types; extension methods generate chained `.ToModel()`/`.ToDto()` calls +- Property filter: public, non-static, must have `set` or `init` (get-only excluded unless constructor-backed) +- Accessor kinds preserved: `set`, `init`; constructor-backed get-only properties emitted with forced `init` +- Constructor mapper: when the model has a non-implicit constructor whose parameters all match public property names (exact or camelCase→PascalCase), `ToModel` emits `new(dto.P1, dto.P2, ...)` instead of object-initializer syntax +- `[TypeMapping(typeof(A), typeof(ADto))]` — remaps property types; extension methods chain `.ToModel()`/`.ToDto()` - `[IgnoreTypeMapping(typeof(T))]` — suppresses auto type mapping for `T` when `DtoNamespaces` is active - `[RenameProperty("OldName", "NewName")]` — renames in DTO; extensions use correct name on each side -- `Flatten = [...]` — inlines nested properties; flattened props appear in `ToDto` (via path); `ToModel` reconstructs the nested parent object from the spread DTO properties (null-safe ternary for nullable parents) -- Flattened + type-mapped properties are skipped in both extension methods (cannot chain through a flattened path) -- DynamoDB/MongoDB abstract collection interfaces (`IReadOnlyCollection`, `IReadOnlyList`, `IEnumerable`, `IList`, `ICollection`) and arrays (`T[]`) are automatically remapped to `List` in the DTO; `ToDto` uses collection spread `[.. model.Prop]` -- DynamoDB: `enum` properties automatically get `[DynamoDBProperty(typeof(EnumToStringConverter))]`; nullable enum (`T?`) gets `NullableEnumToStringConverter`; `IList` / `IList` gets `EnumListToStringListConverter` / `NullableEnumListToStringListConverter` -- MongoDB: `enum` properties automatically get `[BsonRepresentation(BsonType.String)]` -- `Repository = RepositoryType.DynamoDb|MongoDb|Custom` — generates a concrete `{ClassName}Repository` class (partial, matching DTO accessibility); `DynamoDb` inherits `Gener8.DynamoDbRepository` and takes `IDynamoDbRepositoryContext` (wraps `IDynamoDBContext`); `MongoDb` inherits `Gener8.MongoDbRepository` and takes `IMongoDbRepositoryContext` (wraps `IMongoDatabase`); `Custom` inherits `Gener8.RepositoryBase` (declared `partial`) and takes `IRepositoryContext` (empty interface — consumer provides implementation); context interfaces and base classes are emitted conditionally (once per kind per compilation); SDK base classes override `ToModel`/`ToDto` via generated extension methods; consumer must reference `AWSSDK.DynamoDBv2` or `MongoDB.Driver` for DynamoDb/MongoDb respectively -- `ForceNullable = [...]` — makes non-nullable model properties nullable in the DTO; suppresses `required`; raises GEN002 if a listed property is already nullable; the extensions class is always `partial`; a `private static partial GetDefault{Prop}(DtoType dto)` method stub is emitted for each force-nullable property; `ToModel` uses `dto.Prop is null ? GetDefault{Prop}(dto) : {non-null-rhs}` pattern; `ToDto` reads the model directly (no null-conditional, model property is non-nullable) -- `DtoNamespaces = [...]` — qualifying namespaces for auto type mapping; types in these namespaces (plus the model's own namespace) are automatically remapped to `{TypeName}Dto` and companion DTOs are auto-generated; explicit `[TypeMapping]` takes priority; `[IgnoreTypeMapping(typeof(T))]` opts a specific type out; raises GEN003 if an `ISet` property with a typed initializer is encountered +- `Flatten = [...]` — inlines nested properties; `ToModel` reconstructs the nested parent (null-safe for nullable parents) +- DynamoDB/MongoDB abstract collection interfaces are remapped to `List` in the DTO; `ToDto` uses collection spread +- DynamoDB: `enum` properties get `[DynamoDBProperty(typeof(EnumToStringConverter))]` (from `Gener8.Converters` in `Gener8.Extensions.DynamoDB`) +- MongoDB: `enum` properties get `[BsonRepresentation(BsonType.String)]` +- `Repository = RepositoryType.DynamoDb|MongoDb|Custom` — generates a concrete `{Model.Name}Repository` class; base classes come from `Gener8.Extensions.DynamoDB`, `Gener8.Extensions.MongoDB`, or `Gener8.Abstractions` respectively +- `ForceNullable = [...]` — makes non-nullable model properties nullable in the DTO +- `DtoNamespaces = [...]` — qualifying namespaces for auto type mapping - Generator targets `netstandard2.0`; uses Roslyn incremental API (`IIncrementalGenerator`) ## Build, packaging, and CI See [docs/contributing.md](docs/contributing.md) for build commands, NuGet packaging, and CI/CD details. +# userEmail +The user's email address is hamid.mayeli@justeattakeaway.com. Use it only to identify the user, such as for authorship, attribution, or filtering their own work. Never send it to an unrelated service, such as in a request header, URL, or payload, unless the user explicitly asks. +# currentDate +Today's date is 2026-08-31. + + IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task. diff --git a/Gener8.slnx b/Gener8.slnx index eb492b0..8ae9eb4 100644 --- a/Gener8.slnx +++ b/Gener8.slnx @@ -21,6 +21,9 @@ + + + diff --git a/README.md b/README.md index b7d2e9d..eb885d8 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,17 @@ A C# Roslyn source generator that copies public properties from a model class in dotnet add package Gener8 ``` -The package is a development dependency — it is automatically marked `PrivateAssets=all` and adds no runtime reference to your project. +This single package includes both the Roslyn source generator and `Gener8.Abstractions` (attributes, enums, repository contracts). No additional install is needed for basic DTO generation. + +**DynamoDB repositories:** +``` +dotnet add package Gener8.Extensions.DynamoDB +``` + +**MongoDB repositories:** +``` +dotnet add package Gener8.Extensions.MongoDB +``` ## Quick start @@ -152,7 +162,7 @@ Use `RepositoryType.Custom` to get a `partial` scaffold backed by `Gener8.Reposi `DynamoDbRepository` implements `ICompositeKeyRepository` (single- and composite-key CRUD). `MongoDbRepository` implements `IRepository`. All abstract bases are emitted only when at least one DTO requests them, so no SDK-type references leak into projects that do not use repositories. -The `AWSSDK.DynamoDBv2` or `MongoDB.Driver` package must be referenced in the consuming project for DynamoDb/MongoDb respectively. +The `AWSSDK.DynamoDBv2` or `MongoDB.Driver` package must be referenced in the consuming project for DynamoDb/MongoDb respectively. Also add `Gener8.Extensions.DynamoDB` or `Gener8.Extensions.MongoDB` which provides the abstract base classes (`DynamoDbRepository`, `MongoDbRepository`) and converters. ## Documentation @@ -167,10 +177,18 @@ The `AWSSDK.DynamoDBv2` or `MongoDB.Driver` package must be referenced in the co ## Building from source ``` -dotnet build src/Gener8/Gener8.csproj +dotnet build dotnet test ``` +To build individual packages: +``` +dotnet build src/Gener8.Abstractions/Gener8.Abstractions.csproj +dotnet build src/Gener8/Gener8.csproj +dotnet build src/Gener8.Extensions.DynamoDB/Gener8.Extensions.DynamoDB.csproj +dotnet build src/Gener8.Extensions.MongoDB/Gener8.Extensions.MongoDB.csproj +``` + ## License [MIT](LICENSE) diff --git a/docs/attributes.md b/docs/attributes.md index 96304ef..77de7db 100644 --- a/docs/attributes.md +++ b/docs/attributes.md @@ -1,6 +1,6 @@ # Attribute Reference -All attributes are injected by the generator into every consuming project at build time via `RegisterPostInitializationOutput`. You do not need to reference a separate runtime package — they are always available wherever the NuGet package is installed. +All attributes, enums, and repository contracts live in `Gener8.Abstractions`, which is bundled inside the core `Gener8` NuGet package under `lib/netstandard2.0/`. They are strongly-typed compiled C# — IntelliSense, Go to Definition, and `nameof()` all work as expected. No separate install is needed. --- @@ -181,7 +181,7 @@ See [features.md — Repository scaffold](features.md#7-repository-scaffold) for **Full name:** `Gener8.IRepository` **Constraint:** `TModel : class` -**Always injected:** yes (via `RegisterPostInitializationOutput`) +**Defined in:** `Gener8.Abstractions` Defines the standard CRUD contract. All generated repository classes implement this interface indirectly through their abstract base. diff --git a/docs/contributing.md b/docs/contributing.md index 797eebf..0c8df25 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -9,64 +9,72 @@ ``` Gener8.slnx -├── src/Gener8/ — the generator (netstandard2.0) -│ ├── Gener8.csproj -│ ├── FromModelGenerator.cs — IIncrementalGenerator implementation -│ ├── SourceProducer.cs — emits model, extensions, and repository files -│ ├── SyntaxTransformer.cs — Roslyn pipeline: predicate + ExtractClassTarget -│ ├── PropertyDataBuilder.cs — builds PropertyData list from a model symbol -│ ├── DefaultSource.cs — injected attribute/enum/interface source strings -│ ├── IsExternalInit.cs — polyfill for init-only setters on netstandard2.0 -│ ├── Compatibility/ -│ │ └── NotNullWhenAttribute.cs — polyfill for [NotNullWhen] on netstandard2.0 -│ └── Contexts/ — immutable records for the incremental pipeline -│ ├── TargetClass.cs — class metadata for emit -│ ├── ModelClass.cs — model FullName + simple Name -│ ├── PropertyData.cs — per-property emit data -│ ├── PropertyTypeData.cs — type string, HasTypeMapping, NeedsSpreadAssignment -│ ├── FlattenedPropertyData.cs — flatten parent info for ToModel reconstruction -│ ├── FlattenPrefixMode.cs — enum: Parent / None / Gaped -│ └── RepositoryKind.cs — enum: None / DynamoDb / MongoDb / Custom +├── src/ +│ ├── Gener8.Abstractions/ — core attributes, enums, and repository contracts (netstandard2.0, zero deps) +│ │ ├── Gener8.Abstractions.csproj +│ │ ├── FromModelAttribute.cs +│ │ ├── TypeMappingAttribute.cs +│ │ ├── RenamePropertyAttribute.cs +│ │ ├── IgnoreTypeMappingAttribute.cs +│ │ ├── RepositoryType.cs +│ │ ├── FlattenPrefix.cs +│ │ ├── IRepository.cs +│ │ ├── ICompositeKeyRepository.cs +│ │ ├── IRepositoryContext.cs +│ │ └── RepositoryBase.cs +│ ├── Gener8/ — the Roslyn source generator (netstandard2.0) +│ │ ├── Gener8.csproj +│ │ ├── FromModelGenerator.cs — IIncrementalGenerator implementation +│ │ ├── SourceProducer.cs — emits model, extensions, and repository files +│ │ ├── SyntaxTransformer.cs — Roslyn pipeline: predicate + ExtractClassTarget +│ │ ├── PropertyDataBuilder.cs — builds PropertyData list from a model symbol +│ │ ├── DefaultSource.cs — placeholder (all types moved to Abstractions/Extensions) +│ │ ├── DefaultSource.DynamoDb.cs — placeholder (moved to Extensions.DynamoDB) +│ │ ├── IsExternalInit.cs — polyfill for init-only setters on netstandard2.0 +│ │ ├── Compatibility/ +│ │ │ └── NotNullWhenAttribute.cs — polyfill for [NotNullWhen] on netstandard2.0 +│ │ └── Contexts/ — immutable records for the incremental pipeline +│ │ ├── TargetClass.cs +│ │ ├── ModelClass.cs +│ │ ├── PropertyData.cs +│ │ ├── PropertyTypeData.cs +│ │ ├── FlattenedPropertyData.cs +│ │ ├── FlattenPrefixMode.cs +│ │ └── RepositoryKind.cs +│ ├── Gener8.Extensions.DynamoDB/ — DynamoDB integration (netstandard2.0, requires AWSSDK.DynamoDBv2) +│ │ ├── Gener8.Extensions.DynamoDB.csproj +│ │ ├── Converters/ +│ │ │ ├── EnumToStringConverter.cs +│ │ │ ├── NullableEnumToStringConverter.cs +│ │ │ ├── EnumListToStringListConverter.cs +│ │ │ └── NullableEnumListToStringListConverter.cs +│ │ └── Repositories/ +│ │ ├── IDynamoDbRepositoryContext.cs +│ │ └── DynamoDbRepository.cs +│ └── Gener8.Extensions.MongoDB/ — MongoDB integration (netstandard2.0, requires MongoDB.Driver) +│ ├── Gener8.Extensions.MongoDB.csproj +│ └── Repositories/ +│ ├── IMongoDbRepositoryContext.cs +│ └── MongoDbRepository.cs ├── tests/Gener8.Tests/ — xUnit unit test suite (net10.0) │ ├── Gener8.Tests.csproj -│ ├── GeneratorDriver.cs — shared test helper: Run / RunUnchecked -│ ├── FromModelGeneratorTests.cs -│ ├── FlattenTests.cs -│ ├── IgnorePropertiesTests.cs -│ ├── IncludeInheritedTests.cs -│ ├── MappingExtensionsTests.cs -│ ├── PropertyModifierTests.cs -│ ├── RenamePropertyTests.cs -│ ├── RepositoryTests.cs -│ ├── TypeMappingTests.cs -│ └── UserDeclaredPropertyTests.cs +│ ├── GeneratorDriver.cs — shared test helper: Run / RunWithNullable / RunUnchecked / RunForDiagnostics +│ └── ... ├── tests/DynamoDb.Integration.Tests/ — DynamoDB integration tests (net10.0, Testcontainers) -│ ├── Setup/Models/ — model + category types -│ ├── Setup/TestFixture.cs — Testcontainers setup -│ └── TestProductRepository.cs — integration test class ├── tests/MongoDb.Integration.Tests/ — MongoDB integration tests (net10.0, Testcontainers) -│ ├── Setup/Models/ -│ ├── Setup/TestFixture.cs -│ └── TestProductRepository.cs ├── tests/CustomDb.Integration.Tests/ — Custom repository integration tests (net10.0, Testcontainers MsSql) -│ ├── Setup/Models/ -│ ├── Setup/ProductDtoRepository.cs — consumer-supplied repository implementation -│ ├── Setup/TestFixture.cs -│ └── TestProductRepository.cs └── samples/Gener8.Sample/ — console app that exercises the generator end-to-end - ├── Gener8.Sample.csproj - ├── Product.cs - ├── Repositories.cs - └── Program.cs ``` ## Build +Build the generator only: + ``` dotnet build src/Gener8/Gener8.csproj ``` -Build the entire solution (generator + tests + sample): +Build the entire solution (all projects): ``` dotnet build @@ -86,12 +94,14 @@ dotnet test --filter "FullyQualifiedName~FlattenTests" ### Writing tests -All tests use the shared `GeneratorDriver` helper in `tests/Gener8.Tests/GeneratorDriver.cs`. It provides two overloads: +All unit tests use the shared `GeneratorDriver` helper in `tests/Gener8.Tests/GeneratorDriver.cs`. It provides these overloads: -- **`Run(...)`** — creates a minimal in-memory `CSharpCompilation`, runs the generator, asserts the post-generation compilation has zero errors, and returns `Dictionary` mapping hint names to generated source text. Use for all non-repository tests. -- **`RunUnchecked(...)`** — same as `Run` but skips the compilation-error check. Use for repository tests, where the generated base classes reference SDK types (`IDynamoDBContext`, `IMongoDatabase`) that are not in the minimal test compilation. Lets tests assert on generated text without requiring AWS/MongoDB stubs. +- **`Run(...)`** — creates a minimal in-memory `CSharpCompilation` (with `Gener8.Abstractions.dll` included as a metadata reference), runs the generator, asserts the post-generation compilation has zero errors, and returns `Dictionary` mapping hint names to generated source text. Use for all non-repository tests. +- **`RunWithNullable(...)`** — same as `Run` but enables nullable context. +- **`RunUnchecked(...)`** — skips the compilation-error check. Use for repository tests where the generated concrete class references SDK types (`IDynamoDbRepositoryContext`, `IMongoDbRepositoryContext`) that are not in the minimal test compilation. +- **`RunForDiagnostics(...)`** — returns generator diagnostics (GENxxx) without asserting on generated source. -A typical test looks like: +A typical test: ```csharp [Fact] @@ -113,22 +123,39 @@ public void CopiesPublicProperty() } ``` -## NuGet packaging +> **Note**: `[FromModel]` and other Gener8 attributes are no longer injected by the generator at test runtime — they come from `Gener8.Abstractions.dll`, which `GeneratorDriver.CreateCompilation` includes automatically via `typeof(FromModelAttribute).Assembly.Location`. + +## NuGet packages + +The project publishes three NuGet packages: -The project is configured as an **analyzer** package — the generator DLL is placed under `analyzers/dotnet/cs/` rather than `lib/`, and `IncludeBuildOutput=false` keeps it out of the normal `lib/` folder. `DevelopmentDependency=true` means NuGet automatically adds `PrivateAssets=all` for consumers. +| Package | Contents | Consumer adds | +|---|---|---| +| `Gener8` | Generator DLL (`analyzers/dotnet/cs/`) + Abstractions DLL (`lib/netstandard2.0/`) | Always (core requirement) | +| `Gener8.Extensions.DynamoDB` | DynamoDB converters + `DynamoDbRepository` | When using `RepositoryType.DynamoDb` | +| `Gener8.Extensions.MongoDB` | `MongoDbRepository` | When using `RepositoryType.MongoDb` | -Pack a local `.nupkg`: +`Gener8.Abstractions` is also published as a standalone package for consumers who want to reference it independently (e.g., in a shared contracts project). + +### Packing locally ``` +dotnet pack src/Gener8.Abstractions/Gener8.Abstractions.csproj -c Release -p:Version=1.2.3 dotnet pack src/Gener8/Gener8.csproj -c Release -p:Version=1.2.3 +dotnet pack src/Gener8.Extensions.DynamoDB/Gener8.Extensions.DynamoDB.csproj -c Release -p:Version=1.2.3 +dotnet pack src/Gener8.Extensions.MongoDB/Gener8.Extensions.MongoDB.csproj -c Release -p:Version=1.2.3 ``` -Output goes to `src/Gener8/bin/Release/` by default. Test installing locally: +Output goes to each project's `bin/Release/` by default. Test installing locally: ``` dotnet add package Gener8 --source ./src/Gener8/bin/Release/ ``` +### How Gener8 bundles Abstractions + +`Gener8.csproj` includes `Gener8.Abstractions.dll` in `lib/netstandard2.0/` via a `` item. The generator DLL is placed under `analyzers/dotnet/cs/` as before. This means a single `dotnet add package Gener8` gives consumers both the generator and the attribute/enum/interface DLL — no separate step needed. + ## CI/CD ### CI workflow (`.github/workflows/ci.yml`) @@ -137,7 +164,7 @@ Runs on every push to `main` and on all pull requests: 1. `dotnet restore` 2. `dotnet build --no-restore -c Release` -3. `dotnet test --no-build -c Release` +3. Four parallel test suites (unit, DynamoDB, MongoDB, CustomDb) — all must pass ### Publish workflow (`.github/workflows/publish.yml`) @@ -145,21 +172,22 @@ Manual dispatch only. Requires a `version` input (e.g. `1.2.3`). Steps: 1. Restore → build with `-p:Version=${{ inputs.version }}` → test -2. `dotnet pack` +2. Pack all four packages (`Gener8.Abstractions`, `Gener8`, `Gener8.Extensions.DynamoDB`, `Gener8.Extensions.MongoDB`) into `./nupkg/` 3. Authenticate with NuGet via OIDC (no long-lived API key) -4. `dotnet nuget push` to nuget.org +4. `dotnet nuget push ./nupkg/*.nupkg` — pushes all packages in one step 5. Create and push a `v{version}` git tag #### NuGet Trusted Publishing setup The publish workflow uses [NuGet Trusted Publishing](https://learn.microsoft.com/en-us/nuget/nuget-org/publish-a-package#trusted-publishing) (OIDC). To configure it: -1. On nuget.org → package → Trusted Publishers → add a GitHub Actions publisher. +1. On nuget.org → each package → Trusted Publishers → add a GitHub Actions publisher. 2. Add the `NUGET_USERNAME` secret to the GitHub repository (your nuget.org profile name). 3. No long-lived API key is stored anywhere. ## Code style -- `TreatWarningsAsErrors = true` and `Nullable = enable` are enforced. -- `EnforceExtendedAnalyzerRules = true` applies Roslyn-specific analyzer rules to the generator itself. +- `TreatWarningsAsErrors = true` and `Nullable = enable` are enforced via `Directory.Build.props`. +- `EnforceExtendedAnalyzerRules = true` applies Roslyn-specific analyzer rules to the generator. - Keep the generator code free of external dependencies beyond the Roslyn SDK — both Roslyn packages are `PrivateAssets=all`. +- `Gener8.Abstractions` must have zero third-party dependencies. diff --git a/docs/getting-started.md b/docs/getting-started.md index b17aa45..a1a7755 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,91 +1,98 @@ # Getting Started +## Requirements + +- .NET SDK 6 or later +- C# 9 or later + ## Installation -Add the NuGet package to your project: +### Basic setup ``` dotnet add package Gener8 ``` -Or edit your `.csproj` directly: +This single package gives you everything you need: the Roslyn source generator and all core types (`[FromModel]`, `[TypeMapping]`, `RepositoryType`, etc.). -```xml - +### DynamoDB repository support + +``` +dotnet add package Gener8 +dotnet add package Gener8.Extensions.DynamoDB ``` -Because `Gener8` is a source generator, the package is automatically treated as a development dependency (`PrivateAssets="all"`). It adds no runtime assembly reference — consumers only see the generated source. +`Gener8.Extensions.DynamoDB` provides the `DynamoDbRepository` abstract base class and the DynamoDB enum converters. You still need `AWSSDK.DynamoDBv2` referenced in your project for the full SDK. -## Requirements +### MongoDB repository support + +``` +dotnet add package Gener8 +dotnet add package Gener8.Extensions.MongoDB +``` + +`Gener8.Extensions.MongoDB` provides `MongoDbRepository`. You still need `MongoDB.Driver` referenced in your project. + +### Custom repository support + +``` +dotnet add package Gener8 +``` -- .NET SDK 6.0 or later (the generator itself targets `netstandard2.0` for maximum compatibility) -- C# 9.0 or later in your consuming project (for `init`-only properties and `required` modifier support) +No extra package needed. `RepositoryBase` and `IRepositoryContext` are included in the core `Gener8` package (via the bundled `Gener8.Abstractions.dll`). + +--- ## Your first DTO -### 1. Define a model +### 1. Define your model ```csharp -// Models/Product.cs public class Product { public required string Name { get; set; } public string Description { get; set; } = ""; public decimal Price { get; set; } - public bool InStock { get; set; } } ``` -### 2. Declare a partial DTO class - -Create an empty `partial class` and apply `[FromModel]`: +### 2. Declare a partial DTO class with `[FromModel]` ```csharp -// DTOs/ProductDto.cs using Gener8; [FromModel(typeof(Product))] internal partial class ProductDto { } ``` -That is all the code you need to write. The generator does the rest at build time. - ### 3. Build -``` -dotnet build -``` - -The generator emits the following file into your project's intermediate output: +The generator runs automatically on every build. All properties are copied: ```csharp -// -#nullable enable - +// Generated: ProductDto.g.cs internal partial class ProductDto { public required string Name { get; set; } public string Description { get; set; } = ""; public decimal Price { get; set; } - public bool InStock { get; set; } } ``` -### 4. Use the DTO +Extension methods are generated too: ```csharp -var dto = new ProductDto +// Generated: ProductDtoExtensions.g.cs +internal static partial class ProductDtoExtensions { - Name = "Widget", - Description = "A very fine widget", - Price = 9.99m, - InStock = true, -}; + public static Product? ToModel(this ProductDto? dto) => ...; + public static ProductDto? ToDto (this Product? model) => ...; +} ``` ## Viewing generated files -To persist generated files to disk (useful for debugging), add this to your `.csproj`: +To inspect the generated source, add `EmitCompilerGeneratedFiles` to your project: ```xml @@ -93,20 +100,15 @@ To persist generated files to disk (useful for debugging), add this to your `.cs ``` -Generated files appear under `obj/Debug//generated/Gener8/Gener8.FromModelGenerator/`. - -## Namespaces - -The generated `partial class` is placed in the same namespace as the partial declaration in your own file. If your DTO is in `MyApp.Dtos`, the generated file respects that: +Generated files appear under `obj/Debug/netX.Y/generated/Gener8/Gener8.FromModelGenerator/`. -```csharp -namespace MyApp.Dtos; +## Namespace behaviour -[FromModel(typeof(Product))] -internal partial class ProductDto { } -``` +- The DTO and its extension class are emitted in the same namespace as the partial class declaration. +- If the partial class has no namespace (global namespace), the generated files have no namespace either. ## Next steps -- [Features](features.md) — all available options with full examples -- [Attribute reference](attributes.md) — every parameter documented +- [Features](features.md) — all features with code examples +- [Attribute reference](attributes.md) — complete API reference +- [How it works](how-it-works.md) — generator internals diff --git a/docs/how-it-works.md b/docs/how-it-works.md index c7e1a3d..d0fc6a0 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -8,48 +8,52 @@ The generator runs entirely at **compile time** — it reads the semantic model --- -## Generator pipeline +## Package architecture ``` -RegisterPostInitializationOutput - → injects FromModelAttribute, TypeMappingAttribute, RenamePropertyAttribute, - FlattenPrefix, IRepository into every consumer +Gener8 (NuGet) +├── analyzers/dotnet/cs/Gener8.dll — the Roslyn source generator +└── lib/netstandard2.0/Gener8.Abstractions.dll — attributes, enums, interfaces, base classes + +Gener8.Extensions.DynamoDB (NuGet) — optional, for RepositoryType.DynamoDb +└── lib/netstandard2.0/Gener8.Extensions.DynamoDB.dll + ├── Gener8.Converters.EnumToStringConverter + ├── Gener8.Converters.NullableEnumToStringConverter + ├── Gener8.Converters.EnumListToStringListConverter + ├── Gener8.Converters.NullableEnumListToStringListConverter + ├── Gener8.IDynamoDbRepositoryContext + └── Gener8.DynamoDbRepository + +Gener8.Extensions.MongoDB (NuGet) — optional, for RepositoryType.MongoDb +└── lib/netstandard2.0/Gener8.Extensions.MongoDB.dll + ├── Gener8.IMongoDbRepositoryContext + └── Gener8.MongoDbRepository +``` + +`Gener8.Abstractions` is also published as a standalone package. Its types are available to consumers via the core `Gener8` package without a separate install. + +--- +## Generator pipeline + +``` SyntaxProvider.CreateSyntaxProvider predicate → IsPartialClassWithAttributes (syntax-only fast filter) transform → ExtractClassTarget (semantic analysis) .Where(not null) - ↓ - ┌────────────────┴────────────────────────────────┐ - │ │ - RegisterSourceOutput .Select(t => t.Repository) - → Emit (per-DTO: model + extensions .Where(k => k != None) - + concrete repository) .Collect() - │ - RegisterSourceOutput - → EmitRepositoryBaseClasses - (once per kind per compilation) + ↓ + ┌────────────────┴────────────────────────────────┐ + │ │ +RegisterSourceOutput .SelectMany(t => t.AutoDtoTargets) +→ Emit (per-DTO: model + extensions .Collect() + + concrete repository) │ + RegisterSourceOutput + → EmitAutoDtos ``` -### Stage 1 — Attribute injection - -Before any user code is examined, the generator calls `RegisterPostInitializationOutput` to inject source files into the consumer's compilation: +> **Note**: `RegisterPostInitializationOutput` is no longer used. All attributes, enums, and repository contracts that were previously injected as source come from `Gener8.Abstractions.dll` (included in the `Gener8` NuGet under `lib/netstandard2.0/`). Similarly, SDK-specific base classes and converters come from the extension packages — the generator no longer emits these as source files. -| File | Contents | -|---|---| -| `RepositoryType.g.cs` | `RepositoryType` enum (`None`, `DynamoDb`, `MongoDb`, `Custom`) | -| `FlattenPrefix.g.cs` | `FlattenPrefix` enum | -| `FromModelAttribute.g.cs` | `[FromModel]` attribute class (references both enums) | -| `TypeMappingAttribute.g.cs` | `[TypeMapping]` attribute class | -| `RenamePropertyAttribute.g.cs` | `[RenameProperty]` attribute class | -| `IgnoreTypeMappingAttribute.g.cs` | `[IgnoreTypeMapping]` attribute class | -| `IRepository.g.cs` | `Gener8.IRepository` interface | - -The SDK-heavy abstract base classes (`DynamoDbRepository` and `MongoDbRepository`) are **not** injected here. They are emitted conditionally in a separate `RegisterSourceOutput` step after all DTOs have been examined, ensuring they appear at most once per compilation and only when needed. - -This means consumers never need a separate runtime package — all injected types exist only during compilation. - -### Stage 2 — Syntax filter (`IsPartialClassWithAttributes`) +### Stage 1 — Syntax filter (`IsPartialClassWithAttributes`) The predicate runs on every syntax node in the compilation. It performs only **syntax-level** checks (no semantic model access, which would be expensive): @@ -59,24 +63,24 @@ The predicate runs on every syntax node in the compilation. It performs only **s Any class that fails these checks is discarded immediately. This keeps the expensive semantic stage lean. -### Stage 3 — Semantic transform (`ExtractClassTarget`) +### Stage 2 — Semantic transform (`ExtractClassTarget`) For each class that passes the filter, the generator accesses the semantic model: 1. **Resolve the DTO class symbol** — obtains the `INamedTypeSymbol` for the partial class. -2. **Find `[FromModel]`** — locates the attribute and reads the `typeof()` argument to get the model type symbol. +2. **Find `[FromModel]`** — locates the attribute (resolved from `Gener8.Abstractions.dll`) and reads the `typeof()` argument to get the model type symbol. 3. **Extract configuration** — reads `Ignore`, `Flatten`, `FlattenPrefix`, `IncludeInherited`, `DtoNamespaces`, `ForceNullable` from the attribute; reads `[TypeMapping]`, `[RenameProperty]`, and `[IgnoreTypeMapping]` attributes from the class. 4. **Detect constructor params** — `PropertyDataBuilder` inspects the model's non-implicit constructors. If a constructor is found whose parameters all resolve to public properties (by exact name or camelCase→PascalCase), those property names are recorded as constructor-backed. This drives two downstream effects: the properties are included even if get-only on the model, and `ToModel` uses constructor-style initialization. -4b. **Infer type mappings** — when `DtoNamespaces` is set, `PropertyDataBuilder` scans model properties and automatically adds type mappings for any type whose namespace is in the qualifying set, unless it is excluded by `[IgnoreTypeMapping]` or already covered by an explicit `[TypeMapping]`. Each inferred mapping also registers the type as an **auto-target** — the transformer synthesises a `TargetClass` for it (using the same accessibility and namespace as the DTO) and returns it in `AutoDtoTargets`. The pipeline emits these companion DTOs as extra source files. +4b. **Infer type mappings** — when `DtoNamespaces` is set, `PropertyDataBuilder` scans model properties and automatically adds type mappings for any type whose namespace is in the qualifying set, unless excluded by `[IgnoreTypeMapping]` or already covered by an explicit `[TypeMapping]`. Each inferred mapping also registers the type as an **auto-target** — the transformer synthesises a `TargetClass` for it and returns it in `AutoDtoTargets`. The pipeline emits these companion DTOs as extra source files. -5. **Walk model properties** (`GetModelProperties`) — iterates public non-static instance properties, skipping get-only ones unless they are constructor-backed. A `HashSet` tracks already-seen names to handle overrides when `IncludeInherited = true`. Traversal stops at `System.Object`. -6. **Build `PropertyData` records** — delegates to `PropertyDataBuilder`, which resolves the type display string, applies any type mapping (including abstract-collection-to-`List` remapping for DynamoDB), applies any rename, and reads getter/setter/init/required/initializer flags. Constructor-backed get-only properties are forced to `IsInitOnly = true` so the DTO emits `init`. -7. **Handle `Flatten`** — for each property in the flatten list, recursively walks the nested type's properties (one level only), applies prefix logic and type mappings, and emits each as a top-level `PropertyData` with a `FlattenedPropertyData` sub-record (carrying the parent name, fully-qualified parent type, and nested property name needed for `ToModel` reconstruction). -8. **Returns a `TargetClass` record** — an immutable snapshot of everything the `Emit` stage needs, including `ModelClass.PrimaryConstructorParams` (the ordered property names for constructor-style `ToModel`). +5. **Walk model properties** (`GetModelProperties`) — iterates public non-static instance properties, skipping get-only ones unless they are constructor-backed. A `HashSet` tracks already-seen names for `IncludeInherited = true`. Traversal stops at `System.Object`. +6. **Build `PropertyData` records** — delegates to `PropertyDataBuilder`, which resolves the type display string, applies any type mapping (including abstract-collection-to-`List` remapping for DynamoDB), applies any rename, and reads getter/setter/init/required/initializer flags. Constructor-backed get-only properties are forced to `IsInitOnly = true`. +7. **Handle `Flatten`** — for each property in the flatten list, recursively walks the nested type's properties (one level only), applies prefix logic and type mappings, and emits each as a top-level `PropertyData` with a `FlattenedPropertyData` sub-record. +8. **Returns a `TargetClass` record** — an immutable snapshot of everything the `Emit` stage needs. -### Stage 4 — Code generation (`Emit`) +### Stage 3 — Code generation (`Emit`) -Takes a `ClassTarget` and writes up to three `StringBuilder`-based C# source files. +Takes a `TargetClass` and writes up to three `StringBuilder`-based C# source files. **`EmitModel`** writes the partial class (`{ClassName}.g.cs`): @@ -93,6 +97,8 @@ Takes a `ClassTarget` and writes up to three `StringBuilder`-based C# source fil [}] ``` +For DynamoDB repositories, enum properties get `[DynamoDBProperty(typeof(EnumToStringConverter))]` (from `Gener8.Converters`). For MongoDB, enum properties get `[BsonRepresentation(BsonType.String)]`. + **`EmitExtensions`** writes the mapping helpers (`{ClassName}Extensions.g.cs`): ``` @@ -105,45 +111,15 @@ using System.Linq; {extAccessibility} static class {ClassName}Extensions { [return: NotNullIfNotNull(nameof(dto))] - public static {ModelFullName}? ToModel(this {ClassName}? dto) - // constructor style when PrimaryConstructorParams is set: - => dto is null ? null : new(dto.P1, dto.P2); - // or object-initializer style otherwise: - => dto is null ? null : new {ModelFullName} { ... }; + public static {ModelFullName}? ToModel(this {ClassName}? dto) => ...; [return: NotNullIfNotNull(nameof(model))] - public static {ClassName}? ToDto(this {ModelFullName}? model) - => model is null ? null : new {ClassName} { ... }; + public static {ClassName}? ToDto(this {ModelFullName}? model) => ...; } [}] ``` -**`ToModel` style selection** — if `ModelClass.PrimaryConstructorParams` is set, `ToModel` emits `new(dto.P1, dto.P2, ...)` (positional constructor call) for the constructor-param properties. Any remaining settable properties are emitted as an object-initializer block after the constructor call: `new(dto.Name) { Order = dto.Order, }`. When `PrimaryConstructorParams` is not set, the full object-initializer form is used. - -Type-mapped properties generate chained calls (`dto.Prop.ToModel()` / `model.Prop.ToDto()`), with `?.` for nullable types. Flattened properties appear in `ToDto` via their `ReadPath`. In `ToModel`, flattened properties are grouped by parent and the nested object is reconstructed inline (`Parent = new ParentType { Nested = dto.FlatProp, ... }`); nullable parents get a null-safe ternary (`dto.FlatProp is null ? null : new ParentType { ... }`). Renamed properties use `ModelPropertyName` on the model side. DynamoDB/MongoDB abstract collection and array properties use collection spread (`[.. model.Prop]`) in `ToDto`. - -**`EmitRepository`** (only when `target.Repository != RepositoryKind.None`) writes a concrete repository class (`{ClassName}Repository.g.cs`): - -``` -// -#nullable enable - -[namespace X {] - {accessibility} partial class {ClassName}Repository - : Gener8.DynamoDbRepository<{ModelFullName}, {ClassName}> // or MongoDbRepository - { - // DynamoDb: public {ClassName}Repository(IDynamoDbRepositoryContext context) : base(context) {} - // MongoDb: public {ClassName}Repository(IMongoDbRepositoryContext context) : base(context, "{ClassName}") {} - - protected override {ModelFullName} ToModel({ClassName} dto) => dto.ToModel(); - protected override {ClassName} ToDto({ModelFullName} model) => model.ToDto(); - } -[}] -``` - -**`EmitRepositoryBaseClasses`** is called once per compilation (via a `.Collect()`-based `RegisterSourceOutput`) and emits `DynamoDbRepository.g.cs` and/or `MongoDbRepository.g.cs` only if at least one DTO in the compilation requested each kind. - -The hint name for each file is `{Namespace}.{ClassName}[Extensions|Repository].g.cs` (or without namespace prefix for the global namespace). +**`EmitRepository`** (only when `target.Repository != RepositoryKind.None`) writes a concrete repository class (`{ClassName}Repository.g.cs`). The generated class inherits from `Gener8.DynamoDbRepository`, `Gener8.MongoDbRepository`, or `Gener8.RepositoryBase` — these types now come from the extension packages or `Gener8.Abstractions`, not injected source. --- @@ -151,67 +127,13 @@ The hint name for each file is `{Namespace}.{ClassName}[Extensions|Repository].g All records live under the `Gener8.Contexts` namespace and use value-equality semantics for Roslyn incremental caching. -### `TargetClass` - -Carries everything `Emit` needs for one DTO: - -```csharp -record TargetClass( - string ClassName, - string? Namespace, - string Accessibility, - IReadOnlyCollection Properties, - ModelClass Model, - RepositoryKind Repository, - IReadOnlyCollection AutoDtoTargets); // companion DTOs inferred via DtoNamespaces - -record ModelClass( - string FullName, // global::-prefixed fully-qualified name - string Name, // simple class name, used for the repository class name - ImmutableArray PrimaryConstructorParams); // ordered property names for ctor-style ToModel; default = use object initializer -``` - -`RepositoryKind` is an internal enum (`None`, `DynamoDb`, `MongoDb`, `Custom`) — a mirror of the injected `RepositoryType` enum that avoids a dependency on the generated attribute source. - -### `PropertyData` - -Per-property code-generation data, composed of two sub-records: - -```csharp -record PropertyData( - PropertyTypeData TypeData, - string Name, - bool HasGetter, - bool HasSetter, - bool IsInitOnly, - bool IsRequired, - string? Initializer, - string? ModelPropertyName, // original model name when [RenameProperty] was applied; null = same as Name - bool IsUserDeclared, // true when the DTO already declares this property; skip in EmitModel, keep in mappings - FlattenedPropertyData? Flattened); // non-null only for properties introduced via Flatten - -record PropertyTypeData( - string Type, - bool HasTypeMapping, // true when the type was remapped (via [TypeMapping] or collection/array → List) - bool HasGenericTypeMapping, // true when the remapping was a collection/array → List (not a direct [TypeMapping]) - bool NeedsSpreadAssignment, // true when an abstract collection was remapped to List (DynamoDB/MongoDB) - bool IsEnum, // true when the property is an enum type (used for DynamoDB/MongoDB annotations) - bool IsNullable, // true when the property type is nullable - string? EnumCollectionElementType); // non-null for IList / IList: holds "EnumType" or "EnumType?" - -record FlattenedPropertyData( - string ReadPath, // model-side read expression, e.g. "Address?.Street" - string ParentName, // parent property name, e.g. "ShippingAddress" - string ParentTypeFullName, // global::-prefixed parent type for 'new ParentType { }' in ToModel - string NestedPropertyName, // property name on the nested type, e.g. "Street" - bool OriginallyNullable); // true when nested type was nullable before parent nullability was applied -``` +See the source files under `src/Gener8/Contexts/` for full details of `TargetClass`, `ModelClass`, `PropertyData`, `PropertyTypeData`, and `FlattenedPropertyData`. --- ## Incremental caching -Because the transform returns immutable records with value-equality semantics (C# `record` types), Roslyn can cache results between compilations. If a source file that contributed a `TargetClass` has not changed, the cached record is reused and `Emit` is not re-invoked — only the changed inputs flow through the pipeline. +Because the transform returns immutable records with value-equality semantics (C# `record` types), Roslyn can cache results between compilations. If a source file that contributed a `TargetClass` has not changed, the cached record is reused and `Emit` is not re-invoked. --- @@ -224,3 +146,5 @@ The generator itself targets `netstandard2.0` to be compatible with all Roslyn h ## `netstandard2.0` target Source generators must target `netstandard2.0` because the Roslyn SDK that hosts them may run on older .NET Framework versions (e.g., in Visual Studio on Windows). The consuming project can target any framework. + +`Gener8.Abstractions`, `Gener8.Extensions.DynamoDB`, and `Gener8.Extensions.MongoDB` also target `netstandard2.0` to ensure broad compatibility. diff --git a/gener8.png b/gener8.png new file mode 100644 index 0000000..b339f3c Binary files /dev/null and b/gener8.png differ diff --git a/samples/Gener8.Sample/Gener8.Sample.csproj b/samples/Gener8.Sample/Gener8.Sample.csproj index 59b54d2..0d9c8c4 100644 --- a/samples/Gener8.Sample/Gener8.Sample.csproj +++ b/samples/Gener8.Sample/Gener8.Sample.csproj @@ -1,19 +1,20 @@ - Exe net10.0 enable true - - + + + + + - diff --git a/samples/Gener8.Sample/Repositories.cs b/samples/Gener8.Sample/Repositories.cs index 1233f48..0e11b54 100644 --- a/samples/Gener8.Sample/Repositories.cs +++ b/samples/Gener8.Sample/Repositories.cs @@ -47,14 +47,14 @@ public class CustomRepositoryContent public partial class CustomRepositoryContentDto { } } -namespace Gener8 +namespace Gener8.Sample { - partial class RepositoryBase + public partial class CustomRepositoryContentRepository { - public Task GetByIdAsync(object id, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public Task SaveAsync(TModel entity, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public Task DeleteAsync(TModel entity, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public Task DeleteByIdAsync(object id, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public Task> GetAllAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public override Task GetByIdAsync(object id, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public override Task SaveAsync(CustomRepositoryContent entity, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public override Task DeleteAsync(CustomRepositoryContent entity, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public override Task DeleteByIdAsync(object id, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public override Task> GetAllAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException(); } } \ No newline at end of file diff --git a/src/Gener8.Abstractions/FlattenPrefix.cs b/src/Gener8.Abstractions/FlattenPrefix.cs new file mode 100644 index 0000000..87bf4c8 --- /dev/null +++ b/src/Gener8.Abstractions/FlattenPrefix.cs @@ -0,0 +1,8 @@ +namespace Gener8; + +public enum FlattenPrefix +{ + Parent = 0, + None = 1, + Gaped = 2 +} diff --git a/src/Gener8.Abstractions/FromModelAttribute.cs b/src/Gener8.Abstractions/FromModelAttribute.cs new file mode 100644 index 0000000..8812614 --- /dev/null +++ b/src/Gener8.Abstractions/FromModelAttribute.cs @@ -0,0 +1,14 @@ +namespace Gener8; + +[System.AttributeUsage(System.AttributeTargets.Class, Inherited = false, AllowMultiple = false)] +public sealed class FromModelAttribute(System.Type modelType) : System.Attribute +{ + public System.Type ModelType { get; } = modelType; + public string[] Ignore { get; set; } = []; + public bool IncludeInherited { get; set; } + public string[] Flatten { get; set; } = []; + public FlattenPrefix FlattenPrefix { get; set; } + public RepositoryType Repository { get; set; } + public string[] DtoNamespaces { get; set; } = []; + public string[] ForceNullable { get; set; } = []; +} diff --git a/src/Gener8.Abstractions/Gener8.Abstractions.csproj b/src/Gener8.Abstractions/Gener8.Abstractions.csproj new file mode 100644 index 0000000..186e608 --- /dev/null +++ b/src/Gener8.Abstractions/Gener8.Abstractions.csproj @@ -0,0 +1,26 @@ + + + + netstandard2.0 + latest + + + Gener8.Abstractions + 0.0.1 + Hamid Mayeli + Core attributes, enums, and repository contracts for Gener8. Zero third-party dependencies. + source-generator;roslyn;dto;code-generation;c# + https://github.com/hamidmayeli/from-model + https://github.com/hamidmayeli/from-model + git + MIT + README.md + gener8.png + + + + + + + + diff --git a/src/Gener8.Abstractions/ICompositeKeyRepository.cs b/src/Gener8.Abstractions/ICompositeKeyRepository.cs new file mode 100644 index 0000000..9ac2205 --- /dev/null +++ b/src/Gener8.Abstractions/ICompositeKeyRepository.cs @@ -0,0 +1,10 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Gener8; + +public interface ICompositeKeyRepository : IRepository where TModel : class +{ + Task GetByIdAsync(object hashKey, object rangeKey, CancellationToken cancellationToken = default); + Task DeleteByIdAsync(object hashKey, object rangeKey, CancellationToken cancellationToken = default); +} diff --git a/src/Gener8.Abstractions/IRepository.cs b/src/Gener8.Abstractions/IRepository.cs new file mode 100644 index 0000000..8074831 --- /dev/null +++ b/src/Gener8.Abstractions/IRepository.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Gener8; + +public interface IRepository where TModel : class +{ + Task GetByIdAsync(object id, CancellationToken cancellationToken = default); + Task SaveAsync(TModel entity, CancellationToken cancellationToken = default); + Task DeleteAsync(TModel entity, CancellationToken cancellationToken = default); + Task DeleteByIdAsync(object id, CancellationToken cancellationToken = default); + Task> GetAllAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Gener8.Abstractions/IRepositoryContext.cs b/src/Gener8.Abstractions/IRepositoryContext.cs new file mode 100644 index 0000000..42b6605 --- /dev/null +++ b/src/Gener8.Abstractions/IRepositoryContext.cs @@ -0,0 +1,5 @@ +namespace Gener8; + +public interface IRepositoryContext +{ +} diff --git a/src/Gener8.Abstractions/IgnoreTypeMappingAttribute.cs b/src/Gener8.Abstractions/IgnoreTypeMappingAttribute.cs new file mode 100644 index 0000000..4c504ec --- /dev/null +++ b/src/Gener8.Abstractions/IgnoreTypeMappingAttribute.cs @@ -0,0 +1,7 @@ +namespace Gener8; + +[System.AttributeUsage(System.AttributeTargets.Class, Inherited = false, AllowMultiple = true)] +public sealed class IgnoreTypeMappingAttribute(System.Type ignoredType) : System.Attribute +{ + public System.Type IgnoredType { get; } = ignoredType; +} diff --git a/src/Gener8.Abstractions/RenamePropertyAttribute.cs b/src/Gener8.Abstractions/RenamePropertyAttribute.cs new file mode 100644 index 0000000..a20cfd7 --- /dev/null +++ b/src/Gener8.Abstractions/RenamePropertyAttribute.cs @@ -0,0 +1,8 @@ +namespace Gener8; + +[System.AttributeUsage(System.AttributeTargets.Class, Inherited = false, AllowMultiple = true)] +public sealed class RenamePropertyAttribute(string sourceName, string targetName) : System.Attribute +{ + public string SourceName { get; } = sourceName; + public string TargetName { get; } = targetName; +} diff --git a/src/Gener8.Abstractions/RepositoryBase.cs b/src/Gener8.Abstractions/RepositoryBase.cs new file mode 100644 index 0000000..d32b595 --- /dev/null +++ b/src/Gener8.Abstractions/RepositoryBase.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Gener8; + +public abstract class RepositoryBase : IRepository + where TModel : class + where TDto : class +{ + protected readonly IRepositoryContext Context; + + protected RepositoryBase(IRepositoryContext context) => Context = context; + + protected abstract TModel ToModel(TDto dto); + protected abstract TDto ToDto(TModel model); + + // IRepository CRUD contract — concrete subclass provides the implementations. + // The generated partial class adds the body in a second partial file written by the consumer. + public abstract Task GetByIdAsync(object id, CancellationToken cancellationToken = default); + public abstract Task SaveAsync(TModel entity, CancellationToken cancellationToken = default); + public abstract Task DeleteAsync(TModel entity, CancellationToken cancellationToken = default); + public abstract Task DeleteByIdAsync(object id, CancellationToken cancellationToken = default); + public abstract Task> GetAllAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Gener8.Abstractions/RepositoryType.cs b/src/Gener8.Abstractions/RepositoryType.cs new file mode 100644 index 0000000..315458e --- /dev/null +++ b/src/Gener8.Abstractions/RepositoryType.cs @@ -0,0 +1,9 @@ +namespace Gener8; + +public enum RepositoryType +{ + None = 0, + DynamoDb = 1, + MongoDb = 2, + Custom = 3 +} diff --git a/src/Gener8.Abstractions/TypeMappingAttribute.cs b/src/Gener8.Abstractions/TypeMappingAttribute.cs new file mode 100644 index 0000000..2905668 --- /dev/null +++ b/src/Gener8.Abstractions/TypeMappingAttribute.cs @@ -0,0 +1,8 @@ +namespace Gener8; + +[System.AttributeUsage(System.AttributeTargets.Class, Inherited = false, AllowMultiple = true)] +public sealed class TypeMappingAttribute(System.Type sourceType, System.Type targetType) : System.Attribute +{ + public System.Type SourceType { get; } = sourceType; + public System.Type TargetType { get; } = targetType; +} diff --git a/src/Gener8.Extensions.DynamoDB/Converters/EnumListToStringListConverter.cs b/src/Gener8.Extensions.DynamoDB/Converters/EnumListToStringListConverter.cs new file mode 100644 index 0000000..2639e34 --- /dev/null +++ b/src/Gener8.Extensions.DynamoDB/Converters/EnumListToStringListConverter.cs @@ -0,0 +1,25 @@ +using Amazon.DynamoDBv2.DataModel; +using Amazon.DynamoDBv2.DocumentModel; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Gener8.Converters; + +public class EnumListToStringListConverter : IPropertyConverter where TEnum : struct, Enum +{ + public object? FromEntry(DynamoDBEntry? entry) + { + var stringList = entry?.AsListOfString(); + if (stringList == null) return null; + return stringList.Select(s => (TEnum)Enum.Parse(typeof(TEnum), s)).ToList(); + } + + public DynamoDBEntry ToEntry(object? value) + { + if (value is IEnumerable list) + return new DynamoDBList(list.Select(e => (DynamoDBEntry)new Primitive(e.ToString()))); + + return new DynamoDBNull(); + } +} diff --git a/src/Gener8.Extensions.DynamoDB/Converters/EnumToStringConverter.cs b/src/Gener8.Extensions.DynamoDB/Converters/EnumToStringConverter.cs new file mode 100644 index 0000000..2dd4b0d --- /dev/null +++ b/src/Gener8.Extensions.DynamoDB/Converters/EnumToStringConverter.cs @@ -0,0 +1,12 @@ +using Amazon.DynamoDBv2.DataModel; +using Amazon.DynamoDBv2.DocumentModel; +using System; + +namespace Gener8.Converters; + +public class EnumToStringConverter : IPropertyConverter where TEnum : struct, Enum +{ + public object FromEntry(DynamoDBEntry entry) => (TEnum)Enum.Parse(typeof(TEnum), entry.AsString()); + + public DynamoDBEntry ToEntry(object value) => new Primitive(value.ToString()); +} diff --git a/src/Gener8.Extensions.DynamoDB/Converters/NullableEnumListToStringListConverter.cs b/src/Gener8.Extensions.DynamoDB/Converters/NullableEnumListToStringListConverter.cs new file mode 100644 index 0000000..cb0ff63 --- /dev/null +++ b/src/Gener8.Extensions.DynamoDB/Converters/NullableEnumListToStringListConverter.cs @@ -0,0 +1,33 @@ +using Amazon.DynamoDBv2.DataModel; +using Amazon.DynamoDBv2.DocumentModel; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Gener8.Converters; + +public class NullableEnumListToStringListConverter : IPropertyConverter where TEnum : struct, Enum +{ + public object? FromEntry(DynamoDBEntry? entry) + { + var dynamoList = entry as DynamoDBList; + if (dynamoList == null) return null; + + return dynamoList.Entries + .Select(e => + { + var s = e.AsString(); + return string.IsNullOrEmpty(s) ? (TEnum?)null : (TEnum)Enum.Parse(typeof(TEnum), s); + }) + .ToList(); + } + + public DynamoDBEntry ToEntry(object? value) + { + if (value is IEnumerable list) + return new DynamoDBList(list.Select(e => + e.HasValue ? new Primitive(e.Value.ToString()) : new DynamoDBNull())); + + return new DynamoDBNull(); + } +} diff --git a/src/Gener8.Extensions.DynamoDB/Converters/NullableEnumToStringConverter.cs b/src/Gener8.Extensions.DynamoDB/Converters/NullableEnumToStringConverter.cs new file mode 100644 index 0000000..e03b290 --- /dev/null +++ b/src/Gener8.Extensions.DynamoDB/Converters/NullableEnumToStringConverter.cs @@ -0,0 +1,25 @@ +using Amazon.DynamoDBv2.DataModel; +using Amazon.DynamoDBv2.DocumentModel; +using System; + +namespace Gener8.Converters; + +public class NullableEnumToStringConverter : IPropertyConverter where TEnum : struct, Enum +{ + public object? FromEntry(DynamoDBEntry? entry) + { + var s = entry?.AsString(); + if (string.IsNullOrEmpty(s)) + return null; + + return (TEnum)Enum.Parse(typeof(TEnum), s); + } + + public DynamoDBEntry ToEntry(object? value) + { + if (value is TEnum e) + return new Primitive(e.ToString()); + + return new DynamoDBNull(); + } +} diff --git a/src/Gener8.Extensions.DynamoDB/Gener8.Extensions.DynamoDB.csproj b/src/Gener8.Extensions.DynamoDB/Gener8.Extensions.DynamoDB.csproj new file mode 100644 index 0000000..ceb6cbf --- /dev/null +++ b/src/Gener8.Extensions.DynamoDB/Gener8.Extensions.DynamoDB.csproj @@ -0,0 +1,34 @@ + + + + netstandard2.0 + latest + + + Gener8.Extensions.DynamoDB + 0.0.1 + Hamid Mayeli + DynamoDB extension for Gener8: compiled converters and abstract repository base class. Requires AWSSDK.DynamoDBv2. + source-generator;roslyn;dto;dynamodb;aws;code-generation;c# + https://github.com/hamidmayeli/from-model + https://github.com/hamidmayeli/from-model + git + MIT + README.md + gener8.png + + + + + + + + + + + + + + + + diff --git a/src/Gener8.Extensions.DynamoDB/Repositories/DynamoDbRepository.cs b/src/Gener8.Extensions.DynamoDB/Repositories/DynamoDbRepository.cs new file mode 100644 index 0000000..4fdc6e5 --- /dev/null +++ b/src/Gener8.Extensions.DynamoDB/Repositories/DynamoDbRepository.cs @@ -0,0 +1,56 @@ +using Amazon.DynamoDBv2.DataModel; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Gener8; + +public abstract partial class DynamoDbRepository(IDynamoDbRepositoryContext context) : ICompositeKeyRepository + where TModel : class + where TDto : class +{ + protected readonly IDynamoDbRepositoryContext Context = context; + protected IDynamoDBContext DynamoDbContext => Context.Context; + + protected abstract TModel ToModel(TDto dto); + protected abstract TDto ToDto(TModel model); + + public virtual async Task GetByIdAsync(object id, CancellationToken cancellationToken = default) + { + var dto = await DynamoDbContext.LoadAsync(id, cancellationToken); + return ToModel(dto); + } + + public virtual async Task SaveAsync(TModel entity, CancellationToken cancellationToken = default) + { + await DynamoDbContext.SaveAsync(ToDto(entity), cancellationToken); + } + + public virtual async Task DeleteAsync(TModel entity, CancellationToken cancellationToken = default) + { + await DynamoDbContext.DeleteAsync(ToDto(entity), cancellationToken); + } + + public virtual async Task DeleteByIdAsync(object id, CancellationToken cancellationToken = default) + { + await DynamoDbContext.DeleteAsync(id, cancellationToken); + } + + public virtual async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + var search = DynamoDbContext.ScanAsync(new List()); + return (await search.GetRemainingAsync(cancellationToken)).Select(dto => ToModel(dto)); + } + + public virtual async Task GetByIdAsync(object hashKey, object rangeKey, CancellationToken cancellationToken = default) + { + var dto = await DynamoDbContext.LoadAsync(hashKey, rangeKey, cancellationToken); + return ToModel(dto); + } + + public virtual async Task DeleteByIdAsync(object hashKey, object rangeKey, CancellationToken cancellationToken = default) + { + await DynamoDbContext.DeleteAsync(hashKey, rangeKey, cancellationToken); + } +} diff --git a/src/Gener8.Extensions.DynamoDB/Repositories/IDynamoDbRepositoryContext.cs b/src/Gener8.Extensions.DynamoDB/Repositories/IDynamoDbRepositoryContext.cs new file mode 100644 index 0000000..2a9bab7 --- /dev/null +++ b/src/Gener8.Extensions.DynamoDB/Repositories/IDynamoDbRepositoryContext.cs @@ -0,0 +1,8 @@ +using Amazon.DynamoDBv2.DataModel; + +namespace Gener8; + +public interface IDynamoDbRepositoryContext +{ + IDynamoDBContext Context { get; } +} diff --git a/src/Gener8.Extensions.MongoDB/Gener8.Extensions.MongoDB.csproj b/src/Gener8.Extensions.MongoDB/Gener8.Extensions.MongoDB.csproj new file mode 100644 index 0000000..e0e2bc3 --- /dev/null +++ b/src/Gener8.Extensions.MongoDB/Gener8.Extensions.MongoDB.csproj @@ -0,0 +1,38 @@ + + + + + netstandard2.1 + latest + + + Gener8.Extensions.MongoDB + 0.0.1 + Hamid Mayeli + MongoDB extension for Gener8: abstract repository base class. Requires MongoDB.Driver. + source-generator;roslyn;dto;mongodb;code-generation;c# + https://github.com/hamidmayeli/from-model + https://github.com/hamidmayeli/from-model + git + MIT + README.md + gener8.png + + + + + + + + + + + + + + + + diff --git a/src/Gener8.Extensions.MongoDB/Repositories/IMongoDbRepositoryContext.cs b/src/Gener8.Extensions.MongoDB/Repositories/IMongoDbRepositoryContext.cs new file mode 100644 index 0000000..919f33b --- /dev/null +++ b/src/Gener8.Extensions.MongoDB/Repositories/IMongoDbRepositoryContext.cs @@ -0,0 +1,8 @@ +using MongoDB.Driver; + +namespace Gener8; + +public interface IMongoDbRepositoryContext +{ + IMongoDatabase Context { get; } +} diff --git a/src/Gener8.Extensions.MongoDB/Repositories/MongoDbRepository.cs b/src/Gener8.Extensions.MongoDB/Repositories/MongoDbRepository.cs new file mode 100644 index 0000000..4145631 --- /dev/null +++ b/src/Gener8.Extensions.MongoDB/Repositories/MongoDbRepository.cs @@ -0,0 +1,71 @@ +using MongoDB.Bson.Serialization; +using MongoDB.Driver; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Gener8; + +public abstract partial class MongoDbRepository : IRepository + where TModel : class + where TDto : class +{ + protected readonly IMongoCollection Collection; + protected readonly IMongoDbRepositoryContext Context; + + protected MongoDbRepository(IMongoDbRepositoryContext context, string collectionName) + { + Context = context; + Collection = context.Context.GetCollection(collectionName); + } + + protected abstract TModel ToModel(TDto dto); + protected abstract TDto ToDto(TModel model); + + public virtual async Task GetByIdAsync(object id, CancellationToken cancellationToken = default) + { + var filter = Builders.Filter.Eq("_id", id); + var dto = await Collection.Find(filter).FirstOrDefaultAsync(cancellationToken); + return ToModel(dto); + } + + public virtual async Task SaveAsync(TModel entity, CancellationToken cancellationToken = default) + { + var id = GetIdFromEntity(entity); + var filter = Builders.Filter.Eq("_id", id); + var options = new ReplaceOptions { IsUpsert = true }; + + await Collection.ReplaceOneAsync(filter, ToDto(entity), options, cancellationToken); + } + + public virtual async Task DeleteAsync(TModel entity, CancellationToken cancellationToken = default) + { + var id = GetIdFromEntity(entity); + var filter = Builders.Filter.Eq("_id", id); + + await Collection.DeleteOneAsync(filter, cancellationToken); + } + + public virtual async Task DeleteByIdAsync(object id, CancellationToken cancellationToken = default) + { + var filter = Builders.Filter.Eq("_id", id); + await Collection.DeleteOneAsync(filter, cancellationToken); + } + + public virtual async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + var dtos = await Collection.Find(Builders.Filter.Empty).ToListAsync(cancellationToken); + return dtos.Select(dto => ToModel(dto)); + } + + protected virtual object GetIdFromEntity(TModel entity) + { + var classMap = BsonClassMap.LookupClassMap(typeof(TModel)); + if (classMap.IdMemberMap == null) + throw new InvalidOperationException($"No Id member found for type {typeof(TModel).Name}."); + + return classMap.IdMemberMap.Getter(entity); + } +} diff --git a/src/Gener8/DefaultSource.DynamoDb.cs b/src/Gener8/DefaultSource.DynamoDb.cs index 9d019e4..a463599 100644 --- a/src/Gener8/DefaultSource.DynamoDb.cs +++ b/src/Gener8/DefaultSource.DynamoDb.cs @@ -1,216 +1,3 @@ -namespace Gener8; - -partial record DefaultSource -{ - public static readonly DefaultSource EnumToStringConverter = new( - Name: "Gener8.Converters.EnumToStringConverter", - Code: """ - // - #nullable enable - using Amazon.DynamoDBv2.DataModel; - using Amazon.DynamoDBv2.DocumentModel; - using System; - - namespace Gener8.Converters; - - public class EnumToStringConverter : IPropertyConverter where TEnum : struct, Enum - { - public object FromEntry(DynamoDBEntry entry) => Enum.Parse(entry.AsString()); - - public DynamoDBEntry ToEntry(object value) => new Primitive(value.ToString()); - } - """, - Filename: "EnumToStringConverter.g.cs" - ); - - public static readonly DefaultSource NullableEnumToStringConverter = new( - Name: "Gener8.Converters.NullableEnumToStringConverter", - Code: """ - // - #nullable enable - using Amazon.DynamoDBv2.DataModel; - using Amazon.DynamoDBv2.DocumentModel; - using System; - - namespace Gener8.Converters; - - public class NullableEnumToStringConverter : IPropertyConverter where TEnum : struct, Enum - { - public object? FromEntry(DynamoDBEntry? entry) - { - var s = entry?.AsString(); - if (string.IsNullOrEmpty(s)) - { - return null; - } - - return Enum.Parse(s); - } - - public DynamoDBEntry ToEntry(object? value) - { - if (value is TEnum e) - { - return new Primitive(e.ToString()); - } - return new DynamoDBNull(); - } - } - """, - Filename: "NullableEnumToStringConverter.g.cs" - ); - - public static readonly DefaultSource EnumListToStringListConverter = new( - Name: "Gener8.Converters.EnumListToStringListConverter", - Code: """ - // - #nullable enable - using Amazon.DynamoDBv2.DataModel; - using Amazon.DynamoDBv2.DocumentModel; - using System; - using System.Collections.Generic; - using System.Linq; - - namespace Gener8.Converters; - - public class EnumListToStringListConverter : IPropertyConverter where TEnum : struct, Enum - { - public object? FromEntry(DynamoDBEntry? entry) - { - var stringList = entry?.AsListOfString(); - if (stringList == null) return null; - return stringList.Select(s => Enum.Parse(s)).ToList(); - } - - public DynamoDBEntry ToEntry(object? value) - { - if (value is IEnumerable list) - return new DynamoDBList(list.Select(e => (DynamoDBEntry)new Primitive(e.ToString()))); - return new DynamoDBNull(); - } - } - """, - Filename: "EnumListToStringListConverter.g.cs" - ); - - public static readonly DefaultSource NullableEnumListToStringListConverter = new( - Name: "Gener8.Converters.NullableEnumListToStringListConverter", - Code: """ - // - #nullable enable - using Amazon.DynamoDBv2.DataModel; - using Amazon.DynamoDBv2.DocumentModel; - using System; - using System.Collections.Generic; - using System.Linq; - - namespace Gener8.Converters; - - public class NullableEnumListToStringListConverter : IPropertyConverter where TEnum : struct, Enum - { - public object? FromEntry(DynamoDBEntry? entry) - { - var dynamoList = entry as DynamoDBList; - if (dynamoList == null) return null; - return dynamoList.Entries - .Select(e => - { - var s = e.AsString(); - return string.IsNullOrEmpty(s) ? (TEnum?)null : Enum.Parse(s); - }) - .ToList(); - } - - public DynamoDBEntry ToEntry(object? value) - { - if (value is IEnumerable list) - return new DynamoDBList(list.Select(e => - e.HasValue ? new Primitive(e.Value.ToString()) : new DynamoDBNull())); - return new DynamoDBNull(); - } - } - """, - Filename: "NullableEnumListToStringListConverter.g.cs" - ); - - public static readonly DefaultSource DynamoDbRepositoryBaseClass = new( - Name: "Gener8.DynamoDbRepository", - Code: """ - // - using System.Collections.Generic; - using System.Threading; - using System.Threading.Tasks; - using Amazon.DynamoDBv2.DataModel; - using System.Linq; - - #nullable enable - namespace Gener8; - - public interface ICompositeKeyRepository : IRepository where TModel : class - { - Task GetByIdAsync(object hashKey, object rangeKey, CancellationToken cancellationToken = default); - Task DeleteByIdAsync(object hashKey, object rangeKey, CancellationToken cancellationToken = default); - } - - public interface IDynamoDbRepositoryContext - { - IDynamoDBContext Context { get; } - } - - public abstract partial class DynamoDbRepository(IDynamoDbRepositoryContext context) : ICompositeKeyRepository - where TModel : class - where TDto : class - { - protected readonly IDynamoDbRepositoryContext Context = context; - protected IDynamoDBContext DynamoDbContext => Context.Context; - - abstract protected TModel ToModel(TDto dto); - - abstract protected TDto ToDto(TModel model); - - // --- IRepository Implementation (Single Key) --- - - public virtual async Task GetByIdAsync(object id, CancellationToken cancellationToken = default) - { - var dto = await DynamoDbContext.LoadAsync(id, cancellationToken); - return ToModel(dto); - } - - public virtual async Task SaveAsync(TModel entity, CancellationToken cancellationToken = default) - { - await DynamoDbContext.SaveAsync(ToDto(entity), cancellationToken); - } - - public virtual async Task DeleteAsync(TModel entity, CancellationToken cancellationToken = default) - { - await DynamoDbContext.DeleteAsync(ToDto(entity), cancellationToken); - } - - public virtual async Task DeleteByIdAsync(object id, CancellationToken cancellationToken = default) - { - await DynamoDbContext.DeleteAsync(id, cancellationToken); - } - - public virtual async Task> GetAllAsync(CancellationToken cancellationToken = default) - { - var search = DynamoDbContext.ScanAsync(new List()); - return (await search.GetRemainingAsync(cancellationToken)).Select(dto => ToModel(dto)); - } - - // --- ICompositeKeyRepository Implementation (Two Keys) --- - - public virtual async Task GetByIdAsync(object hashKey, object rangeKey, CancellationToken cancellationToken = default) - { - var dto = await DynamoDbContext.LoadAsync(hashKey, rangeKey, cancellationToken); - return ToModel(dto); - } - - public virtual async Task DeleteByIdAsync(object hashKey, object rangeKey, CancellationToken cancellationToken = default) - { - await DynamoDbContext.DeleteAsync(hashKey, rangeKey, cancellationToken); - } - } - """, - Filename: "DynamoDbRepository.g.cs" - ); -} +// DynamoDB converters and repository base classes previously injected from this file have been +// moved to Gener8.Extensions.DynamoDB (separate NuGet package). +namespace Gener8; diff --git a/src/Gener8/DefaultSource.cs b/src/Gener8/DefaultSource.cs index 2efc05b..07d100f 100644 --- a/src/Gener8/DefaultSource.cs +++ b/src/Gener8/DefaultSource.cs @@ -1,251 +1,12 @@ -using System.Collections.Generic; - +// Attribute and enum type definitions have moved to Gener8.Abstractions. +// This file retains only the fully-qualified type name constants used by the generator's +// Roslyn attribute-matching logic (INamedTypeSymbol.ToDisplayString() comparisons). namespace Gener8; -internal partial record DefaultSource(string Name, string Code, string Filename) +internal static class DefaultSource { - public static readonly DefaultSource RepositoryTypeEnum = new( - Name: "Gener8.RepositoryType", - Code: """ - // - namespace Gener8; - - public enum RepositoryType - { - None = 0, - DynamoDb = 1, - MongoDb = 2, - Custom = 3 - } - """, - Filename: "RepositoryType.g.cs" - ); - - public static readonly DefaultSource FromModelAttribute = new( - Name: "Gener8.FromModelAttribute", - Code: """ - // - #nullable enable - namespace Gener8; - - [System.AttributeUsage(System.AttributeTargets.Class, Inherited = false, AllowMultiple = false)] - public sealed class FromModelAttribute(System.Type modelType) : System.Attribute - { - public System.Type ModelType { get; } = modelType; - public string[] Ignore { get; set; } = []; - public bool IncludeInherited { get; set; } - public string[] Flatten { get; set; } = []; - public FlattenPrefix FlattenPrefix { get; set; } - public RepositoryType Repository { get; set; } - public string[] DtoNamespaces { get; set; } = []; - public string[] ForceNullable { get; set; } = []; - } - """, - Filename: "FromModelAttribute.g.cs" - ); - - public static readonly DefaultSource IRepositoryInterface = new( - Name: "Gener8.IRepository", - Code: """ - // - #nullable enable - namespace Gener8; - - public interface IRepository where TModel : class - { - global::System.Threading.Tasks.Task GetByIdAsync(object id, global::System.Threading.CancellationToken cancellationToken = default); - global::System.Threading.Tasks.Task SaveAsync(TModel entity, global::System.Threading.CancellationToken cancellationToken = default); - global::System.Threading.Tasks.Task DeleteAsync(TModel entity, global::System.Threading.CancellationToken cancellationToken = default); - global::System.Threading.Tasks.Task DeleteByIdAsync(object id, global::System.Threading.CancellationToken cancellationToken = default); - global::System.Threading.Tasks.Task> GetAllAsync(global::System.Threading.CancellationToken cancellationToken = default); - } - """, - Filename: "IRepository.g.cs" - ); - - public static readonly DefaultSource MongoDbRepositoryBaseClass = new( - Name: "Gener8.MongoDbRepository", - Code: """ - // - using System; - using System.Collections.Generic; - using System.Threading; - using System.Threading.Tasks; - using MongoDB.Bson.Serialization; - using MongoDB.Driver; - - #nullable enable - namespace Gener8; - - public interface IMongoDbRepositoryContext - { - IMongoDatabase Context { get; } - } - - public abstract partial class MongoDbRepository : IRepository where TModel : class where TDto : class - { - protected readonly IMongoCollection Collection; - protected readonly IMongoDbRepositoryContext Context; - - protected MongoDbRepository(IMongoDbRepositoryContext context, string collectionName) - { - Context = context; - Collection = context.Context.GetCollection(collectionName); - } - - abstract protected TModel ToModel(TDto dto); - - abstract protected TDto ToDto(TModel model); - - public virtual async Task GetByIdAsync(object id, CancellationToken cancellationToken = default) - { - var filter = Builders.Filter.Eq("_id", id); - var dto = await Collection.Find(filter).FirstOrDefaultAsync(cancellationToken); - return ToModel(dto); - } - - public virtual async Task SaveAsync(TModel entity, CancellationToken cancellationToken = default) - { - var id = GetIdFromEntity(entity); - var filter = Builders.Filter.Eq("_id", id); - var options = new ReplaceOptions { IsUpsert = true }; - - await Collection.ReplaceOneAsync(filter, ToDto(entity), options, cancellationToken); - } - - public virtual async Task DeleteAsync(TModel entity, CancellationToken cancellationToken = default) - { - var id = GetIdFromEntity(entity); - var filter = Builders.Filter.Eq("_id", id); - - await Collection.DeleteOneAsync(filter, cancellationToken); - } - - public virtual async Task DeleteByIdAsync(object id, CancellationToken cancellationToken = default) - { - var filter = Builders.Filter.Eq("_id", id); - await Collection.DeleteOneAsync(filter, cancellationToken); - } - - public virtual async Task> GetAllAsync(CancellationToken cancellationToken = default) - { - var dtos = await Collection.Find(Builders.Filter.Empty).ToListAsync(cancellationToken); - return dtos.Select(dto => ToModel(dto)); - } - - protected virtual object GetIdFromEntity(TModel entity) - { - var classMap = BsonClassMap.LookupClassMap(typeof(TModel)); - if (classMap.IdMemberMap == null) - { - throw new InvalidOperationException($"No Id member found for type {typeof(TModel).Name}."); - } - - return classMap.IdMemberMap.Getter(entity); - } - } - """, - Filename: "MongoDbRepository.g.cs" - ); - - public static readonly DefaultSource CustomRepositoryBaseClass = new( - Name: "Gener8.CustomRepository", - Code: """ - // - #nullable enable - namespace Gener8; - - public interface IRepositoryContext - { - } - - public abstract partial class RepositoryBase : IRepository - where TModel : class - where TDto : class - { - protected readonly IRepositoryContext Context; - - protected RepositoryBase(IRepositoryContext context) => Context = context; - - abstract protected TModel ToModel(TDto dto); - - abstract protected TDto ToDto(TModel model); - } - """, - Filename: "CustomRepository.g.cs" - ); - - public static readonly DefaultSource TypeMappingAttribute = new( - Name: "Gener8.TypeMappingAttribute", - Code: """ - // - namespace Gener8; - - [System.AttributeUsage(System.AttributeTargets.Class, Inherited = false, AllowMultiple = true)] - public sealed class TypeMappingAttribute(System.Type sourceType, System.Type targetType) : System.Attribute - { - public System.Type SourceType { get; } = sourceType; - public System.Type TargetType { get; } = targetType; - } - """, - Filename: "TypeMappingAttribute.g.cs" - ); - - public static readonly DefaultSource RenamePropertyAttribute = new( - Name: "Gener8.RenamePropertyAttribute", - Code: """ - // - namespace Gener8; - - [System.AttributeUsage(System.AttributeTargets.Class, Inherited = false, AllowMultiple = true)] - public sealed class RenamePropertyAttribute(string sourceName, string targetName) : System.Attribute - { - public string SourceName { get; } = sourceName; - public string TargetName { get; } = targetName; - } - """, - Filename: "RenamePropertyAttribute.g.cs" - ); - - public static readonly DefaultSource IgnoreTypeMappingAttribute = new( - Name: "Gener8.IgnoreTypeMappingAttribute", - Code: """ - // - namespace Gener8; - - [System.AttributeUsage(System.AttributeTargets.Class, Inherited = false, AllowMultiple = true)] - public sealed class IgnoreTypeMappingAttribute(System.Type ignoredType) : System.Attribute - { - public System.Type IgnoredType { get; } = ignoredType; - } - """, - Filename: "IgnoreTypeMappingAttribute.g.cs" - ); - - public static readonly DefaultSource FlattenPrefixEnum = new( - Name: "Gener8.FlattenPrefix", - Code: """ - // - namespace Gener8; - - public enum FlattenPrefix - { - Parent = 0, - None = 1, - Gaped = 2 - } - """, - Filename: "FlattenPrefix.g.cs" - ); - - public static IEnumerable Essentials => - [ - RepositoryTypeEnum, - FlattenPrefixEnum, - FromModelAttribute, - TypeMappingAttribute, - RenamePropertyAttribute, - IgnoreTypeMappingAttribute, - IRepositoryInterface, - ]; + public static class FromModelAttribute { public const string Name = "Gener8.FromModelAttribute"; } + public static class TypeMappingAttribute { public const string Name = "Gener8.TypeMappingAttribute"; } + public static class RenamePropertyAttribute { public const string Name = "Gener8.RenamePropertyAttribute"; } + public static class IgnoreTypeMappingAttribute { public const string Name = "Gener8.IgnoreTypeMappingAttribute"; } } diff --git a/src/Gener8/FromModelGenerator.cs b/src/Gener8/FromModelGenerator.cs index 9273196..1663ac9 100644 --- a/src/Gener8/FromModelGenerator.cs +++ b/src/Gener8/FromModelGenerator.cs @@ -1,8 +1,6 @@ using Gener8.Contexts; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Text; using System; -using System.Text; namespace Gener8; @@ -11,11 +9,9 @@ public sealed partial class FromModelGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { - context.RegisterPostInitializationOutput(ctx => - { - foreach (var source in DefaultSource.Essentials) - ctx.AddSource(source.Filename, SourceText.From(source.Code, Encoding.UTF8)); - }); + // Attributes, enums, and repository contracts are now in Gener8.Abstractions + // (bundled inside the Gener8 NuGet under lib/netstandard2.0/). There is no longer + // a RegisterPostInitializationOutput step needed for those types. var pipeline = context.SyntaxProvider .CreateSyntaxProvider( @@ -26,7 +22,6 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var nullableEnabled = context.CompilationProvider .Select(static (c, _) => c.Options.NullableContextOptions != NullableContextOptions.Disable); - // Report diagnostics for known errors; emit source for successful targets. context.RegisterSourceOutput(pipeline.Combine(nullableEnabled), static (ctx, pair) => { var (result, nullable) = pair; @@ -36,11 +31,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) ctx.ReportDiagnostic(e); return; } - - try - { - SourceProducer.Emit(ctx, result.Target!, nullable); - } + try { SourceProducer.Emit(ctx, result.Target!, nullable); } catch (Exception ex) { ctx.ReportDiagnostic(Diagnostic.Create( @@ -49,31 +40,10 @@ public void Initialize(IncrementalGeneratorInitializationContext context) } }); - // Downstream pipelines only consume successfully processed targets. var successPipeline = pipeline .Where(static r => r?.Target is not null) .Select(static (r, _) => r!.Target!); - var repositoryKinds = successPipeline - .Select(static (t, _) => t.Repository) - .Where(static k => k != RepositoryKind.None) - .Collect(); - - context.RegisterSourceOutput(repositoryKinds, static (ctx, kinds) => - { - try - { - SourceProducer.EmitRepositoryBaseClasses(ctx, kinds); - } - catch (Exception ex) - { - ctx.ReportDiagnostic(Diagnostic.Create( - Diagnostics.UnexpectedError, Location.None, - "repository base classes", ex.Message)); - } - }); - - // Collect model full names of user-annotated DTOs so auto-generation skips duplicates. var userAnnotatedModelNames = successPipeline .Select(static (t, _) => t.Model.FullName) .Collect(); @@ -87,10 +57,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) static (ctx, pair) => { var ((targets, modelNames), nullable) = pair; - try - { - SourceProducer.EmitAutoDtos(ctx, targets, modelNames, nullable); - } + try { SourceProducer.EmitAutoDtos(ctx, targets, modelNames, nullable); } catch (Exception ex) { ctx.ReportDiagnostic(Diagnostic.Create( diff --git a/src/Gener8/Gener8.csproj b/src/Gener8/Gener8.csproj index de3e395..7c850a6 100644 --- a/src/Gener8/Gener8.csproj +++ b/src/Gener8/Gener8.csproj @@ -9,21 +9,20 @@ Gener8 0.0.1 Hamid Mayeli - C# source generator that copies public properties from a model class to a decorated partial DTO class. + C# source generator that copies public properties from a model class to a decorated partial DTO class. Includes Gener8.Abstractions (attributes, enums, repository contracts). source-generator;roslyn;dto;code-generation;c# https://github.com/hamidmayeli/from-model https://github.com/hamidmayeli/from-model git MIT README.md + gener8.png - + + false - - $(NoWarn);NU5128;RS2008 - - true + $(NoWarn);RS2008 @@ -36,9 +35,28 @@ + + + + + + + + + diff --git a/src/Gener8/SourceProducer.cs b/src/Gener8/SourceProducer.cs index de37a00..ba4431b 100644 --- a/src/Gener8/SourceProducer.cs +++ b/src/Gener8/SourceProducer.cs @@ -461,48 +461,6 @@ public static void EmitAutoDtos( } } - public static void EmitRepositoryBaseClasses(SourceProductionContext context, ImmutableArray kinds) - { - var emittedDynamo = false; - var emittedMongo = false; - var emittedCustom = false; - - foreach (var kind in kinds) - { - if (kind == RepositoryKind.DynamoDb && !emittedDynamo) - { - emittedDynamo = true; - - context.AddSource(DefaultSource.EnumToStringConverter.Filename, - SourceText.From(DefaultSource.EnumToStringConverter.Code, Encoding.UTF8)); - - context.AddSource(DefaultSource.NullableEnumToStringConverter.Filename, - SourceText.From(DefaultSource.NullableEnumToStringConverter.Code, Encoding.UTF8)); - - context.AddSource(DefaultSource.EnumListToStringListConverter.Filename, - SourceText.From(DefaultSource.EnumListToStringListConverter.Code, Encoding.UTF8)); - - context.AddSource(DefaultSource.NullableEnumListToStringListConverter.Filename, - SourceText.From(DefaultSource.NullableEnumListToStringListConverter.Code, Encoding.UTF8)); - - context.AddSource(DefaultSource.DynamoDbRepositoryBaseClass.Filename, - SourceText.From(DefaultSource.DynamoDbRepositoryBaseClass.Code, Encoding.UTF8)); - } - else if (kind == RepositoryKind.MongoDb && !emittedMongo) - { - emittedMongo = true; - context.AddSource(DefaultSource.MongoDbRepositoryBaseClass.Filename, - SourceText.From(DefaultSource.MongoDbRepositoryBaseClass.Code, Encoding.UTF8)); - } - else if (kind == RepositoryKind.Custom && !emittedCustom) - { - emittedCustom = true; - context.AddSource(DefaultSource.CustomRepositoryBaseClass.Filename, - SourceText.From(DefaultSource.CustomRepositoryBaseClass.Code, Encoding.UTF8)); - } - } - } - private static void EmitRepository(SourceProductionContext context, TargetClass target, bool nullableEnabled) { var sb = new StringBuilder(); diff --git a/tests/CustomDb.Integration.Tests/CustomDb.Integration.Tests.csproj b/tests/CustomDb.Integration.Tests/CustomDb.Integration.Tests.csproj index 944decb..573f0c2 100644 --- a/tests/CustomDb.Integration.Tests/CustomDb.Integration.Tests.csproj +++ b/tests/CustomDb.Integration.Tests/CustomDb.Integration.Tests.csproj @@ -24,6 +24,8 @@ + + diff --git a/tests/CustomDb.Integration.Tests/Setup/ProductDtoRepository.cs b/tests/CustomDb.Integration.Tests/Setup/ProductDtoRepository.cs index a05be1a..be2d2a6 100644 --- a/tests/CustomDb.Integration.Tests/Setup/ProductDtoRepository.cs +++ b/tests/CustomDb.Integration.Tests/Setup/ProductDtoRepository.cs @@ -1,31 +1,30 @@ -using Microsoft.Data.SqlClient; +using Microsoft.Data.SqlClient; namespace CustomDb.Integration.Tests.Setup.Models; public partial class ProductRepository { - protected override string CreateWhereClauseForId() => "WHERE Id = @Id"; + protected string CreateWhereClauseForId() => "WHERE Id = @Id"; - protected override string GetDeleteQuery() => "DELETE FROM Products"; + protected string GetDeleteQuery() => "DELETE FROM Products"; - protected override object GetIdFromEntity(Product entity) - => entity.Id; + protected object GetIdFromEntity(Product entity) => entity.Id; - protected override string GetSelectQuery() => "SELECT Id, Name, Description, CategoryName, CategoryDescription, Sizes FROM Products"; + protected string GetSelectQuery() => "SELECT Id, Name, Description, CategoryName, CategoryDescription, Sizes FROM Products"; - protected override string GetUpsertQuery() + protected string GetUpsertQuery() => """ MERGE INTO Products AS target USING (SELECT @Id AS Id, @Name AS Name, @Description AS Description, @CategoryName AS CategoryName, @CategoryDescription AS CategoryDescription, @Sizes AS Sizes) AS source ON target.Id = source.Id - WHEN MATCHED THEN + WHEN MATCHED THEN UPDATE SET Name = source.Name, Description = source.Description, CategoryName = source.CategoryName, CategoryDescription = source.CategoryDescription, Sizes = source.Sizes WHEN NOT MATCHED THEN - INSERT (Id, Name, Description, CategoryName, CategoryDescription, Sizes) + INSERT (Id, Name, Description, CategoryName, CategoryDescription, Sizes) VALUES (source.Id, source.Name, source.Description, source.CategoryName, source.CategoryDescription, source.Sizes); """; - protected override ProductDto ToDto(SqlDataReader reader) + protected ProductDto ReadDto(SqlDataReader reader) => new() { Id = reader.GetGuid(reader.GetOrdinal("Id")), @@ -36,7 +35,7 @@ protected override ProductDto ToDto(SqlDataReader reader) Sizes = [.. reader.GetString(reader.GetOrdinal("Sizes")).Split(',').Select(int.Parse)] }; - protected override void AddAllParameters(Product entity, SqlCommand command) + protected void AddAllParameters(Product entity, SqlCommand command) { command.Parameters.AddWithValue("@Id", entity.Id); command.Parameters.AddWithValue("@Name", entity.Name); diff --git a/tests/CustomDb.Integration.Tests/Setup/RepositoryBase.cs b/tests/CustomDb.Integration.Tests/Setup/RepositoryBase.cs index 81fff7e..aedb30a 100644 --- a/tests/CustomDb.Integration.Tests/Setup/RepositoryBase.cs +++ b/tests/CustomDb.Integration.Tests/Setup/RepositoryBase.cs @@ -1,91 +1,76 @@ -using Microsoft.Data.SqlClient; +using CustomDb.Integration.Tests.Setup; +using Microsoft.Data.SqlClient; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; -namespace Gener8; +namespace CustomDb.Integration.Tests.Setup.Models; -public class RepositoryContext(string connectionString) : IRepositoryContext -{ - public string ConnectionString => connectionString; -} - -partial class RepositoryBase +public partial class ProductRepository { private RepositoryContext TheContext => Context as RepositoryContext ?? throw new InvalidOperationException("Context is not a RepositoryContext"); - public async Task GetByIdAsync(object id, CancellationToken cancellationToken = default) + public override async Task GetByIdAsync(object id, CancellationToken cancellationToken = default) { using var connection = new SqlConnection(TheContext.ConnectionString); await connection.OpenAsync(cancellationToken); var command = connection.CreateCommand(); - command.CommandText = $"{GetSelectQuery()} {CreateWhereClauseForId()}"; - AddIdAsParameter(id, command); + command.Parameters.AddWithValue("@Id", id); var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) - return ToModel(ToDto(reader)); + return ToModel(ReadDto(reader)); return null; } - public async Task SaveAsync(TModel entity, CancellationToken cancellationToken = default) + public override async Task SaveAsync(Product entity, CancellationToken cancellationToken = default) { using var connection = new SqlConnection(TheContext.ConnectionString); await connection.OpenAsync(cancellationToken); var command = connection.CreateCommand(); - command.CommandText = GetUpsertQuery(); AddAllParameters(entity, command); await command.ExecuteNonQueryAsync(cancellationToken); } - public Task DeleteAsync(TModel entity, CancellationToken cancellationToken = default) + public override Task DeleteAsync(Product entity, CancellationToken cancellationToken = default) => DeleteByIdAsync(GetIdFromEntity(entity), cancellationToken); - public async Task DeleteByIdAsync(object id, CancellationToken cancellationToken = default) + public override async Task DeleteByIdAsync(object id, CancellationToken cancellationToken = default) { using var connection = new SqlConnection(TheContext.ConnectionString); await connection.OpenAsync(cancellationToken); var command = connection.CreateCommand(); - command.CommandText = $"{GetDeleteQuery()} {CreateWhereClauseForId()}"; - AddIdAsParameter(id, command); + command.Parameters.AddWithValue("@Id", id); await command.ExecuteNonQueryAsync(cancellationToken); } - public async Task> GetAllAsync(CancellationToken cancellationToken = default) + public override async Task> GetAllAsync(CancellationToken cancellationToken = default) { - var results = new List(); + var results = new List(); using var connection = new SqlConnection(TheContext.ConnectionString); await connection.OpenAsync(cancellationToken); var command = connection.CreateCommand(); - command.CommandText = GetSelectQuery(); var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) - results.Add(ToModel(ToDto(reader))); + results.Add(ToModel(ReadDto(reader))); return results; } - - abstract protected string GetSelectQuery(); - abstract protected string GetUpsertQuery(); - abstract protected string GetDeleteQuery(); - abstract protected string CreateWhereClauseForId(); - abstract protected TDto ToDto(SqlDataReader reader); - abstract protected object GetIdFromEntity(TModel entity); - protected abstract void AddAllParameters(TModel entity, SqlCommand command); - - private static void AddIdAsParameter(object id, SqlCommand command) - => command.Parameters.AddWithValue("@Id", id); } diff --git a/tests/CustomDb.Integration.Tests/Setup/RepositoryContext.cs b/tests/CustomDb.Integration.Tests/Setup/RepositoryContext.cs new file mode 100644 index 0000000..a4571d1 --- /dev/null +++ b/tests/CustomDb.Integration.Tests/Setup/RepositoryContext.cs @@ -0,0 +1,6 @@ +namespace CustomDb.Integration.Tests.Setup; + +public class RepositoryContext(string connectionString) : Gener8.IRepositoryContext +{ + public string ConnectionString => connectionString; +} diff --git a/tests/DynamoDb.Integration.Tests/DynamoDb.Integration.Tests.csproj b/tests/DynamoDb.Integration.Tests/DynamoDb.Integration.Tests.csproj index 7227c46..7bf7ded 100644 --- a/tests/DynamoDb.Integration.Tests/DynamoDb.Integration.Tests.csproj +++ b/tests/DynamoDb.Integration.Tests/DynamoDb.Integration.Tests.csproj @@ -24,6 +24,10 @@ + + + + diff --git a/tests/Gener8.Tests/FlattenTests.cs b/tests/Gener8.Tests/FlattenTests.cs index 3aa2934..04ce937 100644 --- a/tests/Gener8.Tests/FlattenTests.cs +++ b/tests/Gener8.Tests/FlattenTests.cs @@ -143,14 +143,6 @@ public partial class OrderDto { } Assert.DoesNotContain("ShippingAddress", source); } - [Fact] - public void EmitsFlattenPrefixEnumSourceFile() - { - var results = GeneratorDriver.Run("public class Empty { }"); - - Assert.Contains("FlattenPrefix.g.cs", results.Keys); - } - [Fact] public void FlattenNullableType_OverriedsRequiredModifier() { diff --git a/tests/Gener8.Tests/FromModelGeneratorTests.cs b/tests/Gener8.Tests/FromModelGeneratorTests.cs index d49639b..7a31426 100644 --- a/tests/Gener8.Tests/FromModelGeneratorTests.cs +++ b/tests/Gener8.Tests/FromModelGeneratorTests.cs @@ -134,14 +134,6 @@ public partial class MyDto { } Assert.Contains("public partial class MyDto", source); } - [Fact] - public void EmitsAttributeSourceFile() - { - var results = GeneratorDriver.Run("public class Empty { }"); - - Assert.Contains("FromModelAttribute.g.cs", results.Keys); - } - // ----------------------------------------------------------------------- // Nullable context // ----------------------------------------------------------------------- diff --git a/tests/Gener8.Tests/Gener8.Tests.csproj b/tests/Gener8.Tests/Gener8.Tests.csproj index decb658..106b2a8 100644 --- a/tests/Gener8.Tests/Gener8.Tests.csproj +++ b/tests/Gener8.Tests/Gener8.Tests.csproj @@ -1,11 +1,9 @@ - net10.0 enable false - all @@ -19,13 +17,13 @@ - + + + - - diff --git a/tests/Gener8.Tests/GeneratorDriver.cs b/tests/Gener8.Tests/GeneratorDriver.cs index 91c5133..64ab255 100644 --- a/tests/Gener8.Tests/GeneratorDriver.cs +++ b/tests/Gener8.Tests/GeneratorDriver.cs @@ -9,7 +9,7 @@ internal static class GeneratorDriver /// /// Compiles , runs , and returns /// every source file the generator emitted (keyed by hint name). - /// Throws if the input compilation itself has errors (generator bugs would be masked otherwise). + /// Throws if the post-generation compilation has errors. /// internal static IReadOnlyDictionary Run(params string[] sources) => RunCore(NullableContextOptions.Disable, sources); @@ -17,7 +17,7 @@ internal static IReadOnlyDictionary Run(params string[] sources) /// /// Compiles with nullable enabled, runs , and returns /// every source file the generator emitted (keyed by hint name). - /// Throws if the input compilation itself has errors (generator bugs would be masked otherwise). + /// Throws if the post-generation compilation has errors. /// internal static IReadOnlyDictionary RunWithNullable(params string[] sources) => RunCore(NullableContextOptions.Enable, sources); @@ -58,9 +58,6 @@ private static Dictionary RunCore(NullableContextOptions nullabl var generator = new FromModelGenerator(); - // RunGeneratorsAndUpdateCompilation returns a compilation that includes the - // generator-injected sources (e.g. FromModelAttribute from RegisterPostInitializationOutput). - // Check errors on that, not on the pre-generation compilation. var driver = CSharpGeneratorDriver .Create(generator) .RunGeneratorsAndUpdateCompilation(compilation, out var updated, out _); @@ -85,12 +82,19 @@ private static CSharpCompilation CreateCompilation(string[] sources, NullableCon var syntaxTrees = sources.Select(s => CSharpSyntaxTree.ParseText(s)).ToArray(); // Minimal reference set: mscorlib + System.Runtime (enough for most generator inputs). + // Gener8.Abstractions is included so [FromModel], [TypeMapping], etc. are available + // in test source code — they are no longer injected by the generator at runtime. + // netstandard.dll is required because Abstractions.dll targets netstandard2.0; without it + // Roslyn can't resolve System.Attribute's provenance and treats [FromModel] as an error type, + // causing the generator to silently skip all annotated classes. var references = new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location), MetadataReference.CreateFromFile(Assembly.Load("System.Runtime").Location), MetadataReference.CreateFromFile(Assembly.Load("System.Collections").Location), MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location), + MetadataReference.CreateFromFile(typeof(FromModelAttribute).Assembly.Location), + MetadataReference.CreateFromFile(Assembly.Load("netstandard").Location), }; return CSharpCompilation.Create( diff --git a/tests/Gener8.Tests/RenamePropertyTests.cs b/tests/Gener8.Tests/RenamePropertyTests.cs index 368cee0..5a558b7 100644 --- a/tests/Gener8.Tests/RenamePropertyTests.cs +++ b/tests/Gener8.Tests/RenamePropertyTests.cs @@ -69,12 +69,4 @@ public partial class ProductDto { } var source = Assert.Single(results, r => r.Key == "ProductDto.g.cs").Value; Assert.Contains("public string Name", source); } - - [Fact] - public void EmitsRenamePropertyAttributeSourceFile() - { - var results = GeneratorDriver.Run("public class Empty { }"); - - Assert.Contains("RenamePropertyAttribute.g.cs", results.Keys); - } } diff --git a/tests/Gener8.Tests/RepositoryTests.cs b/tests/Gener8.Tests/RepositoryTests.cs index b2c5fd3..8d98d3a 100644 --- a/tests/Gener8.Tests/RepositoryTests.cs +++ b/tests/Gener8.Tests/RepositoryTests.cs @@ -34,39 +34,6 @@ public partial class ProductDto { } Assert.Contains("ProductRepository.g.cs", results.Keys); } - [Fact] - public void EmitsDynamoDbBaseClassFile() - { - var results = GeneratorDriver.RunUnchecked(ProductModel + """ - [FromModel(typeof(Product), Repository = RepositoryType.DynamoDb)] - public partial class ProductDto { } - """); - - Assert.Contains("DynamoDbRepository.g.cs", results.Keys); - } - - [Fact] - public void EmitsNullableEnumStringConverter() - { - var results = GeneratorDriver.RunUnchecked(ProductModel + """ - [FromModel(typeof(Product), Repository = RepositoryType.DynamoDb)] - public partial class ProductDto { } - """); - - Assert.Contains("NullableEnumToStringConverter.g.cs", results.Keys); - } - - [Fact] - public void EmitsEnumStringConverter() - { - var results = GeneratorDriver.RunUnchecked(ProductModel + """ - [FromModel(typeof(Product), Repository = RepositoryType.DynamoDb)] - public partial class ProductDto { } - """); - - Assert.Contains("EnumToStringConverter.g.cs", results.Keys); - } - [Fact] public void DynamoDbRepositoryHasCorrectClassName() { @@ -218,21 +185,6 @@ public partial class ProductDto { } Assert.Contains("ProductRepository.g.cs", results.Keys); } - [Fact] - public void DynamoDbBaseClassEmittedOnlyOnceForMultipleDtos() - { - var results = GeneratorDriver.RunUnchecked(ProductModel + """ - public class Order { public int Id { get; set; } } - [FromModel(typeof(Product), Repository = RepositoryType.DynamoDb)] - public partial class ProductDto { } - [FromModel(typeof(Order), Repository = RepositoryType.DynamoDb)] - public partial class OrderDto { } - """); - - var dynamo = results.Keys.Where(k => k == "DynamoDbRepository.g.cs").ToList(); - Assert.Single(dynamo); - } - // ---- MongoDB ---- [Fact] @@ -246,17 +198,6 @@ public partial class ProductDto { } Assert.Contains("ProductRepository.g.cs", results.Keys); } - [Fact] - public void EmitsMongoDbBaseClassFile() - { - var results = GeneratorDriver.RunUnchecked(ProductModel + """ - [FromModel(typeof(Product), Repository = RepositoryType.MongoDb)] - public partial class ProductDto { } - """); - - Assert.Contains("MongoDbRepository.g.cs", results.Keys); - } - [Fact] public void MongoDbRepositoryInheritsFromBase() { @@ -310,21 +251,6 @@ public partial class ProductDto { } Assert.Contains("My.App.ProductRepository.g.cs", results.Keys); } - [Fact] - public void MongoDbBaseClassEmittedOnlyOnceForMultipleDtos() - { - var results = GeneratorDriver.RunUnchecked(ProductModel + """ - public class Order { public int Id { get; set; } } - [FromModel(typeof(Product), Repository = RepositoryType.MongoDb)] - public partial class ProductDto { } - [FromModel(typeof(Order), Repository = RepositoryType.MongoDb)] - public partial class OrderDto { } - """); - - var mongo = results.Keys.Where(k => k == "MongoDbRepository.g.cs").ToList(); - Assert.Single(mongo); - } - [Fact] public void EnumPropertyHasCorrectConverterInMongoDb() { @@ -365,21 +291,10 @@ public partial class ProductDto { } Assert.Contains("ProductRepository.g.cs", results.Keys); } - [Fact] - public void EmitsCustomBaseClassFile() - { - var results = GeneratorDriver.RunUnchecked(ProductModel + """ - [FromModel(typeof(Product), Repository = RepositoryType.Custom)] - public partial class ProductDto { } - """); - - Assert.Contains("CustomRepository.g.cs", results.Keys); - } - // ---- Mixed (both kinds in the same compilation) ---- [Fact] - public void BothBaseClassesEmittedWhenBothKindsUsed() + public void BothRepositoryFilesEmittedWhenBothKindsUsed() { var results = GeneratorDriver.RunUnchecked(ProductModel + """ public class Order { public int Id { get; set; } } @@ -389,8 +304,8 @@ public partial class ProductDto { } public partial class OrderDto { } """); - Assert.Contains("DynamoDbRepository.g.cs", results.Keys); - Assert.Contains("MongoDbRepository.g.cs", results.Keys); + Assert.Contains("ProductRepository.g.cs", results.Keys); + Assert.Contains("OrderRepository.g.cs", results.Keys); } // ---- Abstract collection remapping (DynamoDB cannot instantiate interfaces) ---- diff --git a/tests/Gener8.Tests/TypeMappingTests.cs b/tests/Gener8.Tests/TypeMappingTests.cs index 624dacf..9eca62d 100644 --- a/tests/Gener8.Tests/TypeMappingTests.cs +++ b/tests/Gener8.Tests/TypeMappingTests.cs @@ -124,22 +124,6 @@ public partial class OrderDto { } Assert.Contains("(System.Collections.Generic.HashSet
)[.. dto.ShippingAddresses.Select(m => m.ToModel())]", ext); } - [Fact] - public void EmitsTypeMappingAttributeSourceFile() - { - var results = GeneratorDriver.Run("public class Empty { }"); - - Assert.Contains("TypeMappingAttribute.g.cs", results.Keys); - } - - [Fact] - public void EmitsIgnoreTypeMappingAttributeSourceFile() - { - var results = GeneratorDriver.Run("public class Empty { }"); - - Assert.Contains("IgnoreTypeMappingAttribute.g.cs", results.Keys); - } - [Fact] public void IgnoreTypeMappingPreventsInferredMapping() { diff --git a/tests/MongoDb.Integration.Tests/MongoDb.Integration.Tests.csproj b/tests/MongoDb.Integration.Tests/MongoDb.Integration.Tests.csproj index 5642a23..0d5156d 100644 --- a/tests/MongoDb.Integration.Tests/MongoDb.Integration.Tests.csproj +++ b/tests/MongoDb.Integration.Tests/MongoDb.Integration.Tests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -24,6 +24,10 @@ + + + +