actionset 0.7.0 → 0.8.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +0 -1
- data/.hound.yml +32 -0
- data/.rubocop.yml +1 -1
- data/.ruby-version +1 -0
- data/CHANGELOG +17 -0
- data/Gemfile.lock +222 -0
- data/actionset.gemspec +12 -10
- data/bin/console +17 -3
- data/lib/action_set/attribute_value.rb +8 -2
- data/lib/action_set/helpers/helper_methods.rb +2 -0
- data/lib/action_set/helpers/pagination/links_for_helper.rb +6 -6
- data/lib/action_set/helpers/sort/next_direction_for_helper.rb +0 -1
- data/lib/active_set.rb +78 -0
- data/lib/active_set/attribute_instruction.rb +74 -0
- data/lib/active_set/column_instruction.rb +46 -0
- data/lib/active_set/exporting/csv_strategy.rb +43 -0
- data/lib/active_set/exporting/operation.rb +30 -0
- data/lib/active_set/filtering/active_record_strategy.rb +98 -0
- data/lib/active_set/filtering/enumerable_strategy.rb +78 -0
- data/lib/active_set/filtering/operation.rb +46 -0
- data/lib/active_set/paginating/active_record_strategy.rb +28 -0
- data/lib/active_set/paginating/enumerable_strategy.rb +33 -0
- data/lib/active_set/paginating/operation.rb +54 -0
- data/lib/active_set/sorting/active_record_strategy.rb +80 -0
- data/lib/active_set/sorting/enumerable_strategy.rb +56 -0
- data/lib/active_set/sorting/operation.rb +35 -0
- data/lib/helpers/throws.rb +19 -0
- data/lib/helpers/transform_to_sortable_numeric.rb +53 -0
- data/lib/patches/core_ext/hash/flatten_keys.rb +58 -0
- metadata +62 -28
@@ -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
|