strict_lazy 0.3.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6e9db46fd9beb35a23ac710e80c9ae7fddebdc576e399bcc4c249bc22a8acb7b
4
+ data.tar.gz: 3eb3eb6e31b616914a06accca417608e62919d20b0ec8768f12d538fd0874479
5
+ SHA512:
6
+ metadata.gz: 34d9415e1488ca61c7a8476cf372fd14e52aba5c6e67b33a863a15d9ee05386dc406b143648ef0278427938d9eb6d61c2e7691cecadf4487aa6b62d3febd0a91
7
+ data.tar.gz: d808ab15745e130af97ad8ba0dca0c5189e8e274fdb0a5455dda2cd64378f172faf77c99b5548080e82f17b92d15e1d4fa359520d8ed36c6d2e062a0a02f6986
data/CHANGELOG.md ADDED
@@ -0,0 +1,54 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.3.0] - 2026-06-14
11
+
12
+ ### Added
13
+
14
+ - `StrictLazy.with_violation(mode) { ... }` — scope the violation policy to a
15
+ block, restoring the previous state afterward (exception-safe). Overrides
16
+ nest and are isolated per Fiber/Thread, so parallel test processes never
17
+ interfere. Useful for relaxing the policy per test type, e.g. `:ignore` in
18
+ model specs while keeping `:raise` in system specs.
19
+ - `StrictLazy.violation=` and `with_violation` now raise `ArgumentError` for
20
+ modes other than `:raise` / `:log` / `:ignore`.
21
+
22
+ ### Changed
23
+
24
+ - `StrictLazy.violation` is now a reader that returns the **effective** policy
25
+ for the current execution context (the innermost `with_violation` override,
26
+ else the global baseline). The global baseline moved to
27
+ `StrictLazy.default_violation`; `StrictLazy.violation=` still sets it, so the
28
+ public API and the Railtie are unchanged.
29
+
30
+ ## [0.2.0] - 2026-06-14
31
+
32
+ ### Added
33
+
34
+ - Support predicate reader names (`lazy_load :published?`), read via
35
+ `record.lazy.published?`. A reader must be a bare name or a `?` predicate; the
36
+ read-only `.lazy` namespace rejects setter (`=`), bang (`!`), and operator
37
+ reader names at declaration time.
38
+
39
+ ## [0.1.0] - 2026-06-07
40
+
41
+ ### Added
42
+
43
+ - Initial release.
44
+ - `include StrictLazy` concern with `lazy_load` declarations (`from:` or block, xor).
45
+ - `StrictLazy.preload(records, *readers)` with eager (`sync: true`) and lazy resolution.
46
+ - `.lazy` namespace access (`record.lazy.x`) that never grows bare methods.
47
+ - Strict detection via `StrictLazy.violation` (`:raise` / `:log` / `:ignore`),
48
+ defaulting to `:raise` in development/test and `:ignore` in production through a Railtie.
49
+ - Per-record callable `default:` for unfulfilled records.
50
+
51
+ [Unreleased]: https://github.com/aki77/strict_lazy/compare/v0.3.0...HEAD
52
+ [0.3.0]: https://github.com/aki77/strict_lazy/compare/v0.2.0...v0.3.0
53
+ [0.2.0]: https://github.com/aki77/strict_lazy/compare/v0.1.0...v0.2.0
54
+ [0.1.0]: https://github.com/aki77/strict_lazy/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 aki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,201 @@
1
+ # StrictLazy
2
+
3
+ Strict, explicit preloading for **computed values** — values that `includes` /
4
+ `preload` cannot express (external APIs, window functions, cross-table
5
+ aggregates). `strict_lazy` applies the spirit of Rails' `strict_loading` to those
6
+ values: it forces you to preload them explicitly in the controller, and **raises
7
+ in development/test** if you read one without preloading — instead of silently
8
+ falling back to N+1.
9
+
10
+ No `batch-loader` / `N1Loader` / `ar_lazy_preload` dependency. Just
11
+ `activesupport`.
12
+
13
+ ## Why
14
+
15
+ A naive view helper can't "register before the first access", so it quietly
16
+ degrades to N+1. Auto-batching gems fix the N+1 but hide the missing preload.
17
+ `strict_lazy` takes the opposite stance — **make the preload mandatory and make
18
+ forgetting it loud** — so every query stays in the controller and view rendering
19
+ issues no hidden queries.
20
+
21
+ | Tool | Target | On unloaded access |
22
+ | --- | --- | --- |
23
+ | `includes` / `preload` | associations only | lazy load (can N+1) |
24
+ | `strict_loading` (Rails) | associations only | **raise** |
25
+ | batch-loader | general | auto-batch, no detection |
26
+ | N1Loader (+ar_lazy_preload) | computed values | auto-batch; plain setup silently N+1s |
27
+ | **`strict_lazy`** | **computed values** | **raise (immediate detection)** |
28
+
29
+ ## Installation
30
+
31
+ ```ruby
32
+ gem "strict_lazy"
33
+ ```
34
+
35
+ ## Quick start
36
+
37
+ Define a resolver, declare the value with `lazy_load`, preload in the
38
+ controller, and read via `.lazy`.
39
+
40
+ ```ruby
41
+ class Post < ApplicationRecord
42
+ include StrictLazy
43
+
44
+ # Block resolver: receives (records, loader); call loader.call(record, value)
45
+ # for each record you fulfill. Posts with zero comments never appear in the
46
+ # GROUP BY, so they fall back to default: 0.
47
+ lazy_load :comments_count, default: 0 do |posts, loader|
48
+ by_id = posts.index_by(&:id)
49
+ Comment.where(post_id: by_id.keys).group(:post_id).count.each do |post_id, n|
50
+ loader.call(by_id[post_id], n)
51
+ end
52
+ end
53
+
54
+ # from: resolver — a named class method, defined BEFORE the lazy_load. Good for
55
+ # complex/reusable resolvers; dedup FKs yourself.
56
+ def self.resolve_avatar(posts, loader)
57
+ urls = AvatarService.bulk_fetch(posts.map(&:author_id).uniq)
58
+ posts.each { |p| loader.call(p, urls[p.author_id]) }
59
+ end
60
+ lazy_load :avatar, from: :resolve_avatar, sync: true
61
+ end
62
+ ```
63
+
64
+ ```ruby
65
+ # controller
66
+ @posts = Post.recent.to_a
67
+ StrictLazy.preload(@posts) # all declared loaders
68
+ # StrictLazy.preload(@posts, :avatar) # or just some
69
+ ```
70
+
71
+ ```erb
72
+ <%= post.lazy.comments_count %>
73
+ <img src="<%= post.lazy.avatar %>">
74
+ ```
75
+
76
+ ## Eager vs lazy (`sync:`)
77
+
78
+ - `sync: false` (default): resolution is deferred to the first `.lazy` read, then
79
+ the whole preloaded group is resolved in one shot and memoized.
80
+ - `sync: true`: resolved eagerly at `StrictLazy.preload` time.
81
+
82
+ ## Strict detection & `violation`
83
+
84
+ Reading a value that was never preloaded triggers the `violation` policy:
85
+
86
+ | mode | behavior |
87
+ | --- | --- |
88
+ | `:raise` | raise `StrictLazy::UnloadedError` (no wasted query) |
89
+ | `:log` | `Rails.logger.warn`, then degrade to a single-record resolve |
90
+ | `:ignore` | silently degrade to a single-record resolve (N+1) |
91
+
92
+ Environment defaults (via the Railtie):
93
+
94
+ - development / test → `:raise`
95
+ - production → `:ignore`
96
+
97
+ Override globally with `StrictLazy.violation = :log`, or in Rails with
98
+ `config.strict_lazy.violation = :log`.
99
+
100
+ ### Scoped overrides — `with_violation`
101
+
102
+ `StrictLazy.with_violation(mode) { ... }` overrides the effective policy for the
103
+ duration of the block, then restores the previous state — even if the block
104
+ raises. Overrides nest (an inner call shadows the outer one) and are scoped to
105
+ the current Fiber/Thread, so parallel test processes never interfere.
106
+
107
+ ```ruby
108
+ StrictLazy.with_violation(:ignore) do
109
+ record.lazy.x # degrades to a single-record resolve instead of raising
110
+ end
111
+ ```
112
+
113
+ The three APIs relate as: `StrictLazy.violation=` sets the global **baseline**,
114
+ `with_violation` applies a **scoped** override, and the `StrictLazy.violation`
115
+ reader returns the **effective** value (innermost override, else the baseline).
116
+
117
+ ### Per-test policy in RSpec
118
+
119
+ `strict_lazy` ships no implicit RSpec hook — wire it up explicitly so the policy
120
+ is visible where it applies. A common setup: model specs don't need preloads
121
+ (`:ignore`), while system/request specs keep the strict baseline (`:raise`).
122
+
123
+ ```ruby
124
+ # spec/rails_helper.rb
125
+ RSpec.configure do |config|
126
+ config.around(:each, type: :model) do |example|
127
+ StrictLazy.with_violation(:ignore) { example.run }
128
+ end
129
+ end
130
+ ```
131
+
132
+ To relax only a few examples, drive the `around` off a tag instead:
133
+
134
+ ```ruby
135
+ RSpec.configure do |config|
136
+ config.around(:each, :ignore_lazy) do |example|
137
+ StrictLazy.with_violation(:ignore) { example.run }
138
+ end
139
+ end
140
+
141
+ it "computes something", :ignore_lazy do
142
+ # ...
143
+ end
144
+ ```
145
+
146
+ ## Defaults
147
+
148
+ `default:` is written for any record the resolver does not fulfill. Pass a
149
+ **callable** for per-record defaults so mutable values are never shared:
150
+
151
+ ```ruby
152
+ lazy_load :tags, default: -> { [] } do |records, loader| ... end # arity 0
153
+ lazy_load :slug, default: ->(record) { "p-#{record.id}" } do ... end # arity 1
154
+ ```
155
+
156
+ A static value (`default: 0`) is written as-is.
157
+
158
+ ## Design notes
159
+
160
+ - **Preload is mandatory.** Detection happens on first access; dev/test raise so
161
+ you catch it immediately.
162
+ - **Resolvers are set-level.** A resolver runs over the whole group, not one
163
+ record. FK dedup/mapping is the resolver's responsibility (there is no `key:`).
164
+ - **Scope the records.** `preload` should cover the collection once; the same
165
+ loader preloaded on overlapping groups resolves more than once.
166
+ - **Definition order (for `from:`).** Define the referenced class method before
167
+ the `lazy_load` declaration. Block resolvers have no such constraint.
168
+ - Values live on the record (`@_lazy_<reader>`) and are GC'd with the request —
169
+ no thread-local cache, no middleware.
170
+ - **Predicate readers.** `lazy_load :published?` is supported and read as
171
+ `record.lazy.published?`; the `?` is encoded in the ivar (`@_lazy_published_pred`)
172
+ so it does not collide with a plain `published` reader. A reader must be a bare
173
+ name or a `?` predicate — the read-only `.lazy` namespace rejects setter (`=`),
174
+ bang (`!`), and operator reader names at declaration time.
175
+
176
+ ## Non-Rails usage
177
+
178
+ Works without Rails: `include StrictLazy`, declare with `lazy_load`, call
179
+ `StrictLazy.preload(records)`, read via `record.lazy.x`. Set the policy yourself
180
+ with `StrictLazy.violation = :raise` (the default).
181
+
182
+ ## Where it fits
183
+
184
+ Use `includes` / `strict_loading` for **associations**, `bullet` to detect N+1
185
+ in associations, and `strict_lazy` for **computed values** you want preloaded
186
+ explicitly and checked strictly.
187
+
188
+ ## Development
189
+
190
+ After checking out the repo, run `bin/setup`. Then `bundle exec rake` runs specs
191
+ and RuboCop. `bundle exec appraisal install && bundle exec appraisal rake spec`
192
+ runs the full Rails matrix.
193
+
194
+ ## Contributing
195
+
196
+ Bug reports and pull requests are welcome on GitHub at
197
+ https://github.com/aki77/strict_lazy.
198
+
199
+ ## License
200
+
201
+ Available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StrictLazy
4
+ # A single +StrictLazy.preload+ × single loader unit of work, shared by every
5
+ # record in the group via +@_batch_<reader>+. The resolver runs exactly once;
6
+ # values are written straight onto each record's +@_lazy_<reader>+ ivar, so the
7
+ # batch keeps no intermediate Hash and never relies on record hash-equality
8
+ # (unsaved records work fine).
9
+ class Batch
10
+ def initialize(model, records, loader)
11
+ @model = model
12
+ @records = records
13
+ @loader = loader
14
+ @resolved = false
15
+ end
16
+
17
+ # Resolve the value for one record, resolving the whole group on first touch.
18
+ def value_for(record)
19
+ resolve! unless @resolved
20
+ record.instance_variable_get(@loader.value_ivar)
21
+ end
22
+
23
+ # Run the resolver once and write defaults for any record it skipped.
24
+ def resolve!
25
+ return if @resolved
26
+
27
+ @resolved = true
28
+ fulfilled = {}.compare_by_identity
29
+ fulfill = lambda do |record, value|
30
+ fulfilled[record] = true
31
+ record.instance_variable_set(@loader.value_ivar, value)
32
+ end
33
+
34
+ @loader.resolve(@model, @records, fulfill)
35
+
36
+ @records.each do |record|
37
+ next if fulfilled.key?(record)
38
+
39
+ record.instance_variable_set(@loader.value_ivar, @loader.default_for(record))
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StrictLazy
4
+ # Base error for the gem.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when a lazy value is read without a preceding StrictLazy.preload
8
+ # while +violation+ is +:raise+ (the default in development/test).
9
+ class UnloadedError < Error; end
10
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StrictLazy
4
+ # The +.lazy+ namespace. Reading +record.lazy.x+ resolves a declared lazy
5
+ # value without ever growing a bare +x+ method on the record itself.
6
+ #
7
+ # Read order:
8
+ # 1. +@_lazy_<reader>+ already set -> return it (resolved / eager / group-resolved)
9
+ # 2. +@_batch_<reader>+ present -> resolve the whole group once, return it
10
+ # 3. no batch and violation :raise -> UnloadedError (no wasted query)
11
+ # 4. no batch and non-strict -> degraded single-record resolve (fallback)
12
+ class Facade
13
+ def initialize(record)
14
+ @record = record
15
+ end
16
+
17
+ def respond_to_missing?(name, include_private = false)
18
+ loaders.key?(name) || super
19
+ end
20
+
21
+ def method_missing(name, *args)
22
+ loader = loaders[name]
23
+ return super unless loader
24
+
25
+ return @record.instance_variable_get(loader.value_ivar) if @record.instance_variable_defined?(loader.value_ivar)
26
+
27
+ batch = @record.instance_variable_get(loader.batch_ivar)
28
+ return batch.value_for(@record) if batch
29
+
30
+ unloaded(loader)
31
+ end
32
+
33
+ private
34
+
35
+ def loaders
36
+ @record.class.lazy_loaders
37
+ end
38
+
39
+ # No preceding preload reached this record. Consult the effective policy
40
+ # (a with_violation override, else the global baseline).
41
+ def unloaded(loader)
42
+ case StrictLazy.violation
43
+ when :raise
44
+ raise UnloadedError, "#{@record.class}##{loader.reader} was read without a preceding " \
45
+ "StrictLazy.preload. Add it to the controller, or set " \
46
+ "StrictLazy.violation to :log/:ignore."
47
+ when :log
48
+ logger&.warn("[StrictLazy] #{@record.class}##{loader.reader} read without preload (degraded to N+1)")
49
+ end
50
+
51
+ # :log and :ignore fall through to a degraded single-record resolve.
52
+ Batch.new(@record.class, [@record], loader).value_for(@record)
53
+ end
54
+
55
+ def logger
56
+ defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : nil
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StrictLazy
4
+ # Immutable definition produced by a +lazy_load+ declaration.
5
+ #
6
+ # It holds the reader name, the resolver (a +from:+ method symbol or a block),
7
+ # whether resolution is eager (+sync:+), and the +default+ used for records the
8
+ # resolver does not fulfill.
9
+ #
10
+ # Resolution always goes through +#resolve+, which is handed the model class so
11
+ # a +from:+ symbol can be dispatched as a class method. A block resolver is
12
+ # +instance_exec+'d on the model class for the same lookup semantics.
13
+ class Loader
14
+ # Internal ivar names are reserved per reader: +@_lazy_<reader>+ holds the
15
+ # resolved value, +@_batch_<reader>+ holds the shared Batch reference.
16
+ attr_reader :reader, :sync, :default
17
+
18
+ def initialize(reader:, sync:, default:, from: nil, block: nil)
19
+ @reader = reader
20
+ @sync = sync
21
+ @default = default
22
+ @from = from
23
+ @block = block
24
+ # The ivar name may not contain `?`, so a predicate reader is encoded
25
+ # (not stripped): +commented?+ and +commented+ get distinct ivars. (A bare
26
+ # reader literally named +commented_pred+ would collide, but reader names
27
+ # are validated to a bare-name/`?` form, making that pairing a non-idiom.)
28
+ ivar_key = reader.to_s.sub(/\?\z/, "_pred")
29
+ @value_ivar = :"@_lazy_#{ivar_key}"
30
+ @batch_ivar = :"@_batch_#{ivar_key}"
31
+ end
32
+
33
+ def sync? = @sync
34
+
35
+ attr_reader :value_ivar, :batch_ivar
36
+
37
+ # Invoke the resolver once over +records+. +loader+ is the
38
+ # +loader.call(record, value)+ callable supplied by the Batch.
39
+ def resolve(model, records, loader)
40
+ if @from
41
+ model.public_send(@from, records, loader)
42
+ else
43
+ model.instance_exec(records, loader, &@block)
44
+ end
45
+ end
46
+
47
+ # Compute the default for a record the resolver did not fulfill.
48
+ # A callable default acts as a per-record factory so mutable values
49
+ # (+[]+, +{}+) are never shared; arity 1 receives the record.
50
+ def default_for(record)
51
+ return @default unless @default.respond_to?(:call)
52
+
53
+ @default.arity.zero? ? @default.call : @default.call(record)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StrictLazy
4
+ # Sets the environment-appropriate +violation+ default: +:raise+ in
5
+ # development/test (catch missing preloads), +:ignore+ in production
6
+ # (degrade to N+1 rather than crash). Override with
7
+ # +config.strict_lazy.violation+. No middleware: values and batches live on
8
+ # the records and are GC'd with the request.
9
+ class Railtie < Rails::Railtie
10
+ config.strict_lazy = ActiveSupport::OrderedOptions.new
11
+
12
+ initializer "strict_lazy.set_violation" do |app|
13
+ default = Rails.env.production? ? :ignore : :raise
14
+ StrictLazy.violation = app.config.strict_lazy.violation || default
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StrictLazy
4
+ VERSION = "0.3.0"
5
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/concern"
5
+ require "active_support/core_ext/class/attribute"
6
+ require "active_support/core_ext/module/attribute_accessors_per_thread"
7
+
8
+ require_relative "strict_lazy/version"
9
+ require_relative "strict_lazy/errors"
10
+ require_relative "strict_lazy/loader"
11
+ require_relative "strict_lazy/batch"
12
+ require_relative "strict_lazy/facade"
13
+
14
+ # strict_lazy applies the spirit of Rails' +strict_loading+ to computed values.
15
+ # Include it in a model, declare values with +lazy_load+, prepare them in the
16
+ # controller with +StrictLazy.preload+, and read them via +record.lazy.x+.
17
+ # Reading without a preceding preload raises in development/test.
18
+ module StrictLazy
19
+ extend ActiveSupport::Concern
20
+
21
+ # The baseline policy, set globally (StrictLazy.violation=) or by the Railtie
22
+ # from the environment. with_violation overrides it for the current execution
23
+ # context only; the +violation+ reader returns the override-aware effective
24
+ # value. :raise (dev/test default), :log, or :ignore.
25
+ mattr_accessor :default_violation, default: :raise
26
+
27
+ # Fiber/Thread-local override stack for with_violation. thread_mattr_accessor
28
+ # is the public API (it honors config.active_support.isolation_level and
29
+ # isolates parallel test processes); we avoid the :nodoc:
30
+ # ActiveSupport::IsolatedExecutionState. Each thread starts at nil, so readers
31
+ # guard with `|| []`.
32
+ thread_mattr_accessor :violation_overrides, instance_accessor: false
33
+
34
+ # The accepted violation policies.
35
+ VALID_VIOLATIONS = %i[raise log ignore].freeze
36
+
37
+ # A valid reader is a bare name, optionally a `?` predicate. Setter (`=`),
38
+ # bang (`!`), and operator readers are rejected: the `.lazy` namespace is
39
+ # read-only, and any other form has no valid ivar to back it.
40
+ READER_FORMAT = /\A[A-Za-z_][A-Za-z0-9_]*\??\z/
41
+
42
+ included do
43
+ # Inherited by STI subclasses; merged (not mutated) on each declaration.
44
+ class_attribute :lazy_loaders, instance_writer: false, default: {}
45
+ end
46
+
47
+ class_methods do
48
+ # Declare a lazy-loaded value.
49
+ #
50
+ # lazy_load :comments_count, default: 0 do |posts, loader|
51
+ # ...
52
+ # end
53
+ #
54
+ # lazy_load :avatar, from: :resolve_avatar, sync: true
55
+ #
56
+ # Exactly one of +from:+ or a block is required (xor). +sync: true+ resolves
57
+ # eagerly at preload time; otherwise resolution is deferred to first read.
58
+ # +default+ is written for records the resolver does not fulfill; pass a
59
+ # callable for per-record (e.g. mutable) defaults.
60
+ def lazy_load(reader, from: nil, sync: false, default: nil, &block)
61
+ validate_lazy_load!(reader, from, block)
62
+
63
+ loader = Loader.new(reader: reader, sync: sync, default: default, from: from, block: block)
64
+ self.lazy_loaders = lazy_loaders.merge(reader => loader)
65
+ end
66
+
67
+ private
68
+
69
+ def validate_lazy_load!(reader, from, block)
70
+ raise ArgumentError, "lazy_load #{reader.inspect}: pass either `from:` or a block, not both" if from && block
71
+ raise ArgumentError, "lazy_load #{reader.inspect}: pass either `from:` or a block" unless from || block
72
+ unless READER_FORMAT.match?(reader.to_s)
73
+ raise ArgumentError, "lazy_load #{reader.inspect}: reader must be a bare name or a `?` predicate; " \
74
+ "the `.lazy` namespace is read-only (no setters, bang, or operator readers)"
75
+ end
76
+
77
+ validate_from_defined!(reader, from)
78
+ end
79
+
80
+ def validate_from_defined!(reader, from)
81
+ return if from.nil? || respond_to?(from)
82
+
83
+ raise ArgumentError, "lazy_load #{reader.inspect}: `from: #{from.inspect}` is not defined on #{name}. " \
84
+ "Define the class method before the lazy_load declaration."
85
+ end
86
+ end
87
+
88
+ # The +.lazy+ namespace facade (memoized per record).
89
+ def lazy
90
+ @_lazy_facade ||= Facade.new(self)
91
+ end
92
+
93
+ # The effective policy for the current execution context: the innermost
94
+ # with_violation override if any, otherwise the global baseline. The facade
95
+ # consults this — never read +default_violation+ directly.
96
+ def self.violation
97
+ (violation_overrides || []).last || default_violation
98
+ end
99
+
100
+ # Backward-compatible global setter. Sets the baseline only; it does not touch
101
+ # any active with_violation override. Existing callers (and the Railtie) keep
102
+ # working unchanged.
103
+ def self.violation=(mode)
104
+ self.default_violation = validate_violation!(mode)
105
+ end
106
+
107
+ # Run the block with +mode+ as the effective policy, restoring the previous
108
+ # state afterward (exception-safe). Overrides nest; an inner call shadows an
109
+ # outer one and unwinds cleanly. Scoped to the current Fiber/Thread, so
110
+ # parallel test processes never interfere.
111
+ #
112
+ # StrictLazy.with_violation(:ignore) { ... } # never raises inside
113
+ def self.with_violation(mode)
114
+ # Validate first: an invalid mode raises here, before the stack is touched,
115
+ # so the begin/ensure only ever runs against a successfully pushed frame.
116
+ validated = validate_violation!(mode)
117
+ begin
118
+ pushed = (violation_overrides || []) + [validated]
119
+ self.violation_overrides = pushed
120
+ yield
121
+ ensure
122
+ self.violation_overrides = pushed[0...-1]
123
+ end
124
+ end
125
+
126
+ def self.validate_violation!(mode)
127
+ return mode if VALID_VIOLATIONS.include?(mode)
128
+
129
+ raise ArgumentError, "StrictLazy violation must be one of #{VALID_VIOLATIONS.inspect}, got #{mode.inspect}"
130
+ end
131
+ private_class_method :validate_violation!
132
+
133
+ # Prepare lazy values for a group of records. With no readers, prepares every
134
+ # declared loader. +sync: true+ loaders resolve immediately; others on first read.
135
+ def self.preload(records, *readers)
136
+ records = Array(records)
137
+ return records if records.empty?
138
+
139
+ model = records.first.class
140
+ loaders_for(model, readers).each do |loader|
141
+ batch = Batch.new(model, records, loader)
142
+ records.each { |record| record.instance_variable_set(loader.batch_ivar, batch) }
143
+ batch.resolve! if loader.sync?
144
+ end
145
+
146
+ records
147
+ end
148
+
149
+ def self.loaders_for(model, readers)
150
+ all = model.lazy_loaders
151
+ readers.empty? ? all.values : readers.map { |r| all.fetch(r) }
152
+ end
153
+ private_class_method :loaders_for
154
+ end
155
+
156
+ require_relative "strict_lazy/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,4 @@
1
+ module StrictLazy
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
@@ -0,0 +1,82 @@
1
+ ---
2
+ name: strict-lazy
3
+ description: "preload/includes では表現できない「計算値」(外部API・ウィンドウ関数・クロステーブル集計) の N+1 を、明示プリロード必須+dev/test で未プリロード読み取りを raise して潰す strict_lazy gem の使い方。使う: (1) 一覧の N+1 を直す依頼で原因が association でなく集計/外部API/計算メソッドのとき、(2) そうした値の事前読み込みを実装するとき。association で表現できる N+1 は対象外 (includes/preload/strict_loading を使う)。"
4
+ ---
5
+
6
+ # strict_lazy
7
+
8
+ `strict_loading` を **計算値** に持ち込む gem。association で表現できない値をコントローラで明示プリロードさせ、未プリロード読み取りを dev/test で raise する。依存は `activesupport` のみ。
9
+
10
+ ## まず適用可否を判断する
11
+
12
+ その値が `belongs_to`/`has_many` で表現できるなら strict_lazy ではなく標準解を使う。誤用すると無駄な複雑さが増える。
13
+
14
+ | N+1 の原因 | 使う道具 |
15
+ | --- | --- |
16
+ | association を辿る | `includes` / `preload` / `eager_load` |
17
+ | association の未ロード検出 | `strict_loading` / `bullet` |
18
+ | **association で表現できない計算値** | **strict_lazy** |
19
+
20
+ strict_lazy が向くのは preload で書けない値のみ:
21
+ - 外部API: `Svc.bulk_fetch(ids)` のようにまとめて引けるもの
22
+ - ウィンドウ関数・生SQL: `ROW_NUMBER() OVER (...)` など
23
+ - クロステーブル集計: `group(:post_id).count` など(`counter_cache` で済むならそちらを優先)
24
+
25
+ ## 使い方(4点セット、欠けると動かない)
26
+
27
+ ### 1. モデルで宣言
28
+
29
+ リゾルバは **ブロック** か **`from:`(クラスメソッド名)** の排他。どちらも `(records, loader)` を受け、解決できた各レコードで `loader.call(record, value)` を呼ぶ。**グループ全体に1回だけ走る**ので、ここで1クエリ/1API にまとめる。
30
+
31
+ ```ruby
32
+ class Post < ApplicationRecord
33
+ include StrictLazy
34
+
35
+ lazy_load :comments_count, default: 0 do |posts, loader|
36
+ by_id = posts.index_by(&:id)
37
+ Comment.where(post_id: by_id.keys).group(:post_id).count.each do |post_id, n|
38
+ loader.call(by_id[post_id], n)
39
+ end
40
+ end
41
+
42
+ # from: は lazy_load より前に定義。FK 重複排除はリゾルバの責任(key: は無い)。
43
+ def self.resolve_avatar(posts, loader)
44
+ urls = AvatarService.bulk_fetch(posts.map(&:author_id).compact.uniq)
45
+ posts.each { |p| loader.call(p, urls[p.author_id]) }
46
+ end
47
+ lazy_load :avatar, from: :resolve_avatar, sync: true
48
+ end
49
+ ```
50
+
51
+ `loader.call` されなかったレコードには `default:` が入る(GROUP BY に出ない 0件 Post が `default: 0` になる仕組み)。
52
+
53
+ ### 2. コントローラでプリロード
54
+
55
+ ```ruby
56
+ @posts = Post.recent # ActiveRecord::Relation のままでよい(.to_a 不要)
57
+ StrictLazy.preload(@posts) # 全ローダー。preload(@posts, :avatar) で一部のみ
58
+ ```
59
+
60
+ ビューで読むコレクションと一致させる。`sync: false`(既定)は初回 `.lazy` 読みで一括解決&メモ化、`sync: true` は preload 時点で即解決。
61
+
62
+ ### 3. ビューで `.lazy.` 経由で読む
63
+
64
+ ```erb
65
+ <%= post.lazy.comments_count %>
66
+ ```
67
+
68
+ 素のメソッドは生えない。必ず `.lazy.` を挟む。
69
+
70
+ ### 4. プリロード忘れは raise
71
+
72
+ 未プリロード読み取り時の `violation`: `:raise`(既定 dev/test、無駄クエリなし)/ `:log`(warn 後 1件解決)/ `:ignore`(既定 prod、静かに 1件解決=N+1)。上書きは `StrictLazy.violation=` か `config.strict_lazy.violation`。
73
+
74
+ ## 落とし穴
75
+
76
+ - `.lazy.` 付け忘れ → 普通のメソッド/カラムを読みプリロードが効かない
77
+ - `from:` を `lazy_load` より後に定義 → 宣言時に raise(ブロックは制約なし)
78
+ - リゾルバを1件ずつ書く → N+1 が消えない。`records` 全体を1回でまとめる
79
+ - `default: []` のような mutable 共有 → callable にする: `-> { [] }`(arity 0)/ `->(r) { ... }`(arity 1)。静的値はそのまま
80
+ - preload グループとビューのコレクションがずれる → 未プリロードのレコードが raise
81
+
82
+ 詳細な引数・挙動は [references/api.md](references/api.md)。
@@ -0,0 +1,82 @@
1
+ ---
2
+ name: strict-lazy
3
+ description: "How to use the strict_lazy gem to eliminate N+1 for 'computed values' (external APIs, window functions, cross-table aggregations) that cannot be expressed with preload/includes — by requiring explicit preloads and raising on unpreloaded reads in dev/test. Use when: (1) fixing N+1 in a list where the cause is aggregation/external API/computed methods rather than associations, (2) implementing pre-loading for such values. Out of scope: N+1 expressible via associations (use includes/preload/strict_loading instead)."
4
+ ---
5
+
6
+ # strict_lazy
7
+
8
+ A gem that brings `strict_loading` to **computed values**. Forces controllers to explicitly preload values that cannot be expressed as associations, and raises on unpreloaded reads in dev/test. Only depends on `activesupport`.
9
+
10
+ ## First: determine applicability
11
+
12
+ If the value can be expressed via `belongs_to`/`has_many`, use the standard solution instead of strict_lazy. Misuse adds unnecessary complexity.
13
+
14
+ | N+1 cause | Tool |
15
+ | --- | --- |
16
+ | Traversing associations | `includes` / `preload` / `eager_load` |
17
+ | Detecting unloaded associations | `strict_loading` / `bullet` |
18
+ | **Computed values not expressible as associations** | **strict_lazy** |
19
+
20
+ strict_lazy is only for values that can't be written with preload:
21
+ - External APIs: things that can be fetched in bulk like `Svc.bulk_fetch(ids)`
22
+ - Window functions / raw SQL: e.g. `ROW_NUMBER() OVER (...)`
23
+ - Cross-table aggregations: e.g. `group(:post_id).count` (prefer `counter_cache` when it suffices)
24
+
25
+ ## Usage (4 required pieces — missing any one breaks it)
26
+
27
+ ### 1. Declare in the model
28
+
29
+ The resolver is either a **block** or **`from:` (class method name)** — mutually exclusive. Both receive `(records, loader)` and call `loader.call(record, value)` for each resolved record. **Runs exactly once for the whole group**, so consolidate into 1 query/1 API call here.
30
+
31
+ ```ruby
32
+ class Post < ApplicationRecord
33
+ include StrictLazy
34
+
35
+ lazy_load :comments_count, default: 0 do |posts, loader|
36
+ by_id = posts.index_by(&:id)
37
+ Comment.where(post_id: by_id.keys).group(:post_id).count.each do |post_id, n|
38
+ loader.call(by_id[post_id], n)
39
+ end
40
+ end
41
+
42
+ # from: must be defined before lazy_load. FK deduplication is the resolver's responsibility (no key: option).
43
+ def self.resolve_avatar(posts, loader)
44
+ urls = AvatarService.bulk_fetch(posts.map(&:author_id).compact.uniq)
45
+ posts.each { |p| loader.call(p, urls[p.author_id]) }
46
+ end
47
+ lazy_load :avatar, from: :resolve_avatar, sync: true
48
+ end
49
+ ```
50
+
51
+ Records not called with `loader.call` receive the `default:` value (how Posts with 0 comments get `default: 0` when absent from GROUP BY results).
52
+
53
+ ### 2. Preload in the controller
54
+
55
+ ```ruby
56
+ @posts = Post.recent # an ActiveRecord::Relation is fine (no .to_a needed)
57
+ StrictLazy.preload(@posts) # all loaders. preload(@posts, :avatar) for a subset
58
+ ```
59
+
60
+ Match the collection you read in the view. `sync: false` (default) resolves lazily on first `.lazy` read and memoizes; `sync: true` resolves immediately at preload time.
61
+
62
+ ### 3. Read via `.lazy.` in the view
63
+
64
+ ```erb
65
+ <%= post.lazy.comments_count %>
66
+ ```
67
+
68
+ No plain method is defined. Always go through `.lazy.`.
69
+
70
+ ### 4. Forgotten preloads raise
71
+
72
+ `violation` on unpreloaded reads: `:raise` (default dev/test — no wasted queries) / `:log` (warn then resolve 1 record) / `:ignore` (default prod — silently resolves 1 record = N+1). Override with `StrictLazy.violation=` or `config.strict_lazy.violation`.
73
+
74
+ ## Pitfalls
75
+
76
+ - Forgetting `.lazy.` → reads a normal method/column, preload has no effect
77
+ - Defining `from:` after `lazy_load` → raises at declaration time (no restriction for blocks)
78
+ - Writing the resolver one record at a time → N+1 persists. Use `records` to consolidate into one call
79
+ - `default: []` and other mutable shared objects → use callable: `-> { [] }` (arity 0) / `->(r) { ... }` (arity 1). Static values are fine as-is
80
+ - Preload group and view collection don't match → unpreloaded records raise
81
+
82
+ For full argument details and behavior, see [references/api.md](references/api.md).
@@ -0,0 +1,195 @@
1
+ # strict_lazy API リファレンス
2
+
3
+ SKILL.md の補足。`lazy_load` の全引数、`StrictLazy.preload`、`violation`、callable default の
4
+ 詳細挙動をまとめる。SKILL.md で全体像をつかんだ上で、引数の細部を確認したいときに読む。
5
+
6
+ ## 目次
7
+
8
+ - [`lazy_load` の宣言](#lazy_load-の宣言)
9
+ - [リゾルバ — ブロック vs `from:`](#リゾルバ--ブロック-vs-from)
10
+ - [`sync:` — 遅延 vs 即時解決](#sync--遅延-vs-即時解決)
11
+ - [`default:` — 未充足レコードの値](#default--未充足レコードの値)
12
+ - [`StrictLazy.preload`](#strictlazypreload)
13
+ - [`record.lazy` の読み取り順序](#recordlazy-の読み取り順序)
14
+ - [`violation` ポリシー](#violation-ポリシー)
15
+ - [ライフサイクルと内部 ivar](#ライフサイクルと内部-ivar)
16
+ - [完全な実装例](#完全な実装例)
17
+
18
+ ## `lazy_load` の宣言
19
+
20
+ ```ruby
21
+ lazy_load(reader, from: nil, sync: false, default: nil, &block)
22
+ ```
23
+
24
+ - `reader` — `.lazy.<reader>` で読む名前 (Symbol)。同名の AR カラムがあっても衝突しない
25
+ (素のメソッドを生やさないため)。
26
+ - `from:` と `&block` は **排他 (xor)**。両方渡す/どちらも渡さないと宣言時に `ArgumentError`。
27
+ - `from:` に渡したメソッドが未定義だと宣言時に `ArgumentError` (「Define the class method
28
+ before the lazy_load declaration.」)。
29
+
30
+ 宣言は `class_attribute :lazy_loaders` にマージされ、**STI サブクラスに継承** される。
31
+
32
+ ## リゾルバ — ブロック vs `from:`
33
+
34
+ どちらも **`(records, loader)` を受け取り、解決できた各レコードについて `loader.call(record, value)`
35
+ を呼ぶ**。リゾルバはグループ全体に対して **1回** 実行される (1レコードずつではない)。
36
+
37
+ ブロックリゾルバはモデルクラス上で `instance_exec` される (= `self` がモデルクラス):
38
+
39
+ ```ruby
40
+ lazy_load :comments_count, default: 0 do |posts, loader|
41
+ by_id = posts.index_by(&:id)
42
+ Comment.where(post_id: by_id.keys).group(:post_id).count.each do |post_id, n|
43
+ loader.call(by_id[post_id], n)
44
+ end
45
+ end
46
+ ```
47
+
48
+ `from:` リゾルバはクラスメソッドとして `public_send` される。複雑/再利用するときに向く。
49
+ **宣言より前に定義** すること:
50
+
51
+ ```ruby
52
+ def self.resolve_avatar(posts, loader)
53
+ urls = AvatarService.bulk_fetch(posts.map(&:author_id).compact.uniq)
54
+ posts.each { |p| loader.call(p, urls[p.author_id]) }
55
+ end
56
+ lazy_load :avatar, from: :resolve_avatar, sync: true
57
+ ```
58
+
59
+ FK の重複排除・ID→値のマッピングは **リゾルバの責任**。gem 側に `key:` のような仕組みはない。
60
+
61
+ ## `sync:` — 遅延 vs 即時解決
62
+
63
+ - `sync: false` (デフォルト) — 最初の `.lazy` 読み取りまで解決を遅延。最初の読み取り時に
64
+ グループ全体を一気に解決してメモ化。一覧で1つも読まれなければクエリは0。
65
+ - `sync: true` — `StrictLazy.preload` の時点で即解決。外部APIを早めに叩く/レスポンス前に
66
+ 確実に取得しておきたいときに使う。
67
+
68
+ どちらも結果はレコードの `@_lazy_<reader>` に乗り、2回目以降の読み取りはクエリ0。
69
+
70
+ ## `default:` — 未充足レコードの値
71
+
72
+ リゾルバが `loader.call` を呼ばなかったレコードに書かれる値。
73
+
74
+ - **静的値** (`default: 0`、`default: "n/a"`) — そのまま全レコードに書かれる。
75
+ - **callable** — レコードごとに呼ばれる **ファクトリ**。mutable な値 (`[]`, `{}`) を共有しないために使う。
76
+ - arity 0: `default: -> { [] }` — 毎回新しいインスタンス。
77
+ - arity 1: `default: ->(record) { "post-#{record.id}" }` — レコードを受け取る。
78
+
79
+ ```ruby
80
+ lazy_load :tags, default: -> { [] } do |records, loader| ... end # 各レコードに別の []
81
+ lazy_load :slug, default: ->(record) { "p-#{record.id}" } do ... end # レコード依存の既定値
82
+ ```
83
+
84
+ `default: []` のように mutable をそのまま渡すと全レコードで同一オブジェクトを共有してしまうので、
85
+ 必ず callable を使う。
86
+
87
+ ## `StrictLazy.preload`
88
+
89
+ ```ruby
90
+ StrictLazy.preload(records, *readers)
91
+ ```
92
+
93
+ - `records` — レコード配列 (単体も `Array()` でラップされる)。`ActiveRecord::Relation` もそのまま渡せる(内部の `Array()` が評価するので `.to_a` 不要)。空なら何もしない。
94
+ - `readers` 省略時は **宣言済みの全ローダー** を準備。指定すると一部だけ。
95
+ - 各ローダーについて `Batch` を作り、全レコードの `@_batch_<reader>` にセット。
96
+ `sync: true` のものはここで即 `resolve!`。
97
+ - モデルは `records.first.class` から決まる。**同一モデルのレコード群** を渡す前提。
98
+
99
+ 注意: 同じローダーを **重複するグループに2回 preload** すると、その分リゾルバが複数回走る。
100
+ ビューで読むコレクションを1回でカバーするように呼ぶ。
101
+
102
+ ### Relation をそのまま渡してよい理由
103
+
104
+ `preload` は各レコードオブジェクトに `@_batch_<reader>` ivar を書き込むため、preload するレコードとビューで読むレコードは **同一オブジェクト** である必要がある。`Relation` はこれを満たす:
105
+
106
+ 1. `Array(relation)` が `relation.to_a` を発火させ、Relation をロードして **結果をキャッシュ** する(`relation.loaded? == true`)。
107
+ 2. ロード済みの同じ `Relation` を再列挙しても(ビューでの `@posts.each` など)、**キャッシュされた同一オブジェクト** が返る ── 再クエリも新規インスタンス生成も起きない。
108
+
109
+ よって `@posts = Post.recent; StrictLazy.preload(@posts)` のあとビューで `@posts.each` すれば、batch ivar が仕込まれたまさにそのオブジェクトを読む。`preload` 内の `Array()` 呼び出しが副作用で Relation のキャッシュを温めるので、明示的な `.to_a` は要らない。
110
+
111
+ 唯一の注意点(上の「ビューで読むコレクションと一致させる」と同じ): ビューで **別の Relation** を評価する ── 例えば `Post.recent` を preload したのにビューで再び `Post.recent` を新たなクエリとして回す ── と、batch ivar を持たない新規オブジェクトが生成され、`violation: :raise` では raise する。preload した Relation は変数に保持して使い回すこと。
112
+
113
+ ## `record.lazy` の読み取り順序
114
+
115
+ `record.lazy.x` の解決は次の順 (`Facade#method_missing`):
116
+
117
+ 1. `@_lazy_<reader>` が既にセット済み → それを返す (解決済み/即時/グループ解決済み)。
118
+ 2. `@_batch_<reader>` がある → グループ全体を1回解決して返す (遅延解決)。
119
+ 3. batch がなく `violation: :raise` → `UnloadedError` (無駄クエリなし)。
120
+ 4. batch がなく非 strict → 1レコード解決に退化 (フォールバック、N+1)。
121
+
122
+ `record.lazy` 自体は `Facade` を1回だけ生成してメモ化 (`@_lazy_facade`)。
123
+ `record.lazy.respond_to?(:x)` は宣言済みローダーを反映する。
124
+
125
+ ## `violation` ポリシー
126
+
127
+ プリロードなしで `.lazy.x` を読んだときの挙動 (上記の 3/4)。
128
+
129
+ | mode | 挙動 |
130
+ | --- | --- |
131
+ | `:raise` | `StrictLazy::UnloadedError` を raise。無駄なクエリは出さない。 |
132
+ | `:log` | `Rails.logger.warn("[StrictLazy] ... read without preload (degraded to N+1)")` の後、1レコード解決。 |
133
+ | `:ignore` | 静かに1レコード解決 (N+1)。 |
134
+
135
+ 設定方法:
136
+
137
+ - グローバル: `StrictLazy.violation = :log`
138
+ - Rails: `config.strict_lazy.violation = :log` (Railtie が初期化時に反映)
139
+ - Railtie の環境デフォルト: production → `:ignore`、それ以外 (development/test) → `:raise`
140
+
141
+ `:raise` の狙いは「開発中にプリロード忘れで即落として気づかせる」、`:ignore` の狙いは
142
+ 「本番ではクラッシュさせず N+1 に退化させて動かし続ける」。
143
+
144
+ ## ライフサイクルと内部 ivar
145
+
146
+ - `@_lazy_<reader>` — 解決済みの値。
147
+ - `@_batch_<reader>` — グループ共有の `Batch` 参照。
148
+ - `@_lazy_facade` — `.lazy` の `Facade` (メモ化)。
149
+
150
+ すべてレコードのインスタンス変数なので、**リクエストと共に GC** される。thread-local キャッシュも
151
+ ミドルウェアもグローバルなレジストリもない。`Batch` は値を各レコードの ivar に直接書くので
152
+ 中間 Hash を持たず、レコードの hash 等価性にも依存しない (= **未保存レコードでも動く**)。
153
+
154
+ ## 完全な実装例
155
+
156
+ ```ruby
157
+ # app/models/post.rb
158
+ class Post < ApplicationRecord
159
+ include StrictLazy
160
+
161
+ # クロステーブル集計 (GROUP BY を1クエリに)
162
+ lazy_load :comments_count, default: 0 do |posts, loader|
163
+ by_id = posts.index_by(&:id)
164
+ Comment.where(post_id: by_id.keys).group(:post_id).count.each do |post_id, n|
165
+ loader.call(by_id[post_id], n)
166
+ end
167
+ end
168
+
169
+ # 外部API (ID をまとめて bulk_fetch、即時解決)
170
+ def self.resolve_avatar(posts, loader)
171
+ urls = AvatarService.bulk_fetch(posts.map(&:author_id).compact.uniq)
172
+ posts.each { |p| loader.call(p, urls[p.author_id]) }
173
+ end
174
+ lazy_load :avatar, from: :resolve_avatar, sync: true
175
+ end
176
+ ```
177
+
178
+ ```ruby
179
+ # app/controllers/posts_controller.rb
180
+ def index
181
+ @posts = Post.recent # ActiveRecord::Relation のままでよい(.to_a 不要)
182
+ StrictLazy.preload(@posts) # comments_count と avatar を準備
183
+ end
184
+ ```
185
+
186
+ ```erb
187
+ <%# app/views/posts/index.html.erb %>
188
+ <% @posts.each do |post| %>
189
+ <span><%= post.lazy.comments_count %></span>
190
+ <img src="<%= post.lazy.avatar %>">
191
+ <% end %>
192
+ ```
193
+
194
+ プリロードを忘れて `post.lazy.comments_count` を読むと、development/test では
195
+ `StrictLazy::UnloadedError` が raise され、コントローラへの `StrictLazy.preload` 追加を促される。
@@ -0,0 +1,182 @@
1
+ # strict_lazy API Reference
2
+
3
+ Supplement to SKILL.md. Covers all `lazy_load` arguments, `StrictLazy.preload`, `violation`, and callable default behavior in detail. Read SKILL.md first for the big picture, then come here to check argument specifics.
4
+
5
+ ## Table of Contents
6
+
7
+ - [`lazy_load` declaration](#lazy_load-declaration)
8
+ - [Resolver — block vs `from:`](#resolver--block-vs-from)
9
+ - [`sync:` — deferred vs immediate resolution](#sync--deferred-vs-immediate-resolution)
10
+ - [`default:` — value for unresolved records](#default--value-for-unresolved-records)
11
+ - [`StrictLazy.preload`](#strictlazypreload)
12
+ - [Read order of `record.lazy`](#read-order-of-recordlazy)
13
+ - [`violation` policy](#violation-policy)
14
+ - [Lifecycle and internal ivars](#lifecycle-and-internal-ivars)
15
+ - [Complete implementation example](#complete-implementation-example)
16
+
17
+ ## `lazy_load` declaration
18
+
19
+ ```ruby
20
+ lazy_load(reader, from: nil, sync: false, default: nil, &block)
21
+ ```
22
+
23
+ - `reader` — the name read via `.lazy.<reader>` (Symbol). No conflict with AR columns of the same name (because no plain method is defined).
24
+ - `from:` and `&block` are **mutually exclusive (xor)**. Passing both or neither raises `ArgumentError` at declaration time.
25
+ - If the method passed to `from:` is undefined, raises `ArgumentError` at declaration time ("Define the class method before the lazy_load declaration.").
26
+
27
+ Declarations are merged into `class_attribute :lazy_loaders` and **inherited by STI subclasses**.
28
+
29
+ ## Resolver — block vs `from:`
30
+
31
+ Both **receive `(records, loader)` and call `loader.call(record, value)` for each resolved record**. The resolver runs **once** for the whole group (not per record).
32
+
33
+ Block resolvers are `instance_exec`'d on the model class (i.e., `self` is the model class):
34
+
35
+ ```ruby
36
+ lazy_load :comments_count, default: 0 do |posts, loader|
37
+ by_id = posts.index_by(&:id)
38
+ Comment.where(post_id: by_id.keys).group(:post_id).count.each do |post_id, n|
39
+ loader.call(by_id[post_id], n)
40
+ end
41
+ end
42
+ ```
43
+
44
+ `from:` resolvers are called as class methods via `public_send`. Better for complex or reusable cases. **Must be defined before the declaration**:
45
+
46
+ ```ruby
47
+ def self.resolve_avatar(posts, loader)
48
+ urls = AvatarService.bulk_fetch(posts.map(&:author_id).compact.uniq)
49
+ posts.each { |p| loader.call(p, urls[p.author_id]) }
50
+ end
51
+ lazy_load :avatar, from: :resolve_avatar, sync: true
52
+ ```
53
+
54
+ FK deduplication and ID→value mapping are **the resolver's responsibility**. The gem has no `key:` mechanism.
55
+
56
+ ## `sync:` — deferred vs immediate resolution
57
+
58
+ - `sync: false` (default) — defers resolution until the first `.lazy` read. At first read, resolves the entire group at once and memoizes. If nothing is read in the list, zero queries.
59
+ - `sync: true` — resolves immediately at `StrictLazy.preload` time. Use when you want to hit an external API early or guarantee retrieval before the response.
60
+
61
+ Either way, the result is stored in the record's `@_lazy_<reader>` ivar; subsequent reads are zero queries.
62
+
63
+ ## `default:` — value for unresolved records
64
+
65
+ The value written to records that `loader.call` was never called for.
66
+
67
+ - **Static values** (`default: 0`, `default: "n/a"`) — written to all records as-is.
68
+ - **Callable** — a **factory** called per record. Use to avoid sharing mutable values (`[]`, `{}`) across records.
69
+ - arity 0: `default: -> { [] }` — new instance every time.
70
+ - arity 1: `default: ->(record) { "post-#{record.id}" }` — receives the record.
71
+
72
+ ```ruby
73
+ lazy_load :tags, default: -> { [] } do |records, loader| ... end # separate [] per record
74
+ lazy_load :slug, default: ->(record) { "p-#{record.id}" } do ... end # record-dependent default
75
+ ```
76
+
77
+ Passing a mutable value directly like `default: []` shares the same object across all records — always use a callable instead.
78
+
79
+ ## `StrictLazy.preload`
80
+
81
+ ```ruby
82
+ StrictLazy.preload(records, *readers)
83
+ ```
84
+
85
+ - `records` — array of records (single records are wrapped with `Array()`). An `ActiveRecord::Relation` can also be passed directly — the internal `Array()` evaluates it (no `.to_a` needed). Does nothing if empty.
86
+ - When `readers` is omitted, **all declared loaders** are prepared. Specify to prepare only a subset.
87
+ - Creates a `Batch` for each loader and sets it on `@_batch_<reader>` for all records. Loaders with `sync: true` are immediately `resolve!`'d here.
88
+ - The model is determined from `records.first.class`. Assumes a **group of records of the same model**.
89
+
90
+ Note: **preloading the same loader for overlapping groups twice** causes the resolver to run multiple times. Call it once to cover the collection you read in the view.
91
+
92
+ ### Why passing a `Relation` directly works
93
+
94
+ `preload` writes the `@_batch_<reader>` ivar onto each record object, so the records you preload and the records you read in the view must be the **same objects**. A `Relation` satisfies this:
95
+
96
+ 1. `Array(relation)` triggers `relation.to_a`, which loads the relation and **caches the result** (`relation.loaded? == true`).
97
+ 2. Re-iterating the same loaded `Relation` (e.g. `@posts.each` in the view) returns the **same cached objects** — not a re-query, not new instances.
98
+
99
+ So `@posts = Post.recent; StrictLazy.preload(@posts)` then `@posts.each` in the view reads the very objects that got the batch ivar. The `Array()` call inside `preload` warms the relation's cache as a side effect, so no explicit `.to_a` is needed.
100
+
101
+ The one caveat (same as the "match the collection" rule above): evaluating a **different** relation in the view — e.g. preloading `Post.recent` but iterating `Post.recent` again as a fresh query — produces new objects without the batch ivar, which then raise under `violation: :raise`. Keep the preloaded relation in a variable and reuse it.
102
+
103
+ ## Read order of `record.lazy`
104
+
105
+ Resolution order for `record.lazy.x` (`Facade#method_missing`):
106
+
107
+ 1. `@_lazy_<reader>` already set → return it (already resolved / sync / group-resolved).
108
+ 2. `@_batch_<reader>` present → resolve the whole group once and return (lazy resolution).
109
+ 3. No batch and `violation: :raise` → raise `UnloadedError` (no wasted queries).
110
+ 4. No batch and non-strict → degrade to 1-record resolution (fallback, N+1).
111
+
112
+ `record.lazy` itself generates a `Facade` once and memoizes it (`@_lazy_facade`).
113
+ `record.lazy.respond_to?(:x)` reflects declared loaders.
114
+
115
+ ## `violation` policy
116
+
117
+ Behavior when `.lazy.x` is read without a preload (cases 3/4 above).
118
+
119
+ | mode | behavior |
120
+ | --- | --- |
121
+ | `:raise` | Raises `StrictLazy::UnloadedError`. No wasted queries. |
122
+ | `:log` | `Rails.logger.warn("[StrictLazy] ... read without preload (degraded to N+1)")`, then resolves 1 record. |
123
+ | `:ignore` | Silently resolves 1 record (N+1). |
124
+
125
+ Configuration:
126
+
127
+ - Global: `StrictLazy.violation = :log`
128
+ - Rails: `config.strict_lazy.violation = :log` (Railtie applies at initialization)
129
+ - Railtie environment defaults: production → `:ignore`, everything else (development/test) → `:raise`
130
+
131
+ The intent of `:raise` is "fail fast during development when a preload is missing"; `:ignore` is "don't crash in production, degrade to N+1 and keep running".
132
+
133
+ ## Lifecycle and internal ivars
134
+
135
+ - `@_lazy_<reader>` — the resolved value.
136
+ - `@_batch_<reader>` — shared `Batch` reference for the group.
137
+ - `@_lazy_facade` — the `Facade` for `.lazy` (memoized).
138
+
139
+ All are instance variables on the record, so they are **GC'd with the request**. No thread-local cache, no middleware, no global registry. `Batch` writes values directly to each record's ivar, so it holds no intermediate Hash and does not depend on record hash equality (= **works with unsaved records**).
140
+
141
+ ## Complete implementation example
142
+
143
+ ```ruby
144
+ # app/models/post.rb
145
+ class Post < ApplicationRecord
146
+ include StrictLazy
147
+
148
+ # Cross-table aggregation (1 query via GROUP BY)
149
+ lazy_load :comments_count, default: 0 do |posts, loader|
150
+ by_id = posts.index_by(&:id)
151
+ Comment.where(post_id: by_id.keys).group(:post_id).count.each do |post_id, n|
152
+ loader.call(by_id[post_id], n)
153
+ end
154
+ end
155
+
156
+ # External API (bulk_fetch IDs together, immediate resolution)
157
+ def self.resolve_avatar(posts, loader)
158
+ urls = AvatarService.bulk_fetch(posts.map(&:author_id).compact.uniq)
159
+ posts.each { |p| loader.call(p, urls[p.author_id]) }
160
+ end
161
+ lazy_load :avatar, from: :resolve_avatar, sync: true
162
+ end
163
+ ```
164
+
165
+ ```ruby
166
+ # app/controllers/posts_controller.rb
167
+ def index
168
+ @posts = Post.recent # an ActiveRecord::Relation is fine (no .to_a needed)
169
+ StrictLazy.preload(@posts) # prepares comments_count and avatar
170
+ end
171
+ ```
172
+
173
+ ```erb
174
+ <%# app/views/posts/index.html.erb %>
175
+ <% @posts.each do |post| %>
176
+ <span><%= post.lazy.comments_count %></span>
177
+ <img src="<%= post.lazy.avatar %>">
178
+ <% end %>
179
+ ```
180
+
181
+ If you forget the preload and read `post.lazy.comments_count`, development/test raises
182
+ `StrictLazy::UnloadedError`, prompting you to add `StrictLazy.preload` in the controller.
metadata ADDED
@@ -0,0 +1,80 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: strict_lazy
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - aki77
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '8.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '8.0'
26
+ description: |
27
+ strict_lazy applies the spirit of Rails' strict_loading to computed values
28
+ (external APIs, window functions, cross-table aggregates) that associations
29
+ cannot express. It forces explicit preloading and raises on unloaded access
30
+ in development/test, so hidden per-record queries never slip into views.
31
+ No batch-loader / N1Loader / ar_lazy_preload dependency — activesupport only.
32
+ email:
33
+ - aki77@users.noreply.github.com
34
+ executables: []
35
+ extensions: []
36
+ extra_rdoc_files: []
37
+ files:
38
+ - CHANGELOG.md
39
+ - LICENSE.txt
40
+ - README.md
41
+ - Rakefile
42
+ - lib/strict_lazy.rb
43
+ - lib/strict_lazy/batch.rb
44
+ - lib/strict_lazy/errors.rb
45
+ - lib/strict_lazy/facade.rb
46
+ - lib/strict_lazy/loader.rb
47
+ - lib/strict_lazy/railtie.rb
48
+ - lib/strict_lazy/version.rb
49
+ - sig/strict_lazy.rbs
50
+ - skills/strict-lazy/SKILL-ja.md
51
+ - skills/strict-lazy/SKILL.md
52
+ - skills/strict-lazy/references/api-ja.md
53
+ - skills/strict-lazy/references/api.md
54
+ homepage: https://github.com/aki77/strict_lazy
55
+ licenses:
56
+ - MIT
57
+ metadata:
58
+ homepage_uri: https://github.com/aki77/strict_lazy
59
+ source_code_uri: https://github.com/aki77/strict_lazy
60
+ changelog_uri: https://github.com/aki77/strict_lazy/blob/main/CHANGELOG.md
61
+ rubygems_mfa_required: 'true'
62
+ rdoc_options: []
63
+ require_paths:
64
+ - lib
65
+ required_ruby_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: 3.4.0
70
+ required_rubygems_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ requirements: []
76
+ rubygems_version: 4.0.10
77
+ specification_version: 4
78
+ summary: Strict, explicit preloading for computed values — raise on unloaded access
79
+ instead of silent N+1.
80
+ test_files: []