Module: Familia::Features::Relationships::TargetMethods::Builder
- Extended by:
- CollectionOperations, Participation::ThroughModelOperations
- Defined in:
- lib/familia/features/relationships/participation/target_methods.rb
Overview
Visual Guide for methods added to TARGET instances:
When Domain calls: participates_in Customer, :domains
Customer instances (TARGET) get these methods: ├── domains # Get the domains collection ├── add_domains_instance(domain, score) # Add one domain to my collection ├── remove_domains_instance(domain) # Remove one domain from my collection ├── add_domains([...]) # Bulk add domains ├── domains_with_permission(flag) # Query by permission flag (sorted_set only) └── each_domains_with_permission(flag) # Stream by permission flag via ZSCAN (sorted_set only)
Class Method Summary collapse
-
.build(target_class, collection_name, type, through = nil, staged = nil, participant_class: nil, max_length: nil) ⇒ Object
Build all target methods for a participation relationship rubocop:disable Metrics/ParameterLists -- mirrors participates_in's surface; every param is threaded through.
-
.build_activate_method(target_class, collection_name, staged_name, through) ⇒ Object
Build method to activate a staged through model Creates: org.activate_members_instance(staged_model, participant, through_attrs: {}).
-
.build_add_item(target_class, collection_name, type, through = nil) ⇒ Object
Build method to add an item to the collection Creates: customer.add_domains_instance(domain, score, through_attrs: {}).
-
.build_bulk_add(target_class, collection_name, type) ⇒ Object
Build method for bulk adding items Creates: customer.add_domains([domain1, domain2, ...]).
-
.build_bulk_stage_method(target_class, collection_name, staged_name, through) ⇒ Object
Build method to bulk stage multiple through models Creates: org.stage_members(through_attrs_list).
-
.build_bulk_unstage_method(target_class, collection_name, staged_name, through) ⇒ Object
Build method to bulk unstage multiple through models Creates: org.unstage_members(staged_models_or_objids).
-
.build_class_add_method(target_class, collection_name, type) ⇒ Object
Build class-level add method Creates: User.add_to_all_users(user, score).
-
.build_class_collection_getter(target_class, collection_name, type) ⇒ Object
Build class-level collection getter Creates: User.all_users (class method).
-
.build_class_level(target_class, collection_name, type, max_length: nil) ⇒ Object
Build class-level collection methods (for class_participates_in).
-
.build_class_remove_method(target_class, collection_name) ⇒ Object
Build class-level remove method Creates: User.remove_from_all_users(user).
-
.build_collection_getter(target_class, collection_name, type) ⇒ Object
Build method to get the collection Creates: customer.domains.
-
.build_permission_enumerator(target_class, collection_name) ⇒ Object
Build streaming permission query for sorted sets (issue #309) Creates: customer.each_domains_with_permission(min_level) { |d| ... }.
-
.build_permission_query(target_class, collection_name) ⇒ Object
Build permission query for sorted sets Creates: customer.domains_with_permission(min_level).
-
.build_remove_item(target_class, collection_name, type, through = nil) ⇒ Object
Build method to remove an item from the collection Creates: customer.remove_domains_instance(domain).
-
.build_stage_method(target_class, collection_name, staged_name, through) ⇒ Object
Build method to stage a through model for deferred activation Creates: org.stage_members_instance(through_attrs: {}).
-
.build_unstage_method(target_class, collection_name, staged_name, _through) ⇒ Object
Build method to unstage (revoke) a staged through model Creates: org.unstage_members_instance(staged_model).
-
.each_permitted_pair(collection, min_permission, batch_size) ⇒ Object
private
Yield [raw_member, score] pairs holding min_permission, one ZRANGEBYSCORE page at a time so peak memory is O(batch_size).
-
.filter_by_permission(collection, min_permission, limit:, offset:, batch_size:) ⇒ Object
private
Collect members whose scores carry min_permission, in score order, applying offset (POST-FILTER: counts members that pass the permission test, not raw entries) and limit.
-
.validate_min_permission!(min_permission) ⇒ Object
private
Validate min_permission eagerly, before any shortcut or scan loop, so an invalid flag raises even when limit: 0 or the collection is empty -- the lazy per-member permission? check never runs in those cases.
-
.validate_permission_query_args!(limit:, offset:, batch_size:) ⇒ Object
private
Validate the pagination kwargs for *_with_permission (issue #309).
Methods included from CollectionOperations
add_to_collection, assert_compatible_cap!, bulk_add_to_collection, ensure_collection_field, member_of_collection?, remove_from_collection, validate_max_length_option!
Methods included from Participation::ThroughModelOperations
build_key, find_and_destroy, find_or_create, validated_attrs
Class Method Details
.build(target_class, collection_name, type, through = nil, staged = nil, participant_class: nil, max_length: nil) ⇒ Object
Build all target methods for a participation relationship rubocop:disable Metrics/ParameterLists -- mirrors participates_in's surface; every param is threaded through
52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 |
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 52 def self.build(target_class, collection_name, type, through = nil, staged = nil, participant_class: nil, max_length: nil) # rubocop:enable Metrics/ParameterLists # FIRST: Ensure the DataType field is defined on the target class. # Declared with record_class: so `each_record` can load participants. TargetMethods::Builder.ensure_collection_field( target_class, collection_name, type, participant_class: participant_class, max_length: max_length ) # Create staging set if staged: option provided TargetMethods::Builder.ensure_collection_field(target_class, staged, :sorted_set) if staged # Core target methods build_collection_getter(target_class, collection_name, type) build_add_item(target_class, collection_name, type, through) build_remove_item(target_class, collection_name, type, through) build_bulk_add(target_class, collection_name, type) # Staged relationship methods (requires through model) if staged && through build_stage_method(target_class, collection_name, staged, through) build_activate_method(target_class, collection_name, staged, through) build_unstage_method(target_class, collection_name, staged, through) build_bulk_stage_method(target_class, collection_name, staged, through) build_bulk_unstage_method(target_class, collection_name, staged, through) end # Type-specific methods return unless type == :sorted_set (target_class, collection_name) (target_class, collection_name) end |
.build_activate_method(target_class, collection_name, staged_name, through) ⇒ Object
Build method to activate a staged through model Creates: org.activate_members_instance(staged_model, participant, through_attrs: {})
Activation completes the relationship:
- ZADD to active collection with participant
- SADD to participant's reverse index
- ZREM from staging collection
- Create composite-keyed through model
- Destroy UUID-keyed staged model
468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 |
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 468 def self.build_activate_method(target_class, collection_name, staged_name, through) method_name = "activate_#{collection_name}_instance" target_class.define_method(method_name) do |staged_model, participant, through_attrs: {}| through_class = Familia.resolve_class(through) active_collection = send(collection_name) staging_collection = send(staged_name) # Calculate score for participant in active set score = if participant.respond_to?(:calculate_participation_score) participant.calculate_participation_score(self.class, collection_name) else Familia.now.to_f end # Transaction: sorted set operations (ZADD active + SADD participations + ZREM staging) transaction do |_tx| # Add to active collection TargetMethods::Builder.add_to_collection( active_collection, participant, score: score, type: :sorted_set, target_class: self.class, collection_name: collection_name, ) # Track participation in reverse index if participant.respond_to?(:track_participation_in) participant.track_participation_in(active_collection.dbkey) end # Remove from staging set and log warning if entry not found removed = staging_collection.remove(staged_model.objid) Familia.debug "[activate] Staging entry not found for #{staged_model.objid}" if removed == 0 end # TRANSACTION BOUNDARY: Through model operations happen outside transaction # (same pattern as build_add_item - see that method for detailed rationale) Participation::StagedOperations.activate( through_class: through_class, staged_model: staged_model, target: self, participant: participant, attrs: through_attrs, ) end end |
.build_add_item(target_class, collection_name, type, through = nil) ⇒ Object
Build method to add an item to the collection Creates: customer.add_domains_instance(domain, score, through_attrs: {})
134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 |
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 134 def self.build_add_item(target_class, collection_name, type, through = nil) method_name = "add_#{collection_name}_instance" target_class.define_method(method_name) do |item, score = nil, through_attrs: {}| collection = send(collection_name) # Calculate score if needed and not provided if type == :sorted_set && score.nil? && item.respond_to?(:calculate_participation_score) score = item.calculate_participation_score(self.class, collection_name) end # Resolve through class if specified through_class = through ? Familia.resolve_class(through) : nil # Use transaction for atomicity between collection add and reverse index tracking # All operations use Horreum's DataType methods (not direct Redis calls) transaction do |_tx| # Add to collection using DataType method (ZADD/SADD/RPUSH) TargetMethods::Builder.add_to_collection( collection, item, score: score, type: type, target_class: self.class, collection_name: collection_name, ) # Track participation in reverse index using DataType method (SADD) item.track_participation_in(collection.dbkey) if item.respond_to?(:track_participation_in) end # TRANSACTION BOUNDARY: Through model operations intentionally happen AFTER # the transaction block closes. This is a deliberate design decision because: # # 1. ThroughModelOperations.find_or_create performs load operations that would # return Redis::Future objects inside a transaction, breaking the flow # 2. The core participation (collection add + tracking) is atomic within the tx # 3. Through model creation is logically separate - if it fails, the participation # itself succeeded and can be cleaned up or retried independently # # If Familia's transaction handling changes in the future, revisit this boundary. through_model = if through_class Participation::ThroughModelOperations.find_or_create( through_class: through_class, target: self, participant: item, attrs: through_attrs, ) end # Return through model if using :through, otherwise self for backward compat through_model || self end end |
.build_bulk_add(target_class, collection_name, type) ⇒ Object
Build method for bulk adding items Creates: customer.add_domains([domain1, domain2, ...])
225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 |
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 225 def self.build_bulk_add(target_class, collection_name, type) method_name = "add_#{collection_name}" target_class.define_method(method_name) do |items| return if items.empty? collection = send(collection_name) # Use transaction for atomicity across all bulk additions and reverse index tracking # All operations use Horreum's DataType methods (not direct Redis calls) transaction do |_tx| # Bulk add to collection using DataType methods (multiple ZADD/SADD/RPUSH) TargetMethods::Builder.bulk_add_to_collection(collection, items, type: type, target_class: self.class, collection_name: collection_name) # Track all participations using DataType methods (multiple SADD) items.each do |item| item.track_participation_in(collection.dbkey) if item.respond_to?(:track_participation_in) end end end end |
.build_bulk_stage_method(target_class, collection_name, staged_name, through) ⇒ Object
Build method to bulk stage multiple through models Creates: org.stage_members(through_attrs_list)
Stages multiple invitations at once. Each entry in the list creates a UUID-keyed through model and adds it to the staging set.
Uses two-phase approach for efficiency:
- Phase 1: Create through models sequentially (save requires inspectable returns)
- Phase 2: Pipeline all ZADD calls (reduces N round-trips to 1)
556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 |
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 556 def self.build_bulk_stage_method(target_class, collection_name, staged_name, through) method_name = "stage_#{collection_name}" target_class.define_method(method_name) do |through_attrs_list| return [] if through_attrs_list.empty? through_class = Familia.resolve_class(through) staging_collection = send(staged_name) # Phase 1: Create through models sequentially (save requires inspectable returns) staged_models = through_attrs_list.map do |attrs| Participation::StagedOperations.stage( through_class: through_class, target: self, attrs: attrs, ) end # Phase 2: Pipeline all ZADD calls (reduces N round-trips to 1) pipelined do |_pipe| now = Familia.now.to_f staged_models.each { |m| staging_collection.add(m.objid, now) } end staged_models end end |
.build_bulk_unstage_method(target_class, collection_name, staged_name, through) ⇒ Object
Build method to bulk unstage multiple through models Creates: org.unstage_members(staged_models_or_objids)
Revokes multiple invitations at once. Accepts either staged model objects or their objids (flexible). Returns count of models destroyed.
Uses two-phase approach for efficiency:
- Phase 1: Pipeline all ZREM calls (reduces N round-trips to 1)
- Phase 2: Destroy models sequentially (load/exists?/destroy! need inspectable returns)
598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 |
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 598 def self.build_bulk_unstage_method(target_class, collection_name, staged_name, through) method_name = "unstage_#{collection_name}" target_class.define_method(method_name) do |staged_models_or_objids| return 0 if staged_models_or_objids.empty? through_class = Familia.resolve_class(through) staging_collection = send(staged_name) # Phase 1: Pipeline all ZREM calls (reduces N round-trips to 1) pipelined do |_pipe| staged_models_or_objids.each do |item| objid = item.respond_to?(:objid) ? item.objid : item staging_collection.remove(objid) end end # Phase 2: Destroy through models sequentially # StagedOperations.unstage returns true on success, false if model didn't exist staged_models_or_objids.count do |item| model = if item.respond_to?(:exists?) item else through_class.load(item.respond_to?(:objid) ? item.objid : item) end Participation::StagedOperations.unstage(staged_model: model) if model end end end |
.build_class_add_method(target_class, collection_name, type) ⇒ Object
Build class-level add method Creates: User.add_to_all_users(user, score)
639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 |
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 639 def self.build_class_add_method(target_class, collection_name, type) method_name = "add_to_#{collection_name}" target_class.define_singleton_method(method_name) do |item, score = nil| collection = send(collection_name.to_s) # Calculate score if needed if type == :sorted_set && score.nil? score = if item.respond_to?(:calculate_participation_score) item.calculate_participation_score('class', collection_name) elsif item.respond_to?(:current_score) item.current_score else Familia.now.to_f end end TargetMethods::Builder.add_to_collection( collection, item, score: score, type: type, target_class: self.class, collection_name: collection_name, ) end end |
.build_class_collection_getter(target_class, collection_name, type) ⇒ Object
Build class-level collection getter Creates: User.all_users (class method)
630 631 632 633 634 635 |
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 630 def self.build_class_collection_getter(target_class, collection_name, type) # No need to define the method - Horreum automatically creates it # when we call class_#{type} above. This method is kept for # backwards compatibility but now does nothing. # The field definition (class_sorted_set :all_users) creates the accessor automatically. end |
.build_class_level(target_class, collection_name, type, max_length: nil) ⇒ Object
Build class-level collection methods (for class_participates_in)
94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 |
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 94 def self.build_class_level(target_class, collection_name, type, max_length: nil) validate_max_length_option!(type, max_length) # FIRST: Ensure the class-level DataType field is defined. # The collection holds instances of target_class itself. Declare it # with record_class: so `each_record` can load the records (issue # #297) without changing read deserialization — see # CollectionOperations#ensure_collection_field for why participation # uses record_class: rather than class: + reference: true. # # Skip if a class-level accessor already exists, mirroring the # method_defined? guard in ensure_collection_field so a pre-declared # collection is not silently overridden (symmetry with instance-level). if target_class.respond_to?(collection_name) existing = target_class.[collection_name.to_s.to_sym] assert_compatible_cap!(existing, max_length, target_class, collection_name, type, dsl: "class_#{type}") else opts = { record_class: target_class } opts[:max_length] = max_length if max_length target_class.send("class_#{type}", collection_name, **opts) end # Class-level collection getter (e.g., User.all_users) build_class_collection_getter(target_class, collection_name, type) build_class_add_method(target_class, collection_name, type) build_class_remove_method(target_class, collection_name) end |
.build_class_remove_method(target_class, collection_name) ⇒ Object
Build class-level remove method Creates: User.remove_from_all_users(user)
669 670 671 672 673 674 675 676 |
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 669 def self.build_class_remove_method(target_class, collection_name) method_name = "remove_from_#{collection_name}" target_class.define_singleton_method(method_name) do |item| collection = send(collection_name.to_s) TargetMethods::Builder.remove_from_collection(collection, item) end end |
.build_collection_getter(target_class, collection_name, type) ⇒ Object
Build method to get the collection Creates: customer.domains
125 126 127 128 129 130 |
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 125 def self.build_collection_getter(target_class, collection_name, type) # No need to define the method - Horreum automatically creates it # when we call ensure_collection_field above. This method is # kept for backwards compatibility but now does nothing. # The field definition (sorted_set :domains) creates the accessor automatically. end |
.build_permission_enumerator(target_class, collection_name) ⇒ Object
Build streaming permission query for sorted sets (issue #309) Creates: customer.each_domains_with_permission(min_level) { |d| ... }
Streams members via ZSCAN, so retained memory is O(1) regardless
of collection size. ZSCAN trade-offs (vs the Array-returning
- Iteration order is UNORDERED (not score order).
- Delivery is at-least-once: a member may be yielded more than once if the sorted set is rehashed mid-scan (duplicates possible); members present for the whole scan are never missed.
min_permission semantics match build_permission_query above: atomic PERMISSION_FLAGs only, roles raise ArgumentError.
389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 |
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 389 def self.(target_class, collection_name) method_name = "each_#{collection_name}_with_permission" target_class.define_method(method_name) do | = :read, batch_size: 100, &block| TargetMethods::Builder.() unless batch_size.is_a?(Integer) && batch_size.positive? raise ArgumentError, "batch_size must be a positive integer (got #{batch_size.inspect})" end unless block return to_enum(:"each_#{collection_name}_with_permission", , batch_size: batch_size) end collection = send(collection_name) cursor = 0 loop do cursor, pairs = collection.scan(cursor, count: batch_size) # ZSCAN yields an Array of [member, score] pairs (not a Hash); # SortedSet#scan already deserializes members and coerces # scores to Float, so yield the member directly. NB: if this # loop ever stops using `score`, Style/HashEachMethods will # want to autocorrect it to each_key, which raises # NoMethodError on an Array — disable the cop, don't obey it # (see the guard in SortedSet#each). pairs.each do |member, score| block.call(member) if ScoreEncoding.(score, ) end break if cursor.zero? end self end end |
.build_permission_query(target_class, collection_name) ⇒ Object
Build permission query for sorted sets Creates: customer.domains_with_permission(min_level)
min_permission takes an atomic PERMISSION_FLAG (:read, :write, :delete, ...). It is validated eagerly (before the limit: 0 shortcut and any scan, so it raises even on an empty collection) and forwarded per-member to ScoreEncoding.permission?, so a PERMISSION_ROLE (:viewer, :editor, :moderator) raises ArgumentError rather than being expanded to its bits -- deliberately, since permission? tests bits individually and would answer "holds any of these" where the caller means "holds all of these". Pass the flag you mean (:edit, not :editor). :admin names a flag as well as a role and so resolves to bit 7 alone; see the "Flags versus roles" section in ScoreEncoding for the full rationale. Pagination is additive and backward compatible: the default call (no kwargs) returns the same Array as before, but pages internally so peak intermediate memory is O(batch_size) instead of O(N) (issue #309).
274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 |
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 274 def self.(target_class, collection_name) method_name = "#{collection_name}_with_permission" target_class.define_method(method_name) do | = :read, limit: nil, offset: 0, batch_size: 500| TargetMethods::Builder.() TargetMethods::Builder.( limit: limit, offset: offset, batch_size: batch_size, ) return [] if limit&.zero? TargetMethods::Builder.( send(collection_name), , limit: limit, offset: offset, batch_size: batch_size ) end end |
.build_remove_item(target_class, collection_name, type, through = nil) ⇒ Object
Build method to remove an item from the collection Creates: customer.remove_domains_instance(domain)
191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 |
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 191 def self.build_remove_item(target_class, collection_name, type, through = nil) method_name = "remove_#{collection_name}_instance" target_class.define_method(method_name) do |item| collection = send(collection_name) # Resolve through class if specified through_class = through ? Familia.resolve_class(through) : nil # Use transaction for atomicity between collection remove and reverse index untracking # All operations use Horreum's DataType methods (not direct Redis calls) transaction do |_tx| # Remove from collection using DataType method (ZREM/SREM/LREM) TargetMethods::Builder.remove_from_collection(collection, item, type: type) # Remove from participation tracking using DataType method (SREM) item.untrack_participation_in(collection.dbkey) if item.respond_to?(:untrack_participation_in) end # TRANSACTION BOUNDARY: Through model destruction intentionally happens AFTER # the transaction block. See build_add_item for detailed rationale. # The core removal is atomic; through model cleanup is a separate operation. return unless through_class Participation::ThroughModelOperations.find_and_destroy( through_class: through_class, target: self, participant: item, ) end end |
.build_stage_method(target_class, collection_name, staged_name, through) ⇒ Object
Build method to stage a through model for deferred activation Creates: org.stage_members_instance(through_attrs: {})
Stage creates a UUID-keyed through model and adds it to the staging set. The participant doesn't exist yet (e.g., invitation sent but not accepted).
433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 |
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 433 def self.build_stage_method(target_class, collection_name, staged_name, through) method_name = "stage_#{collection_name}_instance" target_class.define_method(method_name) do |through_attrs: {}| through_class = Familia.resolve_class(through) staging_collection = send(staged_name) # Create UUID-keyed staged model staged_model = Participation::StagedOperations.stage( through_class: through_class, target: self, attrs: through_attrs, ) # Add to staging set with created_at as score staging_collection.add(staged_model.objid, Familia.now.to_f) staged_model end end |
.build_unstage_method(target_class, collection_name, staged_name, _through) ⇒ Object
Build method to unstage (revoke) a staged through model Creates: org.unstage_members_instance(staged_model)
Unstaging removes the through model from staging and destroys it. Used when an invitation is revoked before acceptance.
528 529 530 531 532 533 534 535 536 537 538 539 540 |
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 528 def self.build_unstage_method(target_class, collection_name, staged_name, _through) method_name = "unstage_#{collection_name}_instance" target_class.define_method(method_name) do |staged_model| staging_collection = send(staged_name) # Remove from staging set staging_collection.remove(staged_model.objid) # Destroy the through model Participation::StagedOperations.unstage(staged_model: staged_model) end end |
.each_permitted_pair(collection, min_permission, batch_size) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Yield [raw_member, score] pairs holding min_permission, one ZRANGEBYSCORE page at a time so peak memory is O(batch_size).
Permission bits are encoded in the FRACTIONAL part of each score (see ScoreEncoding) and do not form a contiguous range, so a score-range query (e.g. ZRANGEBYSCORE x +inf) cannot filter by permission -- it would match members regardless of their bits. Fetch members page by page and test the bits per-member.
Pagination is offset-based (LIMIT page*batch_size, batch_size) on purpose. Do NOT switch to score-cursor pagination with an exclusive bound ("(#score"): encode_score truncates timestamps to integer seconds, so members added in the same second with the same permission bits share IDENTICAL scores, and an exclusive bound would silently drop members straddling a page boundary. The O(N²/batch) deep-offset CPU cost is the accepted trade for correctness and bounded memory.
356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 |
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 356 def self.each_permitted_pair(collection, , batch_size) page = 0 loop do raw_pairs = collection.rangebyscoreraw( '-inf', '+inf', limit: [page * batch_size, batch_size], with_scores: true ) break if raw_pairs.empty? raw_pairs.each do |raw_member, score| yield raw_member, score if ScoreEncoding.(score, ) end break if raw_pairs.size < batch_size page += 1 end end |
.filter_by_permission(collection, min_permission, limit:, offset:, batch_size:) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Collect members whose scores carry min_permission, in score order, applying offset (POST-FILTER: counts members that pass the permission test, not raw entries) and limit.
321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 |
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 321 def self.(collection, , limit:, offset:, batch_size:) matches = [] skipped = 0 each_permitted_pair(collection, , batch_size) do |raw_member, _score| if skipped < offset skipped += 1 next end matches << collection.deserialize_value(raw_member) return matches if limit && matches.size >= limit end matches end |
.validate_min_permission!(min_permission) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Validate min_permission eagerly, before any shortcut or scan loop, so an invalid flag raises even when limit: 0 or the collection is empty -- the lazy per-member permission? check never runs in those cases. Delegates to permission_level_value so the role-vs-flag ArgumentError message stays identical to the per-member path.
297 298 299 300 |
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 297 def self.() ScoreEncoding.() nil end |
.validate_permission_query_args!(limit:, offset:, batch_size:) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Validate the pagination kwargs for *_with_permission (issue #309). Mirrors the validation style in CollectionBase#each_record.
305 306 307 308 309 310 311 312 313 314 315 |
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 305 def self.(limit:, offset:, batch_size:) unless batch_size.is_a?(Integer) && batch_size.positive? raise ArgumentError, "batch_size must be a positive integer (got #{batch_size.inspect})" end unless offset.is_a?(Integer) && !offset.negative? raise ArgumentError, "offset must be a non-negative integer (got #{offset.inspect})" end return if limit.nil? || (limit.is_a?(Integer) && !limit.negative?) raise ArgumentError, "limit must be nil or a non-negative integer (got #{limit.inspect})" end |