Skip to content

Commit d535686

Browse files
authored
Accept null PatientID in STOW (#3235)
* STOW changes in Sql for Patiend ID to accept null * STOW dotnet code changes to allow null PatientID, unit tests for V1 & V2 * Added E2E test for Patient Id scenario * Fix for breaking e2e tests * Changes from review comment * Added comments for readability * Updates from review comments * Updates on v2 conformance statement * Added a separate validator for PatientId * Updates on v2 conformance statement * Updated dicom cast tests to include null patient id scenarios * Changed patientId check from IsNullOrEmpty to IsNullOrWhitespace
1 parent 3cf787d commit d535686

File tree

18 files changed

+6567
-34
lines changed

18 files changed

+6567
-34
lines changed

converter/dicom-cast/src/Microsoft.Health.DicomCast.Core.UnitTests/Features/Worker/ChangeFeedProcessorTests.cs

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using System.Text.Json;
99
using System.Threading;
1010
using System.Threading.Tasks;
11+
using FellowOakDicom;
1112
using Microsoft.Extensions.Logging.Abstractions;
1213
using Microsoft.Extensions.Options;
1314
using Microsoft.Health.Core.Internal;
@@ -280,8 +281,39 @@ public async Task WhenThrowTimeoutRejectedException_ExceptionNotThrown()
280281
await _fhirTransactionPipeline.Received(1).ProcessAsync(changeFeeds1[0], DefaultCancellationToken);
281282
}
282283

284+
[Theory]
285+
[InlineData(nameof(DicomTagException))]
286+
[InlineData(nameof(MissingRequiredDicomTagException))]
287+
public async Task WhenThrowDicomTagException_ExceptionNotThrown(string exception)
288+
{
289+
ChangeFeedEntry[] changeFeeds1 = new[]
290+
{
291+
ChangeFeedGenerator.Generate(1),
292+
};
293+
294+
// Arrange
295+
_changeFeedRetrieveService.RetrieveLatestSequenceAsync(DefaultCancellationToken).Returns(1L);
296+
297+
_changeFeedRetrieveService.RetrieveChangeFeedAsync(0, ChangeFeedProcessor.DefaultLimit, DefaultCancellationToken).Returns(changeFeeds1);
298+
_changeFeedRetrieveService.RetrieveChangeFeedAsync(1, ChangeFeedProcessor.DefaultLimit, DefaultCancellationToken).Returns(Array.Empty<ChangeFeedEntry>());
299+
300+
_fhirTransactionPipeline.When(pipeline => pipeline.ProcessAsync(Arg.Any<ChangeFeedEntry>(), Arg.Any<CancellationToken>())).Do(pipeline => { ThrowDicomTagException(exception); });
301+
302+
// Act
303+
await ExecuteProcessAsync();
304+
305+
// Assert
306+
await _changeFeedRetrieveService.Received(2).RetrieveLatestSequenceAsync(DefaultCancellationToken);
307+
308+
await _changeFeedRetrieveService.ReceivedWithAnyArgs(2).RetrieveChangeFeedAsync(default, default, default);
309+
await _changeFeedRetrieveService.Received(1).RetrieveChangeFeedAsync(0, ChangeFeedProcessor.DefaultLimit, DefaultCancellationToken);
310+
await _changeFeedRetrieveService.Received(1).RetrieveChangeFeedAsync(1, ChangeFeedProcessor.DefaultLimit, DefaultCancellationToken);
311+
312+
await _fhirTransactionPipeline.Received(1).ProcessAsync(changeFeeds1[0], DefaultCancellationToken);
313+
}
314+
283315
[Fact]
284-
public async Task WhenThrowDicomTagException_ExceptionNotThrown()
316+
public async Task WhenMissingRequiredDicomTagException_ExceptionNotThrown()
285317
{
286318
ChangeFeedEntry[] changeFeeds1 = new[]
287319
{
@@ -294,7 +326,7 @@ public async Task WhenThrowDicomTagException_ExceptionNotThrown()
294326
_changeFeedRetrieveService.RetrieveChangeFeedAsync(0, ChangeFeedProcessor.DefaultLimit, DefaultCancellationToken).Returns(changeFeeds1);
295327
_changeFeedRetrieveService.RetrieveChangeFeedAsync(1, ChangeFeedProcessor.DefaultLimit, DefaultCancellationToken).Returns(Array.Empty<ChangeFeedEntry>());
296328

297-
_fhirTransactionPipeline.When(pipeline => pipeline.ProcessAsync(Arg.Any<ChangeFeedEntry>(), Arg.Any<CancellationToken>())).Do(pipeline => { throw new DicomTagException("exception"); });
329+
_fhirTransactionPipeline.When(pipeline => pipeline.ProcessAsync(Arg.Any<ChangeFeedEntry>(), Arg.Any<CancellationToken>())).Do(pipeline => { throw new MissingRequiredDicomTagException(nameof(DicomTag.PatientID)); });
298330

299331
// Act
300332
await ExecuteProcessAsync();
@@ -463,4 +495,16 @@ private async Task ExecuteProcessAsync(TimeSpan? pollIntervalDuringCatchup = nul
463495

464496
await _changeFeedProcessor.ProcessAsync(pollIntervalDuringCatchup.Value, DefaultCancellationToken);
465497
}
498+
499+
private static void ThrowDicomTagException(string exception)
500+
{
501+
if (exception.Equals(nameof(DicomTagException)))
502+
{
503+
throw new DicomTagException("exception");
504+
}
505+
else if (exception.Equals(nameof(MissingRequiredDicomTagException)))
506+
{
507+
throw new MissingRequiredDicomTagException(nameof(DicomTag.PatientID));
508+
}
509+
}
466510
}

converter/dicom-cast/src/Microsoft.Health.DicomCast.Core.UnitTests/Features/Worker/FhirTransaction/Patient/PatientPipelineStepTests.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,24 @@ public async Task GivenNullMetadata_WhenRequestIsPrepared_ThenItShouldNotCreateE
5454
}
5555

