PureXML/Sources/Schema/XSDParser.swift
Mihaela Mihaljevic fe19b0b1ea feat(xsd): add identity constraints (unique, key, keyref)
Validate XSD identity constraints over the instance document in a pass that
walks the tree alongside type validation:

- xs:unique requires the selected nodes' field tuples to be distinct, excluding
  any node that is missing a field.
- xs:key additionally requires every field to be present.
- xs:keyref requires each tuple to equal a tuple of the named key or unique
  visible in an enclosing scope, resolved through a stack of scope frames so a
  keyref and the key it refers to may sit on the same element.

The selector and field XPaths run on the existing XPath engine, relative to the
declaring element and each selected node. Constraints are gathered for every
element declaration at any depth, keyed by element name.

Closes #67
2026-06-08 08:58:36 +02:00

290 lines
14 KiB
Swift

extension PureXML.Schema {
/// Parses an XSD schema document into its global element declarations and its
/// named-type table. Matches the schema vocabulary by local name, so the XML
/// Schema namespace prefix may be anything. Supports global and local element
/// declarations, named and inline simple and complex types, `sequence`,
/// `choice`, and `all` model groups with occurrence, attribute uses, simple
/// content, the full facet set, `list` and `union` simple types, named
/// attribute groups and model groups, and element and group references.
enum XSDParser {
static func parse(_ xsd: String, loader: (String) -> String? = { _ in nil }) throws -> XSDCompiled {
let root = try PureXML.parseTree(xsd)
guard let schema = XSDNode.elementChildren(root).first(where: { XSDNode.localName($0) == "schema" }) else {
throw PureXML.Schema.SchemaError.notASchema
}
var visited: Set<String> = []
let containers = XSDNode.collectContainers(schema, loader, &visited)
var context = XSDContext(
simpleTypes: [:],
attributeGroups: indexByName(allChildren(containers, named: "attributeGroup")),
groups: indexByName(allChildren(containers, named: "group")),
substitutions: XSDNode.substitutionMembers(containers),
)
// Named simple types may restrict or list one another regardless of
// document order, so resolve them over repeated passes: each pass sees
// the types resolved by the previous one. A chain of length n settles
// in at most n passes.
let simpleNodes = allChildren(containers, named: "simpleType").filter { XSDNode.attribute($0, "name") != nil }
for _ in simpleNodes.indices {
for node in simpleNodes {
if let name = XSDNode.attribute(node, "name") {
context.simpleTypes[name] = XSDSimpleParser.simpleType(node, context)
}
}
}
var types: [String: ElementType] = context.simpleTypes.mapValues(ElementType.simple)
for node in allChildren(containers, named: "complexType") {
if let name = XSDNode.attribute(node, "name") {
types[name] = .complex(complexType(node, context))
}
}
var elements: [String: ElementType] = [:]
for node in allChildren(containers, named: "element") {
if let name = XSDNode.attribute(node, "name") {
let type = elementType(node, context)
elements[name] = type
// An `xs:element ref="name"` resolves to this declaration's type
// through a reserved key (a colon keeps it out of the NCName-only
// type namespace), letting forward and recursive refs resolve at
// validation time.
types[elementKey(name)] = type
}
}
return XSDCompiled(elements: elements, types: types, constraints: identityConstraints(containers))
}
/// Gathers identity constraints (`unique`, `key`, `keyref`) declared on
/// any element at any depth, keyed by the element's name.
private static func identityConstraints(_ containers: [XSDTree]) -> [String: [IdentityConstraint]] {
var map: [String: [IdentityConstraint]] = [:]
for container in containers {
for element in descendants(container, named: "element") {
guard let name = XSDNode.attribute(element, "name") else { continue }
let constraints = XSDNode.elementChildren(element).compactMap(constraint)
if !constraints.isEmpty { map[name, default: []] += constraints }
}
}
return map
}
private static func constraint(_ node: XSDTree) -> IdentityConstraint? {
let kind: IdentityConstraintKind
switch XSDNode.localName(node) {
case "unique": kind = .unique
case "key": kind = .key
case "keyref": kind = .keyref(refer: XSDNode.stripPrefix(XSDNode.attribute(node, "refer") ?? ""))
default: return nil
}
let selector = XSDNode.firstChild(node, named: "selector").flatMap { XSDNode.attribute($0, "xpath") } ?? ""
let fields = XSDNode.children(node, named: "field").compactMap { XSDNode.attribute($0, "xpath") }
return IdentityConstraint(name: XSDNode.attribute(node, "name") ?? "", kind: kind, selector: selector, fields: fields)
}
private static func descendants(_ node: XSDTree, named name: String) -> [XSDTree] {
var result: [XSDTree] = []
for child in XSDNode.elementChildren(node) {
if XSDNode.localName(child) == name { result.append(child) }
result += descendants(child, named: name)
}
return result
}
private static func allChildren(_ containers: [XSDTree], named name: String) -> [XSDTree] {
containers.flatMap { XSDNode.children($0, named: name) }
}
static func elementKey(_ name: String) -> String {
"element:\(name)"
}
private static func indexByName(_ nodes: [XSDTree]) -> [String: XSDTree] {
var index: [String: XSDTree] = [:]
for node in nodes {
if let name = XSDNode.attribute(node, "name") { index[name] = node }
}
return index
}
// MARK: Element and type references
private static func elementType(_ node: XSDTree, _ context: XSDContext) -> ElementType {
if let typeName = XSDNode.attribute(node, "type") {
return typeReference(typeName)
}
if let inline = XSDNode.firstChild(node, named: "simpleType") {
return .simple(XSDSimpleParser.simpleType(inline, context))
}
if let inline = XSDNode.firstChild(node, named: "complexType") {
return .complex(complexType(inline, context))
}
return .complex(anyType)
}
private static func typeReference(_ typeName: String) -> ElementType {
let local = XSDNode.stripPrefix(typeName)
if let builtin = BuiltinType(rawValue: local) {
return .simple(SimpleType(base: builtin))
}
return .typeReference(local)
}
private static var anyType: ComplexType {
ComplexType(
allowsOtherAttributes: true,
content: .mixed(Particle(minOccurs: 0, maxOccurs: nil, term: .wildcard(Wildcard()))),
)
}
// MARK: Complex types
private static func complexType(_ node: XSDTree, _ context: XSDContext) -> ComplexType {
let mixed = XSDNode.attribute(node, "mixed") == "true"
var attributes = attributeUses(under: node, context)
if let simpleContent = XSDNode.firstChild(node, named: "simpleContent") {
let inner = derivation(simpleContent)
attributes += inner.map { attributeUses(under: $0, context) } ?? []
return ComplexType(attributes: attributes, content: .simpleContent(simpleContentType(simpleContent, context)))
}
let container = XSDNode.firstChild(node, named: "complexContent").flatMap(derivation) ?? node
attributes += container === node ? [] : attributeUses(under: container, context)
guard let particle = modelGroup(in: container, context) else {
return ComplexType(attributes: attributes, content: .empty)
}
return ComplexType(attributes: attributes, content: mixed ? .mixed(particle) : .elementOnly(particle))
}
private static func derivation(_ node: XSDTree) -> XSDTree? {
XSDNode.firstChild(node, named: "restriction") ?? XSDNode.firstChild(node, named: "extension")
}
private static func simpleContentType(_ node: XSDTree, _ context: XSDContext) -> SimpleType {
guard let inner = derivation(node) else { return SimpleType(base: .string) }
let baseName = XSDNode.stripPrefix(XSDNode.attribute(inner, "base") ?? "string")
let base = BuiltinType(rawValue: baseName) ?? context.simpleTypes[baseName]?.base ?? .string
var facets = context.simpleTypes[baseName]?.facets ?? Facets()
XSDSimpleParser.applyFacets(inner, into: &facets)
return SimpleType(base: base, facets: facets)
}
// MARK: Attribute uses and groups
private static func attributeUses(under node: XSDTree, _ context: XSDContext, visited: Set<String> = []) -> [AttributeUse] {
var uses: [AttributeUse] = []
for child in XSDNode.elementChildren(node) {
switch XSDNode.localName(child) {
case "attribute":
if let use = attributeUse(child, context) { uses.append(use) }
case "attributeGroup":
guard let ref = XSDNode.attribute(child, "ref") else { break }
let name = XSDNode.stripPrefix(ref)
guard !visited.contains(name), let group = context.attributeGroups[name] else { break }
uses += attributeUses(under: group, context, visited: visited.union([name]))
default:
break
}
}
return uses
}
private static func attributeUse(_ node: XSDTree, _ context: XSDContext) -> AttributeUse? {
guard let name = XSDNode.attribute(node, "name") else { return nil }
let required = XSDNode.attribute(node, "use") == "required"
let type: SimpleType = if let typeName = XSDNode.attribute(node, "type") {
XSDSimpleParser.simpleTypeReference(typeName, context)
} else if let inline = XSDNode.firstChild(node, named: "simpleType") {
XSDSimpleParser.simpleType(inline, context)
} else {
SimpleType(base: .string)
}
return AttributeUse(name: PureXML.Model.QualifiedName(name), type: type, required: required)
}
// MARK: Model groups
private static func modelGroup(in node: XSDTree, _ context: XSDContext) -> Particle? {
for (name, compositor) in [("sequence", Compositor.sequence), ("choice", .choice), ("all", .all)] {
if let group = XSDNode.firstChild(node, named: name) {
return groupParticle(group, compositor, context)
}
}
if let groupRef = XSDNode.firstChild(node, named: "group") {
return particle(groupRef, context)
}
return nil
}
private static func groupParticle(
_ node: XSDTree,
_ compositor: Compositor,
_ context: XSDContext,
) -> Particle {
var particles: [Particle] = []
for child in XSDNode.elementChildren(node) {
if let member = particle(child, context) { particles.append(member) }
}
let (minimum, maximum) = XSDNode.occurrence(node)
return Particle(
minOccurs: minimum,
maxOccurs: maximum,
term: .group(Group(compositor: compositor, particles: particles)),
)
}
private static func particle(_ node: XSDTree, _ context: XSDContext) -> Particle? {
let (minimum, maximum) = XSDNode.occurrence(node)
switch XSDNode.localName(node) {
case "element":
return elementParticle(node, minimum, maximum, context)
case "sequence":
return groupParticle(node, .sequence, context)
case "choice":
return groupParticle(node, .choice, context)
case "all":
return groupParticle(node, .all, context)
case "group":
return groupReferenceParticle(node, minimum, maximum, context)
case "any":
return Particle(minOccurs: minimum, maxOccurs: maximum, term: .wildcard(Wildcard()))
default:
return nil
}
}
private static func elementParticle(_ node: XSDTree, _ minimum: Int, _ maximum: Int?, _ context: XSDContext) -> Particle {
if let ref = XSDNode.attribute(node, "ref") {
let name = XSDNode.stripPrefix(ref)
let alternatives = [name] + (context.substitutions[name] ?? [])
if alternatives.count == 1 {
return Particle(minOccurs: minimum, maxOccurs: maximum, term: elementReferenceTerm(name))
}
// The reference admits its substitution-group members, so expand it
// to a choice over the head and every member, each carrying its own
// declared type.
let members = alternatives.map { Particle(term: elementReferenceTerm($0)) }
return Particle(minOccurs: minimum, maxOccurs: maximum, term: .group(Group(compositor: .choice, particles: members)))
}
let name = XSDNode.attribute(node, "name") ?? ""
return Particle(
minOccurs: minimum,
maxOccurs: maximum,
term: .element(name: PureXML.Model.QualifiedName(name), type: elementType(node, context)),
)
}
private static func elementReferenceTerm(_ name: String) -> Term {
.element(name: PureXML.Model.QualifiedName(name), type: .typeReference(elementKey(name)))
}
private static func groupReferenceParticle(_ node: XSDTree, _ minimum: Int, _ maximum: Int?, _ context: XSDContext) -> Particle? {
guard let ref = XSDNode.attribute(node, "ref") else { return nil }
let name = XSDNode.stripPrefix(ref)
guard !context.visitingGroups.contains(name), let definition = context.groups[name],
let inner = modelGroup(in: definition, context.visiting(name))
else {
return nil
}
return Particle(minOccurs: minimum, maxOccurs: maximum, term: inner.term)
}
}
}