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 new Rails/MigrationTimestamp cop #1044

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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 changelog/new_add_new_rails_migration_timestamp_cop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* [#1044](https://github.com/rubocop/rubocop-rails/pull/1044): Add new `Rails/MigrationTimestamp` cop. ([@sambostock][])
7 changes: 7 additions & 0 deletions config/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,13 @@ Rails/MigrationClassName:
Include:
- db/**/*.rb

Rails/MigrationTimestamp:
Description: 'Checks that migration filenames start with a valid timestamp in the past.'
Enabled: pending
VersionAdded: '<<next>>'
Include:
- db/migrate/**/*.rb
Comment on lines +689 to +690
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 noticed that MigrationClassName opts to include everything under db, and uses a node pattern to detect if the file looks like a migration. We could probably do something similar here, if that's preferable.

Choose a reason for hiding this comment

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

if the file looks like a migration

I've wondered about that.
What does "look like a migration" mean really? (We can't rely on the filename having a timestamp prefix, considering that's what we want to check here.)
Would you open the migration file and check if it defines a class that inherits from ActiveRecord::Migration? What if a project implements its own ApplicationMigration base class?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Would you open the migration file and check if it defines a class that inherits from ActiveRecord::Migration?

That is exactly what it does! 😅


Rails/NegateInclude:
Description: 'Prefer `collection.exclude?(obj)` over `!collection.include?(obj)`.'
StyleGuide: 'https://rails.rubystyle.guide#exclude'
Expand Down
60 changes: 60 additions & 0 deletions lib/rubocop/cop/rails/migration_timestamp.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# frozen_string_literal: true

require 'time'

module RuboCop
module Cop
module Rails
# Checks that migration file names start with a valid timestamp.
#
# @example
# # bad
# # db/migrate/bad.rb

# # bad
# # db/migrate/123_bad.rb

# # bad
# # db/migrate/20171301000000_bad.rb
#
# # good
# # db/migrate/20170101000000_good.rb
#
class MigrationTimestamp < Base
include RangeHelp

MSG = 'Migration file name must start with a valid `YYYYmmddHHMMSS_` timestamp in the past.'

def on_new_investigation
file_path = processed_source.file_path
timestamp = File.basename(file_path).split('_', 2).first
return if valid_timestamp?(timestamp)

add_offense(source_range(processed_source.buffer, 1, 0))
end

private

def valid_timestamp?(timestamp, format: '%Y%m%d%H%M%S')
sambostock marked this conversation as resolved.
Show resolved Hide resolved
format_with_utc_suffix = "#{format} %Z"
timestamp_with_utc_suffix = "#{timestamp} UTC"

timestamp &&
# Time.strptime has no way to externally declare what timezone the string is in, so we append it.
(time = Time.strptime(timestamp_with_utc_suffix, format_with_utc_suffix)) &&
# Time.strptime fuzzily accepts invalid dates around boundaries
# | Wrong Days per Month | 24th Hour | 60th Minute | 60th Second
# ---------+----------------------+----------------+----------------+----------------
# Actual | 20000231000000 | 20000101240000 | 20000101006000 | 20000101000060
# Expected | 20000302000000 | 20000102000000 | 20000101010000 | 20000101000100
# We want normalized values, so we can check if Time#strftime matches the original.

Choose a reason for hiding this comment

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

Smart! 💯

time.strftime(format) == timestamp &&
# No timestamps in the future
time <= Time.now.utc
rescue ArgumentError
false
end
end
end
end
end
1 change: 1 addition & 0 deletions lib/rubocop/cop/rails_cops.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
require_relative 'rails/mailer_name'
require_relative 'rails/match_route'
require_relative 'rails/migration_class_name'
require_relative 'rails/migration_timestamp'
require_relative 'rails/negate_include'
require_relative 'rails/not_null_column'
require_relative 'rails/order_by_id'
Expand Down
80 changes: 80 additions & 0 deletions spec/rubocop/cop/rails/migration_timestamp_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# frozen_string_literal: true

RSpec.describe RuboCop::Cop::Rails::MigrationTimestamp, :config do
it 'registers no offenses if timestamp is valid' do
expect_no_offenses(<<~RUBY, 'db/migrate/20170101000000_good.rb')
# ...
RUBY
end

it 'registers an offense if timestamp is impossible' do
expect_offense(<<~RUBY, 'db/migrate/20002222222222_bad.rb')
# ...
^ Migration file name must start with a valid `YYYYmmddHHMMSS_` timestamp in the past.
RUBY
end

it 'registers an offense if timestamp swaps month and day' do
expect_offense(<<~RUBY, 'db/migrate/20003112000000_bad.rb')
# ...
^ Migration file name must start with a valid `YYYYmmddHHMMSS_` timestamp in the past.
RUBY
end

it 'registers an offense if timestamp day is wrong' do
expect_offense(<<~RUBY, 'db/migrate/20000231000000_bad.rb')
# ...
^ Migration file name must start with a valid `YYYYmmddHHMMSS_` timestamp in the past.
RUBY
end

it 'registers an offense if timestamp hours are invalid' do
expect_offense(<<~RUBY, 'db/migrate/20000101240000_bad.rb')
# ...
^ Migration file name must start with a valid `YYYYmmddHHMMSS_` timestamp in the past.
RUBY
end

it 'registers an offense if timestamp minutes are invalid' do
expect_offense(<<~RUBY, 'db/migrate/20000101006000_bad.rb')
# ...
^ Migration file name must start with a valid `YYYYmmddHHMMSS_` timestamp in the past.
RUBY
end

it 'registers an offense if timestamp seconds are invalid' do
expect_offense(<<~RUBY, 'db/migrate/20000101000060_bad.rb')
# ...
^ Migration file name must start with a valid `YYYYmmddHHMMSS_` timestamp in the past.
RUBY
end

it 'registers an offense if timestamp is invalid' do
expect_offense(<<~RUBY, 'db/migrate/123_bad.rb')
# ...
^ Migration file name must start with a valid `YYYYmmddHHMMSS_` timestamp in the past.
RUBY
end

it 'registers an offense if no timestamp at all' do
expect_offense(<<~RUBY, 'db/migrate/bad.rb')
# ...
^ Migration file name must start with a valid `YYYYmmddHHMMSS_` timestamp in the past.
RUBY
end

it 'registers an offense if the timestamp is in the future' do
timestamp = (Time.now.utc + 5).strftime('%Y%m%d%H%M%S')
expect_offense(<<~RUBY, "db/migrate/#{timestamp}_bad.rb")
# ...
^ Migration file name must start with a valid `YYYYmmddHHMMSS_` timestamp in the past.
RUBY
end

it 'registers no offense if the timestamp is in the past' do
timestamp = (Time.now.utc - 5).strftime('%Y%m%d%H%M%S')
expect_no_offenses(<<~RUBY, "db/migrate/#{timestamp}_good.rb")
# ...
RUBY
end
end