better_service 1.1.0 → 2.0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 622ac1a705ab117672d0e9c4896e65b66585c747023d1e00b34824e9c35606a0
4
- data.tar.gz: f4c6f5b6ae7baaa3f61eeb123d4a2a493a7c8acc2ff835c6891396ed543efa1c
3
+ metadata.gz: 6ce89074c072a76a05a669dc079d3d4fbf1ba99a7f758f4ae45c9640b335c4c4
4
+ data.tar.gz: 34bcc32228b8c358f21f4713e783ef3f17e7e38047ab0116abad5a6e0d2a1aec
5
5
  SHA512:
6
- metadata.gz: d4f06ae88607c9d4b6eec865811abbba0f80403914fbcac556ad3949a395ff0b43e16e6663c652959cc950fcb89d64be427eb6e8c20b19ed0aa6b1771012e7b0
7
- data.tar.gz: d1a69946471e0e2f5bd7067889c4f675d0ea94a198d69f9e877283fa5166664f49283a45fd52f282288b690670a6d095f562935f3503c86dce81633d03858e74
6
+ metadata.gz: 1479b3e94a12463614adb2022aa8a8d725939224715c5f5ae876be036f96da3c3a63171d6ca1cda4dc10ac3c6e97f98a615a45b952a9441066f24f064161521d
7
+ data.tar.gz: 2a985c1abfdbb8dc352fdc34351439f35bf0f09ed8784593e04e113a3bd2a38c1d172a3fb1f69836ab24cfd25881b59b52a5c54e5a4a3a6c8b8eacb1fcdb31f8
@@ -7,6 +7,9 @@ module BetterService
7
7
  # the Cacheable concern. It provides methods to invalidate cache keys
8
8
  # for specific users, contexts, or globally.
9
9
  #
10
+ # Supports cascading invalidation through INVALIDATION_MAP - when a context
11
+ # is invalidated, all related contexts are also invalidated automatically.
12
+ #
10
13
  # @example Invalidate cache for a specific context and user
11
14
  # BetterService::CacheService.invalidate_for_context(current_user, "products")
12
15
  #
@@ -15,61 +18,169 @@ module BetterService
15
18
  #
16
19
  # @example Invalidate all cache for a user
17
20
  # BetterService::CacheService.invalidate_for_user(current_user)
21
+ #
22
+ # @example Configure invalidation map
23
+ # BetterService::CacheService.configure_invalidation_map(
24
+ # 'products' => %w[products inventory reports],
25
+ # 'orders' => %w[orders products reports]
26
+ # )
18
27
  class CacheService
28
+ # Default invalidation map - can be customized via configure_invalidation_map
29
+ # Maps primary context to array of contexts that should be invalidated together
30
+ #
31
+ # @return [Hash<String, Array<String>>] Context invalidation mappings
32
+ @invalidation_map = {}
33
+
19
34
  class << self
35
+ # Get the current invalidation map
36
+ #
37
+ # @return [Hash<String, Array<String>>] Current invalidation mappings
38
+ attr_reader :invalidation_map
39
+
40
+ # Configure the invalidation map for cascading cache invalidation
41
+ #
42
+ # The invalidation map defines which cache contexts should be invalidated
43
+ # together. When a primary context is invalidated, all related contexts
44
+ # in the map are also invalidated.
45
+ #
46
+ # @param map [Hash<String, Array<String>>] Invalidation mappings
47
+ # @return [void]
48
+ #
49
+ # @example Configure invalidation relationships
50
+ # BetterService::CacheService.configure_invalidation_map(
51
+ # 'products' => %w[products inventory reports],
52
+ # 'orders' => %w[orders products reports],
53
+ # 'users' => %w[users orders reports],
54
+ # 'categories' => %w[categories products reports],
55
+ # 'inventory' => %w[inventory products reports]
56
+ # )
57
+ def configure_invalidation_map(map)
58
+ @invalidation_map = map.transform_keys(&:to_s).transform_values do |contexts|
59
+ Array(contexts).map(&:to_s)
60
+ end.freeze
61
+ end
62
+
63
+ # Add entries to the invalidation map without replacing existing ones
64
+ #
65
+ # @param entries [Hash<String, Array<String>>] New invalidation mappings to add
66
+ # @return [void]
67
+ #
68
+ # @example Add new context invalidation rules
69
+ # BetterService::CacheService.add_invalidation_rules(
70
+ # 'payments' => %w[payments statistics invoices]
71
+ # )
72
+ def add_invalidation_rules(entries)
73
+ new_entries = entries.transform_keys(&:to_s).transform_values do |contexts|
74
+ Array(contexts).map(&:to_s)
75
+ end
76
+ @invalidation_map = (@invalidation_map || {}).merge(new_entries).freeze
77
+ end
78
+
79
+ # Get all contexts that should be invalidated for a given context
80
+ #
81
+ # If the context exists in the invalidation map, returns all mapped contexts.
82
+ # Otherwise, returns an array containing just the original context.
83
+ #
84
+ # @param context [String, Symbol] The primary context
85
+ # @return [Array<String>] All contexts to invalidate
86
+ #
87
+ # @example
88
+ # contexts_for('products') # => ['products', 'inventory', 'reports']
89
+ # contexts_for('unknown') # => ['unknown']
90
+ def contexts_to_invalidate(context)
91
+ context_str = context.to_s
92
+ (@invalidation_map || {})[context_str] || [context_str]
93
+ end
94
+
20
95
  # Invalidate cache for a specific context and user
