diff --git a/docs/content/in-depth/server/databases/cosmos.md b/docs/content/in-depth/server/databases/cosmos.md index d24f94f0..bfc4e8e7 100644 --- a/docs/content/in-depth/server/databases/cosmos.md +++ b/docs/content/in-depth/server/databases/cosmos.md @@ -86,6 +86,21 @@ Azure Cosmos DB is a fully managed NoSQL database for high-performance applicati } ``` +## Avoid Client-side evaluations + +When constructing a query within a client, avoid the following: + +* Math operations such as division, multiplication, floor, ceiling, and round. +* Accessing date/time components such as year, day, or month. +* The use of DateOnly and TimeOnly types. + +These are not supported by the LINQ provider for Cosmos EF Core. Using them will result in a client-side evaluation. Client-side evaluations have the following effects: + +* In v8.x and earlier, client-side evaluations read the entire dataset into memory to construct the appropriate filter. This causes performance problems. +* In v9.x and later, client-side evaluations are not supported and will result in a `400 Bad Request` or `500 Internal Server Error`. + +## Support and further information + Azure Cosmos DB is supported in the `Microsoft.AspNetCore.Datasync.EFCore` NuGet package since v5.0.11. For more information, review the following links: * [EF Core Azure Cosmos DB Provider](https://learn.microsoft.com/ef/core/providers/cosmos) documentation. diff --git a/docs/content/samples/todoapp/avalonia.md b/docs/content/samples/todoapp/avalonia.md index d21aa5d1..6cb50aa3 100644 --- a/docs/content/samples/todoapp/avalonia.md +++ b/docs/content/samples/todoapp/avalonia.md @@ -3,7 +3,7 @@ title = "Avalonia" +++ > [!INFO] -> The Avalonia sample has been kindly contributed to the community by @timunie. +> The Avalonia sample has been kindly contributed to the community by [@timunie](https://github.com/timunie). ## Run the application first diff --git a/docs/public/404.html b/docs/public/404.html index a9af1ed7..e3d4d160 100644 --- a/docs/public/404.html +++ b/docs/public/404.html @@ -3,7 +3,7 @@ - + @@ -20,17 +20,17 @@ - - - - - - - - - - - + + + + + + + + + + + - + @@ -20,17 +20,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/css/chroma-auto.css b/docs/public/css/chroma-auto.css index 36529f44..816ec248 100644 --- a/docs/public/css/chroma-auto.css +++ b/docs/public/css/chroma-auto.css @@ -1,2 +1,2 @@ -@import "chroma-relearn-light.css?1736358756" screen and (prefers-color-scheme: light); -@import "chroma-relearn-dark.css?1736358756" screen and (prefers-color-scheme: dark); +@import "chroma-relearn-light.css?1738255149" screen and (prefers-color-scheme: light); +@import "chroma-relearn-dark.css?1738255149" screen and (prefers-color-scheme: dark); diff --git a/docs/public/css/format-print.css b/docs/public/css/format-print.css index 1aacf4f8..d2badada 100644 --- a/docs/public/css/format-print.css +++ b/docs/public/css/format-print.css @@ -1,5 +1,5 @@ -@import "theme-relearn-light.css?1736358756"; -@import "chroma-relearn-light.css?1736358756"; +@import "theme-relearn-light.css?1738255149"; +@import "chroma-relearn-light.css?1738255149"; #R-sidebar { display: none; diff --git a/docs/public/css/print.css b/docs/public/css/print.css index 7e2c3e85..0304ab0f 100644 --- a/docs/public/css/print.css +++ b/docs/public/css/print.css @@ -1 +1 @@ -@import "format-print.css?1736358756"; +@import "format-print.css?1738255149"; diff --git a/docs/public/css/swagger.css b/docs/public/css/swagger.css index 4f0f36bc..bf9baa25 100644 --- a/docs/public/css/swagger.css +++ b/docs/public/css/swagger.css @@ -1,7 +1,7 @@ /* Styles to make Swagger-UI fit into our theme */ -@import "fonts.css?1736358756"; -@import "variables.css?1736358756"; +@import "fonts.css?1738255149"; +@import "variables.css?1738255149"; body{ line-height: 1.574; diff --git a/docs/public/css/theme-auto.css b/docs/public/css/theme-auto.css index cae05ef1..d1efe36f 100644 --- a/docs/public/css/theme-auto.css +++ b/docs/public/css/theme-auto.css @@ -1,2 +1,2 @@ -@import "theme-relearn-light.css?1736358756" screen and (prefers-color-scheme: light); -@import "theme-relearn-dark.css?1736358756" screen and (prefers-color-scheme: dark); +@import "theme-relearn-light.css?1738255149" screen and (prefers-color-scheme: light); +@import "theme-relearn-dark.css?1738255149" screen and (prefers-color-scheme: dark); diff --git a/docs/public/css/variant.css b/docs/public/css/variant.css index 19043bbc..2aa1c878 100644 --- a/docs/public/css/variant.css +++ b/docs/public/css/variant.css @@ -1,4 +1,4 @@ -@import "variables.css?1736358756"; +@import "variables.css?1738255149"; html { color-scheme: only var(--INTERNAL-BROWSER-theme); diff --git a/docs/public/in-depth/client/auth/index.html b/docs/public/in-depth/client/auth/index.html index 226106b0..934aab0e 100644 --- a/docs/public/in-depth/client/auth/index.html +++ b/docs/public/in-depth/client/auth/index.html @@ -3,7 +3,7 @@ - + @@ -28,17 +28,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/in-depth/client/index.html b/docs/public/in-depth/client/index.html index f1ef1c79..f3bac1e4 100644 --- a/docs/public/in-depth/client/index.html +++ b/docs/public/in-depth/client/index.html @@ -3,7 +3,7 @@ - + - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/in-depth/client/index.xml b/docs/public/in-depth/client/index.xml index d2753ef7..12645dc1 100644 --- a/docs/public/in-depth/client/index.xml +++ b/docs/public/in-depth/client/index.xml @@ -26,7 +26,7 @@ http://localhost:1313/Datasync/in-depth/client/maui-aot-support/index.html Mon, 01 Jan 0001 00:00:00 +0000 http://localhost:1313/Datasync/in-depth/client/maui-aot-support/index.html - When using Native AOT with .NET MAUI, several requirements will need to be met to ensure the application works in release mode, explicitly on iOS and Mac Catalyst. Implement compiled models for Entity Framework Core. Implement source generation in System.Text.Json. Enable the MTouchInterpreter. The following are basic instructions on how to fulfill these requirements. However, you should consult the official documentation for each requirement. Note Thanks to @richard-einfinity for providing the detailed instructions for enabling Native AOT support. + When using Native AOT with .NET MAUI, several requirements will need to be met to ensure the application works in release mode, explicitly on iOS and Mac Catalyst. Implement compiled models for Entity Framework Core. Implement source generation in System.Text.Json. Enable the Interpreter for iOS and Mac Catalyst. The following are basic instructions on how to fulfill these requirements. However, you should consult the official documentation (linked above) for each requirement. \ No newline at end of file diff --git a/docs/public/in-depth/client/maui-aot-support/index.html b/docs/public/in-depth/client/maui-aot-support/index.html index 93cc7ad4..a1721084 100644 --- a/docs/public/in-depth/client/maui-aot-support/index.html +++ b/docs/public/in-depth/client/maui-aot-support/index.html @@ -3,7 +3,7 @@ - + @@ -28,17 +28,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/in-depth/client/online-operations/index.html b/docs/public/in-depth/client/online-operations/index.html index cceabad3..9e01aac0 100644 --- a/docs/public/in-depth/client/online-operations/index.html +++ b/docs/public/in-depth/client/online-operations/index.html @@ -3,7 +3,7 @@ - + @@ -28,17 +28,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/in-depth/index.html b/docs/public/in-depth/index.html index e9301259..51dc4fc4 100644 --- a/docs/public/in-depth/index.html +++ b/docs/public/in-depth/index.html @@ -3,7 +3,7 @@ - + - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/in-depth/server/databases/azuresql/index.html b/docs/public/in-depth/server/databases/azuresql/index.html index 720a42e3..e972d102 100644 --- a/docs/public/in-depth/server/databases/azuresql/index.html +++ b/docs/public/in-depth/server/databases/azuresql/index.html @@ -3,7 +3,7 @@ - + - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/in-depth/server/databases/cosmos/index.html b/docs/public/in-depth/server/databases/cosmos/index.html index 0b351153..2d4c8c5b 100644 --- a/docs/public/in-depth/server/databases/cosmos/index.html +++ b/docs/public/in-depth/server/databases/cosmos/index.html @@ -3,7 +3,7 @@ - + @@ -23,22 +23,22 @@ - + Azure Cosmos DB :: Datasync Community Toolkit - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/in-depth/server/databases/in-memory/index.html b/docs/public/in-depth/server/databases/in-memory/index.html index 3141ac15..450d3cc3 100644 --- a/docs/public/in-depth/server/databases/in-memory/index.html +++ b/docs/public/in-depth/server/databases/in-memory/index.html @@ -3,7 +3,7 @@ - + - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/in-depth/server/databases/index.html b/docs/public/in-depth/server/databases/index.html index 9e102eee..442172ef 100644 --- a/docs/public/in-depth/server/databases/index.html +++ b/docs/public/in-depth/server/databases/index.html @@ -3,7 +3,7 @@ - + - + Database support :: Datasync Community Toolkit - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/in-depth/server/databases/index.xml b/docs/public/in-depth/server/databases/index.xml index bca2a29c..700a5c88 100644 --- a/docs/public/in-depth/server/databases/index.xml +++ b/docs/public/in-depth/server/databases/index.xml @@ -35,6 +35,13 @@ http://localhost:1313/Datasync/in-depth/server/databases/litedb/index.html LiteDB LiteDB is a serverless database delivered in a single small DLL written in .NET C# managed code. It’s a simple and fast NoSQL database solution for stand-alone applications. To use LiteDb with on-disk persistent storage: Install the Microsoft.AspNetCore.Datasync.LiteDb package from NuGet. Add a singleton for the LiteDatabase to the Program.cs: const connectionString = builder.Configuration.GetValue<string>("LiteDb:ConnectionString"); builder.Services.AddSingleton<LiteDatabase>(new LiteDatabase(connectionString)); Derive models from the LiteDbTableData: public class TodoItem : LiteDbTableData { public string Title { get; set; } public bool Completed { get; set; } }You can use any of the BsonMapper attributes that are supplied with the LiteDb NuGet package. + + MySQL + http://localhost:1313/Datasync/in-depth/server/databases/mysql/index.html + Mon, 01 Jan 0001 00:00:00 +0000 + http://localhost:1313/Datasync/in-depth/server/databases/mysql/index.html + MySQL Add the Pomelo.EntityFrameworkCore.Mysql driver to your project. Info You can probably use the MySql.EntityFrameworkCore library as well. However, we only test with the Pomelo driver. In the OnModelCreating() method of your context, add the following for each entity: protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Model>().Property(m => m.UpdatedAt) .ValueGeneratedOnAddOrUpdate(); modelBuilder.Entity<Model>().Property(m => m.Version) .IsRowVersion(); base.OnModelCreating(modelBuilder); } Test MySQL Context + PostgreSQL http://localhost:1313/Datasync/in-depth/server/databases/postgresql/index.html diff --git a/docs/public/in-depth/server/databases/litedb/index.html b/docs/public/in-depth/server/databases/litedb/index.html index 3578d209..4d4549ab 100644 --- a/docs/public/in-depth/server/databases/litedb/index.html +++ b/docs/public/in-depth/server/databases/litedb/index.html @@ -3,7 +3,7 @@ - + - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/in-depth/server/databases/mysql/index.html b/docs/public/in-depth/server/databases/mysql/index.html new file mode 100644 index 00000000..18443cba --- /dev/null +++ b/docs/public/in-depth/server/databases/mysql/index.html @@ -0,0 +1,309 @@ + + + + + + + + + + + + + + + + + + + + + + + MySQL :: Datasync Community Toolkit + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+
+
+
+ +

MySQL

+ +

MySQL

+

Add the Pomelo.EntityFrameworkCore.Mysql driver to your project.

+ +
+
Info
+
+ +

You can probably use the MySql.EntityFrameworkCore library as well. However, we only test with the Pomelo driver.

+

In the OnModelCreating() method of your context, add the following for each entity:

+
protected override void OnModelCreating(ModelBuilder modelBuilder)
+{
+  modelBuilder.Entity<Model>().Property(m => m.UpdatedAt)
+    .ValueGeneratedOnAddOrUpdate();
+
+  modelBuilder.Entity<Model>().Property(m => m.Version)
+    .IsRowVersion();
+
+  base.OnModelCreating(modelBuilder);
+}
+ +
+
+
+
+
+
+ + + + + + diff --git a/docs/public/in-depth/server/databases/postgresql/index.html b/docs/public/in-depth/server/databases/postgresql/index.html index 2ac29209..3d5ae78a 100644 --- a/docs/public/in-depth/server/databases/postgresql/index.html +++ b/docs/public/in-depth/server/databases/postgresql/index.html @@ -3,7 +3,7 @@ - + - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/in-depth/server/databases/sqlite/index.html b/docs/public/in-depth/server/databases/sqlite/index.html index c39b00d4..11f74977 100644 --- a/docs/public/in-depth/server/databases/sqlite/index.html +++ b/docs/public/in-depth/server/databases/sqlite/index.html @@ -3,7 +3,7 @@ - + @@ -28,17 +28,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/in-depth/server/index.html b/docs/public/in-depth/server/index.html index dd001e38..ad68acba 100644 --- a/docs/public/in-depth/server/index.html +++ b/docs/public/in-depth/server/index.html @@ -3,7 +3,7 @@ - + - + Datasync Server :: Datasync Community Toolkit - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/index.html b/docs/public/index.html index 65643e2e..bf132032 100644 --- a/docs/public/index.html +++ b/docs/public/index.html @@ -3,7 +3,7 @@ - + @@ -24,17 +24,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/samples/index.html b/docs/public/samples/index.html index d1abb868..4c597055 100644 --- a/docs/public/samples/index.html +++ b/docs/public/samples/index.html @@ -3,7 +3,7 @@ - + - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/samples/server/index.html b/docs/public/samples/server/index.html index be964320..7b0c5900 100644 --- a/docs/public/samples/server/index.html +++ b/docs/public/samples/server/index.html @@ -3,7 +3,7 @@ - + - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/samples/todoapp/avalonia/index.html b/docs/public/samples/todoapp/avalonia/index.html index a63ac03b..57259938 100644 --- a/docs/public/samples/todoapp/avalonia/index.html +++ b/docs/public/samples/todoapp/avalonia/index.html @@ -3,7 +3,7 @@ - + - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/samples/todoapp/index.html b/docs/public/samples/todoapp/index.html index b6ffc76b..f5f696f3 100644 --- a/docs/public/samples/todoapp/index.html +++ b/docs/public/samples/todoapp/index.html @@ -3,7 +3,7 @@ - + @@ -28,17 +28,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/samples/todoapp/maui/index.html b/docs/public/samples/todoapp/maui/index.html index 0e118c76..ddf9cda3 100644 --- a/docs/public/samples/todoapp/maui/index.html +++ b/docs/public/samples/todoapp/maui/index.html @@ -3,7 +3,7 @@ - + @@ -28,17 +28,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/samples/todoapp/winui3/index.html b/docs/public/samples/todoapp/winui3/index.html index 7da8c113..4470bd86 100644 --- a/docs/public/samples/todoapp/winui3/index.html +++ b/docs/public/samples/todoapp/winui3/index.html @@ -3,7 +3,7 @@ - + @@ -28,17 +28,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/samples/todoapp/wpf/index.html b/docs/public/samples/todoapp/wpf/index.html index 18ea8664..4e536eeb 100644 --- a/docs/public/samples/todoapp/wpf/index.html +++ b/docs/public/samples/todoapp/wpf/index.html @@ -3,7 +3,7 @@ - + @@ -28,17 +28,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/setup/client/index.html b/docs/public/setup/client/index.html index 2027d75d..2f9aa1e2 100644 --- a/docs/public/setup/client/index.html +++ b/docs/public/setup/client/index.html @@ -3,7 +3,7 @@ - + - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/setup/index.html b/docs/public/setup/index.html index 9b2c830a..eb7cd1bf 100644 --- a/docs/public/setup/index.html +++ b/docs/public/setup/index.html @@ -3,7 +3,7 @@ - + @@ -20,17 +20,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/setup/server/index.html b/docs/public/setup/server/index.html index a379d4ac..f973b816 100644 --- a/docs/public/setup/server/index.html +++ b/docs/public/setup/server/index.html @@ -3,7 +3,7 @@ - + - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/docs/public/sitemap.xml b/docs/public/sitemap.xml index b96a2e93..f4bae653 100644 --- a/docs/public/sitemap.xml +++ b/docs/public/sitemap.xml @@ -57,6 +57,9 @@ http://localhost:1313/Datasync/samples/todoapp/maui/index.html + + http://localhost:1313/Datasync/in-depth/server/databases/mysql/index.html + http://localhost:1313/Datasync/in-depth/server/databases/postgresql/index.html diff --git a/docs/public/tags/index.html b/docs/public/tags/index.html index a815a477..f376b720 100644 --- a/docs/public/tags/index.html +++ b/docs/public/tags/index.html @@ -3,7 +3,7 @@ - + @@ -20,17 +20,17 @@ - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - + + + diff --git a/src/CommunityToolkit.Datasync.Server.Abstractions/Tables/IRepository.cs b/src/CommunityToolkit.Datasync.Server.Abstractions/Tables/IRepository.cs index 268b0db6..37d31636 100644 --- a/src/CommunityToolkit.Datasync.Server.Abstractions/Tables/IRepository.cs +++ b/src/CommunityToolkit.Datasync.Server.Abstractions/Tables/IRepository.cs @@ -66,4 +66,24 @@ public interface IRepository where TEntity : ITableData /// Thrown if the entity creation would produce a normal HTTP error. /// Thrown is there is an error in the repository. ValueTask ReplaceAsync(TEntity entity, byte[]? version = null, CancellationToken cancellationToken = default); + + /// + /// A method that is used to count the number of entities in an query using the + /// preferred mechanism for the repository. + /// + /// The query to be executed. + /// A to observe. + /// The number of items matching the query. + ValueTask CountAsync(IQueryable query, CancellationToken cancellationToken = default) + => ValueTask.FromResult(query.Count()); + + /// + /// A method that is used to execute the query and return the elements matches the query as a list using the + /// preferred mechanism for the repository. + /// + /// The query to be executed. + /// A to observe. + /// The items matching the query. + ValueTask> ToListAsync(IQueryable query, CancellationToken cancellationToken = default) + => ValueTask.FromResult>(query.ToList()); } diff --git a/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableRepository.cs b/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableRepository.cs index 64430016..cf15b76e 100644 --- a/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableRepository.cs +++ b/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableRepository.cs @@ -137,10 +137,10 @@ public virtual async ValueTask CreateAsync(TEntity entity, CancellationToken can await WrapExceptionAsync(entity.Id, async () => { - // We do not use Any() here because it is not supported by all providers (e.g. Cosmos) - if (DataSet.Count(x => x.Id == entity.Id) > 0) + TEntity? existingEntity = await DataSet.FindAsync([entity.Id], cancellationToken).ConfigureAwait(false); + if (existingEntity is not null) { - throw new HttpException((int)HttpStatusCode.Conflict) { Payload = await GetEntityAsync(entity.Id, cancellationToken).ConfigureAwait(false) }; + throw new HttpException((int)HttpStatusCode.Conflict) { Payload = existingEntity }; } UpdateManagedProperties(entity); @@ -209,5 +209,17 @@ await WrapExceptionAsync(entity.Id, async () => _ = await Context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); }, cancellationToken).ConfigureAwait(false); } + + /// + public virtual async ValueTask CountAsync(IQueryable query, CancellationToken cancellationToken = default) + { + return await query.CountAsync(cancellationToken); + } + + /// + public virtual async ValueTask> ToListAsync(IQueryable query, CancellationToken cancellationToken = default) + { + return await query.ToListAsync(cancellationToken); + } #endregion } diff --git a/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs b/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs index 5e6d75b3..f15368aa 100644 --- a/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs +++ b/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs @@ -17,6 +17,9 @@ using System.Diagnostics.CodeAnalysis; using CommunityToolkit.Datasync.Server.OData; using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Primitives; +using System.Globalization; namespace CommunityToolkit.Datasync.Server; @@ -38,6 +41,9 @@ public partial class TableController : ODataController where TEntity : /// - $skip is used to skip some entities /// - $top is used to limit the number of entities returned. /// + /// + /// In addition, the __includeDeleted parameter is used to decide whether to include soft-deleted items in the result. + /// /// /// A cancellation token /// An response object with the items. @@ -50,10 +56,6 @@ public virtual async Task QueryAsync(CancellationToken cancellati await AuthorizeRequestAsync(TableOperation.Query, null, cancellationToken).ConfigureAwait(false); _ = BuildServiceProvider(Request); - IQueryable dataset = (await Repository.AsQueryableAsync(cancellationToken).ConfigureAwait(false)) - .ApplyDataView(AccessControlProvider.GetDataView()) - .ApplyDeletedView(Request, Options.EnableSoftDelete); - ODataValidationSettings validationSettings = new() { MaxTop = Options.MaxTop }; ODataQuerySettings querySettings = new() { PageSize = Options.PageSize, EnsureStableOrdering = true }; ODataQueryContext queryContext = new(EdmModel, typeof(TEntity), new ODataPath()); @@ -69,26 +71,44 @@ public virtual async Task QueryAsync(CancellationToken cancellati return BadRequest(validationException.Message); } - // Note that some IQueryable providers cannot execute all queries against the data source, so we have - // to switch to in-memory processing for those queries. This is done by calling ToListAsync() on the - // IQueryable. This is not ideal, but it is the only way to support all of the OData query options. - IEnumerable? results = null; - await ExecuteQueryWithClientEvaluationAsync(dataset, ds => + // Determine the dataset to be queried for this user. + IQueryable dataset = (await Repository.AsQueryableAsync(cancellationToken).ConfigureAwait(false)) + .ApplyDataView(AccessControlProvider.GetDataView()) + .ApplyDeletedView(Request, Options.EnableSoftDelete); + + // Apply the requested filter from the OData transaction. + IQueryable filteredDataset = dataset.ApplyODataFilter(queryOptions.Filter, querySettings); + + // Count the number of items within the filtered dataset - this is used when $count is requested. + int filteredCount; + try { - results = (IEnumerable)queryOptions.ApplyTo(ds, querySettings); - return Task.CompletedTask; - }); + filteredCount = await Repository.CountAsync(filteredDataset, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException) + { + throw new HttpException(400, "Client-side evaluation is not supported. Please ensure that the query can be translated to a server-side query."); + } - int count = 0; - FilterQueryOption? filter = queryOptions.Filter; - await ExecuteQueryWithClientEvaluationAsync(dataset, async ds => - { - IQueryable q = (IQueryable)(filter?.ApplyTo(ds, new ODataQuerySettings()) ?? ds); - count = await CountAsync(q, cancellationToken); - }); + // Now apply the OrderBy, Skip, and Top options to the dataset. + IQueryable orderedDataset = filteredDataset + .ApplyODataOrderBy(queryOptions.OrderBy, querySettings) + .ApplyODataPaging(queryOptions, querySettings); + + // Get the list of items within the dataset that need to be returned. + IList entitiesInResultSet; + try + { + entitiesInResultSet = await Repository.ToListAsync(orderedDataset, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException) + { + throw new HttpException(400, "Client-side evaluation is not supported. Please ensure that the query can be translated to a server-side query."); + } - PagedResult result = BuildPagedResult(queryOptions, results, count); - Logger.LogInformation("Query: {Count} items being returned", result.Items.Count()); + // Produce the paged result. + PagedResult result = BuildPagedResult(queryOptions, entitiesInResultSet.ApplyODataSelect(queryOptions.SelectExpand, querySettings), filteredCount); + Logger.LogInformation("Query: {Count} items being returned", entitiesInResultSet.Count); return Ok(result); } @@ -135,136 +155,34 @@ internal PagedResult BuildPagedResult(ODataQueryOptions queryOptions, IEnumerabl int resultCount = results?.Count() ?? 0; int skip = (queryOptions.Skip?.Value ?? 0) + resultCount; int top = (queryOptions.Top?.Value ?? 0) - resultCount; - if (results is IEnumerable wrapper) - { - results = wrapper.Select(x => x.ToDictionary()); - } - - PagedResult result = new(results ?? []) { Count = queryOptions.Count != null ? count : null }; - if (queryOptions.Top != null) - { - result.NextLink = skip >= count || top <= 0 ? null : CreateNextLink(Request, skip, top); - } - else - { - result.NextLink = skip >= count ? null : CreateNextLink(Request, skip, 0); - } - - return result; - } - - /// - /// Given a very specific URI, creates a new query string with the same query, but with a different value for the $skip parameter. - /// - /// The original request. - /// The new skip value. - /// The new top value. - /// The new URI for the next page of items. - [NonAction] - [SuppressMessage("Roslynator", "RCS1158:Static member in generic type should use a type parameter", Justification = "Static method in generic non-static class")] - internal static string CreateNextLink(HttpRequest request, int skip = 0, int top = 0) - => CreateNextLink(new UriBuilder(request.GetDisplayUrl()).Query, skip, top); - - /// - /// Given a very specific query string, creates a new query string with the same query, but with a different value for the $skip parameter. - /// - /// The original query string. - /// The new skip value. - /// The new top value. - /// The new URI for the next page of items. - [NonAction] - [SuppressMessage("Roslynator", "RCS1158:Static member in generic type should use a type parameter", Justification = "Static method in generic non-static class")] - internal static string CreateNextLink(string queryString, int skip = 0, int top = 0) - { - List query = (queryString ?? "").TrimStart('?') - .Split('&') - .Where(q => !q.StartsWith($"{SkipParameterName}=") && !q.StartsWith($"{TopParameterName}=")) - .ToList(); - if (skip > 0) + // Internal function to create the nextLink property for the paged result. + static string CreateNextLink(HttpRequest request, int skip = 0, int top = 0) { - query.Add($"{SkipParameterName}={skip}"); - } - - if (top > 0) - { - query.Add($"{TopParameterName}={top}"); - } - - return string.Join('&', query).TrimStart('&'); - } - - /// - /// When doing a query evaluation, certain providers (e.g. Entity Framework) require some things - /// to be done client side. We use a client side evaluator to handle this case when it happens. - /// - /// The exception thrown by the service-side evaluator - /// The reason if the client-side evaluator throws. - /// The client-side evaluator - [NonAction] - internal async Task CatchClientSideEvaluationExceptionAsync(Exception ex, string reason, Func clientSideEvaluator) - { - if (IsClientSideEvaluationException(ex) || IsClientSideEvaluationException(ex.InnerException)) - { - try + Dictionary query = QueryHelpers.ParseNullableQuery(request.QueryString.Value) ?? []; + if (skip > 0) { - await clientSideEvaluator.Invoke(); + query[SkipParameterName] = skip.ToString(CultureInfo.InvariantCulture); } - catch (Exception err) + + if (top > 0) { - Logger.LogError("Error while {reason}: {Message}", reason, err.Message); - throw; + query[TopParameterName] = top.ToString(CultureInfo.InvariantCulture); } + + return QueryHelpers.AddQueryString(string.Empty, query).TrimStart('?'); } - else - { - throw ex; - } - } - /// - /// Executes an evaluation of a query, using a client-side evaluation if necessary. - /// - /// The dataset to be evaluated. - /// The base evaluation to be performed. - [NonAction] - internal async Task ExecuteQueryWithClientEvaluationAsync(IQueryable dataset, Func, Task> evaluator) - { - try + PagedResult result = new(results ?? []) { Count = queryOptions.Count != null ? count : null }; + if (queryOptions.Top is not null) { - await evaluator.Invoke(dataset); + result.NextLink = skip >= count || top <= 0 ? null : CreateNextLink(Request, skip, top); } - catch (Exception ex) when (!Options.DisableClientSideEvaluation) + else { - await CatchClientSideEvaluationExceptionAsync(ex, "executing query", async () => - { - Logger.LogWarning("Error while executing query: possible client-side evaluation ({Message})", ex.InnerException?.Message ?? ex.Message); - await evaluator.Invoke(dataset.ToList().AsQueryable()); - }); + result.NextLink = skip >= count ? null : CreateNextLink(Request, skip, 0); } - } - - /// - /// Determines if a particular exception indicates a client-side evaluation is required. - /// - /// The exception that was thrown by the service-side evaluator - /// true if a client-side evaluation is required. - [NonAction] - [SuppressMessage("Roslynator", "RCS1158:Static member in generic type should use a type parameter.")] - internal static bool IsClientSideEvaluationException(Exception? ex) - => ex is not null and (InvalidOperationException or NotSupportedException); - /// - /// This is an overridable method that calls Count() on the provided queryable. You can override - /// this to calls a provider-specific count mechanism (e.g. CountAsync(). - /// - /// - /// - /// - [NonAction] - public virtual Task CountAsync(IQueryable query, CancellationToken cancellationToken) - { - int result = query.Count(); - return Task.FromResult(result); + return result; } } diff --git a/src/CommunityToolkit.Datasync.Server/Extensions/InternalExtensions.cs b/src/CommunityToolkit.Datasync.Server/Extensions/InternalExtensions.cs index 0a435753..aa94efd1 100644 --- a/src/CommunityToolkit.Datasync.Server/Extensions/InternalExtensions.cs +++ b/src/CommunityToolkit.Datasync.Server/Extensions/InternalExtensions.cs @@ -5,9 +5,12 @@ using CommunityToolkit.Datasync.Server.Abstractions.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Headers; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.Query.Wrapper; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; using System.Globalization; +using System.Linq; using System.Linq.Expressions; using System.Text.Json; using System.Text.Json.Serialization; @@ -35,7 +38,7 @@ internal static IQueryable ApplyDataView(this IQueryable query, Express /// /// Filters out the deleted entities unless the request includes an optional parameter to include them. /// - /// The type of entity being queries. + /// The type of entity being queried. /// The current representing the query. /// The current being processed. /// A flag to indicate if soft-delete is enabled on the table being queried. @@ -43,6 +46,61 @@ internal static IQueryable ApplyDataView(this IQueryable query, Express internal static IQueryable ApplyDeletedView(this IQueryable query, HttpRequest request, bool enableSoftDelete) where T : ITableData => !enableSoftDelete || request.ShouldIncludeDeletedEntities() ? query : query.Where(e => !e.Deleted); + /// + /// Applies the $filter OData query option to the provided query. + /// + /// The type of entity being queried. + /// The current representing the query. + /// The filter query option to apply. + /// The query settings being used. + /// A modified representing the filtered data. + internal static IQueryable ApplyODataFilter(this IQueryable query, FilterQueryOption? filterQueryOption, ODataQuerySettings settings) + => (IQueryable)(filterQueryOption?.ApplyTo(query, settings) ?? query); + + /// + /// Applies the $orderBy OData query option to the provided query. + /// + /// The type of entity being queried. + /// The current representing the query. + /// The ordering query option to apply. + /// The query settings being used. + /// A modified representing the ordered data. + internal static IQueryable ApplyODataOrderBy(this IQueryable query, OrderByQueryOption? orderingQueryOption, ODataQuerySettings settings) where T : ITableData + => orderingQueryOption?.ApplyTo(query, settings).ThenBy(e => e.Id) ?? query.OrderBy(e => e.Id); + + /// + /// Applies the $skip and $top OData query options to the provided query. + /// + /// The type of entity being queried. + /// The current representing the query. + /// The query options to apply. + /// The query settings being used. + /// A modified representing the paged data. + internal static IQueryable ApplyODataPaging(this IQueryable query, ODataQueryOptions options, ODataQuerySettings settings) + { + int takeValue = Math.Min(options.Top?.Value ?? int.MaxValue, settings.PageSize ?? 100); + int skipValue = Math.Max(options.Skip?.Value ?? 0, 0); + return query.Skip(skipValue).Take(takeValue); + } + + /// + /// Applies the $select OData query option to the provided query. + /// + /// The type of entity being queried. + /// The datset to apply the $select option to. + /// The to apply. + /// The query settings being used. + /// The resulting dataset after property selection. + internal static IEnumerable ApplyODataSelect(this IList dataset, SelectExpandQueryOption? queryOption, ODataQuerySettings settings) + { + if (dataset.Count == 0 || queryOption is null) + { + return dataset.Cast().AsEnumerable(); + } + + return queryOption.ApplyTo(dataset.AsQueryable(), settings).Cast().ToList().Select(x => ((ISelectExpandWrapper)x).ToDictionary()); + } + /// /// Determines if the provided entity is in the view of the current user. /// diff --git a/src/CommunityToolkit.Datasync.Server/Models/TableControllerOptions.cs b/src/CommunityToolkit.Datasync.Server/Models/TableControllerOptions.cs index 91151fc2..ca23c1e7 100644 --- a/src/CommunityToolkit.Datasync.Server/Models/TableControllerOptions.cs +++ b/src/CommunityToolkit.Datasync.Server/Models/TableControllerOptions.cs @@ -29,6 +29,10 @@ public class TableControllerOptions /// will get a 500 Internal Server Error if they attempt to use a query that cannot /// be evaluated by the database. /// + /// + /// This option is no longer used (since v9.0.0) + /// + [Obsolete("Client-side evaluation is no longer supported. This option will be removed in a future release.")] public bool DisableClientSideEvaluation { get; set; } /// @@ -44,7 +48,7 @@ public class TableControllerOptions public int MaxTop { get => this._maxTop; - set + set { ArgumentOutOfRangeException.ThrowIfLessThan(value, 1, nameof(MaxTop)); ArgumentOutOfRangeException.ThrowIfGreaterThan(value, MAX_TOP, nameof(MaxTop)); @@ -58,11 +62,11 @@ public int MaxTop public int PageSize { get => this._pageSize; - set + set { ArgumentOutOfRangeException.ThrowIfLessThan(value, 1, nameof(PageSize)); ArgumentOutOfRangeException.ThrowIfGreaterThan(value, MAX_PAGESIZE, nameof(PageSize)); - this._pageSize = value; + this._pageSize = value; } } diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Offline/OperationsQueueManager_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Offline/OperationsQueueManager_Tests.cs index acb41285..c96a053d 100644 --- a/tests/CommunityToolkit.Datasync.Client.Test/Offline/OperationsQueueManager_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Client.Test/Offline/OperationsQueueManager_Tests.cs @@ -40,7 +40,7 @@ public void GetEntityMap_Works() public async Task GetExistingOperationAsync_InvalidId_Throws() { ClientMovie movie = new() { Id = "###" }; - Func act = async () => _ = await queueManager.GetExistingOperationAsync(context.Entry(movie)); + Func act = async () => _ = await queueManager.GetExistingOperationAsync(this.context.Entry(movie)); await act.Should().ThrowAsync(); } #endregion @@ -134,8 +134,8 @@ public async Task PushAsync_Addition_Works() ClientMovie responseMovie = new(TestData.Movies.BlackPanther) { Id = clientMovie.Id, UpdatedAt = DateTimeOffset.UtcNow, Version = Guid.NewGuid().ToString() }; string expectedJson = DatasyncSerializer.Serialize(responseMovie); this.context.Handler.AddResponseContent(expectedJson, HttpStatusCode.Created); - - PushResult results = await queueManager.PushAsync([ typeof(ClientMovie) ], new PushOptions()); + + PushResult results = await queueManager.PushAsync([typeof(ClientMovie)], new PushOptions()); results.IsSuccessful.Should().BeTrue(); results.CompletedOperations.Should().Be(1); results.FailedRequests.Should().BeEmpty(); @@ -365,7 +365,7 @@ public async Task PushOperationAsync_Throws_InvalidType() EntityType = typeof(Entity1).FullName, ItemId = "123", EntityVersion = string.Empty, - Item = """{}""", + Item = "{}", Sequence = 0, Version = 0 }; @@ -407,7 +407,7 @@ public void NullAsEmpty_Works(string value, string expected) [Fact] public void ToOperationKind_Invalid_Throws() { - EntityState sut = EntityState.Detached; + const EntityState sut = EntityState.Detached; Action act = () => _ = sut.ToOperationKind(); act.Should().Throw(); } @@ -501,8 +501,10 @@ public async Task LLP_ModifyAfterInsertInNewContext_NoPush_ShouldUpdateOperation ClientMovie storedClientMovie = newLlpContext.Movies.First(m => m.Id == id); // ensure that it is a lazy loading proxy and not exactly a ClientMovie +#pragma warning disable CA2263 // Prefer generic overload when type is known storedClientMovie.GetType().Should().NotBe(typeof(ClientMovie)) .And.Subject.Namespace.Should().Be("Castle.Proxies"); +#pragma warning restore CA2263 // Prefer generic overload when type is known storedClientMovie.Title = TestData.Movies.MovieList[1].Title; newLlpContext.SaveChanges(); diff --git a/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/CosmosEntityTableRepository_Tests.cs b/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/CosmosEntityTableRepository_Tests.cs index b9d9f59e..0afc3cc7 100644 --- a/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/CosmosEntityTableRepository_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/CosmosEntityTableRepository_Tests.cs @@ -7,39 +7,45 @@ using Microsoft.EntityFrameworkCore; using Xunit.Abstractions; +#pragma warning disable CS9113 // Parameter is unread. + namespace CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test; [ExcludeFromCodeCoverage] [Collection("LiveTestsCollection")] -public class CosmosEntityTableRepository_Tests : RepositoryTests +public class CosmosEntityTableRepository_Tests(DatabaseFixture fixture, ITestOutputHelper output) : RepositoryTests(), IAsyncLifetime { #region Setup - private readonly DatabaseFixture _fixture; private readonly Random random = new(); - private readonly string connectionString; - private readonly List movies; - private readonly Lazy _context; + private readonly string connectionString = Environment.GetEnvironmentVariable("DATASYNC_COSMOS_CONNECTIONSTRING"); + private List movies = []; - public CosmosEntityTableRepository_Tests(DatabaseFixture fixture, ITestOutputHelper output) : base() + public async Task InitializeAsync() { - this._fixture = fixture; - this.connectionString = Environment.GetEnvironmentVariable("DATASYNC_COSMOS_CONNECTIONSTRING"); if (!string.IsNullOrEmpty(this.connectionString)) { - this._context = new Lazy(() => CosmosDbContext.CreateContext(this.connectionString, output)); - this.movies = Context.Movies.AsNoTracking().ToList(); + Context = await CosmosDbContext.CreateContextAsync(this.connectionString, output); + this.movies = await Context.Movies.AsNoTracking().ToListAsync(); + } + } + + public async Task DisposeAsync() + { + if (Context is not null) + { + await Context.DisposeAsync(); } } - private CosmosDbContext Context { get => this._context.Value; } + private CosmosDbContext Context { get; set; } protected override bool CanRunLiveTests() => !string.IsNullOrEmpty(this.connectionString); - protected override Task GetEntityAsync(string id) - => Task.FromResult(Context.Movies.AsNoTracking().SingleOrDefault(m => m.Id == id)); + protected override async Task GetEntityAsync(string id) + => await Context.Movies.AsNoTracking().SingleOrDefaultAsync(m => m.Id == id); - protected override Task GetEntityCountAsync() - => Task.FromResult(Context.Movies.Count()); + protected override async Task GetEntityCountAsync() + => await Context.Movies.CountAsync(); protected override Task> GetPopulatedRepositoryAsync() => Task.FromResult>(new EntityTableRepository(Context)); diff --git a/tests/CommunityToolkit.Datasync.Server.NSwag.Test/swagger.json b/tests/CommunityToolkit.Datasync.Server.NSwag.Test/swagger.json index a2f6ebc4..d58fbd24 100644 --- a/tests/CommunityToolkit.Datasync.Server.NSwag.Test/swagger.json +++ b/tests/CommunityToolkit.Datasync.Server.NSwag.Test/swagger.json @@ -15,7 +15,7 @@ "tags": [ "KitchenReader" ], - "summary": "The GET method is used to retrieve resource representation. The resource is never modified.\nIn this case, an OData v4 query is accepted with the following options:\n\n\n- $count is used to return a count of entities within the search parameters within the response.\n- $filter is used to restrict the entities to be sent.\n- $orderby is used for ordering the entities to be sent.\n- $select is used to select which properties of the entities are sent.\n- $skip is used to skip some entities\n- $top is used to limit the number of entities returned.", + "summary": "The GET method is used to retrieve resource representation. The resource is never modified.\nIn this case, an OData v4 query is accepted with the following options:\n\n\n- $count is used to return a count of entities within the search parameters within the response.\n- $filter is used to restrict the entities to be sent.\n- $orderby is used for ordering the entities to be sent.\n- $select is used to select which properties of the entities are sent.\n- $skip is used to skip some entities\n- $top is used to limit the number of entities returned.\n\n\nIn addition, the __includeDeleted parameter is used to decide whether to include soft-deleted items in the result.", "operationId": "KitchenReader_Query", "parameters": [ { @@ -295,7 +295,7 @@ "tags": [ "KitchenSink" ], - "summary": "The GET method is used to retrieve resource representation. The resource is never modified.\nIn this case, an OData v4 query is accepted with the following options:\n\n\n- $count is used to return a count of entities within the search parameters within the response.\n- $filter is used to restrict the entities to be sent.\n- $orderby is used for ordering the entities to be sent.\n- $select is used to select which properties of the entities are sent.\n- $skip is used to skip some entities\n- $top is used to limit the number of entities returned.", + "summary": "The GET method is used to retrieve resource representation. The resource is never modified.\nIn this case, an OData v4 query is accepted with the following options:\n\n\n- $count is used to return a count of entities within the search parameters within the response.\n- $filter is used to restrict the entities to be sent.\n- $orderby is used for ordering the entities to be sent.\n- $select is used to select which properties of the entities are sent.\n- $skip is used to skip some entities\n- $top is used to limit the number of entities returned.\n\n\nIn addition, the __includeDeleted parameter is used to decide whether to include soft-deleted items in the result.", "operationId": "KitchenSink_Query", "parameters": [ { @@ -750,7 +750,7 @@ "tags": [ "TodoItem" ], - "summary": "The GET method is used to retrieve resource representation. The resource is never modified.\nIn this case, an OData v4 query is accepted with the following options:\n\n\n- $count is used to return a count of entities within the search parameters within the response.\n- $filter is used to restrict the entities to be sent.\n- $orderby is used for ordering the entities to be sent.\n- $select is used to select which properties of the entities are sent.\n- $skip is used to skip some entities\n- $top is used to limit the number of entities returned.", + "summary": "The GET method is used to retrieve resource representation. The resource is never modified.\nIn this case, an OData v4 query is accepted with the following options:\n\n\n- $count is used to return a count of entities within the search parameters within the response.\n- $filter is used to restrict the entities to be sent.\n- $orderby is used for ordering the entities to be sent.\n- $select is used to select which properties of the entities are sent.\n- $skip is used to skip some entities\n- $top is used to limit the number of entities returned.\n\n\nIn addition, the __includeDeleted parameter is used to decide whether to include soft-deleted items in the result.", "operationId": "TodoItem_Query", "parameters": [ { diff --git a/tests/CommunityToolkit.Datasync.Server.Test/Controllers/TableController_Base_Tests.cs b/tests/CommunityToolkit.Datasync.Server.Test/Controllers/TableController_Base_Tests.cs index 90ba79d6..53b78ecd 100644 --- a/tests/CommunityToolkit.Datasync.Server.Test/Controllers/TableController_Base_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Server.Test/Controllers/TableController_Base_Tests.cs @@ -247,18 +247,4 @@ public async Task PostCommitHookAsync_FiresRepositoryUpdated(TableOperation op) firedEvents[0].Timestamp.Should().BeAfter(this.StartTime).And.BeBefore(DateTimeOffset.UtcNow); } #endregion - - #region CreateNextLink - [Theory] - [InlineData(0, 0, "")] - [InlineData(5, 0, "$skip=5")] - [InlineData(0, 5, "$top=5")] - [InlineData(40, 2, "$skip=40&$top=2")] - public void CreateNextLink_Works(int skip, int top, string expected) - { - const string queryString = null; - string result = TableController.CreateNextLink(queryString, skip, top); - result.Should().Be(expected); - } - #endregion } diff --git a/tests/CommunityToolkit.Datasync.Server.Test/Controllers/TableController_Query_Tests.cs b/tests/CommunityToolkit.Datasync.Server.Test/Controllers/TableController_Query_Tests.cs index 5fe84c64..e4854872 100644 --- a/tests/CommunityToolkit.Datasync.Server.Test/Controllers/TableController_Query_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Server.Test/Controllers/TableController_Query_Tests.cs @@ -39,188 +39,6 @@ public void BuildPagedResult_NulLArg_BuildsPagedResult() } #endregion - #region CatchClientSideEvaluationException - [Fact] - public async Task CatchClientSideEvaluationException_NotCCEE_ThrowsOriginalException() - { - TableController controller = new() { Repository = new InMemoryRepository() }; - ApplicationException exception = new("Original exception"); - - static Task evaluator() { throw new ApplicationException("In evaluator"); } - - Func act = async () => await controller.CatchClientSideEvaluationExceptionAsync(exception, "foo", evaluator); - (await act.Should().ThrowAsync()).WithMessage("Original exception"); - } - - [Fact] - public async Task CatchClientSideEvaluationException_NotCCEE_WithInner_ThrowsOriginalException() - { - TableController controller = new() { Repository = new InMemoryRepository() }; - ApplicationException exception = new("Original exception", new ApplicationException()); - - static Task evaluator() { throw new ApplicationException("In evaluator"); } - - Func act = async () => await controller.CatchClientSideEvaluationExceptionAsync(exception, "foo", evaluator); - (await act.Should().ThrowAsync()).WithMessage("Original exception"); - } - - [Fact] - public async Task CatchClientSideEvaluationException_CCEE_ThrowsEvaluatorException() - { - TableController controller = new() { Repository = new InMemoryRepository() }; - NotSupportedException exception = new("Original exception", new ApplicationException("foo")); - - static Task evaluator() { throw new ApplicationException("In evaluator"); } - - Func act = async () => await controller.CatchClientSideEvaluationExceptionAsync(exception, "foo", evaluator); - (await act.Should().ThrowAsync()).WithMessage("In evaluator"); - } - - [Fact] - public async Task CatchClientSideEvaluationException_CCEEInner_ThrowsEvaluatorException() - { - TableController controller = new() { Repository = new InMemoryRepository() }; - ApplicationException exception = new("Original exception", new NotSupportedException("foo")); - - static Task evaluator() { throw new ApplicationException("In evaluator"); } - - Func act = async () => await controller.CatchClientSideEvaluationExceptionAsync(exception, "foo", evaluator); - (await act.Should().ThrowAsync()).WithMessage("In evaluator"); - } - - [Fact] - public async Task CatchClientSideEvaluationException_CCEE_ExecutesEvaluator() - { - bool isExecuted = false; - TableController controller = new() { Repository = new InMemoryRepository() }; - NotSupportedException exception = new("Original exception", new ApplicationException("foo")); - - Func act = async () => await controller.CatchClientSideEvaluationExceptionAsync(exception, "foo", () => { isExecuted = true; return Task.CompletedTask; }); - await act.Should().NotThrowAsync(); - isExecuted.Should().BeTrue(); - } - - [Fact] - public async Task CatchClientSideEvaluationException_CCEEInner_ExecutesEvaluator() - { - bool isExecuted = false; - TableController controller = new() { Repository = new InMemoryRepository() }; - ApplicationException exception = new("Original exception", new NotSupportedException("foo")); - - Func act = async () => await controller.CatchClientSideEvaluationExceptionAsync(exception, "foo", () => { isExecuted = true; return Task.CompletedTask; }); - await act.Should().NotThrowAsync(); - isExecuted.Should().BeTrue(); - } - #endregion - - #region ExecuteQueryWithClientEvaluation - [Fact] - public async Task ExecuteQueryWithClientEvaluation_ExecutesServiceSide() - { - TableController controller = new() { Repository = new InMemoryRepository() }; - controller.Options.DisableClientSideEvaluation = true; - - int evaluations = 0; - Task evaluator(IQueryable dataset) - { - evaluations++; - // if (evaluations == 1) throw new NotSupportedException("Server side"); - // if (evaluations == 2) throw new NotSupportedException("Client side"); - return Task.CompletedTask; - } - - List dataset = []; - - Func act = async () => await controller.ExecuteQueryWithClientEvaluationAsync(dataset.AsQueryable(), evaluator); - - await act.Should().NotThrowAsync(); - evaluations.Should().Be(1); - } - - [Fact] - public async Task ExecuteQueryWithClientEvaluation_ThrowsServiceSide_WhenClientEvaluationDisabled() - { - TableController controller = new() { Repository = new InMemoryRepository() }; - controller.Options.DisableClientSideEvaluation = true; - - int evaluations = 0; -#pragma warning disable IDE0011 // Add braces - Task evaluator(IQueryable dataset) - { - evaluations++; - if (evaluations == 1) throw new NotSupportedException("Server side"); - if (evaluations == 2) throw new NotSupportedException("Client side"); - return Task.CompletedTask; - } -#pragma warning restore IDE0011 // Add braces - - List dataset = []; - - Func act = async () => await controller.ExecuteQueryWithClientEvaluationAsync(dataset.AsQueryable(), evaluator); - - (await act.Should().ThrowAsync()).WithMessage("Server side"); - } - - [Fact] - public async Task ExecuteQueryWithClientEvaluation_ExecutesClientSide_WhenClientEvaluationEnabled() - { - TableController controller = new() { Repository = new InMemoryRepository() }; - controller.Options.DisableClientSideEvaluation = false; - - int evaluations = 0; -#pragma warning disable IDE0011 // Add braces - Task evaluator(IQueryable dataset) - { - evaluations++; - if (evaluations == 1) throw new NotSupportedException("Server side"); - //if (evaluations == 2) throw new NotSupportedException("Client side"); - return Task.CompletedTask; - } -#pragma warning restore IDE0011 // Add braces - - List dataset = []; - - Func act = async () => await controller.ExecuteQueryWithClientEvaluationAsync(dataset.AsQueryable(), evaluator); - - await act.Should().NotThrowAsync(); - evaluations.Should().Be(2); - } - - [Fact] - public async Task ExecuteQueryWithClientEvaluation_ThrowsClientSide_WhenClientEvaluationEnabled() - { - TableController controller = new() { Repository = new InMemoryRepository() }; - controller.Options.DisableClientSideEvaluation = false; - - int evaluations = 0; -#pragma warning disable IDE0011 // Add braces - Task evaluator(IQueryable dataset) - { - evaluations++; - if (evaluations == 1) throw new NotSupportedException("Server side", new ApplicationException("Inner exception")); - if (evaluations == 2) throw new NotSupportedException("Client side"); - return Task.CompletedTask; - } -#pragma warning restore IDE0011 // Add braces - - List dataset = []; - - Func act = async () => await controller.ExecuteQueryWithClientEvaluationAsync(dataset.AsQueryable(), evaluator); - - (await act.Should().ThrowAsync()).WithMessage("Client side"); - evaluations.Should().Be(2); - } - #endregion - - [Fact] - public void IsClientSideEvaluationException_Works() - { - Assert.False(TableController.IsClientSideEvaluationException(null)); - Assert.True(TableController.IsClientSideEvaluationException(new InvalidOperationException())); - Assert.True(TableController.IsClientSideEvaluationException(new NotSupportedException())); - Assert.False(TableController.IsClientSideEvaluationException(new ApplicationException())); - } - #region QueryAsync [Fact] public async Task QueryAsync_Unauthorized_Throws() diff --git a/tests/CommunityToolkit.Datasync.Server.Test/Helpers/BaseTest.cs b/tests/CommunityToolkit.Datasync.Server.Test/Helpers/BaseTest.cs index dfb0ff9c..0548cb28 100644 --- a/tests/CommunityToolkit.Datasync.Server.Test/Helpers/BaseTest.cs +++ b/tests/CommunityToolkit.Datasync.Server.Test/Helpers/BaseTest.cs @@ -34,7 +34,8 @@ protected static IAccessControlProvider FakeAccessControlProvider FakeRepository(TEntity entity = null, bool throwConflict = false) where TEntity : class, ITableData { - IRepository mock = Substitute.For>(); + AbstractRepository mock = Substitute.ForPartsOf>(); + if (throwConflict) { mock.CreateAsync(Arg.Any(), Arg.Any()).Returns(ValueTask.FromException(new HttpException(409))); @@ -62,6 +63,37 @@ protected static IRepository FakeRepository(TEntity entity = n return mock; } + /// + /// An implementation of that is used in substitution tests. + /// + public abstract class AbstractRepository : IRepository where TEntity : class, ITableData + { + public virtual ValueTask> AsQueryableAsync(CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public virtual ValueTask CreateAsync(TEntity entity, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public virtual ValueTask DeleteAsync(string id, byte[] version = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public virtual ValueTask ReadAsync(string id, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public virtual ValueTask ReplaceAsync(TEntity entity, byte[] version = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + } + protected static HttpContext CreateHttpContext(HttpMethod method, string uri, Dictionary headers = null) { Uri requestUri = new(uri); diff --git a/tests/CommunityToolkit.Datasync.Server.Test/Helpers/LiveControllerTests.cs b/tests/CommunityToolkit.Datasync.Server.Test/Helpers/LiveControllerTests.cs index 76de1aeb..cd434b48 100644 --- a/tests/CommunityToolkit.Datasync.Server.Test/Helpers/LiveControllerTests.cs +++ b/tests/CommunityToolkit.Datasync.Server.Test/Helpers/LiveControllerTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using CommunityToolkit.Datasync.TestCommon; using Microsoft.AspNetCore.Mvc; using System.Net; @@ -10,7 +11,7 @@ namespace CommunityToolkit.Datasync.Server.Test.Helpers; /// /// The base set of tests for the controller tests going against a live server. /// -/// +[ExcludeFromCodeCoverage] public abstract class LiveControllerTests : BaseTest where TEntity : class, ITableData { /// @@ -51,7 +52,7 @@ public abstract class LiveControllerTests : BaseTest where TEntity : cl /// /// The Movie Endpoint to put into the controller context. /// - private const string MovieEndpoint = "http://localhost/tables/movies"; + protected const string MovieEndpoint = "http://localhost/tables/movies"; /// /// The number of movies in the dataset. @@ -80,21 +81,6 @@ protected virtual async Task CreateControllerAsync(HttpMethod method = null, str this.tableController.ControllerContext.HttpContext = CreateHttpContext(method ?? HttpMethod.Get, uri); } - private async Task> GetListOfEntitiesAsync(IEnumerable ids) - { - List entities = []; - foreach (string id in ids) - { - TEntity entity = await GetEntityAsync(id); - if (entity != null) - { - entities.Add(entity); - } - } - - return entities; - } - /// /// This is the base test for the individual query tests. /// @@ -122,8 +108,7 @@ private async Task MovieQueryTest(string pathAndQuery, int itemCount, string nex if (nextLinkQuery is not null) { - result.NextLink.Should().NotBeNull(); - Uri.UnescapeDataString(result.NextLink).Should().Be(nextLinkQuery); + result.NextLink.Should().NotBeNull().And.MatchQueryString(nextLinkQuery); } else { @@ -188,6 +173,7 @@ await MovieQueryTest( public async Task Query_Test_003() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$filter=((year div 1000.5) eq 2) and (rating eq 'R')", @@ -216,6 +202,7 @@ await MovieQueryTest( public async Task Query_Test_005() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$filter=(year div 1000.5) eq 2", @@ -272,6 +259,7 @@ await MovieQueryTest( public async Task Query_Test_009() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$filter=bestPictureWinner eq true and ceiling(duration div 60.0) eq 2", @@ -286,6 +274,7 @@ await MovieQueryTest( public async Task Query_Test_010() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$filter=bestPictureWinner eq true and floor(duration div 60.0) eq 2", @@ -300,6 +289,7 @@ await MovieQueryTest( public async Task Query_Test_011() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$filter=bestPictureWinner eq true and round(duration div 60.0) eq 2", @@ -356,6 +346,7 @@ await MovieQueryTest( public async Task Query_Test_015() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$filter=ceiling(duration div 60.0) eq 2", @@ -370,6 +361,7 @@ await MovieQueryTest( public async Task Query_Test_016() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$filter=day(releaseDate) eq 1", @@ -440,6 +432,7 @@ await MovieQueryTest( public async Task Query_Test_021() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$filter=floor(duration div 60.0) eq 2", @@ -454,6 +447,7 @@ await MovieQueryTest( public async Task Query_Test_022() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$filter=month(releaseDate) eq 11", @@ -566,6 +560,7 @@ await MovieQueryTest( public async Task Query_Test_030() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$filter=releaseDate eq cast(1994-10-14,Edm.Date)", @@ -580,6 +575,7 @@ await MovieQueryTest( public async Task Query_Test_031() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$filter=releaseDate ge cast(1999-12-31,Edm.Date)", @@ -594,6 +590,7 @@ await MovieQueryTest( public async Task Query_Test_032() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$filter=releaseDate gt cast(1999-12-31,Edm.Date)", @@ -608,6 +605,7 @@ await MovieQueryTest( public async Task Query_Test_033() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$filter=releaseDate le cast(2000-01-01,Edm.Date)", @@ -622,6 +620,7 @@ await MovieQueryTest( public async Task Query_Test_034() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$filter=releaseDate lt cast(2000-01-01,Edm.Date)", @@ -636,6 +635,7 @@ await MovieQueryTest( public async Task Query_Test_035() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$filter=round(duration div 60.0) eq 2", @@ -776,6 +776,7 @@ await MovieQueryTest( public async Task Query_Test_046() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$filter=year(releaseDate) eq 1994", @@ -1014,6 +1015,7 @@ await MovieQueryTest( public async Task Query_Test_063() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$top=125&$filter=((year div 1000.5) eq 2) and (rating eq 'R')", @@ -1042,6 +1044,7 @@ await MovieQueryTest( public async Task Query_Test_065() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$top=125&$filter=(year div 1000.5) eq 2", @@ -1098,6 +1101,7 @@ await MovieQueryTest( public async Task Query_Test_069() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$top=125&$filter=bestPictureWinner eq true and ceiling(duration div 60.0) eq 2", @@ -1112,6 +1116,7 @@ await MovieQueryTest( public async Task Query_Test_070() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$top=125&$filter=bestPictureWinner eq true and floor(duration div 60.0) eq 2", @@ -1126,6 +1131,7 @@ await MovieQueryTest( public async Task Query_Test_071() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$top=125&$filter=bestPictureWinner eq true and round(duration div 60.0) eq 2", @@ -1182,6 +1188,7 @@ await MovieQueryTest( public async Task Query_Test_075() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$top=125&$filter=ceiling(duration div 60.0) eq 2", @@ -1196,6 +1203,7 @@ await MovieQueryTest( public async Task Query_Test_076() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$top=125&$filter=day(releaseDate) eq 1", @@ -1266,6 +1274,7 @@ await MovieQueryTest( public async Task Query_Test_081() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$top=125&$filter=floor(duration div 60.0) eq 2", @@ -1280,6 +1289,7 @@ await MovieQueryTest( public async Task Query_Test_082() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$top=125&$filter=month(releaseDate) eq 11", @@ -1392,6 +1402,7 @@ await MovieQueryTest( public async Task Query_Test_090() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$top=125&$filter=releaseDate eq cast(1994-10-14,Edm.Date)", @@ -1406,6 +1417,7 @@ await MovieQueryTest( public async Task Query_Test_091() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$top=125&$filter=releaseDate ge cast(1999-12-31,Edm.Date)", @@ -1420,6 +1432,7 @@ await MovieQueryTest( public async Task Query_Test_092() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$top=125&$filter=releaseDate gt cast(1999-12-31,Edm.Date)", @@ -1434,6 +1447,7 @@ await MovieQueryTest( public async Task Query_Test_093() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$top=125&$filter=releaseDate le cast(2000-01-01,Edm.Date)", @@ -1448,6 +1462,7 @@ await MovieQueryTest( public async Task Query_Test_094() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$top=125&$filter=releaseDate lt cast(2000-01-01,Edm.Date)", @@ -1462,6 +1477,7 @@ await MovieQueryTest( public async Task Query_Test_095() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$top=125&$filter=round(duration div 60.0) eq 2", @@ -1602,6 +1618,7 @@ await MovieQueryTest( public async Task Query_Test_106() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$count=true&$top=125&$filter=year(releaseDate) eq 1994", @@ -1840,6 +1857,7 @@ await MovieQueryTest( public async Task Query_Test_123() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=((year div 1000.5) eq 2) and (rating eq 'R')", @@ -1868,6 +1886,7 @@ await MovieQueryTest( public async Task Query_Test_125() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=(year div 1000.5) eq 2", @@ -1924,6 +1943,7 @@ await MovieQueryTest( public async Task Query_Test_129() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=bestPictureWinner eq true and ceiling(duration div 60.0) eq 2", @@ -1938,6 +1958,7 @@ await MovieQueryTest( public async Task Query_Test_130() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=bestPictureWinner eq true and floor(duration div 60.0) eq 2", @@ -1952,6 +1973,7 @@ await MovieQueryTest( public async Task Query_Test_131() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=bestPictureWinner eq true and round(duration div 60.0) eq 2", @@ -2008,6 +2030,7 @@ await MovieQueryTest( public async Task Query_Test_135() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=ceiling(duration div 60.0) eq 2", @@ -2022,6 +2045,7 @@ await MovieQueryTest( public async Task Query_Test_136() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=day(releaseDate) eq 1", @@ -2092,6 +2116,7 @@ await MovieQueryTest( public async Task Query_Test_141() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=floor(duration div 60.0) eq 2", @@ -2106,6 +2131,7 @@ await MovieQueryTest( public async Task Query_Test_142() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=month(releaseDate) eq 11", @@ -2218,6 +2244,7 @@ await MovieQueryTest( public async Task Query_Test_150() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=releaseDate eq cast(1994-10-14,Edm.Date)", @@ -2232,6 +2259,7 @@ await MovieQueryTest( public async Task Query_Test_151() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=releaseDate ge cast(1999-12-31,Edm.Date)", @@ -2246,6 +2274,7 @@ await MovieQueryTest( public async Task Query_Test_152() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=releaseDate gt cast(1999-12-31,Edm.Date)", @@ -2260,6 +2289,7 @@ await MovieQueryTest( public async Task Query_Test_153() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=releaseDate le cast(2000-01-01,Edm.Date)", @@ -2274,6 +2304,7 @@ await MovieQueryTest( public async Task Query_Test_154() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=releaseDate lt cast(2000-01-01,Edm.Date)", @@ -2288,6 +2319,7 @@ await MovieQueryTest( public async Task Query_Test_155() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=round(duration div 60.0) eq 2", @@ -2428,6 +2460,7 @@ await MovieQueryTest( public async Task Query_Test_166() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=year(releaseDate) eq 1994", @@ -2666,6 +2699,7 @@ await MovieQueryTest( public async Task Query_Test_183() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=((year div 1000.5) eq 2) and (rating eq 'R')&$skip=5", @@ -2693,6 +2727,7 @@ await MovieQueryTest( public async Task Query_Test_185() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=(year div 1000.5) eq 2&$skip=5", @@ -2749,6 +2784,7 @@ await MovieQueryTest( public async Task Query_Test_189() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=bestPictureWinner eq true and ceiling(duration div 60.0) eq 2&$skip=5", @@ -2763,6 +2799,7 @@ await MovieQueryTest( public async Task Query_Test_190() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=bestPictureWinner eq true and floor(duration div 60.0) eq 2&$skip=5", @@ -2777,6 +2814,7 @@ await MovieQueryTest( public async Task Query_Test_191() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=bestPictureWinner eq true and round(duration div 60.0) eq 2&$skip=5", @@ -2833,6 +2871,7 @@ await MovieQueryTest( public async Task Query_Test_195() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=ceiling(duration div 60.0) eq 2&$skip=5", @@ -2847,6 +2886,7 @@ await MovieQueryTest( public async Task Query_Test_196() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=day(releaseDate) eq 1&$skip=5", @@ -2917,6 +2957,7 @@ await MovieQueryTest( public async Task Query_Test_201() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=floor(duration div 60.0) eq 2&$skip=5", @@ -2931,6 +2972,7 @@ await MovieQueryTest( public async Task Query_Test_202() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=month(releaseDate) eq 11&$skip=5", @@ -3043,6 +3085,7 @@ await MovieQueryTest( public async Task Query_Test_210() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=releaseDate eq cast(1994-10-14,Edm.Date)&$skip=5", @@ -3056,6 +3099,7 @@ await MovieQueryTest( public async Task Query_Test_211() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=releaseDate ge cast(1999-12-31,Edm.Date)&$skip=5", @@ -3070,6 +3114,7 @@ await MovieQueryTest( public async Task Query_Test_212() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=releaseDate gt cast(1999-12-31,Edm.Date)&$skip=5", @@ -3084,6 +3129,7 @@ await MovieQueryTest( public async Task Query_Test_213() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=releaseDate le cast(2000-01-01,Edm.Date)&$skip=5", @@ -3098,6 +3144,7 @@ await MovieQueryTest( public async Task Query_Test_214() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=releaseDate lt cast(2000-01-01,Edm.Date)&$skip=5", @@ -3112,6 +3159,7 @@ await MovieQueryTest( public async Task Query_Test_215() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=round(duration div 60.0) eq 2&$skip=5", @@ -3251,6 +3299,7 @@ await MovieQueryTest( public async Task Query_Test_226() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$filter=year(releaseDate) eq 1994&$skip=5", @@ -3544,6 +3593,7 @@ await MovieQueryTest( public async Task Query_Test_247() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$top=5&$filter=((year div 1000.5) eq 2) and (rating eq 'R')", @@ -3572,6 +3622,7 @@ await MovieQueryTest( public async Task Query_Test_249() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$top=5&$filter=(year div 1000.5) eq 2", @@ -3628,6 +3679,7 @@ await MovieQueryTest( public async Task Query_Test_253() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$top=5&$filter=bestPictureWinner eq true and ceiling(duration div 60.0) eq 2", @@ -3642,6 +3694,7 @@ await MovieQueryTest( public async Task Query_Test_254() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$top=5&$filter=bestPictureWinner eq true and floor(duration div 60.0) eq 2", @@ -3656,6 +3709,7 @@ await MovieQueryTest( public async Task Query_Test_255() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$top=5&$filter=bestPictureWinner eq true and round(duration div 60.0) eq 2", @@ -3712,6 +3766,7 @@ await MovieQueryTest( public async Task Query_Test_259() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$top=5&$filter=ceiling(duration div 60.0) eq 2", @@ -3726,6 +3781,7 @@ await MovieQueryTest( public async Task Query_Test_260() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$top=5&$filter=day(releaseDate) eq 1", @@ -3796,6 +3852,7 @@ await MovieQueryTest( public async Task Query_Test_265() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Complex math is not supported"); await MovieQueryTest( $"{MovieEndpoint}?$top=5&$filter=floor(duration div 60.0) eq 2", @@ -3810,6 +3867,7 @@ await MovieQueryTest( public async Task Query_Test_266() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$top=5&$filter=month(releaseDate) eq 11", @@ -3922,6 +3980,7 @@ await MovieQueryTest( public async Task Query_Test_274() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$top=5&$filter=releaseDate eq cast(1994-10-14,Edm.Date)", @@ -3936,6 +3995,7 @@ await MovieQueryTest( public async Task Query_Test_275() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$top=5&$filter=releaseDate ge cast(1999-12-31,Edm.Date)", @@ -3950,6 +4010,7 @@ await MovieQueryTest( public async Task Query_Test_276() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$top=5&$filter=releaseDate gt cast(1999-12-31,Edm.Date)", @@ -3964,6 +4025,7 @@ await MovieQueryTest( public async Task Query_Test_277() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$top=5&$filter=releaseDate le cast(2000-01-01,Edm.Date)", @@ -3978,6 +4040,7 @@ await MovieQueryTest( public async Task Query_Test_278() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$top=5&$filter=releaseDate lt cast(2000-01-01,Edm.Date)", @@ -3992,6 +4055,7 @@ await MovieQueryTest( public async Task Query_Test_279() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$top=5&$filter=round(duration div 60.0) eq 2", @@ -4132,6 +4196,7 @@ await MovieQueryTest( public async Task Query_Test_290() { Skip.IfNot(CanRunLiveTests()); + Skip.If(DriverName == "Cosmos", "Date components are not supported"); await MovieQueryTest( $"{MovieEndpoint}?$top=5&$filter=year(releaseDate) eq 1994", diff --git a/tests/CommunityToolkit.Datasync.Server.Test/Helpers/LiveTestsCollection.cs b/tests/CommunityToolkit.Datasync.Server.Test/Helpers/LiveTestsCollection.cs index 55d3c34b..d2c8f798 100644 --- a/tests/CommunityToolkit.Datasync.Server.Test/Helpers/LiveTestsCollection.cs +++ b/tests/CommunityToolkit.Datasync.Server.Test/Helpers/LiveTestsCollection.cs @@ -7,6 +7,7 @@ namespace CommunityToolkit.Datasync.Server.Test.Helpers; /// /// This can be used to share state between the various live tests. It isn't used right now. /// +[ExcludeFromCodeCoverage] public class DatabaseFixture { public bool AzureSqlIsInitialized { get; set; } = false; @@ -15,6 +16,7 @@ public class DatabaseFixture public bool PgIsInitialized { get; set; } = false; } +[ExcludeFromCodeCoverage] [CollectionDefinition("LiveTestsCollection", DisableParallelization = true)] public class LiveTestsCollection : ICollectionFixture { diff --git a/tests/CommunityToolkit.Datasync.Server.Test/Live/Cosmos_Controller_Tests.cs b/tests/CommunityToolkit.Datasync.Server.Test/Live/Cosmos_Controller_Tests.cs index f7b75ccb..39161d44 100644 --- a/tests/CommunityToolkit.Datasync.Server.Test/Live/Cosmos_Controller_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Server.Test/Live/Cosmos_Controller_Tests.cs @@ -4,35 +4,42 @@ using CommunityToolkit.Datasync.Server.EntityFrameworkCore; using CommunityToolkit.Datasync.Server.Test.Helpers; +using CommunityToolkit.Datasync.TestCommon; using CommunityToolkit.Datasync.TestCommon.Databases; using Microsoft.EntityFrameworkCore; +using System; using Xunit.Abstractions; namespace CommunityToolkit.Datasync.Server.Test.Live; [ExcludeFromCodeCoverage] [Collection("LiveTestsCollection")] -public class Cosmos_Controller_Tests : LiveControllerTests +public class Cosmos_Controller_Tests(DatabaseFixture fixture, ITestOutputHelper output) : LiveControllerTests(), IAsyncLifetime { #region Setup - private readonly DatabaseFixture _fixture; private readonly Random random = new(); - private readonly string connectionString; - private readonly List movies; + private readonly string connectionString = Environment.GetEnvironmentVariable("DATASYNC_COSMOS_CONNECTIONSTRING"); + private List movies = []; - public Cosmos_Controller_Tests(DatabaseFixture fixture, ITestOutputHelper output) : base() + public async Task InitializeAsync() { - this._fixture = fixture; - this.connectionString = Environment.GetEnvironmentVariable("DATASYNC_COSMOS_CONNECTIONSTRING"); if (!string.IsNullOrEmpty(this.connectionString)) { // Note: we don't clear entities on every run to speed up the test runs. This can only be done because // the tests are read-only (associated with the query and get capabilities). If the test being run writes // to the database then change clearEntities to true. - output.WriteLine($"CosmosIsInitialized = {this._fixture.CosmosIsInitialized}"); - Context = CosmosDbContext.CreateContext(this.connectionString, output, clearEntities: !this._fixture.CosmosIsInitialized); - this.movies = [.. Context.Movies.AsNoTracking()]; - this._fixture.CosmosIsInitialized = true; + output.WriteLine($"CosmosIsInitialized = {fixture.CosmosIsInitialized}"); + Context = await CosmosDbContext.CreateContextAsync(this.connectionString, output, clearEntities: !fixture.CosmosIsInitialized); + this.movies = await Context.Movies.AsNoTracking().ToListAsync(); + fixture.CosmosIsInitialized = true; + } + } + + public async Task DisposeAsync() + { + if (Context is not null) + { + await Context.DisposeAsync(); } } @@ -42,11 +49,11 @@ public Cosmos_Controller_Tests(DatabaseFixture fixture, ITestOutputHelper output protected override bool CanRunLiveTests() => !string.IsNullOrEmpty(this.connectionString); - protected override Task GetEntityAsync(string id) - => Task.FromResult(Context.Movies.AsNoTracking().SingleOrDefault(m => m.Id == id)); + protected override async Task GetEntityAsync(string id) + => await Context.Movies.AsNoTracking().SingleOrDefaultAsync(m => m.Id == id); - protected override Task GetEntityCountAsync() - => Task.FromResult(Context.Movies.Count()); + protected override async Task GetEntityCountAsync() + => await Context.Movies.CountAsync(); protected override Task> GetPopulatedRepositoryAsync() => Task.FromResult>(new EntityTableRepository(Context)); @@ -54,4 +61,22 @@ protected override Task> GetPopulatedRepositoryAs protected override Task GetRandomEntityIdAsync(bool exists) => Task.FromResult(exists ? this.movies[this.random.Next(this.movies.Count)].Id : Guid.NewGuid().ToString()); #endregion + + /// + /// We test the 400 Bad Request client-side evaluation error here because Cosmos has more restrictions than most, + /// so it's easier to test the code path. + /// + [SkippableFact] + public async Task ClientSideEvaluation_Produces_400BadRequest() + { + Skip.IfNot(CanRunLiveTests()); + + IRepository repository = await GetPopulatedRepositoryAsync(); + TableController tableController = new(repository); + tableController.ControllerContext.HttpContext = CreateHttpContext(HttpMethod.Get, $"{MovieEndpoint}?$filter=((year div 1000.5) eq 2)"); + + Func act = async () => _ = await tableController.QueryAsync(); + + (await act.Should().ThrowAsync()).WithStatusCode(400); + } } diff --git a/tests/CommunityToolkit.Datasync.Server.Test/Models/TableControllerOptions_Tests.cs b/tests/CommunityToolkit.Datasync.Server.Test/Models/TableControllerOptions_Tests.cs index 26be8a03..1fa1fc21 100644 --- a/tests/CommunityToolkit.Datasync.Server.Test/Models/TableControllerOptions_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Server.Test/Models/TableControllerOptions_Tests.cs @@ -14,7 +14,6 @@ public void Ctor_DefaultsDontChange() { TableControllerOptions sut = new(); - sut.DisableClientSideEvaluation.Should().BeFalse(); sut.EnableSoftDelete.Should().BeFalse(); sut.MaxTop.Should().Be(128000); sut.PageSize.Should().Be(100); @@ -37,9 +36,8 @@ public void Ctor_NoNegativeNumbers(int pageSize, int maxTop) [Fact] public void Ctor_Roundtrips() { - TableControllerOptions sut = new() { DisableClientSideEvaluation = true, EnableSoftDelete = true, MaxTop = 100, PageSize = 50, UnauthorizedStatusCode = 510 }; + TableControllerOptions sut = new() { EnableSoftDelete = true, MaxTop = 100, PageSize = 50, UnauthorizedStatusCode = 510 }; - sut.DisableClientSideEvaluation.Should().BeTrue(); sut.EnableSoftDelete.Should().BeTrue(); sut.MaxTop.Should().Be(100); sut.PageSize.Should().Be(50); diff --git a/tests/CommunityToolkit.Datasync.Server.Test/Service/Query_Tests.cs b/tests/CommunityToolkit.Datasync.Server.Test/Service/Query_Tests.cs index 0fc7ee1c..90e48bda 100644 --- a/tests/CommunityToolkit.Datasync.Server.Test/Service/Query_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Server.Test/Service/Query_Tests.cs @@ -3935,13 +3935,12 @@ private async Task MovieQueryTest(string pathAndQuery, int itemCount, string nex PageOfItems result = JsonSerializer.Deserialize>(content, this.serializerOptions); // Payload has the right content - Assert.Equal(itemCount, result!.Items!.Length); - Assert.Equal(nextLinkQuery, result.NextLink == null ? null : Uri.UnescapeDataString(result.NextLink)); - Assert.Equal(totalCount, result.Count); + result?.Items?.Length.Should().Be(itemCount); + result.NextLink.Should().MatchQueryString(nextLinkQuery); + result.Count.Should().Be(totalCount); // The first n items must match what is expected - Assert.True(result.Items.Length >= firstItems.Length); - Assert.Equal(firstItems, result.Items.Take(firstItems.Length).Select(m => m.Id).ToArray()); + result.Items.Take(firstItems.Length).Select(m => m.Id).ToList().Should().ContainInConsecutiveOrder(firstItems); for (int idx = 0; idx < firstItems.Length; idx++) { InMemoryMovie expected = this.factory.GetServerEntityById(firstItems[idx])!; diff --git a/tests/CommunityToolkit.Datasync.TestCommon/Databases/Base/BaseDbContext.cs b/tests/CommunityToolkit.Datasync.TestCommon/Databases/Base/BaseDbContext.cs index 73f06a1a..4a036c67 100644 --- a/tests/CommunityToolkit.Datasync.TestCommon/Databases/Base/BaseDbContext.cs +++ b/tests/CommunityToolkit.Datasync.TestCommon/Databases/Base/BaseDbContext.cs @@ -80,4 +80,36 @@ protected void PopulateDatabase() SaveChanges(); } + + protected async Task PopulateDatabaseAsync() + { + int entityCount = await Movies.CountAsync(); + if (entityCount > 0) + { + return 0; + } + + List movies = [.. TestData.Movies.OfType()]; + MovieIds = movies.ConvertAll(m => m.Id); + + // Make sure we are populating with the right data + bool setUpdatedAt = Attribute.IsDefined(typeof(TEntity).GetProperty("UpdatedAt")!, typeof(UpdatedByRepositoryAttribute)); + bool setVersion = Attribute.IsDefined(typeof(TEntity).GetProperty("Version")!, typeof(UpdatedByRepositoryAttribute)); + foreach (TEntity movie in movies) + { + if (setUpdatedAt) + { + movie.UpdatedAt = DateTimeOffset.UtcNow; + } + + if (setVersion) + { + movie.Version = Guid.NewGuid().ToByteArray(); + } + + Movies.Add(movie); + } + + return await SaveChangesAsync(); + } } \ No newline at end of file diff --git a/tests/CommunityToolkit.Datasync.TestCommon/Databases/CosmosDb/CosmosDbContext.cs b/tests/CommunityToolkit.Datasync.TestCommon/Databases/CosmosDb/CosmosDbContext.cs index 3ca7f146..7e0c3531 100644 --- a/tests/CommunityToolkit.Datasync.TestCommon/Databases/CosmosDb/CosmosDbContext.cs +++ b/tests/CommunityToolkit.Datasync.TestCommon/Databases/CosmosDb/CosmosDbContext.cs @@ -2,17 +2,16 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Xunit.Abstractions; -using Microsoft.EntityFrameworkCore.Diagnostics; namespace CommunityToolkit.Datasync.TestCommon.Databases; [ExcludeFromCodeCoverage] public class CosmosDbContext(DbContextOptions options) : BaseDbContext(options) { - public static CosmosDbContext CreateContext(string connectionString, ITestOutputHelper output = null, bool clearEntities = true) + public static async Task CreateContextAsync(string connectionString, ITestOutputHelper output = null, bool clearEntities = true) { if (string.IsNullOrEmpty(connectionString)) { @@ -24,24 +23,29 @@ public static CosmosDbContext CreateContext(string connectionString, ITestOutput .EnableLogging(output); CosmosDbContext context = new(optionsBuilder.Options); - context.InitializeDatabase(clearEntities); - context.PopulateDatabase(); + await context.InitializeDatabaseAsync(clearEntities); + await context.PopulateDatabaseAsync(); return context; } - internal void InitializeDatabase(bool clearEntities) + internal async Task InitializeDatabaseAsync(bool clearEntities) { if (clearEntities) { - RemoveRange(Movies.ToList()); - SaveChanges(); + List movies = await Movies.ToListAsync(); + if (movies.Count > 0) + { + RemoveRange(movies); + await SaveChangesAsync(); + } } } - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.ConfigureWarnings(w => w.Ignore(CosmosEventId.SyncNotSupported)); - } + // (Issue #199) - remove the sync over async capabilities introduced in .NET 9 + //protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + //{ + // optionsBuilder.ConfigureWarnings(w => w.Ignore(CosmosEventId.SyncNotSupported)); + //} protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/tests/CommunityToolkit.Datasync.TestCommon/FluentExtensions/StringAssertions.cs b/tests/CommunityToolkit.Datasync.TestCommon/FluentExtensions/StringAssertions.cs index 1fa44020..dc071fb5 100644 --- a/tests/CommunityToolkit.Datasync.TestCommon/FluentExtensions/StringAssertions.cs +++ b/tests/CommunityToolkit.Datasync.TestCommon/FluentExtensions/StringAssertions.cs @@ -5,6 +5,8 @@ using FluentAssertions.Execution; using FluentAssertions.Primitives; using FluentAssertions; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Primitives; namespace CommunityToolkit.Datasync.TestCommon; @@ -22,4 +24,18 @@ public static AndConstraint BeAGuid(this StringAssertions curr .FailWith("Expected object to be a Guid, but found {0}", current.Subject); return new AndConstraint(current); } + + public static AndConstraint MatchQueryString(this StringAssertions current, string queryString, string because = "", params object[] becauseArgs) + { + Dictionary q1 = QueryHelpers.ParseNullableQuery(queryString) ?? []; + Dictionary q2 = QueryHelpers.ParseNullableQuery(current.Subject) ?? []; + bool isEquivalent = q1.Count == q2.Count && !q1.Except(q2).Any(); + + Execute.Assertion + .BecauseOf(because, becauseArgs) + .ForCondition(isEquivalent) + .FailWith("Expected query string to match '{0}', but found '{1}'", queryString, current.Subject); + return new AndConstraint(current); + + } } diff --git a/tests/CommunityToolkit.Datasync.TestCommon/RepositoryTests.cs b/tests/CommunityToolkit.Datasync.TestCommon/RepositoryTests.cs index 68839fc0..ded14447 100644 --- a/tests/CommunityToolkit.Datasync.TestCommon/RepositoryTests.cs +++ b/tests/CommunityToolkit.Datasync.TestCommon/RepositoryTests.cs @@ -65,19 +65,6 @@ public async Task AsQueryableAsync_ReturnsQueryable() sut.Should().NotBeNull().And.BeAssignableTo>(); } - [SkippableFact] - public async Task AsQueryableAsync_CanRetrieveSingleItems() - { - Skip.IfNot(CanRunLiveTests()); - - IRepository Repository = await GetPopulatedRepositoryAsync(); - string id = await GetRandomEntityIdAsync(true); - TEntity expected = await GetEntityAsync(id); - TEntity actual = (await Repository.AsQueryableAsync()).Single(m => m.Id == id); - - actual.Should().BeEquivalentTo(expected); - } - [SkippableFact] public async Task AsQueryableAsync_CanRetrieveFilteredLists() { @@ -86,20 +73,7 @@ public async Task AsQueryableAsync_CanRetrieveFilteredLists() IRepository Repository = await GetPopulatedRepositoryAsync(); int expected = TestData.Movies.Count(m => m.Rating == MovieRating.R); IQueryable queryable = await Repository.AsQueryableAsync(); - List actual = queryable.Where(m => m.Rating == MovieRating.R).ToList(); - - actual.Should().HaveCount(expected); - } - - [SkippableFact] - public async Task AsQueryableAsync_CanSelectFromList() - { - Skip.IfNot(CanRunLiveTests()); - - IRepository Repository = await GetPopulatedRepositoryAsync(); - int expected = TestData.Movies.Count(m => m.Rating == MovieRating.R); - IQueryable queryable = await Repository.AsQueryableAsync(); - var actual = queryable.Where(m => m.Rating == MovieRating.R).Select(m => new { m.Id, m.Title }).ToList(); + IList actual = await Repository.ToListAsync(queryable.Where(m => m.Rating == MovieRating.R)); actual.Should().HaveCount(expected); } @@ -111,7 +85,7 @@ public async Task AsQueryableAsync_CanUseTopAndSkip() IRepository Repository = await GetPopulatedRepositoryAsync(); IQueryable queryable = await Repository.AsQueryableAsync(); - List actual = queryable.Where(m => m.Rating == MovieRating.R).Skip(5).Take(20).ToList(); + IList actual = await Repository.ToListAsync(queryable.Where(m => m.Rating == MovieRating.R).Skip(5).Take(20)); actual.Should().HaveCount(20); } @@ -126,7 +100,7 @@ public async Task AsQueryableAsync_CanRetrievePagedDatasyncQuery() IRepository Repository = await GetPopulatedRepositoryAsync(); IQueryable queryable = await Repository.AsQueryableAsync(); - List actual = queryable.Where(m => m.UpdatedAt > DateTimeOffset.UnixEpoch && !m.Deleted).OrderBy(m => m.UpdatedAt).Skip(10).Take(10).ToList(); + IList actual = await Repository.ToListAsync(queryable.Where(m => m.UpdatedAt > DateTimeOffset.UnixEpoch && !m.Deleted).OrderBy(m => m.UpdatedAt).Skip(10).Take(10)); actual.Should().HaveCount(10); } @@ -182,7 +156,9 @@ public async Task CreateAsync_ThrowsConflict() TEntity expected = await GetEntityAsync(id); Func act = async () => await Repository.CreateAsync(sut); - (await act.Should().ThrowAsync()).WithStatusCode(409).And.WithPayload(expected); + HttpException ex = (await act.Should().ThrowAsync()).Subject.First(); + ex.StatusCode.Should().Be(409); + ex.Payload.Should().BeEquivalentTo(expected).And.HaveEquivalentMetadataTo(expected); } [SkippableFact] diff --git a/tests/CommunityToolkit.Datasync.TestService/AccessControlProviders/MovieAccessControlProvider.cs b/tests/CommunityToolkit.Datasync.TestService/AccessControlProviders/MovieAccessControlProvider.cs index adcad3d4..0922e9fb 100644 --- a/tests/CommunityToolkit.Datasync.TestService/AccessControlProviders/MovieAccessControlProvider.cs +++ b/tests/CommunityToolkit.Datasync.TestService/AccessControlProviders/MovieAccessControlProvider.cs @@ -7,6 +7,7 @@ namespace CommunityToolkit.Datasync.TestService.AccessControlProviders; +[ExcludeFromCodeCoverage] public class MovieAccessControlProvider : AccessControlProvider where T : class, IMovie, ITableData { /// diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 7394a12d..48e94bbc 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -25,6 +25,7 @@ true 13.0 en + $(NoWarn);7022 net9.0