strict_lazy 0.4.0 → 0.4.1

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: e52f77c57f79c210c07cb64b06b8a156078ac6a39a4326e75c2a57bc4e686c18
4
- data.tar.gz: 7921a8cabbe73770a442cd449ab26d0c9b4259b7e08ec7cfc52e621cd37ba304
3
+ metadata.gz: c972cb755e6af6d40da941b0a986ee9c0fb43c10810d82fad0c7751cb19c0371
4
+ data.tar.gz: ae53bb2dddab990d59e7867826fae35f2bf8c286a8ff97b69387d00b1c0aefa6
5
5
  SHA512:
6
- metadata.gz: d7e11f8f5d3da3ab4c6c479b0716b4d71d501b2f66496ca66284594df6a1a2cfefdba36ff23b5fc00fac11365a37eb6701b165df195e876e7f5d3676d9e4bc1c
7
- data.tar.gz: 6041ef114555d858c0cd3078e0847f20f726c58120c6ad1aebd2b2e9d62637922ce9b09bbc1a67d7c2854f37d8c826732bbab3d1dd8e6fbb02176b97543d1fae
6
+ metadata.gz: e2de7b79fdcf8d495356bd39d1f622fe99ca9d0154f7073ae63266bace18e0b41e162f7c017c3538cfb69c66182ed45058095204199156946e55b0bacb7b7b99
7
+ data.tar.gz: 705e72f80326d7bf9bc07683864bf9b312bb445e19482c881337c694eb45c94b95851a819fe86dc0f3d9b4f9b5bfe078f1443f0c381716d9c8bf546a8017aa75
data/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.1] - 2026-06-20
11
+
12
+ ### Changed
13
+
14
+ - Exclude `skills/**/*-ja.md` (Japanese skill docs) from the gem package.
15
+
10
16
  ## [0.4.0] - 2026-06-16
11
17
 
12
18
  ### Added
@@ -63,7 +69,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
63
69
  defaulting to `:raise` in development/test and `:ignore` in production through a Railtie.
64
70
  - Per-record callable `default:` for unfulfilled records.
65
71
 
66
- [Unreleased]: https://github.com/aki77/strict_lazy/compare/v0.3.0...HEAD
72
+ [Unreleased]: https://github.com/aki77/strict_lazy/compare/v0.4.1...HEAD
73
+ [0.4.1]: https://github.com/aki77/strict_lazy/compare/v0.4.0...v0.4.1
74
+ [0.4.0]: https://github.com/aki77/strict_lazy/compare/v0.3.0...v0.4.0
67
75
  [0.3.0]: https://github.com/aki77/strict_lazy/compare/v0.2.0...v0.3.0
68
76
  [0.2.0]: https://github.com/aki77/strict_lazy/compare/v0.1.0...v0.2.0
69
77
  [0.1.0]: https://github.com/aki77/strict_lazy/releases/tag/v0.1.0
data/README.md CHANGED
@@ -211,6 +211,10 @@ gh skill install aki77/strict_lazy
211
211
 
212
212
  > `gh skill` is currently a GitHub CLI preview feature.
213
213
 
