The schema

Three patterns, two phases

The schema checks an invoice three ways, and each way is its own pattern — because patterns do not compete, while rules inside one pattern do.

examples/invoice.sch (abridged)

<schema xmlns="http://purl.oclc.org/dsdl/schematron" defaultPhase="basic">

  <title>Invoice rules</title>

  <phase id="basic">
    <active pattern="structure"/>
  </phase>

  <phase id="strict">
    <active pattern="structure"/>
    <active pattern="lines"/>
    <active pattern="totals"/>
  </phase>

  <let name="tax-rate" value="0.2"/>

  <pattern id="structure">
    <title>Structure</title>
    <rule context="invoice">
      <assert test="@id" flag="error">An invoice must have an id.</assert>
      <assert test="total" flag="error">An invoice must have a total.</assert>
      <assert test="count(line) > 0" flag="error">An invoice must have at least one line.</assert>
      <report test="count(line) > 100" flag="info">
        This invoice has <value-of select="count(line)"/> lines, which is unusual.
      </report>
    </rule>
  </pattern>

  <pattern id="lines">
    <title>Line rules</title>
    <!-- These two rules are alternatives, on purpose: a discount line is
         checked by the first rule and never reaches the second. -->
    <rule context="line[@type='discount']">
      <assert test="number(@amount) < 0" flag="error" diagnostics="amount-help">
        A discount line must have a negative amount, but <name/> has <value-of select="@amount"/>.
      </assert>
    </rule>
    <rule context="line">
      <assert test="@qty" flag="error">Every line needs a qty.</assert>
      <assert test="number(@qty) > 0" flag="error" diagnostics="qty-help">
        Quantity must be positive, but is <value-of select="@qty"/>.
      </assert>
      <assert test="number(@amount) >= 0" flag="error">
        A normal line must not have a negative amount; use type="discount" for that.
      </assert>
    </rule>
  </pattern>

  <pattern id="totals">
    <title>Totals</title>
    <rule context="invoice">
      <let name="expected" value="sum(line/@amount) * (1 + $tax-rate)"/>
      <assert test="number(total) >= $expected - 0.01 and number(total) <= $expected + 0.01"
              flag="warning">
        Total is <value-of select="total"/> but the lines plus tax come to <value-of select="$expected"/>.
      </assert>
    </rule>
  </pattern>

  <diagnostics>
    <diagnostic id="qty-help">
      Quantity is the number of units ordered. It must be a positive number.
    </diagnostic>
    <diagnostic id="amount-help">
      Amount is the line total in the invoice currency. Discounts are negative.
    </diagnostic>
  </diagnostics>

</schema>

Note where $tax-rate and $expected live. A schema-level let is a constant; a rule-level let is evaluated with the rule's context node, so $expected means something different for each invoice.

The documents

One that passes, one that does not

examples/invoice-good.xml

<?xml version="1.0" encoding="UTF-8"?>
<invoice id="INV-1001">
  <line qty="2" amount="10.00"/>
  <line qty="1" amount="5.00"/>
  <line type="discount" qty="1" amount="-5.00"/>
  <total>12.00</total>
</invoice>

examples/invoice-bad.xml

<?xml version="1.0" encoding="UTF-8"?>
<invoice>
  <line qty="-2" amount="10.00"/>
  <line type="discount" qty="1" amount="5.00"/>
  <total>99.00</total>
</invoice>

Four things are wrong with the second one. Count them before reading on.

Run it

The default phase checks structure only

$ schematron -s examples/invoice.sch examples/invoice-bad.xml

examples/invoice-bad.xml:
  error    /invoice[1]
           An invoice must have an id.
  1 finding: 1 failed assert, 0 reports

$ echo $?
1

One finding — because defaultPhase="basic" activates only the structure pattern. The line and total problems are still there; nothing was asked to look for them.

$ schematron -s examples/invoice.sch --list-phases

Phases:
  basic  (default)
  strict

Also accepted: #ALL, #DEFAULT