21
96
  #
22
97
  # Deletes all cache keys that match the pattern for the given user and context.
98
+ # Uses cascading invalidation - if the context exists in the invalidation map,
99
+ # all related contexts will also be invalidated.
100
+ #
23
101
  # This is useful when data changes that affects a specific user's cached results.
24
102
  #
25
103
  # @param user [Object] The user whose cache should be invalidated
26
104
  # @param context [String] The context name (e.g., "products", "sidebar")
27
105
  # @param async [Boolean] Whether to perform invalidation asynchronously
106
+ # @param cascade [Boolean] Whether to use cascading invalidation (default: true)
28
107
  # @return [Integer] Number of keys deleted (if supported by cache store)
29
108
  #
30
- # @example
109
+ # @example Basic invalidation
31
110
  # # After creating a product, invalidate products cache for user
32
111
  # BetterService::CacheService.invalidate_for_context(user, "products")
33
- def invalidate_for_context(user, context, async: false)
112
+ #
113
+ # @example With cascading (if map configured for orders -> [orders, products, reports])
114
+ # BetterService::CacheService.invalidate_for_context(user, "orders")
115
+ # # Invalidates: orders, products, reports caches
116
+ #
117
+ # @example Without cascading
118
+ # BetterService::CacheService.invalidate_for_context(user, "orders", cascade: false)
119
+ # # Invalidates: only orders cache
120
+ def invalidate_for_context(user, context, async: false, cascade: true)
34
121
  return 0 unless user && context && !context.to_s.strip.empty?
35
122
 
36
- pattern = build_user_context_pattern(user, context)
123
+ # Get all contexts to invalidate (cascading or single)
124
+ contexts = cascade ? contexts_to_invalidate(context) : [context.to_s]
125
+ total_deleted = 0
37
126
 
38
- if async
39
- invalidate_async(pattern)
40
- 0
41
- else
42
- result = delete_matched(pattern)
43
- # Ensure we return Integer, not Array
44
- result.is_a?(Array) ? result.size : (result || 0)
127
+ contexts.each do |ctx|
128
+ pattern = build_user_context_pattern(user, ctx)
129
+
130
+ if async
131
+ invalidate_async(pattern)
132
+ else
133
+ result = delete_matched(pattern)
134
+ count = result.is_a?(Array) ? result.size : (result || 0)
135
+ total_deleted += count
136
+ end
45
137
  end
138
+
139
+ log_cascading_invalidation(context, contexts) if cascade && contexts.size > 1
140
+ total_deleted
46
141
  end
47
142
 
48
143
  # Invalidate cache globally for a context
49
144
  #
50
145
  # Deletes all cache keys for the given context across all users.
146
+ # Uses cascading invalidation - if the context exists in the invalidation map,
147
+ # all related contexts will also be invalidated.
148
+ #
51
149
  # This is useful when data changes that affects everyone (e.g., global settings).
52
150
  #
53
151
  # @param context [String] The context name
54
152
  # @param async [Boolean] Whether to perform invalidation asynchronously
153
+ # @param cascade [Boolean] Whether to use cascading invalidation (default: true)
55
154
  # @return [Integer] Number of keys deleted (if supported by cache store)
56
155
  #
57
- # @example
156
+ # @example Basic global invalidation
58
157
  # # After updating global sidebar settings
59
158
  # BetterService::CacheService.invalidate_global("sidebar")
60
- def invalidate_global(context, async: false)
159
+ #
160
+ # @example With cascading
161
+ # BetterService::CacheService.invalidate_global("orders")
162
+ # # Invalidates: orders, products, reports caches globally
163
+ def invalidate_global(context, async: false, cascade: true)
61
164
  return 0 unless context && !context.to_s.strip.empty?
62
165
 
63
- pattern = build_global_context_pattern(context)
166
+ # Get all contexts to invalidate (cascading or single)
167
+ contexts = cascade ? contexts_to_invalidate(context) : [context.to_s]
168
+ total_deleted = 0
64
169
 
65
- if async
66
- invalidate_async(pattern)
67
- 0
68
- else
69
- result = delete_matched(pattern)
70
- # Ensure we return Integer, not Array
71
- result.is_a?(Array) ? result.size : (result || 0)
170
+ contexts.each do |ctx|
171
+ pattern = build_global_context_pattern(ctx)
172
+
173
+ if async
174
+ invalidate_async(pattern)
175
+ else
176
+ result = delete_matched(pattern)
177
+ count = result.is_a?(Array) ? result.size : (result || 0)
178
+ total_deleted += count
179
+ end
72
180
  end
181
+
182
+ log_cascading_invalidation(context, contexts, global: true) if cascade && contexts.size > 1
183
+ total_deleted
73
184
  end
74
185
 
75
186
  # Invalidate all cache for a specific user
@@ -175,7 +286,9 @@ module BetterService
175
286
  {
176
287
  cache_store: Rails.cache.class.name,
177
288
  supports_pattern_deletion: supports_delete_matched?,
178
- supports_async: defined?(ActiveJob) ? true : false
289
+ supports_async: defined?(ActiveJob) ? true : false,
290
+ invalidation_map_configured: (@invalidation_map || {}).any?,
291
+ invalidation_map_contexts: (@invalidation_map || {}).keys
179
292
  }
