strict_lazy 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e9db46fd9beb35a23ac710e80c9ae7fddebdc576e399bcc4c249bc22a8acb7b
4
- data.tar.gz: 3eb3eb6e31b616914a06accca417608e62919d20b0ec8768f12d538fd0874479
3
+ metadata.gz: e52f77c57f79c210c07cb64b06b8a156078ac6a39a4326e75c2a57bc4e686c18
4
+ data.tar.gz: 7921a8cabbe73770a442cd449ab26d0c9b4259b7e08ec7cfc52e621cd37ba304
5
5
  SHA512:
6
- metadata.gz: 34d9415e1488ca61c7a8476cf372fd14e52aba5c6e67b33a863a15d9ee05386dc406b143648ef0278427938d9eb6d61c2e7691cecadf4487aa6b62d3febd0a91
7
- data.tar.gz: d808ab15745e130af97ad8ba0dca0c5189e8e274fdb0a5455dda2cd64378f172faf77c99b5548080e82f17b92d15e1d4fa359520d8ed36c6d2e062a0a02f6986
6
+ metadata.gz: d7e11f8f5d3da3ab4c6c479b0716b4d71d501b2f66496ca66284594df6a1a2cfefdba36ff23b5fc00fac11365a37eb6701b165df195e876e7f5d3676d9e4bc1c
7
+ data.tar.gz: 6041ef114555d858c0cd3078e0847f20f726c58120c6ad1aebd2b2e9d62637922ce9b09bbc1a67d7c2854f37d8c826732bbab3d1dd8e6fbb02176b97543d1fae
data/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-06-16
11
+
12
+ ### Added
13
+
14
+ - Nested preload: `StrictLazy.preload` now accepts a Rails-style spec, so lazy
15
+ values on associated records can be prepared in one call —
16
+ `StrictLazy.preload(posts, :comments_count, comments: [:reply_count, { replies: :shout }])`.
17
+ Associations are batch-loaded to avoid N+1; nesting is arbitrarily deep.
18
+
19
+ ### Changed
20
+
21
+ - `StrictLazy.preload` groups records by STI base class, so a mixed-class array
22
+ (STI subtrees, or children gathered across associations) resolves each loader
23
+ once per declaring class. Single-model calls are unchanged.
24
+
10
25
  ## [0.3.0] - 2026-06-14
11
26
 
12
27
  ### Added
data/README.md CHANGED
@@ -73,6 +73,32 @@ StrictLazy.preload(@posts) # all declared loaders
73
73
  <img src="<%= post.lazy.avatar %>">
