familia 2.0.0.pre21 → 2.0.0.pre23

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/claude-code-review.yml +8 -5
  3. data/.talismanrc +5 -1
  4. data/CHANGELOG.rst +76 -0
  5. data/Gemfile.lock +8 -8
  6. data/docs/1106-participates_in-bidirectional-solution.md +201 -58
  7. data/examples/through_relationships.rb +275 -0
  8. data/lib/familia/connection/operation_core.rb +1 -2
  9. data/lib/familia/connection/pipelined_core.rb +1 -3
  10. data/lib/familia/connection/transaction_core.rb +1 -2
  11. data/lib/familia/data_type/serialization.rb +76 -51
  12. data/lib/familia/data_type/types/sorted_set.rb +5 -10
  13. data/lib/familia/data_type/types/stringkey.rb +22 -0
  14. data/lib/familia/features/external_identifier.rb +29 -0
  15. data/lib/familia/features/object_identifier.rb +47 -0
  16. data/lib/familia/features/relationships/README.md +1 -1
  17. data/lib/familia/features/relationships/indexing/rebuild_strategies.rb +15 -15
  18. data/lib/familia/features/relationships/indexing/unique_index_generators.rb +8 -0
  19. data/lib/familia/features/relationships/participation/participant_methods.rb +59 -10
  20. data/lib/familia/features/relationships/participation/target_methods.rb +51 -7
  21. data/lib/familia/features/relationships/participation/through_model_operations.rb +150 -0
  22. data/lib/familia/features/relationships/participation.rb +39 -15
  23. data/lib/familia/features/relationships/participation_relationship.rb +19 -1
  24. data/lib/familia/features/relationships.rb +1 -1
  25. data/lib/familia/horreum/database_commands.rb +6 -1
  26. data/lib/familia/horreum/management.rb +141 -10
  27. data/lib/familia/horreum/persistence.rb +3 -0
  28. data/lib/familia/identifier_extractor.rb +1 -1
  29. data/lib/familia/version.rb +1 -1
  30. data/lib/multi_result.rb +59 -31
  31. data/pr_agent.toml +6 -1
  32. data/try/features/count_any_edge_cases_try.rb +486 -0
  33. data/try/features/count_any_methods_try.rb +197 -0
  34. data/try/features/external_identifier/external_identifier_try.rb +134 -0
  35. data/try/features/object_identifier/object_identifier_try.rb +138 -0
  36. data/try/features/relationships/indexing_rebuild_try.rb +6 -0
  37. data/try/features/relationships/participation_commands_verification_spec.rb +1 -1
  38. data/try/features/relationships/participation_commands_verification_try.rb +1 -1
  39. data/try/features/relationships/participation_method_prefix_try.rb +133 -0
  40. data/try/features/relationships/participation_reverse_index_try.rb +1 -1
  41. data/try/features/relationships/{participation_bidirectional_try.rb → participation_reverse_methods_try.rb} +6 -6
  42. data/try/features/relationships/participation_through_try.rb +173 -0
  43. data/try/integration/data_types/datatype_pipelines_try.rb +5 -3
  44. data/try/integration/data_types/datatype_transactions_try.rb +13 -7
  45. data/try/integration/models/customer_try.rb +3 -3
  46. data/try/unit/data_types/boolean_try.rb +35 -22
  47. data/try/unit/data_types/hash_try.rb +2 -2
  48. data/try/unit/data_types/serialization_try.rb +386 -0
  49. data/try/unit/horreum/destroy_related_fields_cleanup_try.rb +2 -1
  50. metadata +9 -8
  51. data/changelog.d/20251105_flexible_external_identifier_format.rst +0 -66
  52. data/changelog.d/20251107_112554_delano_179_participation_asymmetry.rst +0 -44
  53. data/changelog.d/20251107_213121_delano_fix_thread_safety_races_011CUumCP492Twxm4NLt2FvL.rst +0 -20
  54. data/changelog.d/20251107_fix_participates_in_symbol_resolution.rst +0 -91
  55. data/changelog.d/20251107_optimized_redis_exists_checks.rst +0 -94
  56. data/changelog.d/20251108_frozen_string_literal_pragma.rst +0 -44
@@ -7,6 +7,7 @@ require_relative 'participation_membership'
7
7
  require_relative 'collection_operations'
8
8
  require_relative 'participation/participant_methods'
