Skip to content

Commit 06575d1

Browse files
Add active_record.config.validate_migration_timestamps option.
When set, validates that the timestamp prefix for a migration is in the form YYYYMMDDHHMMSS. This is designed to prevent migration timestamps from being modified by hand. It is turned off by default.
1 parent 9c22f35 commit 06575d1

File tree

11 files changed

+206
-6
lines changed

11 files changed

+206
-6
lines changed

activerecord/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
* Add `active_record.config.validate_migration_timestamps` option for validating migration timestamps.
2+
3+
When set, validates that the timestamp prefix for a migration is in the form YYYYMMDDHHMMSS.
4+
This is designed to prevent migration timestamps from being modified by hand.
5+
6+
*Adrianna Chang*
7+
18
* When using a `DATABASE_URL`, allow for a configuration to map the protocol in the URL to a specific database
29
adapter. This allows decoupling the adapter the application chooses to use from the database connection details
310
set in the deployment environment.

activerecord/lib/active_record.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,14 @@ def self.global_executor_concurrency # :nodoc:
370370
singleton_class.attr_accessor :timestamped_migrations
371371
self.timestamped_migrations = true
372372

373+
##
374+
# :singleton-method:
375+
# Specify whether or not to validate migration timestamps. When set, an error
376+
# will be raised if a timestamp is not a valid timestamp in the form
377+
# YYYYMMDDHHMMSS. +timestamped_migrations+ must be set to true.
378+
singleton_class.attr_accessor :validate_migration_timestamps
379+
self.validate_migration_timestamps = false
380+
373381
##
374382
# :singleton-method:
375383
# Specify strategy to use for executing migrations.

activerecord/lib/active_record/migration.rb

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,16 @@ def initialize(name = nil)
131131
end
132132
end
133133

134+
class InvalidMigrationTimestampError < MigrationError # :nodoc:
135+
def initialize(version = nil, name = nil)
136+
if version && name
137+
super("Invalid timestamp #{version} for migration file: #{name}.\nTimestamp should be in form YYYYMMDDHHMMSS.")
138+
else
139+
super("Invalid timestamp for migration. Timestamp should be in form YYYYMMDDHHMMSS.")
140+
end
141+
end
142+
end
143+
134144
class PendingMigrationError < MigrationError # :nodoc:
135145
include ActiveSupport::ActionableError
136146

@@ -493,7 +503,11 @@ def initialize
493503
#
494504
# 20080717013526_your_migration_name.rb
495505
#
496-
# The prefix is a generation timestamp (in UTC).
506+
# The prefix is a generation timestamp (in UTC). Timestamps should not be
507+
# modified manually. To validate that migration timestamps adhere to the
508+
# format Active Record expects, you can use the following configuration option:
509+
#
510+
# config.active_record.validate_migration_timestamps = true
497511
#
498512
# If you'd prefer to use numeric prefixes, you can turn timestamped migrations
499513
# off by setting:
@@ -1316,6 +1330,9 @@ def migrations # :nodoc:
13161330
migrations = migration_files.map do |file|
13171331
version, name, scope = parse_migration_filename(file)
13181332
raise IllegalMigrationNameError.new(file) unless version
1333+
if validate_timestamp? && !valid_migration_timestamp?(version)
1334+
raise InvalidMigrationTimestampError.new(version, name)
1335+
end
13191336
version = version.to_i
13201337
name = name.camelize
13211338

@@ -1331,6 +1348,9 @@ def migrations_status # :nodoc:
13311348
file_list = migration_files.filter_map do |file|
13321349
version, name, scope = parse_migration_filename(file)
13331350
raise IllegalMigrationNameError.new(file) unless version
1351+
if validate_timestamp? && !valid_migration_timestamp?(version)
1352+
raise InvalidMigrationTimestampError.new(version, name)
1353+
end
13341354
version = schema_migration.normalize_migration_number(version)
13351355
status = db_list.delete(version) ? "up" : "down"
13361356
[status, version, (name + scope).humanize]
@@ -1375,6 +1395,22 @@ def parse_migration_filename(filename)
13751395
File.basename(filename).scan(Migration::MigrationFilenameRegexp).first
13761396
end
13771397

