lutaml-xsd 1.1.13 → 1.2.0

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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +0 -1
  3. data/CLAUDE.md +18 -1
  4. data/Gemfile +2 -1
  5. data/Gemfile.lock +327 -0
  6. data/Rakefile +78 -1
  7. data/TODO.VISION.md +123 -0
  8. data/TODO.cleanup.md +43 -0
  9. data/TODO.conformance.md +99 -0
  10. data/TODO.docs-spa.md +46 -0
  11. data/TODO.interop.md +52 -0
  12. data/TODO.performance.md +80 -0
  13. data/TODO.som.md +47 -0
  14. data/benchmark/engines.rb +77 -0
  15. data/config/validation.yml +4 -0
  16. data/docs/CONFORMANCE.adoc +43 -0
  17. data/docs/JSON_SCHEMA.adoc +42 -0
  18. data/docs/superpowers/plans/2026-10-20-moxml-leptris-migration.md +735 -0
  19. data/docs/superpowers/specs/2026-10-20-moxml-leptris-migration-design.md +169 -0
  20. data/docs/superpowers/specs/2026-10-21-rule-engine-wiring-design.md +84 -0
  21. data/lib/lutaml/xsd/conformance/runner.rb +169 -0
  22. data/lib/lutaml/xsd/json_schema_exporter.rb +240 -0
  23. data/lib/lutaml/xsd/package_builder.rb +2 -2
  24. data/lib/lutaml/xsd/spa/schema_serializer.rb +12 -7
  25. data/lib/lutaml/xsd/validation/engine_context.rb +190 -0
  26. data/lib/lutaml/xsd/validation/rule_engine.rb +36 -18
  27. data/lib/lutaml/xsd/validation/rule_registry.rb +34 -0
  28. data/lib/lutaml/xsd/validation/rules/attribute_validation_rule.rb +126 -17
  29. data/lib/lutaml/xsd/validation/rules/content_model_validation_rule.rb +304 -194
  30. data/lib/lutaml/xsd/validation/rules/element_structure_rule.rb +25 -15
  31. data/lib/lutaml/xsd/validation/rules/identity_constraints_rule.rb +134 -0
  32. data/lib/lutaml/xsd/validation/rules/occurrence_validation_rule.rb +76 -25
  33. data/lib/lutaml/xsd/validation/rules/type_validation_rule.rb +134 -17
  34. data/lib/lutaml/xsd/validation/schema_definition_resolver.rb +254 -0
  35. data/lib/lutaml/xsd/validation/validation_configuration.rb +1 -0
  36. data/lib/lutaml/xsd/validation/validation_job.rb +70 -3
  37. data/lib/lutaml/xsd/validation/validation_rule.rb +44 -5
  38. data/lib/lutaml/xsd/validation/validator.rb +2 -1
  39. data/lib/lutaml/xsd/validation/xml_element.rb +2 -2
  40. data/lib/lutaml/xsd/version.rb +1 -1
  41. data/lib/lutaml/xsd.rb +14 -0
  42. data/lutaml-xsd.gemspec +2 -2
  43. metadata +27 -8
