Skip to content

Commit

Permalink
Merge pull request #310 from getodk/features/engine/meta-instanceid
Browse files Browse the repository at this point in the history
Engine support for [`orx:`]`instanceID` & [`jr:`]`preload="uid"`; vast improvements in support for XML/XPath namespace semantics
  • Loading branch information
eyelidlessness authored Feb 19, 2025
2 parents d55f7ee + 4d97e54 commit d7ecc33
Show file tree
Hide file tree
Showing 43 changed files with 1,503 additions and 283 deletions.
13 changes: 13 additions & 0 deletions .changeset/cold-weeks-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@getodk/xforms-engine': minor
---

- Compute `jr:preload="uid"` on form initialization.
- Ensure submission XML incluces `instanceID` metadata. If not present in form definition, defaults to computing `jr:preload="uid"`.
- Support for use of non-default (XForms) namespaces by primary instance elements, including:
- Production of form-defined namespace declarations in submission XML;
- Preservation of form-defined namespace prefix;
- Use of namespace prefix in bind nodeset;
- Use of namespace prefix in computed expressions.
- Support for use of non-default namespaces by internal secondary instances.
- Partial support for use of non-default namespaces by external XML secondary instances. (Namespaces may be resolved to engine-internal defaults.)
2 changes: 1 addition & 1 deletion packages/scenario/test/jr-preload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe('`jr:preload`', () => {
* the {@link ComparableAnswer} (`actual` value) to utilize a custom
* `toStartWith` assertion generalized over answer types.
*/
it.fails('preloads [specified data in bound] elements', async () => {
it('preloads [specified data in bound] elements', async () => {
const scenario = await Scenario.init(
'Preload attribute',
html(
Expand Down
249 changes: 247 additions & 2 deletions packages/scenario/test/submission.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { OPENROSA_XFORMS_NAMESPACE_URI } from '@getodk/common/constants/xmlns.ts';
import {
OPENROSA_XFORMS_NAMESPACE_URI,
OPENROSA_XFORMS_PREFIX,
XFORMS_NAMESPACE_URI,
} from '@getodk/common/constants/xmlns.ts';
import {
bind,
body,
Expand All @@ -18,7 +22,7 @@ import {
import { TagXFormsElement } from '@getodk/common/test/fixtures/xform-dsl/TagXFormsElement.ts';
import type { XFormsElement } from '@getodk/common/test/fixtures/xform-dsl/XFormsElement.ts';
import { createUniqueId } from 'solid-js';
import { beforeEach, describe, expect, it } from 'vitest';
import { assert, beforeEach, describe, expect, it } from 'vitest';
import { Scenario } from '../src/jr/Scenario.ts';
import { ANSWER_OK, ANSWER_REQUIRED_BUT_EMPTY } from '../src/jr/validation/ValidateOutcome.ts';
import { ReactiveScenario } from '../src/reactive/ReactiveScenario.ts';
Expand Down Expand Up @@ -906,4 +910,245 @@ describe('Form submission', () => {

describe.todo('for multiple requests, chunked by maximum size');
});

describe('submission-specific metadata', () => {
type MetadataElementName = 'instanceID';

type MetaNamespaceURI = OPENROSA_XFORMS_NAMESPACE_URI | XFORMS_NAMESPACE_URI;

type MetadataValueAssertion = (value: string | null) => unknown;

const getMetaChildElement = (
parent: ParentNode | null,
namespaceURI: MetaNamespaceURI,
localName: string
): Element | null => {
if (parent == null) {
return null;
}

for (const child of parent.children) {
if (child.namespaceURI === namespaceURI && child.localName === localName) {
return child;
}
}

return null;
};

/**
* Normally this might be implemented as a
* {@link https://vitest.dev/guide/extending-matchers | custom "matcher" (assertion)}.
* But it's so specific to this sub-suite that it would be silly to sprawl
* it out into other parts of the codebase!
*/
const assertMetadata = (
scenario: Scenario,
metaNamespaceURI: MetaNamespaceURI,
name: MetadataElementName,
assertion: MetadataValueAssertion
): void => {
const serializedInstanceBody = scenario.proposed_serializeInstance();
/**
* 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}.
*/
const instanceXML = `<instance xmlns="${XFORMS_NAMESPACE_URI}">${serializedInstanceBody}</instance>`;

const parser = new DOMParser();
const instanceDocument = parser.parseFromString(instanceXML, 'text/xml');
const instanceElement = instanceDocument.documentElement;
const instanceRoot = instanceElement.firstElementChild;

assert(
instanceRoot != null,
`Failed to find instance root element.\n\nActual serialized XML: ${serializedInstanceBody}\n\nActual instance DOM state: ${instanceElement.outerHTML}`
);

const meta = getMetaChildElement(instanceRoot, metaNamespaceURI, 'meta');
const targetElement = getMetaChildElement(meta, metaNamespaceURI, name);
const value = targetElement?.textContent ?? null;

assertion(value);
};

const PRELOAD_UID_PATTERN =
/^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}$/;

const assertPreloadUIDValue = (value: string | null) => {
assert(value != null, 'Expected preload uid value to be serialized');
expect(value, 'Expected preload uid value to match pattern').toMatch(PRELOAD_UID_PATTERN);
};

describe('instanceID', () => {
describe('preload="uid"', () => {
let scenario: Scenario;

beforeEach(async () => {
// prettier-ignore
scenario = await Scenario.init('Meta instanceID preload uid', html(
head(
title('Meta instanceID preload uid'),
model(
mainInstance(
t('data id="meta-instanceid-preload-uid"',
t('inp', 'inp default value'),
/** @see note on `namespaces` sub-suite! */
t('meta',
t('instanceID')))
),
bind('/data/inp').type('string'),
bind('/data/meta/instanceID').preload('uid')
)
),
body(
input('/data/inp',
label('inp')))
));
});

/**
* @see {@link https://getodk.github.io/xforms-spec/#preload-attributes:~:text=concatenation%20of%20%E2%80%98uuid%3A%E2%80%99%20and%20uuid()}
*/
it('is populated with a concatenation of ‘uuid:’ and uuid()', () => {
assertMetadata(
scenario,
/** @see note on `namespaces` sub-suite! */
XFORMS_NAMESPACE_URI,
'instanceID',
assertPreloadUIDValue
);
});

it('does not change after an input value is changed', () => {
scenario.answer('/data/inp', 'any non-default value!');

assertMetadata(
scenario,
/** @see note on `namespaces` sub-suite! */
XFORMS_NAMESPACE_URI,
'instanceID',
assertPreloadUIDValue
);
});
});

/**
* NOTE: Do not read too much intent into this sub-suite coming after
* tests above with `meta` and `instanceID` in the default (XForms)
* namespace! Those tests were added first because they'll require the
* least work to make pass. The `orx` namespace _is preferred_, {@link
* https://getodk.github.io/xforms-spec/#metadata | per spec}.
*
* This fact is further emphasized by the next sub-suite, exercising
* default behavior when a `meta` subtree node (of either namespace) is
* not present.
*/
describe('namespaces', () => {
it(`preserves the ${OPENROSA_XFORMS_PREFIX} (${OPENROSA_XFORMS_NAMESPACE_URI}) namespace when used in the form definition`, async () => {
// prettier-ignore
const scenario = await Scenario.init(
'ORX Meta ORX instanceID preload uid',
html(
head(
title('ORX Meta ORX instanceID preload uid'),
model(
mainInstance(
t('data id="orx-meta-instanceid-preload-uid"',
t('inp', 'inp default value'),
t('orx:meta',
t('orx:instanceID'))
)
),
bind('/data/inp').type('string'),
bind('/data/orx:meta/orx:instanceID').preload('uid')
)
),
body(
input('/data/inp',
label('inp')))
));

assertMetadata(
scenario,
OPENROSA_XFORMS_NAMESPACE_URI,
'instanceID',
assertPreloadUIDValue
);
});

// This is redundant to other tests already exercising unprefixed names!
it.skip('preserves the default/un-prefixed namespace when used in the form definition');
});

describe('defaults when absent in form definition', () => {
interface MissingInstanceIDLeafNodeCase {
readonly metaNamespaceURI: MetaNamespaceURI;
}

describe.each<MissingInstanceIDLeafNodeCase>([
{ metaNamespaceURI: OPENROSA_XFORMS_NAMESPACE_URI },
{ metaNamespaceURI: XFORMS_NAMESPACE_URI },
])('meta namespace URI: $metaNamespaceURI', ({ metaNamespaceURI }) => {
const expectedNamePrefix =
metaNamespaceURI === OPENROSA_XFORMS_NAMESPACE_URI ? 'orx:' : '';
const metaSubtreeName = `${expectedNamePrefix}meta`;
const instanceIDName = `${expectedNamePrefix}instanceID`;

it(`injects and populates a missing ${instanceIDName} leaf node in an existing ${metaSubtreeName} subtree`, async () => {
// prettier-ignore
const scenario = await Scenario.init(
'ORX Meta ORX instanceID preload uid',
html(
head(
title('ORX Meta ORX instanceID preload uid'),
model(
mainInstance(
t('data id="orx-meta-instanceid-preload-uid"',
t('inp', 'inp default value'),
t(metaSubtreeName)
)
),
bind('/data/inp').type('string')
)
),
body(
input('/data/inp',
label('inp')))
));

assertMetadata(scenario, metaNamespaceURI, 'instanceID', assertPreloadUIDValue);
});
});

it('injects and populates an orx:meta subtree AND orx:instanceID leaf node', async () => {
// prettier-ignore
const scenario = await Scenario.init(
'ORX Meta ORX instanceID preload uid',
html(
head(
title('ORX Meta ORX instanceID preload uid'),
model(
mainInstance(
t('data id="orx-meta-instanceid-preload-uid"',
t('inp', 'inp default value')
)
),
bind('/data/inp').type('string')
)
),
body(
input('/data/inp',
label('inp')))
));

assertMetadata(
scenario,
OPENROSA_XFORMS_NAMESPACE_URI,
'instanceID',
assertPreloadUIDValue
);
});
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
type PreloadAttributeName = 'jr:preload' | 'jr:preloadParams';

/**
* @todo This class is intentionally named to reflect the fact that it is not
* intended to indefinitely block loading a form! Insofar as we currently throw
* this error, the intent is to determine whether we have gaps in our support
* for
* {@link https://getodk.github.io/xforms-spec/#preload-attributes | preload attributes}.
*
* @todo Open question(s) for design around the broader error production story:
* how should we design for conditions which are _optionally errors_ (e.g.
* varying levels of strictness, use case-specific cases where certain kinds of
* errors aren't failures)? In particular, how should we design for:
*
* - Categorization that allows selecting which error conditions are applied, at
* what level of severity?
* - Blocking progress on failure-level severity, proceeding on sub-failure
* severity?
*
* Question applies to this case where we may want to error for unknown preload
* attribute values in dev/test, but may not want to error under most (all?)
* user-facing conditions.
*/
export class UnknownPreloadAttributeValueNotice extends Error {
constructor(
attributeName: PreloadAttributeName,
expectedValues: ReadonlyArray<string | null>,
unknownValue: string | null
) {
const expected = expectedValues.map((value) => JSON.stringify(value)).join(', ');
super(
`Unknown ${attributeName} value. Expected one of ${expected}, got: ${JSON.stringify(unknownValue)}`
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export abstract class InstanceNode<
);
}

return `${parent.contextReference()}/${definition.nodeName}`;
return `${parent.contextReference()}/${definition.qualifiedName.getPrefixedName()}`;
};

// EvaluationContext: node-specific
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { ReactiveScope } from '../../lib/reactivity/scope.ts';
import type { BindComputationExpression } from '../../parse/expression/BindComputationExpression.ts';
import type { AnyBindPreloadDefinition } from '../../parse/model/BindPreloadDefinition.ts';
import type { EvaluationContext } from './EvaluationContext.ts';

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

interface InstanceValueContextDefinitionBind {
readonly preload: AnyBindPreloadDefinition | null;
readonly calculate: BindComputationExpression<'calculate'> | null;
readonly readonly: BindComputationExpression<'readonly'>;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SubmissionState } from '../../../client/submission/SubmissionState.ts';
import type { QualifiedName } from '../../../lib/names/QualifiedName.ts';
import type { EscapedXMLText } from '../../../lib/xml-serialization.ts';
import type {
ClientReactiveSubmittableChildNode,
Expand All @@ -13,7 +14,7 @@ interface ClientReactiveSubmittableLeafNodeCurrentState<RuntimeValue> {
export type SerializedSubmissionValue = string;

interface ClientReactiveSubmittableLeafNodeDefinition {
readonly nodeName: string;
readonly qualifiedName: QualifiedName;
}

export interface ClientReactiveSubmittableLeafNode<RuntimeValue> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SubmissionState } from '../../../client/submission/SubmissionState.ts';
import type { QualifiedName } from '../../../lib/names/QualifiedName.ts';

export interface ClientReactiveSubmittableChildNode {
readonly submissionState: SubmissionState;
Expand All @@ -12,7 +13,7 @@ interface ClientReactiveSubmittableParentNodeCurrentState<
}

export interface ClientReactiveSubmittableParentNodeDefinition {
readonly nodeName: string;
readonly qualifiedName: QualifiedName;
}

export interface ClientReactiveSubmittableParentNode<
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SubmissionState } from '../../../client/submission/SubmissionState.ts';
import type { QualifiedName } from '../../../lib/names/QualifiedName.ts';
import type {
ClientReactiveSubmittableChildNode,
ClientReactiveSubmittableParentNode,
Expand All @@ -12,7 +13,7 @@ interface ClientReactiveSubmittableValueNodeCurrentState {
export type SerializedSubmissionValue = string;

interface ClientReactiveSubmittableValueNodeDefinition {
readonly nodeName: string;
readonly qualifiedName: QualifiedName;
}

export interface ClientReactiveSubmittableValueNode {
Expand Down
Loading

0 comments on commit d7ecc33

Please sign in to comment.