9
9
  require_relative 'participation/target_methods'
10
+ require_relative 'participation/through_model_operations'
10
11
 
11
12
  module Familia
12
13
  module Features
@@ -117,7 +118,7 @@ module Familia
117
118
  # - +ClassName.add_to_collection_name(instance)+ - Add instance to collection
118
119
  # - +ClassName.remove_from_collection_name(instance)+ - Remove instance from collection
119
120
  #
120
- # ==== On Instances (Participant Methods, if bidirectional)
121
+ # ==== On Instances (Participant Methods, if generate_participant_methods)
121
122
  # - +instance.in_class_collection_name?+ - Check membership in class collection
122
123
  # - +instance.add_to_class_collection_name+ - Add self to class collection
123
124
  # - +instance.remove_from_class_collection_name+ - Remove self from class collection
@@ -134,7 +135,9 @@ module Familia
134
135
  # - +:sorted_set+: Ordered by score (default)
135
136
  # - +:set+: Unordered unique membership
136
137
  # - +:list+: Ordered sequence allowing duplicates
137
- # @param bidirectional [Boolean] Whether to generate convenience methods on instances (default: +true+)
138
+ # @param generate_participant_methods [Boolean] Whether to generate convenience methods on instances (default: +true+)
139
+ # @param through [Class, Symbol, String, nil] Optional join model class for
140
+ # storing additional attributes. See +participates_in+ for details.
138
141
  #
139
142
  # @example Simple priority-based global collection
140
143
  # class User < Familia::Horreum
@@ -160,7 +163,7 @@ module Familia
160
163
  # @see #participates_in for instance-level participation relationships
161
164
  # @since 1.0.0
162
165
  def class_participates_in(collection_name, score: nil,
163
- type: :sorted_set, bidirectional: true)
166
+ type: :sorted_set, generate_participant_methods: true, through: nil)
164
167
  # Store metadata for this participation relationship
165
168
  participation_relationships << ParticipationRelationship.new(
166
169
  _original_target: self, # For class-level, original and resolved are the same
@@ -168,21 +171,23 @@ module Familia
168
171
  collection_name: collection_name,
169
172
  score: score,
170
173
  type: type,
171
- bidirectional: bidirectional,
174
+ generate_participant_methods: generate_participant_methods,
175
+ through: through,
176
+ method_prefix: nil, # Not applicable for class-level participation
172
177
  )
173
178
 
174
179
  # STEP 1: Add collection management methods to the class itself
175
180
  # e.g., User.all_users, User.add_to_all_users(user)
176
181
  TargetMethods::Builder.build_class_level(self, collection_name, type)
177
182
 
178
- # STEP 2: Add participation methods to instances (if bidirectional)
183
+ # STEP 2: Add participation methods to instances (if generate_participant_methods)
179
184
  # e.g., user.in_class_all_users?, user.add_to_class_all_users
180
- return unless bidirectional
185
+ return unless generate_participant_methods
181
186
 
182
187
  # Pass the string 'class' as target to distinguish class-level from instance-level
183
188
  # This prevents generating reverse collection methods (user can't have "all_users")
184
189
  # See ParticipantMethods::Builder.build for handling of this special case
185
- ParticipantMethods::Builder.build(self, 'class', collection_name, type, nil)
190
+ ParticipantMethods::Builder.build(self, 'class', collection_name, type, nil, through, nil)
186
191
  end
187
192
 
188
193
  # Define an instance-level participation relationship between two classes.
@@ -203,7 +208,7 @@ module Familia
203
208
  # - +target.remove_participant_class_name(participant)+ - Remove participant from collection
204
209
  # - +target.add_participant_class_names([participants])+ - Bulk add multiple participants
205
210
  #
206
- # ==== On Participant Class (if bidirectional)
211
+ # ==== On Participant Class (if generate_participant_methods)
207
212
  # - +participant.in_target_collection_name?(target)+ - Check membership in target's collection
208
213
  # - +participant.add_to_target_collection_name(target)+ - Add self to target's collection
209
214
  # - +participant.remove_from_target_collection_name(target)+ - Remove self from target's collection
@@ -233,12 +238,18 @@ module Familia
233
238
  # different scores (default)
234
239
  # - +:set+: Unordered unique membership
235
240
  # - +:list+: Ordered sequence, allows duplicates
