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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions docs/rest/SoftDeleteSearch.http
Original file line number Diff line number Diff line change
@@ -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}}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Query parameters for searching soft-deleted resources.
/// </summary>
public class DeletedResourceSearchModel
{
/// <summary>
/// Gets or sets the inclusive lower last-updated bound.
/// </summary>
[FromQuery(Name = KnownQueryParameterNames.Since)]
public PartialDateTime Since { get; set; }

/// <summary>
/// Gets or sets the exclusive upper last-updated bound.
/// </summary>
[FromQuery(Name = KnownQueryParameterNames.Before)]
public PartialDateTime Before { get; set; }

/// <summary>
/// Gets or sets the page size.
/// </summary>
[FromQuery(Name = KnownQueryParameterNames.Count)]
public int? Count { get; set; }

/// <summary>
/// Gets or sets the continuation token.
/// </summary>
[FromQuery(Name = KnownQueryParameterNames.ContinuationToken)]
public string ContinuationToken { get; set; }

/// <summary>
/// Gets or sets the last-updated sort order.
/// </summary>
[FromQuery(Name = KnownQueryParameterNames.Sort)]
public string Sort { get; set; }
}
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,18 @@ Task<SearchResult> SearchHistoryAsync(
CancellationToken cancellationToken,
bool isAsyncOperation = false);

/// <summary>
/// Searches current soft-deleted resources.
/// </summary>
Task<SearchResult> SearchDeletedAsync(
string resourceType,
PartialDateTime since,
PartialDateTime before,
int? count,
string continuationToken,
string sort,
CancellationToken cancellationToken);

/// <summary>
/// Searches resources by queryParameters and returns the raw resource,
/// the current search param values for each resource,
Expand Down
57 changes: 54 additions & 3 deletions src/Microsoft.Health.Fhir.Core/Features/Search/SearchService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,59 @@ public async Task<SearchResult> 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<SearchResult> 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<SearchResult> 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<Tuple<string, string>>();

Expand Down Expand Up @@ -198,9 +251,7 @@ public async Task<SearchResult> 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);

Expand Down
Loading