1 | # frozen_string_literal: true |
||
2 | |||
3 | 1 | require 'erb' |
|
4 | 1 | require 'formatador' |
|
5 | 1 | require 'parallel' |
|
6 | 1 | require 'thor' |
|
7 | 1 | require 'yaml' |
|
8 | |||
9 | 1 | require 'nose' |
|
10 | 1 | require_relative 'nose_cli/measurements' |
|
11 | |||
12 | 1 | module NoSE |
|
13 | # CLI tools for running the advisor |
||
14 | 1 | module CLI |
|
15 | # A command-line interface to running the advisor tool |
||
16 | 1 | class NoSECLI < Thor |
|
0 ignored issues
–
show
Coding Style
introduced
by
![]() |
|||
17 | # The path to the configuration file in the working directory |
||
18 | 1 | CONFIG_FILE_NAME = 'nose.yml' |
|
19 | |||
20 | 1 | check_unknown_options! |
|
21 | |||
22 | 1 | class_option :debug, type: :boolean, aliases: '-d', |
|
23 | desc: 'enable detailed debugging information' |
||
24 | 1 | class_option :parallel, type: :boolean, default: false, |
|
25 | desc: 'run various operations in parallel' |
||
26 | 1 | class_option :colour, type: :boolean, default: nil, aliases: '-c', |
|
27 | desc: 'enabled coloured output' |
||
28 | 1 | class_option :interactive, type: :boolean, default: true, |
|
29 | desc: 'allow actions which require user input' |
||
30 | |||
31 | 1 | def initialize(_options, local_options, config) |
|
32 | super |
||
33 | |||
34 | # Set up a logger for this command |
||
35 | cmd_name = config[:current_command].name |
||
36 | @logger = Logging.logger["nose::#{cmd_name}"] |
||
37 | |||
38 | # Peek ahead into the options and prompt the user to create a config |
||
39 | check_config_file interactive?(local_options) |
||
40 | |||
41 | force_colour(options[:colour]) unless options[:colour].nil? |
||
42 | |||
43 | # Disable parallel processing if desired |
||
44 | Parallel.instance_variable_set(:@processor_count, 0) \ |
||
45 | unless options[:parallel] |
||
46 | end |
||
47 | |||
48 | 1 | private |
|
49 | |||
50 | # Check if the user has disabled interaction |
||
51 | # @return [Boolean] |
||
52 | 1 | def interactive?(options = []) |
|
53 | parse_options = self.class.class_options |
||
54 | opts = Thor::Options.new(parse_options).parse(options) |
||
55 | opts[:interactive] |
||
56 | end |
||
57 | |||
58 | # Check if the user has created a configuration file |
||
59 | # @return [void] |
||
60 | 1 | def check_config_file(interactive) |
|
61 | return if File.file?(CONFIG_FILE_NAME) |
||
62 | |||
63 | if interactive |
||
64 | no_create = no? 'nose.yml is missing, ' \ |
||
65 | 'create from nose.yml.example? [Yn]' |
||
66 | example_cfg = File.join Gem.loaded_specs['nose-cli'].full_gem_path, |
||
67 | 'data', 'nose-cli', 'nose.yml.example' |
||
68 | FileUtils.cp example_cfg, CONFIG_FILE_NAME unless no_create |
||
69 | else |
||
70 | @logger.warn 'Configuration file missing' |
||
71 | end |
||
72 | end |
||
73 | |||
74 | # Add the possibility to set defaults via configuration |
||
75 | # @return [Thor::CoreExt::HashWithIndifferentAccess] |
||
76 | 1 | def options |
|
77 | 91 | original_options = super |
|
78 | 91 | return original_options unless File.exist? CONFIG_FILE_NAME |
|
0 ignored issues
–
show
|
|||
79 | 89 | defaults = YAML.load_file(CONFIG_FILE_NAME).deep_symbolize_keys || {} |
|
80 | 89 | Thor::CoreExt::HashWithIndifferentAccess \ |
|
81 | .new(defaults.merge(original_options)) |
||
82 | end |
||
83 | |||
84 | # Get a backend instance for a given configuration and dataset |
||
85 | # @return [Backend::Backend] |
||
86 | 1 | def get_backend(config, result) |
|
87 | be_class = get_class 'backend', config |
||
88 | be_class.new result.workload.model, result.indexes, |
||
89 | result.plans, result.update_plans, config[:backend] |
||
90 | end |
||
91 | |||
92 | # Get a class of a particular name from the configuration |
||
93 | # @return [Object] |
||
94 | 1 | def get_class(class_name, config) |
|
95 | name = config |
||
96 | name = config[class_name.to_sym][:name] if config.is_a? Hash |
||
97 | require "nose/#{class_name}/#{name}" |
||
98 | name = name.split('_').map(&:capitalize).join |
||
99 | full_class_name = ['NoSE', class_name.capitalize, |
||
100 | name + class_name.capitalize] |
||
101 | full_class_name.reduce(Object) do |mod, name_part| |
||
102 | mod.const_get name_part |
||
103 | end |
||
104 | end |
||
105 | |||
106 | # Get a class given a set of options |
||
107 | # @return [Object] |
||
108 | 1 | def get_class_from_config(options, name, type) |
|
109 | 7 | object_class = get_class name, options[type][:name] |
|
110 | 7 | object_class.new(**options[type]) |
|
111 | end |
||
112 | |||
113 | # Collect all advisor results for schema design problem |
||
114 | # @return [Search::Results] |
||
115 | 1 | def search_result(workload, cost_model, max_space = Float::INFINITY, |
|
116 | objective = Search::Objective::COST, |
||
117 | by_id_graph = false) |
||
0 ignored issues
–
show
|
|||
118 | 7 | enumerated_indexes = IndexEnumerator.new(workload) \ |
|
119 | .indexes_for_workload.to_a |
||
120 | 7 | Search::Search.new(workload, cost_model, objective, by_id_graph) \ |
|
121 | .search_overlap enumerated_indexes, max_space |
||
122 | end |
||
123 | |||
124 | # Load results of a previous search operation |
||
125 | # @return [Search::Results] |
||
126 | 1 | def load_results(plan_file, mix = 'default') |
|
127 | 4 | representer = Serialize::SearchResultRepresenter.represent \ |
|
128 | Search::Results.new |
||
129 | 4 | file = File.read(plan_file) |
|
130 | |||
131 | 4 | case File.extname(plan_file) |
|
132 | when '.json' |
||
133 | 4 | result = representer.from_json(file) |
|
134 | when '.rb' |
||
135 | result = Search::Results.new |
||
136 | workload = binding.eval file, plan_file |
||
0 ignored issues
–
show
|
|||
137 | result.instance_variable_set :@workload, workload |
||
138 | end |
||
139 | |||
140 | result.workload.mix = mix.to_sym unless \ |
||
141 | 4 | mix.nil? || (mix == 'default' && result.workload.mix != :default) |
|
142 | |||
143 | 4 | result |
|
144 | end |
||
145 | |||
146 | # Load plans either from an explicit file or the name |
||
147 | # of something in the plans/ directory |
||
148 | 1 | def load_plans(plan_file, options) |
|
149 | 11 | if File.exist? plan_file |
|
150 | 1 | result = load_results(plan_file, options[:mix]) |
|
151 | else |
||
152 | 10 | schema = Schema.load plan_file |
|
153 | 10 | result = OpenStruct.new |
|
154 | 10 | result.workload = Workload.new schema.model |
|
155 | 10 | result.indexes = schema.indexes.values |
|
156 | end |
||
157 | 11 | backend = get_backend(options, result) |
|
158 | |||
159 | 11 | [result, backend] |
|
160 | end |
||
161 | |||
162 | # Output a list of indexes as text |
||
163 | # @return [void] |
||
164 | 1 | def output_indexes_txt(header, indexes, file) |
|
165 | file.puts Formatador.parse("[blue]#{header}[/]") |
||
166 | indexes.sort_by(&:key).each { |index| file.puts index.inspect } |
||
167 | file.puts |
||
168 | end |
||
169 | |||
170 | # Output a list of query plans as text |
||
171 | # @return [void] |
||
172 | 1 | def output_plans_txt(plans, file, indent, weights) |
|
0 ignored issues
–
show
|
|||
173 | plans.each do |plan| |
||
174 | weight = (plan.weight || weights[plan.query || plan.name]) |
||
175 | next if weight.nil? |
||
0 ignored issues
–
show
|
|||
176 | cost = plan.cost * weight |
||
177 | |||
178 | file.puts "GROUP #{plan.group}" unless plan.group.nil? |
||
179 | |||
180 | weight = " * #{weight} = #{cost}" |
||
181 | file.puts ' ' * (indent - 1) + plan.query.label \ |
||
182 | unless plan.query.nil? || plan.query.label.nil? |
||
183 | file.puts ' ' * (indent - 1) + plan.query.inspect + weight |
||
184 | plan.each { |step| file.puts ' ' * indent + step.inspect } |
||
185 | file.puts |
||
186 | end |
||
187 | end |
||
188 | |||
189 | # Output update plans as text |
||
190 | # @return [void] |
||
191 | 1 | def output_update_plans_txt(update_plans, file, weights, mix = nil) |
|
0 ignored issues
–
show
|
|||
192 | unless update_plans.empty? |
||
193 | header = "Update plans\n" + '━' * 50 |
||
0 ignored issues
–
show
|
|||
194 | file.puts Formatador.parse("[blue]#{header}[/]") |
||
195 | end |
||
196 | |||
197 | update_plans.group_by(&:statement).each do |statement, plans| |
||
198 | weight = if weights.key?(statement) |
||
199 | weights[statement] |
||
200 | elsif weights.key?(statement.group) |
||
201 | weights[statement.group] |
||
202 | else |
||
203 | weights[statement.group][mix] |
||
204 | end |
||
205 | next if weight.nil? |
||
206 | |||
207 | total_cost = plans.sum_by(&:cost) |
||
208 | |||
209 | file.puts "GROUP #{statement.group}" unless statement.group.nil? |
||
210 | |||
211 | file.puts statement.label unless statement.label.nil? |
||
212 | file.puts "#{statement.inspect} * #{weight} = #{total_cost * weight}" |
||
213 | plans.each do |plan| |
||
214 | file.puts Formatador.parse(" for [magenta]#{plan.index.key}[/] " \ |
||
215 | "[yellow]$#{plan.cost}[/]") |
||
216 | query_weights = Hash[plan.query_plans.map do |query_plan| |
||
217 | [query_plan.query, weight] |
||
218 | end] |
||
219 | output_plans_txt plan.query_plans, file, 2, query_weights |
||
220 | |||
221 | plan.update_steps.each do |step| |
||
222 | file.puts ' ' + step.inspect |
||
0 ignored issues
–
show
|
|||
223 | end |
||
224 | |||
225 | file.puts |
||
226 | end |
||
227 | |||
228 | file.puts "\n" |
||
229 | end |
||
230 | end |
||
231 | |||
232 | # Output the results of advising as text |
||
233 | # @return [void] |
||
234 | 1 | def output_txt(result, file = $stdout, enumerated = false, |
|
0 ignored issues
–
show
|
|||
235 | _backend = nil) |
||
236 | if enumerated |
||
237 | header = "Enumerated indexes\n" + '━' * 50 |
||
0 ignored issues
–
show
|
|||
238 | output_indexes_txt header, result.enumerated_indexes, file |
||
239 | end |
||
240 | |||
241 | # Output selected indexes |
||
242 | header = "Indexes\n" + '━' * 50 |
||
0 ignored issues
–
show
|
|||
243 | output_indexes_txt header, result.indexes, file |
||
244 | |||
245 | file.puts Formatador.parse(' Total size: ' \ |
||
246 | "[blue]#{result.total_size}[/]\n\n") |
||
247 | |||
248 | # Output query plans for the discovered indices |
||
249 | header = "Query plans\n" + '━' * 50 |
||
0 ignored issues
–
show
|
|||
250 | file.puts Formatador.parse("[blue]#{header}[/]") |
||
251 | weights = result.workload.statement_weights |
||
252 | weights = result.weights if weights.nil? || weights.empty? |
||
253 | output_plans_txt result.plans, file, 1, weights |
||
254 | |||
255 | result.update_plans = [] if result.update_plans.nil? |
||
256 | output_update_plans_txt result.update_plans, file, weights, |
||
257 | result.workload.mix |
||
258 | |||
259 | file.puts Formatador.parse(' Total cost: ' \ |
||
260 | "[blue]#{result.total_cost}[/]\n") |
||
261 | end |
||
262 | |||
263 | # Output an HTML file with a description of the search results |
||
264 | # @return [void] |
||
265 | 1 | def output_html(result, file = $stdout, enumerated = false, |
|
0 ignored issues
–
show
|
|||
266 | backend = nil) |
||
267 | # Get an SVG diagram of the model |
||
268 | tmpfile = Tempfile.new %w(model svg) |
||
0 ignored issues
–
show
|
|||
269 | result.workload.model.output :svg, tmpfile.path, true |
||
270 | svg = File.open(tmpfile.path).read |
||
271 | |||
272 | enumerated &&= result.enumerated_indexes |
||
273 | tmpl = File.read File.join(File.dirname(__FILE__), |
||
274 | '../templates/report.erb') |
||
275 | ns = OpenStruct.new svg: svg, |
||
276 | backend: backend, |
||
277 | indexes: result.indexes, |
||
278 | enumerated_indexes: enumerated, |
||
279 | workload: result.workload, |
||
280 | update_plans: result.update_plans, |
||
281 | plans: result.plans, |
||
282 | total_size: result.total_size, |
||
283 | total_cost: result.total_cost |
||
284 | |||
285 | force_colour |
||
286 | file.write ERB.new(tmpl, nil, '>').result(ns.instance_eval { binding }) |
||
0 ignored issues
–
show
|
|||
287 | end |
||
288 | |||
289 | # Output the results of advising as JSON |
||
290 | # @return [void] |
||
291 | 1 | def output_json(result, file = $stdout, enumerated = false, |
|
0 ignored issues
–
show
|
|||
292 | _backend = nil) |
||
293 | # Temporarily remove the enumerated indexes |
||
294 | 8 | if enumerated |
|
295 | 1 | enumerated = result.enumerated_indexes |
|
296 | 1 | result.delete_field :enumerated_indexes |
|
297 | end |
||
298 | |||
299 | 8 | file.puts JSON.pretty_generate \ |
|
300 | Serialize::SearchResultRepresenter.represent(result).to_hash |
||
301 | |||
302 | 8 | result.enumerated_indexes = enumerated if enumerated |
|
303 | end |
||
304 | |||
305 | # Output the results of advising as YAML |
||
306 | # @return [void] |
||
307 | 1 | def output_yml(result, file = $stdout, enumerated = false, |
|
0 ignored issues
–
show
|
|||
308 | _backend = nil) |
||
309 | # Temporarily remove the enumerated indexes |
||
310 | 1 | if enumerated |
|
311 | enumerated = result.enumerated_indexes |
||
312 | result.delete_field :enumerated_indexes |
||
313 | end |
||
314 | |||
315 | 1 | file.puts Serialize::SearchResultRepresenter.represent(result).to_yaml |
|
316 | |||
317 | 1 | result.enumerated_indexes = enumerated if enumerated |
|
318 | end |
||
319 | |||
320 | # Filter an options hash for those only relevant to a given command |
||
321 | # @return [Thor::CoreExt::HashWithIndifferentAccess] |
||
322 | 1 | def filter_command_options(opts, command) |
|
323 | Thor::CoreExt::HashWithIndifferentAccess.new(opts.select do |key| |
||
324 | self.class.commands[command].options \ |
||
325 | .each_key.map(&:to_sym).include? key.to_sym |
||
326 | end) |
||
327 | end |
||
328 | |||
329 | # Enable forcing the colour or no colour for output |
||
330 | # We just lie to Formatador about whether or not $stdout is a tty |
||
331 | # @return [void] |
||
332 | 1 | def force_colour(colour = true) |
|
0 ignored issues
–
show
|
|||
333 | stdout_metaclass = class << $stdout; self; end |
||
334 | method = colour ? ->() { true } : ->() { false } |
||
0 ignored issues
–
show
|
|||
335 | stdout_metaclass.send(:define_method, :tty?, &method) |
||
336 | end |
||
337 | end |
||
338 | end |
||
339 | end |
||
340 | |||
341 | 1 | require_relative 'nose_cli/shared_options' |
|
342 | |||
343 | # Require the various subcommands |
||
344 | 1 | require_relative 'nose_cli/analyze' |
|
345 | 1 | require_relative 'nose_cli/benchmark' |
|
346 | 1 | require_relative 'nose_cli/collect_results' |
|
347 | 1 | require_relative 'nose_cli/create' |
|
348 | 1 | require_relative 'nose_cli/diff_plans' |
|
349 | 1 | require_relative 'nose_cli/dump' |
|
350 | 1 | require_relative 'nose_cli/export' |
|
351 | 1 | require_relative 'nose_cli/execute' |
|
352 | 1 | require_relative 'nose_cli/list' |
|
353 | 1 | require_relative 'nose_cli/load' |
|
354 | 1 | require_relative 'nose_cli/genworkload' |
|
355 | 1 | require_relative 'nose_cli/graph' |
|
356 | 1 | require_relative 'nose_cli/plan_schema' |
|
357 | 1 | require_relative 'nose_cli/proxy' |
|
358 | 1 | require_relative 'nose_cli/random_plans' |
|
359 | 1 | require_relative 'nose_cli/reformat' |
|
360 | 1 | require_relative 'nose_cli/repl' |
|
361 | 1 | require_relative 'nose_cli/recost' |
|
362 | 1 | require_relative 'nose_cli/search' |
|
363 | 1 | require_relative 'nose_cli/search_all' |
|
364 | 1 | require_relative 'nose_cli/search_bench' |
|
365 | 1 | require_relative 'nose_cli/texify' |
|
366 | 1 | require_relative 'nose_cli/why' |
|
367 | |||
368 | # Only include the console command if pry is available |
||
369 | begin |
||
370 | 1 | require 'pry' |
|
371 | 1 | require_relative 'nose_cli/console' |
|
372 | rescue LoadError |
||
373 | nil |
||
374 | end |
||
375 |