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 +7 -0
- data/.github/workflows/ci.yml +27 -0
- data/CHANGELOG.md +75 -0
- data/README.md +163 -0
- data/RELEASING.md +62 -0
- data/Rakefile +4 -0
- data/lib/skinny_includes/version.rb +5 -0
- data/lib/skinny_includes.rb +291 -0
- data/spec/skinny_includes_spec.rb +1079 -0
- metadata +64 -0
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,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
|