Skip to content

Commit 015ca41

Browse files
LEGLINK-639: Terminology reads value-set membership status (#1769)
ValueSet CSV files may now carry an optional trailing Status column (system,code,display,status), mirroring the CodeSystem support added in LEGLINK-541/580. Value-set membership status is independent of the code system: a code can remain active in its code system yet be dropped from a value set's membership. When a member carries a membership status it is authoritative and overrides the code system; a member with no membership status falls back to the code-system rejoin (LEGLINK-580 behavior). - Add ValueSetCode : Code carrying its own CodeStatus. - CsvValueSetRecord gains an optional [Index(3)] Status column ([Default(Active)] + [EnumIgnoreCase]). - ProcessValueSetCsv accepts 3 or 4 columns and selects the member type by header width (4-col -> ValueSetCode, 3-col -> plain Code). This also fixes a latent bug where a 4-column value-set file threw and was silently swallowed by LoadCache, leaving that value set absent from the cache. - LoadCache tolerates a missing trailing field for ValueSet as well. - FhirService.ResolveIsActive treats a ValueSetCode status as authoritative, falling through to the code-system rejoin only for plain Codes. Testing: 59/59 Terminology unit tests pass, including 4 new value-set status-parsing tests and 2 override-semantics tests.
1 parent 38faa41 commit 015ca41

6 files changed

Lines changed: 321 additions & 16 deletions

File tree

DotNet/ServiceTests/UnitTests/Terminology/Services/CodeGroupCacheServiceTests.cs

Lines changed: 163 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,9 @@ public void ProcessValueSetCsv_InvalidColumnCount_ThrowsException()
183183
Version = "1.0"
184184
};
185185

186-
var csvContent = @"system,code,display,extra
187-
http://test.system,123,Test Display,Extra Value";
186+
// Four columns (system,code,display,status) is now valid; five columns is not.
187+
var csvContent = @"system,code,display,status,extra
188+
http://test.system,123,Test Display,Active,Extra Value";
188189

189190
using var reader = new StringReader(csvContent);
190191
using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
@@ -193,7 +194,7 @@ public void ProcessValueSetCsv_InvalidColumnCount_ThrowsException()
193194
var ex = Assert.Throws<InvalidOperationException>(() =>
194195
mockService.Object.ProcessValueSetCsv(codeGroup, csv));
195196

196-
Assert.Contains("ValueSet CSV must have exactly 3 columns", ex.Message);
197+
Assert.Contains("ValueSet CSV must have", ex.Message);
197198
}
198199

199200
[Fact]
@@ -221,8 +222,9 @@ public void ProcessValueSetCsv_WithValidData_CallsSetCodeGroup()
221222
"http://test.system,123,Test Display\r\n" +
222223
"http://test.system,456,Another Display";
223224

224-
using var reader = new StringReader(csvData);
225-
using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
225+
// Use the same reader configuration LoadCache builds (MissingFieldFound tolerated), since a
226+
// 3-column value set has no field at the optional status index.
227+
using var csv = CreateCsvReader(csvData);
226228

227229
var codeGroup = new CodeGroup
228230
{
@@ -578,6 +580,162 @@ public async Task LoadCache_MixedCaseStatus_ParsesCaseInsensitively()
578580
Assert.Equal(CodeStatus.Inactive, ((CodeSystemCode)codes[2]).Status);
579581
}
580582

