Class: Odin::Transform::TransformEngine
- Inherits:
-
Object
- Object
- Odin::Transform::TransformEngine
- 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
-
#verb_registry ⇒ Object
readonly
Returns the value of attribute verb_registry.
Class Method Summary collapse
-
.accumulator_overflow_error(name, value) ⇒ Object
Create a T008 Accumulator Overflow error.
-
.dangling_branch_error(directive) ⇒ Object
Create a T012 Dangling Branch error (elif/else with no preceding if).
-
.incompatible_conversion_error(verb_name, detail) ⇒ Object
Create a T011 Incompatible Conversion error.
-
.invalid_output_format_error(format) ⇒ Object
Create a T006 Invalid Output Format error.
-
.lookup_key_not_found_error(table_name, key) ⇒ Object
Create a T004 Lookup Key Not Found error.
-
.lookup_table_not_found_error(table_name) ⇒ Object
Create a T003 Lookup Table Not Found error.
-
.loop_source_not_array_error(path) ⇒ Object
Create a T009 Loop Source Not Array error.
-
.nested_interpolation_error(expr, segment = nil) ⇒ Object
Create a T014 Nested Interpolation error.
-
.source_path_not_found_error(path) ⇒ Object
Create a T005 Source Path Not Found error.
-
.unknown_verb_error(verb_name) ⇒ Object
Create a T001 Unknown Verb error.
Instance Method Summary collapse
-
#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.
-
#evaluate(expr, context) ⇒ Object
── Expression Evaluation ──.
- #execute(transform_def, source_data, import_resolver: nil) ⇒ Object
-
#execute_multi_record(transform_def, raw_input, disc_config) ⇒ Object
── Multi-Record Execution (discriminator-based routing) ──.
-
#initialize ⇒ TransformEngine
constructor
A new instance of TransformEngine.
-
#invoke_verb(name, args, context) ⇒ Object
Public for unit testing verbs directly.
Constructor Details
#initialize ⇒ TransformEngine
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_registry ⇒ Object (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.["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 |