lutaml-model 0.8.2 → 0.8.4
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/.rubocop_todo.yml +23 -23
- data/README.adoc +213 -1
- data/docs/_guides/document-validation.adoc +303 -0
- data/docs/_guides/index.adoc +1 -0
- data/docs/_guides/xml-mapping.adoc +9 -1
- data/docs/_guides/xml_mappings/07_best_practices.adoc +36 -0
- data/docs/_guides/xml_mappings/08_troubleshooting.adoc +89 -0
- data/docs/_tutorials/lutaml-xml-architecture.adoc +6 -1
- data/lib/lutaml/model/attribute.rb +19 -1
- data/lib/lutaml/model/error/liquid_drop_already_registered_error.rb +11 -0
- data/lib/lutaml/model/error/ordered_content_mapping_error.rb +17 -0
- data/lib/lutaml/model/global_context.rb +1 -0
- data/lib/lutaml/model/liquefiable.rb +12 -15
- data/lib/lutaml/model/mapping/mapping_rule.rb +10 -2
- data/lib/lutaml/model/mapping_hash.rb +1 -1
- data/lib/lutaml/model/services/transformer.rb +67 -32
- data/lib/lutaml/model/transform.rb +41 -4
- data/lib/lutaml/model/uninitialized_class.rb +11 -5
- data/lib/lutaml/model/validation/concerns/has_issues.rb +27 -0
- data/lib/lutaml/model/validation/context.rb +36 -0
- data/lib/lutaml/model/validation/issue.rb +62 -0
- data/lib/lutaml/model/validation/layer_result.rb +34 -0
- data/lib/lutaml/model/validation/profile.rb +66 -0
- data/lib/lutaml/model/validation/registry.rb +60 -0
- data/lib/lutaml/model/validation/remediation.rb +33 -0
- data/lib/lutaml/model/validation/remediation_result.rb +20 -0
- data/lib/lutaml/model/validation/report.rb +39 -0
- data/lib/lutaml/model/validation/rule.rb +59 -0
- data/lib/lutaml/model/validation.rb +2 -1
- data/lib/lutaml/model/validation_framework.rb +77 -0
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/model.rb +4 -0
- data/lib/lutaml/xml/adapter/nokogiri_adapter.rb +9 -2
- data/lib/lutaml/xml/adapter/oga_adapter.rb +11 -3
- data/lib/lutaml/xml/adapter/ox_adapter.rb +5 -2
- data/lib/lutaml/xml/adapter/rexml_adapter.rb +10 -3
- data/lib/lutaml/xml/adapter_element.rb +26 -2
- data/lib/lutaml/xml/data_model.rb +14 -0
- data/lib/lutaml/xml/document.rb +3 -0
- data/lib/lutaml/xml/element.rb +8 -2
- data/lib/lutaml/xml/mapping.rb +9 -0
- data/lib/lutaml/xml/model_transform.rb +42 -0
- data/lib/lutaml/xml/schema/xsd/base.rb +4 -1
- data/lib/lutaml/xml/schema/xsd/schema_path.rb +6 -0
- data/lib/lutaml/xml/serialization/instance_methods.rb +3 -1
- data/lib/lutaml/xml/transformation/ordered_applier.rb +46 -2
- data/lib/lutaml/xml/transformation.rb +40 -1
- data/lib/lutaml/xml/xml_element.rb +8 -7
- data/lutaml-model.gemspec +1 -2
- data/spec/lutaml/model/attribute_default_cache_spec.rb +58 -0
- data/spec/lutaml/model/liquefiable_spec.rb +22 -6
- data/spec/lutaml/model/liquid_compatibility_spec.rb +442 -0
- data/spec/lutaml/model/ordered_content_spec.rb +5 -5
- data/spec/lutaml/model/services/transformer_spec.rb +43 -0
- data/spec/lutaml/model/transform_cache_spec.rb +62 -0
- data/spec/lutaml/model/transform_dynamic_attributes_spec.rb +41 -0
- data/spec/lutaml/model/uninitialized_class_deep_dup_spec.rb +39 -0
- data/spec/lutaml/model/uninitialized_class_spec.rb +14 -2
- data/spec/lutaml/model/validation/concerns/has_issues_spec.rb +76 -0
- data/spec/lutaml/model/validation/context_spec.rb +60 -0
- data/spec/lutaml/model/validation/issue_spec.rb +77 -0
- data/spec/lutaml/model/validation/layer_result_spec.rb +66 -0
- data/spec/lutaml/model/validation/profile_spec.rb +134 -0
- data/spec/lutaml/model/validation/registry_spec.rb +94 -0
- data/spec/lutaml/model/validation/remediation_result_spec.rb +23 -0
- data/spec/lutaml/model/validation/remediation_spec.rb +72 -0
- data/spec/lutaml/model/validation/report_spec.rb +58 -0
- data/spec/lutaml/model/validation/rule_spec.rb +134 -0
- data/spec/lutaml/model/validation/uninitialized_class_validate_spec.rb +29 -0
- data/spec/lutaml/model/validation/validation_error_spec.rb +29 -0
- data/spec/lutaml/model/validation/validation_framework_spec.rb +110 -0
- data/spec/lutaml/xml/content_model_validation_spec.rb +157 -0
- data/spec/lutaml/xml/mapping_spec.rb +12 -7
- data/spec/lutaml/xml/schema/xsd/glob_spec.rb +12 -0
- metadata +46 -21
- data/spec/fixtures/liquid_templates/_ceramics.liquid +0 -3
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Document Validation Framework
|
|
3
|
+
nav_order: 12
|
|
4
|
+
parent: Guides
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Document Validation Framework
|
|
8
|
+
:toc:
|
|
9
|
+
:toclevels: 3
|
|
10
|
+
|
|
11
|
+
== Overview
|
|
12
|
+
|
|
13
|
+
Lutaml::Model provides a document-level validation framework
|
|
14
|
+
(`Lutaml::Model::Validation`) for validating structural integrity,
|
|
15
|
+
cross-references, and conformance against domain-specific rules.
|
|
16
|
+
|
|
17
|
+
This framework is **orthogonal** to the existing attribute-level validation
|
|
18
|
+
(`validate`/`validate!` on model instances). Attribute validation checks type
|
|
19
|
+
constraints, enumerations, and collection sizes. The document validation
|
|
20
|
+
framework checks document-level concerns like structural integrity,
|
|
21
|
+
cross-references, and conformance rules.
|
|
22
|
+
|
|
23
|
+
The framework is designed for reuse across the lutaml ecosystem
|
|
24
|
+
(uniword, svg_conform, lutaml-model) and follows open/closed principles --
|
|
25
|
+
you extend it by subclassing, not by modifying framework code.
|
|
26
|
+
|
|
27
|
+
== Core components
|
|
28
|
+
|
|
29
|
+
=== Issue
|
|
30
|
+
|
|
31
|
+
`Lutaml::Model::Validation::Issue` is a `Lutaml::Model::Serializable` subclass
|
|
32
|
+
representing a validation finding.
|
|
33
|
+
|
|
34
|
+
[source,ruby]
|
|
35
|
+
----
|
|
36
|
+
issue = Lutaml::Model::Validation::Issue.new(
|
|
37
|
+
severity: "error", # "error", "warning", "info", or "notice"
|
|
38
|
+
code: "DOC-020", # Rule-specific code
|
|
39
|
+
message: "Missing author",# Human-readable description
|
|
40
|
+
location: "word/document.xml", # Optional: file path or XPath
|
|
41
|
+
line: 42, # Optional: line number
|
|
42
|
+
suggestion: "Add an author element" # Optional: fix hint
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
issue.error? # => true
|
|
46
|
+
issue.warning? # => false
|
|
47
|
+
|
|
48
|
+
# Serializable to JSON/YAML
|
|
49
|
+
issue.to_json
|
|
50
|
+
----
|
|
51
|
+
|
|
52
|
+
Severity values are validated against `Issue::SEVERITIES` (`%w[error warning info notice]`).
|
|
53
|
+
Invalid severities raise `ArgumentError`.
|
|
54
|
+
|
|
55
|
+
=== Rule
|
|
56
|
+
|
|
57
|
+
`Lutaml::Model::Validation::Rule` is an abstract base class. Subclass it and
|
|
58
|
+
override methods to implement domain-specific validation logic.
|
|
59
|
+
|
|
60
|
+
[source,ruby]
|
|
61
|
+
----
|
|
62
|
+
class FontConsistencyRule < Lutaml::Model::Validation::Rule
|
|
63
|
+
def code = "DOC-010"
|
|
64
|
+
def category = :fonts
|
|
65
|
+
def severity = "warning"
|
|
66
|
+
|
|
67
|
+
def applicable?(context)
|
|
68
|
+
context.key?(:fonts)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def check(context)
|
|
72
|
+
fonts = context[:fonts]
|
|
73
|
+
return [] if fonts.length <= 1
|
|
74
|
+
|
|
75
|
+
[issue("Document uses #{fonts.length} different fonts")]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
----
|
|
79
|
+
|
|
80
|
+
Key methods to override:
|
|
81
|
+
|
|
82
|
+
|===
|
|
83
|
+
| Method | Default | Description
|
|
84
|
+
|
|
85
|
+
| `code` | `nil` | Unique rule identifier (e.g., "DOC-010")
|
|
86
|
+
| `category` | `:general` | Rule category for filtering
|
|
87
|
+
| `severity` | `"error"` | Default severity for issues
|
|
88
|
+
| `applicable?(context)` | `true` | Whether to run this rule for the given context
|
|
89
|
+
| `check(context)` | `[]` | Returns an `Array<Issue>` of findings
|
|
90
|
+
| `needs_deferred?` | `false` | For streaming validation
|
|
91
|
+
| `complete(context)` | `[]` | Final issues after deferred collection
|
|
92
|
+
|===
|
|
93
|
+
|
|
94
|
+
Use the private `issue(message, **overrides)` helper inside `check` to create
|
|
95
|
+
issues that inherit the rule's severity and code by default.
|
|
96
|
+
|
|
97
|
+
=== Registry
|
|
98
|
+
|
|
99
|
+
`Lutaml::Model::Validation::Registry` is an instance-based rule store with
|
|
100
|
+
cached instantiation.
|
|
101
|
+
|
|
102
|
+
[source,ruby]
|
|
103
|
+
----
|
|
104
|
+
registry = Lutaml::Model::Validation::Registry.new
|
|
105
|
+
|
|
106
|
+
# Register rule classes (instances are cached after first materialization)
|
|
107
|
+
registry.register(FontConsistencyRule)
|
|
108
|
+
registry.register(RequiredFieldsRule)
|
|
109
|
+
|
|
110
|
+
# Query — instances are cached, subsequent calls return the same objects
|
|
111
|
+
registry.all # => [#<FontConsistencyRule>, #<RequiredFieldsRule>]
|
|
112
|
+
registry.find("DOC-010") # => #<FontConsistencyRule>
|
|
113
|
+
registry.for_category(:fonts) # => [#<FontConsistencyRule>]
|
|
114
|
+
registry.size # => 2
|
|
115
|
+
|
|
116
|
+
# Duplicate registration is prevented
|
|
117
|
+
registry.register(FontConsistencyRule) # no-op
|
|
118
|
+
registry.size # => 2
|
|
119
|
+
|
|
120
|
+
# Reset clears both classes and cached instances
|
|
121
|
+
registry.reset!
|
|
122
|
+
----
|
|
123
|
+
|
|
124
|
+
NOTE: Rule instances are cached after the first call to `all`, `find`, or
|
|
125
|
+
`for_category`. Registering a new rule or calling `reset!` invalidates the
|
|
126
|
+
cache automatically.
|
|
127
|
+
|
|
128
|
+
The `auto_discover(dir, pattern:)` method scans a directory for rule files:
|
|
129
|
+
|
|
130
|
+
[source,ruby]
|
|
131
|
+
----
|
|
132
|
+
registry.auto_discover("lib/rules/", pattern: "**/*_rule.rb")
|
|
133
|
+
----
|
|
134
|
+
|
|
135
|
+
=== Profile
|
|
136
|
+
|
|
137
|
+
`Lutaml::Model::Validation::Profile` selects which rules run during validation.
|
|
138
|
+
Profiles are loaded from YAML and support import-based composition.
|
|
139
|
+
|
|
140
|
+
`Profile.load(path)` validates the YAML structure — it raises `ArgumentError`
|
|
141
|
+
if the file does not contain a `name` string key.
|
|
142
|
+
|
|
143
|
+
[source,yaml]
|
|
144
|
+
----
|
|
145
|
+
# profiles/basic.yml
|
|
146
|
+
name: basic
|
|
147
|
+
description: Basic validation
|
|
148
|
+
rules:
|
|
149
|
+
- RequiredFieldsRule
|
|
150
|
+
- StyleReferencesRule
|
|
151
|
+
----
|
|
152
|
+
|
|
153
|
+
[source,yaml]
|
|
154
|
+
----
|
|
155
|
+
# profiles/strict.yml
|
|
156
|
+
name: strict
|
|
157
|
+
import:
|
|
158
|
+
- basic
|
|
159
|
+
rules:
|
|
160
|
+
- BookmarksRule
|
|
161
|
+
- ImagesRule
|
|
162
|
+
----
|
|
163
|
+
|
|
164
|
+
[source,ruby]
|
|
165
|
+
----
|
|
166
|
+
registry = Lutaml::Model::Validation.new_registry
|
|
167
|
+
registry.register(RequiredFieldsRule)
|
|
168
|
+
registry.register(StyleReferencesRule)
|
|
169
|
+
registry.register(BookmarksRule)
|
|
170
|
+
registry.register(ImagesRule)
|
|
171
|
+
|
|
172
|
+
basic = Lutaml::Model::Validation::Profile.load("profiles/basic.yml")
|
|
173
|
+
strict = Lutaml::Model::Validation::Profile.load("profiles/strict.yml")
|
|
174
|
+
|
|
175
|
+
# Resolve a profile to get rule instances
|
|
176
|
+
profiles = { "basic" => basic, "strict" => strict }
|
|
177
|
+
rules = strict.resolve(registry, profiles)
|
|
178
|
+
# => [RequiredFieldsRule, StyleReferencesRule, BookmarksRule, ImagesRule]
|
|
179
|
+
|
|
180
|
+
# Circular imports are detected and raise ArgumentError
|
|
181
|
+
# profile_a imports profile_b, profile_b imports profile_a => ArgumentError
|
|
182
|
+
----
|
|
183
|
+
|
|
184
|
+
=== Context
|
|
185
|
+
|
|
186
|
+
`Lutaml::Model::Validation::Context` provides mutable error accumulation and
|
|
187
|
+
per-rule state, useful for streaming or multi-pass validation.
|
|
188
|
+
|
|
189
|
+
[source,ruby]
|
|
190
|
+
----
|
|
191
|
+
context = Lutaml::Model::Validation::Context.new
|
|
192
|
+
context.add_error(issue)
|
|
193
|
+
context.add_errors([issue1, issue2])
|
|
194
|
+
context.errors # => [issue, issue1, issue2]
|
|
195
|
+
|
|
196
|
+
# Per-rule state for accumulation during streaming
|
|
197
|
+
state = context.rule_state("DOC-010")
|
|
198
|
+
state[:count] = (state[:count] || 0) + 1
|
|
199
|
+
|
|
200
|
+
context.reset! # Clear all errors and state
|
|
201
|
+
----
|
|
202
|
+
|
|
203
|
+
=== Report and LayerResult
|
|
204
|
+
|
|
205
|
+
`Lutaml::Model::Validation::Report` and `LayerResult` are serializable report
|
|
206
|
+
models for structured validation output.
|
|
207
|
+
|
|
208
|
+
[source,ruby]
|
|
209
|
+
----
|
|
210
|
+
issue = Lutaml::Model::Validation::Issue.new(
|
|
211
|
+
severity: "error", code: "DOC-001", message: "Missing title"
|
|
212
|
+
)
|
|
213
|
+
layer = Lutaml::Model::Validation::LayerResult.new(
|
|
214
|
+
name: "Structure", status: "fail", duration_ms: 15, issues: [issue]
|
|
215
|
+
)
|
|
216
|
+
report = Lutaml::Model::Validation::Report.new(
|
|
217
|
+
source: "document.docx", valid: false, duration_ms: 150, layers: [layer]
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
report.issues # => [issue]
|
|
221
|
+
report.errors # => [issue]
|
|
222
|
+
report.warnings # => []
|
|
223
|
+
report.to_json # Full JSON serialization
|
|
224
|
+
----
|
|
225
|
+
|
|
226
|
+
=== Remediation
|
|
227
|
+
|
|
228
|
+
`Lutaml::Model::Validation::Remediation` is an abstract base class for
|
|
229
|
+
auto-fix logic. Override `id`, `targets`, `applicable?`, `fix`, and `preview`.
|
|
230
|
+
|
|
231
|
+
NOTE: The base `fix` method raises `NotImplementedError`. You must override
|
|
232
|
+
it in your subclass.
|
|
233
|
+
|
|
234
|
+
[source,ruby]
|
|
235
|
+
----
|
|
236
|
+
class FixBrokenReferences < Lutaml::Model::Validation::Remediation
|
|
237
|
+
def id = "REM-001"
|
|
238
|
+
def targets = ["DOC-020"]
|
|
239
|
+
|
|
240
|
+
def applicable?(_context, report)
|
|
241
|
+
report.any? { |i| i.code == "DOC-020" }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def fix(context, report)
|
|
245
|
+
# Apply fixes
|
|
246
|
+
Lutaml::Model::Validation::RemediationResult.new(
|
|
247
|
+
success: true,
|
|
248
|
+
message: "Fixed 3 broken references",
|
|
249
|
+
fixed_codes: ["DOC-020"]
|
|
250
|
+
)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def preview(context, report)
|
|
254
|
+
# Dry-run (optional)
|
|
255
|
+
"Will fix 3 broken references"
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
----
|
|
259
|
+
|
|
260
|
+
== Running validation
|
|
261
|
+
|
|
262
|
+
=== Return issues array
|
|
263
|
+
|
|
264
|
+
[source,ruby]
|
|
265
|
+
----
|
|
266
|
+
issues = Lutaml::Model::Validation.validate(context_hash, registry)
|
|
267
|
+
issues.each do |issue|
|
|
268
|
+
puts "[#{issue.severity}] #{issue.code}: #{issue.message}"
|
|
269
|
+
end
|
|
270
|
+
----
|
|
271
|
+
|
|
272
|
+
=== Raise on errors
|
|
273
|
+
|
|
274
|
+
[source,ruby]
|
|
275
|
+
----
|
|
276
|
+
begin
|
|
277
|
+
Lutaml::Model::Validation.validate!(context_hash, registry)
|
|
278
|
+
rescue Lutaml::Model::Validation::ValidationError => e
|
|
279
|
+
puts e.message # => "[DOC-001] Missing title\n[DOC-020] Broken reference"
|
|
280
|
+
e.issues # => [#<Issue code="DOC-001">, #<Issue code="DOC-020">]
|
|
281
|
+
end
|
|
282
|
+
----
|
|
283
|
+
|
|
284
|
+
`ValidationError` exposes an `issues` accessor containing the error-severity
|
|
285
|
+
issues that triggered the exception.
|
|
286
|
+
|
|
287
|
+
`validate!` raises only for `error` severity issues. Warnings, info, and notice
|
|
288
|
+
issues are returned silently.
|
|
289
|
+
|
|
290
|
+
=== With profiles
|
|
291
|
+
|
|
292
|
+
[source,ruby]
|
|
293
|
+
----
|
|
294
|
+
issues = Lutaml::Model::Validation.validate(context, registry, profile: profile)
|
|
295
|
+
----
|
|
296
|
+
|
|
297
|
+
== Design principles
|
|
298
|
+
|
|
299
|
+
* **Open/Closed**: Extend by subclassing Rule and Remediation, not by modifying framework code.
|
|
300
|
+
* **Instance-based Registry**: Multiple registries can coexist for different validation contexts.
|
|
301
|
+
* **Serializable**: Issue, Report, and RemediationResult serialize to JSON via lutaml-model.
|
|
302
|
+
* **Composable Profiles**: YAML profiles with import resolution for rule reuse.
|
|
303
|
+
* **Orthogonal**: Works alongside existing attribute validation, not replacing it.
|
data/docs/_guides/index.adoc
CHANGED
|
@@ -33,6 +33,7 @@ Task-oriented guides for accomplishing specific goals with Lutaml::Model.
|
|
|
33
33
|
* link:../liquid-templates[Liquid Templates] - Template integration
|
|
34
34
|
* link:../ooxml-examples[OOXML Examples] - Real-world Office Open XML
|
|
35
35
|
* link:../consolidation-mapping[Consolidation Mapping] - Group sibling elements into structured models
|
|
36
|
+
* link:../document-validation[Document Validation] - Document-level validation with rules, profiles, and remediation
|
|
36
37
|
|
|
37
38
|
== By task
|
|
38
39
|
|
|
@@ -307,7 +307,8 @@ end
|
|
|
307
307
|
====
|
|
308
308
|
When a model has `mixed_content`, use `map_content` with `collection: true`
|
|
309
309
|
to capture the multiple text segments between child elements.
|
|
310
|
-
Without `collection: true`,
|
|
310
|
+
Without `collection: true`, a `MixedContentCollectionError` is raised at
|
|
311
|
+
finalization time.
|
|
311
312
|
====
|
|
312
313
|
|
|
313
314
|
.Complete round-trip with `mixed_content` and `map_content collection: true`
|
|
@@ -496,6 +497,13 @@ Use `ordered` when:
|
|
|
496
497
|
|
|
497
498
|
Use `sequence` when you need strict order validation (see <<Sequence patterns>>).
|
|
498
499
|
|
|
500
|
+
[IMPORTANT]
|
|
501
|
+
====
|
|
502
|
+
`ordered` models element-only content -- `map_content` is not allowed.
|
|
503
|
+
If you need to capture text content between elements, use `mixed_content`
|
|
504
|
+
instead (see <<mixed-content>>).
|
|
505
|
+
====
|
|
506
|
+
|
|
499
507
|
Syntax:
|
|
500
508
|
|
|
501
509
|
[source,ruby]
|
|
@@ -324,6 +324,42 @@ end
|
|
|
324
324
|
|
|
325
325
|
== Mixed content practices
|
|
326
326
|
|
|
327
|
+
=== Do not use `map_content` with `ordered` (element-only content)
|
|
328
|
+
|
|
329
|
+
The `ordered` method means element-only content -- text nodes are not modeled.
|
|
330
|
+
Using `map_content` with `ordered` raises `OrderedContentMappingError`.
|
|
331
|
+
If you need text between elements, use `mixed_content` instead.
|
|
332
|
+
|
|
333
|
+
**Don't:**
|
|
334
|
+
|
|
335
|
+
[source,ruby]
|
|
336
|
+
----
|
|
337
|
+
class Paragraph < Lutaml::Model::Serializable
|
|
338
|
+
attribute :text, :string
|
|
339
|
+
|
|
340
|
+
xml do
|
|
341
|
+
element 'p'
|
|
342
|
+
ordered # Element-only -- no text content allowed
|
|
343
|
+
map_content to: :text # ERROR: OrderedContentMappingError
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
----
|
|
347
|
+
|
|
348
|
+
**Do:**
|
|
349
|
+
|
|
350
|
+
[source,ruby]
|
|
351
|
+
----
|
|
352
|
+
class Paragraph < Lutaml::Model::Serializable
|
|
353
|
+
attribute :text, :string, collection: true
|
|
354
|
+
|
|
355
|
+
xml do
|
|
356
|
+
element 'p'
|
|
357
|
+
mixed_content # Allows text + elements
|
|
358
|
+
map_content to: :text
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
----
|
|
362
|
+
|
|
327
363
|
=== Always declare `mixed_content` for elements with interleaved text and children
|
|
328
364
|
|
|
329
365
|
When an XML element contains both text nodes and child elements in arbitrary
|
|
@@ -8,6 +8,95 @@ parent: XML Mappings Guide
|
|
|
8
8
|
|
|
9
9
|
== Common errors
|
|
10
10
|
|
|
11
|
+
=== OrderedContentMappingError
|
|
12
|
+
|
|
13
|
+
**Error message:**
|
|
14
|
+
|
|
15
|
+
----
|
|
16
|
+
Lutaml::Model::OrderedContentMappingError: Element-only content model
|
|
17
|
+
(`ordered`) does not support `map_content` in ModelName. Use `mixed_content`
|
|
18
|
+
instead of `ordered` when you need to capture text content between elements.
|
|
19
|
+
----
|
|
20
|
+
|
|
21
|
+
**Cause:**
|
|
22
|
+
|
|
23
|
+
Using `map_content` (text content mapping) together with `ordered`
|
|
24
|
+
(element-only content model). Element-only models only contain child elements,
|
|
25
|
+
not text nodes.
|
|
26
|
+
|
|
27
|
+
**Solution:**
|
|
28
|
+
|
|
29
|
+
If you need text content between elements, use `mixed_content` instead of
|
|
30
|
+
`ordered`:
|
|
31
|
+
|
|
32
|
+
[source,ruby]
|
|
33
|
+
----
|
|
34
|
+
# WRONG -- ordered + map_content is invalid
|
|
35
|
+
class Paragraph < Lutaml::Model::Serializable
|
|
36
|
+
xml do
|
|
37
|
+
element 'p'
|
|
38
|
+
ordered # Element-only, no text allowed
|
|
39
|
+
map_content to: :text # ERROR!
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# CORRECT -- use mixed_content for text + elements
|
|
44
|
+
class Paragraph < Lutaml::Model::Serializable
|
|
45
|
+
attribute :text, :string, collection: true
|
|
46
|
+
|
|
47
|
+
xml do
|
|
48
|
+
element 'p'
|
|
49
|
+
mixed_content # Allows text + elements
|
|
50
|
+
map_content to: :text # OK!
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
----
|
|
54
|
+
|
|
55
|
+
=== MixedContentCollectionError
|
|
56
|
+
|
|
57
|
+
**Error message:**
|
|
58
|
+
|
|
59
|
+
----
|
|
60
|
+
Lutaml::Model::MixedContentCollectionError: Mixed content requires `text`
|
|
61
|
+
to be a string collection in ModelName. Use `attribute :text, :string,
|
|
62
|
+
collection: true` when `mixed_content` is enabled.
|
|
63
|
+
----
|
|
64
|
+
|
|
65
|
+
**Cause:**
|
|
66
|
+
|
|
67
|
+
Using `mixed_content` with `map_content` mapped to a non-collection attribute.
|
|
68
|
+
Mixed content produces multiple text segments (one between each pair of child
|
|
69
|
+
elements), so the target attribute must be a collection.
|
|
70
|
+
|
|
71
|
+
**Solution:**
|
|
72
|
+
|
|
73
|
+
Declare the attribute with `collection: true`:
|
|
74
|
+
|
|
75
|
+
[source,ruby]
|
|
76
|
+
----
|
|
77
|
+
# WRONG -- non-collection attribute with mixed content
|
|
78
|
+
class Paragraph < Lutaml::Model::Serializable
|
|
79
|
+
attribute :text, :string # Missing collection: true
|
|
80
|
+
|
|
81
|
+
xml do
|
|
82
|
+
element 'p'
|
|
83
|
+
mixed_content
|
|
84
|
+
map_content to: :text # ERROR!
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# CORRECT -- collection attribute for mixed content
|
|
89
|
+
class Paragraph < Lutaml::Model::Serializable
|
|
90
|
+
attribute :text, :string, collection: true
|
|
91
|
+
|
|
92
|
+
xml do
|
|
93
|
+
element 'p'
|
|
94
|
+
mixed_content
|
|
95
|
+
map_content to: :text # OK!
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
----
|
|
99
|
+
|
|
11
100
|
=== NoRootMappingError
|
|
12
101
|
|
|
13
102
|
**Error message:**
|
|
@@ -690,7 +690,8 @@ XmlElement = {
|
|
|
690
690
|
attributes: { orderDate: "1999-10-20" },
|
|
691
691
|
elements: [
|
|
692
692
|
XmlElement(name: "shipTo", ...),
|
|
693
|
-
XmlText("Text content")
|
|
693
|
+
XmlText("Text content"),
|
|
694
|
+
XmlComment(" comment text ")
|
|
694
695
|
],
|
|
695
696
|
content: "Text content", # For mixed content
|
|
696
697
|
}
|
|
@@ -705,6 +706,10 @@ XmlText = {
|
|
|
705
706
|
content: "Text content",
|
|
706
707
|
namespace: nil, # or NamespaceClass
|
|
707
708
|
}
|
|
709
|
+
|
|
710
|
+
XmlComment = {
|
|
711
|
+
content: " comment text ", # Content between <!-- and -->
|
|
712
|
+
}
|
|
708
713
|
```
|
|
709
714
|
|
|
710
715
|
**Key Properties**:
|
|
@@ -330,7 +330,25 @@ module Lutaml
|
|
|
330
330
|
|
|
331
331
|
def default(register = Lutaml::Model::Config.default_register,
|
|
332
332
|
instance_object = nil)
|
|
333
|
-
|
|
333
|
+
if instance_object.nil?
|
|
334
|
+
@default_cache ||= {}
|
|
335
|
+
cached = @default_cache[register]
|
|
336
|
+
return cached if cached
|
|
337
|
+
|
|
338
|
+
result = cast_value(default_value(register, nil), register)
|
|
339
|
+
if immutable_value?(result)
|
|
340
|
+
@default_cache[register] = result
|
|
341
|
+
end
|
|
342
|
+
result
|
|
343
|
+
else
|
|
344
|
+
cast_value(default_value(register, instance_object), register)
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def immutable_value?(value)
|
|
349
|
+
value.nil? || value.is_a?(Numeric) || value.is_a?(String) ||
|
|
350
|
+
value.is_a?(Symbol) || value == true || value == false ||
|
|
351
|
+
value.frozen?
|
|
334
352
|
end
|
|
335
353
|
|
|
336
354
|
def default_value(register, instance_object = nil)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Model
|
|
5
|
+
class OrderedContentMappingError < Error
|
|
6
|
+
def initialize(model_class)
|
|
7
|
+
@model_class = model_class
|
|
8
|
+
super()
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def to_s
|
|
12
|
+
"Element-only content model (`ordered`) does not support `map_content` in #{@model_class}. " \
|
|
13
|
+
"Use `mixed_content` instead of `ordered` when you need to capture text content between elements."
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -36,7 +36,8 @@ module Lutaml
|
|
|
36
36
|
def register_liquid_drop_class
|
|
37
37
|
validate_liquid!
|
|
38
38
|
if base_drop_class
|
|
39
|
-
raise
|
|
39
|
+
raise Lutaml::Model::LiquidDropAlreadyRegisteredError,
|
|
40
|
+
drop_class_name
|
|
40
41
|
end
|
|
41
42
|
|
|
42
43
|
const_set(drop_class_name,
|
|
@@ -45,6 +46,14 @@ module Lutaml
|
|
|
45
46
|
super()
|
|
46
47
|
@object = object
|
|
47
48
|
end
|
|
49
|
+
|
|
50
|
+
def liquefy_value(value)
|
|
51
|
+
if value.is_a?(Array)
|
|
52
|
+
value.map(&:to_liquid)
|
|
53
|
+
else
|
|
54
|
+
value.to_liquid
|
|
55
|
+
end
|
|
56
|
+
end
|
|
48
57
|
end)
|
|
49
58
|
end
|
|
50
59
|
|
|
@@ -99,13 +108,7 @@ module Lutaml
|
|
|
99
108
|
return if base_drop_class.method_defined?(method_name)
|
|
100
109
|
|
|
101
110
|
base_drop_class.define_method(method_name) do
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if value.is_a?(Array)
|
|
105
|
-
value.map(&:to_liquid)
|
|
106
|
-
else
|
|
107
|
-
value.to_liquid
|
|
108
|
-
end
|
|
111
|
+
liquefy_value(@object.public_send(method_name))
|
|
109
112
|
end
|
|
110
113
|
end
|
|
111
114
|
|
|
@@ -114,13 +117,7 @@ module Lutaml
|
|
|
114
117
|
|
|
115
118
|
liquid_mappings.mappings.each do |key, method_name|
|
|
116
119
|
base_drop_class.define_method(key) do
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if value.is_a?(Array)
|
|
120
|
-
value.map(&:to_liquid)
|
|
121
|
-
else
|
|
122
|
-
value.to_liquid
|
|
123
|
-
end
|
|
120
|
+
liquefy_value(@object.public_send(method_name))
|
|
124
121
|
end
|
|
125
122
|
end
|
|
126
123
|
end
|
|
@@ -81,6 +81,10 @@ module Lutaml
|
|
|
81
81
|
@polymorphic_map = polymorphic_map
|
|
82
82
|
@transform = transform
|
|
83
83
|
|
|
84
|
+
# Cache whether this rule needs the full deserialize chain.
|
|
85
|
+
# Over 95% of rules are "simple" (no custom method, no delegate).
|
|
86
|
+
@needs_full_deserialize = has_custom_method_for_deserialization? || !!delegate
|
|
87
|
+
|
|
84
88
|
# Only calculate default_value_map if value_map is not fully provided
|
|
85
89
|
if value_map.empty? || !value_map[:from] || !value_map[:to]
|
|
86
90
|
# Build value_map by starting with defaults from render_nil/render_empty,
|
|
@@ -280,9 +284,13 @@ module Lutaml
|
|
|
280
284
|
end
|
|
281
285
|
|
|
282
286
|
def deserialize(model, value, attributes, mapper_class = nil)
|
|
283
|
-
|
|
284
|
-
|
|
287
|
+
if @needs_full_deserialize
|
|
288
|
+
handle_custom_method(model, value, mapper_class) ||
|
|
289
|
+
handle_delegate(model, value, attributes) ||
|
|
290
|
+
handle_transform_method(model, value, attributes)
|
|
291
|
+
else
|
|
285
292
|
handle_transform_method(model, value, attributes)
|
|
293
|
+
end
|
|
286
294
|
end
|
|
287
295
|
|
|
288
296
|
def has_custom_method_for_serialization?
|