skinny_includes 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '0089447d2acabe947ad968c3939496cfc4e11357f8e57d80357bc9c2b694a0d1'
4
+ data.tar.gz: 9f12f4d7685fd86f217165fd9ba3859c17ccb90ac4d334ffd25f82b33916032f
5
+ SHA512:
6
+ metadata.gz: ec92a26e5ffe31ed3ce3dc5dbd3008eb00f94ba8939f6a5cdb0857724320610d385c31966582473be0898fc35b4c764b92cc02d1a4603b6fc9f784f6ae26fba9
7
+ data.tar.gz: 54437698943556267db6cef5caf536f206dd2386824827d5600f31afd200089dcac13b5e4598a132a2b994eb46ab3d8013301735466e4c5194764d2d3ee080f3
@@ -0,0 +1,27 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [ main ]
6
+ pull_request:
7
+ branches: [ main ]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ ruby: ['3.0', '3.1', '3.2', '3.3']
15
+ rails: ['7.0', '7.1', '7.2', '8.0']
16
+
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+
20
+ - name: Set up Ruby
21
+ uses: ruby/setup-ruby@v1
22
+ with:
23
+ ruby-version: ${{ matrix.ruby }}
24
+ bundler-cache: true
25
+
26
+ - name: Run tests
27
+ run: ruby spec/skinny_includes_spec.rb
data/CHANGELOG.md ADDED
@@ -0,0 +1,75 @@
1
+ # Changelog
2
+
3
+ ## 0.1
4
+
5
+ ### Added
6
+ - **Scoped associations support**: Association scopes are now properly respected when using `with_columns` and `without_columns`
7
+ - Works with `has_many`, `has_one`, and `belongs_to` associations
8
+ - Scopes are applied via `instance_exec` on the base query
9
+ - Example: `Post.includes(:published_comments).with_columns(published_comments: [:body])`
10
+
11
+ - **Nested/chained includes support**: You can now load nested associations with column selection
12
+ - Use hash syntax with `:columns` and `:include` keys
13
+ - Supports unlimited nesting depth
14
+ - Automatically includes necessary foreign keys
15
+ - Example:
16
+ ```ruby
17
+ Post.with_columns(
18
+ comments: {
19
+ columns: [:body],
20
+ include: { author: [:name] }
21
+ }
22
+ )
23
+ ```
24
+ - Works with `belongs_to`, `has_many`, and `has_one` at any nesting level
25
+ - Works with both `with_columns` and `without_columns`
26
+ - Respects scoped associations at all nesting levels
27
+
28
+ ### Fixed
29
+ - **belongs_to foreign key bug**: Fixed issue where the gem tried to select the foreign key column from the associated table in `belongs_to` associations
30
+ - Foreign keys are now only included for `has_many` and `has_one` associations (where they exist on the associated table)
31
+ - For `belongs_to`, the foreign key exists on the source table, not the target
32
+
33
+ ### Testing
34
+ - Added comprehensive test coverage for `belongs_to` associations (15 tests)
35
+ - Added comprehensive test coverage for `has_one` associations (9 tests)
36
+ - Added tests for scoped associations (5 tests)
37
+ - Added tests for nested includes (6 tests)
38
+ - Added tests for all loading strategies (`includes`, `preload`, `eager_load`)
39
+ - Total: 68 tests, all passing
40
+
41
+ ## Implementation Details
42
+
43
+ ### Scoped Associations
44
+ The implementation applies association scopes by calling `instance_exec` on the base query with the scope proc:
45
+
46
+ ```ruby
47
+ base_query = assoc_class.where(fk => parent_ids)
48
+
49
+ if reflection.scope
50
+ base_query = base_query.instance_exec(&reflection.scope)
51
+ end
52
+
53
+ associated_records = base_query.select(*columns).to_a
54
+ ```
55
+
56
+ This ensures that scopes defined like `-> { where(published: true) }` are properly evaluated in the context of the relation.
57
+
58
+ ### Implementation Details
59
+
60
+ #### Nested Includes
61
+ The implementation recursively loads nested associations:
62
+
63
+ 1. Parses column specs to extract `:columns` and `:include` keys
64
+ 2. Automatically includes foreign keys needed for nested associations
65
+ 3. Recursively calls `load_nested_associations` after loading each level
66
+ 4. Supports STI (Single Table Inheritance) by grouping records by class
67
+ 5. Works with all association types (`belongs_to`, `has_many`, `has_one`)
68
+
69
+ The hash syntax makes the structure explicit:
70
+ ```ruby
71
+ {
72
+ columns: [:foo, :bar], # What to select
73
+ include: { nested: [...] } # What to nest
74
+ }
75
+ ```
data/README.md ADDED
@@ -0,0 +1,163 @@
1
+ # skinny_includes
2
+
3
+ Select specific columns when preloading associations. Prevents N+1 queries, reduces memory usage.
4
+
5
+ ## Why?
6
+
7
+ ### Big beautiful columns
8
+
9
+ I have a lot of columns that have associated JSON directly on the table.
10
+
11
+ ```ruby
12
+ class ThingWithJsonDataColumn < ApplicationRecord
13
+ # has a `data` column name with column type of `json`
14
+ end
15
+ ```
16
+
17
+ Sometimes I load 5k of these ThingWithJsonDataColumn objects in an HTTP request. This is expensive.
18
+
19
+ I could write a custom association that excludes the column I don't care about, but that's silly.
20
+
21
+ ## Use case
22
+
23
+ The obvious use case is large JSON or text columns that slow queries and inflate memory. Instead of writing custom scoped associations, exclude them:
24
+
25
+ ```ruby
26
+ post.without_columns(comments: [:metadata_json, :body])
27
+ ```
28
+
29
+ Loads all columns except `metadata_json` and `body`.
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ bundle add skinny_includes
35
+ ```
36
+
37
+ Or manually:
38
+
39
+ ```ruby
40
+ gem 'skinny_includes'
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ Two methods:
46
+
47
+ **with_columns** — whitelist columns:
48
+
49
+ ```ruby
50
+ Post.with_columns(comments: [:author, :upvotes])
51
+ Post.includes(:comments).with_columns(comments: [:author])
52
+ ```
53
+
54
+ **without_columns** — blacklist columns:
55
+
56
+ ```ruby
57
+ Post.without_columns(comments: [:body, :metadata])
58
+ Post.includes(:comments).without_columns(comments: :body)
59
+ ```
60
+
61
+ Both work with multiple associations:
62
+
63
+ ```ruby
64
+ Post.with_columns(comments: [:author], tags: [:name])
65
+ Post.without_columns(comments: [:body], tags: [:description])
66
+ ```
67
+
68
+ Primary keys and foreign keys are always included, even if excluded.
69
+
70
+ ## Scoped Associations
71
+
72
+ Scoped associations are fully supported:
73
+
74
+ ```ruby
75
+ class Post < ApplicationRecord
76
+ has_many :published_comments, -> { where(published: true) }, class_name: 'Comment'
77
+ end
78
+
79
+ Post.includes(:published_comments).with_columns(published_comments: [:body, :author])
80
+ ```
81
+
82
+ The scope is respected—only published comments are loaded, and only the specified columns are selected.
83
+
84
+ ## Loading Strategies
85
+
86
+ Works with all ActiveRecord loading strategies:
87
+
88
+ - `includes` - Recommended, lets Rails choose the strategy
89
+ - `preload` - Always uses separate queries
90
+ - `eager_load` - Converts to the gem's loading strategy automatically
91
+
92
+ ## Supported Association Types
93
+
94
+ - `has_many` ✅
95
+ - `has_one` ✅
96
+ - `belongs_to` ✅
97
+
98
+ All association types work with both `with_columns` and `without_columns`.
99
+
100
+ ## Nested Includes
101
+
102
+ Nested/chained includes are fully supported with hash syntax:
103
+
104
+ ```ruby
105
+ Post.with_columns(
106
+ comments: {
107
+ columns: [:body],
108
+ include: { author: [:name] }
109
+ }
110
+ )
111
+ ```
112
+
113
+ This loads posts with their comments (only `body` column) and each comment's author (only `name` column). Foreign keys are automatically included as needed.
114
+
115
+ ### Multi-level Nesting
116
+
117
+ You can nest as deep as you want:
118
+
119
+ ```ruby
120
+ Post.with_columns(
121
+ comments: {
122
+ columns: [:body],
123
+ include: {
124
+ author: {
125
+ columns: [:name],
126
+ include: { profile: [:website] }
127
+ }
128
+ }
129
+ }
130
+ )
131
+ ```
132
+
133
+ ### Automatic Foreign Key Inclusion
134
+
135
+ Foreign keys are automatically included when you use nested includes, even if you don't explicitly specify them:
136
+
137
+ ```ruby
138
+ # author_id is automatically selected to load the nested author association
139
+ Post.with_columns(
140
+ comments: {
141
+ columns: [:body], # author_id NOT listed, but will be included
142
+ include: { author: [:name] }
143
+ }
144
+ )
145
+ ```
146
+
147
+ ### Nested with `without_columns`
148
+
149
+ Works the same way:
150
+
151
+ ```ruby
152
+ Post.without_columns(
153
+ comments: {
154
+ columns: [:metadata], # Exclude metadata from comments
155
+ include: { author: [:bio] } # Exclude bio from authors
156
+ }
157
+ )
158
+ ```
159
+
160
+ ## Requirements
161
+
162
+ - Ruby 3.0+
163
+ - ActiveRecord 7.0+
data/RELEASING.md ADDED
@@ -0,0 +1,62 @@
1
+ # Release Checklist
2
+
3
+ ## Before Release
4
+
5
+ 1. **Update version** in `lib/skinny_includes/version.rb`
6
+ 2. **Update CHANGELOG.md** with version and date
7
+ 3. **Run tests**: `ruby spec/skinny_includes_spec.rb`
8
+ 4. **Commit all changes**
9
+ 5. **Push to GitHub**
10
+
11
+ ## Release
12
+
13
+ Run the release script:
14
+
15
+ ```bash
16
+ bin/release
17
+ ```
18
+
19
+ This will:
20
+ - ✅ Check git is clean
21
+ - ✅ Check version tag doesn't exist
22
+ - ✅ Run tests
23
+ - ✅ Build gem
24
+ - ✅ Show files included
25
+ - ✅ Ask for confirmation
26
+ - ✅ Push to rubygems.org
27
+ - ✅ Create and push git tag
28
+
29
+ ## Manual Release (if needed)
30
+
31
+ ```bash
32
+ # Build
33
+ gem build skinny_includes.gemspec
34
+
35
+ # Push
36
+ gem push skinny_includes-0.1.0.gem
37
+
38
+ # Tag
39
+ git tag -a v0.1.0 -m "Release v0.1.0"
40
+ git push origin v0.1.0
41
+ ```
42
+
43
+ ## After Release
44
+
45
+ 1. Verify on rubygems.org: https://rubygems.org/gems/skinny_includes
46
+ 2. Test installation: `gem install skinny_includes`
47
+ 3. Update any dependent projects
48
+
49
+ ## Troubleshooting
50
+
51
+ ### "Repushing of gem versions is not allowed"
52
+ You've already pushed this version. Bump the version number.
53
+
54
+ ### "You are not authorized to push"
55
+ Run: `gem signin` or check your rubygems.org credentials
56
+
57
+ ### "Git tag already exists"
58
+ Either delete the tag or bump the version:
59
+ ```bash
60
+ git tag -d v0.1.0
61
+ git push origin :refs/tags/v0.1.0
62
+ ```
data/Rakefile ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ task default: %i[]
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkinnyIncludes
4
+ VERSION = "0.4.0"
5
+ end
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "skinny_includes/version"
4
+
5
+ module SkinnyIncludes
6
+ module Relation
7
+ def with_columns(config)
8
+ config.each_key do |assoc|
9
+ reflection = model.reflect_on_association(assoc)
10
+ raise ArgumentError, "Unknown association: #{assoc}" unless reflection
11
+ end
12
+
13
+ spawn.tap { |relation| relation.with_columns_config!(config) }
14
+ end
15
+
16
+ def without_columns(config)
17
+ config.each_key do |assoc|
18
+ reflection = model.reflect_on_association(assoc)
19
+ raise ArgumentError, "Unknown association: #{assoc}" unless reflection
20
+ end
21
+
22
+ spawn.tap { |relation| relation.without_columns_config!(config) }
23
+ end
24
+
25
+ def with_columns_config!(config)
26
+ @values[:with_columns] = config
27
+ end
28
+
29
+ def without_columns_config!(config)
30
+ @values[:without_columns] = config
31
+ end
32
+
33
+ def with_columns_config
34
+ @values[:with_columns]
35
+ end
36
+
37
+ def without_columns_config
38
+ @values[:without_columns]
39
+ end
40
+
41
+ def load
42
+ return super unless with_columns_config || without_columns_config
43
+ return self if @loaded
44
+
45
+ config = with_columns_config || without_columns_config
46
+ is_without = without_columns_config.present?
47
+
48
+ [:preload, :includes].each do |key|
49
+ if @values[key]&.any?
50
+ remaining = Array(@values[key]).reject do |val|
51
+ config.keys.include?(val) || (val.is_a?(Hash) && (val.keys & config.keys).any?)
52
+ end
53
+
54
+ @values[key] = remaining if remaining != @values[key]
55
+ end
56
+ end
57
+
58
+ super
59
+
60
+ config.each do |assoc, column_spec|
61
+ reflection = model.reflect_on_association(assoc)
62
+ raise ArgumentError, "Unknown association: #{assoc}" unless reflection
63
+
64
+ next if @records.empty?
65
+
66
+ assoc_class = reflection.klass
67
+
68
+ # Parse column spec - could be array, hash with :columns and :include, or nil
69
+ columns, nested_includes = parse_column_spec(column_spec, assoc_class, is_without)
70
+
71
+ pk = assoc_class.primary_key.to_sym
72
+ columns |= [pk]
73
+
74
+ # If there are nested includes, ensure we select their foreign keys
75
+ if nested_includes
76
+ nested_includes.each_key do |nested_assoc|
77
+ nested_reflection = assoc_class.reflect_on_association(nested_assoc)
78
+ if nested_reflection
79
+ nested_fk = nested_reflection.foreign_key.to_sym
80
+ # Only add FK if it's on the current table (has_many/has_one/belongs_to)
81
+ if nested_reflection.macro == :belongs_to ||
82
+ nested_reflection.macro == :has_many ||
83
+ nested_reflection.macro == :has_one
84
+ columns |= [nested_fk] if assoc_class.column_names.include?(nested_fk.to_s)
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ fk = reflection.foreign_key.to_sym
91
+ parent_ids = @records.map(&:id).uniq
92
+
93
+ case reflection.macro
94
+ when :has_many, :has_one
95
+ # For has_many/has_one, the foreign key is on the associated table
96
+ columns |= [fk] if fk
97
+
98
+ # Build base query
99
+ base_query = assoc_class.where(fk => parent_ids)
100
+
101
+ # Apply association scope if present
102
+ if reflection.scope
103
+ base_query = base_query.instance_exec(&reflection.scope)
104
+ end
105
+
106
+ associated_records = base_query
107
+ .select(*columns)
108
+ .to_a
109
+
110
+ grouped = associated_records.group_by(&fk)
111
+
112
+ @records.each do |record|
113
+ records_for_parent = grouped[record.id] || []
114
+
115
+ if reflection.macro == :has_many
116
+ record.association(assoc).target = records_for_parent
117
+ else
118
+ record.association(assoc).target = records_for_parent.first
119
+ end
120
+
121
+ record.association(assoc).loaded!
122
+ end
123
+
124
+ # Load nested associations recursively
125
+ load_nested_associations(associated_records, nested_includes, is_without) if nested_includes && associated_records.any?
126
+
127
+ when :belongs_to
128
+ fk_values = @records.map { |r| r.send(fk) }.compact.uniq
129
+
130
+ base_query = assoc_class.where(pk => fk_values)
131
+
132
+ if reflection.scope
133
+ base_query = base_query.instance_exec(&reflection.scope)
134
+ end
135
+
136
+ associated_records = base_query
137
+ .select(*columns)
138
+ .index_by(&pk)
139
+
140
+ @records.each do |record|
141
+ fk_value = record.send(fk)
142
+ if fk_value
143
+ record.association(assoc).target = associated_records[fk_value]
144
+ record.association(assoc).loaded!
145
+ end
146
+ end
147
+
148
+ # Load nested associations recursively
149
+ load_nested_associations(associated_records.values, nested_includes, is_without) if nested_includes && associated_records.any?
150
+ end
151
+
152
+ # Load nested associations for has_many/has_one (already done above in their case blocks)
153
+ end
154
+
155
+ self
156
+ end
157
+
158
+ private
159
+
160
+ def parse_column_spec(column_spec, assoc_class, is_without)
161
+ if column_spec.is_a?(Hash) && (column_spec.key?(:columns) || column_spec.key?(:include))
162
+ # New hash syntax: { columns: [:foo], include: { bar: [:baz] } }
163
+ columns_list = column_spec[:columns]
164
+ nested = column_spec[:include]
165
+ else
166
+ # Legacy array syntax: [:foo, :bar]
167
+ columns_list = column_spec
168
+ nested = nil
169
+ end
170
+
171
+ if is_without
172
+ excluded = Array(columns_list).map(&:to_s)
173
+ all_columns = assoc_class.column_names
174
+ columns = (all_columns - excluded).map(&:to_sym)
175
+ else
176
+ columns = Array(columns_list || [])
177
+ end
178
+
179
+ [columns, nested]
180
+ end
181
+
182
+ def load_nested_associations(records, nested_config, is_without)
183
+ return if records.empty? || nested_config.nil? || nested_config.empty?
184
+
185
+ # Group records by class (in case of STI)
186
+ records_by_class = records.group_by(&:class)
187
+
188
+ records_by_class.each do |klass, klass_records|
189
+ nested_config.each do |nested_assoc, nested_column_spec|
190
+ nested_reflection = klass.reflect_on_association(nested_assoc)
191
+ raise ArgumentError, "Unknown association: #{nested_assoc}" unless nested_reflection
192
+
193
+ nested_assoc_class = nested_reflection.klass
194
+
195
+ # Parse nested column spec
196
+ nested_columns, deeper_nested = parse_column_spec(nested_column_spec, nested_assoc_class, is_without)
197
+
198
+ pk = nested_assoc_class.primary_key.to_sym
199
+ nested_columns |= [pk]
200
+
201
+ if deeper_nested
202
+ deeper_nested.each_key do |deeper_assoc|
203
+ deeper_reflection = nested_assoc_class.reflect_on_association(deeper_assoc)
204
+ if deeper_reflection
205
+ deeper_fk = deeper_reflection.foreign_key.to_sym
206
+ if nested_assoc_class.column_names.include?(deeper_fk.to_s)
207
+ nested_columns |= [deeper_fk]
208
+ end
209
+ end
210
+ end
211
+ end
212
+
213
+ nested_fk = nested_reflection.foreign_key.to_sym
214
+
215
+ case nested_reflection.macro
216
+ when :has_many, :has_one
217
+ nested_columns |= [nested_fk] if nested_fk
218
+
219
+ parent_ids = klass_records.map(&:id).uniq
220
+ base_query = nested_assoc_class.where(nested_fk => parent_ids)
221
+
222
+ if nested_reflection.scope
223
+ base_query = base_query.instance_exec(&nested_reflection.scope)
224
+ end
225
+
226
+ nested_records = base_query.select(*nested_columns).to_a
227
+ grouped = nested_records.group_by(&nested_fk)
228
+
229
+ klass_records.each do |record|
230
+ records_for_parent = grouped[record.id] || []
231
+
232
+ if nested_reflection.macro == :has_many
233
+ record.association(nested_assoc).target = records_for_parent
234
+ else
235
+ record.association(nested_assoc).target = records_for_parent.first
236
+ end
237
+
238
+ record.association(nested_assoc).loaded!
239
+ end
240
+
241
+ load_nested_associations(nested_records, deeper_nested, is_without) if deeper_nested && nested_records.any?
242
+
243
+ when :belongs_to
244
+ fk_values = klass_records.map { |r| r.send(nested_fk) }.compact.uniq
245
+
246
+ base_query = nested_assoc_class.where(pk => fk_values)
247
+
248
+ if nested_reflection.scope
249
+ base_query = base_query.instance_exec(&nested_reflection.scope)
250
+ end
251
+
252
+ nested_records = base_query.select(*nested_columns).index_by(&pk)
253
+
254
+ klass_records.each do |record|
255
+ fk_value = record.send(nested_fk)
256
+ if fk_value
257
+ record.association(nested_assoc).target = nested_records[fk_value]
258
+ record.association(nested_assoc).loaded!
259
+ end
260
+ end
261
+
262
+ # Recursively load deeper nested associations
263
+ load_nested_associations(nested_records.values, deeper_nested, is_without) if deeper_nested && nested_records.any?
264
+ end
265
+ end
266
+ end
267
+ end
268
+ end
269
+
270
+ module ModelMethods
271
+ extend ActiveSupport::Concern
272
+
273
+ class_methods do
274
+ def with_columns(config)
275
+ all.with_columns(config)
276
+ end
277
+
278
+ def without_columns(config)
279
+ all.without_columns(config)
280
+ end
281
+ end
282
+ end
283
+ end
284
+
285
+ if defined?(ActiveRecord)
286
+ ActiveRecord::Relation.prepend(SkinnyIncludes::Relation)
287
+
288
+ ActiveSupport.on_load(:active_record) do
289
+ include SkinnyIncludes::ModelMethods
290
+ end
291
+ end