actionset 0.7.0 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../attribute_instruction'
4
+ require_relative './enumerable_strategy'
5
+ require_relative './active_record_strategy'
6
+
7
+ class ActiveSet
8
+ module Filtering
9
+ class Operation
10
+ def initialize(set, instructions_hash)
11
+ @set = set
12
+ @instructions_hash = instructions_hash
13
+ end
14
+
15
+ # rubocop:disable Metrics/MethodLength
16
+ def execute
17
+ attribute_instructions = @instructions_hash
18
+ .flatten_keys
19
+ .map { |k, v| AttributeInstruction.new(k, v) }
20
+
21
+ activerecord_filtered_set = attribute_instructions.reduce(@set) do |set, attribute_instruction|
22
+ maybe_set_or_false = ActiveRecordStrategy.new(set, attribute_instruction).execute
23
+ next set unless maybe_set_or_false
24
+
25
+ attribute_instruction.processed = true
26
+ maybe_set_or_false
27
+ end
28
+
29
+ return activerecord_filtered_set if attribute_instructions.all?(&:processed?)
30
+
31
+ attribute_instructions.reject(&:processed?).reduce(activerecord_filtered_set) do |set, attribute_instruction|
32
+ maybe_set_or_false = EnumerableStrategy.new(set, attribute_instruction).execute
33
+ next set unless maybe_set_or_false
34
+
35
+ attribute_instruction.processed = true
36
+ maybe_set_or_false
37
+ end
38
+ end
39
+ # rubocop:enable Metrics/MethodLength
40
+
41
+ def operation_instructions
42
+ @instructions_hash.symbolize_keys
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ActiveSet
4
+ module Paginating
5
+ class ActiveRecordStrategy
6
+ def initialize(set, operation_instructions)
7
+ @set = set
8
+ @operation_instructions = operation_instructions
9
+ end
10
+
11
+ def execute
12
+ return false unless @set.respond_to? :to_sql
13
+ return @set.none if @set.length <= @operation_instructions[:size] &&
14
+ @operation_instructions[:page] > 1
15
+
16
+ @set.limit(@operation_instructions[:size]).offset(page_offset)
17
+ end
18
+
19
+ private
20
+
21
+ def page_offset
22
+ return 0 if @operation_instructions[:page] == 1
23
+
24
+ @operation_instructions[:size] * (@operation_instructions[:page] - 1)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ActiveSet
4
+ module Paginating
5
+ class EnumerableStrategy
6
+ def initialize(set, operation_instructions)
7
+ @set = set
8
+ @operation_instructions = operation_instructions
9
+ end
10
+
11
+ def execute
12
+ return [] if @set.count <= @operation_instructions[:size] &&
13
+ @operation_instructions[:page] > 1
14
+
15
+ @set[page_start..page_end] || []
16
+ end
17
+
18
+ private
19
+
20
+ def page_start
21
+ return 0 if @operation_instructions[:page] == 1
22
+
23
+ @operation_instructions[:size] * (@operation_instructions[:page] - 1)
24
+ end
25
+
26
+ def page_end
27
+ return page_start if @operation_instructions[:size] == 1
28
+
29
+ page_start + @operation_instructions[:size] - 1
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './enumerable_strategy'
4
+ require_relative './active_record_strategy'
5
+
6
+ class ActiveSet
7
+ module Paginating
8
+ class Operation
9
+ def initialize(set, instructions_hash)
10
+ @set = set
11
+ @instructions_hash = instructions_hash
12
+ end
13
+
14
+ def execute
15
+ [ActiveRecordStrategy, EnumerableStrategy].each do |strategy|
16
+ maybe_set_or_false = strategy.new(@set, operation_instructions).execute
17
+ break(maybe_set_or_false) if maybe_set_or_false
18
+ end
19
+ end
20
+
21
+ def operation_instructions
22
+ @instructions_hash.symbolize_keys.tap do |h|
23
+ h[:page] = page_operation_instruction(h[:page])
24
+ h[:size] = size_operation_instruction(h[:size])
25
+ h[:count] = count_operation_instruction(@set)
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def page_operation_instruction(initial)
32
+ return 1 unless initial
33
+ return 1 if initial.to_i <= 0
34
+
35
+ initial.to_i
36
+ end
37
+
38
+ def size_operation_instruction(initial)
39
+ return 25 unless initial
40
+ return 25 if initial.to_i <= 0
41
+
42
+ initial.to_i
43
+ end
44
+
45
+ def count_operation_instruction(set)
46
+ # https://work.stevegrossi.com/2015/04/25/how-to-count-with-activerecord/
47
+ maybe_integer_or_hash = set.size
48
+ return maybe_integer_or_hash.count if maybe_integer_or_hash.is_a?(Hash)
49
+
50
+ maybe_integer_or_hash
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ActiveSet
4
+ module Sorting
5
+ class ActiveRecordStrategy
6
+ def initialize(set, attribute_instructions)
7
+ @set = set
8
+ @attribute_instructions = attribute_instructions
9
+ end
10
+
11
+ def execute
12
+ return false unless @set.respond_to? :to_sql
13
+
14
+ executable_instructions.reduce(set_with_eager_loaded_associations) do |set, attribute_instruction|
15
+ statement = set.merge(order_operation_for(attribute_instruction))
16
+
17
+ return false if throws?(ActiveRecord::StatementInvalid) { statement.load }
18
+
19
+ attribute_instruction.processed = true
20
+ statement
21
+ end
22
+ end
23
+
24
+ def executable_instructions
25
+ return {} unless @set.respond_to? :to_sql
26
+
27
+ @attribute_instructions.select do |attribute_instruction|
28
+ attribute_model = attribute_model_for(attribute_instruction)
29
+ next false unless attribute_model
30
+ next false unless attribute_model.respond_to?(:attribute_names)
31
+ next false unless attribute_model.attribute_names.include?(attribute_instruction.attribute)
32
+
33
+ true
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def set_with_eager_loaded_associations
40
+ associations_hash = @attribute_instructions.reduce({}) { |h, i| h.merge(i.associations_hash) }
41
+ @set.eager_load(associations_hash)
42
+ end
43
+
44
+ # https://stackoverflow.com/a/44912964/2884386
45
+ # Force null values to be sorted as if larger than any non-null value
46
+ # ASC => [-2, -1, 1, 2, nil]
47
+ # DESC => [nil, 2, 1, -1, -2]
48
+ def order_operation_for(attribute_instruction)
49
+ attribute_model = attribute_model_for(attribute_instruction)
50
+
51
+ arel_column = Arel::Table.new(attribute_model.table_name)[attribute_instruction.attribute]
52
+ arel_column = case_insensitive?(attribute_instruction) ? arel_column.lower : arel_column
53
+ arel_direction = direction_operator(attribute_instruction.value)
54
+ nil_sorter = arel_column.send(arel_direction == :asc ? :eq : :not_eq, nil)
55
+
56
+ attribute_model.order(nil_sorter).order(arel_column.send(arel_direction))
57
+ end
58
+
59
+ def attribute_model_for(attribute_instruction)
60
+ return @set.klass if attribute_instruction.associations_array.empty?
61
+
62
+ attribute_instruction
63
+ .associations_array
64
+ .reduce(@set) do |obj, assoc|
65
+ obj.reflections[assoc.to_s]&.klass
66
+ end
67
+ end
68
+
69
+ def case_insensitive?(attribute_instruction)
70
+ attribute_instruction.operator.to_s.casecmp('i').zero?
71
+ end
72
+
73
+ def direction_operator(direction)
74
+ return :desc if direction.to_s.downcase.start_with? 'desc'
75
+
76
+ :asc
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../helpers/transform_to_sortable_numeric'
4
+
5
+ class ActiveSet
6
+ module Sorting
7
+ class EnumerableStrategy
8
+ def initialize(set, attribute_instructions)
9
+ @set = set
10
+ @attribute_instructions = attribute_instructions
11
+ end
12
+
13
+ def execute
14
+ # http://brandon.dimcheff.com/2009/11/18/rubys-sort-vs-sort-by/
15
+ @set.sort_by do |item|
16
+ @attribute_instructions.map do |instruction|
17
+ value_for_comparison = sortable_numeric_for(instruction, item)
18
+ direction_multiplier = direction_multiplier(instruction.value)
19
+
20
+ # Force null values to be sorted as if larger than any non-null value
21
+ # ASC => [-2, -1, 1, 2, nil]
22
+ # DESC => [nil, 2, 1, -1, -2]
23
+ if value_for_comparison.nil?
24
+ [direction_multiplier, 0]
25
+ else
26
+ [0, value_for_comparison * direction_multiplier]
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ def sortable_numeric_for(instruction, item)
33
+ value = instruction.value_for(item: item)
34
+ if value.is_a?(String) || value.is_a?(Symbol)
35
+ value = if case_insensitive?(instruction, value)
36
+ value.to_s.downcase
37
+ else
38
+ value.to_s
39
+ end
40
+ end
41
+
42
+ transform_to_sortable_numeric(value)
43
+ end
44
+
45
+ def case_insensitive?(instruction, _value)
46
+ instruction.operator.to_s.casecmp('i').zero?
47
+ end
48
+
49
+ def direction_multiplier(direction)
50
+ return -1 if direction.to_s.downcase.start_with? 'desc'
51
+
52
+ 1
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../attribute_instruction'
4
+ require_relative './enumerable_strategy'
5
+ require_relative './active_record_strategy'
6
+
7
+ class ActiveSet
8
+ module Sorting
9
+ class Operation
10
+ def initialize(set, instructions_hash)
11
+ @set = set
12
+ @instructions_hash = instructions_hash
13
+ end
14
+
15
+ def execute
16
+ attribute_instructions = @instructions_hash
17
+ .flatten_keys
18
+ .map { |k, v| AttributeInstruction.new(k, v) }
19
+
20
+ activerecord_strategy = ActiveRecordStrategy.new(@set, attribute_instructions)
21
+ if activerecord_strategy.executable_instructions == attribute_instructions
22
+ activerecord_sorted_set = activerecord_strategy.execute
23
+ end
24
+
25
+ return activerecord_sorted_set if attribute_instructions.all?(&:processed?)
26
+
27
+ EnumerableStrategy.new(@set, attribute_instructions).execute
28
+ end
29
+
30
+ def operation_instructions
31
+ @instructions_hash.symbolize_keys
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Returns a Boolean for whether the block raises the Exception expected
4
+ #
5
+ # throws?(StandardError) { raise }
6
+ # => true
7
+ # throws?(NameError) { raise NameError }
8
+ # => true
9
+ # throws?(NoMethodError) { raise NameError }
10
+ # => false
11
+ # throws?(StandardError) { 'foo' }
12
+ # => false
13
+
14
+ def throws?(exception)
15
+ yield
16
+ false
17
+ rescue StandardError => e
18
+ e.is_a? exception
19
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Style/AsciiComments
4
+ # Returns a Numeric for `value` that respects sort-order
5
+ # can be used in Enumerable#sort_by
6
+ #
7
+ # transform_to_sortable_numeric(1)
8
+ # => 1
9
+ # transform_to_sortable_numeric('aB09ü')
10
+ # => (24266512014313/250000000000)
11
+ # transform_to_sortable_numeric(true)
12
+ # => 1
13
+ # transform_to_sortable_numeric(Date.new(2000, 12, 25))
14
+ # => 977720400000
15
+
16
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
17
+ def transform_to_sortable_numeric(value)
18
+ # https://www.justinweiss.com/articles/4-simple-memoization-patterns-in-ruby-and-one-gem/#and-what-about-parameters
19
+ @sortable_numeric ||= Hash.new do |h, key|
20
+ h[key] = if key.is_a?(Numeric)
21
+ key
22
+ elsif key == true
23
+ 1
24
+ elsif key == false
25
+ 0
26
+ elsif key.is_a?(String) || key.is_a?(Symbol)
27
+ string_to_sortable_numeric(key.to_s)
28
+ elsif key.is_a?(Date)
29
+ time_to_sortable_numeric(Time.new(key.year, key.month, key.day, 0o0, 0o0, 0o0, 0))
30
+ elsif key.respond_to?(:to_time)
31
+ time_to_sortable_numeric(key.to_time)
32
+ else
33
+ key
34
+ end
35
+ end
36
+ @sortable_numeric[value]
37
+ end
38
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
39
+ # rubocop:enable Style/AsciiComments
40
+
41
+ def string_to_sortable_numeric(string)
42
+ string # 'aB09ü'
43
+ .split('') # ["a", "B", "0", "9", "ü"]
44
+ .map { |char| char.ord.to_s.rjust(3, '0') } # ["097", "066", "048", "057", "252"]
45
+ .insert(1, '.') # ["097", ".", "066", "048", "057", "252"]
46
+ .reduce(&:concat) # "097.066048057252"
47
+ .to_r # (24266512014313/250000000000)
48
+ end
49
+
50
+ def time_to_sortable_numeric(time)
51
+ # https://stackoverflow.com/a/30604935/2884386
52
+ (time.utc.to_f * 1000).round
53
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/array/wrap'
4
+
5
+ class Hash
6
+ # Returns a flat hash where all nested keys are collapsed into an array of keys.
7
+ #
8
+ # hash = { person: { name: { first: 'Rob' }, age: '28' } }
9
+ # hash.flatten_keys_to_array
10
+ # => {[:person, :name, :first] => "Rob", [:person, :age]=>"28" }
11
+ def flatten_keys_to_array
12
+ _flatten_keys(self)
13
+ end
14
+ alias flatten_keys flatten_keys_to_array
15
+
16
+ # Returns a flat hash where all nested keys are collapsed into a dot-separated string of keys.
17
+ #
18
+ # hash = { person: { name: { first: 'Rob' }, age: '28' } }
19
+ # hash.flatten_keys_to_dotpath
20
+ # => { 'person.name.first' => "Rob", 'person.age'=>"28" }
21
+ def flatten_keys_to_dotpath
22
+ _flatten_keys(self, ->(*keys) { keys.join('.') })
23
+ end
24
+
25
+ # Returns a flat hash where all nested keys are collapsed into a dast-separated string of keys.
26
+ #
27
+ # hash = { person: { name: { first: 'Rob' }, age: '28' } }
28
+ # hash.flatten_keys_to_html_attribute
29
+ # => { 'person-name-first' => "Rob", 'person-age'=>"28" }
30
+ def flatten_keys_to_html_attribute
31
+ _flatten_keys(self, ->(*keys) { keys.join('-') })
32
+ end
33
+
34
+ # Returns a flat hash where all nested keys are collapsed into a string of keys
35
+ # fitting the Rails request param pattern.
36
+ #
37
+ # hash = { person: { name: { first: 'Rob' }, age: '28' } }
38
+ # hash.flatten_keys_to_rails_param
39
+ # => { 'person[name][first]' => "Rob", 'person[age]'=>"28" }
40
+ def flatten_keys_to_rails_param
41
+ _flatten_keys(self, ->(*keys) { keys.map(&:to_s).reduce { |memo, key| memo + "[#{key}]" } })
42
+ end
43
+
44
+ private
45
+
46
+ # refactored from https://stackoverflow.com/a/23861946/2884386
47
+ def _flatten_keys(input, keypath_gen = ->(*keys) { keys }, keys = [], output = {})
48
+ if input.is_a? Hash
49
+ input.each { |k, v| _flatten_keys(v, keypath_gen, keys + Array(k), output) }
50
+ # elsif input.is_a? Array
51
+ # input.each_with_index { |v, i| _flatten_keys(v, keypath_gen, keys + Array(i), output) }
52
+ else
53
+ return output.merge!(keypath_gen.call(*keys) => input)
54
+ end
55
+
56
+ output
57
+ end
58
+ end