Skip to content

Commit 742a051

Browse files
authored
Merge pull request #83 from Ardenexal/feat/74-questionnaire-validation
feat(validation): Questionnaire/QuestionnaireResponse validation
2 parents bc84f29 + f458b7a commit 742a051

170 files changed

Lines changed: 5928 additions & 132 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@
132132
"test-ai-integration": "php scripts/ai-test-runner.php --testsuite=integration",
133133
"test-ai-fhir": "php scripts/ai-test-runner.php --testsuite=fhir",
134134
"test-ai-fhirpath-spec": "php scripts/ai-test-runner.php --testsuite=fhirpath-spec --functional --max-batch-size=50",
135+
"test-ai-questionnaire-spec": "php scripts/ai-test-runner.php --testsuite=questionnaire-spec",
135136
"test": "php ./vendor/phpunit/phpunit/phpunit --testdox --testdox-summary",
136137
"test-unit": "php ./vendor/bin/paratest --testsuite=unit",
137138
"test-coverage": "php ./vendor/bin/paratest --coverage-html coverage --coverage-clover coverage/clover.xml",

phpunit.dist.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
<directory>src/Component/Validation/tests/Integration</directory>
3737
<!-- The FHIRPath specification cases run via the dedicated fhirpath-spec suite -->
3838
<exclude>src/Component/FHIRPath/tests/Integration/FHIRPathSpecificationTest.php</exclude>
39+
<!-- The Questionnaire conformance cases run via the dedicated questionnaire-spec suite -->
40+
<exclude>src/Component/Validation/tests/Integration/FHIRQuestionnaireConformanceTest.php</exclude>
3941
</testsuite>
4042
<!-- Per-module integration suites: CI runs these in parallel matrix legs.
4143
Together they must cover exactly the same tests as the "integration" suite above. -->
@@ -55,10 +57,15 @@
5557
</testsuite>
5658
<testsuite name="integration-validation">
5759
<directory>src/Component/Validation/tests/Integration</directory>
60+
<!-- The Questionnaire conformance cases run via the dedicated questionnaire-spec suite -->
61+
<exclude>src/Component/Validation/tests/Integration/FHIRQuestionnaireConformanceTest.php</exclude>
5862
</testsuite>
5963
<testsuite name="fhirpath-spec">
6064
<file>src/Component/FHIRPath/tests/Integration/FHIRPathSpecificationTest.php</file>
6165
</testsuite>
66+
<testsuite name="questionnaire-spec">
67+
<file>src/Component/Validation/tests/Integration/FHIRQuestionnaireConformanceTest.php</file>
68+
</testsuite>
6269
</testsuites>
6370

6471
<source ignoreSuppressionOfDeprecations="true"

