Skip to content

Commit eca7f61

Browse files
authored
Merge pull request #228 from atomic-ehr/fix/profile-constraint-semantics
TS: Validate optional and repeating profile constraints correctly
2 parents debec32 + a58076f commit eca7f61

9 files changed

Lines changed: 524 additions & 15 deletions

File tree

‎assets/api/writer-generator/typescript/profile-helpers.ts‎

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -369,22 +369,57 @@ export const validateExcluded = (res: object, profileName: string, field: string
369369
: [];
370370
};
371371

372-
/** Checks that `field` structurally contains the expected fixed value. */
373-
export const validateFixedValue = (res: object, profileName: string, field: string, expected: unknown): string[] => {
374-
return matchesValue((res as Record<string, unknown>)[field], expected)
372+
/**
373+
* Shared body for the `fixed[x]`/`pattern[x]` checks, keeping FHIR's two array
374+
* rules apart:
375+
*
376+
* - Across repetitions: a constraint declared on a repeating element applies to
377+
* all repetitions, so every one of them must match and the element must
378+
* actually be an array.
379+
* - Inside one value: arrays nested in the constraint keep `matchesValue`'s
380+
* "each constraint entry matches at least one instance entry" rule.
381+
*/
382+
const matchesConstrainedValue = (value: unknown, expected: unknown, repeating: boolean): boolean =>
383+
repeating
384+
? Array.isArray(value) && value.length > 0 && value.every((item) => matchesValue(item, expected))
385+
: !Array.isArray(value) && matchesValue(value, expected);
386+
387+
/**
388+
* Checks that a present `field` structurally contains the expected fixed value.
389+
* Absence passes — `fixed[x]` applies "if present", so a missing element is
390+
* `validateRequired`'s concern. Pass `repeating` for an element with max > 1.
391+
*/
392+
export const validateFixedValue = (
393+
res: object,
394+
profileName: string,
395+
field: string,
396+
expected: unknown,
397+
repeating = false,
398+
): string[] => {
399+
const value = (res as Record<string, unknown>)[field];
400+
if (value === undefined || value === null) return [];
401+
return matchesConstrainedValue(value, expected, repeating)
375402
? []
376403
: [`${profileName}: field '${field}' does not match expected fixed value`];
377404
};
378405

379406
/**
380407
* Containment constraint for a field that may be absent: absence is
381408
* `validateRequired`'s concern, so an absent field passes; a present one must
382-
* structurally contain `expected`.
409+
* structurally contain `expected` — every repetition, when `repeating`.
383410
*/
384-
export const validatePatternValue = (res: object, profileName: string, field: string, expected: unknown): string[] => {
411+
export const validatePatternValue = (
412+
res: object,
413+
profileName: string,
414+
field: string,
415+
expected: unknown,
416+
repeating = false,
417+
): string[] => {
385418
const value = (res as Record<string, unknown>)[field];
386419
if (value === undefined || value === null) return [];
387-
return matchesValue(value, expected) ? [] : [`${profileName}: field '${field}' does not match expected pattern`];
420+
return matchesConstrainedValue(value, expected, repeating)
421+
? []
422+
: [`${profileName}: field '${field}' does not match expected pattern`];
388423
};
389424

390425
/**

‎examples/on-the-fly/norge-r4/__snapshots__/kjernejournal-profiles.test.ts.snap‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4325,7 +4325,7 @@ export class KjPractitionerRoleProfile {
43254325
...validateRequired(res, profileName, "organization"),
43264326
...validateReference(res, profileName, "organization", ["Organization"]),
43274327
...validateRequired(res, profileName, "code"),
4328-
...validatePatternValue(res, profileName, "code", {"coding":[{"system":"http://nhn.no/kj/fhir/CodeSystem/CategoriesOfHealthProfessionals"}]}),
4328+
...validatePatternValue(res, profileName, "code", {"coding":[{"system":"http://nhn.no/kj/fhir/CodeSystem/CategoriesOfHealthProfessionals"}]}, true),
43294329
...validateReference(res, profileName, "location", ["Location"]),
43304330
...validateReference(res, profileName, "healthcareService", ["HealthcareService"]),
43314331
...validateReference(res, profileName, "endpoint", ["Endpoint"]),

‎examples/typescript-r4-us-core/fhir-types/profile-helpers.ts‎

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -369,22 +369,57 @@ export const validateExcluded = (res: object, profileName: string, field: string
369369
: [];
370370
};
371371

372-
/** Checks that `field` structurally contains the expected fixed value. */
373-
export const validateFixedValue = (res: object, profileName: string, field: string, expected: unknown): string[] => {
374-
return matchesValue((res as Record<string, unknown>)[field], expected)
372+
/**
373+
* Shared body for the `fixed[x]`/`pattern[x]` checks, keeping FHIR's two array
374+
* rules apart:
375+
*
376+
* - Across repetitions: a constraint declared on a repeating element applies to
377+
* all repetitions, so every one of them must match and the element must
378+
* actually be an array.
379+
* - Inside one value: arrays nested in the constraint keep `matchesValue`'s
380+
* "each constraint entry matches at least one instance entry" rule.
381+
*/
382+
const matchesConstrainedValue = (value: unknown, expected: unknown, repeating: boolean): boolean =>
383+
repeating
384+
? Array.isArray(value) && value.length > 0 && value.every((item) => matchesValue(item, expected))
385+
: !Array.isArray(value) && matchesValue(value, expected);
386+
387+
/**
388+
* Checks that a present `field` structurally contains the expected fixed value.
389+
* Absence passes — `fixed[x]` applies "if present", so a missing element is
390+
* `validateRequired`'s concern. Pass `repeating` for an element with max > 1.
391+
*/
392+
export const validateFixedValue = (
393+
res: object,
394+
profileName: string,
395+
field: string,
396+
expected: unknown,
397+
repeating = false,
398+
): string[] => {
399+
const value = (res as Record<string, unknown>)[field];
400+
if (value === undefined || value === null) return [];
401+
return matchesConstrainedValue(value, expected, repeating)
375402
? []
376403
: [`${profileName}: field '${field}' does not match expected fixed value`];
377404
};
378405

379406
/**
380407
* Containment constraint for a field that may be absent: absence is
381408
* `validateRequired`'s concern, so an absent field passes; a present one must
382-
* structurally contain `expected`.
409+
* structurally contain `expected` — every repetition, when `repeating`.
383410
*/
384-
export const validatePatternValue = (res: object, profileName: string, field: string, expected: unknown): string[] => {
411+
export const validatePatternValue = (
412+
res: object,
413+
profileName: string,
414+
field: string,
415+
expected: unknown,
416+
repeating = false,
417+
): string[] => {
385418
const value = (res as Record<string, unknown>)[field];
386419
if (value === undefined || value === null) return [];
387-
return matchesValue(value, expected) ? [] : [`${profileName}: field '${field}' does not match expected pattern`];
420+
return matchesConstrainedValue(value, expected, repeating)
421+
? []
422+
: [`${profileName}: field '${field}' does not match expected pattern`];
388423
};
389424

390425
/**

‎src/api/writer-generator/typescript/profile-validation.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,16 @@ export const collectRegularFieldValidation = (
6666
if (field.required) errors.push(`...validateRequired(res, profileName, ${JSON.stringify(name)})`);
6767

6868
if (field.valueConstraint) {
69-
const valueExpr =
69+
const constrainedValueExpr =
7070
canonicalUrlExpr && name === "url" && field.valueConstraint.value === canonicalUrlExpr.url
7171
? canonicalUrlExpr.expr
7272
: JSON.stringify(field.valueConstraint.value);
7373
const fn = field.valueConstraint.validateOnly ? "validatePatternValue" : "validateFixedValue";
74-
errors.push(`...${fn}(res, profileName, ${JSON.stringify(name)}, ${valueExpr})`);
74+
// A constraint on a repeating element applies to every repetition, so
75+
// the helper needs the declared arity: the instance shape alone cannot
76+
// tell an array-valued element from a wrong value on a single one.
77+
const repeating = field.array ? ", true" : "";
78+
errors.push(`...${fn}(res, profileName, ${JSON.stringify(name)}, ${constrainedValueExpr}${repeating})`);
7579
}
7680

7781
if (field.enum) {
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
2+
3+
exports[`Profile constrained values captures the generated profile module 1`] = `
4+
"// WARNING: This file is autogenerated by @atomic-ehr/codegen.
5+
// GitHub: https://github.com/atomic-ehr/codegen
6+
// Any manual changes made to this file may be overwritten.
7+
8+
import type { CodeableConcept } from "../../hl7-fhir-r4-core/CodeableConcept";
9+
import type { Reference } from "../../hl7-fhir-r4-core/Reference";
10+
import type { ServiceRequest } from "../../hl7-fhir-r4-core/ServiceRequest";
11+
12+
import {
13+
ensureProfile,
14+
applyFixedValue,
15+
validateRequired,
16+
validateExcluded,
17+
validateFixedValue,
18+
validateSliceCardinality,
19+
validateSliceFields,
20+
validateEnum,
21+
validateReference,
22+
validateChoiceRequired,
23+
validateChoiceProhibited,
24+
validateMustSupport,
25+
} from "../../profile-helpers";
26+
27+
export type ConstrainedValuesServiceRequestProfileRaw = {
28+
status: ("draft" | "active" | "on-hold" | "revoked" | "completed" | "entered-in-error" | "unknown");
29+
subject: Reference<"Device" | "Group" | "Location" | "Patient">;
30+
}
31+
32+
// CanonicalURL: http://example.test/StructureDefinition/constrained-values-service-request (pkg: example.test.constrainedvalues#0.1.0)
33+
export class ConstrainedValuesServiceRequestProfile {
34+
static readonly resourceType = "ServiceRequest";
35+
static readonly canonicalUrl = "http://example.test/StructureDefinition/constrained-values-service-request";
36+
37+
private resource: ServiceRequest;
38+
39+
constructor (resource: ServiceRequest) {
40+
this.resource = resource;
41+
}
42+
43+
static from (resource: ServiceRequest) : ConstrainedValuesServiceRequestProfile {
44+
if (!resource.meta?.profile?.includes(ConstrainedValuesServiceRequestProfile.canonicalUrl)) {
45+
throw new Error(\`ConstrainedValuesServiceRequestProfile: meta.profile must include \${ConstrainedValuesServiceRequestProfile.canonicalUrl}\`)
46+
}
47+
const profile = new ConstrainedValuesServiceRequestProfile(resource);
48+
const { errors } = profile.validate();
49+
if (errors.length > 0) throw new Error(errors.join("; "))
50+
return profile;
51+
}
52+
53+
static is (resource: unknown) : resource is ServiceRequest {
54+
if (typeof resource !== "object" || resource === null) return false;
55+
const r = resource as { resourceType?: string; meta?: { profile?: string[] } };
56+
if (r.resourceType !== "ServiceRequest") return false;
57+
return (r.meta?.profile ?? []).includes(ConstrainedValuesServiceRequestProfile.canonicalUrl);
58+
}
59+
60+
static apply (resource: ServiceRequest) : ConstrainedValuesServiceRequestProfile {
61+
ensureProfile(resource, ConstrainedValuesServiceRequestProfile.canonicalUrl);
62+
applyFixedValue(resource, "doNotPerform", false);
63+
applyFixedValue(resource, "category", [{"coding":[{"system":"http://example.test/category","code":"example"}]}]);
64+
applyFixedValue(resource, "intent", "order");
65+
return new ConstrainedValuesServiceRequestProfile(resource);
66+
}
67+
68+
static createResource (args: ConstrainedValuesServiceRequestProfileRaw) : ServiceRequest {
69+
const resource: ServiceRequest = {
70+
resourceType: "ServiceRequest",
71+
doNotPerform: false,
72+
category: [{"coding":[{"system":"http://example.test/category","code":"example"}]}],
73+
intent: "order",
74+
status: args.status,
75+
subject: args.subject,
76+
meta: { profile: [ConstrainedValuesServiceRequestProfile.canonicalUrl] },
77+
}
78+
return resource;
79+
}
80+
81+
static create (args: ConstrainedValuesServiceRequestProfileRaw) : ConstrainedValuesServiceRequestProfile {
82+
const resource = ConstrainedValuesServiceRequestProfile.createResource(args);
83+
return ConstrainedValuesServiceRequestProfile.apply(resource);
84+
}
85+
86+
toResource () : ServiceRequest {
87+
return this.resource;
88+
}
89+
90+
// Field accessors
91+
getStatus () : ("draft" | "active" | "on-hold" | "revoked" | "completed" | "entered-in-error" | "unknown") | undefined {
92+
return this.resource.status as ("draft" | "active" | "on-hold" | "revoked" | "completed" | "entered-in-error" | "unknown") | undefined;
93+
}
94+
95+
setStatus (value: ("draft" | "active" | "on-hold" | "revoked" | "completed" | "entered-in-error" | "unknown")) : this {
96+
Object.assign(this.resource, { status: value });
97+
return this;
98+
}
99+
100+
getSubject () : Reference<"Device" | "Group" | "Location" | "Patient"> | undefined {
101+
return this.resource.subject as Reference<"Device" | "Group" | "Location" | "Patient"> | undefined;
102+
}
103+
104+
setSubject (value: Reference<"Device" | "Group" | "Location" | "Patient">) : this {
105+
Object.assign(this.resource, { subject: value });
106+
return this;
107+
}
108+
109+
getDoNotPerform () : boolean | undefined {
110+
return this.resource.doNotPerform as boolean | undefined;
111+
}
112+
113+
getCategory () : CodeableConcept[] | undefined {
114+
return this.resource.category as CodeableConcept[] | undefined;
115+
}
116+
117+
getIntent () : ("proposal" | "plan" | "directive" | "order" | "original-order" | "reflex-order" | "filler-order" | "instance-order" | "option") | undefined {
118+
return this.resource.intent as ("proposal" | "plan" | "directive" | "order" | "original-order" | "reflex-order" | "filler-order" | "instance-order" | "option") | undefined;
119+
}
120+
121+
// Extensions
122+
// Slices
123+
// Validation
124+
validate(): { errors: string[]; warnings: string[] } {
125+
const profileName = "ConstrainedValuesServiceRequest"
126+
const res = this.resource
127+
return {
128+
errors: [
129+
...validateFixedValue(res, profileName, "doNotPerform", false),
130+
...validateFixedValue(res, profileName, "category", {"coding":[{"system":"http://example.test/category","code":"example"}]}, true),
131+
...validateRequired(res, profileName, "intent"),
132+
...validateFixedValue(res, profileName, "intent", "order"),
133+
...validateEnum(res, profileName, "intent", ["proposal","plan","directive","order","original-order","reflex-order","filler-order","instance-order","option"]),
134+
...validateRequired(res, profileName, "status"),
135+
...validateRequired(res, profileName, "subject"),
136+
],
137+
warnings: [
138+
...validateMustSupport(res, profileName, "category"),
139+
],
140+
}
141+
}
142+
143+
}
144+
145+
"
146+
`;

0 commit comments

Comments
 (0)