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.
@@ -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