Last active
July 30, 2018 23:08
-
-
Save johncarney/985ef325b7606d3feeceb56e57ad03a6 to your computer and use it in GitHub Desktop.
Scans all associations in a Rails app and lists those that have a scope that uses `with_deleted`. Also lists potential uses of those associations in joins. The list of joins will likely include false positives.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# ########################################################################### | |
# | |
# Probes all model associations looking for ones with a scope that call | |
# `with_deleted`. Lists all such associations, along with the location in | |
# source that `with_deleted` is called. | |
# | |
# Usage: | |
# | |
# $ rails runner script/associations-using-with-deleted.rb | |
# | |
# ########################################################################### | |
# --------------------------------------------------------------------------- | |
# | |
# Simple service concern. Adds a `call` class method which instantiates the | |
# service using the given parameters and invokes `call` on the instance. | |
# | |
module Concerns | |
module Service | |
extend ActiveSupport::Concern | |
class_methods do | |
def call(*args) | |
new(*args).call | |
end | |
end | |
end | |
end | |
# --------------------------------------------------------------------------- | |
# | |
# Lists all files under "app/models" and "vendor/*/app/models". | |
# | |
# Files under "concerns" sub-directories are excluded. | |
# | |
ModelFiles = Struct.new(:root) do | |
include Concerns::Service | |
MODEL_GLOBS = %w[ app/models/**/*.rb vendor/*/app/models/**/*.rb ] | |
def files_under_models_directory | |
Dir.chdir(root) do | |
Dir[*MODEL_GLOBS] | |
end | |
end | |
def concern?(path) | |
path["/concerns/"] | |
end | |
def call | |
files_under_models_directory.reject(&method(:concern?)) | |
end | |
end | |
# --------------------------------------------------------------------------- | |
# | |
# Lists all "Ruby-ish" files under theh given root directory. Ruby-ish files | |
# are this with a .rb, .slim, or .erb extension. | |
# | |
RubyishFiles = Struct.new(:root) do | |
include Concerns::Service | |
RUBYISH_GLOBS = %w[ **/*.rb **/*.slim **/*.erb ] | |
def call | |
Dir.chdir(root) do | |
Dir[*RUBYISH_GLOBS] | |
end | |
end | |
end | |
# --------------------------------------------------------------------------- | |
# | |
# Inflects problematic acronyms in a constant name. | |
# | |
# Some acronyms are represented inconsistently in our constant names. For | |
# example, we have Reports::BaseDSLReport and | |
# ReportGenerators::Concerns::Dsl. These acronyms are not included in our | |
# inflections initializer. | |
# | |
InflectProblematicAcronyms = Struct.new(:string) do | |
include Concerns::Service | |
PROBLEMATIC_ACRONYMS = %w[ API DSL ] | |
PROBLEMATIC_ACRONYMS_RE = Regexp.union(*PROBLEMATIC_ACRONYMS.map(&:capitalize)) | |
def call | |
string.gsub(PROBLEMATIC_ACRONYMS_RE, &:upcase) | |
end | |
end | |
# --------------------------------------------------------------------------- | |
# | |
# Given a file path, returns the corresponding constant. | |
# | |
ConstantFromFilePath = Struct.new(:path) do | |
include Concerns::Service | |
def model_path | |
path.split("/models/", 2).last.sub(/#{Regexp.escape(File.extname(path))}\z/, "") | |
end | |
def class_name | |
model_path.camelize | |
end | |
def call | |
class_name.constantize | |
rescue LoadError => x | |
# Try inflecting problematic acronyms. | |
InflectProblematicAcronyms.call(class_name).constantize | |
end | |
end | |
# --------------------------------------------------------------------------- | |
# | |
# Lists all model classes included in the given files. | |
# | |
# Excludes classes that are not descended from ActiveRecord::Base. | |
# | |
Models = Struct.new(:model_files) do | |
include Concerns::Service | |
def model?(klass) | |
klass < ActiveRecord::Base | |
end | |
def klasses | |
model_files.map(&ConstantFromFilePath.method(:call)) | |
end | |
def call | |
klasses.select(&method(:model?)) | |
end | |
end | |
# --------------------------------------------------------------------------- | |
# | |
# Lists all associations defined on the given models. | |
# | |
ModelAssociations = Struct.new(:models) do | |
include Concerns::Service | |
def call | |
models.flat_map(&:reflect_on_all_associations).uniq | |
end | |
end | |
# --------------------------------------------------------------------------- | |
# | |
# Represents an association that invokes a particular method in its scope. | |
# | |
# association The association | |
# method The method invoked | |
# source_location The source code location where the method is invoked | |
# | |
Hit = Struct.new(:association, :method, :source_location) | |
# --------------------------------------------------------------------------- | |
# | |
# Probes the given association's scope and returns a list of invocations to | |
# given method. | |
# | |
class ProbeAssociationScope | |
include Concerns::Service | |
def initialize(association, method) | |
@association = association | |
@method = method | |
@hits = [] | |
end | |
def to_hash | |
{} | |
end | |
def method_missing(method_name, *, **) | |
@hits << Hit.new(@association, method_name, caller.first) if @method == method_name | |
self | |
end | |
def call | |
scope = @association.scope | |
args = Array.new([ scope.arity, 0 ].max) | |
instance_exec(*args, &scope) | |
@hits | |
end | |
end | |
# --------------------------------------------------------------------------- | |
# | |
# Probes all the given associations, returning a list of this with scopes | |
# that invoke the given method. Returns an array of Hit objects. | |
# | |
class ProbeAssociations | |
include Concerns::Service | |
attr_reader :associations, :method_name | |
def initialize(associations, method_name) | |
@associations = associations | |
@method_name = method_name | |
end | |
def scoped?(association) | |
association.scope.present? | |
end | |
def associations_with_scope | |
associations.select(&method(:scoped?)) | |
end | |
def probe_scope(association) | |
ProbeAssociationScope.call(association, method_name) | |
end | |
def call | |
associations_with_scope.flat_map(&method(:probe_scope)) | |
end | |
end | |
# --------------------------------------------------------------------------- | |
# | |
# Given a bunch of associations, look for joins that might be using the | |
# associations. Basically greps .rb and .slim files, so will probably return | |
# a lot of flase positives. | |
# | |
JoinsUsingAssociations = Struct.new(:file, :associations) do | |
include Concerns::Service | |
def pattern | |
@pattern ||= begin | |
associations_subpattern = Regexp.union(associations.map(&:name).map(&Regexp.method(:quote))) | |
/\.joins\([^(]*:(#{associations_subpattern})\b/ | |
end | |
end | |
def file_lines | |
File.readlines(file) | |
end | |
def joins | |
file_lines.each_with_index.select { |line, _| line =~ pattern } | |
end | |
def call | |
joins.map { |line, index| format("%s:%d: %s", file, index + 1, line.rstrip) } | |
end | |
end | |
# --------------------------------------------------------------------------- | |
# | |
# Prettifies the association and caller location for a recorded method | |
# invocation. | |
# | |
HitPresenter = Struct.new(:hit) do | |
include Concerns::Service | |
delegate :association, :source_location, to: :hit | |
def present_association | |
[ association.active_record.name, association.name ].join("#") | |
end | |
def present_source_location | |
source_location.sub("#{Rails.root.to_path}/", "").sub(/:in\s.*\z/, "") | |
end | |
def call | |
[ present_association, present_source_location ] | |
end | |
end | |
# --------------------------------------------------------------------------- | |
TablePresenter = Struct.new(:rows) do | |
include Concerns::Service | |
def columns | |
rows.transpose | |
end | |
def column_widths | |
columns.map { |column| column.map(&:length) }.map(&:max) | |
end | |
def row_format | |
@row_format = column_widths.map { |width| "%-#{width}s" }.join(" ") | |
end | |
def call | |
rows.map { |row| format(row_format, *row) } | |
end | |
end | |
# --------------------------------------------------------------------------- | |
# | |
# Probes all association scopes and returns a formatted table of invocations | |
# to the given method. | |
# | |
HitsPresenter = Struct.new(:hits) do | |
include Concerns::Service | |
def presented_hits | |
@presented_hits ||= hits.map(&HitPresenter.method(:call)) | |
end | |
def call | |
TablePresenter.call(presented_hits) | |
end | |
end | |
# --------------------------------------------------------------------------- | |
JoinsPresenter = Struct.new(:joins) do | |
include Concerns::Service | |
def presented_joins | |
@presented_joins ||= joins.map do |join| | |
location, source = join.scan(/\A(.*?:\d+:\s)(.*)\z/).first | |
[ location, source.strip ] | |
end | |
end | |
def call | |
TablePresenter.call(presented_joins) | |
end | |
end | |
# --------------------------------------------------------------------------- | |
# | |
# Probes all association scopes and returns a formatted table of invocations | |
# to the given method. | |
# | |
class App | |
include Concerns::Service | |
attr_reader :method | |
def initialize(method) | |
@method = method | |
end | |
def model_files | |
ModelFiles.call(Rails.root) | |
end | |
def models | |
Models.call(model_files) | |
end | |
def associations | |
@association ||= ModelAssociations.call(models) | |
end | |
def hits | |
@hits ||= ProbeAssociations.call(associations, method) | |
end | |
def rubyish_files | |
RubyishFiles.call(Rails.root) | |
end | |
def joins_using_associations | |
associations = hits.map(&:association) | |
rubyish_files.flat_map { |file| JoinsUsingAssociations.call(file, associations) } | |
end | |
def call | |
puts HitsPresenter.call(hits) | |
puts | |
puts JoinsPresenter.call(joins_using_associations) | |
end | |
end | |
# --------------------------------------------------------------------------- | |
App.call(:with_deleted) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment