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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +23 -23
  3. data/README.adoc +213 -1
  4. data/docs/_guides/document-validation.adoc +303 -0
  5. data/docs/_guides/index.adoc +1 -0
  6. data/docs/_guides/xml-mapping.adoc +9 -1
  7. data/docs/_guides/xml_mappings/07_best_practices.adoc +36 -0
  8. data/docs/_guides/xml_mappings/08_troubleshooting.adoc +89 -0
  9. data/docs/_tutorials/lutaml-xml-architecture.adoc +6 -1
  10. data/lib/lutaml/model/attribute.rb +19 -1
  11. data/lib/lutaml/model/error/liquid_drop_already_registered_error.rb +11 -0
  12. data/lib/lutaml/model/error/ordered_content_mapping_error.rb +17 -0
  13. data/lib/lutaml/model/global_context.rb +1 -0
  14. data/lib/lutaml/model/liquefiable.rb +12 -15
  15. data/lib/lutaml/model/mapping/mapping_rule.rb +10 -2
  16. data/lib/lutaml/model/mapping_hash.rb +1 -1
  17. data/lib/lutaml/model/services/transformer.rb +67 -32
  18. data/lib/lutaml/model/transform.rb +41 -4
  19. data/lib/lutaml/model/uninitialized_class.rb +11 -5
  20. data/lib/lutaml/model/validation/concerns/has_issues.rb +27 -0
  21. data/lib/lutaml/model/validation/context.rb +36 -0
  22. data/lib/lutaml/model/validation/issue.rb +62 -0
  23. data/lib/lutaml/model/validation/layer_result.rb +34 -0
  24. data/lib/lutaml/model/validation/profile.rb +66 -0
  25. data/lib/lutaml/model/validation/registry.rb +60 -0
  26. data/lib/lutaml/model/validation/remediation.rb +33 -0
  27. data/lib/lutaml/model/validation/remediation_result.rb +20 -0
  28. data/lib/lutaml/model/validation/report.rb +39 -0
  29. data/lib/lutaml/model/validation/rule.rb +59 -0
  30. data/lib/lutaml/model/validation.rb +2 -1
  31. data/lib/lutaml/model/validation_framework.rb +77 -0
  32. data/lib/lutaml/model/version.rb +1 -1
  33. data/lib/lutaml/model.rb +4 -0
  34. data/lib/lutaml/xml/adapter/nokogiri_adapter.rb +9 -2
  35. data/lib/lutaml/xml/adapter/oga_adapter.rb +11 -3
  36. data/lib/lutaml/xml/adapter/ox_adapter.rb +5 -2
  37. data/lib/lutaml/xml/adapter/rexml_adapter.rb +10 -3
  38. data/lib/lutaml/xml/adapter_element.rb +26 -2
  39. data/lib/lutaml/xml/data_model.rb +14 -0
  40. data/lib/lutaml/xml/document.rb +3 -0
  41. data/lib/lutaml/xml/element.rb +8 -2
  42. data/lib/lutaml/xml/mapping.rb +9 -0
  43. data/lib/lutaml/xml/model_transform.rb +42 -0
  44. data/lib/lutaml/xml/schema/xsd/base.rb +4 -1
  45. data/lib/lutaml/xml/schema/xsd/schema_path.rb +6 -0
  46. data/lib/lutaml/xml/serialization/instance_methods.rb +3 -1
  47. data/lib/lutaml/xml/transformation/ordered_applier.rb +46 -2
  48. data/lib/lutaml/xml/transformation.rb +40 -1
  49. data/lib/lutaml/xml/xml_element.rb +8 -7
  50. data/lutaml-model.gemspec +1 -2
  51. data/spec/lutaml/model/attribute_default_cache_spec.rb +58 -0
  52. data/spec/lutaml/model/liquefiable_spec.rb +22 -6
  53. data/spec/lutaml/model/liquid_compatibility_spec.rb +442 -0
  54. data/spec/lutaml/model/ordered_content_spec.rb +5 -5
  55. data/spec/lutaml/model/services/transformer_spec.rb +43 -0
  56. data/spec/lutaml/model/transform_cache_spec.rb +62 -0
  57. data/spec/lutaml/model/transform_dynamic_attributes_spec.rb +41 -0
  58. data/spec/lutaml/model/uninitialized_class_deep_dup_spec.rb +39 -0
  59. data/spec/lutaml/model/uninitialized_class_spec.rb +14 -2
  60. data/spec/lutaml/model/validation/concerns/has_issues_spec.rb +76 -0
  61. data/spec/lutaml/model/validation/context_spec.rb +60 -0
  62. data/spec/lutaml/model/validation/issue_spec.rb +77 -0
  63. data/spec/lutaml/model/validation/layer_result_spec.rb +66 -0
  64. data/spec/lutaml/model/validation/profile_spec.rb +134 -0
  65. data/spec/lutaml/model/validation/registry_spec.rb +94 -0
  66. data/spec/lutaml/model/validation/remediation_result_spec.rb +23 -0
  67. data/spec/lutaml/model/validation/remediation_spec.rb +72 -0
  68. data/spec/lutaml/model/validation/report_spec.rb +58 -0
  69. data/spec/lutaml/model/validation/rule_spec.rb +134 -0
  70. data/spec/lutaml/model/validation/uninitialized_class_validate_spec.rb +29 -0
  71. data/spec/lutaml/model/validation/validation_error_spec.rb +29 -0
  72. data/spec/lutaml/model/validation/validation_framework_spec.rb +110 -0
  73. data/spec/lutaml/xml/content_model_validation_spec.rb +157 -0
  74. data/spec/lutaml/xml/mapping_spec.rb +12 -7
  75. data/spec/lutaml/xml/schema/xsd/glob_spec.rb +12 -0
  76. metadata +46 -21
  77. 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.
@@ -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`, only the first (or last) text segment is captured.
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
- cast_value(default_value(register, instance_object), register)
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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ class LiquidDropAlreadyRegisteredError < Error
6
+ def initialize(drop_class_name)
7
+ super("Liquid drop class '#{drop_class_name}' is already registered.")
8
+ end
9
+ end
10
+ end
11
+ end
@@ -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
@@ -194,6 +194,7 @@ module Lutaml
194
194
  def clear_caches
195
195
  @resolver.clear_all_caches
196
196
  Register.clear_resolve_cache
197
+ Transform.clear_cache!
197
198
  end
198
199
 
199
200
  # =====================================================================
@@ -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 "#{drop_class_name} Already exists!"
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
- value = @object.public_send(method_name)
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
- value = @object.public_send(method_name)
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
- handle_custom_method(model, value, mapper_class) ||
284
- handle_delegate(model, value, attributes) ||
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?
@@ -3,7 +3,7 @@
3
3
  module Lutaml
4
4
  module Model
5
5
  class MappingHash < ::Hash
6
- attr_accessor :ordered, :node
6
+ attr_accessor :ordered, :node, :attribute_order
7
7
 
8
8
  def initialize
9
9
  @ordered = false