michaelmior /
nose-cli
| 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
Loading history...
|
|||
| 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 |