lutaml-model 0.8.3 → 0.8.5

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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/dependent-tests.yml +3 -1
  3. data/.rubocop.yml +18 -0
  4. data/.rubocop_todo.yml +16 -22
  5. data/Gemfile +2 -0
  6. data/README.adoc +327 -3
  7. data/docs/_guides/document-validation.adoc +303 -0
  8. data/docs/_guides/index.adoc +19 -0
  9. data/docs/_guides/jsonld-serialization.adoc +217 -0
  10. data/docs/_guides/rdf-serialization.adoc +344 -0
  11. data/docs/_guides/turtle-serialization.adoc +224 -0
  12. data/docs/_guides/xml-mapping.adoc +9 -1
  13. data/docs/_guides/xml_mappings/07_best_practices.adoc +36 -0
  14. data/docs/_guides/xml_mappings/08_troubleshooting.adoc +89 -0
  15. data/docs/_pages/serialization_adapters.adoc +31 -0
  16. data/docs/_references/index.adoc +1 -0
  17. data/docs/_references/rdf-namespaces.adoc +243 -0
  18. data/docs/_tutorials/lutaml-xml-architecture.adoc +6 -1
  19. data/docs/index.adoc +3 -2
  20. data/lib/lutaml/jsonld/adapter.rb +23 -0
  21. data/lib/lutaml/jsonld/context.rb +69 -0
  22. data/lib/lutaml/jsonld/term_definition.rb +39 -0
  23. data/lib/lutaml/jsonld/transform.rb +174 -0
  24. data/lib/lutaml/jsonld.rb +23 -0
  25. data/lib/lutaml/model/attribute.rb +19 -1
  26. data/lib/lutaml/model/error/liquid_drop_already_registered_error.rb +11 -0
  27. data/lib/lutaml/model/error/ordered_content_mapping_error.rb +17 -0
  28. data/lib/lutaml/model/format_registry.rb +10 -1
  29. data/lib/lutaml/model/global_context.rb +1 -0
  30. data/lib/lutaml/model/liquefiable.rb +12 -15
  31. data/lib/lutaml/model/mapping/mapping_rule.rb +10 -2
  32. data/lib/lutaml/model/mapping_hash.rb +1 -1
  33. data/lib/lutaml/model/serialize/format_conversion.rb +17 -1
  34. data/lib/lutaml/model/services/transformer.rb +67 -32
  35. data/lib/lutaml/model/transform.rb +41 -4
  36. data/lib/lutaml/model/uninitialized_class.rb +11 -5
  37. data/lib/lutaml/model/validation/concerns/has_issues.rb +27 -0
  38. data/lib/lutaml/model/validation/context.rb +36 -0
  39. data/lib/lutaml/model/validation/issue.rb +62 -0
  40. data/lib/lutaml/model/validation/layer_result.rb +34 -0
  41. data/lib/lutaml/model/validation/profile.rb +66 -0
  42. data/lib/lutaml/model/validation/registry.rb +60 -0
  43. data/lib/lutaml/model/validation/remediation.rb +33 -0
  44. data/lib/lutaml/model/validation/remediation_result.rb +20 -0
  45. data/lib/lutaml/model/validation/report.rb +39 -0
  46. data/lib/lutaml/model/validation/rule.rb +59 -0
  47. data/lib/lutaml/model/validation.rb +2 -1
  48. data/lib/lutaml/model/validation_framework.rb +77 -0
  49. data/lib/lutaml/model/version.rb +1 -1
  50. data/lib/lutaml/model.rb +10 -0
  51. data/lib/lutaml/rdf/error.rb +7 -0
  52. data/lib/lutaml/rdf/iri.rb +44 -0
  53. data/lib/lutaml/rdf/language_tagged.rb +11 -0
  54. data/lib/lutaml/rdf/literal.rb +62 -0
  55. data/lib/lutaml/rdf/mapping.rb +71 -0
  56. data/lib/lutaml/rdf/mapping_rule.rb +35 -0
  57. data/lib/lutaml/rdf/member_rule.rb +13 -0
  58. data/lib/lutaml/rdf/namespace.rb +58 -0
  59. data/lib/lutaml/rdf/namespace_set.rb +69 -0
  60. data/lib/lutaml/rdf/namespaces/dcterms_namespace.rb +12 -0
  61. data/lib/lutaml/rdf/namespaces/owl_namespace.rb +12 -0
  62. data/lib/lutaml/rdf/namespaces/rdf_namespace.rb +14 -0
  63. data/lib/lutaml/rdf/namespaces/rdfs_namespace.rb +12 -0
  64. data/lib/lutaml/rdf/namespaces/skos_namespace.rb +12 -0
  65. data/lib/lutaml/rdf/namespaces/xsd_namespace.rb +12 -0
  66. data/lib/lutaml/rdf/namespaces.rb +14 -0
  67. data/lib/lutaml/rdf/transform.rb +36 -0
  68. data/lib/lutaml/rdf.rb +19 -0
  69. data/lib/lutaml/turtle/adapter.rb +35 -0
  70. data/lib/lutaml/turtle/mapping.rb +7 -0
  71. data/lib/lutaml/turtle/transform.rb +158 -0
  72. data/lib/lutaml/turtle.rb +22 -0
  73. data/lib/lutaml/xml/adapter/nokogiri_adapter.rb +9 -2
  74. data/lib/lutaml/xml/adapter/oga_adapter.rb +11 -3
  75. data/lib/lutaml/xml/adapter/ox_adapter.rb +5 -2
  76. data/lib/lutaml/xml/adapter/rexml_adapter.rb +10 -3
  77. data/lib/lutaml/xml/adapter_element.rb +26 -2
  78. data/lib/lutaml/xml/data_model.rb +14 -0
  79. data/lib/lutaml/xml/document.rb +3 -0
  80. data/lib/lutaml/xml/element.rb +8 -2
  81. data/lib/lutaml/xml/mapping.rb +9 -0
  82. data/lib/lutaml/xml/model_transform.rb +42 -0
  83. data/lib/lutaml/xml/schema/xsd/base.rb +4 -1
  84. data/lib/lutaml/xml/serialization/instance_methods.rb +3 -1
  85. data/lib/lutaml/xml/transformation/ordered_applier.rb +46 -2
  86. data/lib/lutaml/xml/transformation.rb +40 -1
  87. data/lib/lutaml/xml/xml_element.rb +8 -7
  88. data/lutaml-model.gemspec +1 -1
  89. data/spec/lutaml/integration/edge_cases_spec.rb +109 -0
  90. data/spec/lutaml/integration/multi_format_spec.rb +106 -0
  91. data/spec/lutaml/integration/round_trip_spec.rb +170 -0
  92. data/spec/lutaml/jsonld/adapter_spec.rb +46 -0
  93. data/spec/lutaml/jsonld/context_spec.rb +114 -0
  94. data/spec/lutaml/jsonld/term_definition_spec.rb +55 -0
  95. data/spec/lutaml/jsonld/transform_spec.rb +211 -0
  96. data/spec/lutaml/model/attribute_default_cache_spec.rb +58 -0
  97. data/spec/lutaml/model/liquefiable_spec.rb +22 -6
  98. data/spec/lutaml/model/liquid_compatibility_spec.rb +442 -0
  99. data/spec/lutaml/model/ordered_content_spec.rb +5 -5
  100. data/spec/lutaml/model/services/transformer_spec.rb +43 -0
  101. data/spec/lutaml/model/transform_cache_spec.rb +62 -0
  102. data/spec/lutaml/model/transform_dynamic_attributes_spec.rb +41 -0
  103. data/spec/lutaml/model/uninitialized_class_deep_dup_spec.rb +39 -0
  104. data/spec/lutaml/model/uninitialized_class_spec.rb +14 -2
  105. data/spec/lutaml/model/validation/concerns/has_issues_spec.rb +76 -0
  106. data/spec/lutaml/model/validation/context_spec.rb +60 -0
  107. data/spec/lutaml/model/validation/issue_spec.rb +77 -0
  108. data/spec/lutaml/model/validation/layer_result_spec.rb +66 -0
  109. data/spec/lutaml/model/validation/profile_spec.rb +134 -0
  110. data/spec/lutaml/model/validation/registry_spec.rb +94 -0
  111. data/spec/lutaml/model/validation/remediation_result_spec.rb +23 -0
  112. data/spec/lutaml/model/validation/remediation_spec.rb +72 -0
  113. data/spec/lutaml/model/validation/report_spec.rb +58 -0
  114. data/spec/lutaml/model/validation/rule_spec.rb +134 -0
  115. data/spec/lutaml/model/validation/uninitialized_class_validate_spec.rb +29 -0
  116. data/spec/lutaml/model/validation/validation_error_spec.rb +29 -0
  117. data/spec/lutaml/model/validation/validation_framework_spec.rb +110 -0
  118. data/spec/lutaml/rdf/graph_serialization_spec.rb +137 -0
  119. data/spec/lutaml/rdf/iri_spec.rb +73 -0
  120. data/spec/lutaml/rdf/literal_spec.rb +98 -0
  121. data/spec/lutaml/rdf/mapping_spec.rb +164 -0
  122. data/spec/lutaml/rdf/member_rule_spec.rb +17 -0
  123. data/spec/lutaml/rdf/namespace_set_spec.rb +115 -0
  124. data/spec/lutaml/rdf/namespace_spec.rb +241 -0
  125. data/spec/lutaml/rdf/rdf_transform_spec.rb +82 -0
  126. data/spec/lutaml/turtle/adapter_spec.rb +47 -0
  127. data/spec/lutaml/turtle/mapping_spec.rb +123 -0
  128. data/spec/lutaml/turtle/transform_spec.rb +273 -0
  129. data/spec/lutaml/xml/content_model_validation_spec.rb +157 -0
  130. data/spec/lutaml/xml/mapping_spec.rb +12 -7
  131. metadata +95 -7
  132. data/spec/fixtures/liquid_templates/_ceramics.liquid +0 -3
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Lutaml
6
+ module JsonLd
7
+ class Transform < Lutaml::Rdf::Transform
8
+ def model_to_data(instance, _format, options = {})
9
+ mapping = extract_mapping(options)
10
+ return {} unless mapping
11
+
12
+ if mapping.rdf_members.any?
13
+ build_graph_document(mapping, instance)
14
+ else
15
+ build_resource_object(mapping, instance)
16
+ end
17
+ end
18
+
19
+ def data_to_model(data, _format, options = {})
20
+ mapping = extract_mapping(options)
21
+ return model_class.new unless mapping
22
+
23
+ hash = data.is_a?(String) ? JSON.parse(data) : data
24
+
25
+ if hash.key?("@graph") && hash["@graph"].is_a?(Array) && !hash["@graph"].empty?
26
+ graph_data = hash["@graph"]
27
+ first = graph_data.first
28
+ hash = first.is_a?(Hash) ? first : {}
29
+ end
30
+
31
+ hash = strip_jsonld_keywords(hash)
32
+
33
+ attrs = {}
34
+ mapping.rdf_predicates.each do |rule|
35
+ value = hash[rule.predicate_name]
36
+ next if value.nil?
37
+
38
+ attrs[rule.to] = if rule.lang_tagged && value.is_a?(Hash)
39
+ flatten_language_map(value)
40
+ else
41
+ value
42
+ end
43
+ end
44
+
45
+ build_instance(attrs, options)
46
+ end
47
+
48
+ private
49
+
50
+ def extract_mapping(options)
51
+ options[:mappings] || mappings_for(:jsonld, lutaml_register)
52
+ end
53
+
54
+ def build_graph_document(mapping, instance)
55
+ context = build_merged_context(mapping, instance)
56
+ graph = []
57
+
58
+ if mapping.rdf_subject
59
+ resource = build_resource_data(mapping, instance)
60
+ graph << resource unless resource.empty?
61
+ end
62
+
63
+ mapping.rdf_members.each do |member_rule|
64
+ collection = Array(instance.public_send(member_rule.attr_name))
65
+ collection.each do |member|
66
+ member_mapping = member.class.mappings[:jsonld]
67
+ next unless member_mapping
68
+
69
+ resource = build_resource_data(member_mapping, member)
70
+ graph << resource unless resource.empty?
71
+ end
72
+ end
73
+
74
+ { "@context" => context, "@graph" => graph }
75
+ end
76
+
77
+ def build_merged_context(mapping, instance)
78
+ context_hash = build_context_from_mapping(mapping).to_hash
79
+
80
+ mapping.rdf_members.each do |member_rule|
81
+ collection = Array(instance.public_send(member_rule.attr_name))
82
+ next if collection.empty?
83
+
84
+ member_mapping = collection.first.class.mappings[:jsonld]
85
+ next unless member_mapping
86
+
87
+ context_hash.merge!(build_context_from_mapping(member_mapping).to_hash)
88
+ end
89
+
90
+ context_hash
91
+ end
92
+
93
+ def build_context_from_mapping(mapping)
94
+ context = Context.new
95
+ mapping.namespace_set.each { |ns| context.prefix(ns) }
96
+ mapping.rdf_predicates.each do |pred|
97
+ if pred.lang_tagged
98
+ context.term(pred.predicate_name,
99
+ id: pred.uri,
100
+ container: :language)
101
+ else
102
+ context.term(pred.predicate_name, id: pred.uri)
103
+ end
104
+ end
105
+ context
106
+ end
107
+
108
+ def build_resource_object(mapping, instance)
109
+ context = build_context_from_mapping(mapping).to_hash
110
+ data = build_resource_data(mapping, instance)
111
+ { "@context" => context }.merge(data)
112
+ end
113
+
114
+ def build_resource_data(mapping, instance)
115
+ result = {}
116
+
117
+ if mapping.rdf_type
118
+ result["@type"] = resolve_type_compact(mapping)
119
+ end
120
+
121
+ if mapping.rdf_subject
122
+ result["@id"] = resolve_subject_uri(mapping, instance)
123
+ end
124
+
125
+ mapping.rdf_predicates.each do |rule|
126
+ value = instance.public_send(rule.to)
127
+ next if value.nil?
128
+ next if value.is_a?(String) && value.empty?
129
+
130
+ result[rule.predicate_name] = if rule.lang_tagged
131
+ build_language_map(value)
132
+ else
133
+ serialize_rdf_value(value)
134
+ end
135
+ end
136
+
137
+ result
138
+ end
139
+
140
+ def build_language_map(values)
141
+ case values
142
+ when Array
143
+ map = {}
144
+ values.each do |v|
145
+ lang = extract_language(v)
146
+ map[lang] = v.to_s if lang
147
+ end
148
+ map.empty? ? nil : map
149
+ else
150
+ lang = extract_language(values)
151
+ lang ? { lang => values.to_s } : values.to_s
152
+ end
153
+ end
154
+
155
+ def flatten_language_map(lang_map)
156
+ lang_map.values
157
+ end
158
+
159
+ def serialize_rdf_value(value)
160
+ case value
161
+ when Array then value.map { |v| serialize_rdf_value(v) }
162
+ when Integer, Float, TrueClass, FalseClass then value
163
+ else value.to_s
164
+ end
165
+ end
166
+
167
+ def strip_jsonld_keywords(data)
168
+ return data unless data.is_a?(Hash)
169
+
170
+ data.reject { |key, _| key.start_with?("@") }
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "model"
4
+ require_relative "rdf"
5
+
6
+ module Lutaml
7
+ module JsonLd
8
+ autoload :Context, "#{__dir__}/jsonld/context"
9
+ autoload :TermDefinition, "#{__dir__}/jsonld/term_definition"
10
+ autoload :Transform, "#{__dir__}/jsonld/transform"
11
+ autoload :Adapter, "#{__dir__}/jsonld/adapter"
12
+ end
13
+ end
14
+
15
+ Lutaml::Model::FormatRegistry.register(
16
+ :jsonld,
17
+ mapping_class: Lutaml::Rdf::Mapping,
18
+ adapter_class: Lutaml::JsonLd::Adapter,
19
+ transformer: Lutaml::JsonLd::Transform,
20
+ key_value: false,
21
+ rdf: true,
22
+ error_types: ["JSON::ParserError"],
23
+ )
@@ -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
@@ -23,7 +23,7 @@ module Lutaml
23
23
  # @param adapter_options [Hash, nil] { available: [...], default: :name }
