feat(schematron): support key(), document(), and current() in tests
Schematron tests, lets, and message expressions can now use key() backed by xsl:key declarations parsed from the schema, document() loading an external file through an injected documentLoader, and current() resolving to the rule context node. A node-keyed function-table factory threads through the evaluation. Completes the Schematron audit.
This commit is contained in:
parent
e1a0b5698c
commit
2db9915fb2
@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Schematron `key()`, `document()`, and `current()` in tests (#80), completing the Schematron audit. Test, let, and message expressions can now resolve nodes through `xsl:key` declarations, load an external document for cross-document checks (via an injected `documentLoader`), and refer to the rule context node with `current()`.
|
||||
- RELAX NG compact `>>` follow-annotations (#79), completing the RNC syntax. A `>>` annotation after a pattern (a foreign element name with optional `[ … ]` content) is skipped, chaining and combining with leading `[ … ]` annotations; both carry no schema semantics. (Namespace declarations inside annotations are already dropped by the annotation skipper.)
|
||||
- XSLT `document()` fragments and base-URI resolution (#82), completing the XSLT function/output audit. A `#fragment` selects a subset of the loaded document via XPointer, a node-set first argument unions the documents named by each node, and a relative URI is resolved against a caller-supplied `baseURI` (also applied to `xsl:include`/`xsl:import`).
|
||||
- XSLT result-tree fragments usable as node-sets (#82). An `xsl:variable` with a body now binds a queryable document fragment instead of a plain string, so `count($rtf/*)`, `$rtf/child`, and `xsl:for-each select="$rtf/..."` work; it still has its concatenated text as a string value.
|
||||
|
||||
@ -1,3 +1,10 @@
|
||||
/// A node-keyed function-table factory, so `current()`, `key()`, and `document()`
|
||||
/// are available in every Schematron test, let, and message expression. File scope
|
||||
/// and private.
|
||||
private typealias Functions = (PureXML.Model.TreeNode) -> PureXML.XPath.FunctionTable
|
||||
/// An `xsl:key` index: key name to value to the matched nodes. File scope and private.
|
||||
private typealias KeyIndex = [String: [String: [PureXML.Model.TreeNode]]]
|
||||
|
||||
public extension PureXML.Validation {
|
||||
/// Rule-based assertion validation (the libxml2 `schematron.h` model).
|
||||
///
|
||||
@ -26,9 +33,9 @@ public extension PureXML.Validation {
|
||||
/// are errors and reports are warnings. With `phase` (or the schema's
|
||||
/// `defaultPhase`), only the patterns that phase activates run; nil or
|
||||
/// `#ALL` runs every pattern.
|
||||
public func validate(_ xml: String, phase: String? = nil) throws -> [ValidationError] {
|
||||
public func validate(_ xml: String, phase: String? = nil, documentLoader: @escaping (String) -> String? = { _ in nil }) throws -> [ValidationError] {
|
||||
let node = try PureXML.parse(xml)
|
||||
let validation = Self.rule(activePatterns(phase: phase), schemaLets: schema.lets, diagnostics: schema.diagnostics)
|
||||
let validation = Self.rule(activePatterns(phase: phase), schemaLets: schema.lets, diagnostics: schema.diagnostics, keys: schema.keys, loader: documentLoader)
|
||||
return Validator<Void>.blank.validating(validation).errors(for: node, in: ())
|
||||
}
|
||||
|
||||
@ -49,25 +56,60 @@ public extension PureXML.Validation {
|
||||
_ patterns: [SchematronPattern],
|
||||
schemaLets: [SchematronLet],
|
||||
diagnostics: [String: [SchematronMessagePart]],
|
||||
keys: [SchematronKey] = [],
|
||||
loader: @escaping (String) -> String? = { _ in nil },
|
||||
) -> Validation<PureXML.Model.Node, Void> {
|
||||
.init(
|
||||
description: "Document satisfies the Schematron schema",
|
||||
check: { context in
|
||||
let root = PureXML.Model.TreeNode(context.subject)
|
||||
let base = bindings(schemaLets, at: root, base: [:])
|
||||
return evaluate(patterns, over: root, base: base, diagnostics: diagnostics)
|
||||
let keyIndex = keyIndex(keys, root)
|
||||
let functions: Functions = { node in functionTable(at: node, keyIndex: keyIndex, loader: loader) }
|
||||
let base = bindings(schemaLets, at: root, base: [:], functions)
|
||||
return evaluate(patterns, over: root, base: base, diagnostics: diagnostics, functions)
|
||||
},
|
||||
when: { $0.codingPath.isEmpty },
|
||||
)
|
||||
}
|
||||
|
||||
/// Indexes every `xsl:key`-matched node by its `use` value, so `key()` can
|
||||
/// resolve a name and value to nodes.
|
||||
private static func keyIndex(_ keys: [SchematronKey], _ root: PureXML.Model.TreeNode) -> KeyIndex {
|
||||
var index: KeyIndex = [:]
|
||||
for key in keys {
|
||||
for node in key.match.nodes(over: root) {
|
||||
let value = (try? key.use.value(at: node).string) ?? ""
|
||||
index[key.name, default: [:]][value, default: []].append(node)
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
/// The XSLT-style functions for a context node: `current`, `key` (over the
|
||||
/// prebuilt index), and `document` (through the injected loader).
|
||||
private static func functionTable(at node: PureXML.Model.TreeNode, keyIndex: KeyIndex, loader: @escaping (String) -> String?) -> PureXML.XPath.FunctionTable {
|
||||
PureXML.XPath.FunctionTable()
|
||||
.adding("current") { _, _ in .nodeSet([.tree(node)]) }
|
||||
.adding("key") { arguments, _ in
|
||||
let name = arguments.first?.string ?? ""
|
||||
let value = arguments.count > 1 ? arguments[1].string : ""
|
||||
return .nodeSet((keyIndex[name]?[value] ?? []).map { .tree($0) })
|
||||
}
|
||||
.adding("document") { arguments, _ in
|
||||
guard let uri = arguments.first?.string, let text = loader(uri),
|
||||
let parsed = try? PureXML.parse(text) else { return .nodeSet([]) }
|
||||
return .nodeSet([.tree(PureXML.Model.TreeNode(parsed))])
|
||||
}
|
||||
}
|
||||
|
||||
private static func evaluate(
|
||||
_ patterns: [SchematronPattern],
|
||||
over root: PureXML.Model.TreeNode,
|
||||
base: [String: PureXML.XPath.Value],
|
||||
diagnostics: [String: [SchematronMessagePart]],
|
||||
_ functions: Functions,
|
||||
) -> [ValidationError] {
|
||||
patterns.flatMap { evaluate($0, over: root, base: base, diagnostics: diagnostics) }
|
||||
patterns.flatMap { evaluate($0, over: root, base: base, diagnostics: diagnostics, functions) }
|
||||
}
|
||||
|
||||
private static func evaluate(
|
||||
@ -75,15 +117,16 @@ public extension PureXML.Validation {
|
||||
over root: PureXML.Model.TreeNode,
|
||||
base: [String: PureXML.XPath.Value],
|
||||
diagnostics: [String: [SchematronMessagePart]],
|
||||
_ functions: Functions,
|
||||
) -> [ValidationError] {
|
||||
// Pattern-level lets extend the schema-level base, evaluated at the root.
|
||||
let patternBase = bindings(pattern.lets, at: root, base: base)
|
||||
let patternBase = bindings(pattern.lets, at: root, base: base, functions)
|
||||
var errors: [ValidationError] = []
|
||||
var claimed: Set<ObjectIdentifier> = []
|
||||
for rule in pattern.rules {
|
||||
for node in rule.context.nodes(over: root) where claimed.insert(ObjectIdentifier(node)).inserted {
|
||||
let variables = bindings(rule.lets, at: node, base: patternBase)
|
||||
errors += apply(rule.assertions, at: node, variables: variables, diagnostics: diagnostics)
|
||||
let variables = bindings(rule.lets, at: node, base: patternBase, functions)
|
||||
errors += apply(rule.assertions, at: node, variables: variables, diagnostics: diagnostics, functions)
|
||||
}
|
||||
}
|
||||
return errors
|
||||
@ -92,10 +135,15 @@ public extension PureXML.Validation {
|
||||
/// Evaluates `<let>` bindings over `base` at `node`, in order, so a later
|
||||
/// binding can reference an earlier one (or a wider-scope one) through
|
||||
/// `$name`.
|
||||
private static func bindings(_ lets: [SchematronLet], at node: PureXML.Model.TreeNode, base: [String: PureXML.XPath.Value]) -> [String: PureXML.XPath.Value] {
|
||||
private static func bindings(
|
||||
_ lets: [SchematronLet],
|
||||
at node: PureXML.Model.TreeNode,
|
||||
base: [String: PureXML.XPath.Value],
|
||||
_ functions: Functions,
|
||||
) -> [String: PureXML.XPath.Value] {
|
||||
var variables = base
|
||||
for binding in lets {
|
||||
if let value = try? binding.value.value(at: node, variables: variables) {
|
||||
if let value = try? binding.value.value(at: node, position: 1, size: 1, variables: variables, functions: functions(node)) {
|
||||
variables[binding.name] = value
|
||||
}
|
||||
}
|
||||
@ -107,14 +155,15 @@ public extension PureXML.Validation {
|
||||
at node: PureXML.Model.TreeNode,
|
||||
variables: [String: PureXML.XPath.Value],
|
||||
diagnostics: [String: [SchematronMessagePart]],
|
||||
_ functions: Functions,
|
||||
) -> [ValidationError] {
|
||||
let path = codingPath(of: node)
|
||||
var errors: [ValidationError] = []
|
||||
for assertion in assertions {
|
||||
let holds = (try? assertion.test.value(at: node, variables: variables).boolean) ?? false
|
||||
let holds = (try? assertion.test.value(at: node, position: 1, size: 1, variables: variables, functions: functions(node)).boolean) ?? false
|
||||
let flagged = assertion.isReport ? holds : !holds
|
||||
guard flagged else { continue }
|
||||
let reason = reason(assertion, at: node, variables: variables, diagnostics: diagnostics)
|
||||
let reason = reason(assertion, at: node, variables: variables, diagnostics: diagnostics, functions)
|
||||
errors.append(ValidationError(reason: reason, at: path, severity: assertion.isReport ? .warning : .error))
|
||||
}
|
||||
return errors
|
||||
@ -127,11 +176,12 @@ public extension PureXML.Validation {
|
||||
at node: PureXML.Model.TreeNode,
|
||||
variables: [String: PureXML.XPath.Value],
|
||||
diagnostics: [String: [SchematronMessagePart]],
|
||||
_ functions: Functions,
|
||||
) -> String {
|
||||
var text = render(assertion.message, at: node, variables: variables)
|
||||
var text = render(assertion.message, at: node, variables: variables, functions)
|
||||
for id in assertion.diagnostics {
|
||||
guard let parts = diagnostics[id] else { continue }
|
||||
let rendered = render(parts, at: node, variables: variables)
|
||||
let rendered = render(parts, at: node, variables: variables, functions)
|
||||
if !rendered.isEmpty { text += " " + rendered }
|
||||
}
|
||||
return text
|
||||
@ -145,11 +195,12 @@ public extension PureXML.Validation {
|
||||
_ parts: [SchematronMessagePart],
|
||||
at node: PureXML.Model.TreeNode,
|
||||
variables: [String: PureXML.XPath.Value],
|
||||
_ functions: Functions,
|
||||
) -> String {
|
||||
let joined = parts.map { part -> String in
|
||||
switch part {
|
||||
case let .text(text): text
|
||||
case let .valueOf(query): (try? query.value(at: node, variables: variables).string) ?? ""
|
||||
case let .valueOf(query): (try? query.value(at: node, position: 1, size: 1, variables: variables, functions: functions(node)).string) ?? ""
|
||||
case .name: node.name?.description ?? ""
|
||||
}
|
||||
}.joined()
|
||||
|
||||
@ -27,6 +27,15 @@ extension PureXML.Validation {
|
||||
let value: PureXML.XPath.Query
|
||||
}
|
||||
|
||||
/// An `xsl:key` declaration (the XSLT query binding): its name, the compiled
|
||||
/// match selecting indexed nodes, and the `use` expression giving each node's
|
||||
/// key value, so the `key()` function can look nodes up by value.
|
||||
struct SchematronKey {
|
||||
let name: String
|
||||
let match: PureXML.XPath.Query
|
||||
let use: PureXML.XPath.Query
|
||||
}
|
||||
|
||||
/// One `<rule>`: a compiled context that selects the nodes the rule fires on,
|
||||
/// its `<let>` variable bindings, and its assertions.
|
||||
struct SchematronRule {
|
||||
@ -56,5 +65,7 @@ extension PureXML.Validation {
|
||||
/// `<diagnostic id=>` message templates, keyed by id, referenced from an
|
||||
/// assertion's `diagnostics=` attribute.
|
||||
let diagnostics: [String: [SchematronMessagePart]]
|
||||
/// `xsl:key` declarations backing the `key()` function in tests.
|
||||
let keys: [SchematronKey]
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,9 +20,23 @@ extension PureXML.Validation {
|
||||
defaultPhase: attribute(schema, "defaultPhase"),
|
||||
lets: ruleLets(schema),
|
||||
diagnostics: diagnostics(root),
|
||||
keys: keys(root),
|
||||
)
|
||||
}
|
||||
|
||||
/// The `xsl:key` declarations (matched by local name `key`, so the XSLT
|
||||
/// namespace is not required) that back the `key()` function.
|
||||
private static func keys(_ root: PureXML.Model.TreeNode) throws -> [SchematronKey] {
|
||||
try descendants(root, localName: "key").compactMap { node in
|
||||
guard let name = attribute(node, "name"), let match = attribute(node, "match") else { return nil }
|
||||
return try SchematronKey(
|
||||
name: name,
|
||||
match: PureXML.XPath.Query(contextExpression(match)),
|
||||
use: PureXML.XPath.Query(attribute(node, "use") ?? "."),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the concrete patterns: an `abstract="true"` pattern is a template
|
||||
/// held aside, an `is-a` pattern is instantiated from its template with each
|
||||
/// `<param name= value=>` substituted for the matching `$name` reference in
|
||||
|
||||
77
Tests/SchematronFunctionTests.swift
Normal file
77
Tests/SchematronFunctionTests.swift
Normal file
@ -0,0 +1,77 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
@Suite("Schematron key()/document()/current() in tests")
|
||||
struct SchematronFunctionTests {
|
||||
private func validate(_ schema: String, _ xml: String, loader: @escaping (String) -> String? = { _ in nil }) throws -> [PureXML.Validation.ValidationError] {
|
||||
try PureXML.Validation.Schematron(schema: schema).validate(xml, documentLoader: loader)
|
||||
}
|
||||
|
||||
private let sch = "http://purl.oclc.org/dsdl/schematron"
|
||||
private let xsl = "http://www.w3.org/1999/XSL/Transform"
|
||||
|
||||
@Test("key() resolves a node by an xsl:key index")
|
||||
func test_key() throws {
|
||||
let schema = """
|
||||
<schema xmlns="\(sch)" xmlns:xsl="\(xsl)">
|
||||
<xsl:key name="byId" match="item" use="@id"/>
|
||||
<pattern>
|
||||
<rule context="ref">
|
||||
<assert test="key('byId', @to)">no item with that id</assert>
|
||||
</rule>
|
||||
</pattern>
|
||||
</schema>
|
||||
"""
|
||||
// ref/@to=a resolves to item id=a; ref/@to=z resolves to nothing -> one failure.
|
||||
let xml = "<root><item id='a'/><item id='b'/><ref to='a'/><ref to='z'/></root>"
|
||||
let errors = try validate(schema, xml)
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.reason.contains("no item") == true)
|
||||
}
|
||||
|
||||
@Test("document() loads an external file for cross-document checks")
|
||||
func test_document() throws {
|
||||
let schema = """
|
||||
<schema xmlns="\(sch)">
|
||||
<pattern>
|
||||
<rule context="ref">
|
||||
<assert test="@to = document('allowed.xml')/allowed/id">id is not in the allowed list</assert>
|
||||
</rule>
|
||||
</pattern>
|
||||
</schema>
|
||||
"""
|
||||
let allowed = "<allowed><id>a</id><id>b</id></allowed>"
|
||||
let xml = "<root><ref to='a'/><ref to='x'/></root>"
|
||||
let errors = try validate(schema, xml) { $0 == "allowed.xml" ? allowed : nil }
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.reason.contains("not in the allowed") == true)
|
||||
}
|
||||
|
||||
@Test("current() refers to the rule's context node inside a predicate")
|
||||
func test_current() throws {
|
||||
let schema = """
|
||||
<schema xmlns="\(sch)">
|
||||
<pattern>
|
||||
<rule context="item">
|
||||
<assert test="//item[@id = current()/@ref]">the referenced item must exist</assert>
|
||||
</rule>
|
||||
</pattern>
|
||||
</schema>
|
||||
"""
|
||||
// item ref=b resolves (item id=b exists); item ref=z does not -> one failure.
|
||||
let xml = "<root><item id='a' ref='b'/><item id='b' ref='z'/></root>"
|
||||
let errors = try validate(schema, xml)
|
||||
#expect(errors.count == 1)
|
||||
}
|
||||
|
||||
@Test("A schema without these functions still validates")
|
||||
func test_plain() throws {
|
||||
let schema = """
|
||||
<schema xmlns="\(sch)">
|
||||
<pattern><rule context="n"><assert test="number(.) < 10">too big</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