logstash-filter-jdbc_static 1.0.0

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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +2 -0
  3. data/CONTRIBUTORS +22 -0
  4. data/Gemfile +2 -0
  5. data/LICENSE +13 -0
  6. data/README.md +94 -0
  7. data/lib/logstash-filter-jdbc_static_jars.rb +5 -0
  8. data/lib/logstash/filters/jdbc/basic_database.rb +117 -0
  9. data/lib/logstash/filters/jdbc/column.rb +38 -0
  10. data/lib/logstash/filters/jdbc/db_object.rb +103 -0
  11. data/lib/logstash/filters/jdbc/loader.rb +114 -0
  12. data/lib/logstash/filters/jdbc/loader_schedule.rb +38 -0
  13. data/lib/logstash/filters/jdbc/lookup.rb +192 -0
  14. data/lib/logstash/filters/jdbc/lookup_processor.rb +91 -0
  15. data/lib/logstash/filters/jdbc/lookup_result.rb +39 -0
  16. data/lib/logstash/filters/jdbc/read_only_database.rb +57 -0
  17. data/lib/logstash/filters/jdbc/read_write_database.rb +86 -0
  18. data/lib/logstash/filters/jdbc/repeating_load_runner.rb +11 -0
  19. data/lib/logstash/filters/jdbc/single_load_runner.rb +43 -0
  20. data/lib/logstash/filters/jdbc/validatable.rb +49 -0
  21. data/lib/logstash/filters/jdbc_static.rb +216 -0
  22. data/logstash-filter-jdbc_static.gemspec +38 -0
  23. data/spec/filters/env_helper.rb +10 -0
  24. data/spec/filters/jdbc/column_spec.rb +70 -0
  25. data/spec/filters/jdbc/db_object_spec.rb +81 -0
  26. data/spec/filters/jdbc/loader_spec.rb +76 -0
  27. data/spec/filters/jdbc/lookup_processor_spec.rb +132 -0
  28. data/spec/filters/jdbc/lookup_spec.rb +129 -0
  29. data/spec/filters/jdbc/read_only_database_spec.rb +66 -0
  30. data/spec/filters/jdbc/read_write_database_spec.rb +89 -0
  31. data/spec/filters/jdbc/repeating_load_runner_spec.rb +24 -0
  32. data/spec/filters/jdbc/single_load_runner_spec.rb +16 -0
  33. data/spec/filters/jdbc_static_file_local_spec.rb +83 -0
  34. data/spec/filters/jdbc_static_spec.rb +70 -0
  35. data/spec/filters/remote_server_helper.rb +24 -0
  36. data/spec/filters/shared_helpers.rb +35 -0
  37. data/spec/helpers/WHY-THIS-JAR.txt +4 -0
  38. data/spec/helpers/derbyrun.jar +0 -0
  39. data/vendor/jar-dependencies/runtime-jars/derby-10.14.1.0.jar +0 -0
  40. data/vendor/jar-dependencies/runtime-jars/derbyclient-10.14.1.0.jar +0 -0
  41. metadata +224 -0