74
74
  ```
75
75
 
76
+ ## Nested preload
77
+
78
+ To prepare lazy values on associated records, pass a Hash to `preload`. The keys
79
+ are associations to traverse; the values are the spec to apply to the associated
80
+ records (a reader, a Hash, or an array mixing both — the same grammar as the
81
+ top level). This mirrors ActiveRecord's `preload` spec.
82
+
83
+ ```ruby
84
+ # comments and replies each declare their own lazy_load readers
85
+ StrictLazy.preload(@posts,
86
+ :comments_count, # reader on @posts
87
+ comments: [:reply_count, { replies: :shout }] # reader on comments + nested
88
+ )
89
+ ```
90
+
91
+ - Plain symbols apply to the current level; Hash keys descend into associations.
92
+ - A Hash-only call (`StrictLazy.preload(@posts, comments: :reply_count)`)
93
+ prepares nothing on `@posts` itself — only the children.
94
+ - Associations are batch-loaded to avoid N+1. If the records aren't
95
+ ActiveRecord-backed (e.g. unsaved), preload the association yourself first.
96
+ - Records may mix classes (STI subtrees, children gathered across associations);
97
+ they are grouped by STI base class so each resolver runs once per class.
98
+ - This traverses **associations** only. Chaining a lazy reader into another lazy
99
+ preload (lazy→lazy) is out of scope — collect those records yourself and call
100
+ `preload` again.
101
+
76
102
  ## Eager vs lazy (`sync:`)
77
103
 
78
104
  - `sync: false` (default): resolution is deferred to the first `.lazy` read, then
@@ -173,6 +199,18 @@ A static value (`default: 0`) is written as-is.
173
199
  name or a `?` predicate — the read-only `.lazy` namespace rejects setter (`=`),
174
200
  bang (`!`), and operator reader names at declaration time.
175
201
 
202
+ ## Agent skill
203
+
204
+ This repo ships an [agent skill](skills/strict-lazy/) (`SKILL.md`, plus a
205
+ Japanese `SKILL-ja.md`) that teaches coding agents when and how to apply
206
+ `strict_lazy`. Install it into your project with the GitHub CLI:
207
+
208
+ ```sh
209
+ gh skill install aki77/strict_lazy
210
+ ```
211
+
212
+ > `gh skill` is currently a GitHub CLI preview feature.
213
+
176
214
  ## Non-Rails usage
177
215
 
178
216
  Works without Rails: `include StrictLazy`, declare with `lazy_load`, call
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StrictLazy
4
+ # Drives +StrictLazy.preload+: interprets the Rails-style spec, prepares each
5
+ # level's readers (grouped by STI base class), and traverses associations to
6
+ # descend into nested records. One instance handles a single level of records;
7
+ # nested levels are handled by recursing into fresh instances.
8
+ class Preloader
9
+ # Prepare +spec+ on +records+. See +StrictLazy.preload+ for the spec grammar.
10
+ def self.call(records, spec)
11
+ records = Array(records)
12
+ return records if records.empty?
13
+
14
+ new(records).call(spec)
15
+ records
16
+ end
17
+
18
+ def initialize(records)
19
+ @records = records
20
+ end
21
+
22
+ def call(spec)
23
+ hashes, readers = spec.partition { |element| element.is_a?(Hash) }
24
+
25
+ preload_here(readers) if prepare_this_level?(spec, readers)
26
+
27
+ hashes.flat_map(&:to_a).each do |association, sub_spec|
28
+ children = traverse(association)
29
+ # Array.wrap (not Kernel#Array) so a Hash sub-spec stays a single element
30
+ # ([hash]) instead of being split into key/value pairs.
31
+ self.class.call(children, Array.wrap(sub_spec))
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ # An empty spec means "all loaders" (the historical no-args behavior); a
38
+ # Hash-only spec prepares nothing here and only descends into children.
39
+ def prepare_this_level?(spec, readers)
40
+ spec.empty? || readers.any?
41
+ end
42
+
43
+ # Prepare +readers+ on this level, grouping by STI base class so each loader's
44
+ # resolver runs once per declaring class with the correct dispatch receiver.
45
+ def preload_here(readers)
46
+ @records.group_by { |record| base_model_for(record) }.each do |model, group|
47
+ loaders_for(model, readers).each do |loader|
48
+ batch = Batch.new(model, group, loader)
49
+ group.each { |record| record.instance_variable_set(loader.batch_ivar, batch) }
50
+ batch.resolve! if loader.sync?
51
+ end
52
+ end
53
+ end
54
+
55
+ # The STI base class (the class the loaders are declared on) for a record,
56
+ # falling back to its class when +base_class+ is unavailable.
57
+ def base_model_for(record)
58
+ klass = record.class
59
+ klass.respond_to?(:base_class) ? klass.base_class : klass
60
+ end
61
+
62
+ # Follow an association across every record and return the flattened
63
+ # children. belongs_to/has_one (singular or nil) and has_many are unified via
64
+ # Array.wrap. When the records are ActiveRecord-backed, the association is
65
+ # batch-preloaded first to avoid N+1; otherwise we rely on the caller having
66
+ # already preloaded it.
67
+ def traverse(association)
68
+ # Reflection lives on the base class and is uniform across the group, so
69
+ # inspecting the first record is enough here (unlike preload_here, which
70
+ # must group every record by class to dispatch resolvers correctly).
71
+ klass = base_model_for(@records.first)
72
+ unless klass.respond_to?(:reflect_on_association) && klass.reflect_on_association(association)
73
+ raise ArgumentError, "StrictLazy.preload: #{klass}##{association} is not an association"
74
+ end
75
+
76
+ preload_association(association)
77
+ # Array.wrap turns nil/singular/has_many into a flat element list; an absent
78
+ # singular association yields [], so no compact is needed.
79
+ @records.flat_map { |record| Array.wrap(record.public_send(association)) }
80
+ end
81
+
82
+ # Batch-preload an ActiveRecord association to avoid N+1. No-op (degrading to
83
+ # the caller's own preloading) when the Preloader is unavailable.
84
+ def preload_association(association)
85
+ return unless defined?(ActiveRecord::Associations::Preloader)
86
+
87
+ ActiveRecord::Associations::Preloader.new(records: @records, associations: association).call
88
+ end
89
+
90
+ def loaders_for(model, readers)
91
+ all = model.lazy_loaders
92
+ readers.empty? ? all.values : readers.map { |r| all.fetch(r) }
93
+ end
94
+ end
95
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StrictLazy
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/strict_lazy.rb CHANGED
@@ -4,12 +4,14 @@ require "active_support"
4
4
  require "active_support/concern"
5
5
  require "active_support/core_ext/class/attribute"
6
6
  require "active_support/core_ext/module/attribute_accessors_per_thread"
7
+ require "active_support/core_ext/array/wrap"
7
8
 
8
9
  require_relative "strict_lazy/version"
9
10
  require_relative "strict_lazy/errors"
10
11
  require_relative "strict_lazy/loader"
11
12
  require_relative "strict_lazy/batch"
12
13
  require_relative "strict_lazy/facade"
14
+ require_relative "strict_lazy/preloader"
13
15
 
14
16
  # strict_lazy applies the spirit of Rails' +strict_loading+ to computed values.
15
17
  # Include it in a model, declare values with +lazy_load+, prepare them in the
@@ -130,27 +132,29 @@ module StrictLazy
130
132
  end
131
133
  private_class_method :validate_violation!
132
134
 
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) }
135
+ # Prepare lazy values for a group of records.
136
+ #
137
+ # The +spec+ is a Rails-style list (mirroring ActiveRecord's +preload+): each
138
+ # element is either a reader name (Symbol) prepared on the given records, or a
139
+ # Hash whose keys are associations to traverse and whose values are the spec to
140
+ # apply recursively to the associated records. A Hash value may itself be a
141
+ # Symbol, a Hash, or an array mixing both — so a single level can prepare its
142
+ # own readers and descend into nested associations at once:
143
+ #
144
+ # StrictLazy.preload(posts,
145
+ # :comments_count, # reader on posts
146
+ # comments: [:score, { replies: :like_count }] # reader on comments + nested
147
+ # )
148
+ #
149
+ # With no spec at all, every declared loader on the records is prepared.
150
+ # +sync: true+ loaders resolve immediately; others on first read.
151
+ #
152
+ # Records may mix classes (e.g. STI subtrees, or children gathered across
153
+ # associations): they are grouped by their STI base class so each loader's
154
+ # resolver runs once per declaring class.
155
+ def self.preload(records, *spec)
156
+ Preloader.call(records, spec)
152
157
  end
153
- private_class_method :loaders_for
154
158
  end
155
159
 
156
160
  require_relative "strict_lazy/railtie" if defined?(Rails::Railtie)
@@ -87,18 +87,29 @@ lazy_load :slug, default: ->(record) { "p-#{record.id}" } do ... end # レコ
87
87
  ## `StrictLazy.preload`
88
88
 
89
89
  ```ruby
