Class: Odin::Transform::TransformEngine

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

Defined Under Namespace

Modules: ErrorCodes Classes: CodedTransformError, CompiledValidation, MappingMods, TransformError, TransformWarning

Constant Summary collapse

CORE_VERBS =

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

{}.freeze
SOURCE_MISSING =

The required-source-missing code: a present-but-null :required field.

"SOURCE_MISSING"
NUMERIC_ARG_VERBS =

Verbs whose leading arguments must be numeric; checked under strictTypes.

%w[
  sqrt abs round floor ceil negate sign trunc ln log log10 log2 exp pow
  add subtract multiply divide mod between formatNumber formatInteger
  formatCurrency toRadians toDegrees
].freeze
NUMERIC_DYN_TYPES =
%i[integer float float_raw currency currency_raw percent null].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeTransformEngine

Returns a new instance of TransformEngine.



133
134
135
# File 'lib/odin/transform/transform_engine.rb', line 133

def initialize
  @verb_registry = build_verb_registry
end

Instance Attribute Details

#verb_registryObject (readonly)

Returns the value of attribute verb_registry.



131
132
133
# File 'lib/odin/transform/transform_engine.rb', line 131

def verb_registry
  @verb_registry
end

Class Method Details

.accumulator_overflow_error(name, value) ⇒ Object

Create a T008 Accumulator Overflow error.



119
120
121
# File 'lib/odin/transform/transform_engine.rb', line 119

def self.accumulator_overflow_error(name, value)
  TransformError.new("Accumulator '#{name}' overflow with value #{value}", code: ErrorCodes::T008_ACCUMULATOR_OVERFLOW)
end

.dangling_branch_error(directive) ⇒ Object

Create a T012 Dangling Branch error (elif/else with no preceding if).



76
77
78
79
80
81
# File 'lib/odin/transform/transform_engine.rb', line 76

def self.dangling_branch_error(directive)
  TransformError.new(
    "'#{directive}' segment has no preceding 'if'",
    code: ErrorCodes::T012_DANGLING_BRANCH
  )
end

.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).



86
87
88
89
90
91
# File 'lib/odin/transform/transform_engine.rb', line 86

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

.invalid_output_format_error(format) ⇒ Object

Create a T006 Invalid Output Format error.



114
115
116
# File 'lib/odin/transform/transform_engine.rb', line 114

def self.invalid_output_format_error(format)
  TransformError.new("Invalid or unsupported output format: #{format}", code: ErrorCodes::T006_INVALID_OUTPUT_FORMAT)
end

.lookup_key_not_found_error(table_name, key) ⇒ Object

Create a T004 Lookup Key Not Found error.



104
105
106
# File 'lib/odin/transform/transform_engine.rb', line 104

def self.lookup_key_not_found_error(table_name, key)
  TransformError.new("Lookup key '#{key}' not found in table '#{table_name}'", code: ErrorCodes::T004_LOOKUP_KEY_NOT_FOUND)
end

.lookup_table_not_found_error(table_name) ⇒ Object

Create a T003 Lookup Table Not Found error.



99
100
101
# File 'lib/odin/transform/transform_engine.rb', line 99

def self.lookup_table_not_found_error(table_name)
  TransformError.new("Lookup table not found: #{table_name}", code: ErrorCodes::T003_LOOKUP_TABLE_NOT_FOUND)
end

.loop_source_not_array_error(path) ⇒ Object

Create a T009 Loop Source Not Array error.



124
125
126
# File 'lib/odin/transform/transform_engine.rb', line 124

def self.loop_source_not_array_error(path)
  TransformError.new("Loop source path '#{path}' does not resolve to an array", code: ErrorCodes::T009_LOOP_SOURCE_NOT_ARRAY)
end

.nested_interpolation_error(expr, segment = nil) ⇒ Object

Create a T014 Nested Interpolation error.



66
67
68
69
70
71
72
73
# File 'lib/odin/transform/transform_engine.rb', line 66

def self.nested_interpolation_error(expr, segment = nil)
  err = TransformError.new(
    "Nested interpolation is not allowed: ${#{expr}}",
    code: ErrorCodes::T014_NESTED_INTERPOLATION
  )
  err.segment = segment if segment
  err
end

.source_path_not_found_error(path) ⇒ Object

Create a T005 Source Path Not Found error.



109
110
111
# File 'lib/odin/transform/transform_engine.rb', line 109

def self.source_path_not_found_error(path)
  TransformError.new("Source path not found: #{path}", code: ErrorCodes::T005_SOURCE_PATH_NOT_FOUND)
end

.unknown_verb_error(verb_name) ⇒ Object

Create a T001 Unknown Verb error.



94
95
96
# File 'lib/odin/transform/transform_engine.rb', line 94

def self.unknown_verb_error(verb_name)
  TransformError.new("Unknown verb: #{verb_name}", code: ErrorCodes::T001_UNKNOWN_VERB)
end

Instance Method Details

#check_verb_arg_types!(verb_name, args) ⇒ Object

T002: under strictTypes, a numeric verb argument that is not a numeric (or null) value fails the field.



379
380
381
382
383
384
385
386
387
388
389
390
391
392
# File 'lib/odin/transform/transform_engine.rb', line 379

def check_verb_arg_types!(verb_name, args)
  return unless NUMERIC_ARG_VERBS.include?(verb_name)

  args.each_with_index do |arg, i|
    next if arg.nil?
    next if NUMERIC_DYN_TYPES.include?(arg.type)

    err = TransformError.new(
      "Verb '#{verb_name}' arg #{i + 1}: expected number, got #{arg.type}",
      code: ErrorCodes::T002_INVALID_VERB_ARGS
    )
    raise CodedTransformError.new(err)
  end
end

#evaluate(expr, context) ⇒ Object

── Expression Evaluation ──



396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
# File 'lib/odin/transform/transform_engine.rb', line 396

def evaluate(expr, context)
  case expr
  when LiteralExpr
    val = expr.value
    if val.is_a?(Types::DynValue) && val.string? && val.value.include?("${")
      return interpolate_string(val.value, context)
    end
    val
  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, import_resolver: nil) ⇒ Object



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
# File 'lib/odin/transform/transform_engine.rb', line 137

def execute(transform_def, source_data, import_resolver: nil)
  # Merge imported tables, constants, accumulators, and segments.
  if import_resolver && transform_def.imports && !transform_def.imports.empty?
    resolve_imports(transform_def, import_resolver)
  end

  # 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
    process_segment_list(transform_def.segments, source, context, output)
  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

      pass_segments = transform_def.segments.select { |s| (s.pass || 0) == pass_num }
      process_segment_list(pass_segments, source, context, output)
    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, warnings: context.warnings)
end

#execute_multi_record(transform_def, raw_input, disc_config) ⇒ Object

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



200
201
202
203
204
205
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
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
# File 'lib/odin/transform/transform_engine.rb', line 200

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, warnings: context.warnings)
end

#invoke_verb(name, args, context) ⇒ Object

Public for unit testing verbs directly



361
362
363
364
365
366
# File 'lib/odin/transform/transform_engine.rb', line 361

def invoke_verb(name, args, context)
  verb_fn = @verb_registry[name]
  raise CodedTransformError.new(self.class.unknown_verb_error(name)) unless verb_fn

  verb_fn.call(args, context)
end