chronicle-etl 0.3.1 → 0.4.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/workflows/ruby.yml +35 -0
- data/.rubocop.yml +31 -1
- data/Guardfile +7 -0
- data/README.md +157 -82
- data/Rakefile +4 -2
- data/chronicle-etl.gemspec +11 -3
- data/exe/chronicle-etl +1 -1
- data/lib/chronicle/etl/cli/connectors.rb +34 -5
- data/lib/chronicle/etl/cli/jobs.rb +90 -24
- data/lib/chronicle/etl/cli/main.rb +41 -19
- data/lib/chronicle/etl/cli/plugins.rb +62 -0
- data/lib/chronicle/etl/cli/subcommand_base.rb +2 -2
- data/lib/chronicle/etl/cli.rb +9 -0
- data/lib/chronicle/etl/config.rb +7 -4
- data/lib/chronicle/etl/configurable.rb +163 -0
- data/lib/chronicle/etl/exceptions.rb +29 -1
- data/lib/chronicle/etl/extractors/csv_extractor.rb +24 -23
- data/lib/chronicle/etl/extractors/extractor.rb +16 -15
- data/lib/chronicle/etl/extractors/file_extractor.rb +34 -11
- data/lib/chronicle/etl/extractors/helpers/input_reader.rb +76 -0
- data/lib/chronicle/etl/extractors/json_extractor.rb +19 -18
- data/lib/chronicle/etl/job.rb +8 -2
- data/lib/chronicle/etl/job_definition.rb +20 -5
- data/lib/chronicle/etl/loaders/csv_loader.rb +36 -9
- data/lib/chronicle/etl/loaders/helpers/encoding_helper.rb +18 -0
- data/lib/chronicle/etl/loaders/json_loader.rb +44 -0
- data/lib/chronicle/etl/loaders/loader.rb +28 -2
- data/lib/chronicle/etl/loaders/rest_loader.rb +5 -5
- data/lib/chronicle/etl/loaders/table_loader.rb +18 -37
- data/lib/chronicle/etl/logger.rb +6 -2
- data/lib/chronicle/etl/models/base.rb +3 -0
- data/lib/chronicle/etl/models/entity.rb +8 -2
- data/lib/chronicle/etl/models/raw.rb +26 -0
- data/lib/chronicle/etl/registry/connector_registration.rb +6 -0
- data/lib/chronicle/etl/registry/plugin_registry.rb +70 -0
- data/lib/chronicle/etl/registry/registry.rb +27 -14
- data/lib/chronicle/etl/runner.rb +35 -17
- data/lib/chronicle/etl/serializers/jsonapi_serializer.rb +6 -0
- data/lib/chronicle/etl/serializers/raw_serializer.rb +10 -0
- data/lib/chronicle/etl/serializers/serializer.rb +2 -1
- data/lib/chronicle/etl/transformers/image_file_transformer.rb +22 -28
- data/lib/chronicle/etl/transformers/null_transformer.rb +1 -1
- data/lib/chronicle/etl/transformers/transformer.rb +3 -2
- data/lib/chronicle/etl/version.rb +1 -1
- data/lib/chronicle/etl.rb +12 -4
- metadata +123 -18
- data/.ruby-version +0 -1
- data/lib/chronicle/etl/extractors/helpers/filesystem_reader.rb +0 -104
- data/lib/chronicle/etl/loaders/stdout_loader.rb +0 -14
- data/lib/chronicle/etl/models/generic.rb +0 -23
@@ -1,4 +1,6 @@
|
|
1
1
|
require 'pp'
|
2
|
+
require 'tty-prompt'
|
3
|
+
|
2
4
|
module Chronicle
|
3
5
|
module ETL
|
4
6
|
module CLI
|
@@ -7,18 +9,28 @@ module Chronicle
|
|
7
9
|
default_task "start"
|
8
10
|
namespace :jobs
|
9
11
|
|
10
|
-
class_option :
|
12
|
+
class_option :name, aliases: '-j', desc: 'Job configuration name'
|
13
|
+
|
14
|
+
class_option :extractor, aliases: '-e', desc: "Extractor class. Default: stdin", banner: 'NAME'
|
11
15
|
class_option :'extractor-opts', desc: 'Extractor options', type: :hash, default: {}
|
12
|
-
class_option :transformer, aliases: '-t', desc: 'Transformer class. Default: null', banner: '
|
16
|
+
class_option :transformer, aliases: '-t', desc: 'Transformer class. Default: null', banner: 'NAME'
|
13
17
|
class_option :'transformer-opts', desc: 'Transformer options', type: :hash, default: {}
|
14
|
-
class_option :loader, aliases: '-l', desc: 'Loader class. Default:
|
18
|
+
class_option :loader, aliases: '-l', desc: 'Loader class. Default: table', banner: 'NAME'
|
15
19
|
class_option :'loader-opts', desc: 'Loader options', type: :hash, default: {}
|
16
|
-
class_option :name, aliases: '-j', desc: 'Job configuration name'
|
17
20
|
|
18
|
-
|
21
|
+
# This is an array to deal with shell globbing
|
22
|
+
class_option :input, aliases: '-i', desc: 'Input filename or directory', default: [], type: 'array', banner: 'FILENAME'
|
23
|
+
class_option :since, desc: "Load records SINCE this date", banner: 'DATE'
|
24
|
+
class_option :until, desc: "Load records UNTIL this date", banner: 'DATE'
|
25
|
+
class_option :limit, desc: "Only extract the first LIMIT records", banner: 'N'
|
26
|
+
|
27
|
+
class_option :output, aliases: '-o', desc: 'Output filename', type: 'string'
|
28
|
+
class_option :fields, desc: 'Output only these fields', type: 'array', banner: 'field1 field2 ...'
|
29
|
+
class_option :header_row, desc: 'Output the header row of tabular output', type: 'boolean'
|
30
|
+
|
31
|
+
# Thor doesn't like `run` as a command name
|
32
|
+
map run: :start
|
19
33
|
desc "run", "Start a job"
|
20
|
-
option :log_level, desc: 'Log level (debug, info, warn, error, fatal)', default: 'info'
|
21
|
-
option :verbose, aliases: '-v', desc: 'Set log level to verbose', type: :boolean
|
22
34
|
option :dry_run, desc: 'Only run the extraction and transform steps, not the loading', type: :boolean
|
23
35
|
long_desc <<-LONG_DESC
|
24
36
|
This will run an ETL job. Each job needs three parts:
|
@@ -33,25 +45,40 @@ module Chronicle
|
|
33
45
|
LONG_DESC
|
34
46
|
# Run an ETL job
|
35
47
|
def start
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
48
|
+
run_job(options)
|
49
|
+
rescue Chronicle::ETL::JobDefinitionError => e
|
50
|
+
missing_plugins = e.job_definition.errors
|
51
|
+
.select { |error| error.is_a?(Chronicle::ETL::PluginLoadError) }
|
52
|
+
.map(&:name)
|
53
|
+
.uniq
|
54
|
+
|
55
|
+
install_missing_plugins(missing_plugins)
|
56
|
+
run_job(options)
|
41
57
|
end
|
42
58
|
|
43
59
|
desc "create", "Create a job"
|
44
60
|
# Create an ETL job
|
45
61
|
def create
|
46
62
|
job_definition = build_job_definition(options)
|
63
|
+
job_definition.validate!
|
64
|
+
|
47
65
|
path = File.join('chronicle', 'etl', 'jobs', options[:name])
|
48
66
|
Chronicle::ETL::Config.write(path, job_definition.definition)
|
67
|
+
rescue Chronicle::ETL::JobDefinitionError => e
|
68
|
+
Chronicle::ETL::Logger.debug(e.full_message)
|
69
|
+
Chronicle::ETL::Logger.fatal("Job definition error".red)
|
49
70
|
end
|
50
71
|
|
51
72
|
desc "show", "Show details about a job"
|
52
73
|
# Show an ETL job
|
53
74
|
def show
|
54
|
-
|
75
|
+
job_definition = build_job_definition(options)
|
76
|
+
job_definition.validate!
|
77
|
+
puts Chronicle::ETL::Job.new(job_definition)
|
78
|
+
rescue Chronicle::ETL::JobDefinitionError => e
|
79
|
+
Chronicle::ETL::Logger.debug(e.full_message)
|
80
|
+
Chronicle::ETL::Logger.fatal("Job definition error".red)
|
81
|
+
exit 1
|
55
82
|
end
|
56
83
|
|
57
84
|
desc "list", "List all available jobs"
|
@@ -69,28 +96,52 @@ LONG_DESC
|
|
69
96
|
[job, extractor, transformer, loader]
|
70
97
|
end
|
71
98
|
|
72
|
-
headers = ['name', 'extractor', 'transformer', 'loader'].map{|h| h.upcase.bold }
|
99
|
+
headers = ['name', 'extractor', 'transformer', 'loader'].map { |h| h.upcase.bold }
|
73
100
|
|
101
|
+
puts "Available jobs:"
|
74
102
|
table = TTY::Table.new(headers, job_details)
|
75
103
|
puts table.render(indent: 0, padding: [0, 2])
|
104
|
+
rescue Chronicle::ETL::ConfigError => e
|
105
|
+
Chronicle::ETL::Logger.debug(e.full_message)
|
106
|
+
Chronicle::ETL::Logger.fatal("Error reading config. #{e.message}".red)
|
107
|
+
exit 1
|
76
108
|
end
|
77
109
|
|
78
110
|
private
|
79
111
|
|
80
|
-
def
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
112
|
+
def run_job(options)
|
113
|
+
job_definition = build_job_definition(options)
|
114
|
+
job = Chronicle::ETL::Job.new(job_definition)
|
115
|
+
runner = Chronicle::ETL::Runner.new(job)
|
116
|
+
runner.run!
|
117
|
+
end
|
118
|
+
|
119
|
+
# TODO: probably could merge this with something in cli/plugin
|
120
|
+
def install_missing_plugins(missing_plugins)
|
121
|
+
prompt = TTY::Prompt.new
|
122
|
+
message = "Plugin#{'s' if missing_plugins.count > 1} specified by job not installed.\n"
|
123
|
+
message += "Do you want to install "
|
124
|
+
message += missing_plugins.map { |name| "chronicle-#{name}".bold}.join(", ")
|
125
|
+
message += " and start the job?"
|
126
|
+
install = prompt.yes?(message)
|
127
|
+
return unless install
|
128
|
+
|
129
|
+
spinner = TTY::Spinner.new("[:spinner] Installing plugins...", format: :dots_2)
|
130
|
+
spinner.auto_spin
|
131
|
+
missing_plugins.each do |plugin|
|
132
|
+
Chronicle::ETL::Registry::PluginRegistry.install(plugin)
|
86
133
|
end
|
134
|
+
spinner.success("(#{'successful'.green})")
|
135
|
+
rescue Chronicle::ETL::PluginNotAvailableError => e
|
136
|
+
spinner.error("Error".red)
|
137
|
+
Chronicle::ETL::Logger.fatal("Plugin '#{e.name}' could not be installed".red)
|
87
138
|
end
|
88
139
|
|
89
140
|
# Create job definition by reading config file and then overwriting with flag options
|
90
141
|
def build_job_definition(options)
|
91
142
|
definition = Chronicle::ETL::JobDefinition.new
|
92
143
|
definition.add_config(load_job_config(options[:name]))
|
93
|
-
definition.add_config(process_flag_options(options))
|
144
|
+
definition.add_config(process_flag_options(options).transform_keys(&:to_sym))
|
94
145
|
definition
|
95
146
|
end
|
96
147
|
|
@@ -100,19 +151,34 @@ LONG_DESC
|
|
100
151
|
|
101
152
|
# Takes flag options and turns them into a runner config
|
102
153
|
def process_flag_options options
|
154
|
+
extractor_options = options[:'extractor-opts'].merge({
|
155
|
+
input: (options[:input] if options[:input].any?),
|
156
|
+
since: options[:since],
|
157
|
+
until: options[:until],
|
158
|
+
limit: options[:limit],
|
159
|
+
}.compact)
|
160
|
+
|
161
|
+
transformer_options = options[:'transformer-opts']
|
162
|
+
|
163
|
+
loader_options = options[:'loader-opts'].merge({
|
164
|
+
output: options[:output],
|
165
|
+
header_row: options[:header_row],
|
166
|
+
fields: options[:fields]
|
167
|
+
}.compact)
|
168
|
+
|
103
169
|
{
|
104
170
|
dry_run: options[:dry_run],
|
105
171
|
extractor: {
|
106
172
|
name: options[:extractor],
|
107
|
-
options:
|
173
|
+
options: extractor_options
|
108
174
|
}.compact,
|
109
175
|
transformer: {
|
110
176
|
name: options[:transformer],
|
111
|
-
options:
|
177
|
+
options: transformer_options
|
112
178
|
}.compact,
|
113
179
|
loader: {
|
114
180
|
name: options[:loader],
|
115
|
-
options:
|
181
|
+
options: loader_options
|
116
182
|
}.compact
|
117
183
|
}
|
118
184
|
end
|
@@ -1,17 +1,18 @@
|
|
1
|
-
require 'thor'
|
2
|
-
require 'chronicle/etl'
|
3
1
|
require 'colorize'
|
4
2
|
|
5
|
-
require 'chronicle/etl/cli/subcommand_base'
|
6
|
-
require 'chronicle/etl/cli/connectors'
|
7
|
-
require 'chronicle/etl/cli/jobs'
|
8
|
-
|
9
3
|
module Chronicle
|
10
4
|
module ETL
|
11
5
|
module CLI
|
12
6
|
# Main entrypoint for CLI app
|
13
|
-
class Main < Thor
|
14
|
-
|
7
|
+
class Main < ::Thor
|
8
|
+
class_before :set_log_level
|
9
|
+
class_before :set_color_output
|
10
|
+
|
11
|
+
class_option :log_level, desc: 'Log level (debug, info, warn, error, fatal, silent)', default: 'info'
|
12
|
+
class_option :verbose, aliases: '-v', desc: 'Set log level to verbose', type: :boolean
|
13
|
+
class_option :silent, desc: 'Silence all output', type: :boolean
|
14
|
+
class_option :'no-color', desc: 'Disable colour output', type: :boolean
|
15
|
+
|
15
16
|
default_task "jobs"
|
16
17
|
|
17
18
|
desc 'connectors:COMMAND', 'Connectors available for ETL jobs', hide: true
|
@@ -20,17 +21,11 @@ module Chronicle
|
|
20
21
|
desc 'jobs:COMMAND', 'Configure and run jobs', hide: true
|
21
22
|
subcommand 'jobs', Jobs
|
22
23
|
|
24
|
+
desc 'plugins:COMMAND', 'Configure plugins', hide: true
|
25
|
+
subcommand 'plugins', Plugins
|
26
|
+
|
23
27
|
# Entrypoint for the CLI
|
24
28
|
def self.start(given_args = ARGV, config = {})
|
25
|
-
if given_args[0] == "--version"
|
26
|
-
puts "#{Chronicle::ETL::VERSION}"
|
27
|
-
exit
|
28
|
-
end
|
29
|
-
|
30
|
-
if given_args.none?
|
31
|
-
abort "No command entered or job specified. To see commands, run `chronicle-etl help`".red
|
32
|
-
end
|
33
|
-
|
34
29
|
# take a subcommand:command and splits them so Thor knows how to hand off to the subcommand class
|
35
30
|
if given_args.any? && given_args[0].include?(':')
|
36
31
|
commands = given_args.shift.split(':')
|
@@ -40,10 +35,20 @@ module Chronicle
|
|
40
35
|
super(given_args, config)
|
41
36
|
end
|
42
37
|
|
38
|
+
def self.exit_on_failure?
|
39
|
+
true
|
40
|
+
end
|
41
|
+
|
42
|
+
desc "version", "Show version"
|
43
|
+
map %w(--version -v) => :version
|
44
|
+
def version
|
45
|
+
shell.say "chronicle-etl #{Chronicle::ETL::VERSION}"
|
46
|
+
end
|
47
|
+
|
43
48
|
# Displays help options for chronicle-etl
|
44
49
|
def help(meth = nil, subcommand = false)
|
45
50
|
if meth && !respond_to?(meth)
|
46
|
-
klass, task = Thor::Util.find_class_and_task_by_namespace("#{meth}:#{meth}")
|
51
|
+
klass, task = ::Thor::Util.find_class_and_task_by_namespace("#{meth}:#{meth}")
|
47
52
|
klass.start(['-h', task].compact, shell: shell)
|
48
53
|
else
|
49
54
|
shell.say "ABOUT".bold
|
@@ -64,7 +69,7 @@ module Chronicle
|
|
64
69
|
|
65
70
|
list = []
|
66
71
|
|
67
|
-
Thor::Util.thor_classes_in(Chronicle::ETL::CLI).each do |thor_class|
|
72
|
+
::Thor::Util.thor_classes_in(Chronicle::ETL::CLI).each do |thor_class|
|
68
73
|
list += thor_class.printable_tasks(false)
|
69
74
|
end
|
70
75
|
list.sort! { |a, b| a[0] <=> b[0] }
|
@@ -85,6 +90,23 @@ module Chronicle
|
|
85
90
|
shell.say
|
86
91
|
end
|
87
92
|
end
|
93
|
+
|
94
|
+
no_commands do
|
95
|
+
def set_color_output
|
96
|
+
String.disable_colorization true if options[:'no-color'] || ENV['NO_COLOR']
|
97
|
+
end
|
98
|
+
|
99
|
+
def set_log_level
|
100
|
+
if options[:silent]
|
101
|
+
Chronicle::ETL::Logger.log_level = Chronicle::ETL::Logger::SILENT
|
102
|
+
elsif options[:verbose]
|
103
|
+
Chronicle::ETL::Logger.log_level = Chronicle::ETL::Logger::DEBUG
|
104
|
+
elsif options[:log_level]
|
105
|
+
level = Chronicle::ETL::Logger.const_get(options[:log_level].upcase)
|
106
|
+
Chronicle::ETL::Logger.log_level = level
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
88
110
|
end
|
89
111
|
end
|
90
112
|
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "tty-prompt"
|
4
|
+
require "tty-spinner"
|
5
|
+
|
6
|
+
|
7
|
+
module Chronicle
|
8
|
+
module ETL
|
9
|
+
module CLI
|
10
|
+
# CLI commands for working with ETL plugins
|
11
|
+
class Plugins < SubcommandBase
|
12
|
+
default_task 'list'
|
13
|
+
namespace :plugins
|
14
|
+
|
15
|
+
desc "install", "Install a plugin"
|
16
|
+
def install(name)
|
17
|
+
spinner = TTY::Spinner.new("[:spinner] Installing plugin #{name}...", format: :dots_2)
|
18
|
+
spinner.auto_spin
|
19
|
+
Chronicle::ETL::Registry::PluginRegistry.install(name)
|
20
|
+
spinner.success("(#{'successful'.green})")
|
21
|
+
rescue Chronicle::ETL::PluginError => e
|
22
|
+
spinner.error("Error".red)
|
23
|
+
Chronicle::ETL::Logger.debug(e.full_message)
|
24
|
+
Chronicle::ETL::Logger.fatal("Plugin '#{name}' could not be installed".red)
|
25
|
+
exit 1
|
26
|
+
end
|
27
|
+
|
28
|
+
desc "uninstall", "Unintall a plugin"
|
29
|
+
def uninstall(name)
|
30
|
+
spinner = TTY::Spinner.new("[:spinner] Uninstalling plugin #{name}...", format: :dots_2)
|
31
|
+
spinner.auto_spin
|
32
|
+
Chronicle::ETL::Registry::PluginRegistry.uninstall(name)
|
33
|
+
spinner.success("(#{'successful'.green})")
|
34
|
+
rescue Chronicle::ETL::PluginError => e
|
35
|
+
spinner.error("Error".red)
|
36
|
+
Chronicle::ETL::Logger.debug(e.full_message)
|
37
|
+
Chronicle::ETL::Logger.fatal("Plugin '#{name}' could not be uninstalled (was it installed?)".red)
|
38
|
+
exit 1
|
39
|
+
end
|
40
|
+
|
41
|
+
desc "list", "Lists available plugins"
|
42
|
+
# Display all available plugins that chronicle-etl has access to
|
43
|
+
def list
|
44
|
+
plugins = Chronicle::ETL::Registry::PluginRegistry.all_installed_latest
|
45
|
+
|
46
|
+
info = plugins.map do |plugin|
|
47
|
+
{
|
48
|
+
name: plugin.name.sub("chronicle-", ""),
|
49
|
+
description: plugin.description,
|
50
|
+
version: plugin.version
|
51
|
+
}
|
52
|
+
end
|
53
|
+
|
54
|
+
headers = ['name', 'description', 'latest version'].map{ |h| h.to_s.upcase.bold }
|
55
|
+
table = TTY::Table.new(headers, info.map(&:values))
|
56
|
+
puts "Installed plugins:"
|
57
|
+
puts table.render(indent: 2, padding: [0, 0])
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -2,11 +2,11 @@ module Chronicle
|
|
2
2
|
module ETL
|
3
3
|
module CLI
|
4
4
|
# Base class for CLI subcommands. Overrides Thor methods so we can use command:subcommand syntax
|
5
|
-
class SubcommandBase < Thor
|
5
|
+
class SubcommandBase < ::Thor
|
6
6
|
# Print usage instructions for a subcommand
|
7
7
|
def self.help(shell, subcommand = false)
|
8
8
|
list = printable_commands(true, subcommand)
|
9
|
-
Thor::Util.thor_classes_in(self).each do |klass|
|
9
|
+
::Thor::Util.thor_classes_in(self).each do |klass|
|
10
10
|
list += klass.printable_commands(false)
|
11
11
|
end
|
12
12
|
list.sort! { |a, b| a[0] <=> b[0] }
|
@@ -0,0 +1,9 @@
|
|
1
|
+
require 'thor'
|
2
|
+
require 'thor/hollaback'
|
3
|
+
require 'chronicle/etl'
|
4
|
+
|
5
|
+
require 'chronicle/etl/cli/subcommand_base'
|
6
|
+
require 'chronicle/etl/cli/connectors'
|
7
|
+
require 'chronicle/etl/cli/jobs'
|
8
|
+
require 'chronicle/etl/cli/plugins'
|
9
|
+
require 'chronicle/etl/cli/main'
|
data/lib/chronicle/etl/config.rb
CHANGED
@@ -24,16 +24,14 @@ module Chronicle
|
|
24
24
|
|
25
25
|
# Returns all jobs available in ~/.config/chronicle/etl/jobs/*.yml
|
26
26
|
def available_jobs
|
27
|
-
|
28
|
-
Dir.glob(File.join(job_directory, "*.yml")).map do |filename|
|
27
|
+
Dir.glob(File.join(config_directory("jobs"), "*.yml")).map do |filename|
|
29
28
|
File.basename(filename, ".*")
|
30
29
|
end
|
31
30
|
end
|
32
31
|
|
33
32
|
# Returns all available credentials available in ~/.config/chronicle/etl/credentials/*.yml
|
34
33
|
def available_credentials
|
35
|
-
|
36
|
-
Dir.glob(File.join(job_directory, "*.yml")).map do |filename|
|
34
|
+
Dir.glob(File.join(config_directory("credentials"), "*.yml")).map do |filename|
|
37
35
|
File.basename(filename, ".*")
|
38
36
|
end
|
39
37
|
end
|
@@ -48,6 +46,11 @@ module Chronicle
|
|
48
46
|
def load_credentials(name)
|
49
47
|
config = self.load("chronicle/etl/credentials/#{name}.yml")
|
50
48
|
end
|
49
|
+
|
50
|
+
def config_directory(type)
|
51
|
+
path = "chronicle/etl/#{type}"
|
52
|
+
Runcom::Config.new(path).current || raise(Chronicle::ETL::ConfigError, "Could not access config directory (#{path})")
|
53
|
+
end
|
51
54
|
end
|
52
55
|
end
|
53
56
|
end
|
@@ -0,0 +1,163 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "ostruct"
|
4
|
+
|
5
|
+
module Chronicle
|
6
|
+
module ETL
|
7
|
+
# A mixin that gives a class
|
8
|
+
# a {Chronicle::ETL::Configurable::ClassMethods#setting} macro to define
|
9
|
+
# settings and their properties (require, type, etc)
|
10
|
+
#
|
11
|
+
# @example Basic usage
|
12
|
+
# class Test < Chronicle::ETL::Extractor
|
13
|
+
# include Chronicle::ETL::Configurable
|
14
|
+
# setting :when, type: :date, required: true
|
15
|
+
# end
|
16
|
+
#
|
17
|
+
# t = Test.new(when: '2022-02-24')
|
18
|
+
# t.config.when
|
19
|
+
module Configurable
|
20
|
+
# An individual setting for this Configurable
|
21
|
+
Setting = Struct.new(:default, :required, :type)
|
22
|
+
private_constant :Setting
|
23
|
+
|
24
|
+
# Collection of user-supplied options for this Configurable
|
25
|
+
class Config < OpenStruct
|
26
|
+
# Config values that aren't nil, as a hash
|
27
|
+
def compacted_h
|
28
|
+
to_h.compact
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# @private
|
33
|
+
def self.included(klass)
|
34
|
+
klass.extend(ClassMethods)
|
35
|
+
klass.include(InstanceMethods)
|
36
|
+
klass.prepend(Initializer)
|
37
|
+
end
|
38
|
+
|
39
|
+
# Initializer method for classes that have Configurable mixed in
|
40
|
+
module Initializer
|
41
|
+
# Make sure this class has a default @config ready to use
|
42
|
+
def initialize(*args)
|
43
|
+
@config = initialize_default_config
|
44
|
+
super
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# Instance methods for classes that have Configurable mixed in
|
49
|
+
module InstanceMethods
|
50
|
+
attr_reader :config
|
51
|
+
|
52
|
+
# Take given options and apply them to this class's settings
|
53
|
+
# and make them available in @config and validates that they
|
54
|
+
# conform to setting rules
|
55
|
+
def apply_options(options)
|
56
|
+
options.transform_keys!(&:to_sym)
|
57
|
+
|
58
|
+
options.each do |name, value|
|
59
|
+
setting = self.class.all_settings[name]
|
60
|
+
raise(Chronicle::ETL::ConnectorConfigurationError, "Unrecognized setting: #{name}") unless setting
|
61
|
+
|
62
|
+
@config[name] = coerced_value(setting, value)
|
63
|
+
end
|
64
|
+
validate_config
|
65
|
+
options
|
66
|
+
end
|
67
|
+
|
68
|
+
# Name of all settings available to this class
|
69
|
+
def self.settings
|
70
|
+
self.class.all_settings.keys
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
def initialize_default_config
|
76
|
+
self.class.config_with_defaults
|
77
|
+
end
|
78
|
+
|
79
|
+
def validate_config
|
80
|
+
missing = (self.class.all_required_settings.keys - @config.compacted_h.keys)
|
81
|
+
raise Chronicle::ETL::ConnectorConfigurationError, "Missing options: #{missing}" if missing.count.positive?
|
82
|
+
end
|
83
|
+
|
84
|
+
def coerced_value(setting, value)
|
85
|
+
setting.type ? __send__("coerce_#{setting.type}", value) : value
|
86
|
+
end
|
87
|
+
|
88
|
+
def coerce_string(value)
|
89
|
+
value.to_s
|
90
|
+
end
|
91
|
+
|
92
|
+
# TODO: think about whether to split up float, integer
|
93
|
+
def coerce_numeric(value)
|
94
|
+
value.to_f
|
95
|
+
end
|
96
|
+
|
97
|
+
def coerce_boolean(value)
|
98
|
+
if value.is_a?(String)
|
99
|
+
value.downcase == "true"
|
100
|
+
else
|
101
|
+
value
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def coerce_time(value)
|
106
|
+
# TODO: handle durations like '3h'
|
107
|
+
if value.is_a?(String)
|
108
|
+
Time.parse(value)
|
109
|
+
else
|
110
|
+
value
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
# Class methods for classes that have Configurable mixed in
|
116
|
+
module ClassMethods
|
117
|
+
# Macro for creating a setting on a class {::Chronicle::ETL::Configurable}
|
118
|
+
#
|
119
|
+
# @param [String] name Name of the setting
|
120
|
+
# @param [Boolean] required whether setting is required
|
121
|
+
# @param [Object] default Default value
|
122
|
+
# @param [Symbol] type Type
|
123
|
+
#
|
124
|
+
# @example Basic usage
|
125
|
+
# setting :when, type: :date, required: true
|
126
|
+
#
|
127
|
+
# @see ::Chronicle::ETL::Configurable
|
128
|
+
def setting(name, default: nil, required: false, type: nil)
|
129
|
+
s = Setting.new(default, required, type)
|
130
|
+
settings[name] = s
|
131
|
+
end
|
132
|
+
|
133
|
+
# Collect all settings defined on this class and its ancestors (that
|
134
|
+
# have Configurable mixin included)
|
135
|
+
def all_settings
|
136
|
+
if superclass.include?(Chronicle::ETL::Configurable)
|
137
|
+
superclass.all_settings.merge(settings)
|
138
|
+
else
|
139
|
+
settings
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
# Filters settings to those that are required.
|
144
|
+
def all_required_settings
|
145
|
+
all_settings.select { |_name, setting| setting.required } || {}
|
146
|
+
end
|
147
|
+
|
148
|
+
def settings
|
149
|
+
@settings ||= {}
|
150
|
+
end
|
151
|
+
|
152
|
+
def setting_exists?(name)
|
153
|
+
all_settings.keys.include? name
|
154
|
+
end
|
155
|
+
|
156
|
+
def config_with_defaults
|
157
|
+
s = all_settings.transform_values(&:default)
|
158
|
+
Config.new(s)
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
@@ -1,9 +1,33 @@
|
|
1
1
|
module Chronicle
|
2
2
|
module ETL
|
3
|
-
class Error < StandardError; end
|
3
|
+
class Error < StandardError; end
|
4
|
+
|
5
|
+
class ConfigError < Error; end
|
4
6
|
|
5
7
|
class RunnerTypeError < Error; end
|
6
8
|
|
9
|
+
class JobDefinitionError < Error
|
10
|
+
attr_reader :job_definition
|
11
|
+
|
12
|
+
def initialize(job_definition)
|
13
|
+
@job_definition = job_definition
|
14
|
+
super
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
class PluginError < Error
|
19
|
+
attr_reader :name
|
20
|
+
|
21
|
+
def initialize(name)
|
22
|
+
@name = name
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
class PluginNotAvailableError < PluginError; end
|
27
|
+
class PluginLoadError < PluginError; end
|
28
|
+
|
29
|
+
class ConnectorConfigurationError < Error; end
|
30
|
+
|
7
31
|
class ConnectorNotAvailableError < Error
|
8
32
|
def initialize(message, provider: nil, name: nil)
|
9
33
|
super(message)
|
@@ -16,6 +40,10 @@ module Chronicle
|
|
16
40
|
class ProviderNotAvailableError < ConnectorNotAvailableError; end
|
17
41
|
class ProviderConnectorNotAvailableError < ConnectorNotAvailableError; end
|
18
42
|
|
43
|
+
class ExtractionError < Error; end
|
44
|
+
|
45
|
+
class SerializationError < Error; end
|
46
|
+
|
19
47
|
class TransformationError < Error
|
20
48
|
attr_reader :transformation
|
21
49
|
|