boosted-rails 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,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: []