Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -517,10 +517,9 @@ private function buildClasses(OutputInterface $output, string $version, PhpNames
$targetNamespace->add($class);

match ($kind) {
'resource' => $resourceCount++,
'complex-type' => $dataTypeCount++,
'primitive-type' => $primitiveCount++,
default => null
'resource' => $resourceCount++,
'complex-type' => $dataTypeCount++,
default => $primitiveCount++,
};
} else {
$output->writeln("<error>Failed to generate class for {$name}</error>");
Expand Down
80 changes: 79 additions & 1 deletion src/Component/Sdc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,92 @@ This component implements the SDC operations:

- **`QuestionnaireResponse/$extract`** — implemented for observation-, definition-, and template-based
extraction (see below). Delivered by the `sdc-extract` feature plan.
- **`Questionnaire/$populate`** — delivered by the `sdc-populate` feature plan.
- **`Questionnaire/$populate`** — implemented for expression-based population (`launchContext` +
`initialExpression`, root/item `variable`, `itemPopulationContext` repeating groups) and
observation-based population (`observationLinkPeriod`), R4 / R4B / R5. Delivered by the
`sdc-populate` feature plan.

Shared prerequisites (see `.goat-flow/plans/sdc-foundation/`):

- The conformance oracle harness (`tests/Integration/AbstractSdcConformanceTest.php`) — a reusable
test base that compares the **deserialized model** field-by-field against a frozen reference
baseline, with an explicit ignore-list for spec-legal serialization divergence.

## `Questionnaire/$populate`

`FHIRQuestionnairePopulateService` pre-fills a `QuestionnaireResponse` from a `Questionnaire`'s SDC
population directives plus caller-supplied launch-context data, per the
[SDC populate operation](https://build.fhir.org/ig/HL7/sdc/en/populate.html). Call
`populate($questionnaire, new PopulateContext(...))`; the returned `PopulateResult` carries the generated
`QuestionnaireResponse` plus an optional companion `OperationOutcome`.

```php
$service = new FHIRQuestionnairePopulateService();
$result = $service->populate($questionnaire, new PopulateContext(
fhirVersion: FhirVersion::R4, // output model namespace (R4 / R4B / R5)
launchContextResources: ['patient' => $patient], // bound as FHIRPath %patient, %encounter, …
subject: 'Patient/123', // sets QuestionnaireResponse.subject (optional)
dataProvider: new BundlePopulationDataProvider($dataBundle), // observation-based (optional)
));
$response = $result->getResponse(); // a QuestionnaireResponse (status: in-progress)
$issues = $result->getIssues(); // an OperationOutcome, or null when nothing to report
```

A canonical URL **string** may be passed instead of a Questionnaire object when the service is
constructed with a `FHIRQuestionnaireResolverInterface`; without one, a string argument yields an empty
QR plus a warning.

### Supported population mechanisms

| Mechanism | Directive | Notes |
|---|---|---|
| **Launch context** | `launchContext` + `initialExpression` | Each supplied resource is bound as `%<name>`; each item's `initialExpression` is evaluated and coerced to the item's answer type. |
| **Variables** | `variable` (root + item) | Resolved in declaration order into further `%<name>` constants; a multi-valued variable binds its first value (a warning records the truncation). |
| **Repeating groups** | `itemPopulationContext` | A group is emitted **once per context result**, with `%<name>` bound to each result for its descendants; nesting is supported. |
| **Observation-based** | `observationLinkPeriod` | Populates from the most-recent eligible `Observation` (status `final`/`amended`/`corrected`, matching `item.code`, within the link period) supplied via the `dataProvider`. |

### Behaviour contract

- **Offline-first.** All launch-context data and observations are supplied by the caller up front; the
service performs no live FHIR fetching.
- **`enableWhen` is not applied.** Disabled items are still populated — the spec treats `enableWhen` as a
display-time concern ("fill in as much data as possible, even if it may not always be needed").
- **Empty set = not answered.** An expression resolving to empty — a boolean included, and an empty
string — produces **no** answer and an `information` issue, never a `false`/empty value.
- **Never throws.** A malformed expression, an unresolvable canonical URL, a missing launch context, an
empty `itemPopulationContext`, or an item with no `linkId` each degrade to an `OperationOutcome` issue
while the rest of the form still populates.
- **Answer coercion is strict-by-source-datatype.** Complex item types (`choice`/`quantity`/`reference`/
`attachment`) require the expression to resolve to the right FHIR datatype object; a bare scalar for a
complex item is a mismatch warning, not a silent coercion.

### Exclusions

- **StructureMap-based population** (`sourceStructureMap`) — requires a FHIR Mapping Language engine,
which the toolkit does not ship. Deferred (see `sdc-populate/backlog.md`).
- **Live data fetching** — `x-fhir-query`, `dataEndpoint`, server-side `data` retrieval. Supply a
`dataProvider` instead.
- **CQL expressions** (`text/cql`) — FHIRPath only; a non-FHIRPath language surfaces a warning and is
skipped.
- **`calculatedExpression`** — continuous re-population as source answers change.
- **`candidateExpression` / `contextExpression` / `answerExpression`** — interactive answer-selection,
a UI concern.
- **`populatehtml` / `populatelink`** — HTML/link rendering (UI concern; not deprecated — current in
SDC v4.0.0).
- **Binding-driven `code`→`Coding` promotion** — a bare code for a choice item is not promoted to a
systematised `Coding` via the item's value-set binding (tracked in `sdc-populate/backlog.md`).
- **Generated SDC profile classes** (Populatable/Extractable Questionnaire, SDC QuestionnaireResponse) —
the engine reads extensions by URL and needs no profile classes; profile conformance is a `Validation`
concern.
- **Generated typed SDC extension classes** — the engine reads extensions by URL via `SafeExtensionReader`
(version-drift robust); typed extension classes are an optional authoring layer only (see `backlog.md`).
- **Access control / PHI authorization** — this offline library populates from whatever context the
caller supplies and performs no permission filtering. The SDC spec's "SHALL NOT populate data the user
is not permitted to access" is a caller responsibility.

For the full design rationale and boundary decisions, see
`.goat-flow/learning-loop/decisions/ADR-011-sdc-populate-boundaries.md`.

## `QuestionnaireResponse/$extract`

`FHIRQuestionnaireResponseExtractService` turns a completed `QuestionnaireResponse` into FHIR
Expand Down
66 changes: 66 additions & 0 deletions src/Component/Sdc/src/BundlePopulationDataProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

namespace Ardenexal\FHIRTools\Component\Sdc;

use Ardenexal\FHIRTools\Component\Sdc\Contract\PopulationDataProviderInterface;

/**
* In-memory {@see PopulationDataProviderInterface} backed by a pre-fetched FHIR `Bundle`.
*
* The caller supplies a `Bundle` (a `searchset`/`collection` of the resources relevant to population);
* this provider surfaces its `Observation` entries. Reads are tolerant of deserializer-origin objects
* (uninitialized typed properties read via `isset`), so a Bundle straight from the serializer is safe.
*/
final class BundlePopulationDataProvider implements PopulationDataProviderInterface
{
/**
* @param object $bundle a pre-fetched FHIR `Bundle` (any version) whose entries hold the resources
* relevant to population; only its `Observation` entries are surfaced
*/
public function __construct(
private readonly object $bundle,
) {
}

/**
* Every `Observation` resource found among the Bundle's entries (deserializer-origin objects tolerated),
* in entry order. Returns an empty list when the Bundle has no entries or none are Observations.
*
* @return list<object>
*/
public function observations(): array
{
$entries = $this->bundle->entry ?? null;
if (!\is_array($entries)) {
return [];
}

$observations = [];
foreach ($entries as $entry) {
if (!\is_object($entry)) {
continue;
}

$resource = $entry->resource ?? null;
if (\is_object($resource) && $this->isObservation($resource)) {
$observations[] = $resource;
}
}

return $observations;
}

/**
* Whether a resource object is an `Observation`, by class basename (version-agnostic — matches
* `Models\R4\...\ObservationResource`, R4B, R5) rather than a hardcoded FQCN.
*/
private function isObservation(object $resource): bool
{
$class = $resource::class;
$short = ($pos = strrpos($class, '\\')) !== false ? substr($class, $pos + 1) : $class;

return $short === 'ObservationResource' || $short === 'Observation';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

declare(strict_types=1);

namespace Ardenexal\FHIRTools\Component\Sdc;
namespace Ardenexal\FHIRTools\Component\Sdc\Contract;

use Ardenexal\FHIRTools\Component\Sdc\ExtractContext;
use Ardenexal\FHIRTools\Component\Sdc\ExtractResult;

/**
* Transforms a completed `QuestionnaireResponse` into FHIR resources per the SDC `$extract` operation.
Expand Down
32 changes: 32 additions & 0 deletions src/Component/Sdc/src/Contract/PopulateServiceInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace Ardenexal\FHIRTools\Component\Sdc\Contract;

use Ardenexal\FHIRTools\Component\Sdc\PopulateContext;
use Ardenexal\FHIRTools\Component\Sdc\PopulateResult;

/**
* Generates a pre-filled `QuestionnaireResponse` from a `Questionnaire` plus contextual data, per the
* SDC `$populate` operation.
*
* The Questionnaire is typed as `object` (not a version-specific class) so a single interface spans
* R4/R4B/R5 — implementations narrow to {@see PopulateContext::$fhirVersion}. This mirrors the
* version-agnostic signature the toolkit's validator and `$extract` service already use.
*/
interface PopulateServiceInterface
{
/**
* Populate a QuestionnaireResponse from a Questionnaire and its launch context.
*
* @param object|string $questionnaire a version-specific Questionnaire model carrying the SDC
* population directives (`launchContext`, `initialExpression`), OR
* a canonical URL string resolved via a configured
* `FHIRQuestionnaireResolverInterface`
* @param PopulateContext $context target version + launch-context resources + subject
*
* @return PopulateResult the generated QuestionnaireResponse plus any informational/warning issues
*/
public function populate(object|string $questionnaire, PopulateContext $context): PopulateResult;
}
33 changes: 33 additions & 0 deletions src/Component/Sdc/src/Contract/PopulationDataProviderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Ardenexal\FHIRTools\Component\Sdc\Contract;

use Ardenexal\FHIRTools\Component\Sdc\FHIRQuestionnairePopulateService;
use Ardenexal\FHIRTools\Component\Sdc\PopulateContext;

/**
* Supplies the candidate resources observation-based population draws on.
*
* This is the named data seam that keeps population **offline-first**: the caller pre-fetches the
* relevant `Observation`s (e.g. into a `data` Bundle) and hands them over, so no live FHIR server or
* `x-fhir-query` fetching happens inside the library. A future live-fetch provider can implement this
* same interface without any change to {@see FHIRQuestionnairePopulateService} or {@see PopulateContext}.
*/
Comment thread
Ardenexal marked this conversation as resolved.
interface PopulationDataProviderInterface
{
/**
* All candidate `Observation` resources available for population. Order is not significant — the
* populate service filters by code, status, link period, and (when a subject is stated) `subject`,
* and selects the most recent itself.
*
* A subject filter is applied only when {@see PopulateContext::$subject} is set: the service then
* excludes any Observation not confirmably about that subject, so a broad or mixed-subject Bundle
* cannot leak another patient's value. When no subject is stated the caller remains responsible for
* supplying only relevant Observations.
*
* @return list<object> version-specific `Observation` model objects
*/
public function observations(): array;
}
Loading
Loading