Completed
Push — master ( f86927...27dfdc )
by Michael
03:07
created

BackendBase.find_query_plan()   A

Complexity

Conditions 3

Size

Total Lines 8

Duplication

Lines 8
Ratio 100 %

Code Coverage

Tests 1
CRAP Score 7.608

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
c 1
b 0
f 0
dl 8
loc 8
ccs 1
cts 5
cp 0.2
crap 7.608
rs 9.4285
1
# frozen_string_literal: true
2
3 1
module NoSE
4
  # Communication with backends for index creation and statement execution
5 1
  module Backend
6
    # Superclass of all database backends
7 1
    class BackendBase
8 1
      def initialize(model, indexes, plans, update_plans, _config)
9 17
        @model = model
10 17
        @indexes = indexes
11 17
        @plans = plans
12 17
        @update_plans = update_plans
13
      end
14
15
      # @abstract Subclasses implement to check if an index is empty
16
      # @return [Boolean]
17 1
      def index_empty?(_index)
18
        true
19
      end
20
21
      # @abstract Subclasses implement to check if an index already exists
22
      # @return [Boolean]
23 1
      def index_exists?(_index)
24
        false
25
      end
26
27
      # @abstract Subclasses implement to remove existing indexes
28
      # @return [void]
29 1
      def drop_index
30
      end
31
32
      # @abstract Subclasses implement to allow inserting
33
      #           data into the backend database
34
      # :nocov:
35
      # @return [void]
36 1
      def index_insert_chunk(_index, _chunk)
37
        fail NotImplementedError
38
      end
39
      # :nocov:
40
41
      # @abstract Subclasses implement to generate a new random ID
42
      # :nocov:
43
      # @return [Object]
44 1
      def generate_id
45
        fail NotImplementedError
46
      end
47
      # :nocov:
48
49
      # @abstract Subclasses should create indexes
50
      # :nocov:
51
      # @return [Enumerable]
52 1
      def indexes_ddl(_execute = false, _skip_existing = false,
53
                      _drop_existing = false)
54
        fail NotImplementedError
55
      end
56
      # :nocov:
57
58
      # @abstract Subclasses should return sample values from the index
59
      # :nocov:
60
      # @return [Array<Hash>]
61 1
      def indexes_sample(_index, _count)
62
        fail NotImplementedError
63
      end
64
      # :nocov:
65
66
      # Prepare a query to be executed with the given plans
67
      # @return [PreparedQuery]
68 1
      def prepare_query(query, fields, conditions, plans = [])
69 9
        plan = plans.empty? ? find_query_plan(query) : plans.first
70
71 9
        state = Plans::QueryState.new(query, @model) unless query.nil?
72 9
        first_step = Plans::RootPlanStep.new state
73 9
        steps = [first_step] + plan.to_a + [nil]
74 9
        PreparedQuery.new query, prepare_query_steps(steps, fields, conditions)
75
      end
76
77
      # Prepare a statement to be executed with the given plans
78 1
      def prepare(statement, plans = [])
79 7
        if statement.is_a? Query
80 1
          prepare_query statement, statement.all_fields,
81
                        statement.conditions, plans
82 6
        elsif statement.is_a? Delete
83 3
          prepare_update statement, [], plans
84 3
        elsif statement.is_a? Disconnect
85 1
          prepare_update statement, statement.conditions, plans
86 2
        elsif statement.is_a? Connection
87 1
          settings = statement.entity.fields.values.map do |field|
88 4
            FieldSetting.new field, nil
89
          end
90 1
          prepare_update statement, settings, plans
91
        else
92 1
          prepare_update statement, statement.settings, plans
93
        end
94
      end
95
96
      # Execute a query with the stored plans
97
      # @return [Array<Hash>]
98 1
      def query(query, plans = [])
99
        prepared = prepare query, plans
100
        prepared.execute query.conditions
101
      end
102
103
      # Prepare an update for execution
104
      # @return [PreparedUpdate]
105 1
      def prepare_update(update, update_settings, plans)
