fluent-plugin-mysql-replicator 1.0.3 → 1.2.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.
@@ -1,5 +1,6 @@
1
1
  require 'net/http'
2
2
  require 'date'
3
+ require 'yajl'
3
4
  require 'fluent/plugin/output'
4
5
 
5
6
  class Fluent::Plugin::MysqlReplicatorElasticsearchOutput < Fluent::Plugin::Output
@@ -38,6 +39,8 @@ class Fluent::Plugin::MysqlReplicatorElasticsearchOutput < Fluent::Plugin::Outpu
38
39
 
39
40
  def start
40
41
  super
42
+ # nil means "not yet detected"; resolved on the first write.
43
+ @suppress_type = nil
41
44
  end
42
45
 
43
46
  def format(tag, time, record)
@@ -57,6 +60,8 @@ class Fluent::Plugin::MysqlReplicatorElasticsearchOutput < Fluent::Plugin::Outpu
57
60
  end
58
61
 
59
62
  def write(chunk)
63
+ detect_type_suppression if @suppress_type.nil?
64
+
60
65
  bulk_message = []
61
66
 
62
67
  chunk.msgpack_each do |tag, time, record|
@@ -66,28 +71,60 @@ class Fluent::Plugin::MysqlReplicatorElasticsearchOutput < Fluent::Plugin::Outpu
66
71
  id_key = tag_parts['primary_key']
67
72
 
68
73
  if tag_parts['event'] == 'delete'
69
- meta = { "delete" => {"_index" => target_index, "_type" => target_type, "_id" => record[id_key]} }
74
+ action = {"_index" => target_index, "_id" => record[id_key]}
75
+ action['_type'] = target_type unless @suppress_type
76
+ meta = { "delete" => action }
70
77
  bulk_message << Yajl::Encoder.encode(meta)
71
78
  else
72
- meta = { "index" => {"_index" => target_index, "_type" => target_type} }
79
+ action = {"_index" => target_index}
80
+ action['_type'] = target_type unless @suppress_type
73
81
  if id_key && record[id_key]
74
- meta['index']['_id'] = record[id_key]
82
+ action['_id'] = record[id_key]
75
83
  end
84
+ meta = { "index" => action }
76
85
  bulk_message << Yajl::Encoder.encode(meta)
77
86
  bulk_message << Yajl::Encoder.encode(record)
78
87
  end
79
88
  end
80
89
  bulk_message << ""
81
90
 
82
- http = Net::HTTP.new(@host, @port.to_i)
83
- http.use_ssl = @ssl
84
-
85
91
  request = Net::HTTP::Post.new('/_bulk', {'content-type' => 'application/json; charset=utf-8'})
86
92
  if @username && @password
87
93
  request.basic_auth(@username, @password)
88
94
  end
89
95
 
90
96
  request.body = bulk_message.join("\n")
91
- http.request(request).value
97
+ new_http.request(request).value
98
+ end
99
+
100
+ private
101
+
102
+ def new_http
103
+ http = Net::HTTP.new(@host, @port.to_i)
104
+ http.use_ssl = @ssl
105
+ http
106
+ end
107
+
108
+ # Mapping types were removed in Elasticsearch 8.x and deprecated in 7.x.
109
+ # Detect the major version once and omit "_type" for 7.x and later.
110
+ def detect_type_suppression
111
+ major = elasticsearch_major_version
112
+ @suppress_type = !major.nil? && major >= 7
113
+ if major
114
+ log.info "mysql_replicator_elasticsearch: detected Elasticsearch #{major}.x, suppress_type=#{@suppress_type}"
115
+ else
116
+ log.warn "mysql_replicator_elasticsearch: could not detect Elasticsearch version, sending '_type' (assuming 6.x)"
117
+ end
118
+ end
119
+
120
+ def elasticsearch_major_version
121
+ request = Net::HTTP::Get.new('/')
122
+ request.basic_auth(@username, @password) if @username && @password
123
+ response = new_http.request(request)
124
+ number = Yajl::Parser.parse(response.body).dig('version', 'number')
125
+ number.to_s.split('.').first.to_i
126
+ rescue => e
127
+ log.warn "mysql_replicator_elasticsearch: version detection failed: #{e.message}"
128
+ nil
92
129
  end