@@ -0,0 +1,169 @@
1
+ # Design: complete Moxml migration, run on leptris, integrate `Moxml::XSD` engine validation
2
+
3
+ - Date: 2026-10-20
4
+ - Status: approved (design discussion 2026-10-18..20)
5
+ - Scope: lutaml-xsd 1.1.x
6
+
7
+ ## 1. Context
8
+
9
+ lutaml-xsd is ~95% on Moxml already: `validation/*`, `definition_extractor.rb`,
10
+ and most of `spa/schema_serializer.rb` use `Moxml::Context`. Remaining gaps:
11
+
12
+ - Direct Nokogiri usage survives in exactly one place:
13
+ `create_type_wrapper_xsd` and `resolve_type_prefix`
14
+ (`lib/lutaml/xsd/spa/schema_serializer.rb`), including
15
+ `require "nokogiri"`.
16
+ - The bundle does not include the leptris adapter, so Moxml's default
17
+ adapter selection falls back to Nokogiri everywhere — including inside
18
+ lutaml-model 0.8.x, which is itself built on Moxml.
19
+ - moxml 0.5.147 (pinned via lutaml-xsd#104) ships `Moxml::XSD`: an
20
+ adapter-agnostic XSD validation facade over engines that implement one
21
+ (leptris, nokogiri — "tier-1"). lutaml-xsd maintains a substantial
22
+ hand-rolled validation stack (5 rules, 14 facet validators, 10
23
+ base-type validators) that does not use it.
24
+
25
+ ## 2. Goals
26
+
27
+ 1. No direct Nokogiri usage in `lib/` — everything through Moxml.
28
+ 2. Dev/CI runs the stack on the Leptris engine via the `leptris` gem
29
+ (Gemfile-only; public dependency contract unchanged).
30
+ 3. Use `Moxml::XSD` where it helps: a full-document engine fast-path in
31
+ `Validation`, plus engine delegation for lexical and content-model
32
+ checks where supported, with the Ruby rule engine remaining
33
+ authoritative and the fallback everywhere else.
34
+ 4. CLAUDE.md documents the Moxml-only XML stack.
35
+
36
+ ## 3. Non-goals
37
+
38
+ - No replacement of the Ruby rule engine by engine validation (tier-1
39
+ scope validates laxly outside named+typed top-level elements /
40
+ one-level content-model descent / lexical checks — silent
41
+ false-negatives are unacceptable).
42
+ - No runtime adapter dependency in the gemspec (moxml itself declares
43
+ zero runtime deps; consumers bundle the adapters they want).
44
+ - `XsdSpecValidator` (schema-level spec compliance) keeps its own rules;
45
+ `Moxml::XSD` validates instances, not schemas.
46
+ - Removing the `xsdvi` dependency (it requires `nokogiri ~> 1.16`, so
47
+ nokogiri remains in the lockfile transitively; lutaml-xsd code simply
48
+ stops touching it).
49
+
50
+ ## 4. Dependency changes
51
+
52
+ | Change | Where | Status |
53
+ |---|---|---|
54
+ | `moxml ~> 0.5.147` pin | gemspec | PR #104 (patch release 1.1.13 follows) |
55
+ | `gem "leptris"` | Gemfile (dev) | this work |
56
+ | drop `gem "nokogiri"` | Gemfile | this work (stays in lock via xsdvi) |
57
+
58
+ Gemspec contract otherwise unchanged. moxml auto-selects leptris when
59
+ the gem is present (`Leptris::XML::Document.create` probe); no adapter
60
+ is hardcoded in lutaml-xsd.
61
+
62
+ ## 5. Workstream A — finish the Moxml port
63
+
64
+ `spa/schema_serializer.rb`:
65
+
66
+ - Delete `require "nokogiri"`.
67
+ - `create_type_wrapper_xsd`: `Moxml::Context.new.parse(File.read(path))`;
68
+ `doc.at_xpath("//xs:schema", ns)` (Moxml `xpath`/`at_xpath` accept
69
+ namespace hashes exactly like Nokogiri);
70
+ `schema.namespace_definitions.find { |nd| nd.href == XSD_NS }`;
71
+ `doc.create_element("element")`; `element_node["name"] = ...`;
72
+ `element_node["type"] = type_ref`;
73
+ `element_node.namespace = xsd_ns_decl`; `schema.add_child(element_node)`;
74
+ `doc.to_xml` into the Tempfile.
75
+ - `resolve_type_prefix`: same xpath + `namespace_definitions` mapping.
76
+ - Spec: the generated wrapper XSD must match current Nokogiri output
77
+ (root `xs:schema`, wrapper `xs:element` in the XSD namespace, correct
78
+ prefix resolution). Regression-gate with a byte-equivalence test
79
+ captured before the port.
80
+
81
+ ## 6. Workstream B — leptris in the bundle
82
+
83
+ - Add `gem "leptris"` to the Gemfile (dev/CI). Latest published:
84
+ 1.9.372.0.
85
+ - Verify `Moxml.new.config.adapter_name == :leptris` under the bundle;
86
+ CI stays adapter-neutral (moxml picks leptris when present, nokogiri
87
+ otherwise) so the suite runs both engines over time.
88
+
89
+ ## 7. Workstream C — `Moxml::XSD` engine integration
90
+
91
+ ### 7.1 New class `Lutaml::Xsd::Validation::EngineXsd`
92
+
93
+ One instance per `ValidationJob`, built from the job's `Moxml::Context`:
94
+
95
+ - `Moxml::XSD.supported?(context)` gates everything. Unsupported
96
+ adapters (Oga/Ox/REXML) yield an inert engine (`nil`-equivalent); job
97
+ behavior is unchanged.
98
+ - Compiles each schema source in `repository.all_schemas` via
99
+ `context.xsd(source)`; schemas with non-nil `compile_error` are
100
+ skipped and surface as warnings — the Ruby rules still judge them.
101
+ - `#validate(document)`: runs each compiled `Moxml::XSD::Schema#validate`,
102
+ maps findings, returns them merged.
103
+ - Provenance: every mapped finding gets `code: "engine_validation"` so
104
+ consumers can tell engine findings from rule findings.
105
+
106
+ ### 7.2 Error mapping
107
+
108
+ `Moxml::XSD::ValidationError(message:, line:, column:, path:)` →
109
+ `Lutaml::Xsd::Validation::ValidationError` with
110
+ `severity: :error` and `location:` derived from line/column/path.
111
+ `to_s` keeps the `"<line>: <message>"` shape.
112
+
113
+ ### 7.3 Job wiring
114
+
115
+ `ValidationJob#execute` gains a phase after `parse_xml`, gated by a new
116
+ config feature `:engine_validation` (default: enabled when supported).
117
+ Findings merge into the existing `ResultCollector`. A document is valid
118
+ only when both engines are silent — engine findings are additive, the
119
+ Ruby rule engine remains authoritative (laxness mitigation).
120
+
121
+ ### 7.4 Delegated checks
122
+
123
+ An `EngineContext` (supported? flag + compiled schema handles) hangs off
124
+ the job and reaches validators through the existing registries:
125
+
126
+ - `validation/base_types/*`: when the engine is available,
127
+ `Moxml::XSD.builtin_valid?` / `schema.simple_valid?` run first; Ruby
128
+ logic stays the fallback (other adapters, engine gaps).
129
+ - `validation/rules/content_model_validation_rule.rb` and
130
+ `occurrence_validation_rule.rb`: consult
131
+ `schema.content_valid?(element_name, children)` the same way.
132
+ - No validator public interface changes; delegation is internal and
133
+ capability-gated (OCP: engine support is a registry concern, not a
134
+ per-validator branch sprawl).
135
+
136
+ ## 8. Testing
137
+
138
+ Real objects throughout (no doubles, per standing rules):
139
+
140
+ - Supported-engine shared context: engine tests run where leptris is
141
+ installed, skip cleanly elsewhere (`Moxml::XSD.supported?` probe).
142
+ - Fallback specs: adapter forced to REXML; behavior byte-identical to
143
+ today (engine inert, no new findings, registries use Ruby paths).
144
+ - Port specs: wrapper-XSD byte-equivalence vs. the captured Nokogiri
145
+ baseline.
146
+ - Mapping specs: each `Moxml::XSD::ValidationError` field lands in the
147
+ right `Validation::ValidationError` field; merge semantics with Ruby
148
+ findings; `compile_error` → warning path.
149
+ - Delegation specs: engine path and Ruby path agree on valid/invalid
150
+ fixtures from `spec/fixtures`; disagreement is a test failure.
151
+
152
+ ## 9. Risks
153
+
154
+ - Tier-1 laxness → engine findings additive only; Ruby engine
155
+ authoritative; documented in CLAUDE.md and YARD.
156
+ - Multi-schema repositories (imports, RNG→XSD-converted files) may
157
+ under-report in the engine → acceptable because findings are additive;
158
+ cross-schema resolution stays the Ruby engine's job.
159
+ - Moxml serialization nuances in the wrapper-XSD port → byte-equivalence
160
+ spec catches drift.
161
+ - leptris platform coverage → Gemfile-only today; runtime adoption is a
162
+ separate, owner-decided decision.
163
+
164
+ ## 10. Rollout order
165
+
166
+ 1. PR #104 merged + 1.1.13 patch release (moxml pin only).
167
+ 2. Workstreams A + B (port + leptris in Gemfile) — one PR.
168
+ 3. Workstream C (engine integration) — one PR.
169
+ 4. CLAUDE.md update lands with (2)/(3).
@@ -0,0 +1,84 @@
1
+ # Design: wire the Ruby rule engine into production validation
2
+
3
+ - Date: 2026-10-21
4
+ - Status: implemented with this PR
5
+ - Scope: lutaml-xsd 1.1.x
6
+
7
+ ## 1. Problem
8
+
9
+ `Validator#build_rule_registry` returns an **empty** registry, and
10
+ `RuleEngine#execute_rule` calls `rule.validate(document, repository,
11
+ collector)` while every concrete rule implements
12
+ `validate(instance_element, schema_definition, collector)`. Consequence:
13
+ the production validation stack runs zero rules. The conformance harness
14
+ (2026-10-20 baseline) measured pipeline mode == engine mode for exactly
15
+ this reason; every deferred rule-depth gap is blocked on this wiring.
16
+
17
+ ## 2. Contract
18
+
19
+ Rules stay element-scoped. The engine walks the instance document
20
+ (depth-first over `XmlDocument#root_element` / `XmlElement#children`),
21
+ resolves each element's schema declaration, and dispatches:
22
+
23
+ ```ruby
24
+ rule.validate(instance_element, rule.schema_argument(schema_def, repository:),
25
+ collector, repository: repository)
26
+ ```
27
+
28
+ - `ValidationRule#schema_argument(schema_def, repository:)` defaults to the
29
+ resolved schema element; rules override it when they validate against
30
+ something else (OccurrenceValidationRule returns the content-model
31
+ particle of the schema element's type).
32
+ - `ValidationRule#runs_without_schema?` defaults false; the structure rule
33
+ returns true so undeclared elements still produce findings (its
34
+ element-existence check takes a nil declaration).
35
+ - `repository:` is the resolver (not the raw repository), giving rules
36
+ named-type lookup (`resolve_type`) for `type="tns:…"` references.
37
+ - Dispatch order follows the existing categories:
38
+ structure → type → constraint → facet → identity. Each rule invocation
39
+ keeps `RuleEngine`'s existing per-rule rescue.
40
+
41
+ ## 3. Schema definition resolution
42
+
43
+ `Validation::SchemaDefinitionResolver` indexes, per repository instance:
44
+
45
+ - top-level element declarations (`schema.element`)
46
+ - inline element declarations, recursively through complex types'
47
+ sequence/choice/all particles and nested complex content
48
+
49
+ Matching: local name equality, plus namespace compatibility — a schema
50
+ with a target namespace matches only that namespace; a no-namespace schema
51
+ matches a no-namespace instance element. Top-level declarations win over
52
+ inline ones. `resolve_type(element)` finds a named complex/simple type for
53
+ `element.type` references across the repository's schemas.
54
+
55
+ Known limits (documented, deliberately deferred): dynamic typing
56
+ (`xsi:type`) and derivation closure are not resolved; UPA/identity
57
+ constraints remain rule-engine backlog items.
58
+
59
+ ## 4. Registry population
60
+
61
+ `Validator#build_rule_registry` registers the five rules, gated by the
62
+ existing feature flags:
63
+
64
+ | rule | category | gate |
65
+ |---|---|---|
66
+ | ElementStructureRule | structure | always |
67
+ | ContentModelValidationRule | structure | `validate_content_models` |
68
+ | TypeValidationRule | type | `validate_types` |
69
+ | AttributeValidationRule | constraint | `validate_attributes` |
70
+ | OccurrenceValidationRule | constraint | `validate_occurrences` |
71
+
72
+ ## 5. Testing
73
+
74
+ - Resolver specs with a real parsed schema and a minimal repository
75
+ stand-in (top-level, inline, namespaced, unknown, type references).
76
+ - Engine integration: a `ValidationJob` with `engine_validation: false`
77
+ and an invalid instance must now produce rule findings (previously an
78
+ empty result). Valid instances stay clean.
79
+ - Conformance harness re-run; pipeline-mode pass-rate recorded.
80
+
81
+ ## 6. Non-goals
82
+
83
+ - No new validation rules; this PR only makes the existing rules execute.
84
+ - No change to the engine fast-path or finding provenance.
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "moxml"
4
+
5
+ module Lutaml
6
+ module Xsd
7
+ module Conformance
8
+ # Runs the conformance corpus: one directory per case under
9
+ # spec/conformance/cases/, each holding schema.xsd plus
10
+ # valid-*.xml / invalid-*.xml instances.
11
+ #
12
+ # Two modes are measured independently:
13
+ # - :engine — Moxml::XSD tier-1 validation (when the adapter
14
+ # ships an engine)
15
+ # - :pipeline — the production Validator stack (ValidationJob with
16
+ # the default rule registry)
17
+ #
18
+ # The report is a pass-rate per mode; conformance is a number.
19
+ class Runner
20
+ MODES = %i[engine pipeline].freeze
21
+
22
+ Case = Struct.new(:name, :schema_path, :valid, :invalid,
23
+ keyword_init: true)
24
+ Result = Struct.new(:case_name, :mode, :passed, :total, :failures,
25
+ :unsupported, keyword_init: true) do
26
+ def pass_rate
27
+ total.zero? ? 0.0 : passed.to_f / total
28
+ end
29
+ end
30
+
31
+ # Minimal repository shape the validation stack consumes.
32
+ class Repository
33
+ def initialize(schemas)
34
+ @schemas = schemas
35
+ end
36
+
37
+ attr_reader :schemas
38
+
39
+ def all_schemas
40
+ @schemas
41
+ end
42
+ end
43
+
44
+ def initialize(cases_dir)
45
+ @cases_dir = cases_dir
46
+ end
47
+
48
+ def cases
49
+ Dir.glob(File.join(@cases_dir, "*")).filter_map do |dir|
50
+ next unless File.directory?(dir)
51
+
52
+ schema = File.join(dir, "schema.xsd")
53
+ next unless File.exist?(schema)
54
+
55
+ Case.new(
56
+ name: File.basename(dir),
57
+ schema_path: schema,
58
+ valid: Dir.glob(File.join(dir, "valid-*.xml")),
59
+ invalid: Dir.glob(File.join(dir, "invalid-*.xml")),
60
+ )
61
+ end
62
+ end
63
+
64
+ # @return [Array<Result>]
65
+ def run
66
+ cases.flat_map do |kase|
67
+ MODES.map { |mode| run_case(kase, mode) }
68
+ end
69
+ end
70
+
71
+ # @return [String] human-readable report
72
+ def report(results)
73
+ lines = ["Conformance report", "=" * 60]
74
+ results.group_by(&:mode).each do |mode, mode_results|
75
+ supported = mode_results.reject(&:unsupported)
76
+ passed = supported.sum(&:passed)
77
+ total = supported.sum(&:total)
78
+ rate = total.zero? ? 0.0 : (passed.to_f / total * 100).round(1)
79
+ lines << format("%-10s %3d/%-3d checks (%5.1f%%)%s",
80
+ mode, passed, total, rate,
81
+ skipped_note(mode_results))
82
+ mode_results.each do |r|
83
+ next if r.unsupported
84
+
85
+ marker = r.failures.empty? ? "ok " : "FAIL"
86
+ lines << format(" %s %-26s %d/%d",
87
+ marker, r.case_name, r.passed, r.total)
88
+ r.failures.each { |f| lines << " - #{f}" }
89
+ end
90
+ end
91
+ lines.join("\n")
92
+ end
93
+
94
+ private
95
+
96
+ def skipped_note(results)
97
+ skipped = results.count(&:unsupported)
98
+ skipped.zero? ? "" : " (#{skipped} cases unsupported)"
99
+ end
100
+
101
+ def run_case(kase, mode)
102
+ checks = kase.valid.map { |path| [path, true] } +
103
+ kase.invalid.map { |path| [path, false] }
104
+
105
+ results = checks.map do |path, expected|
106
+ actual = validate(kase, path, mode)
107
+ [path, expected, actual]
108
+ end
109
+
110
+ unsupported = results.any? { |_, _, actual| actual == :unsupported }
111
+ failures = results.reject do |_path, expected, actual|
112
+ actual == :unsupported || expected == actual
113
+ end.map { |path, expected, _| "#{File.basename(path)} (expected #{expected ? 'valid' : 'invalid'})" }
114
+
115
+ Result.new(
116
+ case_name: kase.name,
117
+ mode: mode,
118
+ passed: unsupported ? 0 : results.count { |_, expected, actual| expected == actual },
119
+ total: unsupported ? 0 : results.length,
120
+ failures: failures,
121
+ unsupported: unsupported,
122
+ )
123
+ end
124
+
125
+ def validate(kase, instance_path, mode)
126
+ case mode
127
+ when :engine then engine_valid?(kase, instance_path)
128
+ when :pipeline then pipeline_valid?(kase, instance_path)
129
+ end
130
+ end
131
+
132
+ def engine_valid?(kase, instance_path)
133
+ engine = engine_for(kase)
134
+ return :unsupported unless engine&.supported?
135
+
136
+ engine.findings_for(File.read(instance_path)).empty?
137
+ end
138
+
139
+ def pipeline_valid?(kase, instance_path)
140
+ config = Validation::ValidationConfiguration.new
141
+ job = Validation::ValidationJob.new(
142
+ xml_content: File.read(instance_path),
143
+ repository: repository_for(kase),
144
+ rule_registry: Validation::RuleRegistry.default(config),
145
+ config: config,
146
+ )
147
+ job.execute.valid?
148
+ end
149
+
150
+ def engine_for(kase)
151
+ @engine_cache ||= {}
152
+ @engine_cache[kase.name] ||= Validation::EngineContext.compile(
153
+ repository_for(kase),
154
+ )
155
+ end
156
+
157
+ def repository_for(kase)
158
+ @repository_cache ||= {}
159
+ @repository_cache[kase.name] ||= begin
160
+ schema = Lutaml::Xml::Schema::Xsd.parse(
161
+ File.read(kase.schema_path), location: kase.schema_path
162
+ )
163
+ Repository.new(schema.target_namespace.to_s => schema)
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Lutaml
6
+ module Xsd
7
+ # Exports an XSD schema to a JSON Schema 2020-12 document.
8
+ #
9
+ # The mapping covers the common core and deliberately records its
10
+ # losses: XSD regular expressions are not ECMA-262 patterns, identity
11
+ # constraints and substitution groups have no direct JSON Schema
12
+ # equivalent, and derivation is flattened. See docs/JSON_SCHEMA.adoc.
13
+ class JsonSchemaExporter
14
+ DRAFT = "https://json-schema.org/draft/2020-12/schema"
15
+
16
+ BUILTIN_TYPES = {
17
+ "string" => { "type" => "string" },
18
+ "normalizedString" => { "type" => "string" },
19
+ "token" => { "type" => "string" },
20
+ "boolean" => { "type" => "boolean" },
21
+ "decimal" => { "type" => "number" },
22
+ "float" => { "type" => "number" },
23
+ "double" => { "type" => "number" },
24
+ "integer" => { "type" => "integer" },
25
+ "positiveInteger" => { "type" => "integer", "minimum" => 1 },
26
+ "negativeInteger" => { "type" => "integer", "maximum" => -1 },
27
+ "nonPositiveInteger" => { "type" => "integer", "maximum" => 0 },
28
+ "nonNegativeInteger" => { "type" => "integer", "minimum" => 0 },
29
+ "long" => { "type" => "integer" },
30
+ "int" => { "type" => "integer" },
31
+ "short" => { "type" => "integer" },
32
+ "byte" => { "type" => "integer" },
33
+ "unsignedLong" => { "type" => "integer", "minimum" => 0 },
34
+ "unsignedInt" => { "type" => "integer", "minimum" => 0 },
35
+ "unsignedShort" => { "type" => "integer", "minimum" => 0 },
36
+ "unsignedByte" => { "type" => "integer", "minimum" => 0 },
37
+ "date" => { "type" => "string", "format" => "date" },
38
+ "dateTime" => { "type" => "string", "format" => "date-time" },
39
+ "time" => { "type" => "string", "format" => "time" },
40
+ "anyURI" => { "type" => "string", "format" => "uri" },
41
+ "QName" => { "type" => "string" },
42
+ }.freeze
43
+
44
+ FACET_MAP = {
45
+ min_length: "minLength",
46
+ max_length: "maxLength",
47
+ min_inclusive: "minimum",
48
+ max_inclusive: "maximum",
49
+ min_exclusive: "exclusiveMinimum",
50
+ max_exclusive: "exclusiveMaximum",
51
+ pattern: "pattern",
52
+ }.freeze
53
+
54
+ def initialize(schema)
55
+ @schema = schema
56
+ end
57
+
58
+ # @return [Hash] JSON Schema 2020-12 document
59
+ def export
60
+ document = {
61
+ "$schema" => DRAFT,
62
+ "$defs" => {},
63
+ }
64
+ id = @schema.target_namespace
65
+ document["$id"] = id if id && !id.empty?
66
+
67
+ Array(@schema.complex_type).each do |complex_type|
68
+ next unless complex_type.name
69
+
70
+ document["$defs"][complex_type.name] =
71
+ complex_type_schema(complex_type)
72
+ end
73
+
74
+ Array(@schema.simple_type).each do |simple_type|
75
+ next unless simple_type.name
76
+
77
+ document["$defs"][simple_type.name] =
78
+ simple_type_schema(simple_type)
79
+ end
80
+
81
+ elements = Array(@schema.element)
82
+ elements.each do |element|
83
+ next unless element.name
84
+
85
+ document["$defs"][element.name] = element_schema(element)
86
+ end
87
+
88
+ unless elements.empty?
89
+ document["oneOf"] = elements.map do |element|
90
+ { "$ref" => "#/$defs/#{element.name}" }
91
+ end
92
+ end
93
+
94
+ document
95
+ end
96
+
97
+ # @return [String] pretty JSON
98
+ def to_json(pretty: true)
99
+ pretty ? JSON.pretty_generate(export) : JSON.generate(export)
100
+ end
101
+
102
+ private
103
+
104
+ def complex_type_schema(complex_type)
105
+ if complex_type.simple_content
106
+ return simple_content_schema(complex_type.simple_content)
107
+ end
108
+
109
+ schema = { "type" => "object" }
110
+ properties, required = properties_for(complex_type)
111
+ schema["properties"] = properties unless properties.empty?
112
+ schema["required"] = required unless required.empty?
113
+ schema["additionalProperties"] = false
114
+ schema
115
+ end
116
+
117
+ def simple_content_schema(simple_content)
118
+ base = simple_content.extension || simple_content.restriction
119
+ content = base && type_schema_for(base)
120
+ content ||= { "type" => "string" }
121
+ content.merge("type" => content["type"] || "string")
122
+ end
123
+
124
+ def properties_for(complex_type)
125
+ properties = {}
126
+ required = []
127
+
128
+ each_element_particle(complex_type) do |element|
129
+ name = element.name || element.ref
130
+ next unless name
131
+
132
+ max_occurs = element.max_occurs.to_s
133
+ max_occurs = "1" if max_occurs.empty?
134
+ min_occurs = element.min_occurs.to_s
135
+ min_occurs = "1" if min_occurs.empty?
136
+
137
+ properties[name] = element_schema(element)
138
+ if max_occurs != "1" && max_occurs != "0"
139
+ properties[name] = { "type" => "array", "items" => properties[name] }
140
+ required << name if min_occurs != "0"
141
+ elsif min_occurs != "0"
142
+ required << name
143
+ end
144
+ end
145
+
146
+ Array(complex_type.attribute).each do |attribute|
147
+ name = attribute.name || attribute.ref
148
+ next unless name
149
+
150
+ properties[name] = attribute_schema(attribute)
151
+ required << name if attribute.use == "required"
152
+ end
153
+
154
+ [properties, required]
155
+ end
156
+
157
+ def each_element_particle(node, &)
158
+ return unless node
159
+
160
+ %i[sequence choice all].each do |kind|
161
+ particle = node.public_send(kind) if node.respond_to?(kind)
162
+ next unless particle
163
+
164
+ Array(particle.element).each(&)
165
+ Array(particle.choice).each { |choice| each_element_particle(choice, &) }
166
+ Array(particle.sequence).each { |seq| each_element_particle(seq, &) }
167
+ end
168
+ return unless node.complex_content
169
+
170
+ %i[extension restriction].each do |kind|
171
+ part = node.complex_content.public_send(kind)
172
+ each_element_particle(part, &) if part
173
+ end
174
+ end
175
+
176
+ def element_schema(element)
177
+ schema = if element.complex_type
178
+ complex_type_schema(element.complex_type)
179
+ elsif element.simple_type
180
+ simple_type_schema(element.simple_type)
181
+ elsif element.type
182
+ type_schema_for(element.type)
183
+ else
184
+ { "type" => "string" }
185
+ end
186
+
187
+ schema = { "type" => ["null", schema["type"]] }.merge(schema.except("type")) if ["true", true].include?(element.nillable)
188
+
189
+ schema = { "const" => element.fixed } if element.fixed && !schema.key?("const")
190
+ schema
191
+ end
192
+
193
+ def attribute_schema(attribute)
194
+ schema = if attribute.simple_type
195
+ simple_type_schema(attribute.simple_type)
196
+ elsif attribute.type
197
+ type_schema_for(attribute.type)
198
+ else
199
+ { "type" => "string" }
200
+ end
201
+
202
+ schema = { "const" => attribute.fixed } if attribute.fixed
203
+ schema
204
+ end
205
+
206
+ def simple_type_schema(simple_type)
207
+ restriction = simple_type.restriction
208
+ return type_schema_for(restriction.base) if restriction.nil? || restriction.base.nil?
209
+
210
+ schema = type_schema_for(restriction.base)
211
+
212
+ Array(restriction.enumeration).each { |e| (schema["enum"] ||= []) << e.value }
213
+ FACET_MAP.each do |facet, json_key|
214
+ value = Array(restriction.public_send(facet)).first
215
+ schema[json_key] = facet_value(value, json_key) if value
216
+ rescue NoMethodError
217
+ next
218
+ end
219
+
220
+ schema
221
+ end
222
+
223
+ def facet_value(value, json_key)
224
+ return value.to_i if %w[minLength maxLength].include?(json_key)
225
+
226
+ return value.to_f if %w[minimum maximum exclusiveMinimum exclusiveMaximum].include?(json_key)
227
+
228
+ value
229
+ end
230
+
231
+ def type_schema_for(type_name)
232
+ local = type_name.to_s.split(":").last
233
+ builtin = BUILTIN_TYPES[local]
234
+ return builtin.dup if builtin
235
+
236
+ { "$ref" => "#/$defs/#{local}" }
237
+ end
238
+ end
239
+ end
240
+ end
@@ -67,7 +67,7 @@ module Lutaml
67
67
  # Clear lazy XML element references that hold non-serializable
68
68
  # adapter nodes throughout the object graph.
69
69
  # lutaml-model's :lazy import_declaration_plan mode stores
70
- # XmlElement references (containing Nokogiri nodes) on every
70
+ # XmlElement references (containing XML adapter nodes) on every
71
71
  # model instance during deserialization.
72
72
  clear_pending_plan_references(schema)
73
73
  Marshal.dump(schema)
@@ -201,7 +201,7 @@ module Lutaml
201
201
  private
202
202
 
203
203
  # Recursively walk an object graph and clear pending_plan_root_element
204
- # on every Lutaml::Model::Serializable instance. This removes Nokogiri
204
+ # on every Lutaml::Model::Serializable instance. This removes XML
205
205
  # adapter_node references that prevent Marshal.dump from succeeding.
206
206
  #
207
207
  # @param root [Object] Root of the object graph to walk