logstash-filter-jdbc_static 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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