180
293
  end
181
294
 
@@ -288,6 +401,20 @@ module BetterService
288
401
 
289
402
  Rails.logger.warn "[BetterService::CacheService] #{message}"
290
403
  end
404
+
405
+ # Log cascading invalidation
406
+ #
407
+ # @param primary_context [String] The primary context that triggered invalidation
408
+ # @param all_contexts [Array<String>] All contexts that were invalidated
409
+ # @param global [Boolean] Whether this was a global invalidation
410
+ # @return [void]
411
+ def log_cascading_invalidation(primary_context, all_contexts, global: false)
412
+ return unless defined?(Rails) && Rails.logger
413
+
414
+ scope = global ? "globally" : "for user"
415
+ Rails.logger.info "[BetterService::CacheService] Cascading invalidation #{scope}: " \
416
+ "'#{primary_context}' -> [#{all_contexts.join(', ')}]"
417
+ end
291
418
  end
292
419
 
293
420
  # ActiveJob for async cache invalidation
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterService
4
+ module Concerns
5
+ module Serviceable
6
+ # RepositoryAware - DSL for declaring repository dependencies in services
7
+ #
8
+ # This concern provides a clean way to declare repository dependencies
9
+ # in service classes, promoting the repository pattern and enforcing
10
+ # separation between business logic and data access.
11
+ #
12
+ # @example Basic usage
13
+ # class Products::CreateService < BetterService::Services::CreateService
14
+ # include BetterService::Concerns::Serviceable::RepositoryAware
15
+ #
16
+ # repository :product
17
+ #
18
+ # process_with do |data|
19
+ # { resource: product_repository.create!(params) }
20
+ # end
21
+ # end
22
+ #
23
+ # @example With custom class name
24
+ # class Bookings::AcceptService < BetterService::Services::ActionService
25
+ # include BetterService::Concerns::Serviceable::RepositoryAware
26
+ #
27
+ # repository :booking, class_name: "Bookings::BookingRepository"
28
+ # repository :user, class_name: "Users::UserRepository", as: :user_repo
29
+ #
30
+ # search_with do
31
+ # { booking: booking_repository.search({ id_eq: params[:id] }, limit: 1) }
32
+ # end
33
+ # end
34
+ #
35
+ # @example Multiple repositories shorthand
36
+ # class Dashboard::IndexService < BetterService::Services::IndexService
37
+ # include BetterService::Concerns::Serviceable::RepositoryAware
38
+ #
39
+ # repositories :user, :booking, :payment
40
+ # end
41
+ #
42
+ module RepositoryAware
43
+ extend ActiveSupport::Concern
44
+
45
+ class_methods do
46
+ # Declare a repository dependency
47
+ #
48
+ # Creates a memoized private accessor method for the repository.
49
+ #
50
+ # @param name [Symbol] Base name for the repository
51
+ # @param class_name [String, nil] Full class name of the repository
52
+ # If nil, derives from name: :product -> "ProductRepository"
53
+ # @param as [Symbol, nil] Custom accessor name
54
+ # If nil, uses "#{name}_repository"
55
+ # @return [void]
56
+ #
57
+ # @example Standard naming
58
+ # repository :product # -> product_repository -> ProductRepository
59
+ #
60
+ # @example Custom class
61
+ # repository :booking, class_name: "Bookings::BookingRepository"
62
+ #
63
+ # @example Custom accessor
64
+ # repository :user, as: :users # -> users -> UserRepository
65
+ def repository(name, class_name: nil, as: nil)
66
+ accessor_name = as || "#{name}_repository"
67
+ repo_class_name = class_name || "#{name.to_s.camelize}Repository"
68
+
69
+ define_method(accessor_name) do
70
+ ivar = "@#{accessor_name}"
71
+ instance_variable_get(ivar) || begin
72
+ klass = repo_class_name.constantize
73
+ instance_variable_set(ivar, klass.new)
74
+ end
75
+ end
76
+
77
+ private accessor_name
78
+ end
79
+
80
+ # Declare multiple repository dependencies
81
+ #
82
+ # Shorthand for declaring multiple repositories with standard naming.
83
+ #
84
+ # @param names [Array<Symbol>] Repository names
85
+ # @return [void]
86
+ #
87
+ # @example
88
+ # repositories :user, :booking, :payment
89
+ # # Equivalent to:
90
+ # # repository :user
91
+ # # repository :booking
92
+ # # repository :payment
93
+ def repositories(*names)
94
+ names.each { |name| repository(name) }
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -3,49 +3,54 @@
3
3
  module BetterService
4
4
  module Concerns
5
5
  module Serviceable
6
- module Transactional
7
- extend ActiveSupport::Concern
8
-
9
- included do
10
- class_attribute :_with_transaction, default: false
11
- end
12
-
13
- # Hook for prepend (same as included but triggered by prepend)
14
- def self.prepended(base)
15
- base.class_attribute :_with_transaction, default: false
16
- base.extend(ClassMethods)
17
- end
18
-
19
- module ClassMethods
20
- # Enable or disable database transactions for this service
6
+ # Provides transaction wrapping for service execution
21
7
  #
22
- # @param value [Boolean] whether to wrap process in a transaction
8
+ # This concern is PREPENDED (not included) to Services::Base to wrap
9
+ # the process method in a database transaction when enabled.
23
10
  #
