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
78 changes: 78 additions & 0 deletions src/converter/choice-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,84 @@ function canonicalToName(url: string): string {
return parts[parts.length - 1];
}

function isChoiceTypeSlicingRoot(element: StructureDefinitionElement): boolean {
if (!element.path.endsWith('[x]') || element.sliceName) return false;
const discriminator = element.slicing?.discriminator;
return (
!!discriminator &&
discriminator.length === 1 &&
discriminator[0].type === 'type' &&
discriminator[0].path.trim() === '$this'
);
}

/**
* Collapse type slicing on choice elements ($this type discriminator) into the
* plain choice representation: the sliced element becomes the choice declaration
* (accumulating every variant in `choices` and keeping the slicing metadata so
* open/closed rules survive), and each slice becomes a regular typed variant
* element. Children of a slice are re-parented under its variant element.
*/
export function collapseChoiceTypeSlicing(
elements: StructureDefinitionElement[],
): StructureDefinitionElement[] {
const result: StructureDefinitionElement[] = [];
let i = 0;

while (i < elements.length) {
const element = elements[i];
if (!isChoiceTypeSlicingRoot(element)) {
result.push(element);
i++;
continue;
}

const rootPath = element.path;
const basePath = rootPath.replace(/\[x\]$/, '');
const fieldName = basePath.split('.').pop() || '';
const { slicing, type, ...rootRest } = element;

const choices = (type || []).map((t) => fieldName + capitalize(canonicalToName(t.code)));
const slicedVariants = new Set<string>();
const collapsed: StructureDefinitionElement[] = [];
let variantPath: string | undefined;

let j = i + 1;
for (; j < elements.length && elements[j].path.startsWith(rootPath); j++) {
const el = elements[j];
if (el.path === rootPath && el.sliceName && el.type?.length === 1) {
const typeName = capitalize(canonicalToName(el.type[0].code));
const variantName = fieldName + typeName;
variantPath = basePath + typeName;
if (!choices.includes(variantName)) choices.push(variantName);
slicedVariants.add(variantName);
const { sliceName, ...sliceRest } = el;
collapsed.push({ ...sliceRest, path: variantPath, choiceOf: fieldName });
} else if (el.path.startsWith(`${rootPath}.`) && variantPath) {
collapsed.push({ ...el, path: variantPath + el.path.slice(rootPath.length) });
} else {
collapsed.push(el);
variantPath = undefined;
}
}

result.push({ ...rootRest, path: basePath, choices, _choiceSlicing: slicing });

// Explicit root types without a matching slice keep plain variant elements,
// mirroring expandChoiceElement for unsliced choice elements.
for (const t of type || []) {
const typeName = capitalize(canonicalToName(t.code));
if (slicedVariants.has(fieldName + typeName)) continue;
const { binding, ...variantRest } = rootRest;
result.push({ ...variantRest, path: basePath + typeName, type: [t], choiceOf: fieldName });
}
result.push(...collapsed);
i = j;
}

return result;
}

export function expandChoiceElement(
element: StructureDefinitionElement,
): StructureDefinitionElement[] {
Expand Down
20 changes: 16 additions & 4 deletions src/converter/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { calculateActions } from './action-calculator.js';
import { expandChoiceElement, isChoiceElement } from './choice-handler.js';
import {
collapseChoiceTypeSlicing,
expandChoiceElement,
isChoiceElement,
} from './choice-handler.js';
import { transformElement } from './element-transformer.js';
import { enrichPath, parsePath } from './path-parser.js';
import { applyActions } from './stack-processor.js';
Expand Down Expand Up @@ -185,7 +189,7 @@ export function translate(
}

const header = buildResourceHeader(structureDefinition, context);
const elements = getDifferential(structureDefinition);
const elements = collapseChoiceTypeSlicing(getDifferential(structureDefinition));

// Note: Root element constraints are not included in the output

Expand Down Expand Up @@ -217,8 +221,16 @@ export function translate(
const actions = calculateActions(prevPath, enrichedPath);

// Transform element
const transformedElement = transformElement(element, structureDefinition);
const elementWithIndex: Record<string, unknown> = { ...transformedElement, index: index++ };
const transformedElement = transformElement(element, structureDefinition) as Record<
string,
unknown
>;
// Slicing metadata from collapsed choice type slicing becomes plain slicing
// on the choice declaration (rules/discriminator, no slices)
const { _choiceSlicing, ...transformedRest } = transformedElement;
const elementWithIndex: Record<string, unknown> = _choiceSlicing
? { ...transformedRest, slicing: _choiceSlicing, index: index++ }
: { ...transformedRest, index: index++ };

// Apply actions
stack = applyActions(stack, actions, elementWithIndex);
Expand Down
65 changes: 65 additions & 0 deletions test/unit/converter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -998,4 +998,69 @@ describe('Converter Algorithm Tests', () => {
});
});
});

describe('choice type slicing', () => {
// Mirrors profiles like KBV_PR_Base_Condition_Diagnosis (kbv.basis), which
// slice a choice element by type ($this discriminator) with open rules to
// attach per-variant constraints without restricting the choice.
const openOnsetSlicing: Partial<StructureDefinitionElement>[] = [
{
path: 'Test.onset[x]',
slicing: { discriminator: [{ type: 'type', path: '$this' }], rules: 'open' },
},
{
path: 'Test.onset[x]',
sliceName: 'onsetPeriod',
min: 0,
max: '1',
type: [{ code: 'Period' }],
},
{
path: 'Test.onset[x]',
sliceName: 'onsetAge',
min: 0,
max: '1',
type: [{ code: 'Age' }],
},
];

it('keeps every declared slice variant as a choice', () => {
const result = translate(createTestStructureDefinition(openOnsetSlicing));

expect(result.elements?.onset?.choices).toEqual(['onsetPeriod', 'onsetAge']);
expect(result.elements?.onsetPeriod).toMatchObject({ type: 'Period', choiceOf: 'onset' });
expect(result.elements?.onsetAge).toMatchObject({ type: 'Age', choiceOf: 'onset' });
});

it('preserves open slicing rules and discriminator on the choice element', () => {
const result = translate(createTestStructureDefinition(openOnsetSlicing));

expect(result.elements?.onset?.slicing?.rules).toBe('open');
expect(result.elements?.onset?.slicing?.discriminator).toEqual([
{ type: 'type', path: '$this' },
]);
});

it('preserves closed slicing rules on the choice element', () => {
// Closed type slicing (e.g. KBV Apgar Score value[x]) forbids variants
// without a slice, so consumers must be able to see rules: closed.
const result = translate(
createTestStructureDefinition([
{
path: 'Test.value[x]',
slicing: { discriminator: [{ type: 'type', path: '$this' }], rules: 'closed' },
type: [{ code: 'CodeableConcept' }],
},
{
path: 'Test.value[x]',
sliceName: 'valueCodeableConcept',
max: '1',
type: [{ code: 'CodeableConcept' }],
},
]),
);

expect(result.elements?.value?.slicing?.rules).toBe('closed');
});
});
});