106
        # Search for plans if they were not given
107 6
        plans = find_update_plans(update) if plans.empty?
108 6
        fail PlanNotFound if plans.empty?
109
110
        # Prepare each plan
111 6
        plans.map do |plan|
112 6
          delete = false
113 6
          insert = false
114 6
          plan.update_steps.each do |step|
115 6
            delete = true if step.is_a?(Plans::DeletePlanStep)
116 6
            insert = true if step.is_a?(Plans::InsertPlanStep)
117
          end
118
119 6
          steps = []
120 6
          add_delete_step(plan, steps) if delete
121 6
          add_insert_step(plan, steps, update_settings) if insert
122
123 6
          PreparedUpdate.new update, prepare_support_plans(plan), steps
124
        end
125
      end
126
127
      # Execute an update with the stored plans
128
      # @return [void]
129 1
      def update(update, plans = [])
130
        prepared = prepare_update update, update.settings, plans
131
        prepared.each { |p| p.execute update.settings, update.conditions }
132
      end
133
134
      # Superclass for all statement execution steps
135 1
      class StatementStep
136 1
        include Supertype
137 1
        attr_reader :index
138
      end
139
140
      # Look up data on an index in the backend
141 1
      class IndexLookupStatementStep < StatementStep
142 1
        def initialize(client, _select, _conditions,
0 ignored issues
show
Coding Style introduced by
Your code style disallows parameter lists longer than 5 parameters. Try using a configuration object instead.
Loading history...
143
                       step, next_step, prev_step)
144 11
          @client = client
145 11
          @step = step
146 11
          @index = step.index
147 11
          @prev_step = prev_step
148 11
          @next_step = next_step
149
150 11
          @eq_fields = step.eq_filter
151 11
          @range_field = step.range_filter
152
        end
153
154 1
        protected
155
156
        # Get lookup values from the query for the first step
157 1
        def initial_results(conditions)
158
          [Hash[conditions.map do |field_id, condition|
159 13
            fail if condition.value.nil?
160 13
            [field_id, condition.value]
161 13
          end]]
162
        end
163
164
        # Construct a list of conditions from the results
165 1
        def result_conditions(conditions, results)
166 13
          results.map do |result|
167 13
            result_condition = @eq_fields.map do |field|
168 13
              Condition.new field, :'=', result[field.id]
169
            end
170
171 13
            unless @range_field.nil?
172
              operator = conditions.values.find(&:range?).operator
173
              result_condition << Condition.new(@range_field, operator,
174
                                                result[@range_field.id])
175
            end
176
177 13
            result_condition
178
          end
179
        end
180
181
        # Decide which fields should be selected
182 1
        def expand_selected_fields(select)
183
          # We just pick whatever is contained in the index that is either
184
          # mentioned in the query or required for the next lookup
185
          # TODO: Potentially try query.all_fields for those not required
186
          #       It should be sufficient to check what is needed for future
187
          #       filtering and sorting and use only those + query.select
188
          select += @next_step.index.hash_fields \
189
            unless @next_step.nil? ||
190 1
                   !@next_step.is_a?(Plans::IndexLookupPlanStep)
191 1
          select &= @step.index.all_fields
192
193 1
          select
194
        end
195
      end
196
197
      # Insert data into an index on the backend
198 1
      class InsertStatementStep < StatementStep
199 1
        def initialize(client, index, _fields)
200 4
          @client = client
201 4
          @index = index
202
        end
203
      end
204
205
      # Delete data from an index on the backend
206 1
      class DeleteStatementStep < StatementStep
207 1
        def initialize(client, index)
208 5
          @client = client
209 5
          @index = index
210
        end
211
      end
212
213
      # Perform filtering external to the backend
214 1
      class FilterStatementStep < StatementStep
215 1
        def initialize(_client, _fields, _conditions,
0 ignored issues
show
Coding Style introduced by
Your code style disallows parameter lists longer than 5 parameters. Try using a configuration object instead.
Loading history...
216
                       step, _next_step, _prev_step)
217 2
          @step = step