236
- # @param bidirectional [Boolean] Whether to generate reverse collection
241
+ # @param generate_participant_methods [Boolean] Whether to generate reverse collection
237
242
  # methods on participant class. If true, methods are generated using the
238
243
  # name of the target class. (default: +true+)
239
244
  # @param as [Symbol, nil] Custom name for reverse collection methods
240
245
  # (e.g., +as: :contracting_orgs+). When provided, overrides the default
241
246
  # method name derived from the target class.
247
+ # @param through [Class, Symbol, String, nil] Optional join model class for
248
+ # storing additional attributes on the relationship. The through model:
249
+ # - Must use +feature :object_identifier+
250
+ # - Gets auto-created when adding to collection (via +through_attrs:+ param)
251
+ # - Gets auto-destroyed when removing from collection
252
+ # - Uses deterministic keys: +{target}:{id}:{participant}:{id}:{through}+
242
253
  #
243
254
  # @example Basic domain-employee relationship
244
255
  #
@@ -284,7 +295,7 @@ module Familia
284
295
  # @see ModelInstanceMethods#current_participations for membership queries
285
296
  # @see ModelInstanceMethods#calculate_participation_score for scoring details
286
297
  #
287
- def participates_in(target, collection_name, score: nil, type: :sorted_set, bidirectional: true, as: nil)
298
+ def participates_in(target, collection_name, score: nil, type: :sorted_set, generate_participant_methods: true, as: nil, through: nil, method_prefix: nil)
288
299
 
289
300
  # Normalize the target class parameter
290
301
  target_class = Familia.resolve_class(target)
@@ -306,6 +317,17 @@ module Familia
306
317
  ERROR
307
318
  end
308
319
 
320
+ # Validate through class if provided
321
+ if through
322
+ through_class = Familia.resolve_class(through)
323
+ raise ArgumentError, "Cannot resolve through class: #{through.inspect}" unless through_class
324
+
325
+ unless through_class.respond_to?(:features_enabled) &&
326
+ through_class.features_enabled.include?(:object_identifier)
327
+ raise ArgumentError, "Through model #{through_class} must use `feature :object_identifier`"
328
+ end
329
+ end
330
+
309
331
  # Store metadata for this participation relationship
310
332
  participation_relationships << ParticipationRelationship.new(
311
333
  _original_target: target, # Original value as passed (Symbol/String/Class)
@@ -313,7 +335,9 @@ module Familia
313
335
  collection_name: collection_name,
314
336
  score: score,
315
337
  type: type,
316
- bidirectional: bidirectional,
338
+ generate_participant_methods: generate_participant_methods,
339
+ through: through,
340
+ method_prefix: method_prefix,
317
341
  )
318
342
 
319
343
  # STEP 0: Add participations tracking field to PARTICIPANT class (Domain)
@@ -322,14 +346,14 @@ module Familia
322
346
 
323
347
  # STEP 1: Add collection management methods to TARGET class (Employee)
324
348
  # Employee gets: domains, add_domain, remove_domain, etc.
325
- TargetMethods::Builder.build(target_class, collection_name, type)
349
+ TargetMethods::Builder.build(target_class, collection_name, type, through)
326
350
 
327
351
  # STEP 2: Add participation methods to PARTICIPANT class (Domain) - only if
328
- # bidirectional. e.g. in_employee_domains?, add_to_employee_domains, etc.
329
- if bidirectional
352
+ # generate_participant_methods. e.g. in_employee_domains?, add_to_employee_domains, etc.
353
+ if generate_participant_methods
330
354
  # `as` parameter allows custom naming for reverse collections
331
355
  # If not provided, we'll let the builder use the pluralized target class name
332
- ParticipantMethods::Builder.build(self, target_class, collection_name, type, as)
356
+ ParticipantMethods::Builder.build(self, target_class, collection_name, type, as, through, method_prefix)
333
357
  end
334
358
  end
335
359
 
@@ -21,7 +21,9 @@ module Familia
21
21
  :collection_name, # Symbol name of the collection (e.g., :members, :domains)
22
22
  :score, # Proc/Symbol/nil - score calculator for sorted sets
23
23
  :type, # Symbol - collection type (:sorted_set, :set, :list)
24
- :bidirectional, # Boolean/Symbol - whether to generate reverse methods
24
+ :generate_participant_methods, # Boolean - whether to generate participant methods
25
+ :through, # Symbol/Class/nil - through model class for join table pattern
26
+ :method_prefix, # Symbol/nil - custom prefix for reverse method names (e.g., :team)
25
27
  ) do
