-
-
Notifications
You must be signed in to change notification settings - Fork 263
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add Rails/UniqueValidationWithoutIndex cop #197
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
# frozen_string_literal: true | ||
|
||
module RuboCop | ||
module Cop | ||
# A mixin to extend cops for Active Record features | ||
module ActiveRecordHelper | ||
extend NodePattern::Macros | ||
|
||
def_node_search :find_set_table_name, <<~PATTERN | ||
(send self :table_name= {str sym}) | ||
PATTERN | ||
|
||
def_node_search :find_belongs_to, <<~PATTERN | ||
(send nil? :belongs_to {str sym} ...) | ||
PATTERN | ||
|
||
def table_name(class_node) | ||
table_name = find_set_table_name(class_node).to_a.last&.first_argument | ||
return table_name.value.to_s if table_name | ||
|
||
namespaces = class_node.each_ancestor(:class, :module) | ||
[class_node, *namespaces] | ||
.reverse | ||
.map { |klass| klass.identifier.children[1] }.join('_') | ||
.tableize | ||
end | ||
|
||
# Resolve relation into column name. | ||
# It just returns column_name if the column exists. | ||
# Or it tries to resolve column_name as a relation. | ||
# It returns `nil` if it can't resolve. | ||
# | ||
# @param name [String] | ||
# @param class_node [RuboCop::AST::Node] | ||
# @param table [RuboCop::Rails::SchemaLoader::Table] | ||
# @return [String, nil] | ||
def resolve_relation_into_column(name:, class_node:, table:) | ||
return name if table.with_column?(name: name) | ||
|
||
find_belongs_to(class_node) do |belongs_to| | ||
next unless belongs_to.first_argument.value.to_s == name | ||
|
||
fk = foreign_key_of(belongs_to) || "#{name}_id" | ||
return fk if table.with_column?(name: fk) | ||
end | ||
nil | ||
end | ||
|
||
def foreign_key_of(belongs_to) | ||
options = belongs_to.last_argument | ||
return unless options.hash_type? | ||
|
||
options.each_pair.find do |pair| | ||
next unless pair.key.sym_type? && pair.key.value == :foreign_key | ||
next unless pair.value.sym_type? || pair.value.str_type? | ||
|
||
break pair.value.value.to_s | ||
end | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,133 @@ | ||
# frozen_string_literal: true | ||
|
||
module RuboCop | ||
module Cop | ||
module Rails | ||
# When you define a uniqueness validation in Active Record model, | ||
# you also should add a unique index for the column. There are two reasons | ||
# First, duplicated records may occur even if Active Record's validation | ||
# is defined. | ||
# Second, it will cause slow queries. The validation executes a `SELECT` | ||
# statement with the target column when inserting/updating a record. | ||
# If the column does not have an index and the table is large, | ||
# the query will be heavy. | ||
# | ||
# Note that the cop does nothing if db/schema.rb does not exist. | ||
# | ||
# @example | ||
# # bad - if the schema does not have a unique index | ||
# validates :account, uniqueness: true | ||
# | ||
# # good - if the schema has a unique index | ||
# validates :account, uniqueness: true | ||
# | ||
# # good - even if the schema does not have a unique index | ||
# validates :account, length: { minimum: MIN_LENGTH } | ||
# | ||
class UniqueValidationWithoutIndex < Cop | ||
include ActiveRecordHelper | ||
|
||
MSG = 'Uniqueness validation should be with a unique index.' | ||
|
||
def on_send(node) | ||
return unless node.method?(:validates) | ||
return unless uniqueness_part(node) | ||
return unless schema | ||
return if with_index?(node) | ||
|
||
add_offense(node) | ||
end | ||
|
||
private | ||
|
||
def with_index?(node) | ||
klass = class_node(node) | ||
return true unless klass # Skip analysis | ||
|
||
table = schema.table_by(name: table_name(klass)) | ||
return true unless table # Skip analysis if it can't find the table | ||
|
||
names = column_names(node) | ||
return true unless names | ||
|
||
table.indices.any? do |index| | ||
index.unique && index.columns.to_set == names | ||
end | ||
end | ||
|
||
def column_names(node) | ||
arg = node.first_argument | ||
return unless arg.str_type? || arg.sym_type? | ||
|
||
ret = [arg.value] | ||
names_from_scope = column_names_from_scope(node) | ||
ret.concat(names_from_scope) if names_from_scope | ||
|
||
ret.map! do |name| | ||
klass = class_node(node) | ||
resolve_relation_into_column( | ||
name: name.to_s, | ||
class_node: klass, | ||
table: schema.table_by(name: table_name(klass)) | ||
) | ||
end | ||
ret.include?(nil) ? nil : ret.to_set | ||
end | ||
|
||
def column_names_from_scope(node) | ||
uniq = uniqueness_part(node) | ||
return unless uniq.hash_type? | ||
|
||
scope = find_scope(uniq) | ||
return unless scope | ||
|
||
case scope.type | ||
when :sym, :str | ||
[scope.value] | ||
when :array | ||
array_node_to_array(scope) | ||
end | ||
end | ||
|
||
def find_scope(pairs) | ||
pairs.each_pair.find do |pair| | ||
key = pair.key | ||
next unless key.sym_type? && key.value == :scope | ||
|
||
break pair.value | ||
end | ||
end | ||
|
||
def class_node(node) | ||
node.each_ancestor.find(&:class_type?) | ||
end | ||
|
||
def uniqueness_part(node) | ||
pairs = node.arguments.last | ||
return unless pairs.hash_type? | ||
|
||
pairs.each_pair.find do |pair| | ||
next unless pair.key.sym_type? && pair.key.value == :uniqueness | ||
|
||
break pair.value | ||
end | ||
end | ||
|
||
def array_node_to_array(node) | ||
node.values.map do |elm| | ||
case elm.type | ||
when :str, :sym | ||
elm.value | ||
else | ||
return nil | ||
end | ||
end | ||
end | ||
|
||
def schema | ||
RuboCop::Rails::SchemaLoader.load(target_ruby_version) | ||
end | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
# frozen_string_literal: true | ||
|
||
module RuboCop | ||
module Rails | ||
# It loads db/schema.rb and return Schema object. | ||
# Cops refers database schema information with this module. | ||
module SchemaLoader | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the schema loader. I implemented it as a mixin at first, but it was a bad idea. So now it is a non-mixin module. If it is a mixin, the |
||
extend self | ||
|
||
# It parses `db/schema.rb` and return it. | ||
# It returns `nil` if it can't find `db/schema.rb`. | ||
# So a cop that uses the loader should handle `nil` properly. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What do you think about having it raise an exception if There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Personally I think missing There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think there is a need to raise warnings or errors. OTOH, documenting the dependency on db/schema.rb will help users. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I do not have a strong opinion for it because I think both of them have pros and cons. By the way, I updated the documentation to mention existence of db/schema.rb. |
||
# | ||
# @return [Schema, nil] | ||
def load(target_ruby_version) | ||
return @schema if defined?(@schema) | ||
|
||
@schema = load!(target_ruby_version) | ||
end | ||
|
||
def reset! | ||
return unless instance_variable_defined?(:@schema) | ||
|
||
remove_instance_variable(:@schema) | ||
end | ||
|
||
private | ||
|
||
def load!(target_ruby_version) | ||
path = db_schema_path | ||
return unless path | ||
|
||
ast = parse(path, target_ruby_version) | ||
Schema.new(ast) | ||
end | ||
|
||
def db_schema_path | ||
path = Pathname.pwd | ||
until path.root? | ||
schema_path = path.join('db/schema.rb') | ||
return schema_path if schema_path.exist? | ||
|
||
path = path.join('../').cleanpath | ||
end | ||
|
||
nil | ||
end | ||
|
||
def parse(path, target_ruby_version) | ||
klass_name = :"Ruby#{target_ruby_version.to_s.sub('.', '')}" | ||
klass = ::Parser.const_get(klass_name) | ||
parser = klass.new(RuboCop::AST::Builder.new) | ||
|
||
buffer = Parser::Source::Buffer.new(path, 1) | ||
buffer.source = path.read | ||
|
||
parser.parse(buffer) | ||
end | ||
end | ||
end | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should be the status
pending
, or not? I'm not sure, so if it should bepending
, please tell me 🙏There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
RuboCop core has not yet released cop with
pending
. Here, let's try withtrue
first.