gl_command 1.2.0 → 1.4.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: 705c23dec5e3038e7a6b4d0736f58589b775edd30ee53056d2478b8361f9a3d6
4
- data.tar.gz: a547c64df86a0f2a533ad43ea5f1ee0a1e2dfcc8fe27e6f1c85b4a018ad75b39
3
+ metadata.gz: affffd85a3150ca92bd45b28822125b8a64117bb41d2f8bffa9fd2861712c9d5
4
+ data.tar.gz: 45e62be391f6958720e3e8aec0a7f04fbaf0d9fe4a5b9b382fc2ae4b91246689
5
5
  SHA512:
6
- metadata.gz: b13e47c074cc18aeeb50cd0e874c088cd7c0091b52fc5d705b4b5b79ddbb18fa1b3d12b596bf2febd85264a1fe5f9e933fa0893444b21369a4a8cc4a3a33c64f
7
- data.tar.gz: 25ac2f9a597e199fef77ebcd08442937615b63102bd8e071d0aea68ede1a2e83da085e902022f54ed32a41ddcf474510e9be811f3dc37941b78f7a2498a0c383
6
+ metadata.gz: 9c2ac5fcedfc9156231e3d146a221945deca5e3f687b2413fe7db9fecd02e9ba3c8f0b94223d0e414fae1946b1ce52eb574118cfaa873a2524cea1f9b8a7812a
7
+ data.tar.gz: 31aee586028c0cf073f22f267ee3ad095f48041aace47df51fe9b4c48ed6a6487f2a1d138ad26e786f75e314adc087e87b573032663d9bf363adfceb6a8ac790
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GLCommand
2
2
 
3
- `GLCommand` is a way to encapsulate business logic.
3
+ `GLCommand` is a way to encapsulate business logic and standardize error handling.
4
4
 
5
5
  Calling a command returns a `GLCommand::Context` which has these properties:
6
6
 
@@ -10,6 +10,21 @@ Calling a command returns a `GLCommand::Context` which has these properties:
10
10
  - `full_error_message` - which renders a string from the error, or can be set explicitly (used to show a legible error to the user).
11
11
  - `success` - `true` if the command executed without an error (false if there is an `error`)
12
12
 