src/Component/CodeGeneration/src/Generator/FHIRExtensionGenerator.php

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -298,19 +298,28 @@ private function buildMultiTypeValueConstructor(
298298
string $url,
299299
?ErrorCollector $errorCollector = null,
300300
): void {
301-
// Union of all allowed PHP types
302-
$phpTypes = [];
303-
$fhirTypes = [];
301+
// Union of all allowed PHP types, plus the per-variant metadata the normalizer needs to
302+
// resolve an incoming value[x] element (e.g. valueDate) back onto the choice property.
303+
// Without variants the choice index is empty and the value is silently dropped on
304+
// deserialization (see findChoicePropertyByKey).
305+
$phpTypes = [];
306+
$variants = [];
304307

305308
foreach ($types as $t) {
306-
$code = $t['code'];
307-
$phpTypes[] = $this->resolvePhpType($code, $version, $context, $errorCollector);
308-
$fhirTypes[] = $code;
309+
$code = $t['code'];
310+
$phpType = $this->resolvePhpType($code, $version, $context, $errorCollector);
311+
$phpTypes[] = $phpType;
309312

310-
$phpType = end($phpTypes);
311313
if ($phpType !== 'bool' && $phpType !== 'int' && $phpType !== 'string') {
312314
$namespace->addUse(ltrim($phpType, '\\'));
313315
}
316+
317+
$variants[] = [
318+
'fhirType' => $code,
319+
'propertyKind' => $this->resolvePropertyKindFromCode($code),
320+
'phpType' => ltrim($phpType, '\\'),
321+
'jsonKey' => 'value' . u($code)->pascal()->toString(),
322+
];
314323
}
315324

316325
$unionType = implode('|', array_unique($phpTypes)) . '|null';
@@ -328,6 +337,7 @@ private function buildMultiTypeValueConstructor(
328337
'fhirType' => 'choice',
329338
'propertyKind' => 'choice',
330339
'isChoice' => true,
340+
'variants' => $variants,
331341
]);
332342
$param->addComment("@var {$unionType} value Value of extension");
333343

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
{
2+
"resourceType": "StructureDefinition",
3+
"id": "test-min-value",
4+
"url": "http://hl7.org/fhir/StructureDefinition/test-min-value",
5+
"version": "4.0.1",
6+
"name": "test-min-value",
7+
"title": "Minimum Value",
8+
"status": "active",
9+
"publisher": "HL7",
10+
"description": "A constraint extension whose value[x] permits several types, mirroring minValue.",
11+
"fhirVersion": "4.0.1",
12+
"kind": "complex-type",
13+
"abstract": false,
14+
"type": "Extension",
15+
"derivation": "constraint",
16+
"baseDefinition": "http://hl7.org/fhir/StructureDefinition/Extension",
17+
"snapshot": {
18+
"element": [
19+
{
20+
"id": "Extension",
21+
"path": "Extension",
22+
"short": "Minimum Value",
23+
"definition": "The inclusive lower bound on the range of allowed values.",
24+
"min": 0,
25+
"max": "1"
26+
},
27+
{
28+
"id": "Extension.id",
29+
"path": "Extension.id",
30+
"representation": ["xmlAttr"],
31+
"min": 0,
32+
"max": "1",
33+
"type": [{"code": "http://hl7.org/fhirpath/System.String"}]
34+
},
35+
{
36+
"id": "Extension.extension",
37+
"path": "Extension.extension",
38+
"max": "0"
39+
},
40+
{
41+
"id": "Extension.url",
42+
"path": "Extension.url",
43+
"representation": ["xmlAttr"],
44+
"min": 1,
45+
"max": "1",
46+
"fixedUri": "http://hl7.org/fhir/StructureDefinition/test-min-value"
47+
},
48+
{
49+
"id": "Extension.value[x]",
50+
"path": "Extension.value[x]",
51+
"short": "Value of extension",
52+
"min": 1,
53+
"max": "1",
54+
"type": [
55+
{"code": "date"},
56+
{"code": "dateTime"},
57+
{"code": "integer"},
58+
{"code": "Period"}
59+
]
60+
}
61+
]
62+
}
63+
}

src/Component/CodeGeneration/tests/Unit/Generator/FHIRExtensionGeneratorTest.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,60 @@ public function testSimpleExtensionConstructorBodyBakesInUrl(): void
151151
self::assertStringContainsString('parent::__construct', $body);
152152
}
153153

154+
// -----------------------------------------------------------------
155+
// Multi-type value[x] extension (minValue-style: choice of value types)
156+
// -----------------------------------------------------------------
157+
158+
public function testMultiTypeValueExtensionHasChoiceValueParameter(): void
159+
{
160+
$sd = $this->loadFixture('MultiTypeValueExtension.json');
161+
$class = $this->generator->generate($sd, 'R4', $this->context, $this->namespace);
162+
163+
$params = $class->getMethod('__construct')->getParameters();
164+
self::assertArrayHasKey('value', $params);
165+
166+
$type = (string) $params['value']->getType();
167+
self::assertStringContainsString('DatePrimitive', $type);
168+
self::assertStringContainsString('int', $type);
169+
self::assertStringContainsString('Period', $type);
170+
}
171+
172+
public function testMultiTypeValueExtensionEmitsChoiceVariants(): void
173+
{
174+
$sd = $this->loadFixture('MultiTypeValueExtension.json');
175+
$class = $this->generator->generate($sd, 'R4', $this->context, $this->namespace);
176+
177+
$valueParam = $class->getMethod('__construct')->getParameters()['value'] ?? null;
178+
self::assertNotNull($valueParam);
179+
180+
$variants = null;
181+
foreach ($valueParam->getAttributes() as $attribute) {
182+
if (str_contains($attribute->getName(), 'FhirProperty')) {
183+
$args = $attribute->getArguments();
184+
self::assertTrue($args['isChoice'] ?? false, 'choice value[x] param must set isChoice');
185+
$variants = $args['variants'] ?? null;
186+
}
187+
}
188+
189+
// Without variants the normalizer's choice index is empty and the value is dropped on
190+
// deserialization — the whole point of this regression test.
191+
self::assertIsArray($variants);
192+
self::assertNotEmpty($variants);
193+
194+
$jsonKeys = array_column($variants, 'jsonKey');
195+
self::assertContains('valueDate', $jsonKeys);
196+
self::assertContains('valueDateTime', $jsonKeys);
197+
self::assertContains('valueInteger', $jsonKeys);
198+
self::assertContains('valuePeriod', $jsonKeys);
199+
200+
foreach ($variants as $variant) {
201+
self::assertArrayHasKey('fhirType', $variant);
202+
self::assertArrayHasKey('propertyKind', $variant);
203+
self::assertArrayHasKey('phpType', $variant);
204+
self::assertArrayHasKey('jsonKey', $variant);
205+
}
206+
}
207+
154208
// -----------------------------------------------------------------
155209
// Complex extension (us-core-race: sub-extension slices)
156210
// -----------------------------------------------------------------