1398+
def validate_timestamp?
1399+
ActiveRecord.timestamped_migrations && ActiveRecord.validate_migration_timestamps
1400+
end
1401+
1402+
def valid_migration_timestamp?(version)
1403+
timestamp = "#{version} UTC"
1404+
timestamp_format = "%Y%m%d%H%M%S %Z"
1405+
time = Time.strptime(timestamp, timestamp_format)
1406+
1407+
# Time.strptime will wrap if a day between 1-31 is specified, even if the day doesn't exist
1408+
# for the given month. Comparing against the original timestamp guards against this.
1409+
time.strftime(timestamp_format) == timestamp
1410+
rescue ArgumentError
1411+
false
1412+
end
1413+
13781414
def move(direction, steps)
13791415
migrator = Migrator.new(direction, migrations, schema_migration, internal_metadata)
13801416

activerecord/test/cases/migration_test.rb

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1802,3 +1802,102 @@ def test_unknown_migration_version_should_raise_an_argument_error
18021802
assert_raise(ArgumentError) { ActiveRecord::Migration[1.0] }
18031803
end
18041804
end
1805+
1806+
class MigrationValidationTest < ActiveRecord::TestCase
1807+
def setup
1808+
@verbose_was, ActiveRecord::Migration.verbose = ActiveRecord::Migration.verbose, false
1809+
@schema_migration = ActiveRecord::Base.connection.schema_migration
1810+
@internal_metadata = ActiveRecord::Base.connection.internal_metadata
1811+
@active_record_validate_timestamps_was = ActiveRecord.validate_migration_timestamps
1812+
ActiveRecord.validate_migration_timestamps = true
1813+
ActiveRecord::Base.connection.schema_cache.clear!
1814+
1815+
@migrations_path = MIGRATIONS_ROOT + "/temp"
1816+
@migrator = ActiveRecord::MigrationContext.new(@migrations_path, @schema_migration, @internal_metadata)
1817+
end
1818+
1819+
def teardown
1820+
@schema_migration.create_table
1821+
@schema_migration.delete_all_versions
1822+
ActiveRecord.validate_migration_timestamps = @active_record_validate_timestamps_was
1823+
ActiveRecord::Migration.verbose = @verbose_was
1824+
end
1825+
1826+
def test_migration_raises_if_timestamp_not_14_digits
1827+
with_temp_migration_file(@migrations_path, "201801010101010000_test_migration.rb") do
1828+
error = assert_raises(ActiveRecord::InvalidMigrationTimestampError) do
1829+
@migrator.up(201801010101010000)
1830+
end
1831+
assert_match(/Invalid timestamp 201801010101010000 for migration file: test_migration/, error.message)
1832+
end
1833+
1834+
with_temp_migration_file(@migrations_path, "201801010_test_migration.rb") do
1835+
error = assert_raises(ActiveRecord::InvalidMigrationTimestampError) do
1836+
@migrator.up(201801010)
1837+
end
1838+
assert_match(/Invalid timestamp 201801010 for migration file: test_migration/, error.message)
1839+
end
1840+
end
1841+
1842+
def test_migration_raises_if_timestamp_is_invalid_date
1843+
# 2023-15-26 is an invalid date
1844+
with_temp_migration_file(@migrations_path, "20231526101010_test_migration.rb") do
1845+
error = assert_raises(ActiveRecord::InvalidMigrationTimestampError) do
1846+
@migrator.up(20231526101010)
1847+
end
1848+
assert_match(/Invalid timestamp 20231526101010 for migration file: test_migration/, error.message)
1849+
end
1850+
end
1851+
1852+
def test_migration_raises_if_timestamp_is_invalid_day_month_combo
1853+
# 2023-02-31 is an invalid date; 02 and 31 are valid month / days individually,
1854+
# but February 31 does not exist.
1855+
with_temp_migration_file(@migrations_path, "20230231101010_test_migration.rb") do
1856+
error = assert_raises(ActiveRecord::InvalidMigrationTimestampError) do
1857+
@migrator.up(20230231101010)
1858+
end
1859+
assert_match(/Invalid timestamp 20230231101010 for migration file: test_migration/, error.message)
1860+
end
1861+
end
1862+
1863+
def test_migration_succeeds_despite_invalid_timestamp_if_validate_timestamps_is_false
1864+
validate_migration_timestamps_was = ActiveRecord.validate_migration_timestamps
1865+
ActiveRecord.validate_migration_timestamps = false
1866+
with_temp_migration_file(@migrations_path, "201801010_test_migration.rb") do
1867+
@migrator.up(201801010)
1868+
assert_equal 201801010, @migrator.current_version
1869+
end
1870+
ensure
1871+
ActiveRecord.validate_migration_timestamps = validate_migration_timestamps_was
1872+
end
1873+
1874+
def test_migration_succeeds_despite_invalid_timestamp_if_timestamped_migrations_is_false
1875+
timestamped_migrations_was = ActiveRecord.timestamped_migrations
1876+
ActiveRecord.timestamped_migrations = false
1877+
with_temp_migration_file(@migrations_path, "201801010_test_migration.rb") do
1878+
@migrator.up(201801010)
1879+
assert_equal 201801010, @migrator.current_version
1880+
end
1881+
ensure
1882+
ActiveRecord.timestamped_migrations = timestamped_migrations_was
1883+
end
1884+
1885+
private
1886+
def with_temp_migration_file(migrations_dir, filename)
1887+
Dir.mkdir(migrations_dir) unless Dir.exist?(migrations_dir)
1888+
path = File.join(migrations_dir, filename)
1889+
1890+
File.open(path, "w+") do |file|
1891+
file << <<-MIGRATION
1892+
class TestMigration < ActiveRecord::Migration::Current
1893+
def change; end
1894+
end
1895+
MIGRATION
1896+
end
1897+
1898+
yield
1899+
ensure
1900+
File.delete(path) if File.exist?(path)
1901+
Dir.rmdir(migrations_dir) if Dir.exist?(migrations_dir)
1902+
end
1903+
end

