yes-core 1.2.0 → 2.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/README.md +13 -0
  4. data/lib/yes/core/active_job_serializers/command_group_serializer.rb +7 -4
  5. data/lib/yes/core/aggregate/dsl/class_name_convention.rb +8 -0
  6. data/lib/yes/core/aggregate/dsl/class_resolvers/command_group/base.rb +34 -0
  7. data/lib/yes/core/aggregate/dsl/class_resolvers/command_group/command.rb +43 -0
  8. data/lib/yes/core/aggregate/dsl/class_resolvers/command_group/guard_evaluator.rb +35 -0
  9. data/lib/yes/core/aggregate/dsl/command_group_data.rb +45 -0
  10. data/lib/yes/core/aggregate/dsl/command_group_definer.rb +100 -0
  11. data/lib/yes/core/aggregate/dsl/method_definers/command_group/base.rb +29 -0
  12. data/lib/yes/core/aggregate/dsl/method_definers/command_group/can_command_group.rb +41 -0
  13. data/lib/yes/core/aggregate/dsl/method_definers/command_group/command_group.rb +40 -0
  14. data/lib/yes/core/aggregate.rb +50 -0
  15. data/lib/yes/core/authorization/command_cerbos_authorizer.rb +20 -5
  16. data/lib/yes/core/authorization/lookup_cache.rb +72 -0
  17. data/lib/yes/core/command_handling/command_group_executor.rb +237 -0
  18. data/lib/yes/core/command_handling/command_group_handler.rb +89 -0
  19. data/lib/yes/core/command_handling/event_publisher.rb +8 -3
  20. data/lib/yes/core/commands/command_group.rb +152 -0
  21. data/lib/yes/core/commands/command_group_response.rb +66 -0
  22. data/lib/yes/core/commands/group.rb +7 -12
  23. data/lib/yes/core/commands/group_payload_normalizer.rb +45 -0
  24. data/lib/yes/core/commands/processor.rb +28 -7
  25. data/lib/yes/core/commands/stateless/handler.rb +9 -3
  26. data/lib/yes/core/commands/stateless/handler_helpers.rb +1 -1
  27. data/lib/yes/core/configuration.rb +39 -0
  28. data/lib/yes/core/data_decryptor.rb +2 -2
  29. data/lib/yes/core/data_encryptor.rb +6 -2
  30. data/lib/yes/core/failed_subscription_notifier.rb +29 -0
  31. data/lib/yes/core/middlewares/encryptor.rb +9 -1
  32. data/lib/yes/core/middlewares/write_encryptor.rb +35 -0
  33. data/lib/yes/core/middlewares.rb +45 -0
  34. data/lib/yes/core/railtie.rb +20 -0
  35. data/lib/yes/core/test_support/aggregate/command_test_dsl.rb +77 -2
  36. data/lib/yes/core/test_support/aggregate/shared_examples.rb +45 -0
  37. data/lib/yes/core/test_support/event_helpers.rb +1 -1
  38. data/lib/yes/core/types.rb +8 -1
  39. data/lib/yes/core/utils/command_utils.rb +21 -0
  40. data/lib/yes/core/utils/hash_utils.rb +7 -2
  41. data/lib/yes/core/version.rb +1 -1
  42. metadata +19 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 684a9c20dfd17ba779b196a7b294b93e04bf531bcc3d31a2adc9f8a4d645b7dd
4
- data.tar.gz: 43e482658ce60a8c7fe4eb5f73f3bf084169857bd06a57a01aadf5ab7cf98e76
3
+ metadata.gz: 4845d0466658aee907edb5bab0679603e25a9b5c088a8df067d0fbfcc060bff4
4
+ data.tar.gz: cca505e973fa99d5b8e4b07c44752b78b330fdc0035b45528c83139ee36a0e4b
5
5
  SHA512:
6
- metadata.gz: 335785676b2baef08117225b60c3a3b170bb39b0c3fe8ffdfa2781c158944b562f53d00884f409fb0c07c15ace4b513c02bd042973da57118ec325701a258acb
7
- data.tar.gz: 72bc5b16b03b00ddc6211806d2566ad5213a25e452836e1ffd09f1e89c10154e98e47edb5ac75573092b1c21ea13cc38f5f49f379f418c2e08c2532d2e498670
6
+ metadata.gz: '08121024f7bb36c1bb17281d772c907f4c1e9c119437caf6642c0461fcc9ce2d434c15fa6dc548ec8c6362b520b5144f8869d7054c8e9a110e33a984d757fd63'
7
+ data.tar.gz: b2b2bcbf061918d7876e27c6014d41adfe1ed5133163a078d09a31490219105a57f47699c5bd2d6c9d3400c12df527c13e2d0f76d64b32f1d4a2b1aaec1b4b8f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.2.0] - 2026-09-01
4
+
5
+ - See root CHANGELOG.md for details.
6
+
7
+ ## [1.4.0] - 2026-06-24
8
+
9
+ - See root CHANGELOG.md for details.
10
+
11
+ ## [1.3.1] - 2026-06-16
12
+
13
+ - See root CHANGELOG.md for details.
14
+
15
+ ## [1.3.0] - 2026-05-18
16
+
17
+ - See root CHANGELOG.md for details.
18
+
3
19
  ## [1.2.0] - 2026-04-30
4
20
 
5
21
  - See root CHANGELOG.md for details.
