diff --git a/CHANGELOG.md b/CHANGELOG.md index 837b1d4..57eecc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ from v1.0.0 onwards. Prior 0.x releases may include breaking changes between min ## [Unreleased] +### Added + +- `safe_memoize_options(**opts)` class-level macro — sets default options for every subsequent `memoize` call on the class. Per-call options take precedence; class defaults take precedence over global `SafeMemoize.configure` defaults. Accepts all `memoize` options except mode-switch options (`shared:`, `fiber_local:`, `ractor_safe:`, `shared_cache:`), which must be specified per call. Call with no arguments to clear class-level defaults. +- `copy_on_read: true` option on `memoize` — returns a `dup` (or `deep_dup` when available, e.g. ActiveRecord objects) of the cached value on every read, preventing callers from mutating shared cached state. `nil` and frozen values are returned as-is. Works across all cache paths (per-instance, LRU, shared, fiber-local, and external store). Incompatible with `ractor_safe:` (ractor-safe values are always frozen; use that guarantee instead). Can be set as a class default via `safe_memoize_options copy_on_read: true`. + ## [1.3.0] - 2026-05-28 ### Added diff --git a/README.md b/README.md index e9cac5f..ebac996 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,8 @@ SafeMemoize uses Ruby's `prepend` mechanism. When you call `memoize :method_name - [Named shared caches via `shared_cache: "name"` — cross-class cache sharing backed by a globally-registered store](#named-shared-caches) - [Automatic cache busting via `cache_bust:` — version-token-based invalidation; works with ActiveRecord `updated_at` and any comparable value](#automatic-cache-busting) - [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) ## Installation @@ -1372,6 +1374,83 @@ end [↑ Back to features](#features) +## Per-class default options (`safe_memoize_options`) + +`safe_memoize_options` sets option defaults for every `memoize` call on the class, eliminating repetition when many methods share the same TTL, LRU cap, or other option. Per-call options still take precedence; class defaults take precedence over global `SafeMemoize.configure` defaults. + +```ruby +class ApiClient + prepend SafeMemoize + safe_memoize_options ttl: 60, max_size: 200, copy_on_read: true + + def fetch(id) = http.get(id) + memoize :fetch # uses ttl: 60, max_size: 200, copy_on_read: true + + def list = http.get("/all") + memoize :list, ttl: 300 # uses max_size: 200, copy_on_read: true; ttl: 300 overrides +end +``` + +Accepted options are the same as `memoize` minus the mode-switch options (`shared:`, `fiber_local:`, `ractor_safe:`, `shared_cache:`), which must be specified per call because they change the entire execution path: + +```ruby +safe_memoize_options( + ttl: 60, + max_size: 100, + ttl_refresh: true, + copy_on_read: true, + namespace: "v2", + if: ->(v) { v.present? }, + cache_bust: :updated_at +) +``` + +Call with no arguments to clear all class-level defaults: + +```ruby +MyClass.safe_memoize_options # clears — subsequent memoize calls use global config or per-call options only +``` + +[↑ Back to features](#features) + +## Copy-on-read + +Pass `copy_on_read: true` to `memoize` to return a `dup` (or `deep_dup` when available, e.g. ActiveRecord objects) of the stored value on every cache read. This prevents callers from mutating the shared cached object: + +```ruby +class ConfigService + prepend SafeMemoize + + def settings = {host: "localhost", port: 8080} + memoize :settings, copy_on_read: true +end + +svc = ConfigService.new +result = svc.settings +result[:host] = "mutated" # only affects the caller's copy + +svc.settings[:host] # => "localhost" — cache is unaffected +``` + +`nil` and frozen values are returned as-is (no dup attempted). `copy_on_read:` works across all cache paths: per-instance hash, LRU (`max_size:`), class-level shared (`shared: true`), fiber-local (`fiber_local: true`), and external stores. It is incompatible with `ractor_safe: true` (ractor-safe values are always frozen; rely on that guarantee instead). + +Set it as a class-wide default with `safe_memoize_options`: + +```ruby +class ReportService + prepend SafeMemoize + safe_memoize_options copy_on_read: true + + def summary = build_summary + memoize :summary + + def details = build_details + memoize :details +end +``` + +[↑ Back to features](#features) + ## Ractor-safe shared cache Pass `ractor_safe: true` (together with `shared: true`) to replace the `Mutex`-backed class-level shared cache with a supervisor `Ractor` that owns the mutable cache hash. All reads and writes are serialised through message passing, so the cache is safe to use from multiple Ractors. @@ -1531,6 +1610,7 @@ Anything **not** listed here — internal modules, private methods, `@__safe_mem | `namespace:` | `String \| nil` | `nil` | Namespace prefix prepended to the cache key's first element; must not contain `:`; takes precedence over the class-level and global namespace | | `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:` | | *(extension options)* | any | — | Unknown kwargs are validated against registered extensions; raise `ArgumentError` if unclaimed | ### `memoize_all` options (class method) @@ -1544,6 +1624,12 @@ All `memoize` option keys above, plus: | `include_protected:` | `Boolean` | `false` | | `include_private:` | `Boolean` | `false` | +### `safe_memoize_options` (class method) + +| 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:` | + ### Instance methods (public) **Inspection** diff --git a/ROADMAP.md b/ROADMAP.md index f3dfbef..d9578bb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,8 +10,8 @@ This document tracks the planned evolution of SafeMemoize through v1.0.0 and bey | Feature | Description | Status | |---|---|---| -| Per-class default options | `safe_memoize_options ttl: 60, max_size: 100` class-level macro that sets option defaults shared by every `memoize` call on the class; per-call options still override | Planned | -| Copy-on-read | `memoize :config, copy_on_read: true` — returns a `dup` (or `deep_dup` when available) of the cached value on every read, preventing callers from mutating shared cached state | Planned | +| Per-class default options | `safe_memoize_options ttl: 60, max_size: 100` class-level macro that sets option defaults shared by every `memoize` call on the class; per-call options still override | Shipped | +| Copy-on-read | `memoize :config, copy_on_read: true` — returns a `dup` (or `deep_dup` when available) of the cached value on every read, preventing callers from mutating shared cached state | Shipped | --- diff --git a/lib/safe_memoize.rb b/lib/safe_memoize.rb index 5c6da5f..268ccfd 100644 --- a/lib/safe_memoize.rb +++ b/lib/safe_memoize.rb @@ -55,6 +55,9 @@ module SafeMemoize # Rescue this to catch any error raised by the library itself. class Error < StandardError; end + # @api private — sentinel distinguishing "not passed" from explicit nil/false in memoize kwargs + UNSET = Object.new.freeze + # @api private SHARED_CACHE_REGISTRY = {} # @api private diff --git a/lib/safe_memoize/class_methods.rb b/lib/safe_memoize/class_methods.rb index 45140ed..1d474df 100644 --- a/lib/safe_memoize/class_methods.rb +++ b/lib/safe_memoize/class_methods.rb @@ -37,7 +37,7 @@ module ClassMethods # a supervisor +Ractor+ rather than a +Mutex+-protected ivar, making it accessible # from worker Ractors. Requires +shared: true+. Cached values are deep-frozen via # +Ractor.make_shareable+. Incompatible with +if:+, +unless:+, +max_size:+, - # +ttl_refresh:+, +key:+, and +store:+. + # +ttl_refresh:+, +key:+, +store:+, and +copy_on_read:+. # @param namespace [String, nil] prefix prepended to every cache key for this method, # scoping it to a logical partition. Takes precedence over both the class-level # {#safe_memoize_namespace} and the global {SafeMemoize::Configuration#namespace}. @@ -59,6 +59,11 @@ module ClassMethods # {SafeMemoize.register_shared_cache} before the class is loaded to supply a custom # adapter. Incompatible with +shared:+, +store:+, +fiber_local:+, +ractor_safe:+, # and +max_size:+. Composes naturally with +namespace:+, +ttl:+, +if:+, and +key:+. + # @param copy_on_read [Boolean] when +true+, every cache read returns a +dup+ (or + # +deep_dup+ when available) of the stored value rather than the cached object + # 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). # @return [void] # @raise [ArgumentError] if the method does not exist, or option values are invalid # @@ -73,10 +78,13 @@ module ClassMethods # @example Conditional — only cache successful responses # memoize :fetch, if: ->(v) { v[:status] == 200 } # + # @example Copy-on-read — protect mutable cached config + # memoize :config, copy_on_read: true + # # @example With a custom store # STORE = SafeMemoize::Stores::Memory.new # memoize :fetch, store: STORE, ttl: 300 - def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, unless: nil, shared: false, key: nil, store: nil, fiber_local: false, ractor_safe: false, namespace: nil, shared_cache: nil, cache_bust: nil, **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, **extension_options) method_name = method_name.to_sym unless method_defined?(method_name) || private_method_defined?(method_name) || protected_method_defined?(method_name) @@ -102,18 +110,46 @@ def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, u cache_bust = injected[:cache_bust] if injected.key?(:cache_bust) end + # :if and :unless are reserved Ruby keywords; use binding to extract them + cond_if = binding.local_variable_get(:if) + cond_unless = binding.local_variable_get(:unless) + + # Apply class-level defaults (safe_memoize_options) for any still-unset options + if (cls_defaults = __safe_memoize_defaults__) + ttl = cls_defaults[:ttl] if ttl.equal?(UNSET) && cls_defaults.key?(:ttl) + max_size = cls_defaults[:max_size] if max_size.equal?(UNSET) && cls_defaults.key?(:max_size) + ttl_refresh = cls_defaults[:ttl_refresh] if ttl_refresh.equal?(UNSET) && cls_defaults.key?(:ttl_refresh) + cond_if = cls_defaults[:if] if cond_if.equal?(UNSET) && cls_defaults.key?(:if) + cond_unless = cls_defaults[:unless] if cond_unless.equal?(UNSET) && cls_defaults.key?(:unless) + key = cls_defaults[:key] if key.equal?(UNSET) && cls_defaults.key?(:key) + cache_bust = cls_defaults[:cache_bust] if cache_bust.equal?(UNSET) && cls_defaults.key?(:cache_bust) + 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) + end + + # Normalize remaining UNSET to original per-call defaults + ttl = nil if ttl.equal?(UNSET) + max_size = nil if max_size.equal?(UNSET) + ttl_refresh = false if ttl_refresh.equal?(UNSET) + shared = false if shared.equal?(UNSET) + key = nil if key.equal?(UNSET) + store = nil if store.equal?(UNSET) + fiber_local = false if fiber_local.equal?(UNSET) + ractor_safe = false if ractor_safe.equal?(UNSET) + namespace = nil if namespace.equal?(UNSET) + 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) + cond_if = nil if cond_if.equal?(UNSET) + cond_unless = nil if cond_unless.equal?(UNSET) + visibility = memoized_method_visibility(method_name) config = SafeMemoize.configuration ttl = config.default_ttl if ttl.nil? max_size = config.default_max_size if max_size.nil? - # :if and :unless are reserved Ruby keywords, so they can't be referenced - # as local variables directly. binding.local_variable_get is the only way - # to read keyword arguments with those names inside the method body. - cond_if = binding.local_variable_get(:if) - cond_unless = binding.local_variable_get(:unless) - ttl = if ttl.nil? nil else @@ -167,6 +203,7 @@ def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, u raise ArgumentError, "ractor_safe: is incompatible with ttl_refresh:" if ttl_refresh raise ArgumentError, "ractor_safe: is incompatible with key:" if key raise ArgumentError, "ractor_safe: is incompatible with store:" if store + raise ArgumentError, "ractor_safe: is incompatible with copy_on_read:" if copy_on_read end if namespace @@ -217,6 +254,18 @@ def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, u ->(result) { !cond_unless.call(result) } end + # Build a value-duplication function for copy_on_read: true. + # Frozen and nil values are returned as-is; deep_dup is preferred when available + # (e.g. ActiveRecord objects) so nested mutable structures are also protected. + dup_fn = if copy_on_read + lambda do |v| + return v if v.nil? || v.frozen? + v.respond_to?(:deep_dup) ? v.deep_dup : v.dup + end + else + ->(v) { v } + end + if effective_store miss = SafeMemoize::Stores::Base::MISS @@ -231,7 +280,7 @@ def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, u effective_store.write(cache_key, cached, expires_in: ttl) if ttl_refresh record_cache_hit(cache_key) call_memo_hooks(:on_hit, cache_key, {value: cached, expires_at: nil, cached_at: nil}) - return cached + return dup_fn.call(cached) end start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) @@ -249,7 +298,7 @@ def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, u record_cache_miss(cache_key, elapsed_time) call_memo_hooks(:on_miss, cache_key, {value: value, expires_at: nil, cached_at: now}) - value + dup_fn.call(value) end send(visibility, method_name) @@ -278,7 +327,7 @@ def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, u record[:expires_at] = memo_expires_at(ttl) if ttl_refresh record_cache_hit(cache_key) call_memo_hooks(:on_hit, cache_key, record) - memo_record_value(record) + dup_fn.call(memo_record_value(record)) else call_memo_hooks(:on_expire, cache_key, record) if record @@ -312,7 +361,7 @@ def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, u record_cache_miss(cache_key, elapsed_time) call_memo_hooks(:on_miss, cache_key, new_record) - value + dup_fn.call(value) end end @@ -356,7 +405,7 @@ def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, u record[:expires_at] = memo_expires_at(ttl) if ttl_refresh record_cache_hit(cache_key) call_memo_hooks(:on_hit, cache_key, record) - record[:value] + dup_fn.call(record[:value]) else call_memo_hooks(:on_expire, cache_key, record) if record && !record_live @@ -388,7 +437,7 @@ def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, u record_cache_miss(cache_key, elapsed_time) call_memo_hooks(:on_miss, cache_key, new_record) - value + dup_fn.call(value) end end end @@ -417,7 +466,7 @@ def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, u record[:expires_at] = memo_expires_at(ttl) if ttl_refresh record_cache_hit(cache_key) call_memo_hooks(:on_hit, cache_key, record) - memo_record_value(record) + dup_fn.call(memo_record_value(record)) else start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) value = Adapters::OpenTelemetry.trace(SafeMemoize.configuration.opentelemetry_tracer, method_name, self.class.name) { super(*args, **kwargs) } @@ -434,7 +483,7 @@ def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, u record_cache_miss(cache_key, elapsed_time) call_memo_hooks(:on_miss, cache_key, new_record) - value + dup_fn.call(value) end end else @@ -442,7 +491,7 @@ def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, u if (record = memo_cache_record(cache_key)) record_cache_hit(cache_key) call_memo_hooks(:on_hit, cache_key, record) - return memo_record_value(record) + return dup_fn.call(memo_record_value(record)) end # Cache miss - compute and store @@ -459,7 +508,7 @@ def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, u call_memo_hooks(:on_miss, cache_key, new_record) end - result + dup_fn.call(result) end end @@ -520,6 +569,41 @@ def safe_memoize_namespace=(ns) @__safe_memoize_namespace__ = ns end + # Sets class-wide default options applied to every subsequent {#memoize} call + # on this class. Per-call options take precedence; class defaults take + # precedence over global {SafeMemoize::Configuration} defaults. + # + # Call with no arguments (or an empty hash) to clear all class-level defaults. + # + # @example Apply a TTL and LRU cap to every memoized method on the class + # class ApiClient + # prepend SafeMemoize + # safe_memoize_options ttl: 60, max_size: 200 + # + # def fetch(id) = http.get(id) + # memoize :fetch # uses ttl: 60, max_size: 200 + # + # def list = http.get("/all") + # memoize :list, ttl: 300 # uses max_size: 200, ttl: 300 + # end + # + # @example Protect all cached values from mutation + # safe_memoize_options copy_on_read: true + # + # @param opts [Hash] any subset of {#memoize} options except mode-switch options + # (+shared:+, +fiber_local:+, +ractor_safe:+, +shared_cache:+) + # @return [Hash] the stored defaults + # @raise [ArgumentError] for disallowed options + def safe_memoize_options(**opts) + disallowed = %i[shared fiber_local ractor_safe shared_cache] + bad = opts.keys & disallowed + unless bad.empty? + raise ArgumentError, + "safe_memoize_options does not accept #{bad.map { |k| ":#{k}" }.join(", ")} — pass mode-switch options per memoize call" + end + @__safe_memoize_defaults__ = opts.empty? ? nil : opts + end + # Memoizes every eligible public instance method defined directly on the class. # # Accepts all options that {#memoize} accepts, plus +:except:+ and +:only:+. @@ -705,6 +789,10 @@ def __safe_memo_class_cache_bust_generators__ @__safe_memo_class_cache_bust_generators__ ||= {} end + def __safe_memoize_defaults__ + @__safe_memoize_defaults__ + 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/sig/safe_memoize.rbs b/sig/safe_memoize.rbs index c188f49..d470a19 100644 --- a/sig/safe_memoize.rbs +++ b/sig/safe_memoize.rbs @@ -18,6 +18,7 @@ module SafeMemoize @__safe_memo_shared_mutex__: Mutex? @__safe_memo_shared_lru_order__: Hash[Symbol, Hash[memo_key, true]]? + UNSET: untyped SHARED_CACHE_REGISTRY: Hash[String, Stores::Base] SHARED_CACHE_MUTEX: Mutex EXTENSION_REGISTRY: Hash[Symbol, untyped] @@ -64,12 +65,13 @@ 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, **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, **untyped extension_options) -> void def safe_memoize_store: () -> Stores::Base? def safe_memoize_store=: (Stores::Base?) -> Stores::Base? def safe_memoize_namespace: () -> String? def safe_memoize_namespace=: (String?) -> String? - def memoize_all: (?except: Array[Symbol | String], ?only: Array[Symbol | String], ?include_protected: bool, ?include_private: bool, ?ttl: Numeric?, ?max_size: Integer?, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool, ?key: (^(*untyped args, **untyped kwargs) -> untyped)?, ?fiber_local: bool, ?namespace: String?, ?shared_cache: String?) -> void + def safe_memoize_options: (**untyped opts) -> Hash[Symbol, untyped]? + def memoize_all: (?except: Array[Symbol | String], ?only: Array[Symbol | String], ?include_protected: bool, ?include_private: bool, ?ttl: Numeric?, ?max_size: Integer?, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool, ?key: (^(*untyped args, **untyped kwargs) -> untyped)?, ?fiber_local: bool, ?namespace: String?, ?shared_cache: String?, ?copy_on_read: bool) -> void def reset_shared_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void def reset_all_shared_memos: () -> void def shared_memoized?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool @@ -85,6 +87,7 @@ module SafeMemoize def __safe_memo_class_key_generators__: () -> Hash[Symbol, Proc] 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 memoized_method_visibility: (Symbol method_name) -> Symbol end diff --git a/spec/safe_memoize_spec.rb b/spec/safe_memoize_spec.rb index e580615..6ebc573 100644 --- a/spec/safe_memoize_spec.rb +++ b/spec/safe_memoize_spec.rb @@ -1436,4 +1436,371 @@ def guarded = 42 end end end + + describe "safe_memoize_options" do + context "ttl default" do + it "applies the class-level ttl to every memoize call that omits ttl:" do + klass = Class.new do + prepend SafeMemoize + + safe_memoize_options ttl: 0.05 + + def value = rand + memoize :value + end + + obj = klass.new + first = obj.value + expect(obj.value).to eq(first) + + sleep 0.07 + expect(obj.value).not_to eq(first) + end + + it "is overridden by an explicit ttl: on the memoize call" do + klass = Class.new do + prepend SafeMemoize + + safe_memoize_options ttl: 0.05 + + def a = rand + memoize :a + + def b = rand + memoize :b, ttl: 999 + end + + obj = klass.new + a_first = obj.a + b_first = obj.b + + sleep 0.07 + + expect(obj.a).not_to eq(a_first) + expect(obj.b).to eq(b_first) + end + end + + context "max_size default" do + it "applies the class-level max_size to every memoize call" do + klass = Class.new do + prepend SafeMemoize + + safe_memoize_options max_size: 2 + + def fetch(n) = n * 10 + memoize :fetch + end + + obj = klass.new + obj.fetch(1) + obj.fetch(2) + obj.fetch(3) + + expect(obj.memo_count(:fetch)).to eq(2) + end + + it "is overridden by an explicit max_size: on the memoize call" do + klass = Class.new do + prepend SafeMemoize + + safe_memoize_options max_size: 2 + + def small(n) = n + memoize :small + + def large(n) = n + memoize :large, max_size: 10 + end + + obj = klass.new + 5.times { |i| obj.small(i) } + 5.times { |i| obj.large(i) } + + expect(obj.memo_count(:small)).to eq(2) + expect(obj.memo_count(:large)).to eq(5) + end + end + + context "copy_on_read default" do + it "applies copy_on_read to all memoized methods on the class" do + klass = Class.new do + prepend SafeMemoize + + safe_memoize_options copy_on_read: true + + def data = [1, 2, 3] + memoize :data + end + + obj = klass.new + r1 = obj.data + r2 = obj.data + expect(r1).to eq(r2) + expect(r1).not_to be(r2) + end + end + + context "global config fallback" do + it "class defaults take precedence over global config" do + SafeMemoize.configure { |c| c.default_max_size = 10 } + + klass = Class.new do + prepend SafeMemoize + + safe_memoize_options max_size: 2 + + def fetch(n) = n + memoize :fetch + end + + obj = klass.new + 5.times { |i| obj.fetch(i) } + expect(obj.memo_count(:fetch)).to eq(2) + ensure + SafeMemoize.reset_configuration! + end + + it "global config still applies when no class default is set for that option" do + SafeMemoize.configure { |c| c.default_max_size = 2 } + + klass = Class.new do + prepend SafeMemoize + + def fetch(n) = n + memoize :fetch + end + + obj = klass.new + 5.times { |i| obj.fetch(i) } + expect(obj.memo_count(:fetch)).to eq(2) + ensure + SafeMemoize.reset_configuration! + end + end + + context "clearing class defaults" do + it "clears all defaults when called with no arguments" do + klass = Class.new do + prepend SafeMemoize + + safe_memoize_options ttl: 0.01 + + def a = rand + memoize :a + end + + klass.safe_memoize_options + + klass.class_eval do + def b = rand + memoize :b + end + + obj = klass.new + b_first = obj.b + sleep 0.02 + expect(obj.b).to eq(b_first) + end + end + + context "disallowed options" do + it "raises ArgumentError for :shared" do + expect do + Class.new do + prepend SafeMemoize + + safe_memoize_options shared: true + end + end.to raise_error(ArgumentError, /:shared/) + end + + it "raises ArgumentError for :fiber_local" do + expect do + Class.new do + prepend SafeMemoize + + safe_memoize_options fiber_local: true + end + end.to raise_error(ArgumentError, /:fiber_local/) + end + + it "raises ArgumentError for :ractor_safe" do + expect do + Class.new do + prepend SafeMemoize + + safe_memoize_options ractor_safe: true + end + end.to raise_error(ArgumentError, /:ractor_safe/) + end + + it "raises ArgumentError for :shared_cache" do + expect do + Class.new do + prepend SafeMemoize + + safe_memoize_options shared_cache: "my_cache" + end + end.to raise_error(ArgumentError, /:shared_cache/) + end + end + end + + describe "copy_on_read: true" do + let(:klass) do + Class.new do + prepend SafeMemoize + + def config = {host: "localhost", port: 8080} + memoize :config, copy_on_read: true + + def tags = %w[a b c] + memoize :tags, copy_on_read: true + + def count = 42 + memoize :count, copy_on_read: true + + def nothing = nil + memoize :nothing, copy_on_read: true + end + end + + it "returns equal values on successive calls" do + obj = klass.new + expect(obj.config).to eq({host: "localhost", port: 8080}) + expect(obj.config).to eq({host: "localhost", port: 8080}) + end + + it "returns a different object identity on every cache hit" do + obj = klass.new + r1 = obj.config + r2 = obj.config + expect(r1).not_to be(r2) + end + + it "prevents caller mutation from corrupting the cache" do + obj = klass.new + result = obj.config + result[:host] = "mutated" + + fresh = obj.config + expect(fresh[:host]).to eq("localhost") + end + + it "returns a different array object on every call" do + obj = klass.new + r1 = obj.tags + r2 = obj.tags + expect(r1).to eq(r2) + expect(r1).not_to be(r2) + end + + it "returns nil as-is (no dup attempted)" do + obj = klass.new + expect(obj.nothing).to be_nil + expect(obj.nothing).to be_nil + end + + it "returns frozen/immediate values as-is" do + obj = klass.new + r1 = obj.count + r2 = obj.count + expect(r1).to eq(42) + expect(r2).to eq(42) + end + + context "with max_size: (locked path)" do + it "still dups on hit through the locked path" do + k = Class.new do + prepend SafeMemoize + + def data = [1, 2, 3] + memoize :data, copy_on_read: true, max_size: 10 + end + + obj = k.new + r1 = obj.data + r2 = obj.data + expect(r1).to eq(r2) + expect(r1).not_to be(r2) + + r2 << 99 + expect(obj.data).to eq([1, 2, 3]) + end + end + + context "with shared: true" do + it "still dups on hit through the shared path" do + k = Class.new do + prepend SafeMemoize + + def shared_data = [10, 20] + memoize :shared_data, copy_on_read: true, shared: true + end + + obj = k.new + r1 = obj.shared_data + r2 = obj.shared_data + expect(r1).to eq(r2) + expect(r1).not_to be(r2) + + r1 << 99 + expect(k.new.shared_data).to eq([10, 20]) + end + end + + context "with ttl:" do + it "returns a dup on every hit, including after a ttl refresh" do + k = Class.new do + prepend SafeMemoize + + def items = %w[x y] + memoize :items, copy_on_read: true, ttl: 60 + end + + obj = k.new + r1 = obj.items + r2 = obj.items + expect(r1).to eq(r2) + expect(r1).not_to be(r2) + end + end + + context "with deep_dup available" do + it "calls deep_dup when the value responds to it" do + inner = Object.new + deep_copy = Object.new + allow(inner).to receive(:respond_to?).with(:deep_dup).and_return(true) + allow(inner).to receive(:respond_to?).with(anything).and_call_original + allow(inner).to receive(:deep_dup).and_return(deep_copy) + allow(inner).to receive(:frozen?).and_return(false) + + k = Class.new do + prepend SafeMemoize + + define_method(:wrapped) { inner } + memoize :wrapped, copy_on_read: true + end + + obj = k.new + obj.wrapped + result = obj.wrapped + expect(result).to be(deep_copy) + end + end + + context "ractor_safe: incompatibility" do + it "raises ArgumentError when combined with ractor_safe:" do + expect do + Class.new do + prepend SafeMemoize + + def value = 42 + memoize :value, shared: true, ractor_safe: true, copy_on_read: true + end + end.to raise_error(ArgumentError, /copy_on_read/) + end + end + end end