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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ce54c61288bef93e5fb13a4f7b040549145511a46fa548b110565f7d9df146b6
4
+ data.tar.gz: c9884dbd4f7c17218a23af522b93a9eef4d1f4144fff706cb09a76aa6ec4f7d5
5
+ SHA512:
6
+ metadata.gz: e6745aa3893a7742b4002879166b7d2f547789a1382aa982bded4ee5398ea7955601bdee59e17695281c1f20006a129b9733ac054787ad68446550f7c37e6e1f
7
+ data.tar.gz: 1d2db1e229d5b09b45e41751dbbac7869a3581878db7286adaea75245ef565d9da6f17b2519e600a75b5daf64cb2275e89fdf7515029e089feecaca5d89fed54
data/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-10-18)
4
+
5
+ First release.
6
+
7
+ - Slim: only readers and writers are generated; strict mode (the default) removes the other attribute methods and names the replacement in the NoMethodError. `strict = false` generates them on first call instead.
8
+ - Keep list (`config/ar_minimizer_keep.txt`) and profiling (`AR_MINIMIZER_PROFILE`, `ar_minimizer:profile`) for methods gems call by name.
9
+ - Manifest (`ar_minimizer:manifest`, `ar_minimizer:manifest:check`).
10
+ - Call-site compilation at load time, in templates, or in place (`ar_minimizer:build`), for receivers whose model is known; `guards = true` also compiles the rest behind a class check.
11
+ - Enum predicates and bang methods as plain methods.
12
+ - Small records: no Hashes for records that never fill them.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sampo Kuokkanen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,143 @@
1
+ # activerecord-minimizer
2
+
3
+ Active Record generates a reader, a writer and one method per attribute
4
+ method pattern for every column of every model: `name?`, `name_changed?`,
5
+ `name_was`, `saved_change_to_name?`, `name_before_type_cast`,
6
+ `restore_name!`, ... 21 patterns in Rails 8.1, so 21 methods per column.
7
+ activerecord-minimizer generates the readers and writers only, and compiles
8
+ the call sites that used the rest into the calls they stood for. Part of
9
+ [rails-minify](../rails-minify).
10
+
11
+ ```ruby
12
+ # Gemfile
13
+ gem "activerecord-minimizer"
14
+
15
+ # config/environments/production.rb
16
+ config.ar_minimizer.slim = true
17
+ ```
18
+
19
+ ## Strict mode
20
+
21
+ With `slim` on, the other 19 methods per column are gone. Calling one raises
22
+ `NoMethodError` naming its replacement, and `respond_to?` says no:
23
+
24
+ ```
25
+ name_changed? was removed by activerecord-minimizer's strict mode; use attribute_changed?(:name, ...)
26
+ ```
27
+
28
+ [rubocop-minify](../rubocop-minify) rewrites those calls in your Ruby and ERB.
29
+ Gems that call them by name (Devise's `will_save_change_to_email?`, Active
30
+ Storage's `service_name_changed?`, counter caches) get them back from a keep
31
+ list, `config/ar_minimizer_keep.txt`, one `name` or `Model#name` per line.
32
+ To find what to put there, run the test suite with `strict = false` and
33
+ `AR_MINIMIZER_PROFILE=tmp/ar_minimizer`, then `bin/rails ar_minimizer:profile`.
34
+
35
+ Rails itself still gets what it needs:
36
+
37
+ * `<attr>_before_type_cast` and `<attr>_came_from_user?` answer `respond_to?`
38
+ and are generated on first call. Form fields and numericality validation ask
39
+ before calling them, and quietly do something else when told no (a form
40
+ would show the cast value, `"abc"` would validate as 0).
41
+ * `<enum>_for_database` and `updated_at_before_type_cast`/`_came_from_user?`
42
+ are generated up front: Rails calls them on hot paths.
43
+ * Aliases (`alias_attribute`, Rails' own `id_value`), partial selects
44
+ (`select(:id)`) and columns only one query selects (`AS trips_count`) work
45
+ as before.
46
+
47
+ `config.ar_minimizer.strict = false` keeps the removed methods working: the
48
+ first call to one generates it on that model.
49
+
50
+ ## Compiling call sites
51
+
52
+ Calls whose receiver is known to be a record of a model with that column are
53
+ compiled to what the generated method would have done:
54
+
55
+ ```ruby
56
+ # what you write # what runs
57
+ user.name_changed? user.attribute_changed?(-"name")
58
+ user.active? (!!user.active)
59
+ user.age? ((user.age || 0) != 0)
60
+ user.name? (!user.name.blank?)
61
+ ```
62
+
63
+ `name?` compiles by column type, to what `query_attribute` works out to for
64
+ those values; enums, time zone aware datetimes, `encrypts`, `serialize` and
65
+ `normalizes` columns keep `query_attribute(:name)`. The receiver's type comes
66
+ from the code (Prism) and the manifest: `self` inside a model or a concern it
67
+ includes, locals and instance variables assigned from `Model.find`, `.new`,
68
+ `.create`, finders and scopes, associations, and block parameters of
69
+ relations. A receiver that may be nil gets a nil check instead
70
+ (`nil == user ? user.name? : ...`). Calls whose receiver isn't known are left
71
+ as written; `config.ar_minimizer.guards = true` compiles them too, behind a
72
+ class check.
73
+
74
+ Where it compiles:
75
+
76
+ * **Load time** (`config.ar_minimizer.load_time = true`) compiles `app/` and
77
+ `lib/` as Ruby loads them, through require-hooks, so bootsnap caches the
78
+ compiled iseqs. Source files stay untouched and no database is needed.
79
+ * **Views** (`config.ar_minimizer.views = true`) compiles each template's Ruby
80
+ as Action View compiles it (any handler: ERB, Herb, ...).
81
+ * **Build** (`RAILS_ENV=production bin/rails ar_minimizer:build`) rewrites
82
+ `app/` and `lib/` in place instead, like explode_sqlite. Production only (or
83
+ `FORCE=1`); `DRY_RUN=1` previews.
84
+
85
+ All three read the **manifest**, `config/ar_minimizer.json`, written by
86
+ `bin/rails ar_minimizer:manifest`: per model its columns and their query kind,
87
+ the attribute method patterns, hand-written methods, superclass, app modules,
88
+ associations, scopes, and names it defines another way (an enum predicate
89
+ with the same name as another model's column). Commit it like
90
+ `db/schema.rb`; `bin/rails ar_minimizer:manifest:check` fails CI when it's
91
+ stale, and an eager-loading boot logs an error if it is.
92
+
93
+ Never compiled: methods any model defines by hand, names used as columns,
94
+ primary key methods, `defined?(...)`, calls with blocks, heredoc arguments.
95
+
96
+ ## More
97
+
98
+ * **Enums** (`config.ar_minimizer.enums = true`) replaces the closures Rails
99
+ defines for enum predicates and bang methods, which build and intern the
100
+ `<name>_for_database` symbol on every call, with ordinary methods carrying
101
+ the enum's values: `def published? = public_send(:status_for_database) == 1`.
102
+ 1.3–1.6× faster predicates; non-finite float values keep Rails' closures.
103
+ * **Small records** (`config.ar_minimizer.small_records = true`). A loaded
104
+ record owns two Hashes most records never put anything in, 160 bytes each:
105
+ the association cache and the attribute set's Attribute objects. Both start
106
+ as one shared frozen empty Hash and get their own on the first insert. A
107
+ read-only record goes from 1,480 to 1,160 bytes (23 columns), at the same
108
+ speed. Destroyed and frozen records still raise on writes.
109
+ * **Census** (`ArMinimizer::Census`, run by rails-minify's
110
+ `bin/rails minify:census`): how many call sites compile, and why the others
111
+ don't.
112
+
113
+ ## Numbers
114
+
115
+ Rails 8.1, Ruby 4.0, M-series Mac. `bench/calls.rb`, ns per call:
116
+
117
+ | call | stock | compiled | stock (YJIT) | compiled (YJIT) |
118
+ |------------------------|------:|---------:|-------------:|----------------:|
119
+ | `name?` (string) | 1050 | 548 | 565 | 328 |
120
+ | `age?` (integer) | 843 | 380 | 464 | 212 |
121
+ | `active?` (boolean) | 535 | 380 | 283 | 214 |
122
+ | `name_changed?` | 768 | 718 | 405 | 327 |
123
+
124
+ (Measured with the class guard; calls on known receivers skip it.)
125
+
126
+ On golf-buddy (191 models, 2009 columns):
127
+
128
+ * Generated attribute methods at boot: 44,835 → about 4,500. Memory used by
129
+ `define_attribute_methods`: 16.4 → about 6 MB; time 374 → about 225 ms.
130
+ * Test suite (4317 runs) in strict mode, after rubocop-minify's rewrites and
131
+ with a 41-line keep list: the same results as the untouched app.
132
+
133
+ ## Requirements and caveats
134
+
135
+ * Ruby 3.4+, Rails 8.0, 8.1 and main (`rake test:rails`).
136
+ * It overrides `define_attribute_method_pattern` and the instance
137
+ `attribute_method?`, both private, and calls nodoc `_has_attribute?`.
138
+ * A stale manifest can compile a call to a method added by hand since: keep
139
+ `ar_minimizer:manifest:check` in CI.
140
+ * Methods generated lazily (`strict = false`) after a server forks are per
141
+ worker; list hot ones in the keep file to generate them at boot.
142
+ * Error highlighting in compiled views can point at the wrong column. Line
143
+ numbers are right.
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ar_minimizer"
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArMinimizer
4
+ # Rewrites application files in place. Run once per release on the
5
+ # production host (or image build), after the app has booted with every model
6
+ # loaded and the schema in place. Never commit the output.
7
+ class Build
8
+ NotProduction = Class.new(StandardError)
9
+ Report = Data.define(:files_changed, :sites, :skipped)
10
+
11
+ def initialize(root:, index: Index.from_models, paths: %w[app lib], env: ENV["RAILS_ENV"] || ENV["RACK_ENV"],
12
+ dry_run: false, force: false, out: nil, guards: false)
13
+ @root = root.to_s
14
+ @index = index
15
+ @paths = paths
16
+ @env = env
17
+ @dry_run = dry_run
18
+ @force = force
19
+ @out = out
20
+ @guards = guards
21
+ end
22
+
23
+ def run
24
+ unless @force || @env == "production"
25
+ raise NotProduction, "ar_minimizer rewrites source in place; run it with RAILS_ENV=production, " \
26
+ "or override with FORCE=1 (rake) or force: true (Ruby)"
27
+ end
28
+
29
+ files_changed = sites = 0
30
+ skipped = []
31
+ files.each do |file|
32
+ relative = file.delete_prefix("#{@root}/")
33
+ result = Rewriter.new(index: @index, path: relative, guards: @guards).rewrite(File.read(file))
34
+ skipped.concat(result.skipped)
35
+ result.skipped.each { @out&.puts "ar_minimizer: skipped #{it}" }
36
+ next if result.sites.zero?
37
+
38
+ files_changed += 1
39
+ sites += result.sites
40
+ @out&.puts "ar_minimizer: #{relative} (#{result.sites} calls)"
41
+ File.write(file, result.source) unless @dry_run
42
+ end
43
+ @out&.puts "ar_minimizer: #{sites} calls in #{files_changed} files, " \
44
+ "#{@index.size} compilable method names#{" (dry run)" if @dry_run}"
45
+ Report.new(files_changed, sites, skipped)
46
+ end
47
+
48
+ private
49
+
50
+ def files
51
+ @paths.flat_map { Dir.glob(File.join(@root, it, "**", "*.rb")) }.sort
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArMinimizer
4
+ # What the attribute-method and enum compilers cover in an app.
5
+ module Census
6
+ Result = Data.define(:models, :columns, :stock_methods, :generated_methods, :enum_methods, :enum_compiled,
7
+ :sites, :files, :manifest) do
8
+ def lines(verbose: false)
9
+ [
10
+ "models: #{models}, #{columns} columns",
11
+ "attribute methods: #{stock_methods} in stock Rails, #{generated_methods || "n/a (slim off)"} generated with slim",
12
+ "enum methods: #{enum_compiled}/#{enum_methods} compiled",
13
+ "attribute call sites: #{sites} compilable in #{files} files under app/ and lib/",
14
+ "manifest: #{manifest}",
15
+ ]
16
+ end
17
+ end
18
+
19
+ def self.report(root:, models: ActiveRecord::Base.descendants, guards: false)
20
+ models = models.reject { it.abstract_class? || !Index.table?(it) }
21
+ index = Index.new(models.map { Index.describe(it) }, keep: Slim.keep || Slim::DEFAULT_KEEP)
22
+ stock = models.sum { it.attribute_method_patterns.count * (it.attribute_names.size + it.attribute_aliases.size) }
23
+ generated = if Slim.enabled?
24
+ models.each(&:define_attribute_methods)
25
+ models.sum { it.send(:generated_attribute_methods).instance_methods(false).size }
26
+ end
27
+ enum_methods = enum_compiled = 0
28
+ models.each do |model|
29
+ mod = model.instance_variable_get(:@_enum_methods_module) or next
30
+ mod.instance_methods(false).each do |name|
31
+ enum_methods += 1
32
+ enum_compiled += 1 if mod.instance_method(name).source_location&.first.to_s.start_with?("enum ")
33
+ end
34
+ end
35
+ sites = files = 0
36
+ Dir.glob(File.join(root.to_s, "{app,lib}", "**", "*.rb")).each do |file|
37
+ n = Rewriter.new(index: index, path: file, guards: guards).rewrite(File.read(file)).sites
38
+ sites += n
39
+ files += 1 if n.positive?
40
+ end
41
+ Result.new(models.size, models.sum { it.attribute_names.size }, stock, generated, enum_methods, enum_compiled,
42
+ sites, files, manifest_state(root, index))
43
+ end
44
+
45
+ def self.manifest_state(root, index)
46
+ path = File.join(root.to_s, "config/ar_minimizer.json")
47
+ return "none (config/ar_minimizer.json; needed for load-time compilation)" unless File.exist?(path)
48
+
49
+ File.read(path) == Manifest.dump(index) ? "up to date" : "out of date: run bin/rails ar_minimizer:manifest"
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArMinimizer
4
+ # Enum predicates and bang methods as ordinary methods.
5
+ #
6
+ # Rails defines them with closures, and each predicate call builds and
7
+ # interns the `<name>_for_database` symbol before calling it:
8
+ #
9
+ # # def active?() status_for_database == 0 end (Rails' own comment)
10
+ # define_method("#{value_method_name}?") { public_send(:"#{name}_for_database") == value }
11
+ #
12
+ # Here the enum's literal data goes into the method source instead, as
13
+ # Roundhouse does for macros with literal arguments:
14
+ #
15
+ # def active? = public_send(:status_for_database) == 0
16
+ # def active! = update!("status" => 0)
17
+ #
18
+ # Rails still runs first (conflict detection, scopes); only the two methods
19
+ # are replaced. Rails allows booleans, integers, floats, symbols and strings
20
+ # as values; non-finite floats, and method names that aren't plain
21
+ # identifiers, keep Rails' closures.
22
+ module Enums
23
+ IDENTIFIER = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
24
+
25
+ class << self
26
+ # Before models load (their class bodies declare the enums).
27
+ def install!
28
+ @enabled = true
29
+ mod = ActiveRecord::Enum.const_get(:EnumMethods)
30
+ mod.prepend(Definer) unless mod < Definer
31
+ end
32
+
33
+ def disable! = @enabled = false
34
+
35
+ def enabled? = @enabled == true
36
+
37
+ def literal(value)
38
+ case value
39
+ when Integer, String, Symbol, true, false, nil then value.inspect
40
+ when Float then value.inspect if value.finite?
41
+ end
42
+ end
43
+ end
44
+
45
+ module Definer
46
+ private
47
+
48
+ def define_enum_methods(name, value_method_name, value, scopes, instance_methods)
49
+ super
50
+ return unless instance_methods && Enums.enabled?
51
+ return unless value_method_name.match?(IDENTIFIER) && (literal = Enums.literal(value))
52
+
53
+ remove_method(:"#{value_method_name}?", :"#{value_method_name}!")
54
+ module_eval(<<~RUBY, "enum #{klass.name}.#{name}", 1)
55
+ def #{value_method_name}? = public_send(#{:"#{name}_for_database".inspect}) == #{literal}
56
+ def #{value_method_name}! = update!(#{name.inspect} => #{literal})
57
+ RUBY
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArMinimizer
4
+ # Which method names the rewriter may compile, built from the loaded models.
5
+ # A name qualifies when, in every model that has it, it is a generated
6
+ # attribute method for the same attribute and proxy, e.g. "name_changed?" ->
7
+ # attribute_changed?("name"). It is dropped if any model defines it by hand
8
+ # (the hand-written method must keep running) or uses it as a column.
9
+ class Index
10
+ # classes: names of the models that have the attribute, for the call-site guard.
11
+ # query: for name?, what the column's values let it compile to (see QUERY).
12
+ Entry = Data.define(:proxy, :attr, :args, :classes, :query) do
13
+ # The proxy as callable on an explicit receiver: public ones directly,
14
+ # private ones (attribute_will_change!, ...) are nil and go through __send__.
15
+ def public_proxy = PUBLIC_ALIASES.fetch(proxy) { ActiveRecord::Base.public_method_defined?(proxy) ? proxy : nil }
16
+
17
+ def same_method?(other) = [proxy, attr, args] == [other.proxy, other.attr, other.args]
18
+ end
19
+
20
+ # What the index needs to know about one model; also what Manifest stores.
21
+ # attributes: { "name" => :string, ... } (query kind, or nil)
22
+ # patterns: [[prefix, suffix, proxy, takes_arguments], ...]
23
+ # hand_written: generated-method names the model defines itself
24
+ # superclass: the parent model's name, if it's a model
25
+ # modules: the app's own modules among its ancestors (concerns)
26
+ # associations: { "site" => ["Site", "one"], "items" => ["Item", "many"] }, not polymorphic
27
+ # scopes: names defined with `scope` (they return relations of the model)
28
+ # shadows: generated-method names of other models' attributes that this
29
+ # model defines some other way (an enum predicate, a delegate, ...)
30
+ Model = Data.define(:name, :attributes, :primary_key, :patterns, :hand_written, :superclass, :modules, :associations, :scopes, :shadows) do
31
+ def initialize(superclass: nil, modules: [], associations: {}, scopes: [], shadows: [], **rest) = super
32
+ end
33
+
34
+ PUBLIC_ALIASES = { "attribute?" => "query_attribute" }.freeze
35
+
36
+ # query_attribute on a real column comes down to: true -> true, false/nil
37
+ # -> false, else !value.zero? if it has zero?, else !value.blank?. A
38
+ # column's type fixes which of those can happen. Decorated types (enums,
39
+ # time zone conversion, encrypts, serialize, normalizes) aren't listed and
40
+ # keep query_attribute.
41
+ QUERY = [
42
+ [ActiveModel::Type::Boolean, :truthy], # true, false, nil
43
+ [ActiveModel::Type::Date, :truthy], [ActiveModel::Type::DateTime, :truthy], [ActiveModel::Type::Time, :truthy],
44
+ [ActiveModel::Type::Integer, :numeric], [ActiveModel::Type::Float, :numeric], [ActiveModel::Type::Decimal, :numeric],
45
+ [ActiveModel::Type::ImmutableString, :string], # and String, Text
46
+ ].freeze
47
+
48
+ attr_reader :entries, :models, :keep
49
+
50
+ def self.from_models(models = ActiveRecord::Base.descendants, keep: Slim.keep || Slim::DEFAULT_KEEP)
51
+ new(describe_all(models), keep: keep)
52
+ end
53
+
54
+ def self.table?(model)
55
+ model.table_exists?
56
+ rescue StandardError
57
+ false
58
+ end
59
+
60
+ def self.describe(model)
61
+ attributes = model.attribute_names.to_h { [it, query_kind(model, it)] }
62
+ patterns = model.attribute_method_patterns.map { [it.prefix, it.suffix, it.proxy_target, it.parameters != false] }
63
+ names = patterns.flat_map { |prefix, suffix, _| attributes.keys.map { "#{prefix}#{it}#{suffix}" } }
64
+ parent = model.superclass
65
+ Model.new(name: constant_name(model), attributes: attributes, primary_key: Array(model.primary_key), patterns: patterns,
66
+ hand_written: names.select { hand_written?(model, it) }.sort,
67
+ superclass: (constant_name(parent) if parent < ActiveRecord::Base && !parent.abstract_class?),
68
+ modules: app_modules(model), associations: associations(model), scopes: scopes(model))
69
+ end
70
+
71
+ def self.associations(model)
72
+ model.reflect_on_all_associations.each_with_object({}) do |reflection, map|
73
+ next if reflection.polymorphic?
74
+
75
+ target = constant_name(reflection.klass)
76
+ map[reflection.name.to_s] = [target, reflection.collection? ? "many" : "one"] if target
77
+ rescue StandardError, NameError
78
+ nil
79
+ end.sort.to_h
80
+ end
81
+
82
+ # Class methods `scope` defined: on the model (or a parent model) itself,
83
+ # with ActiveRecord's scoping code as their source.
84
+ def self.scopes(model)
85
+ model.singleton_methods.select do |name|
86
+ method = model.method(name)
87
+ owner = method.owner
88
+ owner.singleton_class? && owner.attached_object.is_a?(Class) && model <= owner.attached_object &&
89
+ method.source_location&.first.to_s.end_with?("active_record/scoping/named.rb")
90
+ rescue StandardError
91
+ false
92
+ end.map(&:to_s).sort
93
+ end
94
+
95
+ # Named modules among the model's ancestors that the app defines (not gems
96
+ # or Ruby): the concerns whose methods run with a record as self.
97
+ def self.app_modules(model)
98
+ model.ancestors.take_while { !it.equal?(ActiveRecord::Base) }.filter_map do |mod|
99
+ next if mod.is_a?(Class) || !(name = constant_name(mod))
100
+
101
+ file = Module.const_source_location(name)&.first.to_s
102
+ name unless file.empty? || file.include?("/gems/") || file.start_with?(RbConfig::CONFIG["rubylibdir"])
103
+ rescue StandardError
104
+ nil
105
+ end.sort
106
+ end
107
+
108
+ # The name, if it leads back to the class: call-site guards reference it
109
+ # as a constant. HABTM join models all call themselves "HABTM_<Association>".
110
+ def self.constant_name(model)
111
+ name = model.name
112
+ name if name && Object.const_get(name).equal?(model)
113
+ rescue NameError
114
+ nil
115
+ end
116
+
117
+ # The compiled form of name? calls the reader, so it must be the generated one.
118
+ def self.query_kind(model, attr)
119
+ return if hand_written?(model, attr) || model.defined_enums.key?(attr)
120
+
121
+ type = model.type_for_attribute(attr)
122
+ QUERY.find { |klass, _| type.is_a?(klass) }&.last
123
+ end
124
+
125
+ def self.hand_written?(model, name)
126
+ return false unless model.method_defined?(name) || model.private_method_defined?(name)
127
+
128
+ !model.instance_method(name).owner.is_a?(ActiveRecord::AttributeMethods::GeneratedAttributeMethods)
129
+ end
130
+
131
+ def initialize(models, keep:)
132
+ @models = models.sort_by { it.name.to_s }.freeze
133
+ @keep = keep.map(&:to_s).sort.freeze
134
+ found = {} # name => [proxy, attr, args, classes, query]
135
+ poisoned = Set.new
136
+ @models.each do |model|
137
+ model.patterns.each do |prefix, suffix, proxy, args|
138
+ skip_all = @keep.include?(proxy)
139
+ model.attributes.each do |attr, query|
140
+ name = "#{prefix}#{attr}#{suffix}"
141
+ next if poisoned.include?(name)
142
+ next poisoned << name if skip_all || model.primary_key.include?(attr) || model.hand_written.include?(name)
143
+
144
+ query = nil unless proxy == "attribute?"
145
+ if (seen = found[name])
146
+ next poisoned << name unless seen[0] == proxy && seen[1] == attr && seen[2] == args
147
+
148
+ seen[3] << model.name if model.name
149
+ seen[4] = nil unless seen[4] == query
150
+ else
151
+ found[name] = [proxy, attr, args, model.name ? [model.name] : [], query]
152
+ end
153
+ end
154
+ end
155
+ end
156
+ @entries = found.each_with_object({}) do |(name, (proxy, attr, args, classes, query)), entries|
157
+ entries[name] = Entry.new(proxy, attr, args, classes.sort!.freeze, query) unless poisoned.include?(name)
158
+ end.freeze
159
+ end
160
+
161
+ def [](name) = @entries[name.to_s]
162
+
163
+ def model?(name) = by_name.key?(name)
164
+
165
+ def app_module?(name) = includers.key?(name)
166
+
167
+ # The model and every model below it.
168
+ def with_descendants(name)
169
+ @with_descendants ||= {}
170
+ @with_descendants[name] ||= begin
171
+ children = @models.select { it.superclass == name }.map(&:name)
172
+ ([name] + children.flat_map { with_descendants(it) }).uniq.freeze
173
+ end
174
+ end
175
+
176
+ # [class name, "one" or "many"] for model#name, or nil.
177
+ def association(model, name) = by_name[model]&.associations&.[](name.to_s)
178
+
179
+ def scope?(model, name) = by_name[model]&.scopes&.include?(name.to_s) || false
180
+
181
+ # Models (and their subclasses) that include the module.
182
+ def includers_of(name) = includers.fetch(name, [])
183
+
184
+ # Would the entry's method on any of these classes be the generated one?
185
+ def covers?(entry, classes) = classes.all? { entry.classes.include?(it) }
186
+
187
+ def ==(other) = other.is_a?(Index) && entries == other.entries
188
+
189
+ # Same input, same index: what Manifest stores and compares.
190
+ def self.describe_all(models = ActiveRecord::Base.descendants)
191
+ described = models.reject { it.abstract_class? || !table?(it) }.to_h { [it, describe(it)] }
192
+ generated = described.values.flat_map { |m| m.patterns.flat_map { |prefix, suffix, _| m.attributes.keys.map { "#{prefix}#{it}#{suffix}" } } }.uniq
193
+ described.map do |model, m|
194
+ own = m.patterns.flat_map { |prefix, suffix, _| m.attributes.keys.map { "#{prefix}#{it}#{suffix}" } }.to_set
195
+ shadows = generated.select { !own.include?(it) && (model.method_defined?(it) || model.private_method_defined?(it)) && !ActiveRecord::Base.method_defined?(it) }
196
+ shadows.empty? ? m : m.with(shadows: shadows.sort)
197
+ end
198
+ end
199
+
200
+ # Models that define name without it being their attribute method.
201
+ def shadowed(name)
202
+ @shadowed ||= @models.each_with_object({}) { |m, map| m.shadows.each { (map[it] ||= []) << m.name } }
203
+ @shadowed.fetch(name.to_s, [])
204
+ end
205
+
206
+ def size = @entries.size
207
+
208
+ private
209
+
210
+ def by_name = @by_name ||= @models.to_h { [it.name, it] }
211
+
212
+ def includers
213
+ @includers ||= @models.each_with_object(Hash.new { |h, k| h[k] = [] }) do |model, map|
214
+ model.modules.each { map[it] |= with_descendants(model.name) }
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArMinimizer
4
+ # Compiles each matching file as Ruby loads it, instead of Build rewriting
5
+ # files in place, the way ruby-next's runtime mode works: through
6
+ # require-hooks, which also makes bootsnap cache the compiled result. The
7
+ # bootsnap cache key includes the manifest digest, so a new manifest
8
+ # recompiles everything.
9
+ #
10
+ # config.ar_minimizer.load_time = true # needs config/ar_minimizer.json
11
+ module Loader
12
+ class << self
13
+ # index: an Index, or something that returns one when called: with a
14
+ # warm bootsnap cache nothing gets compiled and the index is never needed.
15
+ # version: changes when the index does (Manifest.digest of the file).
16
+ def install!(index:, patterns:, exclude_patterns: nil, version: nil, guards: false)
17
+ require "require-hooks/setup"
18
+ @index = index
19
+ @guards = guards
20
+ @mutex = Mutex.new
21
+ if defined?(RequireHooks::Bootsnap) && RequireHooks::Bootsnap.respond_to?(:add_version_hash)
22
+ RequireHooks::Bootsnap.add_version_hash("ar_minimizer-#{version || Manifest.digest(self.index)}-guards-#{guards}")
23
+ end
24
+ RequireHooks.source_transform(patterns: patterns, exclude_patterns: exclude_patterns) do |path, source|
25
+ Loader.transform(path, source)
26
+ end
27
+ end
28
+
29
+ def index
30
+ return @index unless @index.respond_to?(:call)
31
+
32
+ @mutex.synchronize { @index = @index.call if @index.respond_to?(:call) }
33
+ @index
34
+ end
35
+
36
+ # nil leaves the file as it is.
37
+ def transform(path, source)
38
+ result = Rewriter.new(index: index, path: path, marker: false, guards: @guards).rewrite(source || File.read(path))
39
+ result.source if result.sites.positive?
40
+ end
41
+ end
42
+ end
43
+ end