26
28
  # Get a unique key for this participation relationship
27
29
  # Useful for comparisons and hash keys
@@ -53,6 +55,22 @@ module Familia
53
55
  target_class_base == comparison_target_base &&
54
56
  collection_name == comparison_collection.to_sym
55
57
  end
58
+
59
+ # Check if this relationship uses a through model
60
+ #
61
+ # @return [Boolean] true if through model is configured
62
+ def through_model?
63
+ !through.nil?
64
+ end
65
+
66
+ # Resolve the through class to an actual Class object
67
+ #
68
+ # @return [Class, nil] The resolved through class or nil
69
+ def resolved_through_class
70
+ return nil unless through
71
+
72
+ through.is_a?(Class) ? through : Familia.resolve_class(through)
73
+ end
56
74
  end
57
75
  end
58
76
  end
@@ -38,7 +38,7 @@ module Familia
38
38
  #
39
39
  # # Participation with bidirectional control (no method collisions)
40
40
  # participates_in Customer, :domains
41
- # participates_in Team, :domains, bidirectional: false
41
+ # participates_in Team, :domains, generate_participant_methods: false
42
42
  # participates_in Organization, :domains, type: :set
43
43
  # end
44
44
  #
@@ -263,15 +263,20 @@ module Familia
263
263
  #
264
264
  # | Scenario | Use | Why |
265
265
  # |----------|-----|-----|
266
- # | Check if exists, then create | WATCH | Must prevent duplicate creation |
266
+ # | First-one-wins / idempotency | SET NX | Atomic claim, no read needed |
267
+ # | Distributed lock acquisition | SET NX EX | Claim with automatic expiry |
267
268
  # | Read value, update conditionally | WATCH | Decision depends on current state |
268
269
  # | Compare-and-swap operations | WATCH | Need optimistic locking |
269
270
  # | Version-based updates | WATCH | Must detect concurrent changes |
271
+ # | Status transitions (pending→processing) | WATCH | Must verify current state |
270
272
  # | Batch field updates | MULTI only | No conditional logic |
271
273
  # | Increment + timestamp together | MULTI only | Concurrent increments OK |
272
274
  # | Save object atomically | MULTI only | Just need atomicity |
273
275
  # | Update indexes with save | MULTI only | No state checking needed |
274
276
  #
277
+ # If you don't need to read before deciding, WATCH adds complexity
278
+ # without benefit. SET NX handles the "claim" pattern in one atomic shot.
279
+ #
275
280
  # @param suffix_override [String, nil] Optional suffix override
276
281
  # @return [String] 'OK' on success
277
282
  def watch(...)
@@ -383,7 +383,7 @@ module Familia
383
383
  #
384
384
  def destroy!(identifier, suffix = nil)
385
385
  suffix ||= self.suffix
386
- return MultiResult.new(false, []) if identifier.to_s.empty?
386
+ raise Familia::NoIdentifier, "#{self} requires non-empty identifier" if identifier.to_s.empty?
387
387
 
388
388
  objkey = dbkey identifier, suffix
389
389
 
@@ -450,22 +450,153 @@ module Familia
450
450
  def all(suffix = nil)
451
451
  suffix ||= self.suffix
452
452
  # objects that could not be parsed will be nil
453
- keys(suffix).filter_map { |k| find_by_key(k) }
453
+ find_keys(suffix).filter_map { |k| find_by_key(k) }
454
454
  end
455
455
 
456
- def any?(filter = '*')
457
- matching_keys_count(filter).positive?
456
+ # Returns the number of tracked instances (fast, from instances sorted set).
457
+ #
458
+ # This method provides O(1) performance by querying the `instances` sorted set,
459
+ # which is automatically maintained when objects are created/destroyed through
460
+ # Familia. However, objects deleted outside Familia (e.g., direct Redis commands)
461
+ # may leave stale entries.
462
+ #
463
+ # @return [Integer] Number of instances in the instances sorted set
464
+ #
465
+ # @example
466
+ # User.create(email: 'test@example.com')
467
+ # User.count #=> 1
468
+ #
469
+ # @note For authoritative count, use {#scan_count} (production-safe) or {#keys_count} (blocking)
470
+ # @see #scan_count Production-safe authoritative count via SCAN
471
+ # @see #keys_count Blocking authoritative count via KEYS
472
+ # @see #instances The underlying sorted set
473
+ #
474
+ def count
475
+ instances.count
458
476
  end