5656
[Fact]
57-
public async Task GivenMissingPatientId_WhenPreparingTheRequest_ThenMissingRequiredDicomTagExceptionShouldBeThrown()
57+
public async Task GivenMissingPatientIdTag_WhenPreparingTheRequest_ThenMissingRequiredDicomTagExceptionShouldBeThrown()
5858
{
5959
var context = new FhirTransactionContext(ChangeFeedGenerator.Generate(metadata: new DicomDataset()));
6060

6161
await Assert.ThrowsAsync<MissingRequiredDicomTagException>(() => _patientPipeline.PrepareRequestAsync(context, DefaultCancellationToken));
6262
}
6363

64+
[Fact]
65+
public async Task GivenPatientIdTagPresentWithMissingValue_WhenPreparingTheRequest_ThenMissingRequiredDicomTagExceptionShouldBeThrown()
66+
{
67+
DicomDataset dicomDataset = new DicomDataset();
68+
dicomDataset.AddOrUpdate(DicomTag.PatientID, new string[] { null });
69+
70+
var context = new FhirTransactionContext(ChangeFeedGenerator.Generate(metadata: dicomDataset));
71+
72+
await Assert.ThrowsAsync<MissingRequiredDicomTagException>(() => _patientPipeline.PrepareRequestAsync(context, DefaultCancellationToken));
73+
}
74+
6475
[Fact]
6576
public async Task GivenNoExistingPatient_WhenRequestIsPrepared_ThenCorrectEntryComponentShouldBeCreated()
6677
{

docs/concepts/bulk-update.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,5 @@ There is no change in other APIs. All the other APIs supports only latest versio
211211
> Only one update operation can be performed at a time.
212212
213213
> There is no way to delete only the latest version or revert back to original version.
214+
215+
> We do not support updating any field from non-null to a null value.

docs/concepts/extended-query-tags.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ The following VR types are supported:
7373
7474
> Only the first value will be indexed of a single valued data element that incorrectly has multiple values.
7575
76+
> We do not index extended query tags if the value is null or empty.
77+
7678
#### Responses
7779

7880
| Name | Type | Description |

docs/resources/v2-conformance-statement.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ The following DICOM elements are required to be present in every DICOM file atte
7373
- SOPClassUID
7474
- PatientID
7575

76-
> Note: All identifiers must be between 1 and 64 characters long, and only contain alpha numeric characters or the following special characters: `.`, `-`. PatientID is validated based on its LO VR type.
76+
> Note: All identifiers must be between 1 and 64 characters long, and only contain alpha numeric characters or the following special characters: `.`, `-`. PatientID continues to be a required tag and can have the value as null in the input. PatientID is validated based on its LO VR type.
7777
7878
Each file stored must have a unique combination of StudyInstanceUID, SeriesInstanceUID and SopInstanceUID. The warning code `45070` will be returned if a file with the same identifiers already exists.
7979

@@ -454,6 +454,8 @@ We support searching on below attributes and search type.
454454
| ManufacturerModelName | | X | X | X | X | |
455455
| SOPInstanceUID | | | X | | X | X |
456456

457+
> Note: We do not support searching using empty string for any attributes.
458+
457459
#### Search Matching
458460

459461
We support below matching types.
@@ -877,6 +879,8 @@ We support searching on these attributes:
877879
| ProcedureStepState |
878880
| StudyInstanceUID |
879881

882+
> Note: We do not support searching using empty string for any attributes.
883+
880884
#### Search Matching
881885

882886
We support these matching types:

src/Microsoft.Health.Dicom.Core.UnitTests/Features/Store/StoreDatasetValidatorTestsV1.cs

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,6 @@ public async Task GivenFullValidation_WhenPatientIDInvalid_ExpectErrorProduced()
8080
"""does not validate VR LO: value contains invalid character""",
8181
result.InvalidTagErrors[DicomTag.PatientID].Error);
8282
minimumValidator.DidNotReceive().Validate(Arg.Any<DicomElement>());
83-
84-
minimumValidator.DidNotReceive().Validate(Arg.Any<DicomElement>());
8583
}
8684