214
+ Alternatively, if your project already pulls `strict_lazy` in via Bundler, the
215
+ [bundler-skills](https://github.com/aki77/bundler-skills) plugin auto-syncs this
216
+ skill on `bundle install` — keeping the skill version locked to the gem version.
217
+
214
218
  ## Non-Rails usage
215
219
 
216
220
  Works without Rails: `include StrictLazy`, declare with `lazy_load`, call
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StrictLazy
4
- VERSION = "0.4.0"
4
+ VERSION = "0.4.1"
5
5
  end
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.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - aki77
@@ -48,9 +48,7 @@ files:
48
48
  - lib/strict_lazy/railtie.rb
49
49
  - lib/strict_lazy/version.rb
50
50
  - sig/strict_lazy.rbs
51
- - skills/strict-lazy/SKILL-ja.md
52
51
  - skills/strict-lazy/SKILL.md
53
- - skills/strict-lazy/references/api-ja.md
54
52
  - skills/strict-lazy/references/api.md
55
53
  homepage: https://github.com/aki77/strict_lazy
56
54
  licenses:
@@ -1,82 +0,0 @@
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)。
@@ -1,206 +0,0 @@
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, *spec)
91
- ```
92
-
93
- - `records` — レコード配列 (単体も `Array()` でラップされる)。`ActiveRecord::Relation` もそのまま渡せる(内部の `Array()` が評価するので `.to_a` 不要)。空なら何もしない。
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` 自身には **何も準備せず**、子だけを準備する。
98
- - 各ローダーについて `Batch` を作り、全レコードの `@_batch_<reader>` にセット。
99
- `sync: true` のものはここで即 `resolve!`。
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
- ```
107
-
108
- 注意: 同じローダーを **重複するグループに2回 preload** すると、その分リゾルバが複数回走る。
109
- ビューで読むコレクションを1回でカバーするように呼ぶ。
110
-
111
- 対象外: lazy reader の結果をさらに lazy preload に繋ぐ(lazy→lazy)ケースは非対応。`preload` が辿るのは **関連** のみ。`lazy_load` がレコードを返しそれをさらに preload したい場合は、自分でそれらを集めて再度 `preload` を呼ぶ。
112
-
113
- ### Relation をそのまま渡してよい理由
114
-
115
- `preload` は各レコードオブジェクトに `@_batch_<reader>` ivar を書き込むため、preload するレコードとビューで読むレコードは **同一オブジェクト** である必要がある。`Relation` はこれを満たす:
116
-
117
- 1. `Array(relation)` が `relation.to_a` を発火させ、Relation をロードして **結果をキャッシュ** する(`relation.loaded? == true`)。
118
- 2. ロード済みの同じ `Relation` を再列挙しても(ビューでの `@posts.each` など)、**キャッシュされた同一オブジェクト** が返る ── 再クエリも新規インスタンス生成も起きない。
119
-
120
- よって `@posts = Post.recent; StrictLazy.preload(@posts)` のあとビューで `@posts.each` すれば、batch ivar が仕込まれたまさにそのオブジェクトを読む。`preload` 内の `Array()` 呼び出しが副作用で Relation のキャッシュを温めるので、明示的な `.to_a` は要らない。
121
-
122
- 唯一の注意点(上の「ビューで読むコレクションと一致させる」と同じ): ビューで **別の Relation** を評価する ── 例えば `Post.recent` を preload したのにビューで再び `Post.recent` を新たなクエリとして回す ── と、batch ivar を持たない新規オブジェクトが生成され、`violation: :raise` では raise する。preload した Relation は変数に保持して使い回すこと。
123
-
124
- ## `record.lazy` の読み取り順序
125
-
126
- `record.lazy.x` の解決は次の順 (`Facade#method_missing`):
127
-
128
- 1. `@_lazy_<reader>` が既にセット済み → それを返す (解決済み/即時/グループ解決済み)。
129
- 2. `@_batch_<reader>` がある → グループ全体を1回解決して返す (遅延解決)。
130
- 3. batch がなく `violation: :raise` → `UnloadedError` (無駄クエリなし)。
131
- 4. batch がなく非 strict → 1レコード解決に退化 (フォールバック、N+1)。
132
-
133
- `record.lazy` 自体は `Facade` を1回だけ生成してメモ化 (`@_lazy_facade`)。
134
- `record.lazy.respond_to?(:x)` は宣言済みローダーを反映する。
135
-
136
- ## `violation` ポリシー
137
-
138
- プリロードなしで `.lazy.x` を読んだときの挙動 (上記の 3/4)。
139
-
140
- | mode | 挙動 |
141
- | --- | --- |
142
- | `:raise` | `StrictLazy::UnloadedError` を raise。無駄なクエリは出さない。 |
143
- | `:log` | `Rails.logger.warn("[StrictLazy] ... read without preload (degraded to N+1)")` の後、1レコード解決。 |
144
- | `:ignore` | 静かに1レコード解決 (N+1)。 |
145
-
146
- 設定方法:
147
-
148
- - グローバル: `StrictLazy.violation = :log`
149
- - Rails: `config.strict_lazy.violation = :log` (Railtie が初期化時に反映)
150
- - Railtie の環境デフォルト: production → `:ignore`、それ以外 (development/test) → `:raise`
151
-
152
- `:raise` の狙いは「開発中にプリロード忘れで即落として気づかせる」、`:ignore` の狙いは
153
- 「本番ではクラッシュさせず N+1 に退化させて動かし続ける」。
154
-
155
- ## ライフサイクルと内部 ivar
156
-
157
- - `@_lazy_<reader>` — 解決済みの値。
158
- - `@_batch_<reader>` — グループ共有の `Batch` 参照。
159
- - `@_lazy_facade` — `.lazy` の `Facade` (メモ化)。
160
-
161
- すべてレコードのインスタンス変数なので、**リクエストと共に GC** される。thread-local キャッシュも
162
- ミドルウェアもグローバルなレジストリもない。`Batch` は値を各レコードの ivar に直接書くので
163
- 中間 Hash を持たず、レコードの hash 等価性にも依存しない (= **未保存レコードでも動く**)。
164
-
165
- ## 完全な実装例
166
-
167
- ```ruby
168
- # app/models/post.rb
169
- class Post < ApplicationRecord
170
- include StrictLazy
171
-
172
- # クロステーブル集計 (GROUP BY を1クエリに)
173
- lazy_load :comments_count, default: 0 do |posts, loader|
174
- by_id = posts.index_by(&:id)
175
- Comment.where(post_id: by_id.keys).group(:post_id).count.each do |post_id, n|
176
- loader.call(by_id[post_id], n)
177
- end
178
- end
179
-
180
- # 外部API (ID をまとめて bulk_fetch、即時解決)
181
- def self.resolve_avatar(posts, loader)
182
- urls = AvatarService.bulk_fetch(posts.map(&:author_id).compact.uniq)
183
- posts.each { |p| loader.call(p, urls[p.author_id]) }
184
- end
185
- lazy_load :avatar, from: :resolve_avatar, sync: true
186
- end
187
- ```
188
-
189
- ```ruby
190
- # app/controllers/posts_controller.rb
191
- def index
192
- @posts = Post.recent # ActiveRecord::Relation のままでよい(.to_a 不要)
193
- StrictLazy.preload(@posts) # comments_count と avatar を準備
194
- end
195
- ```
196
-
197
- ```erb
198
- <%# app/views/posts/index.html.erb %>
199
- <% @posts.each do |post| %>
200
- <span><%= post.lazy.comments_count %></span>
201
- <img src="<%= post.lazy.avatar %>">
202
- <% end %>
203
- ```
204
-
205
- プリロードを忘れて `post.lazy.comments_count` を読むと、development/test では
206
- `StrictLazy::UnloadedError` が raise され、コントローラへの `StrictLazy.preload` 追加を促される。