24
24
  def register(format, mapping_class:, adapter_class:, transformer:,
25
25
  adapter_loader: nil, castable_type: nil, key_value: nil,
26
- error_types: nil, adapter_options: nil)
26
+ rdf: nil, error_types: nil, adapter_options: nil)
27
27
  validate_registration!(format, mapping_class, transformer)
28
28
 
29
29
  registered_formats[format] = {
@@ -33,6 +33,7 @@ module Lutaml
33
33
  adapter_loader: adapter_loader,
34
34
  castable_type: castable_type,
35
35
  key_value: key_value,
36
+ rdf: rdf,
36
37
  error_types: error_types,
37
38
  adapter_options: adapter_options,
38
39
  registered_at: Time.now,
@@ -109,6 +110,14 @@ module Lutaml
109
110
  registered_formats.dig(format, :key_value) == true
110
111
  end
111
112
 
113
+ def rdf_formats
114
+ registered_formats.select { |_, info| info[:rdf] }.keys
115
+ end
116
+
117
+ def rdf?(format)
118
+ registered_formats.dig(format, :rdf) == true
119
+ end
120
+
112
121
  def error_types_for(format)
113
122
  registered_formats.dig(format, :error_types)
114
123
  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
@@ -15,7 +15,12 @@ module Lutaml
15
15
  # @param block [Proc] The DSL block to evaluate
16
16
  def process_mapping(format, *_args, &)
17
17
  klass = ::Lutaml::Model::Config.mappings_class_for(format)
18
- mappings[format] ||= klass.new
18
+ existing = mappings[format]
19
+ mappings[format] = if existing.nil? || !existing.is_a?(klass)
20
+ klass.new
21
+ else
22
+ existing
23
+ end
19
24
  mappings[format].instance_eval(&)
20
25
 
21
26
  if mappings[format].respond_to?(:finalize)
@@ -273,6 +278,17 @@ module Lutaml
273
278
  end
274
279
  end
275
280
 
281
+ # Define RDF mappings for multiple formats (Turtle, JSON-LD, etc.).
282
+ # Discovers RDF formats dynamically from FormatRegistry.
283
+ #
284
+ # @param block [Proc] The DSL block
285
+ def rdf(&block)
286
+ Lutaml::Model::FormatRegistry.rdf_formats.each do |format|
287
+ mappings[format] = Lutaml::Rdf::Mapping.new
288
+ mappings[format].instance_eval(&block)
289
+ end
290
+ end
291
+
276
292
  # Get resolved mapping for a format
277
293
  #
278
294
  # Delegates to TransformationRegistry for centralized caching
@@ -5,6 +5,37 @@ module Lutaml
5
5
  def call(value, rule, attribute, format: nil)
6
6
  new(rule, attribute, format).call(value)
7
7
  end
8
+
9
+ private
10
+
11
+ def get_transform_static(obj, direction)
12
+ transform = obj&.transform
13
+ return nil if transform.nil? || transform.is_a?(Class)
14
+
15
+ transform.is_a?(::Hash) ? transform[direction] : transform
16
+ end
17
+
18
+ def apply_static(value, sources, direction, format)
19
+ methods = sources.filter_map do |obj|
20
+ get_transform_static(obj, direction)
21
+ end
22
+
23
+ class_transformers = sources.filter_map do |obj|
24
+ next unless obj&.transform.is_a?(Class) &&
25
+ obj.transform < Lutaml::Model::ValueTransformer
26
+
27
+ obj.transform
28
+ end
29
+
30
+ return value if methods.empty? && class_transformers.empty?
31
+
32
+ apply_direction = direction == :import ? :from : :to
33
+ result = class_transformers.reduce(value) do |v, tc|
34
+ tc.public_send(apply_direction, v, format)
35
+ end
36
+
37
+ methods.reduce(result) { |tv, m| m.call(tv) }
38
+ end
8
39
  end
9
40
 
10
41
  attr_reader :rule, :attribute, :format
@@ -16,46 +47,38 @@ module Lutaml
16
47
  end
17
48
 
18
49
  def call(value)
19
- # Collect all class-based and hash/proc-based transformers
20
- # Apply them in the correct order based on transformation_methods
21
-
22
- # Get ordered transformation methods (already in correct precedence)
23
50
  methods = transformation_methods
24
51
 
25
- # Also check for class-based transformers and add them in correct order
26
- class_transformers = []
27
- if instance_of?(ExportTransformer)
28
- # Export order: attribute first, then rule
29
- class_transformers << attribute.transform if attribute&.transform.is_a?(Class) && attribute.transform < Lutaml::Model::ValueTransformer
30
- class_transformers << rule.transform if rule&.transform.is_a?(Class) && rule.transform < Lutaml::Model::ValueTransformer
31
- else
32
- # Import order: rule first, then attribute
33
- class_transformers << rule.transform if rule&.transform.is_a?(Class) && rule.transform < Lutaml::Model::ValueTransformer
34
- class_transformers << attribute.transform if attribute&.transform.is_a?(Class) && attribute.transform < Lutaml::Model::ValueTransformer
52
+ class_transformers = ordered_sources.filter_map do |obj|
53
+ next unless obj&.transform.is_a?(Class) &&
54
+ obj.transform < Lutaml::Model::ValueTransformer
55
+
56
+ obj.transform
35
57
  end
36
58
 
37
- # Apply class transformers first, then hash/proc transformers
38
59
  result = class_transformers.reduce(value) do |v, transformer_class|
39
60
  apply_class_transformer(v, transformer_class, format)
40
61
  end
41
62
 
42
- # Then apply hash/proc transformers
43
63
  methods.reduce(result) do |transformed_value, method|
44
64
  method.call(transformed_value)
45
65
  end
46
66
  end
47
67
 
48
68
  def apply_class_transformer(value, transformer_class, format)
49
- if instance_of?(ExportTransformer)
69
+ if export_direction?
50
70
  transformer_class.to(value, format)
51
71
  else
52
72
  transformer_class.from(value, format)
53
73
  end
54
74
  end
55
75
 
76
+ def export_direction?
77
+ false
78
+ end
79
+
56
80
  def get_transform(obj, direction)
57
81
  transform = obj&.transform
58
-
59
82
  return nil if transform.is_a?(Class)
60
83
 
61
84
  transform.is_a?(::Hash) ? transform[direction] : transform
@@ -63,26 +86,38 @@ module Lutaml
63
86
  end
64
87
 
65
88
  class ImportTransformer < Transformer
66
- # Precedene of transformations:
67
- # 1. Rule transform
68
- # 2. Attribute transform
89
+ class << self
90
+ def call(value, rule, attribute, format: nil)
91
+ apply_static(value, [rule, attribute], :import, format)
92
+ end
93
+ end
94
+
95
+ def ordered_sources
96
+ [rule, attribute]
97
+ end
98
+
69
99
  def transformation_methods
70
- [
71
- get_transform(rule, :import),
72
- get_transform(attribute, :import),
73
- ].compact
100
+ ordered_sources.filter_map { |obj| get_transform(obj, :import) }
74
101
  end
75
102
  end
76
103
 
77
104
  class ExportTransformer < Transformer
78
- # Precedene of transformations (reverse order):
79
- # 1. Attribute transform
80
- # 2. Rule transform
105
+ class << self
106
+ def call(value, rule, attribute, format: nil)
107
+ apply_static(value, [attribute, rule], :export, format)
108
+ end
109
+ end
110
+
111
+ def ordered_sources
112
+ [attribute, rule]
113
+ end
114
+
81
115
  def transformation_methods
82
- [
83
- get_transform(attribute, :export),
84
- get_transform(rule, :export),
85
- ].compact
116
+ ordered_sources.filter_map { |obj| get_transform(obj, :export) }
117
+ end
118
+
119
+ def export_direction?
120
+ true
86
121
  end
87
122
  end
88
123
  end
@@ -3,21 +3,58 @@
3
3
  module Lutaml
4
4
  module Model
5
5
  class Transform
6
+ @transform_cache = {}
7
+
8
+ # Maximum number of cached Transform instances before eviction.
9
+ MAX_CACHE_SIZE = 256
10
+
6
11
  def self.data_to_model(context, data, format, options = {})
7
- new(context, options[:register]).data_to_model(data, format, options)
12
+ register = options[:register] || Lutaml::Model::Config.default_register
13
+ transform = cached_transform(context, register)
14
+ transform.data_to_model(data, format, options)
8
15
  end
9
16
 
10
17
  def self.model_to_data(context, model, format, options = {})
11
18
  register = model.lutaml_register if model.respond_to?(:lutaml_register)
12
- new(context, register).model_to_data(model, format, options)
19
+ register ||= Lutaml::Model::Config.default_register
20
+ transform = cached_transform(context, register)
21
+ transform.model_to_data(model, format, options)
13
22
  end
14
23
 
15
- attr_reader :context, :attributes, :lutaml_register
24
+ def self.cached_transform(context, register)
25
+ @transform_cache ||= {}
26
+ cache_key = [context.object_id, register]
27
+ entry = @transform_cache[cache_key]
28
+ return entry if entry
29
+
30
+ evict_if_needed if @transform_cache.size >= MAX_CACHE_SIZE
31
+ @transform_cache[cache_key] = new(context, register)
32
+ end
33
+
34
+ def self.clear_cache!
35
+ @transform_cache&.clear
36
+ end
37
+
38
+ def self.cache_size
39
+ @transform_cache&.size || 0
40
+ end
41
+
42
+ def self.evict_if_needed
43
+ # Evict oldest half of entries when cache is full
44
+ keys_to_remove = @transform_cache.keys.first(@transform_cache.size / 2)
45
+ keys_to_remove.each { |k| @transform_cache.delete(k) }
46
+ end
47
+ private_class_method :evict_if_needed
48
+
49
+ attr_reader :context, :lutaml_register
16
50
 
17
51
  def initialize(context, register = nil)
18
52
  @context = context
19
53
  @lutaml_register = register || Lutaml::Model::Config.default_register
20
- @attributes = context.attributes(lutaml_register)
54
+ end
55
+
56
+ def attributes
57
+ context.attributes(lutaml_register)
21
58
  end
22
59
 
23
60
  def model_class
@@ -49,16 +49,22 @@ module Lutaml
49
49
  end
50
50
 
51
51
  def method_missing(method, *_args, &)
52
- if method.end_with?("?")
53
- false
54
- else
55
- super
56
- end
52
+ return false if method.end_with?("?")
53
+
54
+ nil
57
55
  end
58
56
 
59
57
  def respond_to_missing?(method_name, _include_private = false)
60
58
  method_name.end_with?("?")
61
59
  end
60
+
61
+ def dup
62
+ self
63
+ end
64
+
65
+ def clone
66
+ self
67
+ end
62
68
  end
63
69
  end
64
70
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ module Validation
6
+ # Shared severity filtering for objects that expose an `issues`
7
+ # collection. Included by LayerResult and Report.
8
+ module HasIssues
9
+ def errors
10
+ issues.select(&:error?)
11
+ end
12
+
13
+ def warnings
14
+ issues.select(&:warning?)
15
+ end
16
+
17
+ def infos
18
+ issues.select(&:info?)
19
+ end
20
+
21
+ def notices
22
+ issues.select(&:notice?)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end