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.
- 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
|