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,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