583+
[Fact]
584+
public async Task LoadCache_ValueSetWithStatusColumn_LoadsValueSetCodeWithStatus()
585+
{
586+
// A four-column value set file (system,code,display,status) carries its own membership
587+
// status, which is loaded as a ValueSetCode and is authoritative over the code system.
588+
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
589+
var mockConfig = new Mock<IOptions<TerminologyConfig>>();
590+
mockConfig.Setup(x => x.Value).Returns(_config);
591+
592+
var directoryFiles = new Dictionary<string, string[]>
593+
{
594+
["/test/path/vs"] = new[] { "vs.json", "vs.csv" }
595+
};
596+
var fileContents = new Dictionary<string, string>
597+
{
598+
["vs.json"] = "{ \"resourceType\": \"ValueSet\", \"id\": \"test-vs\", " +
599+
"\"url\": \"http://test.valueset\", \"version\": \"1.0\" }",
600+
["vs.csv"] = "system,code,display,status\r\n" +
601+
"http://test.system,123,Test Display,Active\r\n" +
602+
"http://test.system,456,Another Display,Inactive\r\n"
603+
};
604+
605+
var service = new TestableCodeGroupCacheService(
606+
_loggerMock.Object, memoryCache, mockConfig.Object, directoryFiles, fileContents);
607+
608+
// Act
609+
await service.LoadCache();
610+
611+
// Assert - members are ValueSetCode instances carrying the file's membership status.
612+
var codeGroup = service.GetCodeGroup(
613+
CodeGroup.CodeGroupTypes.ValueSet, "http://test.valueset");
614+
615+
Assert.NotNull(codeGroup);
616+
Assert.Equal("test-vs", codeGroup.Id);
617+
var codes = codeGroup.Codes["http://test.system"];
618+
Assert.Equal(2, codes.Count);
619+
Assert.Equal(CodeStatus.Active, ((ValueSetCode)codes[0]).Status);
620+
Assert.Equal(CodeStatus.Inactive, ((ValueSetCode)codes[1]).Status);
621+
}
622+
623+
[Fact]
624+
public async Task LoadCache_ValueSetBlankStatus_DefaultsToActive()
625+
{
626+
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
627+
var mockConfig = new Mock<IOptions<TerminologyConfig>>();
628+
mockConfig.Setup(x => x.Value).Returns(_config);
629+
630+
var directoryFiles = new Dictionary<string, string[]>
631+
{
632+
["/test/path/vs"] = new[] { "vs.json", "vs.csv" }
633+
};
634+
var fileContents = new Dictionary<string, string>
635+
{
636+
["vs.json"] = "{ \"resourceType\": \"ValueSet\", \"id\": \"test-vs\", " +
637+
"\"url\": \"http://test.valueset\", \"version\": \"1.0\" }",
638+
// Second row has a blank status column, which should default to Active.
639+
["vs.csv"] = "system,code,display,status\r\n" +
640+
"http://test.system,123,Test Display,Inactive\r\n" +
641+
"http://test.system,456,Another Display,\r\n"
642+
};
643+
644+
var service = new TestableCodeGroupCacheService(
645+
_loggerMock.Object, memoryCache, mockConfig.Object, directoryFiles, fileContents);
646+
647+
// Act
648+
await service.LoadCache();
649+
650+
// Assert - the blank-status member is loaded as an Active ValueSetCode.
651+
var codeGroup = service.GetCodeGroup(
652+
CodeGroup.CodeGroupTypes.ValueSet, "http://test.valueset");
653+
654+
Assert.NotNull(codeGroup);
655+
var codes = codeGroup.Codes["http://test.system"];
656+
Assert.Equal(2, codes.Count);
657+
Assert.Equal("456", codes[1].Value);
658+
Assert.Equal(CodeStatus.Active, ((ValueSetCode)codes[1]).Status);
659+
}
660+
661+
[Fact]
662+
public async Task LoadCache_ValueSetNoStatusColumn_LoadsPlainCode()
663+
{
664+
// A three-column value set file has no membership status, so its members are plain Code
665+
// instances (not ValueSetCode) and fall back to the code system status when validated.
666+
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
667+
var mockConfig = new Mock<IOptions<TerminologyConfig>>();
668+
mockConfig.Setup(x => x.Value).Returns(_config);
669+
670+
var directoryFiles = new Dictionary<string, string[]>
671+
{
672+
["/test/path/vs"] = new[] { "vs.json", "vs.csv" }
673+
};
674+
var fileContents = new Dictionary<string, string>
675+
{
676+
["vs.json"] = "{ \"resourceType\": \"ValueSet\", \"id\": \"test-vs\", " +
677+
"\"url\": \"http://test.valueset\", \"version\": \"1.0\" }",
678+
["vs.csv"] = "system,code,display\r\n" +
679+
"http://test.system,123,Test Display\r\n" +
680+
"http://test.system,456,Another Display\r\n"
681+
};
682+
683+
var service = new TestableCodeGroupCacheService(
684+
_loggerMock.Object, memoryCache, mockConfig.Object, directoryFiles, fileContents);
685+
686+
// Act
687+
await service.LoadCache();
688+
689+
// Assert - members are plain Code (no membership status), not ValueSetCode.
690+
var codeGroup = service.GetCodeGroup(
691+
CodeGroup.CodeGroupTypes.ValueSet, "http://test.valueset");
692+
693+
Assert.NotNull(codeGroup);
694+
var codes = codeGroup.Codes["http://test.system"];
695+
Assert.Equal(2, codes.Count);
696+
Assert.All(codes, code => Assert.Equal(
697+
typeof(LantanaGroup.Link.Terminology.Application.Models.Code), code.GetType()));
698+
}
699+
700+
[Fact]
701+
public async Task LoadCache_ValueSetMixedCaseStatus_ParsesCaseInsensitively()
702+
{
703+
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
704+
var mockConfig = new Mock<IOptions<TerminologyConfig>>();
705+
mockConfig.Setup(x => x.Value).Returns(_config);
706+
707+
var directoryFiles = new Dictionary<string, string[]>
708+
{
709+
["/test/path/vs"] = new[] { "vs.json", "vs.csv" }
710+
};
711+
var fileContents = new Dictionary<string, string>
712+
{
713+
["vs.json"] = "{ \"resourceType\": \"ValueSet\", \"id\": \"test-vs\", " +
714+
"\"url\": \"http://test.valueset\", \"version\": \"1.0\" }",
715+
["vs.csv"] = "system,code,display,status\r\n" +
716+
"http://test.system,123,Test Display,active\r\n" +
717+
"http://test.system,456,Another Display,INACTIVE\r\n" +
718+
"http://test.system,789,Third Display,Inactive\r\n"
719+
};
720+
721+
var service = new TestableCodeGroupCacheService(
722+
_loggerMock.Object, memoryCache, mockConfig.Object, directoryFiles, fileContents);
723+
724+
// Act
725+
await service.LoadCache();
726+
727+
// Assert - lowercase/uppercase/mixed-case status all load and normalize correctly.
728+
var codeGroup = service.GetCodeGroup(
729+
CodeGroup.CodeGroupTypes.ValueSet, "http://test.valueset");
730+
731+
Assert.NotNull(codeGroup);
732+
var codes = codeGroup.Codes["http://test.system"];
733+
Assert.Equal(3, codes.Count);
734+
Assert.Equal(CodeStatus.Active, ((ValueSetCode)codes[0]).Status);
735+
Assert.Equal(CodeStatus.Inactive, ((ValueSetCode)codes[1]).Status);
736+
Assert.Equal(CodeStatus.Inactive, ((ValueSetCode)codes[2]).Status);
737+
}
738+
581739
// Loads two versions of the same code group into a real cache. Defaults to "4.0.9" and
582740
// "4.0.10", where string ordering would wrongly rank "4.0.9" above "4.0.10" but semantic
583741
// ordering ranks "4.0.10" as the latest. Callers can supply other version strings (including