@@ -0,0 +1,11 @@
1
+ require_relative "single_load_runner"
2
+
3
+ module LogStash module Filters module Jdbc
4
+ class RepeatingLoadRunner < SingleLoadRunner
5
+ # info - attr_reader :local, :loaders, :preloaders
6
+
7
+ def repeated_load
8
+ local.repopulate_all(loaders)
9
+ end
10
+ end
11
+ end end end
@@ -0,0 +1,43 @@
1
+ require_relative 'db_object'
2
+
3
+ module LogStash module Filters module Jdbc
4
+ class SingleLoadRunner
5
+
6
+ attr_reader :local, :loaders, :preloaders
7
+
8
+ def initialize(local, loaders, preloaders)
9
+ @local = local
10
+ @loaders = loaders
11
+ @preloaders = []
12
+ preloaders.map do |pre|
13
+ dbo = DbObject.new(pre)
14
+ @preloaders << dbo
15
+ hash = dbo.as_temp_table_opts
16
+ _dbo = DbObject.new(hash)
17
+ @preloaders << _dbo if _dbo.valid?
18
+ end
19
+ @preloaders.sort!
20
+ end
21
+
22
+ def initial_load
23
+ do_preload
24
+ local.populate_all(loaders)
25
+ end
26
+
27
+ def repeated_load
28
+ end
29
+
30
+ def call
31
+ repeated_load
32
+ end
33
+
34
+ private
35
+
36
+ def do_preload
37
+ preloaders.each do |db_object|
38
+ local.build_db_object(db_object)
39
+ end
40
+ end
41
+ end
42
+
43
+ end end end
@@ -0,0 +1,49 @@
1
+ # encoding: utf-8
2
+
3
+ module LogStash module Filters module Jdbc
4
+ class Validatable
5
+ def self.find_validation_errors(array_of_options)
6
+ if !array_of_options.is_a?(Array)
7
+ return "The options must be an Array"
8
+ end
9
+ errors = []
10
+ array_of_options.each do |options|
11
+ instance = new(options)
12
+ unless instance.valid?
13
+ errors << instance.formatted_errors
14
+ end
15
+ end
16
+ return nil if errors.empty?
17
+ errors.join("; ")
18
+ end
19
+
20
+ def initialize(options)
21
+ pre_initialize(options)
22
+ @options = options
23
+ @valid = false
24
+ @option_errors = []
25
+ parse_options
26
+ post_initialize
27
+ end
28
+
29
+ def valid?
30
+ @valid
31
+ end
32
+
33
+ def formatted_errors
34
+ @option_errors.join(", ")
35
+ end
36
+
37
+ private
38
+
39
+ def pre_initialize(options)
40
+ end
41
+
42
+ def post_initialize
43
+ end
44
+
45
+ def parse_options
46
+ raise "Subclass must implement 'parse_options'"
47
+ end
48
+ end
49
+ end end end
@@ -0,0 +1,216 @@
1
+ # encoding: utf-8
2
+ require "logstash-filter-jdbc_static_jars"
3
+ require "logstash/filters/base"
4
+ require "logstash/namespace"
5
+ require_relative "jdbc/loader"
6
+ require_relative "jdbc/loader_schedule"
7
+ require_relative "jdbc/repeating_load_runner"
8
+ require_relative "jdbc/lookup_processor"
9
+
10
+ # This filter can do multiple enhancements to an event in one pass.
11
+ # Define multiple loader sources and multiple lookup targets.
12
+ # Currently only one remote database connection is supported.
13
+ # [source,ruby]
14
+
15
+ #
16
+ module LogStash module Filters class JdbcStatic < LogStash::Filters::Base
17
+ config_name "jdbc_static"
18
+
19
+ # Define the loaders, an Array of Hashes, to fetch remote data and create local tables.
20
+ # the fetched data will be inserted into the local tables. Make sure that the
21
+ # local table name, columns and datatypes correspond to the shape of the remote data
22
+ # being fetched. The default for max_rows is 1 million rows. You may provide an `id`
23
+ # For example:
24
+ # loaders => [
25
+ # {
26
+ # id => "country_details"
27
+ # query => "select code, name from WORLD.COUNTRY"
28
+ # max_rows => 2000
29
+ # local_table => "country"
30
+ # },
31
+ # {
32
+ # id => "servers_load"
33
+ # query => "select id, ip, name, location from INTERNAL.SERVERS"
34
+ # local_table => "servers"
35
+ # }
36
+ # ]
37
+ # This is optional. You can provide a pre-populated local database server then no initial loaders are needed.
38
+ config :loaders, :required => false, :default => [], :validate => [LogStash::Filters::Jdbc::Loader]
39
+
40
+ # Define an array of Database Objects to create when the plugin first starts.
41
+ # These will usually be the definitions to setup the local in-memory tables.
42
+ # For example:
43
+ # local_db_objects => [
44
+ # {name => "servers", preserve_existing => true, index_columns => ["ip"], columns => [["id", "INTEGER"], ["ip", "varchar(64)"], ["name", "varchar(64)"], ["location", "varchar(64)"]]},
45
+ # ]
46
+ # NOTE: Important! use `preserve_existing => true` to keep a table created and filled in a previous Logstash session. It will default to false and is unneeded if the database is not persistent.
47
+ # NOTE: Important! Tables created here must have the same names as those used in the `loaders` and
48
+ # `local_lookups` configuration options
49
+ config :local_db_objects, :required => false, :default => [], :validate => [LogStash::Filters::Jdbc::DbObject]
50
+
51
+ # Define the list (Array) of enhancement local_lookups to be applied to an event
52
+ # Each entry is a hash of the query string, the target field and value and a
53
+ # parameter hash. Target is overwritten if existing. Target is optional,
54
+ # if omitted the lookup results will be written to the root of the event like this:
55
+ # event.set(<column name (or alias)>, <column value>)
56
+ # Use parameters to have this plugin put values from the event into the query.
57
+ # The parameter maps the symbol used in the query string to the field name in the event.
58
+ # NOTE: when using a query string that includes the LIKE keyword make sure that
59
+ # you provide a Logstash Event sprintf pattern with added wildcards.
60
+ # For example:
61
+ # local_lookups => [
62
+ # {
63
+ # "query" => "select * from country WHERE code = :code",
64
+ # "parameters" => {"code" => "country_code"}
65
+ # "target" => "country_details"
66
+ # },
67
+ # {
68
+ # "query" => "select ip, name from servers WHERE ip LIKE :ip",
69
+ # "parameters" => {"ip" => "%{[response][ip]}%"}
70
+ # "target" => "servers"
71
+ # }
72
+ # ]
73
+ config :local_lookups, :required => true, :validate => [LogStash::Filters::Jdbc::LookupProcessor]
74
+
75
+ # Schedule of when to periodically run loaders, in Cron format
76
+ # for example: "* * * * *" (execute query every minute, on the minute)
77
+ #
78
+ # There is no schedule by default. If no schedule is given, then the loaders are run
79
+ # exactly once.
80
+ config :loader_schedule, :validate => [LogStash::Filters::Jdbc::LoaderSchedule]
81
+
82
+ # Append values to the `tags` field if sql error occured
83
+ # Alternatively, individual `tag_on_failure` arrays can be added to each lookup hash
84
+ config :tag_on_failure, :validate => :array, :default => ["_jdbcstaticfailure"]
85
+
86
+ # Append values to the `tags` field if no record was found and default values were used
87
+ config :tag_on_default_use, :validate => :array, :default => ["_jdbcstaticdefaultsused"]
88
+
89
+ # Remote Load DB Jdbc driver library path to third party driver library.
90
+ config :jdbc_driver_library, :validate => :path
91
+
92
+ # Remote Load DB Jdbc driver class to load, for example "oracle.jdbc.OracleDriver" or "org.apache.derby.jdbc.ClientDriver"
93
+ config :jdbc_driver_class, :validate => :string, :required => true
94
+
95
+ # Remote Load DB Jdbc connection string
96
+ config :jdbc_connection_string, :validate => :string, :required => true
97
+
98
+ # Remote Load DB Jdbc user
99
+ config :jdbc_user, :validate => :string
100
+
101
+ # Remote Load DB Jdbc password
102
+ config :jdbc_password, :validate => :password
103
+
104
+ # NOTE: For the initial release, we are not allowing the user to specify their own local lookup JDBC DB settings.
105
+ # In the near future we have to consider identical config running in multiple pipelines stomping over each other
106
+ # when the database names are common across configs because there is only one Derby server in memory per JVM.
107
+
108
+ # Local Lookup DB Jdbc driver class to load, for example "org.apache.derby.jdbc.ClientDriver"
109
+ # config :lookup_jdbc_driver_class, :validate => :string, :required => false
110
+
111
+ # Local Lookup DB Jdbc driver library path to third party driver library.
112
+ # config :lookup_jdbc_driver_library, :validate => :path, :required => false
113
+
114
+ # Local Lookup DB Jdbc connection string
115
+ # config :lookup_jdbc_connection_string, :validate => :string, :required => false
116
+
117
+ class << self
118
+ alias_method :old_validate_value, :validate_value
119
+
120
+ def validate_value(value, validator)
121
+ if validator.is_a?(Array) && validator.first.respond_to?(:find_validation_errors)
122
+ validation_errors = validator.first.find_validation_errors(value)
123
+ unless validation_errors.nil?
124
+ return false, validation_errors
125
+ end
126
+ else
127
+ return old_validate_value(value, validator)
128
+ end
129
+ [true, value]
130
+ end
131
+ end
132
+
133
+ public
134
+
135
+ def register
136
+ prepare_data_dir
137
+ prepare_runner
138
+ @loader_runner.initial_load
139
+ end
140
+
141
+ def filter(event)
142
+ enhancement_states = @processor.enhance(event)
143
+ filter_matched(event) if enhancement_states.all?
144
+ end
145
+
146
+ def stop
147
+ @scheduler.stop if @scheduler
148
+ @parsed_loaders.each(&:close)
149
+ @processor.close
150
+ end
151
+
152
+ private
153
+
154
+ def prepare_data_dir
155
+ # later, when local persistent databases are allowed set this property to LS_HOME/data/jdbc-static/
156
+ # must take multi-pipelines into account and more than one config using the same jdbc-static settings
157
+ java.lang.System.setProperty("derby.system.home", ENV["HOME"])
158
+ end
159
+
160
+ def prepare_runner
161
+ @parsed_loaders = @loaders.map do |options|
162
+ add_plugin_configs(options)
163
+ loader = Jdbc::Loader.new(options)
164
+ loader.build_remote_db
165
+ loader
166
+ end
167
+ runner_args = [@parsed_loaders, @local_db_objects]
168
+ @processor = Jdbc::LookupProcessor.new(@local_lookups, global_lookup_options)
169
+ runner_args.unshift(@processor.local)
170
+ if @loader_schedule
171
+ require "rufus/scheduler"
172
+ args = []
173
+ @loader_runner = Jdbc::RepeatingLoadRunner.new(*runner_args)
174
+
175
+ cronline = Jdbc::LoaderSchedule.new(@loader_schedule)
176
+ rufus_args = {:max_work_threads => 1, :frequency => cronline.schedule_frequency}
177
+
178
+ @scheduler = Rufus::Scheduler.new(rufus_args)
179
+ @scheduler.cron(cronline.loader_schedule, @loader_runner)
180
+ @scheduler.join
181
+ else
182
+ @loader_runner = Jdbc::SingleLoadRunner.new(*runner_args)
183
+ end
184
+ end
185
+
186
+ def global_lookup_options(options = Hash.new)
187
+ if @tag_on_failure && !@tag_on_failure.empty? && !options.key?("tag_on_failure")
188
+ options["tag_on_failure"] = @tag_on_failure
189
+ end
190
+ if @tag_on_default_use && !@tag_on_default_use.empty? && !options.key?("tag_on_default_use")
191
+ options["tag_on_default_use"] = @tag_on_default_use
192
+ end
193
+ options["lookup_jdbc_driver_class"] = @lookup_jdbc_driver_class
194
+ options["lookup_jdbc_driver_library"] = @lookup_jdbc_driver_library
195
+ options["lookup_jdbc_connection_string"] = @lookup_jdbc_connection_string
196
+ options
197
+ end
198
+
199
+ def add_plugin_configs(options)
200
+ if @jdbc_driver_library
201
+ options["jdbc_driver_library"] = @jdbc_driver_library
202
+ end
203
+ if @jdbc_driver_class
204
+ options["jdbc_driver_class"] = @jdbc_driver_class
205
+ end
206
+ if @jdbc_connection_string
207
+ options["jdbc_connection_string"] = @jdbc_connection_string
208
+ end
209
+ if @jdbc_user
210
+ options["jdbc_user"] = @jdbc_user
211
+ end
212
+ if @jdbc_password
213
+ options["jdbc_password"] = @jdbc_password
214
+ end
215
+ end
216
+ end end end
@@ -0,0 +1,38 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = 'logstash-filter-jdbc_static'
3
+ s.version = '1.0.0'
4
+ s.licenses = ['Apache License (2.0)']
5
+ s.summary = "This filter executes a SQL query to fetch a SQL query result, store it locally then use a second SQL query to update an event."
6
+ s.description = "This gem is a Logstash plugin required to be installed on top of the Logstash core pipeline using $LS_HOME/bin/logstash-plugin install gemname. This gem is not a stand-alone program"
7
+ s.authors = ["Elastic"]
8
+ s.email = 'info@elastic.co'
9
+ s.homepage = "http://www.elastic.co/guide/en/logstash/current/index.html"
10
+ # to fool jar_dependencies to mimic our other plugin gradle vendor script behaviour
11
+ # the rake vendor task removes jars dir and jars downloaded to it
12
+ s.require_paths = ["lib", "jars"]
13
+
14
+ # Files
15
+ s.files = Dir['lib/**/*','vendor/**/*','spec/**/*','*.gemspec','*.md','CONTRIBUTORS','Gemfile','LICENSE','NOTICE.TXT']
16
+ # Tests
17
+ s.test_files = s.files.grep(%r{^(test|spec|features)/})
18
+
19
+ # Special flag to let us know this is actually a logstash plugin
20
+ s.metadata = { "logstash_plugin" => "true", "logstash_group" => "filter" }
21
+
22
+ derby_version = "10.14.1.0"
23
+ s.requirements << "jar 'org.apache.derby:derby', '#{derby_version}'"
24
+ s.requirements << "jar 'org.apache.derby:derbyclient', '#{derby_version}'"
25
+ # we may need 'org.apache.derby:derbynet' in the future, marking this here
26
+
27
+ s.add_development_dependency 'jar-dependencies', '~> 0.3'
28
+
29
+ # Gem dependencies
30
+ s.add_runtime_dependency "logstash-core-plugin-api", ">= 1.60", "<= 2.99"
31
+ s.add_runtime_dependency 'sequel'
32
+ s.add_runtime_dependency 'tzinfo'
33
+ s.add_runtime_dependency 'tzinfo-data'
34
+ s.add_runtime_dependency 'rufus-scheduler'
35
+
36
+ s.add_development_dependency 'logstash-devutils'
37
+ s.add_development_dependency "childprocess"
38
+ end
@@ -0,0 +1,10 @@
1
+ # encoding: utf-8
2
+
3
+ # use the rspec --require command line option to have this file evaluated before rspec runs
4
+ # it i
5
+
6
+ GEM_BASE_DIR = ::File.expand_path("../../..", __FILE__)
7
+ BASE_DERBY_DIR = ::File.join(GEM_BASE_DIR, "spec", "helpers")
8
+ ENV["HOME"] = GEM_BASE_DIR
9
+ ENV["TEST_DEBUG"] = "true"
10
+ java.lang.System.setProperty("ls.logs", "console")
@@ -0,0 +1,70 @@
1
+ # encoding: utf-8
2
+ require "logstash/devutils/rspec/spec_helper"
3
+ require "logstash/filters/jdbc/column"
4
+
5
+ describe LogStash::Filters::Jdbc::Column do
6
+ let(:invalid_messages) do
7
+ [
8
+ "The column options must be an array",
9
+ "The first column option is the name and must be a string",
10
+ "The second column option is the datatype and must be a string"
11
+ ]
12
+ end
13
+
14
+ context "various invalid non-array arguments" do
15
+ it "a nil does not validate" do
16
+ instance = described_class.new(nil)
17
+ expect(instance.valid?).to be_falsey
18
+ expect(instance.formatted_errors).to eq(invalid_messages.join(", "))
19
+ end
20
+
21
+ it "a string does not validate" do
22
+ instance = described_class.new("foo")
23
+ expect(instance.valid?).to be_falsey
24
+ expect(instance.formatted_errors).to eq(invalid_messages.values_at(0,2).join(", "))
25
+ end
26
+
27
+ it "a number does not validate" do
28
+ instance = described_class.new(42)
29
+ expect(instance.valid?).to be_falsey
30
+ expect(instance.formatted_errors).to eq(invalid_messages.join(", "))
31
+ end
32
+ end
33
+
34
+ context "various invalid array arguments" do
35
+ it "a single string element does not validate" do
36
+ instance = described_class.new(["foo"])
37
+ expect(instance.valid?).to be_falsey
38
+ expect(instance.formatted_errors).to eq(invalid_messages.last)
39
+ end
40
+ [ [], [1, 2] ].each do |arg|
41
+ it "do not validate" do
42
+ instance = described_class.new(arg)
43
+ expect(instance.valid?).to be_falsey
44
+ expect(instance.formatted_errors).to eq(invalid_messages.values_at(1,2).join(", "))
45
+ end
46
+ end
47
+ [ ["foo", 3], ["foo", nil] ].each do |arg|
48
+ it "do not validate" do
49
+ instance = described_class.new(arg)
50
+ expect(instance.valid?).to be_falsey
51
+ expect(instance.formatted_errors).to eq(invalid_messages.last)
52
+ end
53
+ end
54
+ [ [3, "foo"], [nil, "foo"] ].each do |arg|
55
+ it "do not validate" do
56
+ instance = described_class.new(arg)
57
+ expect(instance.valid?).to be_falsey
58
+ expect(instance.formatted_errors).to eq(invalid_messages[1])
59
+ end
60
+ end
61
+ end
62
+
63
+ context "a valid array argument" do
64
+ it "does validate" do
65
+ instance = described_class.new(["foo", "varchar2"])
66
+ expect(instance.valid?).to be_truthy
67
+ expect(instance.formatted_errors).to eq("")
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,81 @@
1
+ # encoding: utf-8
2
+ require "logstash/devutils/rspec/spec_helper"
3
+ require "logstash/filters/jdbc/db_object"
4
+
5
+ describe LogStash::Filters::Jdbc::DbObject do
6
+ context "various invalid non-hash arguments" do
7
+ it "a nil does not validate" do
8
+ instance = described_class.new(nil)
9
+ expect(instance.valid?).to be_falsey
10
+ expect(instance.formatted_errors).to eq("DbObject options must be a Hash")
11
+ end
12
+
13
+ it "a string does not validate" do
14
+ instance = described_class.new("foo")
15
+ expect(instance.valid?).to be_falsey
16
+ expect(instance.formatted_errors).to eq("DbObject options must be a Hash")
17
+ end
18
+
19
+ it "a number does not validate" do
20
+ instance = described_class.new(42)
21
+ expect(instance.valid?).to be_falsey
22
+ expect(instance.formatted_errors).to eq("DbObject options must be a Hash")
23
+ end
24
+ end
25
+
26
+ context "various invalid hash arguments" do
27
+ let(:error_messages) do
28
+ [
29
+ "DbObject options must include a 'name' string",
30
+ "DbObject options for 'foo' must include a 'columns' array"
31
+ ]
32
+ end
33
+ "DbObject options must include a 'name' string, DbObject options for 'unnamed' must include a 'columns' array"
34
+ it "an empty hash does not validate" do
35
+ instance = described_class.new({})
36
+ expect(instance.valid?).to be_falsey
37
+ expect(instance.formatted_errors).to eq(error_messages.values_at(0,1).join(", ").gsub('foo', 'unnamed'))
38
+ end
39
+
40
+ it "a name key value only" do
41
+ instance = described_class.new({"name" => "foo"})
42
+ expect(instance.valid?).to be_falsey
43
+ expect(instance.formatted_errors).to eq(error_messages[1])
44
+ end
45
+
46
+ it "a name and bad columns" do
47
+ instance = described_class.new({"name" => "foo", "columns" => 42})
48
+ expect(instance.valid?).to be_falsey
49
+ expect(instance.formatted_errors).to eq(error_messages[1])
50
+ end
51
+
52
+ it "a name and bad columns - empty array" do
53
+ instance = described_class.new({"name" => "foo", "columns" => []})
54
+ expect(instance.valid?).to be_falsey
55
+ msg = "The columns array for 'foo' is not uniform, it should contain arrays of two strings only"
56
+ expect(instance.formatted_errors).to eq(msg)
57
+ end
58
+
59
+ it "a name and bad columns - irregular arrays" do
60
+ instance = described_class.new({"name" => "foo", "columns" => [["ip", "text"], ["name"], ["a", "b", "c"]]})
61
+ expect(instance.valid?).to be_falsey
62
+ msg = "The columns array for 'foo' is not uniform, it should contain arrays of two strings only"
63
+ expect(instance.formatted_errors).to eq(msg)
64
+ end
65
+
66
+ it "a name, good columns and bad index_column" do
67
+ instance = described_class.new({"name" => "foo_index", "index_columns" => ["bar"], "columns" => [["ip", "text"], ["name", "text"]]})
68
+ expect(instance.valid?).to be_falsey
69
+ msg = "The index_columns element: 'bar' must be a column defined in the columns array"
70
+ expect(instance.formatted_errors).to eq(msg)
71
+ end
72
+ end
73
+
74
+ context "a valid hash argument" do
75
+ it "does validate" do
76
+ instance = described_class.new({"name" => "foo", "index_columns" => ["ip"], "columns" => [["ip", "text"], ["name", "text"]]})
77
+ expect(instance.formatted_errors).to eq("")
78
+ expect(instance.valid?).to be_truthy
79
+ end
80
+ end
81
+ end