Issues (116)

lib/nose_cli.rb (26 issues)

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
Class has too many lines. [224/200]
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
Add empty line after guard clause.
Loading history...
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
Prefer keyword arguments for arguments with a boolean default value; use by_id_graph: false instead of by_id_graph = false.
Loading history...
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
The use of eval is a serious security risk.
Loading history...
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
The Assignment, Branch, Condition size for output_plans_txt is considered too high. [<5, 36, 8> 37.22/20]. The ABC size is based on assignments, branches (method calls), and conditions.
Loading history...
173
        plans.each do |plan|
174
          weight = (plan.weight || weights[plan.query || plan.name])
175
          next if weight.nil?
0 ignored issues
show
Add empty line after guard clause.
Loading history...
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
This method is 31 lines long. Your coding style permits a maximum length of 20.
Loading history...
The Assignment, Branch, Condition size for output_update_plans_txt is considered too high. [<9, 48, 12> 50.29/20]. The ABC size is based on assignments, branches (method calls), and conditions.
Loading history...
The method output_update_plans_txt seems to be too complex. Perceived cyclomatic complexity is 12 with a maxiumum of 10 permitted.
Loading history...
Complexity Coding Style introduced by
The method output_update_plans_txt seems to be too complex. Perceived complexity is 13 with a maxiumum of 10 permitted.
Loading history...
192
        unless update_plans.empty?
193
          header = "Update plans\n" + '━' * 50
0 ignored issues
show
Prefer string interpolation to string concatenation.
Loading history...
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
Prefer string interpolation to string concatenation.
Loading history...
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
The Assignment, Branch, Condition size for output_txt is considered too high. [<6, 32, 4> 32.8/20]. The ABC size is based on assignments, branches (method calls), and conditions.
Loading history...
Prefer keyword arguments for arguments with a boolean default value; use enumerated: false instead of enumerated = false.
Loading history...
235
                     _backend = nil)
236
        if enumerated
237
          header = "Enumerated indexes\n" + '━' * 50
0 ignored issues
show
Prefer string interpolation to string concatenation.
Loading history...
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
Prefer string interpolation to string concatenation.
Loading history...
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
Prefer string interpolation to string concatenation.
Loading history...
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
The Assignment, Branch, Condition size for output_html is considered too high. [<6, 25, 1> 25.73/20]. The ABC size is based on assignments, branches (method calls), and conditions.
Loading history...
Prefer keyword arguments for arguments with a boolean default value; use enumerated: false instead of enumerated = false.
Loading history...
266
                      backend = nil)
267
        # Get an SVG diagram of the model
268
        tmpfile = Tempfile.new %w(model svg)
0 ignored issues
show
%w-literals should be delimited by [ and ].
Loading history...
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
Passing safe_level with the 2nd argument of ERB.new is deprecated. Do not use it, and specify other arguments as keyword arguments.
Loading history...
Passing trim_mode with the 3rd argument of ERB.new is deprecated. Use keyword argument like ERB.new(str, trim_mode: '>') instead.
Loading history...
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
Prefer keyword arguments for arguments with a boolean default value; use enumerated: false instead of enumerated = false.
Loading history...
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
Prefer keyword arguments for arguments with a boolean default value; use enumerated: false instead of enumerated = false.
Loading history...
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
Prefer keyword arguments for arguments with a boolean default value; use colour: true instead of colour = true.
Loading history...
333
        stdout_metaclass = class << $stdout; self; end
334
        method = colour ? ->() { true } : ->() { false }
0 ignored issues
show
Omit parentheses for the empty lambda parameters.
Loading history...
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