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 +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +38 -0
- data/lib/strict_lazy/preloader.rb +95 -0
- data/lib/strict_lazy/version.rb +1 -1
- data/lib/strict_lazy.rb +24 -20
- data/skills/strict-lazy/references/api-ja.md +14 -3
- data/skills/strict-lazy/references/api.md +14 -3
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e52f77c57f79c210c07cb64b06b8a156078ac6a39a4326e75c2a57bc4e686c18
|
|
4
|
+
data.tar.gz: 7921a8cabbe73770a442cd449ab26d0c9b4259b7e08ec7cfc52e621cd37ba304
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/strict_lazy/version.rb
CHANGED
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.
|
|
134
|
-
#
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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, *
|
|
90
|
+
StrictLazy.preload(records, *spec)
|
|
91
91
|
```
|
|
92
92
|
|
|
93
93
|
- `records` — レコード配列 (単体も `Array()` でラップされる)。`ActiveRecord::Relation` もそのまま渡せる(内部の `Array()` が評価するので `.to_a` 不要)。空なら何もしない。
|
|
94
|
-
- `
|
|
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
|
-
-
|
|
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, *
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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.
|
|
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
|