Checking What the Schema Cannot
A schema checks each document against rules about its structure and
values. Some rules of a good machine are about its behaviour, or about
another document: every state can be reached, every event the machine
handles is declared, every requirement the machine names exists in the
draft. A query can check them, and a schema-aware query can rely on the
types the schemas give: the vane:implements lists are lists, the
vane:type names are names, and the payload names point into
vane-data.xsd.
The checks
check.xq takes the machine as its context item, reads the draft from
vane.xml, and prints one line for each problem it finds:
check.xq:
(: Checks of a Vane machine that its schemas cannot make. The context
item is the machine; the draft it implements is vane.xml. Prints one
line for each problem, and nothing when there is none. :)
import schema namespace sc = "http://www.w3.org/2005/07/scxml" at "scxml.xsd";
declare namespace vane = "urn:example:vane";
declare namespace xsi = "http://www.w3.org/2001/XMLSchema-instance";
declare option output:method "text";
declare option output:item-separator " ";
declare variable $machine := validate { / };
declare variable $draft := doc("vane.xml");
(: Every state the machine can get to: start with the initial state, and
add the targets of the reached states' transitions, once per state. :)
declare function local:reachable() as xs:string* {
let $states := $machine//(sc:state | sc:final)
return fold-left(1 to count($states), string($machine/sc:scxml/@initial),
fn($reached, $pass) {
distinct-values((
$reached,
$states[@id = $reached]//sc:transition/@target ! string(),
$states[@id = $reached]/@initial ! string()
))
})
};
let $declared := $machine//vane:event/@name ! string()
let $handled := $machine//sc:transition/data(@event) ! string()
let $sent := $machine//(sc:send | sc:raise)/@event ! string()
let $reachable := local:reachable()
let $anchors := $draft//t/@anchor ! string()
let $implemented := $machine//@vane:implements ! data()
let $schema := doc("vane-data.xsd")/xs:schema
let $payloads := $schema/xs:element/@name ! QName($schema/@targetNamespace, .)
return (
(: States that no path from the initial state reaches. :)
$machine//sc:state[not(@id = $reachable)] ! `unreachable state: {@id}`,
(: Events a transition waits for that the machine does not declare. :)
distinct-values($handled[not(. = $declared)]) ! `undeclared event: {.}`,
(: Declared events that nothing handles, sends or raises. :)
$declared[not(. = ($handled, $sent))] ! `unused event: {.}`,
(: Payloads that name no global element of vane-data.xsd. :)
$machine//vane:event[@vane:payload]
[not(data(@vane:payload) = $payloads)]
! `unknown payload: {@vane:payload} of {@name}`,
(: Data items whose type the schema does not have, or whose value is
not one of their type. :)
for $data in $machine//sc:data
let $type := data($data/@vane:type)
return if (empty(function-lookup($type, 1))) then
`unknown type: {$data/@vane:type} of {$data/@id}`
else try {
(validate {
element vane:value {
namespace type { namespace-uri-from-QName($type) },
attribute xsi:type { "type:" || local-name-from-QName($type) },
string($data/@expr)
}
}) ! ()
} catch err:XQDY0027 {
`bad value: {$data/@id} = {$data/@expr} is not a {$data/@vane:type}`
},
(: Anchors the machine names that the draft does not have. :)
distinct-values($implemented[not(. = $anchors)]) ! `unknown requirement: {.}`,
(: The draft's MUST requirements that nothing in the machine implements. :)
$draft//t[bcp14 = ("MUST", "MUST NOT")][not(@anchor = $implemented)]
! `not implemented: {@anchor} ({bcp14})`
)
The machine has four flaws:
$ sxml xquery --context-item=flag.scxml check.xq
unreachable state: archived
undeclared event: flag.evaluated
unused event: flag.archived
not implemented: req-1 (MUST)
The output declarations make the result text, one line per problem, so a machine without problems prints nothing.
Reachable states
A state is reachable when some path of transitions leads to it from
the initial state. local:reachable computes the set the way you would
by hand: start with the initial state, add the targets of the
transitions of every state reached so far, and repeat. Each pass adds
at least one state or adds nothing, so as many passes as there are
states are enough; fold-left over 1 to count($states) runs them,
and distinct-values keeps the set a set. The machine has cycles
(stable to checking and back), which is why the passes collect a
set instead of following paths.
No transition targets archived, so the flag can never get there:
the state is dead code.
Events
Three sets of event names, and two comparisons:
$declared, the names ofvane:events;$handled, the events transitions wait for.data(@event)is a list of tokens, so the transition that waits forkill.request step.doneadds two names;$sent, the events ofsendandraise.
flag.evaluated is handled, in rolling-out, but not declared:
nobody wrote down what it means, or who sends it. flag.archived is
declared but nothing handles it, sends it or raises it.
status.response is declared and sent, but no transition waits for it:
the service sends it to the SDK that asked. A check that compared only
declared and handled events would report it as unused, which it is
not; counting send as a use is what tells an outgoing event from a
forgotten one.
Names that point elsewhere
vane:payload and vane:type name components of vane-data.xsd,
and the schema checks only that they are names. The query checks that
they name something:
- a payload must be a global element of
vane-data.xsd. The query reads the schema as an XML document, withdoc, and makes the names of itsxs:elements into QNames in its target namespace, which compare with the typed payload names; - a type must be a simple type of the imported schema. Every such type
has a constructor function, so
function-lookup($type, 1)is empty exactly when there is no such type.
For a type that exists, the query checks the data item's value against
it, as Typed Data and Schema-Aware Queries did:
it makes a vane:value element with xsi:type naming the type (in
whatever namespace the type has, with a computed namespace binding) and
validates it. validate raises err:XQDY0027 when the value is not
one of the type; try turns that into a line of the report, and the
check goes on.
Requirements
vane:implements holds anchors of the draft. Two checks join the
machine with the draft: every anchor the machine names must be the
anchor of a paragraph of the draft, and every MUST or MUST NOT of the
draft should be implemented somewhere in the machine. req-1, a flag
is in exactly one of its states, is a property of the whole machine,
and nothing says so yet.
A copy of the machine with a mistake for each of the other checks:
$ sed -e 's/expr="3"/expr="30"/' \
-e 's/vane:type="vane:seconds"/vane:type="seconds"/' \
-e 's/vane:payload="vane:rollout"/vane:payload="vane:rolout"/' \
-e 's/req-5 req-8/req-5 req-12/' flag.scxml > worse.scxml
$ sxml validate --schema=scxml.xsd worse.scxml; echo "exit status $?"
exit status 0
$ sxml xquery --context-item=worse.scxml check.xq
unreachable state: archived
undeclared event: flag.evaluated
unused event: flag.archived
unknown payload: vane:rolout of rollout.request
bad value: maxFailures = 30 is not a vane:failure-limit
unknown type: seconds of stepSeconds
unknown type: seconds of pauseSeconds
unknown requirement: req-12
not implemented: req-1 (MUST)
The schema finds none of them. The type without its prefix is the
name seconds in SCXML's namespace, the default namespace of the
machine, and SCXML has no such type.
Fixing the machine with an update
The fixes are small, and each touches one place: say that the machine
implements req-1, declare flag.evaluated, and let an owner archive
a stable or paused flag, which makes the archived state reachable and
the flag.archived event used. An update makes them without retyping the machine:
fix.xq:
(: Fixes the flaws check.xq finds in flag.scxml, and changes nothing else. :)
declare default element namespace "http://www.w3.org/2005/07/scxml";
declare namespace vane = "urn:example:vane";
. transform with {
(: The machine as a whole keeps a flag in exactly one state. :)
insert node attribute vane:implements { "req-1" } into scxml,
(: Declare the event the rolling-out state handles. :)
insert node
<vane:event name="flag.evaluated">
<p xmlns="http://www.w3.org/1999/xhtml">An SDK reports that it evaluated the flag.</p>
</vane:event>
after .//vane:event[@name = "step.done"],
(: An owner can archive a flag that is stable or paused. :)
for $state in .//state[@id = ("stable", "paused")]
return insert node <transition event="flag.archived" target="archived"/>
as first into $state
}
transform with copies the context item, the machine's document node,
applies the updates to the copy and returns it (Updating
Documents explains updates). The result is the
fixed machine, flag-fixed.scxml:
$ sxml xquery --context-item=flag.scxml --output=flag-fixed.scxml --force fix.xq
$ diff flag.scxml flag-fixed.scxml
1,4c1
< <?xml version="1.0" encoding="UTF-8"?>
< <scxml xmlns="http://www.w3.org/2005/07/scxml"
< xmlns:vane="urn:example:vane"
< version="1.0" name="feature_flag" initial="stable" datamodel="ecmascript">
---
> <scxml xmlns="http://www.w3.org/2005/07/scxml" xmlns:vane="urn:example:vane" version="1.0" name="feature_flag" initial="stable" datamodel="ecmascript" vane:implements="req-1">
27c24
< </vane:event>
---
> </vane:event><vane:event name="flag.evaluated"><p xmlns="http://www.w3.org/1999/xhtml">An SDK reports that it evaluated the flag.</p></vane:event>
43c40
< <state id="stable" vane:implements="req-2">
---
> <state id="stable" vane:implements="req-2"><transition event="flag.archived" target="archived"/>
80c77
< <state id="paused" vane:implements="req-5 req-8">
---
> <state id="paused" vane:implements="req-5 req-8"><transition event="flag.archived" target="archived"/>
Everything else is as it was, down to the whitespace between the
untouched elements. The serializer writes the start tag of scxml on
one line, and the new nodes without line breaks, as the query made
them; the XML declaration is left out. The fixed machine is valid, and
the checks find nothing:
$ sxml validate --schema=scxml.xsd flag-fixed.scxml; echo "exit status $?"
exit status 0
$ sxml xquery --context-item=flag-fixed.scxml check.xq
In a build, a script turns "prints nothing" into an exit status:
$ problems=$(sxml xquery --context-item=flag.scxml check.xq); test -z "$problems"; echo "exit status $?"
exit status 1
$ problems=$(sxml xquery --context-item=flag-fixed.scxml check.xq); test -z "$problems"; echo "exit status $?"
exit status 0
The rest of the project uses the fixed machine.