Completed
Push — mongo-graph ( a5e079...6fc2a5 )
by Michael
03:53
created

BackendBase.by_id_graph()   A

Complexity

Conditions 1

Size

Total Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

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