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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module ArMinimizer
|
|
7
|
+
# The Index as a file, so source can be compiled where no model is loaded
|
|
8
|
+
# yet (Loader, while Ruby requires each file) or no database is around.
|
|
9
|
+
# It stores what the index is computed from (each model's columns, patterns
|
|
10
|
+
# and hand-written methods), one model per line. Generate it like
|
|
11
|
+
# db/schema.rb and commit it:
|
|
12
|
+
#
|
|
13
|
+
# bin/rails ar_minimizer:manifest # writes config/ar_minimizer.json
|
|
14
|
+
# bin/rails ar_minimizer:manifest:check # in CI: fails when out of date
|
|
15
|
+
#
|
|
16
|
+
# It goes stale when the schema changes, or when a model gets a hand-written
|
|
17
|
+
# method with a generated method's name, or overrides a reader.
|
|
18
|
+
module Manifest
|
|
19
|
+
FORMAT = 1
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def dump(index) = dump_models(index.models, keep: index.keep)
|
|
23
|
+
|
|
24
|
+
# From Index.describe_all, without building the index (the boot-time check).
|
|
25
|
+
def dump_models(models, keep:)
|
|
26
|
+
pattern_sets = models.map(&:patterns).uniq.sort
|
|
27
|
+
lines = models.map do |m|
|
|
28
|
+
JSON.generate({ "name" => m.name, "patterns" => pattern_sets.index(m.patterns), "primary_key" => m.primary_key,
|
|
29
|
+
# sorted: column order differs between a migrated database and one loaded from schema.rb
|
|
30
|
+
"attributes" => m.attributes.sort.to_h, "hand_written" => m.hand_written,
|
|
31
|
+
"superclass" => m.superclass, "modules" => m.modules,
|
|
32
|
+
"associations" => m.associations, "scopes" => m.scopes, **({ "shadows" => m.shadows } if m.shadows.any?) })
|
|
33
|
+
end.sort
|
|
34
|
+
<<~JSON
|
|
35
|
+
{"format": #{FORMAT}, "ar_minimizer": #{VERSION.to_json}, "keep": #{keep.map(&:to_s).sort.to_json},
|
|
36
|
+
"patterns": #{JSON.generate(pattern_sets)},
|
|
37
|
+
"models": [
|
|
38
|
+
#{lines.join(",\n")}
|
|
39
|
+
]}
|
|
40
|
+
JSON
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def parse(json)
|
|
44
|
+
data = JSON.parse(json)
|
|
45
|
+
raise ArgumentError, "ar_minimizer manifest format #{data["format"]}, expected #{FORMAT}" unless data["format"] == FORMAT
|
|
46
|
+
|
|
47
|
+
models = data["models"].map do |m|
|
|
48
|
+
Index::Model.new(name: m["name"], attributes: m["attributes"].transform_values { it&.to_sym }, primary_key: m["primary_key"],
|
|
49
|
+
patterns: data["patterns"].fetch(m["patterns"]), hand_written: m["hand_written"],
|
|
50
|
+
superclass: m["superclass"], modules: m["modules"] || [],
|
|
51
|
+
associations: m["associations"] || {}, scopes: m["scopes"] || [], shadows: m["shadows"] || [])
|
|
52
|
+
end
|
|
53
|
+
Index.new(models, keep: data["keep"])
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def load(path) = parse(File.read(path))
|
|
57
|
+
|
|
58
|
+
# Changes whenever the compiled output could: the models and this gem's
|
|
59
|
+
# version (both in the dump). Of a dump or a manifest file's contents.
|
|
60
|
+
def digest(index_or_json)
|
|
61
|
+
Digest::SHA256.hexdigest(index_or_json.is_a?(String) ? index_or_json : dump(index_or_json))[0, 16]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArMinimizer
|
|
4
|
+
# Counts calls that take Slim's slow path (method_missing), per
|
|
5
|
+
# Model#method, and writes them out at exit, one file per process. With
|
|
6
|
+
# lazy generation (the default) that is one call per method and model: the
|
|
7
|
+
# list of methods generated at runtime. Put it in Slim.keep_methods to
|
|
8
|
+
# generate them at boot instead, before a forking server forks, so the
|
|
9
|
+
# workers share them.
|
|
10
|
+
#
|
|
11
|
+
# AR_MINIMIZER_PROFILE=tmp/ar_minimizer bin/rails test
|
|
12
|
+
# bin/rails ar_minimizer:profile MIN=100 > config/ar_minimizer_keep.txt
|
|
13
|
+
module Profile
|
|
14
|
+
class << self
|
|
15
|
+
attr_reader :tally
|
|
16
|
+
|
|
17
|
+
def start!(dir)
|
|
18
|
+
@dir = dir.to_s
|
|
19
|
+
@tally = Hash.new(0)
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
# Counter must sit in front of Slim::Record (inert while Slim is off),
|
|
22
|
+
# which resolves aliases without calling super.
|
|
23
|
+
ActiveRecord::Base.prepend(Slim::Record) unless ActiveRecord::Base < Slim::Record
|
|
24
|
+
ActiveRecord::Base.prepend(Counter) unless ActiveRecord::Base < Counter
|
|
25
|
+
at_exit { write }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def stop!
|
|
29
|
+
write
|
|
30
|
+
@tally = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def count(model, name)
|
|
34
|
+
@mutex.synchronize { @tally["#{model.name}##{name}"] += 1 } if @tally
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def write
|
|
38
|
+
return if @tally.nil? || @tally.empty?
|
|
39
|
+
|
|
40
|
+
FileUtils.mkdir_p(@dir)
|
|
41
|
+
File.write(File.join(@dir, "#{Process.pid}.tsv"), @tally.map { |name, n| "#{n}\t#{name}\n" }.join)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Merged counts from every file in +dir+, most called first.
|
|
45
|
+
def report(dir)
|
|
46
|
+
Dir.glob(File.join(dir.to_s, "*.tsv")).each_with_object(Hash.new(0)) do |file, sum|
|
|
47
|
+
File.foreach(file) do |line|
|
|
48
|
+
n, name = line.chomp.split("\t", 2)
|
|
49
|
+
sum[name] += Integer(n)
|
|
50
|
+
end
|
|
51
|
+
end.sort_by { |name, n| [-n, name] }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
module Counter
|
|
56
|
+
def attribute_missing(match, ...)
|
|
57
|
+
Profile.count(self.class, match.proxy_target.sub("attribute", match.attr_name))
|
|
58
|
+
super
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArMinimizer
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
config.ar_minimizer = ActiveSupport::OrderedOptions.new
|
|
6
|
+
|
|
7
|
+
# In config/environments/production.rb:
|
|
8
|
+
#
|
|
9
|
+
# config.ar_minimizer.slim = true # generate only readers and writers
|
|
10
|
+
# config.ar_minimizer.strict = false # ... and generate the rest when first called, instead of removing them
|
|
11
|
+
# config.ar_minimizer.load_time = true # compile app/ and lib/ as they load (needs the manifest)
|
|
12
|
+
# config.ar_minimizer.views = true # compile templates as ActionView loads them
|
|
13
|
+
# config.ar_minimizer.enums = true # enum predicates/bang methods as plain methods
|
|
14
|
+
# config.ar_minimizer.small_records = true # no Hashes for records that never fill them
|
|
15
|
+
# config.ar_minimizer.guards = true # also compile calls on receivers of unknown class, behind a class check
|
|
16
|
+
#
|
|
17
|
+
# config/ar_minimizer.json is the manifest (ar_minimizer:manifest). With it,
|
|
18
|
+
# load_time and views need no database or loaded models; without it, views
|
|
19
|
+
# index the models loaded when each template compiles.
|
|
20
|
+
#
|
|
21
|
+
# config/ar_minimizer_keep.txt, if present, lists methods to generate at
|
|
22
|
+
# boot (one "name" or "Model#name" per line; see ar_minimizer:profile).
|
|
23
|
+
initializer "ar_minimizer", before: :load_config_initializers do |app|
|
|
24
|
+
options = app.config.ar_minimizer
|
|
25
|
+
keep_file = app.root.join("config/ar_minimizer_keep.txt")
|
|
26
|
+
keep_methods = keep_file.exist? ? ArMinimizer::Slim.parse_keep_file(keep_file.read) : []
|
|
27
|
+
manifest_file = app.root.join("config/ar_minimizer.json")
|
|
28
|
+
# Read now (a few KB), indexed only when something has to be compiled.
|
|
29
|
+
manifest_json = manifest_file.read if manifest_file.exist? && (options.load_time || options.views)
|
|
30
|
+
manifest = -> { ArMinimizer::Manifest.parse(manifest_json) } if manifest_json
|
|
31
|
+
|
|
32
|
+
ActiveSupport.on_load(:active_record) do
|
|
33
|
+
ArMinimizer::Enums.install! if options.enums
|
|
34
|
+
ArMinimizer::SmallRecords.install! if options.small_records
|
|
35
|
+
ArMinimizer::Slim.enable!(keep_methods: keep_methods, strict: options.strict != false) if options.slim
|
|
36
|
+
ArMinimizer::Profile.start!(ENV["AR_MINIMIZER_PROFILE"]) if ENV["AR_MINIMIZER_PROFILE"]
|
|
37
|
+
end
|
|
38
|
+
ActiveSupport.on_load(:action_view) { ArMinimizer::Views.install!(index: manifest, guards: !!options.guards) } if options.views
|
|
39
|
+
|
|
40
|
+
if options.load_time
|
|
41
|
+
raise "config.ar_minimizer.load_time needs #{manifest_file}: run bin/rails ar_minimizer:manifest" unless manifest
|
|
42
|
+
|
|
43
|
+
roots = [app.root.join("app"), app.root.join("lib")].map { File.realpath(it) if it.exist? }.compact
|
|
44
|
+
# File.fnmatch? without FNM_PATHNAME: `*` crosses directories.
|
|
45
|
+
ArMinimizer::Loader.install!(index: manifest, patterns: roots.map { "#{it}/*.rb" }, guards: !!options.guards,
|
|
46
|
+
version: ArMinimizer::Manifest.digest(manifest_json))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Every model is loaded after eager loading: say so if the manifest no
|
|
50
|
+
# longer describes them (the compiled code may skip a new hand-written method).
|
|
51
|
+
if manifest && options.verify_manifest != false
|
|
52
|
+
app.config.after_initialize do
|
|
53
|
+
next unless app.config.eager_load
|
|
54
|
+
|
|
55
|
+
live = ArMinimizer::Manifest.dump_models(ArMinimizer::Index.describe_all, keep: ArMinimizer::Slim.keep || ArMinimizer::Slim::DEFAULT_KEEP)
|
|
56
|
+
if live != manifest_json
|
|
57
|
+
Rails.logger.error("ar_minimizer: #{manifest_file} is out of date; run bin/rails ar_minimizer:manifest")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
rake_tasks do
|
|
64
|
+
namespace :ar_minimizer do
|
|
65
|
+
desc "Compile attribute pattern calls (name?, name_changed?, ...) in place " \
|
|
66
|
+
"(production only unless FORCE=1; DRY_RUN=1 to preview; AR_MINIMIZER_PATHS=app,lib)"
|
|
67
|
+
task build: :environment do
|
|
68
|
+
Rails.application.eager_load!
|
|
69
|
+
ArMinimizer::Build.new(
|
|
70
|
+
root: Rails.root,
|
|
71
|
+
paths: ENV.fetch("AR_MINIMIZER_PATHS", "app,lib").split(","),
|
|
72
|
+
env: Rails.env,
|
|
73
|
+
dry_run: ENV["DRY_RUN"] == "1",
|
|
74
|
+
force: ENV["FORCE"] == "1",
|
|
75
|
+
out: $stdout,
|
|
76
|
+
guards: ENV["GUARDS"] == "1" || !!Rails.application.config.ar_minimizer.guards,
|
|
77
|
+
).run
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
desc "Write config/ar_minimizer.json from the loaded models (commit it, like db/schema.rb)"
|
|
81
|
+
task manifest: :environment do
|
|
82
|
+
Rails.application.eager_load!
|
|
83
|
+
path = Rails.root.join("config/ar_minimizer.json")
|
|
84
|
+
index = ArMinimizer::Index.from_models
|
|
85
|
+
File.write(path, ArMinimizer::Manifest.dump(index))
|
|
86
|
+
puts "ar_minimizer: #{path.relative_path_from(Rails.root)} (#{index.size} methods, #{ArMinimizer::Manifest.digest(index)})"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
namespace :manifest do
|
|
90
|
+
desc "Fail if config/ar_minimizer.json doesn't match the models (for CI)"
|
|
91
|
+
task check: :environment do
|
|
92
|
+
Rails.application.eager_load!
|
|
93
|
+
path = Rails.root.join("config/ar_minimizer.json")
|
|
94
|
+
live = ArMinimizer::Manifest.dump(ArMinimizer::Index.from_models)
|
|
95
|
+
abort "ar_minimizer: #{path} is missing or out of date; run bin/rails ar_minimizer:manifest" unless path.exist? && path.read == live
|
|
96
|
+
puts "ar_minimizer: manifest up to date"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
desc "Print slow-path calls recorded with AR_MINIMIZER_PROFILE=dir, as a keep list " \
|
|
101
|
+
"(DIR=tmp/ar_minimizer, MIN=1)"
|
|
102
|
+
task :profile do
|
|
103
|
+
min = Integer(ENV.fetch("MIN", "1"))
|
|
104
|
+
ArMinimizer::Profile.report(ENV.fetch("DIR", "tmp/ar_minimizer")).each do |name, n|
|
|
105
|
+
puts "#{name} # #{n} calls" if n >= min
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|