diff --git a/README.md b/README.md index 3b3f82f..d6c7148 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,77 @@ # Read Me +## DesignTime Integration + +This package can generate GraphQL DataLoaders (and related extensions) at **EF Core design-time** when you scaffold migrations. This is done by registering a custom `IMigrationsCodeGenerator`. + +### 1) Add the design-time package + +Reference `Stackworx.EfCoreGraphQL.DesignTime` from the project EF tooling loads at design-time (commonly your **startup** project, but any project EF loads for design-time services works). + +> Note: EF tooling loads design-time services from the *startup project* and the design-time assembly references it discovers. + +### 2) Register `EfCoreMigrationsCodeGenerator` + +Create a class that implements `IDesignTimeServices` (EF Core tooling discovers it automatically) and register the code generator: + +```csharp +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Migrations.Design; +using Microsoft.Extensions.DependencyInjection; +using Stackworx.EfCoreGraphQL.DesignTime; + +public sealed class DesignTimeServices : IDesignTimeServices +{ + public void ConfigureDesignTimeServices(IServiceCollection services) + => services.AddSingleton(); +} +``` + +Example: see `sample/Sample.DesignTime/DesignTimeServices.cs`. + +### 3) Configure the output directory (required) + +During migration scaffolding EF Core may run with a working directory that **is not** your migrations/target project directory (especially when `--startup-project` differs from the target project). To avoid writing generated files to the wrong place, sidecar output requires an explicit directory. + +Set this environment variable before running `dotnet ef`: + +- `STACKWORX_EFCOREGRAPHQL_SIDECAR_OUTPUT_DIR` + +It must point to an **existing directory**. A typical value is your migrations folder. + +macOS / Linux (zsh/bash): + +```zsh +export STACKWORX_EFCOREGRAPHQL_SIDECAR_OUTPUT_DIR="/absolute/path/to/YourProject/Migrations" +``` + +Windows (PowerShell): + +```powershell +$env:STACKWORX_EFCOREGRAPHQL_SIDECAR_OUTPUT_DIR = "C:\\path\\to\\YourProject\\Migrations" +``` + +If this variable is not set (or points to a non-existent directory), migration scaffolding will fail with a clear error. + +### 4) Scaffold a migration + +Run EF migrations as usual. The generator runs when EF scaffolds/updates the *model snapshot*. + +```zsh +dotnet ef migrations add InitialCreate \ + --project ./src/Your.Migrations.Project \ + --startup-project ./src/Your.Api.Project +``` + +### Generated files + +Sidecar files are written into `STACKWORX_EFCOREGRAPHQL_SIDECAR_OUTPUT_DIR` with names based on the model snapshot name: + +- `{ModelSnapshotName}.DataLoaders.g.cs` +- `{ModelSnapshotName}.DataLoaders.g.hash` + +The `.hash` file prevents unnecessary regeneration when the snapshot hasn’t changed. + ## Goal Auto generate dataloaders and extensions to match EF core navigations. @@ -272,4 +344,3 @@ public sealed class StudentExtensions } } ``` - diff --git a/Stackworx.EfCoreGraphQL.slnx b/Stackworx.EfCoreGraphQL.slnx index 6ca67f5..c74ca3a 100644 --- a/Stackworx.EfCoreGraphQL.slnx +++ b/Stackworx.EfCoreGraphQL.slnx @@ -1,16 +1,18 @@ - - + + + - - - - + + + + + - - + + \ No newline at end of file diff --git a/dotnet-tools.json b/dotnet-tools.json index a74bf0b..bffb60c 100644 --- a/dotnet-tools.json +++ b/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "10.0.2", + "version": "10.0.3", "commands": [ "dotnet-ef" ], diff --git a/sample/Sample.DesignTime/.gitignore b/sample/Sample.DesignTime/.gitignore new file mode 100644 index 0000000..19e0180 --- /dev/null +++ b/sample/Sample.DesignTime/.gitignore @@ -0,0 +1 @@ +db/ diff --git a/sample/Sample.DesignTime/Data/AppDbContext.cs b/sample/Sample.DesignTime/Data/AppDbContext.cs new file mode 100644 index 0000000..93860ab --- /dev/null +++ b/sample/Sample.DesignTime/Data/AppDbContext.cs @@ -0,0 +1,30 @@ +namespace Sample.DesignTime.Data; + +using Microsoft.EntityFrameworkCore; + +public class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Authors => Set(); + public DbSet Books => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(b => + { + b.ToTable("Authors"); + b.HasKey(x => x.Id); + b.Property(x => x.Name).IsRequired(); + }); + + modelBuilder.Entity(b => + { + b.ToTable("Books"); + b.HasKey(x => x.Id); + b.Property(x => x.Title).IsRequired(); + + b.HasOne(x => x.Author) + .WithMany(x => x.Books) + .HasForeignKey(x => x.AuthorId); + }); + } +} diff --git a/sample/Sample.DesignTime/Data/AppDbContextFactory.cs b/sample/Sample.DesignTime/Data/AppDbContextFactory.cs new file mode 100644 index 0000000..f23d86b --- /dev/null +++ b/sample/Sample.DesignTime/Data/AppDbContextFactory.cs @@ -0,0 +1,19 @@ +namespace Sample.DesignTime.Data; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +/// +/// Used by EF Core tooling (dotnet ef) to create the DbContext at design-time. +/// +public sealed class AppDbContextFactory : IDesignTimeDbContextFactory +{ + public AppDbContext CreateDbContext(string[] args) + { + var opts = new DbContextOptionsBuilder() + .UseSqlite("DataSource=db/app.db") + .Options; + + return new AppDbContext(opts); + } +} diff --git a/sample/Sample.DesignTime/Data/Author.cs b/sample/Sample.DesignTime/Data/Author.cs new file mode 100644 index 0000000..f6b31f3 --- /dev/null +++ b/sample/Sample.DesignTime/Data/Author.cs @@ -0,0 +1,9 @@ +namespace Sample.DesignTime.Data; + +public class Author +{ + public int Id { get; set; } + public string Name { get; set; } = null!; + + public List Books { get; set; } = []; +} diff --git a/sample/Sample.DesignTime/Data/Book.cs b/sample/Sample.DesignTime/Data/Book.cs new file mode 100644 index 0000000..aceed7b --- /dev/null +++ b/sample/Sample.DesignTime/Data/Book.cs @@ -0,0 +1,10 @@ +namespace Sample.DesignTime.Data; + +public class Book +{ + public int Id { get; set; } + public string Title { get; set; } = null!; + + public int AuthorId { get; set; } + public Author Author { get; set; } = null!; +} diff --git a/sample/Sample.DesignTime/DesignTimeServices.cs b/sample/Sample.DesignTime/DesignTimeServices.cs new file mode 100644 index 0000000..5aea09a --- /dev/null +++ b/sample/Sample.DesignTime/DesignTimeServices.cs @@ -0,0 +1,16 @@ +namespace Sample.DesignTime; + +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Migrations.Design; +using Microsoft.Extensions.DependencyInjection; +using Stackworx.EfCoreGraphQL.DesignTime; + +/// +/// EF Core tooling will discover this type automatically (when the assembly is referenced) +/// and call it during design-time operations (e.g. migrations scaffolding). +/// +public sealed class DesignTimeServices : IDesignTimeServices +{ + public void ConfigureDesignTimeServices(IServiceCollection services) + => services.AddSingleton(); +} \ No newline at end of file diff --git a/sample/Sample.DesignTime/Migrations/20260221160640_Initial.Designer.cs b/sample/Sample.DesignTime/Migrations/20260221160640_Initial.Designer.cs new file mode 100644 index 0000000..f0f826d --- /dev/null +++ b/sample/Sample.DesignTime/Migrations/20260221160640_Initial.Designer.cs @@ -0,0 +1,75 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Sample.DesignTime.Data; + +#nullable disable + +namespace Sample.DesignTime.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260221160640_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("Sample.DesignTime.Data.Author", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Authors", (string)null); + }); + + modelBuilder.Entity("Sample.DesignTime.Data.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.ToTable("Books", (string)null); + }); + + modelBuilder.Entity("Sample.DesignTime.Data.Book", b => + { + b.HasOne("Sample.DesignTime.Data.Author", "Author") + .WithMany("Books") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Sample.DesignTime.Data.Author", b => + { + b.Navigation("Books"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/sample/Sample.DesignTime/Migrations/20260221160640_Initial.cs b/sample/Sample.DesignTime/Migrations/20260221160640_Initial.cs new file mode 100644 index 0000000..7ee5bda --- /dev/null +++ b/sample/Sample.DesignTime/Migrations/20260221160640_Initial.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Sample.DesignTime.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Authors", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Authors", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Books", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Title = table.Column(type: "TEXT", nullable: false), + AuthorId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Books", x => x.Id); + table.ForeignKey( + name: "FK_Books_Authors_AuthorId", + column: x => x.AuthorId, + principalTable: "Authors", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Books_AuthorId", + table: "Books", + column: "AuthorId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Books"); + + migrationBuilder.DropTable( + name: "Authors"); + } + } +} diff --git a/sample/Sample.DesignTime/Migrations/20260221162418_InitialCreate.Designer.cs b/sample/Sample.DesignTime/Migrations/20260221162418_InitialCreate.Designer.cs new file mode 100644 index 0000000..428ba17 --- /dev/null +++ b/sample/Sample.DesignTime/Migrations/20260221162418_InitialCreate.Designer.cs @@ -0,0 +1,75 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Sample.DesignTime.Data; + +#nullable disable + +namespace Sample.DesignTime.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260221162418_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("Sample.DesignTime.Data.Author", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Authors", (string)null); + }); + + modelBuilder.Entity("Sample.DesignTime.Data.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.ToTable("Books", (string)null); + }); + + modelBuilder.Entity("Sample.DesignTime.Data.Book", b => + { + b.HasOne("Sample.DesignTime.Data.Author", "Author") + .WithMany("Books") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Sample.DesignTime.Data.Author", b => + { + b.Navigation("Books"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/sample/Sample.DesignTime/Migrations/20260221162418_InitialCreate.cs b/sample/Sample.DesignTime/Migrations/20260221162418_InitialCreate.cs new file mode 100644 index 0000000..c9313d7 --- /dev/null +++ b/sample/Sample.DesignTime/Migrations/20260221162418_InitialCreate.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Sample.DesignTime.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/sample/Sample.DesignTime/Migrations/AppDbContextModelSnapshot.DataLoaders.g.cs b/sample/Sample.DesignTime/Migrations/AppDbContextModelSnapshot.DataLoaders.g.cs new file mode 100644 index 0000000..99d3033 --- /dev/null +++ b/sample/Sample.DesignTime/Migrations/AppDbContextModelSnapshot.DataLoaders.g.cs @@ -0,0 +1,99 @@ +// +#nullable enable +#pragma warning disable +// Source: EF Core model navigations +// Style: HotChocolate [DataLoader] decorator methods +// + +namespace Sample.DesignTime.Migrations.Generated.DataLoaders; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using HotChocolate; +using GreenDonut; +using HotChocolate.Types; + +// Author +[ExtendObjectType] +public static class AuthorExtensions +{ + /// + /// Primary Key Data Loader for + /// + [DataLoader] + public static async Task> AuthorById( + IReadOnlyList keys, + Sample.DesignTime.Data.AppDbContext context, + CancellationToken ct) + { + return await context.Set() + .AsNoTracking() + .Where(e => keys.Contains(e.Id)) + .ToDictionaryAsync(e => e.Id, ct); + } + + /// + /// Navigation Data Loader for + /// + [DataLoader] + public static async Task> BooksByAuthorId( + IReadOnlyList keys, + Sample.DesignTime.Data.AppDbContext context, + CancellationToken ct) + { + var items = await context.Set() + .AsNoTracking() + .Where(e => keys.Contains(e.AuthorId)) + .ToListAsync(ct); + + return items.ToLookup(e => e.AuthorId); + } + + /// + /// GraphQL Field Override for + /// + public static async Task> GetBooksAsync( + [Parent] Sample.DesignTime.Data.Author parent, + IBooksByAuthorIdDataLoader loader, + CancellationToken ct) + { + return await loader.LoadAsync(parent.Id, ct); + } + +} + +// Book +[ExtendObjectType(IgnoreFields = ["authorId"])] +public static class BookExtensions +{ + /// + /// Primary Key Data Loader for + /// + [DataLoader] + public static async Task> BookById( + IReadOnlyList keys, + Sample.DesignTime.Data.AppDbContext context, + CancellationToken ct) + { + return await context.Set() + .AsNoTracking() + .Where(e => keys.Contains(e.Id)) + .ToDictionaryAsync(e => e.Id, ct); + } + + /// + /// GraphQL Field Override for + /// + public static async Task GetAuthorAsync( + [Parent] Sample.DesignTime.Data.Book parent, + IAuthorByIdDataLoader loader, + CancellationToken ct) + { + return await loader.LoadAsync(parent.AuthorId, ct); + } + +} + diff --git a/sample/Sample.DesignTime/Migrations/AppDbContextModelSnapshot.DataLoaders.g.hash b/sample/Sample.DesignTime/Migrations/AppDbContextModelSnapshot.DataLoaders.g.hash new file mode 100644 index 0000000..fb35b5e --- /dev/null +++ b/sample/Sample.DesignTime/Migrations/AppDbContextModelSnapshot.DataLoaders.g.hash @@ -0,0 +1 @@ +7A4058366335C592F33D1CEBBEB99E4597521F5F815DE22BB7346D7972C3EDB0 diff --git a/sample/Sample.DesignTime/Migrations/AppDbContextModelSnapshot.cs b/sample/Sample.DesignTime/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..d9b7a29 --- /dev/null +++ b/sample/Sample.DesignTime/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,72 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Sample.DesignTime.Data; + +#nullable disable + +namespace Sample.DesignTime.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("Sample.DesignTime.Data.Author", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Authors", (string)null); + }); + + modelBuilder.Entity("Sample.DesignTime.Data.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.ToTable("Books", (string)null); + }); + + modelBuilder.Entity("Sample.DesignTime.Data.Book", b => + { + b.HasOne("Sample.DesignTime.Data.Author", "Author") + .WithMany("Books") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Sample.DesignTime.Data.Author", b => + { + b.Navigation("Books"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/sample/Sample.DesignTime/Program.cs b/sample/Sample.DesignTime/Program.cs new file mode 100644 index 0000000..f2923a1 --- /dev/null +++ b/sample/Sample.DesignTime/Program.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Sample.DesignTime; +using Sample.DesignTime.Data; +using Sample.DesignTime.Types; + +var builder = WebApplication.CreateBuilder(); + +builder.Services.AddDbContextFactory(opts => +{ + // opts.UseSqlite("DataSource=:memory:"); + opts.UseSqlite("DataSource=db/app.db"); +}); + +builder + .AddGraphQL() + .AddQueryType() + .RegisterDbContextFactory() + .AddDesignTimeTypes() + .InitializeOnStartup(); + +var app = builder.Build(); + +app.MapGraphQL(); +app.MapGet("/", () => Results.Redirect("/graphql")); + +await app.SeedAsync(); + +app.Run(); diff --git a/sample/Sample.DesignTime/Properties/launchSettings.json b/sample/Sample.DesignTime/Properties/launchSettings.json new file mode 100644 index 0000000..d250892 --- /dev/null +++ b/sample/Sample.DesignTime/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "Sample.DesignTime": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5055", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/sample/Sample.DesignTime/Sample.DesignTime.csproj b/sample/Sample.DesignTime/Sample.DesignTime.csproj new file mode 100644 index 0000000..489581b --- /dev/null +++ b/sample/Sample.DesignTime/Sample.DesignTime.csproj @@ -0,0 +1,25 @@ + + + net10.0 + enable + enable + false + + + + + + all + + + + + + + + + + + + + diff --git a/sample/Sample.DesignTime/Types/Query.cs b/sample/Sample.DesignTime/Types/Query.cs new file mode 100644 index 0000000..bf47aec --- /dev/null +++ b/sample/Sample.DesignTime/Types/Query.cs @@ -0,0 +1,13 @@ +namespace Sample.DesignTime.Types; + +using Microsoft.EntityFrameworkCore; +using Sample.DesignTime.Data; + +public class Query +{ + public IQueryable GetBooks(AppDbContext dbContext) + => dbContext.Books.AsQueryable(); + + public IQueryable GetAuthors(AppDbContext dbContext) + => dbContext.Authors.AsQueryable(); +} diff --git a/sample/Sample.DesignTime/WebApplicationExtensions.cs b/sample/Sample.DesignTime/WebApplicationExtensions.cs new file mode 100644 index 0000000..0c45eac --- /dev/null +++ b/sample/Sample.DesignTime/WebApplicationExtensions.cs @@ -0,0 +1,30 @@ +namespace Sample.DesignTime; + +using Microsoft.EntityFrameworkCore; +using Sample.DesignTime.Data; + +public static class WebApplicationExtensions +{ + public static async Task SeedAsync(this WebApplication app) + { + using var scope = app.Services.CreateScope(); + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + + await db.Database.EnsureDeletedAsync(); + await db.Database.MigrateAsync(); + + if (await db.Authors.AnyAsync()) + { + return; + } + + var author = new Author { Name = "Terry Pratchett" }; + db.Authors.Add(author); + db.Books.AddRange( + new Book { Title = "Small Gods", Author = author }, + new Book { Title = "Guards! Guards!", Author = author }); + + await db.SaveChangesAsync(); + } +} diff --git a/sample/Sample.DesignTime/appsettings.json b/sample/Sample.DesignTime/appsettings.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/sample/Sample.DesignTime/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/sample/Sample.DesignTime/db/.gitkeep b/sample/Sample.DesignTime/db/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sample/Sample.Generate/Sample.Generate.csproj b/sample/Sample.Generate/Sample.Generate.csproj index 65e0e71..af84490 100644 --- a/sample/Sample.Generate/Sample.Generate.csproj +++ b/sample/Sample.Generate/Sample.Generate.csproj @@ -10,6 +10,6 @@ + - diff --git a/sample/Sample/DataLoaders.g.cs b/sample/Sample/DataLoaders.g.cs index 303dffb..2075df6 100644 --- a/sample/Sample/DataLoaders.g.cs +++ b/sample/Sample/DataLoaders.g.cs @@ -66,7 +66,7 @@ public static class AuthorExtensions } // Book -[ExtendObjectType] +[ExtendObjectType(IgnoreFields = ["authorId"])] public static class BookExtensions { /// @@ -98,7 +98,7 @@ public static class BookExtensions } // Comment -[ExtendObjectType] +[ExtendObjectType(IgnoreFields = ["postId"])] public static class CommentExtensions { /// @@ -184,7 +184,7 @@ public static class CourseExtensions } // Passport -[ExtendObjectType] +[ExtendObjectType(IgnoreFields = ["personId"])] public static class PassportExtensions { /// @@ -314,6 +314,9 @@ public static class PostExtensions return await loader.LoadAsync(parent.Id, ct); } + /// + /// Skip Navigation Data Loader for + /// [DataLoader] public static async Task> TagsByPosts( IReadOnlyList keys, @@ -329,6 +332,9 @@ public static class PostExtensions return pairs.ToLookup(e => e.Id, x => x.Child); } + /// + /// GraphQL Field Override for + /// public static async Task GetTagsAsync( [Parent] Stackworx.EfCoreGraphQL.Tests.Data.Post parent, ITagsByPostsDataLoader loader, @@ -407,6 +413,9 @@ public static class TagExtensions .ToDictionaryAsync(e => e.Id, ct); } + /// + /// Skip Navigation Data Loader for + /// [DataLoader] public static async Task> PostsByTags( IReadOnlyList keys, @@ -422,6 +431,9 @@ public static class TagExtensions return pairs.ToLookup(e => e.Id, x => x.Child); } + /// + /// GraphQL Field Override for + /// public static async Task GetPostsAsync( [Parent] Stackworx.EfCoreGraphQL.Tests.Data.Tag parent, IPostsByTagsDataLoader loader, @@ -430,6 +442,9 @@ public static class TagExtensions return await loader.LoadAsync(parent.Id, ct); } + /// + /// Skip Navigation Data Loader for + /// [DataLoader] public static async Task> PostsShadowByTagsShadow( IReadOnlyList keys, @@ -445,6 +460,9 @@ public static class TagExtensions return pairs.ToLookup(e => e.Id, x => x.Child); } + /// + /// GraphQL Field Override for + /// public static async Task GetPostsShadowAsync( [Parent] Stackworx.EfCoreGraphQL.Tests.Data.Tag parent, IPostsShadowByTagsShadowDataLoader loader, @@ -503,7 +521,7 @@ public static class UserExtensions } // UserProfile -[ExtendObjectType] +[ExtendObjectType(IgnoreFields = ["userId"])] public static class UserProfileExtensions { /// diff --git a/sample/Sample/Sample.csproj b/sample/Sample/Sample.csproj index 2026b52..2365646 100644 --- a/sample/Sample/Sample.csproj +++ b/sample/Sample/Sample.csproj @@ -8,20 +8,15 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + - - - - - diff --git a/src/Stackworx.EFCoreGraphQL.DesignTime/EfCoreMigrationsCodeGenerator.cs b/src/Stackworx.EFCoreGraphQL.DesignTime/EfCoreMigrationsCodeGenerator.cs new file mode 100644 index 0000000..aad459b --- /dev/null +++ b/src/Stackworx.EFCoreGraphQL.DesignTime/EfCoreMigrationsCodeGenerator.cs @@ -0,0 +1,109 @@ +namespace Stackworx.EfCoreGraphQL.DesignTime; + +using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations.Design; +using Stackworx.EfCoreGraphQL; + +public class EfCoreMigrationsCodeGenerator( + MigrationsCodeGeneratorDependencies dependencies, + CSharpMigrationsGeneratorDependencies csharpDependencies) + : CSharpMigrationsGenerator(dependencies, csharpDependencies) +{ + // The generator runs during design-time migration scaffolding. + // When startup project != target project, CWD is often not the migrations project. + // To avoid writing files into the wrong repo folder, we require an explicit output directory. + private const string SidecarOutputDirEnvVar = "STACKWORX_EFCOREGRAPHQL_SIDECAR_OUTPUT_DIR"; + + public override string GenerateSnapshot(string? modelSnapshotNamespace, Type contextType, string modelSnapshotName, + IModel model) + { + var snapshotCode = base.GenerateSnapshot(modelSnapshotNamespace, contextType, modelSnapshotName, model); + TryGenerateSidecar(snapshotCode, modelSnapshotNamespace, modelSnapshotName, model, contextType); + return snapshotCode; + } + + private static void TryGenerateSidecar(string snapshotCode, + string? modelSnapshotNamespace, + string modelSnapshotName, + IModel model, + Type contextType) + { + // We intentionally fingerprint the snapshot code (not the model) because it already captures + // provider-specific annotations and is stable across EF versions for a given model. + var snapshotHash = Hash(snapshotCode); + + // Sidecar + its hash live next to each other. + var baseName = modelSnapshotName; // typically "{DbContext}ModelSnapshot" + var sidecarFileName = baseName + ".DataLoaders.g.cs"; + var hashFileName = baseName + ".DataLoaders.g.hash"; + + var outputDir = ResolveRequiredOutputDir(); + var sidecarPath = Path.Combine(outputDir, sidecarFileName); + var hashPath = Path.Combine(outputDir, hashFileName); + + var existingHash = File.Exists(hashPath) + ? File.ReadAllText(hashPath).TrimStart('\uFEFF').Trim() + : null; + if (string.Equals(existingHash, snapshotHash, StringComparison.OrdinalIgnoreCase)) + { + return; // no schema/model change + } + + // Generate file content + var @namespace = string.IsNullOrWhiteSpace(modelSnapshotNamespace) + ? "Generated.DataLoaders" + : modelSnapshotNamespace + ".Generated.DataLoaders"; + + var content = DataLoaderGenerator.GenerateString( + model, + contextType, + new GenerateOptions + { + Namespace = @namespace, + // Keep defaults for Mode/Filter; consumers can later add configurability. + }); + + AtomicWrite(sidecarPath, content); + AtomicWrite(hashPath, snapshotHash + Environment.NewLine); + } + + private static string ResolveRequiredOutputDir() + { + var configured = Environment.GetEnvironmentVariable(SidecarOutputDirEnvVar); + if (string.IsNullOrWhiteSpace(configured)) + { + throw new InvalidOperationException( + $"{nameof(Stackworx)} EFCoreGraphQL sidecar generation requires the environment variable '{SidecarOutputDirEnvVar}' to be set to the directory where sidecar files should be written (typically the migrations folder or the project directory containing the snapshot). " + + "This is required to avoid writing files to an unexpected working directory when the EF Core Startup Project differs from the Target Project."); + } + + var fullPath = Path.GetFullPath(configured); + if (!Directory.Exists(fullPath)) + { + throw new DirectoryNotFoundException( + $"Environment variable '{SidecarOutputDirEnvVar}' points to '{configured}', but that directory does not exist (resolved to '{fullPath}')."); + } + + return fullPath; + } + + private static string Hash(string s) + { + var bytes = Encoding.UTF8.GetBytes(s); + var hash = SHA256.HashData(bytes); + return Convert.ToHexString(hash); + } + + private static void AtomicWrite(string path, string content) + { + Directory.CreateDirectory(Path.GetDirectoryName(path) ?? Environment.CurrentDirectory); + + var tmp = path + ".tmp"; + File.WriteAllText(tmp, content, Encoding.UTF8); + + // Replace is atomic on Windows; on Unix it's effectively atomic within a filesystem. + File.Move(tmp, path, overwrite: true); + } +} \ No newline at end of file diff --git a/src/Stackworx.EFCoreGraphQL.DesignTime/README.md b/src/Stackworx.EFCoreGraphQL.DesignTime/README.md new file mode 100644 index 0000000..f551009 --- /dev/null +++ b/src/Stackworx.EFCoreGraphQL.DesignTime/README.md @@ -0,0 +1,11 @@ +# Readme + +Sample project to test the DesignTime integration + +Delete the existing migrations first or test update + +```sh +dotnet tool restore +export STACKWORX_EFCOREGRAPHQL_SIDECAR_OUTPUT_DIR='./Migrations' +dotnet ef migrations add InitialCreate +``` \ No newline at end of file diff --git a/src/Stackworx.EFCoreGraphQL.DesignTime/Stackworx.EfCoreGraphQL.DesignTime.csproj b/src/Stackworx.EFCoreGraphQL.DesignTime/Stackworx.EfCoreGraphQL.DesignTime.csproj new file mode 100644 index 0000000..6bd77d2 --- /dev/null +++ b/src/Stackworx.EFCoreGraphQL.DesignTime/Stackworx.EfCoreGraphQL.DesignTime.csproj @@ -0,0 +1,31 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + + Stackworx.EfCoreGraphQL.DesignTime + 0.0.12-alpha + EF Core design-time integration for Stackworx.EfCoreGraphQL. Enables automatic DataLoader generation during migrations scaffolding. + + + + + all + + + all + + + all + + + + + + all + + + + diff --git a/src/Stackworx.EfCoreGraphQL/DataLoader.cs b/src/Stackworx.EfCoreGraphQL/DataLoader.cs index a769c82..b0a128d 100644 --- a/src/Stackworx.EfCoreGraphQL/DataLoader.cs +++ b/src/Stackworx.EfCoreGraphQL/DataLoader.cs @@ -8,7 +8,7 @@ public record DataLoader { public required string LoaderName { get; init; } - public required Type EntityType { get; init; } + public required string EntityType { get; init; } public required Type KeyType { get; init; } @@ -31,7 +31,7 @@ public enum DataLoaderType ManyToMany, } - public static DataLoader FromEntity(DbContext dbContext, IEntityType entityType) + public static DataLoader FromEntity(Type dbContextClass, IEntityType entityType) { var pk = entityType.FindPrimaryKey() ?? throw new NotSupportedException($"Entity '{entityType.Name}' has no primary key."); @@ -53,13 +53,13 @@ public static DataLoader FromEntity(DbContext dbContext, IEntityType entityType) KeyType = keyType, ReferenceField = keyPropName, IsShadowProperty = pkProp.IsShadowProperty(), - EntityType = entityType.ClrType, - DbContextType = dbContext.GetType(), + EntityType = TypeUtils.GetNestedQualifiedName(entityType.ClrType), + DbContextType = dbContextClass, Notes = $"Primary Key Data Loader for ", }; } - public static DataLoader FromNavigation(DbContext dbContext, INavigation nav) + public static DataLoader FromNavigation(Type dbContextClass, INavigation nav) { var fk = nav.ForeignKey; IProperty prop; @@ -99,8 +99,8 @@ public static DataLoader FromNavigation(DbContext dbContext, INavigation nav) KeyType = keyType, ReferenceField = prop.Name, IsShadowProperty = prop.IsShadowProperty(), - EntityType = entityType.ClrType, - DbContextType = dbContext.GetType(), + EntityType = TypeUtils.GetNestedQualifiedName(entityType.ClrType), + DbContextType = dbContextClass, Notes = $"Navigation Data Loader for ", }; } @@ -139,7 +139,7 @@ public string Emit(int version) sb.AppendLine($" /*"); } - sb.AppendLine($" var items = await context.Set<{TypeUtils.CsDisplay(this.EntityType)}>()"); + sb.AppendLine($" var items = await context.Set<{this.EntityType}>()"); sb.AppendLine($" .AsNoTracking()"); if (this.Nullable) @@ -193,7 +193,7 @@ public string Emit(int version) sb.AppendLine($" CancellationToken ct)"); sb.AppendLine(" {"); - sb.AppendLine($" return await context.Set<{TypeUtils.CsDisplay(this.EntityType)}>()"); + sb.AppendLine($" return await context.Set<{this.EntityType}>()"); sb.AppendLine($" .AsNoTracking()"); if (this.Nullable) diff --git a/src/Stackworx.EfCoreGraphQL/FieldExtension.cs b/src/Stackworx.EfCoreGraphQL/FieldExtension.cs index 3e875a4..3356f97 100644 --- a/src/Stackworx.EfCoreGraphQL/FieldExtension.cs +++ b/src/Stackworx.EfCoreGraphQL/FieldExtension.cs @@ -29,7 +29,7 @@ public record FieldExtension public required bool IsShadowProperty { get; init; } - public static FieldExtension FromNavigation(DbContext dbContext, INavigation nav) + public static FieldExtension FromNavigation(Type dbContextClass, INavigation nav) { var declaringType = nav.DeclaringEntityType.ClrType; var targetType = nav.TargetEntityType.ClrType; @@ -85,7 +85,7 @@ public static FieldExtension FromNavigation(DbContext dbContext, INavigation nav ReferenceField = prop.Name, ReferenceFieldNullable = prop.IsNullable, IsShadowProperty = prop.IsShadowProperty(), - DbContextType = dbContext.GetType(), + DbContextType = dbContextClass, LoaderName = loaderName, Collection = nav.IsCollection, Notes = notes, diff --git a/src/Stackworx.EfCoreGraphQL/ForeignKeyIgnoreFields.cs b/src/Stackworx.EfCoreGraphQL/ForeignKeyIgnoreFields.cs new file mode 100644 index 0000000..5481d64 --- /dev/null +++ b/src/Stackworx.EfCoreGraphQL/ForeignKeyIgnoreFields.cs @@ -0,0 +1,41 @@ +namespace Stackworx.EfCoreGraphQL; + +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore.Metadata; + +internal static class ForeignKeyIgnoreFields +{ + /// + /// Returns GraphQL field names (camelCase) for EF Core foreign-key scalar properties that are + /// redundant when navigations are exposed. + /// + public static IReadOnlyList Get(IEntityType entity) + { + // We skip join entities elsewhere, but keep this helper safe for any entity type. + var pk = entity.FindPrimaryKey(); + var pkProps = pk?.Properties?.ToHashSet() ?? []; + + var names = new HashSet(); + + foreach (var fk in entity.GetForeignKeys()) + { + foreach (var prop in fk.Properties) + { + // Don't hide key fields (important for shared-PK 1:1 patterns). + if (pkProps.Contains(prop)) + { + continue; + } + + // If there is no CLR property (shadow property), HotChocolate won't expose it by default + // when using the CLR type as the object type. Still, we keep it here since some schemas + // may bind fields differently. + var clrName = prop.Name; + names.Add(TypeUtils.ToGraphQlFieldName(clrName)); + } + } + + return names.OrderBy(n => n).ToArray(); + } +} diff --git a/src/Stackworx.EfCoreGraphQL/Generate.cs b/src/Stackworx.EfCoreGraphQL/Generate.cs index edd8be0..35c3024 100644 --- a/src/Stackworx.EfCoreGraphQL/Generate.cs +++ b/src/Stackworx.EfCoreGraphQL/Generate.cs @@ -89,7 +89,7 @@ public static async Task Generate( continue; } - Generate(dbContext, entity, sb, version); + Generate(dbContext.GetType(), entity, sb, version); } await File.WriteAllTextAsync(outPath, sb.ToString()); @@ -113,18 +113,139 @@ public static async Task Generate( } } + /// + /// Generates the DataLoader file content as a string based on the provided EF Core model. + /// This is useful for design-time scenarios (e.g. migrations scaffolding) where you want to + /// write the output next to the EF model snapshot. + /// + public static string GenerateString( + IModel model, + Type contextType, + GenerateOptions? options = null) + { + options ??= new GenerateOptions(); + + var version = GetHotchocolateVersion(); + + var mode = options.Mode; + var filter = options.Filter; + var ns = options.Namespace; + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine("#pragma warning disable"); + sb.AppendLine("// Source: EF Core model navigations"); + sb.AppendLine("// Style: HotChocolate [DataLoader] decorator methods"); + sb.AppendLine("// "); + sb.AppendLine(); + sb.AppendLine($"namespace {ns};"); + sb.AppendLine("using System;"); + sb.AppendLine("using System.Linq;"); + sb.AppendLine("using System.Threading;"); + sb.AppendLine("using System.Threading.Tasks;"); + sb.AppendLine("using System.Collections.Generic;"); + sb.AppendLine("using Microsoft.EntityFrameworkCore;"); + sb.AppendLine("using HotChocolate;"); + sb.AppendLine("using GreenDonut;"); + sb.AppendLine("using HotChocolate.Types;"); + sb.AppendLine(); + + foreach (var entity in model.GetEntityTypes() + .Where(e => !e.IsOwned()) + .OrderBy(e => e.Name)) + { + if (entity.ShouldIgnore()) + { + continue; + } + + if (mode == Mode.OptIn && !entity.ShouldInclude()) + { + continue; + } + + // Allow user to manually exclude specific entities + if (filter is not null && filter(entity)) + { + continue; + } + + Generate(contextType, entity, sb, version); + } + + return sb.ToString(); + } + private static int GetHotchocolateVersion() { - try + const int defaultMajor = 16; + + static int? TryGetMajorFromAssemblyName(string assemblyName) + { + try + { + var asm = Assembly.Load(assemblyName); + return asm.GetName().Version?.Major; + } + catch + { + return null; + } + } + + static int? TryGetMajorFromLoadedAssemblies(params string[] simpleNames) + { + try + { + foreach (var name in simpleNames) + { + var match = AppDomain.CurrentDomain + .GetAssemblies() + .FirstOrDefault(a => string.Equals(a.GetName().Name, name, StringComparison.OrdinalIgnoreCase)); + + var major = match?.GetName().Version?.Major; + if (major is not null) + { + return major; + } + } + + return null; + } + catch + { + return null; + } + } + + // Probe a few well-known HotChocolate assemblies. + // We try loaded assemblies first to avoid Load() issues in some hosting scenarios. + var candidates = new[] { - var asm = Assembly.Load("HotChocolate"); - var version = asm.GetName().Version; - return version?.Major ?? 16; + "HotChocolate", + "HotChocolate.Abstractions", + "HotChocolate.Types", + "HotChocolate.Execution", + "HotChocolate.Data" + }; + + var fromLoaded = TryGetMajorFromLoadedAssemblies(candidates); + if (fromLoaded is not null) + { + return fromLoaded.Value; } - catch + + foreach (var candidate in candidates) { - return 16; + var major = TryGetMajorFromAssemblyName(candidate); + if (major is not null) + { + return major.Value; + } } + + return defaultMajor; } private static bool RunCommand(string fileName, string args) @@ -147,7 +268,7 @@ private static bool RunCommand(string fileName, string args) return process.ExitCode == 0; } - private static void Generate(DbContext dbContext, IEntityType entity, StringBuilder sb, int version) + private static void Generate(Type dbContextClass, IEntityType entity, StringBuilder sb, int version) { // Skip join tables var pk = entity.FindPrimaryKey(); @@ -162,13 +283,26 @@ private static void Generate(DbContext dbContext, IEntityType entity, StringBuil } sb.AppendLine($"// {entity.DisplayName()}"); - sb.AppendLine($"[ExtendObjectType<{TypeUtils.GetNestedQualifiedName(entity.ClrType)}>]"); + + var ignoreFields = ForeignKeyIgnoreFields.Get(entity); + var entityTypeName = TypeUtils.GetNestedQualifiedName(entity.ClrType); + + if (ignoreFields.Count > 0) + { + var ignoreFieldsLiteral = string.Join(", ", ignoreFields.Select(f => $"\"{f}\"")); + sb.AppendLine($"[ExtendObjectType<{entityTypeName}>(IgnoreFields = [{ignoreFieldsLiteral}])]"); + } + else + { + sb.AppendLine($"[ExtendObjectType<{entityTypeName}>]"); + } + sb.AppendLine($"public static class {entity.DisplayName()}Extensions"); sb.AppendLine($"{{"); if (entity.FindPrimaryKey()?.Properties.Count == 1) { - var dataLoader = DataLoader.FromEntity(dbContext, entity); + var dataLoader = DataLoader.FromEntity(dbContextClass, entity); sb.Append(dataLoader.EmitComment()); sb.AppendLine(dataLoader.Emit(version)); } @@ -191,12 +325,12 @@ private static void Generate(DbContext dbContext, IEntityType entity, StringBuil // Skip dependant navigations if (!navigation.IsOnDependent) { - var dataLoader = DataLoader.FromNavigation(dbContext, navigation); + var dataLoader = DataLoader.FromNavigation(dbContextClass, navigation); sb.Append(dataLoader.EmitComment()); sb.AppendLine(dataLoader.Emit(version)); } - var field = FieldExtension.FromNavigation(dbContext, navigation); + var field = FieldExtension.FromNavigation(dbContextClass, navigation); sb.Append(field.EmitComment()); sb.AppendLine(field.Emit()); } @@ -248,7 +382,7 @@ private static void Generate(DbContext dbContext, IEntityType entity, StringBuil if (!navigation.IsOnDependent) { // TODO: emit extension - var manyToMany = ManyToMany.FromNavigation(dbContext, navigation); + var manyToMany = ManyToMany.FromNavigation(dbContextClass, navigation); sb.AppendLine(manyToMany.EmitDataLoader()); sb.AppendLine(manyToMany.EmitFieldExtension()); } diff --git a/src/Stackworx.EfCoreGraphQL/ManyToMany.cs b/src/Stackworx.EfCoreGraphQL/ManyToMany.cs index 4729635..8c7caa3 100644 --- a/src/Stackworx.EfCoreGraphQL/ManyToMany.cs +++ b/src/Stackworx.EfCoreGraphQL/ManyToMany.cs @@ -25,8 +25,12 @@ public record ManyToMany public required Type ChildKeyType { get; init; } public required string LoaderName { get; init; } + + public string? LoaderNotes { get; set; } + + public string? FieldNotes { get; set; } - public static ManyToMany FromNavigation(DbContext dbContext, ISkipNavigation nav) + public static ManyToMany FromNavigation(Type dbContextClassType, ISkipNavigation nav) { var inverse = nav.Inverse; @@ -35,11 +39,18 @@ public static ManyToMany FromNavigation(DbContext dbContext, ISkipNavigation nav ArgumentNullException.ThrowIfNull(parentType); TypeUtils.TryUnwrapCollectionType(inverse.ClrType, out var childType); ArgumentNullException.ThrowIfNull(childType); - + + // TODO: handle case where property not defined (shadow) + var fieldNotes = + $"GraphQL Field Override for "; + + var loaderNotes = + $"Skip Navigation Data Loader for "; + return new ManyToMany { LoaderName = LoaderNames.GroupLoaderName(nav), - DbContextType = dbContext.GetType(), + DbContextType = dbContextClassType, ChildPropertyName = nav.Name, ChildKeyName = nav.ForeignKey.PrincipalKey.Properties.Single().Name, ChildKeyType = nav.ForeignKey.PrincipalKey.Properties.Single().ClrType, @@ -48,6 +59,8 @@ public static ManyToMany FromNavigation(DbContext dbContext, ISkipNavigation nav ParentKeyName = inverse.ForeignKey.PrincipalKey.Properties.Single().Name, ParentKeyType = inverse.ForeignKey.PrincipalKey.Properties.Single().ClrType, ParentType = childType, + FieldNotes = fieldNotes, + LoaderNotes = loaderNotes, }; } @@ -58,6 +71,9 @@ public string EmitDataLoader() var childKeyType = TypeUtils.GetNestedQualifiedName(this.ChildKeyType); var childType = TypeUtils.GetNestedQualifiedName(this.ChildType); + sb.AppendLine($" /// "); + sb.AppendLine($" /// {this.LoaderNotes}"); + sb.AppendLine($" /// "); sb.AppendLine($" [DataLoader]"); sb.AppendLine( @@ -89,6 +105,9 @@ public string EmitFieldExtension() // sb.AppendLine($" // {this.Notes}"); + sb.AppendLine($" /// "); + sb.AppendLine($" /// {this.FieldNotes}"); + sb.AppendLine($" /// "); sb.AppendLine( $" public static async Task<{childType}[]> Get{this.ChildPropertyName}Async("); sb.AppendLine($" [Parent] {parentType} parent,"); diff --git a/src/Stackworx.EfCoreGraphQL/Stackworx.EfCoreGraphQL.csproj b/src/Stackworx.EfCoreGraphQL/Stackworx.EfCoreGraphQL.csproj index 7fabd12..112d599 100644 --- a/src/Stackworx.EfCoreGraphQL/Stackworx.EfCoreGraphQL.csproj +++ b/src/Stackworx.EfCoreGraphQL/Stackworx.EfCoreGraphQL.csproj @@ -7,7 +7,7 @@ - 0.0.11-alpha + 0.0.12-alpha diff --git a/src/Stackworx.EfCoreGraphQL/TypeUtils.cs b/src/Stackworx.EfCoreGraphQL/TypeUtils.cs index 706f722..69e2f53 100644 --- a/src/Stackworx.EfCoreGraphQL/TypeUtils.cs +++ b/src/Stackworx.EfCoreGraphQL/TypeUtils.cs @@ -118,4 +118,9 @@ public static bool TryUnwrapCollectionType(Type clrType, out Type elementType) elementType = clrType; return false; } + + public static string ToGraphQlFieldName(string name) + => string.IsNullOrEmpty(name) || char.IsLower(name[0]) + ? name + : char.ToLowerInvariant(name[0]) + name.Substring(1); } \ No newline at end of file diff --git a/tests/Stackworx.EfCoreGraphQL.Tests/Tests.cs b/tests/Stackworx.EfCoreGraphQL.Tests/Tests.cs index eba0ca7..9ff88af 100644 --- a/tests/Stackworx.EfCoreGraphQL.Tests/Tests.cs +++ b/tests/Stackworx.EfCoreGraphQL.Tests/Tests.cs @@ -13,13 +13,13 @@ public async Task TestPrimaryDataLoader() await AppDbContext.WithSqliteInMemoryAsync(db => { var entity = db.GetEntity(); - var config = DataLoader.FromEntity(db, entity); + var config = DataLoader.FromEntity(db.GetType(), entity); config.Should().BeEquivalentTo(new DataLoader { LoaderName = "UserById", Nullable = false, - EntityType = typeof(User), + EntityType = typeof(User).ToString(), Type = DataLoader.DataLoaderType.OneToOne, KeyType = typeof(int), ReferenceField = "Id", @@ -56,13 +56,13 @@ await AppDbContext.WithSqliteInMemoryAsync(db => nav.IsOnDependent.Should().BeFalse(); nav.IsCollection.Should().BeFalse(); - var dataLoaderConfig = DataLoader.FromNavigation(db, nav); + var dataLoaderConfig = DataLoader.FromNavigation(db.GetType(), nav); dataLoaderConfig.Should().BeEquivalentTo(new DataLoader { LoaderName = "UserProfileByUserId", Nullable = false, - EntityType = typeof(UserProfile), + EntityType = typeof(UserProfile).ToString(), Type = DataLoader.DataLoaderType.OneToOne, KeyType = typeof(int), ReferenceField = "UserId", @@ -86,7 +86,7 @@ await AppDbContext.WithSqliteInMemoryAsync(db => } """); - var fieldConfig = FieldExtension.FromNavigation(db, nav); + var fieldConfig = FieldExtension.FromNavigation(db.GetType(), nav); fieldConfig.Should().BeEquivalentTo(new FieldExtension { ReferenceField = "Id", @@ -126,12 +126,12 @@ await AppDbContext.WithSqliteInMemoryAsync(db => nav.IsOnDependent.Should().BeTrue(); nav.IsCollection.Should().BeFalse(); - var config = DataLoader.FromNavigation(db, nav); + var config = DataLoader.FromNavigation(db.GetType(), nav); config.Should().BeEquivalentTo(new DataLoader { LoaderName = "UserById", - EntityType = typeof(User), + EntityType = typeof(User).ToString(), Nullable = false, Type = DataLoader.DataLoaderType.OneToOne, KeyType = typeof(int), @@ -156,7 +156,7 @@ await AppDbContext.WithSqliteInMemoryAsync(db => } """); - var fieldConfig = FieldExtension.FromNavigation(db, nav); + var fieldConfig = FieldExtension.FromNavigation(db.GetType(), nav); fieldConfig.Should().BeEquivalentTo(new FieldExtension { ReferenceField = "UserId", @@ -196,10 +196,10 @@ await AppDbContext.WithSqliteInMemoryAsync(db => nav.IsOnDependent.Should().BeFalse(); nav.IsCollection.Should().BeFalse(); - DataLoader.FromNavigation(db, nav).Should().BeEquivalentTo(new DataLoader + DataLoader.FromNavigation(db.GetType(), nav).Should().BeEquivalentTo(new DataLoader { LoaderName = "PassportByPersonId", - EntityType = typeof(Passport), + EntityType = typeof(Passport).ToString(), Nullable = true, Type = DataLoader.DataLoaderType.OneToOne, KeyType = typeof(int), @@ -209,7 +209,7 @@ await AppDbContext.WithSqliteInMemoryAsync(db => Notes = "Navigation Data Loader for ", }); - var fieldConfig = FieldExtension.FromNavigation(db, nav); + var fieldConfig = FieldExtension.FromNavigation(db.GetType(), nav); fieldConfig.Should().BeEquivalentTo(new FieldExtension { ReferenceField = "Id", @@ -249,11 +249,11 @@ await AppDbContext.WithSqliteInMemoryAsync(db => nav.IsOnDependent.Should().BeFalse(); nav.IsCollection.Should().BeTrue(); - var config = DataLoader.FromNavigation(db, nav); + var config = DataLoader.FromNavigation(db.GetType(), nav); config.Should().BeEquivalentTo(new DataLoader { LoaderName = "CommentsByPostId", - EntityType = typeof(Comment), + EntityType = typeof(Comment).ToString(), Nullable = true, Type = DataLoader.DataLoaderType.OneToMany, KeyType = typeof(int), @@ -280,7 +280,7 @@ await AppDbContext.WithSqliteInMemoryAsync(db => } """); - var fieldConfig = FieldExtension.FromNavigation(db, nav); + var fieldConfig = FieldExtension.FromNavigation(db.GetType(), nav); fieldConfig.Should().BeEquivalentTo(new FieldExtension { ReferenceField = "Id", @@ -320,8 +320,8 @@ await AppDbContext.WithSqliteInMemoryAsync(db => nav.IsOnDependent.Should().BeFalse(); nav.IsCollection.Should().BeTrue(); - ManyToMany.FromNavigation(db, nav); - var manyToMany = ManyToMany.FromNavigation(db, nav); + ManyToMany.FromNavigation(db.GetType(), nav); + var manyToMany = ManyToMany.FromNavigation(db.GetType(), nav); manyToMany.Should().BeEquivalentTo(new ManyToMany { LoaderName = "TagsByPosts", @@ -334,10 +334,15 @@ await AppDbContext.WithSqliteInMemoryAsync(db => ParentKeyType = typeof(int), ParentType = typeof(Post), DbContextType = typeof(AppDbContext), + FieldNotes = "GraphQL Field Override for ", + LoaderNotes = "Skip Navigation Data Loader for ", }); manyToMany.EmitDataLoader().Should().MatchSource( """ + /// + /// Skip Navigation Data Loader for + /// [DataLoader] public static async Task> TagsByPosts( IReadOnlyList keys, @@ -356,6 +361,9 @@ await AppDbContext.WithSqliteInMemoryAsync(db => manyToMany.EmitFieldExtension().Should().MatchSource( """ + /// + /// GraphQL Field Override for + /// public static async Task GetTagsAsync( [Parent] Stackworx.EfCoreGraphQL.Tests.Data.Post parent, ITagsByPostsDataLoader loader, @@ -378,8 +386,8 @@ await AppDbContext.WithSqliteInMemoryAsync(db => nav.IsOnDependent.Should().BeFalse(); nav.IsCollection.Should().BeTrue(); - ManyToMany.FromNavigation(db, nav); - var manyToMany = ManyToMany.FromNavigation(db, nav); + ManyToMany.FromNavigation(db.GetType(), nav); + var manyToMany = ManyToMany.FromNavigation(db.GetType(), nav); manyToMany.Should().BeEquivalentTo(new ManyToMany { LoaderName = "PostsByTags", @@ -392,10 +400,15 @@ await AppDbContext.WithSqliteInMemoryAsync(db => ParentKeyType = typeof(int), ParentType = typeof(Tag), DbContextType = typeof(AppDbContext), + FieldNotes = "GraphQL Field Override for ", + LoaderNotes = "Skip Navigation Data Loader for ", }); manyToMany.EmitDataLoader().Should().MatchSource( """ + /// + /// Skip Navigation Data Loader for + /// [DataLoader] public static async Task> PostsByTags( IReadOnlyList keys, @@ -414,6 +427,9 @@ await AppDbContext.WithSqliteInMemoryAsync(db => manyToMany.EmitFieldExtension().Should().MatchSource( """ + /// + /// GraphQL Field Override for + /// public static async Task GetPostsAsync( [Parent] Stackworx.EfCoreGraphQL.Tests.Data.Tag parent, IPostsByTagsDataLoader loader, @@ -433,12 +449,12 @@ public async Task TestOneToMany_CompositePrimaryKey() await AppDbContext.WithSqliteInMemoryAsync(db => { var nav = db.GetNavigation(nameof(Order.Items)); - var config = DataLoader.FromNavigation(db, nav); + var config = DataLoader.FromNavigation(db.GetType(), nav); config.Should().BeEquivalentTo(new DataLoader { LoaderName = "GetPostByComments", - EntityType = typeof(OrderItem), + EntityType = typeof(OrderItem).ToString(), Nullable = false, Type = DataLoader.DataLoaderType.OneToMany, KeyType = typeof(int),