feat(schematron): abstract rules and extends
A <rule abstract="true" id="x"> is a template that does not fire on its own; a concrete rule pulls in its let bindings and assertions with <extends rule="x"/>, prepended so the concrete rule extends rather than replaces them. Part of #80
This commit is contained in:
parent
c21419fe7d
commit
7e04f6056c
@ -1,3 +1,10 @@
|
||||
/// The lets and assertions of an abstract Schematron rule, named by id so an
|
||||
/// `<extends>` can pull them into a concrete rule. File-scope and private.
|
||||
private struct AbstractRule {
|
||||
let lets: [PureXML.Validation.SchematronLet]
|
||||
let assertions: [PureXML.Validation.SchematronAssertion]
|
||||
}
|
||||
|
||||
extension PureXML.Validation {
|
||||
/// Parses a Schematron schema document into patterns and phases. Namespace
|
||||
/// agnostic: it matches the `schema`, `phase`, `active`, `pattern`, `rule`,
|
||||
@ -25,16 +32,41 @@ extension PureXML.Validation {
|
||||
}
|
||||
|
||||
private static func pattern(_ node: PureXML.Model.TreeNode) throws -> SchematronPattern {
|
||||
try SchematronPattern(id: attribute(node, "id"), rules: children(node, localName: "rule").compactMap(rule))
|
||||
let ruleNodes = children(node, localName: "rule")
|
||||
var abstracts: [String: AbstractRule] = [:]
|
||||
for ruleNode in ruleNodes where attribute(ruleNode, "abstract") == "true" {
|
||||
if let id = attribute(ruleNode, "id") {
|
||||
abstracts[id] = try AbstractRule(lets: ruleLets(ruleNode), assertions: ruleAssertions(ruleNode))
|
||||
}
|
||||
}
|
||||
let rules = try ruleNodes
|
||||
.filter { attribute($0, "abstract") != "true" }
|
||||
.compactMap { try rule($0, abstracts) }
|
||||
return SchematronPattern(id: attribute(node, "id"), rules: rules)
|
||||
}
|
||||
|
||||
private static func rule(_ node: PureXML.Model.TreeNode) throws -> SchematronRule? {
|
||||
private static func rule(_ node: PureXML.Model.TreeNode, _ abstracts: [String: AbstractRule]) throws -> SchematronRule? {
|
||||
guard let context = attribute(node, "context") else { return nil }
|
||||
let lets = try children(node, localName: "let").compactMap(letBinding)
|
||||
let assertions = try node.children
|
||||
var lets = try ruleLets(node)
|
||||
var assertions = try ruleAssertions(node)
|
||||
// An <extends rule="id"> prepends the abstract rule's lets and
|
||||
// assertions, so the concrete rule extends rather than replaces them.
|
||||
for ext in children(node, localName: "extends") {
|
||||
guard let id = attribute(ext, "rule"), let base = abstracts[id] else { continue }
|
||||
lets = base.lets + lets
|
||||
assertions = base.assertions + assertions
|
||||
}
|
||||
return try SchematronRule(context: PureXML.XPath.Query(contextExpression(context)), lets: lets, assertions: assertions)
|
||||
}
|
||||
|
||||
private static func ruleLets(_ node: PureXML.Model.TreeNode) throws -> [SchematronLet] {
|
||||
try children(node, localName: "let").compactMap(letBinding)
|
||||
}
|
||||
|
||||
private static func ruleAssertions(_ node: PureXML.Model.TreeNode) throws -> [SchematronAssertion] {
|
||||
try node.children
|
||||
.filter { $0.kind == .element && isAssertion($0) }
|
||||
.compactMap(assertion)
|
||||
return try SchematronRule(context: PureXML.XPath.Query(contextExpression(context)), lets: lets, assertions: assertions)
|
||||
}
|
||||
|
||||
private static func letBinding(_ node: PureXML.Model.TreeNode) throws -> SchematronLet? {
|
||||
|
||||
66
Tests/SchematronAbstractTests.swift
Normal file
66
Tests/SchematronAbstractTests.swift
Normal file
@ -0,0 +1,66 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
@Suite("Schematron abstract rules and extends")
|
||||
struct SchematronAbstractTests {
|
||||
private func validate(_ schema: String, _ xml: String) throws -> [PureXML.Validation.ValidationError] {
|
||||
try PureXML.Validation.Schematron(schema: schema).validate(xml)
|
||||
}
|
||||
|
||||
@Test("A concrete rule extends an abstract rule's assertions")
|
||||
func test_extends() throws {
|
||||
let schema = """
|
||||
<schema xmlns="http://purl.oclc.org/dsdl/schematron">
|
||||
<pattern>
|
||||
<rule abstract="true" id="hasId">
|
||||
<assert test="@id">element needs an id</assert>
|
||||
</rule>
|
||||
<rule context="item">
|
||||
<extends rule="hasId"/>
|
||||
<assert test="@name">item needs a name</assert>
|
||||
</rule>
|
||||
</pattern>
|
||||
</schema>
|
||||
"""
|
||||
// Both the inherited and the own assertion hold.
|
||||
#expect(try validate(schema, "<item id=\"1\" name=\"x\"/>").isEmpty)
|
||||
// The inherited assertion fails (no id).
|
||||
#expect(try validate(schema, "<item name=\"x\"/>").count == 1)
|
||||
// Both fail.
|
||||
#expect(try validate(schema, "<item/>").count == 2)
|
||||
}
|
||||
|
||||
@Test("An abstract rule does not fire on its own")
|
||||
func test_abstractDoesNotFire() throws {
|
||||
let schema = """
|
||||
<schema xmlns="http://purl.oclc.org/dsdl/schematron">
|
||||
<pattern>
|
||||
<rule abstract="true" id="never" context="thing">
|
||||
<assert test="false()">should never run</assert>
|
||||
</rule>
|
||||
</pattern>
|
||||
</schema>
|
||||
"""
|
||||
// No concrete rule extends it, so nothing fires even on a matching element.
|
||||
#expect(try validate(schema, "<thing/>").isEmpty)
|
||||
}
|
||||
|
||||
@Test("An extends pulls in the abstract rule's let bindings")
|
||||
func test_extendsLet() throws {
|
||||
let schema = """
|
||||
<schema xmlns="http://purl.oclc.org/dsdl/schematron">
|
||||
<pattern>
|
||||
<rule abstract="true" id="withLimit">
|
||||
<let name="limit" value="10"/>
|
||||
</rule>
|
||||
<rule context="n">
|
||||
<extends rule="withLimit"/>
|
||||
<assert test="number(.) < $limit">over the limit</assert>
|
||||
</rule>
|
||||
</pattern>
|
||||
</schema>
|
||||
"""
|
||||
#expect(try validate(schema, "<n>5</n>").isEmpty)
|
||||
#expect(try !validate(schema, "<n>50</n>").isEmpty)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user