24
- # @example Enable transactions
11
+ # @example Enable transactions in a service
25
12
  # class Booking::CreateService < BetterService::CreateService
26
13
  # with_transaction true
27
14
  # end
28
- #
29
- # @example Disable transactions
30
- # class Booking::ImportService < BetterService::CreateService
31
- # with_transaction false # Disable inherited transaction
32
- # end
33
- def with_transaction(value)
34
- self._with_transaction = value
15
+ module Transactional
16
+ extend ActiveSupport::Concern
17
+
18
+ # Hook for prepend - sets up class attributes and class methods
19
+ def self.prepended(base)
20
+ base.class_attribute :_with_transaction, default: false
21
+ base.extend(ClassMethods)
35
22
  end
36
- end
37
23
 
38
- # Override process to wrap in transaction if enabled
39
- def process(data)
40
- return super(data) unless self.class._with_transaction
24
+ module ClassMethods
25
+ # Enable or disable database transactions for this service
26
+ #
27
+ # @param value [Boolean] whether to wrap process in a transaction
28
+ #
29
+ # @example Enable transactions
30
+ # class Booking::CreateService < BetterService::CreateService
31
+ # with_transaction true
32
+ # end
33
+ #
34
+ # @example Disable transactions
35
+ # class Booking::ImportService < BetterService::CreateService
36
+ # with_transaction false # Disable inherited transaction
37
+ # end
38
+ def with_transaction(value)
39
+ self._with_transaction = value
40
+ end
41
+ end
42
+
43
+ # Override process to wrap in transaction if enabled
44
+ def process(data)
45
+ return super(data) unless self.class._with_transaction
41
46
 
42
- result = nil
43
- ActiveRecord::Base.transaction do
44
- result = super(data)
47
+ result = nil
48
+ ActiveRecord::Base.transaction do
49
+ result = super(data)
50
+ end
51
+ result
45
52
  end
46
- result
47
53
  end
48
54
  end
49
- end
50
55
  end
51
56
  end
@@ -11,6 +11,13 @@ module BetterService
11
11
  # config.instrumentation_enabled = true
12
12
  # config.instrumentation_include_args = false
13
13
  # config.instrumentation_excluded_services = ["HealthCheckService"]
14
+ #
15
+ # # Cache invalidation map for cascading cache invalidation
16
+ # config.cache_invalidation_map = {
17
+ # 'products' => %w[products inventory reports],
18
+ # 'orders' => %w[orders products reports],
19
+ # 'users' => %w[users orders reports]
20
+ # }
14
21
  # end
15
22
  class Configuration
16
23
  # Enable/disable instrumentation globally
@@ -65,6 +72,31 @@ module BetterService
65
72
  # @return [Boolean] Default: false
66
73
  attr_accessor :stats_subscriber_enabled
67
74
 
75
+ # Cache invalidation map for cascading cache invalidation
76
+ #
77
+ # When a context is invalidated, all related contexts in the map
78
+ # are also invalidated automatically.
79
+ #
80
+ # @return [Hash<String, Array<String>>] Default: {}
81
+ #
82
+ # @example
83
+ # config.cache_invalidation_map = {
84
+ # 'products' => %w[products inventory reports],
85
+ # 'orders' => %w[orders products reports]
86
+ # }
87
+ attr_reader :cache_invalidation_map
88
+
89
+ # Set the cache invalidation map
90
+ #
91
+ # Automatically configures CacheService with the provided map.
92
+ #
93
+ # @param map [Hash<String, Array<String>>] Invalidation mappings
94
+ # @return [void]
95
+ def cache_invalidation_map=(map)
96
+ @cache_invalidation_map = map
97
+ CacheService.configure_invalidation_map(map) if map
98
+ end
99
+
68
100
  def initialize
69
101
  # Instrumentation defaults
70
102
  @instrumentation_enabled = true
@@ -76,6 +108,9 @@ module BetterService
76
108
  @log_subscriber_enabled = false
77
109
  @log_subscriber_level = :info
78
110
  @stats_subscriber_enabled = false
111
+
112
+ # Cache defaults
113
+ @cache_invalidation_map = {}
79
114
  end
80
115
  end