8785
[Fact]
@@ -120,6 +118,71 @@ public async Task GivenPartialValidation_WhenPatientIDInvalid_ExpectTagValidated
120118
result.InvalidTagErrors[DicomTag.PatientID].Error);
121119
}
122120

121+
[Theory]
122+
[InlineData("")]
123+
[InlineData(null)]
124+
public async Task GivenPatientIdEmpty_WhenValidated_ExpectErrorProduced(string value)
125+
{
126+
var featureConfigurationEnableFullValidation = Substitute.For<IOptions<FeatureConfiguration>>();
127+
featureConfigurationEnableFullValidation.Value.Returns(new FeatureConfiguration { });
128+
129+
DicomDataset dicomDataset = Samples.CreateRandomInstanceDataset(
130+
validateItems: false,
131+
patientId: value);
132+
133+
if (value == null)
134+
dicomDataset.AddOrUpdate(DicomTag.PatientID, new string[] { null });
135+
136+
IElementMinimumValidator minimumValidator = Substitute.For<IElementMinimumValidator>();
137+
138+
var dicomDatasetValidator = new StoreDatasetValidator(
139+
featureConfigurationEnableFullValidation,
140+
minimumValidator,
141+
_queryTagService,
142+
_storeMeter,
143+
_dicomRequestContextAccessor,
144+
NullLogger<StoreDatasetValidator>.Instance);
145+
146+
var result = await dicomDatasetValidator.ValidateAsync(
147+
dicomDataset,
148+
null,
149+
new CancellationToken());
150+
151+
Assert.Contains(
152+
"DICOM100: (0010,0020) - The required tag '(0010,0020)' is missing.",
153+
result.InvalidTagErrors[DicomTag.PatientID].Error);
154+
}
155+
156+
[Fact]
157+
public async Task GivenPatientIdTagNotPresent_WhenValidated_ExpectErrorProduced()
158+
{
159+
var featureConfigurationEnableFullValidation = Substitute.For<IOptions<FeatureConfiguration>>();
160+
featureConfigurationEnableFullValidation.Value.Returns(new FeatureConfiguration { });
161+
162+
DicomDataset dicomDataset = Samples.CreateRandomInstanceDataset(
163+
validateItems: false);
164+
dicomDataset.Remove(DicomTag.PatientID);
165+
166+
IElementMinimumValidator minimumValidator = Substitute.For<IElementMinimumValidator>();
167+
168+
var dicomDatasetValidator = new StoreDatasetValidator(
169+
featureConfigurationEnableFullValidation,
170+
minimumValidator,
171+
_queryTagService,
172+
_storeMeter,
173+
_dicomRequestContextAccessor,
174+
NullLogger<StoreDatasetValidator>.Instance);
175+
176+
var result = await dicomDatasetValidator.ValidateAsync(
177+
dicomDataset,
178+
null,
179+
new CancellationToken());
180+
181+
Assert.Contains(
182+
"DICOM100: (0010,0020) - The required tag '(0010,0020)' is missing.",
183+
result.InvalidTagErrors[DicomTag.PatientID].Error);
184+
}
185+
123186
[Fact]
124187
public async Task GivenDicomTagWithDifferentVR_WhenValidated_ThenShouldReturnInvalidEntries()
125188
{

src/Microsoft.Health.Dicom.Core.UnitTests/Features/Store/StoreDatasetValidatorTestsV2.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,50 @@ public async Task GivenV2Enabled_WhenPatientIDPAddedWithComma_ExpectTagValidated
189189
Assert.Empty(result.InvalidTagErrors);
190190
}
191191

