diff --git a/docs/rest/SoftDeleteSearch.http b/docs/rest/SoftDeleteSearch.http new file mode 100644 index 0000000000..5745de9885 --- /dev/null +++ b/docs/rest/SoftDeleteSearch.http @@ -0,0 +1,130 @@ +# Soft-deleted resources can be searched at the system or resource-type level. +# The only supported filters are the resource type in the URL and the +# last-updated bounds (_since and _before). + +@hostname = localhost:44348 + +### Get the bearer token, if authentication is enabled +# @name bearer +POST https://{{hostname}}/connect/token +Content-Type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=globalAdminServicePrincipal +&client_secret=globalAdminServicePrincipal +&scope=fhir-api + +### Create a patient that will be soft deleted +PUT https://{{hostname}}/Patient/soft-delete-search-patient +Content-Type: application/fhir+json +Authorization: Bearer {{bearer.response.body.access_token}} + +{ + "resourceType": "Patient", + "id": "soft-delete-search-patient", + "active": true, + "name": [ + { + "use": "official", + "family": "Deleted", + "given": [ + "Pat" + ] + } + ] +} + +### Create an observation that will be soft deleted +PUT https://{{hostname}}/Observation/soft-delete-search-observation +Content-Type: application/fhir+json +Authorization: Bearer {{bearer.response.body.access_token}} + +{ + "resourceType": "Observation", + "id": "soft-delete-search-observation", + "status": "final", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "29463-7", + "display": "Body weight" + } + ] + }, + "subject": { + "reference": "Patient/soft-delete-search-patient" + }, + "valueQuantity": { + "value": 72.5, + "unit": "kg", + "system": "http://unitsofmeasure.org", + "code": "kg" + } +} + +### Create an active patient to show that normal resources are excluded +PUT https://{{hostname}}/Patient/soft-delete-search-active-patient +Content-Type: application/fhir+json +Authorization: Bearer {{bearer.response.body.access_token}} + +{ + "resourceType": "Patient", + "id": "soft-delete-search-active-patient", + "active": true, + "name": [ + { + "family": "Active", + "given": [ + "Alex" + ] + } + ] +} + +### Soft delete the sample patient +DELETE https://{{hostname}}/Patient/soft-delete-search-patient +Authorization: Bearer {{bearer.response.body.access_token}} + +### Soft delete the sample observation +DELETE https://{{hostname}}/Observation/soft-delete-search-observation +Authorization: Bearer {{bearer.response.body.access_token}} + +### Search all soft-deleted resource types +# Returns the deleted Patient and Observation, but not the active Patient. +GET https://{{hostname}}/$delete-search +Authorization: Bearer {{bearer.response.body.access_token}} + +### Search soft-deleted patients +# The resource type is specified in the URL. This returns only the deleted Patient. +GET https://{{hostname}}/Patient/$delete-search +Authorization: Bearer {{bearer.response.body.access_token}} + +### Search soft-deleted observations +GET https://{{hostname}}/Observation/$delete-search +Authorization: Bearer {{bearer.response.body.access_token}} + +### Search resources deleted since a last-updated time +# _since is inclusive. Use a timestamp before the DELETE requests above. +GET https://{{hostname}}/$delete-search?_since=2000-01-01T00:00:00Z +Authorization: Bearer {{bearer.response.body.access_token}} + +### Search a last-updated time range +# _before is exclusive and cannot be in the future. Replace the example values +# with timestamps that bracket the DELETE requests before running this request. +GET https://{{hostname}}/Patient/$delete-search?_since=2026-07-30T20:00:00Z&_before=2026-07-30T22:00:00Z +Authorization: Bearer {{bearer.response.body.access_token}} + +### Sort and page through soft-deleted resources +# Only _lastUpdated is supported for sorting. The default order is descending. +# @name deletedPage +GET https://{{hostname}}/$delete-search?_count=1&_sort=-_lastUpdated +Authorization: Bearer {{bearer.response.body.access_token}} + +### Record the next-page URL +@deletedNextPage = {{deletedPage.response.body.link[0].url}} + +### Get the next page +# @name deletedPage +GET {{deletedNextPage}} +Authorization: Bearer {{bearer.response.body.access_token}} diff --git a/src/Microsoft.Health.Fhir.Api/Features/Routing/UrlResolver.cs b/src/Microsoft.Health.Fhir.Api/Features/Routing/UrlResolver.cs index 99ce45fad0..ef4bca722a 100644 --- a/src/Microsoft.Health.Fhir.Api/Features/Routing/UrlResolver.cs +++ b/src/Microsoft.Health.Fhir.Api/Features/Routing/UrlResolver.cs @@ -339,6 +339,9 @@ public Uri ResolveOperationDefinitionUrl(string operationName) case OperationsConstants.BulkDelete: routeName = RouteNames.BulkDeleteDefinition; break; + case OperationsConstants.DeleteSearch: + routeName = RouteNames.DeleteSearchOperationDefinition; + break; case OperationsConstants.BulkUpdate: routeName = RouteNames.BulkUpdateDefinition; break; diff --git a/src/Microsoft.Health.Fhir.Api/Models/DeletedResourceSearchModel.cs b/src/Microsoft.Health.Fhir.Api/Models/DeletedResourceSearchModel.cs new file mode 100644 index 0000000000..c001b29a69 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Api/Models/DeletedResourceSearchModel.cs @@ -0,0 +1,46 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using Microsoft.AspNetCore.Mvc; +using Microsoft.Health.Fhir.Core.Features; +using Microsoft.Health.Fhir.Core.Models; + +namespace Microsoft.Health.Fhir.Api.Models; + +/// +/// Query parameters for searching soft-deleted resources. +/// +public class DeletedResourceSearchModel +{ + /// + /// Gets or sets the inclusive lower last-updated bound. + /// + [FromQuery(Name = KnownQueryParameterNames.Since)] + public PartialDateTime Since { get; set; } + + /// + /// Gets or sets the exclusive upper last-updated bound. + /// + [FromQuery(Name = KnownQueryParameterNames.Before)] + public PartialDateTime Before { get; set; } + + /// + /// Gets or sets the page size. + /// + [FromQuery(Name = KnownQueryParameterNames.Count)] + public int? Count { get; set; } + + /// + /// Gets or sets the continuation token. + /// + [FromQuery(Name = KnownQueryParameterNames.ContinuationToken)] + public string ContinuationToken { get; set; } + + /// + /// Gets or sets the last-updated sort order. + /// + [FromQuery(Name = KnownQueryParameterNames.Sort)] + public string Sort { get; set; } +} diff --git a/src/Microsoft.Health.Fhir.Core/Data/OperationDefinition/delete-search.json b/src/Microsoft.Health.Fhir.Core/Data/OperationDefinition/delete-search.json new file mode 100644 index 0000000000..2879b2ddf5 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Core/Data/OperationDefinition/delete-search.json @@ -0,0 +1,64 @@ +{ + "resourceType": "OperationDefinition", + "id": "delete-search", + "url": "[base]/OperationDefinition/delete-search", + "version": "1.0.0", + "name": "Delete Search", + "status": "active", + "kind": "operation", + "description": "Searches current soft-deleted resources. The operation supports system-level and resource-type-level invocation and filters only by the resource last-updated time.", + "code": "delete-search", + "system": true, + "type": true, + "instance": false, + "parameter": [ + { + "name": "_since", + "use": "in", + "min": 0, + "max": "1", + "documentation": "An inclusive lower bound on the soft-deleted resource's last-updated time.", + "type": "instant" + }, + { + "name": "_before", + "use": "in", + "min": 0, + "max": "1", + "documentation": "An exclusive upper bound on the soft-deleted resource's last-updated time.", + "type": "instant" + }, + { + "name": "_count", + "use": "in", + "min": 0, + "max": "1", + "documentation": "The maximum number of soft-deleted resources to return in one page.", + "type": "integer" + }, + { + "name": "_continuationToken", + "use": "in", + "min": 0, + "max": "1", + "documentation": "The continuation token used to retrieve the next page.", + "type": "string" + }, + { + "name": "_sort", + "use": "in", + "min": 0, + "max": "1", + "documentation": "The result order. Only _lastUpdated and -_lastUpdated are supported.", + "type": "string" + }, + { + "name": "return", + "use": "out", + "min": 1, + "max": "1", + "documentation": "A history bundle containing current soft-deleted resources.", + "type": "Bundle" + } + ] +} diff --git a/src/Microsoft.Health.Fhir.Core/Features/Operations/OperationsConstants.cs b/src/Microsoft.Health.Fhir.Core/Features/Operations/OperationsConstants.cs index 7f64b99eee..c3657d40ab 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Operations/OperationsConstants.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Operations/OperationsConstants.cs @@ -49,6 +49,8 @@ public static class OperationsConstants public const string BulkDeleteSoftDeleted = "bulk-delete-soft-deleted"; + public const string DeleteSearch = "delete-search"; + public const string Includes = "includes"; public const string BulkUpdate = "bulk-update"; diff --git a/src/Microsoft.Health.Fhir.Core/Features/Routing/KnownRoutes.cs b/src/Microsoft.Health.Fhir.Core/Features/Routing/KnownRoutes.cs index fc51a2e60c..adea2c3d7f 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Routing/KnownRoutes.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Routing/KnownRoutes.cs @@ -96,6 +96,10 @@ internal class KnownRoutes public const string ResourceTypeBulkDeleteOperationDefinition = OperationDefinition + "/" + OperationsConstants.ResourceTypeBulkDelete; public const string BulkDeleteSoftDeletedOperationDefinition = OperationDefinition + "/" + OperationsConstants.BulkDeleteSoftDeleted; + public const string DeleteSearch = "$delete-search"; + public const string DeleteSearchResourceType = ResourceType + "/" + DeleteSearch; + public const string DeleteSearchOperationDefinition = OperationDefinition + "/" + OperationsConstants.DeleteSearch; + public const string BulkUpdate = "$bulk-update"; public const string BulkUpdateResourceType = ResourceType + "/" + BulkUpdate; public const string BulkUpdateJobLocation = OperationsConstants.Operations + "/" + OperationsConstants.BulkUpdate + "/" + IdRouteSegment; diff --git a/src/Microsoft.Health.Fhir.Core/Features/Routing/RouteNames.cs b/src/Microsoft.Health.Fhir.Core/Features/Routing/RouteNames.cs index 4f64cba0c3..9bf5fa9a8c 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Routing/RouteNames.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Routing/RouteNames.cs @@ -25,6 +25,10 @@ internal static class RouteNames internal const string HistoryTypeId = nameof(HistoryTypeId); + internal const string DeleteSearch = nameof(DeleteSearch); + + internal const string DeleteSearchType = nameof(DeleteSearchType); + internal const string SearchCompartmentByResourceType = nameof(SearchCompartmentByResourceType); internal const string AadSmartOnFhirProxyAuthorize = nameof(AadSmartOnFhirProxyAuthorize); @@ -81,6 +85,8 @@ internal static class RouteNames internal const string BulkDeleteSoftDeletedDefinition = nameof(BulkDeleteSoftDeletedDefinition); + internal const string DeleteSearchOperationDefinition = nameof(DeleteSearchOperationDefinition); + internal const string Includes = nameof(Includes); internal const string IncludesOperationDefinition = nameof(IncludesOperationDefinition); diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/ISearchService.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/ISearchService.cs index bfb6e20689..9b5f45daf5 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/ISearchService.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/ISearchService.cs @@ -81,6 +81,18 @@ Task SearchHistoryAsync( CancellationToken cancellationToken, bool isAsyncOperation = false); + /// + /// Searches current soft-deleted resources. + /// + Task SearchDeletedAsync( + string resourceType, + PartialDateTime since, + PartialDateTime before, + int? count, + string continuationToken, + string sort, + CancellationToken cancellationToken); + /// /// Searches resources by queryParameters and returns the raw resource, /// the current search param values for each resource, diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/SearchService.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/SearchService.cs index 4104d8f5b7..dad471a83f 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/SearchService.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/SearchService.cs @@ -95,6 +95,59 @@ public async Task SearchHistoryAsync( string sort, CancellationToken cancellationToken, bool isAsyncOperation = false) + { + return await SearchByVersionTypeAsync( + resourceType, + resourceId, + at, + since, + before, + count, + summary, + continuationToken, + sort, + ResourceVersionType.Latest | ResourceVersionType.History | ResourceVersionType.SoftDeleted, + isAsyncOperation, + cancellationToken); + } + + public async Task SearchDeletedAsync( + string resourceType, + PartialDateTime since, + PartialDateTime before, + int? count, + string continuationToken, + string sort, + CancellationToken cancellationToken) + { + return await SearchByVersionTypeAsync( + resourceType, + resourceId: null, + at: null, + since, + before, + count, + summary: null, + continuationToken, + sort, + ResourceVersionType.SoftDeleted, + isAsyncOperation: false, + cancellationToken); + } + + private async Task SearchByVersionTypeAsync( + string resourceType, + string resourceId, + PartialDateTime at, + PartialDateTime since, + PartialDateTime before, + int? count, + string summary, + string continuationToken, + string sort, + ResourceVersionType resourceVersionTypes, + bool isAsyncOperation, + CancellationToken cancellationToken) { var queryParameters = new List>(); @@ -198,9 +251,7 @@ public async Task SearchHistoryAsync( queryParameters.Add(Tuple.Create(KnownQueryParameterNames.Sort, $"-{KnownQueryParameterNames.LastUpdated}")); } - var historyResourceVersionTypes = ResourceVersionType.Latest | ResourceVersionType.History | ResourceVersionType.SoftDeleted; - - SearchOptions searchOptions = _searchOptionsFactory.Create(resourceType, queryParameters, isAsyncOperation, historyResourceVersionTypes); + SearchOptions searchOptions = _searchOptionsFactory.Create(resourceType, queryParameters, isAsyncOperation, resourceVersionTypes); SearchResult searchResult = await SearchAsync(searchOptions, cancellationToken); diff --git a/src/Microsoft.Health.Fhir.Core/Messages/Search/SearchDeletedResourcesRequest.cs b/src/Microsoft.Health.Fhir.Core/Messages/Search/SearchDeletedResourcesRequest.cs new file mode 100644 index 0000000000..bf2a96837a --- /dev/null +++ b/src/Microsoft.Health.Fhir.Core/Messages/Search/SearchDeletedResourcesRequest.cs @@ -0,0 +1,65 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using Medino; +using Microsoft.Health.Fhir.Core.Models; + +namespace Microsoft.Health.Fhir.Core.Messages.Search +{ + /// + /// A request to search current soft-deleted resources. + /// + public class SearchDeletedResourcesRequest : IRequest + { + /// + /// Initializes a new instance of the class. + /// + public SearchDeletedResourcesRequest( + string resourceType, + PartialDateTime since, + PartialDateTime before, + int? count, + string continuationToken, + string sort) + { + ResourceType = resourceType; + Since = since; + Before = before; + Count = count; + ContinuationToken = continuationToken; + Sort = sort; + } + + /// + /// Gets the optional resource type. + /// + public string ResourceType { get; } + + /// + /// Gets the inclusive lower last-updated bound. + /// + public PartialDateTime Since { get; } + + /// + /// Gets the exclusive upper last-updated bound. + /// + public PartialDateTime Before { get; } + + /// + /// Gets the requested page size. + /// + public int? Count { get; } + + /// + /// Gets the continuation token. + /// + public string ContinuationToken { get; } + + /// + /// Gets the last-updated sort order. + /// + public string Sort { get; } + } +} diff --git a/src/Microsoft.Health.Fhir.Core/Microsoft.Health.Fhir.Core.csproj b/src/Microsoft.Health.Fhir.Core/Microsoft.Health.Fhir.Core.csproj index 0f46f641e9..17a537cb44 100644 --- a/src/Microsoft.Health.Fhir.Core/Microsoft.Health.Fhir.Core.csproj +++ b/src/Microsoft.Health.Fhir.Core/Microsoft.Health.Fhir.Core.csproj @@ -4,6 +4,7 @@ + diff --git a/src/Microsoft.Health.Fhir.CosmosDb.UnitTests/Features/Search/QueryBuilderTests.cs b/src/Microsoft.Health.Fhir.CosmosDb.UnitTests/Features/Search/QueryBuilderTests.cs new file mode 100644 index 0000000000..67ef9176b6 --- /dev/null +++ b/src/Microsoft.Health.Fhir.CosmosDb.UnitTests/Features/Search/QueryBuilderTests.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using Microsoft.Health.Fhir.Core.Features.Search; +using Microsoft.Health.Fhir.CosmosDb.Features.Search.Queries; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using Xunit; + +namespace Microsoft.Health.Fhir.CosmosDb.UnitTests.Features.Search +{ + [Trait(Traits.OwningTeam, OwningTeam.Fhir)] + [Trait(Traits.Category, Categories.Search)] + public class QueryBuilderTests + { + [Fact] + public void GivenSoftDeletedOnlySearch_WhenQueryBuilt_ThenOnlyDeletedResourcesAreSelected() + { + var searchOptions = new SearchOptions + { + ResourceVersionTypes = ResourceVersionType.SoftDeleted, + Sort = [], + }; + + string query = new QueryBuilder().BuildSqlQuerySpec(searchOptions).QueryText; + + Assert.Contains("r.isDeleted =", query); + Assert.Contains("r.isHistory =", query); + } + } +} diff --git a/src/Microsoft.Health.Fhir.CosmosDb/Features/Search/Queries/QueryBuilder.cs b/src/Microsoft.Health.Fhir.CosmosDb/Features/Search/Queries/QueryBuilder.cs index 064f9da5cc..4658dc339f 100644 --- a/src/Microsoft.Health.Fhir.CosmosDb/Features/Search/Queries/QueryBuilder.cs +++ b/src/Microsoft.Health.Fhir.CosmosDb/Features/Search/Queries/QueryBuilder.cs @@ -79,15 +79,17 @@ public QueryDefinition BuildSqlQuerySpec(SearchOptions searchOptions, QueryBuild searchOptions.Expression.AcceptVisitor(expressionQueryBuilder); } - if (!searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.Latest)) + if (searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.History) && + !searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.Latest)) { AppendFilterCondition( "AND", true, (KnownResourceWrapperProperties.IsHistory, true)); } - - if (!searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.History)) + else if ((searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.Latest) || + searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.SoftDeleted)) && + !searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.History)) { AppendFilterCondition( "AND", @@ -95,7 +97,16 @@ public QueryDefinition BuildSqlQuerySpec(SearchOptions searchOptions, QueryBuild (KnownResourceWrapperProperties.IsHistory, false)); } - if (!searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.SoftDeleted)) + if (searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.SoftDeleted) && + !searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.Latest)) + { + AppendFilterCondition( + "AND", + true, + (KnownResourceWrapperProperties.IsDeleted, true)); + } + else if (searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.Latest) && + !searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.SoftDeleted)) { AppendFilterCondition( "AND", diff --git a/src/Microsoft.Health.Fhir.R4.Core.UnitTests/Extensions/OperationDefinitionMediatorExtensionsTests.cs b/src/Microsoft.Health.Fhir.R4.Core.UnitTests/Extensions/OperationDefinitionMediatorExtensionsTests.cs index 8b01d3d41b..9642b19881 100644 --- a/src/Microsoft.Health.Fhir.R4.Core.UnitTests/Extensions/OperationDefinitionMediatorExtensionsTests.cs +++ b/src/Microsoft.Health.Fhir.R4.Core.UnitTests/Extensions/OperationDefinitionMediatorExtensionsTests.cs @@ -36,6 +36,7 @@ public OperationDefinitionMediatorExtensionsTests() [InlineData("member-match")] [InlineData("convert-data")] [InlineData("purge-history")] + [InlineData("delete-search")] public async Task GivenVariousOperationNames_WhenGetOperationDefinitionAsync_ThenCorrectRequestIsSent(string operationName) { // Arrange diff --git a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Controllers/FhirControllerTests.cs b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Controllers/FhirControllerTests.cs index b771b4321e..ba79383ede 100644 --- a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Controllers/FhirControllerTests.cs +++ b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Controllers/FhirControllerTests.cs @@ -152,6 +152,8 @@ public void WhenProvidedAFhirController_CheckIfTheSearchEndpointsHaveTheLatencyM TestIfTargetMethodContainsCustomAttribute(expectedCustomAttribute, "SearchCompartmentByResourceType", _targetFhirControllerClass); TestIfTargetMethodContainsCustomAttribute(expectedCustomAttribute, "SystemHistory", _targetFhirControllerClass); TestIfTargetMethodContainsCustomAttribute(expectedCustomAttribute, "TypeHistory", _targetFhirControllerClass); + TestIfTargetMethodContainsCustomAttribute(expectedCustomAttribute, "DeleteSearch", _targetFhirControllerClass); + TestIfTargetMethodContainsCustomAttribute(expectedCustomAttribute, "DeleteSearchByType", _targetFhirControllerClass); } [Fact] @@ -591,6 +593,20 @@ await RunHistoryTest( Guid.NewGuid().ToString()); } + [Fact] + public async Task GivenSystemDeletedResourceSearch_WhenProcessingRequest_ThenRequestShouldBeCreatedCorrectly() + { + await RunDeletedResourceSearchTest((model, _) => _fhirController.DeleteSearch(model)); + } + + [Fact] + public async Task GivenTypeDeletedResourceSearch_WhenProcessingRequest_ThenRequestShouldBeCreatedCorrectly() + { + await RunDeletedResourceSearchTest( + (model, type) => _fhirController.DeleteSearchByType(type, model), + KnownResourceTypes.Patient); + } + [Fact] public async Task GivenVReadRequest_WhenProcessingRequest_ThenGetResourceRequestShouldBeCreatedCorrectly() { @@ -1444,6 +1460,47 @@ await _mediator.Received(1).SendAsync( Arg.Any()); } + private async Task RunDeletedResourceSearchTest( + Func> action, + string resourceType = null) + { + var resource = new Bundle + { + Id = Guid.NewGuid().ToString(), + VersionId = Guid.NewGuid().ToString(), + }; + var httpContext = new DefaultHttpContext(); + _fhirController.ControllerContext.HttpContext = httpContext; + _mediator.SendAsync( + Arg.Any(), + Arg.Any()) + .Returns(new SearchResourceHistoryResponse(resource.ToResourceElement())); + SearchDeletedResourcesRequest request = null; + _mediator.When(x => x.SendAsync( + Arg.Any(), + Arg.Any())) + .Do(x => request = x.Arg()); + var model = new DeletedResourceSearchModel + { + Since = PartialDateTime.Parse("2025-01-01"), + Before = PartialDateTime.Parse("2025-02-01"), + Count = 10, + ContinuationToken = "token", + Sort = $"-{KnownQueryParameterNames.LastUpdated}", + }; + + IActionResult response = await action(model, resourceType); + + Assert.IsType(response); + Assert.NotNull(request); + Assert.Equal(resourceType, request.ResourceType); + Assert.Equal(model.Since, request.Since); + Assert.Equal(model.Before, request.Before); + Assert.Equal(model.Count, request.Count); + Assert.Equal(model.ContinuationToken, request.ContinuationToken); + Assert.Equal(model.Sort, request.Sort); + } + private static void TestIfTargetMethodContainsCustomAttribute(Type expectedCustomAttributeType, string methodName, Type targetClassType) { MethodInfo bundleMethodInfo = targetClassType.GetMethod(methodName); diff --git a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Controllers/OperationDefinitionControllerTests.cs b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Controllers/OperationDefinitionControllerTests.cs index 86a4f80c2d..a0964e296f 100644 --- a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Controllers/OperationDefinitionControllerTests.cs +++ b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Controllers/OperationDefinitionControllerTests.cs @@ -18,6 +18,7 @@ using Microsoft.Health.Fhir.Core.Exceptions; using Microsoft.Health.Fhir.Core.Extensions; using Microsoft.Health.Fhir.Core.Features; +using Microsoft.Health.Fhir.Core.Features.Operations; using Microsoft.Health.Fhir.Core.Features.Search; using Microsoft.Health.Fhir.Core.Messages.Operation; using Microsoft.Health.Fhir.Core.Registration; @@ -270,6 +271,16 @@ await _mediator.DidNotReceive().SendAsync( Arg.Any()); } + [Fact] + public async Task GivenConfiguration_WhenDeleteSearchIsEnabled_ThenOperationDefinitionShouldBeReturned() + { + await _controller.DeleteSearchOperationDefinition(); + + await _mediator.Received(1).SendAsync( + Arg.Is(request => request.OperationName == OperationsConstants.DeleteSearch), + Arg.Any()); + } + [Theory] [InlineData(true)] [InlineData(false)] diff --git a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Features/Operations/OperationsCapabilityProviderTests.cs b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Features/Operations/OperationsCapabilityProviderTests.cs index daab7b90f7..767e1bcb7e 100644 --- a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Features/Operations/OperationsCapabilityProviderTests.cs +++ b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Features/Operations/OperationsCapabilityProviderTests.cs @@ -407,6 +407,50 @@ public void GivenProvider_WhenAddingDetails_ThenBulkDeleteOperationShouldBeAdded Assert.Equal(OperationDefinitionUrl, restComponent.Operation.First().Definition?.Reference, StringComparer.OrdinalIgnoreCase); } + [Fact] + public async Task GivenAConformanceBuilder_WhenCallingOperationsCapability_ThenDeleteSearchDetailsAreAdded() + { + var provider = new OperationsCapabilityProvider( + _operationsOptions, + _featureOptions, + _coreFeatureOptions, + _implementationGuidesOptions, + _watchdogOptions, + _urlResolver, + _fhirRuntimeConfiguration); + ICapabilityStatementBuilder builder = Substitute.For(); + + await provider.BuildAsync(builder, CancellationToken.None); + + builder.Received(1) + .Apply(Arg.Is>(x => x.Method.Name == nameof(OperationsCapabilityProvider.AddDeleteSearchDetails))); + } + + [Fact] + public void GivenProvider_WhenAddingDetails_ThenDeleteSearchOperationShouldBeAdded() + { + var provider = new OperationsCapabilityProvider( + _operationsOptions, + _featureOptions, + _coreFeatureOptions, + _implementationGuidesOptions, + _watchdogOptions, + _urlResolver, + _fhirRuntimeConfiguration); + var restComponent = new ListedRestComponent + { + Mode = ListedCapabilityStatement.ServerMode, + }; + var capabilityStatement = new ListedCapabilityStatement(); + capabilityStatement.Rest.Add(restComponent); + + provider.AddDeleteSearchDetails(capabilityStatement); + + Assert.Single(restComponent.Operation); + Assert.Equal(OperationsConstants.DeleteSearch, restComponent.Operation.First().Name, StringComparer.OrdinalIgnoreCase); + Assert.Equal(OperationDefinitionUrl, restComponent.Operation.First().Definition?.Reference, StringComparer.OrdinalIgnoreCase); + } + [Theory] [InlineData(true)] [InlineData(false)] diff --git a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Features/Routing/UrlResolverTests.cs b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Features/Routing/UrlResolverTests.cs index 64d71750bd..c9cbbdefbf 100644 --- a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Features/Routing/UrlResolverTests.cs +++ b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Features/Routing/UrlResolverTests.cs @@ -311,6 +311,17 @@ public void GivenAReindexOperation_WhenOperationResultUrlIsResolved_ThenCorrectU }); } + [Fact] + public void GivenADeleteSearchOperation_WhenOperationDefinitionUrlIsResolved_ThenCorrectUrlShouldBeReturned() + { + _urlResolver.ResolveOperationDefinitionUrl(OperationsConstants.DeleteSearch); + + Assert.NotNull(_capturedUrlRouteContext); + Assert.Equal(RouteNames.DeleteSearchOperationDefinition, _capturedUrlRouteContext.RouteName); + Assert.Equal(Scheme, _capturedUrlRouteContext.Protocol); + Assert.Equal(Host, _capturedUrlRouteContext.Host); + } + [Fact] public void GivenAnUnknownOperation_WhenOperationResultUrlIsResolved_ThenOperationNotImplementedExceptionShouldBeThrown() { diff --git a/src/Microsoft.Health.Fhir.Shared.Api/Controllers/FhirController.cs b/src/Microsoft.Health.Fhir.Shared.Api/Controllers/FhirController.cs index a37b92ab67..10f4c4cc0a 100644 --- a/src/Microsoft.Health.Fhir.Shared.Api/Controllers/FhirController.cs +++ b/src/Microsoft.Health.Fhir.Shared.Api/Controllers/FhirController.cs @@ -370,6 +370,53 @@ public async Task TypeHistory( return FhirResult.Create(response); } + /// + /// Returns current soft-deleted resources in the system. + /// + /// Model for last-updated and paging parameters. + [HttpGet] + [Route(KnownRoutes.DeleteSearch, Name = RouteNames.DeleteSearch)] + [AuditEventType(AuditEventSubType.HistorySystem)] + [TypeFilter(typeof(SearchEndpointMetricEmitterAttribute))] + public async Task DeleteSearch(DeletedResourceSearchModel searchModel) + { + ResourceElement response = await _mediator.SearchDeletedResourcesAsync( + resourceType: null, + searchModel.Since, + searchModel.Before, + searchModel.Count, + searchModel.ContinuationToken, + searchModel.Sort, + HttpContext.RequestAborted); + + return FhirResult.Create(response); + } + + /// + /// Returns current soft-deleted resources of a specific type. + /// + /// The resource type. + /// Model for last-updated and paging parameters. + [HttpGet] + [Route(KnownRoutes.DeleteSearchResourceType, Name = RouteNames.DeleteSearchType)] + [AuditEventType(AuditEventSubType.HistoryType)] + [TypeFilter(typeof(SearchEndpointMetricEmitterAttribute))] + public async Task DeleteSearchByType( + string typeParameter, + DeletedResourceSearchModel searchModel) + { + ResourceElement response = await _mediator.SearchDeletedResourcesAsync( + typeParameter, + searchModel.Since, + searchModel.Before, + searchModel.Count, + searchModel.ContinuationToken, + searchModel.Sort, + HttpContext.RequestAborted); + + return FhirResult.Create(response); + } + /// /// Returns the history of a resource /// diff --git a/src/Microsoft.Health.Fhir.Shared.Api/Controllers/OperationDefinitionController.cs b/src/Microsoft.Health.Fhir.Shared.Api/Controllers/OperationDefinitionController.cs index 081d6490de..1ca996f2c6 100644 --- a/src/Microsoft.Health.Fhir.Shared.Api/Controllers/OperationDefinitionController.cs +++ b/src/Microsoft.Health.Fhir.Shared.Api/Controllers/OperationDefinitionController.cs @@ -152,6 +152,14 @@ public async Task BulkDeleteSoftDeletedOperationDefinition() return await GetOperationDefinitionAsync(OperationsConstants.BulkDeleteSoftDeleted); } + [HttpGet] + [Route(KnownRoutes.DeleteSearchOperationDefinition, Name = RouteNames.DeleteSearchOperationDefinition)] + [AllowAnonymous] + public async Task DeleteSearchOperationDefinition() + { + return await GetOperationDefinitionAsync(OperationsConstants.DeleteSearch); + } + [HttpGet] [Route(KnownRoutes.BulkUpdateOperationDefinition, Name = RouteNames.BulkUpdateDefinition)] [AllowAnonymous] @@ -228,6 +236,7 @@ private void CheckIfOperationIsEnabledAndRespond(string operationName) break; case OperationsConstants.MemberMatch: case OperationsConstants.PurgeHistory: + case OperationsConstants.DeleteSearch: operationEnabled = true; break; case OperationsConstants.SearchParameterStatus: diff --git a/src/Microsoft.Health.Fhir.Shared.Api/Features/Operations/OperationsCapabilityProvider.cs b/src/Microsoft.Health.Fhir.Shared.Api/Features/Operations/OperationsCapabilityProvider.cs index e3c996d239..48bf244156 100644 --- a/src/Microsoft.Health.Fhir.Shared.Api/Features/Operations/OperationsCapabilityProvider.cs +++ b/src/Microsoft.Health.Fhir.Shared.Api/Features/Operations/OperationsCapabilityProvider.cs @@ -86,6 +86,7 @@ public Task BuildAsync(ICapabilityStatementBuilder builder, CancellationToken ca builder.Apply(AddMemberMatchDetails); builder.Apply(AddPatientEverythingDetails); + builder.Apply(AddDeleteSearchDetails); if (_operationConfiguration.BulkDelete.Enabled) { @@ -188,6 +189,11 @@ public void AddBulkDeleteDetails(ListedCapabilityStatement capabilityStatement) GetAndAddOperationDefinitionUriToCapabilityStatement(capabilityStatement, OperationsConstants.BulkDelete); } + public void AddDeleteSearchDetails(ListedCapabilityStatement capabilityStatement) + { + GetAndAddOperationDefinitionUriToCapabilityStatement(capabilityStatement, OperationsConstants.DeleteSearch); + } + public void AddBulkUpdateDetails(ListedCapabilityStatement capabilityStatement) { GetAndAddOperationDefinitionUriToCapabilityStatement(capabilityStatement, OperationsConstants.BulkUpdate); diff --git a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Extensions/OperationDefinitionMediatorExtensionsTests.cs b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Extensions/OperationDefinitionMediatorExtensionsTests.cs index 8aac757c63..9ff3722891 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Extensions/OperationDefinitionMediatorExtensionsTests.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Extensions/OperationDefinitionMediatorExtensionsTests.cs @@ -36,6 +36,7 @@ public OperationDefinitionMediatorExtensionsTests() [InlineData("member-match")] [InlineData("convert-data")] [InlineData("purge-history")] + [InlineData("delete-search")] public async Task GivenVariousOperationNames_WhenGetOperationDefinitionAsync_ThenCorrectRequestIsSent(string operationName) { // Arrange diff --git a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchDeletedResourcesHandlerTests.cs b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchDeletedResourcesHandlerTests.cs new file mode 100644 index 0000000000..e1f160d3b2 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchDeletedResourcesHandlerTests.cs @@ -0,0 +1,73 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Linq; +using System.Threading; +using Hl7.Fhir.Model; +using Microsoft.Health.Core.Features.Security.Authorization; +using Microsoft.Health.Fhir.Core.Extensions; +using Microsoft.Health.Fhir.Core.Features.Search; +using Microsoft.Health.Fhir.Core.Features.Search.Filters; +using Microsoft.Health.Fhir.Core.Features.Security; +using Microsoft.Health.Fhir.Core.Features.Security.Authorization; +using Microsoft.Health.Fhir.Core.Messages.Search; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using NSubstitute; +using Xunit; +using Task = System.Threading.Tasks.Task; + +namespace Microsoft.Health.Fhir.Core.UnitTests.Features.Search +{ + [Trait(Traits.OwningTeam, OwningTeam.Fhir)] + [Trait(Traits.Category, Categories.Search)] + public class SearchDeletedResourcesHandlerTests + { + [Fact] + public async Task GivenADeletedResourceSearch_WhenHandled_ThenAHistoryBundleIsReturned() + { + ISearchService searchService = Substitute.For(); + IBundleFactory bundleFactory = Substitute.For(); + var handler = new SearchDeletedResourcesHandler( + searchService, + bundleFactory, + DisabledFhirAuthorizationService.Instance, + new DataResourceFilter(MissingDataFilterCriteria.Default)); + var request = new SearchDeletedResourcesRequest("Patient", null, null, null, null, null); + var searchResult = new SearchResult(Enumerable.Empty(), null, null, Array.Empty>()); + var expectedBundle = new Bundle().ToResourceElement(); + + searchService.SearchDeletedAsync("Patient", null, null, null, null, null, CancellationToken.None).Returns(searchResult); + bundleFactory.CreateHistoryBundle(searchResult).Returns(expectedBundle); + + SearchResourceHistoryResponse response = await handler.HandleAsync(request, CancellationToken.None); + + Assert.Same(expectedBundle, response.Bundle); + } + + [Theory] + [InlineData(DataActions.None)] + [InlineData(DataActions.Write)] + [InlineData(DataActions.ReadById)] + public async Task GivenADeletedResourceSearch_WhenUserLacksSearchAccess_ThenAuthorizationFails(DataActions dataActions) + { + ISearchService searchService = Substitute.For(); + IBundleFactory bundleFactory = Substitute.For(); + IAuthorizationService authorizationService = Substitute.For>(); + var handler = new SearchDeletedResourcesHandler( + searchService, + bundleFactory, + authorizationService, + new DataResourceFilter(MissingDataFilterCriteria.Default)); + var request = new SearchDeletedResourcesRequest(null, null, null, null, null, null); + + authorizationService.CheckAccess(DataActions.Read | DataActions.Search, CancellationToken.None).Returns(dataActions); + + await Assert.ThrowsAsync( + () => handler.HandleAsync(request, CancellationToken.None)); + } + } +} diff --git a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchServiceTests.cs b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchServiceTests.cs index 861d49385e..6c4ba27c01 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchServiceTests.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchServiceTests.cs @@ -168,6 +168,44 @@ public async Task GivenAHistorySearch_WhenUsingSummaryCountOrCountZero_ThenSearc Assert.Same(expectedSearchResult, singlePatientSummaryCount); } + [Fact] + public async Task GivenADeletedResourceSearch_WhenSearched_ThenOnlySoftDeletedResourcesAreRequested() + { + const string resourceType = "Observation"; + var since = PartialDateTime.Parse("2025-01-01"); + var before = PartialDateTime.Parse("2025-02-01"); + var expectedSearchOptions = new SearchOptions(); + + _searchOptionsFactory.Create( + resourceType, + Arg.Is>>(parameters => + parameters.Contains(Tuple.Create(SearchParameterNames.LastUpdated, $"ge{since}")) && + parameters.Contains(Tuple.Create(SearchParameterNames.LastUpdated, $"lt{before}")) && + parameters.Contains(Tuple.Create(KnownQueryParameterNames.Count, "25")) && + parameters.Contains(Tuple.Create(KnownQueryParameterNames.ContinuationToken, "token")) && + parameters.Contains(Tuple.Create(KnownQueryParameterNames.Sort, $"-{KnownQueryParameterNames.LastUpdated}"))), + resourceVersionTypes: ResourceVersionType.SoftDeleted) + .Returns(expectedSearchOptions); + + SearchResult expectedSearchResult = SearchResult.Empty(_unsupportedQueryParameters); + _searchService.SearchImplementation = options => + { + Assert.Same(expectedSearchOptions, options); + return expectedSearchResult; + }; + + SearchResult actual = await _searchService.SearchDeletedAsync( + resourceType, + since, + before, + 25, + "token", + $"-{KnownQueryParameterNames.LastUpdated}", + CancellationToken.None); + + Assert.Same(expectedSearchResult, actual); + } + [Theory] [InlineData(true)] [InlineData(false)] diff --git a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Microsoft.Health.Fhir.Shared.Core.UnitTests.projitems b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Microsoft.Health.Fhir.Shared.Core.UnitTests.projitems index 00d7fb03cc..094e9f9192 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Microsoft.Health.Fhir.Shared.Core.UnitTests.projitems +++ b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Microsoft.Health.Fhir.Shared.Core.UnitTests.projitems @@ -129,6 +129,7 @@ + diff --git a/src/Microsoft.Health.Fhir.Shared.Core/Extensions/FhirMediatorExtensions.cs b/src/Microsoft.Health.Fhir.Shared.Core/Extensions/FhirMediatorExtensions.cs index 8ef08c5f2a..3932864a43 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core/Extensions/FhirMediatorExtensions.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core/Extensions/FhirMediatorExtensions.cs @@ -141,6 +141,25 @@ public static async Task SearchResourceHistoryAsync(this IMedia return result.Bundle; } + public static async Task SearchDeletedResourcesAsync( + this IMediator mediator, + string resourceType, + PartialDateTime since = null, + PartialDateTime before = null, + int? count = null, + string continuationToken = null, + string sort = null, + CancellationToken cancellationToken = default) + { + EnsureArg.IsNotNull(mediator, nameof(mediator)); + + var result = await mediator.SendAsync( + new SearchDeletedResourcesRequest(resourceType, since, before, count, continuationToken, sort), + cancellationToken); + + return result.Bundle; + } + public static async Task SearchResourceCompartmentAsync(this IMediator mediator, string compartmentType, string compartmentId, string resourceType, IReadOnlyList> queries, CancellationToken cancellationToken = default) { EnsureArg.IsNotNull(mediator, nameof(mediator)); diff --git a/src/Microsoft.Health.Fhir.Shared.Core/Features/Search/SearchDeletedResourcesHandler.cs b/src/Microsoft.Health.Fhir.Shared.Core/Features/Search/SearchDeletedResourcesHandler.cs new file mode 100644 index 0000000000..7a17ea8489 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Shared.Core/Features/Search/SearchDeletedResourcesHandler.cs @@ -0,0 +1,66 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Threading; +using System.Threading.Tasks; +using EnsureThat; +using Medino; +using Microsoft.Health.Core.Features.Security.Authorization; +using Microsoft.Health.Fhir.Core.Exceptions; +using Microsoft.Health.Fhir.Core.Features.Security; +using Microsoft.Health.Fhir.Core.Features.Security.Authorization; +using Microsoft.Health.Fhir.Core.Messages.Search; +using Microsoft.Health.Fhir.Core.Models; + +namespace Microsoft.Health.Fhir.Core.Features.Search +{ + /// + /// Handles searches for current soft-deleted resources. + /// + public class SearchDeletedResourcesHandler : IRequestHandler + { + private readonly ISearchService _searchService; + private readonly IBundleFactory _bundleFactory; + private readonly IAuthorizationService _authorizationService; + private readonly IDataResourceFilter _dataResourceFilter; + + /// + /// Initializes a new instance of the class. + /// + public SearchDeletedResourcesHandler( + ISearchService searchService, + IBundleFactory bundleFactory, + IAuthorizationService authorizationService, + IDataResourceFilter dataResourceFilter) + { + _searchService = EnsureArg.IsNotNull(searchService, nameof(searchService)); + _bundleFactory = EnsureArg.IsNotNull(bundleFactory, nameof(bundleFactory)); + _authorizationService = EnsureArg.IsNotNull(authorizationService, nameof(authorizationService)); + _dataResourceFilter = EnsureArg.IsNotNull(dataResourceFilter, nameof(dataResourceFilter)); + } + + /// + public async Task HandleAsync(SearchDeletedResourcesRequest request, CancellationToken cancellationToken) + { + EnsureArg.IsNotNull(request, nameof(request)); + + await _authorizationService.CheckSearchAccess(cancellationToken); + + SearchResult searchResult = await _searchService.SearchDeletedAsync( + request.ResourceType, + request.Since, + request.Before, + request.Count, + request.ContinuationToken, + request.Sort, + cancellationToken); + + searchResult = _dataResourceFilter.Filter(searchResult); + + ResourceElement bundle = _bundleFactory.CreateHistoryBundle(searchResult); + return new SearchResourceHistoryResponse(bundle); + } + } +} diff --git a/src/Microsoft.Health.Fhir.Shared.Core/Microsoft.Health.Fhir.Shared.Core.projitems b/src/Microsoft.Health.Fhir.Shared.Core/Microsoft.Health.Fhir.Shared.Core.projitems index 4d87238f6e..76ca3d1599 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core/Microsoft.Health.Fhir.Shared.Core.projitems +++ b/src/Microsoft.Health.Fhir.Shared.Core/Microsoft.Health.Fhir.Shared.Core.projitems @@ -90,6 +90,7 @@ + diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlQueryGeneratorTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlQueryGeneratorTests.cs index d6ef87d017..96d4e4ce94 100644 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlQueryGeneratorTests.cs +++ b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlQueryGeneratorTests.cs @@ -95,6 +95,7 @@ public void GivenASearchTypeForSoftDeletedOnly_WhenSqlGenerated_ThenFilterForSof var output = _queryGenerator.VisitSqlRoot(sqlExpression, searchOptions); Assert.Contains("IsDeleted = 1", _strBuilder.ToString()); + Assert.Contains("IsHistory = 0", _strBuilder.ToString()); } [Fact] diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/SqlQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/SqlQueryGenerator.cs index e2ad628365..eef028870b 100644 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/SqlQueryGenerator.cs +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/SqlQueryGenerator.cs @@ -1744,12 +1744,15 @@ private void AppendHistoryClause(in IndentedStringBuilder.DelimitedScope delimit return; } - if (resourceVersionType.HasFlag(ResourceVersionType.Latest) && !resourceVersionType.HasFlag(ResourceVersionType.History)) + if ((resourceVersionType.HasFlag(ResourceVersionType.Latest) || + resourceVersionType.HasFlag(ResourceVersionType.SoftDeleted)) && + !resourceVersionType.HasFlag(ResourceVersionType.History)) { delimited.BeginDelimitedElement(); StringBuilder.Append(VLatest.Resource.IsHistory, tableAlias).Append(" = 0 "); } - else if (resourceVersionType.HasFlag(ResourceVersionType.History) && !resourceVersionType.HasFlag(ResourceVersionType.Latest)) + else if (resourceVersionType.HasFlag(ResourceVersionType.History) && + !resourceVersionType.HasFlag(ResourceVersionType.Latest)) { delimited.BeginDelimitedElement(); StringBuilder.Append(VLatest.Resource.IsHistory, tableAlias).Append(" = 1 ");