Tutorial
Enough Schematron to write a real schema. The full eighteen-step version, with every worked file, is spec/tutorial/ in the repo.
Step 1
The smallest useful schema
A schema is patterns; a pattern is rules; a rule is a context plus assertions.
rules.sch
<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<pattern>
<rule context="invoice">
<assert test="total">An invoice must have a total.</assert>
</rule>
</pattern>
</schema>@context is an XPath expression selecting the nodes the rule
applies to. @test is an XPath expression evaluated with each
such node as the context node.
schematron --schema rules.sch data.xmlStep 2
assert versus report
They are opposites, and the direction catches everyone once.
assertfires when its test is false — "this must be true".reportfires when its test is true — "tell me if this happens".
<rule context="invoice">
<assert test="total">An invoice must have a total.</assert>
<report test="count(line) > 100">This invoice has an unusual number of lines.</report>
</rule>An assert that fires is a failure. A report that
fires is an observation. Both land in the report; only failed asserts make a
document invalid.
Step 3
Messages that say what went wrong
Assertion text is mixed content, so it can interpolate the document with value-of and name the offending node with name:
<assert test="number(@qty) > 0">
Quantity must be positive, but <name/> has <value-of select="@qty"/>.
</assert>Longer explanations belong in a diagnostic, referenced by @diagnostics, so the message stays short and the help stays
available:
<assert test="number(@qty) > 0" diagnostics="qty-help">
Quantity must be positive, but is <value-of select="@qty"/>.
</assert>
<diagnostics>
<diagnostic id="qty-help">
Quantity is the number of units ordered. It must be a positive number.
</diagnostic>
</diagnostics>Step 4
First matching rule wins
Use it deliberately, to write an "otherwise" branch:
<pattern id="lines">
<rule context="line[@type='discount']">
<assert test="number(@amount) < 0">A discount must be negative.</assert>
</rule>
<rule context="line">
<assert test="number(@amount) >= 0">A normal line must not be negative.</assert>
</rule>
</pattern>And watch for it shadowing a rule you meant to run:
<pattern>
<rule context="*">…</rule>
<rule context="invoice">…</rule> <!-- never runs -->
</pattern>To apply independent checks to the same node, put them in separate patterns. Each pattern gets its own pass over the document.
Step 5
Namespaces
XPath 1.0 has no default namespace. An unprefixed name in a test matches elements in no namespace — so against a namespaced document, every context fails to match and nothing fires.
<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<ns prefix="inv" uri="http://example.com/invoice"/>
<pattern>
<rule context="inv:invoice">
<assert test="inv:total">An invoice must have a total.</assert>
</rule>
</pattern>
</schema>Declare the prefix with <ns> and use it everywhere — in contexts, in tests, in select attributes.
Steps 6–8
Variables, diagnostics, phases
let binds a name to an expression, at schema, pattern, or rule
scope. A rule-scope let is evaluated with the rule's context
node:
<let name="tax-rate" value="0.2"/>
<rule context="invoice">
<let name="lines-total" value="sum(line/@amount)"/>
<assert test="number(total) = $lines-total * (1 + $tax-rate)">
Total is <value-of select="total"/> but the lines plus tax come to
<value-of select="$lines-total * (1 + $tax-rate)"/>.
</assert>
</rule>phase selects which patterns run, so one schema can serve a
quick structural check and a full audit:
<schema xmlns="http://purl.oclc.org/dsdl/schematron" defaultPhase="basic">
<phase id="basic">
<active pattern="structure"/>
</phase>
<phase id="strict">
<active pattern="structure"/>
<active pattern="lines"/>
<active pattern="totals"/>
</phase>
…
</schema>schematron -s rules.sch --list-phases
schematron -s rules.sch --phase strict data.xml
schematron -s rules.sch --phase '#ALL' data.xmlStep 13
= is not eq
In XPath 1.0, comparing a node-set with = is an existential comparison: it is true if any node in the set
compares true. That is usually what you want in a Schematron test — and
occasionally exactly what you did not want.
Negation does not distribute over an existential comparison.
not(line/@type = 'discount') means "no line is a discount". line/@type != 'discount' means "some line is not a discount".
These are different tests, and both are sometimes correct.
Step 17
When a schema seems to do nothing
Two causes account for nearly all of them, and the tool can tell them apart:
- A missing
nsprefix. Every context fails to match, so no rule ever fires.--verboseshows no fired rules at all. - An earlier rule in the same pattern claimed the nodes.
--explainmarks every rule after the first in a pattern with a reminder that it only sees nodes no earlier rule claimed.
schematron -s rules.sch --lintStep 18
Where to go next
The repo ships a worked schema — examples/invoice.sch with invoice-good.xml and invoice-bad.xml — used
throughout the full tutorial and the CLI tests. Clone it and run it:
git clone https://github.com/schematron-rust/schematron-rust
cd schematron-rust
cargo run -- -s examples/invoice.sch -p strict -v examples/invoice-bad.xml