DotNet/ServiceTests/UnitTests/Terminology/Services/FhirServiceTests.cs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,99 @@ public void ValidateCodeInValueSet_MemberAbsentFromCodeSystem_ReturnsActiveNoIss
460460
Assert.Null(result.Parameter.FirstOrDefault(p => p.Name == "issues"));
461461
}
462462

463+
[Fact]
464+
public void ValidateCodeInValueSet_ValueSetCodeInactive_OverridesActiveCodeSystem_ReturnsInactiveIssue()
465+
{
466+
// Arrange - the member carries its own inactive membership status (a 4-column value set file).
467+
// Even though the CodeSystem marks the code Active, the value set membership status wins.
468+
var valueSetId = "test-vs-member-inactive";
469+
var code = "member-inactive-code";
470+
var system = "http://test.system";
471+
var display = "Member Inactive Code";
472+
473+
var valueSetGroup = new CodeGroup
474+
{
475+
Id = valueSetId,
476+
Type = CodeGroup.CodeGroupTypes.ValueSet,
477+
Codes = new Dictionary<string, List<Code>>
478+
{
479+
{ system, new List<Code> { new ValueSetCode { Value = code, Display = display, Status = CodeStatus.Inactive } } }
480+
}
481+
};
482+
// CodeSystem says Active; it must NOT be consulted because the membership status is authoritative.
483+
var codeSystemGroup = new CodeGroup
484+
{
485+
Url = system,
486+
Type = CodeGroup.CodeGroupTypes.CodeSystem,
487+
Codes = new Dictionary<string, List<Code>>
488+
{
489+
{ system, new List<Code> { new CodeSystemCode { Value = code, Display = display, Status = CodeStatus.Active } } }
490+
}
491+
};
492+
493+
_mockCacheService
494+
.Setup(x => x.GetCodeGroupById(CodeGroup.CodeGroupTypes.ValueSet, valueSetId, It.IsAny<string>()))
495+
.Returns(valueSetGroup);
496+
_mockCacheService
497+
.Setup(x => x.GetCodeGroup(CodeGroup.CodeGroupTypes.CodeSystem, system, It.IsAny<string>()))
498+
.Returns(codeSystemGroup);
499+
500+
// Act
501+
var result = _service.ValidateCodeInValueSet(null, valueSetId, system, code, display, null);
502+
503+
// Assert - membership status makes it inactive despite the active CodeSystem.
504+
Assert.True(result.GetSingleValue<FhirBoolean>("result")?.Value);
505+
var issuesParameter = result.Parameter.FirstOrDefault(p => p.Name == "issues");
506+
Assert.NotNull(issuesParameter);
507+
var outcome = Assert.IsType<OperationOutcome>(issuesParameter.Resource);
508+
Assert.Equal("Code is inactive.", Assert.Single(outcome.Issue).Details?.Text);
509+
}
510+
511+
[Fact]
512+
public void ValidateCodeInValueSet_ValueSetCodeActive_OverridesInactiveCodeSystem_ReturnsNoIssue()
513+
{
514+
// Arrange - the member carries its own active membership status; the CodeSystem is not consulted,
515+
// so an inactive CodeSystem status does not make an active value set member inactive.
516+
var valueSetId = "test-vs-member-active";
517+
var code = "member-active-code";
518+
var system = "http://test.system";
519+
var display = "Member Active Code";
520+
521+
var valueSetGroup = new CodeGroup
522+
{
523+
Id = valueSetId,
524+
Type = CodeGroup.CodeGroupTypes.ValueSet,
525+
Codes = new Dictionary<string, List<Code>>
526+
{
527+
{ system, new List<Code> { new ValueSetCode { Value = code, Display = display, Status = CodeStatus.Active } } }
528+
}
529+
};
530+
// CodeSystem says Inactive; it must NOT be consulted because the membership status is authoritative.
531+
var codeSystemGroup = new CodeGroup
532+
{
533+
Url = system,
534+
Type = CodeGroup.CodeGroupTypes.CodeSystem,
535+
Codes = new Dictionary<string, List<Code>>
536+
{
537+
{ system, new List<Code> { new CodeSystemCode { Value = code, Display = display, Status = CodeStatus.Inactive } } }
538+
}
539+
};
540+
541+
_mockCacheService
542+
.Setup(x => x.GetCodeGroupById(CodeGroup.CodeGroupTypes.ValueSet, valueSetId, It.IsAny<string>()))
543+
.Returns(valueSetGroup);
544+
_mockCacheService
545+
.Setup(x => x.GetCodeGroup(CodeGroup.CodeGroupTypes.CodeSystem, system, It.IsAny<string>()))
546+
.Returns(codeSystemGroup);
547+
548+
// Act
549+
var result = _service.ValidateCodeInValueSet(null, valueSetId, system, code, display, null);
550+
551+
// Assert - active membership status means no inactive issue, despite the inactive CodeSystem.
552+
Assert.True(result.GetSingleValue<FhirBoolean>("result")?.Value);
553+
Assert.Null(result.Parameter.FirstOrDefault(p => p.Name == "issues"));
554+
}
555+
463556
[Fact]
464557
public void ValidateCodeInValueSet_WithInvalidCode_ReturnsFalse()
465558
{

DotNet/Terminology/Application/Models/CsvValueSetRecord.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,13 @@ public class CsvValueSetRecord
2626
/// </summary>
2727
[Index(2)]
2828
public required string Display { get; set; }
29+
30+
/// <summary>
31+
/// Indicates the value set membership status of the code, Active or Inactive.
32+
/// This column is optional; files without it are treated as having no membership status.
33+
/// </summary>
34+
[Index(3)]
35+
[Default(CodeStatus.Active)]
36+
[EnumIgnoreCase]
37+
public CodeStatus Status { get; set; } = CodeStatus.Active;
2938
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace LantanaGroup.Link.Terminology.Application.Models;
2+
3+
/// <summary>
4+
/// Represents a value set member code that carries its own membership status.
5+
/// </summary>
6+
/// <remarks>
7+
/// Value set membership status is independent of the underlying code system status: an intensional
8+
/// value set is expanded from a code system, and a code can remain active in the code system yet be
9+
/// dropped from the value set's membership. When a value set member is loaded as a <see cref="ValueSetCode"/>
10+
/// its <see cref="Status"/> is authoritative and overrides the code system. Members loaded as a plain
11+
/// <see cref="Code"/> (a value set file with no status column) fall back to the code system status.
12+
/// </remarks>
13+
public class ValueSetCode : Code
14+
{
15+
/// <summary>
16+
/// Gets or sets the value set membership status of the code.
17+
/// </summary>
18+
public CodeStatus Status { get; set; } = CodeStatus.Active;
19+
}

DotNet/Terminology/Services/CodeGroupCacheService.cs

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -238,11 +238,16 @@ internal void ProcessValueSetCsv(CodeGroup codeGroup, CsvReader csv)
238238
csv.Read();
239239
csv.ReadHeader();
240240
var headers = csv.HeaderRecord;
241-
if (headers == null || headers.Length != 3)
241+
if (headers == null || headers.Length > 4 || headers.Length < 3)
242242
{
243-
throw new InvalidOperationException("ValueSet CSV must have exactly 3 columns: code, display, and system");
243+
throw new InvalidOperationException("ValueSet CSV must have 3 or 4 columns: system, code, display, and optionally status.");
244244
}
245245

246+
// A 4-column file carries value set membership status; a 3-column file does not.
247+
// Members loaded with membership status are authoritative; members without it fall back to
248+
// the code system status when validated (see FhirService.ResolveIsActive).
249+
bool hasStatusColumn = headers.Length == 4;
250+
246251
var records = csv.GetRecords<CsvValueSetRecord>();
247252
string? system = null;
248253
List<Code>? systemCodes = null;
@@ -277,11 +282,23 @@ internal void ProcessValueSetCsv(CodeGroup codeGroup, CsvReader csv)
277282
continue;
278283
}
279284

