diff --git a/CHANGELOG.md b/CHANGELOG.md index c6f1b12..600fa27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ from v1.0.0 onwards. Prior 0.x releases may include breaking changes between min ## [Unreleased] +## [1.5.0] - 2026-06-02 + +### Added + +- `group:` option on `memoize` — assigns a method to a named invalidation group (`memoize :find, group: :database`). Groups are stored on the class and survive re-memoization; a method can belong to at most one group at a time (re-memoizing with a different group moves it). Accepts any non-empty Symbol or String. Can be set as a class default via `safe_memoize_options group: :my_group`. +- `reset_memo_group(group_name)` instance method — clears all per-instance cached entries for every method in the named group in a single call; each evicted entry fires the `:on_evict` hook. A no-op for unknown groups. +- `reset_shared_memo_group(group_name)` class method — the shared-cache equivalent of `reset_memo_group`; clears all shared-cache entries for every method in the group that was memoized with `shared: true`. +- `memo_group_methods(group_name)` instance method — returns the array of method names belonging to the given group on the instance's class (empty array for unknown groups). +- `memo_groups` instance method — returns all group names registered on the instance's class. +- `safe_memo_group_methods(group_name)` class method — class-level equivalent of `memo_group_methods`. +- `safe_memo_groups` class method — class-level equivalent of `memo_groups`. + ## [1.4.0] - 2026-06-02 ### Added diff --git a/README.md b/README.md index ebac996..318bac8 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ SafeMemoize uses Ruby's `prepend` mechanism. When you call `memoize :method_name - [Plugin / extension architecture — `SafeMemoize::Extension` DSL for adding custom `memoize` options and global lifecycle handlers without monkey-patching](#plugin--extension-architecture) - [Per-class default options via `safe_memoize_options` — set TTL, max size, copy-on-read, and other defaults for every `memoize` call on the class without repeating them](#per-class-default-options-safe_memoize_options) - [Copy-on-read via `copy_on_read: true` — returns a `dup`/`deep_dup` on every cache read to protect shared cached state from caller mutation](#copy-on-read) +- [Cache invalidation groups via `group:` — tag related methods with a group name and bust them all with a single `reset_memo_group` call](#cache-invalidation-groups) ## Installation @@ -194,6 +195,79 @@ obj.reset_all_memos # Clears all memoized values [↑ Back to features](#features) +### Cache invalidation groups + +Tag related methods with `group:` and bust them all at once with a single `reset_memo_group` call: + +```ruby +class RepoService + prepend SafeMemoize + + def find_user(id) = db.query("SELECT * FROM users WHERE id=?", id) + def find_post(id) = db.query("SELECT * FROM posts WHERE id=?", id) + def site_config = db.query("SELECT * FROM config LIMIT 1") + + memoize :find_user, group: :database + memoize :find_post, group: :database + memoize :site_config # no group — unaffected by group reset +end + +svc = RepoService.new +svc.find_user(1) +svc.find_post(42) +svc.site_config + +svc.reset_memo_group(:database) # invalidates find_user and find_post only +svc.memoized?(:site_config) # => true — unaffected +``` + +For `shared: true` methods, use the class method: + +```ruby +class CatalogService + prepend SafeMemoize + + def products = fetch_all_products + def categories = fetch_all_categories + + memoize :products, shared: true, group: :catalog + memoize :categories, shared: true, group: :catalog +end + +CatalogService.reset_shared_memo_group(:catalog) # clears shared cache for both methods +``` + +#### Introspection + +```ruby +svc.memo_groups # => [:database] — all groups on the class +svc.memo_group_methods(:database) # => [:find_user, :find_post] +CatalogService.safe_memo_groups # => [:catalog] +CatalogService.safe_memo_group_methods(:catalog) # => [:products, :categories] +``` + +#### Class-wide group default + +Use `safe_memoize_options` to assign all subsequently memoized methods to the same group: + +```ruby +class ApiClient + prepend SafeMemoize + safe_memoize_options group: :api + + def users = http.get("/users") + def orders = http.get("/orders") + + memoize :users # group: :api + memoize :orders # group: :api + memoize :health, group: nil # override — no group +end +``` + +A method belongs to at most one group at a time; re-memoizing with a different `group:` moves it. + +[↑ Back to features](#features) + ### Lifecycle hooks Register callbacks that fire when cached entries are evicted or expire. @@ -1611,6 +1685,7 @@ Anything **not** listed here — internal modules, private methods, `@__safe_mem | `shared_cache:` | `String \| nil` | `nil` | Name of a globally-registered shared store; incompatible with `shared:`, `store:`, `fiber_local:`, `ractor_safe:`, and `max_size:` | | `cache_bust:` | `Proc \| Symbol \| nil` | `nil` | Version-token callable; invoked on the instance at each lookup; token is folded into the key; incompatible with `key:` | | `copy_on_read:` | `Boolean` | `false` | Return a `dup`/`deep_dup` of the cached value on every read; protects shared state from caller mutation; nil and frozen values pass through; incompatible with `ractor_safe:` | +| `group:` | `Symbol \| String \| nil` | `nil` | Assigns the method to a named invalidation group; call `reset_memo_group` / `reset_shared_memo_group` to bust all methods in the group at once; a method belongs to at most one group | | *(extension options)* | any | — | Unknown kwargs are validated against registered extensions; raise `ArgumentError` if unclaimed | ### `memoize_all` options (class method) @@ -1628,7 +1703,7 @@ All `memoize` option keys above, plus: | Option key | Type | Default | Notes | |---|---|---|---| -| any `memoize` key except mode-switches | — | — | Accepts `ttl:`, `max_size:`, `ttl_refresh:`, `if:`, `unless:`, `key:`, `cache_bust:`, `copy_on_read:`, `namespace:`, `store:`; raises `ArgumentError` for `shared:`, `fiber_local:`, `ractor_safe:`, `shared_cache:` | +| any `memoize` key except mode-switches | — | — | Accepts `ttl:`, `max_size:`, `ttl_refresh:`, `if:`, `unless:`, `key:`, `cache_bust:`, `copy_on_read:`, `namespace:`, `store:`, `group:`; raises `ArgumentError` for `shared:`, `fiber_local:`, `ractor_safe:`, `shared_cache:` | ### Instance methods (public) @@ -1650,10 +1725,18 @@ All `memoize` option keys above, plus: | Method | Returns | |---|---| | `reset_memo(method_name, *args, **kwargs)` | `nil` | +| `reset_memo_group(group_name)` | `nil` | | `reset_all_memos` | `nil` | | `memo_touch(method_name, *args, ttl: nil, **kwargs)` | `Boolean` | | `memo_refresh(method_name, *args, **kwargs)` | cached value | +**Group introspection** + +| Method | Returns | +|---|---| +| `memo_groups` | `Array` — all group names on the class | +| `memo_group_methods(group_name)` | `Array` — methods in the group | + **Warm-up and persistence** | Method | Returns | @@ -1705,11 +1788,19 @@ All `memoize` option keys above, plus: |---|---| | `reset_shared_memo(method_name, *args, **kwargs)` | `nil` | | `reset_all_shared_memos` | `nil` | +| `reset_shared_memo_group(group_name)` | `nil` | | `shared_memoized?(method_name, *args, **kwargs)` | `Boolean` | | `shared_memo_count(method_name = nil)` | `Integer` | | `shared_memo_age(method_name, *args, **kwargs)` | `Numeric \| nil` | | `shared_memo_stale?(method_name, *args, **kwargs)` | `Boolean` | +### Group class methods (available on any class that uses `group:`) + +| Method | Returns | +|---|---| +| `safe_memo_groups` | `Array` — all group names on the class | +| `safe_memo_group_methods(group_name)` | `Array` — methods belonging to the group | + **Ractor-safe shared cache (added when any method uses `ractor_safe: true`)** | Method | Returns | diff --git a/ROADMAP.md b/ROADMAP.md index 7139d0f..16270e8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,7 +10,7 @@ This document tracks the planned evolution of SafeMemoize through v1.0.0 and bey | Feature | Description | Status | |---|---|---| -| Memoization groups | `memoize :find, group: :database` then `reset_memo_group(:database)` to invalidate all methods tagged with the same group at once; groups can span multiple methods on the same class | Planned | +| Memoization groups | `memoize :find, group: :database` then `reset_memo_group(:database)` to invalidate all methods tagged with the same group at once; groups can span multiple methods on the same class | Shipped | --- diff --git a/lib/safe_memoize/class_methods.rb b/lib/safe_memoize/class_methods.rb index 1d474df..b2d117e 100644 --- a/lib/safe_memoize/class_methods.rb +++ b/lib/safe_memoize/class_methods.rb @@ -64,6 +64,11 @@ module ClassMethods # itself. Prevents callers from mutating shared cached state. Frozen and +nil+ # values are returned as-is. Incompatible with +ractor_safe:+ (ractor values are # always frozen; use that guarantee instead). + # @param group [Symbol, String, nil] assigns the method to a named invalidation + # group. Call {PublicMethods#reset_memo_group} on an instance (or + # {.reset_shared_memo_group} for shared-mode methods) to bust every method in the + # group at once. A method may belong to at most one group; re-memoizing with a + # different group moves it. Must be a non-empty Symbol or String. # @return [void] # @raise [ArgumentError] if the method does not exist, or option values are invalid # @@ -84,7 +89,7 @@ module ClassMethods # @example With a custom store # STORE = SafeMemoize::Stores::Memory.new # memoize :fetch, store: STORE, ttl: 300 - def memoize(method_name, ttl: UNSET, max_size: UNSET, ttl_refresh: UNSET, if: UNSET, unless: UNSET, shared: UNSET, key: UNSET, store: UNSET, fiber_local: UNSET, ractor_safe: UNSET, namespace: UNSET, shared_cache: UNSET, cache_bust: UNSET, copy_on_read: UNSET, **extension_options) + def memoize(method_name, ttl: UNSET, max_size: UNSET, ttl_refresh: UNSET, if: UNSET, unless: UNSET, shared: UNSET, key: UNSET, store: UNSET, fiber_local: UNSET, ractor_safe: UNSET, namespace: UNSET, shared_cache: UNSET, cache_bust: UNSET, copy_on_read: UNSET, group: UNSET, **extension_options) method_name = method_name.to_sym unless method_defined?(method_name) || private_method_defined?(method_name) || protected_method_defined?(method_name) @@ -126,6 +131,7 @@ def memoize(method_name, ttl: UNSET, max_size: UNSET, ttl_refresh: UNSET, if: UN copy_on_read = cls_defaults[:copy_on_read] if copy_on_read.equal?(UNSET) && cls_defaults.key?(:copy_on_read) namespace = cls_defaults[:namespace] if namespace.equal?(UNSET) && cls_defaults.key?(:namespace) store = cls_defaults[:store] if store.equal?(UNSET) && cls_defaults.key?(:store) + group = cls_defaults[:group] if group.equal?(UNSET) && cls_defaults.key?(:group) end # Normalize remaining UNSET to original per-call defaults @@ -141,6 +147,7 @@ def memoize(method_name, ttl: UNSET, max_size: UNSET, ttl_refresh: UNSET, if: UN shared_cache = nil if shared_cache.equal?(UNSET) cache_bust = nil if cache_bust.equal?(UNSET) copy_on_read = false if copy_on_read.equal?(UNSET) + group = nil if group.equal?(UNSET) cond_if = nil if cond_if.equal?(UNSET) cond_unless = nil if cond_unless.equal?(UNSET) @@ -213,6 +220,15 @@ def memoize(method_name, ttl: UNSET, max_size: UNSET, ttl_refresh: UNSET, if: UN __safe_memo_method_namespaces__[method_name] = namespace end + if group + unless group.is_a?(Symbol) || group.is_a?(String) + raise ArgumentError, "group: must be a Symbol or String (got #{group.class})" + end + group = group.to_sym + raise ArgumentError, "group: must not be empty" if group.empty? + __safe_memo_register_group__(method_name, group) + end + if shared_cache raise ArgumentError, "shared_cache: must be a String (got #{shared_cache.class})" unless shared_cache.is_a?(String) raise ArgumentError, "shared_cache: must not be empty" if shared_cache.empty? @@ -763,6 +779,35 @@ def shared_memo_stale?(method_name, *args, **kwargs) end end + # Clears all shared-cache entries for every method in the given group. + # + # Only affects methods memoized with +shared: true+. For per-instance cache + # invalidation use {PublicMethods#reset_memo_group} on the instance. + # + # @param group_name [Symbol, String] + # @return [void] + def reset_shared_memo_group(group_name) + group_name = group_name.to_sym + (__safe_memo_groups__[group_name] || []).each { |m| reset_shared_memo(m) } + end + + # Returns the method names belonging to the given invalidation group, or an + # empty array when the group is unknown. + # + # @param group_name [Symbol, String] + # @return [Array] + def safe_memo_group_methods(group_name) + group_name = group_name.to_sym + (__safe_memo_groups__[group_name] || []).dup + end + + # Returns all group names registered on this class. + # + # @return [Array] + def safe_memo_groups + __safe_memo_groups__.keys + end + private def __safe_memo_shared_cache__ @@ -793,6 +838,17 @@ def __safe_memoize_defaults__ @__safe_memoize_defaults__ end + def __safe_memo_groups__ + @__safe_memo_groups__ ||= {} + end + + def __safe_memo_register_group__(method_name, group) + groups = __safe_memo_groups__ + # Remove method from any prior group it belonged to + groups.each_value { |methods| methods.delete(method_name) } + (groups[group] ||= []) << method_name + end + # Resolves the effective first-element key sym for a given bare method name, # applying the active namespace. Used by class-level cache operations where # instance methods (compute_cache_key) are unavailable. diff --git a/lib/safe_memoize/public_methods.rb b/lib/safe_memoize/public_methods.rb index da8d481..b793a2e 100644 --- a/lib/safe_memoize/public_methods.rb +++ b/lib/safe_memoize/public_methods.rb @@ -359,6 +359,37 @@ def reset_memo(method_name, *args, **kwargs) end end + # Clears all per-instance cached entries for every method belonging to the + # given invalidation group (declared via +memoize :method, group: :name+). + # + # A no-op when the group is unknown or has no members. Each evicted entry + # fires the +:on_evict+ hook. For shared-mode methods use the class-level + # {ClassMethods.reset_shared_memo_group} instead. + # + # @param group_name [Symbol, String] + # @return [void] + def reset_memo_group(group_name) + group_name = group_name.to_sym + (self.class.send(:__safe_memo_groups__)[group_name] || []).each { |m| reset_memo(m) } + end + + # Returns the method names belonging to the given invalidation group on + # this instance's class, or an empty array when the group is unknown. + # + # @param group_name [Symbol, String] + # @return [Array] + def memo_group_methods(group_name) + group_name = group_name.to_sym + (self.class.send(:__safe_memo_groups__)[group_name] || []).dup + end + + # Returns all invalidation group names registered on this instance's class. + # + # @return [Array] + def memo_groups + self.class.send(:__safe_memo_groups__).keys + end + # Clears all cached entries for every method on this instance. # Each evicted entry fires the +:on_evict+ hook. # diff --git a/sig/safe_memoize.rbs b/sig/safe_memoize.rbs index d470a19..c9995a4 100644 --- a/sig/safe_memoize.rbs +++ b/sig/safe_memoize.rbs @@ -17,6 +17,7 @@ module SafeMemoize @__safe_memo_shared_cache__: Hash[memo_key, memo_record]? @__safe_memo_shared_mutex__: Mutex? @__safe_memo_shared_lru_order__: Hash[Symbol, Hash[memo_key, true]]? + @__safe_memo_groups__: Hash[Symbol, Array[Symbol]]? UNSET: untyped SHARED_CACHE_REGISTRY: Hash[String, Stores::Base] @@ -65,7 +66,7 @@ module SafeMemoize end module ClassMethods - def memoize: (Symbol | String method_name, ?ttl: Numeric?, ?max_size: Integer?, ?ttl_refresh: bool, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool, ?key: (^(*untyped args, **untyped kwargs) -> untyped)?, ?store: Stores::Base?, ?fiber_local: bool, ?ractor_safe: bool, ?namespace: String?, ?shared_cache: String?, ?cache_bust: (^() -> untyped) | Symbol | nil, ?copy_on_read: bool, **untyped extension_options) -> void + def memoize: (Symbol | String method_name, ?ttl: Numeric?, ?max_size: Integer?, ?ttl_refresh: bool, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool, ?key: (^(*untyped args, **untyped kwargs) -> untyped)?, ?store: Stores::Base?, ?fiber_local: bool, ?ractor_safe: bool, ?namespace: String?, ?shared_cache: String?, ?cache_bust: (^() -> untyped) | Symbol | nil, ?copy_on_read: bool, ?group: Symbol | String | nil, **untyped extension_options) -> void def safe_memoize_store: () -> Stores::Base? def safe_memoize_store=: (Stores::Base?) -> Stores::Base? def safe_memoize_namespace: () -> String? @@ -78,6 +79,9 @@ module SafeMemoize def shared_memo_count: (?Symbol | String method_name) -> Integer def shared_memo_age: (Symbol | String method_name, *untyped args, **untyped kwargs) -> Float? def shared_memo_stale?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool + def reset_shared_memo_group: (Symbol | String group_name) -> void + def safe_memo_group_methods: (Symbol | String group_name) -> Array[Symbol] + def safe_memo_groups: () -> Array[Symbol] private @@ -88,6 +92,8 @@ module SafeMemoize def __safe_memo_method_namespaces__: () -> Hash[Symbol, String] def __safe_memo_class_cache_bust_generators__: () -> Hash[Symbol, Proc | Symbol] def __safe_memoize_defaults__: () -> Hash[Symbol, untyped]? + def __safe_memo_groups__: () -> Hash[Symbol, Array[Symbol]] + def __safe_memo_register_group__: (Symbol method_name, Symbol group) -> void def memoized_method_visibility: (Symbol method_name) -> Symbol end @@ -114,6 +120,9 @@ module SafeMemoize def memo_age: (Symbol | String method_name, *untyped args, **untyped kwargs) -> Float? def memo_stale?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool def reset_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void + def reset_memo_group: (Symbol | String group_name) -> void + def memo_group_methods: (Symbol | String group_name) -> Array[Symbol] + def memo_groups: () -> Array[Symbol] def reset_all_memos: () -> void def memo_inspect: (Symbol | String method_name, *untyped args, **untyped kwargs) -> { cached: bool, value: untyped, hits: Integer, misses: Integer, ttl_remaining: Float?, age: Float?, custom_key: untyped, lru_position: Integer? }? end diff --git a/spec/safe_memoize_spec.rb b/spec/safe_memoize_spec.rb index 6ebc573..5c31586 100644 --- a/spec/safe_memoize_spec.rb +++ b/spec/safe_memoize_spec.rb @@ -1803,4 +1803,273 @@ def value = 42 end end end + + describe "memoization groups (group: option)" do + let(:klass) do + Class.new do + prepend SafeMemoize + + attr_reader :calls + + def initialize + @calls = Hash.new(0) + end + + def fetch_user(id) + @calls[:fetch_user] += 1 + "user_#{id}" + end + + def fetch_post(id) + @calls[:fetch_post] += 1 + "post_#{id}" + end + + def compute + @calls[:compute] += 1 + 42 + end + + memoize :fetch_user, group: :database + memoize :fetch_post, group: :database + memoize :compute + end + end + + let(:obj) { klass.new } + + context "basic group invalidation" do + it "caches methods normally before reset" do + obj.fetch_user(1) + obj.fetch_user(1) + expect(obj.calls[:fetch_user]).to eq(1) + end + + it "reset_memo_group invalidates all methods in the group" do + obj.fetch_user(1) + obj.fetch_post(2) + expect(obj.memoized?(:fetch_user, 1)).to be true + expect(obj.memoized?(:fetch_post, 2)).to be true + + obj.reset_memo_group(:database) + + expect(obj.memoized?(:fetch_user, 1)).to be false + expect(obj.memoized?(:fetch_post, 2)).to be false + end + + it "does not affect methods outside the group" do + obj.compute + expect(obj.memoized?(:compute)).to be true + + obj.reset_memo_group(:database) + + expect(obj.memoized?(:compute)).to be true + end + + it "recomputes values after group reset" do + obj.fetch_user(1) + obj.reset_memo_group(:database) + obj.fetch_user(1) + + expect(obj.calls[:fetch_user]).to eq(2) + end + + it "is a no-op for unknown groups" do + obj.fetch_user(1) + expect { obj.reset_memo_group(:nonexistent) }.not_to raise_error + expect(obj.memoized?(:fetch_user, 1)).to be true + end + + it "accepts group names as strings" do + obj.fetch_user(1) + obj.reset_memo_group("database") + expect(obj.memoized?(:fetch_user, 1)).to be false + end + end + + context "memo_group_methods" do + it "returns method names in the group" do + expect(obj.memo_group_methods(:database)).to match_array(%i[fetch_user fetch_post]) + end + + it "returns empty array for unknown group" do + expect(obj.memo_group_methods(:unknown)).to eq([]) + end + + it "accepts group name as string" do + expect(obj.memo_group_methods("database")).to match_array(%i[fetch_user fetch_post]) + end + end + + context "memo_groups" do + it "returns registered group names" do + expect(obj.memo_groups).to include(:database) + end + + it "does not include groups from other classes" do + other = Class.new do + prepend SafeMemoize + + def data = 1 + memoize :data, group: :cache + end.new + expect(obj.memo_groups).not_to include(:cache) + expect(other.memo_groups).to include(:cache) + end + end + + context "class-level safe_memo_group_methods / safe_memo_groups" do + it "safe_memo_group_methods returns method names" do + expect(klass.safe_memo_group_methods(:database)).to match_array(%i[fetch_user fetch_post]) + end + + it "safe_memo_groups returns group names" do + expect(klass.safe_memo_groups).to include(:database) + end + end + + context "reset_shared_memo_group" do + let(:shared_klass) do + Class.new do + prepend SafeMemoize + + def self.call_count + @call_count ||= Hash.new(0) + end + + def users + self.class.call_count[:users] += 1 + ["alice", "bob"] + end + + def posts + self.class.call_count[:posts] += 1 + ["post1", "post2"] + end + + def config + self.class.call_count[:config] += 1 + {timeout: 30} + end + + memoize :users, shared: true, group: :api + memoize :posts, shared: true, group: :api + memoize :config, shared: true + end + end + + it "clears all shared entries for the group" do + instance = shared_klass.new + instance.users + instance.posts + expect(shared_klass.shared_memoized?(:users)).to be true + expect(shared_klass.shared_memoized?(:posts)).to be true + + shared_klass.reset_shared_memo_group(:api) + + expect(shared_klass.shared_memoized?(:users)).to be false + expect(shared_klass.shared_memoized?(:posts)).to be false + end + + it "does not affect shared methods outside the group" do + instance = shared_klass.new + instance.config + expect(shared_klass.shared_memoized?(:config)).to be true + + shared_klass.reset_shared_memo_group(:api) + + expect(shared_klass.shared_memoized?(:config)).to be true + end + + it "is a no-op for unknown groups" do + expect { shared_klass.reset_shared_memo_group(:unknown) }.not_to raise_error + end + end + + context "group: validation" do + it "raises ArgumentError for non-symbol/string group" do + expect do + Class.new do + prepend SafeMemoize + + def data = 1 + memoize :data, group: 123 + end + end.to raise_error(ArgumentError, /group: must be a Symbol or String/) + end + + it "raises ArgumentError for empty string group" do + expect do + Class.new do + prepend SafeMemoize + + def data = 1 + memoize :data, group: "" + end + end.to raise_error(ArgumentError, /group: must not be empty/) + end + end + + context "re-memoizing with a different group moves the method" do + it "removes method from old group and adds to new group" do + k = Class.new do + prepend SafeMemoize + + def data = 1 + memoize :data, group: :alpha + memoize :data, group: :beta + end + + expect(k.safe_memo_group_methods(:alpha)).not_to include(:data) + expect(k.safe_memo_group_methods(:beta)).to include(:data) + end + end + + context "safe_memoize_options with group:" do + it "applies the group to all subsequently memoized methods" do + k = Class.new do + prepend SafeMemoize + + safe_memoize_options group: :everything + + def foo = 1 + def bar = 2 + memoize :foo + memoize :bar + end + + expect(k.safe_memo_group_methods(:everything)).to match_array(%i[foo bar]) + end + + it "per-call group: overrides the class default" do + k = Class.new do + prepend SafeMemoize + + safe_memoize_options group: :default_group + + def foo = 1 + def bar = 2 + memoize :foo + memoize :bar, group: :special + end + + expect(k.safe_memo_group_methods(:default_group)).to include(:foo) + expect(k.safe_memo_group_methods(:default_group)).not_to include(:bar) + expect(k.safe_memo_group_methods(:special)).to include(:bar) + end + end + + context "on_evict hook fires on group reset" do + it "fires on_evict for each evicted entry" do + evicted = [] + obj.fetch_user(1) + obj.fetch_post(2) + obj.on_memo_evict { |key, _| evicted << key } + + obj.reset_memo_group(:database) + + expect(evicted.length).to eq(2) + end + end + end end