activerecord-precompiled 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 +45 -0
- data/LICENSE.txt +21 -0
- data/README.md +284 -0
- data/exe/ar-precompile +47 -0
- data/lib/active_record_precompiled/assoc_site.rb +65 -0
- data/lib/active_record_precompiled/build.rb +52 -0
- data/lib/active_record_precompiled/capture.rb +94 -0
- data/lib/active_record_precompiled/friendly_site.rb +49 -0
- data/lib/active_record_precompiled/keys.rb +41 -0
- data/lib/active_record_precompiled/preload.rb +619 -0
- data/lib/active_record_precompiled/railtie.rb +46 -0
- data/lib/active_record_precompiled/rewriter.rb +917 -0
- data/lib/active_record_precompiled/schema_dump.rb +181 -0
- data/lib/active_record_precompiled/scope_analyzer.rb +246 -0
- data/lib/active_record_precompiled/scope_check.rb +59 -0
- data/lib/active_record_precompiled/scopes.rb +32 -0
- data/lib/active_record_precompiled/site.rb +481 -0
- data/lib/active_record_precompiled/version.rb +5 -0
- data/lib/active_record_precompiled.rb +64 -0
- data/lib/activerecord-precompiled.rb +4 -0
- metadata +102 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module ActiveRecordPrecompiled
|
|
6
|
+
# The build needs the production schema (columns, in order, with their
|
|
7
|
+
# types; primary keys), never rows. ActiveRecord's schema cache holds just
|
|
8
|
+
# that, so a dump made on production and committed lets the build run where
|
|
9
|
+
# the production database isn't, as in `docker build`: each SQLite database
|
|
10
|
+
# without a database file becomes an empty in-memory one that answers schema
|
|
11
|
+
# questions from its dump.
|
|
12
|
+
#
|
|
13
|
+
# The dumps are kept apart from ActiveRecord's own (db/schema_cache.yml),
|
|
14
|
+
# which the app would load at boot: at runtime the schema guard must compare
|
|
15
|
+
# against the real database.
|
|
16
|
+
module SchemaDump
|
|
17
|
+
MissingSchema = Class.new(StandardError)
|
|
18
|
+
|
|
19
|
+
PATH = "config/ar_precompile_schema_cache.yml"
|
|
20
|
+
|
|
21
|
+
# `mode`: "auto" (the default) uses a dump for each database whose file is
|
|
22
|
+
# missing; "dump" uses the dumps even if it isn't; "database" never does.
|
|
23
|
+
MODES = %w[auto dump database].freeze
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
# Writes the dump of each SQLite database; run it where the production
|
|
27
|
+
# database is, and commit the files.
|
|
28
|
+
def dump(root, out: nil)
|
|
29
|
+
sqlite_pools.uniq { it.db_config.name }.map do |pool|
|
|
30
|
+
path = path(root, pool.db_config)
|
|
31
|
+
ActiveRecord::Tasks::DatabaseTasks.dump_schema_cache(pool, path)
|
|
32
|
+
out&.puts "activerecord-precompiled: wrote the #{pool.db_config.name} schema to #{relative(root, path)}"
|
|
33
|
+
path
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Points the databases that need it at their dumps; the paths used.
|
|
38
|
+
def use(root, mode: nil, out: nil)
|
|
39
|
+
mode = (mode.nil? || mode.empty?) ? "auto" : mode
|
|
40
|
+
raise ArgumentError, "AR_PRECOMPILE_SCHEMA must be one of #{MODES.join(", ")}, not #{mode.inspect}" unless MODES.include?(mode)
|
|
41
|
+
return [] if mode == "database"
|
|
42
|
+
|
|
43
|
+
used = sqlite_pools.filter_map do |pool|
|
|
44
|
+
db_config = pool.db_config
|
|
45
|
+
missing = missing(root, pool)
|
|
46
|
+
next if mode == "auto" && !missing
|
|
47
|
+
|
|
48
|
+
path = path(root, db_config)
|
|
49
|
+
unless File.file?(path)
|
|
50
|
+
message = "no #{db_config.name} database at #{db_config.database} and no schema dump at #{relative(root, path)}"
|
|
51
|
+
hint = "run `RAILS_ENV=production bin/rails ar_precompile:schema:dump` where the production database is, and commit the file"
|
|
52
|
+
raise MissingSchema, "activerecord-precompiled: #{message}; #{hint}" if db_config.primary?
|
|
53
|
+
|
|
54
|
+
# Solid Cache/Queue/Cable and the like: chains on their models stay
|
|
55
|
+
# with ActiveRecord, and nothing creates an empty database file.
|
|
56
|
+
out&.puts "activerecord-precompiled: #{message}; queries on it are left to ActiveRecord (to compile them, #{hint})"
|
|
57
|
+
repoint(pool, nil)
|
|
58
|
+
remove(root, missing, out) if missing.is_a?(String)
|
|
59
|
+
next
|
|
60
|
+
end
|
|
61
|
+
out&.puts "activerecord-precompiled: #{db_config.name} schema from #{relative(root, path)}"
|
|
62
|
+
migrate(root, repoint(pool, path), path, out)
|
|
63
|
+
remove(root, missing, out) if missing.is_a?(String)
|
|
64
|
+
path
|
|
65
|
+
end
|
|
66
|
+
if used.any?
|
|
67
|
+
# The empty in-memory database has no schema_migrations to check the dump against.
|
|
68
|
+
ActiveRecord::ConnectionAdapters::SchemaReflection.use_schema_cache_dump = true
|
|
69
|
+
ActiveRecord::ConnectionAdapters::SchemaReflection.check_schema_cache_dump_version = false
|
|
70
|
+
end
|
|
71
|
+
used
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# PATH for the primary database, config/ar_precompile_<name>_schema_cache.yml for others.
|
|
75
|
+
def path(root, db_config)
|
|
76
|
+
File.join(root.to_s, db_config.primary? ? PATH : "config/ar_precompile_#{db_config.name}_schema_cache.yml")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def sqlite_pools
|
|
82
|
+
ActiveRecord::Base.connection_handler.connection_pool_list(:all).select { it.db_config.adapter.to_s.start_with?("sqlite") }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Whether the database is missing: true if its file is; the file if it
|
|
86
|
+
# has no tables, which is what connecting while the app loads, before
|
|
87
|
+
# the build, leaves behind (Rails 8's pragmas write a 4 KB header).
|
|
88
|
+
def missing(root, pool)
|
|
89
|
+
database = pool.db_config.database.to_s
|
|
90
|
+
return false if database.empty? || database == ":memory:" || database.start_with?("file:")
|
|
91
|
+
|
|
92
|
+
file = File.expand_path(database, root.to_s)
|
|
93
|
+
return true unless File.exist?(file)
|
|
94
|
+
|
|
95
|
+
pool.with_connection { it.data_sources.empty? } && file
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Out of the image: the app would start on it instead of failing.
|
|
99
|
+
def remove(root, file, out)
|
|
100
|
+
FileUtils.rm_f(["", "-wal", "-shm", "-journal"].map { file + it })
|
|
101
|
+
out&.puts "activerecord-precompiled: removed #{relative(root, file)}, a database with no tables (as connecting while the app loads creates)"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The same connection (class, role, shard), on an empty in-memory
|
|
105
|
+
# database with the dump (if any) as its schema cache.
|
|
106
|
+
def repoint(pool, path)
|
|
107
|
+
config = pool.db_config
|
|
108
|
+
memory = ActiveRecord::DatabaseConfigurations::HashConfig.new(
|
|
109
|
+
config.env_name, config.name, config.configuration_hash.merge(database: ":memory:", schema_cache_path: path),
|
|
110
|
+
)
|
|
111
|
+
pool_config = pool.pool_config
|
|
112
|
+
ActiveRecord::Base.connection_handler.establish_connection(
|
|
113
|
+
memory, owner_name: pool_config.connection_descriptor, role: pool_config.role, shard: pool_config.shard,
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A dump older than the newest migration lacks its changes: rebuild the
|
|
118
|
+
# dumped tables, columns in order, and run the newer migrations on them,
|
|
119
|
+
# as production will. SQLite adds a column last and rebuilds a changed
|
|
120
|
+
# table in place, so the order comes out as production's. If a migration
|
|
121
|
+
# can't run on empty tables, the build uses the dump as it is, and the
|
|
122
|
+
# sites it changes fall back to ActiveRecord at runtime.
|
|
123
|
+
def migrate(root, pool, path, out)
|
|
124
|
+
dump = {}
|
|
125
|
+
ActiveRecord::ConnectionAdapters::SchemaCache._load_from(path).encode_with(dump)
|
|
126
|
+
dumped = dump["version"].to_i
|
|
127
|
+
pending = pool.migration_context.migrations.select { it.version > dumped }
|
|
128
|
+
return pool if pending.empty?
|
|
129
|
+
|
|
130
|
+
versions = pending.map(&:version).join(", ")
|
|
131
|
+
pool = repoint(pool, nil)
|
|
132
|
+
pool.with_connection do |connection|
|
|
133
|
+
dump["columns"].each { |table, columns| create_table(connection, table, columns, dump["primary_keys"][table]) }
|
|
134
|
+
dump["indexes"].each_value do |indexes|
|
|
135
|
+
indexes.each { connection.add_index(it.table, it.columns, name: it.name, unique: it.unique, where: it.where, order: it.orders) }
|
|
136
|
+
end
|
|
137
|
+
pool.schema_migration.create_table
|
|
138
|
+
pool.migration_context.migrations.each { pool.schema_migration.create_version(it.version.to_s) if it.version <= dumped }
|
|
139
|
+
verbose, ActiveRecord::Migration.verbose = ActiveRecord::Migration.verbose, false
|
|
140
|
+
begin
|
|
141
|
+
pool.migration_context.migrate
|
|
142
|
+
ensure
|
|
143
|
+
ActiveRecord::Migration.verbose = verbose
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
out&.puts "activerecord-precompiled: applied migrations #{versions} to #{relative(root, path)}; " \
|
|
147
|
+
"dump it again once they have run on production"
|
|
148
|
+
pool
|
|
149
|
+
rescue StandardError => e
|
|
150
|
+
e = e.cause || e # the Migrator's "this and all later migrations canceled"
|
|
151
|
+
message = "activerecord-precompiled: #{relative(root, path)} is at schema version #{dumped}, and migrations " \
|
|
152
|
+
"#{versions} didn't run on it (#{e.class}: #{e.message.lines.first&.strip}); building from it as it is, " \
|
|
153
|
+
"so the queries they change run on ActiveRecord: dump it again once they have run on production"
|
|
154
|
+
out ? out.puts(message) : ActiveRecordPrecompiled.warn(message)
|
|
155
|
+
repoint(pool, path)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The table as the dump has it: columns in order, types, defaults, keys.
|
|
159
|
+
def create_table(connection, table, columns, primary_key)
|
|
160
|
+
keys = Array(primary_key)
|
|
161
|
+
definitions = columns.map do |column|
|
|
162
|
+
sql = +"#{connection.quote_column_name(column.name)} #{column.sql_type}"
|
|
163
|
+
sql << " PRIMARY KEY" if keys == [column.name]
|
|
164
|
+
sql << " AUTOINCREMENT" if keys == [column.name] && column.auto_increment?
|
|
165
|
+
sql << " NOT NULL" unless column.null
|
|
166
|
+
if column.default_function
|
|
167
|
+
sql << " DEFAULT (#{column.default_function})"
|
|
168
|
+
elsif !column.default.nil?
|
|
169
|
+
sql << " DEFAULT #{connection.quote(column.default)}"
|
|
170
|
+
end
|
|
171
|
+
sql << " COLLATE #{connection.quote_column_name(column.collation)}" if column.collation
|
|
172
|
+
sql
|
|
173
|
+
end
|
|
174
|
+
definitions << "PRIMARY KEY (#{keys.map { connection.quote_column_name(it) }.join(", ")})" if keys.size > 1
|
|
175
|
+
connection.execute("CREATE TABLE #{connection.quote_table_name(table)} (#{definitions.join(", ")})")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def relative(root, path) = path.delete_prefix("#{root}/")
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module ActiveRecordPrecompiled
|
|
6
|
+
# Decides whether a scope always builds the same SQL shape, so its SQL can
|
|
7
|
+
# be captured at build time. Static means: one chain of all/where/where.not/order/
|
|
8
|
+
# limit/offset and other static scopes, whose arguments are literals or the
|
|
9
|
+
# scope's own parameters. Enum scopes are static by construction.
|
|
10
|
+
# Anything else raises NotStatic with a reason and a hint.
|
|
11
|
+
class ScopeAnalyzer
|
|
12
|
+
NotStatic = Class.new(StandardError)
|
|
13
|
+
Analysis = Data.define(:arity, :param_columns)
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@trees = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The belongs_to behind a non-column `where` key: `where(site: x)` binds
|
|
20
|
+
# x (or, for a record, its primary key) to `site_id`. Returns the
|
|
21
|
+
# reflection, or why the key can't be compiled.
|
|
22
|
+
def self.belongs_to_key(model, key)
|
|
23
|
+
reflection = model.reflect_on_association(key.to_sym)
|
|
24
|
+
return "`#{key}` is not a column of #{model.name}" unless reflection
|
|
25
|
+
return "`#{key}` is a #{reflection.macro} association; only belongs_to keys are compiled" unless reflection.belongs_to?
|
|
26
|
+
return "`#{key}` is a polymorphic association" if reflection.polymorphic?
|
|
27
|
+
return "`#{key}` has a composite key" if [reflection.foreign_key, Keys.join_primary_key(reflection)].any?(Array)
|
|
28
|
+
|
|
29
|
+
reflection
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def analyze(model, name, depth = 0)
|
|
33
|
+
body = Scopes.body_for(model, name) or raise NotStatic, "`#{name}` is not a scope"
|
|
34
|
+
raise NotStatic, "#{model.name}'s table `#{model.table_name}` doesn't exist" if depth.zero? && !model.table_exists?
|
|
35
|
+
if body == :extension
|
|
36
|
+
raise NotStatic, "it has an extension block (`scope ... do ... end`); define those methods elsewhere"
|
|
37
|
+
end
|
|
38
|
+
if (redefined = redefinition(model, name))
|
|
39
|
+
raise NotStatic, "redefined by #{redefined}; remove that method or the scope"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
analyze_body(model, body, depth)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Scopes Rails generates: enum values, has_rich_text (with_rich_text_*),
|
|
46
|
+
# has_*_attached (with_attached_*). Their source interpolates names, but
|
|
47
|
+
# once defined they always build the same relation.
|
|
48
|
+
GENERATED_BY = %w[active_record/enum.rb action_text/attribute.rb active_storage/attached/model.rb].freeze
|
|
49
|
+
|
|
50
|
+
def generated_scope?(body)
|
|
51
|
+
body.respond_to?(:source_location) && body.arity.zero? &&
|
|
52
|
+
GENERATED_BY.any? { body.source_location&.first.to_s.end_with?(it) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# "path:line" of a method that replaced the scope ActiveRecord generated.
|
|
56
|
+
def redefinition(model, name)
|
|
57
|
+
file, line = model.method(name).source_location
|
|
58
|
+
file.to_s.end_with?("active_record/scoping/named.rb") ? nil : "#{file}:#{line}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A lambda that isn't a named scope, such as an association's scope.
|
|
62
|
+
def analyze_proc(model, body) = analyze_body(model, body, 0)
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def analyze_body(model, body, depth)
|
|
67
|
+
raise NotStatic, "it nests scopes too deeply" if depth > 5
|
|
68
|
+
return Analysis.new(0, {}) if generated_scope?(body)
|
|
69
|
+
|
|
70
|
+
file, line = body.source_location
|
|
71
|
+
raise NotStatic, "its source can't be found (defined with eval?)" unless file && File.file?(file)
|
|
72
|
+
|
|
73
|
+
lambdas = lambdas_at(file, line)
|
|
74
|
+
raise NotStatic, "its lambda can't be found at #{file}:#{line}" if lambdas.empty?
|
|
75
|
+
raise NotStatic, "#{file}:#{line} has more than one lambda; put the scope on its own line" if lambdas.size > 1
|
|
76
|
+
|
|
77
|
+
node = lambdas.first
|
|
78
|
+
params = parameters(node)
|
|
79
|
+
raise NotStatic, "it doesn't take the parameters its source shows" unless body.arity == params.size
|
|
80
|
+
|
|
81
|
+
statements = node.body&.body || []
|
|
82
|
+
raise NotStatic, "its body must be a single query chain" unless statements.size == 1
|
|
83
|
+
|
|
84
|
+
param_columns = {}
|
|
85
|
+
check_chain(statements.first, model, params, param_columns, depth)
|
|
86
|
+
Analysis.new(params.size, param_columns)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def lambdas_at(file, line)
|
|
90
|
+
tree = (@trees[file] ||= Prism.parse_file(file).value)
|
|
91
|
+
found = []
|
|
92
|
+
tree.breadth_first_search do |n|
|
|
93
|
+
if n.is_a?(Prism::LambdaNode) && n.location.start_line == line
|
|
94
|
+
found << n
|
|
95
|
+
elsif n.is_a?(Prism::CallNode) && %i[lambda proc].include?(n.name) && n.receiver.nil? &&
|
|
96
|
+
n.block.is_a?(Prism::BlockNode) && n.location.start_line == line
|
|
97
|
+
found << n.block
|
|
98
|
+
end
|
|
99
|
+
false
|
|
100
|
+
end
|
|
101
|
+
found
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def parameters(node)
|
|
105
|
+
return [] if node.parameters.nil?
|
|
106
|
+
unless node.parameters.is_a?(Prism::BlockParametersNode)
|
|
107
|
+
raise NotStatic, "it uses `it` or numbered parameters; name them: ->(x) { ... }"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
p = node.parameters.parameters
|
|
111
|
+
return [] if p.nil?
|
|
112
|
+
plain = p.optionals.empty? && p.rest.nil? && p.posts.empty? && p.keywords.empty? && p.keyword_rest.nil? && p.block.nil?
|
|
113
|
+
unless plain && p.requireds.all?(Prism::RequiredParameterNode)
|
|
114
|
+
raise NotStatic, "it takes optional, splat, keyword or block parameters; use plain required ones"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
p.requireds.map(&:name)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def check_chain(expr, model, params, param_columns, depth)
|
|
121
|
+
calls = []
|
|
122
|
+
cur = expr
|
|
123
|
+
while cur.is_a?(Prism::CallNode)
|
|
124
|
+
calls.unshift(cur)
|
|
125
|
+
cur = cur.receiver
|
|
126
|
+
end
|
|
127
|
+
if cur || calls.empty?
|
|
128
|
+
raise NotStatic, "`#{code((cur || expr).slice)}` is not a query method; start the body with where/order/limit/offset or a scope"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
calls.each_with_index do |call, i|
|
|
132
|
+
raise NotStatic, "`#{code(call.slice)}` has a block or `&.`" if call.block || call.safe_navigation?
|
|
133
|
+
|
|
134
|
+
# `where.not(hash)`: the bare `where` only opens the chain.
|
|
135
|
+
if call.name == :where && call.arguments.nil?
|
|
136
|
+
unless calls[i + 1]&.name == :not
|
|
137
|
+
raise NotStatic, "`#{code(call.slice)}.#{calls[i + 1]&.name}` is not supported; use where(...) or where.not(...)"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
next
|
|
141
|
+
end
|
|
142
|
+
if call.name == :not && !(i.positive? && calls[i - 1].name == :where && calls[i - 1].arguments.nil?)
|
|
143
|
+
raise NotStatic, "`not` without `where`"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
check_call(call, call.arguments&.arguments || [], model, params, param_columns, depth)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def check_call(call, nodes, model, params, param_columns, depth)
|
|
151
|
+
case call.name
|
|
152
|
+
when :all
|
|
153
|
+
raise NotStatic, "`all` takes no arguments" unless nodes.empty?
|
|
154
|
+
when :where, :not
|
|
155
|
+
unless nodes.size == 1 && (nodes.first.is_a?(Prism::KeywordHashNode) || nodes.first.is_a?(Prism::HashNode))
|
|
156
|
+
raise NotStatic, "`#{code(call.message_loc&.slice)}(#{code(call.arguments&.slice)})` uses an SQL string or other non-hash condition; " \
|
|
157
|
+
"use hash conditions like where(age: a)"
|
|
158
|
+
end
|
|
159
|
+
nodes.first.elements.each { check_condition(it, model, params, param_columns) }
|
|
160
|
+
when :order, :includes, :preload
|
|
161
|
+
nodes.each { literal!(it, "#{call.name} argument") }
|
|
162
|
+
raise NotStatic, "`#{call.name}` needs arguments" if nodes.empty?
|
|
163
|
+
when :limit, :offset
|
|
164
|
+
unless nodes.size == 1 && nodes.first.is_a?(Prism::IntegerNode)
|
|
165
|
+
raise NotStatic, "`#{call.name}` needs an integer literal"
|
|
166
|
+
end
|
|
167
|
+
else
|
|
168
|
+
unless Scopes.body_for(model, call.name)
|
|
169
|
+
raise NotStatic, "`#{call.name}` is not where/order/limit/offset/includes/preload/all or a scope"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
begin
|
|
173
|
+
inner = analyze(model, call.name, depth + 1)
|
|
174
|
+
rescue NotStatic => e
|
|
175
|
+
raise NotStatic, "it uses `#{call.name}`, which can't be compiled: #{e.message}"
|
|
176
|
+
end
|
|
177
|
+
raise NotStatic, "`#{code(call.slice)}` passes the wrong number of arguments" unless inner.arity == nodes.size
|
|
178
|
+
|
|
179
|
+
nodes.each_with_index do |node, i|
|
|
180
|
+
if (index = param_index(node, params))
|
|
181
|
+
param_columns[index] ||= inner.param_columns[i]
|
|
182
|
+
else
|
|
183
|
+
literal!(node, "argument to #{call.name}")
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def check_condition(assoc, model, params, param_columns)
|
|
190
|
+
unless assoc.is_a?(Prism::AssocNode) && (assoc.key.is_a?(Prism::SymbolNode) || assoc.key.is_a?(Prism::StringNode))
|
|
191
|
+
raise NotStatic, "`#{code(assoc.slice)}` is not a `column: value` condition"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
column = assoc.key.unescaped
|
|
195
|
+
value = assoc.value
|
|
196
|
+
unless model.columns_hash.key?(column)
|
|
197
|
+
reflection = ScopeAnalyzer.belongs_to_key(model, column)
|
|
198
|
+
raise NotStatic, reflection if reflection.is_a?(String)
|
|
199
|
+
raise NotStatic, "`#{code(assoc.slice)}`: a range on an association" if value.is_a?(Prism::RangeNode)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
if value.is_a?(Prism::RangeNode)
|
|
203
|
+
[value.left, value.right].compact.each { check_value(it, value, column, params, param_columns) }
|
|
204
|
+
else
|
|
205
|
+
check_value(value, value, column, params, param_columns)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# `whole` is the condition's value (the range, if `node` is one end of it).
|
|
210
|
+
def check_value(node, whole, column, params, param_columns)
|
|
211
|
+
if (index = param_index(node, params))
|
|
212
|
+
param_columns[index] = column
|
|
213
|
+
elsif !literal?(node)
|
|
214
|
+
fixed = whole.slice.sub(node.slice, "x")
|
|
215
|
+
raise NotStatic, "`#{code(node.slice)}` in `#{column}:` is computed when the scope runs; make it a parameter: " \
|
|
216
|
+
"->(x) { where(#{column}: #{code(fixed)}) } and pass the value from the caller"
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Source quoted in a reason: one line, and short.
|
|
221
|
+
def code(text)
|
|
222
|
+
text = text.to_s.gsub(/\s+/, " ").strip
|
|
223
|
+
text.size > 60 ? "#{text[0, 57]}..." : text
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def param_index(node, params)
|
|
227
|
+
node.is_a?(Prism::LocalVariableReadNode) && node.depth.zero? ? params.index(node.name) : nil
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def literal!(node, what)
|
|
231
|
+
raise NotStatic, "#{what} `#{code(node.slice)}` is not a literal" unless literal?(node)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def literal?(node)
|
|
235
|
+
case node
|
|
236
|
+
when Prism::SymbolNode, Prism::StringNode, Prism::IntegerNode, Prism::FloatNode,
|
|
237
|
+
Prism::NilNode, Prism::TrueNode, Prism::FalseNode then true
|
|
238
|
+
when Prism::ArrayNode then node.elements.all? { literal?(it) }
|
|
239
|
+
when Prism::RangeNode then [node.left, node.right].compact.all? { literal?(it) }
|
|
240
|
+
when Prism::HashNode, Prism::KeywordHashNode
|
|
241
|
+
node.elements.all? { it.is_a?(Prism::AssocNode) && literal?(it.key) && literal?(it.value) }
|
|
242
|
+
else false
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecordPrecompiled
|
|
4
|
+
# Lists every scope and whether the build can compile it, with the reason
|
|
5
|
+
# and a fix when it can't. Read-only; safe in any environment.
|
|
6
|
+
class ScopeCheck
|
|
7
|
+
Entry = Data.define(:model, :scope, :signature, :location, :ok, :reason)
|
|
8
|
+
Report = Data.define(:entries, :generated)
|
|
9
|
+
|
|
10
|
+
def initialize(models: nil, root: Dir.pwd)
|
|
11
|
+
@models = models
|
|
12
|
+
@root = root.to_s
|
|
13
|
+
@analyzer = ScopeAnalyzer.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run(out: $stdout)
|
|
17
|
+
entries = []
|
|
18
|
+
generated = 0
|
|
19
|
+
models.each do |model|
|
|
20
|
+
(model.instance_variable_get(:@__ar_precompiled_scopes) || {}).each do |name, body|
|
|
21
|
+
next generated += 1 if @analyzer.generated_scope?(body)
|
|
22
|
+
|
|
23
|
+
entries << entry(model, name, body)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
print(entries, generated, out)
|
|
27
|
+
Report.new(entries, generated)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def models
|
|
33
|
+
@models || ActiveRecord::Base.descendants.reject(&:abstract_class?).select(&:name).sort_by(&:name)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def entry(model, name, body)
|
|
37
|
+
params = body.respond_to?(:parameters) ? body.parameters.map(&:last).compact : []
|
|
38
|
+
signature = "#{model.name}.#{name}#{"(#{params.join(", ")})" if params.any?}"
|
|
39
|
+
location = body.respond_to?(:source_location) && body.source_location ? relative(body.source_location.first(2).join(":")) : "?"
|
|
40
|
+
@analyzer.analyze(model, name)
|
|
41
|
+
Entry.new(model.name, name, signature, location, true, nil)
|
|
42
|
+
rescue ScopeAnalyzer::NotStatic => e
|
|
43
|
+
Entry.new(model.name, name, signature, location, false, relative(e.message))
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
Entry.new(model.name, name, signature, location, false, "#{e.class}: #{e.message.lines.first&.strip}")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def relative(text) = text.gsub("#{@root}/", "")
|
|
49
|
+
|
|
50
|
+
def print(entries, generated, out)
|
|
51
|
+
width = entries.map { it.signature.size }.max.to_i
|
|
52
|
+
entries.each do |e|
|
|
53
|
+
out.puts "#{e.ok ? "✅" : "❌"} #{e.signature.ljust(width)} #{e.location}"
|
|
54
|
+
out.puts " #{e.reason}" unless e.ok
|
|
55
|
+
end
|
|
56
|
+
out.puts "activerecord-precompiled: #{entries.count(&:ok)} of #{entries.size} scopes can be compiled (plus #{generated} generated by Rails: enum, rich text, attachments)"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecordPrecompiled
|
|
4
|
+
# Remembers every scope's body when the scope is defined, so the build can
|
|
5
|
+
# read its source and decide whether it is static. Installed when the gem
|
|
6
|
+
# loads, which Bundler does before the app's models.
|
|
7
|
+
module Scopes
|
|
8
|
+
module Recorder
|
|
9
|
+
def scope(name, body, &block)
|
|
10
|
+
super.tap { (@__ar_precompiled_scopes ||= {})[name.to_sym] = block ? :extension : body }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.install!
|
|
15
|
+
named = ActiveRecord::Scoping::Named::ClassMethods
|
|
16
|
+
named.prepend(Recorder) unless named <= Recorder
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The body given to `scope`, looked up through STI parents; :extension if
|
|
20
|
+
# the scope has an extension block; nil if `name` is not a scope.
|
|
21
|
+
def self.body_for(model, name)
|
|
22
|
+
model.ancestors.each do |klass|
|
|
23
|
+
break if klass == ActiveRecord::Base
|
|
24
|
+
next unless klass.is_a?(Class)
|
|
25
|
+
|
|
26
|
+
body = klass.instance_variable_get(:@__ar_precompiled_scopes)&.[](name.to_sym)
|
|
27
|
+
return body if body
|
|
28
|
+
end
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|