activerecord-minimizer 0.1.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.
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArMinimizer
4
+ # Prepended to ActiveRecord::Base's singleton class. Skips generating the
5
+ # attribute methods whose proxy is not in +keep+ (by default everything but
6
+ # readers and writers).
7
+ #
8
+ # Strict (the default): those methods are gone. Calling one raises
9
+ # NoMethodError naming its replacement; rubocop-minify rewrites the calls,
10
+ # keep_methods lists what gems call by name.
11
+ #
12
+ # strict: false: they keep working through ActiveModel's method_missing,
13
+ # and with +lazy+ the first call generates that one method on that model.
14
+ module Slim
15
+ # Proxy targets whose methods are still generated: the reader and the writer.
16
+ DEFAULT_KEEP = %w[attribute attribute=].freeze
17
+
18
+ MATCH = ActiveModel::AttributeMethods::ClassMethods::AttributeMethodPattern::AttributeMethod
19
+
20
+ # Rails asks respond_to? before calling these, and quietly does something
21
+ # else when the answer is no: form fields show the cast value instead of
22
+ # what the user typed, numericality validates the cast value ("abc" -> 0).
23
+ # Strict mode keeps them answering, generated on first call.
24
+ PROBED = { "_before_type_cast" => "attribute_before_type_cast", "_came_from_user?" => "attribute_came_from_user?" }.freeze
25
+
26
+ class << self
27
+ attr_reader :keep, :keep_methods
28
+
29
+ def keep=(proxies)
30
+ @keep = proxies.map(&:to_s).to_set.freeze
31
+ end
32
+
33
+ # Method names ("name_changed?") or "Model#name_changed?" to keep
34
+ # generating: calls in gems or views that would otherwise take the slow
35
+ # path on a hot path. ArMinimizer::Profile lists them.
36
+ def keep_methods=(names)
37
+ @keep_methods = names.map { it.to_s.strip }.reject(&:empty?).to_set.freeze
38
+ end
39
+
40
+ # One "name" or "Model#name" per line; " # ..." starts a comment.
41
+ def parse_keep_file(text)
42
+ text.lines.map { it.sub(/(\A|\s)#.*/, "").strip }.reject(&:empty?)
43
+ end
44
+
45
+ def enabled? = @enabled == true
46
+
47
+ def lazy? = @lazy == true
48
+
49
+ def strict? = @strict == true
50
+
51
+ # Call before any model instantiates (an initializer is fine).
52
+ #
53
+ # strict: the skipped methods are gone, not generated on first call:
54
+ # calling one raises NoMethodError naming its replacement
55
+ # (attribute_changed?(:name) for name_changed?), respond_to? says no,
56
+ # and records answer respond_to?/method_missing for unknown names
57
+ # without ActiveModel's scan over every attribute method pattern.
58
+ # rubocop-minify rewrites the calls.
59
+ def enable!(keep: DEFAULT_KEEP, keep_methods: [], lazy: true, strict: true)
60
+ self.keep = keep
61
+ self.keep_methods = keep_methods
62
+ @strict = strict
63
+ @lazy = lazy && !strict
64
+ @enabled = true
65
+ ActiveRecord::Base.singleton_class.prepend(self) unless ActiveRecord::Base.singleton_class < self
66
+ ActiveRecord::Base.prepend(Record) unless ActiveRecord::Base < Record
67
+ reset_models
68
+ end
69
+
70
+ def disable!
71
+ @enabled = false
72
+ @strict = false
73
+ reset_models
74
+ end
75
+
76
+ # The class's own String for an attribute name (what generated methods
77
+ # pass), so records don't keep copies made by method-name matching.
78
+ def canonical_name(klass, name)
79
+ names = klass.instance_variable_get(:@__arm_attribute_names) ||
80
+ klass.instance_variable_set(:@__arm_attribute_names, klass.attribute_names.to_h { [it, it] }.freeze)
81
+ names[name] || name
82
+ end
83
+
84
+ def skip?(model, pattern, attr_name, name)
85
+ return false unless enabled?
86
+ return false if Thread.current[:ar_minimizer_generating] == name
87
+ return false if keep.include?(pattern.proxy_target)
88
+ return false if keep_methods.include?(name) || keep_methods.include?("#{model.name}##{name}")
89
+
90
+ !framework_calls?(model, pattern.proxy_target, attr_name.to_s)
91
+ end
92
+
93
+ # For the strict-mode error: the call that replaces a removed method.
94
+ def replacement(model, name)
95
+ model.attribute_method_patterns.each do |pattern|
96
+ next if keep.include?(pattern.proxy_target)
97
+
98
+ attr = pattern.respond_to?(:matched_attribute_name) ? pattern.matched_attribute_name(name) : pattern.match(name)&.attr_name
99
+ next unless attr && (model._has_attribute?(attr) || model.attribute_alias?(attr))
100
+
101
+ proxy = pattern.proxy_target == "attribute?" ? "query_attribute" : pattern.proxy_target
102
+ args = pattern.parameters == false ? ":#{attr}" : ":#{attr}, ..."
103
+ return "#{proxy}(#{args})#{" (private: on self)" unless ActiveRecord::Base.public_method_defined?(proxy)}"
104
+ end
105
+ nil
106
+ end
107
+
108
+ # Generates the skipped method +name+ on +model+, as ActiveRecord would
109
+ # have. Only for the class's own attributes and aliases: columns that
110
+ # only some query selects (`AS trips_count`) never get methods in Rails either.
111
+ def generate(model, name, match)
112
+ return unless model._has_attribute?(match.attr_name) || model.attribute_alias?(match.attr_name)
113
+
114
+ pattern = model.attribute_method_patterns.find { it.proxy_target == match.proxy_target }
115
+ target = model.attribute_aliases[match.attr_name]
116
+ ActiveRecord::AttributeMethods::GeneratedAttributeMethods::LOCK.synchronize do
117
+ Thread.current[:ar_minimizer_generating] = name
118
+ ActiveSupport::CodeGenerator.batch(model.send(:generated_attribute_methods), __FILE__, __LINE__) do |owner|
119
+ if target
120
+ model.send(:define_attribute_method_pattern, pattern, target, owner: owner, as: match.attr_name, override: true)
121
+ else
122
+ model.send(:define_attribute_method_pattern, pattern, match.attr_name, owner: owner, as: match.attr_name)
123
+ end
124
+ end
125
+ ensure
126
+ Thread.current[:ar_minimizer_generating] = nil
127
+ end
128
+ end
129
+
130
+ private
131
+
132
+ # For models loaded before the switch. ActiveRecord only undefines once
133
+ # attribute methods were generated, but alias_attribute defines its
134
+ # methods right away, in the class body.
135
+ def reset_models
136
+ ActiveRecord::Base.descendants.each do |model|
137
+ model.undefine_attribute_methods
138
+ model.send(:generated_attribute_methods).module_eval { undef_method(*instance_methods(false)) }
139
+ end
140
+ end
141
+
142
+ # Methods Rails itself calls by name on hot paths.
143
+ def framework_calls?(model, proxy, attr)
144
+ case proxy
145
+ # every enum predicate (`published?`) and enum scope calls status_for_database
146
+ when "attribute_for_database" then model.defined_enums.key?(attr)
147
+ # cache_version, on every cache key with recyclable cache keys
148
+ when "attribute_before_type_cast", "attribute_came_from_user?" then attr == "updated_at"
149
+ else false
150
+ end
151
+ end
152
+ end
153
+
154
+ def define_attribute_method_pattern(pattern, attr_name, owner:, as:, override: false) # :nodoc:
155
+ name = pattern.method_name(as)
156
+ # alias_attribute passes override: true (and attr_name: the aliased
157
+ # attribute); Record teaches method_missing to resolve those.
158
+ return super unless Slim.skip?(self, pattern, attr_name, name)
159
+ # Only real attributes: the slow path can't find anything else (gems such
160
+ # as activerecord-typedstore call define_attribute_method for store keys).
161
+ return super unless _has_attribute?(attr_name.to_s)
162
+
163
+ # A hand-written method of the same name may call `super`, which needs
164
+ # the generated method to exist.
165
+ return super if method_defined?(name) || private_method_defined?(name)
166
+
167
+ # Still raise DangerousAttributeError where ActiveRecord would.
168
+ instance_method_already_implemented?(name) unless override
169
+ nil
170
+ end
171
+
172
+ # What the slow path needs to stand in for the skipped methods.
173
+ module Record
174
+ # ActiveModel's method_missing only knows real attributes: with
175
+ # `alias_attribute :title, :name`, title_changed? must become
176
+ # attribute_changed?("name"). Rails aliases id_value to id on every model.
177
+ def attribute_missing(match, ...)
178
+ return super unless Slim.enabled?
179
+
180
+ # The name comes from a regex match, a new String each time, and the
181
+ # record's attribute set keeps whatever String it gets as a key. Pass
182
+ # the class's own name String, as generated methods do.
183
+ target = self.class.attribute_aliases[match.attr_name] || match.attr_name
184
+ __send__(match.proxy_target, Slim.canonical_name(self.class, target), ...)
185
+ end
186
+
187
+ private
188
+
189
+ # ActiveModel overrides respond_to? but not respond_to_missing?, so
190
+ # without this `user.method(:name_changed?)` raises NameError.
191
+ def respond_to_missing?(name, include_private = false)
192
+ # Strict: matched_attribute_method only matches readers, writers and
193
+ # the PROBED patterns.
194
+ super || (Slim.enabled? && !matched_attribute_method(name.to_s).nil?)
195
+ end
196
+
197
+ # ActiveModel's method_missing gives up on anything respond_to? (and so
198
+ # respond_to_missing? above) says exists; dispatch pattern matches here.
199
+ def method_missing(name, ...)
200
+ return super unless Slim.enabled?
201
+
202
+ self.class.define_attribute_methods
203
+ match = matched_attribute_method(name.name)
204
+ unless match
205
+ if Slim.strict? && (replacement = Slim.replacement(self.class, name.name))
206
+ raise NoMethodError.new("#{name} was removed by activerecord-minimizer's strict mode; use #{replacement}",
207
+ name, receiver: self)
208
+ end
209
+ return super
210
+ end
211
+
212
+ Slim.generate(self.class, name.name, match) if Slim.lazy? || Slim::PROBED.value?(match.proxy_target)
213
+ attribute_missing(match, ...)
214
+ end
215
+
216
+ # Strict: only readers and writers are left to match (columns only some
217
+ # query selects, `AS trips_count`), so check those two directly instead
218
+ # of every pattern ActiveModel knows.
219
+ def matched_attribute_method(method_name)
220
+ return super unless Slim.strict?
221
+
222
+ Slim::PROBED.each do |suffix, proxy|
223
+ next unless method_name.end_with?(suffix)
224
+
225
+ attr = method_name.delete_suffix(suffix)
226
+ klass = self.class
227
+ return Slim::MATCH.new(proxy, attr) if klass._has_attribute?(attr) || klass.attribute_aliases.key?(attr) || @attributes&.key?(attr)
228
+ end
229
+
230
+ writer = method_name.end_with?("=")
231
+ attr = writer ? method_name.delete_suffix("=") : method_name
232
+ # The class's own attributes have generated readers and writers; what
233
+ # reaches here is columns only this query has, or aliases.
234
+ return unless @attributes&.key?(attr) || self.class.attribute_aliases.key?(attr)
235
+
236
+ Slim::MATCH.new(writer ? "attribute=" : "attribute", attr)
237
+ end
238
+
239
+ # ActiveModel's matched_attribute_method asks this for each candidate
240
+ # attribute, and only knows the attributes this instance loaded. That
241
+ # still has to work for columns only this query has (`select("COUNT(*)
242
+ # AS trips_count")`), but on a partial select (`select(:id)`) name_changed?
243
+ # would raise NoMethodError where the generated method answers. Accept the
244
+ # class's attributes and aliases too, as the generated methods would.
245
+ def attribute_method?(attr_name)
246
+ return super unless Slim.enabled?
247
+
248
+ # Rails' check materializes the attribute in a lazy attribute set,
249
+ # keyed by the String it gets: give it the class's own.
250
+ attr_name = Slim.canonical_name(self.class, attr_name)
251
+ return true if super
252
+
253
+ klass = self.class
254
+ klass._has_attribute?(attr_name) || klass._has_attribute?(klass.attribute_aliases[attr_name].to_s)
255
+ end
256
+ end
257
+ end
258
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArMinimizer
4
+ # Records that never need a Hash don't get one.
5
+ #
6
+ # A record loaded from the database owns two Hashes that most records never
7
+ # put anything in, 160 bytes each on Ruby 4.0 (a fresh record owns about
8
+ # 1,500 bytes all told):
9
+ #
10
+ # @association_cache filled when an association is loaded
11
+ # @attributes.@attributes filled when an Attribute object is built:
12
+ # dirty tracking, writes, keys, ... Plain reads go
13
+ # through LazyAttributeSet#fetch_value and don't.
14
+ #
15
+ # Both start as one shared frozen empty Hash and get their own the first
16
+ # time something goes in. Only that shared Hash is swapped: attributes
17
+ # frozen by `destroy`/`freeze` still raise FrozenError on write, as in Rails.
18
+ module SmallRecords
19
+ EMPTY = {}.freeze
20
+
21
+ class << self
22
+ def install!
23
+ @enabled = true
24
+ ActiveModel::LazyAttributeSet.prepend(AttributeSet) unless ActiveModel::LazyAttributeSet < AttributeSet
25
+ ActiveRecord::Associations.prepend(Associations) unless ActiveRecord::Associations < Associations
26
+ end
27
+
28
+ def disable! = @enabled = false
29
+
30
+ def enabled? = @enabled == true
31
+ end
32
+
33
+ module AttributeSet
34
+ def initialize(values, types, additional_types, default_attributes, attributes = nil)
35
+ super(values, types, additional_types, default_attributes, attributes || (SmallRecords.enabled? ? EMPTY : {}))
36
+ end
37
+
38
+ def []=(name, value)
39
+ @attributes = {} if @attributes.equal?(EMPTY)
40
+ super
41
+ end
42
+
43
+ def write_from_database(name, value)
44
+ @attributes = {} if @attributes.equal?(EMPTY)
45
+ super
46
+ end
47
+
48
+ def write_from_user(name, value)
49
+ @attributes = {} if @attributes.equal?(EMPTY) && !frozen?
50
+ super
51
+ end
52
+
53
+ def write_cast_value(name, value)
54
+ @attributes = {} if @attributes.equal?(EMPTY)
55
+ super
56
+ end
57
+
58
+ def reverse_merge!(target_attributes)
59
+ @attributes = {} if @attributes.equal?(EMPTY)
60
+ super
61
+ end
62
+
63
+ # Rails clones the Hash; a clone of the shared one would be frozen but
64
+ # not the shared one, so writes would raise.
65
+ def initialize_clone(other)
66
+ super
67
+ @attributes = EMPTY if other.instance_variable_get(:@attributes).equal?(EMPTY)
68
+ end
69
+
70
+ private
71
+
72
+ def default_attribute(name, ...)
73
+ @attributes = {} if @attributes.equal?(EMPTY)
74
+ super
75
+ end
76
+ end
77
+
78
+ module Associations
79
+ def initialize_dup(*)
80
+ super
81
+ @association_cache = EMPTY if SmallRecords.enabled?
82
+ end
83
+
84
+ private
85
+
86
+ def init_internals
87
+ super
88
+ @association_cache = EMPTY if SmallRecords.enabled? # Rails' {} is dropped right away
89
+ end
90
+
91
+ def association_instance_set(name, association)
92
+ @association_cache = {} if @association_cache.equal?(EMPTY)
93
+ super
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArMinimizer
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArMinimizer
4
+ # Compiles attribute pattern calls in templates (ERB, or any other handler)
5
+ # the way Build does for app/ and lib/, when ActionView compiles each
6
+ # template, so views need no build step. The index covers the models loaded
7
+ # when a template compiles and is rebuilt when more get loaded; templates
8
+ # compiled before a model loaded leave its calls on the slow path.
9
+ module Views
10
+ class << self
11
+ def install!(index: nil, guards: false)
12
+ @guards = guards
13
+ @fixed_index = index
14
+ @index = nil
15
+ @mutex = Mutex.new
16
+ ActionView::Template.prepend(Template) unless ActionView::Template < Template
17
+ end
18
+
19
+ # index: an Index, something that returns one when called (loaded on
20
+ # the first template compile), or nil to index the loaded models.
21
+ def index
22
+ if @fixed_index.respond_to?(:call)
23
+ @mutex.synchronize { @fixed_index = @fixed_index.call if @fixed_index.respond_to?(:call) }
24
+ end
25
+ return @fixed_index if @fixed_index
26
+
27
+ @mutex.synchronize do
28
+ models = ActiveRecord::Base.descendants.size
29
+ @index = nil unless @models == models
30
+ @models = models
31
+ @index ||= Index.from_models
32
+ end
33
+ end
34
+
35
+ def rewrite(source, path)
36
+ Rewriter.new(index: index, path: path, marker: false, guards: @guards).rewrite(source).source
37
+ end
38
+ end
39
+
40
+ module Template
41
+ private
42
+
43
+ # The whole `def _app_views_...(local_assigns, output_buffer) ... end`,
44
+ # so template locals parse as locals.
45
+ def compiled_source
46
+ Views.rewrite(super, short_identifier)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "ar_minimizer/version"
5
+ require "ar_minimizer/slim"
6
+
7
+ # ActiveRecord defines, for every column, a reader, a writer and one method per
8
+ # attribute method pattern: name?, name_changed?, name_was, saved_change_to_name?,
9
+ # name_before_type_cast, restore_name!, ... (22 patterns in Rails 8.1).
10
+ #
11
+ # ArMinimizer::Slim stops generating everything except the readers and writers.
12
+ # The missing methods still work: ActiveModel's method_missing matches them
13
+ # against the same patterns and calls the same proxy (attribute_changed?("name")).
14
+ #
15
+ # ArMinimizer::Build rewrites static call sites at deploy time so they don't pay
16
+ # for method_missing:
17
+ #
18
+ # user.name_changed?
19
+ # # becomes
20
+ # (::User.equal?(user.class) ? user.attribute_changed?(-"name") : user.name_changed?)
21
+ module ArMinimizer
22
+ autoload :Index, "ar_minimizer/index"
23
+ autoload :Rewriter, "ar_minimizer/rewriter"
24
+ autoload :Build, "ar_minimizer/build"
25
+ autoload :Views, "ar_minimizer/views"
26
+ autoload :Profile, "ar_minimizer/profile"
27
+ autoload :Manifest, "ar_minimizer/manifest"
28
+ autoload :Enums, "ar_minimizer/enums"
29
+ autoload :Census, "ar_minimizer/census"
30
+ autoload :SmallRecords, "ar_minimizer/small_records"
31
+ autoload :Loader, "ar_minimizer/loader"
32
+ end
33
+
34
+ require "ar_minimizer/railtie" if defined?(Rails::Railtie)
metadata ADDED
@@ -0,0 +1,112 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activerecord-minimizer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Sampo Kuokkanen
8
+ bindir: bin
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: '8.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '8.3'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '8.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '8.3'
32
+ - !ruby/object:Gem::Dependency
33
+ name: prism
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '1.2'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '1.2'
46
+ - !ruby/object:Gem::Dependency
47
+ name: require-hooks
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '0.4'
53
+ type: :runtime
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0.4'
60
+ description: Generates only the reader and writer per Active Record column (strict
61
+ mode removes the other 19 attribute methods), compiles call sites to the calls those
62
+ methods stood for, turns enum closures into plain methods and stops records allocating
63
+ Hashes they never fill.
64
+ executables: []
65
+ extensions: []
66
+ extra_rdoc_files: []
67
+ files:
68
+ - CHANGELOG.md
69
+ - LICENSE.txt
70
+ - README.md
71
+ - lib/activerecord-minimizer.rb
72
+ - lib/ar_minimizer.rb
73
+ - lib/ar_minimizer/build.rb
74
+ - lib/ar_minimizer/census.rb
75
+ - lib/ar_minimizer/enums.rb
76
+ - lib/ar_minimizer/index.rb
77
+ - lib/ar_minimizer/loader.rb
78
+ - lib/ar_minimizer/manifest.rb
79
+ - lib/ar_minimizer/profile.rb
80
+ - lib/ar_minimizer/railtie.rb
81
+ - lib/ar_minimizer/rewriter.rb
82
+ - lib/ar_minimizer/slim.rb
83
+ - lib/ar_minimizer/small_records.rb
84
+ - lib/ar_minimizer/version.rb
85
+ - lib/ar_minimizer/views.rb
86
+ homepage: https://github.com/sampokuokkanen/rails-minify/tree/main/activerecord-minimizer
87
+ licenses:
88
+ - MIT
89
+ metadata:
90
+ source_code_uri: https://github.com/sampokuokkanen/rails-minify/tree/main/activerecord-minimizer
91
+ changelog_uri: https://github.com/sampokuokkanen/rails-minify/blob/main/activerecord-minimizer/CHANGELOG.md
92
+ bug_tracker_uri: https://github.com/sampokuokkanen/rails-minify/issues
93
+ rubygems_mfa_required: 'true'
94
+ rdoc_options: []
95
+ require_paths:
96
+ - lib
97
+ required_ruby_version: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '3.4'
102
+ required_rubygems_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '0'
107
+ requirements: []
108
+ rubygems_version: 4.0.20
109
+ specification_version: 4
110
+ summary: Stop generating ~20 attribute methods per column; compile the call sites
111
+ instead
112
+ test_files: []