Merge epic/25-schematron: Schematron rule validation
This commit is contained in:
commit
e8a5d3200e
@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Schematron validation (#25, the libxml2 `schematron.h` model).
|
||||
`PureXML.Validation.Schematron(schema:)` compiles a Schematron schema
|
||||
(namespace-agnostic over the ISO and legacy namespaces) and `validate(_:)`
|
||||
checks a document: each `<rule>`'s `context` XPath selects the nodes it fires
|
||||
on, and each `<assert>`/`<report>` `test` is evaluated relative to that node, a
|
||||
failed assert becoming an error issue and a matched report a warning. Within a
|
||||
pattern a node fires the first matching rule. Built on the XPath context-node
|
||||
evaluation, with a new `XPath.Query.nodes(over:)` that returns matched tree
|
||||
nodes for repeated querying of one tree.
|
||||
- XPointer (#23, the libxml2 `xpointer.h` schemes). The new `PureXML.XPointer`
|
||||
namespace resolves the shorthand bare-name form (`name` = `id('name')`), the
|
||||
`element()` scheme (child-element positions from an id or the document root,
|
||||
|
||||
@ -92,7 +92,7 @@ flowchart TB
|
||||
Querying["Querying: #21 XPath, #22 xmlPattern, #23 XPointer (shipped)"]:::done
|
||||
Regexp["#30 Regular expression engine"]:::epic
|
||||
Xsd["#2 XSD / RELAX NG validation"]:::epic
|
||||
Schematron["#25 Schematron validation"]:::epic
|
||||
Schematron["#25 Schematron validation"]:::done
|
||||
Regexp --> Xsd
|
||||
Querying --> Schematron
|
||||
```
|
||||
|
||||
53
Sources/Validation/Schematron.swift
Normal file
53
Sources/Validation/Schematron.swift
Normal file
@ -0,0 +1,53 @@
|
||||
public extension PureXML.Validation {
|
||||
/// Rule-based assertion validation (the libxml2 `schematron.h` model).
|
||||
///
|
||||
/// A Schematron schema groups `<rule>`s into `<pattern>`s. Each rule has a
|
||||
/// `context` XPath selecting the nodes it fires on, and `<assert>`/`<report>`
|
||||
/// children whose `test` XPath is evaluated relative to each context node. An
|
||||
/// `assert` whose test is false, and a `report` whose test is true, produce an
|
||||
/// ``Issue`` carrying the element's message.
|
||||
///
|
||||
/// Within a pattern a node is processed by the first rule whose context
|
||||
/// selects it. Dynamic message content (`<value-of>`, `<name>`) is rendered as
|
||||
/// its static text; the message substitution itself is not evaluated.
|
||||
struct Schematron {
|
||||
private let patterns: [SchematronPattern]
|
||||
|
||||
/// Compiles a Schematron schema document. Throws if a context or test is
|
||||
/// not a valid XPath expression.
|
||||
public init(schema xml: String) throws {
|
||||
patterns = try SchematronParser.parse(xml)
|
||||
}
|
||||
|
||||
/// Validates an XML document against the schema, returning one issue per
|
||||
/// failed assertion and per matched report, in document order.
|
||||
public func validate(_ xml: String) throws -> [Issue] {
|
||||
let root = try PureXML.parseTree(xml)
|
||||
var issues: [Issue] = []
|
||||
for pattern in patterns {
|
||||
evaluate(pattern, over: root, into: &issues)
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
private func evaluate(_ pattern: SchematronPattern, over root: PureXML.Model.TreeNode, into issues: inout [Issue]) {
|
||||
var claimed: Set<ObjectIdentifier> = []
|
||||
for rule in pattern.rules {
|
||||
for node in rule.context.nodes(over: root) where claimed.insert(ObjectIdentifier(node)).inserted {
|
||||
apply(rule.assertions, at: node, into: &issues)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func apply(_ assertions: [SchematronAssertion], at node: PureXML.Model.TreeNode, into issues: inout [Issue]) {
|
||||
for assertion in assertions {
|
||||
let holds = (try? assertion.test.value(at: node).boolean) ?? false
|
||||
if assertion.isReport {
|
||||
if holds { issues.append(Issue(severity: .warning, message: assertion.message)) }
|
||||
} else if !holds {
|
||||
issues.append(Issue(severity: .error, message: assertion.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
Sources/Validation/SchematronModel.swift
Normal file
22
Sources/Validation/SchematronModel.swift
Normal file
@ -0,0 +1,22 @@
|
||||
extension PureXML.Validation {
|
||||
/// One `<assert>` or `<report>`: a compiled XPath test and its message. An
|
||||
/// assert flags when its test is false; a report flags when its test is true.
|
||||
struct SchematronAssertion {
|
||||
let isReport: Bool
|
||||
let test: PureXML.XPath.Query
|
||||
let message: String
|
||||
}
|
||||
|
||||
/// One `<rule>`: a compiled context that selects the nodes the rule fires on,
|
||||
/// and its assertions.
|
||||
struct SchematronRule {
|
||||
let context: PureXML.XPath.Query
|
||||
let assertions: [SchematronAssertion]
|
||||
}
|
||||
|
||||
/// One `<pattern>`: a list of rules. A node is processed by the first rule in
|
||||
/// the pattern whose context selects it (the ISO Schematron firing rule).
|
||||
struct SchematronPattern {
|
||||
let rules: [SchematronRule]
|
||||
}
|
||||
}
|
||||
62
Sources/Validation/SchematronParser.swift
Normal file
62
Sources/Validation/SchematronParser.swift
Normal file
@ -0,0 +1,62 @@
|
||||
extension PureXML.Validation {
|
||||
/// Parses a Schematron schema document into patterns. Namespace-agnostic: it
|
||||
/// matches the `schema`, `pattern`, `rule`, `assert`, and `report` elements by
|
||||
/// local name, so both the ISO and the legacy Schematron namespaces work.
|
||||
enum SchematronParser {
|
||||
static func parse(_ xml: String) throws -> [SchematronPattern] {
|
||||
let root = try PureXML.parseTree(xml)
|
||||
return try descendants(root, localName: "pattern").map(pattern)
|
||||
}
|
||||
|
||||
private static func pattern(_ node: PureXML.Model.TreeNode) throws -> SchematronPattern {
|
||||
try SchematronPattern(rules: children(node, localName: "rule").compactMap(rule))
|
||||
}
|
||||
|
||||
private static func rule(_ node: PureXML.Model.TreeNode) throws -> SchematronRule? {
|
||||
guard let context = attribute(node, "context") else { return nil }
|
||||
let assertions = try node.children
|
||||
.filter { $0.kind == .element && isAssertion($0) }
|
||||
.compactMap(assertion)
|
||||
return try SchematronRule(context: PureXML.XPath.Query(contextExpression(context)), assertions: assertions)
|
||||
}
|
||||
|
||||
private static func assertion(_ node: PureXML.Model.TreeNode) throws -> SchematronAssertion? {
|
||||
guard let test = attribute(node, "test") else { return nil }
|
||||
let isReport = node.name?.localName == "report"
|
||||
return try SchematronAssertion(
|
||||
isReport: isReport,
|
||||
test: PureXML.XPath.Query(test),
|
||||
message: node.stringValue.trimmingXMLWhitespace(),
|
||||
)
|
||||
}
|
||||
|
||||
private static func isAssertion(_ node: PureXML.Model.TreeNode) -> Bool {
|
||||
node.name?.localName == "assert" || node.name?.localName == "report"
|
||||
}
|
||||
|
||||
/// A relative context selects matching nodes anywhere, so it is searched
|
||||
/// from the document via `//`; an absolute context is used as written.
|
||||
private static func contextExpression(_ context: String) -> String {
|
||||
context.hasPrefix("/") ? context : "//" + context
|
||||
}
|
||||
|
||||
private static func attribute(_ node: PureXML.Model.TreeNode, _ name: String) -> String? {
|
||||
node.attributes.first { $0.name.localName == name || $0.name.description == name }?.value
|
||||
}
|
||||
|
||||
private static func children(_ node: PureXML.Model.TreeNode, localName: String) -> [PureXML.Model.TreeNode] {
|
||||
node.children.filter { $0.kind == .element && $0.name?.localName == localName }
|
||||
}
|
||||
|
||||
private static func descendants(_ node: PureXML.Model.TreeNode, localName: String) -> [PureXML.Model.TreeNode] {
|
||||
var result: [PureXML.Model.TreeNode] = []
|
||||
for child in node.children {
|
||||
if child.kind == .element, child.name?.localName == localName {
|
||||
result.append(child)
|
||||
}
|
||||
result.append(contentsOf: descendants(child, localName: localName))
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -29,6 +29,23 @@ extension PureXML.XPath {
|
||||
try eval(expression, rootContext(node, variables: variables))
|
||||
}
|
||||
|
||||
/// Evaluates a node-set expression against a pre-built tree and returns the
|
||||
/// matched tree nodes in document order (attribute and namespace results
|
||||
/// are dropped). Lets a caller build the tree once and query it repeatedly.
|
||||
static func nodes(_ expression: Expression, over root: PureXML.Model.TreeNode) -> [PureXML.Model.TreeNode] {
|
||||
let context = EvaluationContext(
|
||||
node: .tree(root),
|
||||
position: 1,
|
||||
size: 1,
|
||||
variables: [:],
|
||||
functions: library,
|
||||
)
|
||||
guard let value = try? eval(expression, context), case let .nodeSet(nodes) = value else {
|
||||
return []
|
||||
}
|
||||
return orderUnique(nodes).compactMap(\.treeNode)
|
||||
}
|
||||
|
||||
/// Evaluates an expression against an explicit context: a node already in a
|
||||
/// tree, its proximity position and size, and variable bindings. This is
|
||||
/// the entry point downstream engines (XSLT, Schematron) drive per node.
|
||||
|
||||
@ -38,6 +38,14 @@ public extension PureXML.XPath {
|
||||
try Evaluator.value(expression, over: node, variables: variables)
|
||||
}
|
||||
|
||||
/// Evaluates the query over a pre-built tree and returns the matched tree
|
||||
/// nodes in document order. Build the tree once (``PureXML/parseTree(_:)``)
|
||||
/// and query it repeatedly; pair with ``value(at:position:size:variables:)``
|
||||
/// to evaluate further expressions relative to each result.
|
||||
public func nodes(over root: PureXML.Model.TreeNode) -> [PureXML.Model.TreeNode] {
|
||||
Evaluator.nodes(expression, over: root)
|
||||
}
|
||||
|
||||
/// Evaluates the query against an explicit context: a node already in a
|
||||
/// tree (``PureXML/Model/TreeNode``), its one-based proximity `position`
|
||||
/// within a node-set of `size`, and variable bindings. Downstream engines
|
||||
|
||||
76
Tests/SchematronTests.swift
Normal file
76
Tests/SchematronTests.swift
Normal file
@ -0,0 +1,76 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
@Suite("Schematron")
|
||||
struct SchematronTests {
|
||||
private let schema = """
|
||||
<schema xmlns="http://purl.oclc.org/dsdl/schematron">
|
||||
<pattern>
|
||||
<rule context="book">
|
||||
<assert test="title">a book must have a title</assert>
|
||||
<assert test="@isbn">a book must have an isbn</assert>
|
||||
<report test="count(author) > 2">more than two authors</report>
|
||||
</rule>
|
||||
</pattern>
|
||||
</schema>
|
||||
"""
|
||||
|
||||
private func validate(_ xml: String) throws -> [PureXML.Validation.Issue] {
|
||||
try PureXML.Validation.Schematron(schema: schema).validate(xml)
|
||||
}
|
||||
|
||||
@Test("A conforming document produces no issues")
|
||||
func test_valid() throws {
|
||||
let xml = "<library><book isbn=\"1\"><title>T</title><author>A</author></book></library>"
|
||||
#expect(try validate(xml).isEmpty)
|
||||
}
|
||||
|
||||
@Test("A failed assert is reported as an error")
|
||||
func test_failedAssert() throws {
|
||||
let xml = "<library><book isbn=\"1\"><author>A</author></book></library>"
|
||||
let issues = try validate(xml)
|
||||
#expect(issues.count == 1)
|
||||
#expect(issues.first?.severity == .error)
|
||||
#expect(issues.first?.message == "a book must have a title")
|
||||
}
|
||||
|
||||
@Test("Multiple failed asserts each report")
|
||||
func test_multipleAsserts() throws {
|
||||
let xml = "<library><book><author>A</author></book></library>"
|
||||
let messages = try validate(xml).map(\.message)
|
||||
#expect(messages.contains("a book must have a title"))
|
||||
#expect(messages.contains("a book must have an isbn"))
|
||||
}
|
||||
|
||||
@Test("A matched report is a warning")
|
||||
func test_report() throws {
|
||||
let xml = "<library><book isbn=\"1\"><title>T</title>"
|
||||
+ "<author>A</author><author>B</author><author>C</author></book></library>"
|
||||
let issues = try validate(xml)
|
||||
#expect(issues.count == 1)
|
||||
#expect(issues.first?.severity == .warning)
|
||||
#expect(issues.first?.message == "more than two authors")
|
||||
}
|
||||
|
||||
@Test("Rules fire once per matching node across the document")
|
||||
func test_perNode() throws {
|
||||
let xml = "<library>"
|
||||
+ "<book isbn=\"1\"><title>T</title></book>"
|
||||
+ "<book><author>A</author></book>"
|
||||
+ "</library>"
|
||||
// The second book is missing both title and isbn.
|
||||
let issues = try validate(xml)
|
||||
#expect(issues.count == 2)
|
||||
#expect(issues.allSatisfy { $0.severity == .error })
|
||||
}
|
||||
|
||||
@Test("An invalid test expression is rejected at compile time")
|
||||
func test_invalidSchema() {
|
||||
let bad = """
|
||||
<schema><pattern><rule context="book"><assert test="(">x</assert></rule></pattern></schema>
|
||||
"""
|
||||
#expect(throws: Error.self) {
|
||||
_ = try PureXML.Validation.Schematron(schema: bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user