hanikamu-operation 0.1.1 → 0.2.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: 8ff21656b7650f8c722178312ccfe54ed372086a165bcf13bd358f27d5880dd6
4
- data.tar.gz: 2b22c2521306b5e64a07bc510631c088d84100298947248fd4c27d8fdda8f3f2
3
+ metadata.gz: b935969d9ffb9b34935f54e2f9d8adf907603906916a4c1b555499bd25e97f28
4
+ data.tar.gz: 2fada67131e16535eb5c6480c3a80999315efd0306753aae615a6a582d9cb43d
5
5
  SHA512:
6
- metadata.gz: 33b2a094c24b6d9690e2666a60cf2453d93b2e73113a8d7a247a46f7a0bfeca661301f61f711c26fa94a911d55655b4c9b181930a9fcb5b8f4bbbc8e416db55e
7
- data.tar.gz: 536172f90b5899eed4abf90c95984498dabc4695aa340109430b8be7835076fccca466fad27ce377e01e94c518105ca2e211fc496030560d765ddd2331f8163b
6
+ metadata.gz: cc5970b13fd6511a024b14071b273350604ef811f8bb944ca03f6839ed7f03051d106c63f550225f2b4de181d60c7698f545ecd1219faa1bb813aaa76be340a3
7
+ data.tar.gz: a691099d6fb2be177d4e19bcfff74e9760716465b2a9ceaff2fa0df33a63027e199377749e285f4a8b9cad02224c93e347e4b229eab60f159aa2d4bc5e585454
@@ -0,0 +1,30 @@
1
+ ---
2
+ description: Development environment runs via docker-compose. Use Makefile targets instead of local commands.
3
+ alwaysApply: true
4
+ ---
5
+
6
+ # Docker-Compose Development Environment
7
+
8
+ This project runs entirely inside Docker. Never run Ruby, Bundler, or Redis commands directly on the host.
9
+
10
+ ## Makefile Targets
11
+
12
+ Use these instead of raw commands:
13
+
14
+ | Task | Command |
15
+ |------|---------|
16
+ | Run tests | `make rspec` |
17
+ | Run linter (auto-fix) | `make cops` |
18
+ | Open a shell in the container | `make shell` |
19
+ | Open an IRB console | `make console` |
20
+ | Install gems & rebuild | `make bundle` |
21
+ | Build the Docker image | `make build` |
22
+
23
+ ## Important
24
+
25
+ - All `make` targets use `docker-compose run --rm app` under the hood.
26
+ - Redis is provided as a linked service (`redis://redis:6379/15`) — no local Redis needed.
27
+ - After changing the Gemfile or gemspec, run `make build` to reinstall dependencies.
28
+ - Never use `bundle exec` directly on the host; always go through `make` or `make shell`.
29
+ - Run `make` commands directly without `cd` — use the `working_directory` parameter instead.
30
+ - After every code change, run `make rspec` to verify specs still pass before moving on.
@@ -0,0 +1,5 @@
1
+ {
2
+ "rubyLsp.rubyVersionManager": {
3
+ "identifier": "mise"
4
+ }
5
+ }
data/CHANGELOG.md CHANGED
@@ -7,3 +7,10 @@
7
7
  ## [0.1.1] - 2025-11-26
8
8
 
9
9
  - Updated Gemfile.lock
10
+
11
+ ## [0.1.2] - 2025-12-05
12
+
13
+ - **Breaking Change**: Minimum Ruby version is now 3.4.0
14
+ - Removed `redis-client` as direct dependency (now transitive through `redlock`)
15
+ - Updated CI to test only Ruby 3.4
16
+ - Improved README with clearer FormError vs GuardError examples
data/Dockerfile CHANGED
@@ -1,5 +1,5 @@
1
1
  # Base image
2
- FROM ruby:3.4.4
2
+ FROM ruby:4.0
3
3
 
4
4
  WORKDIR "/app"
5
5
 
data/Makefile CHANGED
@@ -14,6 +14,10 @@ console: ## build the image
14
14
  rspec: ## build the image