280-
systemCodes.Add(new Code
285+
if (hasStatusColumn)
281286
{
282-
Value = code,
283-
Display = display
284-
});
287+
systemCodes.Add(new ValueSetCode
288+
{
289+
Value = code,
290+
Display = display,
291+
Status = record.Status
292+
});
293+
}
294+
else
295+
{
296+
systemCodes.Add(new Code
297+
{
298+
Value = code,
299+
Display = display
300+
});
301+
}
285302
}
286303

287304
SetCodeGroup(codeGroup);
@@ -389,9 +406,10 @@ public async System.Threading.Tasks.Task LoadCache()
389406
using var reader = new StringReader(csvContent);
390407
var config = new CsvConfiguration(CultureInfo.InvariantCulture);
391408

392-
// CodeSystem CSVs have an optional trailing status column, so a missing field is
393-
// tolerated there. ValueSet keeps strict missing-field validation.
394-
if (codeGroup.Type == CodeGroup.CodeGroupTypes.CodeSystem)
409+
// Both CodeSystem and ValueSet CSVs have an optional trailing status column, so a
410+
// missing field is tolerated (a 3-column value set has no Status field to map).
411+
if (codeGroup.Type == CodeGroup.CodeGroupTypes.CodeSystem ||
412+
codeGroup.Type == CodeGroup.CodeGroupTypes.ValueSet)
395413
config.MissingFieldFound = null;
396414

397415
using var csv = new CsvReader(reader, config);

0 commit comments

Comments
 (0)