Class: Lutaml::KeyValue::Transform

Inherits:
Model::Transform
  • Object
show all
Defined in:
lib/lutaml/key_value/transform.rb

Defined Under Namespace

Classes: KvRulePlan, KvSerializePlan

Constant Summary collapse

KV_GROUP_PLANS =

[model class, format, register] -> rows or false (ineligible). Rows: [[rule, attr, kind, child_rows]] with kind :scalar or :model; child_rows is the child model's own row set. Cycle-safe: an in-progress model resolves false, so self-referential models take the interpretive walk. Concurrent::Map under threaded MRI, plain Hash under Opal (Concurrent is unavailable there) — the RULE_RECORDS pattern. Writes are idempotent (the same deterministic plan is computed), so a lost race costs a duplicate build, never a wrong value.

if Lutaml::Model.opal?
  {}
else
  Lutaml::Model::RuntimeCompatibility
    .require_native("concurrent")
  Concurrent::Map.new
end

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.build_kv_group_plan(model_class, format, register) ⇒ Object



138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
# File 'lib/lutaml/key_value/transform.rb', line 138

def self.build_kv_group_plan(model_class, format, register)
  return false unless model_class.is_a?(Class) &&
    model_class.include?(Lutaml::Model::Serialize)

  mapping = model_class.mappings_for(format, register)
  return false if mapping.nil?

  attrs = model_class.attributes(register)
  rows = nil
  mapping.mappings(register).each do |rule|
    eligible = !rule.name.nil? && !rule.multiple_mappings? &&
      rule.delegate.nil? &&
      !rule.has_custom_method_for_deserialization? &&
      !rule.raw_mapping? && !rule.root_mapping? &&
      !rule.hash_mappings && rule.child_mappings.nil? &&
      rule.when_attribute.empty? &&
      !(if rule.polymorphic.is_a?(::Hash)
          !rule.polymorphic.empty?
        else
          !!rule.polymorphic
        end) &&
      rule.transform.is_a?(::Hash) && rule.transform.empty? &&
      rule.value_map(:from) ==
        Lutaml::Model::Serialize::DEFAULT_VALUE_MAP
    return false unless eligible

    attr = attrs[rule.to]
    return false if attr.nil? || attr.derived?
    # Declared ranges keep their eager validation on the
    # interpretive walk; polymorphic/union/custom-collection
    # dispatch and registered type substitutions own their cast
    # (the per-rule walk threads them through cast options).
    return false if attr.collection? && attr.collection.is_a?(Range)
    return false if attr.polymorphic? || attr.union? ||
      attr.custom_collection?

    type = attr.type(register)
    return false if Lutaml::Model::GlobalContext.context(register)
      .substitution_for(type).any?

    if type.is_a?(Class) && type.include?(Lutaml::Model::Serialize)
      child_rows = kv_group_plan(type, format,
                                 Lutaml::Model::Register
                                   .resolve_for_child(type, register))
      return false unless child_rows

      rows ||= []
      rows << [rule, attr, :model, type, child_rows]
    elsif type.is_a?(Class) && type < Lutaml::Model::Type::Value &&
        !attr.value_policy.whole_value?(type) &&
        !Lutaml::Model::Attribute.custom_from_probe?(type)
      rows ||= []
      rows << [rule, attr, :scalar, nil, nil]
    else
      return false
    end
  end
  # No eligible rows at all (empty mapping) — nothing to gain.
  rows
end

.kv_group_instance(model_class, rows, doc, format, register, options) ⇒ Object



268
269
270
271
272
273
274
# File 'lib/lutaml/key_value/transform.rb', line 268

def self.kv_group_instance(model_class, rows, doc, format, register,
options)
  # cached_transform is per (class, register); format rides the
  # call, not the cache.
  cached_transform(model_class, register)
    .kv_group_build(rows, doc, format, register, options)
end

.kv_group_plan(model_class, format, register) ⇒ Object



112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/lutaml/key_value/transform.rb', line 112

def self.kv_group_plan(model_class, format, register)
  # Context generation: specs (and apps) reset registers between
  # parses; a plan cached across a reset references dead attribute
  # objects. The generation key retires stale plans for free.
  key = [model_class, format, register,
         Lutaml::Model::GlobalContext.context_generation]
  plan = KV_GROUP_PLANS[key]
  return plan unless plan.nil?

  # Cycle detection rides a THREAD-LOCAL recursion stack: a shared
  # in-progress set races (a concurrent same-key build would cache
  # false permanently), and the stack is per-build by definition.
  # The false at the cycle point is NOT cached — the outermost
  # build completes and caches the model's real verdict.
  stack = (Thread.current[:kv_group_plan_stack] ||= [])
  return false if stack.include?(key)

  stack.push(key)
  begin
    KV_GROUP_PLANS[key] = build_kv_group_plan(model_class, format,
                                              register)
  ensure
    stack.pop
  end
end

Instance Method Details

#data_to_model(data, format, options = {}) ⇒ Object



4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# File 'lib/lutaml/key_value/transform.rb', line 4

