Skip to content

Commit 67bd9c2

Browse files
committed
Update Query Plan Builder, Update Query Plan Simulator
1 parent 857219b commit 67bd9c2

2 files changed

Lines changed: 125 additions & 28 deletions

File tree

DotNet/Automation.UI/Services/RunExecutor.cs

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,14 @@ public async Task ExecuteAsync(
114114

115115
await fhirDataLoader.WaitForServerAsync(output);
116116

117+
// Resolve the query plan template early so the acquisition simulator uses the
118+
// same plan the scenario is configured with (not always the built-in default).
119+
var queryPlanResolution = await _queryPlanResolver.ResolveAsync(state.Options.QueryPlanTemplateId, cancellationToken);
120+
var queryPlanInput = queryPlanResolution.Input;
121+
var effectiveQueryPlan = queryPlanInput ?? QueryPlanDefaults.GetDefaultAsInput();
122+
if (!string.IsNullOrWhiteSpace(queryPlanResolution.Name))
123+
output.WriteLine($"Using query plan: {queryPlanResolution.Name}");
124+
117125
if (state.Options.PatientProfiles is { Count: > 0 }
118126
|| state.Options.ImportedPatientIds.Count > 0
119127
|| state.Options.ImportedPatientBundles.Count > 0)
@@ -178,7 +186,7 @@ public async Task ExecuteAsync(
178186
generationConfig,
179187
acquisitionSimulation: new FhirGenerationPipeline.AcquisitionSimulationConfig
180188
{
181-
QueryPlan = QueryPlanDefaults.GetDefaultAsInput(),
189+
QueryPlan = effectiveQueryPlan,
182190
ClinicalPeriodStart = scenarioConfig.StartDate,
183191
ClinicalPeriodEnd = scenarioConfig.EndDate
184192
},
@@ -224,19 +232,12 @@ public async Task ExecuteAsync(
224232
var facilityId = state.RunId.ToString();
225233
state.FacilityId = facilityId;
226234

227-
// Resolve the query plan template (null = use built-in defaults).
228-
var queryPlanResolution = await _queryPlanResolver.ResolveAsync(state.Options.QueryPlanTemplateId, cancellationToken);
229-
var queryPlanInput = queryPlanResolution.Input;
230-
if (!string.IsNullOrWhiteSpace(queryPlanResolution.Name))
231-
output.WriteLine($"Using query plan: {queryPlanResolution.Name}");
232-
233235
// Finalize manifest metadata now that we have measure IDs and query plan.
234236
if (generationManifest != null)
235237
{
236238
generationManifest.MeasureIds = measureIds;
237-
var effectiveQueryPlanInput = queryPlanInput ?? QueryPlanDefaults.GetDefaultAsInput();
238-
generationManifest.AcquiredResourceTypes = QueryPlanDefaults.GetAcquiredResourceTypes(effectiveQueryPlanInput);
239-
generationManifest.ParameterQueryResourceTypes = QueryPlanDefaults.GetParameterQueryResourceTypes(effectiveQueryPlanInput);
239+
generationManifest.AcquiredResourceTypes = QueryPlanDefaults.GetAcquiredResourceTypes(effectiveQueryPlan);
240+
generationManifest.ParameterQueryResourceTypes = QueryPlanDefaults.GetParameterQueryResourceTypes(effectiveQueryPlan);
240241
generationManifest.CqlReferencedResourceTypes = CqlResourceTypeExtractor.ExtractForMeasures(state.Options.SelectedMeasures);
241242

242243
// Persist a lightweight manifest snapshot for the UI.

DotNet/Automation/Generation/QueryPlanAcquisitionSimulator.cs

Lines changed: 114 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -151,14 +151,20 @@ private static bool MatchesParameterQuery(
151151
}
152152
}
153153

154-
if (string.Equals(p.ParameterType, "Literal", StringComparison.OrdinalIgnoreCase)
155-
&& string.Equals(p.Name, "category", StringComparison.OrdinalIgnoreCase))
154+
if (string.Equals(p.ParameterType, "Literal", StringComparison.OrdinalIgnoreCase))
156155
{
157-
if (!MatchesLiteralCategory(resource.Resource, p.Literal))
156+
// Skip FHIR pagination/control parameters — they are not filters.
157+
if (string.Equals(p.Name, "_count", StringComparison.OrdinalIgnoreCase)
158+
|| string.Equals(p.Name, "_sort", StringComparison.OrdinalIgnoreCase)
159+
|| string.Equals(p.Name, "_include", StringComparison.OrdinalIgnoreCase)
160+
|| string.Equals(p.Name, "_revinclude", StringComparison.OrdinalIgnoreCase))
161+
continue;
162+
163+
if (!MatchesLiteralFilter(resource.Resource, p.Name, p.Literal))
158164
return false;
159165
}
160166

161-
if (string.Equals(p.Name, "date", StringComparison.OrdinalIgnoreCase)
167+
if (IsTemporalSearchParam(p.Name)
162168
&& string.Equals(p.ParameterType, "Variable", StringComparison.OrdinalIgnoreCase))
163169
{
164170
var isGe = p.Format?.StartsWith("ge", StringComparison.OrdinalIgnoreCase) == true;
@@ -176,7 +182,7 @@ private static bool MatchesParameterQuery(
176182
// ge{S}: resource.End >= S (resource extends into [S, +inf))
177183
// le{E}: resource.Start <= E (resource extends into (-inf, E])
178184
// For instant types (e.g. authoredOn) start == end.
179-
if (!TryGetResourceDateRange(resource.ResourceType, resource.Resource,
185+
if (!TryGetResourceDateRangeForParam(p.Name, resource.ResourceType, resource.Resource,
180186
out var resourceStart, out var resourceEnd))
181187
{
182188
// Fail closed: the query has a date filter and we don't recognize the
@@ -186,8 +192,8 @@ private static bool MatchesParameterQuery(
186192
{
187193
output.WriteLine(
188194
$" [simulator] WARNING: {resource.Key} has no recognized date field for the " +
189-
$"'{resource.ResourceType}' Parameter query 'date' filter; excluding from " +
190-
"predicted-acquired set (fail-closed). Extend TryGetResourceDateRange to model this shape.");
195+
$"'{resource.ResourceType}' Parameter query '{p.Name}' filter; excluding from " +
196+
"predicted-acquired set (fail-closed). Extend TryGetResourceDateRangeForParam to model this shape.");
191197
}
192198
return false;
193199
}
@@ -283,36 +289,126 @@ private static bool TryGetReferencedResourceId(JsonElement resource, string reso
283289
return false;
284290
}
285291

286-
private static bool MatchesLiteralCategory(JsonElement resource, string? literal)
292+
/// <summary>
293+
/// Generic literal filter. Handles:
294+
/// - CodeableConcept arrays (e.g. <c>category</c>) — matches if any coding.code is in the accepted set.
295+
/// - Simple code/string fields (e.g. <c>intent</c>, <c>status</c>) — matches if the field value is in the accepted set.
296+
/// </summary>
297+
private static bool MatchesLiteralFilter(JsonElement resource, string paramName, string? literal)
287298
{
288299
if (string.IsNullOrWhiteSpace(literal))
289300
return true;
290301

291302
var accepted = literal.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
292303
.ToHashSet(StringComparer.OrdinalIgnoreCase);
293304

294-
if (!resource.TryGetProperty("category", out var categories) || categories.ValueKind != JsonValueKind.Array)
305+
if (!resource.TryGetProperty(paramName, out var fieldValue))
295306
return false;
296307

297-
foreach (var category in categories.EnumerateArray())
308+
// Case 1: Simple string/code field (e.g. intent, status)
309+
if (fieldValue.ValueKind == JsonValueKind.String)
298310
{
299-
if (!category.TryGetProperty("coding", out var codingArray) || codingArray.ValueKind != JsonValueKind.Array)
300-
continue;
311+
var value = fieldValue.GetString();
312+
return !string.IsNullOrWhiteSpace(value) && accepted.Contains(value);
313+
}
301314

302-
foreach (var coding in codingArray.EnumerateArray())
315+
// Case 2: CodeableConcept array (e.g. category)
316+
if (fieldValue.ValueKind == JsonValueKind.Array)
317+
{
318+
foreach (var item in fieldValue.EnumerateArray())
303319
{
304-
if (!coding.TryGetProperty("code", out var codeProp) || codeProp.ValueKind != JsonValueKind.String)
305-
continue;
320+
if (item.TryGetProperty("coding", out var codingArray) && codingArray.ValueKind == JsonValueKind.Array)
321+
{
322+
foreach (var coding in codingArray.EnumerateArray())
323+
{
324+
if (coding.TryGetProperty("code", out var codeProp)
325+
&& codeProp.ValueKind == JsonValueKind.String)
326+
{
327+
var code = codeProp.GetString();
328+
if (!string.IsNullOrWhiteSpace(code) && accepted.Contains(code))
329+
return true;
330+
}
331+
}
332+
}
333+
}
334+
return false;
335+
}
306336

307-
var code = codeProp.GetString();
308-
if (!string.IsNullOrWhiteSpace(code) && accepted.Contains(code))
309-
return true;
337+
// Case 3: Single CodeableConcept object (e.g. code)
338+
if (fieldValue.ValueKind == JsonValueKind.Object
339+
&& fieldValue.TryGetProperty("coding", out var singleCodingArray)
340+
&& singleCodingArray.ValueKind == JsonValueKind.Array)
341+
{
342+
foreach (var coding in singleCodingArray.EnumerateArray())
343+
{
344+
if (coding.TryGetProperty("code", out var codeProp)
345+
&& codeProp.ValueKind == JsonValueKind.String)
346+
{
347+
var code = codeProp.GetString();
348+
if (!string.IsNullOrWhiteSpace(code) && accepted.Contains(code))
349+
return true;
350+
}
310351
}
352+
return false;
311353
}
312354

313355
return false;
314356
}
315357

358+
/// <summary>
359+
/// Determines whether a FHIR search parameter name is a temporal (date-like) filter.
360+
/// Recognized names: <c>date</c>, <c>authoredon</c>, <c>authored</c>, <c>issued</c>,
361+
/// <c>effective</c>, <c>onset-date</c>, <c>recorded-date</c>.
362+
/// </summary>
363+
private static bool IsTemporalSearchParam(string paramName)
364+
=> string.Equals(paramName, "date", StringComparison.OrdinalIgnoreCase)
365+
|| string.Equals(paramName, "authoredon", StringComparison.OrdinalIgnoreCase)
366+
|| string.Equals(paramName, "authored", StringComparison.OrdinalIgnoreCase)
367+
|| string.Equals(paramName, "issued", StringComparison.OrdinalIgnoreCase)
368+
|| string.Equals(paramName, "effective", StringComparison.OrdinalIgnoreCase)
369+
|| string.Equals(paramName, "onset-date", StringComparison.OrdinalIgnoreCase)
370+
|| string.Equals(paramName, "recorded-date", StringComparison.OrdinalIgnoreCase);
371+
372+
/// <summary>
373+
/// Resolves the resource's date range for a given FHIR search parameter name.
374+
/// Named search parameters like <c>authoredon</c> map to specific fields regardless of
375+
/// what <c>TryGetResourceDateRange</c> would pick for the generic <c>date</c> param.
376+
/// </summary>
377+
private static bool TryGetResourceDateRangeForParam(string paramName, string resourceType,
378+
JsonElement resource, out DateTimeOffset start, out DateTimeOffset end)
379+
{
380+
// Named search parameters that map to specific fields.
381+
if (string.Equals(paramName, "authoredon", StringComparison.OrdinalIgnoreCase)
382+
|| string.Equals(paramName, "authored", StringComparison.OrdinalIgnoreCase))
383+
{
384+
return TryGetInstant(resource, "authoredOn", out start, out end);
385+
}
386+
387+
if (string.Equals(paramName, "issued", StringComparison.OrdinalIgnoreCase))
388+
{
389+
return TryGetInstant(resource, "issued", out start, out end);
390+
}
391+
392+
if (string.Equals(paramName, "effective", StringComparison.OrdinalIgnoreCase))
393+
{
394+
return TryGetEffective(resource, out start, out end);
395+
}
396+
397+
if (string.Equals(paramName, "onset-date", StringComparison.OrdinalIgnoreCase))
398+
{
399+
return TryGetInstant(resource, "onsetDateTime", out start, out end)
400+
|| TryGetPeriod(resource, "onsetPeriod", out start, out end);
401+
}
402+
403+
if (string.Equals(paramName, "recorded-date", StringComparison.OrdinalIgnoreCase))
404+
{
405+
return TryGetInstant(resource, "recordedDate", out start, out end);
406+
}
407+
408+
// Generic "date" → resource-type-aware resolution.
409+
return TryGetResourceDateRange(resourceType, resource, out start, out end);
410+
}
411+
316412
/// <summary>
317413
/// Returns the resource's date range (start, end) for FHIR <c>date</c> search-param
318414
/// matching with overlap semantics. For instant-style fields the start and end are

0 commit comments

Comments
 (0)