nested_select 0.2.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/.idea/nested_select.iml +6 -5
- data/.rubocop.yml +83 -0
- data/ABOUT_NESTED_SELECT.md +3 -2
- data/CHANGELOG.md +9 -0
- data/README.md +135 -17
- data/lib/nested_select/deep_merger.rb +28 -0
- data/lib/nested_select/preloader/association.rb +35 -3
- data/lib/nested_select/preloader/branch.rb +19 -0
- data/lib/nested_select/preloader/through_association.rb +68 -6
- data/lib/nested_select/preloader.rb +22 -6
- data/lib/nested_select/relation.rb +20 -4
- data/lib/nested_select/version.rb +1 -1
- data/lib/nested_select.rb +0 -1
- data/test_habtm_reflections.rb +60 -0
- metadata +18 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b07e539c2e4dd0ab26ae3991c3af0cf5943601dae0c538774cff62ac9709db21
|
4
|
+
data.tar.gz: af8cdda70c7dfff3b6fe1feba72d39b5c8bcf372c285ef6110d4f45fa471fbee
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ebbc597b3ecf57cb80da6cff33852a7dff935cb8b8fcd30d2d5223dcfb54b31c415b80df0c05bb48a46ea6bd4f54d158e1a68c3516d3014622b3fc3ebaf81744
|
7
|
+
data.tar.gz: 0aebc50d2cb3dcd2363dd6167f30dc479f0c415e6b5cc1786e623c0cb8be3544df9c4aae2426f77ed5c5fe335b1c2986a8587d5910ba7d8e30fe6cbb516ceb5d
|
data/.idea/nested_select.iml
CHANGED
@@ -59,6 +59,7 @@
|
|
59
59
|
<orderEntry type="library" scope="PROVIDED" name="reline (v0.6.0, RVM: ruby-3.3.4 [ns1]) [gem]" level="application" />
|
60
60
|
<orderEntry type="library" scope="PROVIDED" name="rubocop (v1.70.0, RVM: ruby-3.3.4 [ns1]) [gem]" level="application" />
|
61
61
|
<orderEntry type="library" scope="PROVIDED" name="rubocop-ast (v1.37.0, RVM: ruby-3.3.4 [ns1]) [gem]" level="application" />
|
62
|
+
<orderEntry type="library" scope="PROVIDED" name="rubocop-shopify (v2.15.1, RVM: ruby-3.3.4 [ns1]) [gem]" level="application" />
|
62
63
|
<orderEntry type="library" scope="PROVIDED" name="ruby-progressbar (v1.13.0, RVM: ruby-3.3.4 [ns1]) [gem]" level="application" />
|
63
64
|
<orderEntry type="library" scope="PROVIDED" name="securerandom (v0.4.1, RVM: ruby-3.3.4 [ns1]) [gem]" level="application" />
|
64
65
|
<orderEntry type="library" scope="PROVIDED" name="sqlite3 (v2.5.0, RVM: ruby-3.3.4 [ns1]) [gem]" level="application" />
|
@@ -77,21 +78,21 @@
|
|
77
78
|
<option name="myRootTask">
|
78
79
|
<RakeTaskImpl id="rake">
|
79
80
|
<subtasks>
|
80
|
-
<RakeTaskImpl description="Build nested_select-0.
|
81
|
+
<RakeTaskImpl description="Build nested_select-0.3.0.gem into the pkg directory" fullCommand="build" id="build" />
|
81
82
|
<RakeTaskImpl id="build">
|
82
83
|
<subtasks>
|
83
|
-
<RakeTaskImpl description="Generate SHA512 checksum of nested_select-0.
|
84
|
+
<RakeTaskImpl description="Generate SHA512 checksum of nested_select-0.3.0.gem into the checksums directory" fullCommand="build:checksum" id="checksum" />
|
84
85
|
</subtasks>
|
85
86
|
</RakeTaskImpl>
|
86
87
|
<RakeTaskImpl description="Remove any temporary products" fullCommand="clean" id="clean" />
|
87
88
|
<RakeTaskImpl description="Remove any generated files" fullCommand="clobber" id="clobber" />
|
88
|
-
<RakeTaskImpl description="Build and install nested_select-0.
|
89
|
+
<RakeTaskImpl description="Build and install nested_select-0.3.0.gem into system gems" fullCommand="install" id="install" />
|
89
90
|
<RakeTaskImpl id="install">
|
90
91
|
<subtasks>
|
91
|
-
<RakeTaskImpl description="Build and install nested_select-0.
|
92
|
+
<RakeTaskImpl description="Build and install nested_select-0.3.0.gem into system gems without network access" fullCommand="install:local" id="local" />
|
92
93
|
</subtasks>
|
93
94
|
</RakeTaskImpl>
|
94
|
-
<RakeTaskImpl description="Create tag v0.
|
95
|
+
<RakeTaskImpl description="Create tag v0.3.0 and build and push nested_select-0.3.0.gem to https://rubygems.org" fullCommand="release[remote]" id="release[remote]" />
|
95
96
|
<RakeTaskImpl description="Run RuboCop" fullCommand="rubocop" id="rubocop" />
|
96
97
|
<RakeTaskImpl id="rubocop">
|
97
98
|
<subtasks>
|
data/.rubocop.yml
CHANGED
@@ -6,3 +6,86 @@ Style/StringLiterals:
|
|
6
6
|
|
7
7
|
Style/StringLiteralsInInterpolation:
|
8
8
|
EnforcedStyle: double_quotes
|
9
|
+
|
10
|
+
Style/SingleLineMethods:
|
11
|
+
Description: 'Avoid single-line methods.'
|
12
|
+
StyleGuide: '#no-single-line-methods'
|
13
|
+
Enabled: false
|
14
|
+
VersionAdded: '0.9'
|
15
|
+
VersionChanged: '1.8'
|
16
|
+
AllowIfMethodIsEmpty: true
|
17
|
+
|
18
|
+
Style/AsciiComments:
|
19
|
+
Description: 'Use only ascii symbols in comments.'
|
20
|
+
StyleGuide: '#english-comments'
|
21
|
+
Enabled: false
|
22
|
+
VersionAdded: '0.9'
|
23
|
+
VersionChanged: '1.21'
|
24
|
+
AllowedChars:
|
25
|
+
- ©
|
26
|
+
|
27
|
+
Layout/LineLength:
|
28
|
+
Description: 'Checks that line length does not exceed the configured limit.'
|
29
|
+
StyleGuide: '#max-line-length'
|
30
|
+
Enabled: true
|
31
|
+
VersionAdded: '0.25'
|
32
|
+
VersionChanged: '1.4'
|
33
|
+
Max: 120
|
34
|
+
# To make it possible to copy or click on URIs in the code, we allow lines
|
35
|
+
# containing a URI to be longer than Max.
|
36
|
+
AllowHeredoc: true
|
37
|
+
AllowURI: true
|
38
|
+
URISchemes:
|
39
|
+
- http
|
40
|
+
- https
|
41
|
+
# The IgnoreCopDirectives option causes the LineLength rule to ignore cop
|
42
|
+
# directives like '# rubocop: enable ...' when calculating a line's length.
|
43
|
+
IgnoreCopDirectives: true
|
44
|
+
# The AllowedPatterns option is a list of !ruby/regexp and/or string
|
45
|
+
# elements. Strings will be converted to Regexp objects. A line that matches
|
46
|
+
# any regular expression listed in this option will be ignored by LineLength.
|
47
|
+
AllowedPatterns: []
|
48
|
+
IgnoredPatterns: [] # deprecated
|
49
|
+
Exclude:
|
50
|
+
- "./test/**/*"
|
51
|
+
|
52
|
+
Metrics/ClassLength:
|
53
|
+
Description: 'Avoid classes longer than 100 lines of code.'
|
54
|
+
Enabled: false
|
55
|
+
VersionAdded: '0.25'
|
56
|
+
VersionChanged: '0.87'
|
57
|
+
CountComments: false # count full line comments?
|
58
|
+
Max: 100
|
59
|
+
CountAsOne: []
|
60
|
+
|
61
|
+
Lint/MissingCopEnableDirective:
|
62
|
+
Description: 'Checks for a `# rubocop:enable` after `# rubocop:disable`.'
|
63
|
+
Enabled: true
|
64
|
+
VersionAdded: '0.52'
|
65
|
+
# Maximum number of consecutive lines the cop can be disabled for.
|
66
|
+
# 0 allows only single-line disables
|
67
|
+
# 1 would mean the maximum allowed is the following:
|
68
|
+
# # rubocop:disable SomeCop
|
69
|
+
# a = 1
|
70
|
+
# # rubocop:enable SomeCop
|
71
|
+
# .inf for any size
|
72
|
+
MaximumRangeSize: .inf
|
73
|
+
|
74
|
+
Style/MethodCallWithArgsParentheses:
|
75
|
+
Enabled: true
|
76
|
+
IgnoredMethods:
|
77
|
+
- require
|
78
|
+
- require_relative
|
79
|
+
- require_dependency
|
80
|
+
- yield
|
81
|
+
- raise
|
82
|
+
- puts
|
83
|
+
Exclude:
|
84
|
+
- "/**/Gemfile"
|
85
|
+
|
86
|
+
Style/ClassAndModuleChildren:
|
87
|
+
Enabled: false
|
88
|
+
|
89
|
+
Lint/UnderscorePrefixedVariableName:
|
90
|
+
Exclude:
|
91
|
+
- "./test/**/**/*"
|
data/ABOUT_NESTED_SELECT.md
CHANGED
@@ -52,7 +52,8 @@ Regardless of the major rails version and implementation, you will end up patchi
|
|
52
52
|
So you just need to define a way to deliver select_values to instance of `ActiveRecord::Associations::Preloader::Association`
|
53
53
|
|
54
54
|
### How preloading happens in rails >= 7.0
|
55
|
-
To be honest ( and opinionated :) ), current preloading implementation is
|
55
|
+
To be honest ( and opinionated :) ), current preloading implementation is messy,
|
56
|
+
and we need to adapt to this mess without delivering some more.
|
56
57
|
|
57
58
|
Let's look at the scopes example from a specs:
|
58
59
|
```ruby
|
@@ -83,5 +84,5 @@ To be able to select limited attributes sets, we need to deliver them to `Associ
|
|
83
84
|
|
84
85
|
**_Rem:_** Each `Preloader::ThroughAssociation` object creates it's own `Preloader` and starts additional 'isolated' preloading process.
|
85
86
|
|
86
|
-
The implementation of
|
87
|
+
The implementation of nested_select adds a `nested_select_values` attributes into instances of `Preloader`, `Branch`, `Association` hierarchy
|
87
88
|
and some methods to populate corresponding select_values over the tree, trying to be as less invasive as it could be.
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,12 @@
|
|
1
|
+
## [0.3.0] - 2025-01-25
|
2
|
+
|
3
|
+
- nested_select belongs_to limitation now prevents accidental foreign_key absence
|
4
|
+
- primary keys are no longer need to be nested_selected
|
5
|
+
- nested selects will combine all selections on multiple select invocations like the usual select values does
|
6
|
+
- tests restructured
|
7
|
+
- test/README added
|
8
|
+
- removed biolerplates for basic selection ( you don't need to specify "table.*" in the root collection seletc )
|
9
|
+
|
1
10
|
## [0.2.0] - 2025-01-25
|
2
11
|
|
3
12
|
- Tests are now a part of this repo
|
data/README.md
CHANGED
@@ -1,11 +1,10 @@
|
|
1
1
|
# WIP disclaimer
|
2
|
-
The gem is under active development now.
|
3
|
-
|
4
|
-
|
5
|
-
Use in prod with caution only if you are properly covered by your CI.
|
2
|
+
The gem is under active development now.
|
3
|
+
Use in prod with caution only if you are properly covered by your CI.
|
4
|
+
Read **Safety** and **Limitations** sections before.
|
6
5
|
|
7
6
|
# Nested select -- 7 times faster and 33 times less RAM on preloading relations with heavy columns!
|
8
|
-
nested_select allows
|
7
|
+
nested_select allows the partial selection of the relations attributes during preloading process, leading to less RAM and CPU usage.
|
9
8
|
Here is a benchmark output for a [gist I've created](https://gist.github.com/alekseyl/5d08782808a29df6813f16965f70228a) to emulate real-life example: displaying a course with its structure.
|
10
9
|
|
11
10
|
Given:
|
@@ -24,9 +23,10 @@ nested_select 0.096008 0.002876 0.098884 ( 0.466985)
|
|
24
23
|
simple includes 0.209188 0.058340 0.267528 ( 0.903893)
|
25
24
|
|
26
25
|
----------------- Memory comparison, for root_collection_size: 1 ---------
|
27
|
-
|
26
|
+
# partial selection
|
28
27
|
D, [2025-01-12T19:08:36.163282 #503] DEBUG -- : Topic Load (4.1ms) SELECT "topics"."id", "topics"."position", "topics"."title", "topics"."course_id" FROM "topics" WHERE "topics"."deleted_at" IS NULL AND "topics"."course_id" = $1 [["course_id", 1624]]
|
29
28
|
D, [2025-01-12T19:08:36.168803 #503] DEBUG -- : Lesson Load (3.9ms) SELECT "lessons"."id", "lessons"."title", "lessons"."topic_id", "lessons"."position", "lessons"."topic_id" FROM "lessons" WHERE "lessons"."deleted_at" IS NULL AND "lessons"."topic_id" = $1 [["topic_id", 7297]]
|
29
|
+
# includes in full
|
30
30
|
D, [2025-01-12T19:08:37.220379 #503] DEBUG -- : Topic Load (4.2ms) SELECT "topics"."id", "topics"."position", "topics"."title", "topics"."course_id" FROM "topics" WHERE "topics"."deleted_at" IS NULL AND "topics"."course_id" = $1 [["course_id", 1624]]
|
31
31
|
D, [2025-01-12T19:08:37.247484 #503] DEBUG -- : Lesson Load (25.7ms) SELECT "lessons".* FROM "lessons" WHERE "lessons"."deleted_at" IS NULL AND "lessons"."topic_id" = $1 [["topic_id", 7297]]
|
32
32
|
|
@@ -50,7 +50,7 @@ irb(main):280:0> compare_nested_select(ids, 100)
|
|
50
50
|
------- CPU comparison, for root_collection_size: 100 ----
|
51
51
|
user system total real
|
52
52
|
nested_select 1.571095 0.021778 1.592873 ( 2.263369)
|
53
|
-
simple includes 5.374909 1.704284 7.079193 ( 15.488579)
|
53
|
+
simple includes 5.374909 1.704284 7.079193 ( 15.488579)
|
54
54
|
|
55
55
|
----------------- Memory comparison, for root_collection_size: 100 ---------
|
56
56
|
------ Nested Select memory consumption for root_collection_size: 100 ------
|
@@ -64,9 +64,12 @@ Total allocated: 33.05 MB (38332 objects)
|
|
64
64
|
Total retained: 32.00 MB (24057 objects)
|
65
65
|
RAM ratio improvements x15.57707431190517 on retain objects
|
66
66
|
RAM ratio improvements x11.836000856510193 on total_allocated objects
|
67
|
-
|
68
67
|
```
|
69
68
|
|
69
|
+
Despite this little click bait it's pretty obvious that it might not be even the biggest numbers,
|
70
|
+
if you have heavy relations instantiation for heavy views or reports generation,
|
71
|
+
and you want it to be less demanding in RAM and CPU -- you should try nested_select
|
72
|
+
|
70
73
|
## Installation
|
71
74
|
|
72
75
|
Install the gem and add to the application's Gemfile by executing:
|
@@ -78,19 +81,124 @@ If bundler is not being used to manage dependencies, install the gem by executin
|
|
78
81
|
$ gem install nested_select
|
79
82
|
|
80
83
|
## Usage
|
84
|
+
|
85
|
+
### Specify which attributes to load in preloading models
|
81
86
|
Assume you have a relation users <- profile, and you want to preview users in a paginated feed,
|
82
|
-
and you need only :
|
87
|
+
and you need only :photo_url attribute of a profile, with nested_select you can do it like this:
|
83
88
|
|
84
89
|
```ruby
|
85
|
-
|
86
|
-
|
90
|
+
class User
|
91
|
+
has_one :profile
|
92
|
+
end
|
93
|
+
|
94
|
+
class Profile
|
95
|
+
belongs_to :user
|
96
|
+
end
|
97
|
+
|
98
|
+
# this will preload profile with exact attributes:
|
99
|
+
# :id -- since its a primary key,
|
100
|
+
# :user_id -- since its a foreign_key
|
101
|
+
# and the :photo_url as requested
|
102
|
+
User.includes(:profile).select(profile: :photo_url).limit(10)
|
87
103
|
```
|
88
104
|
|
89
|
-
|
105
|
+
### Partial through relations preloading
|
106
|
+
Whenever you are using through relations between models rails will fully load all intermediate objects under the hood,
|
107
|
+
that is definitely wastes lots of RAM, CPU including those on the DB side.
|
108
|
+
You can limit through objects only to relation columns.
|
109
|
+
Ex:
|
110
|
+
|
111
|
+
```ruby
|
112
|
+
class User
|
113
|
+
has_one :user_profile, inverse_of: :user
|
114
|
+
has_many :avatars, through: :user_profile, inverse_of: :user
|
115
|
+
end
|
116
|
+
|
117
|
+
# pay attention user_profile relation, wasn't included explicitly,
|
118
|
+
# but still rails needed to be preloaded to match and preload avatars here
|
119
|
+
user = User.includes(:avatars)
|
120
|
+
.select(avatars: [:img_url, { user_profile: [:zip_code] }]).first
|
121
|
+
|
122
|
+
# Now user - loaded fully
|
123
|
+
# avatars - foreign and primary keys needed to establish relations + img_url
|
124
|
+
# user_profile - foreign and primary keys + zip_code
|
125
|
+
```
|
126
|
+
**REM:** Through preloading happens in reverse, so to nest their selection
|
127
|
+
you must start from the latest, in this case avatar, and go to the previous ones in this case its a user_profile
|
128
|
+
|
129
|
+
If you want intermediate models to be completely skinny, you should select like this:
|
130
|
+
|
131
|
+
```ruby
|
132
|
+
class User
|
133
|
+
has_one :user_profile, inverse_of: :user
|
134
|
+
has_many :avatars, through: :user_profile, inverse_of: :user
|
135
|
+
has_many :through_avatar_images, through: :avatars, class_name: :Image, source: :images
|
136
|
+
end
|
137
|
+
|
138
|
+
# only through_avatar_images is matter here, and we want everything
|
139
|
+
user = User.includes(:through_avatar_images)
|
140
|
+
.select(through_avatar_images: ["images.*", avatars: [user_profile: [:id]]]).first
|
141
|
+
|
142
|
+
# through_avatar_images -- loaded in full
|
143
|
+
# avatars, user_profile -- only relations columns id, user_profile_id e.t.c
|
144
|
+
```
|
145
|
+
**REM** As for version 0.4.0 for the earliest relation in a through chain you need to select something,
|
146
|
+
otherwise nested_select will select everything ))
|
147
|
+
|
148
|
+
# Safety
|
90
149
|
How safe is the partial model loading? Earlier version of rails and activerecord would return nil in the case,
|
91
150
|
when attribute wasn't selected from a DB, but rails 6 started to raise a ActiveModel::MissingAttributeError.
|
92
151
|
So the major problem is already solved -- your code will not operate based on falsy blank values, it will raise an exception.
|
93
|
-
|
152
|
+
|
153
|
+
But if you are working with attributes directly ( which you should not btw ), you will see nil, without any exception.
|
154
|
+
Using as_json on such models will also deliver json without exception and without skipped attributes.
|
155
|
+
|
156
|
+
## Partial selection in multiple preloading branches
|
157
|
+
If you are doing some strange or narrow cases whenever you preloading same objects via different preloading branches,
|
158
|
+
including the most common case through relations, which rails preloads in full, then you must be very accurate
|
159
|
+
with nested selection, cause rails loads and attach associations only once, if it was partial
|
160
|
+
than you might get yourself into trouble. BUT nested_select will check and raise an exception
|
161
|
+
if you are trying to re-instantiate with a different set of attributes. Ex:
|
162
|
+
|
163
|
+
```
|
164
|
+
ActiveModel::MissingAttributeError: Reflection 'avatars' already loaded with a different set of basic attributes.
|
165
|
+
expected: ["img_url", "user_profile_id", "id"], already loaded with: ["created_at", "user_profile_id", "id"]
|
166
|
+
Hint: ensure that you are using same set of attributes for entrance of same relation
|
167
|
+
on nesting selection tree including reverse through relations
|
168
|
+
```
|
169
|
+
|
170
|
+
# Limitations
|
171
|
+
|
172
|
+
## belongs_to foreign keys limitations
|
173
|
+
Rails preloading happens from loaded records to their reflections step by step.
|
174
|
+
That's makes it pretty easy to include foreign keys for has_* relations, and very hard for belongs_to,
|
175
|
+
to work this out you need to analyze includes based on the already loaded records, analyze and traverse their relations.
|
176
|
+
This needs a lot of monkey patching, and for now I decided not to go this way.
|
177
|
+
That means in case when nesting selects based on belongs_to reflections,
|
178
|
+
you'll need to select their foreign keys **EXPLICITLY!**
|
179
|
+
|
180
|
+
## will not work with ar_lazy_preload
|
181
|
+
Right now it will not work with ar_lazy_preload gem. nested_select relies on the includes_values definition
|
182
|
+
of a relation. If you are doing it in a lazy way, there weren't any explicit includes, that means it will not extract any nested selection.
|
183
|
+
|
184
|
+
```ruby
|
185
|
+
class Avatar < ApplicationRecord
|
186
|
+
belongs_to user
|
187
|
+
has_one :image
|
188
|
+
end
|
189
|
+
|
190
|
+
class Image < ApplicationRecord
|
191
|
+
belongs_to :avatar
|
192
|
+
end
|
193
|
+
|
194
|
+
Image.includes(avatar: :user).select(avatar: [:size, { user: [:email] }]).load # <--- will raise a Missing Attribute exception
|
195
|
+
|
196
|
+
#> ActiveModel::MissingAttributeError: Parent reflection avatar was missing foreign key user_id in nested selection
|
197
|
+
#> while trying to preload belongs_to reflection named user.
|
198
|
+
#> Hint: didn't you forgot to add user_id inside [:id, :size]?
|
199
|
+
|
200
|
+
Image.includes(avatar: :user).select(avatar: [:size, :user_id, { user: [:email] }]).load
|
201
|
+
```
|
94
202
|
|
95
203
|
## Testing
|
96
204
|
|
@@ -98,15 +206,25 @@ Just cover your actions with proper tests and you are safe.
|
|
98
206
|
docker compose run test
|
99
207
|
```
|
100
208
|
|
101
|
-
##
|
209
|
+
## TODO
|
210
|
+
- [x] Cover all relation combinations and add missing functionality
|
211
|
+
- [x] Ensure relations foreign keys are present on the selection
|
212
|
+
- [x] Ensure primary key will be added
|
213
|
+
- [-] Ensure belongs_to will add a foreign_key column (Too hard to manage :(, its definitely not a low hanging fruit)
|
214
|
+
- [x] Optimize through relations ( since they loading a whole set of attributes )
|
215
|
+
- [ ] Separated rails version testing
|
216
|
+
- [x] Merge multiple nested selections
|
217
|
+
- [x] Don't apply any selection if blank ( allows to limit only part of subselection tree)
|
218
|
+
- [x] Allows to use custom attributes
|
219
|
+
- [ ] Eager loading?
|
102
220
|
|
103
|
-
|
221
|
+
## Development
|
104
222
|
|
105
|
-
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
106
223
|
|
107
224
|
## Contributing
|
108
225
|
|
109
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/
|
226
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/alekseyl/nested_select. This project is intended to be a safe,
|
227
|
+
welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/nested_select/blob/master/CODE_OF_CONDUCT.md).
|
110
228
|
|
111
229
|
## License
|
112
230
|
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module NestedSelect
|
2
|
+
module DeepMerger
|
3
|
+
|
4
|
+
# {user_profile: [:zip_code]} + {user_profile: [:bio]} -> { user_profile: [:zip_code, :bio] }
|
5
|
+
refine Hash do
|
6
|
+
def deep_combine(other)
|
7
|
+
merge!(other.except(*keys))
|
8
|
+
merge!(other.slice(*keys).map{ |key, value| [key, [self[key], value].flatten.deep_combine_elements]}.to_h )
|
9
|
+
end
|
10
|
+
|
11
|
+
def relation_nesting_depth
|
12
|
+
1 + [*values.grep(Hash), *values.grep(Array)].map(&:relation_nesting_depth).max.to_i
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
refine Array do
|
17
|
+
def deep_combine_elements
|
18
|
+
[*grep_v(Hash), grep(Hash).inject(&:deep_combine)].uniq.compact
|
19
|
+
end
|
20
|
+
# in terms of relation/selection nesting every array node
|
21
|
+
# should not add +1 to depth, that mean that array nesting
|
22
|
+
# does not count as nesting [[[]]] has depth equal 0.
|
23
|
+
def relation_nesting_depth
|
24
|
+
[*grep(Hash), *grep(Array)].map(&:relation_nesting_depth).max.to_i
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -4,14 +4,46 @@ module NestedSelect
|
|
4
4
|
attr_reader :nested_select_values
|
5
5
|
|
6
6
|
def build_scope
|
7
|
-
nested_select_values.blank? ? super
|
8
|
-
|
7
|
+
nested_select_values.blank? ? super :
|
8
|
+
super.select(*nested_select_values.grep_v(Hash).map{_1.try(:to_s) }.uniq)
|
9
9
|
end
|
10
10
|
|
11
11
|
def apply_nested_select_values(partial_select_values)
|
12
12
|
foreign_key = reflection.foreign_key unless reflection.is_a?(ActiveRecord::Reflection::BelongsToReflection)
|
13
|
+
@nested_select_values = [*partial_select_values, *foreign_key, *reflection.klass.primary_key]
|
14
|
+
ensure_nesting_selection_integrity!
|
15
|
+
end
|
16
|
+
|
17
|
+
def ensure_nesting_selection_integrity!
|
18
|
+
single_owner = owners.first
|
19
|
+
# do nothing unless not yet loaded
|
20
|
+
return unless single_owner.association(reflection.name).loaded?
|
21
|
+
|
22
|
+
single_reflection_record = single_owner.send(reflection.name)
|
23
|
+
return if single_reflection_record.blank?
|
24
|
+
|
25
|
+
single_reflection_record = single_reflection_record.first if single_reflection_record.is_a?(Enumerable)
|
26
|
+
|
27
|
+
attributes_loaded = single_reflection_record.attributes.keys.map(&:to_s)
|
28
|
+
current_selection = @nested_select_values.grep_v(Hash).map(&:to_s)
|
29
|
+
|
30
|
+
basic_attributes_matched = (attributes_loaded & reflection.klass.column_names).tally ==
|
31
|
+
(current_selection & reflection.klass.column_names).tally
|
32
|
+
|
33
|
+
# this is not a 100% safe verification, but it will match cases with custom attributes selection for example:
|
34
|
+
# "(SELECT COUNT(*) FROM images) as IMG_count" =~ /img_count/
|
35
|
+
custom_attributes_matched = (attributes_loaded - reflection.klass.column_names).all? do |loaded_attr|
|
36
|
+
(current_selection - reflection.klass.column_names).any? do |upcoming_custom_attr|
|
37
|
+
upcoming_custom_attr =~ /#{loaded_attr}/i
|
38
|
+
end
|
39
|
+
end
|
13
40
|
|
14
|
-
|
41
|
+
raise ActiveModel::MissingAttributeError, <<~ERR if !basic_attributes_matched || !custom_attributes_matched
|
42
|
+
Reflection '#{reflection.name}' already loaded with a different set of basic attributes.
|
43
|
+
expected: #{current_selection}, already loaded with: #{attributes_loaded}
|
44
|
+
Hint: ensure that you are using same set of attributes for entrance of same relation
|
45
|
+
on nesting selection tree including reverse through relations
|
46
|
+
ERR
|
15
47
|
end
|
16
48
|
end
|
17
49
|
end
|
@@ -4,11 +4,30 @@ module NestedSelect
|
|
4
4
|
module Branch
|
5
5
|
attr_accessor :nested_select_values
|
6
6
|
def preloaders_for_reflection(reflection, reflection_records)
|
7
|
+
prevent_belongs_to_foreign_key_absence!(reflection)
|
8
|
+
|
7
9
|
super.tap do |ldrs|
|
10
|
+
# nested_select_values contains current level selection + nested relation selections
|
8
11
|
ldrs.each{ _1.apply_nested_select_values(nested_select_values) } if nested_select_values.present?
|
9
12
|
end
|
10
13
|
end
|
11
14
|
|
15
|
+
private
|
16
|
+
def prevent_belongs_to_foreign_key_absence!(reflection)
|
17
|
+
return unless reflection.is_a?(ActiveRecord::Reflection::BelongsToReflection)
|
18
|
+
|
19
|
+
# ActiveRecord will not raise in case its missing, so we should prevent silent error here
|
20
|
+
if parent.nested_select_values.present? &&
|
21
|
+
!parent.nested_select_values.grep_v(Hash)
|
22
|
+
.map(&:to_sym).include?(reflection.foreign_key.to_sym)
|
23
|
+
|
24
|
+
raise ActiveModel::MissingAttributeError, <<~ERR
|
25
|
+
Parent reflection #{parent.association} was missing foreign key #{reflection.foreign_key} in nested selection,
|
26
|
+
while trying to preload belongs_to reflection named #{reflection.name}.
|
27
|
+
Hint: didn't you forgot to add #{reflection.foreign_key} inside #{parent.nested_select_values}?
|
28
|
+
ERR
|
29
|
+
end
|
30
|
+
end
|
12
31
|
end
|
13
32
|
end
|
14
33
|
end
|
@@ -1,22 +1,84 @@
|
|
1
1
|
module NestedSelect
|
2
2
|
module Preloader
|
3
3
|
module ThroughAssociation
|
4
|
+
# this preloader root will preload intermediate records, so here we should apply 'through'
|
5
|
+
# selection limitation AS A BASIC nested selection and it wuold be either __minimize_through_selection sym OR
|
6
|
+
# nested_selection tree
|
7
|
+
# def source_preloaders
|
8
|
+
# @source_preloaders ||= ActiveRecord::Associations::Preloader.new(
|
9
|
+
# records: middle_records,
|
10
|
+
# associations: source_reflection.name,
|
11
|
+
# scope: scope,
|
12
|
+
# associate_by_default: false
|
13
|
+
# ).tap {
|
14
|
+
# byebug
|
15
|
+
# _1.apply_nested_select_values([*@limit_through_selection])
|
16
|
+
# }.loaders
|
17
|
+
# end
|
4
18
|
def through_preloaders
|
5
19
|
@through_preloaders ||= ActiveRecord::Associations::Preloader.new(
|
6
20
|
records: owners,
|
7
21
|
associations: through_reflection.name,
|
8
22
|
scope: through_scope,
|
9
23
|
associate_by_default: false,
|
10
|
-
).tap
|
24
|
+
).tap do
|
25
|
+
_1.apply_nested_select_values(nested_select_values.grep(Hash))
|
26
|
+
end.loaders
|
11
27
|
end
|
12
28
|
|
13
|
-
def
|
14
|
-
|
29
|
+
# def through_scope
|
30
|
+
# if @limit_through_selection.present?
|
31
|
+
# # through_selection is either __minimize_through_selection symbol, or an array
|
32
|
+
# through_selection = [*@limit_through_selection]
|
33
|
+
# through_selection = [*through_selection.grep_v(Hash),
|
34
|
+
# *through_selection.grep(Hash).first&.dig(through_reflection.source_reflection_name)]
|
35
|
+
# through_selection << through_reflection.foreign_key
|
36
|
+
# through_selection << through_reflection.klass.primary_key
|
37
|
+
# super.select(through_selection)
|
38
|
+
# else
|
39
|
+
# super
|
40
|
+
# end
|
41
|
+
# end
|
15
42
|
|
16
|
-
|
17
|
-
|
18
|
-
|
43
|
+
# def through_scope
|
44
|
+
# if @limit_through_selection.present?
|
45
|
+
# super.select(through_reflection.foreign_key.to_sym, through_reflection.klass.primary_key.to_sym)
|
46
|
+
# else
|
47
|
+
# super
|
48
|
+
# end
|
49
|
+
# end
|
50
|
+
# def through_selection_nesting
|
51
|
+
# return if @limit_through_selection.blank?
|
52
|
+
# through_limit_selection = [through_reflection.foreign_key.to_sym, through_reflection.klass.primary_key.to_sym]
|
53
|
+
# through_limit_selection << :__minimize_through_selection if through_reflection.is_a?(ActiveRecord::Reflection::ThroughReflection)
|
54
|
+
# through_limit_selection
|
55
|
+
# end
|
56
|
+
|
57
|
+
def apply_nested_select_values(partial_select_values)
|
58
|
+
|
59
|
+
if reflection.parent_reflection.is_a?(ActiveRecord::Reflection::HasAndBelongsToManyReflection)
|
60
|
+
# when parent reflection is a HasAndBelongsToManyReflection,
|
61
|
+
# then we don't need foreign_key to be included, as it does in super
|
62
|
+
@nested_select_values = partial_select_values
|
63
|
+
else
|
64
|
+
@limit_through_selection = partial_select_values.delete(:__minimize_through_selection)
|
65
|
+
super(partial_select_values)
|
66
|
+
end
|
19
67
|
end
|
68
|
+
# def exract_through_selections(partial_select_values)
|
69
|
+
# # __minimize_through_selection: [ :user_id, user_profile: [] ]
|
70
|
+
# # there should not be more than one such limitation definition
|
71
|
+
# through_selection_rules, cleaned_partial_select_values = partial_select_values&.partition do
|
72
|
+
# _1 == :__minimize_through_selection || _1.is_a?(Hash) && _1[:__minimize_through_selection].present?
|
73
|
+
# end
|
74
|
+
# @limit_through_selection = through_selection_rules.map do
|
75
|
+
# _1.is_a?(Hash) && _1[:__minimize_through_selection] || _1
|
76
|
+
# end.first
|
77
|
+
#
|
78
|
+
# byebug
|
79
|
+
# cleaned_partial_select_values
|
80
|
+
# end
|
81
|
+
|
20
82
|
end
|
21
83
|
end
|
22
84
|
end
|
@@ -9,25 +9,41 @@ module NestedSelect
|
|
9
9
|
|
10
10
|
included do
|
11
11
|
ActiveRecord::Associations::Preloader::Branch.prepend(Branch)
|
12
|
-
ActiveRecord::Associations::Preloader::ThroughAssociation.prepend(
|
13
|
-
ActiveRecord::Associations::Preloader::Association.prepend(
|
12
|
+
ActiveRecord::Associations::Preloader::ThroughAssociation.prepend(ThroughAssociation)
|
13
|
+
ActiveRecord::Associations::Preloader::Association.prepend(Association)
|
14
14
|
end
|
15
|
+
|
16
|
+
# first one will start from the roots [included_1: [{}], included_2: [{}] ]
|
15
17
|
def apply_nested_select_values(nested_select_values)
|
16
18
|
distribute_nested_select_over_loading_tree(@tree, nested_select_values)
|
17
19
|
end
|
18
20
|
|
21
|
+
# this one either subtree roots only when its first, OR current branch selection root
|
22
|
+
# + its branches in array
|
23
|
+
# 1) [:__minimize_through_selection, ...]
|
24
|
+
# 2) []
|
25
|
+
# Логика следующая:
|
26
|
+
# если мы передаем __minimize_through_selection как символ, то его надо пробросить до всех бранчей, они с этим разберутся, там где надо
|
27
|
+
# если у нас там хеш, то его надо оставить на текущем уровне и дальше если это through, то оно превратит это в нестед селект для вложенных
|
19
28
|
def distribute_nested_select_over_loading_tree(sub_tree, nested_select_values)
|
20
29
|
# nested_select_values = [:id, :title, comments: [:id, :body], cover: [:id, img: [:url]]]
|
21
30
|
return if nested_select_values.blank?
|
22
31
|
|
23
|
-
sub_tree.nested_select_values = nested_select_values.grep_v(Hash)
|
32
|
+
sub_tree.nested_select_values = [*nested_select_values.grep_v(Hash)]
|
24
33
|
# sub_nested_select_values = { comments: [:id, :body], cover: [:id, img: [:url]] }
|
25
|
-
sub_nested_select_values = nested_select_values.grep(Hash).inject(&:merge)&.symbolize_keys
|
34
|
+
sub_nested_select_values = nested_select_values.grep(Hash).inject({}, &:merge)&.symbolize_keys
|
35
|
+
|
26
36
|
# it could be a case when selection tree is not that deep than Branch tree.
|
27
37
|
return if sub_nested_select_values.blank?
|
28
38
|
|
29
|
-
|
30
|
-
|
39
|
+
# its possible to subselect in reverse direction for through relation's
|
40
|
+
# in that case includes are implicit, but we need to add that reverse tree into nested select
|
41
|
+
reverse_nested_selections = sub_nested_select_values.except(*sub_tree.children.map(&:association))
|
42
|
+
# this is for reverse selection of through models
|
43
|
+
sub_tree.nested_select_values << reverse_nested_selections if reverse_nested_selections.present?
|
44
|
+
|
45
|
+
sub_tree.children.each do |chld_brnch|
|
46
|
+
distribute_nested_select_over_loading_tree(chld_brnch, sub_nested_select_values[chld_brnch.association])
|
31
47
|
end
|
32
48
|
end
|
33
49
|
end
|
@@ -1,22 +1,38 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
+
require_relative "deep_merger"
|
2
3
|
|
3
4
|
module NestedSelect
|
4
5
|
module Relation
|
6
|
+
using ::NestedSelect::DeepMerger
|
5
7
|
|
6
8
|
attr_accessor :nested_select_values
|
7
9
|
def select(*fields)
|
8
|
-
|
9
|
-
|
10
|
+
# {user_profile: [:zip_code]} + {user_profile: [:bio]} -> { user_profile: [:zip_code, :bio] }
|
11
|
+
@nested_select_values = [*@nested_select_values, *fields.grep(Hash)].deep_combine_elements
|
12
|
+
# returning self means -- there was only nesting selection,
|
13
|
+
# and we should not interfere with default selection
|
14
|
+
fields.grep_v(Hash).present? ? super(*fields.grep_v(Hash)) : self
|
10
15
|
end
|
11
16
|
|
17
|
+
# # when nested_select interferes the 'through' selection, its doing this in reverse
|
18
|
+
# # in this case the first one preload wins a selection scope,
|
19
|
+
# # so we need to make them all the same across all selection trees
|
20
|
+
# # ( except for the cases when traversing ends up in same place using different path,
|
21
|
+
# # this case is out of the normal sense )
|
22
|
+
# def combine_reverse_selection_sub_trees
|
23
|
+
# @nested_select_values.permutation.each do |left, right|
|
24
|
+
#
|
25
|
+
# end
|
26
|
+
# end
|
27
|
+
|
12
28
|
def preload_associations(records) # :nodoc:
|
13
29
|
preload = preload_values
|
14
30
|
preload += includes_values unless eager_loading?
|
15
31
|
scope = strict_loading_value ? StrictLoadingScope : nil
|
16
32
|
preload.each do |associations|
|
17
33
|
ActiveRecord::Associations::Preloader.new(records:, associations:, scope:)
|
18
|
-
|
19
|
-
|
34
|
+
.tap{_1.apply_nested_select_values(nested_select_values) } # <-- Patching code
|
35
|
+
.call
|
20
36
|
end
|
21
37
|
end
|
22
38
|
end
|
data/lib/nested_select.rb
CHANGED
@@ -0,0 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "test_helper"
|
3
|
+
|
4
|
+
class TestHabtmReflections < ActiveSupport::TestCase
|
5
|
+
include ActiveRecord::TestFixtures
|
6
|
+
|
7
|
+
self.use_instantiated_fixtures = true
|
8
|
+
|
9
|
+
|
10
|
+
test "select allows nesting attribute selection" do
|
11
|
+
item = Item.includes(users: [user_profile: :avatars])
|
12
|
+
.select("items.*", users: [:id, :name, user_profile: [:id, :bio, avatars: [:img_url]]])
|
13
|
+
.find(identify(:mug))
|
14
|
+
|
15
|
+
user = item.users.first
|
16
|
+
assert_equal(user.name, "Frodo")
|
17
|
+
assert_raises(ActiveModel::MissingAttributeError) { user.membership }
|
18
|
+
assert_equal(user.reload.membership, "basic")
|
19
|
+
|
20
|
+
assert_includes(user.user_profile.avatars.map(&:img_url), avatars(:frodo_avatar).img_url)
|
21
|
+
end
|
22
|
+
|
23
|
+
test "works fine with inverse_of basic reflection" do
|
24
|
+
user = User.includes(user_profile: :avatars)
|
25
|
+
.select("users.*", user_profile: [:id, :user_id, avatars: [:id, :user_profile_id]])
|
26
|
+
.find(identify(:frodo))
|
27
|
+
|
28
|
+
# NestedSelect::Preloader::Branch#preloaders_for_reflection
|
29
|
+
assert_equal(user.user_profile.user.object_id, user.object_id)
|
30
|
+
assert_equal(user.user_profile.object_id, user.user_profile.avatars.first.user_profile.object_id)
|
31
|
+
end
|
32
|
+
|
33
|
+
test "works fine with through reflection" do
|
34
|
+
user = User.includes(:avatars)
|
35
|
+
.select("users.*", avatars: [:id, :user_profile_id])
|
36
|
+
.find(identify(:frodo))
|
37
|
+
|
38
|
+
assert_raises(ActiveModel::MissingAttributeError) { user.avatars.first.img_url }
|
39
|
+
assert_equal(user.avatars, [avatars(:frodo_avatar)])
|
40
|
+
assert_equal(user.avatars.first.reload.img_url, "https://api.rubyonrails.org/")
|
41
|
+
end
|
42
|
+
|
43
|
+
test "partial selection always includes foreign keys" do
|
44
|
+
user = User.includes(user_profile: :avatars)
|
45
|
+
.select("users.*", user_profile: [:id, :bio])
|
46
|
+
.find(identify(:frodo))
|
47
|
+
|
48
|
+
assert_equal(user.user_profile.user_id, user.id)
|
49
|
+
end
|
50
|
+
|
51
|
+
test "partial selection always includes foreign keys also for through reflection bb" do
|
52
|
+
user = User.includes(:avatars)
|
53
|
+
.select("users.*", avatars: [:id, :img_url])
|
54
|
+
.find(identify(:frodo))
|
55
|
+
|
56
|
+
assert_equal(user.avatars, [avatars(:frodo_avatar)])
|
57
|
+
assert_not_nil(user.avatars.first.user_profile_id)
|
58
|
+
end
|
59
|
+
|
60
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: nested_select
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- alekseyl
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-
|
11
|
+
date: 2025-02-09 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -164,6 +164,20 @@ dependencies:
|
|
164
164
|
- - ">="
|
165
165
|
- !ruby/object:Gem::Version
|
166
166
|
version: '0'
|
167
|
+
- !ruby/object:Gem::Dependency
|
168
|
+
name: rubocop-shopify
|
169
|
+
requirement: !ruby/object:Gem::Requirement
|
170
|
+
requirements:
|
171
|
+
- - ">="
|
172
|
+
- !ruby/object:Gem::Version
|
173
|
+
version: '0'
|
174
|
+
type: :development
|
175
|
+
prerelease: false
|
176
|
+
version_requirements: !ruby/object:Gem::Requirement
|
177
|
+
requirements:
|
178
|
+
- - ">="
|
179
|
+
- !ruby/object:Gem::Version
|
180
|
+
version: '0'
|
167
181
|
description: ActiveRecord improved select on nested models, allows partial instantiation
|
168
182
|
on nested models, easy one step improvements on performance and memory
|
169
183
|
email:
|
@@ -188,6 +202,7 @@ files:
|
|
188
202
|
- Rakefile
|
189
203
|
- docker-compose.yml
|
190
204
|
- lib/nested_select.rb
|
205
|
+
- lib/nested_select/deep_merger.rb
|
191
206
|
- lib/nested_select/preloader.rb
|
192
207
|
- lib/nested_select/preloader/association.rb
|
193
208
|
- lib/nested_select/preloader/branch.rb
|
@@ -195,6 +210,7 @@ files:
|
|
195
210
|
- lib/nested_select/relation.rb
|
196
211
|
- lib/nested_select/version.rb
|
197
212
|
- sig/nested_select.rbs
|
213
|
+
- test_habtm_reflections.rb
|
198
214
|
homepage: https://github.com/alekseyl/nested_select
|
199
215
|
licenses:
|
200
216
|
- MIT
|