Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[
{
Id: 1,
Name: rex
},
{
Name: rex
}
]
50 changes: 50 additions & 0 deletions src/Verify.EntityFramework.Tests/AllDataTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,56 @@ public async Task CompositeKey()
await Verify(data.AllData());
}

// AsNoTracking on a keyless entity type threw under ThrowOnAntiPatterns
[Test]
public async Task Keyless()
{
var builder = new DbContextOptionsBuilder<KeylessDbContext>();
builder.UseInMemoryDatabase(nameof(AllDataTests) + nameof(Keyless));
builder.ThrowOnAntiPatterns();
await using var data = new KeylessDbContext(builder.Options);
data.Add(new Animal { Id = 1, Name = "rex" });
await data.SaveChangesAsync();

await Verify(data.AllData());
}

// EF builds the query for Reload and GetDatabaseValues itself, with IgnoreQueryFilters, which threw under ThrowOnAntiPatterns
[Test]
public async Task Reload()
{
var builder = new DbContextOptionsBuilder<KeylessDbContext>();
builder.UseInMemoryDatabase(nameof(AllDataTests) + nameof(Reload));
builder.ThrowOnAntiPatterns();
await using var data = new KeylessDbContext(builder.Options);
var animal = new Animal { Id = 1, Name = "rex" };
data.Add(animal);
await data.SaveChangesAsync();

var entry = data.Entry(animal);
entry.Reload();
await entry.ReloadAsync();
await Assert.That(await entry.GetDatabaseValuesAsync()).IsNotNull();
}

public class KeylessDbContext(DbContextOptions options) :
DbContext(options)
{
public DbSet<Animal> Animals { get; set; } = null!;
public DbSet<AnimalName> AnimalNames { get; set; } = null!;

protected override void OnModelCreating(ModelBuilder model) =>
model
.Entity<AnimalName>()
.HasNoKey()
.ToInMemoryQuery(() => Animals.Select(_ => new AnimalName { Name = _.Name }));
}

public class AnimalName
{
public required string Name { get; set; }
}

static AllDataDbContext BuildData([CallerMemberName] string databaseName = "")
{
var builder = new DbContextOptionsBuilder<AllDataDbContext>();
Expand Down
5 changes: 5 additions & 0 deletions src/Verify.EntityFramework/AntiPatternInterceptor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ class AntiPatternInterceptor :

public Expression QueryCompilationStarting(Expression query, QueryExpressionEventData data)
{
if (InternalQuery.Is(query))
{
return query;
}

DiscardedOrderByDetector.ThrowIfDiscarded(query);
ConstantOrderingDetector.ThrowIfConstant(query);
RedundantNullCheckDetector.ThrowIfRedundant(query);
Expand Down
42 changes: 42 additions & 0 deletions src/Verify.EntityFramework/InternalQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// A query that EF builds itself, which the code under test can not change, so is not checked for anti-patterns.
// EntityEntry.Reload and GetDatabaseValues query the row of an entry by its key, as
// root.AsNoTracking().IgnoreQueryFilters().Where(key).Select(_ => object[]), then FirstOrDefault.
static class InternalQuery
{
public static bool Is(Expression query)
{
var expression = query;
while (expression is MethodCallExpression { Method.IsStatic: true, Arguments.Count: > 0 } call)
{
if (IsDatabaseValues(call))
{
return true;
}

expression = call.Arguments[0];
}

return false;
}

static bool IsDatabaseValues(MethodCallExpression select)
{
if (!Is(select, typeof(Queryable), nameof(Queryable.Select)) ||
select.Type != typeof(IQueryable<object[]>) ||
select.Arguments[0] is not MethodCallExpression where ||
!Is(where, typeof(Queryable), nameof(Queryable.Where)) ||
where.Arguments[0] is not MethodCallExpression ignore ||
!Is(ignore, typeof(EntityFrameworkQueryableExtensions), nameof(EntityFrameworkQueryableExtensions.IgnoreQueryFilters)) ||
ignore.Arguments[0] is not MethodCallExpression noTracking ||
!Is(noTracking, typeof(EntityFrameworkQueryableExtensions), nameof(EntityFrameworkQueryableExtensions.AsNoTracking)))
{
return false;
}

return noTracking.Arguments[0] is EntityQueryRootExpression;
}

static bool Is(MethodCallExpression call, Type type, string name) =>
call.Method.DeclaringType == type &&
call.Method.Name == name;
}
4 changes: 2 additions & 2 deletions src/Verify.EntityFramework/VerifyEntityFramework.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@ static async Task<List<object>> QueryEntities<T>(DbContext data, IEntityType ent
queryable = data.Set<T>();
}

queryable = queryable.AsNoTracking();

var key = entityType.FindPrimaryKey();
// EF never tracks a keyless entity type, so AsNoTracking would be redundant
if (key != null)
{
queryable = queryable.AsNoTracking();
var method = nameof(Queryable.OrderBy);
foreach (var property in key.Properties)
{
Expand Down
Loading