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