Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,24 @@ public interface IRepository<TEntity> where TEntity : ITableData
/// <exception cref="HttpException">Thrown if the entity creation would produce a normal HTTP error.</exception>
/// <exception cref="RepositoryException">Thrown is there is an error in the repository.</exception>
ValueTask ReplaceAsync(TEntity entity, byte[]? version = null, CancellationToken cancellationToken = default);

/// <summary>
/// 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.
/// </summary>
/// <param name="query">The queryable being counted.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe.</param>
/// <returns>The count of entities matching the query.</returns>
ValueTask<int> CountAsync(IQueryable query, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(query.Cast<object>().Count());

/// <summary>
/// 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.
/// </summary>
/// <param name="query">The queryable being executed.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe.</param>
/// <returns>The entities matching the query.</returns>
ValueTask<List<object>> ToListAsync(IQueryable query, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(query.Cast<object>().ToList());
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ namespace CommunityToolkit.Datasync.Server.EntityFrameworkCore;
/// <summary>
/// The base class for all the Entity Framework Core based table data classes.
/// </summary>
[Index(nameof(UpdatedAt), nameof(Deleted))]
public abstract class BaseEntityTableData : ITableData
{
/// <inheritdoc />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

Expand All @@ -11,6 +12,7 @@ namespace CommunityToolkit.Datasync.Server.EntityFrameworkCore;
/// A version of the <see cref="BaseEntityTableData"/> that is compatible with
/// most of the Entity Framework Core drivers.
/// </summary>
[Index(nameof(UpdatedAt), nameof(Deleted))]
public class EntityTableData : BaseEntityTableData
{
/// <inheritdoc />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ 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)
if ((await DataSet.CountAsync(x => x.Id == entity.Id, cancellationToken)) > 0)
{
throw new HttpException((int)HttpStatusCode.Conflict) { Payload = await GetEntityAsync(entity.Id, cancellationToken).ConfigureAwait(false) };
}
Expand Down Expand Up @@ -209,5 +209,23 @@ await WrapExceptionAsync(entity.Id, async () =>
_ = await Context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}, cancellationToken).ConfigureAwait(false);
}

/// <inheritdoc />
/// <remarks>
/// The entity framework core edition of this method uses the async method.
/// </remarks>
public virtual async ValueTask<int> CountAsync(IQueryable query, CancellationToken cancellationToken = default)
{
return await EntityFrameworkQueryableExtensions.CountAsync(query.Cast<object>(), cancellationToken).ConfigureAwait(false);
}

/// <inheritdoc />
/// <remarks>
/// The entity framework core edition of this method uses the async method.
/// </remarks>
public virtual async ValueTask<List<object>> ToListAsync(IQueryable query, CancellationToken cancellationToken = default)
{
return await EntityFrameworkQueryableExtensions.ToListAsync(query.Cast<object>(), cancellationToken).ConfigureAwait(false);
}
#endregion
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,22 +69,34 @@ public virtual async Task<IActionResult> 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<object>? results = null;
await ExecuteQueryWithClientEvaluationAsync(dataset, ds =>
List<object>? results = null;
await ExecuteQueryWithClientEvaluationAsync(dataset, async ds =>
{
results = (IEnumerable<object>)queryOptions.ApplyTo(ds, querySettings);
return Task.CompletedTask;
IQueryable query = queryOptions.ApplyTo(ds, querySettings);
// results = query.Cast<object>().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<TEntity> q = (IQueryable<TEntity>)(filter?.ApplyTo(ds, new ODataQuerySettings()) ?? ds);
count = await CountAsync(q, cancellationToken);
IQueryable<TEntity> q = (IQueryable<TEntity>)(queryOptions.Filter?.ApplyTo(ds, new ODataQuerySettings()) ?? ds);
// count = q.Cast<object>().Count();
count = await CountAsync(q, cancellationToken).ConfigureAwait(false);
});

PagedResult result = BuildPagedResult(queryOptions, results, count);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ public AzureSqlEntityTableRepository_Tests(DatabaseFixture fixture, ITestOutputH

protected override bool CanRunLiveTests() => !string.IsNullOrEmpty(this.connectionString);

protected override Task<AzureSqlEntityMovie> GetEntityAsync(string id)
=> Task.FromResult(Context.Movies.AsNoTracking().SingleOrDefault(m => m.Id == id));
protected override async Task<AzureSqlEntityMovie> GetEntityAsync(string id)
=> (await Context.Movies.AsNoTracking().SingleOrDefaultAsync(m => m.Id == id)).Clone();

protected override Task<int> GetEntityCountAsync()
=> Task.FromResult(Context.Movies.Count());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,39 +7,50 @@
using Microsoft.EntityFrameworkCore;
using Xunit.Abstractions;

#pragma warning disable CS9113 // Parameter is unread.

namespace CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test;

/// <summary>
/// Note that this is a completely different set of tests than the RepositoryTests because CosmosDB
/// EF Core driver is async only - there is no sync version of the driver in EFCore 9.0. They basically
/// broke the driver to enforce async behavior.
/// </summary>
/// <param name="fixture"></param>
/// <param name="output"></param>
[ExcludeFromCodeCoverage]
[Collection("LiveTestsCollection")]
public class CosmosEntityTableRepository_Tests : RepositoryTests<CosmosEntityMovie>
public class CosmosEntityTableRepository_Tests(DatabaseFixture fixture, ITestOutputHelper output) : RepositoryTests<CosmosEntityMovie>, IAsyncLifetime
{
#region Setup
private readonly DatabaseFixture _fixture;
private readonly Random random = new();
private readonly string connectionString;
private readonly List<CosmosEntityMovie> movies;
private readonly Lazy<CosmosDbContext> _context;
private string connectionString = string.Empty;
private List<CosmosEntityMovie> 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>(() => 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();
}
}

private CosmosDbContext Context { get => this._context.Value; }
public Task DisposeAsync()
{
return Task.CompletedTask;
}

private CosmosDbContext Context { get; set; }

protected override bool CanRunLiveTests() => !string.IsNullOrEmpty(this.connectionString);

protected override Task<CosmosEntityMovie> GetEntityAsync(string id)
=> Task.FromResult(Context.Movies.AsNoTracking().SingleOrDefault(m => m.Id == id));
protected override Task<CosmosEntityMovie> GetEntityAsync(string id)
=> Context.Movies.AsNoTracking().SingleOrDefaultAsync(m => m.Id == id);

protected override Task<int> GetEntityCountAsync()
=> Task.FromResult(Context.Movies.Count());
=> Context.Movies.CountAsync();

protected override Task<IRepository<CosmosEntityMovie>> GetPopulatedRepositoryAsync()
=> Task.FromResult<IRepository<CosmosEntityMovie>>(new EntityTableRepository<CosmosEntityMovie>(Context));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<TableData> accessProvider = FakeAccessControlProvider<TableData>(TableOperation.Query, true);
IRepository<TableData> repository = FakeRepository(entity, true);
TableController<TableData> controller = new(repository, accessProvider);
IAccessControlProvider<InMemoryMovie> accessProvider = FakeAccessControlProvider<InMemoryMovie>(TableOperation.Query, true);
InMemoryRepository<InMemoryMovie> repository = new(TestCommon.TestData.Movies.OfType<InMemoryMovie>().Take(takeCount).ToList());
TableController<InMemoryMovie> 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<TableData> accessProvider = FakeAccessControlProvider<TableData>(TableOperation.Query, true, m => m.Id == filter);
IRepository<TableData> repository = FakeRepository(entity, true);
TableController<TableData> controller = new(repository, accessProvider);
IAccessControlProvider<InMemoryMovie> accessProvider = FakeAccessControlProvider<InMemoryMovie>(TableOperation.Query, true, m => m.Id == filter);
InMemoryRepository<InMemoryMovie> repository = new(TestCommon.TestData.Movies.OfType<InMemoryMovie>());
TableController<InMemoryMovie> controller = new(repository, accessProvider);
controller.ControllerContext.HttpContext = CreateHttpContext(HttpMethod.Get, "https://localhost/table");

OkObjectResult result = await controller.QueryAsync() as OkObjectResult;
Expand All @@ -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<InMemoryMovie> accessProvider = FakeAccessControlProvider<InMemoryMovie>(TableOperation.Query, true);
InMemoryRepository<InMemoryMovie> repository = new(TestCommon.TestData.Movies.OfType<InMemoryMovie>());

// 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<TableData> accessProvider = FakeAccessControlProvider<TableData>(TableOperation.Query, true);
IRepository<TableData> repository = FakeRepository(entity, true);
TableControllerOptions options = new() { EnableSoftDelete = true };
TableController<TableData> controller = new(repository, accessProvider) { Options = options };
controller.ControllerContext.HttpContext = CreateHttpContext(HttpMethod.Get, "https://localhost/table");
TableController<InMemoryMovie> 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<InMemoryMovie> accessProvider = FakeAccessControlProvider<InMemoryMovie>(TableOperation.Query, true);
InMemoryRepository<InMemoryMovie> repository = new(TestCommon.TestData.Movies.OfType<InMemoryMovie>());

// 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<TableData> accessProvider = FakeAccessControlProvider<TableData>(TableOperation.Query, true);
IRepository<TableData> repository = FakeRepository(entity, true);
TableControllerOptions options = new() { EnableSoftDelete = true };
TableController<TableData> controller = new(repository, accessProvider) { Options = options };
controller.ControllerContext.HttpContext = CreateHttpContext(HttpMethod.Get, "https://localhost/table?__includedeleted=true");
TableController<InMemoryMovie> 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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -37,7 +39,7 @@ internal void InitializeDatabase()
UPDATE
[dbo].[{0}]
SET
[UpdatedAt] = GETUTCDATE()
[UpdatedAt] = SYSDATETIMEOFFSET()
WHERE
[Id] IN (SELECT [Id] FROM INSERTED);
END
Expand All @@ -56,6 +58,10 @@ protected override void ConfigureConventions(ModelConfigurationBuilder configura

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<AzureSqlEntityMovie>(entity =>
{
entity.Property(e => e.UpdatedAt).HasColumnType("datetimeoffset(7)").IsRequired(false);
});
modelBuilder.Entity<AzureSqlEntityMovie>().ToTable(tb => tb.HasTrigger("datasync_trigger"));
base.OnModelCreating(modelBuilder);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ protected void ExecuteRawSqlOnEachEntity(string format)
/// Populates the database with the core set of movies. Ensures that we have the same data for all tests.
/// </summary>
protected void PopulateDatabase()
{
AddMoviesToTable();
SaveChanges();
}

protected async Task PopulateDatabaseAsync()
{
AddMoviesToTable();
await SaveChangesAsync();
}

private void AddMoviesToTable()
{
List<TEntity> movies = [.. TestData.Movies.OfType<TEntity>()];
MovieIds = movies.ConvertAll(m => m.Id);
Expand All @@ -70,7 +82,5 @@ protected void PopulateDatabase()

Movies.Add(movie);
}

SaveChanges();
}
}
Loading
Loading