diff --git a/src/Stackworx.EfCoreGraphQL/DataLoader.cs b/src/Stackworx.EfCoreGraphQL/DataLoader.cs index b0a128d..44d0125 100644 --- a/src/Stackworx.EfCoreGraphQL/DataLoader.cs +++ b/src/Stackworx.EfCoreGraphQL/DataLoader.cs @@ -20,6 +20,12 @@ public record DataLoader public bool Nullable { get; init; } + /// + /// True when the key property is a nullable value type (int?), so its value is reached through + /// .Value. A nullable reference type (string?) is dereferenced directly. + /// + public bool KeyIsNullableValueType { get; init; } + public DataLoaderType Type { get; init; } public string? Notes { get; set; } @@ -49,6 +55,7 @@ public static DataLoader FromEntity(Type dbContextClass, IEntityType entityType) { LoaderName = LoaderNames.BatchLoaderName(entityType, pkProp), Nullable = false, + KeyIsNullableValueType = TypeUtils.TryUnwrapNullable(pkProp.ClrType, out _), Type = DataLoader.DataLoaderType.OneToOne, KeyType = keyType, ReferenceField = keyPropName, @@ -75,7 +82,8 @@ public static DataLoader FromNavigation(Type dbContextClass, INavigation nav) } var keyType = prop.ClrType; - if (TypeUtils.TryUnwrapNullable(keyType, out var inner)) + var keyIsNullableValueType = TypeUtils.TryUnwrapNullable(keyType, out var inner); + if (keyIsNullableValueType) { keyType = inner; } @@ -95,6 +103,7 @@ public static DataLoader FromNavigation(Type dbContextClass, INavigation nav) ? LoaderNames.GroupLoaderName(nav.TargetEntityType, prop) : LoaderNames.BatchLoaderName(nav.TargetEntityType, prop), Nullable = nullable, + KeyIsNullableValueType = keyIsNullableValueType, Type = type, KeyType = keyType, ReferenceField = prop.Name, @@ -114,10 +123,30 @@ public string EmitComment() return sb.ToString(); } + /// + /// How the key is read off an entity. A nullable value type needs .Value; a nullable reference + /// type only needs the null-forgiving operator, since it has no .Value member. + /// + private string KeyAccess + { + get + { + if (!this.Nullable) + { + return $"e.{this.ReferenceField}"; + } + + return this.KeyIsNullableValueType + ? $"e.{this.ReferenceField}!.Value" + : $"e.{this.ReferenceField}!"; + } + } + public string Emit(int version) { var sb = new StringBuilder(); var keyType = TypeUtils.GetNestedQualifiedName(this.KeyType); + var keyAccess = this.KeyAccess; sb.AppendLine($" [DataLoader]"); @@ -142,26 +171,10 @@ public string Emit(int version) sb.AppendLine($" var items = await context.Set<{this.EntityType}>()"); sb.AppendLine($" .AsNoTracking()"); - if (this.Nullable) - { - sb.AppendLine($" .Where(e => keys.Contains(e.{this.ReferenceField}!.Value))"); - } - else - { - sb.AppendLine($" .Where(e => keys.Contains(e.{this.ReferenceField}))"); - } - + sb.AppendLine($" .Where(e => keys.Contains({keyAccess}))"); sb.AppendLine($" .ToListAsync(ct);"); sb.AppendLine(); - - if (this.Nullable) - { - sb.AppendLine($" return items.ToLookup(e => e.{this.ReferenceField}!.Value);"); - } - else - { - sb.AppendLine($" return items.ToLookup(e => e.{this.ReferenceField});"); - } + sb.AppendLine($" return items.ToLookup(e => {keyAccess});"); if (this.IsShadowProperty) { @@ -196,16 +209,8 @@ public string Emit(int version) sb.AppendLine($" return await context.Set<{this.EntityType}>()"); sb.AppendLine($" .AsNoTracking()"); - if (this.Nullable) - { - sb.AppendLine($" .Where(e => keys.Contains(e.{this.ReferenceField}!.Value))"); - sb.AppendLine($" .ToDictionaryAsync(e => e.{this.ReferenceField}!.Value, ct);"); - } - else - { - sb.AppendLine($" .Where(e => keys.Contains(e.{this.ReferenceField}))"); - sb.AppendLine($" .ToDictionaryAsync(e => e.{this.ReferenceField}, ct);"); - } + sb.AppendLine($" .Where(e => keys.Contains({keyAccess}))"); + sb.AppendLine($" .ToDictionaryAsync(e => {keyAccess}, ct);"); sb.AppendLine(" }"); break; diff --git a/src/Stackworx.EfCoreGraphQL/FieldExtension.cs b/src/Stackworx.EfCoreGraphQL/FieldExtension.cs index 3356f97..760f23e 100644 --- a/src/Stackworx.EfCoreGraphQL/FieldExtension.cs +++ b/src/Stackworx.EfCoreGraphQL/FieldExtension.cs @@ -18,6 +18,12 @@ public record FieldExtension public required bool ReferenceFieldNullable { get; init; } + /// + /// True when the reference field is a nullable value type (int?), so its value is reached + /// through .Value. A nullable reference type (string?) is passed directly. + /// + public bool ReferenceFieldIsNullableValueType { get; init; } + public required Type DbContextType { get; init; } // TODO: Data Loader? @@ -84,6 +90,7 @@ public static FieldExtension FromNavigation(Type dbContextClass, INavigation nav NavigationName = nav.Name, ReferenceField = prop.Name, ReferenceFieldNullable = prop.IsNullable, + ReferenceFieldIsNullableValueType = TypeUtils.TryUnwrapNullable(prop.ClrType, out _), IsShadowProperty = prop.IsShadowProperty(), DbContextType = dbContextClass, LoaderName = loaderName, @@ -140,10 +147,16 @@ public string Emit() if (this.ReferenceFieldNullable) { + // A nullable value type is unwrapped with .Value; a nullable reference type has no such + // member and is passed straight through once the null check has narrowed it. + var keyAccess = this.ReferenceFieldIsNullableValueType + ? $"parent.{this.ReferenceField}.Value" + : $"parent.{this.ReferenceField}"; + sb.AppendLine($" if (parent.{this.ReferenceField} is not null)"); sb.AppendLine($" {{"); // TODO: this wont always be appropriate - sb.AppendLine($" return await loader.LoadAsync(parent.{this.ReferenceField}.Value, ct);"); + sb.AppendLine($" return await loader.LoadAsync({keyAccess}, ct);"); sb.AppendLine($" }}"); sb.AppendLine(); sb.AppendLine($" return null;"); diff --git a/src/Stackworx.EfCoreGraphQL/Generate.cs b/src/Stackworx.EfCoreGraphQL/Generate.cs index 35c3024..9d1b4de 100644 --- a/src/Stackworx.EfCoreGraphQL/Generate.cs +++ b/src/Stackworx.EfCoreGraphQL/Generate.cs @@ -40,59 +40,9 @@ public static async Task Generate( { options ??= new GenerateOptions(); - var version = GetHotchocolateVersion(); - - var mode = options.Mode; - var filter = options.Filter; - var ns = options.Namespace; var ci = options.CI; - var model = dbContext.Model; - - 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(dbContext.GetType(), entity, sb, version); - } - - await File.WriteAllTextAsync(outPath, sb.ToString()); + await File.WriteAllTextAsync(outPath, BuildSource(dbContext.Model, dbContext.GetType(), options)); if (ci) { @@ -122,9 +72,13 @@ public static string GenerateString( IModel model, Type contextType, GenerateOptions? options = null) - { - options ??= new GenerateOptions(); + => BuildSource(model, contextType, options ?? new GenerateOptions()); + private static string BuildSource( + IModel model, + Type contextType, + GenerateOptions options) + { var version = GetHotchocolateVersion(); var mode = options.Mode; @@ -151,6 +105,11 @@ public static string GenerateString( sb.AppendLine("using HotChocolate.Types;"); sb.AppendLine(); + // HotChocolate emits one DataLoader class per [DataLoader] method name, and every method we + // generate lands in the same namespace. The same loader can be reachable from two entities, so + // names are tracked across the whole run to keep each one emitted exactly once. + var emittedLoaders = new Dictionary(StringComparer.Ordinal); + foreach (var entity in model.GetEntityTypes() .Where(e => !e.IsOwned()) .OrderBy(e => e.Name)) @@ -171,12 +130,47 @@ public static string GenerateString( continue; } - Generate(contextType, entity, sb, version); + Generate(contextType, entity, sb, version, emittedLoaders, options.IgnoreForeignKeyFields); } return sb.ToString(); } + /// + /// Emits a data loader unless one with the same name has already been emitted. + /// + /// + /// A loader name is reachable from more than one entity when a dependent's primary key is also its + /// foreign key to the principal: the principal's navigation loader and the dependent's primary-key + /// loader are keyed on the same property, so both resolve to the same name and the same query. + /// Field extensions reference the loader interface by name, so skipping the repeat is safe. + /// + private static void AppendLoader( + StringBuilder sb, + Dictionary emittedLoaders, + string loaderName, + string comment, + string code) + { + if (emittedLoaders.TryGetValue(loaderName, out var existing)) + { + if (string.Equals(existing, code, StringComparison.Ordinal)) + { + return; + } + + // Two different queries want the same name. Emitting both cannot compile, and silently + // dropping one would leave field extensions bound to the wrong loader. + throw new InvalidOperationException( + $"Two different data loaders were generated with the name '{loaderName}'. " + + $"Exclude one of the navigations with [{nameof(EFCoreGraphQLIgnoreAttribute)}] or rename it."); + } + + emittedLoaders[loaderName] = code; + sb.Append(comment); + sb.AppendLine(code); + } + private static int GetHotchocolateVersion() { const int defaultMajor = 16; @@ -268,7 +262,13 @@ private static bool RunCommand(string fileName, string args) return process.ExitCode == 0; } - private static void Generate(Type dbContextClass, IEntityType entity, StringBuilder sb, int version) + private static void Generate( + Type dbContextClass, + IEntityType entity, + StringBuilder sb, + int version, + Dictionary emittedLoaders, + bool ignoreForeignKeyFields) { // Skip join tables var pk = entity.FindPrimaryKey(); @@ -284,7 +284,9 @@ private static void Generate(Type dbContextClass, IEntityType entity, StringBuil sb.AppendLine($"// {entity.DisplayName()}"); - var ignoreFields = ForeignKeyIgnoreFields.Get(entity); + var ignoreFields = ignoreForeignKeyFields + ? ForeignKeyIgnoreFields.Get(entity) + : []; var entityTypeName = TypeUtils.GetNestedQualifiedName(entity.ClrType); if (ignoreFields.Count > 0) @@ -303,8 +305,7 @@ private static void Generate(Type dbContextClass, IEntityType entity, StringBuil if (entity.FindPrimaryKey()?.Properties.Count == 1) { var dataLoader = DataLoader.FromEntity(dbContextClass, entity); - sb.Append(dataLoader.EmitComment()); - sb.AppendLine(dataLoader.Emit(version)); + AppendLoader(sb, emittedLoaders, dataLoader.LoaderName, dataLoader.EmitComment(), dataLoader.Emit(version)); } foreach (var navigation in entity.GetNavigations() @@ -323,14 +324,26 @@ private static void Generate(Type dbContextClass, IEntityType entity, StringBuil } // Skip dependant navigations - if (!navigation.IsOnDependent) + var dataLoader = navigation.IsOnDependent + ? null + : DataLoader.FromNavigation(dbContextClass, navigation); + + var field = FieldExtension.FromNavigation(dbContextClass, navigation); + + // A shadow foreign key has no CLR property to read, so neither the loader nor the field + // override can be written against it. Skip the navigation and leave HotChocolate's default + // resolution in place: emitting an override that throws turns any query for the field into + // an error, which is worse than not overriding it at all. + if (field.IsShadowProperty || dataLoader?.IsShadowProperty == true) { - var dataLoader = DataLoader.FromNavigation(dbContextClass, navigation); - sb.Append(dataLoader.EmitComment()); - sb.AppendLine(dataLoader.Emit(version)); + continue; + } + + if (dataLoader is not null) + { + AppendLoader(sb, emittedLoaders, dataLoader.LoaderName, dataLoader.EmitComment(), dataLoader.Emit(version)); } - var field = FieldExtension.FromNavigation(dbContextClass, navigation); sb.Append(field.EmitComment()); sb.AppendLine(field.Emit()); } @@ -383,7 +396,7 @@ private static void Generate(Type dbContextClass, IEntityType entity, StringBuil { // TODO: emit extension var manyToMany = ManyToMany.FromNavigation(dbContextClass, navigation); - sb.AppendLine(manyToMany.EmitDataLoader()); + AppendLoader(sb, emittedLoaders, manyToMany.LoaderName, string.Empty, manyToMany.EmitDataLoader()); sb.AppendLine(manyToMany.EmitFieldExtension()); } } diff --git a/src/Stackworx.EfCoreGraphQL/GenerateOptions.cs b/src/Stackworx.EfCoreGraphQL/GenerateOptions.cs index 329efd6..1289bb1 100644 --- a/src/Stackworx.EfCoreGraphQL/GenerateOptions.cs +++ b/src/Stackworx.EfCoreGraphQL/GenerateOptions.cs @@ -20,4 +20,14 @@ public class GenerateOptions /// When enabled, generation will fail the process if generated output differs from git HEAD. /// public bool CI { get; set; } + + /// + /// When enabled, foreign-key scalar fields are hidden from the schema via + /// ExtendObjectType(IgnoreFields = ...), on the basis that the navigation replaces them. + /// + /// + /// Disable this for an existing schema: hiding the scalars removes fields that clients may already + /// select (e.g. a foreign key that doubles as a business identifier), which breaks their queries. + /// + public bool IgnoreForeignKeyFields { get; set; } = true; } \ No newline at end of file diff --git a/tests/Stackworx.EfCoreGraphQL.Tests/Data/Account.cs b/tests/Stackworx.EfCoreGraphQL.Tests/Data/Account.cs new file mode 100644 index 0000000..9579f06 --- /dev/null +++ b/tests/Stackworx.EfCoreGraphQL.Tests/Data/Account.cs @@ -0,0 +1,11 @@ +namespace Stackworx.EfCoreGraphQL.Tests.Data; + +public class Account +{ + public int Id { get; set; } + + public required string Name { get; set; } + + // optional navigation to the dependent that shares this entity's primary key + public AccountBalance? Balance { get; set; } +} diff --git a/tests/Stackworx.EfCoreGraphQL.Tests/Data/AccountBalance.cs b/tests/Stackworx.EfCoreGraphQL.Tests/Data/AccountBalance.cs new file mode 100644 index 0000000..3a2e330 --- /dev/null +++ b/tests/Stackworx.EfCoreGraphQL.Tests/Data/AccountBalance.cs @@ -0,0 +1,11 @@ +namespace Stackworx.EfCoreGraphQL.Tests.Data; + +public class AccountBalance +{ + // shared primary key: this is both the PK and the FK to Account + public int AccountId { get; set; } + + public decimal Amount { get; set; } + + public Account Account { get; set; } = null!; +} diff --git a/tests/Stackworx.EfCoreGraphQL.Tests/Data/AppDbContext.cs b/tests/Stackworx.EfCoreGraphQL.Tests/Data/AppDbContext.cs index 84d9c21..32e39d3 100644 --- a/tests/Stackworx.EfCoreGraphQL.Tests/Data/AppDbContext.cs +++ b/tests/Stackworx.EfCoreGraphQL.Tests/Data/AppDbContext.cs @@ -34,6 +34,16 @@ public class AppDbContext : DbContext public DbSet Enrollments => this.Set(); + public DbSet Attachments => this.Set(); + + public DbSet Tenants => this.Set(); + + public DbSet Sites => this.Set(); + + public DbSet Accounts => this.Set(); + + public DbSet AccountBalances => this.Set(); + public AppDbContext(DbContextOptions options) : base(options) { } @@ -154,6 +164,44 @@ protected override void OnModelCreating(ModelBuilder model) b.HasIndex(x => x.PersonId).IsUnique(); }); + // Optional FK that is a nullable reference type (string?) rather than a Nullable + model.Entity(b => + { + b.HasKey(x => x.Id); + b.Property(x => x.Name).IsRequired(); + b.HasMany(x => x.Sites) + .WithOne(x => x.Tenant) + .HasForeignKey(x => x.TenantId) + .IsRequired(false) + .OnDelete(DeleteBehavior.SetNull); + }); + + model.Entity(b => + { + b.HasKey(x => x.Id); + b.Property(x => x.Name).IsRequired(); + b.HasIndex(x => x.TenantId); + }); + + // 1 : 1 sharing a primary key: AccountBalance.AccountId is both its PK and its FK to Account, + // so the primary-key loader and Account.Balance's navigation loader resolve to the same name. + model.Entity(b => + { + b.HasKey(x => x.Id); + b.Property(x => x.Name).IsRequired(); + b.HasOne(x => x.Balance) + .WithOne(x => x.Account) + .HasForeignKey(x => x.AccountId) + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + }); + + model.Entity(b => + { + b.HasKey(x => x.AccountId); + b.Property(x => x.Amount).IsRequired(); + }); + // Many : Many (skip navigations) model.Entity(b => { @@ -169,12 +217,11 @@ protected override void OnModelCreating(ModelBuilder model) b.Property(x => x.Name).IsRequired(); }); - // Shadow FK variation: Comment → Post without a PostId property + // Comment → Post via the declared Comment.PostId property model.Entity(b => { b.HasKey(x => x.Id); b.Property(x => x.Text).IsRequired(); - b.Property("PostId"); // shadow FK b.HasOne(x => x.Post) .WithMany(x => x.Comments) .HasForeignKey("PostId") @@ -183,6 +230,21 @@ protected override void OnModelCreating(ModelBuilder model) b.HasIndex("PostId"); }); + // Genuine shadow FK: Attachment → Comment with no CommentId member on Attachment. WithMany() is + // left navigation-less so Comment keeps the navigations the other tests assert against. + model.Entity(b => + { + b.HasKey(x => x.Id); + b.Property(x => x.FileName).IsRequired(); + b.Property("CommentId"); // shadow FK + b.HasOne(x => x.Comment) + .WithMany() + .HasForeignKey("CommentId") + .IsRequired(false) + .OnDelete(DeleteBehavior.Cascade); + b.HasIndex("CommentId"); + }); + // Many : Many with payload (explicit join entity) model.Entity(b => { diff --git a/tests/Stackworx.EfCoreGraphQL.Tests/Data/Attachment.cs b/tests/Stackworx.EfCoreGraphQL.Tests/Data/Attachment.cs new file mode 100644 index 0000000..129a664 --- /dev/null +++ b/tests/Stackworx.EfCoreGraphQL.Tests/Data/Attachment.cs @@ -0,0 +1,11 @@ +namespace Stackworx.EfCoreGraphQL.Tests.Data; + +public class Attachment +{ + public int Id { get; set; } + + public required string FileName { get; set; } + + // The foreign key really is a shadow property: there is no CommentId member on this type. + public Comment? Comment { get; set; } +} diff --git a/tests/Stackworx.EfCoreGraphQL.Tests/Data/Comment.cs b/tests/Stackworx.EfCoreGraphQL.Tests/Data/Comment.cs index 38b7929..c8277ff 100644 --- a/tests/Stackworx.EfCoreGraphQL.Tests/Data/Comment.cs +++ b/tests/Stackworx.EfCoreGraphQL.Tests/Data/Comment.cs @@ -6,5 +6,5 @@ public class Comment public required string Text { get; set; } public int? PostId { get; set; } - public Post? Post { get; set; } // FK is shadow: int? PostId + public Post? Post { get; set; } } \ No newline at end of file diff --git a/tests/Stackworx.EfCoreGraphQL.Tests/Data/Site.cs b/tests/Stackworx.EfCoreGraphQL.Tests/Data/Site.cs new file mode 100644 index 0000000..c2789a9 --- /dev/null +++ b/tests/Stackworx.EfCoreGraphQL.Tests/Data/Site.cs @@ -0,0 +1,13 @@ +namespace Stackworx.EfCoreGraphQL.Tests.Data; + +public class Site +{ + public int Id { get; set; } + + public required string Name { get; set; } + + // optional FK that is a nullable reference type, not a nullable value type + public string? TenantId { get; set; } + + public Tenant? Tenant { get; set; } +} diff --git a/tests/Stackworx.EfCoreGraphQL.Tests/Data/Tenant.cs b/tests/Stackworx.EfCoreGraphQL.Tests/Data/Tenant.cs new file mode 100644 index 0000000..edf9f8a --- /dev/null +++ b/tests/Stackworx.EfCoreGraphQL.Tests/Data/Tenant.cs @@ -0,0 +1,11 @@ +namespace Stackworx.EfCoreGraphQL.Tests.Data; + +public class Tenant +{ + // string key, so dependents hold a nullable reference type rather than a Nullable + public required string Id { get; set; } + + public required string Name { get; set; } + + public ICollection Sites { get; set; } = []; +} diff --git a/tests/Stackworx.EfCoreGraphQL.Tests/Tests.cs b/tests/Stackworx.EfCoreGraphQL.Tests/Tests.cs index 9ff88af..d07c94c 100644 --- a/tests/Stackworx.EfCoreGraphQL.Tests/Tests.cs +++ b/tests/Stackworx.EfCoreGraphQL.Tests/Tests.cs @@ -1,5 +1,6 @@ namespace Stackworx.EfCoreGraphQL.Tests; +using System.Text.RegularExpressions; using FluentAssertions; using Stackworx.EfCoreGraphQL.Tests.Data; @@ -201,6 +202,7 @@ await AppDbContext.WithSqliteInMemoryAsync(db => LoaderName = "PassportByPersonId", EntityType = typeof(Passport).ToString(), Nullable = true, + KeyIsNullableValueType = true, Type = DataLoader.DataLoaderType.OneToOne, KeyType = typeof(int), ReferenceField = "PersonId", @@ -255,6 +257,7 @@ await AppDbContext.WithSqliteInMemoryAsync(db => LoaderName = "CommentsByPostId", EntityType = typeof(Comment).ToString(), Nullable = true, + KeyIsNullableValueType = true, Type = DataLoader.DataLoaderType.OneToMany, KeyType = typeof(int), ReferenceField = "PostId", @@ -466,4 +469,149 @@ await AppDbContext.WithSqliteInMemoryAsync(db => return Task.CompletedTask; }); } + + [Fact] + public async Task TestSharedPrimaryKeyOneToOneEmitsEachLoaderOnce() + { + await AppDbContext.WithSqliteInMemoryAsync(db => + { + var source = DataLoaderGenerator.GenerateString(db.Model, typeof(AppDbContext)); + + // AccountBalance.AccountId is both its primary key and its foreign key to Account, so + // AccountBalance's primary-key loader and Account.Balance's navigation loader resolve to the + // same name. HotChocolate emits one class per [DataLoader] method name, so a second copy of + // the method makes the generated file uncompilable. + Regex.Matches(source, @"> AccountBalanceByAccountId\(").Should().HaveCount(1); + + // The field extension still needs the interface to exist. + source.Should().Contain("IAccountBalanceByAccountIdDataLoader"); + + return Task.CompletedTask; + }); + } + + [Fact] + public async Task TestEveryLoaderNameIsEmittedOnce() + { + await AppDbContext.WithSqliteInMemoryAsync(db => + { + var source = DataLoaderGenerator.GenerateString(db.Model, typeof(AppDbContext)); + + var loaderNames = Regex.Matches(source, @"public static async Task<[^>]*(?:>|>>) (\w+)\(") + .Select(m => m.Groups[1].Value) + .Where(name => !name.StartsWith("Get", StringComparison.Ordinal)) + .ToList(); + + loaderNames.Should().OnlyHaveUniqueItems(); + + return Task.CompletedTask; + }); + } + + [Fact] + public async Task TestNullableReferenceTypeForeignKeyDoesNotUseValue() + { + await AppDbContext.WithSqliteInMemoryAsync(db => + { + var nav = db.GetNavigation(nameof(Tenant.Sites)); + var config = DataLoader.FromNavigation(db.GetType(), nav); + + config.Nullable.Should().BeTrue(); + + // Site.TenantId is string?, which is a nullable reference type. Only Nullable has .Value, + // so emitting it here would not compile. + config.KeyIsNullableValueType.Should().BeFalse(); + + config.Emit(Version).Should().MatchSource( + """ + [DataLoader] + public static async Task> SitesByTenantId( + IReadOnlyList keys, + Stackworx.EfCoreGraphQL.Tests.Data.AppDbContext context, + CancellationToken ct) + { + var items = await context.Set() + .AsNoTracking() + .Where(e => keys.Contains(e.TenantId!)) + .ToListAsync(ct); + + return items.ToLookup(e => e.TenantId!); + } + """); + + return Task.CompletedTask; + }); + } + + [Fact] + public async Task TestNullableReferenceTypeForeignKeyFieldDoesNotUseValue() + { + await AppDbContext.WithSqliteInMemoryAsync(db => + { + var nav = db.GetNavigation(nameof(Site.Tenant)); + var field = FieldExtension.FromNavigation(db.GetType(), nav); + + field.ReferenceFieldNullable.Should().BeTrue(); + field.ReferenceFieldIsNullableValueType.Should().BeFalse(); + + field.Emit().Should().MatchSource( + """ + public static async Task GetTenantAsync( + [Parent] Stackworx.EfCoreGraphQL.Tests.Data.Site parent, + ITenantByIdDataLoader loader, + CancellationToken ct) + { + if (parent.TenantId is not null) + { + return await loader.LoadAsync(parent.TenantId, ct); + } + + return null; + } + """); + + return Task.CompletedTask; + }); + } + + [Fact] + public async Task TestForeignKeyFieldsCanBeKept() + { + await AppDbContext.WithSqliteInMemoryAsync(db => + { + var hidden = DataLoaderGenerator.GenerateString(db.Model, typeof(AppDbContext)); + hidden.Should().Contain("IgnoreFields = [\"authorId\"]"); + + // Hiding foreign-key scalars removes fields an existing client may already select, so it has + // to be possible to keep them. + var kept = DataLoaderGenerator.GenerateString( + db.Model, + typeof(AppDbContext), + new GenerateOptions { IgnoreForeignKeyFields = false }); + + kept.Should().NotContain("IgnoreFields"); + + return Task.CompletedTask; + }); + } + + [Fact] + public async Task TestShadowForeignKeyNavigationIsSkipped() + { + await AppDbContext.WithSqliteInMemoryAsync(db => + { + var source = DataLoaderGenerator.GenerateString(db.Model, typeof(AppDbContext)); + + // Attachment.Comment is backed by a shadow FK, so no resolver can read the key off the CLR + // type. The navigation is skipped rather than overridden with a resolver that throws when + // the field is queried. + source.Should().NotContain("is a Shadow Property"); + source.Should().NotContain("GetCommentAsync"); + + // Navigations with a real FK property are still generated. + source.Should().Contain("GetPostAsync"); + + return Task.CompletedTask; + }); + } } \ No newline at end of file