477
+ alias size count
478
+ alias length count
459
479
 
460
- # Returns the number of dbkeys matching the given filter pattern
461
- # @param filter [String] dbkey pattern to match (default: '*')
462
- # @return [Integer] Number of matching keys
480
+ # Returns authoritative count using blocking KEYS command (production-dangerous).
463
481
  #
464
- def matching_keys_count(filter = '*')
482
+ # ⚠️ WARNING: This method uses the KEYS command which blocks Redis during execution.
483
+ # It scans ALL keys in the database and should NEVER be used in production.
484
+ #
485
+ # @param filter [String] Key pattern to match (default: '*')
486
+ # @return [Integer] Number of matching keys in Redis
487
+ #
488
+ # @example
489
+ # User.keys_count #=> 1 (all User objects)
490
+ # User.keys_count('a*') #=> 1 (Users with IDs starting with 'a')
491
+ #
492
+ # @note For production-safe authoritative count, use {#scan_count}
493
+ # @see #scan_count Production-safe alternative using SCAN
494
+ # @see #count Fast count from instances sorted set
495
+ #
496
+ def keys_count(filter = '*')
465
497
  dbclient.keys(dbkey(filter)).compact.size
466
498
  end
467
- alias size matching_keys_count
468
- alias length matching_keys_count
499
+
500
+ # Returns authoritative count using non-blocking SCAN command (production-safe).
501
+ #
502
+ # This method uses cursor-based SCAN iteration to count matching keys without
503
+ # blocking Redis. Safe for production use as it processes keys in chunks.
504
+ #
505
+ # @param filter [String] Key pattern to match (default: '*')
506
+ # @return [Integer] Number of matching keys in Redis
507
+ #
508
+ # @example
509
+ # User.scan_count #=> 1 (all User objects)
510
+ # User.scan_count('a*') #=> 1 (Users with IDs starting with 'a')
511
+ #
512
+ # @note For fast count (potentially stale), use {#count}
513
+ # @see #count Fast count from instances sorted set
514
+ # @see #keys_count Blocking alternative (production-dangerous)
515
+ #
516
+ def scan_count(filter = '*')
517
+ pattern = dbkey(filter)
518
+ count = 0
519
+ cursor = "0"
520
+
521
+ loop do
522
+ cursor, keys = dbclient.scan(cursor, match: pattern, count: 1000)
523
+ count += keys.size
524
+ break if cursor == "0"
525
+ end
526
+
527
+ count
528
+ end
529
+ alias count! scan_count
530
+
531
+ # Checks if any tracked instances exist (fast, from instances sorted set).
532
+ #
533
+ # This method provides O(1) performance by querying the `instances` sorted set.
534
+ # However, objects deleted outside Familia may leave stale entries.
535
+ #
536
+ # @return [Boolean] true if instances sorted set is non-empty
537
+ #
538
+ # @example
539
+ # User.create(email: 'test@example.com')
540
+ # User.any? #=> true
541
+ #
542
+ # @note For authoritative check, use {#scan_any?} (production-safe) or {#keys_any?} (blocking)
543
+ # @see #scan_any? Production-safe authoritative check via SCAN
544
+ # @see #keys_any? Blocking authoritative check via KEYS
545
+ # @see #count Fast count of instances
546
+ #
547
+ def any?
548
+ count.positive?
549
+ end
550
+
551
+ # Checks if any objects exist using blocking KEYS command (production-dangerous).
552
+ #
553
+ # ⚠️ WARNING: This method uses the KEYS command which blocks Redis during execution.
554
+ # It scans ALL keys in the database and should NEVER be used in production.
555
+ #
556
+ # @param filter [String] Key pattern to match (default: '*')
557
+ # @return [Boolean] true if any matching keys exist in Redis
558
+ #
559
+ # @example
560
+ # User.keys_any? #=> true (any User objects)
561
+ # User.keys_any?('a*') #=> true (Users with IDs starting with 'a')
562
+ #
563
+ # @note For production-safe authoritative check, use {#scan_any?}
564
+ # @see #scan_any? Production-safe alternative using SCAN
565
+ # @see #any? Fast existence check from instances sorted set
566
+ #
567
+ def keys_any?(filter = '*')
568
+ keys_count(filter).positive?
569
+ end
570
+
571
+ # Checks if any objects exist using non-blocking SCAN command (production-safe).
572
+ #
573
+ # This method uses cursor-based SCAN iteration to check for matching keys without
574
+ # blocking Redis. Safe for production use and returns early on first match.
575
+ #
576
+ # @param filter [String] Key pattern to match (default: '*')
577
+ # @return [Boolean] true if any matching keys exist in Redis
578
+ #
579
+ # @example
580
+ # User.scan_any? #=> true (any User objects)
581
+ # User.scan_any?('a*') #=> true (Users with IDs starting with 'a')
582
+ #
583
+ # @note For fast check (potentially stale), use {#any?}
584
+ # @see #any? Fast existence check from instances sorted set
585
+ # @see #keys_any? Blocking alternative (production-dangerous)
586
+ #
587
+ def scan_any?(filter = '*')
588
+ pattern = dbkey(filter)
589
+ cursor = "0"
590
+
591
+ loop do
592
+ cursor, keys = dbclient.scan(cursor, match: pattern, count: 100)
593
+ return true unless keys.empty?
594
+ break if cursor == "0"
595
+ end
596
+
597
+ false
598
+ end
599
+ alias any! scan_any?
469
600
 
