From 949b368d753fe595360eb346b965663095be4ebe Mon Sep 17 00:00:00 2001 From: Adrian Hall Date: Thu, 4 Dec 2025 14:26:20 +0000 Subject: [PATCH 1/6] (#413) dotnet 10 support (#418) dotnet 10 support without EF Core 10 support. --- .github/workflows/build-library.yml | 4 +- .github/workflows/build-template.yml | 4 +- Datasync.Toolkit.sln | 5 - Directory.Packages.props | 77 ++-- .../Sample.Datasync.Server.csproj | 8 +- .../CommunityToolkit.Datasync.Client.csproj | 1 - .../Operations/PullOperationManager.cs | 1 + ...ityToolkit.Datasync.Server.CosmosDb.csproj | 1 - .../DatasyncOperationTransformer.cs | 124 ++++-- .../InternalExtensions.cs | 123 +++--- .../DatasyncDocumentFilter.cs | 102 ++--- .../DatasyncOperationExtensions.cs | 44 ++- src/Directory.Build.props | 4 +- .../GlobalUsings.cs | 2 +- .../Query/IDatasyncPullQuery_Tests.cs | 2 +- .../Query/IDatasyncQueryable_Tests.cs | 2 +- .../Service/DatasyncServiceClient_Tests.cs | 7 +- .../Service/Integration_Query_Tests.cs | 2 +- .../GlobalUsings.cs | 2 +- .../GlobalUsings.cs | 2 +- .../CosmosTableData_Tests.cs | 2 +- .../PackedKeyRepository_Tests.cs | 2 +- .../GlobalUsings.cs | 2 +- .../GlobalUsings.cs | 2 +- .../GlobalUsings.cs | 2 +- .../MongoTableData_Tests.cs | 2 +- .../GlobalUsings.cs | 2 +- .../NSwag_Tests.cs | 36 +- .../Service/NSwagServer.cs | 20 - .../swagger.json | 6 +- ...oolkit.Datasync.Server.OpenApi.Test.csproj | 6 + .../GlobalUsings.cs | 2 +- .../OpenApi_Tests.cs | 23 +- .../Service/OpenApiServer.cs | 20 +- .../openapi.json | 355 ++++++++++++++---- .../Service/SwashbuckleServer.cs | 15 +- .../Swashbuckle_Tests.cs | 31 +- .../swagger.json | 13 +- .../GlobalUsings.cs | 2 +- .../Fixtures/PostgreSqlDatabaseFixture.cs | 1 - .../HttpExceptionAssertions.cs | 8 +- .../FluentExtensions/ObjectAssertions.cs | 6 +- .../FluentExtensions/StringAssertions.cs | 6 +- .../RepositoryTests.cs | 7 +- ...mmunityToolkit.Datasync.TestService.csproj | 1 - tests/Directory.Build.props | 6 +- 46 files changed, 702 insertions(+), 393 deletions(-) diff --git a/.github/workflows/build-library.yml b/.github/workflows/build-library.yml index f26cc1ac..c45c9cd6 100644 --- a/.github/workflows/build-library.yml +++ b/.github/workflows/build-library.yml @@ -27,13 +27,13 @@ on: workflow_dispatch: env: - DOTNET_VERSION: '9.0.x' + DOTNET_VERSION: '10.0.x' DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 DOTNET_NOLOGO: true DOTNET_CONFIGURATION: 'Release' NuGetDirectory: ${{ github.workspace }}/nuget SolutionFile: 'Datasync.Toolkit.sln' - BASE_VERSION: '9.0.0' + BASE_VERSION: '10.0.0' permissions: pull-requests: write diff --git a/.github/workflows/build-template.yml b/.github/workflows/build-template.yml index cefbed6b..f7df2e77 100644 --- a/.github/workflows/build-template.yml +++ b/.github/workflows/build-template.yml @@ -12,13 +12,13 @@ on: workflow_dispatch: env: - DOTNET_VERSION: '9.0.x' + DOTNET_VERSION: '10.0.x' DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 DOTNET_NOLOGO: true DOTNET_CONFIGURATION: 'Release' NuGetDirectory: ${{ github.workspace }}/nuget SolutionFile: 'Datasync Solution.sln' - BASE_VERSION: '9.0.0' + BASE_VERSION: '10.0.0' permissions: pull-requests: write diff --git a/Datasync.Toolkit.sln b/Datasync.Toolkit.sln index 8eab0267..665b4693 100644 --- a/Datasync.Toolkit.sln +++ b/Datasync.Toolkit.sln @@ -60,10 +60,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommunityToolkit.Datasync.C EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommunityToolkit.Datasync.Client.Test", "tests\CommunityToolkit.Datasync.Client.Test\CommunityToolkit.Datasync.Client.Test.csproj", "{2889E6B2-9CD1-437C-A43C-98CFAFF68B99}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{75F709FD-8CC2-4558-A802-FE57086167C2}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample.Datasync.Server", "samples\datasync-server\src\Sample.Datasync.Server\Sample.Datasync.Server.csproj", "{A9967817-2A2C-4C6D-A133-967A6062E9B3}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.CosmosDb", "src\CommunityToolkit.Datasync.Server.CosmosDb\CommunityToolkit.Datasync.Server.CosmosDb.csproj", "{D9356867-0A30-4B17-BD4C-0F7EF70984C6}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.MongoDB", "src\CommunityToolkit.Datasync.Server.MongoDB\CommunityToolkit.Datasync.Server.MongoDB.csproj", "{DC20ACF9-12E9-41D9-B672-CB5FD85548E9}" @@ -215,7 +211,6 @@ Global {45D47A4E-AD58-40C8-B4CC-95BC888C47A7} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5} {D3B72031-D4BD-44D3-973C-2752AB1570F6} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5} {2889E6B2-9CD1-437C-A43C-98CFAFF68B99} = {D59F1489-5D74-4F52-B78B-88037EAB2838} - {A9967817-2A2C-4C6D-A133-967A6062E9B3} = {75F709FD-8CC2-4558-A802-FE57086167C2} {D9356867-0A30-4B17-BD4C-0F7EF70984C6} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5} {DC20ACF9-12E9-41D9-B672-CB5FD85548E9} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5} {4FC45D20-0BA9-484B-9040-641687659AF6} = {D59F1489-5D74-4F52-B78B-88037EAB2838} diff --git a/Directory.Packages.props b/Directory.Packages.props index 2d469575..553b7840 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,53 +3,58 @@ true true + + 10.0.0 + 9.0.11 + 4.9.0 + 8.4.3 + - - - + + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - + + + - + - - - - + + + + - - - - - - - + + + + + + - + - + diff --git a/samples/datasync-server/src/Sample.Datasync.Server/Sample.Datasync.Server.csproj b/samples/datasync-server/src/Sample.Datasync.Server/Sample.Datasync.Server.csproj index f0c61a3f..08e6be26 100644 --- a/samples/datasync-server/src/Sample.Datasync.Server/Sample.Datasync.Server.csproj +++ b/samples/datasync-server/src/Sample.Datasync.Server/Sample.Datasync.Server.csproj @@ -1,15 +1,15 @@  - net9.0 + net10.0 enable enable 2fc55b72-4090-46ad-ae44-8b6a415339b8 - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/CommunityToolkit.Datasync.Client/CommunityToolkit.Datasync.Client.csproj b/src/CommunityToolkit.Datasync.Client/CommunityToolkit.Datasync.Client.csproj index f4831587..fa1bf45b 100644 --- a/src/CommunityToolkit.Datasync.Client/CommunityToolkit.Datasync.Client.csproj +++ b/src/CommunityToolkit.Datasync.Client/CommunityToolkit.Datasync.Client.csproj @@ -12,7 +12,6 @@ - diff --git a/src/CommunityToolkit.Datasync.Client/Offline/Operations/PullOperationManager.cs b/src/CommunityToolkit.Datasync.Client/Offline/Operations/PullOperationManager.cs index 1fa3ff23..0aecc986 100644 --- a/src/CommunityToolkit.Datasync.Client/Offline/Operations/PullOperationManager.cs +++ b/src/CommunityToolkit.Datasync.Client/Offline/Operations/PullOperationManager.cs @@ -111,6 +111,7 @@ public async Task ExecuteAsync(IEnumerable requests, Pu _ = await context.SaveChangesAsync(true, false, cancellationToken).ConfigureAwait(false); } } + currentMetadata = null; } diff --git a/src/CommunityToolkit.Datasync.Server.CosmosDb/CommunityToolkit.Datasync.Server.CosmosDb.csproj b/src/CommunityToolkit.Datasync.Server.CosmosDb/CommunityToolkit.Datasync.Server.CosmosDb.csproj index 442f5414..54ea0839 100644 --- a/src/CommunityToolkit.Datasync.Server.CosmosDb/CommunityToolkit.Datasync.Server.CosmosDb.csproj +++ b/src/CommunityToolkit.Datasync.Server.CosmosDb/CommunityToolkit.Datasync.Server.CosmosDb.csproj @@ -12,7 +12,6 @@ - diff --git a/src/CommunityToolkit.Datasync.Server.OpenApi/DatasyncOperationTransformer.cs b/src/CommunityToolkit.Datasync.Server.OpenApi/DatasyncOperationTransformer.cs index f6d5ad66..49717ebc 100644 --- a/src/CommunityToolkit.Datasync.Server.OpenApi/DatasyncOperationTransformer.cs +++ b/src/CommunityToolkit.Datasync.Server.OpenApi/DatasyncOperationTransformer.cs @@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.OpenApi; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; namespace CommunityToolkit.Datasync.Server.OpenApi; @@ -15,6 +15,11 @@ namespace CommunityToolkit.Datasync.Server.OpenApi; /// public class DatasyncOperationTransformer : IOpenApiOperationTransformer { + /// + /// The list of processed entity names (which are those we have added to the schema already). + /// + private readonly List processedEntityNames = []; + /// public async Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) { @@ -69,6 +74,35 @@ public async Task TransformAsync(OpenApiOperation operation, OpenApiOperationTra internal static bool IsDatasyncController(OpenApiOperationTransformerContext context) => context.Description.ActionDescriptor.FilterDescriptors.Any(fd => fd.Filter is DatasyncControllerAttribute); + /// + /// Adds a schema for the given type if it is not already processed. + /// + /// The transformer context holding the OpenApi document. + /// The entity type to add. + /// A to observe. + internal async Task AddSchemaIfNotProcessedAsync(OpenApiOperationTransformerContext context, Type entityType, CancellationToken cancellationToken) + { + if (this.processedEntityNames.Contains(entityType.Name)) + { + return; + } + + OpenApiSchema schema = await context.GetOrCreateSchemaAsync(entityType, cancellationToken: cancellationToken).ConfigureAwait(false); + schema.SetSystemPropertiesReadonly(); + _ = context.Document!.AddComponent(entityType.Name, schema); + + Type pagedEntityType = typeof(PagedResult<>).MakeGenericType(entityType); + OpenApiSchema pagedSchema = await context.GetOrCreateSchemaAsync(pagedEntityType, cancellationToken: cancellationToken).ConfigureAwait(false); + pagedSchema.SetSchemaPropertyReadonly("items"); + pagedSchema.SetSchemaItemsReference("items", new OpenApiSchemaReference(entityType.Name, context.Document)); + pagedSchema.SetSchemaPropertyReadonly("count"); + pagedSchema.SetSchemaPropertyReadonly("nextLink"); + pagedSchema.AdditionalPropertiesAllowed = false; + _ = context.Document.AddComponent($"{entityType.Name}Page", pagedSchema); + + this.processedEntityNames.Add(entityType.Name); + } + /// /// Retrieves the entity type for the controller. /// @@ -96,6 +130,20 @@ internal static Type GetEntityType(OpenApiOperationTransformerContext context) throw new InvalidOperationException("Expecting the controller to be derived from TableController."); } + /// + /// Gets a reference to the schema for the given entity type, adding it to the document if needed. + /// + /// the transformer context holding the document. + /// The entity type being referenced. + /// A to observe. + /// A task that returns the schema reference when resolved. + internal async Task GetSchemaReferenceAsync(OpenApiOperationTransformerContext context, Type entityType, CancellationToken cancellationToken) + { + context.ThrowIfDocumentIsUnavailable(); + await AddSchemaIfNotProcessedAsync(context, entityType, cancellationToken).ConfigureAwait(false); + return new OpenApiSchemaReference(entityType.Name, context.Document); + } + /// /// Transforms a create operation. /// @@ -103,21 +151,18 @@ internal static Type GetEntityType(OpenApiOperationTransformerContext context) /// The operation transformer context. /// A cancellation token to observe. /// A task that resolves when the operation is complete. - internal Task TransformCreateAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + internal async Task TransformCreateAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) { Type entityType = GetEntityType(context); + OpenApiSchemaReference schemaRef = await GetSchemaReferenceAsync(context, entityType, cancellationToken).ConfigureAwait(false); - operation.AddRequestBody(context.GetSchemaForType(entityType)); + operation.AddRequestBody(schemaRef); - operation.Responses.AddEntityResponse(StatusCodes.Status201Created, - context.GetSchemaForType(entityType), includeConditionalHeaders: true); + operation.Responses ??= []; + operation.Responses.AddEntityResponse(StatusCodes.Status201Created, schemaRef, includeConditionalHeaders: true); operation.Responses.AddStatusCode(StatusCodes.Status400BadRequest); - operation.Responses.AddEntityResponse(StatusCodes.Status409Conflict, - context.GetSchemaForType(entityType), includeConditionalHeaders: true); - operation.Responses.AddEntityResponse(StatusCodes.Status412PreconditionFailed, - context.GetSchemaForType(entityType), includeConditionalHeaders: true); - - return Task.CompletedTask; + operation.Responses.AddEntityResponse(StatusCodes.Status409Conflict, schemaRef, includeConditionalHeaders: true); + operation.Responses.AddEntityResponse(StatusCodes.Status412PreconditionFailed, schemaRef, includeConditionalHeaders: true); } /// @@ -127,9 +172,13 @@ internal Task TransformCreateAsync(OpenApiOperation operation, OpenApiOperationT /// The operation transformer context. /// A cancellation token to observe. /// A task that resolves when the operation is complete. - internal Task TransformDeleteAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + internal async Task TransformDeleteAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) { Type entityType = GetEntityType(context); + OpenApiSchemaReference schemaRef = await GetSchemaReferenceAsync(context, entityType, cancellationToken).ConfigureAwait(false); + + operation.Parameters ??= []; + operation.Responses ??= []; operation.Parameters.AddIfMatchHeader(); operation.Parameters.AddIfUnmodifiedSinceHeader(); @@ -137,12 +186,8 @@ internal Task TransformDeleteAsync(OpenApiOperation operation, OpenApiOperationT operation.Responses.AddStatusCode(StatusCodes.Status400BadRequest); operation.Responses.AddStatusCode(StatusCodes.Status404NotFound); operation.Responses.AddStatusCode(StatusCodes.Status410Gone); - operation.Responses.AddEntityResponse(StatusCodes.Status409Conflict, - context.GetSchemaForType(entityType), includeConditionalHeaders: true); - operation.Responses.AddEntityResponse(StatusCodes.Status412PreconditionFailed, - context.GetSchemaForType(entityType), includeConditionalHeaders: true); - - return Task.CompletedTask; + operation.Responses.AddEntityResponse(StatusCodes.Status409Conflict, schemaRef, includeConditionalHeaders: true); + operation.Responses.AddEntityResponse(StatusCodes.Status412PreconditionFailed, schemaRef, includeConditionalHeaders: true); } /// @@ -152,10 +197,14 @@ internal Task TransformDeleteAsync(OpenApiOperation operation, OpenApiOperationT /// The operation transformer context. /// A cancellation token to observe. /// A task that resolves when the operation is complete. - internal Task TransformQueryAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + internal async Task TransformQueryAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) { Type entityType = GetEntityType(context); - Type pagedEntityType = typeof(PagedResult<>).MakeGenericType(entityType); + await AddSchemaIfNotProcessedAsync(context, entityType, cancellationToken).ConfigureAwait(false); + OpenApiSchemaReference pagedSchemaRef = new($"{entityType.Name}Page", context.Document!); + + operation.Parameters ??= []; + operation.Responses ??= []; operation.Parameters.AddBooleanQueryParameter("$count", "Whether to include the total count of items matching the query in the result"); operation.Parameters.AddStringQueryParameter("$filter", "The filter to apply to the query"); @@ -165,11 +214,8 @@ internal Task TransformQueryAsync(OpenApiOperation operation, OpenApiOperationTr operation.Parameters.AddIntQueryParameter("$top", "The number of items to return", 1); operation.Parameters.AddIncludeDeletedQuery(); - operation.Responses.AddEntityResponse(StatusCodes.Status200OK, - context.GetSchemaForType(pagedEntityType), includeConditionalHeaders: false); + operation.Responses.AddEntityResponse(StatusCodes.Status200OK, pagedSchemaRef, includeConditionalHeaders: false); operation.Responses.AddStatusCode(StatusCodes.Status400BadRequest); - - return Task.CompletedTask; } /// @@ -179,21 +225,22 @@ internal Task TransformQueryAsync(OpenApiOperation operation, OpenApiOperationTr /// The operation transformer context. /// A cancellation token to observe. /// A task that resolves when the operation is complete. - internal Task TransformReadAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + internal async Task TransformReadAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) { Type entityType = GetEntityType(context); + OpenApiSchemaReference schemaRef = await GetSchemaReferenceAsync(context, entityType, cancellationToken).ConfigureAwait(false); + + operation.Parameters ??= []; + operation.Responses ??= []; operation.Parameters.AddIncludeDeletedQuery(); operation.Parameters.AddIfNoneMatchHeader(); operation.Parameters.AddIfModifiedSinceHeader(); - operation.Responses.AddEntityResponse(StatusCodes.Status200OK, - context.GetSchemaForType(entityType), includeConditionalHeaders: true); + operation.Responses.AddEntityResponse(StatusCodes.Status200OK, schemaRef, includeConditionalHeaders: true); operation.Responses.AddStatusCode(StatusCodes.Status304NotModified); operation.Responses.AddStatusCode(StatusCodes.Status404NotFound); operation.Responses.AddStatusCode(StatusCodes.Status410Gone); - - return Task.CompletedTask; } /// @@ -203,25 +250,24 @@ internal Task TransformReadAsync(OpenApiOperation operation, OpenApiOperationTra /// The operation transformer context. /// A cancellation token to observe. /// A task that resolves when the operation is complete. - internal Task TransformReplaceAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + internal async Task TransformReplaceAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) { Type entityType = GetEntityType(context); + OpenApiSchemaReference schemaRef = await GetSchemaReferenceAsync(context, entityType, cancellationToken).ConfigureAwait(false); + + operation.Parameters ??= []; + operation.Responses ??= []; - operation.AddRequestBody(context.GetSchemaForType(entityType)); + operation.AddRequestBody(schemaRef); operation.Parameters.AddIncludeDeletedQuery(); operation.Parameters.AddIfMatchHeader(); operation.Parameters.AddIfUnmodifiedSinceHeader(); - operation.Responses.AddEntityResponse(StatusCodes.Status200OK, - context.GetSchemaForType(entityType), includeConditionalHeaders: true); + operation.Responses.AddEntityResponse(StatusCodes.Status200OK, schemaRef, includeConditionalHeaders: true); operation.Responses.AddStatusCode(StatusCodes.Status400BadRequest); operation.Responses.AddStatusCode(StatusCodes.Status404NotFound); operation.Responses.AddStatusCode(StatusCodes.Status410Gone); - operation.Responses.AddEntityResponse(StatusCodes.Status409Conflict, - context.GetSchemaForType(entityType), includeConditionalHeaders: true); - operation.Responses.AddEntityResponse(StatusCodes.Status412PreconditionFailed, - context.GetSchemaForType(entityType), includeConditionalHeaders: true); - - return Task.CompletedTask; + operation.Responses.AddEntityResponse(StatusCodes.Status409Conflict, schemaRef, includeConditionalHeaders: true); + operation.Responses.AddEntityResponse(StatusCodes.Status412PreconditionFailed, schemaRef, includeConditionalHeaders: true); } } diff --git a/src/CommunityToolkit.Datasync.Server.OpenApi/InternalExtensions.cs b/src/CommunityToolkit.Datasync.Server.OpenApi/InternalExtensions.cs index 82ed11b0..fefcba0e 100644 --- a/src/CommunityToolkit.Datasync.Server.OpenApi/InternalExtensions.cs +++ b/src/CommunityToolkit.Datasync.Server.OpenApi/InternalExtensions.cs @@ -4,9 +4,9 @@ using Microsoft.AspNetCore.OpenApi; using Microsoft.AspNetCore.WebUtilities; -using Microsoft.OpenApi.Any; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using System.Net.Mime; +using System.Runtime.CompilerServices; namespace CommunityToolkit.Datasync.Server.OpenApi; @@ -20,15 +20,15 @@ internal static class InternalExtensions /// /// The operation to modify. /// The schema for the entity in the body. - internal static void AddRequestBody(this OpenApiOperation operation, OpenApiSchema bodySchema) + internal static void AddRequestBody(this OpenApiOperation operation, IOpenApiSchema bodySchema) { - operation.RequestBody ??= new OpenApiRequestBody(); - operation.RequestBody.Content.Add(MediaTypeNames.Application.Json, new OpenApiMediaType - { - Schema = bodySchema - }); - operation.RequestBody.Description = "The entity to process."; - operation.RequestBody.Required = true; + operation.RequestBody ??= new OpenApiRequestBody() + { + Content = new Dictionary(), + Description = "The entity to process.", + Required = true + }; + operation.RequestBody.Content?.Add(MediaTypeNames.Application.Json, new OpenApiMediaType { Schema = bodySchema }); } /// @@ -37,7 +37,7 @@ internal static void AddRequestBody(this OpenApiOperation operation, OpenApiSche /// The parameters collection. /// The parameter name. /// The parameter description. - internal static void AddBooleanQueryParameter(this IList parameters, string paramName, string description) + internal static void AddBooleanQueryParameter(this IList parameters, string paramName, string description) { parameters.Add(new OpenApiParameter { @@ -45,11 +45,7 @@ internal static void AddBooleanQueryParameter(this IList param In = ParameterLocation.Query, Description = description, Required = false, - Schema = new OpenApiSchema - { - Type = "string", - Enum = [new OpenApiString("true"), new OpenApiString("false")] - } + Schema = new OpenApiSchema { Type = JsonSchemaType.Boolean } }); } @@ -59,7 +55,7 @@ internal static void AddBooleanQueryParameter(this IList param /// The parameters collection. /// The parameter name. /// The parameter description. - internal static void AddDateTimeHeader(this IList parameters, string headerName, string description) + internal static void AddDateTimeHeader(this IList parameters, string headerName, string description) { parameters.Add(new OpenApiParameter { @@ -67,7 +63,7 @@ internal static void AddDateTimeHeader(this IList parameters, In = ParameterLocation.Header, Description = description, Required = false, - Schema = new OpenApiSchema { Type = "string", Format = "date-time" } + Schema = new OpenApiSchema { Type = JsonSchemaType.String, Format = "date-time" } }); } @@ -78,30 +74,26 @@ internal static void AddDateTimeHeader(this IList parameters, /// The status code for the response. /// The schema of the entity. /// If true, include the headers for conditional access. - internal static void AddEntityResponse(this OpenApiResponses responses, - int statusCode, OpenApiSchema schema, bool includeConditionalHeaders = true) + internal static void AddEntityResponse(this OpenApiResponses responses, int statusCode, IOpenApiSchema schema, bool includeConditionalHeaders = true) { OpenApiResponse response = new() { Description = ReasonPhrases.GetReasonPhrase(statusCode), Content = new Dictionary { - ["application/json"] = new OpenApiMediaType - { - Schema = schema - } + [MediaTypeNames.Application.Json] = new OpenApiMediaType { Schema = schema } }, - Headers = includeConditionalHeaders ? new Dictionary + Headers = includeConditionalHeaders ? new Dictionary { ["ETag"] = new OpenApiHeader { Description = "The ETag value for the entity", - Schema = new OpenApiSchema { Type = "string" } + Schema = new OpenApiSchema { Type = JsonSchemaType.String } }, ["Last-Modified"] = new OpenApiHeader { Description = "The last modified timestamp for the entity", - Schema = new OpenApiSchema { Type = "string", Format = "date-time" } + Schema = new OpenApiSchema { Type = JsonSchemaType.String, Format = "date-time" } } } : null }; @@ -112,35 +104,35 @@ internal static void AddEntityResponse(this OpenApiResponses responses, /// Adds the __includeDeleted query parameter to the operation. /// /// The parameters list to modify. - internal static void AddIncludeDeletedQuery(this IList parameters) + internal static void AddIncludeDeletedQuery(this IList parameters) => parameters.AddBooleanQueryParameter("__includeDeleted", "Include deleted items in the response ('true' or 'false')."); /// /// Adds the If-Modified-Since header to the operation. /// /// The parameters list to modify. - internal static void AddIfModifiedSinceHeader(this IList parameters) + internal static void AddIfModifiedSinceHeader(this IList parameters) => parameters.AddDateTimeHeader("If-Modified-Since", "Timestamp to conditionally fetch the entity"); /// /// Adds the If-Modified-Since header to the operation. /// /// The parameters list to modify. - internal static void AddIfUnmodifiedSinceHeader(this IList parameters) + internal static void AddIfUnmodifiedSinceHeader(this IList parameters) => parameters.AddDateTimeHeader("If-Unmodified-Since", "Timestamp to conditionally fetch the entity"); /// /// Adds the If-None-Match header to the operation. /// /// The parameters list to modify. - internal static void AddIfNoneMatchHeader(this IList parameters) + internal static void AddIfNoneMatchHeader(this IList parameters) => parameters.AddStringHeader("If-None-Match", "ETag value to conditionally fetch the entity"); /// /// Adds the If-Match header to the operation. /// /// The parameters list to modify. - internal static void AddIfMatchHeader(this IList parameters) + internal static void AddIfMatchHeader(this IList parameters) => parameters.AddStringHeader("If-Match", "ETag value to conditionally fetch the entity"); /// @@ -150,7 +142,7 @@ internal static void AddIfMatchHeader(this IList parameters) /// The parameter name. /// The parameter description. /// The minimum value for the parameter. - internal static void AddIntQueryParameter(this IList parameters, string paramName, string description, int minValue = 0) + internal static void AddIntQueryParameter(this IList parameters, string paramName, string description, int minValue = 0) { parameters.Add(new OpenApiParameter { @@ -158,7 +150,7 @@ internal static void AddIntQueryParameter(this IList parameter In = ParameterLocation.Query, Description = description, Required = false, - Schema = new OpenApiSchema { Type = "integer", Minimum = minValue } + Schema = new OpenApiSchema { Type = JsonSchemaType.Integer, Minimum = minValue.ToString() } }); } @@ -176,7 +168,7 @@ internal static void AddStatusCode(this OpenApiResponses responses, int statusCo /// The parameters collection. /// The parameter name. /// The parameter description. - internal static void AddStringHeader(this IList parameters, string headerName, string description) + internal static void AddStringHeader(this IList parameters, string headerName, string description) { parameters.Add(new OpenApiParameter { @@ -184,7 +176,7 @@ internal static void AddStringHeader(this IList parameters, st In = ParameterLocation.Header, Description = description, Required = false, - Schema = new OpenApiSchema { Type = "string" } + Schema = new OpenApiSchema { Type = JsonSchemaType.String } }); } @@ -194,7 +186,7 @@ internal static void AddStringHeader(this IList parameters, st /// The parameters collection. /// The parameter name. /// The parameter description. - internal static void AddStringQueryParameter(this IList parameters, string paramName, string description) + internal static void AddStringQueryParameter(this IList parameters, string paramName, string description) { parameters.Add(new OpenApiParameter { @@ -202,22 +194,59 @@ internal static void AddStringQueryParameter(this IList parame In = ParameterLocation.Query, Description = description, Required = false, - Schema = new OpenApiSchema { Type = "string" } + Schema = new OpenApiSchema { Type = JsonSchemaType.String } }); } /// - /// Retrieves the schema for a given type. If the schema is not found, it is generated. If the schema - /// cannot be generated, an exception is thrown. + /// In a paged schema, sets the schema of the items to reference the provided schema. + /// + /// The schema to adjust. + /// The name of the property holding the items. + /// The schema reference. + internal static void SetSchemaItemsReference(this OpenApiSchema schema, string propertyName, OpenApiSchemaReference schemaRef) + { + if (schema.Properties is not null && schema.Properties.TryGetValue(propertyName, out IOpenApiSchema? value)) + { + ((OpenApiSchema)value).Items = schemaRef; + } + } + + /// + /// Sets the property within the schema to be read-only. /// - /// The operation context. - /// The entity type. - /// The schema for the entity type. - /// Thrown if the schema cannot be found or generated. - internal static OpenApiSchema GetSchemaForType(this OpenApiOperationTransformerContext context, Type type) + /// The schema to adjust. + /// The name of the property. + internal static void SetSchemaPropertyReadonly(this OpenApiSchema schema, string propertyName) { - // TODO: Add support for retrieving schemas. - return new OpenApiSchema { Type = "object" }; + if (schema.Properties is not null && schema.Properties.TryGetValue(propertyName, out IOpenApiSchema? value)) + { + ((OpenApiSchema)value).ReadOnly = true; + } + } + + /// + /// Sets the system properties within the schema to be read-only + /// + /// The schema to adjust. + internal static void SetSystemPropertiesReadonly(this OpenApiSchema schema) + { + schema.SetSchemaPropertyReadonly("updatedAt"); + schema.SetSchemaPropertyReadonly("version"); + schema.SetSchemaPropertyReadonly("deleted"); + } + + /// + /// Throws an exception if the document is not available in the context. + /// + /// The context to check. + /// Thrown if the document is unavailable + internal static void ThrowIfDocumentIsUnavailable(this OpenApiOperationTransformerContext context) + { + if (context.Document == null) + { + throw new InvalidOperationException("The OpenAPI document is not available in this context."); + } } /// diff --git a/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncDocumentFilter.cs b/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncDocumentFilter.cs index 0d4f9561..191bd250 100644 --- a/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncDocumentFilter.cs +++ b/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncDocumentFilter.cs @@ -4,7 +4,7 @@ using CommunityToolkit.Datasync.Server.Filters; using Microsoft.AspNetCore.Mvc.ApiExplorer; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using Swashbuckle.AspNetCore.SwaggerGen; using System.Diagnostics.CodeAnalysis; using System.Reflection; @@ -33,8 +33,8 @@ private enum OpType } // The names of the QueryAsync() and CreateAsync() methods in the TableController. - private const string queryMethod = nameof(TableController.QueryAsync); - private const string createMethod = nameof(TableController.CreateAsync); + private const string queryMethod = nameof(TableController<>.QueryAsync); + private const string createMethod = nameof(TableController<>.CreateAsync); // The list of entity names that have already had their schema adjusted. private readonly List processedEntityNames = []; @@ -51,7 +51,7 @@ public void Apply(OpenApiDocument document, DocumentFilterContext context) if (TryGetTableEntityType(controller, out Type? entityType)) { string? routePath = GetRoutePathFromContext(context, controller); - if (routePath != null) + if (routePath is not null) { ProcessController(entityType!, routePath, document, context); } @@ -59,6 +59,30 @@ public void Apply(OpenApiDocument document, DocumentFilterContext context) } } + /// + /// Adds the schema reference for the entity type if it hasn't already been processed. + /// + /// The document filter context. + /// The OpenApi document. + /// The entity type + internal void AddSchemaReferenceForEntity(DocumentFilterContext context, OpenApiDocument document, Type entityType) + { + if (this.processedEntityNames.Contains(entityType.Name)) + { + return; + } + + // Generate a schema for the entity if it doesn't exist. + if (context.SchemaRepository.Schemas.GetValueOrDefault(entityType.Name) is null) + { + _ = context.SchemaGenerator.GenerateSchema(entityType, context.SchemaRepository); + } + + context.SchemaRepository.Schemas[entityType.Name].MakeSystemPropertiesReadonly(); + _ = document.AddComponent(entityType.Name, context.SchemaRepository.Schemas[entityType.Name]); + this.processedEntityNames.Add(entityType.Name); + } + /// /// Applies the necessary changes to the for a single controller. /// @@ -74,50 +98,37 @@ internal void ProcessController(Type entityType, string routePath, OpenApiDocume // Get the various operations Dictionary operations = []; - AddOperationIfPresent(operations, OpType.Create, document, allEntitiesPath, OperationType.Post); - AddOperationIfPresent(operations, OpType.Delete, document, singleEntityPath, OperationType.Delete); - AddOperationIfPresent(operations, OpType.GetById, document, singleEntityPath, OperationType.Get); - AddOperationIfPresent(operations, OpType.List, document, allEntitiesPath, OperationType.Get); - AddOperationIfPresent(operations, OpType.Replace, document, singleEntityPath, OperationType.Put); - - // Make the system properties in the entity read-only - if (!this.processedEntityNames.Contains(entityType.Name)) - { - // Generate a schema for the entity if it doesn't exist. - if (context.SchemaRepository.Schemas.GetValueOrDefault(entityType.Name) == null) - { - _ = context.SchemaGenerator.GenerateSchema(entityType, context.SchemaRepository); - } + AddOperationIfPresent(operations, OpType.Create, document, allEntitiesPath, HttpMethod.Post); + AddOperationIfPresent(operations, OpType.Delete, document, singleEntityPath, HttpMethod.Delete); + AddOperationIfPresent(operations, OpType.GetById, document, singleEntityPath, HttpMethod.Get); + AddOperationIfPresent(operations, OpType.List, document, allEntitiesPath, HttpMethod.Get); + AddOperationIfPresent(operations, OpType.Replace, document, singleEntityPath, HttpMethod.Put); - // This is a Datasync schema, so update the schema for the datasync attributes. - context.SchemaRepository.Schemas[entityType.Name].MakeSystemPropertiesReadonly(); - context.SchemaRepository.Schemas[entityType.Name].UnresolvedReference = false; - context.SchemaRepository.Schemas[entityType.Name].Reference = new OpenApiReference - { - Id = entityType.Name, - Type = ReferenceType.Schema - }; - this.processedEntityNames.Add(entityType.Name); - } + // Add the schema for the entity type if it doesn't already exist. + AddSchemaReferenceForEntity(context, document, entityType); + IOpenApiSchema schema = new OpenApiSchemaReference(entityType.Name, document); + // Create the schema for a list of entities. Type listEntityType = typeof(Page<>).MakeGenericType(entityType); - OpenApiSchema listSchemaRef = context.SchemaRepository.Schemas.GetValueOrDefault(listEntityType.Name) + IOpenApiSchema listSchemaRef = context.SchemaRepository.Schemas.GetValueOrDefault(listEntityType.Name) ?? context.SchemaGenerator.GenerateSchema(listEntityType, context.SchemaRepository); foreach (KeyValuePair operation in operations) { + operation.Value.Responses ??= []; + // Each operation also has certain modifications. switch (operation.Key) { case OpType.Create: // Request Edits operation.Value.AddConditionalHeader(true); - operation.Value.AddRequestWithContent(context.SchemaRepository.Schemas[entityType.Name]); + operation.Value.AddRequestWithContent(schema); // Response Edits - operation.Value.AddResponseWithContent("201", "Created", context.SchemaRepository.Schemas[entityType.Name]); + operation.Value.AddResponseWithContent("201", "Created", schema); operation.Value.Responses["400"] = new OpenApiResponse { Description = "Bad Request" }; - operation.Value.AddConflictResponse(context.SchemaRepository.Schemas[entityType.Name]); + operation.Value.AddConflictResponse(schema); break; case OpType.Delete: @@ -128,7 +139,7 @@ internal void ProcessController(Type entityType, string routePath, OpenApiDocume operation.Value.Responses["204"] = new OpenApiResponse { Description = "No Content" }; operation.Value.Responses["404"] = new OpenApiResponse { Description = "Not Found" }; operation.Value.Responses["410"] = new OpenApiResponse { Description = "Gone" }; - operation.Value.AddConflictResponse(context.SchemaRepository.Schemas[entityType.Name]); + operation.Value.AddConflictResponse(schema); break; case OpType.GetById: @@ -136,7 +147,7 @@ internal void ProcessController(Type entityType, string routePath, OpenApiDocume operation.Value.AddConditionalHeader(true); // Response Edits - operation.Value.AddResponseWithContent("200", "OK", context.SchemaRepository.Schemas[entityType.Name]); + operation.Value.AddResponseWithContent("200", "OK", schema); operation.Value.Responses["304"] = new OpenApiResponse { Description = "Not Modified" }; operation.Value.Responses["404"] = new OpenApiResponse { Description = "Not Found" }; break; @@ -153,14 +164,14 @@ internal void ProcessController(Type entityType, string routePath, OpenApiDocume case OpType.Replace: // Request Edits operation.Value.AddConditionalHeader(); - operation.Value.AddRequestWithContent(context.SchemaRepository.Schemas[entityType.Name]); + operation.Value.AddRequestWithContent(schema); // Response Edits - operation.Value.AddResponseWithContent("200", "OK", context.SchemaRepository.Schemas[entityType.Name]); + operation.Value.AddResponseWithContent("200", "OK", schema); operation.Value.Responses["400"] = new OpenApiResponse { Description = "Bad Request" }; operation.Value.Responses["404"] = new OpenApiResponse { Description = "Not Found" }; operation.Value.Responses["410"] = new OpenApiResponse { Description = "Gone" }; - operation.Value.AddConflictResponse(context.SchemaRepository.Schemas[entityType.Name]); + operation.Value.AddConflictResponse(schema); break; } } @@ -179,14 +190,13 @@ internal void ProcessController(Type entityType, string routePath, OpenApiDocume /// The being processed. /// The expected path for the operation type. /// The operation type being processed. - private static void AddOperationIfPresent(Dictionary operations, OpType opType, OpenApiDocument document, string path, OperationType operationType) + private static void AddOperationIfPresent(Dictionary operations, OpType opType, OpenApiDocument document, string path, HttpMethod operationType) { - if (document.Paths.TryGetValue(path, out OpenApiPathItem? pathValue)) + if (document.Paths.TryGetValue(path, out IOpenApiPathItem? pathValue) + && pathValue.Operations is not null + && pathValue.Operations.TryGetValue(operationType, out OpenApiOperation? operation)) { - if (pathValue!.Operations.TryGetValue(operationType, out OpenApiOperation? operation)) - { - operations[opType] = operation!; - } + operations[opType] = operation!; } } @@ -228,7 +238,7 @@ internal static bool IsApiDescriptionForController(ApiDescription description, T /// The assembly to query. Be default, the calling assembly is queried. /// The list of table controllers in the assembly. internal static List GetAllTableControllers(Assembly? assembly) - => (assembly ?? Assembly.GetCallingAssembly()).GetTypes().Where(IsTableController).ToList(); + => [.. (assembly ?? Assembly.GetCallingAssembly()).GetTypes().Where(IsTableController)]; /// /// Determines if the controller type provided is a datasync table controller. @@ -237,9 +247,9 @@ internal static List GetAllTableControllers(Assembly? assembly) /// true if the type is a datasync table controller. internal static bool IsTableController(Type type) { - if (!type.IsAbstract && type.BaseType != null && type.BaseType.IsGenericType == true) + if (!type.IsAbstract && type.BaseType is not null && type.BaseType.IsGenericType == true) { - if (type.GetCustomAttribute() != null) + if (type.GetCustomAttribute() is not null) { return true; } diff --git a/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncOperationExtensions.cs b/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncOperationExtensions.cs index caed0448..f0f91728 100644 --- a/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncOperationExtensions.cs +++ b/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncOperationExtensions.cs @@ -2,7 +2,8 @@ // 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.OpenApi.Models; +using Microsoft.OpenApi; +using System; namespace CommunityToolkit.Datasync.Server.Swashbuckle; @@ -19,6 +20,8 @@ internal static class DatasyncOperationExtensions /// If true, add a If-None-Match header. internal static void AddConditionalHeader(this OpenApiOperation operation, bool ifNoneMatch = false) { + operation.Parameters ??= []; + string headerName = ifNoneMatch ? "If-None-Match" : "If-Match"; string description = ifNoneMatch ? "Conditionally execute only if the entity version does not match the provided string (RFC 9110 13.1.2)." @@ -30,7 +33,7 @@ internal static void AddConditionalHeader(this OpenApiOperation operation, bool Description = description, In = ParameterLocation.Header, Required = false, - Schema = new OpenApiSchema { Type = "string" } + Schema = new OpenApiSchema { Type = JsonSchemaType.String } }); } @@ -41,8 +44,9 @@ internal static void AddConditionalHeader(this OpenApiOperation operation, bool /// The name of the query parameter. /// The OpenAPI type for the query parameter. /// The OpenAPI description for the query parameter. - internal static void AddODataQueryParameter(this OpenApiOperation operation, string parameterName, string parameterType, string description) + internal static void AddODataQueryParameter(this OpenApiOperation operation, string parameterName, JsonSchemaType parameterType, string description) { + operation.Parameters ??= []; operation.Parameters.Add(new OpenApiParameter { Name = parameterName, @@ -59,13 +63,13 @@ internal static void AddODataQueryParameter(this OpenApiOperation operation, str /// The reference. internal static void AddODataQueryParameters(this OpenApiOperation operation) { - operation.AddODataQueryParameter("$count", "boolean", "If true, return the total number of items matched by the filter"); - operation.AddODataQueryParameter("$filter", "string", "An OData filter describing the entities to be returned"); - operation.AddODataQueryParameter("$orderby", "string", "A comma-separated list of ordering instructions. Each ordering instruction is a field name with an optional direction (asc or desc)."); - operation.AddODataQueryParameter("$select", "string", "A comma-separated list of fields to be returned in the result set."); - operation.AddODataQueryParameter("$skip", "integer", "The number of items in the list to skip for paging support."); - operation.AddODataQueryParameter("$top", "integer", "The number of items in the list to return for paging support."); - operation.AddODataQueryParameter("__includedeleted", "boolean", "If true, soft-deleted items are returned as well as non-deleted items."); + operation.AddODataQueryParameter("$count", JsonSchemaType.Boolean, "If true, return the total number of items matched by the filter"); + operation.AddODataQueryParameter("$filter", JsonSchemaType.String, "An OData filter describing the entities to be returned"); + operation.AddODataQueryParameter("$orderby", JsonSchemaType.String, "A comma-separated list of ordering instructions. Each ordering instruction is a field name with an optional direction (asc or desc)."); + operation.AddODataQueryParameter("$select", JsonSchemaType.String, "A comma-separated list of fields to be returned in the result set."); + operation.AddODataQueryParameter("$skip", JsonSchemaType.Integer, "The number of items in the list to skip for paging support."); + operation.AddODataQueryParameter("$top", JsonSchemaType.Integer, "The number of items in the list to return for paging support."); + operation.AddODataQueryParameter("__includedeleted", JsonSchemaType.Boolean, "If true, soft-deleted items are returned as well as non-deleted items."); } /// @@ -75,7 +79,7 @@ internal static void AddODataQueryParameters(this OpenApiOperation operation) /// The HTTP status code to model. /// The description of the HTTP status code. /// The schema of the entity to return. - internal static void AddResponseWithContent(this OpenApiOperation operation, string statusCode, string description, OpenApiSchema schema) + internal static void AddResponseWithContent(this OpenApiOperation operation, string statusCode, string description, IOpenApiSchema schema) { OpenApiResponse response = new() { @@ -83,16 +87,20 @@ internal static void AddResponseWithContent(this OpenApiOperation operation, str Content = new Dictionary { [JsonMediaType] = new OpenApiMediaType { Schema = schema } - } + }, + Headers = new Dictionary() }; string etagDescription = statusCode is "409" or "412" ? "The opaque versioning identifier of the conflicting entity" : "The opaque versioning identifier of the entity"; + response.Headers.Add("ETag", new OpenApiHeader { - Schema = new OpenApiSchema { Type = "string" }, + Schema = new OpenApiSchema { Type = JsonSchemaType.String }, Description = $"{etagDescription}, per RFC 9110 8.8.3." }); + + operation.Responses ??= []; operation.Responses[statusCode] = response; } /// @@ -100,7 +108,7 @@ internal static void AddResponseWithContent(this OpenApiOperation operation, str /// /// The to modify. /// The schema of the entity in the request. - internal static void AddRequestWithContent(this OpenApiOperation operation, OpenApiSchema schema) + internal static void AddRequestWithContent(this OpenApiOperation operation, IOpenApiSchema schema) { operation.RequestBody = new OpenApiRequestBody { @@ -119,7 +127,7 @@ internal static void AddRequestWithContent(this OpenApiOperation operation, Open /// /// The to modify. /// The schema of the entity to return. - internal static void AddConflictResponse(this OpenApiOperation operation, OpenApiSchema schema) + internal static void AddConflictResponse(this OpenApiOperation operation, IOpenApiSchema schema) { operation.AddResponseWithContent("409", "Conflict", schema); operation.AddResponseWithContent("412", "Precondition failed", schema); @@ -129,13 +137,13 @@ internal static void AddConflictResponse(this OpenApiOperation operation, OpenAp /// Makes the system properties in the schema read-only. /// /// The to edit. - public static void MakeSystemPropertiesReadonly(this OpenApiSchema schema) + public static void MakeSystemPropertiesReadonly(this IOpenApiSchema schema) { - foreach (KeyValuePair property in schema.Properties) + foreach (KeyValuePair property in schema.Properties!) { if (SystemProperties.Contains(property.Key)) { - property.Value.ReadOnly = true; + ((OpenApiSchema)property.Value).ReadOnly = true; } } } diff --git a/src/Directory.Build.props b/src/Directory.Build.props index bc4bfd73..8ac1990c 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -23,10 +23,10 @@ true true true - 13.0 + latest en enable - net9.0 + net10.0 diff --git a/tests/CommunityToolkit.Datasync.Client.Test/GlobalUsings.cs b/tests/CommunityToolkit.Datasync.Client.Test/GlobalUsings.cs index 8664b537..e531aafa 100644 --- a/tests/CommunityToolkit.Datasync.Client.Test/GlobalUsings.cs +++ b/tests/CommunityToolkit.Datasync.Client.Test/GlobalUsings.cs @@ -2,6 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -global using FluentAssertions; +global using AwesomeAssertions; global using System.Diagnostics.CodeAnalysis; global using Xunit; diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Query/IDatasyncPullQuery_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Query/IDatasyncPullQuery_Tests.cs index ae346dfa..c0f2d470 100644 --- a/tests/CommunityToolkit.Datasync.Client.Test/Query/IDatasyncPullQuery_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Client.Test/Query/IDatasyncPullQuery_Tests.cs @@ -1177,7 +1177,7 @@ public void Linq_Where_StartsWith_InvariantIgnoreCase() ); } - [Fact] + [Fact(Skip = "OData v8.4 does not allow string.contains")] public void Linq_Where_String_Contains() { string[] ratings = ["A", "B"]; diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Query/IDatasyncQueryable_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Query/IDatasyncQueryable_Tests.cs index 88c95b2d..f57752d7 100644 --- a/tests/CommunityToolkit.Datasync.Client.Test/Query/IDatasyncQueryable_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Client.Test/Query/IDatasyncQueryable_Tests.cs @@ -1416,7 +1416,7 @@ public void Linq_Where_StartsWith_InvariantIgnoreCase() ); } - [Fact] + [Fact(Skip = "OData v8.4 does not allow string.contains")] public void Linq_Where_String_Contains() { string[] ratings = ["A", "B"]; diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Service/DatasyncServiceClient_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Service/DatasyncServiceClient_Tests.cs index 36290fc2..edad68d3 100644 --- a/tests/CommunityToolkit.Datasync.Client.Test/Service/DatasyncServiceClient_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Client.Test/Service/DatasyncServiceClient_Tests.cs @@ -11,12 +11,11 @@ using CommunityToolkit.Datasync.Client.Http; using CommunityToolkit.Datasync.Client.Serialization; using CommunityToolkit.Datasync.Client.Test.Helpers; -using CommunityToolkit.Datasync.TestCommon; using CommunityToolkit.Datasync.TestCommon.Databases; using CommunityToolkit.Datasync.TestCommon.Mocks; using CommunityToolkit.Datasync.TestCommon.Models; -using FluentAssertions.Equivalency; -using FluentAssertions.Extensions; +using AwesomeAssertions.Equivalency; +using AwesomeAssertions.Extensions; using System.Net; using System.Net.Http.Headers; using System.Text; @@ -3548,7 +3547,7 @@ public void Linq_Where_StartsWith_InvariantIgnoreCase() ); } - [Fact] + [Fact(Skip = "OData v8.4 does not allow string.contains")] public void Linq_Where_String_Contains() { string[] ratings = ["A", "B"]; diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Service/Integration_Query_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Service/Integration_Query_Tests.cs index bb20aee7..de43fd19 100644 --- a/tests/CommunityToolkit.Datasync.Client.Test/Service/Integration_Query_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Client.Test/Service/Integration_Query_Tests.cs @@ -540,7 +540,7 @@ await KitchenSinkQueryTest( // ); //} - [Fact] + [Fact(Skip = "OData v8.4 does not allow string.contains")] public async Task KitchenSinkQueryTest_020() { SeedKitchenSinkWithCountryData(); diff --git a/tests/CommunityToolkit.Datasync.Server.Abstractions.Test/GlobalUsings.cs b/tests/CommunityToolkit.Datasync.Server.Abstractions.Test/GlobalUsings.cs index 8664b537..e531aafa 100644 --- a/tests/CommunityToolkit.Datasync.Server.Abstractions.Test/GlobalUsings.cs +++ b/tests/CommunityToolkit.Datasync.Server.Abstractions.Test/GlobalUsings.cs @@ -2,6 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -global using FluentAssertions; +global using AwesomeAssertions; global using System.Diagnostics.CodeAnalysis; global using Xunit; diff --git a/tests/CommunityToolkit.Datasync.Server.Automapper.Test/GlobalUsings.cs b/tests/CommunityToolkit.Datasync.Server.Automapper.Test/GlobalUsings.cs index b6533953..c9ee8a68 100644 --- a/tests/CommunityToolkit.Datasync.Server.Automapper.Test/GlobalUsings.cs +++ b/tests/CommunityToolkit.Datasync.Server.Automapper.Test/GlobalUsings.cs @@ -2,6 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -global using FluentAssertions; +global using AwesomeAssertions; global using System.Diagnostics.CodeAnalysis; global using Xunit; \ No newline at end of file diff --git a/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/CosmosTableData_Tests.cs b/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/CosmosTableData_Tests.cs index e834fc0d..6772e223 100644 --- a/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/CosmosTableData_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/CosmosTableData_Tests.cs @@ -5,7 +5,7 @@ using CommunityToolkit.Datasync.Common.Test; using CommunityToolkit.Datasync.Server.CosmosDb; using CommunityToolkit.Datasync.TestCommon; -using FluentAssertions; +using AwesomeAssertions; namespace CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test; diff --git a/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/PackedKeyRepository_Tests.cs b/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/PackedKeyRepository_Tests.cs index 3ee6a60b..0bb4a7e3 100644 --- a/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/PackedKeyRepository_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/PackedKeyRepository_Tests.cs @@ -6,7 +6,7 @@ using CommunityToolkit.Datasync.Server.CosmosDb.Test.Models; using CommunityToolkit.Datasync.Server.CosmosDb.Test.Options; using CommunityToolkit.Datasync.TestCommon; -using FluentAssertions; +using AwesomeAssertions; using Microsoft.Azure.Cosmos; using Microsoft.Azure.Cosmos.Linq; using System.Collections.ObjectModel; diff --git a/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/GlobalUsings.cs b/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/GlobalUsings.cs index af04271b..bd7d0840 100644 --- a/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/GlobalUsings.cs +++ b/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/GlobalUsings.cs @@ -2,7 +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. -global using FluentAssertions; +global using AwesomeAssertions; global using System.Diagnostics.CodeAnalysis; global using Xunit; diff --git a/tests/CommunityToolkit.Datasync.Server.InMemory.Test/GlobalUsings.cs b/tests/CommunityToolkit.Datasync.Server.InMemory.Test/GlobalUsings.cs index b6533953..c9ee8a68 100644 --- a/tests/CommunityToolkit.Datasync.Server.InMemory.Test/GlobalUsings.cs +++ b/tests/CommunityToolkit.Datasync.Server.InMemory.Test/GlobalUsings.cs @@ -2,6 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -global using FluentAssertions; +global using AwesomeAssertions; global using System.Diagnostics.CodeAnalysis; global using Xunit; \ No newline at end of file diff --git a/tests/CommunityToolkit.Datasync.Server.LiteDb.Test/GlobalUsings.cs b/tests/CommunityToolkit.Datasync.Server.LiteDb.Test/GlobalUsings.cs index 8664b537..e531aafa 100644 --- a/tests/CommunityToolkit.Datasync.Server.LiteDb.Test/GlobalUsings.cs +++ b/tests/CommunityToolkit.Datasync.Server.LiteDb.Test/GlobalUsings.cs @@ -2,6 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -global using FluentAssertions; +global using AwesomeAssertions; global using System.Diagnostics.CodeAnalysis; global using Xunit; diff --git a/tests/CommunityToolkit.Datasync.Server.MongoDB.Test/MongoTableData_Tests.cs b/tests/CommunityToolkit.Datasync.Server.MongoDB.Test/MongoTableData_Tests.cs index ca81a347..6d060875 100644 --- a/tests/CommunityToolkit.Datasync.Server.MongoDB.Test/MongoTableData_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Server.MongoDB.Test/MongoTableData_Tests.cs @@ -4,7 +4,7 @@ using CommunityToolkit.Datasync.Common.Test; using CommunityToolkit.Datasync.TestCommon; -using FluentAssertions; +using AwesomeAssertions; using Xunit; namespace CommunityToolkit.Datasync.Server.MongoDB.Test; diff --git a/tests/CommunityToolkit.Datasync.Server.NSwag.Test/GlobalUsings.cs b/tests/CommunityToolkit.Datasync.Server.NSwag.Test/GlobalUsings.cs index e67647b3..1fa788ae 100644 --- a/tests/CommunityToolkit.Datasync.Server.NSwag.Test/GlobalUsings.cs +++ b/tests/CommunityToolkit.Datasync.Server.NSwag.Test/GlobalUsings.cs @@ -2,7 +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. -global using FluentAssertions; +global using AwesomeAssertions; global using System.Diagnostics.CodeAnalysis; global using Xunit; diff --git a/tests/CommunityToolkit.Datasync.Server.NSwag.Test/NSwag_Tests.cs b/tests/CommunityToolkit.Datasync.Server.NSwag.Test/NSwag_Tests.cs index c9a1e3fe..bccfd54d 100644 --- a/tests/CommunityToolkit.Datasync.Server.NSwag.Test/NSwag_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Server.NSwag.Test/NSwag_Tests.cs @@ -6,8 +6,11 @@ using CommunityToolkit.Datasync.Server.NSwag.Test.Service; using CommunityToolkit.Datasync.TestCommon; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using NSwag; using System.Reflection; using System.Text.RegularExpressions; @@ -17,12 +20,26 @@ namespace CommunityToolkit.Datasync.Server.NSwag.Test; [ExcludeFromCodeCoverage] public class NSwag_Tests { - private readonly TestServer server = NSwagServer.CreateTestServer(); - [Fact] public async Task NSwag_GeneratesSwagger() { - HttpClient client = this.server.CreateClient(); + using IHost host = new HostBuilder().ConfigureWebHost(builder => + { + builder + .UseTestServer() + .UseEnvironment("Test") + .UseContentRoot(AppContext.BaseDirectory) + .UseStartup(); + }).Build(); + await host.StartAsync(); + + TestServer server = host.GetTestServer(); + + using IServiceScope scope = server.Services.CreateScope(); + ServiceDbContext context = scope.ServiceProvider.GetRequiredService(); + context.InitializeDatabase(); + + HttpClient client = server.CreateClient(); string actualContent = (await client.GetStringAsync("swagger/v1/swagger.json")).NormalizeContent(); string expectedContent = Assembly.GetExecutingAssembly().ReadExternalFile("swagger.json"); @@ -56,17 +73,16 @@ public void NSwag_AddMissingSchemaProperties_CornerCase() act.Should().NotThrow(); } - [Fact] - public void ContainsRequestHeader_ReturnsFalse_WhenQueryParam() + [Theory] + [InlineData("X-DOES-NOT-EXIST", false)] + [InlineData("$count", false)] + public void ContainsRequestHeader_ReturnsFalse_WhenQueryParam(string headerName, bool expected) { OpenApiOperation sut = new(); sut.AddODataQueryParameters(); - // Something that doesn't exist. - sut.ContainsRequestHeader("X-DOES-NOT-EXIST").Should().BeFalse(); - - // Something that exists as a query parameter. - sut.ContainsRequestHeader("$count").Should().BeFalse(); + // Check the requested parameters + sut.ContainsRequestHeader(headerName).Should().Be(expected); } [Theory] diff --git a/tests/CommunityToolkit.Datasync.Server.NSwag.Test/Service/NSwagServer.cs b/tests/CommunityToolkit.Datasync.Server.NSwag.Test/Service/NSwagServer.cs index 4086dd57..08004139 100644 --- a/tests/CommunityToolkit.Datasync.Server.NSwag.Test/Service/NSwagServer.cs +++ b/tests/CommunityToolkit.Datasync.Server.NSwag.Test/Service/NSwagServer.cs @@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -12,25 +11,6 @@ namespace CommunityToolkit.Datasync.Server.NSwag.Test.Service; -[ExcludeFromCodeCoverage] -internal static class NSwagServer -{ - internal static TestServer CreateTestServer() - { - IWebHostBuilder builder = new WebHostBuilder() - .UseEnvironment("Test") - .UseContentRoot(AppContext.BaseDirectory) - .UseStartup(); - TestServer server = new(builder); - - using IServiceScope scope = server.Services.CreateScope(); - ServiceDbContext context = scope.ServiceProvider.GetRequiredService(); - context.InitializeDatabase(); - - return server; - } -} - [ExcludeFromCodeCoverage] internal class ServiceStartup { diff --git a/tests/CommunityToolkit.Datasync.Server.NSwag.Test/swagger.json b/tests/CommunityToolkit.Datasync.Server.NSwag.Test/swagger.json index d58fbd24..f0528939 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.\n\n\nIn addition, the __includeDeleted parameter is used to decide whether to include soft-deleted items in the result.", + "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- $count is used to return a count of entities within the search parameters within the PagedResult 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.\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.\n\n\nIn addition, the __includeDeleted parameter is used to decide whether to include soft-deleted items in the result.", + "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- $count is used to return a count of entities within the search parameters within the PagedResult 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.\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.\n\n\nIn addition, the __includeDeleted parameter is used to decide whether to include soft-deleted items in the result.", + "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- $count is used to return a count of entities within the search parameters within the PagedResult 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.\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.OpenApi.Test/CommunityToolkit.Datasync.Server.OpenApi.Test.csproj b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/CommunityToolkit.Datasync.Server.OpenApi.Test.csproj index 30d0eb2a..a6ac1138 100644 --- a/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/CommunityToolkit.Datasync.Server.OpenApi.Test.csproj +++ b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/CommunityToolkit.Datasync.Server.OpenApi.Test.csproj @@ -10,6 +10,12 @@ + + + InterceptorsPreview + $(InterceptorsNamespaces);Microsoft.AspNetCore.OpenApi.Generated + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/GlobalUsings.cs b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/GlobalUsings.cs index e67647b3..1fa788ae 100644 --- a/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/GlobalUsings.cs +++ b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/GlobalUsings.cs @@ -2,7 +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. -global using FluentAssertions; +global using AwesomeAssertions; global using System.Diagnostics.CodeAnalysis; global using Xunit; diff --git a/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/OpenApi_Tests.cs b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/OpenApi_Tests.cs index 102150e2..56b2ab62 100644 --- a/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/OpenApi_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/OpenApi_Tests.cs @@ -4,7 +4,10 @@ using CommunityToolkit.Datasync.Server.OpenApi.Test.Service; using CommunityToolkit.Datasync.TestCommon; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using System.Reflection; using System.Text.RegularExpressions; @@ -13,12 +16,26 @@ namespace CommunityToolkit.Datasync.Server.OpenApi.Test; [ExcludeFromCodeCoverage] public class OpenApi_Tests { - private readonly TestServer server = OpenApiServer.CreateTestServer(); - [Fact] public async Task GeneratesCorrectOpenApiFile() { - HttpClient client = this.server.CreateClient(); + using IHost host = new HostBuilder().ConfigureWebHost(builder => + { + builder + .UseTestServer() + .UseEnvironment("Test") + .UseContentRoot(AppContext.BaseDirectory) + .UseStartup(); + }).Build(); + await host.StartAsync(); + + TestServer server = host.GetTestServer(); + + using IServiceScope scope = server.Services.CreateScope(); + ServiceDbContext context = scope.ServiceProvider.GetRequiredService(); + context.InitializeDatabase(); + + HttpClient client = server.CreateClient(); string actualContent = (await client.GetStringAsync("openapi/v1.json")).NormalizeContent(); string expectedContent = Assembly.GetExecutingAssembly().ReadExternalFile("openapi.json"); diff --git a/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/Service/OpenApiServer.cs b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/Service/OpenApiServer.cs index 80f56ffe..eaa0c695 100644 --- a/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/Service/OpenApiServer.cs +++ b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/Service/OpenApiServer.cs @@ -9,28 +9,10 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; namespace CommunityToolkit.Datasync.Server.OpenApi.Test.Service; -[ExcludeFromCodeCoverage] -internal static class OpenApiServer -{ - internal static TestServer CreateTestServer() - { - IWebHostBuilder builder = new WebHostBuilder() - .UseEnvironment("Test") - .UseContentRoot(AppContext.BaseDirectory) - .UseStartup(); - TestServer server = new(builder); - - using IServiceScope scope = server.Services.CreateScope(); - ServiceDbContext context = scope.ServiceProvider.GetRequiredService(); - context.InitializeDatabase(); - - return server; - } -} - [ExcludeFromCodeCoverage] internal class ServiceStartup { diff --git a/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/openapi.json b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/openapi.json index 709ef711..f03a1bea 100644 --- a/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/openapi.json +++ b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/openapi.json @@ -1,5 +1,5 @@ { - "openapi": "3.0.1", + "openapi": "3.1.1", "info": { "title": "CommunityToolkit.Datasync.Server.OpenApi.Test | v1", "version": "1.0.0" @@ -21,11 +21,7 @@ "in": "query", "description": "Whether to include the total count of items matching the query in the result", "schema": { - "enum": [ - "true", - "false" - ], - "type": "string" + "type": "boolean" } }, { @@ -75,11 +71,7 @@ "in": "query", "description": "Include deleted items in the response ('true' or 'false').", "schema": { - "enum": [ - "true", - "false" - ], - "type": "string" + "type": "boolean" } } ], @@ -89,7 +81,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/KitchenSinkPage" } } } @@ -119,11 +111,7 @@ "in": "query", "description": "Include deleted items in the response ('true' or 'false').", "schema": { - "enum": [ - "true", - "false" - ], - "type": "string" + "type": "boolean" } }, { @@ -165,7 +153,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/KitchenSink" } } } @@ -192,7 +180,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/KitchenSink" } } }, @@ -219,7 +207,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/KitchenSink" } } } @@ -247,7 +235,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/KitchenSink" } } } @@ -272,7 +260,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/KitchenSink" } } } @@ -289,11 +277,7 @@ "in": "query", "description": "Whether to include the total count of items matching the query in the result", "schema": { - "enum": [ - "true", - "false" - ], - "type": "string" + "type": "boolean" } }, { @@ -343,11 +327,7 @@ "in": "query", "description": "Include deleted items in the response ('true' or 'false').", "schema": { - "enum": [ - "true", - "false" - ], - "type": "string" + "type": "boolean" } } ], @@ -357,7 +337,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/KitchenSinkPage" } } } @@ -433,7 +413,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/KitchenSink" } } } @@ -458,7 +438,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/KitchenSink" } } } @@ -483,11 +463,7 @@ "in": "query", "description": "Include deleted items in the response ('true' or 'false').", "schema": { - "enum": [ - "true", - "false" - ], - "type": "string" + "type": "boolean" } }, { @@ -529,7 +505,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/KitchenSink" } } } @@ -563,11 +539,7 @@ "in": "query", "description": "Include deleted items in the response ('true' or 'false').", "schema": { - "enum": [ - "true", - "false" - ], - "type": "string" + "type": "boolean" } }, { @@ -593,7 +565,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/KitchenSink" } } }, @@ -620,7 +592,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/KitchenSink" } } } @@ -654,7 +626,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/KitchenSink" } } } @@ -679,7 +651,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/KitchenSink" } } } @@ -697,7 +669,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/TodoItem" } } }, @@ -724,7 +696,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/TodoItem" } } } @@ -752,7 +724,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/TodoItem" } } } @@ -777,7 +749,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/TodoItem" } } } @@ -794,11 +766,7 @@ "in": "query", "description": "Whether to include the total count of items matching the query in the result", "schema": { - "enum": [ - "true", - "false" - ], - "type": "string" + "type": "boolean" } }, { @@ -848,11 +816,7 @@ "in": "query", "description": "Include deleted items in the response ('true' or 'false').", "schema": { - "enum": [ - "true", - "false" - ], - "type": "string" + "type": "boolean" } } ], @@ -862,7 +826,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/TodoItemPage" } } } @@ -938,7 +902,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/TodoItem" } } } @@ -963,7 +927,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/TodoItem" } } } @@ -988,11 +952,7 @@ "in": "query", "description": "Include deleted items in the response ('true' or 'false').", "schema": { - "enum": [ - "true", - "false" - ], - "type": "string" + "type": "boolean" } }, { @@ -1034,7 +994,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/TodoItem" } } } @@ -1068,11 +1028,7 @@ "in": "query", "description": "Include deleted items in the response ('true' or 'false').", "schema": { - "enum": [ - "true", - "false" - ], - "type": "string" + "type": "boolean" } }, { @@ -1098,7 +1054,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/TodoItem" } } }, @@ -1125,7 +1081,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/TodoItem" } } } @@ -1159,7 +1115,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/TodoItem" } } } @@ -1184,7 +1140,7 @@ "content": { "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/TodoItem" } } } @@ -1193,7 +1149,240 @@ } } }, - "components": { }, + "components": { + "schemas": { + "KitchenSink": { + "type": "object", + "properties": { + "booleanValue": { + "type": "boolean" + }, + "byteValue": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "uint8" + }, + "byteArrayValue": { + "type": [ + "null", + "string" + ], + "format": "byte" + }, + "charValue": { + "maxLength": 1, + "minLength": 1, + "type": "string", + "format": "char" + }, + "dateOnlyValue": { + "type": "string", + "format": "date" + }, + "dateTimeValue": { + "type": "string", + "format": "date-time" + }, + "dateTimeOffsetValue": { + "type": "string", + "format": "date-time" + }, + "decimalValue": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + }, + "doubleValue": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + }, + "enumValue": { + "type": "integer" + }, + "floatValue": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "float" + }, + "guidValue": { + "type": [ + "null", + "string" + ], + "format": "uuid" + }, + "intValue": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "longValue": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "nullableDouble": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "null", + "number", + "string" + ], + "format": "double" + }, + "nullableEnumValue": { + "type": "integer" + }, + "stringValue": { + "type": [ + "null", + "string" + ] + }, + "timeOnlyValue": { + "type": "string", + "format": "time" + }, + "updatedAt": { + "type": [ + "null", + "string" + ], + "format": "date-time", + "readOnly": true + }, + "version": { + "type": "string", + "format": "byte", + "readOnly": true + }, + "id": { + "type": "string" + }, + "deleted": { + "type": "boolean", + "readOnly": true + } + } + }, + "KitchenSinkPage": { + "required": [ + "items" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KitchenSink" + }, + "readOnly": true + }, + "count": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int32", + "readOnly": true + }, + "nextLink": { + "type": [ + "null", + "string" + ], + "readOnly": true + } + }, + "additionalProperties": false + }, + "TodoItem": { + "type": "object", + "properties": { + "title": { + "type": [ + "null", + "string" + ] + }, + "updatedAt": { + "type": [ + "null", + "string" + ], + "format": "date-time", + "readOnly": true + }, + "version": { + "type": "string", + "format": "byte", + "readOnly": true + }, + "id": { + "type": "string" + }, + "deleted": { + "type": "boolean", + "readOnly": true + } + } + }, + "TodoItemPage": { + "required": [ + "items" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TodoItem" + }, + "readOnly": true + }, + "count": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int32", + "readOnly": true + }, + "nextLink": { + "type": [ + "null", + "string" + ], + "readOnly": true + } + }, + "additionalProperties": false + } + } + }, "tags": [ { "name": "KitchenReader" diff --git a/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Service/SwashbuckleServer.cs b/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Service/SwashbuckleServer.cs index 72aaed62..d1b5cb88 100644 --- a/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Service/SwashbuckleServer.cs +++ b/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Service/SwashbuckleServer.cs @@ -9,6 +9,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; namespace CommunityToolkit.Datasync.Server.Swashbuckle.Test.Service; @@ -17,11 +18,15 @@ internal static class SwashbuckleServer { internal static TestServer CreateTestServer() { - IWebHostBuilder builder = new WebHostBuilder() - .UseEnvironment("Test") - .UseContentRoot(AppContext.BaseDirectory) - .UseStartup(); - TestServer server = new(builder); + IHost host = new HostBuilder().ConfigureWebHost(builder => + { + builder + .UseTestServer() + .UseEnvironment("Test") + .UseContentRoot(AppContext.BaseDirectory) + .UseStartup(); + }).Build(); + TestServer server = host.GetTestServer(); using IServiceScope scope = server.Services.CreateScope(); ServiceDbContext context = scope.ServiceProvider.GetRequiredService(); diff --git a/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Swashbuckle_Tests.cs b/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Swashbuckle_Tests.cs index fd2592c0..79974673 100644 --- a/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Swashbuckle_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Swashbuckle_Tests.cs @@ -2,14 +2,18 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using AwesomeAssertions; using CommunityToolkit.Datasync.Server.Swashbuckle.Test.Service; using CommunityToolkit.Datasync.TestCommon; -using FluentAssertions; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using NSubstitute; using Swashbuckle.AspNetCore.SwaggerGen; using System.Reflection; +using System.Text.RegularExpressions; using Xunit; namespace CommunityToolkit.Datasync.Server.Swashbuckle.Test; @@ -17,20 +21,33 @@ namespace CommunityToolkit.Datasync.Server.Swashbuckle.Test; [ExcludeFromCodeCoverage] public class Swashbuckle_Tests { - private readonly TestServer server = SwashbuckleServer.CreateTestServer(); - [Fact] public async Task Swashbuckle_GeneratesSwagger() { - HttpClient client = this.server.CreateClient(); + using IHost host = new HostBuilder().ConfigureWebHost(builder => + { + builder + .UseTestServer() + .UseEnvironment("Test") + .UseContentRoot(AppContext.BaseDirectory) + .UseStartup(); + }).Build(); + await host.StartAsync(); + + TestServer server = host.GetTestServer(); + + using IServiceScope scope = server.Services.CreateScope(); + ServiceDbContext context = scope.ServiceProvider.GetRequiredService(); + context.InitializeDatabase(); + HttpClient client = server.CreateClient(); string actualContent = (await client.GetStringAsync("swagger/v1/swagger.json")).NormalizeContent(); string expectedContent = Assembly.GetExecutingAssembly().ReadExternalFile("swagger.json"); // There is an x-generator field that is library specific and completely irrelevant // to the comparison, so this line will remove it for comparison purposes. - //Regex generatorRegex = new("\"x-generator\": \"[^\\\"]+\","); - //actualContent = generatorRegex.Replace(actualContent, "", 1); - //expectedContent = generatorRegex.Replace(expectedContent, "", 1); + Regex generatorRegex = new("\"x-generator\": \"[^\\\"]+\","); + actualContent = generatorRegex.Replace(actualContent, "", 1); + expectedContent = generatorRegex.Replace(expectedContent, "", 1); // If the expected content is different, it is really hard to diagnose why. // Likelihood is that NSwag changed the formatting, and you just need to diff --git a/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/swagger.json b/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/swagger.json index f5757d81..ab6c2268 100644 --- a/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/swagger.json +++ b/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/swagger.json @@ -1114,5 +1114,16 @@ "additionalProperties": false } } - } + }, + "tags": [ + { + "name": "KitchenReader" + }, + { + "name": "KitchenSink" + }, + { + "name": "TodoItem" + } + ] } \ No newline at end of file diff --git a/tests/CommunityToolkit.Datasync.Server.Test/GlobalUsings.cs b/tests/CommunityToolkit.Datasync.Server.Test/GlobalUsings.cs index 8664b537..e531aafa 100644 --- a/tests/CommunityToolkit.Datasync.Server.Test/GlobalUsings.cs +++ b/tests/CommunityToolkit.Datasync.Server.Test/GlobalUsings.cs @@ -2,6 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -global using FluentAssertions; +global using AwesomeAssertions; global using System.Diagnostics.CodeAnalysis; global using Xunit; diff --git a/tests/CommunityToolkit.Datasync.TestCommon/Fixtures/PostgreSqlDatabaseFixture.cs b/tests/CommunityToolkit.Datasync.TestCommon/Fixtures/PostgreSqlDatabaseFixture.cs index d1a21ac3..7c1a5a95 100644 --- a/tests/CommunityToolkit.Datasync.TestCommon/Fixtures/PostgreSqlDatabaseFixture.cs +++ b/tests/CommunityToolkit.Datasync.TestCommon/Fixtures/PostgreSqlDatabaseFixture.cs @@ -24,7 +24,6 @@ public PostgreSqlDatabaseFixture() .WithUsername("testuser") .WithPassword("testpassword") .WithDatabase("testdb") - .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(5432)) .Build(); } diff --git a/tests/CommunityToolkit.Datasync.TestCommon/FluentExtensions/HttpExceptionAssertions.cs b/tests/CommunityToolkit.Datasync.TestCommon/FluentExtensions/HttpExceptionAssertions.cs index 7bcea058..1f8dfd5d 100644 --- a/tests/CommunityToolkit.Datasync.TestCommon/FluentExtensions/HttpExceptionAssertions.cs +++ b/tests/CommunityToolkit.Datasync.TestCommon/FluentExtensions/HttpExceptionAssertions.cs @@ -3,8 +3,8 @@ // See the LICENSE file in the project root for more information. using CommunityToolkit.Datasync.Server; -using FluentAssertions; -using FluentAssertions.Specialized; +using AwesomeAssertions; +using AwesomeAssertions.Specialized; namespace CommunityToolkit.Datasync.TestCommon; @@ -12,7 +12,7 @@ namespace CommunityToolkit.Datasync.TestCommon; public static class FluentHttpExceptionAssertions { /// - /// An extension to FluentAssertions to validate the payload of a . + /// An extension to AwesomeAssertions to validate the payload of a . /// public static AndConstraint> WithPayload(this ExceptionAssertions current, object payload, string because = "", params object[] becauseArgs) { @@ -21,7 +21,7 @@ public static AndConstraint> WithPayload(this } /// - /// An extension to FluentAssertions to validate the StatusCode of a + /// An extension to AwesomeAssertions to validate the StatusCode of a /// public static AndConstraint> WithStatusCode(this ExceptionAssertions current, int statusCode, string because = "", params object[] becauseArgs) { diff --git a/tests/CommunityToolkit.Datasync.TestCommon/FluentExtensions/ObjectAssertions.cs b/tests/CommunityToolkit.Datasync.TestCommon/FluentExtensions/ObjectAssertions.cs index 3033b7cf..d9acfbbf 100644 --- a/tests/CommunityToolkit.Datasync.TestCommon/FluentExtensions/ObjectAssertions.cs +++ b/tests/CommunityToolkit.Datasync.TestCommon/FluentExtensions/ObjectAssertions.cs @@ -4,10 +4,8 @@ using CommunityToolkit.Datasync.Server; using CommunityToolkit.Datasync.TestCommon.Models; -using FluentAssertions; -using FluentAssertions.Execution; -using FluentAssertions.Primitives; -using Microsoft.AspNetCore.Mvc.Filters; +using AwesomeAssertions; +using AwesomeAssertions.Primitives; using System.Net.Http.Headers; using System.Text.Json; diff --git a/tests/CommunityToolkit.Datasync.TestCommon/FluentExtensions/StringAssertions.cs b/tests/CommunityToolkit.Datasync.TestCommon/FluentExtensions/StringAssertions.cs index e891d433..df3e6ce4 100644 --- a/tests/CommunityToolkit.Datasync.TestCommon/FluentExtensions/StringAssertions.cs +++ b/tests/CommunityToolkit.Datasync.TestCommon/FluentExtensions/StringAssertions.cs @@ -2,8 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using FluentAssertions.Primitives; -using FluentAssertions; +using AwesomeAssertions.Primitives; +using AwesomeAssertions; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Primitives; @@ -13,7 +13,7 @@ namespace CommunityToolkit.Datasync.TestCommon; public static class FluentStringAssertions { /// - /// An extension to FluentAssertions to validate that a string is a GUID. + /// An extension to AwesomeAssertions to validate that a string is a GUID. /// public static AndConstraint BeAGuid(this StringAssertions current, string because = "", params object[] becauseArgs) { diff --git a/tests/CommunityToolkit.Datasync.TestCommon/RepositoryTests.cs b/tests/CommunityToolkit.Datasync.TestCommon/RepositoryTests.cs index 8aa09179..d96b0160 100644 --- a/tests/CommunityToolkit.Datasync.TestCommon/RepositoryTests.cs +++ b/tests/CommunityToolkit.Datasync.TestCommon/RepositoryTests.cs @@ -6,7 +6,7 @@ using CommunityToolkit.Datasync.Server; using CommunityToolkit.Datasync.TestCommon.Models; -using FluentAssertions; +using AwesomeAssertions; using Xunit; namespace CommunityToolkit.Datasync.TestCommon; @@ -19,9 +19,10 @@ namespace CommunityToolkit.Datasync.TestCommon; public abstract class RepositoryTests where TEntity : class, ITableData, IMovie, new() { /// - /// The time that the current test started. + /// The time that the current test started. Note that we subtrace 1ms to account + /// for rounding errors and clock skew. /// - protected DateTimeOffset StartTime { get; } = DateTimeOffset.UtcNow; + protected DateTimeOffset StartTime { get; } = DateTimeOffset.UtcNow.AddMilliseconds(-1); /// /// Returns true if all the requirements for live tests are met. diff --git a/tests/CommunityToolkit.Datasync.TestService/CommunityToolkit.Datasync.TestService.csproj b/tests/CommunityToolkit.Datasync.TestService/CommunityToolkit.Datasync.TestService.csproj index d3a5262c..58763ee4 100644 --- a/tests/CommunityToolkit.Datasync.TestService/CommunityToolkit.Datasync.TestService.csproj +++ b/tests/CommunityToolkit.Datasync.TestService/CommunityToolkit.Datasync.TestService.csproj @@ -17,7 +17,6 @@ - diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 3ccbcf97..5a8c1b4f 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -23,10 +23,10 @@ false false true - 13.0 + latest en $(NoWarn);7022 - net9.0 + net10.0 @@ -35,11 +35,9 @@ - - From db097bb3fc77feba89213abd916bd75557449a2e Mon Sep 17 00:00:00 2001 From: duckblaster Date: Fri, 12 Dec 2025 02:46:56 +1300 Subject: [PATCH 2/6] Fix LINQ conversion of `.Contains()` (#423) --- .../Query/Linq/ExpressionExtensions.cs | 84 +++++++++++++++++++ .../Query/IDatasyncPullQuery_Tests.cs | 2 +- .../Query/IDatasyncQueryable_Tests.cs | 2 +- .../Service/DatasyncServiceClient_Tests.cs | 2 +- .../Service/Integration_Query_Tests.cs | 2 +- 5 files changed, 88 insertions(+), 4 deletions(-) diff --git a/src/CommunityToolkit.Datasync.Client/Query/Linq/ExpressionExtensions.cs b/src/CommunityToolkit.Datasync.Client/Query/Linq/ExpressionExtensions.cs index 1b4acf34..c9a34c7a 100644 --- a/src/CommunityToolkit.Datasync.Client/Query/Linq/ExpressionExtensions.cs +++ b/src/CommunityToolkit.Datasync.Client/Query/Linq/ExpressionExtensions.cs @@ -6,8 +6,10 @@ // a generalized "nullable" option here to allow us to do that. #nullable disable +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; +using System.Reflection; namespace CommunityToolkit.Datasync.Client.Query.Linq; @@ -17,6 +19,30 @@ namespace CommunityToolkit.Datasync.Client.Query.Linq; /// internal static class ExpressionExtensions { + private static readonly MethodInfo Contains; + private static readonly MethodInfo SequenceEqual; + + static ExpressionExtensions() + { + Dictionary> queryableMethodGroups = typeof(Enumerable) + .GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) + .GroupBy(mi => mi.Name) + .ToDictionary(e => e.Key, l => l.ToList()); + + MethodInfo GetMethod(string name, int genericParameterCount, Func parameterGenerator) + => queryableMethodGroups[name].Single(mi => ((genericParameterCount == 0 && !mi.IsGenericMethod) + || (mi.IsGenericMethod && mi.GetGenericArguments().Length == genericParameterCount)) + && mi.GetParameters().Select(e => e.ParameterType).SequenceEqual( + parameterGenerator(mi.IsGenericMethod ? mi.GetGenericArguments() : []))); + + Contains = GetMethod( + nameof(Enumerable.Contains), 1, + types => [typeof(IEnumerable<>).MakeGenericType(types[0]), types[0]]); + SequenceEqual = GetMethod( + nameof(Enumerable.SequenceEqual), 1, + types => [typeof(IEnumerable<>).MakeGenericType(types[0]), typeof(IEnumerable<>).MakeGenericType(types[0])]); + } + /// /// Walk the expression and compute all the subtrees that are not dependent on any /// of the expressions parameters. @@ -127,6 +153,7 @@ internal static bool IsValidLambdaExpression(this MethodCallExpression expressio /// The partially evaluated expression internal static Expression PartiallyEvaluate(this Expression expression) { + expression = expression.RemoveSpanImplicitCast(); List subtrees = expression.FindIndependentSubtrees(); return VisitorHelper.VisitAll(expression, (Expression expr, Func recurse) => { @@ -143,6 +170,63 @@ internal static Expression PartiallyEvaluate(this Expression expression) }); } + internal static Expression RemoveSpanImplicitCast(this Expression expression) + { + return VisitorHelper.VisitAll(expression, (Expression expr, Func recurse) => + { + if (expr is MethodCallExpression methodCall) + { + MethodInfo method = methodCall.Method; + + if (method.DeclaringType == typeof(MemoryExtensions)) + { + switch (method.Name) + { + case nameof(MemoryExtensions.Contains) + when methodCall.Arguments is [Expression arg0, Expression arg1] && TryUnwrapSpanImplicitCast(arg0, out Expression unwrappedArg0): + { + Expression unwrappedExpr = Expression.Call( + Contains.MakeGenericMethod(methodCall.Method.GetGenericArguments()[0]), + unwrappedArg0, arg1); + return recurse(unwrappedExpr); + } + + case nameof(MemoryExtensions.SequenceEqual) + when methodCall.Arguments is [Expression arg0, Expression arg1] + && TryUnwrapSpanImplicitCast(arg0, out Expression unwrappedArg0) + && TryUnwrapSpanImplicitCast(arg1, out Expression unwrappedArg1): + { + Expression unwrappedExpr = Expression.Call( + SequenceEqual.MakeGenericMethod(methodCall.Method.GetGenericArguments()[0]), + unwrappedArg0, unwrappedArg1); + return recurse(unwrappedExpr); + } + } + + static bool TryUnwrapSpanImplicitCast(Expression expression, out Expression result) + { + if (expression is MethodCallExpression + { + Method: { Name: "op_Implicit", DeclaringType: { IsGenericType: true } implicitCastDeclaringType }, + Arguments: [Expression unwrapped] + } + && implicitCastDeclaringType.GetGenericTypeDefinition() is Type genericTypeDefinition + && (genericTypeDefinition == typeof(Span<>) || genericTypeDefinition == typeof(ReadOnlySpan<>))) + { + result = unwrapped; + return true; + } + + result = null; + return false; + } + } + } + + return recurse(expr); + }); + } + /// /// Remove the quote from quoted expressions. /// diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Query/IDatasyncPullQuery_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Query/IDatasyncPullQuery_Tests.cs index c0f2d470..ae346dfa 100644 --- a/tests/CommunityToolkit.Datasync.Client.Test/Query/IDatasyncPullQuery_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Client.Test/Query/IDatasyncPullQuery_Tests.cs @@ -1177,7 +1177,7 @@ public void Linq_Where_StartsWith_InvariantIgnoreCase() ); } - [Fact(Skip = "OData v8.4 does not allow string.contains")] + [Fact] public void Linq_Where_String_Contains() { string[] ratings = ["A", "B"]; diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Query/IDatasyncQueryable_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Query/IDatasyncQueryable_Tests.cs index f57752d7..88c95b2d 100644 --- a/tests/CommunityToolkit.Datasync.Client.Test/Query/IDatasyncQueryable_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Client.Test/Query/IDatasyncQueryable_Tests.cs @@ -1416,7 +1416,7 @@ public void Linq_Where_StartsWith_InvariantIgnoreCase() ); } - [Fact(Skip = "OData v8.4 does not allow string.contains")] + [Fact] public void Linq_Where_String_Contains() { string[] ratings = ["A", "B"]; diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Service/DatasyncServiceClient_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Service/DatasyncServiceClient_Tests.cs index edad68d3..354b2faf 100644 --- a/tests/CommunityToolkit.Datasync.Client.Test/Service/DatasyncServiceClient_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Client.Test/Service/DatasyncServiceClient_Tests.cs @@ -3547,7 +3547,7 @@ public void Linq_Where_StartsWith_InvariantIgnoreCase() ); } - [Fact(Skip = "OData v8.4 does not allow string.contains")] + [Fact] public void Linq_Where_String_Contains() { string[] ratings = ["A", "B"]; diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Service/Integration_Query_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Service/Integration_Query_Tests.cs index de43fd19..bb20aee7 100644 --- a/tests/CommunityToolkit.Datasync.Client.Test/Service/Integration_Query_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Client.Test/Service/Integration_Query_Tests.cs @@ -540,7 +540,7 @@ await KitchenSinkQueryTest( // ); //} - [Fact(Skip = "OData v8.4 does not allow string.contains")] + [Fact] public async Task KitchenSinkQueryTest_020() { SeedKitchenSinkWithCountryData(); From 58f5f5f9f8b5ed819c694e0988b2f7658574034d Mon Sep 17 00:00:00 2001 From: Adrian Hall Date: Mon, 16 Feb 2026 14:24:17 +0000 Subject: [PATCH 3/6] (#432) Removed all MySQL testing (#433) --- Directory.Packages.props | 2 - .../MySqlEntityTableRepository_Tests.cs | 97 ------------------- .../Live/MySQL_Controller_Tests.cs | 55 ----------- ...ommunityToolkit.Datasync.TestCommon.csproj | 2 - .../Databases/MySQL/MysqlDbContext.cs | 50 ---------- .../Databases/MySQL/MysqlEntityMovie.cs | 64 ------------ .../Fixtures/MySqlDatabaseFixture.cs | 49 ---------- .../Fixtures/MySqlTestFixture.cs | 0 .../RepositoryTests.cs | 20 ++++ ...mmunityToolkit.Datasync.TestService.csproj | 1 - 10 files changed, 20 insertions(+), 320 deletions(-) delete mode 100644 tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/MySqlEntityTableRepository_Tests.cs delete mode 100644 tests/CommunityToolkit.Datasync.Server.Test/Live/MySQL_Controller_Tests.cs delete mode 100644 tests/CommunityToolkit.Datasync.TestCommon/Databases/MySQL/MysqlDbContext.cs delete mode 100644 tests/CommunityToolkit.Datasync.TestCommon/Databases/MySQL/MysqlEntityMovie.cs delete mode 100644 tests/CommunityToolkit.Datasync.TestCommon/Fixtures/MySqlDatabaseFixture.cs delete mode 100644 tests/CommunityToolkit.Datasync.TestCommon/Fixtures/MySqlTestFixture.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 553b7840..34c67cd8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -38,7 +38,6 @@ - @@ -47,7 +46,6 @@ - diff --git a/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/MySqlEntityTableRepository_Tests.cs b/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/MySqlEntityTableRepository_Tests.cs deleted file mode 100644 index e9df8a0d..00000000 --- a/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/MySqlEntityTableRepository_Tests.cs +++ /dev/null @@ -1,97 +0,0 @@ -// 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.TestCommon; -using CommunityToolkit.Datasync.TestCommon.Databases; -using CommunityToolkit.Datasync.TestCommon.Fixtures; -using Microsoft.EntityFrameworkCore; -using Xunit.Abstractions; - -#pragma warning disable CS9113 // Parameter is unread. - -namespace CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test; - -[ExcludeFromCodeCoverage] -[Collection("LiveTestsCollection")] -public class MysqlEntityTableRepository_Tests(MySqlDatabaseFixture fixture, ITestOutputHelper output) : RepositoryTests, IClassFixture, IAsyncLifetime -{ - #region Setup - private readonly Random random = new(); - private List movies = []; - - public async Task InitializeAsync() - { - Context = await MysqlDbContext.CreateContextAsync(fixture.ConnectionString, output); - this.movies = await Context.Movies.AsNoTracking().ToListAsync(); - } - - public async Task DisposeAsync() - { - if (Context is not null) - { - await Context.DisposeAsync(); - } - } - - private MysqlDbContext Context { get; set; } - - protected override bool CanRunLiveTests() => true; - - protected override async Task GetEntityAsync(string id) - => await Context.Movies.AsNoTracking().SingleOrDefaultAsync(m => m.Id == id); - - protected override async Task GetEntityCountAsync() - => await Context.Movies.CountAsync(); - - protected override Task> GetPopulatedRepositoryAsync() - => Task.FromResult>(new EntityTableRepository(Context)); - - protected override Task GetRandomEntityIdAsync(bool exists) - => Task.FromResult(exists ? this.movies[this.random.Next(this.movies.Count)].Id : Guid.NewGuid().ToString()); - #endregion - - [SkippableFact] - public void EntityTableRepository_BadDbSet_Throws() - { - Skip.IfNot(CanRunLiveTests()); - Action act = () => _ = new EntityTableRepository(Context); - act.Should().Throw(); - } - - [SkippableFact] - public void EntityTableRepository_GoodDbSet_Works() - { - Skip.IfNot(CanRunLiveTests()); - Action act = () => _ = new EntityTableRepository(Context); - act.Should().NotThrow(); - } - - [SkippableFact] - public async Task WrapExceptionAsync_ThrowsConflictException_WhenDbConcurrencyUpdateExceptionThrown() - { - Skip.IfNot(CanRunLiveTests()); - EntityTableRepository repository = await GetPopulatedRepositoryAsync() as EntityTableRepository; - string id = await GetRandomEntityIdAsync(true); - MysqlEntityMovie expectedPayload = await GetEntityAsync(id); - - static Task innerAction() => throw new DbUpdateConcurrencyException("Concurrency exception"); - - Func act = async () => await repository.WrapExceptionAsync(id, innerAction); - (await act.Should().ThrowAsync()).WithStatusCode(409).And.WithPayload(expectedPayload); - } - - [SkippableFact] - public async Task WrapExceptionAsync_ThrowsRepositoryException_WhenDbUpdateExceptionThrown() - { - Skip.IfNot(CanRunLiveTests()); - EntityTableRepository repository = await GetPopulatedRepositoryAsync() as EntityTableRepository; - string id = await GetRandomEntityIdAsync(true); - MysqlEntityMovie expectedPayload = await GetEntityAsync(id); - - static Task innerAction() => throw new DbUpdateException("Non-concurrency exception"); - - Func act = async () => await repository.WrapExceptionAsync(id, innerAction); - await act.Should().ThrowAsync(); - } -} diff --git a/tests/CommunityToolkit.Datasync.Server.Test/Live/MySQL_Controller_Tests.cs b/tests/CommunityToolkit.Datasync.Server.Test/Live/MySQL_Controller_Tests.cs deleted file mode 100644 index 4d6dcc0e..00000000 --- a/tests/CommunityToolkit.Datasync.Server.Test/Live/MySQL_Controller_Tests.cs +++ /dev/null @@ -1,55 +0,0 @@ -// 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.EntityFrameworkCore; -using CommunityToolkit.Datasync.Server.Test.Helpers; -using CommunityToolkit.Datasync.TestCommon.Databases; -using CommunityToolkit.Datasync.TestCommon.Fixtures; -using Microsoft.EntityFrameworkCore; -using Microsoft.VisualStudio.TestPlatform.Utilities; -using Xunit.Abstractions; - -namespace CommunityToolkit.Datasync.Server.Test.Live; - -[ExcludeFromCodeCoverage] -[Collection("LiveTestsCollection")] -public class MySQL_Controller_Tests(MySqlDatabaseFixture fixture, ITestOutputHelper output) : LiveControllerTests, IClassFixture, IAsyncLifetime -{ - #region Setup - private readonly Random random = new(); - private List movies = []; - - public async Task InitializeAsync() - { - Context = await MysqlDbContext.CreateContextAsync(fixture.ConnectionString, output); - this.movies = await Context.Movies.AsNoTracking().ToListAsync(); - } - - public async Task DisposeAsync() - { - if (Context is not null) - { - await Context.DisposeAsync(); - } - } - - private MysqlDbContext Context { get; set; } - - protected override string DriverName { get; } = "MySQL"; - - protected override bool CanRunLiveTests() => true; - - protected override async Task GetEntityAsync(string id) - => await Context.Movies.AsNoTracking().SingleOrDefaultAsync(m => m.Id == id); - - protected override async Task GetEntityCountAsync() - => await Context.Movies.CountAsync(); - - protected override Task> GetPopulatedRepositoryAsync() - => Task.FromResult>(new EntityTableRepository(Context)); - - protected override Task GetRandomEntityIdAsync(bool exists) - => Task.FromResult(exists ? this.movies[this.random.Next(this.movies.Count)].Id : Guid.NewGuid().ToString()); - #endregion -} diff --git a/tests/CommunityToolkit.Datasync.TestCommon/CommunityToolkit.Datasync.TestCommon.csproj b/tests/CommunityToolkit.Datasync.TestCommon/CommunityToolkit.Datasync.TestCommon.csproj index cf2bdbcf..6e743b60 100644 --- a/tests/CommunityToolkit.Datasync.TestCommon/CommunityToolkit.Datasync.TestCommon.csproj +++ b/tests/CommunityToolkit.Datasync.TestCommon/CommunityToolkit.Datasync.TestCommon.csproj @@ -19,10 +19,8 @@ - - diff --git a/tests/CommunityToolkit.Datasync.TestCommon/Databases/MySQL/MysqlDbContext.cs b/tests/CommunityToolkit.Datasync.TestCommon/Databases/MySQL/MysqlDbContext.cs deleted file mode 100644 index df951d29..00000000 --- a/tests/CommunityToolkit.Datasync.TestCommon/Databases/MySQL/MysqlDbContext.cs +++ /dev/null @@ -1,50 +0,0 @@ -// 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 Microsoft.EntityFrameworkCore; -using Xunit.Abstractions; - -namespace CommunityToolkit.Datasync.TestCommon.Databases; - -[ExcludeFromCodeCoverage] -public class MysqlDbContext(DbContextOptions options) : BaseDbContext(options) -{ - public static async Task CreateContextAsync(string connectionString, ITestOutputHelper output = null, bool clearEntities = true) - { - if (string.IsNullOrEmpty(connectionString)) - { - throw new ArgumentNullException(nameof(connectionString)); - } - - DbContextOptionsBuilder optionsBuilder = new DbContextOptionsBuilder() - .UseMySql(connectionString: connectionString, serverVersion: ServerVersion.AutoDetect(connectionString), options => options.EnableRetryOnFailure()) - .EnableLogging(output); - MysqlDbContext context = new(optionsBuilder.Options); - - await context.InitializeDatabaseAsync(clearEntities); - await context.PopulateDatabaseAsync(); - return context; - } - - internal async Task InitializeDatabaseAsync(bool clearEntities) - { - await Database.EnsureCreatedAsync(); - - if (clearEntities) - { - await ExecuteRawSqlOnEachEntityAsync("DELETE FROM {0}"); - } - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity().Property(m => m.UpdatedAt) - .ValueGeneratedOnAddOrUpdate(); - - modelBuilder.Entity().Property(m => m.Version) - .IsRowVersion(); - - base.OnModelCreating(modelBuilder); - } -} diff --git a/tests/CommunityToolkit.Datasync.TestCommon/Databases/MySQL/MysqlEntityMovie.cs b/tests/CommunityToolkit.Datasync.TestCommon/Databases/MySQL/MysqlEntityMovie.cs deleted file mode 100644 index 644ef26f..00000000 --- a/tests/CommunityToolkit.Datasync.TestCommon/Databases/MySQL/MysqlEntityMovie.cs +++ /dev/null @@ -1,64 +0,0 @@ -// 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.EntityFrameworkCore; -using CommunityToolkit.Datasync.TestCommon.Models; -using System.ComponentModel.DataAnnotations; - -namespace CommunityToolkit.Datasync.TestCommon.Databases; - -[ExcludeFromCodeCoverage] -public class MysqlEntityMovie : EntityTableData, IMovie, IEquatable -{ - /// - /// True if the movie won the oscar for Best Picture - /// - public bool BestPictureWinner { get; set; } - - /// - /// The running time of the movie - /// - [Required] - [Range(60, 360)] - public int Duration { get; set; } - - /// - /// The MPAA rating for the movie, if available. - /// - public MovieRating Rating { get; set; } = MovieRating.Unrated; - - /// - /// The release date of the movie. - /// - [Required] - public DateOnly ReleaseDate { get; set; } - - /// - /// The title of the movie. - /// - [Required] - [StringLength(128, MinimumLength = 2)] - public string Title { get; set; } = string.Empty; - - /// - /// The year that the movie was released. - /// - [Required] - [Range(1920, 2030)] - public int Year { get; set; } - - /// - /// Determines if this movie has the same content as another movie. - /// - /// The other movie - /// true if the content is the same - public bool Equals(IMovie other) - => other != null - && other.BestPictureWinner == BestPictureWinner - && other.Duration == Duration - && other.Rating == Rating - && other.ReleaseDate == ReleaseDate - && other.Title == Title - && other.Year == Year; -} diff --git a/tests/CommunityToolkit.Datasync.TestCommon/Fixtures/MySqlDatabaseFixture.cs b/tests/CommunityToolkit.Datasync.TestCommon/Fixtures/MySqlDatabaseFixture.cs deleted file mode 100644 index faaaf8c3..00000000 --- a/tests/CommunityToolkit.Datasync.TestCommon/Fixtures/MySqlDatabaseFixture.cs +++ /dev/null @@ -1,49 +0,0 @@ -// 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 Testcontainers.MySql; -using Xunit; - -namespace CommunityToolkit.Datasync.TestCommon.Fixtures; - -/// -/// A test fixture for impementing a MySQL database using Testcontainers. -/// -[ExcludeFromCodeCoverage] -public class MySqlDatabaseFixture : IAsyncLifetime -{ - private readonly MySqlContainer _container; - - public MySqlDatabaseFixture() - { - this._container = new MySqlBuilder() - .WithImage("mysql:lts-oracle") - .WithCleanUp(true) - .WithUsername("testuser") - .WithPassword("testpassword") - .WithDatabase("testdb") - .Build(); - } - - /// - public async Task DisposeAsync() - { - if (this._container is not null) - { - await this._container.DisposeAsync(); - } - } - - /// - public async Task InitializeAsync() - { - await this._container.StartAsync(); - ConnectionString = this._container.GetConnectionString(); - } - - /// - /// The connection string for the MySQL database. - /// - public string ConnectionString { get; private set; } = string.Empty; -} diff --git a/tests/CommunityToolkit.Datasync.TestCommon/Fixtures/MySqlTestFixture.cs b/tests/CommunityToolkit.Datasync.TestCommon/Fixtures/MySqlTestFixture.cs deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/CommunityToolkit.Datasync.TestCommon/RepositoryTests.cs b/tests/CommunityToolkit.Datasync.TestCommon/RepositoryTests.cs index d96b0160..fbe3f4f2 100644 --- a/tests/CommunityToolkit.Datasync.TestCommon/RepositoryTests.cs +++ b/tests/CommunityToolkit.Datasync.TestCommon/RepositoryTests.cs @@ -148,6 +148,10 @@ public async Task CreateAsync_CreatesNewEntity_WithSpecifiedId() TEntity addition = TestData.Movies.OfType(TestData.Movies.BlackPanther, id); TEntity sut = addition.Clone(); await Repository.CreateAsync(sut); + + // Let's sleep for a tiny bit of time to allow any writes to actually happen. + await Task.Delay(100); + TEntity actual = await GetEntityAsync(id); actual.Should().BeEquivalentTo(addition); @@ -169,6 +173,10 @@ public async Task CreateAsync_CreatesNewEntity_WithNullId(string id) addition.Id = id; TEntity sut = addition.Clone(); await Repository.CreateAsync(sut); + + // Let's sleep for a tiny bit of time to allow any writes to actually happen. + await Task.Delay(100); + TEntity actual = await GetEntityAsync(sut.Id); actual.Should().BeEquivalentTo(addition); @@ -205,6 +213,10 @@ public async Task CreateAsync_UpdatesMetadata() byte[] expectedVersion = new byte[] { 0x01, 0x02, 0x03, 0x04 }; sut.Version = expectedVersion.ToArray(); await Repository.CreateAsync(sut); + + // Let's sleep for a tiny bit of time to allow any writes to actually happen. + await Task.Delay(100); + TEntity actual = await GetEntityAsync(id); actual.Should().BeEquivalentTo(addition); @@ -399,6 +411,10 @@ public async Task ReplaceAsync_Replaces_OnVersionMatch() TEntity expected = await GetEntityAsync(id); byte[] version = expected.Version.ToArray(); await Repository.ReplaceAsync(replacement, version); + + // Let's sleep for a tiny bit of time to allow any writes to actually happen. + await Task.Delay(100); + TEntity actual = await GetEntityAsync(id); actual.Should().BeEquivalentTo(replacement).And.NotBeEquivalentTo(expected); @@ -417,6 +433,10 @@ public async Task ReplaceAsync_Replaces_OnNoVersion() TEntity expected = await GetEntityAsync(id); byte[] version = expected.Version.ToArray(); await Repository.ReplaceAsync(replacement); + + // Let's sleep for a tiny bit of time to allow any writes to actually happen. + await Task.Delay(100); + TEntity actual = await GetEntityAsync(id); actual.Should().BeEquivalentTo(replacement).And.NotBeEquivalentTo(expected); diff --git a/tests/CommunityToolkit.Datasync.TestService/CommunityToolkit.Datasync.TestService.csproj b/tests/CommunityToolkit.Datasync.TestService/CommunityToolkit.Datasync.TestService.csproj index 58763ee4..063cb23e 100644 --- a/tests/CommunityToolkit.Datasync.TestService/CommunityToolkit.Datasync.TestService.csproj +++ b/tests/CommunityToolkit.Datasync.TestService/CommunityToolkit.Datasync.TestService.csproj @@ -18,6 +18,5 @@ - From 06fa8a8219a06a69db8408493be318f6fc1157bc Mon Sep 17 00:00:00 2001 From: Adrian Hall Date: Mon, 16 Feb 2026 15:27:19 +0000 Subject: [PATCH 4/6] (#434) Updated all libraries for .NET 10 compatibility (#435) * (#434) Update Automapper to v16.0.0 (.NET 10 compatibility) * (#434) Updates all other packages to .NET 10 compatible packages --- Directory.Packages.props | 9 +++-- ...kit.Datasync.Server.Automapper.Test.csproj | 1 + .../MappedTableRepository_Tests.cs | 40 +++++++++++++++++-- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 34c67cd8..c33147f9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,14 +10,14 @@ 8.4.3 - + - + @@ -31,10 +31,11 @@ - + + - + diff --git a/tests/CommunityToolkit.Datasync.Server.Automapper.Test/CommunityToolkit.Datasync.Server.Automapper.Test.csproj b/tests/CommunityToolkit.Datasync.Server.Automapper.Test/CommunityToolkit.Datasync.Server.Automapper.Test.csproj index f9d81cd2..823acacb 100644 --- a/tests/CommunityToolkit.Datasync.Server.Automapper.Test/CommunityToolkit.Datasync.Server.Automapper.Test.csproj +++ b/tests/CommunityToolkit.Datasync.Server.Automapper.Test/CommunityToolkit.Datasync.Server.Automapper.Test.csproj @@ -17,5 +17,6 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + diff --git a/tests/CommunityToolkit.Datasync.Server.Automapper.Test/MappedTableRepository_Tests.cs b/tests/CommunityToolkit.Datasync.Server.Automapper.Test/MappedTableRepository_Tests.cs index 1199fe2d..2aa456f5 100644 --- a/tests/CommunityToolkit.Datasync.Server.Automapper.Test/MappedTableRepository_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Server.Automapper.Test/MappedTableRepository_Tests.cs @@ -8,20 +8,33 @@ using CommunityToolkit.Datasync.TestCommon; using CommunityToolkit.Datasync.TestCommon.Databases; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Xunit; using Xunit.Abstractions; namespace CommunityToolkit.Datasync.Server.Automapper.Test; [ExcludeFromCodeCoverage] -public class MappedTableRepository_Tests(ITestOutputHelper output) : RepositoryTests() +public class MappedTableRepository_Tests : RepositoryTests, IDisposable { #region Setup + private readonly ITestOutputHelper output; private readonly Random random = new(); private SqliteDbContext context; private EntityTableRepository innerRepository; - private readonly IMapper mapper = new MapperConfiguration(c => c.AddProfile(new MapperProfile())).CreateMapper(); + private readonly ILoggerFactory loggerFactory; + private readonly IMapper mapper; private MappedTableRepository repository; private List movies; + private bool _disposedValue; + + public MappedTableRepository_Tests(ITestOutputHelper output) + { + this.output = output; + this.loggerFactory = new LoggerFactory([ new XunitLoggerProvider(this.output) ]); + MapperConfiguration config = new(c => c.AddProfile(new MapperProfile()), this.loggerFactory); + this.mapper = config.CreateMapper(); + } protected override Task GetEntityAsync(string id) => Task.FromResult(this.mapper.Map(this.context.Movies.AsNoTracking().SingleOrDefault(m => m.Id == id))); @@ -31,7 +44,7 @@ protected override Task GetEntityCountAsync() protected override Task> GetPopulatedRepositoryAsync() { - this.context = SqliteDbContext.CreateContext(output); + this.context = SqliteDbContext.CreateContext(this.output); this.innerRepository = new EntityTableRepository(this.context); this.repository = new MappedTableRepository(this.mapper, this.innerRepository); this.movies = this.context.Movies.AsNoTracking().ToList().ConvertAll(m => this.mapper.Map(m)); @@ -40,5 +53,26 @@ protected override Task> GetPopulatedRepositoryAsync() protected override Task GetRandomEntityIdAsync(bool exists) => Task.FromResult(exists ? this.movies[this.random.Next(this.movies.Count)].Id : Guid.NewGuid().ToString()); + + protected virtual void Dispose(bool disposing) + { + if (!this._disposedValue) + { + if (disposing) + { + this.context.Dispose(); + this.loggerFactory.Dispose(); + } + + this._disposedValue = true; + } + } + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } #endregion } From be0a5aca5c9cc92c07268ce905113ccb98690610 Mon Sep 17 00:00:00 2001 From: Adrian Hall Date: Mon, 16 Feb 2026 16:57:34 +0000 Subject: [PATCH 5/6] (#419) EF Core 10 update. (#438) * (#419) EF Core 10 update. * (#419) Updated fixtures to add an image, fixing deprecated constructor. * (#419) Updated Mongo fixture to support image naming. --- Directory.Packages.props | 8 ++++---- .../Fixtures/MongoDatabaseFixture.cs | 4 ++-- .../Fixtures/MsSqlDatabaseFixture.cs | 4 ++-- .../Fixtures/PostgreSqlDatabaseFixture.cs | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index c33147f9..7fb0bcdc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,9 +4,9 @@ true - 10.0.0 - 9.0.11 - 4.9.0 + 10.0.3 + 10.0.3 + 4.10.0 8.4.3 @@ -38,7 +38,7 @@ - + diff --git a/tests/CommunityToolkit.Datasync.TestCommon/Fixtures/MongoDatabaseFixture.cs b/tests/CommunityToolkit.Datasync.TestCommon/Fixtures/MongoDatabaseFixture.cs index a20e9365..aeafb24a 100644 --- a/tests/CommunityToolkit.Datasync.TestCommon/Fixtures/MongoDatabaseFixture.cs +++ b/tests/CommunityToolkit.Datasync.TestCommon/Fixtures/MongoDatabaseFixture.cs @@ -11,12 +11,12 @@ namespace CommunityToolkit.Datasync.TestCommon.Fixtures; [ExcludeFromCodeCoverage] public class MongoDatabaseFixture : IAsyncLifetime { + private const string imageName = "mongo:latest"; private readonly MongoDbContainer _container; public MongoDatabaseFixture() { - this._container = new MongoDbBuilder() - .WithImage("mongo:latest") + this._container = new MongoDbBuilder(imageName) .Build(); } diff --git a/tests/CommunityToolkit.Datasync.TestCommon/Fixtures/MsSqlDatabaseFixture.cs b/tests/CommunityToolkit.Datasync.TestCommon/Fixtures/MsSqlDatabaseFixture.cs index 2988a9e0..630ae784 100644 --- a/tests/CommunityToolkit.Datasync.TestCommon/Fixtures/MsSqlDatabaseFixture.cs +++ b/tests/CommunityToolkit.Datasync.TestCommon/Fixtures/MsSqlDatabaseFixture.cs @@ -10,12 +10,12 @@ namespace CommunityToolkit.Datasync.TestCommon.Fixtures; [ExcludeFromCodeCoverage] public class MsSqlDatabaseFixture : IAsyncLifetime { + private const string imageName = "mcr.microsoft.com/mssql/server:2025-CU2-ubuntu-22.04"; private readonly MsSqlContainer _container; public MsSqlDatabaseFixture() { - this._container = new MsSqlBuilder() - .Build(); + this._container = new MsSqlBuilder(imageName).Build(); } /// diff --git a/tests/CommunityToolkit.Datasync.TestCommon/Fixtures/PostgreSqlDatabaseFixture.cs b/tests/CommunityToolkit.Datasync.TestCommon/Fixtures/PostgreSqlDatabaseFixture.cs index 7c1a5a95..75d35086 100644 --- a/tests/CommunityToolkit.Datasync.TestCommon/Fixtures/PostgreSqlDatabaseFixture.cs +++ b/tests/CommunityToolkit.Datasync.TestCommon/Fixtures/PostgreSqlDatabaseFixture.cs @@ -14,12 +14,12 @@ namespace CommunityToolkit.Datasync.TestCommon.Fixtures; [ExcludeFromCodeCoverage] public class PostgreSqlDatabaseFixture : IAsyncLifetime { + private const string imageName = "postgres:18-alpine"; private readonly PostgreSqlContainer _container; public PostgreSqlDatabaseFixture() { - this._container = new PostgreSqlBuilder() - .WithImage("postgres:17-alpine") + this._container = new PostgreSqlBuilder(imageName) .WithCleanUp(true) .WithUsername("testuser") .WithPassword("testpassword") From d6fb27afbef2728057d28181ddb4e6994900f6f1 Mon Sep 17 00:00:00 2001 From: Adrian Hall Date: Mon, 16 Feb 2026 17:41:11 +0000 Subject: [PATCH 6/6] (#437) Template upgraded to .NET 10 (#439) --- templates/DatasyncTemplates.csproj | 2 +- .../Template.DatasyncServer.csproj.template | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/templates/DatasyncTemplates.csproj b/templates/DatasyncTemplates.csproj index b5a1be73..da473919 100644 --- a/templates/DatasyncTemplates.csproj +++ b/templates/DatasyncTemplates.csproj @@ -9,7 +9,7 @@ CommunityToolkit.Datasync.Server.Template.CSharp dotnet-new;template;aspnetcore;datasync Template - net8.0 + net10.0 Template for creating an ASP.NET Core Service using the Community Datasync Toolkit diff --git a/templates/Template.DatasyncServer/Template.DatasyncServer.csproj.template b/templates/Template.DatasyncServer/Template.DatasyncServer.csproj.template index 91010622..439e7d47 100644 --- a/templates/Template.DatasyncServer/Template.DatasyncServer.csproj.template +++ b/templates/Template.DatasyncServer/Template.DatasyncServer.csproj.template @@ -1,6 +1,6 @@ - net9.0 + net10.0 enable enable latest @@ -10,8 +10,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive