diff --git a/readme.md b/readme.md index 4e616db..c3836b4 100644 --- a/readme.md +++ b/readme.md @@ -77,7 +77,7 @@ builder.UseSqlServer(connection); builder.EnableRecording(); var data = new SampleDbContext(builder.Options); ``` -snippet source | anchor +snippet source | anchor `EnableRecording` should only be called in the test context. @@ -106,7 +106,7 @@ await data await Verify(); ``` -snippet source | anchor +snippet source | anchor Will result in the following verified file: @@ -157,7 +157,7 @@ await Verify( entries }); ``` -snippet source | anchor +snippet source | anchor @@ -189,7 +189,7 @@ await data2 await Verify(); ``` -snippet source | anchor +snippet source | anchor @@ -251,7 +251,7 @@ await data await Verify(); ``` -snippet source | anchor +snippet source | anchor @@ -298,7 +298,7 @@ public async Task Added() await Verify(data.ChangeTracker); } ``` -snippet source | anchor +snippet source | anchor Will result in the following verified file: @@ -343,7 +343,7 @@ public async Task Deleted() await Verify(data.ChangeTracker); } ``` -snippet source | anchor +snippet source | anchor Will result in the following verified file: @@ -388,7 +388,7 @@ public async Task Modified() await Verify(data.ChangeTracker); } ``` -snippet source | anchor +snippet source | anchor Will result in the following verified file: @@ -423,7 +423,7 @@ var queryable = data.Companies .Where(_ => _.Content == "value"); await Verify(queryable); ``` -snippet source | anchor +snippet source | anchor Will result in the following verified file: @@ -481,7 +481,7 @@ await Verify(data.AllData()) serializer => serializer.TypeNameHandling = TypeNameHandling.Objects); ``` -snippet source | anchor +snippet source | anchor Will result in the following verified file with all data in the database: @@ -564,7 +564,7 @@ public async Task IgnoreNavigationProperties() .IgnoreNavigationProperties(); } ``` -snippet source | anchor +snippet source | anchor @@ -577,7 +577,7 @@ var options = DbContextOptions(); using var data = new SampleDbContext(options); VerifyEntityFramework.IgnoreNavigationProperties(); ``` -snippet source | anchor +snippet source | anchor @@ -598,7 +598,7 @@ protected override void ConfigureWebHost(IWebHostBuilder webBuilder) _ => dataBuilder.Options)); } ``` -snippet source | anchor +snippet source | anchor Then use the same identifier for recording: @@ -614,7 +614,7 @@ var companies = await httpClient.GetFromJsonAsync("/companies"); var entries = Recording.Stop(testName); ``` -snippet source | anchor +snippet source | anchor The results will not be automatically included in verified file so it will have to be verified manually: @@ -629,7 +629,7 @@ await Verify( sql = entries }); ``` -snippet source | anchor +snippet source | anchor diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 02cc709..b06167a 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -10,7 +10,7 @@ true all low - true + false true diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index b4c7902..bc41d0e 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -13,6 +13,7 @@ + diff --git a/src/Verify.EntityFramework.Tests/CoreTests.MissingOrderBy.verified.txt b/src/Verify.EntityFramework.Tests/CoreTests.MissingOrderBy.verified.txt new file mode 100644 index 0000000..a1b3dfc --- /dev/null +++ b/src/Verify.EntityFramework.Tests/CoreTests.MissingOrderBy.verified.txt @@ -0,0 +1,8 @@ +{ + Type: Exception, + Message: +SelectExpression must have at least one ordering. +Expression: +SELECT c.Id, c.Content +FROM Companies AS c +} \ No newline at end of file diff --git a/src/Verify.EntityFramework.Tests/CoreTests.NestedMissingOrderBy.verified.txt b/src/Verify.EntityFramework.Tests/CoreTests.NestedMissingOrderBy.verified.txt new file mode 100644 index 0000000..ac169be --- /dev/null +++ b/src/Verify.EntityFramework.Tests/CoreTests.NestedMissingOrderBy.verified.txt @@ -0,0 +1,7 @@ +{ + Type: Exception, + Message: +TableExpression must have at least one ordering. +Expression: +Employees AS e +} \ No newline at end of file diff --git a/src/Verify.EntityFramework.Tests/CoreTests.WithNestedOrderBy.verified.txt b/src/Verify.EntityFramework.Tests/CoreTests.WithNestedOrderBy.verified.txt new file mode 100644 index 0000000..53a55ea --- /dev/null +++ b/src/Verify.EntityFramework.Tests/CoreTests.WithNestedOrderBy.verified.txt @@ -0,0 +1,40 @@ +[ + { + Id: 1, + Content: Company1, + Employees: [ + { + Id: 2, + CompanyId: 1, + Content: Employee1, + Age: 25 + }, + { + Id: 3, + CompanyId: 1, + Content: Employee2, + Age: 31 + } + ] + }, + { + Id: 4, + Content: Company2, + Employees: [ + { + Id: 5, + CompanyId: 4, + Content: Employee4, + Age: 34 + } + ] + }, + { + Id: 6, + Content: Company3 + }, + { + Id: 7, + Content: Company4 + } +] \ No newline at end of file diff --git a/src/Verify.EntityFramework.Tests/CoreTests.WithOrderBy.verified.txt b/src/Verify.EntityFramework.Tests/CoreTests.WithOrderBy.verified.txt new file mode 100644 index 0000000..f0dca98 --- /dev/null +++ b/src/Verify.EntityFramework.Tests/CoreTests.WithOrderBy.verified.txt @@ -0,0 +1,18 @@ +[ + { + Id: 1, + Content: Company1 + }, + { + Id: 4, + Content: Company2 + }, + { + Id: 6, + Content: Company3 + }, + { + Id: 7, + Content: Company4 + } +] \ No newline at end of file diff --git a/src/Verify.EntityFramework.Tests/CoreTests.cs b/src/Verify.EntityFramework.Tests/CoreTests.cs index d6af7eb..85ad605 100644 --- a/src/Verify.EntityFramework.Tests/CoreTests.cs +++ b/src/Verify.EntityFramework.Tests/CoreTests.cs @@ -2,6 +2,53 @@ [Parallelizable(ParallelScope.All)] public class CoreTests { + [Test] + public async Task MissingOrderBy() + { + await using var database = await DbContextBuilder.GetOrderRequiredDatabase(); + var data = database.Context; + await ThrowsTask( + () => data.Companies + .ToListAsync()) + .IgnoreStackTrace(); + } + + [Test] + public async Task NestedMissingOrderBy() + { + await using var database = await DbContextBuilder.GetOrderRequiredDatabase(); + var data = database.Context; + await ThrowsTask( + () => data.Companies + .Include(_ => _.Employees) + .OrderBy(_ => _.Content) + .ToListAsync()) + .IgnoreStackTrace(); + } + + [Test] + public async Task WithOrderBy() + { + await using var database = await DbContextBuilder.GetOrderRequiredDatabase(); + var data = database.Context; + await Verify( + data.Companies + .OrderBy(_ => _.Content) + .ToListAsync()); + } + + [Test] + public async Task WithNestedOrderBy() + { + await using var database = await DbContextBuilder.GetOrderRequiredDatabase(); + var data = database.Context; + await Verify( + data.Companies + .Include(_ => _.Employees.OrderBy(_ => _.Age)) + .OrderBy(_ => _.Content) + .ToListAsync()); + } + #region Added [Test] @@ -232,7 +279,7 @@ public class CoreTests [Test] public async Task AllData() { - var database = await DbContextBuilder.GetDatabase("AllData"); + var database = await DbContextBuilder.GetDatabase(); var data = database.Context; #region AllData @@ -248,7 +295,7 @@ public class CoreTests [Test] public async Task Queryable() { - var database = await DbContextBuilder.GetDatabase("Queryable"); + var database = await DbContextBuilder.GetDatabase(); await database.AddData( new Company { @@ -268,7 +315,7 @@ public class CoreTests [Test] public async Task SetSelect() { - var database = await DbContextBuilder.GetDatabase("SetSelect"); + var database = await DbContextBuilder.GetDatabase(); var data = database.Context; var query = data @@ -280,7 +327,7 @@ public class CoreTests [Test] public async Task NestedQueryable() { - var database = await DbContextBuilder.GetDatabase("NestedQueryable"); + var database = await DbContextBuilder.GetDatabase(); await database.AddData( new Company { @@ -312,7 +359,7 @@ public class CoreTests [Test] public async Task Parameters() { - var database = await DbContextBuilder.GetDatabase("Parameters"); + var database = await DbContextBuilder.GetDatabase(); var data = database.Context; data.Add( new Company @@ -330,7 +377,7 @@ public class CoreTests [Test] public async Task MultiRecording() { - var database = await DbContextBuilder.GetDatabase("MultiRecording"); + var database = await DbContextBuilder.GetDatabase(); var data = database.Context; Recording.Start(); var company = new Company @@ -363,7 +410,7 @@ public class CoreTests [Test] public async Task MultiDbContexts() { - var database = await DbContextBuilder.GetDatabase("MultiDbContexts"); + var database = await DbContextBuilder.GetDatabase(); var connectionString = database.ConnectionString; #region MultiDbContexts @@ -395,7 +442,7 @@ public class CoreTests [Test] public async Task RecordingTest() { - var database = await DbContextBuilder.GetDatabase("Recording"); + var database = await DbContextBuilder.GetDatabase(); var data = database.Context; #region Recording @@ -422,7 +469,7 @@ public class CoreTests [Test] public async Task RecordingDisabledTest() { - var database = await DbContextBuilder.GetDatabase("RecordingDisabledTest"); + var database = await DbContextBuilder.GetDatabase(); var data = database.Context; #region RecordingDisableForInstance @@ -551,7 +598,7 @@ public class CoreTests [Test] public async Task RecordingSpecific() { - var database = await DbContextBuilder.GetDatabase("RecordingSpecific"); + var database = await DbContextBuilder.GetDatabase(); var data = database.Context; #region RecordingSpecific diff --git a/src/Verify.EntityFramework.Tests/Snippets/DbContextBuilder.cs b/src/Verify.EntityFramework.Tests/Snippets/DbContextBuilder.cs index 9c00050..caea6be 100644 --- a/src/Verify.EntityFramework.Tests/Snippets/DbContextBuilder.cs +++ b/src/Verify.EntityFramework.Tests/Snippets/DbContextBuilder.cs @@ -3,7 +3,8 @@ public static class DbContextBuilder { - static DbContextBuilder() => + static DbContextBuilder() + { sqlInstance = new( buildTemplate: CreateDb, constructInstance: builder => @@ -11,8 +12,19 @@ public static class DbContextBuilder builder.EnableRecording(); return new(builder.Options); }); + orderRequiredSqlInstance = new( + buildTemplate: CreateDb, + storage: Storage.FromSuffix("ThrowForMissingOrderBy"), + constructInstance: builder => + { + builder.EnableRecording(); + builder.ThrowForMissingOrderBy(); + return new(builder.Options); + }); + } static SqlInstance sqlInstance; + static SqlInstance orderRequiredSqlInstance; static async Task CreateDb(SampleDbContext data) { @@ -63,6 +75,9 @@ public static class DbContextBuilder await data.SaveChangesAsync(); } - public static Task> GetDatabase(string suffix) + public static Task> GetDatabase([CallerMemberName] string suffix = "") => sqlInstance.Build(suffix); + + public static Task> GetOrderRequiredDatabase([CallerMemberName] string suffix = "") + => orderRequiredSqlInstance.Build(suffix); } \ No newline at end of file diff --git a/src/Verify.EntityFramework/GlobalUsings.cs b/src/Verify.EntityFramework/GlobalUsings.cs index eebd66a..8ef0c1a 100644 --- a/src/Verify.EntityFramework/GlobalUsings.cs +++ b/src/Verify.EntityFramework/GlobalUsings.cs @@ -1,10 +1,13 @@ global using System.Data; global using System.Data.Common; global using System.Diagnostics.CodeAnalysis; +global using System.Linq.Expressions; global using Argon; global using Microsoft.EntityFrameworkCore; global using Microsoft.EntityFrameworkCore.ChangeTracking; global using Microsoft.EntityFrameworkCore.Diagnostics; global using Microsoft.EntityFrameworkCore.Metadata; +global using Microsoft.EntityFrameworkCore.Query; global using Microsoft.EntityFrameworkCore.Query.Internal; +global using Microsoft.EntityFrameworkCore.Query.SqlExpressions; global using VerifyTests.EntityFramework; \ No newline at end of file diff --git a/src/Verify.EntityFramework/MissingOrder/MissingOrderByVisitor.cs b/src/Verify.EntityFramework/MissingOrder/MissingOrderByVisitor.cs new file mode 100644 index 0000000..e76f9c0 --- /dev/null +++ b/src/Verify.EntityFramework/MissingOrder/MissingOrderByVisitor.cs @@ -0,0 +1,88 @@ +sealed class MissingOrderByVisitor : ExpressionVisitor +{ + List orderedExpressions = []; + + [return: NotNullIfNotNull(nameof(expression))] + public override Expression? Visit(Expression? expression) + { + if (expression is null) + { + return null; + } + + switch (expression) + { + case ShapedQueryExpression shapedQueryExpression: + Visit(shapedQueryExpression.QueryExpression); + return shapedQueryExpression; + + case RelationalSplitCollectionShaperExpression splitExpression: + foreach (var table in splitExpression.SelectExpression.Tables) + { + Visit(table); + } + + Visit(splitExpression.InnerShaper); + + return splitExpression; + + case TableExpression tableExpression: + { + foreach (var orderedExpression in orderedExpressions) + { + if (orderedExpression.Expression is ColumnExpression columnExpression) + { + if (columnExpression.Table == tableExpression) + { + return base.Visit(expression); + } + + if (columnExpression.Table is PredicateJoinExpressionBase joinExpression) + { + if (joinExpression.Table == tableExpression) + { + return base.Visit(expression); + } + } + } + } + + throw new( + $""" + TableExpression must have at least one ordering. + Expression: + {ExpressionPrinter.Print(tableExpression)} + """); + } + case SelectExpression selectExpression: + { + var orderings = selectExpression.Orderings; + if (orderings.Count == 0) + { + throw new( + $""" + SelectExpression must have at least one ordering. + Expression: + {PrintShortSql(selectExpression)} + """); + } + + foreach (var ordering in orderings) + { + orderedExpressions.Add(ordering); + } + + return base.Visit(expression); + } + + case NonQueryExpression nonQueryExpression: + return nonQueryExpression; + + default: + return base.Visit(expression); + } + } + + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "PrintShortSql")] + static extern string PrintShortSql(SelectExpression expression); +} \ No newline at end of file diff --git a/src/Verify.EntityFramework/MissingOrder/RelationalFactory.cs b/src/Verify.EntityFramework/MissingOrder/RelationalFactory.cs new file mode 100644 index 0000000..e383665 --- /dev/null +++ b/src/Verify.EntityFramework/MissingOrder/RelationalFactory.cs @@ -0,0 +1,13 @@ +class RelationalFactory : RelationalShapedQueryCompilingExpressionVisitorFactory +{ + public RelationalFactory(ShapedQueryCompilingExpressionVisitorDependencies dependencies, RelationalShapedQueryCompilingExpressionVisitorDependencies relationalDependencies) : + base(dependencies, relationalDependencies) + { + } + + public override ShapedQueryCompilingExpressionVisitor Create(QueryCompilationContext context) + => new RelationalVisitor( + Dependencies, + RelationalDependencies, + context); +} \ No newline at end of file diff --git a/src/Verify.EntityFramework/MissingOrder/RelationalVisitor.cs b/src/Verify.EntityFramework/MissingOrder/RelationalVisitor.cs new file mode 100644 index 0000000..c8f6304 --- /dev/null +++ b/src/Verify.EntityFramework/MissingOrder/RelationalVisitor.cs @@ -0,0 +1,15 @@ +class RelationalVisitor : + RelationalShapedQueryCompilingExpressionVisitor +{ + public RelationalVisitor(ShapedQueryCompilingExpressionVisitorDependencies dependencies, RelationalShapedQueryCompilingExpressionVisitorDependencies relationalDependencies, QueryCompilationContext context) : + base(dependencies, relationalDependencies, context) + { + } + + [return: NotNullIfNotNull("node")] + public override Expression? Visit(Expression? node) + { + new MissingOrderByVisitor().Visit(node); + return base.Visit(node); + } +} \ No newline at end of file diff --git a/src/Verify.EntityFramework/Verify.EntityFramework.csproj b/src/Verify.EntityFramework/Verify.EntityFramework.csproj index d37d08d..a245f41 100644 --- a/src/Verify.EntityFramework/Verify.EntityFramework.csproj +++ b/src/Verify.EntityFramework/Verify.EntityFramework.csproj @@ -4,6 +4,9 @@ + + + diff --git a/src/Verify.EntityFramework/VerifyEntityFramework.cs b/src/Verify.EntityFramework/VerifyEntityFramework.cs index 0b7b56a..ab9e771 100644 --- a/src/Verify.EntityFramework/VerifyEntityFramework.cs +++ b/src/Verify.EntityFramework/VerifyEntityFramework.cs @@ -131,10 +131,18 @@ public static class VerifyEntityFramework return new(result, [new("sql", sql)]); } + public static DbContextOptionsBuilder ThrowForMissingOrderBy(this DbContextOptionsBuilder builder) + where TContext : DbContext => + builder.ReplaceService(); + public static DbContextOptionsBuilder EnableRecording(this DbContextOptionsBuilder builder) where TContext : DbContext => builder.EnableRecording(null); + public static DbContextOptionsBuilder EnableRecording(this DbContextOptionsBuilder builder, string? identifier) + where TContext : DbContext => + builder.AddInterceptors(new LogCommandInterceptor(identifier)); + static ConcurrentBag recordingDisabledContextIds = []; public static void DisableRecording(this TContext context) @@ -144,8 +152,4 @@ public static class VerifyEntityFramework internal static bool IsRecordingDisabled(this TContext context) where TContext : DbContext => recordingDisabledContextIds.Contains(context.ContextId.InstanceId); - - public static DbContextOptionsBuilder EnableRecording(this DbContextOptionsBuilder builder, string? identifier) - where TContext : DbContext => - builder.AddInterceptors(new LogCommandInterceptor(identifier)); } \ No newline at end of file