Skip to content

Instantly share code, notes, and snippets.

@johncarney
Last active July 30, 2018 23:08
Show Gist options
  • Save johncarney/985ef325b7606d3feeceb56e57ad03a6 to your computer and use it in GitHub Desktop.
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.
# ###########################################################################
#
# 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