13
+ # Table of contents
14
+
15
+ - [Installation](#installation)
16
+ - [Using GLCommand](#using-glcommand)
17
+ - [Success/Failure](#successfailure)
18
+ - [Displaying errors (use `full_error_message`)](#displaying-errors-use-full_error_message)
19
+ - [stop_and_fail!](#stop_and_fail)
20
+ - [Validations](#validations)
21
+ - [GLExceptionNotifier](#glexceptionnotifier)
22
+ - [Chainable](#chainable)
23
+ - [Testing `GLCommand`s](#testing-glcommands)
24
+ - [Stubbing with `build_context`](#stubbing-with-build_context)
25
+ - [Rspec matchers](#rspec-matchers)
26
+ - [Publishing the gem to Rubygems](#publishing-the-gem-to-rubygems)
27
+
13
28
 
14
29
  ## Installation
15
30
 
@@ -50,7 +65,7 @@ class SomeCommand < GLCommand::Callable
50
65
  end
51
66
  ```
52
67
 
53
- ## Success/Failure
68
+ ### Success/Failure
54
69
 
55
70
  GLCommand context's are successful by default (`successful?` aliases `success?`).
56
71
 
@@ -70,13 +85,14 @@ Here are the ways of adding an error to a command:
70
85
 
71
86
  If you invoke a command with `.call!` all of the above will raise an exception
72
87
 
73
- If a command fails, it will call its `rollback` method before returning (even when invoked with `.call!`)
88
+ If a command fails, it calls its `rollback` method before returning (even when invoked with `.call!`)
89
+
74
90
 
75
- ### Displaying errors
91
+ ### Displaying errors (use `full_error_message`)
76
92
 
77
93
  In addition to encapsulating business logic, GLCommand also standardizes error handling.
78
94
 
79
- This means that rather than having to rescue errors in controllers, you can just render the command's `full_error_message`
95
+ This means that rather than having to rescue errors in controllers, just render the command's `full_error_message`
80
96
 
81
97
  ```ruby
82
98
  result = GLCommand::Callable.call(params)
@@ -88,7 +104,7 @@ else
88
104
  end
89
105
  ```
90
106
 
91
- In general, use `context.full_error_message` to render errors.
107
+ In general, use `context.full_error_message` to render errors (rather than `context.error.message` which might not have the full error message text).
92
108
 
93
109
 
94
110
  ### `stop_and_fail!`
@@ -139,7 +155,7 @@ If validations fail, `GLExceptionNotifier` is not called
139
155
 
140
156
  ## GLExceptionNotifier
141
157
 
142
- [ExceptionNotifier](https://github.com/givelively/gl_exception_notifier) is Give Lively's wrapper for notify our error monitoring service (currently [Sentry](https://github.com/getsentry/sentry-ruby))
158
+ [GLExceptionNotifier](https://github.com/givelively/gl_exception_notifier) is Give Lively's wrapper for notify our error monitoring service (currently [Sentry](https://github.com/getsentry/sentry-ruby))
143
159
 
144
160
  When a command fails `GLExceptionNotifier` is called, unless:
145
161
 
@@ -147,7 +163,7 @@ When a command fails `GLExceptionNotifier` is called, unless:
147
163
  - The failure is a validation failure
148
164
  - `stop_and_fail!` is called with `no_notify: true` - for example `stop_and_fail!('An error message', no_notify: true)`
149
165
 
150
- **NOTE:** commands that invoke other commands with `call!` inherit the no_notify property of called command.
166
+ **NOTE:** commands that invoke other commands with `call!` inherit the `no_notify` property of the called command.
151
167
 
152
168
  ```ruby
153
169
  class InteriorCommand < GLCommand::Callable
@@ -211,9 +227,104 @@ class SomeChain < GLCommand::Chainable
211
227
  chain(:item)
212
228
  end
213
229
  end
230
+ ```
231
+
232
+ ## RSpec Matchers
233
+
234
+ `GLCommand` comes with a set of RSpec matchers to make testing your command's interface declarative and simple.
235
+
236
+ ### Setup
237
+
238
+ To enable the matchers, add the following line to your `spec/spec_helper.rb` or `spec/rails_helper.rb`:
239
+
240
+ ```ruby
241
+ require 'gl_command/rspec'
242
+ ```
243
+
244
+ This will automatically include the necessary matchers and configure RSpec for specs marked with `type: :command`.
245
+
246
+ ### Usage
247
+
248
+ You can now test your command's interface like this:
249
+
250
+ ```ruby
251
+ # spec/commands/some_command_spec.rb
252
+
253
+ RSpec.describe SomeCommand, type: :command do
254
+ describe 'interface' do
255
+ it { is_expected.to require(:user).being(User) }
256
+ it { is_expected.to allow(:subject) }
257
+ it { is_expected.to returns(:message) }
258
+ it { is_expected.not_to require(:other_thing) }
259
+ end
260
+ end
261
+ ```
262
+
263
+ **Note:** The `.being(ClassName)` chain is supported for `require` and `allow` but not for `return`, as `GLCommand` does not store type information for return values.
264
+
265
+ ## Testing `GLCommand`s
266
+
267
+ Give Lively uses Rspec for testing, so this section assumes you're using RSpec.
268
+
269
+ ### Stubbing with `build_context`
270
+
271
+ If you need the response from a command (typically because you are stubbing it), use the `build_context` method to create a context with the desired response. This has the advantage of using the actual Command's `requires`, `allows`, and `returns` methods.
214
272
 
273
+ ```ruby
274
+ class SomeCommand < GLCommand::Callable
275
+ requires user: User
276
+ allows :subject
277
+ returns :message
278
+
279
+ def call
280
+ user.update!(subject:)
281
+ context.message = "Hello - user subject: #{user.subject}"
282
+ end
283
+ end
284
+
285
+ result = SomeCommand.build_context(user: User.new, message: "Hello!")
286
+ result.success? # true
287
+ result.full_error_message # nil
288
+ result_error = SomeCommand.build_context(error: "invalid")
289
+ result_error.success? # false
290
+ result_error.full_error_message # "invalid"
291
+
292
+ SomeCommand.build_context(other_thing: "some other thing")
293
+ # ArgumentError: Unknown argument or return attribute: 'other_thing'
294
+ ```
295
+
296
+ ### RSpec Matchers
297
+
298
+ `GLCommand` comes with a set of RSpec matchers to make testing your command's interface declarative and simple.
299
+
300
+ ### Setup
301
+
302
+ To enable the matchers, add the following line to your `spec/spec_helper.rb` or `spec/rails_helper.rb`:
303
+
304
+ ```ruby
305
+ require 'gl_command/rspec'
306
+ ```
307
+
308
+ This will automatically include the necessary matchers and configure RSpec for specs marked with `type: :command`.
309
+
310
+ ### Usage
311
+
312
+ You can now test your command's interface like this:
313
+
314
+ ```ruby
315
+ # spec/commands/some_command_spec.rb
316
+
317
+ RSpec.describe SomeCommand, type: :command do
318
+ describe 'interface' do
319
+ it { is_expected.to require(:user).being(User) }
320
+ it { is_expected.to allow(:subject) }
321
+ it { is_expected.to returns(:message) }
322
+ it { is_expected.not_to require(:other_thing) }
323
+ end
324
+ end
215
325
  ```
216
326
 
327
+ **Note:** The `.being(ClassName)` chain is supported for `require` and `allow` but not for `return`, as `GLCommand` does not store type information for return values.
217
328
 
218
329
  ## Publishing the gem to Rubygems
219
330
 
data/gl_command.gemspec CHANGED
@@ -19,5 +19,7 @@ Gem::Specification.new do |spec|
19
19
  spec.add_dependency 'activerecord', '>= 3.2.0'
20
20
  spec.add_dependency 'gl_exception_notifier', '>= 1.0.2'
21
21
 
22
+ spec.add_development_dependency 'rspec', '~> 3.0'
23
+
22
24
  spec.metadata['rubygems_mfa_required'] = 'true'
23
25
  end
@@ -202,6 +202,7 @@ module GLCommand
202
202
 
203
203
  chain_rollback if self.class.chain? # defined in GLCommand::Chainable
204
204
  rollback
205
+ instrument_command(:after_rollback)
205
206
  end
206
207
 
207
208
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
@@ -1,5 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module GLCommand
2
4
  class ContextInspect
5
+ PERMITTED_OUTPUTS = %i[string hash].freeze
6
+
3
7
  class << self
4
8
  def error(error_obj)
5
9
  return '' if error_obj.blank?
@@ -7,35 +11,50 @@ module GLCommand
7
11
  error_obj.is_a?(Array) ? error_obj.uniq.join(', ') : error_obj.to_s
8
12
  end
9
13
 
10
- def hash_params(hash)
11
- hash.map do |key, value|
12
- value_s =
13
- if value.nil?
14
- 'nil'
15
- elsif value.respond_to?(:to_sql)
16
- object_param_as_sql(value)
17
- elsif value.respond_to?(:uuid)
18
- object_param_with_id(value, :uuid)
19
- elsif value.respond_to?(:id)
20
- object_param_with_id(value, :id)
21
- else
22
- value
23
- end
24
- "#{key}: #{value_s}"
25
- end.join(', ')
14
+ def hash_params(hash, output: :string)
15
+ unless PERMITTED_OUTPUTS.include?(output)
16
+ raise "Unknown output type: #{output}, must be one of #{PERMITTED_OUTPUTS}"
17
+ end
18
+
19
+ result = hash.map { |key, value| output_for(key:, value:, output:) }
20
+ output == :string ? result.join(', ') : result.to_h
26
21
  end
27
22
 
28
23
  private
29
24
 
25
+ def output_for(key:, value:, output:)
26
+ value_s = if value.nil?
27
+ 'nil'
28
+ elsif value.respond_to?(:to_sql)
29
+ object_param_as_sql(value, output:)
30
+ elsif value.respond_to?(:id)
31
+ object_param_with_id(value, :id, output:)
32
+ elsif value.respond_to?(:uuid)
33
+ object_param_with_id(value, :uuid, output:)
34
+ else
35
+ value
36
+ end
37
+
38
+ output == :string ? "#{key}: #{value_s}" : [key, value_s]
39
+ end
40
+
30
41
  # Active record objects can be really big - rather than rendering the whole object, just show the ID
31
- def object_param_with_id(obj, key)
42
+ def object_param_with_id(obj, key, output:)
32
43
  obj_id = obj.send(key)
33
- id_value = obj_id.is_a?(Integer) ? obj_id : "\"#{obj_id}\""
34
- "#<#{obj.class.name} #{key}=#{id_value}>"
44
+ if output == :string
45
+ id_value = obj_id.is_a?(Integer) ? obj_id : "\"#{obj_id}\""
46
+ "#<#{obj.class.name} #{key}=#{id_value}>"
47
+ else
48
+ obj_id
49
+ end
35
50
  end
36
51
 
37
- def object_param_as_sql(obj)
38
- "#<#{obj.class.name} sql=\"#{obj.to_sql}\">"
52
+ def object_param_as_sql(obj, output:)
53
+ if output == :string
54
+ "#<#{obj.class.name} sql=\"#{obj.to_sql}\">"
55
+ else
56
+ obj.to_sql
57
+ end
39
58
  end
40
59
  end
41
60
  end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file is intended to be required by lib/gl_command/rspec.rb
4
+
5
+ module GLCommand
6
+ module Matchers
7
+ # Base matcher for `requires` and `allows`, which store a Hash of { attribute: Type }.
8
+ class CommandArgumentMatcher
9
+ def initialize(attribute)
10
+ @attribute = attribute
11
+ @expected_type = nil
12
+ end
13
+
14
+ def being(expected_type)
15
+ @expected_type = expected_type
16
+ self
17
+ end
18
+
19
+ def matches?(command_class)
20
+ @command_class = command_class.is_a?(Class) ? command_class : command_class.class
21
+ attributes = @command_class.public_send(scope)
22
+
23
+ return false unless attributes.key?(@attribute)
24
+ return true if @expected_type.nil? # Type check not requested
25
+
26
+ attributes[@attribute] == @expected_type
27
+ end
28
+
29
+ def description
30
+ "#{action} argument `#{@attribute}`"
31
+ end
32
+
33
+ def failure_message
34
+ message = "Expected #{@command_class.name} to #{action} `#{@attribute}`"
35
+ message += " of type `#{@expected_type}`" if @expected_type
36
+ message
37
+ end
38
+
39
+ def failure_message_when_negated
40
+ message = "Expected #{@command_class.name} not to #{action} `#{@attribute}`"
41
+ message += " of type `#{@expected_type}`" if @expected_type
42
+ message
43
+ end
44
+ end
45
+
46
+ class RequireArgumentMatcher < CommandArgumentMatcher
47
+ private
48
+
49
+ def scope; :requires; end
50
+ def action; 'require'; end
51
+ end
52
+
53
+ class AllowArgumentMatcher < CommandArgumentMatcher
54
+ private
55
+
56
+ def scope; :allows; end
57
+ def action; 'allow'; end
58
+ end
59
+
60
+ # Specific matcher for `returns`, which only stores an Array of keys.
61
+ class ReturnAttributeMatcher
62
+ def initialize(attribute)
63
+ @attribute = attribute
64
+ @type_check_attempted = false
65
+ end
66
+
67
+ def being(_expected_type)
68
+ @type_check_attempted = true
69
+ self
70
+ end
71
+
72
+ def matches?(command_class)
73
+ @command_class = command_class.is_a?(Class) ? command_class : command_class.class
74
+
75
+ if @type_check_attempted
76
+ @failure_reason = 'GLCommand::Callable does not store types for `returns`, so `.being()` cannot be used.'
77
+ return false
78
+ end
79
+
80
+ @command_class.returns.include?(@attribute)
81
+ end
82
+
83
+ def description
84
+ "return attribute `#{@attribute}`"
85
+ end
86
+
87
+ def failure_message
88
+ return @failure_reason if @failure_reason
89
+
90
+ "Expected #{@command_class.name} to return `#{@attribute}`"
91
+ end
92
+
93
+ def failure_message_when_negated
94
+ "Expected #{@command_class.name} not to return `#{@attribute}`"
95
+ end
96
+ end
97
+
98
+ # Helper methods to provide the clean syntax in specs
99
+ def require(attribute)
100
+ RequireArgumentMatcher.new(attribute)
101
+ end
102
+
103
+ def allow(attribute)
104
+ AllowArgumentMatcher.new(attribute)
105
+ end
106
+
107
+ def returns(attribute)
108
+ ReturnAttributeMatcher.new(attribute)
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'gl_command/rspec/matchers'
4
+
5
+ # A shared context to automatically set the subject of a spec to the
6
+ # described class. This allows `is_expected` to work directly on the
7
+ # command class in specs with `type: :command`.
8
+ RSpec.shared_context 'GLCommand::Command subject' do
9
+ subject { described_class }
10
+ end
11
+
12
+ RSpec.configure do |config|
13
+ # Makes the matcher methods (require, allow, returns) available in these specs.
14
+ config.include GLCommand::Matchers, type: :command
15
+
16
+ # Allows `is_expected` to work directly on the command class.
17
+ config.include_context 'GLCommand::Command subject', type: :command
18
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'active_support/concern'
4
4
  require 'active_model'
5
+ require 'active_record'
5
6
 
6
7
  module GLCommand
7
8
  module Validatable
@@ -1,3 +1,3 @@
1
1
  module GLCommand
2
- VERSION = '1.2.0'.freeze
2
+ VERSION = '1.4.0'.freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gl_command
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Give Lively
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-04-01 00:00:00.000000000 Z
11
+ date: 2025-07-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: 1.0.2
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
41
55
  description:
42
56
  email:
43
57
  executables: []
@@ -54,6 +68,8 @@ files:
54
68
  - lib/gl_command/chainable_context.rb
55
69
  - lib/gl_command/context.rb
56
70
  - lib/gl_command/context_inspect.rb
71
+ - lib/gl_command/rspec.rb
72
+ - lib/gl_command/rspec/matchers.rb
57
73
  - lib/gl_command/validatable.rb
58
74
  - lib/gl_command/version.rb
59
75
  homepage: https://github.com/givelively/gl_command