src/Component/FHIRPath/src/Evaluator/ComparisonService.php

Lines changed: 5 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Ardenexal\FHIRTools\Component\FHIRPath\Exception\EvaluationException;
88
use Ardenexal\FHIRTools\Component\FHIRPath\Type\FHIRPathDecimal;
99
use Ardenexal\FHIRTools\Component\FHIRPath\Type\FHIRPathTemporalTypeInterface;
10+
use Ardenexal\FHIRTools\Component\Metadata\Ucum\UcumConverter;
1011

1112
/**
1213
* Handles FHIRPath comparison operations with collection semantics and precision-aware temporal comparisons.
@@ -75,26 +76,9 @@ final class ComparisonService
7576
'milliseconds' => 'ms',
7677
];
7778

78-
private const UCUM_CONVERSIONS = [
79-
'1' => ['base' => '1', 'factor' => 1.0],
80-
'kg' => ['base' => 'kg', 'factor' => 1.0],
81-
'g' => ['base' => 'kg', 'factor' => 0.001],
82-
'mg' => ['base' => 'kg', 'factor' => 0.000001],
83-
'[lb_av]' => ['base' => 'kg', 'factor' => 0.45359237],
84-
'm' => ['base' => 'm', 'factor' => 1.0],
85-
'cm' => ['base' => 'm', 'factor' => 0.01],
86-
'mm' => ['base' => 'm', 'factor' => 0.001],
87-
'km' => ['base' => 'm', 'factor' => 1000.0],
88-
'[in_i]' => ['base' => 'm', 'factor' => 0.0254],
89-
'[ft_i]' => ['base' => 'm', 'factor' => 0.3048],
90-
'L' => ['base' => 'L', 'factor' => 1.0],
91-
'mL' => ['base' => 'L', 'factor' => 0.001],
92-
'wk' => ['base' => 'd', 'factor' => 7.0],
93-
'd' => ['base' => 'd', 'factor' => 1.0],
94-
];
95-
9679
public function __construct(
97-
private readonly FHIRPathEvaluator $evaluator
80+
private readonly FHIRPathEvaluator $evaluator,
81+
private readonly UcumConverter $ucum = new UcumConverter(),
9882
) {
9983
}
10084

@@ -1014,8 +998,8 @@ private function compareQuantityValues(array $left, array $right): ?float
1014998
return $left['value'] * $leftSeconds - $right['value'] * $rightSeconds;
1015999
}
10161000

1017-
$leftConverted = $this->convertUcumToBase($leftUnit, $left['value']);
1018-
$rightConverted = $this->convertUcumToBase($rightUnit, $right['value']);
1001+
$leftConverted = $this->ucum->toBase($leftUnit, $left['value']);
1002+
$rightConverted = $this->ucum->toBase($rightUnit, $right['value']);
10191003

10201004
if ($leftConverted === null || $rightConverted === null) {
10211005
return null;
@@ -1028,22 +1012,6 @@ private function compareQuantityValues(array $left, array $right): ?float
10281012
return $leftConverted['value'] - $rightConverted['value'];
10291013
}
10301014

1031-
/**
1032-
* @return array{base: string, value: float}|null
1033-
*/
1034-
private function convertUcumToBase(string $unit, float $value): ?array
1035-
{
1036-
$definition = self::UCUM_CONVERSIONS[$unit] ?? null;
1037-
if ($definition === null) {
1038-
return null;
1039-
}
1040-
1041-
return [
1042-
'base' => $definition['base'],
1043-
'value' => $value * $definition['factor'],
1044-
];
1045-
}
1046-
10471015
/**
10481016
* Truncate a normalized date/time string to the given precision level.
10491017
*
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Ardenexal\FHIRTools\Component\Metadata\Ucum;
6+
7+
/**
8+
* Minimal UCUM unit conversion shared across components.
9+
*
10+
* Converts a recognised UCUM code to a canonical base unit so two quantities of the same
11+
* physical dimension can be compared. This is intentionally a small, hand-maintained table —
12+
* NOT a full UCUM engine — covering the units the test corpus and FHIRPath comparisons exercise
13+
* (mass, length, volume, and the day/week duration pair). Unknown codes return null, which callers
14+
* treat as "not commensurable / cannot compare".
15+
*
16+
* Calendar-duration keyword semantics (year/month approximations, the FHIRPath month↔year
17+
* incomparability rule) deliberately live in the FHIRPath ComparisonService, not here: those are
18+
* FHIRPath temporal rules, not UCUM unit conversion.
19+
*/
20+
final class UcumConverter
21+
{
22+
public const string SYSTEM_URL = 'http://unitsofmeasure.org';
23+
24+
/**
25+
* UCUM code => canonical base unit + multiplicative factor to that base.
26+
*/
27+
private const array CONVERSIONS = [
28+
'1' => ['base' => '1', 'factor' => 1.0],
29+
'kg' => ['base' => 'kg', 'factor' => 1.0],
30+
'g' => ['base' => 'kg', 'factor' => 0.001],
31+
'mg' => ['base' => 'kg', 'factor' => 0.000001],
32+
'[lb_av]' => ['base' => 'kg', 'factor' => 0.45359237],
33+
'm' => ['base' => 'm', 'factor' => 1.0],
34+
'cm' => ['base' => 'm', 'factor' => 0.01],
35+
'mm' => ['base' => 'm', 'factor' => 0.001],
36+
'km' => ['base' => 'm', 'factor' => 1000.0],
37+
'[in_i]' => ['base' => 'm', 'factor' => 0.0254],
38+
'[ft_i]' => ['base' => 'm', 'factor' => 0.3048],
39+
'[mi_i]' => ['base' => 'm', 'factor' => 1609.344],
40+
'L' => ['base' => 'L', 'factor' => 1.0],
41+
'mL' => ['base' => 'L', 'factor' => 0.001],
42+
'wk' => ['base' => 'd', 'factor' => 7.0],
43+
'd' => ['base' => 'd', 'factor' => 1.0],
44+
];
45+
46+
/**
47+
* Convert a value in the given UCUM code to its canonical base unit.
48+
*
49+
* Returns null when the code is not in the conversion table.
50+
*
51+
* @return array{base: string, value: float}|null
52+
*/
53+
public function toBase(string $code, float $value): ?array
54+
{
55+
$definition = self::CONVERSIONS[$code] ?? null;
56+
if ($definition === null) {
57+
return null;
58+
}
59+
60+
return [
61+
'base' => $definition['base'],
62+
'value' => $value * $definition['factor'],
63+
];
64+
}
65+
66+
/**
67+
* Whether the given UCUM code is one this converter can convert (i.e. present in the table).
68+
*
69+
* Lets callers distinguish "unit not recognised by this minimal converter" from "units are of
70+
* different physical dimensions" when {@see compare()} returns null — the two are not the same
71+
* diagnosis, even though both block a comparison.
72+
*/
73+
public function knows(string $code): bool
74+
{
75+
return isset(self::CONVERSIONS[$code]);
76+
}
77+
78+
/**
79+
* Whether two UCUM codes belong to the same physical dimension (both recognised and sharing a
80+
* canonical base unit), i.e. their quantities can be ordered against one another.
81+
*/
82+
public function areCommensurable(string $codeA, string $codeB): bool
83+
{
84+
$a = self::CONVERSIONS[$codeA] ?? null;
85+
$b = self::CONVERSIONS[$codeB] ?? null;
86+
87+
return $a !== null && $b !== null && $a['base'] === $b['base'];
88+
}
89+
90+
/**
91+
* Compare two quantities by their UCUM codes after converting to a common base unit.
92+
*
93+
* Returns the sign of (a - b) as -1, 0, or 1, or null when the units are not commensurable
94+
* (different dimension, or either code unrecognised) and therefore cannot be ordered.
95+
*/
96+
public function compare(float $valueA, string $codeA, float $valueB, string $codeB): ?int
97+
{
98+
$a = $this->toBase($codeA, $valueA);
99+
$b = $this->toBase($codeB, $valueB);
100+
101+
if ($a === null || $b === null || $a['base'] !== $b['base']) {
102+
return null;
103+
}
104+
105+
return $a['value'] <=> $b['value'];
106+
}
107+
}

0 commit comments

Comments
 (0)