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
@@ -4,6 +4,7 @@ require_relative './sort/link_for_helper'
|
|
4
4
|
require_relative './pagination/links_for_helper'
|
5
5
|
require_relative './pagination/path_for_helper'
|
6
6
|
require_relative './params/form_for_object_helper'
|
7
|
+
require_relative './export/path_for_helper'
|
7
8
|
|
8
9
|
module ActionSet
|
9
10
|
module Helpers
|
@@ -11,6 +12,7 @@ module ActionSet
|
|
11
12
|
include Sort::LinkForHelper
|
12
13
|
include Pagination::LinksForHelper
|
13
14
|
include Params::FormForObjectHelper
|
15
|
+
include Export::PathForHelper
|
14
16
|
end
|
15
17
|
end
|
16
18
|
end
|
@@ -21,12 +21,12 @@ module Pagination
|
|
21
21
|
'aria-label': 'Page navigation'
|
22
22
|
)) do
|
23
23
|
safe_join([
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
24
|
+
pagination_first_page_link_for(set),
|
25
|
+
pagination_prev_page_link_for(set),
|
26
|
+
pagination_current_page_description_for(set),
|
27
|
+
pagination_next_page_link_for(set),
|
28
|
+
pagination_last_page_link_for(set)
|
29
|
+
])
|
30
30
|
end
|
31
31
|
end
|
32
32
|
end
|
@@ -11,7 +11,6 @@ module Sort
|
|
11
11
|
def next_sort_direction_for(attribute, format: :short)
|
12
12
|
direction = current_sort_direction_for(attribute)
|
13
13
|
|
14
|
-
return ascending_str(format) unless direction.presence_in %w[asc desc ascending descending]
|
15
14
|
return ascending_str(format) if direction.presence_in %w[desc descending]
|
16
15
|
return descending_str(format) if direction.presence_in %w[asc ascending]
|
17
16
|
|
data/lib/active_set.rb
ADDED
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/core_ext/hash/reverse_merge'
|
4
|
+
require 'patches/core_ext/hash/flatten_keys'
|
5
|
+
require 'helpers/throws'
|
6
|
+
require 'active_set/attribute_instruction'
|
7
|
+
require 'active_set/filtering/operation'
|
8
|
+
require 'active_set/sorting/operation'
|
9
|
+
require 'active_set/paginating/operation'
|
10
|
+
require 'active_set/exporting/operation'
|
11
|
+
|
12
|
+
class ActiveSet
|
13
|
+
include Enumerable
|
14
|
+
|
15
|
+
attr_reader :set, :view, :instructions
|
16
|
+
|
17
|
+
def initialize(set, view: nil, instructions: {})
|
18
|
+
@set = set
|
19
|
+
@view = view || set
|
20
|
+
@instructions = instructions
|
21
|
+
end
|
22
|
+
|
23
|
+
def each(&block)
|
24
|
+
@view.each(&block)
|
25
|
+
end
|
26
|
+
|
27
|
+
# :nocov:
|
28
|
+
def inspect
|
29
|
+
"#<ActiveSet:#{object_id} @instructions=#{@instructions.inspect}>"
|
30
|
+
end
|
31
|
+
|
32
|
+
def ==(other)
|
33
|
+
return @view == other unless other.is_a?(ActiveSet)
|
34
|
+
|
35
|
+
@view == other.view
|
36
|
+
end
|
37
|
+
|
38
|
+
def method_missing(method_name, *args, &block)
|
39
|
+
return @view.send(method_name, *args, &block) if @view.respond_to?(method_name)
|
40
|
+
|
41
|
+
super
|
42
|
+
end
|
43
|
+
|
44
|
+
def respond_to_missing?(method_name, include_private = false)
|
45
|
+
@view.respond_to?(method_name) || super
|
46
|
+
end
|
47
|
+
# :nocov:
|
48
|
+
|
49
|
+
def filter(instructions_hash)
|
50
|
+
filterer = Filtering::Operation.new(@view, instructions_hash)
|
51
|
+
reinitialize(filterer.execute, :filter, filterer.operation_instructions)
|
52
|
+
end
|
53
|
+
|
54
|
+
def sort(instructions_hash)
|
55
|
+
sorter = Sorting::Operation.new(@view, instructions_hash)
|
56
|
+
reinitialize(sorter.execute, :sort, sorter.operation_instructions)
|
57
|
+
end
|
58
|
+
|
59
|
+
def paginate(instructions_hash)
|
60
|
+
paginater = Paginating::Operation.new(@view, instructions_hash)
|
61
|
+
reinitialize(paginater.execute, :paginate, paginater.operation_instructions)
|
62
|
+
end
|
63
|
+
|
64
|
+
def export(instructions_hash)
|
65
|
+
exporter = Exporting::Operation.new(@view, instructions_hash)
|
66
|
+
exporter.execute
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
def reinitialize(processed_set, method, instructions)
|
72
|
+
self.class.new(@set,
|
73
|
+
view: processed_set,
|
74
|
+
instructions: @instructions.merge(
|
75
|
+
method => instructions
|
76
|
+
))
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class ActiveSet
|
4
|
+
class AttributeInstruction
|
5
|
+
attr_accessor :processed
|
6
|
+
attr_reader :keypath, :value
|
7
|
+
|
8
|
+
def initialize(keypath, value)
|
9
|
+
# `keypath` can be an Array (e.g. [:parent, :child, :grandchild, :attribute])
|
10
|
+
# or a String (e.g. 'parent.child.grandchild.attribute')
|
11
|
+
@keypath = Array(keypath).map(&:to_s).flat_map { |x| x.split('.') }
|
12
|
+
@value = value
|
13
|
+
@processed = false
|
14
|
+
end
|
15
|
+
|
16
|
+
def processed?
|
17
|
+
@processed
|
18
|
+
end
|
19
|
+
|
20
|
+
def attribute
|
21
|
+
attribute = @keypath.last
|
22
|
+
return attribute.sub(operator_regex, '') if attribute&.match operator_regex
|
23
|
+
|
24
|
+
attribute
|
25
|
+
end
|
26
|
+
|
27
|
+
def operator(default: '==')
|
28
|
+
attribute = @keypath.last
|
29
|
+
return attribute[operator_regex, 1] if attribute&.match operator_regex
|
30
|
+
|
31
|
+
default
|
32
|
+
end
|
33
|
+
|
34
|
+
def associations_array
|
35
|
+
return [] unless @keypath.any?
|
36
|
+
|
37
|
+
@keypath.slice(0, @keypath.length - 1)
|
38
|
+
end
|
39
|
+
|
40
|
+
def associations_hash
|
41
|
+
return {} unless @keypath.any?
|
42
|
+
|
43
|
+
associations_array.reverse.reduce({}) do |hash, association|
|
44
|
+
{ association => hash }
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def value_for(item:)
|
49
|
+
resource_for(item: item).public_send(attribute)
|
50
|
+
rescue StandardError
|
51
|
+
# :nocov:
|
52
|
+
nil
|
53
|
+
# :nocov:
|
54
|
+
end
|
55
|
+
|
56
|
+
def resource_for(item:)
|
57
|
+
associations_array.reduce(item) do |resource, association|
|
58
|
+
return nil unless resource.respond_to? association
|
59
|
+
|
60
|
+
resource.public_send(association)
|
61
|
+
end
|
62
|
+
rescue StandardError
|
63
|
+
# :nocov:
|
64
|
+
nil
|
65
|
+
# :nocov:
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
def operator_regex
|
71
|
+
/\((.*?)\)/
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative './attribute_instruction'
|
4
|
+
|
5
|
+
class ActiveSet
|
6
|
+
class ColumnInstruction
|
7
|
+
def initialize(instructions_hash, item)
|
8
|
+
@instructions_hash = instructions_hash.symbolize_keys
|
9
|
+
@item = item
|
10
|
+
end
|
11
|
+
|
12
|
+
def key
|
13
|
+
return @instructions_hash[:key] if @instructions_hash.key? :key
|
14
|
+
|
15
|
+
return titleized_attribute_key unless attribute_instruction.attribute
|
16
|
+
|
17
|
+
attribute_resource = attribute_instruction.resource_for(item: @item)
|
18
|
+
return titleized_attribute_key unless attribute_resource&.class&.respond_to?(:human_attribute_name)
|
19
|
+
|
20
|
+
attribute_resource.class.human_attribute_name(attribute_instruction.attribute)
|
21
|
+
end
|
22
|
+
|
23
|
+
def value
|
24
|
+
return default unless @instructions_hash.key?(:value)
|
25
|
+
return @instructions_hash[:value].call(@item) if @instructions_hash[:value]&.respond_to? :call
|
26
|
+
|
27
|
+
attribute_instruction.value_for(item: @item)
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def attribute_instruction
|
33
|
+
AttributeInstruction.new(@instructions_hash[:value], nil)
|
34
|
+
end
|
35
|
+
|
36
|
+
def default
|
37
|
+
return @instructions_hash[:default] if @instructions_hash.key? :default
|
38
|
+
|
39
|
+
'—'
|
40
|
+
end
|
41
|
+
|
42
|
+
def titleized_attribute_key
|
43
|
+
attribute_instruction.keypath.map(&:titleize).join(' ')
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../column_instruction'
|
4
|
+
|
5
|
+
class ActiveSet
|
6
|
+
module Exporting
|
7
|
+
class CSVStrategy
|
8
|
+
require 'csv'
|
9
|
+
|
10
|
+
def initialize(set, column_instructions)
|
11
|
+
@set = set
|
12
|
+
@column_instructions = column_instructions
|
13
|
+
end
|
14
|
+
|
15
|
+
def execute
|
16
|
+
::CSV.generate do |output|
|
17
|
+
output << column_keys_for(item: @set.first)
|
18
|
+
@set.each do |item|
|
19
|
+
output << column_values_for(item: item)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def column_keys_for(item:)
|
27
|
+
columns.map do |column|
|
28
|
+
ColumnInstruction.new(column, item).key
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def column_values_for(item:)
|
33
|
+
columns.map do |column|
|
34
|
+
ColumnInstruction.new(column, item).value
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def columns
|
39
|
+
@column_instructions.compact
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative './csv_strategy'
|
4
|
+
|
5
|
+
class ActiveSet
|
6
|
+
module Exporting
|
7
|
+
class Operation
|
8
|
+
def initialize(set, instructions_hash)
|
9
|
+
@set = set
|
10
|
+
@instructions_hash = instructions_hash
|
11
|
+
end
|
12
|
+
|
13
|
+
def execute
|
14
|
+
strategy_for(format: operation_instructions[:format].to_s.downcase)
|
15
|
+
.new(@set, operation_instructions[:columns])
|
16
|
+
.execute
|
17
|
+
end
|
18
|
+
|
19
|
+
def operation_instructions
|
20
|
+
@instructions_hash.symbolize_keys
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def strategy_for(format:)
|
26
|
+
return CSVStrategy if format == 'csv'
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class ActiveSet
|
4
|
+
module Filtering
|
5
|
+
class ActiveRecordStrategy
|
6
|
+
def initialize(set, attribute_instruction)
|
7
|
+
@set = set
|
8
|
+
@attribute_instruction = attribute_instruction
|
9
|
+
end
|
10
|
+
|
11
|
+
def execute
|
12
|
+
return false unless @set.respond_to? :to_sql
|
13
|
+
|
14
|
+
if execute_where_operation?
|
15
|
+
statement = where_operation
|
16
|
+
elsif execute_merge_operation?
|
17
|
+
begin
|
18
|
+
statement = merge_operation
|
19
|
+
rescue ArgumentError # thrown if merging a non-ActiveRecord::Relation
|
20
|
+
return false
|
21
|
+
end
|
22
|
+
else
|
23
|
+
return false
|
24
|
+
end
|
25
|
+
|
26
|
+
statement
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def execute_where_operation?
|
32
|
+
return false unless attribute_model
|
33
|
+
return false unless attribute_model.respond_to?(:attribute_names)
|
34
|
+
return false unless attribute_model.attribute_names.include?(@attribute_instruction.attribute)
|
35
|
+
|
36
|
+
true
|
37
|
+
end
|
38
|
+
|
39
|
+
def execute_merge_operation?
|
40
|
+
return false unless attribute_model
|
41
|
+
return false unless attribute_model.respond_to?(@attribute_instruction.attribute)
|
42
|
+
return false if attribute_model.method(@attribute_instruction.attribute).arity.zero?
|
43
|
+
|
44
|
+
true
|
45
|
+
end
|
46
|
+
|
47
|
+
def where_operation
|
48
|
+
initial_relation
|
49
|
+
.where(
|
50
|
+
arel_column.send(
|
51
|
+
@attribute_instruction.operator(default: 'eq'),
|
52
|
+
@attribute_instruction.value
|
53
|
+
)
|
54
|
+
)
|
55
|
+
end
|
56
|
+
|
57
|
+
def merge_operation
|
58
|
+
initial_relation
|
59
|
+
.merge(
|
60
|
+
attribute_model.public_send(
|
61
|
+
@attribute_instruction.attribute,
|
62
|
+
@attribute_instruction.value
|
63
|
+
)
|
64
|
+
)
|
65
|
+
end
|
66
|
+
|
67
|
+
def initial_relation
|
68
|
+
return @set if @attribute_instruction.associations_array.empty?
|
69
|
+
|
70
|
+
@set.eager_load(@attribute_instruction.associations_hash)
|
71
|
+
end
|
72
|
+
|
73
|
+
def arel_column
|
74
|
+
attribute_type = attribute_model.columns_hash[@attribute_instruction.attribute].type
|
75
|
+
|
76
|
+
# This is to work around an bug in ActiveRecord,
|
77
|
+
# where BINARY fields aren't found properly when using the `arel_table` class method
|
78
|
+
# to build an ARel::Node
|
79
|
+
if attribute_type == :binary
|
80
|
+
Arel::Table.new(attribute_model.table_name)[@attribute_instruction.attribute]
|
81
|
+
else
|
82
|
+
attribute_model.arel_table[@attribute_instruction.attribute]
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def attribute_model
|
87
|
+
return @set.klass if @attribute_instruction.associations_array.empty?
|
88
|
+
return @attribute_model if defined? @attribute_model
|
89
|
+
|
90
|
+
@attribute_model = @attribute_instruction
|
91
|
+
.associations_array
|
92
|
+
.reduce(@set) do |obj, assoc|
|
93
|
+
obj.reflections[assoc.to_s]&.klass
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class ActiveSet
|
4
|
+
module Filtering
|
5
|
+
class EnumerableStrategy
|
6
|
+
def initialize(set, attribute_instruction)
|
7
|
+
@set = set
|
8
|
+
@attribute_instruction = attribute_instruction
|
9
|
+
end
|
10
|
+
|
11
|
+
def execute
|
12
|
+
return false unless @set.respond_to? :select
|
13
|
+
|
14
|
+
@set.select do |item|
|
15
|
+
next attribute_matches_for?(item) if can_match_attribute_for?(item)
|
16
|
+
next class_method_matches_for?(item) if can_match_class_method_for?(item)
|
17
|
+
|
18
|
+
false
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def can_match_attribute_for?(item)
|
25
|
+
attribute_item = attribute_item_for(item)
|
26
|
+
|
27
|
+
return false unless attribute_item
|
28
|
+
return false unless attribute_item.respond_to?(@attribute_instruction.attribute)
|
29
|
+
return false if attribute_item.method(@attribute_instruction.attribute).arity.positive?
|
30
|
+
|
31
|
+
true
|
32
|
+
end
|
33
|
+
|
34
|
+
def can_match_class_method_for?(item)
|
35
|
+
attribute_item = attribute_item_for(item)
|
36
|
+
|
37
|
+
return false unless attribute_item
|
38
|
+
return false unless attribute_item.class
|
39
|
+
return false unless attribute_item.class.respond_to?(@attribute_instruction.attribute)
|
40
|
+
return false if attribute_item.class.method(@attribute_instruction.attribute).arity.zero?
|
41
|
+
|
42
|
+
true
|
43
|
+
end
|
44
|
+
|
45
|
+
def attribute_matches_for?(item)
|
46
|
+
@attribute_instruction
|
47
|
+
.value_for(item: item)
|
48
|
+
.public_send(
|
49
|
+
@attribute_instruction.operator,
|
50
|
+
@attribute_instruction.value
|
51
|
+
)
|
52
|
+
end
|
53
|
+
|
54
|
+
# rubocop:disable Metrics/MethodLength
|
55
|
+
def class_method_matches_for?(item)
|
56
|
+
maybe_item_or_collection_or_nil = attribute_item_for(item)
|
57
|
+
.class
|
58
|
+
.public_send(
|
59
|
+
@attribute_instruction.attribute,
|
60
|
+
@attribute_instruction.value
|
61
|
+
)
|
62
|
+
if maybe_item_or_collection_or_nil.nil?
|
63
|
+
false
|
64
|
+
elsif maybe_item_or_collection_or_nil.respond_to?(:each)
|
65
|
+
maybe_item_or_collection_or_nil.include? attribute_item_for(item)
|
66
|
+
else
|
67
|
+
maybe_item_or_collection_or_nil == attribute_item_for(item)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
# rubocop:enable Metrics/MethodLength
|
71
|
+
|
72
|
+
def attribute_item_for(item)
|
73
|
+
@attribute_instruction
|
74
|
+
.resource_for(item: item)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|