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