81
116
 
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterService
4
+ module Repository
5
+ # BaseRepository - Generic repository pattern for data access
6
+ #
7
+ # Provides a clean abstraction layer between services and ActiveRecord models.
8
+ # Repositories handle all database queries using predicates-based search,
9
+ # enforcing separation of concerns and enabling easier testing.
10
+ #
11
+ # @example Basic usage
12
+ # class ProductRepository < BetterService::Repository::BaseRepository
13
+ # def initialize(model_class = Product)
14
+ # super
15
+ # end
16
+ # end
17
+ #
18
+ # repo = ProductRepository.new
19
+ # products = repo.search({ status_eq: 'active' }, includes: [:category])
20
+ #
21
+ # @example With predicates
22
+ # repo.search({
23
+ # user_id_eq: user.id,
24
+ # status_in: ['pending', 'confirmed'],
25
+ # created_at_gteq: 1.week.ago
26
+ # }, order: 'created_at DESC', per_page: 20)
27
+ #
28
+ class BaseRepository
29
+ attr_reader :model
30
+
31
+ # Initialize repository with a model class
32
+ #
33
+ # @param model_class [Class, nil] ActiveRecord model class
34
+ # If nil, derives class name from repository name
35
+ def initialize(model_class = nil)
36
+ @model = model_class || derive_model_class
37
+ end
38
+
39
+ # Search records using predicates
40
+ #
41
+ # Supports flexible querying through predicates hash that gets
42
+ # translated to ActiveRecord scopes via model's Searchable concern.
43
+ #
44
+ # @param predicates [Hash] Search predicates (e.g., { status_eq: 'active' })
45
+ # @param page [Integer] Page number for pagination (default: 1)
46
+ # @param per_page [Integer] Records per page (default: 20)
47
+ # @param includes [Array] Associations to eager load
48
+ # @param joins [Array] Associations to join
49
+ # @param order [String, Hash, nil] Order clause
50
+ # @param order_scope [Hash, nil] Named scope for ordering { field:, direction: }
51
+ # @param limit [Integer, Symbol, nil] Limit results
52
+ # - 1: returns single record (first)
53
+ # - Integer > 1: limit to N records
54
+ # - nil: no limit (returns all)
55
+ # - :default: apply pagination
56
+ # @return [ActiveRecord::Relation, Object, nil] Query result
57
+ #
58
+ # @example Basic search
59
+ # search({ status_eq: 'active' })
60
+ #
61
+ # @example With pagination
62
+ # search({ user_id_eq: 1 }, page: 2, per_page: 25)
63
+ #
64
+ # @example Single record
65
+ # search({ id_eq: 123 }, limit: 1)
66
+ #
67
+ # @example With eager loading
68
+ # search({}, includes: [:user, :comments], order: 'created_at DESC')
69
+ def search(predicates = {}, page: 1, per_page: 20, includes: [],
70
+ joins: [], order: nil, order_scope: nil, limit: :default)
71
+ cleaned_predicates = (predicates || {}).compact
72
+
73
+ scope = build_base_scope(cleaned_predicates)
74
+ scope = apply_joins(scope, joins)
75
+ scope = apply_includes(scope, includes)
76
+ scope = apply_ordering(scope, order, order_scope)
77
+ apply_limit_or_pagination(scope, limit, page, per_page)
78
+ end
79
+
80
+ # Delegate basic ActiveRecord methods to model
81
+ delegate :find, :find_by, :where, :all, :count, :exists?, to: :model
82
+
83
+ # Build a new unsaved record
84
+ #
85
+ # @param attributes [Hash] Attributes for the new record
86
+ # @return [ActiveRecord::Base] Unsaved model instance
87
+ def build(attributes = {})
88
+ model.new(attributes)
89
+ end
90
+ alias new build
91
+
92
+ # Create a new record (may return invalid record)
93
+ #
94
+ # @param attributes [Hash] Attributes for the new record
95
+ # @return [ActiveRecord::Base] Created model instance
96
+ def create(attributes = {})
97
+ model.create(attributes)
98
+ end
99
+
100
+ # Create a new record (raises on validation failure)
101
+ #
102
+ # @param attributes [Hash] Attributes for the new record
103
+ # @return [ActiveRecord::Base] Created model instance
104
+ # @raise [ActiveRecord::RecordInvalid] if validation fails
105
+ def create!(attributes = {})
106
+ model.create!(attributes)
107
+ end
108
+
109
+ # Update an existing record
110
+ #
111
+ # @param record_or_id [ActiveRecord::Base, Integer, String] Record or ID
112
+ # @param attributes [Hash] Attributes to update
113
+ # @return [ActiveRecord::Base] Updated model instance
114
+ # @raise [ActiveRecord::RecordInvalid] if validation fails
115
+ def update(record_or_id, attributes)
116
+ record = resolve_record(record_or_id)
117
+ record.update!(attributes)
118
+ record
119
+ end
120
+ alias update! update
121
+
122
+ # Destroy a record
123
+ #
124
+ # @param record_or_id [ActiveRecord::Base, Integer, String] Record or ID
125
+ # @return [ActiveRecord::Base] Destroyed model instance
126
+ def destroy(record_or_id)
127
+ record = resolve_record(record_or_id)
128
+ record.destroy!
129
+ record
130
+ end
131
+ alias destroy! destroy
132
+
133
+ # Delete a record without callbacks
134
+ #
135
+ # @param record_or_id [ActiveRecord::Base, Integer, String] Record or ID
136
+ # @return [Integer] Number of deleted records
137
+ def delete(record_or_id)
138
+ id = record_or_id.respond_to?(:id) ? record_or_id.id : record_or_id
139
+ model.where(id: id).delete_all
140
+ end
141
+
142
+ private
143
+
144
+ # Resolve a record from ID or return the record itself
145
+ #
146
+ # @param record_or_id [ActiveRecord::Base, Integer, String] Record or ID
147
+ # @return [ActiveRecord::Base] The resolved record
148
+ def resolve_record(record_or_id)
149
+ if record_or_id.is_a?(model)
150
+ record_or_id
151
+ else
152
+ find(record_or_id)
153
+ end
154
+ end
155
+
156
+ # Derive model class from repository name
157
+ #
158
+ # ProductRepository -> Product
159
+ # Bookings::BookingRepository -> Bookings::Booking
160
+ #
161
+ # @return [Class] The derived model class
162
+ # @raise [BetterService::Errors::Configuration::ConfigurationError]
163
+ def derive_model_class
164
+ class_name = self.class.name.gsub(/Repository$/, "")
165
+ class_name.constantize
166
+ rescue NameError
167
+ raise Errors::Configuration::ConfigurationError,
168
+ "Could not derive model class from #{self.class.name}. " \
169
+ "Pass model_class explicitly to initialize."
170
+ end
171
+
172
+ # Build base scope from predicates
173
+ #
174
+ # @param predicates [Hash] Search predicates
175
+ # @return [ActiveRecord::Relation] Base query scope
176
+ def build_base_scope(predicates)
177
+ if model.respond_to?(:search) && predicates.present?
178
+ model.search(predicates)
179
+ else
180
+ model.all
181
+ end
182
+ end
183
+
184
+ # Apply joins to scope
185
+ #
186
+ # @param scope [ActiveRecord::Relation] Current scope
187
+ # @param joins [Array] Associations to join
188
+ # @return [ActiveRecord::Relation] Scope with joins
189
+ def apply_joins(scope, joins)
190
+ return scope if joins.blank?
191
+
192
+ scope.joins(*joins)
193
+ end
194
+
195
+ # Apply includes to scope
196
+ #
197
+ # @param scope [ActiveRecord::Relation] Current scope
198
+ # @param includes [Array] Associations to eager load
199
+ # @return [ActiveRecord::Relation] Scope with includes
200
+ def apply_includes(scope, includes)
201
+ return scope if includes.blank?
202
+
203
+ scope.includes(*includes)
204
+ end
205
+
206
+ # Apply ordering to scope
207
+ #
208
+ # @param scope [ActiveRecord::Relation] Current scope
209
+ # @param order [String, Hash, nil] Order clause
210
+ # @param order_scope [Hash, nil] Named scope for ordering
211
+ # @return [ActiveRecord::Relation] Ordered scope
212
+ def apply_ordering(scope, order, order_scope)
213
+ if order_scope.present?
214
+ scope_name = "#{order_scope[:field]}_#{order_scope[:direction]}"
215
+ scope.respond_to?(scope_name) ? scope.send(scope_name) : scope
216
+ elsif order.present?
217
+ scope.order(order)
218
+ else
219
+ scope
220
+ end
221
+ end
222
+
223
+ # Apply limit or pagination to scope
224
+ #
225
+ # @param scope [ActiveRecord::Relation] Current scope
226
+ # @param limit [Integer, Symbol, nil] Limit specification
227
+ # @param page [Integer] Page number
228
+ # @param per_page [Integer] Records per page
229
+ # @return [ActiveRecord::Relation, Object, nil] Limited scope or record
230
+ def apply_limit_or_pagination(scope, limit, page, per_page)
231
+ case limit
232
+ when 1
233
+ scope.first
234
+ when Integer
235
+ scope.limit(limit)
236
+ when nil
237
+ scope
238
+ when :default
239
+ paginate(scope, page: page, per_page: per_page)
240
+ else
241
+ paginate(scope, page: page, per_page: per_page)
242
+ end
243
+ end
244
+
245
+ # Apply pagination to scope
246
+ #
247
+ # @param scope [ActiveRecord::Relation] Current scope
248
+ # @param page [Integer] Page number
249
+ # @param per_page [Integer] Records per page
250
+ # @return [ActiveRecord::Relation] Paginated scope
251
+ def paginate(scope, page:, per_page:)
252
+ offset_value = ([page.to_i, 1].max - 1) * per_page.to_i
253
+ scope.offset(offset_value).limit(per_page)
254
+ end
255
+ end
256
+ end
257
+ end
@@ -9,21 +9,21 @@ module BetterService
9
9
  # Returns: { resource: {}, metadata: { action: :custom_action_name } }
