better_pluck 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ab797655791d6f81727b4da00bd628a89da68b10343f9c50303b39f1651b6592
4
+ data.tar.gz: 274e7f8dca7d9b9ae5c5e1b0590d1399b9e6144a81e2b8ceaac5d0ec45d91603
5
+ SHA512:
6
+ metadata.gz: a1a031b47a3322543f3534e6afffcb2a9c1e2f8e05b80dab5cbbd7572e9e9edfcb8fec66a081ca9436029fd3111674ab142e0fe3bcb4f2940d84abf310f2f311
7
+ data.tar.gz: 9b66b44a7c09a438a57d33e7afc7f1170d5c453d1b0031943fcc7641d388d8a8602ccd9ef35130369eb52089491d7bc0e22bfab2625324d0619e527d5396f7af
data/.gitignore ADDED
@@ -0,0 +1,10 @@
1
+ *.gem
2
+ *.rbc
3
+ pkg/
4
+ spec/reports/
5
+ test/tmp/
6
+ test/version_tmp/
7
+ tmp/
8
+ .bundle/
9
+ Gemfile.lock
10
+ .DS_Store
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
data/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # BetterPluck
2
+
3
+ `BetterPluck` adds `pluck_with_methods` and `pluck_with_display_name` to ActiveRecord. These methods allow you to pluck not only database columns but also virtual methods and association data, returning lightweight `Struct` objects instead of heavy ActiveRecord instances.
4
+
5
+ This approach can reduce memory usage by up to 3-20x compared to loading full ActiveRecord objects.
6
+
7
+ Primary purpose: to be used in huge form dropdowns for select inputs.
8
+
9
+ ## Installation
10
+
11
+ Add this line to your application's Gemfile:
12
+
13
+ ```ruby
14
+ gem 'better_pluck', github: "NazarK/better_pluck"
15
+ ```
16
+
17
+ And then execute:
18
+
19
+ $ bundle install
20
+
21
+ ## Usage
22
+
23
+ ### Basic usage
24
+
25
+ You can pluck database columns and instance methods:
26
+
27
+ ```ruby
28
+ # Assuming Article has a method :name_on_site
29
+ articles = Article.pluck_with_methods(:id, :name, :email, :name_on_site)
30
+
31
+ articles.first.id # => 1
32
+ articles.first.name_on_site # => "My Article" (virtual method)
33
+ ```
34
+
35
+ ### Associations
36
+
37
+ You can also pluck data from nested associations:
38
+
39
+ ```ruby
40
+ articles = Article.pluck_with_methods(
41
+ :id,
42
+ :name,
43
+ author: [:name, :email, :display_name]
44
+ )
45
+
46
+ articles.first.author.email # => "author@example.com"
47
+ articles.first.author.display_name # => "John Doe"
48
+ ```
49
+
50
+ ### Pluck with Display Name
51
+
52
+ A convenience method specifically for dropdown lists and collections:
53
+
54
+ ```ruby
55
+ # Automatically includes :display_name for the model and associations
56
+ articles = Article.pluck_with_display_name(:id, :name, author: [:name, :email])
57
+
58
+ articles.first.display_name # => "Article Name"
59
+ articles.first.author.display_name # => "Author Name <author@example.com>"
60
+ ```
61
+
62
+ ## How it works
63
+
64
+ 1. **Parsing**: It parses the requested fields to distinguish between database columns, methods, and associations.
65
+ 2. **Joining**: It automatically applies `left_joins` for requested associations.
66
+ 3. **Plucking**: It uses the standard ActiveRecord `pluck` to fetch only the required database columns.
67
+ 4. **Struct Generation**: It creates a `Struct` class (cached for performance) and injects the source code of requested instance methods into it.
68
+ 5. **Instantiation**: It maps the raw data into these `Struct` objects.
69
+
70
+ ## Requirements
71
+
72
+ * ActiveRecord >= 6.0
73
+ * method_source
74
+ * concurrent-ruby
@@ -0,0 +1,29 @@
1
+ lib = File.expand_path("../lib", __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require "better_pluck/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "better_pluck"
7
+ spec.version = BetterPluck::VERSION
8
+ spec.authors = ["NazarK"]
9
+ spec.email = ["nazar.kuliev@gmail.com"]
10
+
11
+ spec.summary = "Pluck with methods for ActiveRecord"
12
+ spec.description = "Convert arrays of AR objects to Struct objects including virtual methods."
13
+ spec.homepage = "https://github.com/NazarK/better_pluck"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.bindir = "exe"
18
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
19
+ spec.require_paths = ["lib"]
20
+ spec.required_ruby_version = ">= 2.7.0"
21
+
22
+ spec.add_dependency "activerecord", ">= 6.0", "< 9.0"
23
+ spec.add_dependency "activesupport", ">= 6.0", "< 9.0"
24
+ spec.add_dependency "concurrent-ruby", "~> 1.0"
25
+ spec.add_dependency "method_source", "~> 1.0"
26
+
27
+ spec.add_development_dependency "bundler", "~> 2.0"
28
+ spec.add_development_dependency "rake", "~> 13.0"
29
+ end
@@ -0,0 +1,186 @@
1
+ # this module is supposed to be included in ActiveRecord descendants
2
+ # to reduce ram usage by converting arrays of AR objects to Struct objects
3
+ # in particular for dropdown lists (in f.input collection: Article.pluck_with_display_name(:id,:name,:email), as: :select)
4
+ # also takes associations display_name from their ActiveRecord class
5
+ # usage: zones = Zone.pluck_with_display_name(:id,:name, partner: [:имя,:email], location: [:name])
6
+ # zones.first.display_name, zones.first.partner.display_name
7
+
8
+ #pluck_with_methods method also available where you can list any fields and methods
9
+ #example: articles = Article.pluck_with_methods(:id, :name, :email, :name_on_site, author: [:name, :email,:display_name])
10
+ #
11
+ #in returned result you can use:
12
+ #article.first.name_on_site, article.first.author.email, articles.first.author.display_name etc.
13
+ #
14
+ #returned structure is much cheaper (in terms of memory usage) than ActiveRecord objects, about 3 times less memory usage
15
+
16
+ #NK 2026-10-13, done with Gemini AI
17
+
18
+ module BetterPluck::PluckWithMethods
19
+ extend ActiveSupport::Concern
20
+
21
+ # Use Concurrent::Map for thread-safe caching across multiple Puma threads
22
+ STRUCT_CLASSES = Concurrent::Map.new
23
+
24
+ class_methods do
25
+
26
+ # usage: zones = Zone.pluck_with_methods(:id, :name, :display_name, partner: [:имя, :email, :display_name], location: [:name, :display_name])
27
+ def pluck_with_methods(*fields_and_methods)
28
+ require "method_source" unless defined?(MethodSource)
29
+
30
+ metadata = _pluck_methods_parse_fields(self, fields_and_methods)
31
+ pluck_columns = []
32
+ _pluck_methods_build_columns(self, metadata, pluck_columns, self.table_name)
33
+
34
+ joins_arg = _pluck_methods_build_joins(metadata)
35
+
36
+ query = joins_arg.present? ? left_joins(joins_arg) : all
37
+ raw_rows = query.pluck(*pluck_columns)
38
+
39
+ # Root struct class
40
+ root_struct_klass = _pluck_methods_get_struct(metadata)
41
+
42
+ raw_rows.map do |row|
43
+ row_data = row.is_a?(Array) ? row.dup : [row]
44
+ _pluck_methods_instantiate(root_struct_klass, metadata, row_data)
45
+ end
46
+ end
47
+
48
+ # usage: articles = Article.pluck_with_display_name(:id,:name, author: [:name,:email])
49
+ # articles.first.display_name, articles.first.author.display_name
50
+ def pluck_with_display_name(*fields_and_methods)
51
+ fields_and_methods << :display_name unless fields_and_methods.include?(:display_name)
52
+
53
+ processed_params = fields_and_methods.map do |arg|
54
+ if arg.is_a?(Hash)
55
+ arg.each_with_object({}) do |(assoc, fields), hash|
56
+ fields = Array(fields).dup
57
+ fields << :display_name unless fields.include?(:display_name)
58
+ hash[assoc] = fields
59
+ end
60
+ else
61
+ arg
62
+ end
63
+ end
64
+
65
+ pluck_with_methods(*processed_params)
66
+ end
67
+
68
+ private
69
+
70
+ def _pluck_methods_parse_fields(klass, fields)
71
+ db_columns = []
72
+ methods = {}
73
+ associations = {}
74
+ all_fields = []
75
+
76
+ available_columns = klass.column_names.map(&:to_sym) rescue []
77
+
78
+ # Sort fields to ensure deterministic cache key and struct structure
79
+ # Symbols first, then hashes (associations) sorted by their first key
80
+ sorted_fields = Array(fields).sort_by { |f| f.is_a?(Hash) ? [1, f.keys.first.to_s] : [0, f.to_s] }
81
+
82
+ sorted_fields.each do |item|
83
+ if item.is_a?(Hash)
84
+ # Sort association keys within the hash
85
+ item.keys.sort_by(&:to_s).each do |assoc_name|
86
+ assoc_fields = item[assoc_name]
87
+ assoc_reflection = klass.reflect_on_association(assoc_name)
88
+ if assoc_reflection
89
+ associations[assoc_name.to_sym] = _pluck_methods_parse_fields(assoc_reflection.klass, Array(assoc_fields))
90
+ all_fields << assoc_name.to_sym
91
+ end
92
+ end
93
+ else
94
+ name = item.to_sym
95
+ all_fields << name
96
+ if available_columns.include?(name)
97
+ db_columns << name
98
+ else
99
+ begin
100
+ source = klass.instance_method(name).source
101
+ methods[name] = source
102
+ rescue
103
+ # Treat as db column if not a method or source not found
104
+ db_columns << name
105
+ end
106
+ end
107
+ end
108
+ end
109
+
110
+ {
111
+ klass_name: klass.name,
112
+ db_columns: db_columns,
113
+ methods: methods,
114
+ associations: associations,
115
+ all_fields: all_fields,
116
+ total_db_columns: db_columns.size + associations.values.sum { |a| a[:total_db_columns] }
117
+ }
118
+ end
119
+
120
+ def _pluck_methods_build_columns(klass, metadata, list, table_alias)
121
+ metadata[:db_columns].each do |col|
122
+ list << Arel.sql("#{klass.connection.quote_table_name(table_alias)}.\#{col}")
123
+ end
124
+
125
+ metadata[:associations].each do |assoc_name, sub_metadata|
126
+ assoc_reflection = klass.reflect_on_association(assoc_name)
127
+ # ARs left_joins usually uses the table name for the join alias unless there is a collision
128
+ _pluck_methods_build_columns(assoc_reflection.klass, sub_metadata, list, assoc_reflection.table_name)
129
+ end
130
+ end
131
+
132
+ def _pluck_methods_build_joins(metadata)
133
+ return nil if metadata[:associations].empty?
134
+
135
+ joins = metadata[:associations].map do |assoc_name, sub_metadata|
136
+ sub_joins = _pluck_methods_build_joins(sub_metadata)
137
+ if sub_joins
138
+ { assoc_name => sub_joins }
139
+ else
140
+ assoc_name
141
+ end
142
+ end
143
+
144
+ joins.size == 1 ? joins.first : joins
145
+ end
146
+
147
+ def _pluck_methods_get_struct(metadata)
148
+ metadata[:struct_klass] ||= begin
149
+ # 1st list: database fields and associations
150
+ # 2nd list: virtual methods (copied from ActiveRecord)
151
+ db_and_assoc = metadata[:all_fields] - metadata[:methods].keys
152
+ cache_key = [metadata[:klass_name], db_and_assoc, metadata[:methods].keys]
153
+
154
+ STRUCT_CLASSES.compute_if_absent(cache_key) do
155
+ struct_klass = Struct.new(*metadata[:all_fields])
156
+ metadata[:methods].each do |name, source|
157
+ struct_klass.class_eval(source)
158
+ end
159
+ struct_klass
160
+ end
161
+ end
162
+ end
163
+
164
+ def _pluck_methods_instantiate(struct_klass, metadata, row_data)
165
+ args = metadata[:all_fields].map do |field|
166
+ if metadata[:db_columns].include?(field)
167
+ row_data.shift
168
+ elsif (sub_metadata = metadata[:associations][field])
169
+ sub_struct_klass = _pluck_methods_get_struct(sub_metadata)
170
+ sub_col_count = sub_metadata[:total_db_columns]
171
+
172
+ if row_data[0...sub_col_count].all?(&:nil?)
173
+ row_data.shift(sub_col_count)
174
+ nil
175
+ else
176
+ _pluck_methods_instantiate(sub_struct_klass, sub_metadata, row_data)
177
+ end
178
+ else
179
+ nil # Method field
180
+ end
181
+ end
182
+ struct_klass.new(*args)
183
+ end
184
+
185
+ end
186
+ end
@@ -0,0 +1,3 @@
1
+ module BetterPluck
2
+ VERSION = "0.8.0"
3
+ end
@@ -0,0 +1,9 @@
1
+ require "better_pluck/version"
2
+ require "better_pluck/pluck_with_methods"
3
+
4
+ module BetterPluck
5
+ end
6
+
7
+ ActiveSupport.on_load(:active_record) do
8
+ include BetterPluck::PluckWithMethods
9
+ end
metadata ADDED
@@ -0,0 +1,143 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: better_pluck
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.8.0
5
+ platform: ruby
6
+ authors:
7
+ - NazarK
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '6.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '6.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: activesupport
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '6.0'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '9.0'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '6.0'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '9.0'
52
+ - !ruby/object:Gem::Dependency
53
+ name: concurrent-ruby
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - "~>"
57
+ - !ruby/object:Gem::Version
58
+ version: '1.0'
59
+ type: :runtime
60
+ prerelease: false
61
+ version_requirements: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - "~>"
64
+ - !ruby/object:Gem::Version
65
+ version: '1.0'
66
+ - !ruby/object:Gem::Dependency
67
+ name: method_source
68
+ requirement: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - "~>"
71
+ - !ruby/object:Gem::Version
72
+ version: '1.0'
73
+ type: :runtime
74
+ prerelease: false
75
+ version_requirements: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - "~>"
78
+ - !ruby/object:Gem::Version
79
+ version: '1.0'
80
+ - !ruby/object:Gem::Dependency
81
+ name: bundler
82
+ requirement: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - "~>"
85
+ - !ruby/object:Gem::Version
86
+ version: '2.0'
87
+ type: :development
88
+ prerelease: false
89
+ version_requirements: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - "~>"
92
+ - !ruby/object:Gem::Version
93
+ version: '2.0'
94
+ - !ruby/object:Gem::Dependency
95
+ name: rake
96
+ requirement: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - "~>"
99
+ - !ruby/object:Gem::Version
100
+ version: '13.0'
101
+ type: :development
102
+ prerelease: false
103
+ version_requirements: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - "~>"
106
+ - !ruby/object:Gem::Version
107
+ version: '13.0'
108
+ description: Convert arrays of AR objects to Struct objects including virtual methods.
109
+ email:
110
+ - nazar.kuliev@gmail.com
111
+ executables: []
112
+ extensions: []
113
+ extra_rdoc_files: []
114
+ files:
115
+ - ".gitignore"
116
+ - Gemfile
117
+ - README.md
118
+ - better_pluck.gemspec
119
+ - lib/better_pluck.rb
120
+ - lib/better_pluck/pluck_with_methods.rb
121
+ - lib/better_pluck/version.rb
122
+ homepage: https://github.com/NazarK/better_pluck
123
+ licenses:
124
+ - MIT
125
+ metadata: {}
126
+ rdoc_options: []
127
+ require_paths:
128
+ - lib
129
+ required_ruby_version: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - ">="
132
+ - !ruby/object:Gem::Version
133
+ version: 2.7.0
134
+ required_rubygems_version: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ requirements: []
140
+ rubygems_version: 3.6.9
141
+ specification_version: 4
142
+ summary: Pluck with methods for ActiveRecord
143
+ test_files: []