470
601
  # Instantiates an object from a hash of field values.
471
602
  #
@@ -396,6 +396,9 @@ module Familia
396
396
  obj.delete!
397
397
  end
398
398
  end
399
+
400
+ # Remove from instances collection if available
401
+ self.class.instances.remove(identifier) if self.class.respond_to?(:instances)
399
402
  end
400
403
 
401
404
  # Structured lifecycle logging and instrumentation
@@ -29,7 +29,7 @@ module Familia
29
29
  # @return [String] The extracted identifier or class name
30
30
  # @raise [Familia::NotDistinguishableError] If value is not a Class or Familia::Base
31
31
  #
32
- def identifier_extractor(value, strict_values: true)
32
+ def identifier_extractor(value)
33
33
  case value
34
34
  when ::Symbol, ::String, ::Integer, ::Float
35
35
  Familia.trace :IDENTIFIER_EXTRACTOR, nil, 'simple_value' if Familia.debug?
@@ -4,5 +4,5 @@
4
4
 
5
5
  module Familia
6
6
  # Version information for the Familia
7
- VERSION = '2.0.0.pre21'.freeze unless defined?(Familia::VERSION)
7
+ VERSION = '2.0.0.pre23'.freeze unless defined?(Familia::VERSION)
8
8
  end
data/lib/multi_result.rb CHANGED
@@ -2,25 +2,28 @@
2
2
  #
3
3
  # frozen_string_literal: true
4
4
 
5
- # Represents the result of a Valkey/Redis transaction operation.
5
+ # Represents the result of a Valkey/Redis transaction or pipeline operation.
6
6
  #
7
- # This class encapsulates the outcome of a Database transaction,
8
- # providing access to both the success status and the individual
9
- # command results returned by the transaction.
7
+ # This class encapsulates the outcome of a Database multi-command operation,
8
+ # providing access to both the command results and derived success status
9
+ # based on the presence of errors in the results.
10
10
  #
11
- # @attr_reader success [Boolean] Indicates whether all commands
12
- # in the transaction completed successfully.
13
- # @attr_reader results [Array<String>] Array of return values
14
- # from the Database commands executed in the transaction.
11
+ # Success is determined by checking for Exception objects in the results array.
12
+ # When Redis commands fail within a transaction or pipeline, they return
13
+ # exception objects rather than raising them, allowing other commands to
14
+ # continue executing.
15
+ #
16
+ # @attr_reader results [Array] Array of return values from the Database commands.
17
+ # Values can be strings, integers, booleans, or Exception objects for failed commands.
15
18
  #
16
19
  # @example Creating a MultiResult instance
17
- # result = MultiResult.new(true, ["OK", "OK"])
20
+ # result = MultiResult.new(["OK", "OK", 1])
18
21
  #
19
22
  # @example Checking transaction success
20
23
  # if result.successful?
21
- # puts "Transaction completed successfully"
24
+ # puts "All commands completed without errors"
22
25
  # else