10
10
  #
11
11
  # Example:
12
- # class Bookings::AcceptService < BetterService::Services::ActionService
13
- # action_name :accepted # Sets metadata action
12
+ # class Orders::ConfirmService < BetterService::Services::ActionService
13
+ # action_name :confirmed # Sets metadata action
14
14
  #
15
15
  # schema do
16
16
  # required(:id).filled(:integer)
17
17
  # end
18
18
  #
19
19
  # search_with do
20
- # { resource: user.bookings.find(params[:id]) }
20
+ # { resource: user.orders.find(params[:id]) }
21
21
  # end
22
22
  #
23
23
  # process_with do |data|
24
- # booking = data[:resource]
25
- # booking.update!(status: 'accepted', accepted_at: Time.current)
26
- # { resource: booking }
24
+ # order = data[:resource]
25
+ # order.update!(status: 'confirmed', confirmed_at: Time.current)
26
+ # { resource: order }
27
27
  # end
28
28
  # end
29
29
  class ActionService < Services::Base
@@ -298,6 +298,103 @@ module BetterService
298
298
  }
299
299
  end
300
300
 
301
+ # Build a failure response hash
302
+ #
303
+ # @param error_message [String] Human-readable error message
304
+ # @param errors [Hash, Array] Validation errors in various formats
305
+ # @return [Hash] Standardized failure response
306
+ #
307
+ # @example Simple error
308
+ # failure_result("Record not found")
309
+ #
310
+ # @example With validation errors
311
+ # failure_result("Validation failed", { email: ["is invalid"] })
312
+ def failure_result(error_message, errors = {})
313
+ {
314
+ success: false,
315
+ error: error_message,
316
+ errors: format_errors_for_response(errors)
317
+ }
318
+ end
319
+
320
+ # Build a validation failure response with the failed resource
321
+ #
322
+ # Used when ActiveRecord validation fails and the form
323
+ # needs to be re-rendered with the invalid model.
324
+ #
325
+ # @param failed_resource [ActiveRecord::Base] Model with validation errors
326
+ # @return [Hash] Failure response with errors and failed_resource
327
+ #
328
+ # @example
329
+ # user = User.new(invalid_params)
330
+ # user.valid? # => false
331
+ # validation_failure_result(user)
332
+ def validation_failure_result(failed_resource)
333
+ {
334
+ success: false,
335
+ errors: format_model_validation_errors(failed_resource.errors),
336
+ failed_resource: failed_resource
337
+ }
338
+ end
339
+
340
+ # Check if data contains an error (for phase flow control)
341
+ #
342
+ # @param data [Hash] Data from previous phase
343
+ # @return [Boolean] true if data contains error
344
+ #
345
+ # @example
346
+ # def process(data)
347
+ # return data if error?(data) # Pass through errors
348
+ # # ... processing logic
349
+ # end
350
+ def error?(data)
351
+ data.is_a?(Hash) && (data[:error] || data[:success] == false)
352
+ end
353
+
354
+ # Format errors into standard array format
355
+ #
356
+ # @param errors [Hash, Array] Errors in various formats
357
+ # @return [Array<Hash>] Standardized error array
358
+ def format_errors_for_response(errors)
359
+ case errors
360
+ when Hash
361
+ errors.flat_map do |key, messages|
362
+ Array(messages).map { |msg| { key: key.to_s, message: msg } }
363
+ end
364
+ when Array
365
+ errors.map do |err|
366
+ if err.is_a?(Hash) && err[:key] && err[:message]
367
+ err
368
+ elsif err.is_a?(String)
369
+ { key: "base", message: err }
370
+ else
371
+ { key: "base", message: err.to_s }
372
+ end
373
+ end
374
+ else
375
+ []
376
+ end
377
+ end
378
+
379
+ # Format ActiveRecord/ActiveModel errors into standard format
380
+ #
381
+ # Used specifically for ActiveModel::Errors objects (from Rails models).
382
+ # For Dry::Schema validation errors, the Validatable concern has its own
383
+ # format_validation_errors method.
384
+ #
385
+ # @param errors [ActiveModel::Errors] ActiveRecord errors object
386
+ # @return [Array<Hash>] Standardized error array
387
+ def format_model_validation_errors(errors)
388
+ return [] if errors.blank?
389
+
390
+ errors.map do |error|
391
+ {
392
+ key: error.attribute.to_s,
393
+ message: error.message
394
+ }
395
+ end
396
+ end
397
+
301
398
  # Prepend Instrumentation at the end, after call method is defined