90
- StrictLazy.preload(records, *readers)
90
+ StrictLazy.preload(records, *spec)
91
91
  ```
92
92
 
93
93
  - `records` — レコード配列 (単体も `Array()` でラップされる)。`ActiveRecord::Relation` もそのまま渡せる(内部の `Array()` が評価するので `.to_a` 不要)。空なら何もしない。
94
- - `readers` 省略時は **宣言済みの全ローダー** を準備。指定すると一部だけ。
94
+ - `spec` Rails 流のリスト(ActiveRecord の `preload` と同じ流儀)。各要素は次のいずれか:
95
+ - **reader 名** (Symbol) — `records` に対して準備、または
96
+ - **Hash** — キーは辿る関連、値はその関連先レコードに(再帰的に)適用する spec。Hash の値は Symbol・Hash・両者を混在した配列のいずれも可。
97
+ - `spec` が **完全に空** のときは `records` に **宣言済みの全ローダー** を準備。Hash だけの spec(例 `preload(posts, comments: :reply_count)`)は `records` 自身には **何も準備せず**、子だけを準備する。
95
98
  - 各ローダーについて `Batch` を作り、全レコードの `@_batch_<reader>` にセット。
96
99
  `sync: true` のものはここで即 `resolve!`。
97
- - モデルは `records.first.class` から決まる。**同一モデルのレコード群** を渡す前提。
100
+ - レコードは **STI ベースクラス**(`class.base_class`)でグループ化され、各ローダーのリゾルバは宣言クラスごとに1回走る。混在クラスの配列(STI サブツリーや、関連を跨いで集めた子)も正しく扱える。
101
+ - 関連を辿る際は `ActiveRecord::Associations::Preloader` で一括ロードして N+1 を回避する。AR でないレコードはこれをスキップ(自分で関連を先に preload しておくこと)。関連でない名前を辿ろうとすると `ArgumentError`。
102
+
103
+ ```ruby
104
+ # posts の reader + comments の reader + comments.replies の reader
105
+ StrictLazy.preload(@posts, :comments_count, comments: [:reply_count, { replies: :shout }])
106
+ ```
98
107
 
99
108
  注意: 同じローダーを **重複するグループに2回 preload** すると、その分リゾルバが複数回走る。
100
109
  ビューで読むコレクションを1回でカバーするように呼ぶ。
101
110
 
111
+ 対象外: lazy reader の結果をさらに lazy preload に繋ぐ(lazy→lazy)ケースは非対応。`preload` が辿るのは **関連** のみ。`lazy_load` がレコードを返しそれをさらに preload したい場合は、自分でそれらを集めて再度 `preload` を呼ぶ。
112
+
102
113
  ### Relation をそのまま渡してよい理由
103
114
 
104
115
  `preload` は各レコードオブジェクトに `@_batch_<reader>` ivar を書き込むため、preload するレコードとビューで読むレコードは **同一オブジェクト** である必要がある。`Relation` はこれを満たす:
@@ -79,16 +79,27 @@ Passing a mutable value directly like `default: []` shares the same object acros
79
79
  ## `StrictLazy.preload`
80
80
 
81
81
  ```ruby
