warped 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_support/core_ext/class/attribute"
5
+ require "active_support/core_ext/enumerable"
6
+
7
+ module Warped
8
+ module Controllers
9
+ # Provides functionality for filtering records from an +ActiveRecord::Relation+ in a controller.
10
+ #
11
+ # Example usage:
12
+ #
13
+ # class UsersController < ApplicationController
14
+ # include Filterable
15
+ #
16
+ # filterable_by :name, :created_at, 'accounts.kind'
17
+ #
18
+ # def index
19
+ # scope = filter(User.joins(:account))
20
+ # render json: scope
21
+ # end
22
+ # end
23
+ #
24
+ # Example requests:
25
+ # GET /users?name=John
26
+ # GET /users?created_at=2020-01-01
27
+ # GET /users?accounts.kind=premium
28
+ # GET /users?accounts.kind=premium&accounts.kind.rel=not_eq
29
+ #
30
+ # Filters can be combined:
31
+ # GET /users?name=John&created_at=2020-01-01
32
+ #
33
+ # Renaming filter keys:
34
+ #
35
+ # In some cases, you may not want to expose the actual column names to the client.
36
+ # In such cases, you can rename the filter keys by passing a hash to the +filterable_by+ method.
37
+ #
38
+ # Example:
39
+ #
40
+ # class UsersController < ApplicationController
41
+ # include Filterable
42
+ #
43
+ # filterable_by :name, :created_at, 'accounts.kind' => 'kind'
44
+ #
45
+ # def index
46
+ # scope = filter(User.joins(:account))
47
+ # render json: scope
48
+ # end
49
+ # end
50
+ #
51
+ # Example requests:
52
+ # GET /users?kind=premium
53
+ #
54
+ # Using relations:
55
+ #
56
+ # In some cases, you may want to filter records based on a relation.
57
+ # For example, you may want to filter users based on operands like:
58
+ # - greater than
59
+ # - less than
60
+ # - not equal
61
+ # @see Warped::Queries::Filter::RELATIONS
62
+ # To see the full list of operands, check the +Warped::Queries::Filter::RELATIONS+ constant.
63
+ #
64
+ # To use the operands, you must pass a parameter appended with `.rel`, and the value of a valid operand.
65
+ #
66
+ # Example requests:
67
+ # GET /users?created_at=2020-01-01&created_at.rel=>
68
+ # GET /users?created_at=2020-01-01&created_at.rel=<
69
+ # GET /users?created_at=2020-01-01&created_at.rel=not_eq
70
+ #
71
+ # When the operand relation requires multiple values, like +in+, +not_in+, or +between+,
72
+ # you can pass an array of values.
73
+ #
74
+ # Example requests:
75
+ # GET /users?created_at[]=2020-01-01&created_at[]=2020-01-03&created_at.rel=in
76
+ # GET /users?created_at[]=2020-01-01&created_at[]=2020-01-03&created_at.rel=between
77
+ module Filterable
78
+ extend ActiveSupport::Concern
79
+
80
+ included do
81
+ class_attribute :filter_fields, default: []
82
+ class_attribute :mapped_filter_fields, default: []
83
+ end
84
+
85
+ class_methods do
86
+ # @param keys [Array<Symbol,String,Hash>]
87
+ # @param mapped_keys [Hash<Symbol,String>]
88
+ def filterable_by(*keys, **mapped_keys)
89
+ self.filter_fields = keys
90
+ self.mapped_filter_fields = mapped_keys.to_a
91
+ end
92
+ end
93
+
94
+ # @param scope [ActiveRecord::Relation]
95
+ # @param filter_conditions [Array<Hash>]
96
+ # @option filter_conditions [Symbol,String] :field
97
+ # @option filter_conditions [String,Integer,Array<String,Integer>] :value
98
+ # @option filter_conditions [String] :relation
99
+ # @return [ActiveRecord::Relation]
100
+ def filter(scope, filter_conditions: filter_conditions(*filter_fields, *mapped_filter_fields))
101
+ Warped::Queries::Filter.call(scope, filter_conditions:)
102
+ end
103
+
104
+ # @param fields [Array<Symbol,String>]
105
+ # @return [Array<Hash>]
106
+ def filter_conditions(*fields)
107
+ fields.filter_map do |filter_opt|
108
+ field = filter_name(filter_opt)
109
+
110
+ next if filter_value(filter_opt).blank? && %w[is_null is_not_null].exclude?(filter_rel_value(filter_opt))
111
+
112
+ {
113
+ field:,
114
+ value: filter_value(filter_opt),
115
+ relation: filter_rel_value(filter_opt).presence || (filter_value(filter_opt).is_a?(Array) ? "in" : "=")
116
+ }
117
+ end
118
+ end
119
+
120
+ private
121
+
122
+ def filter_name(filter)
123
+ filter.is_a?(Array) ? filter.first : filter
124
+ end
125
+
126
+ def filter_mapped_name(filter)
127
+ filter.is_a?(Array) ? filter.last : filter
128
+ end
129
+
130
+ def filter_value(filter)
131
+ param_key = filter_mapped_name(filter)
132
+ params[param_key]
133
+ end
134
+
135
+ def filter_rel_value(filter)
136
+ param_key = filter_mapped_name(filter)
137
+ params["#{param_key}.rel"]
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_support/core_ext/class/attribute"
5
+
6
+ module Warped
7
+ module Controllers
8
+ # Provides functionality for paginating records from an +ActiveRecord::Relation+ in a controller.
9
+ #
10
+ # Example usage:
11
+ #
12
+ # class UsersController < ApplicationController
13
+ # include Pageable
14
+ #
15
+ # def index
16
+ # scope = paginate(User.all)
17
+ # render json: scope, root: :users, meta: page_info
18
+ # end
19
+ # end
20
+ #
21
+ # Example requests:
22
+ # GET /users?page=1&per_page=10
23
+ # GET /users?page=2&per_page=50
24
+ #
25
+ # The +per_page+ parameter is optional. If not provided, the default value will be used.
26
+ #
27
+ # The default value can be set at a controller level using the +default_per_page+ class attribute.
28
+ # Example:
29
+ # class UsersController < ApplicationController
30
+ # include Pageable
31
+ #
32
+ # self.default_per_page = 50
33
+ #
34
+ # def index
35
+ # scope = paginate(User.all)
36
+ # render json: scope, root: :users, meta: page_info
37
+ # end
38
+ # end
39
+ #
40
+ # Or by overriding the +default_per_page+ controller instance method.
41
+ # Example:
42
+ # class UsersController < ApplicationController
43
+ # include Pageable
44
+ #
45
+ # def index
46
+ # scope = paginate(User.all)
47
+ # render json: scope, root: :users, meta: page_info
48
+ # end
49
+ #
50
+ # private
51
+ #
52
+ # def default_per_page
53
+ # 50
54
+ # end
55
+ # end
56
+ #
57
+ # The +per_page+ value can also be set at action level, by passing the +per_page+ parameter to
58
+ # the +paginate+ method.
59
+ # Example:
60
+ # class UsersController < ApplicationController
61
+ # include Pageable
62
+ #
63
+ # def index
64
+ # scope = paginate(User.all, per_page: 50)
65
+ # render json: scope, root: :users, meta: page_info
66
+ # end
67
+ #
68
+ # def other_index
69
+ # # The default per_page value is used.
70
+ # scope = paginate(User.all)
71
+ # render json: scope, root: :users, meta: page_info
72
+ # end
73
+ # end
74
+ #
75
+ # The pagination metadata can be accessed by calling the +page_info+ method.
76
+ # It includes the following keys:
77
+ # - +total_count+: The total number of records in the collection.
78
+ # - +total_pages+: The total number of pages.
79
+ # - +next_page+: The next page number.
80
+ # - +prev_page+: The previous page number.
81
+ # - +page+: The current page number.
82
+ # - +per_page+: The number of records per page.
83
+ # *Warning*: The +page_info+ method will raise an +ArgumentError+ if the method +paginate+ was not
84
+ # called within the action.
85
+ module Pageable
86
+ extend ActiveSupport::Concern
87
+
88
+ included do
89
+ class_attribute :default_per_page, default: Queries::Paginate::DEFAULT_PER_PAGE
90
+ end
91
+
92
+ # Paginates the given scope.
93
+ #
94
+ # @param scope [ActiveRecord::Relation] The scope to be paginated.
95
+ # @param page [String,Integer,nil] The page number.
96
+ # @param per_page [String,Integer,nil] The number of records per page.
97
+ # @return [ActiveRecord::Relation] The paginated scope.
98
+ def paginate(scope, page: self.page, per_page: self.per_page)
99
+ @page_info, paginated_scope = Queries::Paginate.call(scope, page:, per_page:)
100
+ paginated_scope
101
+ end
102
+
103
+ # Retrieves the page number from the request parameters.
104
+ #
105
+ # @return [String,nil] The page number if present, otherwise nil.
106
+ def page
107
+ params[:page]&.to_i || 1
108
+ end
109
+
110
+ # Retrieves the number of records per page from the request parameters or defaults to the
111
+ # controller's default value.
112
+ #
113
+ # @return [String,Integer] The number of records per page.
114
+ def per_page
115
+ params[:per_page].presence || self.class.default_per_page
116
+ end
117
+
118
+ # Retrieves pagination metadata.
119
+ #
120
+ # @return [Hash] Metadata about the pagination.
121
+ # @raise [ArgumentError] If pagination was not performed.
122
+ # @see Warped::Queries::Paginate#metadata
123
+ def page_info
124
+ return @page_info if @page_info.present?
125
+
126
+ raise ActionController::BadRequest, "Pagination was not performed"
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_support/core_ext/class/attribute"
5
+
6
+ module Warped
7
+ module Controllers
8
+ # Provides functionality for searching records from an +ActiveRecord::Relation+ in a controller.
9
+ #
10
+ # Example usage:
11
+ # class User < ApplicationRecord
12
+ # scope :search, ->(term) { where('name ILIKE ?', "%#{term}%") }
13
+ # end
14
+ #
15
+ # class UsersController < ApplicationController
16
+ # include Searchable
17
+ #
18
+ # def index
19
+ # scope = search(User.all)
20
+ # render json: scope
21
+ # end
22
+ # end
23
+ #
24
+ # Example requests:
25
+ # GET /users?q=John
26
+ # GET /users?q=John%20Doe
27
+ #
28
+ # There are cases where the search scope is not called +search+.
29
+ # In such cases, you can use the +searchable_by+ method to override the search scope.
30
+ #
31
+ # Example:
32
+ # class User < ApplicationRecord
33
+ # scope :search_case_sensitive, ->(term) { where('name LIKE ?', "%#{term}%") }
34
+ # end
35
+ #
36
+ # class UsersController < ApplicationController
37
+ # include Searchable
38
+ #
39
+ # searchable_by :search_case_sensitive
40
+ #
41
+ # def index
42
+ # scope = search(User.all)
43
+ # render json: scope
44
+ # end
45
+ # end
46
+ #
47
+ # When only overriding the search scope for a single action, you can pass the scope name as an argument to
48
+ # the +search+ method.
49
+ # Example:
50
+ # class UsersController < ApplicationController
51
+ # include Searchable
52
+ #
53
+ # def index
54
+ # # The default search scope name is overridden.
55
+ # # runs User.all.search_case_sensitive(search_term)
56
+ # scope = search(User.all, model_search_scope: :search_case_sensitive)
57
+ # render json: scope
58
+ # end
59
+ #
60
+ # def other_index
61
+ # # The default search scope name is used.
62
+ # # runs User.all.search(search_term)
63
+ # scope = search(User.all)
64
+ # render json: scope
65
+ # end
66
+ # end
67
+ #
68
+ # In addition, you can override the search term parameter name for the entire controller by implementing
69
+ # the +search_param+ method.
70
+ #
71
+ # Example:
72
+ # class UsersController < ApplicationController
73
+ # include Searchable
74
+ #
75
+ # def index
76
+ # scope = search(User.all)
77
+ # render json: scope
78
+ # end
79
+ #
80
+ # private
81
+ #
82
+ # def search_param
83
+ # :term
84
+ # end
85
+ # end
86
+ #
87
+ # Or you can override the search term parameter name for a single action by passing the parameter name as
88
+ # an argument to the +search_term+ method.
89
+ #
90
+ # Example:
91
+ # class UsersController < ApplicationController
92
+ # include Searchable
93
+ #
94
+ # def index
95
+ # # The default search term parameter name (+q+) is overridden.
96
+ # # GET /users?term=John
97
+ # scope = search(User.all, search_term: params[:term])
98
+ # render json: scope
99
+ # end
100
+ #
101
+ # def other_index
102
+ # # The default search term parameter name (+q+) is used.
103
+ # # GET /other_users?q=John
104
+ # scope = search(User.all)
105
+ # render json: scope
106
+ # end
107
+ # end
108
+ #
109
+ # Example requests:
110
+ # GET /users?term=John
111
+ # GET /other_users?q=John%20Doe
112
+ module Searchable
113
+ extend ActiveSupport::Concern
114
+
115
+ included do
116
+ class_attribute :model_search_scope, default: :search
117
+ class_attribute :search_param, default: :q
118
+ end
119
+
120
+ class_methods do
121
+ # Sets the search scope.
122
+ #
123
+ # @param scope [Symbol] The name of the search scope.
124
+ def searchable_by(scope = model_search_scope, param: :q)
125
+ self.model_search_scope = scope
126
+ self.search_param = param
127
+ end
128
+ end
129
+
130
+ # Searches records based on the provided scope and search term.
131
+ #
132
+ # @param scope [ActiveRecord::Relation] The scope to search within.
133
+ # @param search_term [String] The search term.
134
+ # @param model_search_scope [Symbol] The name of the search scope.
135
+ # @return [ActiveRecord::Relation] The result of the search.
136
+ def search(scope, search_term: self.search_term, model_search_scope: self.model_search_scope)
137
+ Queries::Search.call(scope, search_term:, model_search_scope:)
138
+ end
139
+
140
+ # Retrieves the search term from the request parameters.
141
+ #
142
+ # @return [String, nil] The search term if present, otherwise nil.
143
+ def search_term
144
+ params[search_param]&.strip
145
+ end
146
+
147
+ # Returns the name of the parameter used for searching.
148
+ #
149
+ # @return [Symbol] The search parameter name.
150
+ def search_param
151
+ self.class.search_param
152
+ end
153
+
154
+ # Returns the name of the model's search scope.
155
+ #
156
+ # @return [Symbol] The name of the model's search scope.
157
+ def model_search_scope
158
+ self.class.model_search_scope
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_support/core_ext/class/attribute"
5
+
6
+ module Warped
7
+ module Controllers
8
+ # Provides functionality for sorting records from an +ActiveRecord::Relation+ in a controller.
9
+ #
10
+ # Example usage:
11
+ #
12
+ # class UsersController < ApplicationController
13
+ # include Sortable
14
+ #
15
+ # sortable_by :name, :created_at, 'accounts.kind'
16
+ #
17
+ # def index
18
+ # scope = sort(User.joins(:account))
19
+ # render json: scope
20
+ # end
21
+ # end
22
+ #
23
+ # Example requests:
24
+ # GET /users?sort_key=name
25
+ # GET /users?sort_key=name&sort_direction=asc_nulls_first
26
+ # GET /users?sort_key=created_at&sort_direction=asc
27
+ #
28
+ # Renaming sort keys:
29
+ #
30
+ # In some cases, you may not want to expose the actual column names to the client.
31
+ # In such cases, you can rename the sort keys by passing a hash to the +sortable_by+ method.
32
+ #
33
+ # Example:
34
+ #
35
+ # class UsersController < ApplicationController
36
+ # include Sortable
37
+ #
38
+ # sortable_by :name, :created_at, 'accounts.referrals_count' => 'referrals'
39
+ #
40
+ # def index
41
+ # scope = sort(User.joins(:account))
42
+ # render json: scope
43
+ # end
44
+ # end
45
+ #
46
+ # Example requests:
47
+ # GET /users?sort_key=referrals&sort_direction=asc
48
+ #
49
+ # The +sort_key+ and +sort_direction+ parameters are optional. If not provided, the default sort key and direction
50
+ # will be used.
51
+ #
52
+ # The default sort key and sort direction can be set at a controller level using the +default_sort_direction+ and
53
+ # +default_sort_key+ class attributes.
54
+ module Sortable
55
+ extend ActiveSupport::Concern
56
+
57
+ included do
58
+ class_attribute :sort_fields, default: []
59
+ class_attribute :mapped_sort_fields, default: {}
60
+ class_attribute :default_sort_key, default: :id
61
+ class_attribute :default_sort_direction, default: :desc
62
+ end
63
+
64
+ class_methods do
65
+ # @param keys [Array<Symbol,String>]
66
+ # @param mapped_keys [Hash<Symbol,String>]
67
+ def sortable_by(*keys, **mapped_keys)
68
+ self.sort_fields = keys.map(&:to_s)
69
+ self.mapped_sort_fields = mapped_keys.with_indifferent_access
70
+ end
71
+ end
72
+
73
+ # @param scope [ActiveRecord::Relation] The scope to sort.
74
+ # @param sort_key [String, Symbol] The sort key.
75
+ # @param sort_direction [String, Symbol] The sort direction.
76
+ # @return [ActiveRecord::Relation]
77
+ def sort(scope, sort_key: self.sort_key, sort_direction: self.sort_direction)
78
+ return scope unless sort_key && sort_direction
79
+
80
+ validate_sort_key!
81
+
82
+ Queries::Sort.call(scope, sort_key:, sort_direction:)
83
+ end
84
+
85
+ protected
86
+
87
+ # @return [Symbol] The sort direction.
88
+ def sort_direction
89
+ @sort_direction ||= params[:sort_direction] || default_sort_direction
90
+ end
91
+
92
+ def sort_key
93
+ @sort_key ||= mapped_sort_fields.key(params[:sort_key]).presence ||
94
+ params[:sort_key] ||
95
+ default_sort_key
96
+ end
97
+
98
+ private
99
+
100
+ def validate_sort_key!
101
+ return if valid_sort_key?
102
+
103
+ possible_values = sort_fields + mapped_sort_fields.values
104
+ message = "Invalid sort key: #{sort_key}, must be one of #{possible_values}"
105
+ raise ActionController::BadRequest, message
106
+ end
107
+
108
+ def valid_sort_key?
109
+ sort_key == default_sort_key ||
110
+ sort_fields.include?(sort_key) ||
111
+ mapped_sort_fields[sort_key].present?
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_support/core_ext/class/attribute"
5
+
6
+ module Warped
7
+ module Controllers
8
+ # Provides functionality for filtering, sorting, searching, and paginating records
9
+ # from an +ActiveRecord::Relation+ in a controller.
10
+ #
11
+ # Example usage:
12
+ #
13
+ # class UsersController < ApplicationController
14
+ # include Tabulatable
15
+ #
16
+ # tabulatable_by :name, :email, :age, 'posts.created_at', 'posts.id' => 'post_id'
17
+ #
18
+ # def index
19
+ # users = User.left_joins(:posts).group(:id)
20
+ # users = tabulate(users)
21
+ # render json: users, meta: tabulate_info
22
+ # end
23
+ # end
24
+ #
25
+ # There are cases where not all fields should be filterable or sortable.
26
+ # In such cases, you can use the `filterable_by` and `sortable_by` methods to
27
+ # override the tabulate fields.
28
+ #
29
+ # Example:
30
+ #
31
+ # class PostsController < ApplicationController
32
+ # include Tabulatable
33
+ #
34
+ # tabulatable_by :title, :content, :created_at, user: 'users.name'
35
+ # filterable_by :created_at, user: 'users.name'
36
+ #
37
+ # def index
38
+ # posts = Post.left_joins(:user).group(:id)
39
+ # posts = tabulate(posts)
40
+ # render json: posts, meta: tabulate_info
41
+ # end
42
+ # end
43
+ module Tabulatable
44
+ extend ActiveSupport::Concern
45
+
46
+ included do
47
+ include Filterable
48
+ include Sortable
49
+ include Searchable
50
+ include Pageable
51
+
52
+ class_attribute :tabulate_fields, default: []
53
+ class_attribute :mapped_tabulate_fields, default: []
54
+ end
55
+
56
+ class_methods do
57
+ def tabulatable_by(*keys, **mapped_keys)
58
+ self.tabulate_fields = keys
59
+ self.mapped_tabulate_fields = mapped_keys.to_a
60
+
61
+ filterable_by(*keys, **mapped_keys) if filter_fields.empty? && mapped_filter_fields.empty?
62
+ sortable_by(*keys, **mapped_keys) if sort_fields.empty? && mapped_sort_fields.empty?
63
+ end
64
+ end
65
+
66
+ # @param scope [ActiveRecord::Relation]
67
+ # @return [ActiveRecord::Relation]
68
+ # @see Filterable#filter
69
+ # @see Sortable#sort
70
+ # @see Searchable#search
71
+ # @see Pageable#paginate
72
+ def tabulate(scope)
73
+ scope = filter(scope)
74
+ scope = search(scope)
75
+ scope = sort(scope)
76
+ paginate(scope)
77
+ end
78
+
79
+ # @return [Hash]
80
+ def tabulate_info
81
+ {
82
+ filters: filter_conditions(*filter_fields, *mapped_filter_fields),
83
+ sorts: sort_conditions(*sort_fields, *mapped_sort_fields),
84
+ search_term:,
85
+ search_param:,
86
+ page_info:
87
+ }
88
+ end
89
+ end
90
+ end
91
+ end
File without changes
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+ require "active_support/core_ext/string/inflections"
5
+
6
+ module Warped
7
+ module Jobs
8
+ ##
9
+ # Base class for all jobs in the application.
10
+ # This class is used to provide a common interface for all jobs used by Warped
11
+ # and to allow for easy configuration of the parent class.
12
+ # By default, the parent class is set to +ActiveJob::Base+.
13
+ # @see Warped
14
+ #
15
+ # @example Change the parent class for Warped::Jobs::Base
16
+ # Warped.configure do |config|
17
+ # config.base_job_parent_class = "ApplicationJob"
18
+ # end
19
+ #
20
+ class Base < Warped.base_job_parent_class.constantize
21
+ end
22
+ end
23
+ end