23
- # puts "Transaction failed"
26
+ # puts "#{result.errors.size} command(s) failed"
24
27
  # end
25
28
  #
26
29
  # @example Accessing individual command results
@@ -28,24 +31,55 @@
28
31
  # puts "Command #{index + 1} returned: #{value}"
29
32
  # end
30
33
  #
34
+ # @example Inspecting errors
35
+ # if result.errors?
36
+ # result.errors.each do |error|
37
+ # puts "Error: #{error.message}"
38
+ # end
39
+ # end
40
+ #
31
41
  class MultiResult
32
- # @return [Boolean] true if all commands in the transaction succeeded,
33
- # false otherwise
34
- attr_reader :success
35
-
36
- # @return [Array<String>] The raw return values from the Database commands
42
+ # @return [Array] The raw return values from the Database commands
37
43
  attr_reader :results
38
44
 
39
45
  # Creates a new MultiResult instance.
40
46
  #
41
- # @param success [Boolean] Whether all commands succeeded
42
- # @param results [Array<String>] The raw results from Database commands
43
- def initialize(success, results)
44
- @success = success
47
+ # @param results [Array] The raw results from Database commands.
48
+ # Exception objects in the array indicate command failures.
49
+ def initialize(results)
45
50
  @results = results
46
51
  end
47
52
 
48
- # Returns a tuple representing the result of the transaction.
53
+ # Returns all Exception objects from the results array.
54
+ #
55
+ # This method is memoized for performance when called multiple times
56
+ # on the same MultiResult instance.
57
+ #
58
+ # @return [Array<Exception>] Array of exceptions that occurred during execution
59
+ def errors
60
+ @errors ||= results.select { |ret| ret.is_a?(Exception) }
61
+ end
62
+
63
+ # Checks if any errors occurred during execution.
64
+ #
65
+ # @return [Boolean] true if at least one command failed, false otherwise
66
+ def errors?
67
+ !errors.empty?
68
+ end
69
+
70
+ # Checks if all commands completed successfully (no exceptions).
71
+ #
72
+ # This is the primary method for determining if a multi-command
73
+ # operation completed without errors.
74
+ #
75
+ # @return [Boolean] true if no exceptions in results, false otherwise
76
+ def successful?
77
+ errors.empty?
78
+ end
79
+ alias success? successful?
80
+ alias areyouhappynow? successful?
81
+
82
+ # Returns a tuple representing the result of the operation.
49
83
  #
50
84
  # @return [Array] A tuple containing the success status and the raw results.
51
85
  # The success status is a boolean indicating if all commands succeeded.
@@ -61,21 +95,15 @@ class MultiResult
61
95
 
62
96
  # Returns the number of results in the multi-operation.
63
97
  #
64
- # @return [Integer] The number of individual command results returned by the transaction.
98
+ # @return [Integer] The number of individual command results returned
65
99
  def size
66
100
  results.size
67
101
  end
68
102
 
103
+ # Returns a hash representation of the result.
104
+ #
105
+ # @return [Hash] Hash with :success and :results keys
69
106
  def to_h
70
107
  { success: successful?, results: results }
71
108
  end
72
-
73
- # Convenient method to check if the commit was successful.
74
- #
75
- # @return [Boolean] true if all commands succeeded, false otherwise
76
- def successful?
77
- @success
78
- end
79
- alias success? successful?
80
- alias areyouhappynow? successful?
81
109
  end
data/pr_agent.toml CHANGED
@@ -9,12 +9,17 @@ response_language = "en"
9
9
  # Enable RAG context enrichment for codebase duplication compliance checks
10
10
  enable_rag = true
11
11
  # Include related repositories for comprehensive context
12
- rag_repo_list = ['delano/familia', 'delano/tryouts', 'delano/otto']
12
+ rag_repo_list = ['onetimesecret/onetimesecret', 'delano/tryouts']
13
13
 
14
14
  [compliance]
15
15
  # Reference custom compliance checklist for project-specific rules
16
16
  custom_compliance_path = "pr_compliance_checklist.yaml"
17
17
 
18
+ [pr_reviewer]
19
+ # Disable automatic label additions (triggers Claude review workflow noise)
20
+ enable_review_labels_security = false
21
+ enable_review_labels_effort = false
22
+
18
23
  [ignore]
19
24
  # Reduce noise by excluding generated files and build artifacts
20
25
  glob = [