diff options
44 files changed, 926 insertions, 151 deletions
diff --git a/actionmailer/test/message_delivery_test.rb b/actionmailer/test/message_delivery_test.rb index a79d77e1e5..f4c4f43bdc 100644 --- a/actionmailer/test/message_delivery_test.rb +++ b/actionmailer/test/message_delivery_test.rb @@ -76,7 +76,7 @@ class MessageDeliveryTest < ActiveSupport::TestCase test "should enqueue a delivery with a delay" do travel_to Time.new(2004, 11, 24, 01, 04, 44) do - assert_performed_with(job: ActionMailer::DeliveryJob, at: Time.current.to_f + 600.seconds, args: ["DelayedMailer", "test_message", "deliver_now", 1, 2, 3]) do + assert_performed_with(job: ActionMailer::DeliveryJob, at: Time.current.to_f + 600, args: ["DelayedMailer", "test_message", "deliver_now", 1, 2, 3]) do @mail.deliver_later wait: 600.seconds end end diff --git a/actionpack/lib/action_controller/metal/strong_parameters.rb b/actionpack/lib/action_controller/metal/strong_parameters.rb index d304dcf468..3b293baa73 100644 --- a/actionpack/lib/action_controller/metal/strong_parameters.rb +++ b/actionpack/lib/action_controller/metal/strong_parameters.rb @@ -862,7 +862,7 @@ module ActionController # end # # # This will pass with flying colors as long as there's a person key in the - # # parameters, otherwise it'll raise an ActionController::MissingParameter + # # parameters, otherwise it'll raise an ActionController::ParameterMissing # # exception, which will get caught by ActionController::Base and turned # # into a 400 Bad Request reply. # def update diff --git a/actionpack/lib/action_dispatch/system_testing/test_helpers/screenshot_helper.rb b/actionpack/lib/action_dispatch/system_testing/test_helpers/screenshot_helper.rb index ddc961cf84..e37f6d02aa 100644 --- a/actionpack/lib/action_dispatch/system_testing/test_helpers/screenshot_helper.rb +++ b/actionpack/lib/action_dispatch/system_testing/test_helpers/screenshot_helper.rb @@ -22,7 +22,7 @@ module ActionDispatch # fails add +take_failed_screenshot+ to the teardown block before clearing # sessions. def take_failed_screenshot - take_screenshot if failed? + take_screenshot if failed? && supports_screenshot? end private @@ -55,6 +55,10 @@ module ActionDispatch def failed? !passed? && !skipped? end + + def supports_screenshot? + page.driver.public_methods(false).include?(:save_screenshot) + end end end end diff --git a/actionpack/lib/action_dispatch/system_testing/test_helpers/setup_and_teardown.rb b/actionpack/lib/action_dispatch/system_testing/test_helpers/setup_and_teardown.rb index 1c89bfacfa..187ba2cc5f 100644 --- a/actionpack/lib/action_dispatch/system_testing/test_helpers/setup_and_teardown.rb +++ b/actionpack/lib/action_dispatch/system_testing/test_helpers/setup_and_teardown.rb @@ -11,8 +11,8 @@ module ActionDispatch def after_teardown take_failed_screenshot - super Capybara.reset_sessions! + super end end end diff --git a/actionpack/test/dispatch/system_testing/screenshot_helper_test.rb b/actionpack/test/dispatch/system_testing/screenshot_helper_test.rb index 3b4ea96c4f..d6b501b3ac 100644 --- a/actionpack/test/dispatch/system_testing/screenshot_helper_test.rb +++ b/actionpack/test/dispatch/system_testing/screenshot_helper_test.rb @@ -1,5 +1,6 @@ require "abstract_unit" require "action_dispatch/system_testing/test_helpers/screenshot_helper" +require "capybara/dsl" class ScreenshotHelperTest < ActiveSupport::TestCase test "image path is saved in tmp directory" do @@ -25,4 +26,28 @@ class ScreenshotHelperTest < ActiveSupport::TestCase end end end + + test "rack_test driver does not support screenshot" do + begin + original_driver = Capybara.current_driver + Capybara.current_driver = :rack_test + + new_test = ActionDispatch::SystemTestCase.new("x") + assert_not new_test.send(:supports_screenshot?) + ensure + Capybara.current_driver = original_driver + end + end + + test "selenium driver supports screenshot" do + begin + original_driver = Capybara.current_driver + Capybara.current_driver = :selenium + + new_test = ActionDispatch::SystemTestCase.new("x") + assert new_test.send(:supports_screenshot?) + ensure + Capybara.current_driver = original_driver + end + end end diff --git a/actionview/test/template/date_helper_test.rb b/actionview/test/template/date_helper_test.rb index bfd3ecd6fd..d257147e1f 100644 --- a/actionview/test/template/date_helper_test.rb +++ b/actionview/test/template/date_helper_test.rb @@ -832,7 +832,7 @@ class DateHelperTest < ActionView::TestCase def test_select_date_with_too_big_range_between_start_year_and_end_year assert_raise(ArgumentError) { select_date(Time.mktime(2003, 8, 16), start_year: 2000, end_year: 20000, prefix: "date[first]", order: [:month, :day, :year]) } - assert_raise(ArgumentError) { select_date(Time.mktime(2003, 8, 16), start_year: Date.today.year - 100.years, end_year: 2000, prefix: "date[first]", order: [:month, :day, :year]) } + assert_raise(ArgumentError) { select_date(Time.mktime(2003, 8, 16), start_year: 100, end_year: 2000, prefix: "date[first]", order: [:month, :day, :year]) } end def test_select_date_can_have_more_then_1000_years_interval_if_forced_via_parameter diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 55773f2172..abc415ea14 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,21 @@ +* Fix `rake db:schema:load` with subdirectories. + + *Ryuta Kamizono* + +* Fix `rake db:migrate:status` with subdirectories. + + *Ryuta Kamizono* + +* Don't share options between reference id and type columns + + When using a polymorphic reference column in a migration, sharing options + between the two columns doesn't make sense since they are different types. + The `reference_id` column is usually an integer and the `reference_type` + column a string so options like `unsigned: true` will result in an invalid + table definition. + + *Ryuta Kamizono* + * Use `max_identifier_length` for `index_name_length` in PostgreSQL adapter. *Ryuta Kamizono* diff --git a/activerecord/lib/active_record/associations/association_scope.rb b/activerecord/lib/active_record/associations/association_scope.rb index badde9973f..120d75416c 100644 --- a/activerecord/lib/active_record/associations/association_scope.rb +++ b/activerecord/lib/active_record/associations/association_scope.rb @@ -25,7 +25,7 @@ module ActiveRecord chain_head, chain_tail = get_chain(reflection, association, alias_tracker) scope.extending! Array(reflection.options[:extend]) - add_constraints(scope, owner, klass, reflection, chain_head, chain_tail) + add_constraints(scope, owner, reflection, chain_head, chain_tail) end def join_type @@ -60,8 +60,8 @@ module ActiveRecord table.create_join(table, table.create_on(constraint), join_type) end - def last_chain_scope(scope, table, reflection, owner, association_klass) - join_keys = reflection.join_keys(association_klass) + def last_chain_scope(scope, table, reflection, owner) + join_keys = reflection.join_keys key = join_keys.key foreign_key = join_keys.foreign_key @@ -80,8 +80,8 @@ module ActiveRecord value_transformation.call(value) end - def next_chain_scope(scope, table, reflection, association_klass, foreign_table, next_reflection) - join_keys = reflection.join_keys(association_klass) + def next_chain_scope(scope, table, reflection, foreign_table, next_reflection) + join_keys = reflection.join_keys key = join_keys.key foreign_key = join_keys.foreign_key @@ -120,10 +120,10 @@ module ActiveRecord [runtime_reflection, previous_reflection] end - def add_constraints(scope, owner, association_klass, refl, chain_head, chain_tail) + def add_constraints(scope, owner, refl, chain_head, chain_tail) owner_reflection = chain_tail table = owner_reflection.alias_name - scope = last_chain_scope(scope, table, owner_reflection, owner, association_klass) + scope = last_chain_scope(scope, table, owner_reflection, owner) reflection = chain_head while reflection @@ -132,7 +132,7 @@ module ActiveRecord unless reflection == chain_tail foreign_table = next_reflection.alias_name - scope = next_chain_scope(scope, table, reflection, association_klass, foreign_table, next_reflection) + scope = next_chain_scope(scope, table, reflection, foreign_table, next_reflection) end # Exclude the scope of the association itself, because that diff --git a/activerecord/lib/active_record/associations/join_dependency.rb b/activerecord/lib/active_record/associations/join_dependency.rb index 87e0847ec1..8995b1e352 100644 --- a/activerecord/lib/active_record/associations/join_dependency.rb +++ b/activerecord/lib/active_record/associations/join_dependency.rb @@ -171,7 +171,7 @@ module ActiveRecord chain = child.reflection.chain foreign_table = parent.table foreign_klass = parent.base_klass - child.join_constraints(foreign_table, foreign_klass, child, join_type, tables, chain) + child.join_constraints(foreign_table, foreign_klass, join_type, tables, chain) end def make_outer_joins(parent, child) diff --git a/activerecord/lib/active_record/associations/join_dependency/join_association.rb b/activerecord/lib/active_record/associations/join_dependency/join_association.rb index f5fcba1236..97cfec0302 100644 --- a/activerecord/lib/active_record/associations/join_dependency/join_association.rb +++ b/activerecord/lib/active_record/associations/join_dependency/join_association.rb @@ -23,7 +23,7 @@ module ActiveRecord JoinInformation = Struct.new :joins, :binds - def join_constraints(foreign_table, foreign_klass, node, join_type, tables, chain) + def join_constraints(foreign_table, foreign_klass, join_type, tables, chain) joins = [] binds = [] tables = tables.reverse @@ -34,35 +34,16 @@ module ActiveRecord table = tables.shift klass = reflection.klass - join_keys = reflection.join_keys(klass) + join_keys = reflection.join_keys key = join_keys.key foreign_key = join_keys.foreign_key constraint = build_constraint(klass, table, key, foreign_table, foreign_key) predicate_builder = PredicateBuilder.new(TableMetadata.new(klass, table)) - scope_chain_items = reflection.scopes.map do |item| - if item.is_a?(Relation) - item - else - ActiveRecord::Relation.create(klass, table, predicate_builder) - .instance_exec(node, &item) - end - end + scope_chain_items = reflection.join_scopes(table, predicate_builder) + klass_scope = reflection.klass_join_scope(table, predicate_builder) - klass_scope = - if klass.current_scope - klass.current_scope.clone.tap { |scope| - scope.joins_values = [] - } - else - relation = ActiveRecord::Relation.create( - klass, - table, - predicate_builder, - ) - klass.send(:build_default_scope, relation) - end scope_chain_items.concat [klass_scope].compact rel = scope_chain_items.inject(scope_chain_items.shift) do |left, right| diff --git a/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb index 5eb7787226..4682afc188 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb @@ -116,16 +116,12 @@ module ActiveRecord private - def as_options(value, default = {}) - if value.is_a?(Hash) - value - else - default - end + def as_options(value) + value.is_a?(Hash) ? value : {} end def polymorphic_options - as_options(polymorphic, options) + as_options(polymorphic) end def index_options diff --git a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb index e683106527..c9dd915e98 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb @@ -1028,9 +1028,8 @@ module ActiveRecord sm_table = quote_table_name(ActiveRecord::Migrator.schema_migrations_table_name) migrated = select_values("SELECT version FROM #{sm_table}").map(&:to_i) - paths = migrations_paths.map { |p| "#{p}/[0-9]*_*.rb" } - versions = Dir[*paths].map do |filename| - filename.split("/").last.split("_").first.to_i + versions = ActiveRecord::Migrator.migration_files(migrations_paths).map do |file| + ActiveRecord::Migrator.parse_migration_filename(file).first.to_i end unless migrated.include?(version) diff --git a/activerecord/lib/active_record/migration.rb b/activerecord/lib/active_record/migration.rb index 263a8a7da3..c47e32df59 100644 --- a/activerecord/lib/active_record/migration.rb +++ b/activerecord/lib/active_record/migration.rb @@ -1056,10 +1056,6 @@ module ActiveRecord Array(@migrations_paths) end - def match_to_migration_filename?(filename) # :nodoc: - Migration::MigrationFilenameRegexp.match?(File.basename(filename)) - end - def parse_migration_filename(filename) # :nodoc: File.basename(filename).scan(Migration::MigrationFilenameRegexp).first end @@ -1067,9 +1063,7 @@ module ActiveRecord def migrations(paths) paths = Array(paths) - files = Dir[*paths.map { |p| "#{p}/**/[0-9]*_*.rb" }] - - migrations = files.map do |file| + migrations = migration_files(paths).map do |file| version, name, scope = parse_migration_filename(file) raise IllegalMigrationNameError.new(file) unless version version = version.to_i @@ -1081,6 +1075,30 @@ module ActiveRecord migrations.sort_by(&:version) end + def migrations_status(paths) + paths = Array(paths) + + db_list = ActiveRecord::SchemaMigration.normalized_versions + + file_list = migration_files(paths).map do |file| + version, name, scope = parse_migration_filename(file) + raise IllegalMigrationNameError.new(file) unless version + version = ActiveRecord::SchemaMigration.normalize_migration_number(version) + status = db_list.delete(version) ? "up" : "down" + [status, version, (name + scope).humanize] + end.compact + + db_list.map! do |version| + ["up", version, "********** NO FILE **********"] + end + + (db_list + file_list).sort_by { |_, version, _| version } + end + + def migration_files(paths) + Dir[*paths.flat_map { |path| "#{path}/**/[0-9]*_*.rb" }] + end + private def move(direction, migrations_paths, steps) diff --git a/activerecord/lib/active_record/railties/databases.rake b/activerecord/lib/active_record/railties/databases.rake index fabd326649..1c7206aca4 100644 --- a/activerecord/lib/active_record/railties/databases.rake +++ b/activerecord/lib/active_record/railties/databases.rake @@ -110,28 +110,13 @@ db_namespace = namespace :db do unless ActiveRecord::SchemaMigration.table_exists? abort "Schema migrations table does not exist yet." end - db_list = ActiveRecord::SchemaMigration.normalized_versions - - file_list = - ActiveRecord::Tasks::DatabaseTasks.migrations_paths.flat_map do |path| - Dir.foreach(path).map do |file| - next unless ActiveRecord::Migrator.match_to_migration_filename?(file) - - version, name, scope = ActiveRecord::Migrator.parse_migration_filename(file) - version = ActiveRecord::SchemaMigration.normalize_migration_number(version) - status = db_list.delete(version) ? "up" : "down" - [status, version, (name + scope).humanize] - end.compact - end - db_list.map! do |version| - ["up", version, "********** NO FILE **********"] - end # output puts "\ndatabase: #{ActiveRecord::Base.connection_config[:database]}\n\n" puts "#{'Status'.center(8)} #{'Migration ID'.ljust(14)} Migration Name" puts "-" * 50 - (db_list + file_list).sort_by { |_, version, _| version }.each do |status, version, name| + paths = ActiveRecord::Tasks::DatabaseTasks.migrations_paths + ActiveRecord::Migrator.migrations_status(paths).each do |status, version, name| puts "#{status.center(8)} #{version.ljust(14)} #{name}" end puts diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index 61a2279292..24ca8b0be4 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -172,8 +172,8 @@ module ActiveRecord JoinKeys = Struct.new(:key, :foreign_key) # :nodoc: - def join_keys(association_klass) - JoinKeys.new(foreign_key, active_record_primary_key) + def join_keys + get_join_keys klass end # Returns a list of scopes that should be applied for this Reflection @@ -187,6 +187,30 @@ module ActiveRecord end deprecate :scope_chain + def join_scopes(table, predicate_builder) # :nodoc: + if scope + [ActiveRecord::Relation.create(klass, table, predicate_builder) + .instance_exec(&scope)] + else + [] + end + end + + def klass_join_scope(table, predicate_builder) # :nodoc: + if klass.current_scope + klass.current_scope.clone.tap { |scope| + scope.joins_values = [] + } + else + relation = ActiveRecord::Relation.create( + klass, + table, + predicate_builder, + ) + klass.send(:build_default_scope, relation) + end + end + def constraints chain.map(&:scopes).flatten end @@ -260,6 +284,20 @@ module ActiveRecord def chain collect_join_chain end + + def get_join_keys(association_klass) + JoinKeys.new(join_pk(association_klass), join_fk) + end + + private + + def join_pk(_) + foreign_key + end + + def join_fk + active_record_primary_key + end end # Base class for AggregateReflection and AssociationReflection. Objects of @@ -687,11 +725,6 @@ module ActiveRecord end end - def join_keys(association_klass) - key = polymorphic? ? association_primary_key(association_klass) : association_primary_key - JoinKeys.new(key, foreign_key) - end - def join_id_for(owner) # :nodoc: owner[foreign_key] end @@ -701,6 +734,14 @@ module ActiveRecord def calculate_constructable(macro, options) !polymorphic? end + + def join_fk + foreign_key + end + + def join_pk(klass) + polymorphic? ? association_primary_key(klass) : association_primary_key + end end class HasAndBelongsToManyReflection < AssociationReflection # :nodoc: @@ -720,7 +761,7 @@ module ActiveRecord class ThroughReflection < AbstractReflection #:nodoc: attr_reader :delegate_reflection delegate :foreign_key, :foreign_type, :association_foreign_key, - :active_record_primary_key, :type, to: :source_reflection + :active_record_primary_key, :type, :get_join_keys, to: :source_reflection def initialize(delegate_reflection) @delegate_reflection = delegate_reflection @@ -806,6 +847,10 @@ module ActiveRecord source_reflection.scopes + super end + def join_scopes(table, predicate_builder) # :nodoc: + source_reflection.join_scopes(table, predicate_builder) + super + end + def source_type_scope through_reflection.klass.where(foreign_type => options[:source_type]) end @@ -816,10 +861,6 @@ module ActiveRecord through_reflection.has_scope? end - def join_keys(association_klass) - source_reflection.join_keys(association_klass) - end - # A through association is nested if there would be more than one join table def nested? source_reflection.through_reflection? || through_reflection.through_reflection? @@ -954,6 +995,7 @@ module ActiveRecord end private + def actual_source_reflection # FIXME: this is a horrible name source_reflection.send(:actual_source_reflection) end @@ -990,6 +1032,15 @@ module ActiveRecord end end + def join_scopes(table, predicate_builder) # :nodoc: + scopes = @previous_reflection.join_scopes(table, predicate_builder) + super + if @previous_reflection.options[:source_type] + scopes + [@previous_reflection.source_type_scope] + else + scopes + end + end + def klass @reflection.klass end @@ -1006,10 +1057,6 @@ module ActiveRecord @reflection.plural_name end - def join_keys(association_klass) - @reflection.join_keys(association_klass) - end - def type @reflection.type end @@ -1023,6 +1070,10 @@ module ActiveRecord source_type = @previous_reflection.options[:source_type] lambda { |object| where(type => source_type) } end + + def get_join_keys(association_klass) + @reflection.get_join_keys(association_klass) + end end class RuntimeReflection < PolymorphicReflection # :nodoc: diff --git a/activerecord/test/cases/migration/references_statements_test.rb b/activerecord/test/cases/migration/references_statements_test.rb index df15d7cb45..06c44c8c52 100644 --- a/activerecord/test/cases/migration/references_statements_test.rb +++ b/activerecord/test/cases/migration/references_statements_test.rb @@ -50,6 +50,13 @@ module ActiveRecord assert column_exists?(table_name, :taggable_type, :string, default: "Photo") end + def test_does_not_share_options_with_reference_type_column + add_reference table_name, :taggable, type: :integer, limit: 2, polymorphic: true + assert column_exists?(table_name, :taggable_id, :integer, limit: 2) + assert column_exists?(table_name, :taggable_type, :string) + assert_not column_exists?(table_name, :taggable_type, :string, limit: 2) + end + def test_creates_named_index add_reference table_name, :tag, index: { name: "index_taggings_on_tag_id" } assert index_exists?(table_name, :tag_id, name: "index_taggings_on_tag_id") diff --git a/activerecord/test/cases/migrator_test.rb b/activerecord/test/cases/migrator_test.rb index 20d70b75ac..aadbc375af 100644 --- a/activerecord/test/cases/migrator_test.rb +++ b/activerecord/test/cases/migrator_test.rb @@ -124,6 +124,67 @@ class MigratorTest < ActiveRecord::TestCase assert_equal migration_list.last, migrations.first end + def test_migrations_status + path = MIGRATIONS_ROOT + "/valid" + + ActiveRecord::SchemaMigration.create(version: 2) + ActiveRecord::SchemaMigration.create(version: 10) + + assert_equal [ + ["down", "001", "Valid people have last names"], + ["up", "002", "We need reminders"], + ["down", "003", "Innocent jointable"], + ["up", "010", "********** NO FILE **********"], + ], ActiveRecord::Migrator.migrations_status(path) + end + + def test_migrations_status_in_subdirectories + path = MIGRATIONS_ROOT + "/valid_with_subdirectories" + + ActiveRecord::SchemaMigration.create(version: 2) + ActiveRecord::SchemaMigration.create(version: 10) + + assert_equal [ + ["down", "001", "Valid people have last names"], + ["up", "002", "We need reminders"], + ["down", "003", "Innocent jointable"], + ["up", "010", "********** NO FILE **********"], + ], ActiveRecord::Migrator.migrations_status(path) + end + + def test_migrations_status_with_schema_define_in_subdirectories + path = MIGRATIONS_ROOT + "/valid_with_subdirectories" + prev_paths = ActiveRecord::Migrator.migrations_paths + ActiveRecord::Migrator.migrations_paths = path + + ActiveRecord::Schema.define(version: 3) do + end + + assert_equal [ + ["up", "001", "Valid people have last names"], + ["up", "002", "We need reminders"], + ["up", "003", "Innocent jointable"], + ], ActiveRecord::Migrator.migrations_status(path) + ensure + ActiveRecord::Migrator.migrations_paths = prev_paths + end + + def test_migrations_status_from_two_directories + paths = [MIGRATIONS_ROOT + "/valid_with_timestamps", MIGRATIONS_ROOT + "/to_copy_with_timestamps"] + + ActiveRecord::SchemaMigration.create(version: "20100101010101") + ActiveRecord::SchemaMigration.create(version: "20160528010101") + + assert_equal [ + ["down", "20090101010101", "People have hobbies"], + ["down", "20090101010202", "People have descriptions"], + ["up", "20100101010101", "Valid with timestamps people have last names"], + ["down", "20100201010101", "Valid with timestamps we need reminders"], + ["down", "20100301010101", "Valid with timestamps innocent jointable"], + ["up", "20160528010101", "********** NO FILE **********"], + ], ActiveRecord::Migrator.migrations_status(paths) + end + def test_migrator_interleaved_migrations pass_one = [Sensor.new("One", 1)] diff --git a/activerecord/test/cases/validations/uniqueness_validation_test.rb b/activerecord/test/cases/validations/uniqueness_validation_test.rb index 277280b42e..28605d2f8e 100644 --- a/activerecord/test/cases/validations/uniqueness_validation_test.rb +++ b/activerecord/test/cases/validations/uniqueness_validation_test.rb @@ -385,7 +385,7 @@ class UniquenessValidationTest < ActiveRecord::TestCase def test_validate_uniqueness_with_limit_and_utf8 if current_adapter?(:SQLite3Adapter) - # Event.title has limit 5, but does SQLite doesn't truncate. + # Event.title has limit 5, but SQLite doesn't truncate. e1 = Event.create(title: "一二三四五å…七八") assert e1.valid?, "Could not create an event with a unique 8 characters title" diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 2999820c42..58956ad0e8 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,133 @@ +* Add `rfc3339` aliases to `xmlschema` for `Time` and `ActiveSupport::TimeWithZone` + + For naming consistency when using the RFC 3339 profile of ISO 8601 in applications. + + *Andrew White* + +* Add `Time.rfc3339` parsing method + + The `Time.xmlschema` and consequently its alias `iso8601` accepts timestamps + without a offset in contravention of the RFC 3339 standard. This method + enforces that constraint and raises an `ArgumentError` if it doesn't. + + *Andrew White* + +* Add `ActiveSupport::TimeZone.rfc3339` parsing method + + Previously there was no way to get a RFC 3339 timestamp into a specific + timezone without either using `parse` or chaining methods. The new method + allows parsing directly into the timezone, e.g: + + >> Time.zone = "Hawaii" + => "Hawaii" + >> Time.zone.rfc3339("1999-12-31T14:00:00Z") + => Fri, 31 Dec 1999 14:00:00 HST -10:00 + + This new method has stricter semantics than the current `parse` method + and will raise an `ArgumentError` instead of returning nil, e.g: + + >> Time.zone = "Hawaii" + => "Hawaii" + >> Time.zone.rfc3339("foobar") + ArgumentError: invalid date + >> Time.zone.parse("foobar") + => nil + + It will also raise an `ArgumentError` when either the time or offset + components are missing, e.g: + + >> Time.zone = "Hawaii" + => "Hawaii" + >> Time.zone.rfc3339("1999-12-31") + ArgumentError: invalid date + >> Time.zone.rfc3339("1999-12-31T14:00:00") + ArgumentError: invalid date + + *Andrew White* + +* Add `ActiveSupport::TimeZone.iso8601` parsing method + + Previously there was no way to get a ISO 8601 timestamp into a specific + timezone without either using `parse` or chaining methods. The new method + allows parsing directly into the timezone, e.g: + + >> Time.zone = "Hawaii" + => "Hawaii" + >> Time.zone.iso8601("1999-12-31T14:00:00Z") + => Fri, 31 Dec 1999 14:00:00 HST -10:00 + + If the timestamp is a ISO 8601 date (YYYY-MM-DD) then the time is set + to midnight, e.g: + + >> Time.zone = "Hawaii" + => "Hawaii" + >> Time.zone.iso8601("1999-12-31") + => Fri, 31 Dec 1999 00:00:00 HST -10:00 + + This new method has stricter semantics than the current `parse` method + and will raise an `ArgumentError` instead of returning nil, e.g: + + >> Time.zone = "Hawaii" + => "Hawaii" + >> Time.zone.iso8601("foobar") + ArgumentError: invalid date + >> Time.zone.parse("foobar") + => nil + + *Andrew White* + +* Deprecate implicit coercion of `ActiveSupport::Duration` + + Currently `ActiveSupport::Duration` implicitly converts to a seconds + value when used in a calculation except for the explicit examples of + addition and subtraction where the duration is the receiver, e.g: + + >> 2 * 1.day + => 172800 + + This results in lots of confusion especially when using durations + with dates because adding/subtracting a value from a date treats + integers as a day and not a second, e.g: + + >> Date.today + => Wed, 01 Mar 2017 + >> Date.today + 2 * 1.day + => Mon, 10 Apr 2490 + + To fix this we're implementing `coerce` so that we can provide a + deprecation warning with the intent of removing the implicit coercion + in Rails 5.2, e.g: + + >> 2 * 1.day + DEPRECATION WARNING: Implicit coercion of ActiveSupport::Duration + to a Numeric is deprecated and will raise a TypeError in Rails 5.2. + => 172800 + + In Rails 5.2 it will raise `TypeError`, e.g: + + >> 2 * 1.day + TypeError: ActiveSupport::Duration can't be coerced into Integer + + This is the same behavior as with other types in Ruby, e.g: + + >> 2 * "foo" + TypeError: String can't be coerced into Integer + >> "foo" * 2 + => "foofoo" + + As part of this deprecation add `*` and `/` methods to `AS::Duration` + so that calculations that keep the duration as the receiver work + correctly whether the final receiver is a `Date` or `Time`, e.g: + + >> Date.today + => Wed, 01 Mar 2017 + >> Date.today + 1.day * 2 + => Fri, 03 Mar 2017 + + Fixes #27457. + + *Andrew White* + * Update `DateTime#change` to support `:usec` and `:nsec` options. Adding support for these options now allows us to update the `DateTime#end_of` diff --git a/activesupport/lib/active_support/cache/mem_cache_store.rb b/activesupport/lib/active_support/cache/mem_cache_store.rb index e09cee3335..5eee04a34e 100644 --- a/activesupport/lib/active_support/cache/mem_cache_store.rb +++ b/activesupport/lib/active_support/cache/mem_cache_store.rb @@ -156,7 +156,7 @@ module ActiveSupport expires_in = options[:expires_in].to_i if expires_in > 0 && !options[:raw] # Set the memcache expire a few minutes in the future to support race condition ttls on read - expires_in += 5.minutes + expires_in += 300 end rescue_error_with false do @data.send(method, key, value, expires_in, options) diff --git a/activesupport/lib/active_support/core_ext/time/calculations.rb b/activesupport/lib/active_support/core_ext/time/calculations.rb index cbdcb86d6d..7b7aeef25a 100644 --- a/activesupport/lib/active_support/core_ext/time/calculations.rb +++ b/activesupport/lib/active_support/core_ext/time/calculations.rb @@ -53,6 +53,29 @@ class Time end alias_method :at_without_coercion, :at alias_method :at, :at_with_coercion + + # Creates a +Time+ instance from an RFC 3339 string. + # + # Time.rfc3339('1999-12-31T14:00:00-10:00') # => 2000-01-01 00:00:00 -1000 + # + # If the time or offset components are missing then an +ArgumentError+ will be raised. + # + # Time.rfc3339('1999-12-31') # => ArgumentError: invalid date + def rfc3339(str) + parts = Date._rfc3339(str) + + raise ArgumentError, "invalid date" if parts.empty? + + Time.new( + parts.fetch(:year), + parts.fetch(:mon), + parts.fetch(:mday), + parts.fetch(:hour), + parts.fetch(:min), + parts.fetch(:sec) + parts.fetch(:sec_fraction, 0), + parts.fetch(:offset) + ) + end end # Returns the number of seconds since 00:00:00. diff --git a/activesupport/lib/active_support/core_ext/time/conversions.rb b/activesupport/lib/active_support/core_ext/time/conversions.rb index f2bbe55aa6..595bda6b4f 100644 --- a/activesupport/lib/active_support/core_ext/time/conversions.rb +++ b/activesupport/lib/active_support/core_ext/time/conversions.rb @@ -64,4 +64,7 @@ class Time def formatted_offset(colon = true, alternate_utc_string = nil) utc? && alternate_utc_string || ActiveSupport::TimeZone.seconds_to_utc_offset(utc_offset, colon) end + + # Aliased to +xmlschema+ for compatibility with +DateTime+ + alias_method :rfc3339, :xmlschema end diff --git a/activesupport/lib/active_support/duration.rb b/activesupport/lib/active_support/duration.rb index 70cf78519d..d26bbac511 100644 --- a/activesupport/lib/active_support/duration.rb +++ b/activesupport/lib/active_support/duration.rb @@ -1,5 +1,7 @@ require "active_support/core_ext/array/conversions" require "active_support/core_ext/object/acts_like" +require "active_support/core_ext/string/filters" +require "active_support/deprecation" module ActiveSupport # Provides accurate date and time measurements using Date#advance and @@ -88,6 +90,25 @@ module ActiveSupport @parts.default = 0 end + def coerce(other) #:nodoc: + ActiveSupport::Deprecation.warn(<<-MSG.squish) + Implicit coercion of ActiveSupport::Duration to a Numeric + is deprecated and will raise a TypeError in Rails 5.2. + MSG + + [other, value] + end + + # Compares one Duration with another or a Numeric to this Duration. + # Numeric values are treated as seconds. + def <=>(other) + if Duration === other + value <=> other.value + elsif Numeric === other + value <=> other + end + end + # Adds another Duration or a Numeric to this Duration. Numeric values # are treated as seconds. def +(other) @@ -109,6 +130,24 @@ module ActiveSupport self + (-other) end + # Multiplies this Duration by a Numeric and returns a new Duration. + def *(other) + if Numeric === other + Duration.new(value * other, parts.map { |type, number| [type, number * other] }) + else + value * other + end + end + + # Divides this Duration by a Numeric and returns a new Duration. + def /(other) + if Numeric === other + Duration.new(value / other, parts.map { |type, number| [type, number / other] }) + else + value / other + end + end + def -@ #:nodoc: Duration.new(-value, parts.map { |type, number| [type, -number] }) end @@ -212,8 +251,6 @@ module ActiveSupport ISO8601Serializer.new(self, precision: precision).serialize end - delegate :<=>, to: :value - private def sum(sign, time = ::Time.current) diff --git a/activesupport/lib/active_support/time_with_zone.rb b/activesupport/lib/active_support/time_with_zone.rb index 857cc1a664..e31983cf26 100644 --- a/activesupport/lib/active_support/time_with_zone.rb +++ b/activesupport/lib/active_support/time_with_zone.rb @@ -148,6 +148,7 @@ module ActiveSupport "#{time.strftime(PRECISIONS[fraction_digits.to_i])}#{formatted_offset(true, 'Z'.freeze)}" end alias_method :iso8601, :xmlschema + alias_method :rfc3339, :xmlschema # Coerces time to a string for JSON encoding. The default format is ISO 8601. # You can get %Y/%m/%d %H:%M:%S +offset style by setting diff --git a/activesupport/lib/active_support/values/time_zone.rb b/activesupport/lib/active_support/values/time_zone.rb index 09cb9cbbe1..18477b9f6b 100644 --- a/activesupport/lib/active_support/values/time_zone.rb +++ b/activesupport/lib/active_support/values/time_zone.rb @@ -340,6 +340,41 @@ module ActiveSupport end # Method for creating new ActiveSupport::TimeWithZone instance in time zone + # of +self+ from an ISO 8601 string. + # + # Time.zone = 'Hawaii' # => "Hawaii" + # Time.zone.iso8601('1999-12-31T14:00:00') # => Fri, 31 Dec 1999 14:00:00 HST -10:00 + # + # If the time components are missing then they will be set to zero. + # + # Time.zone = 'Hawaii' # => "Hawaii" + # Time.zone.iso8601('1999-12-31') # => Fri, 31 Dec 1999 00:00:00 HST -10:00 + # + # If the string is invalid then an +ArgumentError+ will be raised unlike +parse+ + # which returns +nil+ when given an invalid date string. + def iso8601(str) + parts = Date._iso8601(str) + + raise ArgumentError, "invalid date" if parts.empty? + + time = Time.new( + parts.fetch(:year), + parts.fetch(:mon), + parts.fetch(:mday), + parts.fetch(:hour, 0), + parts.fetch(:min, 0), + parts.fetch(:sec, 0) + parts.fetch(:sec_fraction, 0), + parts.fetch(:offset, 0) + ) + + if parts[:offset] + TimeWithZone.new(time.utc, self) + else + TimeWithZone.new(nil, self, time) + end + end + + # Method for creating new ActiveSupport::TimeWithZone instance in time zone # of +self+ from parsed string. # # Time.zone = 'Hawaii' # => "Hawaii" @@ -359,6 +394,36 @@ module ActiveSupport parts_to_time(Date._parse(str, false), now) end + # Method for creating new ActiveSupport::TimeWithZone instance in time zone + # of +self+ from an RFC 3339 string. + # + # Time.zone = 'Hawaii' # => "Hawaii" + # Time.zone.rfc3339('2000-01-01T00:00:00Z') # => Fri, 31 Dec 1999 14:00:00 HST -10:00 + # + # If the time or zone components are missing then an +ArgumentError+ will + # be raised. This is much stricter than either +parse+ or +iso8601+ which + # allow for missing components. + # + # Time.zone = 'Hawaii' # => "Hawaii" + # Time.zone.rfc3339('1999-12-31') # => ArgumentError: invalid date + def rfc3339(str) + parts = Date._rfc3339(str) + + raise ArgumentError, "invalid date" if parts.empty? + + time = Time.new( + parts.fetch(:year), + parts.fetch(:mon), + parts.fetch(:mday), + parts.fetch(:hour), + parts.fetch(:min), + parts.fetch(:sec) + parts.fetch(:sec_fraction, 0), + parts.fetch(:offset) + ) + + TimeWithZone.new(time.utc, self) + end + # Parses +str+ according to +format+ and returns an ActiveSupport::TimeWithZone. # # Assumes that +str+ is a time in the time zone +self+, diff --git a/activesupport/test/core_ext/duration_test.rb b/activesupport/test/core_ext/duration_test.rb index 6facb04f1f..2b1a715b7a 100644 --- a/activesupport/test/core_ext/duration_test.rb +++ b/activesupport/test/core_ext/duration_test.rb @@ -84,8 +84,46 @@ class DurationTest < ActiveSupport::TestCase assert_nothing_raised { Date.today - Date.today } end + def test_plus + assert_equal 2.seconds, 1.second + 1.second + assert_instance_of ActiveSupport::Duration, 1.second + 1.second + assert_equal 2.seconds, 1.second + 1 + assert_instance_of ActiveSupport::Duration, 1.second + 1 + end + + def test_minus + assert_equal 1.second, 2.seconds - 1.second + assert_instance_of ActiveSupport::Duration, 2.seconds - 1.second + assert_equal 1.second, 2.seconds - 1 + assert_instance_of ActiveSupport::Duration, 2.seconds - 1 + end + + def test_multiply + assert_equal 7.days, 1.day * 7 + assert_instance_of ActiveSupport::Duration, 1.day * 7 + + assert_deprecated do + assert_equal 86400, 1.day * 1.second + end + end + + def test_divide + assert_equal 1.day, 7.days / 7 + assert_instance_of ActiveSupport::Duration, 7.days / 7 + + assert_deprecated do + assert_equal 1, 1.day / 1.day + end + end + + def test_date_added_with_multiplied_duration + assert_equal Date.civil(2017, 1, 3), Date.civil(2017, 1, 1) + 1.day * 2 + end + def test_plus_with_time - assert_equal 1 + 1.second, 1.second + 1, "Duration + Numeric should == Numeric + Duration" + assert_deprecated do + assert_equal 1 + 1.second, 1.second + 1, "Duration + Numeric should == Numeric + Duration" + end end def test_time_plus_duration_returns_same_time_datatype @@ -104,6 +142,13 @@ class DurationTest < ActiveSupport::TestCase assert_equal 'expected a time or date, got ""', e.message, "ensure ArgumentError is not being raised by dependencies.rb" end + def test_implicit_coercion_is_deprecated + assert_deprecated { 1 + 1.second } + assert_deprecated { 1 - 1.second } + assert_deprecated { 1 * 1.second } + assert_deprecated { 1 / 1.second } + end + def test_fractional_weeks assert_equal((86400 * 7) * 1.5, 1.5.weeks) assert_equal((86400 * 7) * 1.7, 1.7.weeks) @@ -241,13 +286,20 @@ class DurationTest < ActiveSupport::TestCase def test_comparable assert_equal(-1, (0.seconds <=> 1.second)) assert_equal(-1, (1.second <=> 1.minute)) - assert_equal(-1, (1 <=> 1.minute)) + + assert_deprecated do + assert_equal(-1, (1 <=> 1.minute)) + end + assert_equal(0, (0.seconds <=> 0.seconds)) assert_equal(0, (0.seconds <=> 0.minutes)) assert_equal(0, (1.second <=> 1.second)) assert_equal(1, (1.second <=> 0.second)) assert_equal(1, (1.minute <=> 1.second)) - assert_equal(1, (61 <=> 1.minute)) + + assert_deprecated do + assert_equal(1, (61 <=> 1.minute)) + end end def test_twelve_months_equals_one_year diff --git a/activesupport/test/core_ext/time_ext_test.rb b/activesupport/test/core_ext/time_ext_test.rb index a399e36dc9..bd644c8457 100644 --- a/activesupport/test/core_ext/time_ext_test.rb +++ b/activesupport/test/core_ext/time_ext_test.rb @@ -569,6 +569,11 @@ class TimeExtCalculationsTest < ActiveSupport::TestCase Time::DATE_FORMATS.delete(:custom) end + def test_rfc3339_with_fractional_seconds + time = Time.new(1999, 12, 31, 19, 0, Rational(1, 8), -18000) + assert_equal "1999-12-31T19:00:00.125-05:00", time.rfc3339(3) + end + def test_to_date assert_equal Date.new(2005, 2, 21), Time.local(2005, 2, 21, 17, 44, 30).to_date end @@ -910,6 +915,37 @@ class TimeExtCalculationsTest < ActiveSupport::TestCase def test_all_year assert_equal Time.local(2011, 1, 1, 0, 0, 0)..Time.local(2011, 12, 31, 23, 59, 59, Rational(999999999, 1000)), Time.local(2011, 6, 7, 10, 10, 10).all_year end + + def test_rfc3339_parse + time = Time.rfc3339("1999-12-31T19:00:00.125-05:00") + + assert_equal 1999, time.year + assert_equal 12, time.month + assert_equal 31, time.day + assert_equal 19, time.hour + assert_equal 0, time.min + assert_equal 0, time.sec + assert_equal 125000, time.usec + assert_equal(-18000, time.utc_offset) + + exception = assert_raises(ArgumentError) do + Time.rfc3339("1999-12-31") + end + + assert_equal "invalid date", exception.message + + exception = assert_raises(ArgumentError) do + Time.rfc3339("1999-12-31T19:00:00") + end + + assert_equal "invalid date", exception.message + + exception = assert_raises(ArgumentError) do + Time.rfc3339("foobar") + end + + assert_equal "invalid date", exception.message + end end class TimeExtMarshalingTest < ActiveSupport::TestCase diff --git a/activesupport/test/core_ext/time_with_zone_test.rb b/activesupport/test/core_ext/time_with_zone_test.rb index 1534daacb9..3cc29ca040 100644 --- a/activesupport/test/core_ext/time_with_zone_test.rb +++ b/activesupport/test/core_ext/time_with_zone_test.rb @@ -144,6 +144,16 @@ class TimeWithZoneTest < ActiveSupport::TestCase assert_equal "1999-12-31T19:00:00-05:00", @twz.xmlschema(nil) end + def test_iso8601_with_fractional_seconds + @twz += Rational(1, 8) + assert_equal "1999-12-31T19:00:00.125-05:00", @twz.iso8601(3) + end + + def test_rfc3339_with_fractional_seconds + @twz += Rational(1, 8) + assert_equal "1999-12-31T19:00:00.125-05:00", @twz.rfc3339(3) + end + def test_to_yaml yaml = <<-EOF.strip_heredoc --- !ruby/object:ActiveSupport::TimeWithZone diff --git a/activesupport/test/time_zone_test.rb b/activesupport/test/time_zone_test.rb index 4794b55742..1615d8fdb2 100644 --- a/activesupport/test/time_zone_test.rb +++ b/activesupport/test/time_zone_test.rb @@ -215,6 +215,95 @@ class TimeZoneTest < ActiveSupport::TestCase assert_equal secs, twz.to_f end + def test_iso8601 + zone = ActiveSupport::TimeZone["Eastern Time (US & Canada)"] + twz = zone.iso8601("1999-12-31T19:00:00") + assert_equal Time.utc(1999, 12, 31, 19), twz.time + assert_equal Time.utc(2000), twz.utc + assert_equal zone, twz.time_zone + end + + def test_iso8601_with_fractional_seconds + zone = ActiveSupport::TimeZone["Eastern Time (US & Canada)"] + twz = zone.iso8601("1999-12-31T19:00:00.750") + assert_equal 750000, twz.time.usec + assert_equal Time.utc(1999, 12, 31, 19, 0, 0 + Rational(3, 4)), twz.time + assert_equal Time.utc(2000, 1, 1, 0, 0, 0 + Rational(3, 4)), twz.utc + assert_equal zone, twz.time_zone + end + + def test_iso8601_with_zone + zone = ActiveSupport::TimeZone["Eastern Time (US & Canada)"] + twz = zone.iso8601("1999-12-31T14:00:00-10:00") + assert_equal Time.utc(1999, 12, 31, 19), twz.time + assert_equal Time.utc(2000), twz.utc + assert_equal zone, twz.time_zone + end + + def test_iso8601_with_invalid_string + zone = ActiveSupport::TimeZone["Eastern Time (US & Canada)"] + + exception = assert_raises(ArgumentError) do + zone.iso8601("foobar") + end + + assert_equal "invalid date", exception.message + end + + def test_iso8601_with_missing_time_components + zone = ActiveSupport::TimeZone["Eastern Time (US & Canada)"] + twz = zone.iso8601("1999-12-31") + assert_equal Time.utc(1999, 12, 31, 0, 0, 0), twz.time + assert_equal Time.utc(1999, 12, 31, 5, 0, 0), twz.utc + assert_equal zone, twz.time_zone + end + + def test_iso8601_with_old_date + zone = ActiveSupport::TimeZone["Eastern Time (US & Canada)"] + twz = zone.iso8601("1883-12-31T19:00:00") + assert_equal [0, 0, 19, 31, 12, 1883], twz.to_a[0, 6] + assert_equal zone, twz.time_zone + end + + def test_iso8601_far_future_date_with_time_zone_offset_in_string + zone = ActiveSupport::TimeZone["Eastern Time (US & Canada)"] + twz = zone.iso8601("2050-12-31T19:00:00-10:00") # i.e., 2050-01-01 05:00:00 UTC + assert_equal [0, 0, 0, 1, 1, 2051], twz.to_a[0, 6] + assert_equal zone, twz.time_zone + end + + def test_iso8601_should_not_black_out_system_timezone_dst_jump + with_env_tz("EET") do + zone = ActiveSupport::TimeZone["Pacific Time (US & Canada)"] + twz = zone.iso8601("2012-03-25T03:29:00") + assert_equal [0, 29, 3, 25, 3, 2012], twz.to_a[0, 6] + end + end + + def test_iso8601_should_black_out_app_timezone_dst_jump + with_env_tz("EET") do + zone = ActiveSupport::TimeZone["Pacific Time (US & Canada)"] + twz = zone.iso8601("2012-03-11T02:29:00") + assert_equal [0, 29, 3, 11, 3, 2012], twz.to_a[0, 6] + end + end + + def test_iso8601_doesnt_use_local_dst + with_env_tz "US/Eastern" do + zone = ActiveSupport::TimeZone["UTC"] + twz = zone.iso8601("2013-03-10T02:00:00") + assert_equal Time.utc(2013, 3, 10, 2, 0, 0), twz.time + end + end + + def test_iso8601_handles_dst_jump + with_env_tz "US/Eastern" do + zone = ActiveSupport::TimeZone["Eastern Time (US & Canada)"] + twz = zone.iso8601("2013-03-10T02:00:00") + assert_equal Time.utc(2013, 3, 10, 3, 0, 0), twz.time + end + end + def test_parse zone = ActiveSupport::TimeZone["Eastern Time (US & Canada)"] twz = zone.parse("1999-12-31 19:00:00") @@ -314,6 +403,99 @@ class TimeZoneTest < ActiveSupport::TestCase end end + def test_rfc3339 + zone = ActiveSupport::TimeZone["Eastern Time (US & Canada)"] + twz = zone.rfc3339("1999-12-31T14:00:00-10:00") + assert_equal Time.utc(1999, 12, 31, 19), twz.time + assert_equal Time.utc(2000), twz.utc + assert_equal zone, twz.time_zone + end + + def test_rfc3339_with_fractional_seconds + zone = ActiveSupport::TimeZone["Eastern Time (US & Canada)"] + twz = zone.iso8601("1999-12-31T14:00:00.750-10:00") + assert_equal 750000, twz.time.usec + assert_equal Time.utc(1999, 12, 31, 19, 0, 0 + Rational(3, 4)), twz.time + assert_equal Time.utc(2000, 1, 1, 0, 0, 0 + Rational(3, 4)), twz.utc + assert_equal zone, twz.time_zone + end + + def test_rfc3339_with_missing_time + zone = ActiveSupport::TimeZone["Eastern Time (US & Canada)"] + + exception = assert_raises(ArgumentError) do + zone.rfc3339("1999-12-31") + end + + assert_equal "invalid date", exception.message + end + + def test_rfc3339_with_missing_offset + zone = ActiveSupport::TimeZone["Eastern Time (US & Canada)"] + + exception = assert_raises(ArgumentError) do + zone.rfc3339("1999-12-31T19:00:00") + end + + assert_equal "invalid date", exception.message + end + + def test_rfc3339_with_invalid_string + zone = ActiveSupport::TimeZone["Eastern Time (US & Canada)"] + + exception = assert_raises(ArgumentError) do + zone.rfc3339("foobar") + end + + assert_equal "invalid date", exception.message + end + + def test_rfc3339_with_old_date + zone = ActiveSupport::TimeZone["Eastern Time (US & Canada)"] + twz = zone.rfc3339("1883-12-31T19:00:00-05:00") + assert_equal [0, 0, 19, 31, 12, 1883], twz.to_a[0, 6] + assert_equal zone, twz.time_zone + end + + def test_rfc3339_far_future_date_with_time_zone_offset_in_string + zone = ActiveSupport::TimeZone["Eastern Time (US & Canada)"] + twz = zone.rfc3339("2050-12-31T19:00:00-10:00") # i.e., 2050-01-01 05:00:00 UTC + assert_equal [0, 0, 0, 1, 1, 2051], twz.to_a[0, 6] + assert_equal zone, twz.time_zone + end + + def test_rfc3339_should_not_black_out_system_timezone_dst_jump + with_env_tz("EET") do + zone = ActiveSupport::TimeZone["Pacific Time (US & Canada)"] + twz = zone.rfc3339("2012-03-25T03:29:00-07:00") + assert_equal [0, 29, 3, 25, 3, 2012], twz.to_a[0, 6] + end + end + + def test_rfc3339_should_black_out_app_timezone_dst_jump + with_env_tz("EET") do + zone = ActiveSupport::TimeZone["Pacific Time (US & Canada)"] + twz = zone.rfc3339("2012-03-11T02:29:00-08:00") + assert_equal [0, 29, 3, 11, 3, 2012], twz.to_a[0, 6] + end + end + + def test_rfc3339_doesnt_use_local_dst + with_env_tz "US/Eastern" do + zone = ActiveSupport::TimeZone["UTC"] + twz = zone.rfc3339("2013-03-10T02:00:00Z") + assert_equal Time.utc(2013, 3, 10, 2, 0, 0), twz.time + end + end + + def test_rfc3339_handles_dst_jump + with_env_tz "US/Eastern" do + zone = ActiveSupport::TimeZone["Eastern Time (US & Canada)"] + twz = zone.iso8601("2013-03-10T02:00:00-05:00") + assert_equal Time.utc(2013, 3, 10, 3, 0, 0), twz.time + end + end + def test_strptime zone = ActiveSupport::TimeZone["Eastern Time (US & Canada)"] twz = zone.strptime("1999-12-31 12:00:00", "%Y-%m-%d %H:%M:%S") diff --git a/guides/source/active_model_basics.md b/guides/source/active_model_basics.md index 732e553c62..72daa29f7f 100644 --- a/guides/source/active_model_basics.md +++ b/guides/source/active_model_basics.md @@ -320,7 +320,7 @@ person.serializable_hash # => {"name"=>"Bob"} #### ActiveModel::Serializers Rails provides an `ActiveModel::Serializers::JSON` serializer. -This module automatically include the `ActiveModel::Serialization`. +This module automatically includes the `ActiveModel::Serialization`. ##### ActiveModel::Serializers::JSON diff --git a/guides/source/active_record_querying.md b/guides/source/active_record_querying.md index 31220f9be2..31865ea375 100644 --- a/guides/source/active_record_querying.md +++ b/guides/source/active_record_querying.md @@ -1547,7 +1547,7 @@ SELECT people.id, people.name, comments.text FROM people INNER JOIN comments ON comments.person_id = people.id -WHERE comments.created_at = '2015-01-01' +WHERE comments.created_at > '2015-01-01' ``` ### Retrieving specific data from multiple tables @@ -1871,7 +1871,7 @@ Which will execute: ```sql SELECT count(DISTINCT clients.id) AS count_all FROM clients - LEFT OUTER JOIN orders ON orders.client_id = client.id WHERE + LEFT OUTER JOIN orders ON orders.client_id = clients.id WHERE (clients.first_name = 'Ryan' AND orders.status = 'received') ``` diff --git a/guides/source/association_basics.md b/guides/source/association_basics.md index 6e68935f9b..73c9c10c1f 100644 --- a/guides/source/association_basics.md +++ b/guides/source/association_basics.md @@ -154,7 +154,7 @@ case, the column definition might look like this: ```ruby create_table :accounts do |t| - t.belongs_to :supplier, index: true, unique: true, foreign_key: true + t.belongs_to :supplier, index: { unique: true }, foreign_key: true # ... end ``` diff --git a/guides/source/configuring.md b/guides/source/configuring.md index de921e2705..a4f3882124 100644 --- a/guides/source/configuring.md +++ b/guides/source/configuring.md @@ -350,9 +350,9 @@ All these configuration options are delegated to the `I18n` library. `config/environments/production.rb` which is generated by Rails. The default value is `true` if this configuration is not set. -* `config.active_record.dump_schemas` controls which database schemas will be dumped when calling db:structure:dump. - The options are `:schema_search_path` (the default) which dumps any schemas listed in schema_search_path, - `:all` which always dumps all schemas regardless of the schema_search_path, +* `config.active_record.dump_schemas` controls which database schemas will be dumped when calling `db:structure:dump`. + The options are `:schema_search_path` (the default) which dumps any schemas listed in `schema_search_path`, + `:all` which always dumps all schemas regardless of the `schema_search_path`, or a string of comma separated schemas. * `config.active_record.belongs_to_required_by_default` is a boolean value and @@ -362,10 +362,10 @@ All these configuration options are delegated to the `I18n` library. * `config.active_record.warn_on_records_fetched_greater_than` allows setting a warning threshold for query result size. If the number of records returned by a query exceeds the threshold, a warning is logged. This can be used to - identify queries which might be causing memory bloat. + identify queries which might be causing a memory bloat. * `config.active_record.index_nested_attribute_errors` allows errors for nested - has_many relationships to be displayed with an index as well as the error. + `has_many` relationships to be displayed with an index as well as the error. Defaults to `false`. * `config.active_record.use_schema_cache_dump` enables users to get schema cache information diff --git a/guides/source/testing.md b/guides/source/testing.md index 4231500729..3f53ccb242 100644 --- a/guides/source/testing.md +++ b/guides/source/testing.md @@ -610,9 +610,9 @@ For creating Rails system tests, you use the `test/system` directory in your application. Rails provides a generator to create a system test skeleton for you. ```bash -$ bin/rails generate system_test users_create_test +$ bin/rails generate system_test users_create invoke test_unit - create test/system/users_create_test.rb + create test/system/users_creates_test.rb ``` Here's what a freshly-generated system test looks like: @@ -620,10 +620,12 @@ Here's what a freshly-generated system test looks like: ```ruby require "application_system_test_case" -class UsersCreateTest < ApplicationSystemTestCase - visit users_url - - assert_selector "h1", text: "Users" +class UsersCreatesTest < ApplicationSystemTestCase + # test "visiting the index" do + # visit users_creates_url + # + # assert_selector "h1", text: "UsersCreate" + # end end ``` @@ -728,6 +730,9 @@ Run the system tests. bin/rails test:system ``` +NOTE: By default, running `bin/rails test` won't run your system tests. +Make sure to run `bin/rails test:system` to actually run them. + #### Creating articles system test Now let's test the flow for creating a new article in our blog. @@ -1436,7 +1441,7 @@ second batch of assertions, we ensure that the email does indeed contain what we expect. The helper `read_fixture` is used to read in the content from this file. NOTE: `email.body.to_s` is present when there's only one (HTML or text) part present. -If the mailer provides both, you can test your fixture against specific parts +If the mailer provides both, you can test your fixture against specific parts with `email.text_part.body.to_s` or `email.html_part.body.to_s`. Here's the content of the `invite` fixture: diff --git a/guides/source/upgrading_ruby_on_rails.md b/guides/source/upgrading_ruby_on_rails.md index 6005298127..3afc0e5309 100644 --- a/guides/source/upgrading_ruby_on_rails.md +++ b/guides/source/upgrading_ruby_on_rails.md @@ -80,7 +80,7 @@ It is only soft-deprecated, which means that your code will not break at the moment and no deprecation warning will be displayed but this constant will be removed in the future. -Also, if you have pretty old YAML documents containg dumps of such objects, +Also, if you have pretty old YAML documents containing dumps of such objects, you may need to load and dump them again to make sure that they reference the right constant and that loading them won't break in the future. diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index 54bf0ec65e..a483535df1 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,3 +1,24 @@ +* Avoid running system tests by default with the `bin/rails test` + and `bin/rake test` commands since they may be expansive. + + *Robin Dupret* (#28286) + +* Improve encryption for encrypted secrets. + + Switch to aes-128-gcm authenticated encryption. Also generate a random + initialization vector for each encryption so the same input and key can + generate different encrypted data. + + Double the encryption key entropy by properly extracting the underlying + bytes from the hexadecimal seed key. + + NOTE: Since the encryption mechanism has been switched, you need to run + this script to upgrade: + + https://gist.github.com/kaspth/bc37989c2f39a5642112f28b1d93f343 + + *Stephen Touset* + ## Rails 5.1.0.beta1 (February 23, 2017) ## * Add encrypted secrets in `config/secrets.yml.enc`. diff --git a/railties/lib/rails/generators/erb.rb b/railties/lib/rails/generators/erb.rb index d5e326d6ee..97d9ab29d4 100644 --- a/railties/lib/rails/generators/erb.rb +++ b/railties/lib/rails/generators/erb.rb @@ -17,8 +17,8 @@ module Erb # :nodoc: :erb end - def filename_with_extensions(name, format = self.format) - [name, format, handler].compact.join(".") + def filename_with_extensions(name, file_format = format) + [name, file_format, handler].compact.join(".") end end end diff --git a/railties/lib/rails/secrets.rb b/railties/lib/rails/secrets.rb index a083914109..2a95712cd9 100644 --- a/railties/lib/rails/secrets.rb +++ b/railties/lib/rails/secrets.rb @@ -1,4 +1,5 @@ require "yaml" +require "active_support/message_encryptor" module Rails # Greatly inspired by Ara T. Howard's magnificent sekrets gem. 😘 @@ -12,6 +13,7 @@ module Rails end end + @cipher = "aes-128-gcm" @read_encrypted_secrets = false @root = File # Wonky, but ensures `join` uses the current directory. @@ -30,20 +32,19 @@ module Rails end def generate_key - cipher = new_cipher - SecureRandom.hex(cipher.key_len)[0, cipher.key_len] + SecureRandom.hex(OpenSSL::Cipher.new(@cipher).key_len) end def key ENV["RAILS_MASTER_KEY"] || read_key_file || handle_missing_key end - def encrypt(text) - cipher(:encrypt, text) + def encrypt(data) + encryptor.encrypt_and_sign(data) end def decrypt(data) - cipher(:decrypt, data) + encryptor.decrypt_and_verify(data) end def read @@ -97,14 +98,8 @@ module Rails end end - def new_cipher - OpenSSL::Cipher.new("aes-256-cbc") - end - - def cipher(mode, data) - cipher = new_cipher.public_send(mode) - cipher.key = key - cipher.update(data) << cipher.final + def encryptor + @encryptor ||= ActiveSupport::MessageEncryptor.new([ key ].pack("H*"), cipher: @cipher) end end end diff --git a/railties/lib/rails/test_help.rb b/railties/lib/rails/test_help.rb index 8e290239bd..0f9bf98737 100644 --- a/railties/lib/rails/test_help.rb +++ b/railties/lib/rails/test_help.rb @@ -11,10 +11,6 @@ require "rails/generators/test_case" require "active_support/testing/autorun" -if defined?(Capybara) && defined?(Puma) - require "action_dispatch/system_test_case" -end - if defined?(ActiveRecord::Base) ActiveRecord::Migration.maintain_test_schema! @@ -48,12 +44,3 @@ class ActionDispatch::IntegrationTest super end end - -if defined?(Capybara) && defined?(Puma) - class ActionDispatch::SystemTestCase - def before_setup # :nodoc: - @routes = Rails.application.routes - super - end - end -end diff --git a/railties/lib/rails/test_unit/minitest_plugin.rb b/railties/lib/rails/test_unit/minitest_plugin.rb index e44fe78bbd..8decdb0f4f 100644 --- a/railties/lib/rails/test_unit/minitest_plugin.rb +++ b/railties/lib/rails/test_unit/minitest_plugin.rb @@ -62,9 +62,9 @@ module Minitest options[:patterns] = opts.order! unless run_via.rake? end - def self.rake_run(patterns) # :nodoc: + def self.rake_run(patterns, exclude_patterns = []) # :nodoc: self.run_via = :rake unless run_via.set? - ::Rails::TestRequirer.require_files(patterns) + ::Rails::TestRequirer.require_files(patterns, exclude_patterns) autorun end @@ -88,7 +88,13 @@ module Minitest # If run via `ruby` we've been passed the files to run directly, or if run # via `rake` then they have already been eagerly required. unless run_via.ruby? || run_via.rake? - ::Rails::TestRequirer.require_files(options[:patterns]) + # If there are no given patterns, we can assume that the user + # simply runs the `bin/rails test` command without extra arguments. + if options[:patterns].empty? + ::Rails::TestRequirer.require_files(options[:patterns], ["test/system/**/*"]) + else + ::Rails::TestRequirer.require_files(options[:patterns]) + end end unless options[:full_backtrace] || ENV["BACKTRACE"] diff --git a/railties/lib/rails/test_unit/test_requirer.rb b/railties/lib/rails/test_unit/test_requirer.rb index fe35934abc..92e5fcf0bc 100644 --- a/railties/lib/rails/test_unit/test_requirer.rb +++ b/railties/lib/rails/test_unit/test_requirer.rb @@ -4,10 +4,13 @@ require "rake/file_list" module Rails class TestRequirer # :nodoc: class << self - def require_files(patterns) + def require_files(patterns, exclude_patterns = []) patterns = expand_patterns(patterns) - Rake::FileList[patterns.compact.presence || "test/**/*_test.rb"].to_a.each do |file| + file_list = Rake::FileList[patterns.compact.presence || "test/**/*_test.rb"] + file_list.exclude(exclude_patterns) + + file_list.to_a.each do |file| require File.expand_path(file) end end diff --git a/railties/lib/rails/test_unit/testing.rake b/railties/lib/rails/test_unit/testing.rake index 4dde3d3c97..ef19bd7626 100644 --- a/railties/lib/rails/test_unit/testing.rake +++ b/railties/lib/rails/test_unit/testing.rake @@ -4,15 +4,15 @@ require "rails/test_unit/minitest_plugin" task default: :test -desc "Runs all tests in test folder" +desc "Runs all tests in test folder except system ones" task :test do $: << "test" - pattern = if ENV.key?("TEST") - ENV["TEST"] + + if ENV.key?("TEST") + Minitest.rake_run([ENV["TEST"]]) else - "test" + Minitest.rake_run(["test"], ["test/system/**/*"]) end - Minitest.rake_run([pattern]) end namespace :test do diff --git a/railties/test/application/test_runner_test.rb b/railties/test/application/test_runner_test.rb index e773b52dbb..a8e3a7ec5b 100644 --- a/railties/test/application/test_runner_test.rb +++ b/railties/test/application/test_runner_test.rb @@ -576,6 +576,80 @@ module ApplicationTests capture(:stderr) { run_test_command("test/models/warnings_test.rb -w") }) end + def test_reset_sessions_before_rollback_on_system_tests + app_file "test/system/reset_session_before_rollback_test.rb", <<-RUBY + require "application_system_test_case" + + class ResetSessionBeforeRollbackTest < ApplicationSystemTestCase + def teardown_fixtures + puts "rollback" + super + end + + Capybara.singleton_class.prepend(Module.new do + def reset_sessions! + puts "reset sessions" + super + end + end) + + test "dummy" do + end + end + RUBY + + run_test_command("test/system/reset_session_before_rollback_test.rb").tap do |output| + assert_match "reset sessions\nrollback", output + assert_match "1 runs, 0 assertions, 0 failures, 0 errors, 0 skips", output + end + end + + def test_system_tests_are_not_run_with_the_default_test_command + app_file "test/system/dummy_test.rb", <<-RUBY + require "application_system_test_case" + + class DummyTest < ApplicationSystemTestCase + test "something" do + assert true + end + end + RUBY + + run_test_command("").tap do |output| + assert_match "0 runs, 0 assertions, 0 failures, 0 errors, 0 skips", output + end + end + + def test_system_tests_are_not_run_through_rake_test + app_file "test/system/dummy_test.rb", <<-RUBY + require "application_system_test_case" + + class DummyTest < ApplicationSystemTestCase + test "something" do + assert true + end + end + RUBY + + output = Dir.chdir(app_path) { `bin/rake test` } + assert_match "0 runs, 0 assertions, 0 failures, 0 errors, 0 skips", output + end + + def test_system_tests_are_run_through_rake_test_when_given_in_TEST + app_file "test/system/dummy_test.rb", <<-RUBY + require "application_system_test_case" + + class DummyTest < ApplicationSystemTestCase + test "something" do + assert true + end + end + RUBY + + output = Dir.chdir(app_path) { `bin/rake test TEST=test/system/dummy_test.rb` } + assert_match "1 runs, 1 assertions, 0 failures, 0 errors, 0 skips", output + end + private def run_test_command(arguments = "test/unit/test_test.rb") Dir.chdir(app_path) { `bin/rails t #{arguments}` } diff --git a/railties/test/secrets_test.rb b/railties/test/secrets_test.rb index 36e42cf1f9..953408f0b4 100644 --- a/railties/test/secrets_test.rb +++ b/railties/test/secrets_test.rb @@ -54,9 +54,9 @@ class Rails::SecretsTest < ActiveSupport::TestCase test "reading from key file" do run_secrets_generator do - File.binwrite("config/secrets.yml.key", "How do I know you feel it?") + File.binwrite("config/secrets.yml.key", "00112233445566778899aabbccddeeff") - assert_equal "How do I know you feel it?", Rails::Secrets.key + assert_equal "00112233445566778899aabbccddeeff", Rails::Secrets.key end end |