302
399
  # This wraps the entire call method (including cache logic from Cacheable)
303
400
  prepend Concerns::Instrumentation
@@ -9,10 +9,10 @@ module BetterService
9
9
  # Returns: { resource: {}, metadata: { action: :created } }
10
10
  #
11
11
  # Example:
12
- # class Bookings::CreateService < BetterService::Services::CreateService
12
+ # class Orders::CreateService < BetterService::Services::CreateService
13
13
  # schema do
14
14
  # required(:title).filled(:string)
15
- # required(:date).filled(:date)
15
+ # required(:total).filled(:decimal)
16
16
  # end
17
17
  #
18
18
  # search_with do
@@ -20,11 +20,11 @@ module BetterService
20
20
  # end
21
21
  #
22
22
  # process_with do |data|
23
- # booking = user.bookings.create!(
23
+ # order = user.orders.create!(
24
24
  # title: params[:title],
25
- # date: params[:date]
25
+ # total: params[:total]
26
26
  # )
27
- # { resource: booking }
27
+ # { resource: order }
28
28
  # end
29
29
  # end
30
30
  class CreateService < Services::Base
@@ -9,19 +9,19 @@ module BetterService
9
9
  # Returns: { resource: {}, metadata: { action: :deleted } }
10
10
  #
11
11
  # Example:
12
- # class Bookings::DestroyService < BetterService::Services::DestroyService
12
+ # class Orders::DestroyService < BetterService::Services::DestroyService
13
13
  # schema do
14
14
  # required(:id).filled(:integer)
15
15
  # end
16
16
  #
17
17
  # search_with do
18
- # { resource: user.bookings.find(params[:id]) }
18
+ # { resource: user.orders.find(params[:id]) }
19
19
  # end
20
20
  #
21
21
  # process_with do |data|
22
- # booking = data[:resource]
23
- # booking.destroy!
24
- # { resource: booking }
22
+ # order = data[:resource]
23
+ # order.destroy!
24
+ # { resource: order }
25
25
  # end
26
26
  # end
27
27
  class DestroyService < Services::Base
@@ -9,9 +9,9 @@ module BetterService
9
9
  # Returns: { items: [], metadata: { action: :index, stats: {}, pagination: {} } }
10
10
  #
11
11
  # Example:
12
- # class Bookings::IndexService < BetterService::Services::IndexService
12
+ # class Orders::IndexService < BetterService::Services::IndexService
13
13
  # search_with do
