moxml 0.5.157 → 0.5.158
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/benchmark/README.md +10 -10
- data/benchmark/scoreboard.json +38 -38
- data/docs/_pages/performance.adoc +13 -15
- data/lib/moxml/context.rb +6 -2
- data/lib/moxml/version.rb +1 -1
- data/lib/moxml/xsd/schema_rules.rb +335 -0
- data/lib/moxml/xsd.rb +15 -2
- data/spec/moxml/xsd_underdetection_spec.rb +174 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a173c6faca10ad59c37096bc873999f16f7e5b05ae7afb9f2eba6041ec8147a2
|
|
4
|
+
data.tar.gz: e430658fbe932063794a0a04d2c05224a367da83ebc2f55c9eef8d7e67e431af
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a68cad1dab561fa57921867cf856f9222e21f0413e7e4611dce3fa7f91e23b6ae52dc260921e03c8a0f83185ce1d7a6bd66f26e394151c55d08fe63b1b310686
|
|
7
|
+
data.tar.gz: 63ffc8a538f79c85bbc3179875d489f0ee20b8c36ae2498eac1694f0e1538e0ecf07e796a8364613fbe54d9a656da34965e87e9c1165960bd7815c19939e0669
|
data/benchmark/README.md
CHANGED
|
@@ -92,16 +92,16 @@ rewrites this block and `docs/_pages/performance.adoc`):
|
|
|
92
92
|
<!-- SCOREBOARD:BEGIN -->
|
|
93
93
|
| Row | Ratio | Kind | Status |
|
|
94
94
|
| --- | ----- | ---- | ------ |
|
|
95
|
-
| `parse` |
|
|
96
|
-
| `serialize` | 1.
|
|
97
|
-
| `xpath //field (count)` |
|
|
98
|
-
| `xpath //field (each)` | 0.
|
|
99
|
-
| `attr pairs listing` | 7.
|
|
100
|
-
| `build add_element` | 0.
|
|
101
|
-
| `walk+read (warm)` | 0.
|
|
102
|
-
| `walk+read (cold)` | 0.
|
|
103
|
-
| `children recursion (cold)` | 0.
|
|
104
|
-
| `plan records (typed)` | 1.
|
|
95
|
+
| `parse` | 3.59x | parity | met |
|
|
96
|
+
| `serialize` | 1.83x | parity | met |
|
|
97
|
+
| `xpath //field (count)` | 4.79x | parity | met |
|
|
98
|
+
| `xpath //field (each)` | 0.44x | structural | tracked (below parity) |
|
|
99
|
+
| `attr pairs listing` | 7.46x | parity | met |
|
|
100
|
+
| `build add_element` | 0.57x | structural | tracked (below parity) |
|
|
101
|
+
| `walk+read (warm)` | 0.64x | structural | tracked (below parity) |
|
|
102
|
+
| `walk+read (cold)` | 0.17x | structural | tracked (below parity), paused: leptris-ruby#527 (1.9.380 crossing regression) |
|
|
103
|
+
| `children recursion (cold)` | 0.13x | structural | tracked (below parity), paused: leptris-ruby#527 (1.9.380 crossing regression) |
|
|
104
|
+
| `plan records (typed)` | 1.14x | data | informational |
|
|
105
105
|
<!-- SCOREBOARD:END -->
|
|
106
106
|
|
|
107
107
|
The open rows are the known structural gap: every node the facade
|
data/benchmark/scoreboard.json
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"protocol": {
|
|
3
|
-
"measured": "2026-10-
|
|
4
|
-
"ruby": "3.3.
|
|
3
|
+
"measured": "2026-10-23T15:54:45Z",
|
|
4
|
+
"ruby": "3.3.13",
|
|
5
5
|
"adapter": "leptris",
|
|
6
|
-
"adapter_version": "1.9.
|
|
6
|
+
"adapter_version": "1.9.381.0",
|
|
7
7
|
"nokogiri": "1.19.4",
|
|
8
8
|
"reps": 9,
|
|
9
9
|
"warmup": 2,
|
|
10
10
|
"clock": "CLOCK_PROCESS_CPUTIME_ID",
|
|
11
|
-
"load": 1.
|
|
11
|
+
"load": 1.87
|
|
12
12
|
},
|
|
13
13
|
"rows": [
|
|
14
14
|
{
|
|
15
15
|
"row": "parse",
|
|
16
16
|
"kind": "parity",
|
|
17
17
|
"parity": true,
|
|
18
|
-
"baseline_us":
|
|
19
|
-
"candidate_us":
|
|
20
|
-
"ratio":
|
|
18
|
+
"baseline_us": 1027.8,
|
|
19
|
+
"candidate_us": 286.6,
|
|
20
|
+
"ratio": 3.586,
|
|
21
21
|
"baseline_allocs": 7,
|
|
22
22
|
"candidate_allocs": 18
|
|
23
23
|
},
|
|
@@ -25,39 +25,39 @@
|
|
|
25
25
|
"row": "serialize",
|
|
26
26
|
"kind": "parity",
|
|
27
27
|
"parity": true,
|
|
28
|
-
"baseline_us":
|
|
29
|
-
"candidate_us":
|
|
30
|
-
"ratio": 1.
|
|
28
|
+
"baseline_us": 517.0,
|
|
29
|
+
"candidate_us": 281.9,
|
|
30
|
+
"ratio": 1.834,
|
|
31
31
|
"baseline_allocs": 30,
|
|
32
32
|
"candidate_allocs": 14
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
35
|
"row": "xpath //field (count)",
|
|
36
|
-
"kind": "
|
|
37
|
-
"parity":
|
|
38
|
-
"baseline_us":
|
|
39
|
-
"candidate_us":
|
|
40
|
-
"ratio":
|
|
36
|
+
"kind": "parity",
|
|
37
|
+
"parity": true,
|
|
38
|
+
"baseline_us": 121.9,
|
|
39
|
+
"candidate_us": 25.4,
|
|
40
|
+
"ratio": 4.791,
|
|
41
41
|
"baseline_allocs": 13,
|
|
42
|
-
"candidate_allocs":
|
|
42
|
+
"candidate_allocs": 8
|
|
43
43
|
},
|
|
44
44
|
{
|
|
45
45
|
"row": "xpath //field (each)",
|
|
46
46
|
"kind": "structural",
|
|
47
47
|
"parity": false,
|
|
48
|
-
"baseline_us":
|
|
49
|
-
"candidate_us":
|
|
50
|
-
"ratio": 0.
|
|
48
|
+
"baseline_us": 361.9,
|
|
49
|
+
"candidate_us": 822.7,
|
|
50
|
+
"ratio": 0.44,
|
|
51
51
|
"baseline_allocs": 1213,
|
|
52
|
-
"candidate_allocs":
|
|
52
|
+
"candidate_allocs": 10
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
"row": "attr pairs listing",
|
|
56
56
|
"kind": "parity",
|
|
57
57
|
"parity": true,
|
|
58
|
-
"baseline_us":
|
|
59
|
-
"candidate_us":
|
|
60
|
-
"ratio": 7.
|
|
58
|
+
"baseline_us": 502.0,
|
|
59
|
+
"candidate_us": 67.3,
|
|
60
|
+
"ratio": 7.458,
|
|
61
61
|
"baseline_allocs": 3300,
|
|
62
62
|
"candidate_allocs": 0
|
|
63
63
|
},
|
|
@@ -65,9 +65,9 @@
|
|
|
65
65
|
"row": "build add_element",
|
|
66
66
|
"kind": "structural",
|
|
67
67
|
"parity": false,
|
|
68
|
-
"baseline_us":
|
|
69
|
-
"candidate_us":
|
|
70
|
-
"ratio": 0.
|
|
68
|
+
"baseline_us": 5532.8,
|
|
69
|
+
"candidate_us": 9658.2,
|
|
70
|
+
"ratio": 0.573,
|
|
71
71
|
"baseline_allocs": 20358,
|
|
72
72
|
"candidate_allocs": 11363
|
|
73
73
|
},
|
|
@@ -75,9 +75,9 @@
|
|
|
75
75
|
"row": "walk+read (warm)",
|
|
76
76
|
"kind": "structural",
|
|
77
77
|
"parity": false,
|
|
78
|
-
"baseline_us":
|
|
79
|
-
"candidate_us":
|
|
80
|
-
"ratio": 0.
|
|
78
|
+
"baseline_us": 1805.4,
|
|
79
|
+
"candidate_us": 2797.9,
|
|
80
|
+
"ratio": 0.645,
|
|
81
81
|
"baseline_allocs": 4503,
|
|
82
82
|
"candidate_allocs": 2
|
|
83
83
|
},
|
|
@@ -85,9 +85,9 @@
|
|
|
85
85
|
"row": "walk+read (cold)",
|
|
86
86
|
"kind": "structural",
|
|
87
87
|
"parity": false,
|
|
88
|
-
"baseline_us":
|
|
89
|
-
"candidate_us":
|
|
90
|
-
"ratio": 0.
|
|
88
|
+
"baseline_us": 2938.6,
|
|
89
|
+
"candidate_us": 17354.2,
|
|
90
|
+
"ratio": 0.169,
|
|
91
91
|
"baseline_allocs": 6011,
|
|
92
92
|
"candidate_allocs": 10232
|
|
93
93
|
},
|
|
@@ -95,9 +95,9 @@
|
|
|
95
95
|
"row": "children recursion (cold)",
|
|
96
96
|
"kind": "structural",
|
|
97
97
|
"parity": false,
|
|
98
|
-
"baseline_us":
|
|
99
|
-
"candidate_us":
|
|
100
|
-
"ratio": 0.
|
|
98
|
+
"baseline_us": 3346.5,
|
|
99
|
+
"candidate_us": 25949.4,
|
|
100
|
+
"ratio": 0.129,
|
|
101
101
|
"baseline_allocs": 5711,
|
|
102
102
|
"candidate_allocs": 8732
|
|
103
103
|
},
|
|
@@ -105,9 +105,9 @@
|
|
|
105
105
|
"row": "plan records (typed)",
|
|
106
106
|
"kind": "data",
|
|
107
107
|
"parity": false,
|
|
108
|
-
"baseline_us":
|
|
109
|
-
"candidate_us":
|
|
110
|
-
"ratio": 1.
|
|
108
|
+
"baseline_us": 2901.1,
|
|
109
|
+
"candidate_us": 2554.9,
|
|
110
|
+
"ratio": 1.136,
|
|
111
111
|
"baseline_allocs": 8112,
|
|
112
112
|
"candidate_allocs": 13232
|
|
113
113
|
}
|
|
@@ -12,28 +12,26 @@ the marked block below from a live measurement; see
|
|
|
12
12
|
`benchmark/README.md` for the protocol and row definitions.
|
|
13
13
|
|
|
14
14
|
// SCOREBOARD:BEGIN
|
|
15
|
-
Generated
|
|
15
|
+
Generated by `SCORE_PUBLISH=1 bundle exec ruby benchmark/scoreboard.rb`. Protocol: CPU-time medians of an interleaved A/B run (raw nokogiri vs moxml on leptris); the ratio is baseline / candidate, so > 1.0 means the facade is faster. Warmups discarded, GC between samples.
|
|
16
16
|
|
|
17
|
-
Measurement: Ruby 3.3.
|
|
17
|
+
Measurement: Ruby 3.3.13, leptris 1.9.381.0, nokogiri 1.19.4, 2026-10-23T15:54:45Z, 1-minute load 1.87, 9 repetitions.
|
|
18
18
|
|
|
19
19
|
[cols="2,1,1,1",options="header"]
|
|
20
20
|
|===
|
|
21
21
|
| Row | Ratio | Kind | Status
|
|
22
|
-
| `parse` |
|
|
23
|
-
| `serialize` | 1.
|
|
24
|
-
| `xpath //field (count)` |
|
|
25
|
-
| `xpath //field (each)` | 0.
|
|
26
|
-
| `attr pairs listing` | 7.
|
|
27
|
-
| `build add_element` | 0.
|
|
28
|
-
| `walk+read (warm)` | 0.
|
|
29
|
-
| `walk+read (cold)` | 0.
|
|
30
|
-
| `children recursion (cold)` | 0.
|
|
31
|
-
| `plan records (typed)` | 1.
|
|
22
|
+
| `parse` | 3.59x | parity | met
|
|
23
|
+
| `serialize` | 1.83x | parity | met
|
|
24
|
+
| `xpath //field (count)` | 4.79x | parity | met
|
|
25
|
+
| `xpath //field (each)` | 0.44x | structural | tracked (below parity)
|
|
26
|
+
| `attr pairs listing` | 7.46x | parity | met
|
|
27
|
+
| `build add_element` | 0.57x | structural | tracked (below parity)
|
|
28
|
+
| `walk+read (warm)` | 0.64x | structural | tracked (below parity)
|
|
29
|
+
| `walk+read (cold)` | 0.17x | structural | tracked (below parity), paused: leptris-ruby#527 (1.9.380 crossing regression)
|
|
30
|
+
| `children recursion (cold)` | 0.13x | structural | tracked (below parity), paused: leptris-ruby#527 (1.9.380 crossing regression)
|
|
31
|
+
| `plan records (typed)` | 1.14x | data | informational
|
|
32
32
|
|===
|
|
33
33
|
|
|
34
|
-
*Parity rows* must stay at or above the 0.95 measurement floor. *Structural rows* are measured and trend-locked (10% regression tolerance) while their gaps remain open. *Data API* rows are informational.
|
|
35
|
-
|
|
36
|
-
NOTE: the 1.9.380 engine line regressed the crossing-bound rows ~2x (and the XPath evaluator itself) against 1.9.37x — tracked as leptris-ruby#527; this baseline records the 380.1 reality the lock now guards.
|
|
34
|
+
*Parity rows* must stay at or above the 0.95 measurement floor. *Structural rows* are measured and trend-locked (10% regression tolerance) while their gaps remain open: per-node wrapper minting costs (#312 endgame) and engine construction. *Data API* rows are informational.
|
|
37
35
|
// SCOREBOARD:END
|
|
38
36
|
|
|
39
37
|
=== Memory management
|
data/lib/moxml/context.rb
CHANGED
|
@@ -207,15 +207,19 @@ module Moxml
|
|
|
207
207
|
# @raise [Moxml::NotImplementedError] on adapters without a validator
|
|
208
208
|
def xsd(schema_source)
|
|
209
209
|
handle = config.adapter.xsd_compile(schema_source.to_s)
|
|
210
|
-
Moxml::XSD::Schema.new(handle, self)
|
|
210
|
+
Moxml::XSD::Schema.new(handle, self, schema_source.to_s)
|
|
211
211
|
end
|
|
212
212
|
|
|
213
213
|
# Compile an XSD schema from a file path — <xs:include href>
|
|
214
214
|
# resolves relative to the file's directory (the engine
|
|
215
215
|
# resolves it; reading the file into #xsd would lose that).
|
|
216
|
+
# The #452 rule index reads this document only; included
|
|
217
|
+
# schema parts stay engine-validated (the index can only
|
|
218
|
+
# under-report).
|
|
216
219
|
def xsd_file(path)
|
|
217
220
|
handle = config.adapter.xsd_compile_file(path.to_s)
|
|
218
|
-
|
|
221
|
+
source = File.read(path.to_s)
|
|
222
|
+
Moxml::XSD::Schema.new(handle, self, source)
|
|
219
223
|
end
|
|
220
224
|
|
|
221
225
|
# Compile a RELAX NG schema from source or from a file path (the
|
data/lib/moxml/version.rb
CHANGED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moxml
|
|
4
|
+
module XSD
|
|
5
|
+
# Ruby-side post-validation rules closing the tier-1
|
|
6
|
+
# under-detections tracked in #452: an engine may accept, inside
|
|
7
|
+
# its recognized scope, instances that violate
|
|
8
|
+
#
|
|
9
|
+
# * a strict `xs:any` wildcard with an undeclared element,
|
|
10
|
+
# * a strict `xs:anyAttribute` with an undeclared attribute,
|
|
11
|
+
# * `xsi:nil` on an element whose declaration is not nillable.
|
|
12
|
+
#
|
|
13
|
+
# The rules run from an index of the schema SOURCE (this gem's
|
|
14
|
+
# own XML machinery — no engine involvement) and merge with the
|
|
15
|
+
# engine's findings; exact duplicates are dropped. Findings are
|
|
16
|
+
# one-directional: the index covers one schema document at
|
|
17
|
+
# tier-1's one-level content depth, so a missing piece can only
|
|
18
|
+
# under-report, never false-report — the same lax-scope posture
|
|
19
|
+
# tier-1 already has.
|
|
20
|
+
module SchemaRules
|
|
21
|
+
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
|
22
|
+
|
|
23
|
+
# A wildcard's namespace attribute, per XML Schema
|
|
24
|
+
# Structures §3.10.2 (##any default; processContents default
|
|
25
|
+
# strict). :tns / :local are resolved against the indexed
|
|
26
|
+
# schema at check time.
|
|
27
|
+
class Wildcard
|
|
28
|
+
def initialize(value, process)
|
|
29
|
+
@process = (process || "strict").to_sym
|
|
30
|
+
case value
|
|
31
|
+
when nil, "##any", ""
|
|
32
|
+
@mode = :any
|
|
33
|
+
when "##other"
|
|
34
|
+
@mode = :other
|
|
35
|
+
else
|
|
36
|
+
@mode = :list
|
|
37
|
+
@namespaces = value.split(/\s+/).reject(&:empty?).freeze
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def strict?
|
|
42
|
+
@process == :strict
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def covers?(namespace)
|
|
46
|
+
case @mode
|
|
47
|
+
when :any then true
|
|
48
|
+
when :other
|
|
49
|
+
namespace != :local && namespace != :tns
|
|
50
|
+
else
|
|
51
|
+
@namespaces.any? { |entry| entry_covers?(entry, namespace) }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def entry_covers?(entry, namespace)
|
|
58
|
+
namespace == case entry
|
|
59
|
+
when "##targetNamespace" then :tns
|
|
60
|
+
when "##local" then :local
|
|
61
|
+
else entry
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
ContentModel = Struct.new(:declared_children, :any,
|
|
67
|
+
:declared_attributes, :any_attribute)
|
|
68
|
+
SchemaIndex = Struct.new(:target_namespace, :element_default,
|
|
69
|
+
:attribute_default, :elements,
|
|
70
|
+
:attributes, :contents)
|
|
71
|
+
|
|
72
|
+
class << self
|
|
73
|
+
# Index one schema document. Returns nil when the source
|
|
74
|
+
# cannot be indexed — the engine's answer then stands alone
|
|
75
|
+
# (today's behavior).
|
|
76
|
+
def index(schema_source, context)
|
|
77
|
+
root = context.parse(schema_source).root
|
|
78
|
+
return nil unless root&.name == "schema"
|
|
79
|
+
|
|
80
|
+
index = SchemaIndex.new(
|
|
81
|
+
root["targetNamespace"].to_s,
|
|
82
|
+
root["elementFormDefault"] == "qualified",
|
|
83
|
+
root["attributeFormDefault"] == "qualified",
|
|
84
|
+
{}, {}, {}
|
|
85
|
+
)
|
|
86
|
+
child_elements(root).each do |decl|
|
|
87
|
+
case decl.name
|
|
88
|
+
when "element" then index_global_element(decl, index)
|
|
89
|
+
when "attribute" then index_global_attribute(decl, index)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
index
|
|
93
|
+
rescue StandardError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Post-validation findings for one instance document.
|
|
98
|
+
def findings(doc, index)
|
|
99
|
+
return [] unless index
|
|
100
|
+
|
|
101
|
+
root = doc.root
|
|
102
|
+
return [] unless root
|
|
103
|
+
|
|
104
|
+
root_qname = qname(root)
|
|
105
|
+
declaration = index.elements[root_qname]
|
|
106
|
+
model = index.contents[root_qname]
|
|
107
|
+
findings = []
|
|
108
|
+
findings << nil_finding(root, declaration) if declaration
|
|
109
|
+
findings.concat(attribute_findings(root, model, index))
|
|
110
|
+
if model
|
|
111
|
+
child_elements(root).each do |child|
|
|
112
|
+
findings.concat(child_findings(child, model, index))
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
findings.compact
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def index_global_element(decl, index)
|
|
121
|
+
name = decl["name"] or return
|
|
122
|
+
qname = "{#{index.target_namespace}}#{name}"
|
|
123
|
+
index.elements[qname] = { nillable: decl["nillable"] == "true" }
|
|
124
|
+
type_attr = decl.attribute("type")
|
|
125
|
+
model = if type_attr.nil? || type_attr.value.empty?
|
|
126
|
+
inline_model(decl, index)
|
|
127
|
+
else
|
|
128
|
+
named_model(decl, index)
|
|
129
|
+
end
|
|
130
|
+
index.contents[qname] = model if model
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def index_global_attribute(decl, index)
|
|
134
|
+
name = decl["name"] or return
|
|
135
|
+
index.attributes["{#{index.target_namespace}}#{name}"] = true
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def inline_model(decl, index)
|
|
139
|
+
type_node = child_elements(decl).find do |child|
|
|
140
|
+
child.name == "complexType"
|
|
141
|
+
end
|
|
142
|
+
content_model(type_node, index) if type_node
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# A named complexType reference, indexed only when the
|
|
146
|
+
# definition sits directly under this schema element.
|
|
147
|
+
def named_model(decl, index)
|
|
148
|
+
type_name = decl.attribute("type")&.value.to_s.split(":").last
|
|
149
|
+
return nil unless type_name
|
|
150
|
+
|
|
151
|
+
type_node = child_elements(decl.parent).find do |child|
|
|
152
|
+
child.name == "complexType" && child["name"] == type_name
|
|
153
|
+
end
|
|
154
|
+
content_model(type_node, index) if type_node
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def content_model(type_node, index)
|
|
158
|
+
model = ContentModel.new([], nil, [], nil)
|
|
159
|
+
collect_model(type_node, index, model)
|
|
160
|
+
model
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Particle presence across the type's subtree (sequence /
|
|
164
|
+
# choice / all / complexContent wrappers flattened): we only
|
|
165
|
+
# ADD findings for strict-wildcard matches, so flattening
|
|
166
|
+
# can only under-report, never over-report. Cardinality and
|
|
167
|
+
# order stay the engine's job.
|
|
168
|
+
def collect_model(node, index, model)
|
|
169
|
+
child_elements(node).each do |child|
|
|
170
|
+
case child.name
|
|
171
|
+
when "element"
|
|
172
|
+
add_particle(child, index, model,
|
|
173
|
+
model.declared_children)
|
|
174
|
+
when "attribute"
|
|
175
|
+
add_particle(child, index, model,
|
|
176
|
+
model.declared_attributes)
|
|
177
|
+
when "any"
|
|
178
|
+
model.any ||= Wildcard.new(child["namespace"],
|
|
179
|
+
child["processContents"])
|
|
180
|
+
when "anyAttribute"
|
|
181
|
+
model.any_attribute ||=
|
|
182
|
+
Wildcard.new(child["namespace"],
|
|
183
|
+
child["processContents"])
|
|
184
|
+
else
|
|
185
|
+
collect_model(child, index, model)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def add_particle(particle, index, _model, sink)
|
|
191
|
+
ref = particle["ref"]
|
|
192
|
+
name = ref&.split(":")&.last || particle["name"]
|
|
193
|
+
return unless name
|
|
194
|
+
|
|
195
|
+
sink << if ref
|
|
196
|
+
"{#{index.target_namespace}}#{name}"
|
|
197
|
+
else
|
|
198
|
+
"{#{form_namespace(particle, index)}}#{name}"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Particles declare with form="qualified"/"unqualified",
|
|
203
|
+
# else the schema's elementFormDefault (elements) /
|
|
204
|
+
# attributeFormDefault (attributes) — both default
|
|
205
|
+
# unqualified.
|
|
206
|
+
def form_namespace(particle, index)
|
|
207
|
+
default = if particle.name == "element"
|
|
208
|
+
index.element_default
|
|
209
|
+
else
|
|
210
|
+
index.attribute_default
|
|
211
|
+
end
|
|
212
|
+
case particle["form"]
|
|
213
|
+
when "qualified" then index.target_namespace
|
|
214
|
+
when "unqualified" then ""
|
|
215
|
+
else default ? index.target_namespace : ""
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def child_findings(child, model, index)
|
|
220
|
+
qname = qname(child)
|
|
221
|
+
findings = []
|
|
222
|
+
if model.declared_children.include?(qname)
|
|
223
|
+
declaration = index.elements[qname]
|
|
224
|
+
findings << nil_finding(child, declaration) if declaration
|
|
225
|
+
elsif undeclared_strict_wildcard_match?(child, model, index,
|
|
226
|
+
qname)
|
|
227
|
+
findings << Moxml::XSD::ValidationError.new(
|
|
228
|
+
message: "element <#{local(qname)}> matches a strict " \
|
|
229
|
+
"wildcard but is not declared",
|
|
230
|
+
path: path_of(child),
|
|
231
|
+
)
|
|
232
|
+
end
|
|
233
|
+
findings.concat(
|
|
234
|
+
attribute_findings(child, index.contents[qname], index),
|
|
235
|
+
)
|
|
236
|
+
findings
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def undeclared_strict_wildcard_match?(_child, model, index, qname)
|
|
240
|
+
wildcard = model.any
|
|
241
|
+
return false unless wildcard&.strict?
|
|
242
|
+
return false if index.elements.key?(qname)
|
|
243
|
+
|
|
244
|
+
namespace = resolve_namespace(qname, index)
|
|
245
|
+
wildcard.covers?(namespace) && !index.elements.key?(qname)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def attribute_findings(element, model, index)
|
|
249
|
+
return [] unless model
|
|
250
|
+
|
|
251
|
+
any_attribute = model.any_attribute
|
|
252
|
+
element.attributes.filter_map do |attribute|
|
|
253
|
+
next if attribute.namespace&.uri == XSI_NS
|
|
254
|
+
|
|
255
|
+
qname = attribute_qname(attribute)
|
|
256
|
+
declared = model.declared_attributes.include?(qname) ||
|
|
257
|
+
index.attributes.key?(qname)
|
|
258
|
+
next if declared
|
|
259
|
+
next unless any_attribute&.strict?
|
|
260
|
+
next if index.attributes.key?(qname)
|
|
261
|
+
next unless any_attribute.covers?(
|
|
262
|
+
resolve_namespace(qname, index),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
Moxml::XSD::ValidationError.new(
|
|
266
|
+
message: "attribute #{local(qname)} matches a strict " \
|
|
267
|
+
"wildcard but is not declared",
|
|
268
|
+
path: path_of(element),
|
|
269
|
+
)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def nil_finding(element, declaration)
|
|
274
|
+
return if declaration[:nillable]
|
|
275
|
+
|
|
276
|
+
value = element.attributes.find do |attribute|
|
|
277
|
+
attribute.namespace&.uri == XSI_NS &&
|
|
278
|
+
attribute.name == "nil"
|
|
279
|
+
end&.value
|
|
280
|
+
return unless %w[true 1].include?(value)
|
|
281
|
+
|
|
282
|
+
Moxml::XSD::ValidationError.new(
|
|
283
|
+
message: "element <#{element.name}> is not nillable but " \
|
|
284
|
+
"carries xsi:nil=\"#{value}\"",
|
|
285
|
+
path: path_of(element),
|
|
286
|
+
)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# {uri}local → the wildcard vocabulary: :tns when the
|
|
290
|
+
# namespace is the schema's target, :local when empty, the
|
|
291
|
+
# URI otherwise.
|
|
292
|
+
def resolve_namespace(qname, index)
|
|
293
|
+
namespace = namespace_of(qname)
|
|
294
|
+
if namespace.empty?
|
|
295
|
+
:local
|
|
296
|
+
elsif namespace == index.target_namespace
|
|
297
|
+
:tns
|
|
298
|
+
else
|
|
299
|
+
namespace
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def qname(node)
|
|
304
|
+
"{#{node.namespace&.uri}}#{node.name}"
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def attribute_qname(attribute)
|
|
308
|
+
"{#{attribute.namespace&.uri}}#{attribute.name}"
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def namespace_of(qname)
|
|
312
|
+
qname[/^\{(.*)\}/, 1].to_s
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def local(qname)
|
|
316
|
+
qname[/\}(.*)$/, 1].to_s
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def child_elements(node)
|
|
320
|
+
node.children.select(&:element?)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def path_of(node)
|
|
324
|
+
segments = [node.name]
|
|
325
|
+
current = node
|
|
326
|
+
while (parent = current.parent)&.element?
|
|
327
|
+
segments.unshift(parent.name)
|
|
328
|
+
current = parent
|
|
329
|
+
end
|
|
330
|
+
segments.join("/")
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
data/lib/moxml/xsd.rb
CHANGED
|
@@ -28,6 +28,8 @@ module Moxml
|
|
|
28
28
|
module XSD
|
|
29
29
|
SUPPORTED_ADAPTERS = %i[leptris nokogiri].freeze
|
|
30
30
|
|
|
31
|
+
autoload :SchemaRules, "moxml/xsd/schema_rules"
|
|
32
|
+
|
|
31
33
|
# One validation finding, harmonized across adapters (#362.2):
|
|
32
34
|
# +message+ always present; +line+ / +column+ when the engine
|
|
33
35
|
# reports them; +path+ (the element path) when the engine
|
|
@@ -89,9 +91,11 @@ module Moxml
|
|
|
89
91
|
"schema released (deterministic drop); compile a new one"
|
|
90
92
|
end
|
|
91
93
|
|
|
92
|
-
def initialize(handle, context)
|
|
94
|
+
def initialize(handle, context, schema_source = nil)
|
|
93
95
|
@handle = handle
|
|
94
96
|
@context = context
|
|
97
|
+
@rules_index =
|
|
98
|
+
schema_source && SchemaRules.index(schema_source, context)
|
|
95
99
|
assert_adapter_matches_handle!
|
|
96
100
|
end
|
|
97
101
|
|
|
@@ -143,7 +147,16 @@ module Moxml
|
|
|
143
147
|
parsed
|
|
144
148
|
end
|
|
145
149
|
end
|
|
146
|
-
@context.config.adapter.xsd_validate(
|
|
150
|
+
engine_errors = @context.config.adapter.xsd_validate(
|
|
151
|
+
@handle, doc.native
|
|
152
|
+
)
|
|
153
|
+
return engine_errors unless @rules_index
|
|
154
|
+
|
|
155
|
+
# The #452 post-validation rules only ever add findings for
|
|
156
|
+
# documents the engine accepted; exact duplicates are
|
|
157
|
+
# dropped for engines that already detect them.
|
|
158
|
+
(engine_errors +
|
|
159
|
+
SchemaRules.findings(doc, @rules_index)).uniq
|
|
147
160
|
end
|
|
148
161
|
|
|
149
162
|
def valid?(node_or_xml)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
# #452: tier-1 engines can accept, inside their recognized scope,
|
|
6
|
+
# instances that violate strict wildcards or xsi:nil. The
|
|
7
|
+
# Ruby-side SchemaRules post-validation closes those gaps from the
|
|
8
|
+
# schema source; findings merge with the engine's (duplicates
|
|
9
|
+
# dropped), so every adapter answers invalid for the four
|
|
10
|
+
# corpus shapes.
|
|
11
|
+
RSpec.describe "Moxml::XSD tier-1 under-detections (#452)" do
|
|
12
|
+
%i[nokogiri leptris].each do |adapter_name|
|
|
13
|
+
describe "on #{adapter_name}" do
|
|
14
|
+
let(:ctx) { Moxml.new(adapter_name) }
|
|
15
|
+
|
|
16
|
+
before do
|
|
17
|
+
skip "#{adapter_name} requires an XSD-capable engine" unless
|
|
18
|
+
Moxml::XSD.supported?(ctx)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
describe "strict xs:any with an undeclared element" do
|
|
22
|
+
let(:schema) do
|
|
23
|
+
ctx.xsd(<<~XSD)
|
|
24
|
+
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
|
25
|
+
targetNamespace="http://example.com/w"
|
|
26
|
+
elementFormDefault="qualified">
|
|
27
|
+
<xs:element name="container">
|
|
28
|
+
<xs:complexType>
|
|
29
|
+
<xs:sequence>
|
|
30
|
+
<xs:any namespace="##targetNamespace"
|
|
31
|
+
processContents="strict" minOccurs="0"/>
|
|
32
|
+
</xs:sequence>
|
|
33
|
+
</xs:complexType>
|
|
34
|
+
</xs:element>
|
|
35
|
+
<xs:element name="known" type="xs:string"/>
|
|
36
|
+
</xs:schema>
|
|
37
|
+
XSD
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it "rejects the undeclared wildcard match" do
|
|
41
|
+
doc = ctx.parse(
|
|
42
|
+
'<container xmlns="http://example.com/w"><unknown>x</unknown></container>',
|
|
43
|
+
)
|
|
44
|
+
expect(schema.valid?(doc)).to be(false)
|
|
45
|
+
expect(schema.validate(doc).map(&:message)).to include(
|
|
46
|
+
/matches a strict wildcard but is not declared/,
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "accepts the declared wildcard match" do
|
|
51
|
+
doc = ctx.parse(
|
|
52
|
+
'<container xmlns="http://example.com/w"><known>x</known></container>',
|
|
53
|
+
)
|
|
54
|
+
expect(schema.valid?(doc)).to be(true)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it "leaves lax wildcards to the engine" do
|
|
58
|
+
lax = ctx.xsd(<<~XSD)
|
|
59
|
+
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
|
60
|
+
targetNamespace="http://example.com/w"
|
|
61
|
+
elementFormDefault="qualified">
|
|
62
|
+
<xs:element name="container">
|
|
63
|
+
<xs:complexType>
|
|
64
|
+
<xs:sequence>
|
|
65
|
+
<xs:any namespace="##targetNamespace"
|
|
66
|
+
processContents="lax" minOccurs="0"/>
|
|
67
|
+
</xs:sequence>
|
|
68
|
+
</xs:complexType>
|
|
69
|
+
</xs:element>
|
|
70
|
+
</xs:schema>
|
|
71
|
+
XSD
|
|
72
|
+
doc = ctx.parse(
|
|
73
|
+
'<container xmlns="http://example.com/w"><unknown>x</unknown></container>',
|
|
74
|
+
)
|
|
75
|
+
expect(lax.valid?(doc)).to be(true)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
describe "strict xs:anyAttribute with an undeclared attribute" do
|
|
80
|
+
let(:schema) do
|
|
81
|
+
ctx.xsd(<<~XSD)
|
|
82
|
+
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
|
83
|
+
targetNamespace="http://example.com/aa"
|
|
84
|
+
elementFormDefault="qualified">
|
|
85
|
+
<xs:element name="settings">
|
|
86
|
+
<xs:complexType>
|
|
87
|
+
<xs:complexContent>
|
|
88
|
+
<xs:restriction base="xs:anyType">
|
|
89
|
+
<xs:attribute name="lang"/>
|
|
90
|
+
<xs:anyAttribute namespace="##targetNamespace ##local"
|
|
91
|
+
processContents="strict"/>
|
|
92
|
+
</xs:restriction>
|
|
93
|
+
</xs:complexContent>
|
|
94
|
+
</xs:complexType>
|
|
95
|
+
</xs:element>
|
|
96
|
+
</xs:schema>
|
|
97
|
+
XSD
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it "rejects the undeclared wildcard match" do
|
|
101
|
+
doc = ctx.parse(
|
|
102
|
+
'<settings xmlns="http://example.com/aa" lang="en" bogus="1">v</settings>',
|
|
103
|
+
)
|
|
104
|
+
expect(schema.valid?(doc)).to be(false)
|
|
105
|
+
expect(schema.validate(doc).map(&:message)).to include(
|
|
106
|
+
/attribute bogus matches a strict wildcard but is not declared/,
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it "accepts the declared attribute" do
|
|
111
|
+
# self-closing: the empty-content-type reading of the
|
|
112
|
+
# restriction is the engine's answer, not this rule's
|
|
113
|
+
doc = ctx.parse(
|
|
114
|
+
'<settings xmlns="http://example.com/aa" lang="en"/>',
|
|
115
|
+
)
|
|
116
|
+
expect(schema.valid?(doc)).to be(true)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
describe "xsi:nil on a non-nillable element" do
|
|
121
|
+
let(:schema) do
|
|
122
|
+
ctx.xsd(<<~XSD)
|
|
123
|
+
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
|
124
|
+
elementFormDefault="qualified">
|
|
125
|
+
<xs:element name="memo" type="xs:string" nillable="true"/>
|
|
126
|
+
<xs:element name="strict" type="xs:string"/>
|
|
127
|
+
</xs:schema>
|
|
128
|
+
XSD
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it "rejects xsi:nil on the non-nillable declaration" do
|
|
132
|
+
doc = ctx.parse(
|
|
133
|
+
'<strict xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:nil="true"/>',
|
|
134
|
+
)
|
|
135
|
+
expect(schema.valid?(doc)).to be(false)
|
|
136
|
+
expect(schema.validate(doc).map(&:message)).to include(
|
|
137
|
+
/is not nillable but carries xsi:nil/,
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it "accepts xsi:nil on the nillable declaration" do
|
|
142
|
+
doc = ctx.parse(
|
|
143
|
+
'<memo xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:nil="true"/>',
|
|
144
|
+
)
|
|
145
|
+
expect(schema.valid?(doc)).to be(true)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it "accepts the element without xsi:nil" do
|
|
149
|
+
doc = ctx.parse("<strict>text</strict>")
|
|
150
|
+
expect(schema.valid?(doc)).to be(true)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
describe "wrong-namespace root" do
|
|
155
|
+
let(:schema) do
|
|
156
|
+
ctx.xsd(<<~XSD)
|
|
157
|
+
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
|
158
|
+
targetNamespace="http://example.com/q"
|
|
159
|
+
elementFormDefault="qualified">
|
|
160
|
+
<xs:element name="note" type="xs:string"/>
|
|
161
|
+
</xs:schema>
|
|
162
|
+
XSD
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it "rejects the root in the wrong namespace" do
|
|
166
|
+
doc = ctx.parse(
|
|
167
|
+
'<note xmlns="http://example.com/other">hi</note>',
|
|
168
|
+
)
|
|
169
|
+
expect(schema.valid?(doc)).to be(false)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: moxml
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.5.
|
|
4
|
+
version: 0.5.158
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
@@ -318,6 +318,7 @@ files:
|
|
|
318
318
|
- lib/moxml/xpath/ruby/generator.rb
|
|
319
319
|
- lib/moxml/xpath/ruby/node.rb
|
|
320
320
|
- lib/moxml/xsd.rb
|
|
321
|
+
- lib/moxml/xsd/schema_rules.rb
|
|
321
322
|
- lib/moxml/xslt.rb
|
|
322
323
|
- lib/moxml/xslt/stylesheet.rb
|
|
323
324
|
- moxml.gemspec
|
|
@@ -631,6 +632,7 @@ files:
|
|
|
631
632
|
- spec/moxml/xsd_handle_mismatch_spec.rb
|
|
632
633
|
- spec/moxml/xsd_release_spec.rb
|
|
633
634
|
- spec/moxml/xsd_spec.rb
|
|
635
|
+
- spec/moxml/xsd_underdetection_spec.rb
|
|
634
636
|
- spec/moxml/xslt/stylesheet_spec.rb
|
|
635
637
|
- spec/performance/README.md
|
|
636
638
|
- spec/performance/benchmark_spec.rb
|