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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 295706af2c9d8f419f850ddcc81775e1ebb9708320fb6c06bd1d2fcc8f57405b
4
- data.tar.gz: a9270172f9899183ca057294c5dbf358486ae7ca6cf97c2387ef3f009f4ca99c
3
+ metadata.gz: b07e539c2e4dd0ab26ae3991c3af0cf5943601dae0c538774cff62ac9709db21
4
+ data.tar.gz: af8cdda70c7dfff3b6fe1feba72d39b5c8bcf372c285ef6110d4f45fa471fbee
5
5
  SHA512:
6
- metadata.gz: 539b57d526b2d3d6a15f9bef1a8bfe104779e02c409aa64fd6765aaee0fc5b4c4a86d2a92fd44f559dbde686d5fb7713ee81e72df815aeada368fc28eeb9a480
7
- data.tar.gz: e9bd63e18fd429b8e2443e3c20d34c5a37b9eb7dd9e408ded4bcee3d109c4617e15e006cec32f996aff299ac3bdbb7b0150b637a756e6d388980f8f07fcb7f58
6
+ metadata.gz: ebbc597b3ecf57cb80da6cff33852a7dff935cb8b8fcd30d2d5223dcfb54b31c415b80df0c05bb48a46ea6bd4f54d158e1a68c3516d3014622b3fc3ebaf81744
7
+ data.tar.gz: 0aebc50d2cb3dcd2363dd6167f30dc479f0c415e6b5cc1786e623c0cb8be3544df9c4aae2426f77ed5c5fe335b1c2986a8587d5910ba7d8e30fe6cbb516ceb5d
@@ -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.1.0.gem into the pkg directory" fullCommand="build" id="build" />
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.1.0.gem into the checksums directory" fullCommand="build:checksum" id="checksum" />
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.1.0.gem into system gems" fullCommand="install" id="install" />
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.1.0.gem into system gems without network access" fullCommand="install:local" id="local" />
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.1.0 and build and push nested_select-0.1.0.gem to https://rubygems.org" fullCommand="release[remote]" id="release[remote]" />
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/**/**/*"
@@ -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 a kinda mess, and we need to adapt to this mess without delivering some more.
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 nested_seelct adds a `nested_select_values` attributes into instances of `Preloader`, `Branch`, `Association` hierarchy
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. As of version 0.2.0 you are safe to try in your prod console
3
- to uncover it's potential, and try in dev/test env.
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 to select attributes of relations during preloading process, leading to less RAM and CPU usage.
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 :photo attribute of a profile, with nested_select you can do it like this:
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
- # this will preload profile with exact attributes: :id, :user_id and :photo
86
- User.includes(:profile).select(profile: :photo).limit(10)
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
- ## Safety
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
- Just cover your actions with proper tests and you are safe.
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
- ## Development
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
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
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/[USERNAME]/nested_select. This project is intended to be a safe, 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).
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
- : super.select(*nested_select_values)
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
- @nested_select_values = [*partial_select_values, *foreign_key].uniq
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 { _1.apply_nested_select_values(nested_select_values) }.loaders
24
+ ).tap do
25
+ _1.apply_nested_select_values(nested_select_values.grep(Hash))
26
+ end.loaders
11
27
  end
12
28
 
13
- def apply_nested_select_values( partial_select_values )
14
- return super unless reflection.parent_reflection.is_a?( ActiveRecord::Reflection::HasAndBelongsToManyReflection)
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
- # when parent reflection is a HasAndBelongsToManyReflection,
17
- # then we don't need foreign_key to be included, as it does in super
18
- @nested_select_values = partial_select_values
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( ThroughAssociation )
13
- ActiveRecord::Associations::Preloader::Association.prepend( Association )
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
- sub_tree.children.each do
30
- distribute_nested_select_over_loading_tree( _1, sub_nested_select_values[_1.association])
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
- @nested_select_values = fields.grep(Hash)
9
- super(*fields.grep_v(Hash))
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
- .tap{ _1.apply_nested_select_values(nested_select_values) } # <-- Patching code
19
- .call
34
+ .tap{_1.apply_nested_select_values(nested_select_values) } # <-- Patching code
35
+ .call
20
36
  end
21
37
  end
22
38
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NestedSelect
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/nested_select.rb CHANGED
@@ -1,5 +1,4 @@
1
1
  # frozen_string_literal: true
2
-
3
2
  require_relative "nested_select/version"
4
3
 
5
4
  module NestedSelect
@@ -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.2.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-01-25 00:00:00.000000000 Z
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