actionset 0.7.0 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
- 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
- ])
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
 
@@ -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