Skip to content

Commit d7ecc33

Browse files
Merge pull request #310 from getodk/features/engine/meta-instanceid
Engine support for [`orx:`]`instanceID` & [`jr:`]`preload="uid"`; vast improvements in support for XML/XPath namespace semantics
2 parents d55f7ee + 4d97e54 commit d7ecc33

Some content is hidden

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

43 files changed

+1503
-283
lines changed

.changeset/cold-weeks-change.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@getodk/xforms-engine': minor
3+
---
4+
5+
- Compute `jr:preload="uid"` on form initialization.
6+
- Ensure submission XML incluces `instanceID` metadata. If not present in form definition, defaults to computing `jr:preload="uid"`.
7+
- Support for use of non-default (XForms) namespaces by primary instance elements, including:
8+
- Production of form-defined namespace declarations in submission XML;
9+
- Preservation of form-defined namespace prefix;
10+
- Use of namespace prefix in bind nodeset;
11+
- Use of namespace prefix in computed expressions.
12+
- Support for use of non-default namespaces by internal secondary instances.
13+
- Partial support for use of non-default namespaces by external XML secondary instances. (Namespaces may be resolved to engine-internal defaults.)

packages/scenario/test/jr-preload.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ describe('`jr:preload`', () => {
3838
* the {@link ComparableAnswer} (`actual` value) to utilize a custom
3939
* `toStartWith` assertion generalized over answer types.
4040
*/
41-
it.fails('preloads [specified data in bound] elements', async () => {
41+
it('preloads [specified data in bound] elements', async () => {
4242
const scenario = await Scenario.init(
4343
'Preload attribute',
4444
html(

packages/scenario/test/submission.test.ts

Lines changed: 247 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { OPENROSA_XFORMS_NAMESPACE_URI } from '@getodk/common/constants/xmlns.ts';
1+
import {
2+
OPENROSA_XFORMS_NAMESPACE_URI,
3+
OPENROSA_XFORMS_PREFIX,
4+
XFORMS_NAMESPACE_URI,
5+
} from '@getodk/common/constants/xmlns.ts';
26
import {
37
bind,
48
body,
@@ -18,7 +22,7 @@ import {
1822
import { TagXFormsElement } from '@getodk/common/test/fixtures/xform-dsl/TagXFormsElement.ts';
1923
import type { XFormsElement } from '@getodk/common/test/fixtures/xform-dsl/XFormsElement.ts';
2024
import { createUniqueId } from 'solid-js';
21-
import { beforeEach, describe, expect, it } from 'vitest';
25+
import { assert, beforeEach, describe, expect, it } from 'vitest';
2226
import { Scenario } from '../src/jr/Scenario.ts';
2327
import { ANSWER_OK, ANSWER_REQUIRED_BUT_EMPTY } from '../src/jr/validation/ValidateOutcome.ts';
2428
import { ReactiveScenario } from '../src/reactive/ReactiveScenario.ts';
@@ -906,4 +910,245 @@ describe('Form submission', () => {
906910

907911
describe.todo('for multiple requests, chunked by maximum size');
908912
});
913+
914+
describe('submission-specific metadata', () => {
915+
type MetadataElementName = 'instanceID';
916+
917+
type MetaNamespaceURI = OPENROSA_XFORMS_NAMESPACE_URI | XFORMS_NAMESPACE_URI;
918+
919+
type MetadataValueAssertion = (value: string | null) => unknown;
920+
921+
const getMetaChildElement = (
922+
parent: ParentNode | null,
923+
namespaceURI: MetaNamespaceURI,
924+
localName: string
925+
): Element | null => {
926+
if (parent == null) {
927+
return null;
928+
}
929+
930+
for (const child of parent.children) {
931+
if (child.namespaceURI === namespaceURI && child.localName === localName) {
932+
return child;
933+
}
934+
}
935+
936+
return null;
937+
};
938+
939+
/**
940+
* Normally this might be implemented as a
941+
* {@link https://vitest.dev/guide/extending-matchers | custom "matcher" (assertion)}.
942+
* But it's so specific to this sub-suite that it would be silly to sprawl
943+
* it out into other parts of the codebase!
944+
*/
945+
const assertMetadata = (
946+
scenario: Scenario,
947+
metaNamespaceURI: MetaNamespaceURI,
948+
name: MetadataElementName,
949+
assertion: MetadataValueAssertion
950+
): void => {
951+
const serializedInstanceBody = scenario.proposed_serializeInstance();
952+
/**
953+
* Important: we intentionally omit the default namespace when serializing instance XML. We need to restore it here to reliably traverse nodes when {@link metaNamespaceURI} is {@link XFORMS_NAMESPACE_URI}.
954+
*/
955+
const instanceXML = `<instance xmlns="${XFORMS_NAMESPACE_URI}">${serializedInstanceBody}</instance>`;
956+
957+
const parser = new DOMParser();
958+
const instanceDocument = parser.parseFromString(instanceXML, 'text/xml');
959+
const instanceElement = instanceDocument.documentElement;
960+
const instanceRoot = instanceElement.firstElementChild;
961+
962+
assert(
963+
instanceRoot != null,
964+
`Failed to find instance root element.\n\nActual serialized XML: ${serializedInstanceBody}\n\nActual instance DOM state: ${instanceElement.outerHTML}`
965+
);
966+
967+
const meta = getMetaChildElement(instanceRoot, metaNamespaceURI, 'meta');
968+
const targetElement = getMetaChildElement(meta, metaNamespaceURI, name);
969+
const value = targetElement?.textContent ?? null;
970+
971+
assertion(value);
972+
};
973+
974+
const PRELOAD_UID_PATTERN =
975+
/^uuid:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/;
976+
977+
const assertPreloadUIDValue = (value: string | null) => {
978+
assert(value != null, 'Expected preload uid value to be serialized');
979+
expect(value, 'Expected preload uid value to match pattern').toMatch(PRELOAD_UID_PATTERN);
980+
};
981+
982+
describe('instanceID', () => {
983+
describe('preload="uid"', () => {
984+
let scenario: Scenario;
985+
986+
beforeEach(async () => {
987+
// prettier-ignore
988+
scenario = await Scenario.init('Meta instanceID preload uid', html(
989+
head(
990+
title('Meta instanceID preload uid'),
991+
model(
992+
mainInstance(
993+
t('data id="meta-instanceid-preload-uid"',
994+
t('inp', 'inp default value'),
995+
/** @see note on `namespaces` sub-suite! */
996+
t('meta',
997+
t('instanceID')))
998+
),
999+
bind('/data/inp').type('string'),
1000+
bind('/data/meta/instanceID').preload('uid')
1001+
)
1002+
),
1003+
body(
1004+
input('/data/inp',
1005+
label('inp')))
1006+
));
1007+
});
1008+
1009+
/**
1010+
* @see {@link https://getodk.github.io/xforms-spec/#preload-attributes:~:text=concatenation%20of%20%E2%80%98uuid%3A%E2%80%99%20and%20uuid()}
1011+
*/
1012+
it('is populated with a concatenation of ‘uuid:’ and uuid()', () => {
1013+
assertMetadata(
1014+
scenario,
1015+
/** @see note on `namespaces` sub-suite! */
1016+
XFORMS_NAMESPACE_URI,
1017+
'instanceID',
1018+
assertPreloadUIDValue
1019+
);
1020+
});
1021+
1022+
it('does not change after an input value is changed', () => {
1023+
scenario.answer('/data/inp', 'any non-default value!');
1024+
1025+
assertMetadata(
1026+
scenario,
1027+
/** @see note on `namespaces` sub-suite! */
1028+
XFORMS_NAMESPACE_URI,
1029+
'instanceID',
1030+
assertPreloadUIDValue
1031+
);
1032+
});
1033+
});
1034+
1035+
/**
1036+
* NOTE: Do not read too much intent into this sub-suite coming after
1037+
* tests above with `meta` and `instanceID` in the default (XForms)
1038+
* namespace! Those tests were added first because they'll require the
1039+
* least work to make pass. The `orx` namespace _is preferred_, {@link
1040+
* https://getodk.github.io/xforms-spec/#metadata | per spec}.
1041+
*
1042+
* This fact is further emphasized by the next sub-suite, exercising
1043+
* default behavior when a `meta` subtree node (of either namespace) is
1044+
* not present.
1045+
*/
1046+
describe('namespaces', () => {
1047+
it(`preserves the ${OPENROSA_XFORMS_PREFIX} (${OPENROSA_XFORMS_NAMESPACE_URI}) namespace when used in the form definition`, async () => {
1048+
// prettier-ignore
1049+
const scenario = await Scenario.init(
1050+
'ORX Meta ORX instanceID preload uid',
1051+
html(
1052+
head(
1053+
title('ORX Meta ORX instanceID preload uid'),
1054+
model(
1055+
mainInstance(
1056+
t('data id="orx-meta-instanceid-preload-uid"',
1057+
t('inp', 'inp default value'),
1058+
t('orx:meta',
1059+
t('orx:instanceID'))
1060+
)
1061+
),
1062+
bind('/data/inp').type('string'),
1063+
bind('/data/orx:meta/orx:instanceID').preload('uid')
1064+
)
1065+
),
1066+
body(
1067+
input('/data/inp',
1068+
label('inp')))
1069+
));
1070+
1071+
assertMetadata(
1072+
scenario,
1073+
OPENROSA_XFORMS_NAMESPACE_URI,
1074+
'instanceID',
1075+
assertPreloadUIDValue
1076+
);
1077+
});
1078+
1079+
// This is redundant to other tests already exercising unprefixed names!
1080+
it.skip('preserves the default/un-prefixed namespace when used in the form definition');
1081+
});
1082+
1083+
describe('defaults when absent in form definition', () => {
1084+
interface MissingInstanceIDLeafNodeCase {
1085+
readonly metaNamespaceURI: MetaNamespaceURI;
1086+
}
1087+
1088+
describe.each<MissingInstanceIDLeafNodeCase>([
1089+
{ metaNamespaceURI: OPENROSA_XFORMS_NAMESPACE_URI },
1090+
{ metaNamespaceURI: XFORMS_NAMESPACE_URI },
1091+
])('meta namespace URI: $metaNamespaceURI', ({ metaNamespaceURI }) => {
1092+
const expectedNamePrefix =
1093+
metaNamespaceURI === OPENROSA_XFORMS_NAMESPACE_URI ? 'orx:' : '';
1094+
const metaSubtreeName = `${expectedNamePrefix}meta`;
1095+
const instanceIDName = `${expectedNamePrefix}instanceID`;
1096+
1097+
it(`injects and populates a missing ${instanceIDName} leaf node in an existing ${metaSubtreeName} subtree`, async () => {
1098+
// prettier-ignore
1099+
const scenario = await Scenario.init(
1100+
'ORX Meta ORX instanceID preload uid',
1101+
html(
1102+
head(
1103+
title('ORX Meta ORX instanceID preload uid'),
1104+
model(
1105+
mainInstance(
1106+
t('data id="orx-meta-instanceid-preload-uid"',
1107+
t('inp', 'inp default value'),
1108+
t(metaSubtreeName)
1109+
)
1110+
),
1111+
bind('/data/inp').type('string')
1112+
)
1113+
),
1114+
body(
1115+
input('/data/inp',
1116+
label('inp')))
1117+
));
1118+
1119+
assertMetadata(scenario, metaNamespaceURI, 'instanceID', assertPreloadUIDValue);
1120+
});
1121+
});
1122+
1123+
it('injects and populates an orx:meta subtree AND orx:instanceID leaf node', async () => {
1124+
// prettier-ignore
1125+
const scenario = await Scenario.init(
1126+
'ORX Meta ORX instanceID preload uid',
1127+
html(
1128+
head(
1129+
title('ORX Meta ORX instanceID preload uid'),
1130+
model(
1131+
mainInstance(
1132+
t('data id="orx-meta-instanceid-preload-uid"',
1133+
t('inp', 'inp default value')
1134+
)
1135+
),
1136+
bind('/data/inp').type('string')
1137+
)
1138+
),
1139+
body(
1140+
input('/data/inp',
1141+
label('inp')))
1142+
));
1143+
1144+
assertMetadata(
1145+
scenario,
1146+
OPENROSA_XFORMS_NAMESPACE_URI,
1147+
'instanceID',
1148+
assertPreloadUIDValue
1149+
);
1150+
});
1151+
});
1152+
});
1153+
});
9091154
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
type PreloadAttributeName = 'jr:preload' | 'jr:preloadParams';
2+
3+
/**
4+
* @todo This class is intentionally named to reflect the fact that it is not
5+
* intended to indefinitely block loading a form! Insofar as we currently throw
6+
* this error, the intent is to determine whether we have gaps in our support
7+
* for
8+
* {@link https://getodk.github.io/xforms-spec/#preload-attributes | preload attributes}.
9+
*
10+
* @todo Open question(s) for design around the broader error production story:
11+
* how should we design for conditions which are _optionally errors_ (e.g.
12+
* varying levels of strictness, use case-specific cases where certain kinds of
13+
* errors aren't failures)? In particular, how should we design for:
14+
*
15+
* - Categorization that allows selecting which error conditions are applied, at
16+
* what level of severity?
17+
* - Blocking progress on failure-level severity, proceeding on sub-failure
18+
* severity?
19+
*
20+
* Question applies to this case where we may want to error for unknown preload
21+
* attribute values in dev/test, but may not want to error under most (all?)
22+
* user-facing conditions.
23+
*/
24+
export class UnknownPreloadAttributeValueNotice extends Error {
25+
constructor(
26+
attributeName: PreloadAttributeName,
27+
expectedValues: ReadonlyArray<string | null>,
28+
unknownValue: string | null
29+
) {
30+
const expected = expectedValues.map((value) => JSON.stringify(value)).join(', ');
31+
super(
32+
`Unknown ${attributeName} value. Expected one of ${expected}, got: ${JSON.stringify(unknownValue)}`
33+
);
34+
}
35+
}

packages/xforms-engine/src/instance/abstract/InstanceNode.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ export abstract class InstanceNode<
167167
);
168168
}
169169

170-
return `${parent.contextReference()}/${definition.nodeName}`;
170+
return `${parent.contextReference()}/${definition.qualifiedName.getPrefixedName()}`;
171171
};
172172

173173
// EvaluationContext: node-specific

packages/xforms-engine/src/instance/internal-api/InstanceValueContext.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import type { ReactiveScope } from '../../lib/reactivity/scope.ts';
22
import type { BindComputationExpression } from '../../parse/expression/BindComputationExpression.ts';
3+
import type { AnyBindPreloadDefinition } from '../../parse/model/BindPreloadDefinition.ts';
34
import type { EvaluationContext } from './EvaluationContext.ts';
45

56
export type DecodeInstanceValue = (value: string) => string;
67

78
interface InstanceValueContextDefinitionBind {
9+
readonly preload: AnyBindPreloadDefinition | null;
810
readonly calculate: BindComputationExpression<'calculate'> | null;
911
readonly readonly: BindComputationExpression<'readonly'>;
1012
}

packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableLeafNode.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { SubmissionState } from '../../../client/submission/SubmissionState.ts';
2+
import type { QualifiedName } from '../../../lib/names/QualifiedName.ts';
23
import type { EscapedXMLText } from '../../../lib/xml-serialization.ts';
34
import type {
45
ClientReactiveSubmittableChildNode,
@@ -13,7 +14,7 @@ interface ClientReactiveSubmittableLeafNodeCurrentState<RuntimeValue> {
1314
export type SerializedSubmissionValue = string;
1415

1516
interface ClientReactiveSubmittableLeafNodeDefinition {
16-
readonly nodeName: string;
17+
readonly qualifiedName: QualifiedName;
1718
}
1819

1920
export interface ClientReactiveSubmittableLeafNode<RuntimeValue> {

packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableParentNode.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { SubmissionState } from '../../../client/submission/SubmissionState.ts';
2+
import type { QualifiedName } from '../../../lib/names/QualifiedName.ts';
23

34
export interface ClientReactiveSubmittableChildNode {
45
readonly submissionState: SubmissionState;
@@ -12,7 +13,7 @@ interface ClientReactiveSubmittableParentNodeCurrentState<
1213
}
1314

1415
export interface ClientReactiveSubmittableParentNodeDefinition {
15-
readonly nodeName: string;
16+
readonly qualifiedName: QualifiedName;
1617
}
1718

1819
export interface ClientReactiveSubmittableParentNode<

packages/xforms-engine/src/instance/internal-api/submission/ClientReactiveSubmittableValueNode.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { SubmissionState } from '../../../client/submission/SubmissionState.ts';
2+
import type { QualifiedName } from '../../../lib/names/QualifiedName.ts';
23
import type {
34
ClientReactiveSubmittableChildNode,
45
ClientReactiveSubmittableParentNode,
@@ -12,7 +13,7 @@ interface ClientReactiveSubmittableValueNodeCurrentState {
1213
export type SerializedSubmissionValue = string;
1314

1415
interface ClientReactiveSubmittableValueNodeDefinition {
15-
readonly nodeName: string;
16+
readonly qualifiedName: QualifiedName;
1617
}
1718

1819
export interface ClientReactiveSubmittableValueNode {

0 commit comments

Comments
 (0)