data/README.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  Core event sourcing framework providing the aggregate DSL, commands, events, read models, and supporting infrastructure for the [Yes](https://github.com/yousty/yes) framework.
4
4
 
5
+ ## Compatibility with pg_eventstore
6
+
7
+ ⚠️ **pg_eventstore 3.x requires yes-core >= 2.1.1.** pg_eventstore 3.0.0 generates event
8
+ ids with `SecureRandom.uuid_v7` (it moved the default off the database's
9
+ `gen_random_uuid()`, which always produced v4). Those ids arrive as `causation_id` /
10
+ `correlation_id`, and `Types::UUID` accepted only v4 before 2.1.1 — so
11
+ `TransactionDetails.new` raised `Dry::Struct::Error`, the event handler failed, and the
12
+ subscription died once its restarts ran out.
13
+
14
+ `Types::UUID` now accepts any RFC 9562 version (1-8) while still requiring a valid
15
+ version and variant nibble.
16
+
17
+
5
18
  ## Installation
6
19
 
7
20
  Add this line to your application's Gemfile:
@@ -3,22 +3,25 @@
3
3
  module Yes
4
4
  module Core
5
5
  module ActiveJobSerializers
6
- # ActiveJob serializer for CommandGroup objects.
6
+ # ActiveJob serializer for {Yes::Core::Commands::Group} (legacy stateless
7
+ # cross-aggregate groups) and {Yes::Core::Commands::CommandGroup}
8
+ # (aggregate-DSL groups). Both round-trip through `to_h` / `Class.new`.
7
9
  class CommandGroupSerializer < ActiveJob::Serializers::ObjectSerializer
8
10
  # @param argument [Object] the argument to check
9
11
  # @return [Boolean] true if the argument can be serialized
10
12
  def serialize?(argument)
11
- argument.is_a? Yes::Core::Commands::Group
13
+ argument.is_a?(Yes::Core::Commands::Group) ||
14
+ argument.is_a?(Yes::Core::Commands::CommandGroup)
12
15
  end
13
16
 
14
- # @param command_group [Yes::Core::Commands::Group] the command group to serialize
17
+ # @param command_group [Yes::Core::Commands::Group, Yes::Core::Commands::CommandGroup]
15
18
  # @return [Hash] the serialized representation
16
19
  def serialize(command_group)
17
20
  super(command_group.to_h.merge(_type: command_group.class.name))
18
21
  end
19
22
 
20
23
  # @param hash [Hash] the serialized representation
21
- # @return [Yes::Core::Commands::Group] the deserialized command group
24
+ # @return [Yes::Core::Commands::Group, Yes::Core::Commands::CommandGroup]
22
25
  def deserialize(hash)
23
26
  symbolized_hash = hash.deep_symbolize_keys
24
27
  Object.const_get(symbolized_hash[:_type]).new(symbolized_hash.except(:_aj_serialized, :_type))
@@ -43,6 +43,14 @@ module Yes
43
43
  "#{context}::#{aggregate}::Commands::#{name.to_s.camelize}::GuardEvaluator"
44
44
  end
45
45
 
46
+ def command_group_class_name(name)
47
+ "#{context}::#{aggregate}::CommandGroups::#{name.to_s.camelize}::Command"
48
+ end
49
+
50
+ def command_group_guard_evaluator_class_name(name)
51
+ "#{context}::#{aggregate}::CommandGroups::#{name.to_s.camelize}::GuardEvaluator"
52
+ end
53
+
46
54
  def state_updater_class_name(name)
47
55
  "#{context}::#{aggregate}::Commands::#{name.to_s.camelize}::StateUpdater"
48
56
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ class Aggregate
6
+ module Dsl
7
+ module ClassResolvers
8
+ module CommandGroup
9
+ # Base class for command_group-related class resolvers.
10
+ #
11
+ # Mirrors {ClassResolvers::Command::Base} but binds to
12
+ # {CommandGroupData} instead of {CommandData}.
13
+ #
14
+ # @abstract Subclass and implement {ClassResolvers::Base#class_type},
15
+ # {ClassResolvers::Base#class_name}, and
16
+ # {ClassResolvers::Base#generate_class}.
17
+ class Base < ClassResolvers::Base
18
+ # @param command_group_data [Yes::Core::Aggregate::Dsl::CommandGroupData]
19
+ def initialize(command_group_data)
20
+ @command_group_data = command_group_data
21
+
22
+ super(command_group_data.context_name, command_group_data.aggregate_name)
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :command_group_data
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ class Aggregate
6
+ module Dsl
7
+ module ClassResolvers
8
+ module CommandGroup
9
+ # Resolves or generates the Command class for an aggregate-DSL
10
+ # command group. The generated class is a {Yes::Core::Commands::CommandGroup}
11
+ # subclass carrying the group's identity (context, aggregate,
12
+ # group_name) and the ordered list of sub-command names.
13
+ class Command < Base
14
+ private
15
+
16
+ def class_type
17
+ :command_group
18
+ end
19
+
20
+ def class_name
21
+ command_group_data.name
22
+ end
23
+
24
+ def generate_class
25
+ group_name = command_group_data.name
26
+ ctx = command_group_data.context_name
27
+ agg = command_group_data.aggregate_name
28
+ sub_commands = command_group_data.sub_command_names.dup
29
+
30
+ Class.new(Yes::Core::Commands::CommandGroup).tap do |klass|
31
+ klass.context = ctx
32
+ klass.aggregate = agg
33
+ klass.group_name = group_name
34
+ klass.sub_command_names = sub_commands
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ class Aggregate
6
+ module Dsl
7
+ module ClassResolvers
8
+ module CommandGroup
9
+ # Resolves or generates the GuardEvaluator class for a command_group.
10
+ #
11
+ # Unlike the per-command resolver, no `:no_change` guard is
12
+ # auto-injected — command groups are intended to be lighter on
13
+ # guard checks and rely on whatever set of guards the user
14
+ # declares explicitly in the DSL block.
15
+ class GuardEvaluator < Base
16
+ private
17
+
18
+ def class_type
19
+ :command_group_guard_evaluator
20
+ end
21
+
22
+ def class_name
23
+ command_group_data.name
24
+ end
25
+
26
+ def generate_class
27
+ Class.new(Yes::Core::CommandHandling::GuardEvaluator)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ class Aggregate
6
+ module Dsl
7
+ # Data object that holds information about a command_group definition
8
+ # in an aggregate.
9
+ #
10
+ # @example
11
+ # CommandGroupData.new(:create_apprenticeship, MyAggregate, context: 'Companies', aggregate: 'Apprenticeship')
12
+ class CommandGroupData
13
+ attr_reader :name, :context_name, :aggregate_name, :aggregate_class
14
+ attr_accessor :sub_command_names, :guard_names
15
+
16
+ # @param name [Symbol] the name of the command group
17
+ # @param aggregate_class [Class] the aggregate class the group belongs to
18
+ # @param options [Hash] additional options
19
+ # @option options [String] :context the context name
20
+ # @option options [String] :aggregate the aggregate name
21
+ def initialize(name, aggregate_class, options = {})
22
+ @name = name
23
+ @aggregate_class = aggregate_class
24
+ @context_name = options[:context]
25
+ @aggregate_name = options[:aggregate]
26
+ @sub_command_names = []
27
+ @guard_names = []
28
+ end
29
+
30
+ # @param name [Symbol] sub-command name to append (preserves order)
31
+ # @return [void]
32
+ def add_sub_command(name)
33
+ @sub_command_names << name
34
+ end
35
+
36
+ # @param name [Symbol] guard name to record on this group
37
+ # @return [void]
38
+ def add_guard(name)
39
+ @guard_names << name
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ class Aggregate
6
+ module Dsl
7
+ # Factory class that creates and defines command_groups on aggregates.
8
+ #
9
+ # Mirrors {CommandDefiner}. The DSL evaluator inside accepts two
10
+ # methods: `command :sub_command_name` to push a sub-command symbol,
11
+ # and `guard(name, error_extra: …) { … }` to register a group-level
12
+ # guard on the generated GuardEvaluator class.
13
+ #
14
+ # @example
15
+ # group_data = CommandGroupData.new(:create_apprenticeship, MyAggregate,
16
+ # context: 'Companies', aggregate: 'Apprenticeship')
17
+ # CommandGroupDefiner.new(group_data).call do
18
+ # command :assign_company
19
+ # command :change_name
20
+ #
21
+ # guard(:company_assigned) { company_id.present? }
22
+ # end
23
+ class CommandGroupDefiner
24
+ # Raised when an unknown sub-command name is referenced.
25
+ class UnknownSubCommandError < Yes::Core::Error; end
26
+
27
+ attr_reader :command_group_data
28
+ private :command_group_data
29
+
30
+ # @param command_group_data [CommandGroupData]
31
+ def initialize(command_group_data)
32
+ @command_group_data = command_group_data
33
+ end
34
+
35
+ # Generates and registers all classes/methods for the command group.
36
+ #
37
+ # @yield Block for declaring sub-commands and guards
38
+ # @return [void]
39
+ def call(&block)
40
+ create_and_register_guard_evaluator
41
+ evaluate_dsl_block(&block) if block
42
+ create_and_register_command
43
+ define_aggregate_methods
44
+ end
45
+
46
+ private
47
+
48
+ def create_and_register_guard_evaluator
49
+ @guard_evaluator_class = ClassResolvers::CommandGroup::GuardEvaluator.new(command_group_data).call
50
+ end
51
+
52
+ def create_and_register_command
53
+ ClassResolvers::CommandGroup::Command.new(command_group_data).call
54
+ end
55
+
56
+ def define_aggregate_methods
57
+ MethodDefiners::CommandGroup::CommandGroup.new(command_group_data).call
58
+ MethodDefiners::CommandGroup::CanCommandGroup.new(command_group_data).call
59
+ end
60
+
61
+ def evaluate_dsl_block(&)
62
+ DslEvaluator.new(command_group_data, @guard_evaluator_class).instance_eval(&)
63
+ end
64
+
65
+ # DSL evaluator that backs the `command_group :name do … end` block.
66
+ class DslEvaluator
67
+ attr_reader :command_group_data, :guard_evaluator_class
68
+
69
+ def initialize(command_group_data, guard_evaluator_class)
70
+ @command_group_data = command_group_data
71
+ @guard_evaluator_class = guard_evaluator_class
72
+ end
73
+
74
+ # Declare a sub-command. Order is preserved as execution order.
75
+ #
76
+ # @param name [Symbol] the sub-command name (must match a command
77
+ # declared on the same aggregate)
78
+ # @return [void]
79
+ def command(name)
80
+ command_group_data.add_sub_command(name.to_sym)
81
+ end
82
+
83
+ # Register a group-level guard. Semantics match the per-command
84
+ # `guard` DSL — `:no_change` is recognized as the magic name that
85
+ # raises {NoChangeTransition} on failure.
86
+ #
87
+ # @param name [Symbol] the guard name
88
+ # @param error_extra [Hash, Proc] extra error context
89
+ # @yield Block returning true if the guard passes
90
+ # @return [void]
91
+ def guard(name, error_extra: {}, &)
92
+ command_group_data.add_guard(name)
93
+ guard_evaluator_class.guard(name, error_extra:, &)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ class Aggregate
6
+ module Dsl
7
+ module MethodDefiners
8
+ module CommandGroup
9
+ # Base class for command_group method definers.
10
+ class Base
11
+ def initialize(command_group_data)
12
+ @name = command_group_data.name
13
+ @aggregate_class = command_group_data.aggregate_class
14
+ end
15
+
16
+ def call
17
+ raise NotImplementedError, "#{self.class} must implement #call"
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :name, :aggregate_class
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ class Aggregate
6
+ module Dsl
7
+ module MethodDefiners
8
+ module CommandGroup
9
+ # Defines `aggregate.can_<group_name>?(payload = {})` and the
10
+ # `<group_name>_error` accessor on the aggregate class.
11
+ #
12
+ # Mirrors {MethodDefiners::Command::CanCommand} but resolves the
13
+ # group's GuardEvaluator class instead of a command's.
14
+ class CanCommandGroup < Base
15
+ def call
16
+ can_method = :"can_#{@name}?"
17
+ error_method = :"#{@name}_error"
18
+
19
+ aggregate_class.attr_accessor error_method
20
+ group_name = @name
21
+
22
+ aggregate_class.define_method(can_method) do |payload = {}|
23
+ cmd = command_utilities.build_group_command(group_name, payload)
24
+ guard_evaluator_class = command_utilities.fetch_guard_evaluator_class_for_group(group_name)
25
+
26
+ Yes::Core::CommandHandling::GuardRunner.new(self).call(
27
+ cmd, group_name, guard_evaluator_class, skip_guards: false
28
+ ).present?
29
+ rescue Yes::Core::CommandHandling::GuardEvaluator::InvalidTransition,
30
+ Yes::Core::CommandHandling::GuardEvaluator::NoChangeTransition,
31
+ Yes::Core::Command::Invalid
32
+ false
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ class Aggregate
6
+ module Dsl
7
+ module MethodDefiners
8
+ module CommandGroup
9
+ # Defines `aggregate.<group_name>(payload = nil, **options)` on the
10
+ # aggregate class. Mirrors {MethodDefiners::Command::Command} but
11
+ # delegates to {Yes::Core::CommandHandling::CommandGroupHandler}.
12
+ class CommandGroup < Base
13
+ def call
14
+ group_name = @name
15
+
16
+ aggregate_class.define_method(group_name) do |payload = nil, **options|
17
+ payload = payload.clone if payload.is_a?(Hash)
18
+
19
+ guards = options.delete(:guards)
20
+ guards = true if guards.nil?
21
+ metadata = options.delete(:metadata)
22
+
23
+ if payload.nil? && !options.empty?
24
+ payload = options
25
+ elsif payload.nil?
26
+ payload = {}
27
+ end
28
+
29
+ Yes::Core::CommandHandling::CommandGroupHandler.new(self).call(
30
+ group_name, payload, guards:, metadata:
31
+ )
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -66,6 +66,7 @@ module Yes
66
66
  if tp.self == subclass
67
67
  subclass.setup_read_model_classes if subclass.read_model_enabled?
68
68
  subclass.setup_authorizer_classes
69
+ subclass.validate_command_groups!
69
70
  tp.disable
70
71
  end
71
72
  end.enable
@@ -337,6 +338,55 @@ module Yes
337
338
  @commands ||= {}
338
339
  end
339
340
 
341
+ # Defines a command_group on the aggregate.
342
+ #
343
+ # A command_group declares a compound action that runs multiple
344
+ # existing aggregate commands in declaration order, atomically, with
345
+ # the sub-commands' guards bypassed. The group itself has its own,
346
+ # leaner guard set declared inside the block via `guard :name`.
347
+ #
348
+ # @param name [Symbol] the group name (also the aggregate method name)
349
+ # @yield Block accepting `command :sub_name` and `guard :name`
350
+ # @return [void]
351
+ #
352
+ # @example
353
+ # command_group :create_apprenticeship do
354
+ # command :assign_company
355
+ # command :assign_user
356
+ # command :change_name
357
+ # command :publish
358
+ #
359
+ # guard(:company_assigned) { payload.company_id.present? }
360
+ # end
361
+ def command_group(name, &)
362
+ @command_groups ||= {}
363
+ group_data = Dsl::CommandGroupData.new(name, self, context:, aggregate:)
364
+ @command_groups[name] = group_data
365
+ Dsl::CommandGroupDefiner.new(group_data).call(&)
366
+ end
367
+
368
+ # @return [Hash] The command groups defined on this aggregate
369
+ def command_groups
370
+ @command_groups ||= {}
371
+ end
372
+
373
+ # Validates that each command_group references commands actually
374
+ # defined on this aggregate. Called from the end-of-class
375
+ # {TracePoint} hook set up in {.inherited}.
376
+ #
377
+ # @raise [Dsl::CommandGroupDefiner::UnknownSubCommandError]
378
+ # @return [void]
379
+ def validate_command_groups!
380
+ command_groups.each do |group_name, data|
381
+ unknown = data.sub_command_names - commands.keys
382
+ next if unknown.empty?
383
+
384
+ raise Dsl::CommandGroupDefiner::UnknownSubCommandError,
385
+ "command_group :#{group_name} on #{name} references unknown commands: " \
386
+ "#{unknown.join(', ')}. Define them with `command :<name>` before using them."
387
+ end
388
+ end
389
+
340
390
  private
341
391
 
342
392
  #
@@ -36,7 +36,8 @@ module Yes
36
36
 
37
37
  raise_command_unauthorized_error!(decision)
38
38
  end
39
- otl_trackable :call, OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Cerbos Authorize Command')
39
+ otl_trackable :call,
40
+ OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Cerbos Authorize Command', track_sql: true)
40
41
 
41
42
  private
42
43
 
@@ -60,6 +61,10 @@ module Yes
60
61
  raise self::CommandNotAuthorized, msg
61
62
  end
62
63
 
64
+ # Loads the resource the command acts on. Commands batched into one request
65
+ # commonly target the same resource, so the lookup is cached for the duration
66
+ # of the authorization pass (see {LookupCache}).
67
+ #
63
68
  # @param command [Yes::Core::Command] command to authorize
64
69
  # @return [ActiveRecord::Base] resource to authorize
65
70
  # @raise [StandardError] if RESOURCE[:name] or RESOURCE[:read_model] is not defined
@@ -70,7 +75,10 @@ module Yes
70
75
  raise StandardError, message
71
76
  end
72
77
 
73
- read_model(command).find_by(id: command.send("#{self::RESOURCE[:name]}_id"))
78
+ model = read_model(command)
79
+ id = command.send("#{self::RESOURCE[:name]}_id")
80
+
81
+ LookupCache.fetch([:resource, model, id]) { model.find_by(id:) }
74
82
  end
75
83
 
76
84
  # Returns the appropriate read model class for the command.
@@ -145,12 +153,19 @@ module Yes
145
153
  resource&.try(:auth_attributes)&.as_json || {}
146
154
  end
147
155
 
156
+ # Builds the principal data for the request. It is derived purely from the
157
+ # auth data, which is the same for every command of a batch, so it is built
158
+ # once per authorization pass (see {LookupCache}). The result is shared
159
+ # between commands and must not be mutated.
160
+ #
148
161
  # @param auth_data [Hash] authorization data
149
162
  # @return [Hash] principal data for Cerbos check_resource
150
163
  def principal_data(auth_data)
151
- Yes::Core.configuration.cerbos_principal_data_builder.call(
152
- auth_data.with_indifferent_access
153
- )
164
+ data = auth_data.with_indifferent_access
165
+
166
+ LookupCache.fetch([:principal_data, data]) do
167
+ Yes::Core.configuration.cerbos_principal_data_builder.call(data)
168
+ end
154
169
  end
155
170
 
156
171
  # @param auth_data [Hash]
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module Authorization
6
+ # Scoped memoization for the read-only lookups an authorization pass repeats.
7
+ #
8
+ # Authorizing a batch of commands resolves the same two things once per command:
9
+ # the principal data (which depends only on the request's auth data) and the
10
+ # authorized resource (which the commands of a batch commonly share). Both are
11
+ # pure reads, and a batch is authorized in full before any of its commands is
12
+ # executed, so no write can invalidate them while the pass is running.
13
+ #
14
+ # Caching is only active inside {.with_scope}. Outside one, {.fetch} just yields,
15
+ # so callers keep their uncached behaviour unless they opt in. The store lives in
16
+ # ActiveSupport::IsolatedExecutionState, which keeps it per thread/fiber, and
17
+ # {.with_scope} always clears it on the way out so nothing leaks into the next
18
+ # request.
19
+ #
20
+ # Cached values are shared by every {.fetch} for the same key, so callers must
21
+ # treat them as read-only.
22
+ #
23
+ # @example Caching the lookups of one authorization pass
24
+ # LookupCache.with_scope do
25
+ # commands.each { |command| authorizer_for(command).call(command, auth_data) }
26
+ # end
27
+ class LookupCache
28
+ STORE_KEY = :yes_core_authorization_lookup_cache
29
+
30
+ class << self
31
+ # Runs the block with caching enabled, clearing the cache afterwards.
32
+ # A nested scope reuses the cache of the outermost one and leaves clearing
33
+ # to it.
34
+ #
35
+ # @yield the block to run with caching enabled
36
+ # @return [Object] the block's return value
37
+ def with_scope
38
+ return yield if active?
39
+
40
+ ActiveSupport::IsolatedExecutionState[STORE_KEY] = {}
41
+
42
+ begin
43
+ yield
44
+ ensure
45
+ ActiveSupport::IsolatedExecutionState.delete(STORE_KEY)
46
+ end
47
+ end
48
+
49
+ # Returns the value cached under key, computing it via the block on a miss.
50
+ # Without an open scope the block's value is returned uncached.
51
+ #
52
+ # @param key [Object] cache key
53
+ # @yield computes the value when it is not cached yet
54
+ # @return [Object] the cached or freshly computed value
55
+ def fetch(key)
56
+ return yield unless active?
57
+
58
+ store = ActiveSupport::IsolatedExecutionState[STORE_KEY]
59
+ return store[key] if store.key?(key)
60
+
61
+ store[key] = yield
62
+ end
63
+
64
+ # @return [Boolean] whether a scope is currently open
65
+ def active?
66
+ ActiveSupport::IsolatedExecutionState.key?(STORE_KEY)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end