boosted-rails 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "boosted/services/base"
4
+
5
+ module Boosted
6
+ module Queries
7
+ ##
8
+ # Filter a scope by a set of conditions
9
+ #
10
+ # This class provides a simple interface for filtering a scope by a set of conditions.
11
+ # The conditions are passed as an array of hashes, where each hash contains the following
12
+ # keys:
13
+ # - +relation+: the relation to use for the filter (e.g. "=", ">", "in", etc.)
14
+ # - +field+: the field to filter by
15
+ # - +value+: the value to filter by
16
+ #
17
+ #
18
+ # @example Filter a scope by a set of conditions
19
+ # Boosted::Queries::Filter.call(User.all, filter_conditions: [
20
+ # { relation: "=", field: :first_name, value: "John" },
21
+ # { relation: ">", field: :age, value: 18 }
22
+ # ])
23
+ # # => #<ActiveRecord::Relation [...]>
24
+ #
25
+ # @see RELATIONS
26
+ # To see the list of available relations, check the +RELATIONS+ constant.
27
+ class Filter
28
+ RELATIONS = %w[= != > >= < <= between in not_in starts_with ends_with contains is_null is_not_null].freeze
29
+
30
+ # @param scope [ActiveRecord::Relation] the scope to filter
31
+ # @param filter_conditions [Array<Hash>] the conditions to filter by
32
+ # @return [ActiveRecord::Relation] the filtered scope
33
+ def self.call(scope, filter_conditions:)
34
+ new(scope, filter_conditions:).call
35
+ end
36
+
37
+ # @param scope [ActiveRecord::Relation] the scope to filter
38
+ # @param filter_conditions [Array<Hash>] the conditions to filter by
39
+ # @return [ActiveRecord::Relation] the filtered scope
40
+ def initialize(scope, filter_conditions:)
41
+ super()
42
+ @scope = scope
43
+ @filter_conditions = filter_conditions
44
+ end
45
+
46
+ def call
47
+ filter_conditions.reduce(scope) do |scope, filter_condition|
48
+ relation = filter_condition[:relation].to_s
49
+ field = filter_condition[:field]
50
+ value = filter_condition[:value]
51
+
52
+ validate_relation!(relation)
53
+ filtered_scope(scope, relation, field, value)
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ attr_reader :scope, :filter_conditions
60
+
61
+ def validate_relation!(relation)
62
+ return if RELATIONS.include?(relation)
63
+
64
+ raise ArgumentError, "relation must be one of: #{RELATIONS.join(", ")}"
65
+ end
66
+
67
+ def filtered_scope(scope, relation, field, value)
68
+ case relation
69
+ when "=", "in"
70
+ scope.where(field => value)
71
+ when "!=", "not_in"
72
+ scope.where.not(field => value)
73
+ when "between"
74
+ scope.where("#{field} BETWEEN ? AND ?", value.first, value.last)
75
+ when "starts_with"
76
+ scope.where("#{field} LIKE ?", "#{value}%")
77
+ when "ends_with"
78
+ scope.where("#{field} LIKE ?", "%#{value}")
79
+ when "contains"
80
+ scope.where("#{field} LIKE ?", "%#{value}%")
81
+ when "is_null"
82
+ scope.where(field => nil)
83
+ when "is_not_null"
84
+ scope.where.not(field => nil)
85
+ else # '>', '>=', '<', '<='
86
+ scope.where("#{field} #{relation} ?", value)
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "boosted/services/base"
4
+
5
+ module Boosted
6
+ module Queries
7
+ ##
8
+ # Paginate a scope
9
+ #
10
+ # This class provides a way to paginate a scope and return the metadata.
11
+ #
12
+ # @example
13
+ # Boosted::Queries::Paginate.call(User.all, page: 2, per_page: 10)
14
+ # # => [{ total_count: 100, total_pages: 10, next_page: 3, prev_page: 1, page: 2, per_page: 10 }, <ActiveRecord::Relation [...]>]
15
+ #
16
+ # @see MAX_PER_PAGE
17
+ # @see DEFAULT_PER_PAGE
18
+ # To see the maximum number of records per page and the default number of records per page,
19
+ # check the +MAX_PER_PAGE+ and +DEFAULT_PER_PAGE+ constants.
20
+ class Paginate
21
+ MAX_PER_PAGE = 100
22
+ DEFAULT_PER_PAGE = 10
23
+
24
+ # @param scope [ActiveRecord::Relation] the scope to paginate
25
+ # @param page [Integer] the page number
26
+ # @param per_page [Integer] the number of records per page
27
+ # @return [Array<Hash, ActiveRecord::Relation>] the metadata and the paginated scope
28
+ def self.call(scope, page: nil, per_page: nil)
29
+ new(scope, page:, per_page:).call
30
+ end
31
+
32
+ # @param scope [ActiveRecord::Relation] the scope to paginate
33
+ # @param page [Integer] the page number
34
+ # @param per_page [Integer] the number of records per page
35
+ # @return [Array<Hash, ActiveRecord::Relation>] the metadata and the paginated scope
36
+ def initialize(scope, page: nil, per_page: nil)
37
+ super()
38
+ @scope = scope
39
+ @page = page
40
+ @per_page = per_page
41
+ end
42
+
43
+ def call
44
+ offset = (page - 1) * per_page
45
+ paginated_scope = scope.limit(per_page).offset(offset)
46
+
47
+ [metadata(scope), paginated_scope]
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :scope
53
+
54
+ def page
55
+ @page.present? ? @page.to_i : 1
56
+ end
57
+
58
+ def per_page
59
+ @per_page.present? ? [@per_page.to_i, MAX_PER_PAGE].min : DEFAULT_PER_PAGE
60
+ end
61
+
62
+ def collection_count(scope)
63
+ if scope.group_values.empty?
64
+ scope.count(:all)
65
+ else
66
+ sql = Arel.star.count.over(Arel::Nodes::Grouping.new([]))
67
+ scope.unscope(:order).pick(sql).to_i
68
+ end
69
+ end
70
+
71
+ def metadata(scope)
72
+ total_count = collection_count(scope)
73
+ total_pages = pages(total_count, per_page)
74
+ next_page = next_page(page, total_pages)
75
+ prev_page = prev_page(page)
76
+
77
+ {
78
+ total_count:,
79
+ total_pages:,
80
+ next_page:,
81
+ prev_page:,
82
+ page:,
83
+ per_page:
84
+ }
85
+ end
86
+
87
+ def pages(count, per_page)
88
+ (count.to_f / per_page).ceil
89
+ end
90
+
91
+ def next_page(page, pages)
92
+ page < pages ? page + 1 : nil
93
+ end
94
+
95
+ def prev_page(page)
96
+ page > 1 ? page - 1 : nil
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "boosted/services/base"
4
+
5
+ module Boosted
6
+ module Queries
7
+ ##
8
+ # Search for records in a scope
9
+ #
10
+ # This class provides a way to search for records in a scope.
11
+ # It uses a scope in the model to perform the search.
12
+ #
13
+ # @example
14
+ # Boosted::Queries::Search.call(User.all, search_term: "John")
15
+ # # => #<ActiveRecord::Relation [...]>
16
+ #
17
+ # By default, it uses the +search+ scope in the model to perform the search.
18
+ # You can also specify a different scope to use for searching, like so:
19
+ # @example
20
+ # Boosted::Queries::Search.call(User.all, search_term: "John", model_search_scope: :search_by_name)
21
+ # # => #<ActiveRecord::Relation [...]>
22
+ #
23
+ class Search
24
+ # @param scope [ActiveRecord::Relation] the scope to search in
25
+ # @param search_term [String] the term to search for
26
+ # @param model_search_scope [String, Symbol] the name of the scope to use for searching in the model
27
+ # @return [ActiveRecord::Relation] the searched scope
28
+ def self.call(scope, search_term:, model_search_scope: :search)
29
+ new(scope, search_term:, model_search_scope:).call
30
+ end
31
+
32
+ # @param scope [ActiveRecord::Relation] the scope to search in
33
+ # @param search_term [String] the term to search for
34
+ # @param model_search_scope [String, Symbol] the name of the scope to use for searching in the model
35
+ # @return [ActiveRecord::Relation] the searched scope
36
+ def initialize(scope, search_term:, model_search_scope: :search)
37
+ super()
38
+ @scope = scope
39
+ @search_term = search_term
40
+ @model_search_scope = model_search_scope
41
+ end
42
+
43
+ def call
44
+ return scope if search_term.blank?
45
+
46
+ validate_model_search_scope!
47
+
48
+ scope.public_send(model_search_scope, search_term)
49
+ end
50
+
51
+ private
52
+
53
+ attr_reader :scope, :search_term, :model_search_scope
54
+
55
+ def validate_model_search_scope!
56
+ return if scope.respond_to?(model_search_scope)
57
+
58
+ raise ArgumentError, "#{scope.klass} does not respond to #{model_search_scope}"
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "boosted/services/base"
4
+
5
+ module Boosted
6
+ module Queries
7
+ ##
8
+ # Sorts a scope by a given key and direction
9
+ #
10
+ # This class provides a way to sort a scope by a given key and direction.
11
+ # It uses the +reorder+ method to sort the scope, so it will remove any existing order.
12
+ #
13
+ # @example Sort a scope by a key and direction
14
+ # Boosted::Queries::Sort.call(User.all, sort_key: "name", sort_direction: "asc")
15
+ # # => #<ActiveRecord::Relation [...]>
16
+ #
17
+ # @example Sort a scope by a key with format +table.column+ and direction
18
+ # Boosted::Queries::Sort.call(User.join(:accounts), sort_key: "accounts.updated_at", sort_direction: "asc")
19
+ # # => #<ActiveRecord::Relation [...]>
20
+ class Sort
21
+ SORT_DIRECTIONS = %w[asc desc].freeze
22
+ NULLS_SORT_DIRECTION = %w[asc_nulls_first asc_nulls_last desc_nulls_first desc_nulls_last].freeze
23
+
24
+ # @param scope [ActiveRecord::Relation] the scope to sort
25
+ # @param sort_key [String, Symbol] the key to sort by
26
+ # @param sort_direction [String, Symbol] the direction to sort by
27
+ # @return [ActiveRecord::Relation] the sorted scope
28
+ def self.call(scope, sort_key:, sort_direction: :desc)
29
+ new(scope, sort_key:, sort_direction:).call
30
+ end
31
+
32
+ # @param scope [ActiveRecord::Relation] the scope to sort
33
+ # @param sort_key [String, Symbol] the key to sort by
34
+ # @param sort_direction [String, Symbol] the direction to sort by
35
+ # @return [ActiveRecord::Relation] the sorted scope
36
+ def initialize(scope, sort_key:, sort_direction: :desc)
37
+ super()
38
+ @scope = scope
39
+ @sort_key = sort_key.to_s
40
+ @sort_direction = sort_direction.to_s.downcase
41
+ end
42
+
43
+ # @return [ActiveRecord::Relation]
44
+ def call
45
+ validate_sort_direction!(sort_direction)
46
+
47
+ order = if NULLS_SORT_DIRECTION.include?(sort_direction)
48
+ arel_order
49
+ else
50
+ { sort_key => sort_direction }
51
+ end
52
+
53
+ scope.reorder(order)
54
+ end
55
+
56
+ private
57
+
58
+ attr_reader :scope, :sort_key, :sort_direction
59
+
60
+ def validate_sort_direction!(sort_direction)
61
+ return if SORT_DIRECTIONS.include?(sort_direction) || NULLS_SORT_DIRECTION.include?(sort_direction)
62
+
63
+ raise ArgumentError, "Invalid sort direction: #{sort_direction}, must be one of #{SORT_DIRECTIONS}"
64
+ end
65
+
66
+ def arel_order
67
+ arel_table.send(order_nulls_direction.first).send("nulls_#{order_nulls_direction.last}")
68
+ end
69
+
70
+ def arel_table
71
+ sort_key_arr = sort_key.split(".")
72
+
73
+ if sort_key_arr.size == 1
74
+ # sort_key has format 'column'
75
+ scope.klass.arel_table[sort_key]
76
+ else
77
+ # sort_key has format 'table.column'
78
+ Arel::Table.new(sort_key_arr.first)[sort_key_arr.last]
79
+ end
80
+ end
81
+
82
+ def order_nulls_direction
83
+ @order_nulls_direction ||= begin
84
+ order_nulls_dir = sort_direction.split("_")
85
+ [order_nulls_dir.first, order_nulls_dir.last]
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boosted
4
+ class Railtie < Rails::Railtie
5
+ rake_tasks do
6
+ load "boosted/tasks/boosted.rake"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "boosted/jobs/base"
4
+
5
+ module Boosted
6
+ module Services
7
+ # Base class for all services
8
+ #
9
+ # This class provides a simple interface for creating services.
10
+ # It also provides a way to call services asynchronously using +ActiveJob+.
11
+ #
12
+ # @example Create a service
13
+ # class PrintService < Boosted::Services::Base
14
+ # def call(message)
15
+ # puts message
16
+ # end
17
+ # end
18
+ #
19
+ # SomeService.call('Hello World') # => "Hello World"
20
+ # SomeService.new('Hello World').call # => "Hello World"
21
+ #
22
+ # @example Call a service asynchronously
23
+ # class SlowService < Boosted::Services::Base
24
+ # enable_job!
25
+ #
26
+ # def call(message)
27
+ # sleep 5
28
+ # puts message
29
+ # end
30
+ # end
31
+ #
32
+ # SlowService.call_later('Hello World') # => "Hello World" after 5 seconds
33
+ # SlowService::Job.perform_later('Hello World') # => "Hello World" after 5 seconds
34
+ class Base
35
+ def self.call(...)
36
+ new(...).call
37
+ end
38
+
39
+ def self.call_later(...)
40
+ unless @job_enabled
41
+ message = "#{self.class.name}::Job is not implemented, make sure to call enable_job! in the service"
42
+ raise NotImplementedError, message
43
+ end
44
+
45
+ self::Async.perform_later(...)
46
+ end
47
+
48
+ def call
49
+ raise NotImplementedError, "#{self.class.name}#call not implemented"
50
+ end
51
+
52
+ # This method is used to create a Job class that inherits from +Boosted::Jobs::Base+
53
+ # and calls the service asynchronously.
54
+ # @example Enable async for a service
55
+ # class SomeService < Bosted::Services::Base
56
+ # enable_job!
57
+ #
58
+ # def call(...)
59
+ # # service logic
60
+ # end
61
+ # end
62
+ #
63
+ # # SomeService.call_later(...)
64
+ # # SomeService::Job.perform_later(...)
65
+ def self.enable_job!
66
+ @job_enabled = true
67
+
68
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
69
+ # class SomeService
70
+ # class Job < Boosted::Jobs::Base
71
+ #
72
+ # def perform(...)
73
+ # SomeService.call(...)
74
+ # end
75
+ # end
76
+ #
77
+ # def self.call_later(...)
78
+ # Job.perform_later(...)
79
+ # end
80
+ # end
81
+ class Job < Boosted::Jobs::Base
82
+ def perform(...)
83
+ #{name}.call(...)
84
+ end
85
+ end
86
+
87
+ def self.call_later(...)
88
+ Job.perform_later(...)
89
+ end
90
+ RUBY
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :boosted do
4
+ task :install do
5
+ initializer_file = File.join(File.dirname(__FILE__), "templates", "boosted.rb.tt")
6
+ destination = "config/initializers/boosted.rb"
7
+
8
+ FileUtils.cp(initializer_file, destination)
9
+ end
10
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ Boosted.configure do |config|
4
+ # Change the parent class of Boosted::Job::Base.
5
+ # This is useful if you want to use a different ActiveJob parent class.
6
+ # Default: 'ActiveJob::Base'
7
+ # config.base_job_parent_class = 'ActiveJob::Base'
8
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boosted
4
+ VERSION = "0.1.0"
5
+ end
data/lib/boosted.rb ADDED
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/configurable"
4
+
5
+ ##
6
+ # The main module for the Boosted gem.
7
+ #
8
+ # This module is used to provide configuration options for the gem.
9
+ #
10
+ # @example Configurations
11
+ # # config/initializers/boosted.rb
12
+ #
13
+ # Boosted.configure do |config|
14
+ # config.base_job_parent_class = "ApplicationJob" # Default: "ActiveJob::Base"
15
+ # end
16
+ #
17
+ # @see https://api.rubyonrails.org/classes/ActiveSupport/Configurable.html
18
+ #
19
+ module Boosted
20
+ include ActiveSupport::Configurable
21
+
22
+ config_accessor :base_job_parent_class, instance_accessor: false, default: "ActiveJob::Base"
23
+ end
24
+
25
+ # load all files in lib/boosted except for the tasks
26
+ require "zeitwerk"
27
+ loader = Zeitwerk::Loader.for_gem
28
+ loader.setup
29
+ loader.ignore("lib/boosted/tasks")
30
+ loader.ignore("lib/boosted/railtie.rb") unless defined?(Rails::Railtie)
31
+ loader.eager_load
data/sig/boosted.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Boosted
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,108 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: boosted-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Juan Aparicio
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-02-26 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '6.0'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '8.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '6.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '8.0'
33
+ - !ruby/object:Gem::Dependency
34
+ name: zeitwerk
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.4'
40
+ type: :runtime
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '2.4'
47
+ description:
48
+ email:
49
+ - apariciojuan30@gmail.com
50
+ executables: []
51
+ extensions: []
52
+ extra_rdoc_files: []
53
+ files:
54
+ - ".rspec"
55
+ - ".rubocop.yml"
56
+ - CHANGELOG.md
57
+ - CODE_OF_CONDUCT.md
58
+ - Gemfile
59
+ - Gemfile.lock
60
+ - LICENSE.txt
61
+ - README.md
62
+ - Rakefile
63
+ - boosted.gemspec
64
+ - lib/boosted.rb
65
+ - lib/boosted/controllers/filterable.rb
66
+ - lib/boosted/controllers/pageable.rb
67
+ - lib/boosted/controllers/searchable.rb
68
+ - lib/boosted/controllers/sortable.rb
69
+ - lib/boosted/controllers/tabulatable.rb
70
+ - lib/boosted/emails/.keep
71
+ - lib/boosted/jobs/base.rb
72
+ - lib/boosted/queries/filter.rb
73
+ - lib/boosted/queries/paginate.rb
74
+ - lib/boosted/queries/search.rb
75
+ - lib/boosted/queries/sort.rb
76
+ - lib/boosted/railtie.rb
77
+ - lib/boosted/services/base.rb
78
+ - lib/boosted/tasks/bosted.rake
79
+ - lib/boosted/tasks/templates/initializer.rb
80
+ - lib/boosted/version.rb
81
+ - sig/boosted.rbs
82
+ homepage: https://github.com/gogrow-dev/boosted
83
+ licenses:
84
+ - MIT
85
+ metadata:
86
+ homepage_uri: https://github.com/gogrow-dev/boosted
87
+ source_code_uri: https://github.com/gogrow-dev/boosted
88
+ rubygems_mfa_required: 'true'
89
+ post_install_message:
90
+ rdoc_options: []
91
+ require_paths:
92
+ - lib
93
+ required_ruby_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: 3.1.0
98
+ required_rubygems_version: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ requirements: []
104
+ rubygems_version: 3.4.10
105
+ signing_key:
106
+ specification_version: 4
107
+ summary: Set of modules to boost your Ruby on Rails development
108
+ test_files: []