192+
[Theory]
193+
[InlineData("")]
194+
[InlineData(" ")]
195+
[InlineData(null)]
196+
public async Task GivenV2Enabled_WhenPatientIDTagPresentAndValueEmpty_ExpectTagValidatedAndWarningsProduced(string value)
197+
{
198+
DicomDataset dicomDataset = Samples.CreateRandomInstanceDataset(
199+
validateItems: false,
200+
patientId: value);
201+
202+
if (value == null)
203+
dicomDataset.AddOrUpdate(DicomTag.PatientID, new string[] { null });
204+
205+
var result = await _dicomDatasetValidator.ValidateAsync(
206+
dicomDataset,
207+
null,
208+
new CancellationToken());
209+
210+
Assert.True(result.InvalidTagErrors.Any());
211+
Assert.Single(result.InvalidTagErrors);
212+
Assert.False(result.HasCoreTagError);
213+
Assert.False(result.InvalidTagErrors[DicomTag.PatientID].IsRequiredCoreTag);
214+
Assert.Equal("DICOM100: (0010,0020) - The required tag '(0010,0020)' is missing.", result.InvalidTagErrors[DicomTag.PatientID].Error);
215+
}
216+
217+
[Fact]
218+
public async Task GivenV2Enabled_WhenPatientIDTagNotPresent_ExpectErrorProduced()
219+
{
220+
DicomDataset dicomDataset = Samples.CreateRandomInstanceDataset(
221+
validateItems: false);
222+
dicomDataset.Remove(DicomTag.PatientID);
223+
224+
var result = await _dicomDatasetValidator.ValidateAsync(
225+
dicomDataset,
226+
null,
227+
new CancellationToken());
228+
229+
Assert.True(result.InvalidTagErrors.Any());
230+
Assert.Single(result.InvalidTagErrors);
231+
Assert.True(result.HasCoreTagError);
232+
Assert.True(result.InvalidTagErrors[DicomTag.PatientID].IsRequiredCoreTag);
233+
Assert.Equal("DICOM100: (0010,0020) - The required tag '(0010,0020)' is missing.", result.InvalidTagErrors[DicomTag.PatientID].Error);
234+
}
235+
192236
[Fact]
193237
public async Task GivenV2Enabled_WhenNonRequiredTagNull_ExpectTagValidatedAndNoErrorProduced()
194238
{

0 commit comments

Comments
 (0)