Class: Odin::Transform::TransformEngine

Inherits:
Object
  • Object
show all
Defined in:
lib/odin/transform/transform_engine.rb

Defined Under Namespace

Modules: ErrorCodes Classes: TransformError

Constant Summary collapse

CORE_VERBS =

Verb registry — populated in Phase 9-10. For now, core verbs only.

{}.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeTransformEngine

Returns a new instance of TransformEngine.



47
48
49
# File 'lib/odin/transform/transform_engine.rb', line 47

def initialize
  @verb_registry = build_verb_registry
end

Instance Attribute Details

#verb_registryObject (readonly)

Returns the value of attribute verb_registry.



45
46
47
# File 'lib/odin/transform/transform_engine.rb', line 45

def verb_registry
  @verb_registry
end

Class Method Details

.incompatible_conversion_error(verb_name, detail) ⇒ Object

Create a T011 Incompatible Conversion error. Used when a verb receives an unknown or incompatible conversion target (e.g., unknown unit in dateDiff or distance).



38
39
40
41
42
43
# File 'lib/odin/transform/transform_engine.rb', line 38

def self.incompatible_conversion_error(verb_name, detail)
  TransformError.new(
    "#{verb_name}: incompatible conversion — #{detail}",
    code: ErrorCodes::T011_INCOMPATIBLE_CONVERSION
  )
end

Instance Method Details

#evaluate(expr, context) ⇒ Object

── Expression Evaluation ──



285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
# File 'lib/odin/transform/transform_engine.rb', line 285

def evaluate(expr, context)
  case expr
  when LiteralExpr
    expr.value
  when CopyExpr
    val = resolve_path(expr.source_path, context)
    # Apply CopyExpr-level extraction directives only for compatible source formats
    # (fixed-width, csv, delimited, flat — NOT odin, json, xml)
    if expr.directives && !expr.directives.empty?
      src_fmt = context.source_format
      if src_fmt == "fixed-width" || src_fmt == "csv" || src_fmt == "delimited" || src_fmt == "flat"
        val = apply_extraction_directives(val, expr.directives)
      end
    end
    val
  when VerbExpr
    evaluate_verb(expr, context)
  when ObjectExpr
    evaluate_object(expr, context)
  else
    Types::DynValue.of_null
  end
end

#execute(transform_def, source_data) ⇒ Object



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
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/odin/transform/transform_engine.rb', line 51

def execute(transform_def, source_data)
  # Check for multi-record mode (discriminator dispatch)
  disc_config = transform_def.discriminator_config
  if disc_config
    raw_str = case source_data
              when String then source_data
              when Types::DynValue
                source_data.string? ? source_data.value : nil
              end
    return execute_multi_record(transform_def, raw_str, disc_config) if raw_str
  end

  # 1. Normalize source data to DynValue
  source = normalize_source(source_data, transform_def.source_format)

  # 2. Build context
  context = build_context(transform_def, source)

  # 3. Process segments (multi-pass support)
  output = {}
  passes = transform_def.passes
  if passes.empty?
    # Single implicit pass
    transform_def.segments.each do |segment|
      process_segment(segment, source, context, output)
    end
  else
    # Multi-pass: explicit passes first, then pass-0 (implicit)
    all_passes = passes.include?(0) ? passes : passes + [0]
    first_pass = true
    all_passes.each do |pass_num|
      unless first_pass
        reset_non_persist_accumulators(context, transform_def.accumulators)
      end
      first_pass = false

      transform_def.segments.each do |segment|
        seg_pass = segment.pass || 0
        next unless seg_pass == pass_num

        process_segment(segment, source, context, output)
      end
    end
  end

  # 4. Apply confidential enforcement
  if transform_def.header.enforce_confidential != ConfidentialMode::NONE
    apply_confidential(output, transform_def.header.enforce_confidential, context.field_modifiers)
  end

  # 5. Convert output to DynValue (preserves types like date, timestamp)
  output_dv = Types::DynValue.from_ruby(output)

  # 6. Format output
  formatted = format_output(output_dv, transform_def, context)

  # 7. Convert output to plain Ruby for result (DynValues -> native Ruby)
  plain_output = deep_to_ruby(output)

  TransformResult.new(output: plain_output, formatted: formatted, output_dv: output_dv, errors: context.errors)
end

#execute_multi_record(transform_def, raw_input, disc_config) ⇒ Object

── Multi-Record Execution (discriminator-based routing) ──



115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
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
198
199
200
201
202
203
204
205
206
207
# File 'lib/odin/transform/transform_engine.rb', line 115

def execute_multi_record(transform_def, raw_input, disc_config)
  # Parse discriminator config
  disc = parse_discriminator_config(disc_config)
  return TransformResult.new(output: {}, formatted: "", errors: []) unless disc

  source_format = transform_def.source_format
  delimiter = transform_def.header.source_options["delimiter"] || ","

  # Build segment routing map: _type literal value -> segment
  segment_map = {}
  transform_def.segments.each do |seg|
    next unless seg.discriminator_value

    seg.discriminator_value.split(",").each do |type_val|
      segment_map[type_val.strip] = seg
    end
  end

  context = build_context(transform_def, Types::DynValue.of_null)
  context.source_format = source_format

  output = {}
  array_accumulators = {}

  # Initialize array accumulators
  transform_def.segments.each do |seg|
    if seg.is_array
      array_accumulators[seg.name] = []
    end
  end

  # Process each record/line
  lines = raw_input.split(/[\r\n]+/)
  lines.each do |line|
    next if line.strip.empty?

    disc_value = extract_discriminator_value(line, disc, delimiter)
    segment = segment_map[disc_value]
    next unless segment

    record_source = parse_record(line, source_format, delimiter)
    record_output = {}

    # Set the record as the current source for path resolution
    context.source = record_source

    # Process field mappings
    segment.field_mappings.each do |mapping|
      process_mapping(mapping, record_source, context, record_output)
    end

    # Process children
    segment.children.each do |child|
      process_segment(child, record_source, context, record_output)
    end

    # Merge into output
    seg_name = segment.name

    if segment.is_array
      array_accumulators[seg_name] ||= []
      array_accumulators[seg_name] << record_output
    else
      # Merge fields into existing segment object
      if output[seg_name].is_a?(Hash)
        record_output.each { |k, v| output[seg_name][k] = v }
      else
        output[seg_name] = record_output
      end
    end
  end

  # Merge array accumulators into output in segment order
  transform_def.segments.each do |seg|
    next unless seg.is_array

    items = array_accumulators[seg.name]
    next unless items

    output[seg.name] = items
  end

  # Convert output to DynValue
  output_dv = Types::DynValue.from_ruby(output)

  # Format output
  formatted = format_output(output_dv, transform_def, context)

  # Convert output to plain Ruby
  plain_output = deep_to_ruby(output)

  TransformResult.new(output: plain_output, formatted: formatted, output_dv: output_dv, errors: context.errors)
end

#invoke_verb(name, args, context) ⇒ Object

Public for unit testing verbs directly

Raises:



276
277
278
279
280
281
# File 'lib/odin/transform/transform_engine.rb', line 276

def invoke_verb(name, args, context)
  verb_fn = @verb_registry[name]
  raise TransformError.new("Unknown verb: %#{name}") unless verb_fn

  verb_fn.call(args, context)
end