15
15
  docker-compose run --rm app bash -c "bundle exec rspec"
16
16
 
17
+ .PHONY: bundle
18
+ bundle: ## install gems and rebuild image
19
+ docker-compose run --rm app bundle install
20
+ $(MAKE) build
17
21
 
18
22
  .PHONY: cops
19
23
  cops: ## build the image
data/README.md CHANGED
@@ -8,9 +8,8 @@ A Ruby gem that extends [hanikamu-service](https://github.com/Hanikamu/hanikamu-
8
8
 
9
9
  1. [Why Hanikamu::Operation?](#why-hanikamuoperation)
10
10
  2. [Quick Start](#quick-start)
11
- 3. [Installation](#installation)
12
- 4. [Setup](#setup)
13
- 5. [Usage](#usage)
11
+ 3. [Setup](#setup)
12
+ 4. [Usage](#usage)
14
13
  - [Basic Operation](#basic-operation)
15
14
  - [Distributed Locking](#distributed-locking-with-within_mutex)
16
15
  - [Database Transactions](#database-transactions-with-within_transaction)
@@ -18,14 +17,14 @@ A Ruby gem that extends [hanikamu-service](https://github.com/Hanikamu/hanikamu-
18
17
  - [Guard Conditions](#guard-conditions)
19
18
  - [Block Requirements](#block-requirements)
20
19
  - [Complete Example](#complete-example-combining-all-features)
21
- 6. [Error Handling](#error-handling)
22
- 7. [Best Practices](#best-practices)
23
- 8. [Configuration Reference](#configuration-reference)
24
- 9. [Testing](#testing)
25
- 10. [Development](#development)
26
- 11. [Contributing](#contributing)
27
- 12. [License](#license)
28
- 13. [Credits](#credits)
20
+ 5. [Error Handling](#error-handling)
21
+ 6. [Best Practices](#best-practices)
22
+ 7. [Configuration Reference](#configuration-reference)
23
+ 8. [Testing](#testing)
24
+ 9. [Development](#development)
25
+ 10. [Contributing](#contributing)
26
+ 11. [License](#license)
27
+ 12. [Credits](#credits)
29
28
 
30
29
  ## Why Hanikamu::Operation?
31
30
 
@@ -63,9 +62,11 @@ Use `Hanikamu::Operation` (instead of plain `Hanikamu::Service`) when your busin
63
62
 
64
63
  **1. Install the gem**
65
64
 
65
+ Requires Ruby 3.4.0 or later.
66
+
66
67
  ```ruby
67
68
  # Gemfile
68
- gem 'hanikamu-operation', '~> 0.1.1'
69
+ gem 'hanikamu-operation', '~> 0.2.0'
69
70
  ```
70
71
 
71
72
  ```bash
@@ -122,32 +123,19 @@ else
122
123
  end
123
124
  ```
124
125
 
125
- ## Installation
126
-
127
- Add to your application's Gemfile:
128
-
129
- ```ruby
130
- gem 'hanikamu-operation', '~> 0.1.1'
131
- ```
132
-
133
- Then execute:
134
-
135
- ```bash
136
- bundle install
137
- ```
138
-
139
126
  ## Setup
140
127
 
141
- ### Rails Application Setup Guide
128
+ ### Rails Application Setup
142
129
 
143
130
  Follow these steps to integrate Hanikamu::Operation into a Rails application:
144
131
 
145
132
  **Step 1: Add the gem to your Gemfile**
146
133
 
134
+ Requires Ruby 3.4.0 or later.
135
+
147
136
  ```ruby
148
137
  # Gemfile
149
- gem 'hanikamu-operation', '~> 0.1.1'
150
- gem 'redis-client', '~> 0.22' # Required for distributed locking
138
+ gem 'hanikamu-operation', '~> 0.2.0'
151
139
  ```
152
140
 
153
141
  ```bash
@@ -214,68 +202,7 @@ brew services start redis
214
202
  REDIS_URL=redis://localhost:6379/0
215
203
  ```
216
204
 
217
- **Step 5: Create your first operation**
218
-
219
- ```bash
220
- # Create operations directory
221
- mkdir -p app/operations/users
222
- ```
223
-
224
- ```ruby
225
- # app/operations/users/create_user_operation.rb
226
- module Users
227
- class CreateUserOperation < Hanikamu::Operation
228
- attribute :email, Types::String
229
- attribute :password, Types::String
230
-
231
- validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
232
- validates :password, length: { minimum: 8 }
233
-
234
- within_transaction(:base)
235
-
236
- def execute
237
- user = User.create!(
238
- email: email,
239
- password: password
240
- )
241
-
242
- response user: user
243
- end
244
- end
245
- end
246
- ```
247
-
248
- **Step 6: Use in your controller**
249
-
250
- ```ruby
251
- # app/controllers/users_controller.rb
252
- class UsersController < ApplicationController
253
- def create
254
- result = Users::CreateUserOperation.call(user_params)
255
-
256
- if result.success?
257
- user = result.success.user
258
- render json: { user: user }, status: :created
259
- else
260
- error = result.failure
261
- case error
262
- when Hanikamu::Operation::FormError
263
- render json: { errors: error.errors.full_messages }, status: :unprocessable_entity
264
- else
265
- render json: { error: error.message }, status: :internal_server_error
266
- end
267
- end
268
- end
269
-
270
- private
271
-
272
- def user_params
273
- params.require(:user).permit(:email, :password)
274
- end
275
- end
276
- ```
277
-
278
- **Step 7: Configure for production**
205
+ **Step 5: Production configuration**
279
206
 
280
207
  Set your Redis URL in production (Heroku, AWS, etc.):
281
208
 
@@ -284,71 +211,11 @@ Set your Redis URL in production (Heroku, AWS, etc.):
284
211
  heroku addons:create heroku-redis:mini
285
212
  # REDIS_URL is automatically set
286
213
 
287
- # Or set manually
214
+ # Or set manually for other providers
288
215
  heroku config:set REDIS_URL=redis://your-redis-host:6379/0
289
216
  ```
290
217
 
291
- ### Detailed Configuration Options
292
-
293
- If you need more control, create a detailed initializer:
294
-
295
- ```ruby
296
- # config/initializers/hanikamu_operation.rb
297
- require 'redis-client'
298
-
299
- Hanikamu::Operation.configure do |config|
300
- # Required: Redis client for distributed locking
301
- config.redis_client = RedisClient.new(
302
- url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'),
303
- reconnect_attempts: 3,
304
- timeout: 1.0
305
- )
306
-
307
- # Optional: Customize Redlock settings (these are the defaults)
308
- config.mutex_expire_milliseconds = 1500 # Lock TTL
309
- config.redlock_retry_count = 6 # Number of retry attempts
310
- config.redlock_retry_delay = 500 # Milliseconds between retries
311
- config.redlock_retry_jitter = 50 # Random jitter to prevent thundering herd
312
- config.redlock_timeout = 0.1 # Redis command timeout
313
-
314
- # Optional: Add errors to whitelist (Redlock::LockError is always included by default)
315
- config.whitelisted_errors = [CustomBusinessError]
316
- end
317
- ```
318
-
319
- ### Redis Setup by Environment
320
-
321
- **For Development (Docker Compose)**:
322
-
323
- ```yaml
324
- # docker-compose.yml
325
- services:
326
- redis:
327
- image: redis:7-alpine
328
- volumes:
329
- - redis_data:/data
330
- networks:
331
- - app_network
332
-
333
- app:
334
- # ... your app config
335
- environment:
336
- REDIS_URL: redis://redis:6379/0
337
- depends_on:
338
- - redis
339
- networks:
340
- - app_network
341
-
342
- volumes:
343
- redis_data:
344
-
345
- networks:
346
- app_network:
347
- ```
348
-
349
- **For Production**:
350
-
351
- Use a managed Redis service (AWS ElastiCache, Heroku Redis, Redis Labs, etc.) and set the `REDIS_URL` environment variable.
218
+ For advanced configuration options, see the [Configuration Reference](#configuration-reference) section below.
352
219
 
353
220
  ## Usage
354
221
 
@@ -465,6 +332,38 @@ def mutex_lock
465
332
  end
466
333
  ```
467
334
 
335
+ **Conditional locking with `:if` / `:unless`**:
336
+
337
+ When the lock key is derived from an optional attribute, a `nil` value produces a degenerate shared key (e.g., `"Order$"`) that serializes all operations. Use `:if` or `:unless` to skip the Redis lock entirely when it isn't needed:
338
+
339
+ ```ruby
340
+ class ProcessOrderOperation < Hanikamu::Operation
341
+ attribute :order_id?, Types::Params::Integer.optional
342
+
343
+ within_mutex(:mutex_lock, if: -> { order_id.present? })
344
+
345
+ def execute
346
+ # ...
347
+ response(successful: true)
348
+ end
349
+
350
+ def mutex_lock
351
+ "Order$#{order_id}"
352
+ end
353
+ end
354
+ ```
355
+
356
+ - When `order_id` is present, the lock is acquired on `"Order$42"` as usual.
357
+ - When `order_id` is `nil`, no Redis round-trip occurs — the operation runs without a lock.
358
+
359
+ The `:unless` option works inversely:
360
+
361
+ ```ruby
362
+ within_mutex(:mutex_lock, unless: -> { order_id.nil? })
363
+ ```
364
+
365
+ The lambda is evaluated via `instance_exec` on the operation instance, so it has access to all attributes and methods. Providing both `:if` and `:unless` raises `ArgumentError`.
366
+
468
367
  ### Database Transactions with `within_transaction`
469
368
 
470
369
  Ensure multiple database changes succeed or fail together atomically. If any database operation raises an exception, all changes are rolled back.
@@ -764,52 +663,68 @@ Operations validate at three distinct levels, each serving a specific purpose:
764
663
 
765
664
  ### FormError vs GuardError: Practical Examples
766
665
 
767
- **FormError Example** - Invalid or incorrect input arguments:
666
+ Here's a complete example demonstrating the difference between form validations and guard conditions:
768
667
 
769
668
  ```ruby
770
- # Attempting to create a user with invalid inputs
771
- result = Users::CreateUserOperation.call(
772
- email: "taken@example.com",
773
- password: "short",
774
- password_confirmation: "wrong"
775
- )
776
- # => Failure(#<Hanikamu::Operation::FormError:
777
- # Email has been taken,
778
- # Password is too short,
779
- # Password confirmation does not match password>)
780
-
781
- # Correcting the arguments allows success
782
- result = Users::CreateUserOperation.call(
783
- email: "unique@example.com",
784
- password: "securePassword123!",
785
- password_confirmation: "securePassword123!"
786
- )
787
- # => Success(#<struct user=#<User id: 46, email: "unique@example.com">>)
669
+ class TestOperation < Hanikamu::Operation
670
+ attribute :sentence, Types::String
671
+
672
+ # Form validation - checks input value format/content
673
+ validates :sentence, presence: true
674
+ validates :sentence, exclusion: { in: ["form_error"], message: "is not allowed" }
675
+
676
+ # Guard validation - checks business rules/state
677
+ guard do
678
+ delegates :sentence
679
+
680
+ validates :sentence, exclusion: { in: ["guard_error"], message: "is not allowed" }
681
+ end
682
+
683
+ within_mutex(:mutex_lock)
684
+ within_transaction(:base)
685
+
686
+ def execute
687
+ response(message: sentence.reverse)
688
+ end
689
+
690
+ def mutex_lock
691
+ self.class.name
692
+ end
693
+ end
788
694
  ```
789
695
 
790
- **GuardError Example** - Valid arguments but invalid system state:
696
+ **FormError Example** - Invalid input value triggers form validation:
791
697
 
792
698
  ```ruby
793
- # First attempt succeeds
794
- result = Users::CompleteUserOperation.call!(user_id: 46)
795
- # => Success(#<struct user=#<User id: 46, completed_at: "2025-11-26">>)
699
+ # Form validation fails - input itself is invalid
700
+ result = TestOperation.call(sentence: "form_error")
701
+ # => Failure(#<Hanikamu::Operation::FormError: Sentence is not allowed>)
702
+
703
+ # Correcting the input allows the operation to proceed
704
+ result = TestOperation.call(sentence: "hello world")
705
+ # => Success(#<struct message="dlrow olleh">)
706
+ ```
796
707
 
797
- # Second attempt fails due to state, even with valid arguments
798
- result = Users::CompleteUserOperation.call!(user_id: 46)
799
- # => Failure(#<Hanikamu::Operation::GuardError: User has already been completed>)
708
+ **GuardError Example** - Valid input but business rule violated:
800
709
 
801
- # The arguments are still correct, but the operation cannot proceed
802
- # because the user's state has changed
710
+ ```ruby
711
+ # Input passes form validation, but guard fails
712
+ result = TestOperation.call(sentence: "guard_error")
713
+ # => Failure(#<Hanikamu::Operation::GuardError: Sentence is not allowed>)
714
+
715
+ # The input format is valid, but the business rule prevents execution
803
716
  ```
804
717
 
805
718
  **Type Error Example** - Wrong argument type:
806
719
 
807
720
  ```ruby
808
- # Passing wrong type raises immediately
809
- Users::CompleteUserOperation.call!(user_id: "not-a-number")
721
+ # Passing wrong type raises immediately before any validations
722
+ TestOperation.call!(sentence: 123)
810
723
  # => Raises Dry::Struct::Error
811
724
  ```
812
725
 
726
+ **Key Insight**: Form validations check if the *input is correct*, while guards check if the *operation can proceed* given the current state.
727
+
813
728
  ### Using `.call!` (Raises Exceptions)
814
729
 
815
730
  ```ruby
@@ -6,7 +6,7 @@ module Hanikamu
6
6
  class Operation < Hanikamu::Service
7
7
  include ActiveModel::Validations
8
8
 
9
- Error = Class.new(Hanikamu::Service::Error)
9
+ class Error < Hanikamu::Service::Error; end
10
10
 
11
11
  # Error classes
12
12
  class FormError < Hanikamu::Service::Error
@@ -91,9 +91,15 @@ module Hanikamu
91
91
  end
92
92
 
93
93
  # DSL methods
94
- def within_mutex(lock_key, expire_milliseconds: nil)
94
+ def within_mutex(lock_key, expire_milliseconds: nil, if: nil, unless: nil)
95
+ if_condition = binding.local_variable_get(:if)
96
+ unless_condition = binding.local_variable_get(:unless)
97
+ validate_mutex_conditions!(if_condition, unless_condition)
98
+
95
99
  @_mutex_lock_key = lock_key
96
100
  @_mutex_expire_milliseconds = expire_milliseconds || Hanikamu::Operation.config.mutex_expire_milliseconds
101
+ @_mutex_if_condition = if_condition
102
+ @_mutex_unless_condition = unless_condition
97
103
  end
98
104
 
99
105
  def within_transaction(klass)
@@ -144,7 +150,24 @@ module Hanikamu
144
150
  end
145
151
  # rubocop:enable Metrics/MethodLength
146
152
 
147
- attr_reader :_mutex_lock_key, :_mutex_expire_milliseconds, :_transaction_klass, :_block
153
+ attr_reader :_mutex_lock_key, :_mutex_expire_milliseconds, :_mutex_if_condition, :_mutex_unless_condition,
154
+ :_transaction_klass, :_block
155
+
156
+ private
157
+
158
+ def validate_mutex_conditions!(if_condition, unless_condition)
159
+ if if_condition && unless_condition
160
+ raise ArgumentError, "Cannot specify both :if and :unless conditions for within_mutex"
161
+ end
162
+
163
+ if if_condition && !if_condition.respond_to?(:call)
164
+ raise ArgumentError, "within_mutex :if condition must be a callable (e.g. a Proc or lambda)"
165
+ end
166
+
167
+ return unless unless_condition && !unless_condition.respond_to?(:call)
168
+
169
+ raise ArgumentError, "within_mutex :unless condition must be a callable (e.g. a Proc or lambda)"
170
+ end
148
171
  end
149
172
 
150
173
  def call!(&block)
@@ -167,15 +190,11 @@ module Hanikamu
167
190
  end
168
191
 
169
192
  def within_mutex!(&)
170
- return yield if _lock_key.nil?
171
-
172
- Hanikamu::Operation.redis_lock.lock!(_lock_key, self.class._mutex_expire_milliseconds, &)
173
- end
174
-
175
- def _lock_key
176
- return if self.class._mutex_lock_key.blank?
193
+ return yield if self.class._mutex_lock_key.blank?
194
+ return yield unless _should_apply_mutex?
177
195
 
178
- public_send(self.class._mutex_lock_key)
196
+ lock_key = public_send(self.class._mutex_lock_key)
197
+ Hanikamu::Operation.redis_lock.lock!(lock_key, self.class._mutex_expire_milliseconds, &)
179
198
  end
180
199
 
181
200
  def within_transaction!(&)
@@ -186,6 +205,13 @@ module Hanikamu
186
205
 
187
206
  private
188
207
 
208
+ def _should_apply_mutex?
209
+ return false if self.class._mutex_if_condition && !instance_exec(&self.class._mutex_if_condition)
210
+ return false if self.class._mutex_unless_condition && instance_exec(&self.class._mutex_unless_condition)
211
+
212
+ true
213
+ end
214
+
189
215
  def transaction_class
190
216
  return if self.class._transaction_klass.nil?
191
217
  return ActiveRecord::Base if self.class._transaction_klass == :base
metadata CHANGED
@@ -1,15 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hanikamu-operation
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nicolai Seerup
8
8
  - Alejandro Jimenez
9
- autorequire:
10
9
  bindir: exe
11
10
  cert_chain: []
12
- date: 2025-11-27 00:00:00.000000000 Z
11
+ date: 1980-01-02 00:00:00.000000000 Z
13
12
  dependencies:
14
13
  - !ruby/object:Gem::Dependency
15
14
  name: activemodel
@@ -102,11 +101,12 @@ dependencies:
102
101
  description: |
103
102
  Ruby gem for building robust service operations with guard validations, distributed mutex locks via Redlock,
104
103
  database transactions, and comprehensive error handling. Thread-safe and designed for production Rails applications.
105
- email:
106
104
  executables: []
107
105
  extensions: []
108
106
  extra_rdoc_files: []
109
107
  files:
108
+ - ".cursor/rules/docker-environment.mdc"
109
+ - ".vscode/settings.json"
110
110
  - CHANGELOG.md
111
111
  - Dockerfile
112
112
  - LICENSE.txt
@@ -124,7 +124,6 @@ metadata:
124
124
  source_code_uri: https://github.com/Hanikamu/hanikamu-operation
125
125
  changelog_uri: https://github.com/Hanikamu/hanikamu-operation/blob/main/CHANGELOG.md
126
126
  rubygems_mfa_required: 'true'
127
- post_install_message:
128
127
  rdoc_options: []
129
128
  require_paths:
130
129
  - lib
@@ -132,15 +131,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
132
131
  requirements:
133
132
  - - ">="
134
133
  - !ruby/object:Gem::Version
135
- version: 3.2.0
134
+ version: 3.4.0
136
135
  required_rubygems_version: !ruby/object:Gem::Requirement
137
136
  requirements:
138
137
  - - ">="
139
138
  - !ruby/object:Gem::Version
140
139
  version: '0'
141
140
  requirements: []
142
- rubygems_version: 3.5.3
143
- signing_key:
141
+ rubygems_version: 4.0.3
144
142
  specification_version: 4
145
143
  summary: Service objects with guards, distributed locks, and transactions
146
144
  test_files: []