Files
datapipeline/test/core/operations_test.rb
T

179 lines
6.5 KiB
Ruby

require_relative '../test_helper'
# on_error, validate, stats, hooks, watermark, dry run, limit
class OperationsTest < PipelineTest
ROWS = (1..5).map { |i| { 'id' => i.to_s, 'shkzg' => i == 3 ? 'X' : 'S', 'betrag' => i == 4 ? 'abc' : "#{i}.50" } }.freeze
RULES = { 'id' => { required: true, format: /\A\d+\z/ }, 'shkzg' => { in: %w[S H] }, 'betrag' => { type: :decimal } }.freeze
def failing_map = proc { map { |r| r['id'] == '2' ? raise('kaputt') : r } }
def test_abort_is_default
error = assert_raises(RuntimeError) { run_pipeline(ROWS, &failing_map) }
assert_equal 'kaputt', error.message
assert_equal 'kaputt', DataPipeline.runs.last.error.message
end
def test_skip
map = failing_map
target, stats = run_pipeline(ROWS) { on_error :skip; instance_eval(&map) }
assert_equal 4, target.output.size
assert_equal 1, stats.rejected
assert_equal 'map#1', stats.errors.first[:step]
refute stats.ok?
end
def test_collect_batch_and_stream
[false, true].each do |streamed|
rejects = MemoryStorage.new
map = failing_map
target, = run_pipeline(ROWS, streamed:) { on_error :collect, to: rejects; instance_eval(&map) }
assert_equal [ROWS[1].merge('_step' => 'map#1', '_error' => 'kaputt')], rejects.streamed_rows
assert_equal 4, target.output.size
end
end
def test_on_error_arguments
assert_raises(ArgumentError) { DataPipeline.new { on_error :collect } }
assert_raises(ArgumentError) { DataPipeline.new { on_error :ignore } }
end
def test_edge_errors_always_abort
assert_raises(RuntimeError) { run_pipeline(ROWS) { on_error :skip; header { raise 'h' }; map { |r| r } } }
end
def test_skip_in_parallel_mode
target, stats = run_pipeline(ROWS) { on_error :skip; transform { |r| r['id'] == '1' ? raise('x') : r } }
assert_equal 4, target.output.size
assert_equal 1, stats.rejected
end
def test_validate_rules
assert_raises(DataPipeline::ValidationError) { run_pipeline(ROWS) { validate RULES } }
target, stats = run_pipeline(ROWS) { on_error :skip; validate RULES }
assert_equal %w[1 2 5], target.output.map { |r| r['id'] }
assert_equal 2, stats.rejected
end
def test_validate_block
target, stats = run_pipeline(ROWS) { on_error :skip; validate { |r| 'zu gross' if r['id'].to_i > 3 } }
assert_equal 3, target.output.size
assert_equal 'zu gross', stats.errors.first[:error]
end
def test_validation_messages
errors = DataPipeline::Validation.errors({ 'a' => '12345', 'd' => '2025-13-01' },
'a' => { max: 3, format: /x/ }, 'd' => { type: :date }, 'b' => { required: true })
assert_equal ['a: longer than 3', 'a: format /x/', 'd: not date', 'b: missing'], errors
assert_empty DataPipeline::Validation.errors({ 'a' => '' }, 'a' => { max: 1, type: :integer })
end
def test_validate_turns_chaining_on
target, = run_pipeline(ROWS) { on_error :skip; transform { |r| r.merge('x' => 1) }; validate RULES }
assert_equal 3, target.output.size
assert(target.output.all? { |r| r['x'] })
end
def test_stats
_target, stats = run_pipeline(ROWS) { map { |r| [r, r] } }
assert_equal [5, 10, 10], [stats.read, stats.written, stats.output]
assert stats.ok?
assert_kind_of Float, stats.duration
map = failing_map
_target, stats = run_pipeline(ROWS) { on_error :skip; instance_eval(&map) }
assert_match(/\Aread=5 written=4 rejected=1/, stats.to_s)
end
def test_on_finish_after_success_and_abort
seen = []
run_pipeline(ROWS) { on_finish { |s| seen << s.error } }
assert_raises(RuntimeError) { run_pipeline(ROWS) { on_finish { |s| seen << s.error.class }; map { raise 'x' } } }
assert_equal [nil, RuntimeError], seen
end
def test_hook_error_is_only_logged
log = StringIO.new
stats = DataPipeline.new do
logger Logger.new(log)
read MemoryStorage.new(rows: ROWS)
write MemoryStorage.new
on_finish { raise 'hook' }
end.run
assert stats.ok?
assert_includes log.string, 'on_finish: RuntimeError: hook'
end
def test_logger_messages
log = StringIO.new
DataPipeline.new do
logger Logger.new(log)
on_error :skip
read MemoryStorage.new(rows: ROWS)
write MemoryStorage.new
map { |r| r['id'] == '1' ? raise('weg') : r }
end.run
assert_includes log.string, 'rejected by map#1: weg'
assert_includes log.string, 'finish read=5 written=4 rejected=1'
end
def test_watermark
with_tmpdir do |dir|
file = "#{dir}/state.json"
wm = nil
run_pipeline(ROWS) { wm = watermark :t, column: 'id', file:, initial: 0 }
assert_equal '5', wm.value
run_pipeline([{ 'id' => '9' }, { 'id' => '10' }]) { watermark :t, column: 'id', file: }
assert_equal '10', JSON.parse(File.read(file))['t'], 'numeric texts compared as numbers'
assert_raises(RuntimeError) { run_pipeline([{ 'id' => '99' }]) { watermark :t, column: 'id', file:; map { raise 'x' } } }
assert_equal '10', JSON.parse(File.read(file))['t'], 'not saved after abort'
run_pipeline(ROWS, streamed: true) { watermark :s, column: 'id', file: }
assert_equal({ 't' => '10', 's' => '5' }, JSON.parse(File.read(file)))
end
end
def test_dry_run
with_tmpdir do |dir|
rejects = MemoryStorage.new
target = nil
text = capture_stdout do
target, = run_pipeline(ROWS, dry_run: true, preview: 2) do
watermark :d, column: 'id', file: "#{dir}/state.json"
on_error :collect, to: rejects
map { |r| r['id'] == '1' ? raise('x') : r }
end
end
assert_nil target.written
assert_empty target.streamed_rows
assert_empty rejects.streamed_rows
refute File.exist?("#{dir}/state.json")
assert text.start_with?('DRY RUN')
assert_equal 3, text.lines.size
assert DataPipeline.runs.last.dry_run
end
end
def test_dry_run_creates_no_csv_file
with_tmpdir do |dir|
capture_stdout do
DataPipeline.new(dry_run: true) do
logger PipelineHelper::QUIET
read MemoryStorage.new(rows: ROWS)
write container(:csv, file: "#{dir}/dry.csv")
end.run
end
refute File.exist?("#{dir}/dry.csv")
end
end
def test_limit
target, stats = run_pipeline(ROWS, limit: 2) { map { |r| r } }
assert_equal [2, 2], [target.output.size, stats.read]
target, stats = run_pipeline(ROWS, streamed: true, limit: 3) { trailer { |c| { 'n' => c.read } } }
assert_equal({ 'n' => 3 }, target.output.last)
assert_equal 3, stats.read
end
end