diff --git a/src/CommunityToolkit.Datasync.Server.Abstractions/Tables/IRepository.cs b/src/CommunityToolkit.Datasync.Server.Abstractions/Tables/IRepository.cs index 268b0db..9a5fde7 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); + + /// + /// Executes a count against the query provided, which came from this data store. This allows you + /// to override the count operation to provide a more efficient count operation. + /// + /// The queryable being counted. + /// A to observe. + /// The count of entities matching the query. + ValueTask CountAsync(IQueryable query, CancellationToken cancellationToken = default) + => ValueTask.FromResult(query.Cast().Count()); + + /// + /// Executes a query retrieval against the query provided, which came from this data store. This allows you + /// to override the ToList operation to provide a more efficient operation. + /// + /// The queryable being executed. + /// A to observe. + /// The entities matching the query. + ValueTask> ToListAsync(IQueryable query, CancellationToken cancellationToken = default) + => ValueTask.FromResult(query.Cast().ToList()); } diff --git a/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableRepository.cs b/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableRepository.cs index 554b4a6..965bc9a 100644 --- a/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableRepository.cs +++ b/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableRepository.cs @@ -209,5 +209,23 @@ await WrapExceptionAsync(entity.Id, async () => _ = await Context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); }, cancellationToken).ConfigureAwait(false); } + + /// + /// + /// The entity framework core edition of this method uses the async method. + /// + public virtual async ValueTask CountAsync(IQueryable query, CancellationToken cancellationToken = default) + { + return await EntityFrameworkQueryableExtensions.CountAsync(query.Cast(), cancellationToken).ConfigureAwait(false); + } + + /// + /// + /// The entity framework core edition of this method uses the async method. + /// + public virtual async ValueTask> ToListAsync(IQueryable query, CancellationToken cancellationToken = default) + { + return await EntityFrameworkQueryableExtensions.ToListAsync(query.Cast(), cancellationToken).ConfigureAwait(false); + } #endregion } diff --git a/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs b/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs index 5e6d75b..02ad707 100644 --- a/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs +++ b/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs @@ -69,22 +69,34 @@ 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 => + List? results = null; + await ExecuteQueryWithClientEvaluationAsync(dataset, async ds => { - results = (IEnumerable)queryOptions.ApplyTo(ds, querySettings); - return Task.CompletedTask; + IQueryable query = queryOptions.ApplyTo(ds, querySettings); + // results = query.Cast().ToList(); + results = await Repository.ToListAsync(queryOptions.ApplyTo(ds, querySettings), cancellationToken).ConfigureAwait(false); + + // If the request results in an ISelectExpandWrapper, then $select was used and + // the model will be incomplete. JSON rendering just turns this into a dictionary, + // so we'll do the same here. + if (results.Count > 0) + { + for (int i = 0; i < results.Count; i++) + { + if (results[i] is ISelectExpandWrapper wrapper) + { + results[i] = wrapper.ToDictionary(); + } + } + } }); 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); + IQueryable q = (IQueryable)(queryOptions.Filter?.ApplyTo(ds, new ODataQuerySettings()) ?? ds); + // count = q.Cast().Count(); + count = await CountAsync(q, cancellationToken).ConfigureAwait(false); }); PagedResult result = BuildPagedResult(queryOptions, results, count); diff --git a/src/CommunityToolkit.Datasync.Server/Models/DatasyncServiceOptions.cs b/src/CommunityToolkit.Datasync.Server/Models/DatasyncServiceOptions.cs index 7cb1a29..48ababb 100644 --- a/src/CommunityToolkit.Datasync.Server/Models/DatasyncServiceOptions.cs +++ b/src/CommunityToolkit.Datasync.Server/Models/DatasyncServiceOptions.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using CommunityToolkit.Datasync.Server.Abstractions.Json; +using CommunityToolkit.Datasync.Server.OData; using System.Text.Json; using System.Text.Json.Serialization; diff --git a/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/AzureSqlEntityTableRepository_Tests.cs b/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/AzureSqlEntityTableRepository_Tests.cs index 0511b1b..9f91206 100644 --- a/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/AzureSqlEntityTableRepository_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/AzureSqlEntityTableRepository_Tests.cs @@ -35,8 +35,8 @@ public AzureSqlEntityTableRepository_Tests(DatabaseFixture fixture, ITestOutputH 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()); diff --git a/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/CosmosEntityTableRepository_Tests.cs b/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/CosmosEntityTableRepository_Tests.cs index 91038e2..755c5ff 100644 --- a/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/CosmosEntityTableRepository_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/CosmosEntityTableRepository_Tests.cs @@ -4,11 +4,10 @@ using CommunityToolkit.Datasync.TestCommon; using CommunityToolkit.Datasync.TestCommon.Databases; -using CommunityToolkit.Datasync.TestCommon.Models; using Microsoft.EntityFrameworkCore; using Xunit.Abstractions; -using TestData = CommunityToolkit.Datasync.TestCommon.TestData; +#pragma warning disable CS9113 // Parameter is unread. namespace CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test; @@ -21,18 +20,13 @@ namespace CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test; /// [ExcludeFromCodeCoverage] [Collection("LiveTestsCollection")] -public class CosmosEntityTableRepository_Tests(DatabaseFixture fixture, ITestOutputHelper output) : IAsyncLifetime +public class CosmosEntityTableRepository_Tests(DatabaseFixture fixture, ITestOutputHelper output) : RepositoryTests, IAsyncLifetime { #region Setup private readonly Random random = new(); private string connectionString = string.Empty; private List movies; - /// - /// The time that the current test started. - /// - protected DateTimeOffset StartTime { get; } = DateTimeOffset.UtcNow; - public async Task InitializeAsync() { this.connectionString = Environment.GetEnvironmentVariable("DATASYNC_COSMOS_CONNECTIONSTRING"); @@ -50,18 +44,18 @@ public Task DisposeAsync() private CosmosDbContext Context { get; set; } - protected bool CanRunLiveTests() => !string.IsNullOrEmpty(this.connectionString); + protected override bool CanRunLiveTests() => !string.IsNullOrEmpty(this.connectionString); - protected Task GetEntityAsync(string id) + protected override Task GetEntityAsync(string id) => Context.Movies.AsNoTracking().SingleOrDefaultAsync(m => m.Id == id); - protected Task GetEntityCountAsync() + protected override Task GetEntityCountAsync() => Context.Movies.CountAsync(); - protected Task> GetPopulatedRepositoryAsync() + protected override Task> GetPopulatedRepositoryAsync() => Task.FromResult>(new EntityTableRepository(Context)); - protected Task GetRandomEntityIdAsync(bool exists) + protected override Task GetRandomEntityIdAsync(bool exists) => Task.FromResult(exists ? this.movies[this.random.Next(this.movies.Count)].Id : Guid.NewGuid().ToString()); #endregion @@ -108,377 +102,4 @@ public async Task WrapExceptionAsync_ThrowsRepositoryException_WhenDbUpdateExcep Func act = async () => await repository.WrapExceptionAsync(id, innerAction); await act.Should().ThrowAsync(); } - - /// - /// The tests below this line are direct copies of the RepositoryTests tests, but - /// specifically modified for Cosmos DB. - /// - /// - #region Repository Tests - #region AsQueryableAsync - [SkippableFact] - public async Task AsQueryableAsync_ReturnsQueryable() - { - Skip.IfNot(CanRunLiveTests()); - - IRepository Repository = await GetPopulatedRepositoryAsync(); - IQueryable sut = await Repository.AsQueryableAsync(); - - sut.Should().NotBeNull().And.BeAssignableTo>(); - } - - [SkippableFact] - public async Task AsQueryableAsync_CanRetrieveSingleItems() - { - Skip.IfNot(CanRunLiveTests()); - - IRepository Repository = await GetPopulatedRepositoryAsync(); - string id = await GetRandomEntityIdAsync(true); - CosmosEntityMovie expected = await GetEntityAsync(id); - IQueryable queryable = await Repository.AsQueryableAsync(); - CosmosEntityMovie actual = queryable.Single(m => m.Id == id); - - actual.Should().BeEquivalentTo(expected); - } - - [SkippableFact] - public async Task AsQueryableAsync_CanRetrieveFilteredLists() - { - Skip.IfNot(CanRunLiveTests()); - - IRepository Repository = await GetPopulatedRepositoryAsync(); - int expected = TestData.Movies.Count(m => m.Rating == MovieRating.R); - IQueryable queryable = await Repository.AsQueryableAsync(); - List actual = await queryable.Where(m => m.Rating == MovieRating.R).ToListAsync(); - - 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(); - - actual.Should().HaveCount(expected); - } - - [SkippableFact] - public async Task AsQueryableAsync_CanUseTopAndSkip() - { - Skip.IfNot(CanRunLiveTests()); - - IRepository Repository = await GetPopulatedRepositoryAsync(); - IQueryable queryable = await Repository.AsQueryableAsync(); - List actual = await queryable.Where(m => m.Rating == MovieRating.R).Skip(5).Take(20).ToListAsync(); - - actual.Should().HaveCount(20); - } - - /// - /// This test simulates a paged response from the client for a datasync operation. - /// - [SkippableFact] - public async Task AsQueryableAsync_CanRetrievePagedDatasyncQuery() - { - Skip.IfNot(CanRunLiveTests()); - - 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(); - - actual.Should().HaveCount(10); - } - #endregion - - #region CreateAsync - [SkippableFact] - public async Task CreateAsync_CreatesNewEntity_WithSpecifiedId() - { - Skip.IfNot(CanRunLiveTests()); - - IRepository Repository = await GetPopulatedRepositoryAsync(); - string id = await GetRandomEntityIdAsync(false); - CosmosEntityMovie addition = TestData.Movies.OfType(TestData.Movies.BlackPanther, id); - CosmosEntityMovie sut = addition.Clone(); - await Repository.CreateAsync(sut); - CosmosEntityMovie actual = await GetEntityAsync(id); - - actual.Should().BeEquivalentTo(addition); - actual.Should().NotBeEquivalentTo(addition).And.HaveEquivalentMetadataTo(sut); - actual.Id.Should().Be(id); - actual.UpdatedAt.Should().BeAfter(StartTime); - actual.Version.Should().NotBeNullOrEmpty(); - } - - [SkippableTheory] - [InlineData(null)] - [InlineData("")] - public async Task CreateAsync_CreatesNewEntity_WithNullId(string id) - { - Skip.IfNot(CanRunLiveTests()); - - IRepository Repository = await GetPopulatedRepositoryAsync(); - CosmosEntityMovie addition = TestData.Movies.OfType(TestData.Movies.BlackPanther); - addition.Id = id; - CosmosEntityMovie sut = addition.Clone(); - await Repository.CreateAsync(sut); - CosmosEntityMovie actual = await GetEntityAsync(sut.Id); - - actual.Should().BeEquivalentTo(addition); - actual.UpdatedAt.Should().BeAfter(StartTime); - } - - [SkippableFact] - public async Task CreateAsync_ThrowsConflict() - { - Skip.IfNot(CanRunLiveTests()); - - IRepository Repository = await GetPopulatedRepositoryAsync(); - string id = await GetRandomEntityIdAsync(true); - CosmosEntityMovie addition = TestData.Movies.OfType(TestData.Movies.BlackPanther, id); - CosmosEntityMovie sut = addition.Clone(); - CosmosEntityMovie expected = await GetEntityAsync(id); - Func act = async () => await Repository.CreateAsync(sut); - - (await act.Should().ThrowAsync()).WithStatusCode(409).And.WithPayload(expected); - } - - [SkippableFact] - public async Task CreateAsync_UpdatesMetadata() - { - Skip.IfNot(CanRunLiveTests()); - - IRepository Repository = await GetPopulatedRepositoryAsync(); - string id = await GetRandomEntityIdAsync(false); - CosmosEntityMovie addition = TestData.Movies.OfType(TestData.Movies.BlackPanther, id); - CosmosEntityMovie sut = addition.Clone(); - sut.UpdatedAt = DateTimeOffset.UtcNow.AddDays(-1); - byte[] expectedVersion = [ 0x01, 0x02, 0x03, 0x04 ]; - sut.Version = [.. expectedVersion]; - await Repository.CreateAsync(sut); - CosmosEntityMovie actual = await GetEntityAsync(id); - - actual.Should().BeEquivalentTo(addition); - actual.Should().NotBeEquivalentTo(addition).And.HaveEquivalentMetadataTo(sut); - actual.Id.Should().Be(id); - actual.UpdatedAt.Should().BeAfter(StartTime); - actual.Version.Should().NotBeEquivalentTo(expectedVersion); - } - - [SkippableFact] - public async Task CreateAsync_StoresDisconnectedEntity() - { - Skip.IfNot(CanRunLiveTests()); - - IRepository Repository = await GetPopulatedRepositoryAsync(); - string id = await GetRandomEntityIdAsync(false); - CosmosEntityMovie addition = TestData.Movies.OfType(TestData.Movies.BlackPanther, id); - CosmosEntityMovie sut = addition.Clone(); - sut.UpdatedAt = DateTimeOffset.UtcNow.AddDays(-1); - byte[] expectedVersion = [0x01, 0x02, 0x03, 0x04]; - sut.Version = [.. expectedVersion]; - await Repository.CreateAsync(sut); - CosmosEntityMovie actual = await GetEntityAsync(id); - - actual.Should().NotBeSameAs(sut); - } - #endregion - - #region DeleteAsync - [SkippableTheory] - [InlineData(null)] - [InlineData("")] - public async Task DeleteAsync_Throws_OnBadIds(string id) - { - Skip.IfNot(CanRunLiveTests()); - - IRepository Repository = await GetPopulatedRepositoryAsync(); - Func act = async () => await Repository.DeleteAsync(id); - - (await act.Should().ThrowAsync()).WithStatusCode(400); - (await GetEntityCountAsync()).Should().Be(TestData.Movies.Count()); - } - - [SkippableFact] - public async Task DeleteAsync_Throws_OnMissingIds() - { - Skip.IfNot(CanRunLiveTests()); - - IRepository Repository = await GetPopulatedRepositoryAsync(); - string id = await GetRandomEntityIdAsync(false); - Func act = async () => await Repository.DeleteAsync(id); - - (await act.Should().ThrowAsync()).WithStatusCode(404); - (await GetEntityCountAsync()).Should().Be(TestData.Movies.Count()); - } - - [SkippableFact] - public async Task DeleteAsync_Throws_WhenVersionMismatch() - { - Skip.IfNot(CanRunLiveTests()); - - IRepository Repository = await GetPopulatedRepositoryAsync(); - string id = await GetRandomEntityIdAsync(true); - CosmosEntityMovie expected = await GetEntityAsync(id); - byte[] version = Guid.NewGuid().ToByteArray(); - Func act = async () => await Repository.DeleteAsync(id, version); - - (await act.Should().ThrowAsync()).WithStatusCode(412).And.WithPayload(expected); - } - - [SkippableFact] - public async Task DeleteAsync_Deletes_WhenVersionMatch() - { - Skip.IfNot(CanRunLiveTests()); - - IRepository Repository = await GetPopulatedRepositoryAsync(); - string id = await GetRandomEntityIdAsync(true); - CosmosEntityMovie expected = await GetEntityAsync(id); - byte[] version = expected.Version.ToArray(); - await Repository.DeleteAsync(id, version); - CosmosEntityMovie actual = await GetEntityAsync(id); - - actual.Should().BeNull(); - } - - [SkippableFact] - public async Task DeleteAsync_Deletes_WhenNoVersion() - { - Skip.IfNot(CanRunLiveTests()); - - IRepository Repository = await GetPopulatedRepositoryAsync(); - string id = await GetRandomEntityIdAsync(true); - await Repository.DeleteAsync(id); - CosmosEntityMovie actual = await GetEntityAsync(id); - - actual.Should().BeNull(); - } - #endregion - - #region ReadAsync - [SkippableFact] - public async Task ReadAsync_ReturnsDisconnectedEntity() - { - Skip.IfNot(CanRunLiveTests()); - - IRepository Repository = await GetPopulatedRepositoryAsync(); - string id = await GetRandomEntityIdAsync(true); - CosmosEntityMovie expected = await GetEntityAsync(id); - CosmosEntityMovie actual = await Repository.ReadAsync(id); - - actual.Should().BeEquivalentTo(expected).And.NotBeSameAs(expected); - } - - [SkippableTheory] - [InlineData(null)] - [InlineData("")] - public async Task ReadAsync_Throws_OnBadId(string id) - { - Skip.IfNot(CanRunLiveTests()); - - IRepository Repository = await GetPopulatedRepositoryAsync(); - Func act = async () => _ = await Repository.ReadAsync(id); - - (await act.Should().ThrowAsync()).WithStatusCode(400); - } - - [SkippableFact] - public async Task ReadAsync_Throws_OnMissingId() - { - Skip.IfNot(CanRunLiveTests()); - - IRepository Repository = await GetPopulatedRepositoryAsync(); - string id = await GetRandomEntityIdAsync(false); - Func act = async () => _ = await Repository.ReadAsync(id); - - (await act.Should().ThrowAsync()).WithStatusCode(404); - } - #endregion - - #region ReplaceAsync - [SkippableTheory] - [InlineData(null)] - [InlineData("")] - public async Task ReplaceAsync_Throws_OnBadId(string id) - { - Skip.IfNot(CanRunLiveTests()); - - IRepository Repository = await GetPopulatedRepositoryAsync(); - CosmosEntityMovie replacement = TestData.Movies.OfType(TestData.Movies.BlackPanther); - replacement.Id = id; - Func act = async () => await Repository.ReplaceAsync(replacement); - - (await act.Should().ThrowAsync()).WithStatusCode(400); - } - - [SkippableFact] - public async Task ReplaceAsync_Throws_OnMissingId() - { - Skip.IfNot(CanRunLiveTests()); - - IRepository Repository = await GetPopulatedRepositoryAsync(); - string id = await GetRandomEntityIdAsync(false); - CosmosEntityMovie replacement = TestData.Movies.OfType(TestData.Movies.BlackPanther, id); - Func act = async () => await Repository.ReplaceAsync(replacement); - - (await act.Should().ThrowAsync()).WithStatusCode(404); - } - - [SkippableFact] - public async Task ReplaceAsync_Throws_OnVersionMismatch() - { - Skip.IfNot(CanRunLiveTests()); - - IRepository Repository = await GetPopulatedRepositoryAsync(); - string id = await GetRandomEntityIdAsync(true); - CosmosEntityMovie replacement = TestData.Movies.OfType(TestData.Movies.BlackPanther, id); - CosmosEntityMovie expected = await GetEntityAsync(id); - byte[] version = Guid.NewGuid().ToByteArray(); - Func act = async () => await Repository.ReplaceAsync(replacement, version); - - (await act.Should().ThrowAsync()).WithStatusCode(412).And.WithPayload(expected); - } - - [SkippableFact] - public async Task ReplaceAsync_Replaces_OnVersionMatch() - { - Skip.IfNot(CanRunLiveTests()); - - IRepository Repository = await GetPopulatedRepositoryAsync(); - string id = await GetRandomEntityIdAsync(true); - CosmosEntityMovie replacement = TestData.Movies.OfType(TestData.Movies.BlackPanther, id); - CosmosEntityMovie expected = await GetEntityAsync(id); - byte[] version = expected.Version.ToArray(); - await Repository.ReplaceAsync(replacement, version); - CosmosEntityMovie actual = await GetEntityAsync(id); - - actual.Should().BeEquivalentTo(replacement).And.NotBeEquivalentTo(expected); - actual.Version.Should().NotBeEquivalentTo(version); - actual.UpdatedAt.Should().BeAfter(StartTime); - } - - [SkippableFact] - public async Task ReplaceAsync_Replaces_OnNoVersion() - { - Skip.IfNot(CanRunLiveTests()); - - IRepository Repository = await GetPopulatedRepositoryAsync(); - string id = await GetRandomEntityIdAsync(true); - CosmosEntityMovie replacement = TestData.Movies.OfType(TestData.Movies.BlackPanther, id); - CosmosEntityMovie expected = await GetEntityAsync(id); - byte[] version = expected.Version.ToArray(); - await Repository.ReplaceAsync(replacement); - CosmosEntityMovie actual = await GetEntityAsync(id); - - actual.Should().BeEquivalentTo(replacement).And.NotBeEquivalentTo(expected); - actual.Version.Should().NotBeEquivalentTo(version); - actual.UpdatedAt.Should().BeAfter(StartTime); - } - #endregion - #endregion } \ No newline at end of file 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 5fe84c6..e812b33 100644 --- a/tests/CommunityToolkit.Datasync.Server.Test/Controllers/TableController_Query_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Server.Test/Controllers/TableController_Query_Tests.cs @@ -255,30 +255,28 @@ public async Task QueryAsync_RepositoryException_Throws() [Fact] public async Task QueryAsync_NoExtras_Works() { - TableData entity = new() { Id = "0da7fb24-3606-442f-9f68-c47c6e7d09d4" }; + int takeCount = 5; // Should be less than pagesize. - IAccessControlProvider accessProvider = FakeAccessControlProvider(TableOperation.Query, true); - IRepository repository = FakeRepository(entity, true); - TableController controller = new(repository, accessProvider); + IAccessControlProvider accessProvider = FakeAccessControlProvider(TableOperation.Query, true); + InMemoryRepository repository = new(TestCommon.TestData.Movies.OfType().Take(takeCount).ToList()); + TableController controller = new(repository, accessProvider); controller.ControllerContext.HttpContext = CreateHttpContext(HttpMethod.Get, "https://localhost/table"); OkObjectResult result = await controller.QueryAsync() as OkObjectResult; result.Should().NotBeNull(); PagedResult pagedResult = result.Value as PagedResult; pagedResult.Should().NotBeNull(); - pagedResult.Items.Should().HaveCount(1); + pagedResult.Items.Should().HaveCount(takeCount); } [Theory] - [InlineData("0da7fb24-3606-442f-9f68-c47c6e7d09d4", 1)] + [InlineData("id-010", 1)] [InlineData("1", 0)] public async Task QueryAsync_DataView_Works(string filter, int count) { - TableData entity = new() { Id = "0da7fb24-3606-442f-9f68-c47c6e7d09d4" }; - - IAccessControlProvider accessProvider = FakeAccessControlProvider(TableOperation.Query, true, m => m.Id == filter); - IRepository repository = FakeRepository(entity, true); - TableController controller = new(repository, accessProvider); + IAccessControlProvider accessProvider = FakeAccessControlProvider(TableOperation.Query, true, m => m.Id == filter); + InMemoryRepository repository = new(TestCommon.TestData.Movies.OfType()); + TableController controller = new(repository, accessProvider); controller.ControllerContext.HttpContext = CreateHttpContext(HttpMethod.Get, "https://localhost/table"); OkObjectResult result = await controller.QueryAsync() as OkObjectResult; @@ -289,43 +287,61 @@ public async Task QueryAsync_DataView_Works(string filter, int count) } [Theory] - [InlineData(true, 0)] - [InlineData(false, 1)] - public async Task QueryAsync_DeletedSkipped_Works(bool isDeleted, int count) + [InlineData(true)] + [InlineData(false)] + public async Task QueryAsync_DeletedSkipped_Works(bool isDeleted) { - TableData entity = new() { Id = "0da7fb24-3606-442f-9f68-c47c6e7d09d4", Deleted = isDeleted }; + IAccessControlProvider accessProvider = FakeAccessControlProvider(TableOperation.Query, true); + InMemoryRepository repository = new(TestCommon.TestData.Movies.OfType()); + + // Set the deleted flag on the first item in the repository, but only if isDeleted == true + int expectedCount = TestCommon.TestData.Movies.MovieList.Length; + if (isDeleted) + { + InMemoryMovie entity = repository.GetEntity("id-010"); + entity.Deleted = isDeleted; + repository.StoreEntity(entity); + expectedCount--; + } - IAccessControlProvider accessProvider = FakeAccessControlProvider(TableOperation.Query, true); - IRepository repository = FakeRepository(entity, true); TableControllerOptions options = new() { EnableSoftDelete = true }; - TableController controller = new(repository, accessProvider) { Options = options }; - controller.ControllerContext.HttpContext = CreateHttpContext(HttpMethod.Get, "https://localhost/table"); + TableController controller = new(repository, accessProvider) { Options = options }; + controller.ControllerContext.HttpContext = CreateHttpContext(HttpMethod.Get, "https://localhost/table?$count=true"); OkObjectResult result = await controller.QueryAsync() as OkObjectResult; result.Should().NotBeNull(); PagedResult pagedResult = result.Value as PagedResult; pagedResult.Should().NotBeNull(); - pagedResult.Items.Should().HaveCount(count); + pagedResult.Items.Should().HaveCount(100); + pagedResult.Count.Should().Be(expectedCount); // Total count } [Theory] - [InlineData(true, 1)] - [InlineData(false, 1)] - public async Task QueryAsync_DeletedIncluded_Works(bool isDeleted, int count) + [InlineData(true)] + [InlineData(false)] + public async Task QueryAsync_DeletedIncluded_Works(bool isDeleted) { - TableData entity = new() { Id = "0da7fb24-3606-442f-9f68-c47c6e7d09d4", Deleted = isDeleted }; + IAccessControlProvider accessProvider = FakeAccessControlProvider(TableOperation.Query, true); + InMemoryRepository repository = new(TestCommon.TestData.Movies.OfType()); + + // Set the deleted flag on the first item in the repository, but only if isDeleted == true + if (isDeleted) + { + InMemoryMovie entity = repository.GetEntity("id-010"); + entity.Deleted = isDeleted; + repository.StoreEntity(entity); + } - IAccessControlProvider accessProvider = FakeAccessControlProvider(TableOperation.Query, true); - IRepository repository = FakeRepository(entity, true); TableControllerOptions options = new() { EnableSoftDelete = true }; - TableController controller = new(repository, accessProvider) { Options = options }; - controller.ControllerContext.HttpContext = CreateHttpContext(HttpMethod.Get, "https://localhost/table?__includedeleted=true"); + TableController controller = new(repository, accessProvider) { Options = options }; + controller.ControllerContext.HttpContext = CreateHttpContext(HttpMethod.Get, "https://localhost/table?$count=true&__includedeleted=true"); OkObjectResult result = await controller.QueryAsync() as OkObjectResult; result.Should().NotBeNull(); PagedResult pagedResult = result.Value as PagedResult; pagedResult.Should().NotBeNull(); - pagedResult.Items.Should().HaveCount(count); + pagedResult.Items.Should().HaveCount(100); // Page length + pagedResult.Count.Should().Be(TestCommon.TestData.Movies.MovieList.Length); // Total count } #endregion } diff --git a/tests/CommunityToolkit.Datasync.TestCommon/Databases/AzureSql/AzureSqlDbContext.cs b/tests/CommunityToolkit.Datasync.TestCommon/Databases/AzureSql/AzureSqlDbContext.cs index 4f6eb85..9d6cd36 100644 --- a/tests/CommunityToolkit.Datasync.TestCommon/Databases/AzureSql/AzureSqlDbContext.cs +++ b/tests/CommunityToolkit.Datasync.TestCommon/Databases/AzureSql/AzureSqlDbContext.cs @@ -2,7 +2,9 @@ // 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.Server; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Xunit.Abstractions; @@ -37,7 +39,7 @@ internal void InitializeDatabase() UPDATE [dbo].[{0}] SET - [UpdatedAt] = GETUTCDATE() + [UpdatedAt] = SYSDATETIMEOFFSET() WHERE [Id] IN (SELECT [Id] FROM INSERTED); END @@ -56,6 +58,10 @@ protected override void ConfigureConventions(ModelConfigurationBuilder configura protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity(entity => + { + entity.Property(e => e.UpdatedAt).HasColumnType("datetimeoffset(7)").IsRequired(false); + }); modelBuilder.Entity().ToTable(tb => tb.HasTrigger("datasync_trigger")); base.OnModelCreating(modelBuilder); } diff --git a/tests/CommunityToolkit.Datasync.TestCommon/RepositoryTests.cs b/tests/CommunityToolkit.Datasync.TestCommon/RepositoryTests.cs index 68839fc..9f95a8e 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,7 +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(); + List actual = await Repository.ToListAsync(queryable.Where(m => m.Rating == MovieRating.R)); actual.Should().HaveCount(expected); } @@ -99,7 +86,7 @@ public async Task AsQueryableAsync_CanSelectFromList() 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(); + List actual = await Repository.ToListAsync(queryable.Where(m => m.Rating == MovieRating.R).Select(m => new { m.Id, m.Title })); actual.Should().HaveCount(expected); } @@ -111,7 +98,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(); + List actual = await Repository.ToListAsync(queryable.Where(m => m.Rating == MovieRating.R).Skip(5).Take(20)); actual.Should().HaveCount(20); } @@ -126,7 +113,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(); + List 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); }