feat(schematron): rule-level let variable bindings
<rule> may now declare <let name= value=> bindings, evaluated at each context node in order (so a later binding can reference an earlier one through $name). The bindings are available to the rule's assert/report tests and to <value-of select="$name"> in messages, using the XPath engine's existing variable support. Schema- and pattern-level let, abstract rules/extends, diagnostics, and key()/document() remain. Part of #80
This commit is contained in:
parent
c7802ed9d0
commit
d0671e40ba
@ -61,21 +61,37 @@ public extension PureXML.Validation {
|
||||
var claimed: Set<ObjectIdentifier> = []
|
||||
for rule in pattern.rules {
|
||||
for node in rule.context.nodes(over: root) where claimed.insert(ObjectIdentifier(node)).inserted {
|
||||
errors += apply(rule.assertions, at: node)
|
||||
errors += apply(rule.assertions, at: node, variables: bindings(rule.lets, at: node))
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
private static func apply(_ assertions: [SchematronAssertion], at node: PureXML.Model.TreeNode) -> [ValidationError] {
|
||||
/// Evaluates a rule's `<let>` bindings at the context node, in order, so a
|
||||
/// later binding can reference an earlier one through `$name`.
|
||||
private static func bindings(_ lets: [SchematronLet], at node: PureXML.Model.TreeNode) -> [String: PureXML.XPath.Value] {
|
||||
var variables: [String: PureXML.XPath.Value] = [:]
|
||||
for binding in lets {
|
||||
if let value = try? binding.value.value(at: node, variables: variables) {
|
||||
variables[binding.name] = value
|
||||
}
|
||||
}
|
||||
return variables
|
||||
}
|
||||
|
||||
private static func apply(
|
||||
_ assertions: [SchematronAssertion],
|
||||
at node: PureXML.Model.TreeNode,
|
||||
variables: [String: PureXML.XPath.Value],
|
||||
) -> [ValidationError] {
|
||||
let path = codingPath(of: node)
|
||||
var errors: [ValidationError] = []
|
||||
for assertion in assertions {
|
||||
let holds = (try? assertion.test.value(at: node).boolean) ?? false
|
||||
let holds = (try? assertion.test.value(at: node, variables: variables).boolean) ?? false
|
||||
if assertion.isReport {
|
||||
if holds { errors.append(ValidationError(reason: render(assertion.message, at: node), at: path, severity: .warning)) }
|
||||
if holds { errors.append(ValidationError(reason: render(assertion.message, at: node, variables: variables), at: path, severity: .warning)) }
|
||||
} else if !holds {
|
||||
errors.append(ValidationError(reason: render(assertion.message, at: node), at: path, severity: .error))
|
||||
errors.append(ValidationError(reason: render(assertion.message, at: node, variables: variables), at: path, severity: .error))
|
||||
}
|
||||
}
|
||||
return errors
|
||||
@ -85,11 +101,15 @@ public extension PureXML.Validation {
|
||||
/// `<value-of>` as the string value of its XPath, `<name>` as the node's
|
||||
/// name. Whitespace runs are collapsed so the rendered message reads
|
||||
/// cleanly regardless of source indentation.
|
||||
private static func render(_ parts: [SchematronMessagePart], at node: PureXML.Model.TreeNode) -> String {
|
||||
private static func render(
|
||||
_ parts: [SchematronMessagePart],
|
||||
at node: PureXML.Model.TreeNode,
|
||||
variables: [String: PureXML.XPath.Value],
|
||||
) -> String {
|
||||
let joined = parts.map { part -> String in
|
||||
switch part {
|
||||
case let .text(text): text
|
||||
case let .valueOf(query): (try? query.value(at: node).string) ?? ""
|
||||
case let .valueOf(query): (try? query.value(at: node, variables: variables).string) ?? ""
|
||||
case .name: node.name?.description ?? ""
|
||||
}
|
||||
}.joined()
|
||||
|
||||
@ -17,10 +17,18 @@ extension PureXML.Validation {
|
||||
let message: [SchematronMessagePart]
|
||||
}
|
||||
|
||||
/// A `<let name= value=>` variable binding: its name and the compiled XPath
|
||||
/// whose value is bound to `$name` for the rule's assertions.
|
||||
struct SchematronLet {
|
||||
let name: String
|
||||
let value: PureXML.XPath.Query
|
||||
}
|
||||
|
||||
/// One `<rule>`: a compiled context that selects the nodes the rule fires on,
|
||||
/// and its assertions.
|
||||
/// its `<let>` variable bindings, and its assertions.
|
||||
struct SchematronRule {
|
||||
let context: PureXML.XPath.Query
|
||||
let lets: [SchematronLet]
|
||||
let assertions: [SchematronAssertion]
|
||||
}
|
||||
|
||||
|
||||
@ -30,10 +30,16 @@ extension PureXML.Validation {
|
||||
|
||||
private static func rule(_ node: PureXML.Model.TreeNode) 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
|
||||
.filter { $0.kind == .element && isAssertion($0) }
|
||||
.compactMap(assertion)
|
||||
return try SchematronRule(context: PureXML.XPath.Query(contextExpression(context)), assertions: assertions)
|
||||
return try SchematronRule(context: PureXML.XPath.Query(contextExpression(context)), lets: lets, assertions: assertions)
|
||||
}
|
||||
|
||||
private static func letBinding(_ node: PureXML.Model.TreeNode) throws -> SchematronLet? {
|
||||
guard let name = attribute(node, "name"), let value = attribute(node, "value") else { return nil }
|
||||
return try SchematronLet(name: name, value: PureXML.XPath.Query(value))
|
||||
}
|
||||
|
||||
private static func assertion(_ node: PureXML.Model.TreeNode) throws -> SchematronAssertion? {
|
||||
|
||||
59
Tests/SchematronLetTests.swift
Normal file
59
Tests/SchematronLetTests.swift
Normal file
@ -0,0 +1,59 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
@Suite("Schematron let variables")
|
||||
struct SchematronLetTests {
|
||||
private func validate(_ schema: String, _ xml: String) throws -> [PureXML.Validation.ValidationError] {
|
||||
try PureXML.Validation.Schematron(schema: schema).validate(xml)
|
||||
}
|
||||
|
||||
@Test("A let binding is available to the rule's test")
|
||||
func test_letInTest() throws {
|
||||
let schema = """
|
||||
<schema xmlns="http://purl.oclc.org/dsdl/schematron">
|
||||
<pattern>
|
||||
<rule context="order">
|
||||
<let name="total" value="sum(item/@price)"/>
|
||||
<assert test="$total <= 100">order total exceeds 100</assert>
|
||||
</rule>
|
||||
</pattern>
|
||||
</schema>
|
||||
"""
|
||||
#expect(try validate(schema, "<order><item price=\"40\"/><item price=\"50\"/></order>").isEmpty)
|
||||
#expect(try !validate(schema, "<order><item price=\"60\"/><item price=\"50\"/></order>").isEmpty)
|
||||
}
|
||||
|
||||
@Test("A let binding can be rendered in a message")
|
||||
func test_letInMessage() throws {
|
||||
let schema = """
|
||||
<schema xmlns="http://purl.oclc.org/dsdl/schematron">
|
||||
<pattern>
|
||||
<rule context="age">
|
||||
<let name="limit" value="100"/>
|
||||
<assert test="number(.) < $limit">Age <value-of select="."/> reaches the limit <value-of select="$limit"/></assert>
|
||||
</rule>
|
||||
</pattern>
|
||||
</schema>
|
||||
"""
|
||||
let errors = try validate(schema, "<age>120</age>")
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.reason == "Age 120 reaches the limit 100")
|
||||
}
|
||||
|
||||
@Test("A later let can reference an earlier one")
|
||||
func test_letChaining() throws {
|
||||
let schema = """
|
||||
<schema xmlns="http://purl.oclc.org/dsdl/schematron">
|
||||
<pattern>
|
||||
<rule context="box">
|
||||
<let name="base" value="number(@n)"/>
|
||||
<let name="doubled" value="$base * 2"/>
|
||||
<assert test="$doubled = 10">doubled is not ten</assert>
|
||||
</rule>
|
||||
</pattern>
|
||||
</schema>
|
||||
"""
|
||||
#expect(try validate(schema, "<box n=\"5\"/>").isEmpty)
|
||||
#expect(try !validate(schema, "<box n=\"3\"/>").isEmpty)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user