Run it

The strict phase finds all four

$ schematron -s examples/invoice.sch -p strict examples/invoice-bad.xml

examples/invoice-bad.xml:
  error    /invoice[1]
           An invoice must have an id.
  error    /invoice[1]/line[1]
           Quantity must be positive, but is -2.
           - Quantity is the number of units ordered. It must be a positive number.
  error    /invoice[1]/line[2]
           A discount line must have a negative amount, but line has 5.00.
           - Amount is the line total in the invoice currency. Discounts are negative.
  warning  /invoice[1]
           Total is 99.00 but the lines plus tax come to 18.
  4 findings: 4 failed asserts, 0 reports

The first column is each finding's @flag. Lines beginning - are the diagnostics the assertions referenced. And the good document, for contrast:

$ schematron -s examples/invoice.sch -p strict examples/invoice-good.xml

examples/invoice-good.xml:
  no findings

$ echo $?
0

Look closely at line[2].

The discount line has qty="1" and a positive amount. It was caught by the discount rule, not the general line rule — because line[@type='discount'] comes first in the pattern and claimed the node. That is first-matching-rule-wins doing exactly what it was written to do.

Diagnose

What the schema will do, before it runs

--explain prints the compiled schema. It needs no document, and it is the fastest way to see a rule that can never fire.

$ schematron -s examples/invoice.sch --explain

Invoice rules

query binding: (default, XPath 1.0)
namespaces: none declared

pattern structure
  Structure
  rule 1 context invoice
    assert  @id
    assert  total
    assert  count(line) > 0
    report  count(line) > 100

pattern lines
  Line rules
  rule 1 context line[@type='discount']
    assert  number(@amount) < 0
  rule 2 context line
       (only fires on nodes no earlier rule claimed)
    assert  @qty
    assert  number(@qty) > 0
    assert  number(@amount) >= 0

pattern totals
  Totals
  rule 1 context invoice
    assert  number(total) >= $expected - 0.01 and number(total) <= $expected + 0.01

--verbose answers the other half — why did this fire? — by printing the test and the rule behind each finding:

$ schematron -s examples/invoice.sch -p strict -v examples/invoice-bad.xml

examples/invoice-bad.xml:
  error    /invoice[1]
           An invoice must have an id.
           test: @id
           rule: invoice
  error    /invoice[1]/line[1]
           Quantity must be positive, but is -2.
           test: number(@qty) > 0
           rule: line
           - Quantity is the number of units ordered. It must be a positive number.
  error    /invoice[1]/line[2]
           A discount line must have a negative amount, but line has 5.00.
           test: number(@amount) < 0
           rule: line[@type='discount']
           - Amount is the line total in the invoice currency. Discounts are negative.
  warning  /invoice[1]
           Total is 99.00 but the lines plus tax come to 18.
           test: number(total) >= $expected - 0.01 and number(total) <= $expected + 0.01
           rule: invoice
  4 findings: 4 failed asserts, 0 reports

An empty list of fired rules here is the signature of the other common failure: a missing namespace prefix. Diagnosing that →

Filter

Fail on errors, still show warnings

--flag filters what is reported, and therefore what counts toward the exit code. The total mismatch above is flagged warning, so it disappears here:

$ schematron -s examples/invoice.sch -p strict --flag error examples/invoice-bad.xml

examples/invoice-bad.xml:
  error    /invoice[1]
           An invoice must have an id.
  error    /invoice[1]/line[1]
           Quantity must be positive, but is -2.
           - Quantity is the number of units ordered. It must be a positive number.
  error    /invoice[1]/line[2]
           A discount line must have a negative amount, but line has 5.00.
           - Amount is the line total in the invoice currency. Discounts are negative.
  3 findings: 3 failed asserts, 0 reports

$ echo $?
1

That is the shape of a build gate: fail on error, and run a second unfiltered pass to surface warnings without breaking the build.

The same run as SVRL and JSON The full tutorial