82
- StrictLazy.preload(records, *readers)
82
+ StrictLazy.preload(records, *spec)
83
83
  ```
84
84
 
85
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.
86
+ - `spec` a Rails-style list (mirrors ActiveRecord's `preload`). Each element is either:
87
+ - a **reader name** (Symbol) — prepared on `records`, or
88
+ - a **Hash** — keys are associations to traverse, values are the spec applied to the associated records (recursively). A Hash value may be a Symbol, a Hash, or an array mixing both.
89
+ - When `spec` is **entirely empty**, **all declared loaders** on `records` are prepared. A Hash-only spec (e.g. `preload(posts, comments: :reply_count)`) prepares **nothing** on `records` itself — only the children.
87
90
  - 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**.
91
+ - Records are grouped by **STI base class** (`class.base_class`), so each loader's resolver runs once per declaring class. A mixed-class array (STI subtrees, or children gathered across associations) is handled correctly.
92
+ - Associations are batch-loaded via `ActiveRecord::Associations::Preloader` to avoid N+1 while traversing. Non-AR records skip this (preload the association yourself first). Traversing a name that isn't an association raises `ArgumentError`.
93
+
94
+ ```ruby
95
+ # reader on posts + reader on comments + reader on comments.replies
96
+ StrictLazy.preload(@posts, :comments_count, comments: [:reply_count, { replies: :shout }])
97
+ ```
89
98
 
90
99
  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
100
 
101
+ Out of scope: chaining a lazy reader's result into another lazy preload (lazy→lazy). `preload` traverses **associations** only. If a `lazy_load` returns records you want to preload further, collect them yourself and call `preload` again.
102
+
92
103
  ### Why passing a `Relation` directly works
93
104
 
94
105
  `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:
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: strict_lazy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - aki77
@@ -44,6 +44,7 @@ files:
44
44
  - lib/strict_lazy/errors.rb
45
45
  - lib/strict_lazy/facade.rb
46
46
  - lib/strict_lazy/loader.rb
47
+ - lib/strict_lazy/preloader.rb
47
48
  - lib/strict_lazy/railtie.rb
48
49
  - lib/strict_lazy/version.rb
49
50
  - sig/strict_lazy.rbs