Skip to content
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

Merged
merged 1 commit into from
Feb 17, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ Metrics/BlockLength:
- 'Rakefile'
- '**/*.rake'
- 'spec/**/*.rb'
- '*.gemspec'

Naming/FileName:
Exclude:
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## master (unreleased)

### New features

* [#197](https://github.com/rubocop-hq/rubocop-rails/pull/197): Add `Rails/UniqueValidationWithoutIndex` cop. ([@pocke][])

### Bug fixes

* [#180](https://github.com/rubocop-hq/rubocop-rails/issues/180): Fix a false positive for `HttpPositionalArguments` when using `get` method with `:to` option. ([@koic][])
Expand Down
7 changes: 7 additions & 0 deletions config/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,13 @@ Rails/UniqBeforePluck:
- aggressive
AutoCorrect: false

Rails/UniqueValidationWithoutIndex:
Description: 'Uniqueness validation should be with a unique index.'
Enabled: true
Copy link
Contributor Author

@pocke pocke Feb 8, 2020

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 be pending, please tell me 🙏

Copy link
Member

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 with true first.

VersionAdded: '2.5'
Include:
- app/models/**/*.rb

Rails/UnknownEnv:
Description: 'Use correct environment name.'
Enabled: true
Expand Down
3 changes: 3 additions & 0 deletions lib/rubocop-rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

require 'rubocop'
require 'rack/utils'
require 'active_support/inflector'

require_relative 'rubocop/rails'
require_relative 'rubocop/rails/version'
require_relative 'rubocop/rails/inject'
require_relative 'rubocop/rails/schema_loader'
require_relative 'rubocop/rails/schema_loader/schema'

RuboCop::Rails::Inject.defaults!

Expand Down
62 changes: 62 additions & 0 deletions lib/rubocop/cop/mixin/active_record_helper.rb
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
133 changes: 133 additions & 0 deletions lib/rubocop/cop/rails/unique_validation_without_index.rb
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
2 changes: 2 additions & 0 deletions lib/rubocop/cop/rails_cops.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# frozen_string_literal: true

require_relative 'mixin/active_record_helper'
require_relative 'mixin/target_rails_version'

require_relative 'rails/action_filter'
Expand Down Expand Up @@ -57,5 +58,6 @@
require_relative 'rails/skips_model_validations'
require_relative 'rails/time_zone'
require_relative 'rails/uniq_before_pluck'
require_relative 'rails/unique_validation_without_index'
require_relative 'rails/unknown_env'
require_relative 'rails/validation'
61 changes: 61 additions & 0 deletions lib/rubocop/rails/schema_loader.rb
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
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 @schema cache only works for a single cop and single file. So it needs to parse db/schema.rb many times.
But if the non-mixin module, just once parsing is enough.

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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about having it raise an exception if schema.rb is missing?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I think missing schema.rb should be handled, it means it shouldn't display a stack trace. Because it is not a bug of rubocop-rails.
But I think it is a good idea to notify missing shcema.rb to the user. So, how about a warning instead of an exception?
For example: "db/schema.rb is missing. Disable Rails/UniqueValidationWithoutIndex cop if you don't use db/schema.rb."

Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.
The warning can tell users that the cop does not work. It may be a bug, or using schema.sql instead.
The current behavior conceals it. but it will be easy to use rubocop-rails, because no-configuration is necessary even if the cop doesn't work.

By the way, I updated the documentation to mention existence of db/schema.rb.
https://github.com/rubocop-hq/rubocop-rails/pull/197/files#diff-af4cb6c7e18c12f0a26b2ef45466f731R15

#
# @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
Loading