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 +7 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +143 -0
- data/lib/activerecord-minimizer.rb +3 -0
- data/lib/ar_minimizer/build.rb +54 -0
- data/lib/ar_minimizer/census.rb +52 -0
- data/lib/ar_minimizer/enums.rb +61 -0
- data/lib/ar_minimizer/index.rb +218 -0
- data/lib/ar_minimizer/loader.rb +43 -0
- data/lib/ar_minimizer/manifest.rb +65 -0
- data/lib/ar_minimizer/profile.rb +62 -0
- data/lib/ar_minimizer/railtie.rb +111 -0
- data/lib/ar_minimizer/rewriter.rb +470 -0
- data/lib/ar_minimizer/slim.rb +258 -0
- data/lib/ar_minimizer/small_records.rb +97 -0
- data/lib/ar_minimizer/version.rb +5 -0
- data/lib/ar_minimizer/views.rb +50 -0
- data/lib/ar_minimizer.rb +34 -0
- metadata +112 -0
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,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
|