ductr 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +14 -0
- data/.vscode/settings.json +18 -0
- data/COPYING +674 -0
- data/COPYING.LESSER +165 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +121 -0
- data/README.md +37 -0
- data/Rakefile +37 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/ductr.gemspec +50 -0
- data/exe/ductr +24 -0
- data/lib/ductr/adapter.rb +94 -0
- data/lib/ductr/cli/default.rb +25 -0
- data/lib/ductr/cli/main.rb +60 -0
- data/lib/ductr/cli/new_project_generator.rb +72 -0
- data/lib/ductr/cli/templates/project/bin_ductr.rb +7 -0
- data/lib/ductr/cli/templates/project/config_app.rb +5 -0
- data/lib/ductr/cli/templates/project/config_development.yml +8 -0
- data/lib/ductr/cli/templates/project/config_environment_development.rb +18 -0
- data/lib/ductr/cli/templates/project/gemfile.rb +6 -0
- data/lib/ductr/cli/templates/project/rubocop.yml +14 -0
- data/lib/ductr/cli/templates/project/tool-versions +1 -0
- data/lib/ductr/configuration.rb +145 -0
- data/lib/ductr/etl/controls/buffered_destination.rb +65 -0
- data/lib/ductr/etl/controls/buffered_transform.rb +76 -0
- data/lib/ductr/etl/controls/control.rb +46 -0
- data/lib/ductr/etl/controls/destination.rb +28 -0
- data/lib/ductr/etl/controls/paginated_source.rb +47 -0
- data/lib/ductr/etl/controls/source.rb +21 -0
- data/lib/ductr/etl/controls/transform.rb +28 -0
- data/lib/ductr/etl/fiber_control.rb +136 -0
- data/lib/ductr/etl/fiber_runner.rb +68 -0
- data/lib/ductr/etl/kiba_runner.rb +26 -0
- data/lib/ductr/etl/parser.rb +115 -0
- data/lib/ductr/etl/runner.rb +37 -0
- data/lib/ductr/etl_job.rb +161 -0
- data/lib/ductr/job.rb +58 -0
- data/lib/ductr/job_etl_runner.rb +37 -0
- data/lib/ductr/job_status.rb +56 -0
- data/lib/ductr/kiba_job.rb +130 -0
- data/lib/ductr/log/formatters/color_formatter.rb +48 -0
- data/lib/ductr/log/logger.rb +169 -0
- data/lib/ductr/log/outputs/file_output.rb +30 -0
- data/lib/ductr/log/outputs/standard_output.rb +39 -0
- data/lib/ductr/pipeline.rb +133 -0
- data/lib/ductr/pipeline_runner.rb +95 -0
- data/lib/ductr/pipeline_step.rb +92 -0
- data/lib/ductr/registry.rb +55 -0
- data/lib/ductr/rufus_trigger.rb +106 -0
- data/lib/ductr/scheduler.rb +117 -0
- data/lib/ductr/store/job_serializer.rb +59 -0
- data/lib/ductr/store/job_store.rb +59 -0
- data/lib/ductr/store/pipeline_serializer.rb +106 -0
- data/lib/ductr/store/pipeline_store.rb +48 -0
- data/lib/ductr/store.rb +81 -0
- data/lib/ductr/trigger.rb +49 -0
- data/lib/ductr/version.rb +6 -0
- data/lib/ductr.rb +143 -0
- data/sig/ductr.rbs +1107 -0
- metadata +292 -0
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "thor"
|
4
|
+
require "thor/group"
|
5
|
+
|
6
|
+
module Ductr
|
7
|
+
module CLI
|
8
|
+
#
|
9
|
+
# Thor generator to create a new project
|
10
|
+
#
|
11
|
+
class NewProjectGenerator < Thor::Group
|
12
|
+
include Thor::Actions
|
13
|
+
desc "Generate a new project"
|
14
|
+
argument :name, type: :string, optional: true, default: ""
|
15
|
+
|
16
|
+
#
|
17
|
+
# The templates source used to create a new project
|
18
|
+
#
|
19
|
+
# @return [String] the templates source absolute path
|
20
|
+
#
|
21
|
+
def self.source_root
|
22
|
+
"#{__dir__}/templates/project"
|
23
|
+
end
|
24
|
+
|
25
|
+
#
|
26
|
+
# Doing some setup before generating file,
|
27
|
+
# creates the project directory and sets it as destination for the generator
|
28
|
+
#
|
29
|
+
# @return [void]
|
30
|
+
#
|
31
|
+
def init
|
32
|
+
empty_directory name
|
33
|
+
self.destination_root = "#{destination_root}/#{name}"
|
34
|
+
end
|
35
|
+
|
36
|
+
#
|
37
|
+
# Creates files in the project's root
|
38
|
+
#
|
39
|
+
# @return [void]
|
40
|
+
#
|
41
|
+
def gen_root
|
42
|
+
copy_file "gemfile.rb", "Gemfile"
|
43
|
+
copy_file "rubocop.yml", ".rubocop.yml"
|
44
|
+
copy_file "tool-versions", ".tool-versions"
|
45
|
+
|
46
|
+
create_file "app/jobs/.gitkeep"
|
47
|
+
create_file "app/pipelines/.gitkeep"
|
48
|
+
create_file "app/schedulers/.gitkeep"
|
49
|
+
end
|
50
|
+
|
51
|
+
#
|
52
|
+
# Creates the bin file for the project
|
53
|
+
#
|
54
|
+
# @return [void]
|
55
|
+
#
|
56
|
+
def gen_bin
|
57
|
+
copy_file "bin_ductr.rb", "bin/ductr"
|
58
|
+
end
|
59
|
+
|
60
|
+
#
|
61
|
+
# Creates files in the `config` folder
|
62
|
+
#
|
63
|
+
# @return [void]
|
64
|
+
#
|
65
|
+
def gen_config
|
66
|
+
copy_file "config_app.rb", "config/app.rb"
|
67
|
+
copy_file "config_development.yml", "config/development.yml"
|
68
|
+
copy_file "config_environment_development.rb", "config/environment/development.rb"
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "active_support/cache/file_store"
|
3
|
+
|
4
|
+
Ductr.configure do |config|
|
5
|
+
# Store configuration.
|
6
|
+
#
|
7
|
+
# You can pass an `ActiveSupport::Cache::Store` class or a symbol
|
8
|
+
config.store(ActiveSupport::Cache::FileStore, "tmp/store")
|
9
|
+
|
10
|
+
# Logging configuration.
|
11
|
+
#
|
12
|
+
# The following logging levels are available:
|
13
|
+
# :debug, :info, :warn, :error, :fatal
|
14
|
+
config.logging.level = :debug
|
15
|
+
|
16
|
+
# Append logs to the stdout by default, you can add/replace outputs at will.
|
17
|
+
config.logging.add_output(Ductr::Log::StandardOutput, Ductr::Log::ColorFormatter)
|
18
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
ruby 3.1.0
|
@@ -0,0 +1,145 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "yaml"
|
4
|
+
require "erb"
|
5
|
+
|
6
|
+
module Ductr
|
7
|
+
#
|
8
|
+
# Contains the framework's configuration, including:
|
9
|
+
# - `DUCTR_ENV` environment variable
|
10
|
+
# - `Ductr.configure` block
|
11
|
+
# - Project root path
|
12
|
+
# - YML file configuration
|
13
|
+
#
|
14
|
+
class Configuration
|
15
|
+
# @return [Struct] The active job configuration, available options are
|
16
|
+
# `queue_adapter`, `default_queue_name`, `queue_name_prefix` & `queue_name_delimiter`
|
17
|
+
attr_reader :active_job
|
18
|
+
|
19
|
+
# @return [Class<Ductr::Log::Logger>] The logger constant
|
20
|
+
attr_reader :logging
|
21
|
+
|
22
|
+
# @return [String] The project root
|
23
|
+
attr_reader :root
|
24
|
+
|
25
|
+
# @return [Class<ActiveSupport::Cache::Store>, Symbol] The store adapter to use
|
26
|
+
# @see https://edgeapi.rubyonrails.org/classes/ActiveSupport/Cache.html#method-c-lookup_store
|
27
|
+
attr_reader :store_adapter
|
28
|
+
|
29
|
+
# @return [Array] The store adapter config
|
30
|
+
attr_reader :store_parameters
|
31
|
+
|
32
|
+
# @return [Hash] The parsed YML configuration
|
33
|
+
attr_reader :yml
|
34
|
+
|
35
|
+
#
|
36
|
+
# Initializing environment to "development" by default, setting project root, parsing YML with the config gem
|
37
|
+
# and aliasing semantic logger gem constant to make it usable through the `Ductr.configure` block
|
38
|
+
#
|
39
|
+
def initialize(env)
|
40
|
+
@root = Dir.pwd
|
41
|
+
@yml = load_yaml("#{root}/config/#{env}.yml")
|
42
|
+
|
43
|
+
@logging = Log::Logger
|
44
|
+
logging.level = :debug
|
45
|
+
|
46
|
+
@active_job = Struct.new(:queue_adapter, :default_queue_name, :queue_name_prefix, :queue_name_delimiter).new
|
47
|
+
@store_adapter = ActiveSupport::Cache::FileStore
|
48
|
+
@store_parameters = ["tmp/store"]
|
49
|
+
end
|
50
|
+
|
51
|
+
#
|
52
|
+
# Configures the store instance.
|
53
|
+
#
|
54
|
+
# @param [Class<ActiveSupport::Cache::Store>, Symbol] adapter The store adapter class
|
55
|
+
# @param [Array] *parameters The store adapter configuration
|
56
|
+
#
|
57
|
+
# @return [void]
|
58
|
+
#
|
59
|
+
def store(adapter, *parameters)
|
60
|
+
@store_adapter = adapter
|
61
|
+
@store_parameters = parameters
|
62
|
+
end
|
63
|
+
|
64
|
+
#
|
65
|
+
# Memoize configured adapters based on the YAML configuration.
|
66
|
+
#
|
67
|
+
# @return [Array<Adapter>] The configured Adapter instances
|
68
|
+
#
|
69
|
+
def adapters
|
70
|
+
@adapters ||= yml.adapters.to_h.map do |name, entry|
|
71
|
+
adapter_class = Ductr.adapter_registry.find(entry.adapter)
|
72
|
+
config = entry.to_h.except(:adapter)
|
73
|
+
|
74
|
+
adapter_class.new(name, **config)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
#
|
79
|
+
# Find an adapter based on its name.
|
80
|
+
#
|
81
|
+
# @param [Symbol] name The name of the adapter to find
|
82
|
+
#
|
83
|
+
# @raise [AdapterNotFoundError] If no adapter match the given name
|
84
|
+
# @return [Adapter] The adapter found
|
85
|
+
#
|
86
|
+
def adapter(name)
|
87
|
+
not_found_error = -> { raise AdapterNotFoundError, "The adapter named \"#{name}\" does not exist" }
|
88
|
+
|
89
|
+
adapters.find(not_found_error) do |adapter|
|
90
|
+
adapter.name == name
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
#
|
95
|
+
# Configures active job with the given options.
|
96
|
+
#
|
97
|
+
# @return [void]
|
98
|
+
#
|
99
|
+
def apply_active_job_config
|
100
|
+
ActiveJob::Base.logger = logging.new("ActiveJob")
|
101
|
+
|
102
|
+
active_job.each_pair do |opt, value|
|
103
|
+
next unless value
|
104
|
+
|
105
|
+
ActiveJob::Base.send("#{opt}=", value)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
private
|
110
|
+
|
111
|
+
#
|
112
|
+
# Load YAML configuration localized at given path.
|
113
|
+
# Parse the file with ERB before parsing YAML, so we can use env vars in config files.
|
114
|
+
#
|
115
|
+
# @param [String] path The path of the YAML file to load
|
116
|
+
#
|
117
|
+
# @return [Struct] The parsed YAML configuration
|
118
|
+
#
|
119
|
+
def load_yaml(path)
|
120
|
+
return {} unless path && File.exist?(path)
|
121
|
+
|
122
|
+
erb = ERB.new File.read(path)
|
123
|
+
yaml = YAML.load(erb.result, symbolize_names: true)
|
124
|
+
|
125
|
+
hash_to_struct(yaml)
|
126
|
+
end
|
127
|
+
|
128
|
+
#
|
129
|
+
# Recursively convert Hash into Struct.
|
130
|
+
#
|
131
|
+
# @param [Hash] hash The hash to convert
|
132
|
+
#
|
133
|
+
# @return [Struct] The converted hash
|
134
|
+
#
|
135
|
+
def hash_to_struct(hash)
|
136
|
+
values = hash.values.map do |value|
|
137
|
+
next hash_to_struct(value) if value.is_a?(Hash)
|
138
|
+
|
139
|
+
value
|
140
|
+
end
|
141
|
+
|
142
|
+
Struct.new(*hash.keys).new(*values)
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Ductr
|
4
|
+
module ETL
|
5
|
+
#
|
6
|
+
# Base class to implement buffered destinations.
|
7
|
+
#
|
8
|
+
class BufferedDestination < Destination
|
9
|
+
# @return [Array] The row buffer
|
10
|
+
attr_reader :buffer
|
11
|
+
|
12
|
+
#
|
13
|
+
# The buffer size option, default to 10_000.
|
14
|
+
#
|
15
|
+
# @return [Integer] The buffer size
|
16
|
+
#
|
17
|
+
def buffer_size
|
18
|
+
@options[:buffer_size] || 10_000
|
19
|
+
end
|
20
|
+
|
21
|
+
#
|
22
|
+
# Pushes the row inside the buffer or flushes it when full.
|
23
|
+
#
|
24
|
+
# @param [Object] row The row to write
|
25
|
+
#
|
26
|
+
# @return [void]
|
27
|
+
#
|
28
|
+
def write(row)
|
29
|
+
@buffer ||= []
|
30
|
+
|
31
|
+
@buffer.push row
|
32
|
+
flush_buffer if @buffer.size == buffer_size
|
33
|
+
end
|
34
|
+
|
35
|
+
#
|
36
|
+
# Flushes the buffer, called when the last row is reached.
|
37
|
+
#
|
38
|
+
# @return [void]
|
39
|
+
#
|
40
|
+
def close
|
41
|
+
flush_buffer unless @buffer.empty?
|
42
|
+
super
|
43
|
+
end
|
44
|
+
|
45
|
+
#
|
46
|
+
# Calls #on_flush and reset the buffer.
|
47
|
+
#
|
48
|
+
# @return [void]
|
49
|
+
#
|
50
|
+
def flush_buffer
|
51
|
+
on_flush
|
52
|
+
@buffer = []
|
53
|
+
end
|
54
|
+
|
55
|
+
#
|
56
|
+
# Called each time the buffer have to be emptied.
|
57
|
+
#
|
58
|
+
# @return [void]
|
59
|
+
#
|
60
|
+
def on_flush
|
61
|
+
raise NotImplementedError, "A buffered destination must implement the `#on_flush` method"
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Ductr
|
4
|
+
module ETL
|
5
|
+
#
|
6
|
+
# Base class to implement buffered transforms.
|
7
|
+
#
|
8
|
+
class BufferedTransform < Transform
|
9
|
+
# @return [Array] The row buffer
|
10
|
+
attr_reader :buffer
|
11
|
+
|
12
|
+
#
|
13
|
+
# The buffer size option, default to 10_000.
|
14
|
+
#
|
15
|
+
# @return [Integer] The buffer size
|
16
|
+
#
|
17
|
+
def buffer_size
|
18
|
+
@options[:buffer_size] || 10_000
|
19
|
+
end
|
20
|
+
|
21
|
+
#
|
22
|
+
# Pushes the row inside the buffer or flushes it when full.
|
23
|
+
#
|
24
|
+
# @param [Object] row The row to process
|
25
|
+
# @yield [row] The row yielder
|
26
|
+
#
|
27
|
+
# @return [nil] Returning nil to complies with kiba
|
28
|
+
#
|
29
|
+
def process(row, &)
|
30
|
+
@buffer ||= []
|
31
|
+
|
32
|
+
@buffer.push row
|
33
|
+
flush_buffer(&) if @buffer.size == buffer_size
|
34
|
+
|
35
|
+
# avoid returning a row, see
|
36
|
+
# https://github.com/thbar/kiba/wiki/Implementing-ETL-transforms#generating-more-than-one-output-row-per-input-row-aka-yielding-transforms
|
37
|
+
nil
|
38
|
+
end
|
39
|
+
|
40
|
+
#
|
41
|
+
# Called when the last row is reached.
|
42
|
+
#
|
43
|
+
# @yield [row] The row yielder
|
44
|
+
#
|
45
|
+
# @return [void]
|
46
|
+
#
|
47
|
+
def close(&)
|
48
|
+
flush_buffer(&) unless @buffer.empty?
|
49
|
+
super
|
50
|
+
end
|
51
|
+
|
52
|
+
#
|
53
|
+
# Calls #on_flush and reset the buffer.
|
54
|
+
#
|
55
|
+
# @yield [row] The row yielder
|
56
|
+
#
|
57
|
+
# @return [void]
|
58
|
+
#
|
59
|
+
def flush_buffer(&)
|
60
|
+
on_flush(&)
|
61
|
+
@buffer = []
|
62
|
+
end
|
63
|
+
|
64
|
+
#
|
65
|
+
# Called each time the buffer have to be emptied.
|
66
|
+
#
|
67
|
+
# @yield [row] The row yielder
|
68
|
+
#
|
69
|
+
# @return [void]
|
70
|
+
#
|
71
|
+
def on_flush(&)
|
72
|
+
raise NotImplementedError, "A buffered transform must implement the `#on_flush` method"
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Ductr
|
4
|
+
module ETL
|
5
|
+
#
|
6
|
+
# Base class for all types of ETL control.
|
7
|
+
#
|
8
|
+
class Control
|
9
|
+
extend Forwardable
|
10
|
+
|
11
|
+
class << self
|
12
|
+
# @return [Symbol] The control type, written when registering the control into its adapter
|
13
|
+
attr_accessor :type
|
14
|
+
end
|
15
|
+
|
16
|
+
#
|
17
|
+
# @!method call_method
|
18
|
+
# Invokes the job's method linked to the control.
|
19
|
+
# @return [Object] Something returned by the method, e.g. a query, a file, a row, ...
|
20
|
+
#
|
21
|
+
def_delegator :@job_method, :call, :call_method
|
22
|
+
|
23
|
+
# @return [Symbol] The method to be called by the control
|
24
|
+
attr_reader :job_method
|
25
|
+
|
26
|
+
# @return [Hash] The configuration hash of the control's adapter
|
27
|
+
attr_reader :options
|
28
|
+
|
29
|
+
# @return [Adapter] The control's adapter
|
30
|
+
attr_reader :adapter
|
31
|
+
|
32
|
+
#
|
33
|
+
# Creates a new control based on the job instance and the configured adapter.
|
34
|
+
#
|
35
|
+
# @param [Method] job_method The job's method to be called by the control
|
36
|
+
# @param [Adapter] adapter The configured adapter
|
37
|
+
# @param [Hash] **options The configuration hash of the control's adapter
|
38
|
+
#
|
39
|
+
def initialize(job_method, adapter = nil, **options)
|
40
|
+
@job_method = job_method
|
41
|
+
@adapter = adapter
|
42
|
+
@options = options
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Ductr
|
4
|
+
module ETL
|
5
|
+
#
|
6
|
+
# Base class for implementing destinations.
|
7
|
+
#
|
8
|
+
class Destination < Control
|
9
|
+
#
|
10
|
+
# Writes the row into the destination.
|
11
|
+
#
|
12
|
+
# @param [Object] row The row to write
|
13
|
+
#
|
14
|
+
# @return [void]
|
15
|
+
#
|
16
|
+
def write(row)
|
17
|
+
call_method(row)
|
18
|
+
end
|
19
|
+
|
20
|
+
#
|
21
|
+
# Called when the last row is reached, closes the adapter.
|
22
|
+
#
|
23
|
+
# @return [void]
|
24
|
+
#
|
25
|
+
def close; end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Ductr
|
4
|
+
module ETL
|
5
|
+
#
|
6
|
+
# Base class to implement paginated source.
|
7
|
+
#
|
8
|
+
class PaginatedSource < Source
|
9
|
+
#
|
10
|
+
# The page size option, default to 10_000.
|
11
|
+
#
|
12
|
+
# @return [Integer] The page size
|
13
|
+
#
|
14
|
+
def page_size
|
15
|
+
@options[:page_size] || 10_000
|
16
|
+
end
|
17
|
+
|
18
|
+
#
|
19
|
+
# Iterates over pages and calls #each_page.
|
20
|
+
#
|
21
|
+
# @yield [row] The row yielder
|
22
|
+
#
|
23
|
+
# @return [void]
|
24
|
+
#
|
25
|
+
def each(&)
|
26
|
+
@offset ||= 0
|
27
|
+
|
28
|
+
loop do
|
29
|
+
break unless each_page(&)
|
30
|
+
|
31
|
+
@offset += page_size
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
#
|
36
|
+
# Called once per pages.
|
37
|
+
#
|
38
|
+
# @yield [row] The row yielder
|
39
|
+
#
|
40
|
+
# @return [void]
|
41
|
+
#
|
42
|
+
def each_page(&)
|
43
|
+
raise NotImplementedError, "A paginated source must implement the `#each_page` method"
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Ductr
|
4
|
+
module ETL
|
5
|
+
#
|
6
|
+
# The base class for implementing sources.
|
7
|
+
#
|
8
|
+
class Source < Control
|
9
|
+
#
|
10
|
+
# Iterates over rows.
|
11
|
+
#
|
12
|
+
# @yield [row] The row yielder
|
13
|
+
#
|
14
|
+
# @return [void]
|
15
|
+
#
|
16
|
+
def each(&)
|
17
|
+
call_method.each(&)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Ductr
|
4
|
+
module ETL
|
5
|
+
#
|
6
|
+
# Base class for implementing transforms.
|
7
|
+
#
|
8
|
+
class Transform < Control
|
9
|
+
#
|
10
|
+
# Calls the control method and passes the row.
|
11
|
+
#
|
12
|
+
# @param [Object] row The row to process
|
13
|
+
#
|
14
|
+
# @return [void]
|
15
|
+
#
|
16
|
+
def process(row)
|
17
|
+
call_method(row)
|
18
|
+
end
|
19
|
+
|
20
|
+
#
|
21
|
+
# Called when the last row is reached.
|
22
|
+
#
|
23
|
+
# @return [void]
|
24
|
+
#
|
25
|
+
def close; end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|