14
- # { items: user.bookings.to_a }
14
+ # { items: user.orders.to_a }
15
15
  # end
16
16
  #
17
17
  # process_with do |data|
@@ -9,9 +9,9 @@ module BetterService
9
9
  # Returns: { resource: {}, metadata: { action: :show } }
10
10
  #
11
11
  # Example:
12
- # class Bookings::ShowService < BetterService::Services::ShowService
12
+ # class Orders::ShowService < BetterService::Services::ShowService
13
13
  # search_with do
14
- # { resource: user.bookings.find(params[:id]) }
14
+ # { resource: user.orders.find(params[:id]) }
15
15
  # end
16
16
  # end
17
17
  class ShowService < Services::Base
@@ -9,20 +9,20 @@ module BetterService
9
9
  # Returns: { resource: {}, metadata: { action: :updated } }
10
10
  #
11
11
  # Example:
12
- # class Bookings::UpdateService < BetterService::Services::UpdateService
12
+ # class Orders::UpdateService < BetterService::Services::UpdateService
13
13
  # schema do
14
14
  # required(:id).filled(:integer)
15
15
  # optional(:title).filled(:string)
16
16
  # end
17
17
  #
18
18
  # search_with do
19
- # { resource: user.bookings.find(params[:id]) }
19
+ # { resource: user.orders.find(params[:id]) }
20
20
  # end
21
21
  #
22
22
  # process_with do |data|
23
- # booking = data[:resource]
24
- # booking.update!(params.except(:id))
25
- # { resource: booking }
23
+ # order = data[:resource]
24
+ # order.update!(params.except(:id))
25
+ # { resource: order }
26
26
  # end
27
27
  # end
28
28
  class UpdateService < Services::Base
@@ -1,3 +1,3 @@
1
1
  module BetterService
2
- VERSION = "1.1.0"
2
+ VERSION = "2.0.0"
3
3
  end
@@ -97,7 +97,7 @@ module BetterService
97
97
  if result[:success] == false && !result[:optional_failure]
98
98
  raise Errors::Workflowable::Runtime::StepExecutionError.new(
99
99
  "Step #{step_or_branch_group.name} failed in branch",
100
- code: ErrorCodes::STEP_EXECUTION_FAILED,
100
+ code: ErrorCodes::STEP_FAILED,
101
101
  context: {
102
102
  step: step_or_branch_group.name,
103
103
  branch: @name,
@@ -4,6 +4,8 @@ require "better_service/configuration"
4
4
  require "better_service/errors/better_service_error"
5
5
  require "better_service/cache_service"
6
6
  require "better_service/presenter"
7
+ require "better_service/repository/base_repository"
8
+ require "better_service/concerns/serviceable/repository_aware"
7
9
  require "better_service/concerns/instrumentation"
8
10
  require "better_service/subscribers/log_subscriber"
9
11
  require "better_service/subscribers/stats_subscriber"
@@ -8,7 +8,7 @@ module BetterService
8
8
  #
9
9
  # Usage:
10
10
  # rails generate better_service:locale products
11
- # rails generate better_service:locale bookings
11
+ # rails generate better_service:locale orders
12
12
  #
13
13
  # This generates config/locales/products_services.en.yml with scaffolded
14
14
  # translations for common service actions (create, update, destroy, etc.)
metadata CHANGED
@@ -1,19 +1,22 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: better_service
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - alessiobussolari
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-11-13 00:00:00.000000000 Z
11
+ date: 2025-11-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '8.1'
17
20
  - - ">="
18
21
  - !ruby/object:Gem::Version
19
22
  version: 8.1.1
@@ -21,6 +24,9 @@ dependencies:
21
24
  prerelease: false
22
25
  version_requirements: !ruby/object:Gem::Requirement
23
26
  requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: '8.1'
24
30
  - - ">="
25
31
  - !ruby/object:Gem::Version
26
32
  version: 8.1.1
@@ -86,6 +92,7 @@ files:
86
92
  - lib/better_service/concerns/serviceable/cacheable.rb
87
93
  - lib/better_service/concerns/serviceable/messageable.rb
88
94
  - lib/better_service/concerns/serviceable/presentable.rb
95
+ - lib/better_service/concerns/serviceable/repository_aware.rb
89
96
  - lib/better_service/concerns/serviceable/transactional.rb
90
97
  - lib/better_service/concerns/serviceable/validatable.rb
91
98
  - lib/better_service/concerns/serviceable/viewable.rb
@@ -117,6 +124,7 @@ files:
117
124
  - lib/better_service/errors/workflowable/runtime/workflow_runtime_error.rb
118
125
  - lib/better_service/presenter.rb
119
126
  - lib/better_service/railtie.rb
127
+ - lib/better_service/repository/base_repository.rb
120
128
  - lib/better_service/services/action_service.rb
121
129
  - lib/better_service/services/base.rb
122
130
  - lib/better_service/services/create_service.rb
@@ -166,7 +174,6 @@ homepage: https://github.com/alessiobussolari/better_service
166
174
  licenses:
167
175
  - WTFPL
168
176
  metadata:
169
- homepage_uri: https://github.com/alessiobussolari/better_service
170
177
  source_code_uri: https://github.com/alessiobussolari/better_service
171
178
  changelog_uri: https://github.com/alessiobussolari/better_service/blob/main/CHANGELOG.md
172
179
  rubygems_mfa_required: 'true'