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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -48,21 +48,24 @@ public async Task ResetDeltaTokenAsync(string queryId, CancellationToken cancell
/// <param name="queryId">The query ID of the table.</param>
/// <param name="value">The value of the delta token.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe.</param>
/// <returns>A task that completes when the delta token has been set in the persistent store.</returns>
public async Task SetDeltaTokenAsync(string queryId, DateTimeOffset value, CancellationToken cancellationToken = default)
/// <returns>true if this query ID was set for the first time; false otherwise.</returns>
public async Task<bool> SetDeltaTokenAsync(string queryId, DateTimeOffset value, CancellationToken cancellationToken = default)
{
ValidateQueryId(queryId);
long unixms = value.ToUnixTimeMilliseconds();
DatasyncDeltaToken? deltaToken = await context.DatasyncDeltaTokens.FindAsync([queryId], cancellationToken).ConfigureAwait(false);
if (deltaToken is null)
{
_ = context.DatasyncDeltaTokens.Add(new DatasyncDeltaToken() { Id = queryId, Value = unixms });
return true;
}
else if (deltaToken.Value != unixms)
{
deltaToken.Value = unixms;
_ = context.DatasyncDeltaTokens.Update(deltaToken);
}

return false;
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,5 @@ internal interface IDeltaTokenStore
/// <param name="value">The value of the delta token.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe.</param>
/// <returns>A task that completes when the delta token has been set in the persistent store.</returns>
Task SetDeltaTokenAsync(string queryId, DateTimeOffset value, CancellationToken cancellationToken = default);
Task<bool> SetDeltaTokenAsync(string queryId, DateTimeOffset value, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,12 @@ public async Task<PullResult> ExecuteAsync(IEnumerable<PullRequest> requests, Pu
if (metadata.UpdatedAt.HasValue && metadata.UpdatedAt.Value > lastSynchronization)
{
lastSynchronization = metadata.UpdatedAt.Value;
await DeltaTokenStore.SetDeltaTokenAsync(pullResponse.QueryId, metadata.UpdatedAt.Value, cancellationToken).ConfigureAwait(false);
bool isAdded = await DeltaTokenStore.SetDeltaTokenAsync(pullResponse.QueryId, metadata.UpdatedAt.Value, cancellationToken).ConfigureAwait(false);
if (isAdded)
{
// Sqlite oddity - you can't add then update; it changes the change type to UPDATE, which then fails.
_ = await context.SaveChangesAsync(true, false, cancellationToken).ConfigureAwait(false);
}
}
}

Expand Down Expand Up @@ -117,7 +122,7 @@ public async Task<PullResult> ExecuteAsync(IEnumerable<PullRequest> requests, Pu
foreach (PullRequest request in requests)
{
DateTimeOffset lastSynchronization = await context.DeltaTokenStore.GetDeltaTokenAsync(request.QueryId, cancellationToken).ConfigureAwait(false);
PrepareQueryDescription(request.QueryDescription, lastSynchronization);
request.QueryDescription = PrepareQueryDescription(request.QueryDescription, lastSynchronization);
serviceRequestQueue.Enqueue(request);
}

Expand Down Expand Up @@ -171,10 +176,12 @@ internal async Task<Page<object>> GetPageAsync(HttpClient client, Uri requestUri
/// <summary>
/// Prepares the query description for use as a pull request.
/// </summary>
/// <param name="query">The query description to modify.</param>
/// <param name="source">The query description to modify.</param>
/// <param name="lastSynchronization">The last synchronization date/time</param>
internal static void PrepareQueryDescription(QueryDescription query, DateTimeOffset lastSynchronization)
/// <returns>A modified query description for the actual query.</returns>
internal static QueryDescription PrepareQueryDescription(QueryDescription source, DateTimeOffset lastSynchronization)
{
QueryDescription query = new(source);
if (lastSynchronization.ToUnixTimeMilliseconds() > 0L)
{
BinaryOperatorNode deltaTokenFilter = new(BinaryOperatorKind.GreaterThan)
Expand All @@ -185,12 +192,13 @@ internal static void PrepareQueryDescription(QueryDescription query, DateTimeOff
query.Filter = query.Filter is null ? deltaTokenFilter : new BinaryOperatorNode(BinaryOperatorKind.And, query.Filter, deltaTokenFilter);
}

query.QueryParameters.Add(ODataQueryParameters.IncludeDeleted, "true");
query.QueryParameters[ODataQueryParameters.IncludeDeleted] = "true";
query.RequestTotalCount = true;
query.Top = null;
query.Skip = 0;
query.Ordering.Clear();
query.Ordering.Add(new OrderByNode(new MemberAccessNode(null, "updatedAt"), true));
return query;
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,30 @@ namespace CommunityToolkit.Datasync.Client.Query.Linq;
/// </summary>
internal class QueryDescription
{
/// <summary>
/// Creates a new blank <see cref="QueryDescription"/>
/// </summary>
internal QueryDescription()
{
}

/// <summary>
/// Creates a new <see cref="QueryDescription"/> based on a source <see cref="QueryDescription"/>.
/// </summary>
/// <param name="source">The source of the <see cref="QueryDescription"/></param>
internal QueryDescription(QueryDescription source)
{
Filter = source.Filter; // Note: we don't clone the filter, so you have to be careful to not change any nodes.
RequestTotalCount = source.RequestTotalCount;
Ordering = [..source.Ordering];
ProjectionArgumentType = source.ProjectionArgumentType;
Projections = [..source.Projections];
QueryParameters = new Dictionary<string, string>(source.QueryParameters);
Selection = new List<string>(source.Selection);
Skip = source.Skip;
Top = source.Top;
}

/// <summary>
/// The <see cref="QueryNode"/> for the query filter expression.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Licensed to the .NET Foundation under one or more agreements.
// 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.Client.Offline;
using CommunityToolkit.Datasync.TestCommon.Databases;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

namespace CommunityToolkit.Datasync.Client.Test.Helpers;

[ExcludeFromCodeCoverage]
public class IntegrationDbContext(DbContextOptions<IntegrationDbContext> options) : OfflineDbContext(options)
{
public DbSet<ClientMovie> Movies => Set<ClientMovie>();

public ServiceApplicationFactory Factory { get; set; }

public SqliteConnection Connection { get; set; }

public string Filename { get; set; }

protected override void OnDatasyncInitialization(DatasyncOfflineOptionsBuilder optionsBuilder)
{
optionsBuilder.UseHttpClient(Factory.CreateClient());
optionsBuilder.Entity<ClientMovie>(cfg =>
{
cfg.ClientName = "movies";
cfg.Endpoint = new Uri($"/{Factory.MovieEndpoint}", UriKind.Relative);
});
}

protected override void Dispose(bool disposing)
{
if (disposing)
{
Connection.Close();
}

base.Dispose(disposing);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Licensed to the .NET Foundation under one or more agreements.
// 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.InMemory;
using CommunityToolkit.Datasync.Server;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using CommunityToolkit.Datasync.TestCommon.Databases;
using CommunityToolkit.Datasync.TestCommon.Models;
using System.Linq.Expressions;

namespace CommunityToolkit.Datasync.Client.Test.Helpers;

[ExcludeFromCodeCoverage]
public class ServiceApplicationFactory : WebApplicationFactory<Program>
{
internal string KitchenSinkEndpoint = "api/in-memory/kitchensink";
internal string MovieEndpoint = "api/in-memory/movies";
internal string PagedMovieEndpoint = "api/in-memory/pagedmovies";
internal string SoftDeletedMovieEndpoint = "api/in-memory/softmovies";

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
// base.ConfigureWebHost(builder);
}

internal IList<TEntity> GetEntities<TEntity>() where TEntity : InMemoryTableData
{
using IServiceScope scope = Services.CreateScope();
InMemoryRepository<TEntity> repository = scope.ServiceProvider.GetRequiredService<IRepository<TEntity>>() as InMemoryRepository<TEntity>;
return [.. repository.GetEntities()];
}

internal int Count<TEntity>() where TEntity : InMemoryTableData
{
using IServiceScope scope = Services.CreateScope();
InMemoryRepository<TEntity> repository = scope.ServiceProvider.GetRequiredService<IRepository<TEntity>>() as InMemoryRepository<TEntity>;
return repository.GetEntities().Count;
}

internal InMemoryMovie GetRandomMovie()
{
// Note that we don't use all movies, since some of them are not "valid", which will result in a 400 error instead
// of the expected error when testing replace or create functionality.
using IServiceScope scope = Services.CreateScope();
InMemoryRepository<InMemoryMovie> repository = scope.ServiceProvider.GetRequiredService<IRepository<InMemoryMovie>>() as InMemoryRepository<InMemoryMovie>;
List<InMemoryMovie> entities = repository.GetEntities().Where(x => IsValid(x)).ToList();
return entities[new Random().Next(entities.Count)];
}

internal TEntity GetServerEntityById<TEntity>(string id) where TEntity : InMemoryTableData
{
using IServiceScope scope = Services.CreateScope();
InMemoryRepository<TEntity> repository = scope.ServiceProvider.GetRequiredService<IRepository<TEntity>>() as InMemoryRepository<TEntity>;
return repository.GetEntity(id);
}

/// <summary>
/// Checks that the movie is "valid" according to the server.
/// </summary>
/// <param name="movie">The movie to check.</param>
/// <returns><c>true</c> if valid, <c>false</c> otherwise.</returns>
internal static bool IsValid(IMovie movie)
{
return movie.Title.Length >= 2 && movie.Title.Length <= 60
&& movie.Year >= 1920 && movie.Year <= 2030
&& movie.Duration >= 60 && movie.Duration <= 360;
}

internal void RunWithRepository<TEntity>(Action<InMemoryRepository<TEntity>> action) where TEntity : InMemoryTableData
{
using IServiceScope scope = Services.CreateScope();
InMemoryRepository<TEntity> repository = scope.ServiceProvider.GetRequiredService<IRepository<TEntity>>() as InMemoryRepository<TEntity>;
action.Invoke(repository);
}

internal void SoftDelete<TEntity>(TEntity entity, bool deleted = true) where TEntity : InMemoryTableData
{
using IServiceScope scope = Services.CreateScope();
InMemoryRepository<TEntity> repository = scope.ServiceProvider.GetRequiredService<IRepository<TEntity>>() as InMemoryRepository<TEntity>;
entity.Deleted = deleted;
repository.StoreEntity(entity);
}

internal void SoftDelete<TEntity>(Expression<Func<TEntity, bool>> expression, bool deleted = true) where TEntity : InMemoryTableData
{
using IServiceScope scope = Services.CreateScope();
InMemoryRepository<TEntity> repository = scope.ServiceProvider.GetRequiredService<IRepository<TEntity>>() as InMemoryRepository<TEntity>;
foreach (TEntity entity in repository.GetEntities().Where(expression.Compile()))
{
entity.Deleted = deleted;
repository.StoreEntity(entity);
}
}
}
123 changes: 123 additions & 0 deletions tests/CommunityToolkit.Datasync.Client.Test/Helpers/ServiceTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Licensed to the .NET Foundation under one or more agreements.
// 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.InMemory;
using CommunityToolkit.Datasync.TestCommon.Databases;
using CommunityToolkit.Datasync.TestCommon.Models;
using CommunityToolkit.Datasync.TestCommon.TestData;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Spatial;
using System.Linq.Expressions;

namespace CommunityToolkit.Datasync.Client.Test.Helpers;

[ExcludeFromCodeCoverage]
public abstract class ServiceTest(ServiceApplicationFactory factory)
{
protected readonly HttpClient client = factory.CreateClient();

protected DateTimeOffset StartTime { get; } = DateTimeOffset.UtcNow;

internal IntegrationDbContext GetOfflineContext(bool useRealFile = false)
{
string filename = null;
string connectionString = "Data Source=:memory:";
if (useRealFile)
{
filename = Path.GetTempFileName();
SqliteConnectionStringBuilder builder = new();
builder.DataSource = filename;
builder.Mode = SqliteOpenMode.ReadWriteCreate;
connectionString = builder.ConnectionString;
}

SqliteConnection connection = new(connectionString);
connection.Open();

DbContextOptionsBuilder<IntegrationDbContext> optionsBuilder = new();
optionsBuilder.UseSqlite(connection);
optionsBuilder.LogTo(Console.WriteLine);
optionsBuilder.EnableSensitiveDataLogging();
optionsBuilder.EnableDetailedErrors();

IntegrationDbContext context = new(optionsBuilder.Options)
{
Factory = factory,
Filename = filename,
Connection = connection
};

context.Database.EnsureCreated();
return context;
}

internal DatasyncServiceClient<ClientMovie> GetMovieClient()
=> new(new Uri($"/{factory.MovieEndpoint}", UriKind.Relative), this.client);

internal DatasyncServiceClient<ClientMovie> GetSoftDeletedMovieClient()
=> new(new Uri($"/{factory.SoftDeletedMovieEndpoint}", UriKind.Relative), this.client);

internal DatasyncServiceClient<ClientKitchenSink> GetKitchenSinkClient()
=> new(new Uri($"/{factory.KitchenSinkEndpoint}", UriKind.Relative), this.client);

internal int Count<TEntity>() where TEntity : InMemoryTableData
=> factory.Count<TEntity>();

internal InMemoryMovie GetRandomMovie()
=> factory.GetRandomMovie();

internal TEntity GetServerEntityById<TEntity>(string id) where TEntity : InMemoryTableData
=> factory.GetServerEntityById<TEntity>(id);

protected void SeedKitchenSinkWithCountryData()
{
factory.RunWithRepository<InMemoryKitchenSink>(repository =>
{
repository.Clear();
foreach (Country countryRecord in CountryData.GetCountries())
{
InMemoryKitchenSink model = new()
{
Id = countryRecord.IsoCode,
Version = Guid.NewGuid().ToByteArray(),
UpdatedAt = DateTimeOffset.UtcNow,
Deleted = false,
PointValue = GeographyPoint.Create(countryRecord.Latitude, countryRecord.Longitude),
StringValue = countryRecord.CountryName
};
repository.StoreEntity(model);
}
});
}

protected void SeedKitchenSinkWithDateTimeData()
{
factory.RunWithRepository<InMemoryKitchenSink>(repository =>
{
repository.Clear();
DateOnly SourceDate = new(2022, 1, 1);
for (int i = 0; i < 365; i++)
{
DateOnly date = SourceDate.AddDays(i);
InMemoryKitchenSink model = new()
{
Id = string.Format("id-{0:000}", i),
Version = Guid.NewGuid().ToByteArray(),
UpdatedAt = DateTimeOffset.UtcNow,
Deleted = false,
DateOnlyValue = date,
TimeOnlyValue = new TimeOnly(date.Month, date.Day)
};
repository.StoreEntity(model);
}
});
}

internal void SoftDelete<TEntity>(TEntity entity, bool deleted = true) where TEntity : InMemoryTableData
=> factory.SoftDelete<TEntity>(entity, deleted);

internal void SoftDelete<TEntity>(Expression<Func<TEntity, bool>> expression, bool deleted = true) where TEntity : InMemoryTableData
=> factory.SoftDelete<TEntity>(expression, deleted);
}
Loading