218
        end
219
220
        # Filter results by a list of fields given in the step
221
        # @return [Array<Hash>]
222 1
        def process(conditions, results)
223
          # Extract the equality conditions
224 2
          eq_conditions = conditions.values.select do |condition|
225 3
            !condition.range? && @step.eq.include?(condition.field)
226
          end
227
228
          # XXX: This assumes that the range filter step is the same as
229
          #      the one in the query, which is always true for now
230 2
          range = @step.range && conditions.values.find(&:range?)
231
232 6
          results.select! { |row| include_row?(row, eq_conditions, range) }
233
234 2
          results
235
        end
236
237 1
        private
238
239
        # Check if the row should be included in the result
240
        # @return [Boolean]
241 1
        def include_row?(row, eq_conditions, range)
242 4
          select = eq_conditions.all? do |condition|
243 2
            row[condition.field.id] == condition.value
244
          end
245
246 4
          if range
247 2
            range_check = row[range.field.id].method(range.operator)
248 2
            select &&= range_check.call range.value
249
          end
250
251 4
          select
252
        end
253
      end
254
255
      # Perform sorting external to the backend
256 1
      class SortStatementStep < StatementStep
257 1
        def initialize(_client, _fields, _conditions,
0 ignored issues
show
Coding Style introduced by
Your code style disallows parameter lists longer than 5 parameters. Try using a configuration object instead.
Loading history...
258
                       step, _next_step, _prev_step)
259 1
          @step = step
260
        end
261
262
        # Sort results by a list of fields given in the step
263
        # @return [Array<Hash>]
264 1
        def process(_conditions, results)
265 1
          results.sort_by! do |row|
266 2
            @step.sort_fields.map do |field|
267 2
              row[field.id]
268
            end
269
          end
270
        end
271
      end
272
273
      # Perform a client-side limit of the result set size
274 1
      class LimitStatementStep < StatementStep
275 1
        def initialize(_client, _fields, _conditions,
0 ignored issues
show
Coding Style introduced by
Your code style disallows parameter lists longer than 5 parameters. Try using a configuration object instead.
Loading history...
276
                       step, _next_step, _prev_step)
277 1
          @limit = step.limit
278
        end
279
280
        # Remove results past the limit
281
        # @return [Array<Hash>]
282 1
        def process(_conditions, results)
283 1
          results[0..@limit - 1]
284
        end
285
      end
286
287 1
      private
288
289
      # Find plans for a given query
290
      # @return [Plans::QueryPlan]
291 1 View Code Duplication
      def find_query_plan(query)
1 ignored issue
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
292
        plan = @plans.find do |possible_plan|
293
          possible_plan.query == query
294
        end unless query.nil?
295
        fail PlanNotFound if plan.nil?
296
297
        plan
298
      end
299
300
      # Prepare all the steps for executing a given query
301
      # @return [Array<StatementStep>]
302 1
      def prepare_query_steps(steps, fields, conditions)
303 9
        steps.each_cons(3).map do |prev_step, step, next_step|
304 9
          step_class = StatementStep.subtype_class step.subtype_name
305
306
          # Check if the subclass has overridden this step
307 9
          subclass_step_name = step_class.name.sub \
308
            'NoSE::Backend::BackendBase', self.class.name
309 9
          step_class = Object.const_get subclass_step_name
310 9
          step_class.new client, fields, conditions,
311
                         step, next_step, prev_step
312
        end
313
      end
314
315
      # Find plans for a given update
316
      # @return [Array<Plans::UpdatePlan>]
317 1
      def find_update_plans(update)
318
        @update_plans.select do |possible_plan|
319
          possible_plan.statement == update
320
        end
321 View Code Duplication
      end
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
322
323
      # Add a delete step to a prepared update plan
324
      # @return [void]
325 1
      def add_delete_step(plan, steps)
326 4
        step_class = DeleteStatementStep
327 4
        subclass_step_name = step_class.name.sub \
328
          'NoSE::Backend::BackendBase', self.class.name
329 4
        step_class = Object.const_get subclass_step_name