93
130
  end
@@ -24,6 +24,9 @@ CREATE TABLE IF NOT EXISTS `settings` (
24
24
  `prepared_query` TEXT NOT NULL,
25
25
  `interval` int(11) NOT NULL,
26
26
  `primary_key` varchar(255) DEFAULT 'id',
27
+ -- Comma-separated column names whose MySQL JSON values should be parsed into nested objects.
28
+ -- Intended for Elasticsearch; leave empty for Solr or destinations that cannot store JSON objects.
29
+ `json_columns` varchar(255) DEFAULT NULL,
27
30
  `enable_delete` int(11) DEFAULT '1',
28
31
  -- On enabling 'enable_loose_insert: 1', make it faster synchronization to skip checking hash_tables.
29
32
  `enable_loose_insert` int(11) DEFAULT '0',
@@ -0,0 +1,94 @@
1
+ # Shared helpers for the end-to-end replication tests.
2
+ #
3
+ # Connection settings come from environment variables (with local defaults):
4
+ # MYSQL_HOST MYSQL_PORT MYSQL_USER MYSQL_PASSWORD
5
+ # ES_HOST ES_PORT
6
+ require 'mysql2'
7
+ require 'net/http'
8
+ require 'json'
9
+
10
+ module E2E
11
+ module_function
12
+
13
+ ROOT = File.expand_path('../..', __dir__)
14
+
15
+ def mysql_config(database: nil)
16
+ cfg = {
17
+ host: ENV['MYSQL_HOST'] || '127.0.0.1',
18
+ port: (ENV['MYSQL_PORT'] || 3306).to_i,
19
+ username: ENV['MYSQL_USER'] || 'root',
20
+ password: ENV['MYSQL_PASSWORD'] || 'root',
21
+ }
22
+ cfg[:database] = database if database
23
+ cfg
24
+ end
25
+
26
+ def es_base
27
+ "http://#{ENV['ES_HOST'] || '127.0.0.1'}:#{ENV['ES_PORT'] || '9200'}"
28
+ end
29
+
30
+ # Major version of the Elasticsearch under test (defaults to 6).
31
+ def es_major_version
32
+ (ENV['ES_MAJOR_VERSION'] || '6').to_i
33
+ end
34
+
35
+ # Realtime GET by id. Returns [http_status, parsed_body].
36
+ # Elasticsearch 7.x+ dropped custom mapping types, so documents are addressed
37
+ # via the "_doc" endpoint instead of the original type name.
38
+ def es_get(index, type, id)
39
+ type_path = es_major_version >= 7 ? '_doc' : type
40
+ res = Net::HTTP.get_response(URI("#{es_base}/#{index}/#{type_path}/#{id}"))
41
+ [res.code.to_i, (JSON.parse(res.body) rescue {})]
42
+ end
43
+
44
+ # Drop an index so a test starts from a clean slate (ignores "not found").
45
+ def es_delete_index(index)
46
+ uri = URI("#{es_base}/#{index}")
47
+ Net::HTTP.start(uri.host, uri.port) { |h| h.request(Net::HTTP::Delete.new(uri)) }
48
+ rescue StandardError
49
+ # ignore: the index may not exist yet
50
+ end
51
+
52
+ # Poll a condition until it becomes truthy, otherwise fail with a timeout.
53
+ def wait_until(description, timeout: 90, log_path: nil)
54
+ deadline = Time.now + timeout
55
+ loop do
56
+ return if yield
57
+ fail_with("timeout (#{timeout}s) waiting for: #{description}", log_path) if Time.now > deadline
58
+ sleep 1
59
+ end
60
+ end
61
+
62
+ def fail_with(message, log_path = nil)
63
+ warn "\n[E2E] FAILED: #{message}"
64
+ if log_path && File.exist?(log_path)
65
+ warn "\n----- fluentd.log (tail) -----"
66
+ warn File.readlines(log_path).last(60).join
67
+ warn "------------------------------"
68
+ end
69
+ exit 1
70
+ end
71
+
72
+ def step(message)
73
+ puts "[E2E] #{message}"
74
+ end
75
+
76
+ # Boot fluentd as a child process. Returns the pid; logs go to log_path.
77
+ def spawn_fluentd(conf, log_path)
78
+ log = File.open(log_path, 'w')
79
+ Process.spawn(
80
+ 'bundle', 'exec', 'fluentd',
81
+ '-c', conf,
82
+ '-p', File.join(ROOT, 'lib', 'fluent', 'plugin'),
83
+ '--no-supervisor',
84
+ out: log, err: log, chdir: ROOT
85
+ )
86
+ end
87
+
88
+ def stop_fluentd(pid)
89
+ Process.kill('TERM', pid)
90
+ Process.wait(pid)
91
+ rescue Errno::ESRCH, Errno::ECHILD
92
+ # already gone
93
+ end
94
+ end
@@ -0,0 +1,30 @@
1
+ # E2E pipeline for the multi-table input plugin.
2
+ # in_mysql_replicator_multi reads replication settings from the management
3
+ # database (replicator_manager.settings) and persists per-row hashes into
4
+ # replicator_manager.hash_tables. See test/e2e/replication_multi_e2e_test.rb.
5
+ #
6
+ # bulk_insert_count is set to 1 so hash_tables is flushed immediately on each
7
+ # insert, which keeps the test timing deterministic.
8
+ <source>
9
+ @type mysql_replicator_multi
10
+ manager_host "#{ENV['MYSQL_HOST'] || '127.0.0.1'}"
11
+ manager_port "#{ENV['MYSQL_PORT'] || '3306'}"
12
+ manager_username "#{ENV['MYSQL_USER'] || 'root'}"
13
+ manager_password "#{ENV['MYSQL_PASSWORD'] || 'root'}"
14
+ manager_database "#{ENV['MYSQL_MANAGER_DATABASE'] || 'replicator_manager'}"
15
+ tag multiindex.multitype.${event}.${primary_key}
16
+ bulk_insert_count 1
17
+ bulk_insert_timeout 2
18
+ </source>
19
+
20
+ <match multiindex.**>
21
+ @type mysql_replicator_elasticsearch
22
+ host "#{ENV['ES_HOST'] || '127.0.0.1'}"
23
+ port "#{ENV['ES_PORT'] || '9200'}"
24
+ <buffer>
25
+ @type memory
26
+ flush_mode interval
27
+ flush_interval 1s
28
+ flush_thread_count 1
29
+ </buffer>
30
+ </match>
@@ -0,0 +1,29 @@
1
+ # E2E pipeline: MySQL (in_mysql_replicator) -> Elasticsearch (out_mysql_replicator_elasticsearch)
2
+ # Connection details are injected via environment variables so the same config
3
+ # works both in CI and locally. See test/e2e/replication_e2e_test.rb.
4
+ <source>
5
+ @type mysql_replicator
6
+ host "#{ENV['MYSQL_HOST'] || '127.0.0.1'}"
7
+ port "#{ENV['MYSQL_PORT'] || '3306'}"
8
+ username "#{ENV['MYSQL_USER'] || 'root'}"
9
+ password "#{ENV['MYSQL_PASSWORD'] || 'root'}"
10
+ database "#{ENV['MYSQL_DATABASE'] || 'e2e_source'}"
11
+ query SELECT id, name, age, profile FROM users
12
+ primary_key id
13
+ json_columns profile
14
+ interval 2s
15
+ enable_delete true
16
+ tag myindex.mytype.${event}.${primary_key}
17
+ </source>
18
+
19
+ <match myindex.**>
20
+ @type mysql_replicator_elasticsearch
21
+ host "#{ENV['ES_HOST'] || '127.0.0.1'}"
22
+ port "#{ENV['ES_PORT'] || '9200'}"
23
+ <buffer>
24
+ @type memory
25
+ flush_mode interval
26
+ flush_interval 1s
27
+ flush_thread_count 1
28
+ </buffer>
29
+ </match>
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env ruby
2
+ # End-to-end test for the multi-table input plugin (in_mysql_replicator_multi).
3
+ #
4
+ # Unlike the single plugin, this one is driven by a management database:
5
+ # - replicator_manager.settings : one row per replication job
6
+ # - replicator_manager.hash_tables: persisted per-row hashes (delete detection)
7
+ #
8
+ # This test builds that management DB from setup_mysql_replicator_multi.sql,
9
+ # registers a settings row pointing at a source table, boots Fluentd wiring
10
+ # in_mysql_replicator_multi -> out_mysql_replicator_elasticsearch, and asserts
11
+ # that INSERT / UPDATE / DELETE propagate to Elasticsearch AND that the
12
+ # hash_tables state is maintained.
13
+ #
14
+ # Delete detection in the multi plugin is gap-based against hash_tables, so the
15
+ # test seeds three rows and deletes the *middle* one (id=2) to exercise it.
16
+ require_relative 'e2e_helper'
17
+ include E2E
18
+
19
+ CONF = File.join(ROOT, 'test', 'e2e', 'fluent_multi.conf')
20
+ LOG_PATH = File.join(ROOT, 'test', 'e2e', 'fluentd_multi.log')
21
+ SETUP_SQL = File.join(ROOT, 'setup_mysql_replicator_multi.sql')
22
+
23
+ SOURCE_DB = ENV['MYSQL_DATABASE'] || 'e2e_source'
24
+ MANAGER_DB = ENV['MYSQL_MANAGER_DATABASE'] || 'replicator_manager'
25
+ SETTING_NAME = 'users_to_es'
26
+ INDEX = 'multiindex'
27
+ TYPE = 'multitype'
28
+
29
+ def hash_table_count(client, pk: nil)
30
+ where = "setting_name = '#{SETTING_NAME}'"
31
+ where += " AND setting_query_pk = #{pk}" if pk
32
+ client.query("SELECT COUNT(*) AS c FROM `#{MANAGER_DB}`.hash_tables WHERE #{where}").first['c']
33
+ end
34
+
35
+ # --- 1. Seed the source database --------------------------------------------
36
+ step "seeding MySQL source database '#{SOURCE_DB}'"
37
+ es_delete_index(INDEX)
38
+ client = Mysql2::Client.new(mysql_config)
39
+ client.query("DROP DATABASE IF EXISTS `#{SOURCE_DB}`")
40
+ client.query("CREATE DATABASE `#{SOURCE_DB}`")
41
+ client.query("USE `#{SOURCE_DB}`")
42
+ client.query(<<~SQL)
43
+ CREATE TABLE users (
44
+ id INT NOT NULL AUTO_INCREMENT,
45
+ name VARCHAR(255) NOT NULL,
46
+ age INT NOT NULL,
47
+ profile JSON,
48
+ PRIMARY KEY (id)
49
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8
50
+ SQL
51
+ client.query(<<~SQL)
52
+ INSERT INTO users (name, age, profile) VALUES
53
+ ('alice', 20, '{"city":"Tokyo","tags":["a","b"]}'),
54
+ ('bob', 30, '{"city":"Osaka","tags":["c"]}'),
55
+ ('carol', 40, '{"city":"Nagoya","tags":[]}')
56
+ SQL
57
+
58
+ # --- 2. Build the management database and register a replication setting ----
59
+ step "building management database '#{MANAGER_DB}' from setup SQL"
60
+ client.query("DROP DATABASE IF EXISTS `#{MANAGER_DB}`")
61
+ File.read(SETUP_SQL).split(/;\s*$/).map(&:strip).reject(&:empty?).each do |stmt|
62
+ client.query(stmt)
63
+ end
64
+
65
+ step "registering settings row '#{SETTING_NAME}'"
66
+ cfg = mysql_config
67
+ client.query(<<~SQL)
68
+ INSERT INTO `#{MANAGER_DB}`.settings
69
+ (is_active, name, host, port, username, password, `database`,
70
+ query, prepared_query, `interval`, primary_key, enable_delete, json_columns)
71
+ VALUES
72
+ (1, '#{SETTING_NAME}', '#{cfg[:host]}', #{cfg[:port]},
73
+ '#{cfg[:username]}', '#{client.escape(cfg[:password].to_s)}', '#{SOURCE_DB}',
74
+ 'SELECT id, name, age, profile FROM users ORDER BY id', '', 2, 'id', 1, 'profile')
75
+ SQL
76
+
77
+ # --- 3. Boot Fluentd --------------------------------------------------------
78
+ step "starting Fluentd (#{CONF})"
79
+ fluentd_pid = spawn_fluentd(CONF, LOG_PATH)
80
+
81
+ begin
82
+ # --- 4. INSERT is replicated to Elasticsearch -----------------------------
83
+ step "asserting INSERT replication"
84
+ {1 => 'alice', 2 => 'bob', 3 => 'carol'}.each do |id, name|
85
+ wait_until("user #{id} (#{name}) indexed in Elasticsearch", log_path: LOG_PATH) do
86
+ code, body = es_get(INDEX, TYPE, id)
87
+ code == 200 && body.dig('_source', 'name') == name
88
+ end
89
+ end
90
+ step " INSERT OK"
91
+
92
+ # --- 4b. JSON column is indexed as a nested object (not an escaped string) -
93
+ step "asserting JSON column is replicated as a nested object"
94
+ wait_until("user 1 profile.city == Tokyo in Elasticsearch", log_path: LOG_PATH) do
95
+ code, body = es_get(INDEX, TYPE, 1)
96
+ code == 200 && body.dig('_source', 'profile', 'city') == 'Tokyo'
97
+ end
98
+ _, body = es_get(INDEX, TYPE, 1)
99
+ unless body.dig('_source', 'profile', 'tags') == ['a', 'b']
100
+ fail_with("profile was not indexed as a nested object: #{body.dig('_source', 'profile').inspect}", LOG_PATH)
101
+ end
102
+ step " JSON OK"
103
+
104
+ # --- 5. hash_tables state is persisted ------------------------------------
105
+ step "asserting hash_tables persistence"
106
+ wait_until("3 rows recorded in hash_tables for '#{SETTING_NAME}'", log_path: LOG_PATH) do
107
+ hash_table_count(client) == 3
108
+ end
109
+ step " hash_tables OK"
110
+
111
+ # --- 6. UPDATE is replicated ----------------------------------------------
112
+ step "asserting UPDATE replication"
113
+ client.query("UPDATE `#{SOURCE_DB}`.users SET age = 21 WHERE id = 1")
114
+ wait_until("user 1 age updated to 21 in Elasticsearch", log_path: LOG_PATH) do
115
+ code, body = es_get(INDEX, TYPE, 1)
116
+ code == 200 && body.dig('_source', 'age') == 21
117
+ end
118
+ step " UPDATE OK"
119
+
120
+ # --- 7. DELETE (middle id) is replicated ----------------------------------
121
+ step "asserting DELETE replication (middle id=2)"
122
+ client.query("DELETE FROM `#{SOURCE_DB}`.users WHERE id = 2")
123
+ wait_until("user 2 removed from Elasticsearch", log_path: LOG_PATH) do
124
+ code, _ = es_get(INDEX, TYPE, 2)
125
+ code == 404
126
+ end
127
+ wait_until("hash_tables entry for id=2 removed", log_path: LOG_PATH) do
128
+ hash_table_count(client, pk: 2) == 0
129
+ end
130
+ # Surviving rows must remain.
131
+ code1, = es_get(INDEX, TYPE, 1)
132
+ code3, = es_get(INDEX, TYPE, 3)
133
+ fail_with("surviving rows were unexpectedly removed (id1=#{code1}, id3=#{code3})", LOG_PATH) unless code1 == 200 && code3 == 200
134
+ step " DELETE OK"
135
+
136
+ puts "\n[E2E] PASSED (multi): insert/update/delete replicated and hash_tables maintained"
137
+ ensure
138
+ step "stopping Fluentd"
139
+ stop_fluentd(fluentd_pid)
140
+ end
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env ruby
2
+ # End-to-end test for the single-table input plugin (in_mysql_replicator).
3
+ #
4
+ # It seeds a source table in MySQL, boots a real Fluentd process wiring
5
+ # in_mysql_replicator -> out_mysql_replicator_elasticsearch (fluent_single.conf),
6
+ # and asserts that INSERT / UPDATE / DELETE on MySQL are replicated to
7
+ # Elasticsearch.
8
+ require_relative 'e2e_helper'
9
+ include E2E
10
+
11
+ CONF = File.join(ROOT, 'test', 'e2e', 'fluent_single.conf')
12
+ LOG_PATH = File.join(ROOT, 'test', 'e2e', 'fluentd_single.log')
13
+
14
+ DB = ENV['MYSQL_DATABASE'] || 'e2e_source'
15
+ INDEX = 'myindex'
16
+ TYPE = 'mytype'
17
+
18
+ # --- 1. Seed the source database --------------------------------------------
19
+ step "seeding MySQL source database '#{DB}'"
20
+ es_delete_index(INDEX)
21
+ client = Mysql2::Client.new(mysql_config)
22
+ client.query("DROP DATABASE IF EXISTS `#{DB}`")
23
+ client.query("CREATE DATABASE `#{DB}`")
24
+ client.query("USE `#{DB}`")
25
+ client.query(<<~SQL)
26
+ CREATE TABLE users (
27
+ id INT NOT NULL AUTO_INCREMENT,
28
+ name VARCHAR(255) NOT NULL,
29
+ age INT NOT NULL,
30
+ profile JSON,
31
+ PRIMARY KEY (id)
32
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8
33
+ SQL
34
+ client.query(<<~SQL)
35
+ INSERT INTO users (name, age, profile) VALUES
36
+ ('alice', 20, '{"city":"Tokyo","tags":["a","b"]}'),
37
+ ('bob', 30, '{"city":"Osaka","tags":["c"]}')
38
+ SQL
39
+
40
+ # --- 2. Boot Fluentd --------------------------------------------------------
41
+ step "starting Fluentd (#{CONF})"
42
+ fluentd_pid = spawn_fluentd(CONF, LOG_PATH)
43
+
44
+ begin
45
+ # --- 3. INSERT is replicated ----------------------------------------------
46
+ step "asserting INSERT replication"
47
+ wait_until("user 1 (alice) indexed in Elasticsearch", log_path: LOG_PATH) do
48
+ code, body = es_get(INDEX, TYPE, 1)
49
+ code == 200 && body.dig('_source', 'name') == 'alice'
50
+ end
51
+ wait_until("user 2 (bob) indexed in Elasticsearch", log_path: LOG_PATH) do
52
+ code, body = es_get(INDEX, TYPE, 2)
53
+ code == 200 && body.dig('_source', 'name') == 'bob'
54
+ end
55
+ step " INSERT OK"
56
+
57
+ # --- 3b. JSON column is indexed as a nested object (not an escaped string) -
58
+ step "asserting JSON column is replicated as a nested object"
59
+ wait_until("user 1 profile.city == Tokyo in Elasticsearch", log_path: LOG_PATH) do
60
+ code, body = es_get(INDEX, TYPE, 1)
61
+ code == 200 && body.dig('_source', 'profile', 'city') == 'Tokyo'
62
+ end
63
+ _, body = es_get(INDEX, TYPE, 1)
64
+ unless body.dig('_source', 'profile', 'tags') == ['a', 'b']
65
+ fail_with("profile was not indexed as a nested object: #{body.dig('_source', 'profile').inspect}", LOG_PATH)
66
+ end
67
+ step " JSON OK"
68
+
69
+ # --- 4. UPDATE is replicated ----------------------------------------------
70
+ step "asserting UPDATE replication"
71
+ client.query("UPDATE users SET age = 21 WHERE id = 1")
72
+ wait_until("user 1 age updated to 21 in Elasticsearch", log_path: LOG_PATH) do
73
+ code, body = es_get(INDEX, TYPE, 1)
74
+ code == 200 && body.dig('_source', 'age') == 21
75
+ end
76
+ step " UPDATE OK"
77
+
78
+ # --- 5. DELETE is replicated ----------------------------------------------
79
+ step "asserting DELETE replication"
80
+ client.query("DELETE FROM users WHERE id = 2")
81
+ wait_until("user 2 removed from Elasticsearch", log_path: LOG_PATH) do
82
+ code, _ = es_get(INDEX, TYPE, 2)
83
+ code == 404
84
+ end
85
+ step " DELETE OK"
86
+
87
+ puts "\n[E2E] PASSED (single): insert/update/delete replicated MySQL -> Elasticsearch"
88
+ ensure
89
+ step "stopping Fluentd"
90
+ stop_fluentd(fluentd_pid)
91
+ end
@@ -30,11 +30,111 @@ class MysqlReplicatorInputTest < Test::Unit::TestCase
30
30
  tag input.mysql
31
31
  query SELECT id, text from search_text
32
32
  enable_delete no
33
+ json_columns geometry,attrs
33
34
  ]
34
35
  assert_equal 'localhost', d.instance.host
35
36
  assert_equal 3306, d.instance.port
36
37
  assert_equal 30, d.instance.interval
37
38
  assert_equal 'input.mysql', d.instance.tag
38
39
  assert_equal false, d.instance.enable_delete
40
+ assert_equal ['geometry', 'attrs'], d.instance.json_columns
41
+ end
42
+
43
+ def test_json_columns_defaults_to_empty
44
+ d = create_driver
45
+ assert_equal [], d.instance.json_columns
46
+ end
47
+
48
+ def test_parse_json_columns
49
+ d = create_driver
50
+ row = {
51
+ 'id' => 1,
52
+ 'geometry' => '{"type":"Polygon","coordinates":[[136.8,35.1]]}',
53
+ 'tags' => '[1,2,3]',
54
+ 'broken' => 'not json {',
55
+ 'plain' => 'hello',
56
+ 'number' => 5,
57
+ }
58
+ d.instance.parse_json_columns!(row, ['geometry', 'tags', 'broken', 'number', 'missing'])
59
+
60
+ assert_equal({'type' => 'Polygon', 'coordinates' => [[136.8, 35.1]]}, row['geometry'])
61
+ assert_equal([1, 2, 3], row['tags'])
62
+ assert_equal('not json {', row['broken']) # malformed JSON stays as the original string
63
+ assert_equal(5, row['number']) # non-string values are untouched
64
+ assert_equal('hello', row['plain']) # columns not listed are untouched
65
+ end
66
+
67
+ def test_parse_json_columns_noop_when_empty
68
+ d = create_driver
69
+ row = {'geometry' => '{"a":1}'}
70
+ d.instance.parse_json_columns!(row, [])
71
+ assert_equal('{"a":1}', row['geometry'])
72
+ end
73
+
74
+ # --- #42: delete detection must not build an integer Range from primary keys ---
75
+
76
+ def deleted_ids(previous_ids, current_ids)
77
+ conf = %[
78
+ tag input.mysql
79
+ query SELECT id, text from search_test
80
+ ]
81
+ create_driver(conf).instance.detect_deleted_ids(previous_ids, current_ids)
82
+ end
83
+
84
+ def test_detect_deleted_ids_first_poll_reports_no_deletes
85
+ assert_equal [], deleted_ids([], [1, 2, 3])
86
+ end
87
+
88
+ def test_detect_deleted_ids_first_poll_with_string_keys_does_not_raise
89
+ # Regression for #42: the old `[*1...'c']` raised "bad value for range".
90
+ assert_nothing_raised do
91
+ assert_equal [], deleted_ids([], %w[a b c])
92
+ end
93
+ end
94
+
95
+ def test_detect_deleted_ids_first_poll_with_sparse_ids_has_no_phantom_deletes
96
+ # The old code returned `[*1...99] - [1, 50, 99]`, emitting deletes for ids
97
+ # that never existed. The first poll must only establish a baseline.
98
+ assert_equal [], deleted_ids([], [1, 50, 99])
99
+ end
100
+
101
+ def test_detect_deleted_ids_diffs_against_previous_snapshot
102
+ assert_equal [2], deleted_ids([1, 2, 3], [1, 3])
103
+ end
104
+
105
+ def test_detect_deleted_ids_empty_current_does_not_mass_delete
106
+ assert_equal [], deleted_ids([1, 2, 3], [])
107
+ end
108
+
109
+ # --- #4: a nested sub-query fires only for a SELECT template with ${...} ---
110
+
111
+ def nested?(value)
112
+ conf = %[
113
+ tag input.mysql
114
+ query SELECT id, text from search_test
115
+ ]
116
+ create_driver(conf).instance.nested_query_value?(value)
117
+ end
118
+
119
+ def test_nested_query_value_true_for_select_with_placeholder
120
+ assert_true nested?("SELECT * FROM child WHERE parent_id = ${id}")
121
+ end
122
+
123
+ def test_nested_query_value_false_for_plain_text_starting_with_select
124
+ # Regression for #4: a data value beginning with "SELECT" must not run as SQL.
125
+ assert_false nested?("SELECT YOUR PLAN")
126
+ end
127
+
128
+ def test_nested_query_value_false_for_word_select
129
+ assert_false nested?("Selecting the best option")
130
+ end
131
+
132
+ def test_nested_query_value_false_for_select_without_placeholder
133
+ assert_false nested?("select count(*) from search_test")
134
+ end
135
+
136
+ def test_nested_query_value_false_for_non_string_values
137
+ assert_false nested?(12345)
138
+ assert_false nested?(nil)
39
139
  end
40
140
  end
@@ -21,7 +21,18 @@ class MysqlReplicatorElasticsearchOutput < Test::Unit::TestCase
21
21
  {'age' => 26, 'request_id' => '42'}
22
22
  end
23
23
 
24
+ # The plugin detects the Elasticsearch version on the first write via GET /,
25
+ # so stub that endpoint (defaulting to 6.x, which keeps "_type").
26
+ def stub_elastic_version(url, version="6.8.23")
27
+ stub_request(:get, url).to_return(
28
+ :status => 200,
29
+ :headers => {"Content-Type" => "application/json"},
30
+ :body => %({"version":{"number":"#{version}"}})
31
+ )
32
+ end
33
+
24
34
  def stub_elastic(url="http://localhost:9200/_bulk")
35
+ stub_elastic_version(url.sub('/_bulk', '/'))
25
36
  stub_request(:post, url).with do |req|
26
37
  @content_type = req.headers["Content-Type"]
27
38
  @index_cmds = req.body.split("\n").map {|r| JSON.parse(r) }
@@ -29,6 +40,7 @@ class MysqlReplicatorElasticsearchOutput < Test::Unit::TestCase
29
40
  end
30
41
 
31
42
  def stub_elastic_unavailable(url="http://localhost:9200/_bulk")
43
+ stub_elastic_version(url.sub('/_bulk', '/'))
32
44
  stub_request(:post, url).to_return(:status => [503, "Service Unavailable"])
33
45
  end
34
46
 
@@ -58,6 +70,16 @@ class MysqlReplicatorElasticsearchOutput < Test::Unit::TestCase
58
70
  assert_equal('mytype', index_cmds.first['index']['_type'])
59
71
  end
60
72
 
73
+ def test_auto_detects_es8_and_omits_type
74
+ stub_elastic
75
+ # Override the version endpoint to report Elasticsearch 8.x.
76
+ stub_elastic_version("http://localhost:9200/", "8.18.0")
77
+ driver.run(default_tag: @tag) do
78
+ driver.feed(sample_record)
79
+ end
80
+ assert(!index_cmds.first['index'].has_key?('_type'))
81
+ end
82
+
61
83
  def test_writes_to_speficied_host
62
84
  driver.configure("host 192.168.33.50\n")
63
85
  elastic_request = stub_elastic("http://192.168.33.50:9200/_bulk")