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