330 4
        steps << step_class.new(client, plan.index)
331 View Code Duplication
      end
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
332
333
      # Add an insert step to a prepared update plan
334
      # @return [void]
335 1
      def add_insert_step(plan, steps, update_settings)
336 2
        step_class = InsertStatementStep
337 2
        subclass_step_name = step_class.name.sub \
338
          'NoSE::Backend::BackendBase', self.class.name
339 2
        step_class = Object.const_get subclass_step_name
340
        steps << step_class.new(client, plan.index,
341 2
                                update_settings.map(&:field))
342
      end
343
344
      # Prepare plans for each support query
345
      # @return [Array<PreparedQuery>]
346 1
      def prepare_support_plans(plan)
347 6
        plan.query_plans.map do |query_plan|
348 8
          query = query_plan.instance_variable_get(:@query)
349 8
          prepare_query query, query_plan.select_fields, query_plan.params,
350
                        [query_plan.steps]
351
        end
352
      end
353
    end
354
355
    # A prepared query which can be executed against the backend
356 1
    class PreparedQuery
357 1
      attr_reader :query, :steps
358
359 1
      def initialize(query, steps)
360 9
        @query = query
361 9
        @steps = steps
362
      end
363
364
      # Execute the query for the given set of conditions
365
      # @return [Array<Hash>]
366 1
      def execute(conditions)
367 11
        results = nil
368
369 11
        @steps.each do |step|
370 11
          if step.is_a?(BackendBase::IndexLookupStatementStep)
371 11
            field_ids = step.index.all_fields.map(&:id)
372 24
            field_conds = conditions.select { |key| field_ids.include? key }
373
          else
374
            field_conds = conditions
375
          end
376 11
          results = step.process field_conds, results
377
378
          # The query can't return any results at this point, so we're done
379 11
          break if results.empty?
380
        end
381
382
        # Only return fields selected by the query if one is given
383
        # (we have no query to refer to for manually-defined plans)
384 11
        unless @query.nil?
385 11
          select_ids = @query.select.map(&:id).to_set
386 40
          results.map { |row| row.select! { |k, _| select_ids.include? k } }
387
        end
388
389 11
        results
390
      end
391
    end
392
393
    # An update prepared with a backend which is ready to execute
394 1
    class PreparedUpdate
395 1
      attr_reader :statement, :steps
396
397 1
      def initialize(statement, support_plans, steps)
398 6
        @statement = statement
399 6
        @support_plans = support_plans
400 6
        @delete_step = steps.find do |step|
401 6
          step.is_a? BackendBase::DeleteStatementStep
402
        end
403 6
        @insert_step = steps.find do |step|
404 6
          step.is_a? BackendBase::InsertStatementStep
405
        end
406
      end
407
408
      # Execute the statement for the given set of conditions
409
      # @return [void]
410 1
      def execute(update_settings, update_conditions)
0 ignored issues
show
Coding Style introduced by
The Assignment, Branch, Condition size for execute is considered too high. [25.5/20]. The ABC size is based on assignments, branches (method calls), and conditions.
Loading history...
Coding Style introduced by
This method is 23 lines long. Your coding style permits a maximum length of 20.
Loading history...
411
        # Execute all the support queries
412 6
        settings = initial_update_settings update_settings, update_conditions
413
414
        # Execute the support queries for this update
415 6
        support = support_results update_conditions
416
417
        # Perform the deletion
418 6
        @delete_step.process support unless support.empty? || @delete_step.nil?
419 6
        return if @insert_step.nil?
420
421
        # Get the fields which should be used from the original statement
422
        # If we deleted old entries, then we just need the primary key
423
        # attributes of the index, otherwise we need everything
424 2
        index = @insert_step.index
425 2
        include_fields = if @delete_step.nil?
426 2
                           index.hash_fields + index.order_fields
427
                         else
428
                           index.all_fields
429
                         end
430
431
        # Add fields from the original statement
432 2
        update_conditions.each_value do |condition|
433 3
          next unless include_fields.include? condition.field
