schema-tools 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.
- checksums.yaml +7 -0
- data/LICENSE +201 -0
- data/README.md +305 -0
- data/bin/integrate +136 -0
- data/bin/setup +23 -0
- data/lib/schema_tools/api_aware_mappings_diff.rb +79 -0
- data/lib/schema_tools/catchup.rb +23 -0
- data/lib/schema_tools/client.rb +472 -0
- data/lib/schema_tools/close.rb +28 -0
- data/lib/schema_tools/config.rb +46 -0
- data/lib/schema_tools/delete.rb +28 -0
- data/lib/schema_tools/diff.rb +263 -0
- data/lib/schema_tools/download.rb +114 -0
- data/lib/schema_tools/json_diff.rb +234 -0
- data/lib/schema_tools/migrate/migrate.rb +90 -0
- data/lib/schema_tools/migrate/migrate_breaking_change.rb +373 -0
- data/lib/schema_tools/migrate/migrate_new.rb +33 -0
- data/lib/schema_tools/migrate/migrate_non_breaking_change.rb +74 -0
- data/lib/schema_tools/migrate/migrate_verify.rb +19 -0
- data/lib/schema_tools/migrate/migration_step.rb +36 -0
- data/lib/schema_tools/migrate/rollback.rb +211 -0
- data/lib/schema_tools/new_alias.rb +165 -0
- data/lib/schema_tools/painless_scripts_delete.rb +21 -0
- data/lib/schema_tools/painless_scripts_download.rb +26 -0
- data/lib/schema_tools/painless_scripts_upload.rb +31 -0
- data/lib/schema_tools/rake_tasks.rb +15 -0
- data/lib/schema_tools/schema_files.rb +53 -0
- data/lib/schema_tools/seed.rb +64 -0
- data/lib/schema_tools/settings_diff.rb +64 -0
- data/lib/schema_tools/settings_filter.rb +27 -0
- data/lib/seeder/seeder.rb +539 -0
- data/lib/tasks/schema.rake +150 -0
- data/lib/tasks/test.rake +8 -0
- metadata +190 -0
@@ -0,0 +1,263 @@
|
|
1
|
+
require 'json'
|
2
|
+
require_relative 'json_diff'
|
3
|
+
require_relative 'schema_files'
|
4
|
+
require_relative 'client'
|
5
|
+
require_relative 'settings_filter'
|
6
|
+
|
7
|
+
module SchemaTools
|
8
|
+
def self.diff_all_schemas(client:)
|
9
|
+
Diff.diff_all_schemas(client)
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.diff_schema(alias_name, client:)
|
13
|
+
print_schema_diff(Diff.generate_schema_diff(alias_name, client))
|
14
|
+
nil # Console output method, returns nil
|
15
|
+
end
|
16
|
+
|
17
|
+
class Diff
|
18
|
+
# Compare all schemas to their corresponding downloaded alias settings and mappings
|
19
|
+
def self.diff_all_schemas(client)
|
20
|
+
schemas = SchemaFiles.discover_all_schemas
|
21
|
+
|
22
|
+
if schemas.empty?
|
23
|
+
puts "No schemas found in #{Config.schemas_path}"
|
24
|
+
return
|
25
|
+
end
|
26
|
+
|
27
|
+
puts "Found #{schemas.length} schema(s) to compare:"
|
28
|
+
schemas.each do |schema|
|
29
|
+
puts " - #{schema}"
|
30
|
+
end
|
31
|
+
puts
|
32
|
+
|
33
|
+
schemas.each do |alias_name|
|
34
|
+
begin
|
35
|
+
print_schema_diff(generate_schema_diff(alias_name, client))
|
36
|
+
rescue => e
|
37
|
+
puts "✗ Diff failed for #{alias_name}: #{e.message}"
|
38
|
+
end
|
39
|
+
puts
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# Generate a nicely formatted diff representation for a single schema
|
44
|
+
# Compare a single schema to its corresponding downloaded alias settings and mappings
|
45
|
+
def self.generate_schema_diff(alias_name, client)
|
46
|
+
result = {
|
47
|
+
alias_name: alias_name,
|
48
|
+
status: nil,
|
49
|
+
settings_diff: nil,
|
50
|
+
mappings_diff: nil,
|
51
|
+
error: nil
|
52
|
+
}
|
53
|
+
json_diff = JsonDiff.new
|
54
|
+
|
55
|
+
begin
|
56
|
+
unless client.alias_exists?(alias_name)
|
57
|
+
result[:status] = :alias_not_found
|
58
|
+
result[:error] = "Alias '#{alias_name}' not found in cluster"
|
59
|
+
return result
|
60
|
+
end
|
61
|
+
|
62
|
+
alias_indices = client.get_alias_indices(alias_name)
|
63
|
+
|
64
|
+
if alias_indices.length > 1
|
65
|
+
result[:status] = :multiple_indices
|
66
|
+
result[:error] = "Alias '#{alias_name}' points to multiple indices: #{alias_indices.join(', ')}. This configuration is not supported for diffing."
|
67
|
+
return result
|
68
|
+
end
|
69
|
+
|
70
|
+
index_name = alias_indices.first
|
71
|
+
|
72
|
+
local_settings = SchemaFiles.get_settings(alias_name)
|
73
|
+
local_mappings = SchemaFiles.get_mappings(alias_name)
|
74
|
+
|
75
|
+
if local_settings.nil? || local_mappings.nil?
|
76
|
+
result[:status] = :local_files_not_found
|
77
|
+
result[:error] = "Local schema files not found for #{alias_name}"
|
78
|
+
return result
|
79
|
+
end
|
80
|
+
|
81
|
+
remote_settings = client.get_index_settings(index_name)
|
82
|
+
remote_mappings = client.get_index_mappings(index_name)
|
83
|
+
|
84
|
+
if remote_settings.nil? || remote_mappings.nil?
|
85
|
+
result[:status] = :remote_fetch_failed
|
86
|
+
result[:error] = "Failed to retrieve remote settings or mappings for #{index_name}"
|
87
|
+
return result
|
88
|
+
end
|
89
|
+
|
90
|
+
# Filter remote settings to match local format
|
91
|
+
filtered_remote_settings = SettingsFilter.filter_internal_settings(remote_settings)
|
92
|
+
|
93
|
+
# Normalize both local and remote settings to ensure consistent comparison
|
94
|
+
normalized_local_settings = self.normalize_local_settings(local_settings)
|
95
|
+
normalized_remote_settings = self.normalize_remote_settings(filtered_remote_settings)
|
96
|
+
|
97
|
+
result[:settings_diff] = json_diff.generate_diff(normalized_remote_settings, normalized_local_settings)
|
98
|
+
result[:mappings_diff] = json_diff.generate_diff(remote_mappings, local_mappings)
|
99
|
+
|
100
|
+
result[:comparison_context] = {
|
101
|
+
new_files: {
|
102
|
+
settings: "#{alias_name}/settings.json",
|
103
|
+
mappings: "#{alias_name}/mappings.json"
|
104
|
+
},
|
105
|
+
old_api: {
|
106
|
+
settings: "GET /#{index_name}/_settings",
|
107
|
+
mappings: "GET /#{index_name}/_mappings"
|
108
|
+
}
|
109
|
+
}
|
110
|
+
|
111
|
+
if result[:settings_diff] == "No changes detected" && result[:mappings_diff] == "No changes detected"
|
112
|
+
result[:status] = :no_changes
|
113
|
+
else
|
114
|
+
result[:status] = :changes_detected
|
115
|
+
end
|
116
|
+
|
117
|
+
rescue => e
|
118
|
+
result[:status] = :error
|
119
|
+
result[:error] = e.message
|
120
|
+
end
|
121
|
+
|
122
|
+
result
|
123
|
+
end
|
124
|
+
|
125
|
+
# print_schema_diff(generate_schema_diff(alias_name))
|
126
|
+
def self.print_schema_diff(schema_diff)
|
127
|
+
puts "=" * 60
|
128
|
+
puts "Comparing schema: #{schema_diff[:alias_name]}"
|
129
|
+
puts "=" * 60
|
130
|
+
|
131
|
+
# Handle errors by printing and returning
|
132
|
+
if schema_diff[:status] == :alias_not_found
|
133
|
+
puts "❌ #{schema_diff[:error]}"
|
134
|
+
return
|
135
|
+
elsif schema_diff[:status] == :multiple_indices
|
136
|
+
puts "⚠️ #{schema_diff[:error]}"
|
137
|
+
return
|
138
|
+
elsif schema_diff[:status] == :local_files_not_found
|
139
|
+
puts "❌ #{schema_diff[:error]}"
|
140
|
+
return
|
141
|
+
elsif schema_diff[:status] == :remote_fetch_failed
|
142
|
+
puts "❌ #{schema_diff[:error]}"
|
143
|
+
return
|
144
|
+
elsif schema_diff[:status] == :error
|
145
|
+
puts "❌ Error: #{schema_diff[:error]}"
|
146
|
+
return
|
147
|
+
end
|
148
|
+
|
149
|
+
# Show what's being compared
|
150
|
+
puts "New (Local Files):"
|
151
|
+
puts " #{schema_diff[:comparison_context][:new_files][:settings]}"
|
152
|
+
puts " #{schema_diff[:comparison_context][:new_files][:mappings]}"
|
153
|
+
puts
|
154
|
+
puts "Old (Remote API):"
|
155
|
+
puts " #{schema_diff[:comparison_context][:old_api][:settings]}"
|
156
|
+
puts " #{schema_diff[:comparison_context][:old_api][:mappings]}"
|
157
|
+
puts
|
158
|
+
|
159
|
+
# Display the diffs
|
160
|
+
puts "Settings Comparison:"
|
161
|
+
puts schema_diff[:settings_diff]
|
162
|
+
puts
|
163
|
+
|
164
|
+
puts "Mappings Comparison:"
|
165
|
+
puts schema_diff[:mappings_diff]
|
166
|
+
end
|
167
|
+
|
168
|
+
private
|
169
|
+
|
170
|
+
def self.normalize_local_settings(local_settings)
|
171
|
+
return local_settings unless local_settings.is_a?(Hash)
|
172
|
+
|
173
|
+
# If local settings already have "index" wrapper, use it
|
174
|
+
if local_settings.key?("index")
|
175
|
+
if local_settings["index"].is_a?(Hash)
|
176
|
+
# Normalize the index settings and return
|
177
|
+
normalized_index = normalize_values(local_settings["index"])
|
178
|
+
return { "index" => normalized_index }
|
179
|
+
else
|
180
|
+
# If index exists but is not a hash, return as-is (invalid format)
|
181
|
+
return local_settings
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
# If local settings are empty, don't add index wrapper
|
186
|
+
# This prevents empty {} from becoming { "index" => {} }
|
187
|
+
return local_settings if local_settings.empty?
|
188
|
+
|
189
|
+
# If local settings don't have "index" wrapper, wrap them in "index"
|
190
|
+
# This handles cases like { "number_of_shards": 1 } which should be compared as { "index": { "number_of_shards": 1 } }
|
191
|
+
normalized_settings = normalize_values(local_settings)
|
192
|
+
{ "index" => normalized_settings }
|
193
|
+
end
|
194
|
+
|
195
|
+
# Normalize remote settings to ensure consistent comparison
|
196
|
+
# Remote settings may come back as strings from ES API, so normalize them too
|
197
|
+
def self.normalize_remote_settings(remote_settings)
|
198
|
+
return remote_settings unless remote_settings.is_a?(Hash)
|
199
|
+
|
200
|
+
# If remote settings already have "index" wrapper, normalize it
|
201
|
+
if remote_settings.key?("index")
|
202
|
+
if remote_settings["index"].is_a?(Hash)
|
203
|
+
normalized_index = normalize_values(remote_settings["index"])
|
204
|
+
return { "index" => normalized_index }
|
205
|
+
else
|
206
|
+
return remote_settings
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
# If remote settings don't have "index" wrapper, normalize and wrap them
|
211
|
+
normalized_settings = normalize_values(remote_settings)
|
212
|
+
{ "index" => normalized_settings }
|
213
|
+
end
|
214
|
+
|
215
|
+
# Normalize string values to their proper types for Elasticsearch comparison
|
216
|
+
# Handles the gotcha cases where ES accepts string inputs but returns canonical types
|
217
|
+
def self.normalize_values(obj)
|
218
|
+
case obj
|
219
|
+
when Hash
|
220
|
+
normalized = {}
|
221
|
+
obj.each do |key, value|
|
222
|
+
normalized[key] = normalize_values(value)
|
223
|
+
end
|
224
|
+
normalized
|
225
|
+
when Array
|
226
|
+
obj.map { |item| normalize_values(item) }
|
227
|
+
when String
|
228
|
+
normalize_string_value(obj)
|
229
|
+
else
|
230
|
+
obj
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
# Convert string values to their proper types based on Elasticsearch behavior
|
235
|
+
def self.normalize_string_value(str)
|
236
|
+
# Handle boolean values
|
237
|
+
case str.downcase
|
238
|
+
when "true"
|
239
|
+
true
|
240
|
+
when "false"
|
241
|
+
false
|
242
|
+
when "1"
|
243
|
+
# Could be boolean true or numeric 1, default to numeric for settings
|
244
|
+
1
|
245
|
+
when "0"
|
246
|
+
# Could be boolean false or numeric 0, default to numeric for settings
|
247
|
+
0
|
248
|
+
else
|
249
|
+
# Handle numeric strings
|
250
|
+
if str.match?(/\A-?\d+\z/)
|
251
|
+
# Integer string
|
252
|
+
str.to_i
|
253
|
+
elsif str.match?(/\A-?\d*\.\d+\z/)
|
254
|
+
# Float string
|
255
|
+
str.to_f
|
256
|
+
else
|
257
|
+
# Keep as string if it doesn't match any pattern
|
258
|
+
str
|
259
|
+
end
|
260
|
+
end
|
261
|
+
end
|
262
|
+
end
|
263
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'fileutils'
|
3
|
+
require_relative 'config'
|
4
|
+
require_relative 'settings_filter'
|
5
|
+
|
6
|
+
module SchemaTools
|
7
|
+
def self.download(client:)
|
8
|
+
aliases = client.list_aliases
|
9
|
+
indices = client.list_indices
|
10
|
+
|
11
|
+
single_aliases = aliases.select { |alias_name, indices| indices.length == 1 && !alias_name.start_with?('.') }
|
12
|
+
multi_aliases = aliases.select { |alias_name, indices| indices.length > 1 && !alias_name.start_with?('.') }
|
13
|
+
unaliased_indices = indices.reject { |index| aliases.values.flatten.include?(index) || index.start_with?('.') || client.index_closed?(index) }
|
14
|
+
|
15
|
+
# Create a combined list with sequential numbering
|
16
|
+
options = []
|
17
|
+
|
18
|
+
puts "\nAliases pointing to 1 index:"
|
19
|
+
if single_aliases.empty?
|
20
|
+
puts " (none)"
|
21
|
+
else
|
22
|
+
single_aliases.each_with_index do |(alias_name, indices), index|
|
23
|
+
option_number = options.length + 1
|
24
|
+
options << { type: :alias, name: alias_name, index: indices.first }
|
25
|
+
puts " #{option_number}. #{alias_name} -> #{indices.first}"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
puts "\nIndexes not part of any aliases:"
|
30
|
+
if unaliased_indices.empty?
|
31
|
+
puts " (none)"
|
32
|
+
else
|
33
|
+
unaliased_indices.each_with_index do |index_name, index|
|
34
|
+
option_number = options.length + 1
|
35
|
+
options << { type: :index, name: index_name, index: index_name }
|
36
|
+
puts " #{option_number}. #{index_name}"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
if multi_aliases.any?
|
41
|
+
puts "\nAliases pointing to more than 1 index (cannot choose):"
|
42
|
+
multi_aliases.each do |alias_name, indices|
|
43
|
+
puts " - #{alias_name} -> #{indices.join(', ')}"
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
if options.empty?
|
48
|
+
puts "\nNo aliases or indices available to download."
|
49
|
+
return
|
50
|
+
end
|
51
|
+
|
52
|
+
puts "\nPlease choose an alias or index to download:"
|
53
|
+
puts "Enter the number (1-#{options.length}):"
|
54
|
+
|
55
|
+
choice = STDIN.gets&.chomp
|
56
|
+
if choice.nil?
|
57
|
+
puts "No input provided. Exiting."
|
58
|
+
exit 1
|
59
|
+
end
|
60
|
+
|
61
|
+
choice_num = choice.to_i
|
62
|
+
if choice_num < 1 || choice_num > options.length
|
63
|
+
puts "Invalid choice. Please enter a number between 1 and #{options.length}."
|
64
|
+
exit 1
|
65
|
+
end
|
66
|
+
|
67
|
+
selected_option = options[choice_num - 1]
|
68
|
+
|
69
|
+
if selected_option[:type] == :alias
|
70
|
+
download_alias(selected_option[:name], selected_option[:index], client)
|
71
|
+
else
|
72
|
+
download_index(selected_option[:name], client)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
def self.download_alias(alias_name, index_name, client)
|
79
|
+
puts "Downloading alias '#{alias_name}' (index: #{index_name})..."
|
80
|
+
download_schema(alias_name, index_name, client)
|
81
|
+
end
|
82
|
+
|
83
|
+
def self.download_index(index_name, client)
|
84
|
+
puts "Downloading index '#{index_name}'..."
|
85
|
+
download_schema(index_name, index_name, client)
|
86
|
+
end
|
87
|
+
|
88
|
+
def self.download_schema(folder_name, index_name, client)
|
89
|
+
settings = client.get_index_settings(index_name)
|
90
|
+
mappings = client.get_index_mappings(index_name)
|
91
|
+
|
92
|
+
if settings.nil? || mappings.nil?
|
93
|
+
puts "Failed to retrieve settings or mappings for #{index_name}"
|
94
|
+
exit 1
|
95
|
+
end
|
96
|
+
|
97
|
+
# Filter out internal settings
|
98
|
+
filtered_settings = SettingsFilter.filter_internal_settings(settings)
|
99
|
+
|
100
|
+
schema_path = File.join(Config.schemas_path, folder_name)
|
101
|
+
FileUtils.mkdir_p(schema_path)
|
102
|
+
|
103
|
+
settings_file = File.join(schema_path, 'settings.json')
|
104
|
+
mappings_file = File.join(schema_path, 'mappings.json')
|
105
|
+
|
106
|
+
File.write(settings_file, JSON.pretty_generate(filtered_settings))
|
107
|
+
File.write(mappings_file, JSON.pretty_generate(mappings))
|
108
|
+
|
109
|
+
puts "✓ Schema downloaded to #{schema_path}"
|
110
|
+
puts " - settings.json"
|
111
|
+
puts " - mappings.json"
|
112
|
+
end
|
113
|
+
|
114
|
+
end
|
@@ -0,0 +1,234 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
module SchemaTools
|
4
|
+
class JsonDiff
|
5
|
+
def initialize()
|
6
|
+
end
|
7
|
+
|
8
|
+
# Keys to ignore in diff comparisons (noisy metadata)
|
9
|
+
IGNORED_KEYS = [].freeze
|
10
|
+
|
11
|
+
# Generate a detailed diff between two JSON objects
|
12
|
+
# Returns a formatted string showing additions, removals, and modifications
|
13
|
+
def generate_diff(old_json, new_json, context: {})
|
14
|
+
old_normalized = normalize_json(old_json)
|
15
|
+
new_normalized = normalize_json(new_json)
|
16
|
+
|
17
|
+
# Filter out ignored keys
|
18
|
+
old_filtered = filter_ignored_keys(old_normalized)
|
19
|
+
new_filtered = filter_ignored_keys(new_normalized)
|
20
|
+
|
21
|
+
if old_filtered == new_filtered
|
22
|
+
return "No changes detected"
|
23
|
+
end
|
24
|
+
|
25
|
+
diff_lines = []
|
26
|
+
diff_lines << "=== Changes Detected ==="
|
27
|
+
diff_lines << ""
|
28
|
+
|
29
|
+
# Generate detailed diff
|
30
|
+
changes = compare_objects(old_filtered, new_filtered, "")
|
31
|
+
|
32
|
+
if changes.empty?
|
33
|
+
diff_lines << "No changes detected"
|
34
|
+
else
|
35
|
+
changes.each { |change| diff_lines << change }
|
36
|
+
end
|
37
|
+
|
38
|
+
diff_lines.join("\n")
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def normalize_json(json_obj)
|
44
|
+
return {} unless json_obj
|
45
|
+
normalized = JSON.parse(JSON.generate(json_obj))
|
46
|
+
|
47
|
+
# Normalize OpenSearch/Elasticsearch-specific behavior
|
48
|
+
if normalized.is_a?(Hash) && normalized.key?('properties')
|
49
|
+
normalized = normalize_mappings(normalized)
|
50
|
+
end
|
51
|
+
|
52
|
+
normalized
|
53
|
+
end
|
54
|
+
|
55
|
+
def normalize_mappings(mappings)
|
56
|
+
return mappings unless mappings.is_a?(Hash) && mappings.key?('properties')
|
57
|
+
|
58
|
+
normalized = mappings.dup
|
59
|
+
normalized['properties'] = normalize_properties(mappings['properties'])
|
60
|
+
normalized
|
61
|
+
end
|
62
|
+
|
63
|
+
def normalize_properties(properties)
|
64
|
+
return properties unless properties.is_a?(Hash)
|
65
|
+
|
66
|
+
normalized = {}
|
67
|
+
properties.each do |key, value|
|
68
|
+
if value.is_a?(Hash)
|
69
|
+
# Remove implicit "type": "object" if the field has "properties"
|
70
|
+
if value.key?('properties') && value['type'] == 'object'
|
71
|
+
normalized_value = value.dup
|
72
|
+
normalized_value.delete('type')
|
73
|
+
normalized[key] = normalize_properties(normalized_value)
|
74
|
+
else
|
75
|
+
normalized[key] = normalize_properties(value)
|
76
|
+
end
|
77
|
+
else
|
78
|
+
normalized[key] = value
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
normalized
|
83
|
+
end
|
84
|
+
|
85
|
+
def filter_ignored_keys(obj, path_prefix = "")
|
86
|
+
return obj unless obj.is_a?(Hash)
|
87
|
+
|
88
|
+
filtered = {}
|
89
|
+
obj.each do |key, value|
|
90
|
+
current_path = path_prefix.empty? ? key : "#{path_prefix}.#{key}"
|
91
|
+
|
92
|
+
# Skip ignored keys
|
93
|
+
next if IGNORED_KEYS.any? { |ignored_key| current_path == ignored_key }
|
94
|
+
|
95
|
+
# Recursively filter nested objects
|
96
|
+
if value.is_a?(Hash)
|
97
|
+
filtered[key] = filter_ignored_keys(value, current_path)
|
98
|
+
else
|
99
|
+
filtered[key] = value
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
filtered
|
104
|
+
end
|
105
|
+
|
106
|
+
def compare_objects(old_obj, new_obj, path_prefix)
|
107
|
+
changes = []
|
108
|
+
|
109
|
+
# Handle different object types
|
110
|
+
if old_obj.is_a?(Hash) && new_obj.is_a?(Hash)
|
111
|
+
changes.concat(compare_hashes(old_obj, new_obj, path_prefix))
|
112
|
+
elsif old_obj.is_a?(Array) && new_obj.is_a?(Array)
|
113
|
+
changes.concat(compare_arrays(old_obj, new_obj, path_prefix))
|
114
|
+
elsif old_obj != new_obj
|
115
|
+
changes << format_change(path_prefix, old_obj, new_obj)
|
116
|
+
end
|
117
|
+
|
118
|
+
changes
|
119
|
+
end
|
120
|
+
|
121
|
+
def compare_hashes(old_hash, new_hash, path_prefix)
|
122
|
+
changes = []
|
123
|
+
all_keys = (old_hash.keys + new_hash.keys).uniq.sort
|
124
|
+
|
125
|
+
all_keys.each do |key|
|
126
|
+
current_path = path_prefix.empty? ? key : "#{path_prefix}.#{key}"
|
127
|
+
|
128
|
+
# Skip ignored keys
|
129
|
+
next if IGNORED_KEYS.any? { |ignored_key| current_path == ignored_key }
|
130
|
+
|
131
|
+
old_value = old_hash[key]
|
132
|
+
new_value = new_hash[key]
|
133
|
+
|
134
|
+
if old_value.nil? && !new_value.nil?
|
135
|
+
changes << "➕ ADDED: #{current_path}"
|
136
|
+
changes.concat(format_value_details("New value", new_value, " "))
|
137
|
+
elsif !old_value.nil? && new_value.nil?
|
138
|
+
changes << "➖ REMOVED: #{current_path}"
|
139
|
+
changes.concat(format_value_details("Old value", old_value, " "))
|
140
|
+
elsif old_value != new_value
|
141
|
+
if old_value.is_a?(Hash) && new_value.is_a?(Hash) ||
|
142
|
+
old_value.is_a?(Array) && new_value.is_a?(Array)
|
143
|
+
changes.concat(compare_objects(old_value, new_value, current_path))
|
144
|
+
else
|
145
|
+
changes << "🔄 MODIFIED: #{current_path}"
|
146
|
+
changes.concat(format_value_details("Old value", old_value, " "))
|
147
|
+
changes.concat(format_value_details("New value", new_value, " "))
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
changes
|
153
|
+
end
|
154
|
+
|
155
|
+
def compare_arrays(old_array, new_array, path_prefix)
|
156
|
+
changes = []
|
157
|
+
|
158
|
+
if old_array.length != new_array.length
|
159
|
+
changes << "ARRAY LENGTH CHANGED: #{path_prefix} (#{old_array.length} → #{new_array.length})"
|
160
|
+
end
|
161
|
+
|
162
|
+
# Compare elements up to the minimum length
|
163
|
+
min_length = [old_array.length, new_array.length].min
|
164
|
+
(0...min_length).each do |index|
|
165
|
+
current_path = "#{path_prefix}[#{index}]"
|
166
|
+
old_value = old_array[index]
|
167
|
+
new_value = new_array[index]
|
168
|
+
|
169
|
+
if old_value != new_value
|
170
|
+
if old_value.is_a?(Hash) && new_value.is_a?(Hash) ||
|
171
|
+
old_value.is_a?(Array) && new_value.is_a?(Array)
|
172
|
+
changes.concat(compare_objects(old_value, new_value, current_path))
|
173
|
+
else
|
174
|
+
changes << "🔄 MODIFIED: #{current_path}"
|
175
|
+
changes.concat(format_value_details("Old value", old_value, " "))
|
176
|
+
changes.concat(format_value_details("New value", new_value, " "))
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
# Handle added elements
|
182
|
+
if new_array.length > old_array.length
|
183
|
+
(old_array.length...new_array.length).each do |index|
|
184
|
+
current_path = "#{path_prefix}[#{index}]"
|
185
|
+
changes << "➕ ADDED: #{current_path}"
|
186
|
+
changes.concat(format_value_details("New value", new_array[index], " "))
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
# Handle removed elements
|
191
|
+
if old_array.length > new_array.length
|
192
|
+
(new_array.length...old_array.length).each do |index|
|
193
|
+
current_path = "#{path_prefix}[#{index}]"
|
194
|
+
changes << "➖ REMOVED: #{current_path}"
|
195
|
+
changes.concat(format_value_details("Old value", old_array[index], " "))
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
changes
|
200
|
+
end
|
201
|
+
|
202
|
+
def format_change(path, old_value, new_value)
|
203
|
+
"🔄 MODIFIED: #{path}"
|
204
|
+
end
|
205
|
+
|
206
|
+
def format_value_details(label, value, indent)
|
207
|
+
details = []
|
208
|
+
details << "#{indent}#{label}:"
|
209
|
+
|
210
|
+
if value.is_a?(String)
|
211
|
+
# Handle multiline strings
|
212
|
+
if value.include?("\n")
|
213
|
+
details << "#{indent} \"\"\""
|
214
|
+
value.split("\n").each do |line|
|
215
|
+
details << "#{indent} #{line}"
|
216
|
+
end
|
217
|
+
details << "#{indent} \"\"\""
|
218
|
+
else
|
219
|
+
details << "#{indent} \"#{value}\""
|
220
|
+
end
|
221
|
+
elsif value.is_a?(Hash) || value.is_a?(Array)
|
222
|
+
# Format complex objects with proper indentation
|
223
|
+
formatted = JSON.pretty_generate(value)
|
224
|
+
formatted.split("\n").each do |line|
|
225
|
+
details << "#{indent} #{line}"
|
226
|
+
end
|
227
|
+
else
|
228
|
+
details << "#{indent} #{value.inspect}"
|
229
|
+
end
|
230
|
+
|
231
|
+
details
|
232
|
+
end
|
233
|
+
end
|
234
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
require_relative '../schema_files'
|
2
|
+
require_relative 'migrate_breaking_change'
|
3
|
+
require_relative 'migrate_non_breaking_change'
|
4
|
+
require_relative 'migrate_new'
|
5
|
+
require_relative '../diff'
|
6
|
+
require_relative '../settings_diff'
|
7
|
+
require_relative '../api_aware_mappings_diff'
|
8
|
+
require 'json'
|
9
|
+
|
10
|
+
module SchemaTools
|
11
|
+
def self.migrate_all(client:)
|
12
|
+
puts "Discovering all schemas and migrating each to their latest revisions..."
|
13
|
+
|
14
|
+
schemas = SchemaFiles.discover_all_schemas
|
15
|
+
|
16
|
+
if schemas.empty?
|
17
|
+
puts "No schemas found in #{Config.schemas_path}"
|
18
|
+
return
|
19
|
+
end
|
20
|
+
|
21
|
+
puts "Found #{schemas.length} schema(s) to migrate:"
|
22
|
+
schemas.each do |schema|
|
23
|
+
puts " - #{schema}"
|
24
|
+
end
|
25
|
+
puts
|
26
|
+
|
27
|
+
schemas.each do |alias_name|
|
28
|
+
begin
|
29
|
+
migrate_one_schema(alias_name: alias_name, client: client)
|
30
|
+
rescue => e
|
31
|
+
puts "✗ Migration failed for #{alias_name}: #{e.message}"
|
32
|
+
raise e
|
33
|
+
end
|
34
|
+
puts
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.migrate_one_schema(alias_name:, client:)
|
39
|
+
puts "=" * 60
|
40
|
+
puts "Migrating alias #{alias_name}"
|
41
|
+
puts "=" * 60
|
42
|
+
|
43
|
+
schema_path = File.join(Config.schemas_path, alias_name)
|
44
|
+
unless Dir.exist?(schema_path)
|
45
|
+
raise "Schema folder not found: #{schema_path}"
|
46
|
+
end
|
47
|
+
|
48
|
+
# Check if it's an index name (not an alias)
|
49
|
+
if !client.alias_exists?(alias_name) && client.index_exists?(alias_name)
|
50
|
+
puts "ERROR: Migration not run for index \"#{alias_name}\""
|
51
|
+
puts " To prevent downtime, this tool only migrates aliased indexes."
|
52
|
+
puts ""
|
53
|
+
puts " Create a new alias for your index by running:"
|
54
|
+
puts " rake schema:alias"
|
55
|
+
puts ""
|
56
|
+
puts " Then rename the schema folder to the alias name and re-run:"
|
57
|
+
puts " rake schema:migrate"
|
58
|
+
puts ""
|
59
|
+
puts " Then change your application to read and write to the alias name instead of the index name `#{alias_name}`."
|
60
|
+
raise "Migration not run for alias #{alias_name} because #{alias_name} is an index, not an alias"
|
61
|
+
end
|
62
|
+
|
63
|
+
unless client.alias_exists?(alias_name)
|
64
|
+
puts "Alias '#{alias_name}' not found. Creating new index and alias..."
|
65
|
+
migrate_to_new_alias(alias_name, client)
|
66
|
+
return
|
67
|
+
end
|
68
|
+
|
69
|
+
indices = client.get_alias_indices(alias_name)
|
70
|
+
if indices.length > 1
|
71
|
+
puts "This tool can only migrate aliases that point at one index."
|
72
|
+
raise "Alias '#{alias_name}' points to multiple indices: #{indices.join(', ')}"
|
73
|
+
end
|
74
|
+
|
75
|
+
if indices.length == 0
|
76
|
+
raise "Alias '#{alias_name}' points to no indices."
|
77
|
+
end
|
78
|
+
|
79
|
+
index_name = indices.first
|
80
|
+
puts "Alias '#{alias_name}' points to index '#{index_name}'"
|
81
|
+
begin
|
82
|
+
attempt_non_breaking_migration(alias_name:, index_name:, client:)
|
83
|
+
rescue => e
|
84
|
+
puts "✗ Failed to update index '#{index_name}': #{e.message}"
|
85
|
+
puts "This appears to be a breaking change. Starting breaking change migration..."
|
86
|
+
|
87
|
+
MigrateBreakingChange.migrate(alias_name:, client:)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|