guides/source/configuring.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ NOTE: If you need to apply configuration directly to a class, use a [lazy load h
5858

5959
Below are the default values associated with each target version. In cases of conflicting values, newer versions take precedence over older versions.
6060

61+
#### Default Values for Target Version 7.2
62+
63+
- [`config.active_record.validate_migration_timestamps`](#config-active-record-validate-migration-timestamps): `true`
64+
6165
#### Default Values for Target Version 7.1
6266

6367
- [`config.action_dispatch.debug_exception_log_level`](#config-action-dispatch-debug-exception-log-level): `:error`
@@ -1047,6 +1051,17 @@ Specifies if an error should be raised if the order of a query is ignored during
10471051
10481052
Controls whether migrations are numbered with serial integers or with timestamps. The default is `true`, to use timestamps, which are preferred if there are multiple developers working on the same application.
10491053
1054+
#### `config.active_record.validate_migration_timestamps`
1055+
1056+
Controls whether to validate migration timestamps. When set, an error will be raised if a timestamp is not a valid timestamp in the form YYYYMMDDHHMMSS. `config.active_record.timestamped_migrations` must be set to `true`.
1057+
1058+
The default value depends on the `config.load_defaults` target version:
1059+
1060+
| Starting with version | The default value is |
1061+
| --------------------- | -------------------- |
1062+
| (original) | `false` |
1063+
| 7.2 | `true` |
1064+
10501065
#### `config.active_record.db_warnings_action`
10511066
10521067
Controls the action to be taken when a SQL query produces a warning. The following options are available:

railties/lib/rails/application/configuration.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,10 @@ def load_defaults(target_version)
323323
end
324324
when "7.2"
325325
load_defaults "7.1"
326+
327+
if respond_to?(:active_record)
328+
active_record.validate_migration_timestamps = true
329+
end
326330
else
327331
raise "Unknown version #{target_version.to_s.inspect}"
328332
end

railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_7_2.rb.tt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,13 @@
88
#
99
# Read the Guide for Upgrading Ruby on Rails for more info on each option.
1010
# https://guides.rubyonrails.org/upgrading_ruby_on_rails.html
11+
12+
###
13+
# Enable validation of migration timestamps. Migration timestamps must be
14+
# in the form YYYYMMDDHHMMSS, or an ActiveRecord::InvalidMigrationTimestampError
15+
# will be raised.
16+
#
17+
# Applications with existing timestamped migrations that do not adhere to the
18+
# expected format can disable validation by setting this config to `false`.
19+
#++
20+
# Rails.application.config.active_record.validate_migration_timestamps = true

railties/test/application/loading_test.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,7 @@ class User
319319
test "columns migrations also trigger reloading" do
320320
add_to_config <<-RUBY
321321
config.enable_reloading = true
322+
config.active_record.timestamped_migrations = false
322323
RUBY
323324

324325
app_file "config/routes.rb", <<-RUBY

railties/test/application/rake/migrations_test.rb

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class RakeMigrationsTest < ActiveSupport::TestCase
88
def setup
99
build_app
1010
FileUtils.rm_rf("#{app_path}/config/environments")
11+
add_to_config("config.active_record.timestamped_migrations = false")
1112
end
1213

1314
def teardown
@@ -17,7 +18,7 @@ def teardown
1718
test "running migrations with given scope" do
1819
rails "generate", "model", "user", "username:string", "password:string"
1920

20-
app_file "db/migrate/01_a_migration.bukkits.rb", <<-MIGRATION
21+
app_file "db/migrate/02_a_migration.bukkits.rb", <<-MIGRATION
2122
class AMigration < ActiveRecord::Migration::Current
2223
end
2324
MIGRATION
@@ -200,6 +201,8 @@ class TwoMigration < ActiveRecord::Migration::Current
200201
end
201202

202203
test "migration status" do
204+
remove_from_config("config.active_record.timestamped_migrations = false")
205+
203206
rails "generate", "model", "user", "username:string", "password:string"
204207
rails "generate", "migration", "add_email_to_users", "email:string"
205208
rails "db:migrate"
@@ -217,7 +220,7 @@ class TwoMigration < ActiveRecord::Migration::Current
217220
end
218221

219222
test "migration status without timestamps" do
220-
add_to_config("config.active_record.timestamped_migrations = false")
223+
remove_from_config("config.active_record.timestamped_migrations = false")
221224

222225
rails "generate", "model", "user", "username:string", "password:string"
223226
rails "generate", "migration", "add_email_to_users", "email:string"
@@ -236,6 +239,8 @@ class TwoMigration < ActiveRecord::Migration::Current
236239
end
237240

238241
test "migration status after rollback and redo" do
242+
remove_from_config("config.active_record.timestamped_migrations = false")
243+
239244
rails "generate", "model", "user", "username:string", "password:string"
240245
rails "generate", "migration", "add_email_to_users", "email:string"
241246
rails "db:migrate"
@@ -259,6 +264,8 @@ class TwoMigration < ActiveRecord::Migration::Current
259264
end
260265

261266
test "migration status after rollback and forward" do
267+
remove_from_config("config.active_record.timestamped_migrations = false")
268+
262269
rails "generate", "model", "user", "username:string", "password:string"
263270
rails "generate", "migration", "add_email_to_users", "email:string"
264271
rails "db:migrate"
@@ -283,6 +290,8 @@ class TwoMigration < ActiveRecord::Migration::Current
283290

284291
test "raise error on any move when current migration does not exist" do
285292
Dir.chdir(app_path) do
293+
remove_from_config("config.active_record.timestamped_migrations = false")
294+
286295
rails "generate", "model", "user", "username:string", "password:string"
287296
rails "generate", "migration", "add_email_to_users", "email:string"
288297
rails "db:migrate"
@@ -382,8 +391,6 @@ class TwoMigration < ActiveRecord::Migration::Current
382391
end
383392

384393
test "migration status after rollback and redo without timestamps" do
385-
add_to_config("config.active_record.timestamped_migrations = false")
386-
387394
rails "generate", "model", "user", "username:string", "password:string"
388395
rails "generate", "migration", "add_email_to_users", "email:string"
389396
rails "db:migrate"
@@ -497,6 +504,8 @@ class TwoMigration < ActiveRecord::Migration::Current
497504

498505
test "migration status migrated file is deleted" do
499506
Dir.chdir(app_path) do
507+
remove_from_config("config.active_record.timestamped_migrations = false")
508+
500509
rails "generate", "model", "user", "username:string", "password:string"
501510
rails "generate", "migration", "add_email_to_users", "email:string"
502511
rails "db:migrate"
@@ -523,7 +532,7 @@ class ApplicationRecord < ActiveRecord::Base
523532

524533
rails("db:migrate")
525534

526-
app_file "db/migrate/01_a_migration.bukkits.rb", <<-MIGRATION
535+
app_file "db/migrate/02_a_migration.bukkits.rb", <<-MIGRATION
527536
class AMigration < ActiveRecord::Migration::Current
528537
def change
529538
User.first

railties/test/application/rake/multi_dbs_test.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ class RakeMultiDbsTest < ActiveSupport::TestCase
1010
def setup
1111
build_app(multi_db: true)
1212
FileUtils.rm_rf("#{app_path}/config/environments")
13+
add_to_config("config.active_record.timestamped_migrations = false")
1314
end
1415

1516
def teardown
@@ -779,11 +780,13 @@ class TwoMigration < ActiveRecord::Migration::Current
779780
end
780781

781782
test "db:migrate:status works on all databases" do
783+
remove_from_config("config.active_record.timestamped_migrations = false")
782784
require "#{app_path}/config/environment"
783785
db_migrate_and_migrate_status
784786
end
785787

786788
test "db:migrate:status:namespace works" do
789+
remove_from_config("config.active_record.timestamped_migrations = false")
787790
require "#{app_path}/config/environment"
788791
ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config|
789792
db_migrate_namespaced db_config.name

0 commit comments

Comments
 (0)