434 3
          settings.merge! condition.field.id => condition.value
435
        end
436
437 2
        if support.empty?
438 1
          support = [settings]
439
        else
440 1
          support.each do |row|
441 3
            row.merge!(settings) { |_, value, _| value }
442
          end
443
        end
444
445
        # Stop if we have nothing to insert, otherwise insert
446 2
        return if support.empty?
447 2
        @insert_step.process support
448
      end
449
450 1
      private
451
452
      # Get the initial values which will be used in the first plan step
453
      # @return [Hash]
454 1
      def initial_update_settings(update_settings, update_conditions)
455 6
        if !@insert_step.nil? && @delete_step.nil?
456
          # Populate the data to insert for Insert statements
457 2
          settings = Hash[update_settings.map do |setting|
458 1
            [setting.field.id, setting.value]
459
          end]
460
        else
461
          # Get values for updates and deletes
462 4
          settings = Hash[update_conditions.map do |field_id, condition|
463 5
            [field_id, condition.value]
464
          end]
465
        end
466
467 6
        settings
468
      end
469
470
      # Execute all the support queries
471
      # @return [Array<Hash>]
472 1
      def support_results(settings)
0 ignored issues
show
Coding Style introduced by
The Assignment, Branch, Condition size for support_results is considered too high. [51.58/20]. The ABC size is based on assignments, branches (method calls), and conditions.
Loading history...
Coding Style introduced by
This method is 43 lines long. Your coding style permits a maximum length of 20.
Loading history...
Complexity Coding Style introduced by
The method support_results seems to be too complex. Perceived complexity is 12 with a maxiumum of 10 permitted.
Loading history...
473
        # Get a hash of values used in settings
474 14
        setting_values = Hash[settings.map { |k, v| [k, v.value] }]
475
476 6
        if @support_plans.empty?
477 1
          support = @support_plans.map do |plan|
478
            plan.execute settings
479
          end
480
481
          # Combine the results from multiple support queries
482 1
          unless support.empty?
483
            support = support.first.product(*support[1..-1])
484
            support.map! do |results|
485
              results.reduce(&:merge!).merge!(setting_values)
486
            end
487
          end
488
        else
489
          # Execute the first support query to get a list of IDs
490 5
          first_query = @support_plans.first.query
491
492
          # We may not have a statement if this is manually defined
493 5
          if @statement.nil?
494
            select_key = false
495
            entity_fields = nil
496
          else
497 5
            id = @statement.entity.id_field
498 5
            select_key = first_query.select.include? id
499
500
            # Select any fields from the entity being modified if required
501
            entity_fields = @support_plans.first.execute settings \
502
              if first_query.graph.size == 1 && \
503 5
                 first_query.graph.entities.first == @statement.entity
504
          end
505
506 5
          if select_key
507
            # Pull the IDs from the first support query
508 1
            conditions = entity_fields.map do |row|
509 1
              { id.id => Condition.new(id, :'=', row[id.id]) }
510
            end
511
          else
512
            # Use the ID specified in the statement conditions
513 4
            conditions = [settings]
514
          end
515
516
          # Execute the support queries for each ID
517 5
          support = conditions.each_with_index.flat_map do |condition, i|
518 5
            results = @support_plans[(select_key ? 1 : 0)..-1].map do |plan|
519 7
              plan.execute condition
520
            end
521
522
            # Combine the results of the different support queries
523 5
            results[0].product(*results[1..-1]).map do |result|
524 4
              row = result.reduce(&:merge!)
525 4
              row.merge!(entity_fields[i]) unless entity_fields.nil?
526 4
              row.merge!(setting_values)
527
528 4
              row
529
            end
530
          end
531
        end
532
533 6
        support
534
      end
535
    end
536
537
    # Raised when a statement is executed that we have no plan for
538 1
    class PlanNotFound < StandardError
539
    end
540
541
    # Raised when a backend attempts to create an index that already exists
542 1
    class IndexAlreadyExists < StandardError
543
    end
544
  end
545
end
546