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 ");