def data_to_model(data, format, options = {})
  # Use child's own default register if it has one
  # This ensures versioned schemas (e.g., MML v2 with lutaml_default_register = :mml_v2)
  # are instantiated with their native context
  # TODO.max-perf/32: constant per (model class, register) — the
  # transform itself is cached per that pair, so resolve once.
  child_register = @kv_child_register ||= Lutaml::Model::Register
    .resolve_for_child(
      model_class, lutaml_register
    )

  # TODO.max-perf/37: eligible models hydrate in one pass —
  # collect raw values per rule, recurse into eligible child
  # models, build every instance through the bulk constructor.
  # Falls back to the per-rule walk for anything ineligible.
  if !options.key?(:mappings) && data.is_a?(::Hash) &&
      i[json yaml toml hash].include?(format) &&
      (group = self.class.kv_group_plan(model_class, format,
                                        lutaml_register)) &&
      (instance = kv_group_build(group, data, format, child_register,
                                 options))
    root_and_parent_assignment(instance, options)
    return instance
  end

  if model_class.include?(Lutaml::Model::Serialize)
    instance = model_class.new(lutaml_register: child_register)
  else
    instance = model_class.new
    register_accessor_methods_for(instance, child_register)
  end
  root_and_parent_assignment(instance, options)
  mappings = extract_mappings(options, format)

  rules = mappings.mappings(lutaml_register)
  # lutaml-model#88: nil unless the mapping partitions a wire key
  # with when_attribute rules — the common case pays one scan.
  partition = kv_partition(rules)
  rules.each do |rule|
    process_mapping_rule(data, instance, format, rule, options, partition)
  end

  instance
end

#kv_group_build(rows, doc, format, register, options) ⇒ Object

Build values bottom-up, then ONE instance per model: present keys through the casting setters, ABSENT rules through the real per-rule walk (their extractor/defaults/sentinel semantics are the walk's own — reproducing them here would fork them). The per-rule walk is eliminated exactly for the present-key hot path. Returns nil (caller falls back) on any shape the plan does not cover, e.g. a non-Hash item where a model was expected.



206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'lib/lutaml/key_value/transform.rb', line 206

def kv_group_build(rows, doc, format, register, options)
  setters = []
  children = []
  absent = []
  rows.each do |rule, attr, kind, type, child_rows|
    unless Lutaml::Model::Utils.string_or_symbol_key?(doc, rule.name)
      absent << rule
      next
    end
    v = Lutaml::Model::Utils.fetch_str_or_sym(doc, rule.name)

    if kind == :scalar
      setters << [:"#{rule.to}=", v]
      next
    end

    child_register = Lutaml::Model::Register.resolve_for_child(type,
                                                               register)
    if attr.collection?
      # A present-but-nil collection reaches the per-rule walk,
      # which owns the sentinel interplay for that edge
      # (render_nil :as_empty semantics).
      return nil if v.nil?

      items = v.is_a?(::Array) ? v : [v]
      built = []
      items.each do |item|
        return nil unless item.is_a?(::Hash)

        child = self.class.kv_group_instance(type, child_rows, item,
                                             format, child_register,
                                             options)
        return nil if child.nil?

        built << child
      end
      setters << [:"#{rule.to}=", built]
      children.concat(built)
    else
      return nil unless v.is_a?(::Hash)

      child = self.class.kv_group_instance(type, child_rows, v,
                                           format, child_register,
                                           options)
      return nil if child.nil?

      setters << [:"#{rule.to}=", child]
      children << child
    end
  end
  instance = model_class.new(lutaml_register: register)
  setters.each { |name, value| instance.public_send(name, value) }
  absent.each do |rule|
    process_mapping_rule(doc, instance, format, rule, options, nil)
  end
  children.each do |child|
    child.lutaml_parent = instance
    child.lutaml_root ||= instance.lutaml_root || instance
  end
  instance
end

#model_to_data(instance, format, options = {}) ⇒ Object



49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/lutaml/key_value/transform.rb', line 49

def model_to_data(instance, format, options = {})
  # NEW ARCHITECTURE: Use KeyValue::Transformation if available
  # This provides symmetric OOP architecture with XmlDataModel
  if context.is_a?(Class) && context.include?(Lutaml::Model::Serialize)
    transformation = context.transformation_for(format, lutaml_register)

    # transformation_for returns nil for cyclic dependencies or :building sentinel
    # Fall back to legacy approach in these cases
    if transformation.is_a?(Lutaml::KeyValue::Transformation)
      # Use new Transformation to get KeyValueElement
      kv_element = transformation.transform(instance, options)
      # Convert KeyValueElement to hash for backward compatibility with adapters
      # The to_hash method returns {"__root__" => {actual_hash}}
      kv_hash = kv_element.to_hash
      # For root element, return just the content hash
      return kv_hash["__root__"] || kv_hash
    end
  end

  # LEGACY ARCHITECTURE: Fall back to Hash-based approach
  # This maintains backward compatibility for models without transformations
  mappings = extract_mappings(options, format)

  rules = mappings.mappings(lutaml_register)
  partition = kv_partition(rules)
  hash = {}
  handled_groups = nil
  rules.each do |rule|
    next unless valid_mapping?(rule, options)

    group = partition && kv_partition_group(rule, partition)
    if group
      next if handled_groups&.include?(group)

      (handled_groups ||= []) << group
      process_partition_group!(instance, group, hash, format, options)
    else
      process_rule!(instance, rule, hash, format, mappings, options)
    end
  end

  hash.keys == [""] ? hash[""] : hash
end