actionset 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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