chronicle-etl 0.3.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
|