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.
- checksums.yaml +4 -4
- data/.github/workflows/claude-code-review.yml +8 -5
- data/.talismanrc +5 -1
- data/CHANGELOG.rst +76 -0
- data/Gemfile.lock +8 -8
- data/docs/1106-participates_in-bidirectional-solution.md +201 -58
- data/examples/through_relationships.rb +275 -0
- data/lib/familia/connection/operation_core.rb +1 -2
- data/lib/familia/connection/pipelined_core.rb +1 -3
- data/lib/familia/connection/transaction_core.rb +1 -2
- data/lib/familia/data_type/serialization.rb +76 -51
- data/lib/familia/data_type/types/sorted_set.rb +5 -10
- data/lib/familia/data_type/types/stringkey.rb +22 -0
- data/lib/familia/features/external_identifier.rb +29 -0
- data/lib/familia/features/object_identifier.rb +47 -0
- data/lib/familia/features/relationships/README.md +1 -1
- data/lib/familia/features/relationships/indexing/rebuild_strategies.rb +15 -15
- data/lib/familia/features/relationships/indexing/unique_index_generators.rb +8 -0
- data/lib/familia/features/relationships/participation/participant_methods.rb +59 -10
- data/lib/familia/features/relationships/participation/target_methods.rb +51 -7
- data/lib/familia/features/relationships/participation/through_model_operations.rb +150 -0
- data/lib/familia/features/relationships/participation.rb +39 -15
- data/lib/familia/features/relationships/participation_relationship.rb +19 -1
- data/lib/familia/features/relationships.rb +1 -1
- data/lib/familia/horreum/database_commands.rb +6 -1
- data/lib/familia/horreum/management.rb +141 -10
- data/lib/familia/horreum/persistence.rb +3 -0
- data/lib/familia/identifier_extractor.rb +1 -1
- data/lib/familia/version.rb +1 -1
- data/lib/multi_result.rb +59 -31
- data/pr_agent.toml +6 -1
- data/try/features/count_any_edge_cases_try.rb +486 -0
- data/try/features/count_any_methods_try.rb +197 -0
- data/try/features/external_identifier/external_identifier_try.rb +134 -0
- data/try/features/object_identifier/object_identifier_try.rb +138 -0
- data/try/features/relationships/indexing_rebuild_try.rb +6 -0
- data/try/features/relationships/participation_commands_verification_spec.rb +1 -1
- data/try/features/relationships/participation_commands_verification_try.rb +1 -1
- data/try/features/relationships/participation_method_prefix_try.rb +133 -0
- data/try/features/relationships/participation_reverse_index_try.rb +1 -1
- data/try/features/relationships/{participation_bidirectional_try.rb → participation_reverse_methods_try.rb} +6 -6
- data/try/features/relationships/participation_through_try.rb +173 -0
- data/try/integration/data_types/datatype_pipelines_try.rb +5 -3
- data/try/integration/data_types/datatype_transactions_try.rb +13 -7
- data/try/integration/models/customer_try.rb +3 -3
- data/try/unit/data_types/boolean_try.rb +35 -22
- data/try/unit/data_types/hash_try.rb +2 -2
- data/try/unit/data_types/serialization_try.rb +386 -0
- data/try/unit/horreum/destroy_related_fields_cleanup_try.rb +2 -1
- metadata +9 -8
- data/changelog.d/20251105_flexible_external_identifier_format.rst +0 -66
- data/changelog.d/20251107_112554_delano_179_participation_asymmetry.rst +0 -44
- data/changelog.d/20251107_213121_delano_fix_thread_safety_races_011CUumCP492Twxm4NLt2FvL.rst +0 -20
- data/changelog.d/20251107_fix_participates_in_symbol_resolution.rst +0 -91
- data/changelog.d/20251107_optimized_redis_exists_checks.rst +0 -94
- 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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
#
|
|
329
|
-
if
|
|
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
|
-
:
|
|
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,
|
|
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
|
-
# |
|
|
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
|
-
|
|
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
|
-
|
|
453
|
+
find_keys(suffix).filter_map { |k| find_by_key(k) }
|
|
454
454
|
end
|
|
455
455
|
|
|
456
|
-
|
|
457
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
468
|
-
|
|
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
|
|
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?
|
data/lib/familia/version.rb
CHANGED
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
|
|
8
|
-
# providing access to both the
|
|
9
|
-
#
|
|
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
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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(
|
|
20
|
+
# result = MultiResult.new(["OK", "OK", 1])
|
|
18
21
|
#
|
|
19
22
|
# @example Checking transaction success
|
|
20
23
|
# if result.successful?
|
|
21
|
-
# puts "
|
|
24
|
+
# puts "All commands completed without errors"
|
|
22
25
|
# else
|
|
23
|
-
# puts "
|
|
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 [
|
|
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
|
|
42
|
-
#
|
|
43
|
-
def initialize(
|
|
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
|
|
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
|
|
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 = ['
|
|
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 = [
|